增加违禁词管理 增加多选操作

This commit is contained in:
2026-07-05 03:00:49 +08:00
parent 4d5b49c2a9
commit 10d0732f8c
24 changed files with 836 additions and 35 deletions
+4 -4
View File
@@ -279,13 +279,13 @@ function toggleSelect(id) {
s.has(id) ? s.delete(id) : s.add(id)
selected.value = s
}
// Header checkbox controls the whole filtered set (not just the visible page).
// Header checkbox selects/deselects the CURRENT PAGE only.
const allSelected = computed(() =>
filtered.value.length > 0 && filtered.value.every((a) => selected.value.has(a.id)))
pagedItems.value.length > 0 && pagedItems.value.every((a) => selected.value.has(a.id)))
function toggleSelectAll() {
const s = new Set(selected.value)
if (allSelected.value) filtered.value.forEach((a) => s.delete(a.id))
else filtered.value.forEach((a) => s.add(a.id))
if (allSelected.value) pagedItems.value.forEach((a) => s.delete(a.id))
else pagedItems.value.forEach((a) => s.add(a.id))
selected.value = s
}
async function deleteSelected() {
+182
View File
@@ -0,0 +1,182 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { api, jsonBody } from '../api'
import Icon from '../components/Icon.vue'
const items = ref([])
const loading = ref(false)
const newWord = ref('')
const toast = ref('')
let toastTimer = null
function flash(msg) { toast.value = msg; clearTimeout(toastTimer); toastTimer = setTimeout(() => (toast.value = ''), 1800) }
async function load() {
loading.value = true
const r = await api('/banned-words')
items.value = r.data?.data || []
loading.value = false
}
async function add() {
const word = newWord.value.trim()
if (!word) { flash('违禁词不能为空'); return }
const r = await api('/banned-words', jsonBody('POST', { word }))
if (r.ok) { newWord.value = ''; flash('已添加'); load() }
else flash(r.data?.detail || '添加失败')
}
async function del(w) {
if (!confirm(`删除违禁词「${w.word}」?`)) return
const r = await api(`/banned-words/${w.id}`, { method: 'DELETE' })
if (r.ok) { flash('已删除'); selected.value.delete(w.id); load() }
else flash(r.data?.detail || '删除失败')
}
// multi-select — header checkbox selects/deselects the CURRENT PAGE only.
const selected = ref(new Set())
function toggleSelect(id) {
const s = new Set(selected.value)
s.has(id) ? s.delete(id) : s.add(id)
selected.value = s
}
const allSelected = computed(() =>
pagedItems.value.length > 0 && pagedItems.value.every((w) => selected.value.has(w.id)))
function toggleSelectAll() {
const s = new Set(selected.value)
if (allSelected.value) pagedItems.value.forEach((w) => s.delete(w.id))
else pagedItems.value.forEach((w) => s.add(w.id))
selected.value = s
}
async function delSelected() {
const ids = [...selected.value]
if (!ids.length) return
if (!confirm(`确认删除选中的 ${ids.length} 个违禁词?`)) return
let ok = 0
for (const id of ids) {
const r = await api(`/banned-words/${id}`, { method: 'DELETE' })
if (r.ok) ok++
}
selected.value = new Set()
flash(`已删除 ${ok}`)
load()
}
// pagination (client-side; the full list arrives in one payload)
const page = ref(1)
const pageSize = 20
const totalPages = computed(() => Math.max(1, Math.ceil(items.value.length / pageSize)))
const pagedItems = computed(() => {
const start = (Math.min(page.value, totalPages.value) - 1) * pageSize
return items.value.slice(start, start + pageSize)
})
function goPage(n) {
const t = Math.max(1, Math.min(totalPages.value, n))
if (t !== page.value) page.value = t
}
const pageNumbers = computed(() => {
const n = totalPages.value, cur = page.value
if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1)
const want = new Set([1, n, cur - 1, cur, cur + 1])
if (cur <= 3) { want.add(2); want.add(3); want.add(4) }
if (cur >= n - 2) { want.add(n - 1); want.add(n - 2); want.add(n - 3) }
const list = [...want].filter((x) => x >= 1 && x <= n).sort((a, b) => a - b)
const out = []
for (let i = 0; i < list.length; i++) {
if (i > 0 && list[i] - list[i - 1] > 1) out.push(null)
out.push(list[i])
}
return out
})
onMounted(load)
</script>
<template>
<section class="theme-text space-y-4">
<div class="card p-4 flex items-center justify-between gap-3 flex-wrap">
<div>
<h2 class="text-sm font-semibold">违禁词管理</h2>
<p class="text-xs text-white/45 mt-0.5">提示词包含违禁词的生成请求(画图台 + API)会被<strong class="text-white/70">直接拦截</strong>,并累计触发次数(见用户管理)匹配不区分大小写</p>
</div>
<div class="flex items-center gap-2">
<button v-if="selected.size" @click="delSelected" class="btn-soft danger shrink-0" title="删除选中的违禁词">
<Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ selected.size }})
</button>
<input v-model="newWord" @keyup.enter="add" class="field !py-1.5 text-xs w-52" placeholder="输入违禁词后回车" />
<button @click="add" class="btn-primary shrink-0">+ 添加</button>
</div>
</div>
<div class="card overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="text-[10px] uppercase tracking-[0.2em] text-white/40 border-b border-white/[0.06]">
<th class="text-center px-3 py-3 font-medium w-9">
<input type="checkbox" :checked="allSelected" @change="toggleSelectAll" class="chk" title="全选本页" />
</th>
<th class="text-left px-5 py-3 font-medium">违禁词</th>
<th class="text-right px-3 py-3 font-medium">触发次数</th>
<th class="text-left px-3 py-3 font-medium">添加时间</th>
<th class="text-right px-3 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading && !items.length"><td colspan="5" class="text-center text-xs text-white/40 py-10">加载中</td></tr>
<tr v-else-if="!items.length"><td colspan="5" class="text-center text-xs text-white/40 py-10">还没有违禁词</td></tr>
<tr v-for="w in pagedItems" :key="w.id" class="border-b border-white/[0.04] hover:bg-white/[0.03] transition-colors">
<td class="px-3 py-3.5 align-middle text-center">
<input type="checkbox" :checked="selected.has(w.id)" @change="toggleSelect(w.id)" @click.stop class="chk" />
</td>
<td class="px-5 py-3.5 align-middle text-sm font-medium text-white/90">{{ w.word }}</td>
<td class="px-3 py-3.5 align-middle text-right tabular-nums" :class="w.hits > 0 ? 'text-rose-300' : 'text-white/50'">{{ w.hits }}</td>
<td class="px-3 py-3.5 align-middle text-xs text-white/50">{{ new Date(w.created_at).toLocaleString() }}</td>
<td class="px-3 py-3.5 align-middle text-right">
<button @click="del(w)" class="act danger" title="删除"><Icon name="trash" class="w-3.5 h-3.5" /></button>
</td>
</tr>
</tbody>
</table>
<div v-if="totalPages > 1" class="flex items-center justify-between px-5 py-3 border-t border-white/[0.06] text-xs text-white/45">
<div><span class="tabular-nums text-white/75">{{ items.length }}</span><span class="ml-1">个违禁词</span></div>
<div class="flex items-center gap-1">
<template v-for="(n, i) in pageNumbers" :key="i">
<span v-if="n === null" class="px-1 text-white/30"></span>
<button v-else @click="goPage(n)" class="pg" :class="page === n && 'pg-on'">{{ n }}</button>
</template>
</div>
</div>
</div>
<transition name="fade">
<div v-if="toast" class="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] bg-slate-900 text-white text-xs px-4 py-2 rounded-lg shadow-lg">{{ toast }}</div>
</transition>
</section>
</template>
<style scoped>
.act {
display: inline-flex; align-items: center; justify-content: center;
width: 1.9rem; height: 1.9rem; border-radius: 0.5rem;
color: rgb(255 255 255 / 0.7); background: rgb(255 255 255 / 0.04);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
transition: background 0.15s, color 0.15s;
}
.act:hover { background: rgb(255 255 255 / 0.1); color: white; }
.act.danger { color: rgb(253 164 175); background: rgb(244 63 94 / 0.12); box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3); }
.act.danger:hover { color: white; background: rgb(244 63 94 / 0.25); }
.btn-soft.danger {
color: rgb(253 164 175);
background: rgb(244 63 94 / 0.12);
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3);
}
.btn-soft.danger:hover {
color: white;
background: rgb(244 63 94 / 0.25);
}
.chk { accent-color: rgb(217 70 239); width: 0.9rem; height: 0.9rem; cursor: pointer; }
.pg { min-width: 1.75rem; padding: 0.3rem 0.55rem; font-size: 0.72rem; font-weight: 500; text-align: center; border-radius: 0.45rem; color: rgb(255 255 255 / 0.7); background: rgb(255 255 255 / 0.04); box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08); transition: background 0.15s, color 0.15s; }
.pg:hover:not(.pg-on) { background: rgb(255 255 255 / 0.1); color: white; }
.pg-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); box-shadow: none; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
+4 -3
View File
@@ -75,12 +75,13 @@ function toggleSelect(code) {
s.has(code) ? s.delete(code) : s.add(code)
selected.value = s
}
// Header checkbox selects/deselects the CURRENT PAGE only.
const allSelected = computed(() =>
filtered.value.length > 0 && filtered.value.every((c) => selected.value.has(c.code)))
pagedItems.value.length > 0 && pagedItems.value.every((c) => selected.value.has(c.code)))
function toggleSelectAll() {
const s = new Set(selected.value)
if (allSelected.value) filtered.value.forEach((c) => s.delete(c.code))
else filtered.value.forEach((c) => s.add(c.code))
if (allSelected.value) pagedItems.value.forEach((c) => s.delete(c.code))
else pagedItems.value.forEach((c) => s.add(c.code))
selected.value = s
}
async function delSelected() {
+3 -2
View File
@@ -234,8 +234,9 @@ function useExample(ex) {
<div v-for="(w, i) in [...works, ...works]" :key="w.id + '-' + i"
class="shrink-0 w-56 h-56 rounded-2xl overflow-hidden ring-1 ring-white/[0.08] hover:ring-white/30 hover:scale-[1.02] transition-all cursor-pointer relative"
@click="go('/logs')">
<img :src="imgSrc(w.image)" loading="lazy"
class="w-full h-full object-cover" />
<!-- background-image (not <img>) so Edge shows no 视觉搜索 overlay icon. -->
<div :style="{ backgroundImage: `url(${imgSrc(w.image)})` }"
class="w-full h-full bg-cover bg-center"></div>
<div v-if="w.title" class="absolute inset-x-0 bottom-0 p-3 bg-gradient-to-t from-black/85 via-black/30 to-transparent">
<div class="text-xs font-medium text-white line-clamp-1">{{ w.title }}</div>
</div>
+143 -8
View File
@@ -3,6 +3,7 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { api, generatedUrl, thumbUrl } from '../api'
import { fmtTs, fmtSize } from '../utils/format'
import { copyText } from '../utils/clipboard'
import { zipSync } from 'fflate'
import Icon from '../components/Icon.vue'
import MediaLightbox from '../components/MediaLightbox.vue'
@@ -38,6 +39,92 @@ async function load() {
loading.value = false
}
// Admin delete: remove the file (+derived stills) from storage, then reload
// so pagination and the KPI strip stay accurate.
async function deleteFile(f) {
if (!f || !f.name) return
if (!confirm('确定删除这个文件?删除后不可恢复')) return
const r = await api('/images?name=' + encodeURIComponent(f.name), { method: 'DELETE' })
flash(r.ok ? '已删除' : (r.data?.detail || '删除失败'))
if (r.ok) load()
}
// multi-select (keyed by file name) — bulk delete/download from the toolbar.
const picked = ref(new Set())
function togglePick(f) {
const s = new Set(picked.value)
s.has(f.name) ? s.delete(f.name) : s.add(f.name)
picked.value = s
}
const pageAllPicked = computed(() =>
items.value.length > 0 && items.value.every((f) => picked.value.has(f.name)))
function togglePickAll() {
const s = new Set(picked.value)
if (pageAllPicked.value) items.value.forEach((f) => s.delete(f.name))
else items.value.forEach((f) => s.add(f.name))
picked.value = s
}
async function deletePicked() {
const names = [...picked.value]
if (!names.length) return
if (!confirm(`确定删除选中的 ${names.length} 个文件?删除后不可恢复`)) return
let ok = 0
for (const n of names) {
const r = await api('/images?name=' + encodeURIComponent(n), { method: 'DELETE' })
if (r.ok) ok++
}
picked.value = new Set()
flash(`已删除 ${ok}`)
load()
}
// Single pick → direct file download; multiple → bundle into one zip.
const zipping = ref(false)
async function downloadPicked() {
const names = [...picked.value]
if (!names.length) return
if (names.length === 1) {
const a = document.createElement('a')
a.href = generatedUrl(names[0])
a.download = names[0].split('/').pop()
document.body.appendChild(a)
a.click()
a.remove()
return
}
zipping.value = true
flash('打包中…')
try {
// Fetch concurrently (10 at a time) so large batches pack fast.
const bufs = []
let next = 0
await Promise.all(Array.from({ length: Math.min(10, names.length) }, async () => {
while (next < names.length) {
const i = next++
bufs[i] = await (await fetch(generatedUrl(names[i]))).arrayBuffer()
}
}))
const entries = {}
names.forEach((n, i) => {
let name = n.split('/').pop()
while (entries[name]) name = '_' + name
entries[name] = [new Uint8Array(bufs[i]), { level: 0 }]
})
const zipped = zipSync(entries)
const url = URL.createObjectURL(new Blob([zipped], { type: 'application/zip' }))
const a = document.createElement('a')
a.href = url
a.download = `图片-${names.length}个-${Date.now()}.zip`
document.body.appendChild(a)
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(url), 30000)
flash('已打包下载')
} catch {
flash('打包失败')
}
zipping.value = false
}
function absUrl(name) {
const u = generatedUrl(name)
return u.startsWith('http') ? u : location.origin + u
@@ -148,9 +235,22 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
<button @click="setKind('image')" class="fp" :class="kind === 'image' && 'fp-on'">图像</button>
<button @click="setKind('video')" class="fp" :class="kind === 'video' && 'fp-on'">视频</button>
</div>
<button @click="load" class="btn-soft">
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
</button>
<div class="flex items-center gap-2">
<button @click="togglePickAll" class="btn-soft" :class="pageAllPicked && '!bg-white/90 !text-slate-900'">
<Icon name="check" class="w-3.5 h-3.5" /> 全选本页
</button>
<template v-if="picked.size">
<button @click="downloadPicked" :disabled="zipping" class="btn-soft disabled:opacity-50">
<Icon name="download" class="w-3.5 h-3.5" /> {{ zipping ? '打包中…' : `下载选中 (${picked.size})` }}
</button>
<button @click="deletePicked" class="btn-soft danger">
<Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ picked.size }})
</button>
</template>
<button @click="load" class="btn-soft">
<Icon name="refresh" class="w-3.5 h-3.5" /> 刷新
</button>
</div>
</div>
<!-- grid -->
@@ -186,11 +286,17 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
<!-- gradient veil (always visible so the prompt overlay reads) -->
<div class="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/85 via-black/40 to-transparent pointer-events-none"></div>
<!-- kind chip -->
<span class="absolute top-3 left-3 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ring-1"
:class="f.kind === 'video' ? 'bg-fuchsia-500/20 text-fuchsia-200 ring-fuchsia-400/30' : 'bg-indigo-500/20 text-indigo-200 ring-indigo-400/30'">
{{ f.kind === 'video' ? '视频' : '图像' }}
</span>
<!-- select + kind chip -->
<div class="absolute top-3 left-3 flex items-center gap-1.5">
<button @click.stop.prevent="togglePick(f)" :title="picked.has(f.name) ? '取消选择' : '选择'"
class="pick" :class="picked.has(f.name) && 'pick-on'">
<Icon name="check" class="w-3 h-3" />
</button>
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ring-1"
:class="f.kind === 'video' ? 'bg-fuchsia-500/20 text-fuchsia-200 ring-fuchsia-400/30' : 'bg-indigo-500/20 text-indigo-200 ring-indigo-400/30'">
{{ f.kind === 'video' ? '视频' : '图像' }}
</span>
</div>
<!-- quick actions, hover-revealed; same style as 首页内容 -->
<div class="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
@@ -202,6 +308,10 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center">
<Icon name="download" class="w-3.5 h-3.5" />
</a>
<button @click.stop.prevent="deleteFile(f)" title="删除"
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-rose-600/80 text-white grid place-items-center">
<Icon name="trash" class="w-3.5 h-3.5" />
</button>
</div>
<!-- caption: prompt (truncated 2 lines) + meta line -->
@@ -304,4 +414,29 @@ html.dark .fp-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); }
.pg:hover:not(.pg-on) { background: var(--hover); color: var(--fg); }
.pg-on { background: rgb(15 23 42); color: white; box-shadow: none; }
html.dark .pg-on { background: rgb(255 255 255 / 0.92); color: rgb(15 23 42); }
.btn-soft.danger {
color: rgb(253 164 175);
background: rgb(244 63 94 / 0.12);
box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3);
}
.btn-soft.danger:hover {
color: white;
background: rgb(244 63 94 / 0.25);
}
/* card select toggle — always visible rounded-square check button */
.pick {
width: 1.4rem; height: 1.4rem; border-radius: 0.375rem;
display: inline-flex; align-items: center; justify-content: center;
color: rgb(255 255 255 / 0.85);
background: rgb(0 0 0 / 0.45);
box-shadow: inset 0 0 0 1.5px rgb(255 255 255 / 0.75);
transition: background 0.15s, box-shadow 0.15s;
}
.pick svg { opacity: 0; transition: opacity 0.15s; }
.pick:hover { background: rgb(0 0 0 / 0.65); }
.pick:hover svg { opacity: 0.6; }
.pick-on { background: rgb(217 70 239); box-shadow: inset 0 0 0 1.5px rgb(255 255 255 / 0.9); }
.pick-on svg { opacity: 1; }
</style>
+21 -1
View File
@@ -396,7 +396,7 @@ let prevPending = 0
async function loadHistory() {
// Server-side filter: status IN (pending, success), newest 12 — exactly the
// rows the grid shows, in one query (no client over-fetch).
const r = await api('/logs?limit=10&statuses=pending,success&source=user')
const r = await api('/logs?limit=10&statuses=pending,success&source=user&exclude_showcase=1&media=1')
if (!r.ok) return
history.value = (r.data?.data || [])
.filter((e) => e.status === 'pending' || e.file)
@@ -423,6 +423,22 @@ async function loadHistory() {
prevPending = serverPending.size
}
// Delete one of my works: remove the stored file (+thumb) server-side, then
// drop the card locally so it disappears before the next history poll.
async function deleteItem(item) {
if (!item || !item.url) return
if (!confirm('确定删除这个作品?删除后不可恢复')) return
const rel = (item.url || '').split('?')[0].split('/images/').pop()
const r = await api('/my-files?file=' + encodeURIComponent(rel), { method: 'DELETE' })
if (r.ok) {
tasks.value = tasks.value.filter((t) => t.id !== item.id)
history.value = history.value.filter((h) => h.id !== item.id)
flash('已删除')
} else {
flash(r.data?.detail || '删除失败')
}
}
// Click a generated IMAGE → use it as a reference. Single-ref model: replace the
// existing ref. Multi-ref: append if there's room, else replace the last one.
function useAsRef(item) {
@@ -726,6 +742,10 @@ onUnmounted(() => {
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center">
<Icon name="plus" class="w-3.5 h-3.5" />
</button>
<button @click.stop.prevent="deleteItem(item)" title="删除"
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-rose-600/80 text-white grid place-items-center">
<Icon name="trash" class="w-3.5 h-3.5" />
</button>
</div>
<div class="absolute inset-x-0 bottom-0 p-2.5 pointer-events-none">
<div class="pg-cap text-[11px] leading-tight font-medium line-clamp-2 transition-colors"
+137 -5
View File
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import { api, generatedUrl, thumbUrl } from '../api'
import { fmtTs } from '../utils/format'
import { copyText } from '../utils/clipboard'
import { zipSync } from 'fflate'
import Icon from '../components/Icon.vue'
import MediaLightbox from '../components/MediaLightbox.vue'
@@ -123,6 +124,99 @@ async function copyPrompt(e) {
}
const toast = ref('')
// Delete one of my works (file + thumb server-side), then reload the page so
// pagination stays accurate.
async function deleteEntry(e) {
if (!e || !e.file) return
if (!confirm('确定删除这个作品?删除后不可恢复')) return
const r = await api('/my-files?file=' + encodeURIComponent(e.file), { method: 'DELETE' })
toast.value = r.ok ? '已删除' : (r.data?.detail || '删除失败')
setTimeout(() => (toast.value = ''), 1500)
if (r.ok) load()
}
// multi-select (keyed by file path) — bulk delete/download from the filter bar.
const picked = ref(new Set())
function togglePick(e) {
if (!e.file) return
const s = new Set(picked.value)
s.has(e.file) ? s.delete(e.file) : s.add(e.file)
picked.value = s
}
const pageAllPicked = computed(() => {
const files = filtered.value.filter((e) => e.status === 'success' && e.file)
return files.length > 0 && files.every((e) => picked.value.has(e.file))
})
function togglePickAll() {
const s = new Set(picked.value)
const files = filtered.value.filter((e) => e.status === 'success' && e.file)
if (pageAllPicked.value) files.forEach((e) => s.delete(e.file))
else files.forEach((e) => s.add(e.file))
picked.value = s
}
async function deletePicked() {
const files = [...picked.value]
if (!files.length) return
if (!confirm(`确定删除选中的 ${files.length} 个作品?删除后不可恢复`)) return
let ok = 0
for (const f of files) {
const r = await api('/my-files?file=' + encodeURIComponent(f), { method: 'DELETE' })
if (r.ok) ok++
}
picked.value = new Set()
toast.value = `已删除 ${ok}`
setTimeout(() => (toast.value = ''), 1500)
load()
}
// Single pick → direct file download; multiple → bundle into one zip.
const zipping = ref(false)
async function downloadPicked() {
const files = [...picked.value]
if (!files.length) return
if (files.length === 1) {
const a = document.createElement('a')
a.href = generatedUrl(files[0])
a.download = files[0].split('/').pop()
document.body.appendChild(a)
a.click()
a.remove()
return
}
zipping.value = true
toast.value = '打包中…'
try {
// Fetch concurrently (10 at a time) so large batches pack fast.
const bufs = []
let next = 0
await Promise.all(Array.from({ length: Math.min(10, files.length) }, async () => {
while (next < files.length) {
const i = next++
bufs[i] = await (await fetch(generatedUrl(files[i]))).arrayBuffer()
}
}))
const entries = {}
files.forEach((f, i) => {
let name = f.split('/').pop()
while (entries[name]) name = '_' + name
entries[name] = [new Uint8Array(bufs[i]), { level: 0 }]
})
const zipped = zipSync(entries)
const url = URL.createObjectURL(new Blob([zipped], { type: 'application/zip' }))
const a = document.createElement('a')
a.href = url
a.download = `作品-${files.length}个-${Date.now()}.zip`
document.body.appendChild(a)
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(url), 30000)
toast.value = '已打包下载'
} catch {
toast.value = '打包失败'
}
zipping.value = false
setTimeout(() => (toast.value = ''), 1500)
}
const lightbox = ref(null)
// Videos whose first-frame thumbnail is missing (old videos) — fall back to
// the muted <video> preview for those cards.
@@ -168,6 +262,18 @@ onUnmounted(() => {
<div class="flex-1 min-w-[180px]">
<input v-model="search" class="field !py-1.5 text-xs" placeholder="搜索提示词或模型…" />
</div>
<button @click="togglePickAll" class="text-xs rounded-lg px-2.5 py-1.5 transition-colors inline-flex items-center gap-1"
:class="pageAllPicked ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'">
<Icon name="check" class="w-3.5 h-3.5" /> 全选本页
</button>
<template v-if="picked.size">
<button @click="downloadPicked" :disabled="zipping" class="text-xs rounded-lg px-2.5 py-1.5 bg-slate-900 text-white hover:bg-slate-700 inline-flex items-center gap-1 disabled:opacity-50">
<Icon name="download" class="w-3.5 h-3.5" /> {{ zipping ? '打包中…' : `下载选中 (${picked.size})` }}
</button>
<button @click="deletePicked" class="text-xs rounded-lg px-2.5 py-1.5 bg-rose-600 text-white hover:bg-rose-500 inline-flex items-center gap-1">
<Icon name="trash" class="w-3.5 h-3.5" /> 删除选中 ({{ picked.size }})
</button>
</template>
</div>
<!-- Empty -->
@@ -217,11 +323,18 @@ onUnmounted(() => {
</div>
</div>
<!-- kind chip -->
<span class="absolute top-3 left-3 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ring-1"
:class="e.kind === 'video' ? 'bg-fuchsia-500/20 text-fuchsia-200 ring-fuchsia-400/30' : 'bg-indigo-500/20 text-indigo-200 ring-indigo-400/30'">
{{ e.kind === 'video' ? '视频' : '图像' }}
</span>
<!-- select + kind chip -->
<div class="absolute top-3 left-3 flex items-center gap-1.5">
<button v-if="e.status === 'success' && e.file" @click.stop.prevent="togglePick(e)"
:title="picked.has(e.file) ? '取消选择' : '选择'"
class="pick" :class="picked.has(e.file) && 'pick-on'">
<Icon name="check" class="w-3 h-3" />
</button>
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ring-1"
:class="e.kind === 'video' ? 'bg-fuchsia-500/20 text-fuchsia-200 ring-fuchsia-400/30' : 'bg-indigo-500/20 text-indigo-200 ring-indigo-400/30'">
{{ e.kind === 'video' ? '视频' : '图像' }}
</span>
</div>
<!-- hover actions (only when there's a file) -->
<div v-if="e.status === 'success' && e.file"
@@ -234,6 +347,10 @@ onUnmounted(() => {
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-black/70 text-white grid place-items-center">
<Icon name="download" class="w-3.5 h-3.5" />
</a>
<button @click.stop.prevent="deleteEntry(e)" title="删除"
class="w-7 h-7 rounded-lg bg-black/50 ring-1 ring-white/10 hover:bg-rose-600/80 text-white grid place-items-center">
<Icon name="trash" class="w-3.5 h-3.5" />
</button>
</div>
<!-- caption (over a real image) -->
@@ -309,4 +426,19 @@ onUnmounted(() => {
}
.pg:hover:not(.pg-on) { background: rgb(226 232 240); color: rgb(15 23 42); }
.pg-on { background: rgb(15 23 42); color: white; box-shadow: none; }
/* card select toggle — always visible rounded-square check button */
.pick {
width: 1.4rem; height: 1.4rem; border-radius: 0.375rem;
display: inline-flex; align-items: center; justify-content: center;
color: rgb(255 255 255 / 0.85);
background: rgb(0 0 0 / 0.45);
box-shadow: inset 0 0 0 1.5px rgb(255 255 255 / 0.75);
transition: background 0.15s, box-shadow 0.15s;
}
.pick svg { opacity: 0; transition: opacity 0.15s; }
.pick:hover { background: rgb(0 0 0 / 0.65); }
.pick:hover svg { opacity: 0.6; }
.pick-on { background: rgb(217 70 239); box-shadow: inset 0 0 0 1.5px rgb(255 255 255 / 0.9); }
.pick-on svg { opacity: 1; }
</style>
+10 -3
View File
@@ -169,12 +169,13 @@ function toggleSelect(id) {
s.has(id) ? s.delete(id) : s.add(id)
selected.value = s
}
// Header checkbox selects/deselects the CURRENT PAGE only.
const allSelected = computed(() =>
filtered.value.length > 0 && filtered.value.every((u) => selected.value.has(u.id)))
pagedItems.value.length > 0 && pagedItems.value.every((u) => selected.value.has(u.id)))
function toggleSelectAll() {
const s = new Set(selected.value)
if (allSelected.value) filtered.value.forEach((u) => s.delete(u.id))
else filtered.value.forEach((u) => s.add(u.id))
if (allSelected.value) pagedItems.value.forEach((u) => s.delete(u.id))
else pagedItems.value.forEach((u) => s.add(u.id))
selected.value = s
}
async function delSelected() {
@@ -274,6 +275,7 @@ async function quickCredits(u, delta) {
<col class="w-24" /> <!-- credits -->
<col class="w-24" /> <!-- recharge total -->
<col class="w-20" /> <!-- generation count -->
<col class="w-20" /> <!-- banned word hits -->
<col class="w-28" /> <!-- registered -->
<col class="w-28" /> <!-- last login -->
<col class="w-32" /> <!-- login IP -->
@@ -294,6 +296,7 @@ async function quickCredits(u, delta) {
<th class="text-right px-3 py-3 font-medium">积分</th>
<th class="text-right px-3 py-3 font-medium">累计充值</th>
<th class="text-right px-3 py-3 font-medium">生图次数</th>
<th class="text-right px-3 py-3 font-medium">违禁触发</th>
<th class="text-left px-3 py-3 font-medium">注册时间</th>
<th class="text-left px-3 py-3 font-medium">最近登录</th>
<th class="text-left px-3 py-3 font-medium">登录 IP</th>
@@ -350,6 +353,10 @@ async function quickCredits(u, delta) {
:class="u.generation_count > 0 ? 'text-white/85' : 'text-white/25'">
{{ (u.generation_count || 0).toLocaleString('en-US') }}
</td>
<td class="px-3 py-3.5 align-middle text-right tabular-nums whitespace-nowrap"
:class="u.banned_word_hits > 0 ? 'text-rose-300' : 'text-white/25'">
{{ (u.banned_word_hits || 0).toLocaleString('en-US') }}
</td>
<td class="px-3 py-3.5 align-middle text-xs whitespace-nowrap">
<div v-if="u.created_at" class="leading-tight" :title="fmtTs(u.created_at)">
<div class="text-white/65 tabular-nums">{{ fmtDate(u.created_at) }}</div>