220 lines
10 KiB
Vue
220 lines
10 KiB
Vue
<script setup>
|
||
import { ref, computed, onMounted, watch } 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 qs = new URLSearchParams({
|
||
limit: String(pageSize),
|
||
offset: String((page.value - 1) * pageSize),
|
||
})
|
||
const r = await api('/banned-words?' + qs.toString())
|
||
items.value = r.data?.data || []
|
||
total.value = Number(r.data?.total ?? items.value.length)
|
||
loading.value = false
|
||
// Deleting the last row of the last page can leave the cursor past the end.
|
||
if (page.value > totalPages.value) page.value = totalPages.value
|
||
}
|
||
|
||
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 || '添加失败')
|
||
}
|
||
|
||
// bulk import — paste words separated by newlines / commas / 、 / ;
|
||
const importOpen = ref(false)
|
||
const importText = ref('')
|
||
const importing = ref(false)
|
||
async function doImport() {
|
||
const text = importText.value.trim()
|
||
if (!text) { flash('请先粘贴要导入的违禁词'); return }
|
||
importing.value = true
|
||
const r = await api('/banned-words/import', jsonBody('POST', { text }))
|
||
importing.value = false
|
||
if (r.ok) {
|
||
importOpen.value = false
|
||
importText.value = ''
|
||
flash(`导入完成:新增 ${r.data?.added ?? 0} 个,跳过 ${r.data?.skipped ?? 0} 个`)
|
||
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()
|
||
}
|
||
|
||
// Server-side pagination: items IS the current page.
|
||
const page = ref(1)
|
||
const pageSize = 20
|
||
const total = ref(0)
|
||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||
const pagedItems = computed(() => items.value)
|
||
watch(page, () => { load() })
|
||
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>
|
||
<button @click="importOpen = true" class="btn-soft 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">{{ total ? (Math.min(page, totalPages) - 1) * pageSize + 1 : 0 }}–{{ Math.min(total, Math.min(page, totalPages) * pageSize) }}</span><span class="ml-1">/ {{ total }} 条</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>
|
||
|
||
<div v-if="importOpen" class="fixed inset-0 z-50 grid place-items-center bg-black/60 p-4" @click.self="importOpen = false">
|
||
<div class="card w-full max-w-lg p-5 space-y-3">
|
||
<h3 class="text-sm font-semibold">批量导入违禁词</h3>
|
||
<p class="text-xs text-white/45">每行一个,或用逗号、顿号、分号分隔;已存在的词会自动跳过。</p>
|
||
<textarea v-model="importText" rows="8" class="field w-full text-xs font-mono resize-y" placeholder="违禁词1 违禁词2 违禁词3"></textarea>
|
||
<div class="flex justify-end gap-2">
|
||
<button @click="importOpen = false" class="btn-soft">取消</button>
|
||
<button @click="doImport" :disabled="importing" class="btn-primary">{{ importing ? '导入中…' : '导入' }}</button>
|
||
</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>
|