feat: 小程序移除管理后台入口,新增admin-web前端项目
将管理后台功能从微信小程序中剥离,独立为Vue.js前端项目admin-web Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
23
admin-web/.gitignore
vendored
Normal file
23
admin-web/.gitignore
vendored
Normal 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
24
admin-web/README.md
Normal 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/).
|
||||
5
admin-web/babel.config.js
Normal file
5
admin-web/babel.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
}
|
||||
19
admin-web/jsconfig.json
Normal file
19
admin-web/jsconfig.json
Normal 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
48
admin-web/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
BIN
admin-web/public/favicon.ico
Normal file
BIN
admin-web/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
16
admin-web/public/index.html
Normal file
16
admin-web/public/index.html
Normal 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
9
admin-web/src/App.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'App'
|
||||
}
|
||||
</script>
|
||||
58
admin-web/src/components/ConfirmHost.vue
Normal file
58
admin-web/src/components/ConfirmHost.vue
Normal 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>
|
||||
57
admin-web/src/components/ImagePreviewHost.vue
Normal file
57
admin-web/src/components/ImagePreviewHost.vue
Normal 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>
|
||||
34
admin-web/src/components/ToastHost.vue
Normal file
34
admin-web/src/components/ToastHost.vue
Normal 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>
|
||||
148
admin-web/src/layouts/AppLayout.vue
Normal file
148
admin-web/src/layouts/AppLayout.vue
Normal 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
11
admin-web/src/main.js
Normal 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')
|
||||
102
admin-web/src/router/index.js
Normal file
102
admin-web/src/router/index.js
Normal 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
|
||||
40
admin-web/src/store/index.js
Normal file
40
admin-web/src/store/index.js
Normal 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
|
||||
}
|
||||
}
|
||||
1140
admin-web/src/styles/theme.css
Normal file
1140
admin-web/src/styles/theme.css
Normal file
File diff suppressed because it is too large
Load Diff
36
admin-web/src/utils/auth.js
Normal file
36
admin-web/src/utils/auth.js
Normal 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)
|
||||
}
|
||||
59
admin-web/src/utils/feedback.js
Normal file
59
admin-web/src/utils/feedback.js
Normal 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 })
|
||||
}
|
||||
131
admin-web/src/utils/request.js
Normal file
131
admin-web/src/utils/request.js
Normal 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
|
||||
290
admin-web/src/views/Batch.vue
Normal file
290
admin-web/src/views/Batch.vue
Normal 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="示例: 点击链接领取红包 今天下午三点开会"
|
||||
></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>
|
||||
187
admin-web/src/views/Detect.vue
Normal file
187
admin-web/src/views/Detect.vue
Normal 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>
|
||||
297
admin-web/src/views/History.vue
Normal file
297
admin-web/src/views/History.vue
Normal 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>
|
||||
135
admin-web/src/views/Home.vue
Normal file
135
admin-web/src/views/Home.vue
Normal 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>
|
||||
56
admin-web/src/views/Inbox.vue
Normal file
56
admin-web/src/views/Inbox.vue
Normal 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>
|
||||
86
admin-web/src/views/Login.vue
Normal file
86
admin-web/src/views/Login.vue
Normal 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>
|
||||
145
admin-web/src/views/Profile.vue
Normal file
145
admin-web/src/views/Profile.vue
Normal 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>
|
||||
94
admin-web/src/views/Register.vue
Normal file
94
admin-web/src/views/Register.vue
Normal 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>
|
||||
268
admin-web/src/views/admin/Dashboard.vue
Normal file
268
admin-web/src/views/admin/Dashboard.vue
Normal 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>
|
||||
353
admin-web/src/views/admin/Review.vue
Normal file
353
admin-web/src/views/admin/Review.vue
Normal 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>
|
||||
173
admin-web/src/views/admin/Samples.vue
Normal file
173
admin-web/src/views/admin/Samples.vue
Normal 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>
|
||||
191
admin-web/src/views/admin/Users.vue
Normal file
191
admin-web/src/views/admin/Users.vue
Normal 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
26
admin-web/vue.config.js
Normal 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
6262
admin-web/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user