feat(admin): 表格页整体美化 — 筛选改下拉、统一工具栏、页头放刷新

- 全站卡片/表格/按钮/输入框/徽标共用样式换成墨黑中性风
- 用户/日志/模型/兑换码/用户日志的筛选胶囊改为下拉(SelectMenu 面板改亮色)
- 统一 toolbar:控件同高 36px,搜索框带图标且限宽
- 刷新按钮移到页头独立一行(统计卡上方),新增页面标题
- 修复浅色模式下 bg-white/ 规则误伤 hover:bg-white/ 导致行底色偏灰
- 用户表加最小宽度,修复邮箱列被挤成 0 宽

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
2026-10-27 16:40:37 +08:00
co-authored by Devin
parent 6372b7cb8b
commit cce126cd52
9 changed files with 117 additions and 457 deletions
+7 -6
View File
@@ -14,6 +14,7 @@ const props = defineProps({
mono: { type: Boolean, default: false }, mono: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }, disabled: { type: Boolean, default: false },
up: { type: Boolean, default: false }, // 面板向上展开(底部居中的输入框用) up: { type: Boolean, default: false }, // 面板向上展开(底部居中的输入框用)
compact: { type: Boolean, default: false }, // 工具栏用的紧凑尺寸
}) })
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
@@ -74,8 +75,8 @@ onUnmounted(() => document.removeEventListener('mousedown', onDocClick))
<button type="button" @click="toggle" @keydown="onKeydown" <button type="button" @click="toggle" @keydown="onKeydown"
:aria-expanded="open" :aria-expanded="open"
:disabled="disabled" :disabled="disabled"
class="field flex items-center justify-between gap-2 text-left h-10 !py-0 disabled:opacity-50 disabled:cursor-not-allowed" class="field flex items-center justify-between gap-2 text-left !py-0 disabled:opacity-50 disabled:cursor-not-allowed"
:class="[mono ? 'font-mono' : '', selected ? '' : 'text-[color:var(--fg-faint)]']"> :class="[compact ? 'h-9 !text-[13px] font-semibold min-w-[7.5rem]' : 'h-10', mono ? 'font-mono' : '', selected ? '' : 'text-[color:var(--fg-faint)]']">
<span class="truncate">{{ label }}</span> <span class="truncate">{{ label }}</span>
<Icon name="chevron" <Icon name="chevron"
class="w-4 h-4 shrink-0 text-[color:var(--fg-3)] transition-transform duration-200" class="w-4 h-4 shrink-0 text-[color:var(--fg-3)] transition-transform duration-200"
@@ -91,18 +92,18 @@ onUnmounted(() => document.removeEventListener('mousedown', onDocClick))
leave-from-class="opacity-100 translate-y-0" leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-1"> leave-to-class="opacity-0 -translate-y-1">
<div v-if="open" ref="panel" <div v-if="open" ref="panel"
class="absolute z-30 w-full max-h-64 overflow-auto rounded-xl border border-[color:var(--glass-border)] class="absolute z-30 w-full min-w-[9rem] max-h-64 overflow-auto rounded-xl border border-[rgb(24_24_27/0.08)]
bg-[var(--menu-bg)] backdrop-blur-xl p-1.5 shadow-[var(--glass-shadow-lg)]" bg-white p-1.5 shadow-[0_2px_6px_rgb(24_24_27/0.05),0_20px_40px_-12px_rgb(24_24_27/0.22)]"
:class="up ? 'bottom-full mb-2' : 'mt-2'"> :class="up ? 'bottom-full mb-2' : 'mt-2'">
<button v-for="(o, i) in options" :key="o.value" type="button" <button v-for="(o, i) in options" :key="o.value" type="button"
:data-idx="i" @click="pick(o)" @mouseenter="active = i" :data-idx="i" @click="pick(o)" @mouseenter="active = i"
class="w-full flex items-center justify-between gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors" class="w-full flex items-center justify-between gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors"
:class="[ :class="[
mono ? 'font-mono' : '', mono ? 'font-mono' : '',
i === active ? 'bg-[var(--m-yellow)] text-[#111]' : 'text-[color:var(--fg-2)]', i === active ? 'bg-[#f4f4f5] text-[#18181b]' : 'text-[color:var(--fg-2)]',
]"> ]">
<span class="truncate">{{ o.label }}</span> <span class="truncate">{{ o.label }}</span>
<Icon v-if="o.value === modelValue" name="check" class="w-4 h-4 shrink-0 text-[var(--m-orange)]" /> <Icon v-if="o.value === modelValue" name="check" class="w-4 h-4 shrink-0 text-[#18181b]" />
</button> </button>
<div v-if="!options.length" class="px-3 py-2 text-xs text-[color:var(--fg-3)]">无选项</div> <div v-if="!options.length" class="px-3 py-2 text-xs text-[color:var(--fg-3)]">无选项</div>
</div> </div>
+53 -341
View File
@@ -112,7 +112,7 @@ html:not(.dark) .theme-text :is(.text-white, [class*="text-white/9"], [class*="t
html:not(.dark) .theme-text :is([class*="text-white/7"], [class*="text-white/6"]) { color: rgb(24 21 36 / 0.7); } html:not(.dark) .theme-text :is([class*="text-white/7"], [class*="text-white/6"]) { color: rgb(24 21 36 / 0.7); }
html:not(.dark) .theme-text :is([class*="text-white/5"], [class*="text-white/4"]) { color: rgb(24 21 36 / 0.52); } html:not(.dark) .theme-text :is([class*="text-white/5"], [class*="text-white/4"]) { color: rgb(24 21 36 / 0.52); }
html:not(.dark) .theme-text :is([class*="text-white/3"], [class*="text-white/2"], [class*="text-white/1"]) { color: rgb(24 21 36 / 0.4); } html:not(.dark) .theme-text :is([class*="text-white/3"], [class*="text-white/2"], [class*="text-white/1"]) { color: rgb(24 21 36 / 0.4); }
html:not(.dark) .theme-text [class*="bg-white/"] { background-color: var(--surface-2); } html:not(.dark) .theme-text :is([class^="bg-white/"], [class*=" bg-white/"]) { background-color: var(--surface-2); }
html:not(.dark) .theme-text [class*="border-white/"] { border-color: var(--hairline); } html:not(.dark) .theme-text [class*="border-white/"] { border-color: var(--hairline); }
html:not(.dark) .theme-text [class*="ring-white/"] { --tw-ring-color: var(--hairline); } html:not(.dark) .theme-text [class*="ring-white/"] { --tw-ring-color: var(--hairline); }
/* White placeholders (placeholder:text-white/30 etc.) — the rescue above only /* White placeholders (placeholder:text-white/30 etc.) — the rescue above only
@@ -127,7 +127,7 @@ html:not(.dark) .theme-text :is([class*="bg-slate-900"], [class*="bg-slate-800"]
matcher also catches the /80 opacity variants. Badges keep their pale tinted matcher also catches the /80 opacity variants. Badges keep their pale tinted
bg (bg-*-500/10) which reads fine on white. */ bg (bg-*-500/10) which reads fine on white. */
html:not(.dark) .theme-text :is([class*="text-sky-300"], [class*="text-sky-200"]) { color: rgb(24 24 27); } html:not(.dark) .theme-text :is([class*="text-sky-300"], [class*="text-sky-200"]) { color: rgb(24 24 27); }
html:not(.dark) .theme-text :is([class*="text-orange-300"], [class*="text-orange-200"]) { color: rgb(124 58 237); } html:not(.dark) .theme-text :is([class*="text-orange-300"], [class*="text-orange-200"]) { color: rgb(24 24 27); }
html:not(.dark) .theme-text :is([class*="text-rose-300"], [class*="text-rose-200"]) { color: rgb(225 29 72); } html:not(.dark) .theme-text :is([class*="text-rose-300"], [class*="text-rose-200"]) { color: rgb(225 29 72); }
html:not(.dark) .theme-text :is([class*="text-emerald-300"], [class*="text-emerald-200"]) { color: rgb(5 150 105); } html:not(.dark) .theme-text :is([class*="text-emerald-300"], [class*="text-emerald-200"]) { color: rgb(5 150 105); }
html:not(.dark) .theme-text :is([class*="text-sky-300"], [class*="text-sky-200"]) { color: rgb(2 132 199); } html:not(.dark) .theme-text :is([class*="text-sky-300"], [class*="text-sky-200"]) { color: rgb(2 132 199); }
@@ -337,325 +337,42 @@ button:not(:disabled), [role="button"]:not([aria-disabled="true"]) { cursor: poi
over-media tables are untouched; specificity outranks the per-view over-media tables are untouched; specificity outranks the per-view
Tailwind hover utilities and the light-mode white rescue. */ Tailwind hover utilities and the light-mode white rescue. */
.theme-text .card table thead { .theme-text .card table thead {
background: linear-gradient(180deg, rgb(24 24 27 / 0.07), rgb(24 24 27 / 0.025)); background: #fafafa;
border-bottom: 1px solid rgb(24 24 27 / 0.07);
} }
html.dark .theme-text .card table thead { .theme-text .card table thead :is(th, td) { font-weight: 700; letter-spacing: 0.04em; }
background: linear-gradient(180deg, rgb(24 24 27 / 0.13), rgb(24 24 27 / 0.05)); .theme-text .card table tbody tr { transition: background-color 0.12s ease; }
} .theme-text .card table tbody tr:hover { background-color: #f7f7f8; }
.theme-text .card table tbody tr { transition: background-color 0.15s ease; } .theme-text .card table tbody tr:last-child { border-bottom: 0; }
.theme-text .card table tbody tr:hover { background-color: rgb(24 24 27 / 0.08); }
html.dark .theme-text .card table tbody tr:hover { background-color: rgb(24 24 27 / 0.11); }
/* Checkbox column + first data column get a hair more air against the
card edge so dense tables don't feel cramped. */
.theme-text .card table tbody tr:hover :is(td, th):first-child {
box-shadow: inset 2px 0 0 rgb(24 24 27 / 0.55);
}
::-webkit-scrollbar { width: 9px; height: 9px; }
::-webkit-scrollbar-thumb { background: rgb(24 21 36 / 0.2); border-radius: 5px; border: 2px solid transparent; background-clip: content-box; }
::-webkit-scrollbar-thumb:hover { background: rgb(24 21 36 / 0.38); background-clip: content-box; }
::-webkit-scrollbar-track { background: transparent; }
html.dark ::-webkit-scrollbar-thumb { background: rgb(255 255 255 / 0.18); background-clip: content-box; }
html.dark ::-webkit-scrollbar-thumb:hover { background: rgb(255 255 255 / 0.32); background-clip: content-box; }
/* ===== Ambient background — aurora wash used by the public shell ===== */
.ambient-bg {
background-color: var(--app-bg);
}
html.dark .ambient-bg {
background-image:
radial-gradient(60rem 40rem at 12% -10%, rgb(24 24 27 / 0.2), transparent 60%),
radial-gradient(52rem 36rem at 92% 8%, rgb(24 24 27 / 0.13), transparent 60%),
radial-gradient(46rem 32rem at 8% 96%, rgb(24 24 27 / 0.1), transparent 60%);
}
/* ===== Glass / aurora design kit =====
Frosted surfaces, hairline edges, soft layered shadows and violet→cyan
gradient accents. Same class names as the previous memphis kit so every
view picks the new look up without markup changes. */
/* Gradient headline text — violet → cyan swipe. */
.anime-grad-text {
background: linear-gradient(100deg, #18181b 0%, #3f3f46 45%, #52525b 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
padding: 0 0.05em;
}
html.dark .anime-grad-text {
background: linear-gradient(100deg, #71717a 0%, #e879f9 45%, #67e8f9 100%);
-webkit-background-clip: text;
background-clip: text;
}
/* Section eyebrow — tiny uppercase kicker with a leading star. */
.anime-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.22em;
text-transform: uppercase;
}
.anime-eyebrow::before { content: '✦'; font-size: 0.85em; }
/* Sticker label — glass chip with a soft glow. */
.sticker {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.34rem 0.85rem;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
color: var(--fg);
background: var(--surface);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
}
/* Chunky CTA — violet→cyan gradient with a glow. */
.bubble-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.7rem 1.4rem;
border-radius: 0.8rem;
font-size: 14px;
font-weight: 800;
color: #fff;
border: 1px solid rgb(255 255 255 / 0.18);
cursor: pointer;
background: linear-gradient(120deg, #18181b, #27272a 55%, #52525b);
box-shadow: 0 8px 24px var(--accent-glow);
transition: transform 0.15s ease, box-shadow 0.15s ease, filter 0.15s ease;
}
.bubble-btn:hover { transform: translateY(-2px); box-shadow: 0 12px 32px var(--accent-glow); filter: brightness(1.06); }
.bubble-btn:active { transform: scale(0.97) translateY(1px); box-shadow: 0 4px 12px var(--accent-glow); }
/* Glass card — frosted surface, hairline edge, soft shadow. */
.anime-card {
position: relative;
border-radius: 1.1rem;
background: var(--surface);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.anime-card:hover {
transform: translateY(-3px);
box-shadow: var(--glass-shadow-lg);
}
/* Selected pill — gradient fill with a glow. */
.chip-on {
color: #fff !important;
background: linear-gradient(120deg, #18181b, #27272a) !important;
border: 1px solid transparent !important;
box-shadow: 0 4px 16px var(--accent-glow) !important;
}
/* Twinkling star field — drop into any relatively-positioned decorative layer.
Uses layered radial-gradients so it's a single element, no extra DOM. */
.anime-stars {
position: absolute;
inset: 0;
pointer-events: none;
background-image:
radial-gradient(1.5px 1.5px at 12% 22%, rgb(24 24 27 / 0.7), transparent),
radial-gradient(1.5px 1.5px at 78% 12%, rgb(24 24 27 / 0.7), transparent),
radial-gradient(1px 1px at 42% 62%, rgb(24 24 27 / 0.6), transparent),
radial-gradient(1.5px 1.5px at 88% 72%, rgb(24 24 27 / 0.6), transparent),
radial-gradient(1px 1px at 24% 84%, rgb(24 24 27 / 0.55), transparent),
radial-gradient(1px 1px at 62% 34%, rgb(167 139 250 / 0.55), transparent);
animation: twinkle 4.5s ease-in-out infinite;
}
@keyframes twinkle { 0%, 100% { opacity: 0.85; } 50% { opacity: 0.4; } }
@media (prefers-reduced-motion: reduce) { .anime-stars { animation: none; } }
.ambient-grain::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
background-image: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22180%22 height=%22180%22><filter id=%22n%22><feTurbulence baseFrequency=%220.9%22 numOctaves=%222%22 seed=%225%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.4%22/></svg>');
opacity: 0.035;
mix-blend-mode: multiply;
z-index: 1;
}
/* Float a soft mesh "orb" anywhere */
.mesh-orb {
position: absolute;
border-radius: 9999px;
filter: blur(80px);
pointer-events: none;
z-index: 0;
}
/* ===== Glass utilities ===== */
/* Dot texture layer — kept faint so it reads as grain, not polka dots. */
.memphis-dots {
background-image: radial-gradient(circle, rgb(24 21 36 / 0.07) 1.1px, transparent 1.1px);
background-size: 26px 26px;
}
html.dark .memphis-dots {
background-image: radial-gradient(circle, rgb(255 255 255 / 0.05) 1.1px, transparent 1.1px);
}
/* Frosted surface helpers for one-off markup. */
.ink-card {
background: var(--surface);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow-lg);
}
.ink-card-sm {
background: var(--surface);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
}
/* Lift on hover — the signature card interaction. */
.chunky-hover { transition: transform 0.18s ease, box-shadow 0.18s ease; }
.chunky-hover:hover { transform: translateY(-3px); box-shadow: var(--glass-shadow-lg); }
/* Squishy press for buttons. */
.memphis-press { transition: transform 0.15s ease; }
.memphis-press:active { transform: scale(0.97) translateY(1px); }
/* Empty-state badge — frosted tile with a gradient star. */
.empty-badge {
width: 4.5rem;
height: 4.5rem;
display: grid;
place-items: center;
font-size: 1.8rem;
font-weight: 900;
color: #fff;
background: linear-gradient(135deg, #18181b, #52525b);
border: 1px solid rgb(255 255 255 / 0.2);
border-radius: 1.2rem;
box-shadow: 0 12px 32px var(--accent-glow);
animation: memphis-float 6s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) { .empty-badge { animation: none; } }
@keyframes memphis-wiggle { 0%, 100% { transform: rotate(-3deg); } 50% { transform: rotate(3deg); } }
@keyframes memphis-float { 0%, 100% { transform: translateY(0) rotate(0deg); } 50% { transform: translateY(-10px) rotate(1.5deg); } }
@keyframes memphis-bounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-8px); } }
.animate-memphis-wiggle { animation: memphis-wiggle 2s ease-in-out infinite; }
.animate-memphis-float { animation: memphis-float 6s ease-in-out infinite; }
.animate-memphis-bounce { animation: memphis-bounce 2s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.animate-memphis-wiggle, .animate-memphis-float, .animate-memphis-bounce { animation: none; }
}
/* ===== Dark public-shell theme overrides =====
Apply by adding `.public-dark` to a wrapper element. Views can keep their
existing Tailwind classes (text-slate-*, .card, .field) — these rules
remap them to the dark palette. */
.public-dark { color: rgb(255 255 255 / 0.9); }
.public-dark .card {
background: rgb(255 255 255 / 0.04);
border-color: rgb(255 255 255 / 0.1);
color: rgb(255 255 255 / 0.92);
box-shadow: var(--glass-shadow), inset 0 1px 0 rgb(255 255 255 / 0.07);
}
.public-dark .field {
background: rgb(255 255 255 / 0.04);
border-color: rgb(255 255 255 / 0.12);
color: rgb(255 255 255 / 0.95);
transition: border-color 0.18s, background 0.18s, box-shadow 0.18s;
}
.public-dark .field::placeholder { color: rgb(255 255 255 / 0.3); }
/* Single focus treatment shared by every <.field> across the admin shell —
a violet glow ring, matching the light theme's focus affordance. */
.public-dark .field:focus,
.public-dark .field:focus-visible {
border-color: rgb(24 24 27 / 0.7);
background: rgb(255 255 255 / 0.06);
box-shadow: 0 0 0 3px rgb(24 24 27 / 0.25);
outline: none;
}
.public-dark .btn-soft {
background: rgb(255 255 255 / 0.05);
border-color: rgb(255 255 255 / 0.12);
color: rgb(255 255 255 / 0.85);
}
.public-dark .btn-soft:hover { background: rgb(24 24 27 / 0.25); color: white; }
.public-dark .btn-ghost { color: rgb(255 255 255 / 0.65); border-color: transparent; }
.public-dark .btn-ghost:hover { background: rgb(24 24 27 / 0.18); color: white; }
.public-dark .btn-primary {
background: linear-gradient(120deg, #18181b, #27272a); color: #fff;
box-shadow: 0 6px 18px var(--accent-glow);
}
.public-dark .btn-primary:hover { filter: brightness(1.08); }
.public-dark .pill { background: rgb(255 255 255 / 0.06); color: rgb(255 255 255 / 0.85); border-color: rgb(255 255 255 / 0.12); }
/* Remap slate text tokens */
.public-dark .text-slate-900,
.public-dark .text-slate-800,
.public-dark .text-slate-700 { color: rgb(255 255 255 / 0.95); }
.public-dark .text-slate-600 { color: rgb(255 255 255 / 0.75); }
.public-dark .text-slate-500 { color: rgb(255 255 255 / 0.55); }
.public-dark .text-slate-400 { color: rgb(255 255 255 / 0.4); }
.public-dark .text-slate-300 { color: rgb(255 255 255 / 0.3); }
/* Backgrounds & borders that views used */
.public-dark .bg-white { background: rgb(255 255 255 / 0.12); color: rgb(255 255 255 / 0.95); }
.public-dark .bg-slate-50, .public-dark .bg-slate-50\/60 { background: rgb(255 255 255 / 0.025); }
.public-dark .bg-slate-100 { background: rgb(255 255 255 / 0.06); }
.public-dark .bg-slate-200 { background: rgb(255 255 255 / 0.1); }
.public-dark .border-slate-100,
.public-dark .border-slate-200,
.public-dark .border-slate-300 { border-color: rgb(255 255 255 / 0.08); }
.public-dark .ring-slate-200 { --tw-ring-color: rgb(255 255 255 / 0.08); }
/* Tables in dark */
.public-dark .th { color: rgb(255 255 255 / 0.4); }
.public-dark .row { border-color: rgb(255 255 255 / 0.06); }
.public-dark .row:hover { background: rgb(24 24 27 / 0.07); }
/* Inline code blocks (pill highlight) — only for inline <code>, NOT code inside
a <pre> block (docs examples), which would get an unwanted full-width tint. */
.public-dark code { background: rgb(255 255 255 / 0.08); color: rgb(255 255 255 / 0.9); }
.public-dark pre code { background: transparent; padding: 0; }
/* Filter pills + segmented controls that admin pages stamp with bg-slate-900
for the "selected" state — that lands invisible on the dark shell. Treat
slate-900 as a strong-contrast surface in the dark palette. */
.public-dark .bg-slate-900 { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); }
.public-dark .hover\:bg-slate-700:hover { background: rgb(255 255 255 / 0.85); color: rgb(15 23 42); }
.public-dark .text-slate-700 { color: rgb(255 255 255 / 0.85); }
.public-dark .text-slate-200 { color: rgb(255 255 255 / 0.7); }
.public-dark .text-slate-100 { color: rgb(255 255 255 / 0.92); }
/* Divider utilities used by lists (recent activity, table rows). */
.public-dark .divide-slate-50 > :not([hidden]) ~ :not([hidden]),
.public-dark .divide-slate-100 > :not([hidden]) ~ :not([hidden]) {
border-color: rgb(255 255 255 / 0.05);
}
.public-dark .border-slate-100\/80 { border-color: rgb(255 255 255 / 0.06); }
.public-dark .hover\:bg-slate-50:hover { background: rgb(255 255 255 / 0.04); } .public-dark .hover\:bg-slate-50:hover { background: rgb(255 255 255 / 0.04); }
.public-dark .hover\:bg-slate-100:hover { background: rgb(255 255 255 / 0.06); } .public-dark .hover\:bg-slate-100:hover { background: rgb(255 255 255 / 0.06); }
/* ===== 后台页头 + 统一工具栏 ===== */
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.page-title { font-size: 20px; font-weight: 800; letter-spacing: -0.03em; color: var(--fg); }
.toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 10px 12px; }
.toolbar .field { height: 36px; padding-top: 0 !important; padding-bottom: 0 !important; font-size: 13px !important; }
.toolbar input.field:not([type]), .toolbar input.field[type="text"], .toolbar input.field[type="search"] {
padding-left: 34px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='m21 21-4.3-4.3'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: 11px center;
background-size: 16px;
}
.toolbar :is(.btn-soft, .btn-primary) { height: 36px; padding: 0 14px; font-size: 13px; }
.toolbar > .flex-1 { flex: 0 1 300px; min-width: 200px; }
.toolbar > .w-44 { flex: 0 1 240px; width: auto; }
.toolbar > .btn-primary { margin-left: auto; }
@layer components { @layer components {
/* surfaces — frosted card with a hairline glass edge + soft shadow. /* surfaces — frosted card with a hairline glass edge + soft shadow.
The inset top line is the light's "sheen" on the glass — barely The inset top line is the light's "sheen" on the glass — barely
visible on white, reads as a subtle rim-light in dark mode. */ visible on white, reads as a subtle rim-light in dark mode. */
.card { .card {
@apply rounded-2xl; @apply rounded-2xl;
background: var(--surface); background: #fff;
-webkit-backdrop-filter: blur(16px); border: 1px solid rgb(24 24 27 / 0.07);
backdrop-filter: blur(16px); box-shadow: 0 1px 2px rgb(24 24 27 / 0.03), 0 10px 28px -16px rgb(24 24 27 / 0.12);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow), inset 0 1px 0 rgb(255 255 255 / 0.55);
} }
/* buttons — each variant is self-contained (Tailwind v4 can't @apply a custom class) */ /* buttons — each variant is self-contained (Tailwind v4 can't @apply a custom class) */
@@ -663,31 +380,26 @@ html.dark .memphis-dots {
@apply inline-flex items-center justify-center gap-1.5 rounded-lg text-sm font-bold @apply inline-flex items-center justify-center gap-1.5 rounded-lg text-sm font-bold
disabled:opacity-50 disabled:cursor-not-allowed px-3.5 py-2; disabled:opacity-50 disabled:cursor-not-allowed px-3.5 py-2;
color: #fff; color: #fff;
background: linear-gradient(120deg, #18181b, #27272a 60%, #52525b 140%); background: #18181b;
border: 1px solid rgb(255 255 255 / 0.16); border: 1px solid #18181b;
box-shadow: 0 6px 18px var(--accent-glow); box-shadow: 0 6px 16px -6px rgb(9 9 11 / 0.45);
transition: transform 0.13s ease, box-shadow 0.13s ease, filter 0.13s ease; transition: transform 0.13s ease, box-shadow 0.13s ease, background 0.13s ease;
} }
.btn-primary:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 10px 26px var(--accent-glow); filter: brightness(1.07); } .btn-primary:hover:not(:disabled) { background: #27272a; box-shadow: 0 10px 22px -8px rgb(9 9 11 / 0.5); }
.btn-primary:active:not(:disabled) { transform: scale(0.97) translateY(1px); box-shadow: 0 3px 10px var(--accent-glow); } .btn-primary:active:not(:disabled) { transform: scale(0.97); box-shadow: none; }
.btn-soft { .btn-soft {
@apply inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-semibold @apply inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-semibold
disabled:opacity-50 disabled:cursor-not-allowed px-3 py-1.5; disabled:opacity-50 disabled:cursor-not-allowed px-3 py-1.5;
color: var(--fg-2); color: var(--fg);
background: var(--surface); background: #fff;
-webkit-backdrop-filter: blur(10px); border: 1px solid rgb(24 24 27 / 0.12);
backdrop-filter: blur(10px); transition: transform 0.13s ease, background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
border: 1px solid var(--ink-soft);
transition: transform 0.13s ease, box-shadow 0.13s ease, background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
} }
.btn-soft:hover:not(:disabled) { .btn-soft:hover:not(:disabled) {
color: var(--fg); background: #fafafa;
background: var(--hover); border-color: rgb(24 24 27 / 0.3);
border-color: rgb(24 24 27 / 0.45);
box-shadow: 0 4px 14px rgb(24 24 27 / 0.16);
} }
html.dark .btn-soft:hover:not(:disabled) { border-color: rgb(24 24 27 / 0.5); box-shadow: 0 4px 14px rgb(24 24 27 / 0.22); } .btn-soft:active:not(:disabled) { transform: scale(0.97); }
.btn-soft:active:not(:disabled) { transform: scale(0.97) translateY(1px); box-shadow: none; }
.btn-ghost { .btn-ghost {
@apply inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-medium @apply inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-medium
disabled:opacity-50 disabled:cursor-not-allowed px-3 py-1.5; disabled:opacity-50 disabled:cursor-not-allowed px-3 py-1.5;
@@ -697,8 +409,7 @@ html.dark .memphis-dots {
} }
.btn-ghost:hover:not(:disabled) { .btn-ghost:hover:not(:disabled) {
color: var(--fg); color: var(--fg);
background: var(--hover); background: rgb(24 24 27 / 0.06);
border-color: rgb(24 24 27 / 0.3);
} }
/* text link / quiet action */ /* text link / quiet action */
@@ -710,13 +421,14 @@ html.dark .memphis-dots {
@apply w-full rounded-lg px-3 py-2 text-sm @apply w-full rounded-lg px-3 py-2 text-sm
outline-none placeholder:text-slate-400; outline-none placeholder:text-slate-400;
color: var(--fg); color: var(--fg);
background: var(--surface); background: #fff;
border: 1px solid var(--ink-soft); border: 1px solid rgb(24 24 27 / 0.12);
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
} }
.field:hover { border-color: rgb(24 24 27 / 0.22); }
.field:focus, .field:focus-visible { .field:focus, .field:focus-visible {
border-color: rgb(24 24 27 / 0.65); border-color: #18181b;
box-shadow: 0 0 0 3px rgb(24 24 27 / 0.18); box-shadow: 0 0 0 3px rgb(24 24 27 / 0.08);
} }
html.dark .field:focus, html.dark .field:focus-visible { html.dark .field:focus, html.dark .field:focus-visible {
border-color: rgb(24 24 27 / 0.7); border-color: rgb(24 24 27 / 0.7);
@@ -748,20 +460,20 @@ html.dark .memphis-dots {
/* status pill */ /* status pill */
.pill { .pill {
@apply inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold; @apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold;
border: 1px solid var(--ink-soft); border: 1px solid transparent;
} }
/* table primitives */ /* table primitives */
.th { @apply text-left px-4 py-3 text-[11px] font-bold uppercase tracking-wider; color: var(--fg-faint); } .th { @apply text-left px-4 py-3 text-[11px] font-bold uppercase tracking-wider; color: var(--fg-3); }
.td { @apply px-4 py-3.5 align-middle; } .td { @apply px-4 py-3.5 align-middle; }
.row { @apply border-t transition-colors; border-color: var(--hairline); } .row { @apply border-t transition-colors; border-color: var(--hairline); }
.row:hover { background: rgb(24 24 27 / 0.06); } .row:hover { background: #f7f7f8; }
/* avatar chip for identity columns */ /* avatar chip for identity columns */
.avatar { .avatar {
@apply w-8 h-8 rounded-full grid place-items-center text-xs font-bold shrink-0; @apply w-8 h-8 rounded-full grid place-items-center text-xs font-bold shrink-0;
border: 1px solid var(--ink); border: 1px solid rgb(24 24 27 / 0.1);
background: var(--surface-2); background: #f4f4f5;
} }
} }
+11 -20
View File
@@ -5,6 +5,7 @@ import { ref, computed, onMounted, watch } from 'vue'
import { api, jsonBody } from '../api' import { api, jsonBody } from '../api'
import { fmtTs } from '../utils/format' import { fmtTs } from '../utils/format'
import Icon from '../components/Icon.vue' import Icon from '../components/Icon.vue'
import SelectMenu from '../components/SelectMenu.vue'
const items = ref([]) const items = ref([])
const stats = ref({ total: 0, active: 0, redeemed: 0, active_amount: 0, redeemed_amount: 0 }) const stats = ref({ total: 0, active: 0, redeemed: 0, active_amount: 0, redeemed_amount: 0 })
@@ -14,7 +15,9 @@ const loading = ref(false)
const statusFilter = ref('') // '' | 'active'(未使用) | 'used'(已使用) const statusFilter = ref('') // '' | 'active'(未使用) | 'used'(已使用)
const typeFilter = ref('') // '' | 'normal' | 'marketing' const typeFilter = ref('') // '' | 'normal' | 'marketing'
const search = ref('') const search = ref('')
function setFilter(fn) { fn(); resetAndLoad() } const TYPE_OPTS = [{ value: '', label: '全部类型' }, { value: 'normal', label: '普通' }, { value: 'marketing', label: '营销' }]
const STATUS_OPTS = [{ value: '', label: '所有状态' }, { value: 'active', label: '未使用' }, { value: 'used', label: '已使用' }]
watch([typeFilter, statusFilter], resetAndLoad)
function resetAndLoad() { function resetAndLoad() {
if (page.value !== 1) page.value = 1 if (page.value !== 1) page.value = 1
else load() else load()
@@ -140,6 +143,10 @@ const pageNumbers = computed(() => {
<template> <template>
<section class="space-y-4"> <section class="space-y-4">
<div class="page-head">
<h2 class="page-title">兑换码管理</h2>
<button @click="load" class="btn-soft"><Icon name="refresh" class="w-3.5 h-3.5" /> 刷新</button>
</div>
<!-- KPI strip — same shape as LogsView / InvitesAdminView --> <!-- KPI strip — same shape as LogsView / InvitesAdminView -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3"> <div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="card p-4"> <div class="card p-4">
@@ -198,31 +205,15 @@ const pageNumbers = computed(() => {
</div> </div>
<!-- Toolbar --> <!-- Toolbar -->
<div class="card p-3 flex items-center gap-3 flex-wrap"> <div class="card toolbar">
<div class="flex items-center gap-1"> <SelectMenu v-model="typeFilter" compact :options="TYPE_OPTS" />
<button @click="setFilter(() => typeFilter = '')" class="fp" :class="typeFilter === '' && 'fp-on'">全部类型</button> <SelectMenu v-model="statusFilter" compact :options="STATUS_OPTS" />
<button @click="setFilter(() => typeFilter = 'normal')" class="fp" :class="typeFilter === 'normal' && 'fp-on'">普通</button>
<button @click="setFilter(() => typeFilter = 'marketing')" class="fp" :class="typeFilter === 'marketing' && 'fp-rose'">
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>营销
</button>
</div>
<div class="w-px h-5 bg-white/10"></div>
<div class="flex items-center gap-1">
<button @click="setFilter(() => statusFilter = '')" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button>
<button @click="setFilter(() => statusFilter = 'active')" class="fp" :class="statusFilter === 'active' && 'fp-emerald'">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>未使用
</button>
<button @click="setFilter(() => statusFilter = 'used')" class="fp" :class="statusFilter === 'used' && 'fp-on'">已使用</button>
</div>
<div class="flex-1 min-w-[160px]"> <div class="flex-1 min-w-[160px]">
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索兑换码…" /> <input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索兑换码…" />
</div> </div>
<button v-if="selected.size" @click="delSelected" class="btn-soft danger" title="删除选中的兑换码"> <button v-if="selected.size" @click="delSelected" class="btn-soft danger" title="删除选中的兑换码">
<Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ selected.size }}) <Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ selected.size }})
</button> </button>
<button @click="load" class="btn-soft">
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
</button>
</div> </div>
<!-- table --> <!-- table -->
+12 -29
View File
@@ -5,6 +5,7 @@ import { fmtTs, fmtDate, fmtClock } from '../utils/format'
import { copyText } from '../utils/clipboard' import { copyText } from '../utils/clipboard'
import { generatedUrl, thumbUrl } from '../api' import { generatedUrl, thumbUrl } from '../api'
import Icon from '../components/Icon.vue' import Icon from '../components/Icon.vue'
import SelectMenu from '../components/SelectMenu.vue'
import MediaLightbox from '../components/MediaLightbox.vue' import MediaLightbox from '../components/MediaLightbox.vue'
const items = ref([]) const items = ref([])
@@ -83,6 +84,9 @@ function goPage(n) {
} }
// Filters reset the cursor so a narrower view always starts on page 1. // Filters reset the cursor so a narrower view always starts on page 1.
const KIND_OPTS = [{ value: '', label: '全部类型' }, { value: 'image', label: '图像' }, { value: 'video', label: '视频' }]
const STATUS_OPTS = [{ value: '', label: '所有状态' }, { value: 'success', label: '成功' }, { value: 'failed', label: '失败' }, { value: 'pending', label: '进行中' }]
const SOURCE_OPTS = [{ value: '', label: '所有来源' }, { value: 'user', label: '画图台' }, { value: 'v1', label: 'API' }, { value: 'admin', label: '测试' }]
function setKind(v) { kindFilter.value = v; page.value = 1; load() } function setKind(v) { kindFilter.value = v; page.value = 1; load() }
function setStatus(v) { statusFilter.value = v; page.value = 1; load() } function setStatus(v) { statusFilter.value = v; page.value = 1; load() }
function setSource(v) { sourceFilter.value = v; page.value = 1; load() } function setSource(v) { sourceFilter.value = v; page.value = 1; load() }
@@ -153,6 +157,10 @@ const sourcePill = (s) => ({
<template> <template>
<section class="space-y-4"> <section class="space-y-4">
<div class="page-head">
<h2 class="page-title">日志管理</h2>
<button @click="load" class="btn-soft"><Icon name="refresh" class="w-3.5 h-3.5" /> 刷新</button>
</div>
<!-- KPI strip — dense pills aligned with the dashboard tints --> <!-- KPI strip — dense pills aligned with the dashboard tints -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3"> <div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="card p-4"> <div class="card p-4">
@@ -174,32 +182,10 @@ const sourcePill = (s) => ({
</div> </div>
<!-- Toolbar --> <!-- Toolbar -->
<div class="card p-3 flex items-center gap-3 flex-wrap"> <div class="card toolbar">
<div class="flex items-center gap-1"> <SelectMenu :model-value="kindFilter" compact :options="KIND_OPTS" @update:model-value="setKind" />
<button @click="setKind('')" class="fp" :class="kindFilter === '' && 'fp-on'">全部</button> <SelectMenu :model-value="statusFilter" compact :options="STATUS_OPTS" @update:model-value="setStatus" />
<button @click="setKind('image')" class="fp" :class="kindFilter === 'image' && 'fp-on'">图像</button> <SelectMenu :model-value="sourceFilter" compact :options="SOURCE_OPTS" @update:model-value="setSource" />
<button @click="setKind('video')" class="fp" :class="kindFilter === 'video' && 'fp-on'">视频</button>
</div>
<div class="w-px h-5 bg-white/10"></div>
<div class="flex items-center gap-1">
<button @click="setStatus('')" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button>
<button @click="setStatus('success')" class="fp" :class="statusFilter === 'success' && 'fp-emerald'">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>成功
</button>
<button @click="setStatus('failed')" class="fp" :class="statusFilter === 'failed' && 'fp-rose'">
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>失败
</button>
<button @click="setStatus('pending')" class="fp" :class="statusFilter === 'pending' && 'fp-amber'">
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>进行中
</button>
</div>
<div class="w-px h-5 bg-white/10"></div>
<div class="flex items-center gap-1">
<button @click="setSource('')" class="fp" :class="sourceFilter === '' && 'fp-on'">所有来源</button>
<button @click="setSource('user')" class="fp" :class="sourceFilter === 'user' && 'fp-on'">画图台</button>
<button @click="setSource('v1')" class="fp" :class="sourceFilter === 'v1' && 'fp-on'">API</button>
<button @click="setSource('admin')" class="fp" :class="sourceFilter === 'admin' && 'fp-on'">测试</button>
</div>
<div class="w-44"> <div class="w-44">
<input v-model="userSearch" @keyup.enter="doUserSearch" @change="doUserSearch" <input v-model="userSearch" @keyup.enter="doUserSearch" @change="doUserSearch"
class="field !py-1.5 text-xs" placeholder="搜索用户 名称/邮箱/ID…" /> class="field !py-1.5 text-xs" placeholder="搜索用户 名称/邮箱/ID…" />
@@ -208,9 +194,6 @@ const sourcePill = (s) => ({
<input v-model="search" @keyup.enter="doUserSearch" @change="doUserSearch" <input v-model="search" @keyup.enter="doUserSearch" @change="doUserSearch"
class="field !py-1.5 text-xs" placeholder="搜索 模型 / 提示词 / 错误…" /> class="field !py-1.5 text-xs" placeholder="搜索 模型 / 提示词 / 错误…" />
</div> </div>
<button @click="load" class="btn-soft">
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
</button>
</div> </div>
<!-- Table --> <!-- Table -->
+10 -19
View File
@@ -2,6 +2,7 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { api } from '../api' import { api } from '../api'
import Icon from '../components/Icon.vue' import Icon from '../components/Icon.vue'
import SelectMenu from '../components/SelectMenu.vue'
import ModelFormModal from '../components/ModelFormModal.vue' import ModelFormModal from '../components/ModelFormModal.vue'
import TestModal from '../components/TestModal.vue' import TestModal from '../components/TestModal.vue'
import { points } from '../credits' import { points } from '../credits'
@@ -15,6 +16,8 @@ const testing = ref(null) // model being tested, or null
const kindFilter = ref('') // '' | 'image' | 'video' const kindFilter = ref('') // '' | 'image' | 'video'
const saleFilter = ref('') // '' | 'sellable' | 'unsellable' const saleFilter = ref('') // '' | 'sellable' | 'unsellable'
const KIND_OPTS = [{ value: '', label: '全部类型' }, { value: 'image', label: '图像' }, { value: 'video', label: '视频' }]
const SALE_OPTS = [{ value: '', label: '全部状态' }, { value: 'sellable', label: '已上架' }, { value: 'unsellable', label: '未上架' }]
const search = ref('') const search = ref('')
const TYPE_LABEL = { image: '生图', video: '生视频' } const TYPE_LABEL = { image: '生图', video: '生视频' }
@@ -72,6 +75,10 @@ onMounted(loadModels)
<template> <template>
<section class="space-y-4"> <section class="space-y-4">
<div class="page-head">
<h2 class="page-title">模型管理</h2>
<button @click="loadModels" class="btn-soft"><Icon name="refresh" class="w-3.5 h-3.5" /> 刷新</button>
</div>
<!-- KPI strip — same shape as LogsView / InvitesAdminView --> <!-- KPI strip — same shape as LogsView / InvitesAdminView -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3"> <div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="card p-4"> <div class="card p-4">
@@ -93,28 +100,12 @@ onMounted(loadModels)
</div> </div>
<!-- Toolbar --> <!-- Toolbar -->
<div class="card p-3 flex items-center gap-3 flex-wrap"> <div class="card toolbar">
<div class="flex items-center gap-1"> <SelectMenu v-model="kindFilter" compact :options="KIND_OPTS" />
<button @click="kindFilter = ''" class="fp" :class="kindFilter === '' && 'fp-on'">全部</button> <SelectMenu v-model="saleFilter" compact :options="SALE_OPTS" />
<button @click="kindFilter = 'image'" class="fp" :class="kindFilter === 'image' && 'fp-on'">图像</button>
<button @click="kindFilter = 'video'" class="fp" :class="kindFilter === 'video' && 'fp-on'">视频</button>
</div>
<div class="w-px h-5 bg-white/10"></div>
<div class="flex items-center gap-1">
<button @click="saleFilter = ''" class="fp" :class="saleFilter === '' && 'fp-on'">全部</button>
<button @click="saleFilter = 'sellable'" class="fp" :class="saleFilter === 'sellable' && 'fp-emerald'">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>上架
</button>
<button @click="saleFilter = 'unsellable'" class="fp" :class="saleFilter === 'unsellable' && 'fp-white'">
<span class="w-1.5 h-1.5 rounded-full bg-white/40"></span>未上架
</button>
</div>
<div class="flex-1 min-w-[200px]"> <div class="flex-1 min-w-[200px]">
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 模型 ID / 别名…" /> <input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 模型 ID / 别名…" />
</div> </div>
<button @click="loadModels" class="btn-soft">
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
</button>
</div> </div>
<!-- Table --> <!-- Table -->
+1 -1
View File
@@ -100,7 +100,7 @@ async function cont(o) {
</div> </div>
<!-- Filter bar --> <!-- Filter bar -->
<div class="card p-3 flex items-center gap-3 flex-wrap"> <div class="card toolbar">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<button v-for="s in [['','全部'],['pending','待支付'],['paid','已支付'],['cancelled','已取消']]" :key="s[0]" <button v-for="s in [['','全部'],['pending','待支付'],['paid','已支付'],['cancelled','已取消']]" :key="s[0]"
@click="setStatus(s[0])" @click="setStatus(s[0])"
+6 -14
View File
@@ -10,6 +10,7 @@ import { fmtDate, fmtClock, fmtTs } from '../utils/format'
import { copyText } from '../utils/clipboard' import { copyText } from '../utils/clipboard'
import { points } from '../credits' import { points } from '../credits'
import Icon from '../components/Icon.vue' import Icon from '../components/Icon.vue'
import SelectMenu from '../components/SelectMenu.vue'
import MediaLightbox from '../components/MediaLightbox.vue' import MediaLightbox from '../components/MediaLightbox.vue'
const router = useRouter() const router = useRouter()
@@ -44,6 +45,8 @@ async function copyError(e) {
// 来源筛选走服务端:画图台 = source "user",API = source "v1"。 // 来源筛选走服务端:画图台 = source "user",API = source "v1"。
const SOURCE_PARAM = { web: 'user', api: 'v1' } const SOURCE_PARAM = { web: 'user', api: 'v1' }
const STATUS_OPTS = [{ value: '', label: '所有状态' }, { value: 'success', label: '成功' }, { value: 'failed', label: '失败' }, { value: 'pending', label: '进行中' }]
const SOURCE_OPTS = [{ value: '', label: '全部来源' }, { value: 'web', label: '画图台' }, { value: 'api', label: 'API' }]
// 服务端分页 —— 不再只拉前 200 条;按页向后端取,可翻到全部历史。 // 服务端分页 —— 不再只拉前 200 条;按页向后端取,可翻到全部历史。
async function load() { async function load() {
@@ -158,20 +161,9 @@ const params = (e) => {
</div> </div>
<!-- Filter bar --> <!-- Filter bar -->
<div class="card p-3 flex items-center gap-3 flex-wrap"> <div class="card toolbar">
<div class="flex items-center gap-1.5"> <SelectMenu :model-value="statusFilter" compact :options="STATUS_OPTS" @update:model-value="setStatus" />
<button v-for="s in [['','全部'],['success','成功'],['failed','失败'],['pending','进行中']]" :key="s[0]" <SelectMenu :model-value="sourceFilter" compact :options="SOURCE_OPTS" @update:model-value="setSource" />
@click="setStatus(s[0])"
class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
:class="statusFilter === s[0] ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">{{ s[1] }}</button>
</div>
<div class="w-px h-5 bg-slate-200"></div>
<div class="flex items-center gap-1.5">
<button v-for="s in [['','全部来源'],['web','画图台'],['api','API']]" :key="s[0]"
@click="setSource(s[0])"
class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
:class="sourceFilter === s[0] ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">{{ s[1] }}</button>
</div>
<div class="flex-1 min-w-[180px]"> <div class="flex-1 min-w-[180px]">
<input v-model="search" @keyup.enter="doSearch" @change="doSearch" <input v-model="search" @keyup.enter="doSearch" @change="doSearch"
class="field !py-1.5 text-xs" placeholder="搜索 提示词 / 模型 / 错误…" /> class="field !py-1.5 text-xs" placeholder="搜索 提示词 / 模型 / 错误…" />
+1 -1
View File
@@ -244,7 +244,7 @@ onUnmounted(() => {
</div> </div>
<!-- Filter bar --> <!-- Filter bar -->
<div class="card p-3 flex items-center gap-3 flex-wrap"> <div class="card toolbar">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<button @click="setKind('')" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors" <button @click="setKind('')" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors"
:class="kindFilter === '' ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">全部</button> :class="kindFilter === '' ? 'chip-on' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">全部</button>
+16 -26
View File
@@ -85,7 +85,14 @@ function goPage(n) {
if (target !== page.value) page.value = target if (target !== page.value) page.value = target
} }
watch(page, () => { load() }) watch(page, () => { load() })
function setFilter(fn) { fn(); resetAndLoad() } const ROLE_OPTS = [
{ value: '', label: '全部角色' }, { value: 'admin', label: '管理员' },
{ value: 'agent', label: '代理' }, { value: 'user', label: '用户' },
]
const STATUS_OPTS = [
{ value: '', label: '所有状态' }, { value: 'active', label: '正常' }, { value: 'disabled', label: '禁用' },
]
watch([roleFilter, statusFilter], resetAndLoad)
function resetAndLoad() { function resetAndLoad() {
if (page.value !== 1) page.value = 1 if (page.value !== 1) page.value = 1
else load() else load()
@@ -210,6 +217,10 @@ async function doRecharge() {
<template> <template>
<section class="space-y-4"> <section class="space-y-4">
<div class="page-head">
<h2 class="page-title">用户管理</h2>
<button @click="load" class="btn-soft"><Icon name="refresh" class="w-3.5 h-3.5" /> 刷新</button>
</div>
<!-- KPI strip — same shape as LogsView / ModelsView --> <!-- KPI strip — same shape as LogsView / ModelsView -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3"> <div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="card p-4"> <div class="card p-4">
@@ -231,36 +242,15 @@ async function doRecharge() {
</div> </div>
<!-- Toolbar --> <!-- Toolbar -->
<div class="card p-3 flex items-center gap-3 flex-wrap"> <div class="card toolbar">
<div class="flex items-center gap-1"> <SelectMenu v-model="roleFilter" compact :options="ROLE_OPTS" />
<button @click="setFilter(() => roleFilter = '')" class="fp" :class="roleFilter === '' && 'fp-on'">全部角色</button> <SelectMenu v-model="statusFilter" compact :options="STATUS_OPTS" />
<button @click="setFilter(() => roleFilter = 'admin')" class="fp" :class="roleFilter === 'admin' && 'fp-orange'">
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>管理员
</button>
<button @click="setFilter(() => roleFilter = 'agent')" class="fp" :class="roleFilter === 'agent' && 'fp-amber'">
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>代理
</button>
<button @click="setFilter(() => roleFilter = 'user')" class="fp" :class="roleFilter === 'user' && 'fp-on'">用户</button>
</div>
<div class="w-px h-5 bg-white/10"></div>
<div class="flex items-center gap-1">
<button @click="setFilter(() => statusFilter = '')" class="fp" :class="statusFilter === '' && 'fp-on'">所有状态</button>
<button @click="setFilter(() => statusFilter = 'active')" class="fp" :class="statusFilter === 'active' && 'fp-emerald'">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>正常
</button>
<button @click="setFilter(() => statusFilter = 'disabled')" class="fp" :class="statusFilter === 'disabled' && 'fp-rose'">
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>禁用
</button>
</div>
<div class="flex-1 min-w-[200px]"> <div class="flex-1 min-w-[200px]">
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 邮箱 / 用户名 / ID…" /> <input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 邮箱 / 用户名 / ID…" />
</div> </div>
<button v-if="selected.size" @click="delSelected" class="btn-soft danger" title="删除选中的用户"> <button v-if="selected.size" @click="delSelected" class="btn-soft danger" title="删除选中的用户">
<Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ selected.size }}) <Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ selected.size }})
</button> </button>
<button @click="load" class="btn-soft">
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
</button>
<button @click="showAdd = true" class="btn-primary"> <button @click="showAdd = true" class="btn-primary">
<Icon name="plus" class="w-3.5 h-3.5" /> 新建用户 <Icon name="plus" class="w-3.5 h-3.5" /> 新建用户
</button> </button>
@@ -278,7 +268,7 @@ async function doRecharge() {
</div> </div>
<div v-else class="overflow-x-auto"> <div v-else class="overflow-x-auto">
<table class="w-full text-sm table-fixed min-w-[1360px]"> <table class="w-full text-sm table-fixed min-w-[1500px]">
<colgroup> <colgroup>
<col class="w-9" /> <!-- select --> <col class="w-9" /> <!-- select -->
<col class="w-36" /> <!-- username --> <col class="w-36" /> <!-- username -->