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
+11 -20
View File
@@ -5,6 +5,7 @@ import { ref, computed, onMounted, watch } from 'vue'
import { api, jsonBody } from '../api'
import { fmtTs } from '../utils/format'
import Icon from '../components/Icon.vue'
import SelectMenu from '../components/SelectMenu.vue'
const items = ref([])
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 typeFilter = ref('') // '' | 'normal' | 'marketing'
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() {
if (page.value !== 1) page.value = 1
else load()
@@ -140,6 +143,10 @@ const pageNumbers = computed(() => {
<template>
<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 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="card p-4">
@@ -198,31 +205,15 @@ const pageNumbers = computed(() => {
</div>
<!-- Toolbar -->
<div class="card p-3 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-1">
<button @click="setFilter(() => typeFilter = '')" class="fp" :class="typeFilter === '' && 'fp-on'">全部类型</button>
<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="card toolbar">
<SelectMenu v-model="typeFilter" compact :options="TYPE_OPTS" />
<SelectMenu v-model="statusFilter" compact :options="STATUS_OPTS" />
<div class="flex-1 min-w-[160px]">
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索兑换码…" />
</div>
<button v-if="selected.size" @click="delSelected" class="btn-soft danger" title="删除选中的兑换码">
<Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ selected.size }})
</button>
<button @click="load" class="btn-soft">
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
</button>
</div>
<!-- table -->
+12 -29
View File
@@ -5,6 +5,7 @@ import { fmtTs, fmtDate, fmtClock } from '../utils/format'
import { copyText } from '../utils/clipboard'
import { generatedUrl, thumbUrl } from '../api'
import Icon from '../components/Icon.vue'
import SelectMenu from '../components/SelectMenu.vue'
import MediaLightbox from '../components/MediaLightbox.vue'
const items = ref([])
@@ -83,6 +84,9 @@ function goPage(n) {
}
// 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 setStatus(v) { statusFilter.value = v; page.value = 1; load() }
function setSource(v) { sourceFilter.value = v; page.value = 1; load() }
@@ -153,6 +157,10 @@ const sourcePill = (s) => ({
<template>
<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 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="card p-4">
@@ -174,32 +182,10 @@ const sourcePill = (s) => ({
</div>
<!-- Toolbar -->
<div class="card p-3 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-1">
<button @click="setKind('')" class="fp" :class="kindFilter === '' && 'fp-on'">全部</button>
<button @click="setKind('image')" class="fp" :class="kindFilter === 'image' && 'fp-on'">图像</button>
<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="card toolbar">
<SelectMenu :model-value="kindFilter" compact :options="KIND_OPTS" @update:model-value="setKind" />
<SelectMenu :model-value="statusFilter" compact :options="STATUS_OPTS" @update:model-value="setStatus" />
<SelectMenu :model-value="sourceFilter" compact :options="SOURCE_OPTS" @update:model-value="setSource" />
<div class="w-44">
<input v-model="userSearch" @keyup.enter="doUserSearch" @change="doUserSearch"
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"
class="field !py-1.5 text-xs" placeholder="搜索 模型 / 提示词 / 错误…" />
</div>
<button @click="load" class="btn-soft">
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
</button>
</div>
<!-- Table -->
+10 -19
View File
@@ -2,6 +2,7 @@
import { ref, computed, onMounted } from 'vue'
import { api } from '../api'
import Icon from '../components/Icon.vue'
import SelectMenu from '../components/SelectMenu.vue'
import ModelFormModal from '../components/ModelFormModal.vue'
import TestModal from '../components/TestModal.vue'
import { points } from '../credits'
@@ -15,6 +16,8 @@ const testing = ref(null) // model being tested, or null
const kindFilter = ref('') // '' | 'image' | 'video'
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 TYPE_LABEL = { image: '生图', video: '生视频' }
@@ -72,6 +75,10 @@ onMounted(loadModels)
<template>
<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 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="card p-4">
@@ -93,28 +100,12 @@ onMounted(loadModels)
</div>
<!-- Toolbar -->
<div class="card p-3 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-1">
<button @click="kindFilter = ''" class="fp" :class="kindFilter === '' && 'fp-on'">全部</button>
<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="card toolbar">
<SelectMenu v-model="kindFilter" compact :options="KIND_OPTS" />
<SelectMenu v-model="saleFilter" compact :options="SALE_OPTS" />
<div class="flex-1 min-w-[200px]">
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 模型 ID / 别名…" />
</div>
<button @click="loadModels" class="btn-soft">
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
</button>
</div>
<!-- Table -->
+1 -1
View File
@@ -100,7 +100,7 @@ async function cont(o) {
</div>
<!-- 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">
<button v-for="s in [['','全部'],['pending','待支付'],['paid','已支付'],['cancelled','已取消']]" :key="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 { points } from '../credits'
import Icon from '../components/Icon.vue'
import SelectMenu from '../components/SelectMenu.vue'
import MediaLightbox from '../components/MediaLightbox.vue'
const router = useRouter()
@@ -44,6 +45,8 @@ async function copyError(e) {
// 来源筛选走服务端:画图台 = source "user",API = source "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 条;按页向后端取,可翻到全部历史。
async function load() {
@@ -158,20 +161,9 @@ const params = (e) => {
</div>
<!-- Filter bar -->
<div class="card p-3 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-1.5">
<button v-for="s in [['','全部'],['success','成功'],['failed','失败'],['pending','进行中']]" :key="s[0]"
@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="card toolbar">
<SelectMenu :model-value="statusFilter" compact :options="STATUS_OPTS" @update:model-value="setStatus" />
<SelectMenu :model-value="sourceFilter" compact :options="SOURCE_OPTS" @update:model-value="setSource" />
<div class="flex-1 min-w-[180px]">
<input v-model="search" @keyup.enter="doSearch" @change="doSearch"
class="field !py-1.5 text-xs" placeholder="搜索 提示词 / 模型 / 错误…" />
+1 -1
View File
@@ -244,7 +244,7 @@ onUnmounted(() => {
</div>
<!-- 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">
<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>
+16 -26
View File
@@ -85,7 +85,14 @@ function goPage(n) {
if (target !== page.value) page.value = target
}
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() {
if (page.value !== 1) page.value = 1
else load()
@@ -210,6 +217,10 @@ async function doRecharge() {
<template>
<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 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="card p-4">
@@ -231,36 +242,15 @@ async function doRecharge() {
</div>
<!-- Toolbar -->
<div class="card p-3 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-1">
<button @click="setFilter(() => roleFilter = '')" class="fp" :class="roleFilter === '' && 'fp-on'">全部角色</button>
<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="card toolbar">
<SelectMenu v-model="roleFilter" compact :options="ROLE_OPTS" />
<SelectMenu v-model="statusFilter" compact :options="STATUS_OPTS" />
<div class="flex-1 min-w-[200px]">
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索 邮箱 / 用户名 / ID…" />
</div>
<button v-if="selected.size" @click="delSelected" class="btn-soft danger" title="删除选中的用户">
<Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ selected.size }})
</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">
<Icon name="plus" class="w-3.5 h-3.5" /> 新建用户
</button>
@@ -278,7 +268,7 @@ async function doRecharge() {
</div>
<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>
<col class="w-9" /> <!-- select -->
<col class="w-36" /> <!-- username -->