增加违禁词管理 增加多选操作
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user