feat: 小程序移除管理后台入口,新增admin-web前端项目

将管理后台功能从微信小程序中剥离,独立为Vue.js前端项目admin-web

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
刘正航
2026-05-14 13:49:07 +08:00
parent f342fdc9b4
commit 49c946dd55
39 changed files with 10760 additions and 257 deletions

23
admin-web/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

24
admin-web/README.md Normal file
View File

@@ -0,0 +1,24 @@
# admin-web
## Project setup
```
yarn install
```
### Compiles and hot-reloads for development
```
yarn serve
```
### Compiles and minifies for production
```
yarn build
```
### Lints and fixes files
```
yarn lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

View File

@@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

19
admin-web/jsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "node",
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
}

48
admin-web/package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "admin-web",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"axios": "^1.6.0",
"core-js": "^3.8.3",
"vue": "^2.6.14",
"vue-router": "^3.6.5"
},
"devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"vue-template-compiler": "^2.6.14"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "@babel/eslint-parser"
},
"rules": {
"no-unused-vars": "warn",
"vue/multi-word-component-names": "off"
}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>内容风控管理后台</title>
</head>
<body>
<noscript>
<strong>需要启用 JavaScript 才能正常使用本系统。</strong>
</noscript>
<div id="app"></div>
</body>
</html>

9
admin-web/src/App.vue Normal file
View File

@@ -0,0 +1,9 @@
<template>
<router-view />
</template>
<script>
export default {
name: 'App'
}
</script>

View File

@@ -0,0 +1,58 @@
<template>
<transition name="fade">
<div v-if="visible" class="modal-mask" @click.self="handleCancel">
<div class="modal-card">
<div class="modal-title">{{ title }}</div>
<div class="modal-content">{{ content }}</div>
<div class="btn-row">
<button v-if="showCancel" class="btn btn-ghost" type="button" @click="handleCancel">{{ cancelText }}</button>
<button class="btn btn-primary" type="button" @click="handleConfirm">{{ confirmText }}</button>
</div>
</div>
</div>
</transition>
</template>
<script>
export default {
name: 'ConfirmHost',
data() {
return {
visible: false,
title: '提示',
content: '',
confirmText: '确定',
cancelText: '取消',
showCancel: true,
onConfirm: null,
onCancel: null
}
},
methods: {
open(opts) {
this.title = opts.title || '提示'
this.content = opts.content || ''
this.confirmText = opts.confirmText || '确定'
this.cancelText = opts.cancelText || '取消'
this.showCancel = opts.showCancel !== false
this.onConfirm = opts.onConfirm
this.onCancel = opts.onCancel
this.visible = true
},
handleConfirm() {
this.visible = false
const fn = this.onConfirm
this.onConfirm = null
this.onCancel = null
if (typeof fn === 'function') fn()
},
handleCancel() {
this.visible = false
const fn = this.onCancel
this.onConfirm = null
this.onCancel = null
if (typeof fn === 'function') fn()
}
}
}
</script>

View File

@@ -0,0 +1,57 @@
<template>
<transition name="fade">
<div v-if="visible" class="preview-mask" @click.self="close">
<div class="preview-stage">
<div class="preview-close" @click="close">×</div>
<div v-if="urls.length > 1" class="preview-nav preview-prev" @click="prev"></div>
<img class="preview-img" :src="currentUrl" alt="preview" />
<div v-if="urls.length > 1" class="preview-nav preview-next" @click="next"></div>
</div>
</div>
</transition>
</template>
<script>
export default {
name: 'ImagePreviewHost',
data() {
return {
visible: false,
urls: [],
index: 0
}
},
computed: {
currentUrl() {
return this.urls[this.index] || ''
}
},
methods: {
open({ urls, current }) {
this.urls = Array.isArray(urls) ? urls.slice() : [urls]
const idx = this.urls.indexOf(current)
this.index = idx >= 0 ? idx : 0
this.visible = true
document.addEventListener('keydown', this.handleKey)
},
close() {
this.visible = false
document.removeEventListener('keydown', this.handleKey)
},
prev() {
this.index = (this.index - 1 + this.urls.length) % this.urls.length
},
next() {
this.index = (this.index + 1) % this.urls.length
},
handleKey(e) {
if (e.key === 'Escape') this.close()
else if (e.key === 'ArrowLeft') this.prev()
else if (e.key === 'ArrowRight') this.next()
}
},
beforeDestroy() {
document.removeEventListener('keydown', this.handleKey)
}
}
</script>

View File

@@ -0,0 +1,34 @@
<template>
<div class="toast-host">
<div
v-for="item in list"
:key="item.id"
class="toast-item"
:class="{ 'is-error': item.type === 'error', 'is-success': item.type === 'success' }"
>
{{ item.message }}
</div>
</div>
</template>
<script>
let uid = 0
export default {
name: 'ToastHost',
data() {
return {
list: []
}
},
methods: {
push({ message, type = 'info', duration = 2000 }) {
const id = ++uid
this.list.push({ id, message, type })
setTimeout(() => {
this.list = this.list.filter((item) => item.id !== id)
}, duration)
}
}
}
</script>

View File

@@ -0,0 +1,148 @@
<template>
<div class="app-shell">
<header class="topbar">
<div class="topbar-inner">
<div class="topbar-brand">
<div class="topbar-brand-mark">VB</div>
<span>内容风控平台</span>
</div>
<nav class="topbar-nav">
<router-link
v-for="tab in userTabs"
:key="tab.path"
:to="tab.path"
class="topbar-tab"
active-class="is-active"
:exact="tab.exact"
>{{ tab.label }}</router-link>
</nav>
<div class="topbar-actions">
<div v-if="isAdminUser" class="topbar-admin" v-click-outside="closeAdminMenu">
<div
class="topbar-admin-trigger"
:class="{ 'is-active': adminMenuOpen || isAdminRoute }"
@click="toggleAdminMenu"
>管理后台 <span style="font-size: 10px;"></span></div>
<div v-if="adminMenuOpen" class="topbar-admin-menu">
<router-link
v-for="item in adminTabs"
:key="item.path"
:to="item.path"
class="topbar-admin-item"
@click.native="closeAdminMenu"
>{{ item.label }}</router-link>
</div>
</div>
<div class="topbar-user">
<span class="topbar-avatar">{{ avatarText }}</span>
<span>{{ userName }}</span>
</div>
<button class="btn btn-ghost btn-sm" type="button" @click="handleLogout">退出</button>
</div>
</div>
</header>
<main>
<router-view />
</main>
</div>
</template>
<script>
import { store, isAdmin, logout, refreshUser } from '@/store'
import { confirm, toast } from '@/utils/feedback'
const userTabs = [
{ path: '/', label: '首页', exact: true },
{ path: '/detect', label: '发布' },
{ path: '/history', label: '历史' },
{ path: '/inbox', label: '私信' },
{ path: '/batch', label: '批量' },
{ path: '/profile', label: '我的' }
]
const adminTabs = [
{ path: '/admin/dashboard', label: '运营看板' },
{ path: '/admin/review', label: '复核与申诉' },
{ path: '/admin/samples', label: '样本管理' },
{ path: '/admin/users', label: '用户管理' }
]
export default {
name: 'AppLayout',
directives: {
clickOutside: {
bind(el, binding) {
el.__clickOutsideHandler__ = (event) => {
if (!(el === event.target || el.contains(event.target))) {
binding.value(event)
}
}
document.addEventListener('click', el.__clickOutsideHandler__)
},
unbind(el) {
document.removeEventListener('click', el.__clickOutsideHandler__)
}
}
},
data() {
return {
adminMenuOpen: false,
userTabs,
adminTabs
}
},
computed: {
storeRef() {
return store
},
isAdminUser() {
return isAdmin()
},
userName() {
const u = store.user
return (u && (u.nickname || u.username)) || '未登录'
},
avatarText() {
const name = this.userName
if (!name) return 'U'
return name.slice(0, 1).toUpperCase()
},
isAdminRoute() {
return /^\/admin\//.test(this.$route.path)
}
},
watch: {
$route() {
this.adminMenuOpen = false
}
},
mounted() {
refreshUser()
},
methods: {
toggleAdminMenu() {
this.adminMenuOpen = !this.adminMenuOpen
},
closeAdminMenu() {
this.adminMenuOpen = false
},
async handleLogout() {
const { confirm: ok } = await confirm({
title: '退出登录',
content: '确定要退出当前账号吗?',
confirmText: '退出',
cancelText: '取消'
})
if (!ok) return
logout()
toast('已退出登录', 'success')
this.$router.replace('/login')
}
}
}
</script>

11
admin-web/src/main.js Normal file
View File

@@ -0,0 +1,11 @@
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import './styles/theme.css'
Vue.config.productionTip = false
new Vue({
router,
render: (h) => h(App)
}).$mount('#app')

View File

@@ -0,0 +1,102 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import { store, isLoggedIn, isAdmin } from '@/store'
Vue.use(VueRouter)
const Login = () => import('@/views/Login.vue')
const Register = () => import('@/views/Register.vue')
const AppLayout = () => import('@/layouts/AppLayout.vue')
const Home = () => import('@/views/Home.vue')
const Detect = () => import('@/views/Detect.vue')
const History = () => import('@/views/History.vue')
const Inbox = () => import('@/views/Inbox.vue')
const Batch = () => import('@/views/Batch.vue')
const Profile = () => import('@/views/Profile.vue')
const AdminDashboard = () => import('@/views/admin/Dashboard.vue')
const AdminReview = () => import('@/views/admin/Review.vue')
const AdminSamples = () => import('@/views/admin/Samples.vue')
const AdminUsers = () => import('@/views/admin/Users.vue')
const routes = [
{ path: '/login', name: 'login', component: Login, meta: { public: true } },
{ path: '/register', name: 'register', component: Register, meta: { public: true } },
{
path: '/',
component: AppLayout,
children: [
{ path: '', name: 'home', component: Home, meta: { title: '首页' } },
{ path: 'detect', name: 'detect', component: Detect, meta: { title: '信息发布' } },
{ path: 'history', name: 'history', component: History, meta: { title: '发布历史' } },
{ path: 'inbox', name: 'inbox', component: Inbox, meta: { title: '私信收件' } },
{ path: 'batch', name: 'batch', component: Batch, meta: { title: '批量识别' } },
{ path: 'profile', name: 'profile', component: Profile, meta: { title: '个人中心' } },
{
path: 'admin/dashboard',
name: 'admin-dashboard',
component: AdminDashboard,
meta: { title: '运营看板', requiresAdmin: true }
},
{
path: 'admin/review',
name: 'admin-review',
component: AdminReview,
meta: { title: '复核与申诉', requiresAdmin: true }
},
{
path: 'admin/samples',
name: 'admin-samples',
component: AdminSamples,
meta: { title: '样本管理', requiresAdmin: true }
},
{
path: 'admin/users',
name: 'admin-users',
component: AdminUsers,
meta: { title: '用户管理', requiresAdmin: true }
}
]
},
{ path: '*', redirect: '/' }
]
const router = new VueRouter({
mode: 'hash',
routes
})
router.beforeEach((to, from, next) => {
if (to.meta && to.meta.public) {
if (isLoggedIn()) {
next({ path: '/' })
return
}
next()
return
}
if (!isLoggedIn()) {
next({ path: '/login' })
return
}
if (to.matched.some((record) => record.meta && record.meta.requiresAdmin)) {
if (!isAdmin()) {
next({ path: '/' })
return
}
}
next()
})
router.afterEach((to) => {
const title = (to.meta && to.meta.title) || ''
document.title = title ? `${title} · 内容风控管理后台` : '内容风控管理后台'
// touch store so admin guard re-evaluates when user data changes
void store.user
})
export default router

View File

@@ -0,0 +1,40 @@
import Vue from 'vue'
import { getToken, setToken, getUser, setUser, clearAuth } from '@/utils/auth'
import { request } from '@/utils/request'
export const store = Vue.observable({
token: getToken(),
user: getUser()
})
export function isLoggedIn() {
return !!store.token
}
export function isAdmin() {
return !!(store.user && store.user.is_admin)
}
export function setAuth(token, user) {
store.token = token || ''
store.user = user || null
setToken(token)
setUser(user)
}
export function logout() {
store.token = ''
store.user = null
clearAuth()
}
export async function refreshUser() {
try {
const user = await request({ url: '/auth/me' })
store.user = user
setUser(user)
return user
} catch (err) {
return null
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
const TOKEN_KEY = 'admin_web_token'
const USER_KEY = 'admin_web_user'
export function getToken() {
return localStorage.getItem(TOKEN_KEY) || ''
}
export function setToken(token) {
if (token) {
localStorage.setItem(TOKEN_KEY, token)
} else {
localStorage.removeItem(TOKEN_KEY)
}
}
export function getUser() {
try {
const raw = localStorage.getItem(USER_KEY)
return raw ? JSON.parse(raw) : null
} catch (err) {
return null
}
}
export function setUser(user) {
if (user) {
localStorage.setItem(USER_KEY, JSON.stringify(user))
} else {
localStorage.removeItem(USER_KEY)
}
}
export function clearAuth() {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
}

View File

@@ -0,0 +1,59 @@
import Vue from 'vue'
import ToastHost from '@/components/ToastHost.vue'
import ConfirmHost from '@/components/ConfirmHost.vue'
import ImagePreviewHost from '@/components/ImagePreviewHost.vue'
let toastInstance = null
let confirmInstance = null
let previewInstance = null
function mountInstance(Component) {
const Ctor = Vue.extend(Component)
const instance = new Ctor()
instance.$mount()
document.body.appendChild(instance.$el)
return instance
}
function ensureToast() {
if (!toastInstance) toastInstance = mountInstance(ToastHost)
return toastInstance
}
function ensureConfirm() {
if (!confirmInstance) confirmInstance = mountInstance(ConfirmHost)
return confirmInstance
}
function ensurePreview() {
if (!previewInstance) previewInstance = mountInstance(ImagePreviewHost)
return previewInstance
}
export function toast(message, type = 'info', duration = 2000) {
if (!message) return
ensureToast().push({ message, type, duration })
}
toast.success = (message, duration) => toast(message, 'success', duration)
toast.error = (message, duration) => toast(message, 'error', duration)
toast.info = (message, duration) => toast(message, 'info', duration)
export function confirm({ title = '提示', content = '', confirmText = '确定', cancelText = '取消', showCancel = true } = {}) {
return new Promise((resolve) => {
ensureConfirm().open({
title,
content,
confirmText,
cancelText,
showCancel,
onConfirm: () => resolve({ confirm: true }),
onCancel: () => resolve({ confirm: false })
})
})
}
export function previewImage(urls, current) {
if (!urls || !urls.length) return
ensurePreview().open({ urls, current })
}

View File

@@ -0,0 +1,131 @@
import axios from 'axios'
import { getToken, clearAuth } from '@/utils/auth'
import { toast } from '@/utils/feedback'
const BASE_URL = '/api'
const instance = axios.create({
baseURL: BASE_URL,
timeout: 30000
})
instance.interceptors.request.use(
(config) => {
const token = getToken()
if (token) {
config.headers = config.headers || {}
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(err) => Promise.reject(err)
)
function handleUnauthorized() {
clearAuth()
toast('登录已过期,请重新登录', 'error')
setTimeout(() => {
if (location.hash !== '#/login') {
location.hash = '#/login'
}
}, 400)
}
instance.interceptors.response.use(
(response) => response,
(err) => {
if (err && err.response && err.response.status === 401) {
handleUnauthorized()
}
return Promise.reject(err)
}
)
export function request({ url, method = 'GET', data, params, headers, responseType } = {}) {
return new Promise((resolve, reject) => {
instance({
url,
method,
data,
params,
headers,
responseType
})
.then((res) => {
if (responseType === 'blob' || responseType === 'arraybuffer') {
resolve(res)
return
}
const body = res.data || {}
if (body.code === 0) {
resolve(body.data)
return
}
const message = body.message || '请求失败'
toast(message, 'error')
reject(new Error(message))
})
.catch((err) => {
if (err && err.response && err.response.status === 401) {
reject(err)
return
}
const msg = (err && err.message) || '网络异常'
if (!/Unauthorized/.test(msg)) {
toast(msg, 'error')
}
reject(err)
})
})
}
export function uploadFile(file) {
const formData = new FormData()
formData.append('file', file)
return new Promise((resolve, reject) => {
instance({
url: '/upload/image',
method: 'POST',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' }
})
.then((res) => {
const body = res.data || {}
if (body.code === 0) {
resolve(body.data)
return
}
const message = body.message || '上传失败'
toast(message, 'error')
reject(new Error(message))
})
.catch((err) => {
const msg = (err && err.message) || '上传失败'
toast(msg, 'error')
reject(err)
})
})
}
export function downloadBlob({ url, method = 'POST', data, filename }) {
return request({ url, method, data, responseType: 'blob' }).then((res) => {
const blob = new Blob([res.data])
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = filename || 'download'
document.body.appendChild(link)
link.click()
setTimeout(() => {
URL.revokeObjectURL(link.href)
document.body.removeChild(link)
}, 0)
})
}
export function getServerBase() {
return ''
}
export default instance

View File

@@ -0,0 +1,290 @@
<template>
<div class="container">
<section class="hero fade-up">
<div class="hero-badge">BATCH SCAN</div>
<h1 class="hero-title">批量文本筛查</h1>
<p class="hero-sub">上传 TXT 文件或手动输入每行一条文本系统自动逐行检测</p>
</section>
<section class="card fade-up fade-up-delay-1">
<div class="card-title">上传文件</div>
<div class="card-desc">支持 TXT 文本文件每行一条待检测内容</div>
<div class="btn-row">
<label class="btn btn-primary" style="position: relative;">
选择 TXT 文件
<input
type="file"
accept=".txt,text/plain"
style="position: absolute; inset: 0; opacity: 0; cursor: pointer;"
@change="onChooseFile"
/>
</label>
</div>
<div class="field" v-if="fileName">
<div class="row">
<span class="label">已选文件</span>
<span class="value">{{ fileName }}</span>
</div>
<div class="row">
<span class="label">文本条数</span>
<span class="value">{{ lineCount }} </span>
</div>
</div>
</section>
<section class="card fade-up fade-up-delay-1">
<div class="card-title">手动输入</div>
<div class="card-desc">或直接粘贴文本内容每行一条</div>
<textarea
class="textarea"
v-model="inputText"
rows="8"
placeholder="示例:&#10;点击链接领取红包&#10;今天下午三点开会"
></textarea>
<div class="btn-row">
<button class="btn btn-ghost" @click="fillDemo">填充示例</button>
<button class="btn btn-accent" :disabled="loading" @click="submit">
{{ loading ? '识别中...' : '开始识别' }}
</button>
</div>
</section>
<section class="card fade-up fade-up-delay-2" v-if="summary">
<div class="card-title">识别汇总</div>
<div class="grid-3">
<div class="kpi">
<div class="kpi-value">{{ summary.total }}</div>
<div class="kpi-label">总条数</div>
</div>
<div class="kpi">
<div class="kpi-value">{{ summary.spam_count }}</div>
<div class="kpi-label">垃圾信息</div>
</div>
<div class="kpi">
<div class="kpi-value">{{ summary.ham_count }}</div>
<div class="kpi-label">正常信息</div>
</div>
</div>
<div class="field">
<div class="row">
<span class="label">垃圾占比</span>
<span class="value">{{ summary.spam_ratio_text }}</span>
</div>
<div class="progress-track">
<div class="progress-fill" :style="{ width: summary.spam_ratio_text }"></div>
</div>
</div>
<div class="field">
<div class="row">
<span class="label">拦截占比</span>
<span class="value">{{ summary.blocked_ratio_text }}</span>
</div>
<div class="progress-track">
<div class="progress-fill-safe" :style="{ width: summary.blocked_ratio_text }"></div>
</div>
</div>
</section>
<section class="card fade-up fade-up-delay-3" v-if="items.length">
<div class="card-title">明细结果</div>
<div class="btn-row" style="margin-bottom: 12px;">
<button class="btn btn-ghost" :disabled="exporting" @click="exportXLSX">
{{ exporting ? '导出中...' : '导出 Excel 文件' }}
</button>
<button class="btn btn-ghost" @click="copyCSV">复制 CSV 内容</button>
</div>
<div class="list-item" v-for="(item, idx) in items" :key="idx">
<div class="item-title">{{ item.text }}</div>
<div class="row">
<span class="label">判定结果</span>
<span :class="item.prediction === 'spam' ? 'status-spam' : 'status-ham'">{{ item.prediction_text }}</span>
</div>
<div class="row" v-if="item.category_label">
<span class="label">分类标签</span>
<span class="status-spam">{{ item.category_label }}</span>
</div>
<div class="row">
<span class="label">置信度</span>
<span class="value">{{ item.confidence_text }}</span>
</div>
<div class="progress-track">
<div
:class="item.prediction === 'spam' ? 'progress-fill' : 'progress-fill-safe'"
:style="{ width: item.confidence_text }"
></div>
</div>
<div class="field" v-if="item.reason_tokens && item.reason_tokens.length">
<span class="field-label">风险关键词</span>
<div class="chip-group">
<span
v-for="(tk, i) in item.reason_tokens"
:key="i"
class="tag tag-danger"
@click="showTokenWeight(tk)"
>{{ tk.token }}</span>
</div>
</div>
</div>
</section>
</div>
</template>
<script>
import { request } from '@/utils/request'
import { toast, confirm } from '@/utils/feedback'
export default {
name: 'BatchView',
data() {
return {
inputText: '',
fileName: '',
lineCount: 0,
loading: false,
exporting: false,
summary: null,
items: []
}
},
methods: {
formatPercent(value, digits = 2) {
return `${(Number(value || 0) * 100).toFixed(digits)}%`
},
parseLines() {
return (this.inputText || '')
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length >= 2)
},
onChooseFile(e) {
const file = (e.target.files || [])[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
const content = String(reader.result || '')
const lines = content
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length >= 2)
this.inputText = lines.join('\n')
this.fileName = file.name
this.lineCount = lines.length
toast(`已读取 ${lines.length} 条文本`, 'success')
}
reader.onerror = () => toast('文件读取失败', 'error')
reader.readAsText(file, 'utf-8')
e.target.value = ''
},
async submit() {
if (this.loading) return
const items = this.parseLines()
if (!items.length) {
toast('请至少输入一条有效文本', 'error')
return
}
this.loading = true
try {
const data = await request({ url: '/spam/predict/batch', method: 'POST', data: { items } })
const sum = data.summary || {}
this.summary = {
...sum,
spam_ratio_text: this.formatPercent(sum.spam_ratio, 2),
blocked_ratio_text: this.formatPercent(sum.blocked_ratio, 2)
}
this.items = (data.items || []).map((item) => ({
...item,
confidence_text: this.formatPercent(item.confidence, 2)
}))
} finally {
this.loading = false
}
},
fillDemo() {
this.inputText = [
'点击链接领取购物补贴,名额有限。',
'明天下午三点上线前演练。',
'高薪兼职日结,扫码进群。',
'测试报告我已经同步到项目群。'
].join('\n')
},
showTokenWeight(tk) {
const w = Number(tk.weight || 0)
const direction = w >= 0 ? '倾向垃圾判定' : '倾向正常判定'
confirm({
title: '关键词权重',
content: `关键词「${tk.token}\n权重贡献${w >= 0 ? '+' : ''}${w.toFixed(4)}\n${direction}`,
showCancel: false,
confirmText: '关闭'
})
},
generateCSV() {
if (!this.items.length) return ''
const headers = ['文本', '判定结果', '分类标签', '置信度', '垃圾概率', '正常概率', '风险关键词']
const rows = this.items.map((item) => {
const prediction = item.prediction === 'spam' ? '垃圾信息' : '正常信息'
const categoryLabel = item.category_label || ''
const confidence = item.confidence_text || '0%'
const spamProb = this.formatPercent(item.spam_probability, 4)
const hamProb = this.formatPercent(item.ham_probability, 4)
const tokens = (item.reason_tokens || []).map((t) => t.token || t).join('; ')
const text = (item.text || '').replace(/"/g, '""')
const tokensEscaped = tokens.replace(/"/g, '""')
return `"${text}","${prediction}","${categoryLabel}","${confidence}","${spamProb}","${hamProb}","${tokensEscaped}"`
})
return [headers.join(','), ...rows].join('\n')
},
async exportXLSX() {
if (!this.items.length) {
toast('暂无识别结果可导出', 'error')
return
}
this.exporting = true
try {
const res = await request({
url: '/spam/export/xlsx',
method: 'POST',
data: { items: this.items },
responseType: 'blob'
})
const blob = new Blob([res.data], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')
const filename = `batch_detect_${ts}.xlsx`
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = filename
document.body.appendChild(link)
link.click()
setTimeout(() => {
URL.revokeObjectURL(link.href)
document.body.removeChild(link)
}, 0)
toast('导出成功', 'success')
} catch (err) {
toast('导出失败', 'error')
} finally {
this.exporting = false
}
},
async copyCSV() {
if (!this.items.length) {
toast('暂无识别结果可复制', 'error')
return
}
const csv = this.generateCSV()
try {
await navigator.clipboard.writeText(csv)
toast('CSV 内容已复制到剪贴板', 'success')
} catch (err) {
toast('复制失败,请手动选择文本', 'error')
}
}
}
}
</script>

View File

@@ -0,0 +1,187 @@
<template>
<div class="container">
<section class="hero fade-up">
<div class="hero-badge">REAL-TIME CHECK</div>
<h1 class="hero-title">文本信息发布</h1>
<p class="hero-sub">支持公开发布私有发布与用户私信提交时自动执行垃圾信息识别</p>
</section>
<section class="card fade-up fade-up-delay-1">
<div class="card-title">发布内容</div>
<div class="field">
<label class="field-label">内容文本</label>
<textarea class="textarea" v-model="text" placeholder="请输入要发布的文本信息" rows="4"></textarea>
<div class="field-help">当前字数{{ text.length }}建议不少于 2 个字符</div>
</div>
<div class="grid-2">
<div class="field">
<label class="field-label">发布类型</label>
<select class="select" v-model="visibility">
<option v-for="opt in visibilityOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<div class="field" v-if="visibility === 'direct'">
<label class="field-label">接收人用户名</label>
<input class="input" v-model.trim="recipientUsername" placeholder="私信发送时必填" />
</div>
</div>
<button class="btn btn-primary" :disabled="loading" @click="publish">
{{ loading ? '检测中...' : '提交发布' }}
</button>
</section>
<section class="card fade-up fade-up-delay-2">
<div class="card-title">快捷示例</div>
<div class="chip-group">
<span
v-for="(item, idx) in quickTexts"
:key="idx"
class="chip"
@click="text = item"
>{{ item }}</span>
</div>
</section>
<section class="card fade-up fade-up-delay-3" v-if="result">
<div class="card-title">识别反馈</div>
<div class="row">
<span class="label">发布结果</span>
<span :class="result.publish_allowed ? 'status-ham' : 'status-spam'">
{{ result.publish_allowed ? '发布成功' : '已拦截,需申诉' }}
</span>
</div>
<div class="row" v-if="result.detect && result.detect.category_label">
<span class="label">分类标签</span>
<span class="status-spam">{{ result.detect.category_label }}</span>
</div>
<div class="row">
<span class="label">模型判断</span>
<span class="value">{{ result.detect && result.detect.prediction_text }}</span>
</div>
<div class="row">
<span class="label">垃圾概率</span>
<span class="value">{{ result.detect_spam_probability_text }}</span>
</div>
<div class="progress-track">
<div class="progress-fill" :style="{ width: result.detect_spam_probability_text }"></div>
</div>
<div class="row">
<span class="label">检测置信度</span>
<span class="value">{{ result.detect.confidence_text }}</span>
</div>
<div class="progress-track">
<div class="progress-fill-safe" :style="{ width: result.detect.confidence_text }"></div>
</div>
<div class="row">
<span class="label">本次阈值</span>
<span class="value">{{ result.post_threshold_text }}</span>
</div>
<div class="field" v-if="result.detect.reason_tokens && result.detect.reason_tokens.length">
<span class="field-label">风险关键词</span>
<div class="chip-group">
<span
v-for="(tk, i) in result.detect.reason_tokens"
:key="i"
class="tag tag-danger"
@click="showTokenWeight(tk)"
>{{ tk.token }}</span>
</div>
</div>
<button class="btn btn-ghost" @click="$router.push('/history')">查看发布历史</button>
</section>
</div>
</template>
<script>
import { request } from '@/utils/request'
import { toast, confirm } from '@/utils/feedback'
const QUICK_TEXTS = [
'大家好,今晚 8 点社区线上读书会,欢迎参加。',
'恭喜中奖领取大额现金,点击链接立即到账。',
'本周活动报名已开放,请在群里接龙。',
'高薪兼职日结,扫码进群立刻赚钱。'
]
const VISIBILITY_OPTIONS = [
{ value: 'public', label: '公开信息发布' },
{ value: 'private', label: '私有信息发布' },
{ value: 'direct', label: '用户私信发布' }
]
export default {
name: 'DetectView',
data() {
return {
text: '',
loading: false,
result: null,
quickTexts: QUICK_TEXTS,
visibilityOptions: VISIBILITY_OPTIONS,
visibility: 'public',
recipientUsername: ''
}
},
methods: {
formatPercent(value, digits = 2) {
const num = Number(value || 0)
return `${(num * 100).toFixed(digits)}%`
},
async publish() {
if (this.loading) return
const text = (this.text || '').trim()
if (text.length < 2) {
toast('请输入至少 2 个字符', 'error')
return
}
const payload = { text, visibility: this.visibility }
if (this.visibility === 'direct') {
const receiver = (this.recipientUsername || '').trim()
if (!receiver) {
toast('私信请填写接收人用户名', 'error')
return
}
payload.recipient_username = receiver
}
this.loading = true
try {
const result = await request({ url: '/content/publish', method: 'POST', data: payload })
this.result = {
...result,
detect: {
...(result.detect || {}),
confidence_text: this.formatPercent((result.detect || {}).confidence, 2)
},
post_threshold_text: this.formatPercent((result.post || {}).threshold, 1),
detect_spam_probability_text: this.formatPercent((result.detect || {}).spam_probability, 2)
}
toast(result.publish_allowed ? '发布成功' : '已拦截,可申诉', result.publish_allowed ? 'success' : 'error')
} finally {
this.loading = false
}
},
showTokenWeight(tk) {
const weight = Number(tk.weight || 0)
const direction = weight >= 0 ? '倾向垃圾判定' : '倾向正常判定'
confirm({
title: '关键词权重',
content: `关键词「${tk.token}\n权重贡献${weight >= 0 ? '+' : ''}${weight.toFixed(4)}\n${direction}`,
showCancel: false,
confirmText: '关闭'
})
}
}
}
</script>

View File

@@ -0,0 +1,297 @@
<template>
<div class="container">
<section class="hero fade-up">
<div class="hero-badge">PUBLISH HISTORY</div>
<h1 class="hero-title">个人发布历史</h1>
<p class="hero-sub">查看公开/私有/私信发布结果被拦截内容可在线提交申诉</p>
</section>
<section class="card fade-up fade-up-delay-1">
<div class="card-title">筛选条件</div>
<div class="grid-2">
<div class="field">
<label class="field-label">发布状态</label>
<select class="select" v-model="statusFilter" @change="fetchList()">
<option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<div class="field">
<label class="field-label">发布类型</label>
<select class="select" v-model="visibilityFilter" @change="fetchList()">
<option v-for="opt in visibilityOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
</div>
</section>
<section class="card fade-up fade-up-delay-2" v-if="list.length">
<div class="card-title">历史记录</div>
<div class="list-item" v-for="item in list" :key="item.id">
<div class="item-title">{{ item.text }}</div>
<div class="item-sub">类型{{ item.visibility_text }} · 时间{{ item.created_text }}</div>
<div class="row">
<span class="label">发布状态</span>
<span :class="item.status === 'blocked' ? 'status-spam' : 'status-ham'">
{{ item.status === 'blocked' ? '已拦截' : '已发布' }}
</span>
</div>
<div class="row" v-if="item.category_label">
<span class="label">分类标签</span>
<span class="status-spam">{{ item.category_label }}</span>
</div>
<div class="row">
<span class="label">垃圾概率</span>
<span class="value">{{ item.spam_probability_text }}</span>
</div>
<div class="progress-track">
<div class="progress-fill" :style="{ width: item.spam_probability_text }"></div>
</div>
<div class="row">
<span class="label">申诉状态</span>
<span class="value">{{ item.appeal_status_text }}</span>
</div>
<div class="field" v-if="item.reason_tokens && item.reason_tokens.length">
<span class="field-label">风险关键词</span>
<div class="chip-group">
<span
v-for="(tk, i) in item.reason_tokens"
:key="i"
class="tag tag-danger"
@click="showTokenWeight(tk)"
>{{ tk.token }}</span>
</div>
</div>
<div
v-if="item.status === 'blocked' && item.appeal_status !== 'pending' && appealPostId !== item.id"
>
<button class="btn btn-accent" @click="startAppeal(item.id)">发起申诉</button>
</div>
<div v-if="appealPostId === item.id" style="margin-top: 12px;">
<div class="field">
<label class="field-label">申诉理由类型</label>
<select class="select" v-model="appealReasonTypeValue" @change="onReasonTypeChange">
<option v-for="opt in appealReasonTypeOptions" :key="opt.label" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<textarea
class="textarea"
v-model="appealReason"
placeholder="可补充申诉理由(选择快捷理由后可省略)"
></textarea>
<div class="field">
<label class="field-label">证据截图最多 3 </label>
<div class="evidence-grid">
<div class="evidence-item" v-for="(file, idx) in appealEvidenceFiles" :key="idx">
<img class="evidence-thumb" :src="file.preview" alt="evidence" />
<div class="evidence-remove" @click="removeEvidence(idx)">×</div>
</div>
<label class="evidence-add" v-if="appealEvidenceFiles.length < 3">
<span class="evidence-add-icon">+</span>
<input type="file" accept="image/*" @change="onPickEvidence" />
</label>
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" :disabled="appealSubmitting" @click="submitAppeal">
{{ appealSubmitting ? '提交中...' : '提交申诉' }}
</button>
<button class="btn btn-ghost" @click="cancelAppeal">取消</button>
</div>
</div>
<button class="btn btn-ghost btn-sm" style="margin-top: 12px;" @click="removeItem(item.id)">删除记录</button>
</div>
</section>
<section class="card fade-up fade-up-delay-2" v-else>
<div class="empty">{{ loading ? '加载中...' : '暂无发布记录,先去「发布」页面提交文本。' }}</div>
</section>
</div>
</template>
<script>
import { request, uploadFile } from '@/utils/request'
import { toast, confirm } from '@/utils/feedback'
const STATUS_OPTIONS = [
{ value: '', label: '全部状态' },
{ value: 'published', label: '发布成功' },
{ value: 'blocked', label: '已拦截' }
]
const VISIBILITY_OPTIONS = [
{ value: '', label: '全部类型' },
{ value: 'public', label: '公开' },
{ value: 'private', label: '私有' },
{ value: 'direct', label: '私信' }
]
const VIS_LABEL = { public: '公开信息', private: '私有信息', direct: '用户私信' }
const APPEAL_STATUS_TEXT = { none: '未发起', pending: '处理中', approved: '已通过', rejected: '已驳回' }
const CATEGORY_LABELS = { fraud: '疑似诈骗', harassment: '疑似骚扰', advertisement: '疑似广告', spam: '疑似垃圾' }
const REASON_TYPE_OPTIONS = [
{ value: '', label: '请选择申诉理由类型' },
{ value: '正常活动文案', label: '正常活动文案' },
{ value: '正常社区通知', label: '正常社区通知' },
{ value: '私信沟通内容', label: '私信沟通内容' },
{ value: '__other__', label: '其他(需手动填写)' }
]
export default {
name: 'HistoryView',
data() {
return {
loading: false,
list: [],
statusOptions: STATUS_OPTIONS,
visibilityOptions: VISIBILITY_OPTIONS,
appealReasonTypeOptions: REASON_TYPE_OPTIONS,
statusFilter: '',
visibilityFilter: '',
appealPostId: null,
appealReasonTypeValue: '',
appealReason: '',
appealEvidenceFiles: [],
appealSubmitting: false
}
},
mounted() {
this.fetchList()
},
beforeDestroy() {
this.appealEvidenceFiles.forEach((f) => f.preview && URL.revokeObjectURL(f.preview))
},
methods: {
formatPercent(value, digits = 2) {
return `${(Number(value || 0) * 100).toFixed(digits)}%`
},
async fetchList() {
this.loading = true
try {
const data = await request({
url: '/content/posts/history',
params: {
status: this.statusFilter,
visibility: this.visibilityFilter,
page: 1,
page_size: 80
}
})
this.list = (data.items || []).map((item) => ({
...item,
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19),
spam_probability_text: this.formatPercent(item.spam_probability, 2),
visibility_text: VIS_LABEL[item.visibility] || item.visibility,
appeal_status_text: APPEAL_STATUS_TEXT[item.appeal_status] || item.appeal_status,
category_label: CATEGORY_LABELS[item.category] || ''
}))
} finally {
this.loading = false
}
},
startAppeal(postId) {
this.cleanEvidencePreviews()
this.appealPostId = postId
this.appealReasonTypeValue = ''
this.appealReason = ''
this.appealEvidenceFiles = []
},
cancelAppeal() {
this.cleanEvidencePreviews()
this.appealPostId = null
this.appealReasonTypeValue = ''
this.appealReason = ''
this.appealEvidenceFiles = []
},
onReasonTypeChange() {
// "其他" 不写入 reason_type 字段,让用户手动填写理由
},
cleanEvidencePreviews() {
this.appealEvidenceFiles.forEach((f) => f.preview && URL.revokeObjectURL(f.preview))
},
onPickEvidence(e) {
const files = Array.from(e.target.files || [])
const remaining = 3 - this.appealEvidenceFiles.length
const toAdd = files.slice(0, remaining).map((file) => ({
file,
preview: URL.createObjectURL(file)
}))
this.appealEvidenceFiles = [...this.appealEvidenceFiles, ...toAdd]
e.target.value = ''
},
removeEvidence(idx) {
const item = this.appealEvidenceFiles[idx]
if (item && item.preview) URL.revokeObjectURL(item.preview)
this.appealEvidenceFiles.splice(idx, 1)
},
async uploadAllEvidence() {
const urls = []
for (const item of this.appealEvidenceFiles) {
try {
const res = await uploadFile(item.file)
if (res && res.url) urls.push(res.url)
} catch (err) {
// toast already shown by uploadFile
}
}
return urls
},
async submitAppeal() {
const postId = this.appealPostId
if (!postId) return
const rt = this.appealReasonTypeValue
const reasonType = rt && rt !== '__other__' ? rt : ''
const reason = (this.appealReason || '').trim()
if (!reasonType && reason.length < 2) {
toast('申诉理由至少 2 个字符', 'error')
return
}
this.appealSubmitting = true
try {
const evidenceUrls = await this.uploadAllEvidence()
await request({
url: `/content/posts/${postId}/appeal`,
method: 'POST',
data: { reason_type: reasonType, reason, evidence_urls: evidenceUrls }
})
toast('申诉提交成功', 'success')
this.cancelAppeal()
this.fetchList()
} finally {
this.appealSubmitting = false
}
},
showTokenWeight(tk) {
const w = Number(tk.weight || 0)
const direction = w >= 0 ? '倾向垃圾判定' : '倾向正常判定'
confirm({
title: '关键词权重',
content: `关键词「${tk.token}\n权重贡献${w >= 0 ? '+' : ''}${w.toFixed(4)}\n${direction}`,
showCancel: false,
confirmText: '关闭'
})
},
async removeItem(id) {
const { confirm: ok } = await confirm({
title: '删除确认',
content: '确定删除这条发布记录吗?',
confirmText: '删除',
cancelText: '取消'
})
if (!ok) return
await request({ url: `/content/posts/${id}`, method: 'DELETE' })
toast('删除成功', 'success')
this.fetchList()
}
}
}
</script>

View File

@@ -0,0 +1,135 @@
<template>
<div class="container">
<section class="hero fade-up">
<div class="hero-badge">CONTROL CENTER</div>
<h1 class="hero-title">{{ user ? '欢迎,' + (user.nickname || user.username) : '社区内容风控工作台' }}</h1>
<p class="hero-sub">发布内容将实时进入朴素贝叶斯识别流程疑似垃圾信息自动拦截并支持申诉</p>
<div class="hero-meta" v-if="modelInfo">
<span class="hero-metric">版本 {{ modelInfo.version || '未训练' }}</span>
<span class="hero-metric">阈值 {{ thresholdText }}</span>
<span class="hero-metric">样本 {{ modelInfo.sample_count || 0 }}</span>
</div>
</section>
<section class="card fade-up fade-up-delay-1" v-if="modelInfo">
<div class="card-title">检测引擎状态</div>
<div class="grid-4">
<div class="kpi">
<div class="kpi-label">模型版本</div>
<div class="kpi-value">{{ modelInfo.version || '未训练' }}</div>
</div>
<div class="kpi">
<div class="kpi-label">训练样本</div>
<div class="kpi-value">{{ modelInfo.sample_count || 0 }}</div>
</div>
<div class="kpi">
<div class="kpi-label">垃圾阈值</div>
<div class="kpi-value">{{ thresholdText }}</div>
</div>
<div class="kpi">
<div class="kpi-label">最近训练</div>
<div class="kpi-value small">{{ modelInfo.trained_at || '--' }}</div>
</div>
</div>
</section>
<section class="card fade-up fade-up-delay-2">
<div class="card-title">常用功能</div>
<div class="card-desc">日常使用的发布检测与管理入口</div>
<div class="grid-3">
<div
v-for="item in userModules"
:key="item.path"
class="module-card"
@click="goto(item.path)"
>
<div class="module-name">{{ item.name }}</div>
<div class="module-desc">{{ item.desc }}</div>
<div class="module-tag">{{ item.tag }}</div>
</div>
</div>
</section>
<section class="card fade-up fade-up-delay-3" v-if="isAdminUser">
<div class="card-title">管理员功能</div>
<div class="card-desc">支持阈值调节复核处理样本维护和用户管理</div>
<div class="grid-2">
<div
v-for="item in adminModules"
:key="item.path"
class="module-card"
@click="goto(item.path)"
>
<div class="module-name">{{ item.name }}</div>
<div class="module-desc">{{ item.desc }}</div>
<div class="module-tag">{{ item.tag }}</div>
</div>
</div>
</section>
</div>
</template>
<script>
import { request } from '@/utils/request'
import { store, isAdmin, refreshUser } from '@/store'
const USER_MODULES = [
{ name: '信息发布', desc: '公开/私有/私信内容发布与实时检测', tag: '内容发布', path: '/detect' },
{ name: '发布历史', desc: '查看历史发布并发起申诉', tag: '历史追溯', path: '/history' },
{ name: '私信收件', desc: '查看通过检测的私信内容', tag: '收件箱', path: '/inbox' },
{ name: '批量识别', desc: '多条文本批量检测并给出风险汇总', tag: '批量筛查', path: '/batch' },
{ name: '个人中心', desc: '维护身份资料,修改密码', tag: '账号设置', path: '/profile' }
]
const ADMIN_MODULES = [
{ name: '运营看板', desc: '监控发布、拦截、样本和模型状态', tag: '数据概览', path: '/admin/dashboard' },
{ name: '复核与申诉', desc: '处理拦截复核和用户申诉', tag: '审核处理', path: '/admin/review' },
{ name: '样本管理', desc: '维护训练样本并触发模型重训', tag: '模型迭代', path: '/admin/samples' },
{ name: '用户管理', desc: '编辑用户信息和权限', tag: '权限管理', path: '/admin/users' }
]
export default {
name: 'HomeView',
data() {
return {
loading: true,
modelInfo: null,
userModules: USER_MODULES,
adminModules: ADMIN_MODULES
}
},
computed: {
user() {
return store.user
},
isAdminUser() {
return isAdmin()
},
thresholdText() {
const t = this.modelInfo && this.modelInfo.threshold
if (t === null || t === undefined) return '--'
return `${(Number(t) * 100).toFixed(1)}%`
}
},
mounted() {
this.bootstrap()
},
methods: {
async bootstrap() {
this.loading = true
try {
const [, modelInfo] = await Promise.all([
refreshUser(),
request({ url: '/spam/model/info' })
])
this.modelInfo = modelInfo
} finally {
this.loading = false
}
},
goto(path) {
this.$router.push(path)
}
}
}
</script>

View File

@@ -0,0 +1,56 @@
<template>
<div class="container">
<section class="hero fade-up">
<div class="hero-badge">INBOX</div>
<h1 class="hero-title">用户私信收件箱</h1>
<p class="hero-sub">仅展示通过检测后成功送达的私信内容</p>
</section>
<section class="card fade-up fade-up-delay-1" v-if="list.length">
<div class="card-title">私信列表</div>
<div class="list-item" v-for="item in list" :key="item.id">
<div class="item-title">{{ item.text }}</div>
<div class="item-sub">发送人{{ item.nickname || item.username }}{{ item.username }}</div>
<div class="row">
<span class="label">发送时间</span>
<span class="value">{{ item.created_text }}</span>
</div>
</div>
</section>
<section class="card fade-up fade-up-delay-1" v-else>
<div class="empty">{{ loading ? '加载中...' : '暂无私信内容。' }}</div>
</section>
</div>
</template>
<script>
import { request } from '@/utils/request'
export default {
name: 'InboxView',
data() {
return {
loading: false,
list: []
}
},
mounted() {
this.fetchList()
},
methods: {
async fetchList() {
this.loading = true
try {
const data = await request({ url: '/content/posts/inbox', params: { page: 1, page_size: 80 } })
this.list = (data.items || []).map((item) => ({
...item,
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19)
}))
} finally {
this.loading = false
}
}
}
}
</script>

View File

@@ -0,0 +1,86 @@
<template>
<div class="container">
<section class="hero fade-up">
<div class="hero-badge">SECURE ACCESS</div>
<h1 class="hero-title">垃圾信息识别平台</h1>
<p class="hero-sub">登录后即可使用文本检测发布拦截申诉处理和管理看板能力</p>
<div class="hero-meta">
<span class="hero-metric">实时风控</span>
<span class="hero-metric">闭环审核</span>
<span class="hero-metric">模型迭代</span>
</div>
</section>
<section class="card fade-up fade-up-delay-1" style="max-width: 460px; margin: 24px auto;">
<div class="card-title">账号登录</div>
<div class="card-desc">请输入用户名和密码进入你的工作台</div>
<div class="field">
<label class="field-label">用户名</label>
<input class="input" v-model.trim="username" placeholder="请输入用户名" @keyup.enter="submit" />
</div>
<div class="field">
<label class="field-label">密码</label>
<input class="input" type="password" v-model.trim="password" placeholder="请输入密码" @keyup.enter="submit" />
</div>
<button class="btn btn-primary" :disabled="loading" @click="submit" style="width: 100%;">
{{ loading ? '登录中...' : '立即登录' }}
</button>
<div class="btn-row">
<button class="btn btn-ghost" type="button" @click="fillDemo">填充演示账号</button>
<button class="btn btn-accent" type="button" @click="goRegister">注册新账号</button>
</div>
</section>
</div>
</template>
<script>
import { request } from '@/utils/request'
import { setAuth } from '@/store'
import { toast } from '@/utils/feedback'
export default {
name: 'LoginView',
data() {
return {
username: '',
password: '',
loading: false
}
},
methods: {
fillDemo() {
this.username = 'admin'
this.password = 'Admin@123456'
},
goRegister() {
this.$router.push('/register')
},
async submit() {
if (this.loading) return
if (!this.username || !this.password) {
toast('请输入用户名和密码', 'error')
return
}
this.loading = true
try {
const data = await request({
url: '/auth/login',
method: 'POST',
data: { username: this.username, password: this.password }
})
setAuth(data.token, data.user)
toast('登录成功', 'success')
setTimeout(() => this.$router.replace('/'), 200)
} catch (err) {
// toast already triggered
} finally {
this.loading = false
}
}
}
}
</script>

View File

@@ -0,0 +1,145 @@
<template>
<div class="container">
<section class="hero fade-up">
<div class="hero-badge">PROFILE</div>
<h1 class="hero-title">个人中心</h1>
<p class="hero-sub">完善你的身份信息便于审计追踪和团队协同</p>
</section>
<section class="card fade-up fade-up-delay-1">
<div class="card-title">账号资料</div>
<div class="grid-2">
<div class="field">
<label class="field-label">昵称</label>
<input class="input" v-model.trim="form.nickname" placeholder="请输入昵称" />
</div>
<div class="field">
<label class="field-label">手机号</label>
<input class="input" v-model.trim="form.phone" placeholder="请输入手机号" />
</div>
<div class="field">
<label class="field-label">公司</label>
<input class="input" v-model.trim="form.company" placeholder="请输入公司名称" />
</div>
<div class="field">
<label class="field-label">岗位</label>
<input class="input" v-model.trim="form.title" placeholder="请输入岗位" />
</div>
</div>
<div class="field">
<label class="field-label">新密码</label>
<input class="input" type="password" v-model.trim="form.password" placeholder="留空表示不修改" />
<div class="field-help">密码长度需不少于 6 </div>
</div>
<button class="btn btn-primary" :disabled="loading" @click="save">
{{ loading ? '保存中...' : '保存资料' }}
</button>
</section>
<section class="card fade-up fade-up-delay-2" v-if="isAdminUser">
<div class="card-title">管理员入口</div>
<div class="card-desc">管理后台功能快速访问</div>
<div class="grid-2">
<div class="module-card" @click="$router.push('/admin/dashboard')">
<div class="module-name">运营看板</div>
<div class="module-desc">监控发布拦截样本和模型状态</div>
<div class="module-tag">数据概览</div>
</div>
<div class="module-card" @click="$router.push('/admin/review')">
<div class="module-name">复核与申诉</div>
<div class="module-desc">处理拦截复核和用户申诉</div>
<div class="module-tag">审核处理</div>
</div>
<div class="module-card" @click="$router.push('/admin/samples')">
<div class="module-name">样本管理</div>
<div class="module-desc">维护训练样本并触发模型重训</div>
<div class="module-tag">模型迭代</div>
</div>
<div class="module-card" @click="$router.push('/admin/users')">
<div class="module-name">用户管理</div>
<div class="module-desc">编辑用户信息和权限</div>
<div class="module-tag">权限管理</div>
</div>
</div>
</section>
<section class="card fade-up fade-up-delay-3">
<button class="btn btn-ghost" style="width: 100%;" @click="handleLogout">退出登录</button>
</section>
</div>
</template>
<script>
import { request } from '@/utils/request'
import { store, isAdmin, logout, refreshUser, setAuth } from '@/store'
import { toast, confirm } from '@/utils/feedback'
export default {
name: 'ProfileView',
data() {
return {
loading: false,
form: {
nickname: '',
company: '',
title: '',
phone: '',
password: ''
}
}
},
computed: {
user() {
return store.user
},
isAdminUser() {
return isAdmin()
}
},
mounted() {
this.loadProfile()
},
methods: {
async loadProfile() {
await refreshUser()
const profile = await request({ url: '/user/profile' })
this.form = {
nickname: profile.nickname || '',
company: profile.company || '',
title: profile.title || '',
phone: profile.phone || '',
password: ''
}
},
async save() {
if (this.loading) return
const payload = { ...this.form }
if (!payload.password) delete payload.password
this.loading = true
try {
const user = await request({ url: '/user/profile', method: 'PUT', data: payload })
setAuth(store.token, user)
toast('保存成功', 'success')
this.form.password = ''
} finally {
this.loading = false
}
},
async handleLogout() {
const { confirm: ok } = await confirm({
title: '退出登录',
content: '确定要退出当前账号吗?',
confirmText: '退出',
cancelText: '取消'
})
if (!ok) return
logout()
toast('已退出登录', 'success')
this.$router.replace('/login')
}
}
}
</script>

View File

@@ -0,0 +1,94 @@
<template>
<div class="container">
<section class="hero fade-up">
<div class="hero-badge">CREATE ACCOUNT</div>
<h1 class="hero-title">注册新账号</h1>
<p class="hero-sub">填写基础信息后即可进入内容风控平台</p>
</section>
<section class="card fade-up fade-up-delay-1" style="max-width: 520px; margin: 24px auto;">
<div class="card-title">账号信息</div>
<div class="field">
<label class="field-label">用户名 *</label>
<input class="input" v-model.trim="form.username" placeholder="登录用户名" />
</div>
<div class="field">
<label class="field-label">密码 *</label>
<input class="input" type="password" v-model.trim="form.password" placeholder="不少于 6 位" />
</div>
<div class="field">
<label class="field-label">昵称</label>
<input class="input" v-model.trim="form.nickname" placeholder="展示昵称" />
</div>
<div class="grid-2">
<div class="field">
<label class="field-label">公司</label>
<input class="input" v-model.trim="form.company" placeholder="所属公司" />
</div>
<div class="field">
<label class="field-label">岗位</label>
<input class="input" v-model.trim="form.title" placeholder="岗位名称" />
</div>
</div>
<div class="field">
<label class="field-label">手机号</label>
<input class="input" v-model.trim="form.phone" placeholder="联系方式" />
</div>
<button class="btn btn-primary" :disabled="loading" style="width: 100%;" @click="submit">
{{ loading ? '提交中...' : '提交注册' }}
</button>
<button class="btn btn-ghost" type="button" style="width: 100%;" @click="goBack">返回登录</button>
</section>
</div>
</template>
<script>
import { request } from '@/utils/request'
import { toast } from '@/utils/feedback'
export default {
name: 'RegisterView',
data() {
return {
loading: false,
form: {
username: '',
password: '',
nickname: '',
company: '',
title: '',
phone: ''
}
}
},
methods: {
goBack() {
this.$router.push('/login')
},
async submit() {
if (this.loading) return
if (!this.form.username || !this.form.password) {
toast('用户名和密码必填', 'error')
return
}
this.loading = true
try {
await request({ url: '/auth/register', method: 'POST', data: this.form })
toast('注册成功,请登录', 'success')
setTimeout(() => this.$router.push('/login'), 300)
} catch (err) {
// toast already shown
} finally {
this.loading = false
}
}
}
}
</script>

View File

@@ -0,0 +1,268 @@
<template>
<div class="container">
<section class="hero fade-up">
<div class="hero-badge">OPS DASHBOARD</div>
<h1 class="hero-title">垃圾信息运营看板</h1>
<p class="hero-sub">覆盖发布拦截申诉样本与模型状态支持日常运营与风险监控</p>
</section>
<section class="card fade-up fade-up-delay-1" v-if="kpis.length">
<div class="card-title">核心指标</div>
<div class="grid-3">
<div class="kpi" v-for="item in kpis" :key="item.label">
<div class="kpi-value">{{ item.value }}</div>
<div class="kpi-label">{{ item.label }}</div>
</div>
</div>
</section>
<div class="grid-2">
<section class="card fade-up fade-up-delay-2" v-if="stats && stats.threshold">
<div class="card-title">检测阈值配置</div>
<div class="row"><span class="label">当前阈值</span><span class="value">{{ stats.threshold_text }}</span></div>
<div class="row"><span class="label">更新时间</span><span class="value">{{ stats.threshold.updated_at || '--' }}</span></div>
</section>
<section class="card fade-up fade-up-delay-2" v-if="stats && stats.model_info">
<div class="card-title">模型信息</div>
<div class="row"><span class="label">模型版本</span><span class="value">{{ stats.model_info.version || '未训练' }}</span></div>
<div class="row"><span class="label">训练时间</span><span class="value">{{ stats.model_info.trained_at || '--' }}</span></div>
<div class="row"><span class="label">样本数量</span><span class="value">{{ stats.model_info.sample_count || 0 }}</span></div>
</section>
</div>
<section class="card fade-up fade-up-delay-3" v-if="bars.length">
<div class="card-title"> 7 天发布趋势</div>
<div class="list-item" v-for="item in bars" :key="item.date">
<div class="row">
<span class="label">{{ item.label }}</span>
<span class="value">{{ item.value }} </span>
</div>
<div class="progress-track">
<div class="progress-fill-safe" :style="{ width: item.percent_text }"></div>
</div>
</div>
</section>
<div class="grid-2">
<section class="card fade-up fade-up-delay-3" v-if="sourceDist.length">
<div class="card-title">训练样本来源</div>
<div class="list-item" v-for="item in sourceDist" :key="item.name">
<div class="row"><span class="item-title">{{ item.name }}</span><span class="value">{{ item.value }}</span></div>
</div>
</section>
<section class="card fade-up fade-up-delay-3" v-if="topKeywords.length">
<div class="card-title">高频风险词</div>
<div class="chip-group">
<span class="tag" v-for="kw in topKeywords" :key="kw.token">{{ kw.token }} × {{ kw.count }}</span>
</div>
</section>
</div>
<section class="card fade-up fade-up-delay-3">
<button class="btn btn-accent" :disabled="reportLoading" @click="generateReport">
{{ reportLoading ? '生成中...' : '生成运营报告' }}
</button>
</section>
<transition name="fade">
<div v-if="report" class="modal-mask" @click.self="closeReport">
<div class="modal-card" style="width: min(720px, 96vw); max-height: 88vh; overflow-y: auto;">
<div class="modal-title" style="text-align: center;">垃圾信息运营报告</div>
<div class="muted" style="text-align: center;">{{ report.period }}</div>
<div class="report-section">
<div class="report-section-title">汇总统计</div>
<div class="grid-3">
<div class="report-kpi">
<div class="report-kpi-value">{{ report.summary.total_posts }}</div>
<div class="report-kpi-label">总发布量</div>
</div>
<div class="report-kpi">
<div class="report-kpi-value">{{ report.summary.total_blocked }}</div>
<div class="report-kpi-label">拦截量</div>
</div>
<div class="report-kpi">
<div class="report-kpi-value">{{ report.summary.total_published }}</div>
<div class="report-kpi-label">正常发布</div>
</div>
</div>
<div class="row" style="margin-top: 10px;">
<span class="label">拦截率</span>
<span class="value">{{ formatPercent(report.summary.blocked_ratio, 2) }}</span>
</div>
<div class="row">
<span class="label">平均误判率</span>
<span class="value">{{ report.summary.avg_misjudge_rate_text || '0%' }}</span>
</div>
</div>
<div class="report-section">
<div class="report-section-title">垃圾信息数量变化 14 </div>
<div v-for="item in report.spam_trend" :key="'s' + item.date" style="margin-top: 6px;">
<div class="row">
<span class="label">{{ item.label }}</span>
<span class="value">拦截 {{ item.blocked }} / 发布 {{ item.published }}</span>
</div>
<div class="progress-track">
<div class="progress-fill" :style="{ width: item.blocked_percent }"></div>
</div>
</div>
</div>
<div class="report-section">
<div class="report-section-title">高频风险词 Top10</div>
<div class="chip-group">
<span
class="tag tag-danger"
v-for="(kw, i) in (report.top_keywords || []).slice(0, 10)"
:key="kw.token"
>{{ kw.token }} × {{ kw.count }}</span>
</div>
</div>
<div class="report-section">
<div class="report-section-title">误判率趋势 14 </div>
<div v-for="item in report.misjudge_trend" :key="'m' + item.date" style="margin-top: 6px;">
<div class="row">
<span class="label">{{ item.label }}</span>
<span class="value">{{ item.misjudge_rate_text }}</span>
</div>
<div class="progress-track">
<div class="progress-fill-safe" :style="{ width: item.rate_percent }"></div>
</div>
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" @click="copyReportText">复制报告文本</button>
<button class="btn btn-ghost" @click="closeReport">关闭</button>
</div>
</div>
</div>
</transition>
</div>
</template>
<script>
import { request } from '@/utils/request'
import { toast } from '@/utils/feedback'
export default {
name: 'AdminDashboard',
data() {
return {
loading: false,
stats: null,
kpis: [],
bars: [],
sourceDist: [],
topKeywords: [],
report: null,
reportLoading: false
}
},
mounted() {
this.fetchStats()
},
methods: {
formatPercent(value, digits = 2) {
return `${(Number(value || 0) * 100).toFixed(digits)}%`
},
normalizeKpis(stats) {
return [
{ label: '系统用户', value: stats.user_count || 0 },
{ label: '发布总量', value: stats.post_count || 0 },
{ label: '拦截总量', value: stats.blocked_count || 0 },
{ label: '待处理申诉', value: stats.pending_appeal_count || 0 },
{ label: '训练样本', value: stats.sample_count || 0 },
{ label: '近 7 天拦截率', value: this.formatPercent(stats.blocked_ratio_7d, 2) }
]
},
normalizeBars(trend) {
const rows = Array.isArray(trend) ? trend : []
const maxVal = Math.max(1, ...rows.map((r) => Number(r.post_count || 0)))
return rows.map((row) => {
const value = Number(row.post_count || 0)
const ratio = value / maxVal
return {
...row,
value,
percent_text: `${Math.max(6, Math.round(ratio * 100))}%`
}
})
},
async fetchStats() {
this.loading = true
try {
const stats = await request({ url: '/admin/stats' })
const normalized = {
...stats,
threshold_text: stats && stats.threshold ? this.formatPercent(stats.threshold.spam_threshold, 1) : '--'
}
this.stats = normalized
this.kpis = this.normalizeKpis(normalized)
this.bars = this.normalizeBars(normalized.trend_7d || [])
this.sourceDist = normalized.source_distribution || []
this.topKeywords = normalized.top_keywords || []
} finally {
this.loading = false
}
},
async generateReport() {
this.reportLoading = true
try {
const report = await request({ url: '/admin/stats/report' })
const maxBlocked = Math.max(...(report.spam_trend || []).map((r) => r.blocked || 0), 1)
const spamTrend = (report.spam_trend || []).map((item) => ({
...item,
blocked_percent: `${Math.max(4, Math.round(((item.blocked || 0) / maxBlocked) * 100))}%`
}))
const misjudgeTrend = (report.misjudge_trend || []).map((item) => ({
...item,
rate_percent: `${Math.round((item.misjudge_rate || 0) * 100)}%`
}))
this.report = { ...report, spam_trend: spamTrend, misjudge_trend: misjudgeTrend }
toast('报告已生成', 'success')
} finally {
this.reportLoading = false
}
},
closeReport() {
this.report = null
},
async copyReportText() {
const report = this.report
if (!report) return
const summary = report.summary || {}
const lines = [
`【垃圾信息运营报告】`,
`报告周期:${report.period}`,
`生成时间:${(report.report_date || '').replace('T', ' ').slice(0, 19)}`,
'',
`【汇总统计】`,
`总发布量:${summary.total_posts || 0}`,
`拦截量:${summary.total_blocked || 0}`,
`正常发布:${summary.total_published || 0}`,
`拦截率:${this.formatPercent(summary.blocked_ratio, 2)}`,
`复核总数:${summary.total_reviews || 0}`,
`误判放行:${summary.total_approved || 0}`,
`平均误判率:${summary.avg_misjudge_rate_text || '0%'}`,
'',
`【高频风险词 Top10】`,
(report.top_keywords || []).slice(0, 10).map((k) => `${k.token}(${k.count}次)`).join('、'),
'',
`【近 7 日趋势】`,
(report.spam_trend || []).slice(-7).map((t) => `${t.label}: 拦截${t.blocked}条,发布${t.published}`).join('\n')
]
try {
await navigator.clipboard.writeText(lines.join('\n'))
toast('报告已复制到剪贴板', 'success')
} catch (err) {
toast('复制失败,请手动选择', 'error')
}
}
}
}
</script>

View File

@@ -0,0 +1,353 @@
<template>
<div class="container">
<section class="hero fade-up">
<div class="hero-badge">REVIEW CENTER</div>
<h1 class="hero-title">复核与申诉后台</h1>
<p class="hero-sub">支持阈值调节拦截复核申诉处理与分页筛选满足商用审核流程</p>
</section>
<section class="card fade-up fade-up-delay-1">
<div class="card-title">检测阈值调节</div>
<div class="card-desc">阈值越低拦截越严格推荐范围0.65 - 0.85</div>
<div class="row">
<span class="label">垃圾阈值0-1</span>
<input class="input threshold-input" type="number" step="0.01" v-model="thresholdInput" />
</div>
<button class="btn btn-primary" @click="saveThreshold">更新阈值</button>
</section>
<section class="card fade-up fade-up-delay-1">
<div class="card-title">拦截记录筛选</div>
<div class="field">
<label class="field-label">关键词</label>
<input class="input" v-model="interceptKeyword" placeholder="搜索拦截文本关键词" />
</div>
<div class="grid-2">
<div class="field">
<label class="field-label">发布状态</label>
<select class="select" v-model="interceptStatus">
<option v-for="opt in interceptStatusOptions" :key="opt.label" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<div class="field">
<label class="field-label">复核状态</label>
<select class="select" v-model="interceptReviewStatus">
<option v-for="opt in interceptReviewStatusOptions" :key="opt.label" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" @click="applyInterceptFilters">查询</button>
<button class="btn btn-ghost" @click="clearInterceptFilters">重置</button>
</div>
</section>
<section class="card fade-up fade-up-delay-2">
<div class="card-title">信息拦截人工复核</div>
<div class="muted"> {{ interceptPager.total }} {{ interceptPager.page }} / {{ interceptPager.totalPages }} </div>
<div v-if="intercepts.length">
<div class="list-item" v-for="item in intercepts" :key="item.id">
<div class="item-title">{{ item.text }}</div>
<div class="item-sub">用户{{ item.nickname || item.username }} · 垃圾概率{{ item.spam_probability_text }}</div>
<div class="item-sub" v-if="item.category_label">分类标签:<span class="status-spam">{{ item.category_label }}</span></div>
<div class="item-sub">复核状态{{ item.review_status_text }} · 申诉状态{{ item.appeal_status_text }}</div>
<div class="item-sub">发布时间{{ item.created_text }}</div>
<textarea
class="textarea note-textarea"
v-model="reviewNoteMap[item.id]"
placeholder="可填写复核备注(将写入处理记录)"
></textarea>
<div class="btn-row">
<button class="btn btn-accent" @click="reviewIntercept(item.id, 'spam')">确认垃圾</button>
<button class="btn btn-ghost" @click="reviewIntercept(item.id, 'ham')">误判放行</button>
</div>
</div>
<div class="pager-row">
<button class="btn btn-ghost pager-btn" :disabled="!interceptPager.hasPrev" @click="changeInterceptPage(-1)">上一页</button>
<button class="btn btn-ghost pager-btn" :disabled="!interceptPager.hasNext" @click="changeInterceptPage(1)">下一页</button>
</div>
</div>
<div v-else class="empty">没有匹配的拦截记录</div>
</section>
<section class="card fade-up fade-up-delay-2">
<div class="card-title">申诉记录筛选</div>
<div class="field">
<label class="field-label">关键词</label>
<input class="input" v-model="appealKeyword" placeholder="搜索申诉文本或申诉理由" />
</div>
<div class="field">
<label class="field-label">申诉状态</label>
<select class="select" v-model="appealStatus">
<option v-for="opt in appealStatusOptions" :key="opt.label" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<div class="btn-row">
<button class="btn btn-primary" @click="applyAppealFilters">查询</button>
<button class="btn btn-ghost" @click="clearAppealFilters">重置</button>
</div>
</section>
<section class="card fade-up fade-up-delay-3">
<div class="card-title">拦截信息申诉处理</div>
<div class="muted"> {{ appealPager.total }} {{ appealPager.page }} / {{ appealPager.totalPages }} </div>
<div v-if="appeals.length">
<div class="list-item" v-for="item in appeals" :key="item.id">
<div class="item-title">{{ item.text }}</div>
<div class="item-sub">申诉人{{ item.nickname || item.username }} · 当前状态{{ item.appeal_status_text }}</div>
<div class="item-sub" v-if="item.category_label">分类标签:<span class="status-spam">{{ item.category_label }}</span></div>
<div class="item-sub">申诉理由类型{{ item.appeal_reason_type || '未选择' }}</div>
<div class="item-sub">申诉理由{{ item.appeal_reason || '未填写' }}</div>
<div class="item-sub">时间{{ item.created_text }}</div>
<div class="field" v-if="item.appeal_evidence_urls && item.appeal_evidence_urls.length">
<span class="field-label">证据截图</span>
<div class="evidence-grid">
<div
class="evidence-item"
v-for="url in item.appeal_evidence_urls"
:key="url"
>
<img
class="evidence-thumb evidence-clickable"
:src="url"
@click="previewEvidence(item.appeal_evidence_urls, url)"
alt="evidence"
/>
</div>
</div>
</div>
<textarea
class="textarea note-textarea"
v-model="appealNoteMap[item.id]"
placeholder="可填写申诉处理备注"
></textarea>
<div class="btn-row">
<button class="btn btn-primary" @click="processAppeal(item.id, 'approve')">通过申诉</button>
<button class="btn btn-ghost" @click="processAppeal(item.id, 'reject')">驳回申诉</button>
</div>
</div>
<div class="pager-row">
<button class="btn btn-ghost pager-btn" :disabled="!appealPager.hasPrev" @click="changeAppealPage(-1)">上一页</button>
<button class="btn btn-ghost pager-btn" :disabled="!appealPager.hasNext" @click="changeAppealPage(1)">下一页</button>
</div>
</div>
<div v-else class="empty">没有匹配的申诉记录</div>
</section>
</div>
</template>
<script>
import { request } from '@/utils/request'
import { toast, previewImage } from '@/utils/feedback'
const INTERCEPT_STATUS_OPTIONS = [
{ value: '', label: '全部发布状态' },
{ value: 'blocked', label: '已拦截' },
{ value: 'published', label: '已发布' }
]
const INTERCEPT_REVIEW_STATUS_OPTIONS = [
{ value: '', label: '全部复核状态' },
{ value: 'pending', label: '待复核' },
{ value: 'confirmed_spam', label: '确认垃圾' },
{ value: 'approved_ham', label: '误判放行' },
{ value: 'none', label: '未进入复核' }
]
const APPEAL_STATUS_OPTIONS = [
{ value: 'pending', label: '待处理申诉' },
{ value: '', label: '全部申诉' },
{ value: 'approved', label: '已通过申诉' },
{ value: 'rejected', label: '已驳回申诉' }
]
const REVIEW_STATUS_TEXT = { none: '无', pending: '待复核', confirmed_spam: '确认垃圾', approved_ham: '误判放行' }
const APPEAL_STATUS_TEXT = { none: '无', pending: '待处理', approved: '已通过', rejected: '已驳回' }
const CATEGORY_LABELS = { fraud: '疑似诈骗', harassment: '疑似骚扰', advertisement: '疑似广告', spam: '疑似垃圾' }
function buildPager(total, page, pageSize) {
const totalValue = Number(total || 0)
const totalPages = Math.max(Math.ceil(totalValue / pageSize), 1)
return {
page,
pageSize,
total: totalValue,
totalPages,
hasPrev: page > 1,
hasNext: page < totalPages
}
}
export default {
name: 'AdminReview',
data() {
return {
thresholdInput: '0.75',
interceptKeyword: '',
interceptStatus: 'blocked',
interceptReviewStatus: 'pending',
interceptStatusOptions: INTERCEPT_STATUS_OPTIONS,
interceptReviewStatusOptions: INTERCEPT_REVIEW_STATUS_OPTIONS,
intercepts: [],
interceptPager: buildPager(0, 1, 10),
appealKeyword: '',
appealStatus: 'pending',
appealStatusOptions: APPEAL_STATUS_OPTIONS,
appeals: [],
appealPager: buildPager(0, 1, 10),
reviewNoteMap: {},
appealNoteMap: {}
}
},
mounted() {
this.bootstrap()
},
methods: {
formatPercent(value, digits = 2) {
return `${(Number(value || 0) * 100).toFixed(digits)}%`
},
async bootstrap() {
await Promise.all([this.fetchThreshold(), this.fetchIntercepts(), this.fetchAppeals()])
},
async fetchThreshold() {
const data = await request({ url: '/admin/detection/threshold' })
this.thresholdInput = String(data.spam_threshold || 0.75)
},
normalizeIntercepts(rows = []) {
return rows.map((item) => ({
...item,
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19),
review_status_text: REVIEW_STATUS_TEXT[item.manual_review_status] || item.manual_review_status,
spam_probability_text: this.formatPercent(item.spam_probability, 2),
appeal_status_text: APPEAL_STATUS_TEXT[item.appeal_status] || item.appeal_status,
category_label: CATEGORY_LABELS[item.category] || ''
}))
},
normalizeAppeals(rows = []) {
return rows.map((item) => ({
...item,
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19),
appeal_status_text: APPEAL_STATUS_TEXT[item.appeal_status] || item.appeal_status,
category_label: CATEGORY_LABELS[item.category] || '',
appeal_evidence_urls: (item.appeal_evidence_urls || []).map((url) =>
url.startsWith('http') ? url : url
)
}))
},
async fetchIntercepts() {
const data = await request({
url: '/admin/intercepts',
params: {
keyword: (this.interceptKeyword || '').trim(),
status: this.interceptStatus,
review_status: this.interceptReviewStatus,
page: this.interceptPager.page,
page_size: this.interceptPager.pageSize
}
})
this.intercepts = this.normalizeIntercepts(data.items || [])
this.interceptPager = buildPager(data.total || 0, this.interceptPager.page, this.interceptPager.pageSize)
},
async fetchAppeals() {
const data = await request({
url: '/admin/appeals',
params: {
keyword: (this.appealKeyword || '').trim(),
status: this.appealStatus,
page: this.appealPager.page,
page_size: this.appealPager.pageSize
}
})
this.appeals = this.normalizeAppeals(data.items || [])
this.appealPager = buildPager(data.total || 0, this.appealPager.page, this.appealPager.pageSize)
},
applyInterceptFilters() {
this.interceptPager = buildPager(0, 1, this.interceptPager.pageSize)
this.fetchIntercepts()
},
clearInterceptFilters() {
this.interceptKeyword = ''
this.interceptStatus = 'blocked'
this.interceptReviewStatus = 'pending'
this.interceptPager = buildPager(0, 1, this.interceptPager.pageSize)
this.fetchIntercepts()
},
changeInterceptPage(delta) {
const next = this.interceptPager.page + delta
if (next < 1 || next > this.interceptPager.totalPages) return
this.interceptPager = { ...this.interceptPager, page: next }
this.fetchIntercepts()
},
applyAppealFilters() {
this.appealPager = buildPager(0, 1, this.appealPager.pageSize)
this.fetchAppeals()
},
clearAppealFilters() {
this.appealKeyword = ''
this.appealStatus = 'pending'
this.appealPager = buildPager(0, 1, this.appealPager.pageSize)
this.fetchAppeals()
},
changeAppealPage(delta) {
const next = this.appealPager.page + delta
if (next < 1 || next > this.appealPager.totalPages) return
this.appealPager = { ...this.appealPager, page: next }
this.fetchAppeals()
},
async saveThreshold() {
const value = Number(this.thresholdInput)
if (Number.isNaN(value) || value <= 0 || value >= 1) {
toast('阈值需在 0 到 1 之间', 'error')
return
}
await request({ url: '/admin/detection/threshold', method: 'PUT', data: { spam_threshold: value } })
toast('阈值更新成功', 'success')
this.fetchThreshold()
},
async reviewIntercept(id, decision) {
const note = (this.reviewNoteMap[id] || '').trim()
await request({
url: `/admin/intercepts/${id}/review`,
method: 'PUT',
data: {
decision,
note: note || (decision === 'spam' ? '人工复核确认为垃圾信息' : '人工复核后解除拦截')
}
})
toast('复核完成', 'success')
this.$set(this.reviewNoteMap, id, '')
await Promise.all([this.fetchIntercepts(), this.fetchAppeals()])
},
async processAppeal(id, action) {
const note = (this.appealNoteMap[id] || '').trim()
await request({
url: `/admin/appeals/${id}/process`,
method: 'PUT',
data: {
action,
note: note || (action === 'approve' ? '申诉通过,解除拦截' : '申诉驳回,维持拦截')
}
})
toast('申诉处理完成', 'success')
this.$set(this.appealNoteMap, id, '')
await Promise.all([this.fetchIntercepts(), this.fetchAppeals()])
},
previewEvidence(urls, current) {
previewImage(urls, current)
}
}
}
</script>

View File

@@ -0,0 +1,173 @@
<template>
<div class="container">
<section class="hero fade-up">
<div class="hero-badge">SAMPLE LAB</div>
<h1 class="hero-title">训练样本管理</h1>
<p class="hero-sub">支持增删样本批量导入启停样本与一键重训持续提升识别准确率</p>
</section>
<section class="card fade-up fade-up-delay-1">
<div class="card-title">筛选样本</div>
<div class="grid-2">
<div class="field">
<label class="field-label">关键词</label>
<input class="input" v-model="keyword" placeholder="输入关键词" />
</div>
<div class="field">
<label class="field-label">标签</label>
<div>
<label class="radio-row"><input type="radio" v-model="label" value="" />全部</label>
<label class="radio-row"><input type="radio" v-model="label" value="spam" />垃圾</label>
<label class="radio-row"><input type="radio" v-model="label" value="ham" />正常</label>
</div>
</div>
</div>
<button class="btn btn-primary" :disabled="loading" @click="fetchSamples">
{{ loading ? '查询中...' : '查询' }}
</button>
</section>
<section class="card fade-up fade-up-delay-1">
<div class="card-title">新增样本</div>
<textarea class="textarea" v-model="form.text" placeholder="输入样本文本" rows="3"></textarea>
<div class="field">
<label class="radio-row"><input type="radio" v-model="form.label" value="spam" />垃圾</label>
<label class="radio-row"><input type="radio" v-model="form.label" value="ham" />正常</label>
</div>
<button class="btn btn-accent" @click="createSample">提交样本</button>
</section>
<section class="card fade-up fade-up-delay-2">
<div class="card-title">批量导入</div>
<div class="card-desc">支持 JSON 数组导入导入后可直接重训模型</div>
<textarea class="textarea" v-model="importText" rows="4"></textarea>
<div class="btn-row">
<button class="btn btn-primary" @click="importSamples">执行导入</button>
<button class="btn btn-ghost" @click="retrain">一键重训模型</button>
</div>
</section>
<section class="card fade-up fade-up-delay-3" v-if="samples.length">
<div class="card-title">样本列表</div>
<div class="list-item" v-for="item in samples" :key="item.id">
<div class="item-title">{{ item.text }}</div>
<div class="item-sub">标签{{ item.label === 'spam' ? '垃圾' : '正常' }} · 来源{{ item.source }} · ID{{ item.id }}</div>
<div class="row">
<span class="label">参与训练</span>
<label class="switch">
<input type="checkbox" :checked="item.is_active" @change="toggleActive(item, $event)" />
<span class="switch-slider"></span>
</label>
</div>
<button class="btn btn-ghost btn-sm" @click="deleteSample(item.id)">删除样本</button>
</div>
</section>
<section class="card fade-up fade-up-delay-3" v-else-if="!loading">
<div class="empty">暂无样本先去新增或导入</div>
</section>
</div>
</template>
<script>
import { request } from '@/utils/request'
import { toast, confirm } from '@/utils/feedback'
export default {
name: 'AdminSamples',
data() {
return {
keyword: '',
label: '',
loading: false,
samples: [],
form: {
text: '',
label: 'spam'
},
importText: '[{"text":"点击领取限时现金券","label":"spam"},{"text":"今天下午发布会彩排","label":"ham"}]'
}
},
mounted() {
this.fetchSamples()
},
methods: {
async fetchSamples() {
this.loading = true
try {
const data = await request({
url: '/spam/samples',
params: {
keyword: this.keyword,
label: this.label,
page: 1,
page_size: 80
}
})
this.samples = data.items || []
} finally {
this.loading = false
}
},
async createSample() {
const text = (this.form.text || '').trim()
if (text.length < 2) {
toast('样本文本至少 2 个字符', 'error')
return
}
await request({
url: '/spam/samples',
method: 'POST',
data: { text, label: this.form.label || 'spam' }
})
toast('新增成功', 'success')
this.form.text = ''
this.fetchSamples()
},
async deleteSample(id) {
const { confirm: ok } = await confirm({
title: '删除样本',
content: `确认删除样本 ID ${id} 吗?`,
confirmText: '删除',
cancelText: '取消'
})
if (!ok) return
await request({ url: `/spam/samples/${id}`, method: 'DELETE' })
toast('删除成功', 'success')
this.fetchSamples()
},
async toggleActive(item, e) {
const active = !!e.target.checked
try {
await request({
url: `/spam/samples/${item.id}`,
method: 'PUT',
data: { is_active: active }
})
item.is_active = active
toast('状态已更新', 'success')
} catch (err) {
e.target.checked = !active
}
},
async retrain() {
await request({ url: '/spam/train', method: 'POST', data: {} })
toast('模型重训完成', 'success')
},
async importSamples() {
let items = []
try {
items = JSON.parse(this.importText)
} catch (err) {
toast('JSON 格式错误', 'error')
return
}
await request({ url: '/spam/samples/import', method: 'POST', data: { items } })
toast('导入完成', 'success')
this.fetchSamples()
}
}
}
</script>

View File

@@ -0,0 +1,191 @@
<template>
<div class="container">
<section class="hero fade-up">
<div class="hero-badge">USER ADMIN</div>
<h1 class="hero-title">用户与权限管理</h1>
<p class="hero-sub">支持账号查询权限调整批量导入适用于企业商用场景</p>
</section>
<section class="card fade-up fade-up-delay-1">
<div class="card-title">搜索用户</div>
<div class="field">
<label class="field-label">关键词</label>
<input class="input" v-model="keyword" placeholder="输入用户名或昵称" @keyup.enter="fetchUsers" />
</div>
<button class="btn btn-primary" :disabled="loading" @click="fetchUsers">
{{ loading ? '查询中...' : '查询' }}
</button>
</section>
<section class="card fade-up fade-up-delay-1">
<div class="card-title">批量导入</div>
<div class="card-desc">粘贴 JSON 数组支持批量新增或更新用户信息</div>
<textarea class="textarea" v-model="importText" rows="4"></textarea>
<button class="btn btn-accent" @click="importUsers">执行导入</button>
</section>
<section class="card fade-up fade-up-delay-2" v-if="users.length">
<div class="card-title">用户列表</div>
<div class="list-item" v-for="item in users" :key="item.id">
<div class="item-title">{{ item.nickname }}{{ item.username }}</div>
<div class="item-sub">{{ item.company || '未填写公司' }} · {{ item.title || '未填写岗位' }} · {{ item.is_admin ? '管理员' : '普通用户' }}</div>
<div class="row">
<span class="label">信誉分</span>
<div class="credit-score-bar">
<div class="credit-fill" :style="{ width: ((item.credit_score || 100) / 2) + '%' }"></div>
<span class="credit-value">{{ item.credit_score || 100 }}</span>
</div>
</div>
<div v-if="editUserId === item.id" style="margin-top: 10px;">
<div class="grid-2">
<div class="field">
<label class="field-label">昵称</label>
<input class="input" v-model="editForm.nickname" placeholder="昵称" />
</div>
<div class="field">
<label class="field-label">手机号</label>
<input class="input" v-model="editForm.phone" placeholder="手机号" />
</div>
<div class="field">
<label class="field-label">公司</label>
<input class="input" v-model="editForm.company" placeholder="公司" />
</div>
<div class="field">
<label class="field-label">岗位</label>
<input class="input" v-model="editForm.title" placeholder="岗位" />
</div>
<div class="field">
<label class="field-label">新密码可选</label>
<input class="input" type="password" v-model="editForm.password" placeholder="留空表示不修改" />
</div>
<div class="field">
<label class="field-label">信誉分0-200</label>
<input class="input" type="number" v-model.number="editForm.credit_score" />
</div>
</div>
<div class="row">
<span class="label">管理员权限</span>
<label class="switch">
<input type="checkbox" v-model="editForm.is_admin" />
<span class="switch-slider"></span>
</label>
</div>
<div class="btn-row">
<button class="btn btn-primary" @click="saveEdit">保存</button>
<button class="btn btn-ghost" @click="cancelEdit">取消</button>
</div>
</div>
<div v-else class="btn-row">
<button class="btn btn-ghost btn-sm" @click="startEdit(item)">编辑</button>
<button class="btn btn-danger btn-sm" @click="removeUser(item.id)">删除</button>
</div>
</div>
</section>
<section class="card fade-up fade-up-delay-2" v-else-if="!loading">
<div class="empty">暂无用户</div>
</section>
</div>
</template>
<script>
import { request } from '@/utils/request'
import { toast, confirm } from '@/utils/feedback'
export default {
name: 'AdminUsers',
data() {
return {
keyword: '',
loading: false,
users: [],
editUserId: null,
editForm: {
nickname: '',
company: '',
title: '',
phone: '',
is_admin: false,
password: '',
credit_score: 100
},
importText: '[{"username":"operator01","password":"123456","nickname":"运营同学","company":"示例科技公司"}]'
}
},
mounted() {
this.fetchUsers()
},
methods: {
async fetchUsers() {
this.loading = true
try {
const data = await request({
url: '/admin/users',
params: {
keyword: this.keyword,
page: 1,
page_size: 80
}
})
this.users = data.items || []
} finally {
this.loading = false
}
},
startEdit(row) {
this.editUserId = row.id
this.editForm = {
nickname: row.nickname || '',
company: row.company || '',
title: row.title || '',
phone: row.phone || '',
is_admin: !!row.is_admin,
password: '',
credit_score: row.credit_score || 100
}
},
cancelEdit() {
this.editUserId = null
},
async saveEdit() {
const id = this.editUserId
if (!id) return
const payload = { ...this.editForm }
if (!payload.password) delete payload.password
await request({ url: `/admin/users/${id}`, method: 'PUT', data: payload })
toast('用户已更新', 'success')
this.editUserId = null
this.fetchUsers()
},
async removeUser(id) {
const { confirm: ok } = await confirm({
title: '删除用户',
content: `确认删除用户 ID ${id} 吗?`,
confirmText: '删除',
cancelText: '取消'
})
if (!ok) return
await request({ url: `/admin/users/${id}`, method: 'DELETE' })
toast('删除成功', 'success')
this.fetchUsers()
},
async importUsers() {
let items = []
try {
items = JSON.parse(this.importText)
} catch (err) {
toast('JSON 格式错误', 'error')
return
}
await request({ url: '/admin/users/import', method: 'POST', data: { items } })
toast('导入完成', 'success')
this.fetchUsers()
}
}
}
</script>

26
admin-web/vue.config.js Normal file
View File

@@ -0,0 +1,26 @@
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true,
lintOnSave: false,
devServer: {
port: 8080,
host: '0.0.0.0',
historyApiFallback: true,
proxy: {
'/api': {
target: 'http://127.0.0.1:5000',
changeOrigin: true,
ws: false,
logLevel: 'debug',
onError(err, req, res) {
console.error('[proxy error]', req && req.url, err && err.message)
if (res && !res.headersSent) {
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' })
res.end(JSON.stringify({ code: -1, message: '后端代理失败,请确认 Flask 已在 5000 端口运行:' + (err && err.message) }))
}
}
}
}
}
})

6262
admin-web/yarn.lock Normal file

File diff suppressed because it is too large Load Diff