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:
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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])"
|
||||||
|
|||||||
@@ -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="搜索 提示词 / 模型 / 错误…" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
Reference in New Issue
Block a user