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