Compare commits
24 Commits
f7d0601c4e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0f7a758eb | ||
|
|
b8acc8be43 | ||
|
|
829599bc17 | ||
|
|
f3c0c44f27 | ||
|
|
eaa5a27370 | ||
|
|
200a0ae2e4 | ||
|
|
83618bd714 | ||
|
|
1978326724 | ||
|
|
49c946dd55 | ||
|
|
f342fdc9b4 | ||
|
|
25fd25005a | ||
|
|
45bfa93e85 | ||
|
|
00ead01cb8 | ||
|
|
f7fdc635c7 | ||
|
|
f5b706d892 | ||
|
|
7f2036fbb2 | ||
|
|
38cb9345d6 | ||
|
|
2dcd7ce9f6 | ||
|
|
cedfd066c4 | ||
|
|
84f0943578 | ||
|
|
8efd86968f | ||
|
|
385ebe25e7 | ||
|
|
6d62120443 | ||
|
|
5279816452 |
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
/backend/venv/
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Uploads
|
||||
/backend/uploads/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Archives
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.7z
|
||||
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
@@ -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
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
}
|
||||
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
@@ -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
|
After Width: | Height: | Size: 4.2 KiB |
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
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'App'
|
||||
}
|
||||
</script>
|
||||
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
@@ -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
@@ -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
@@ -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>
|
||||
28
admin-web/src/main.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import Vue from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/theme.css'
|
||||
|
||||
Vue.config.productionTip = false
|
||||
|
||||
Vue.config.errorHandler = (err, vm, info) => {
|
||||
console.error('[vue error]', info, err)
|
||||
}
|
||||
|
||||
Vue.config.warnHandler = (msg, vm, trace) => {
|
||||
console.warn('[vue warn]', msg, trace)
|
||||
}
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
console.warn('[unhandled rejection]', event.reason)
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
window.addEventListener('error', (event) => {
|
||||
console.error('[window error]', event.error || event.message)
|
||||
})
|
||||
|
||||
new Vue({
|
||||
router,
|
||||
render: (h) => h(App)
|
||||
}).$mount('#app')
|
||||
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
@@ -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
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)
|
||||
}
|
||||
98
admin-web/src/utils/feedback.js
Normal file
@@ -0,0 +1,98 @@
|
||||
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 })
|
||||
}
|
||||
|
||||
export async function copyText(text) {
|
||||
if (text === undefined || text === null) return false
|
||||
const value = String(text)
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
return true
|
||||
} catch (err) {
|
||||
console.warn('[copyText] navigator.clipboard 不可用,回退到 execCommand', err)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = value
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.top = '0'
|
||||
textarea.style.left = '0'
|
||||
textarea.style.width = '1px'
|
||||
textarea.style.height = '1px'
|
||||
textarea.style.opacity = '0'
|
||||
textarea.style.pointerEvents = 'none'
|
||||
document.body.appendChild(textarea)
|
||||
|
||||
textarea.focus()
|
||||
textarea.select()
|
||||
textarea.setSelectionRange(0, value.length)
|
||||
|
||||
const ok = document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
return !!ok
|
||||
} catch (err) {
|
||||
console.error('[copyText] execCommand 也失败了', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
125
admin-web/src/utils/request.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import axios from 'axios'
|
||||
import { getToken, clearAuth } from '@/utils/auth'
|
||||
|
||||
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(url) {
|
||||
console.warn('[auth] 登录已过期,自动退出登录', url || '')
|
||||
clearAuth()
|
||||
setTimeout(() => {
|
||||
if (location.hash !== '#/login') {
|
||||
location.hash = '#/login'
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
|
||||
instance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(err) => {
|
||||
if (err && err.response && err.response.status === 401) {
|
||||
handleUnauthorized(err.config && err.config.url)
|
||||
}
|
||||
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 || '请求失败'
|
||||
console.error('[request]', method, url, '业务失败:', message, body)
|
||||
reject(new Error(message))
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err && err.response && err.response.status === 401) {
|
||||
reject(new Error('Unauthorized'))
|
||||
return
|
||||
}
|
||||
const msg = (err && err.message) || '网络异常'
|
||||
console.error('[request]', method, url, '请求异常:', msg, err)
|
||||
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 || '上传失败'
|
||||
console.error('[upload] 业务失败:', message, body)
|
||||
reject(new Error(message))
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err && err.response && err.response.status === 401) {
|
||||
reject(new Error('Unauthorized'))
|
||||
return
|
||||
}
|
||||
const msg = (err && err.message) || '上传失败'
|
||||
console.error('[upload] 请求异常:', msg, err)
|
||||
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
|
||||
295
admin-web/src/views/Batch.vue
Normal file
@@ -0,0 +1,295 @@
|
||||
<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, copyText } 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 = (e) => {
|
||||
console.error('[batch] 文件读取失败', e)
|
||||
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) {
|
||||
console.error('[batch] 导出失败', err)
|
||||
toast('导出失败', 'error')
|
||||
} finally {
|
||||
this.exporting = false
|
||||
}
|
||||
},
|
||||
async copyCSV() {
|
||||
if (!this.items.length) {
|
||||
toast('暂无识别结果可复制', 'error')
|
||||
return
|
||||
}
|
||||
const csv = this.generateCSV()
|
||||
const ok = await copyText(csv)
|
||||
if (ok) {
|
||||
toast('CSV 内容已复制到剪贴板', 'success')
|
||||
} else {
|
||||
console.error('[batch] 复制失败,CSV 内容打印到控制台供手动复制\n' + csv)
|
||||
toast('复制失败,请手动选择文本', 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
189
admin-web/src/views/Detect.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<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')
|
||||
} catch (err) {
|
||||
console.error('[detect] 发布失败', err)
|
||||
} 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
@@ -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>
|
||||
138
admin-web/src/views/Home.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<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 {
|
||||
await refreshUser()
|
||||
const modelInfo = await request({ url: '/spam/model/info' }).catch((err) => {
|
||||
console.warn('[home] 模型信息加载失败', err)
|
||||
return null
|
||||
})
|
||||
if (modelInfo) this.modelInfo = modelInfo
|
||||
} catch (err) {
|
||||
console.warn('[home] bootstrap 异常', err)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
goto(path) {
|
||||
this.$router.push(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
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
@@ -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
@@ -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
@@ -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>
|
||||
270
admin-web/src/views/admin/Dashboard.vue
Normal file
@@ -0,0 +1,270 @@
|
||||
<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, copyText } 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')
|
||||
]
|
||||
const content = lines.join('\n')
|
||||
const ok = await copyText(content)
|
||||
if (ok) {
|
||||
toast('报告已复制到剪贴板', 'success')
|
||||
} else {
|
||||
console.error('[dashboard] 复制失败,报告内容打印到控制台供手动复制\n' + content)
|
||||
toast('复制失败,请手动选择', 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
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>
|
||||
174
admin-web/src/views/admin/Samples.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<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) {
|
||||
console.error('[samples] JSON 格式错误', err)
|
||||
toast('JSON 格式错误', 'error')
|
||||
return
|
||||
}
|
||||
await request({ url: '/spam/samples/import', method: 'POST', data: { items } })
|
||||
toast('导入完成', 'success')
|
||||
this.fetchSamples()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
192
admin-web/src/views/admin/Users.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<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) {
|
||||
console.error('[users] JSON 格式错误', err)
|
||||
toast('JSON 格式错误', 'error')
|
||||
return
|
||||
}
|
||||
await request({ url: '/admin/users/import', method: 'POST', data: { items } })
|
||||
toast('导入完成', 'success')
|
||||
this.fetchUsers()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
30
admin-web/vue.config.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const { defineConfig } = require('@vue/cli-service')
|
||||
|
||||
module.exports = defineConfig({
|
||||
transpileDependencies: true,
|
||||
lintOnSave: false,
|
||||
devServer: {
|
||||
port: 8080,
|
||||
host: '0.0.0.0',
|
||||
historyApiFallback: true,
|
||||
client: {
|
||||
overlay: false,
|
||||
logging: 'warn'
|
||||
},
|
||||
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
68
backend/app/ml/spam_categorizer.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""垃圾信息分类标签模块
|
||||
|
||||
在朴素贝叶斯二分类(spam/ham)基础上,对判定为 spam 的文本进行细分类标签。
|
||||
分类优先级:诈骗 > 骚扰 > 广告(按危害程度排序)
|
||||
"""
|
||||
|
||||
CATEGORY_KEYWORDS = {
|
||||
"fraud": [
|
||||
"中奖", "幸运粉丝", "幸运用户", "银行卡异常", "社保异常", "账号冻结",
|
||||
"解封", "立即验证", "验证码", "欠费停机", "退款待确认", "违章信息",
|
||||
"紧急通知", "账户异常", "风险", "核验", "被冻结", "将被冻结",
|
||||
],
|
||||
"harassment": [
|
||||
"兼职", "日结", "高薪", "刷单", "赚钱", "外快", "宝妈", "学生都能做",
|
||||
"添加微信", "扫码进群", "进群立刻", "想赚", "零花钱", "在家办公",
|
||||
"无需面试", "火热招募", "秒赚", "招募",
|
||||
],
|
||||
"advertisement": [
|
||||
"领取", "优惠", "红包", "优惠券", "秒杀", "返现", "补贴", "会员",
|
||||
"特价", "低价", "点击链接", "扫码", "免费领取", "无门槛", "现金券",
|
||||
"盲盒", "百分百中奖", "隐藏优惠券", "内部价", "货到付款", "限时",
|
||||
"最后", "名额", "先到先得",
|
||||
],
|
||||
}
|
||||
|
||||
CATEGORY_LABELS = {
|
||||
"fraud": "疑似诈骗",
|
||||
"harassment": "疑似骚扰",
|
||||
"advertisement": "疑似广告",
|
||||
"spam": "疑似垃圾",
|
||||
"ham": "",
|
||||
}
|
||||
|
||||
CATEGORY_PRIORITY = ["fraud", "harassment", "advertisement"]
|
||||
|
||||
|
||||
def categorize_spam(text: str) -> tuple[str, str]:
|
||||
"""根据关键词匹配判定垃圾信息的具体分类标签
|
||||
|
||||
Args:
|
||||
text: 待分类的文本内容
|
||||
|
||||
Returns:
|
||||
tuple[str, str]: (category_code, category_label)
|
||||
- category_code: fraud | harassment | advertisement | spam
|
||||
- category_label: 疑似诈骗 | 疑似骚扰 | 疑似广告 | 疑似垃圾
|
||||
"""
|
||||
text_lower = text.lower()
|
||||
|
||||
for category in CATEGORY_PRIORITY:
|
||||
keywords = CATEGORY_KEYWORDS.get(category, [])
|
||||
for kw in keywords:
|
||||
if kw.lower() in text_lower:
|
||||
return category, CATEGORY_LABELS[category]
|
||||
|
||||
return "spam", CATEGORY_LABELS["spam"]
|
||||
|
||||
|
||||
def get_category_label(category: str) -> str:
|
||||
"""获取分类标签的中文显示文本
|
||||
|
||||
Args:
|
||||
category: 分类代码
|
||||
|
||||
Returns:
|
||||
str: 中文标签文本
|
||||
"""
|
||||
return CATEGORY_LABELS.get(category, "")
|
||||
@@ -16,6 +16,7 @@ class User(db.Model):
|
||||
title = db.Column(db.String(64), default="")
|
||||
phone = db.Column(db.String(32), default="")
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
credit_score = db.Column(db.Integer, default=100) # 信誉分 0-200,默认100
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
@@ -40,6 +41,7 @@ class User(db.Model):
|
||||
"title": self.title,
|
||||
"phone": self.phone,
|
||||
"is_admin": self.is_admin,
|
||||
"credit_score": self.credit_score,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
@@ -77,6 +79,7 @@ class SpamPredictionLog(db.Model):
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
|
||||
text = db.Column(db.Text, nullable=False)
|
||||
prediction = db.Column(db.String(16), nullable=False) # spam | ham
|
||||
category = db.Column(db.String(32), default="") # fraud | harassment | advertisement | spam | 空
|
||||
spam_probability = db.Column(db.Float, nullable=False)
|
||||
ham_probability = db.Column(db.Float, nullable=False)
|
||||
confidence = db.Column(db.Float, nullable=False)
|
||||
@@ -90,6 +93,7 @@ class SpamPredictionLog(db.Model):
|
||||
"user_id": self.user_id,
|
||||
"text": self.text,
|
||||
"prediction": self.prediction,
|
||||
"category": self.category or "",
|
||||
"spam_probability": round(float(self.spam_probability), 4),
|
||||
"ham_probability": round(float(self.ham_probability), 4),
|
||||
"confidence": round(float(self.confidence), 4),
|
||||
@@ -128,6 +132,7 @@ class ContentPost(db.Model):
|
||||
|
||||
status = db.Column(db.String(16), nullable=False, default="published") # published | blocked
|
||||
prediction = db.Column(db.String(16), nullable=False, default="ham")
|
||||
category = db.Column(db.String(32), default="") # fraud | harassment | advertisement | spam | 空
|
||||
spam_probability = db.Column(db.Float, nullable=False, default=0)
|
||||
ham_probability = db.Column(db.Float, nullable=False, default=0)
|
||||
confidence = db.Column(db.Float, nullable=False, default=0)
|
||||
@@ -161,6 +166,7 @@ class ContentPost(db.Model):
|
||||
"visibility": self.visibility,
|
||||
"status": self.status,
|
||||
"prediction": self.prediction,
|
||||
"category": self.category or "",
|
||||
"spam_probability": round(float(self.spam_probability), 4),
|
||||
"ham_probability": round(float(self.ham_probability), 4),
|
||||
"confidence": round(float(self.confidence), 4),
|
||||
|
||||
@@ -147,6 +147,114 @@ def stats():
|
||||
)
|
||||
|
||||
|
||||
@admin_bp.get("/stats/report")
|
||||
@admin_required
|
||||
def generate_report():
|
||||
"""生成运营报告:垃圾信息变化、风险词排名、误判率趋势"""
|
||||
now = datetime.utcnow()
|
||||
week_ago = now - timedelta(days=13) # 近14天
|
||||
|
||||
# 1. 垃圾信息数量变化(近14天)
|
||||
blocked_trend_rows = (
|
||||
db.session.query(func.date(ContentPost.created_at), func.count(ContentPost.id))
|
||||
.filter(ContentPost.created_at >= week_ago, ContentPost.status == "blocked")
|
||||
.group_by(func.date(ContentPost.created_at))
|
||||
.all()
|
||||
)
|
||||
published_trend_rows = (
|
||||
db.session.query(func.date(ContentPost.created_at), func.count(ContentPost.id))
|
||||
.filter(ContentPost.created_at >= week_ago, ContentPost.status == "published")
|
||||
.group_by(func.date(ContentPost.created_at))
|
||||
.all()
|
||||
)
|
||||
|
||||
blocked_map = {_day_key(day): int(count or 0) for day, count in blocked_trend_rows}
|
||||
published_map = {_day_key(day): int(count or 0) for day, count in published_trend_rows}
|
||||
|
||||
spam_trend = []
|
||||
today = now.date()
|
||||
for offset in range(13, -1, -1):
|
||||
day = today - timedelta(days=offset)
|
||||
key = day.isoformat()
|
||||
spam_trend.append({
|
||||
"date": key,
|
||||
"label": day.strftime("%m-%d"),
|
||||
"blocked": blocked_map.get(key, 0),
|
||||
"published": published_map.get(key, 0),
|
||||
"total": blocked_map.get(key, 0) + published_map.get(key, 0)
|
||||
})
|
||||
|
||||
# 2. 高频风险词排名(近14天)
|
||||
blocked_logs = (
|
||||
ContentPost.query.filter(ContentPost.created_at >= week_ago, ContentPost.status == "blocked")
|
||||
.order_by(ContentPost.id.desc())
|
||||
.limit(1000)
|
||||
.all()
|
||||
)
|
||||
token_counter = Counter()
|
||||
for row in blocked_logs:
|
||||
token_counter.update(_tokenize(row.text))
|
||||
top_keywords = [{"token": token, "count": count} for token, count in token_counter.most_common(20)]
|
||||
|
||||
# 3. 误判率趋势(近14天,基于人工复核)
|
||||
review_trend_rows = (
|
||||
db.session.query(func.date(ContentPost.manual_review_at), func.count(ContentPost.id))
|
||||
.filter(ContentPost.manual_review_at >= week_ago, ContentPost.manual_review_status != "none")
|
||||
.group_by(func.date(ContentPost.manual_review_at))
|
||||
.all()
|
||||
)
|
||||
approved_trend_rows = (
|
||||
db.session.query(func.date(ContentPost.manual_review_at), func.count(ContentPost.id))
|
||||
.filter(ContentPost.manual_review_at >= week_ago, ContentPost.manual_review_status == "approved_ham")
|
||||
.group_by(func.date(ContentPost.manual_review_at))
|
||||
.all()
|
||||
)
|
||||
|
||||
review_map = {_day_key(day): int(count or 0) for day, count in review_trend_rows}
|
||||
approved_map = {_day_key(day): int(count or 0) for day, count in approved_trend_rows}
|
||||
|
||||
misjudge_trend = []
|
||||
for offset in range(13, -1, -1):
|
||||
day = today - timedelta(days=offset)
|
||||
key = day.isoformat()
|
||||
reviewed = review_map.get(key, 0)
|
||||
approved = approved_map.get(key, 0)
|
||||
misjudge_rate = round(approved / reviewed, 4) if reviewed > 0 else 0
|
||||
misjudge_trend.append({
|
||||
"date": key,
|
||||
"label": day.strftime("%m-%d"),
|
||||
"reviewed": reviewed,
|
||||
"approved": approved,
|
||||
"misjudge_rate": misjudge_rate,
|
||||
"misjudge_rate_text": f"{misjudge_rate * 100:.1f}%"
|
||||
})
|
||||
|
||||
# 4. 汇总统计
|
||||
total_blocked_14d = sum(blocked_map.values())
|
||||
total_published_14d = sum(published_map.values())
|
||||
total_reviews_14d = sum(review_map.values())
|
||||
total_approved_14d = sum(approved_map.values())
|
||||
avg_misjudge_rate = round(total_approved_14d / total_reviews_14d, 4) if total_reviews_14d > 0 else 0
|
||||
|
||||
return ok({
|
||||
"report_date": now.isoformat(),
|
||||
"period": "近14天",
|
||||
"spam_trend": spam_trend,
|
||||
"top_keywords": top_keywords,
|
||||
"misjudge_trend": misjudge_trend,
|
||||
"summary": {
|
||||
"total_blocked": total_blocked_14d,
|
||||
"total_published": total_published_14d,
|
||||
"total_posts": total_blocked_14d + total_published_14d,
|
||||
"blocked_ratio": round(total_blocked_14d / (total_blocked_14d + total_published_14d), 4) if (total_blocked_14d + total_published_14d) > 0 else 0,
|
||||
"total_reviews": total_reviews_14d,
|
||||
"total_approved": total_approved_14d,
|
||||
"avg_misjudge_rate": avg_misjudge_rate,
|
||||
"avg_misjudge_rate_text": f"{avg_misjudge_rate * 100:.1f}%"
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@admin_bp.get("/detection/threshold")
|
||||
@admin_required
|
||||
def get_threshold():
|
||||
@@ -309,11 +417,17 @@ def process_appeal(post_id: int):
|
||||
row.prediction = "ham"
|
||||
row.manual_review_status = "approved_ham"
|
||||
_upsert_manual_sample(row.text, "ham", admin.id if admin else None)
|
||||
# 申诉通过,增加用户信誉分
|
||||
if row.author:
|
||||
row.author.credit_score = min(200, (row.author.credit_score or 100) + 10)
|
||||
else:
|
||||
row.status = "blocked"
|
||||
row.prediction = "spam"
|
||||
row.manual_review_status = "confirmed_spam"
|
||||
_upsert_manual_sample(row.text, "spam", admin.id if admin else None)
|
||||
# 申诉驳回,减少用户信誉分
|
||||
if row.author:
|
||||
row.author.credit_score = max(0, (row.author.credit_score or 100) - 5)
|
||||
|
||||
db.session.commit()
|
||||
return ok(_serialize_post(row), "申诉处理完成")
|
||||
@@ -404,6 +518,12 @@ def update_user(user_id: int):
|
||||
user.phone = (payload.get("phone") or "").strip()
|
||||
if "is_admin" in payload:
|
||||
user.is_admin = bool(payload.get("is_admin"))
|
||||
if "credit_score" in payload:
|
||||
try:
|
||||
credit = int(payload.get("credit_score", 100))
|
||||
user.credit_score = max(0, min(200, credit))
|
||||
except Exception:
|
||||
pass
|
||||
if payload.get("password"):
|
||||
if len(payload["password"]) < 6:
|
||||
return fail("密码至少6位", 400)
|
||||
@@ -427,3 +547,73 @@ def delete_user(user_id: int):
|
||||
db.session.commit()
|
||||
return ok({}, "用户已删除")
|
||||
|
||||
|
||||
@admin_bp.put("/users/<int:user_id>/credit")
|
||||
@admin_required
|
||||
def update_user_credit(user_id: int):
|
||||
"""手动调整用户信誉分"""
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
return fail("用户不存在", 404)
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
try:
|
||||
credit = int(payload.get("credit_score", user.credit_score or 100))
|
||||
credit = max(0, min(200, credit))
|
||||
except Exception:
|
||||
return fail("信誉分必须是0-200之间的整数", 400)
|
||||
|
||||
user.credit_score = credit
|
||||
db.session.commit()
|
||||
return ok(user.to_dict(), "信誉分已更新")
|
||||
|
||||
|
||||
@admin_bp.post("/users/recalculate-credit")
|
||||
@admin_required
|
||||
def recalculate_all_credit():
|
||||
"""根据用户发布历史和申诉通过率重新计算信誉分"""
|
||||
users = User.query.filter_by(is_admin=False).all()
|
||||
updated_count = 0
|
||||
|
||||
for user in users:
|
||||
posts = ContentPost.query.filter_by(user_id=user.id).all()
|
||||
if not posts:
|
||||
continue
|
||||
|
||||
# 计算发布成功率
|
||||
published_count = sum(1 for p in posts if p.status == "published")
|
||||
blocked_count = sum(1 for p in posts if p.status == "blocked")
|
||||
total_count = len(posts)
|
||||
|
||||
if total_count == 0:
|
||||
continue
|
||||
|
||||
publish_ratio = published_count / total_count
|
||||
|
||||
# 计算申诉通过率
|
||||
appeals = [p for p in posts if p.appeal_status != "none"]
|
||||
approved_appeals = sum(1 for p in appeals if p.appeal_status == "approved")
|
||||
appeal_ratio = approved_appeals / len(appeals) if appeals else 0
|
||||
|
||||
# 基础信誉分:发布成功率贡献
|
||||
base_score = 100
|
||||
if publish_ratio >= 0.9:
|
||||
base_score += 30 # 90%以上发布成功,+30
|
||||
elif publish_ratio >= 0.7:
|
||||
base_score += 15 # 70%以上,+15
|
||||
elif publish_ratio < 0.5:
|
||||
base_score -= 20 # 低于50%,-20
|
||||
|
||||
# 申诉通过率贡献
|
||||
if appeal_ratio >= 0.8 and len(appeals) >= 3:
|
||||
base_score += 20 # 80%以上申诉通过且有3次以上申诉,+20
|
||||
elif appeal_ratio >= 0.5 and len(appeals) >= 2:
|
||||
base_score += 10
|
||||
|
||||
# 限制范围
|
||||
user.credit_score = max(0, min(200, base_score))
|
||||
updated_count += 1
|
||||
|
||||
db.session.commit()
|
||||
return ok({"updated_count": updated_count}, "信誉分批量重算完成")
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from flask_jwt_extended import jwt_required
|
||||
|
||||
from app.extensions import db
|
||||
from app.ml.naive_bayes_classifier import NaiveBayesSpamClassifier
|
||||
from app.ml.spam_categorizer import categorize_spam, get_category_label
|
||||
from app.models import ContentPost, DetectionConfig, SpamPredictionLog, SpamTrainingSample, User
|
||||
from app.utils.auth import current_user
|
||||
from app.utils.response import fail, ok
|
||||
@@ -77,12 +78,29 @@ def _resolve_recipient(payload: dict, visibility: str, current_user_id: int):
|
||||
return recipient, None
|
||||
|
||||
|
||||
def _predict_and_decide(text: str) -> tuple[dict, float, bool]:
|
||||
def _predict_and_decide(text: str, user_credit: int = 100) -> tuple[dict, float, bool, str, str]:
|
||||
"""根据用户信誉分调整阈值系数。信誉分越高,阈值越高(降低敏感度)"""
|
||||
clf = _ensure_ready()
|
||||
result = clf.predict(text)
|
||||
threshold = float(_get_config().spam_threshold)
|
||||
blocked = float(result["spam_probability"]) >= threshold
|
||||
return result, threshold, blocked
|
||||
base_threshold = float(_get_config().spam_threshold)
|
||||
|
||||
# 信誉分影响阈值系数:credit 0-200,默认100
|
||||
# credit > 100:阈值提高(降低敏感度,减少误判)
|
||||
# credit < 100:阈值降低(提高敏感度,加强拦截)
|
||||
# 系数范围:0.85 - 1.15
|
||||
credit_factor = 1.0 + (user_credit - 100) * 0.0015 # 每10分变化1.5%
|
||||
credit_factor = max(0.85, min(1.15, credit_factor))
|
||||
|
||||
adjusted_threshold = base_threshold * credit_factor
|
||||
blocked = float(result["spam_probability"]) >= adjusted_threshold
|
||||
|
||||
# 分类标签
|
||||
category = ""
|
||||
category_label = ""
|
||||
if blocked:
|
||||
category, category_label = categorize_spam(result["text"])
|
||||
|
||||
return result, adjusted_threshold, blocked, category, category_label
|
||||
|
||||
|
||||
@content_bp.post("/publish")
|
||||
@@ -103,7 +121,7 @@ def publish_text():
|
||||
if err:
|
||||
return fail(err, 400)
|
||||
|
||||
result, threshold, blocked = _predict_and_decide(text)
|
||||
result, threshold, blocked, category, category_label = _predict_and_decide(text, user.credit_score or 100)
|
||||
|
||||
post = ContentPost(
|
||||
user_id=user.id,
|
||||
@@ -112,6 +130,7 @@ def publish_text():
|
||||
visibility=visibility,
|
||||
status="blocked" if blocked else "published",
|
||||
prediction=result["prediction"],
|
||||
category=category,
|
||||
spam_probability=result["spam_probability"],
|
||||
ham_probability=result["ham_probability"],
|
||||
confidence=result["confidence"],
|
||||
@@ -125,6 +144,7 @@ def publish_text():
|
||||
user_id=user.id,
|
||||
text=result["text"],
|
||||
prediction=result["prediction"],
|
||||
category=category,
|
||||
spam_probability=result["spam_probability"],
|
||||
ham_probability=result["ham_probability"],
|
||||
confidence=result["confidence"],
|
||||
@@ -134,16 +154,27 @@ def publish_text():
|
||||
|
||||
db.session.add(post)
|
||||
db.session.add(detect_log)
|
||||
|
||||
# 发布成功(未被拦截),小幅增加信誉分;被拦截则小幅减少
|
||||
if not blocked:
|
||||
user.credit_score = min(200, (user.credit_score or 100) + 1)
|
||||
else:
|
||||
user.credit_score = max(0, (user.credit_score or 100) - 2)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
feedback = "发布成功" if not blocked else "疑似垃圾信息,系统已拦截,可提交申诉"
|
||||
feedback = "发布成功" if not blocked else f"{category_label or '疑似垃圾信息'},系统已拦截,可提交申诉"
|
||||
return ok(
|
||||
{
|
||||
"publish_allowed": not blocked,
|
||||
"action": "published" if not blocked else "blocked",
|
||||
"feedback": feedback,
|
||||
"post": _serialize_post(post),
|
||||
"detect": result,
|
||||
"detect": {
|
||||
**result,
|
||||
"category": category,
|
||||
"category_label": category_label,
|
||||
},
|
||||
},
|
||||
feedback,
|
||||
)
|
||||
@@ -171,13 +202,14 @@ def edit_post(post_id: int):
|
||||
if err:
|
||||
return fail(err, 400)
|
||||
|
||||
result, threshold, blocked = _predict_and_decide(text)
|
||||
result, threshold, blocked, category, category_label = _predict_and_decide(text, user.credit_score or 100)
|
||||
|
||||
post.text = result["text"]
|
||||
post.visibility = visibility
|
||||
post.recipient_user_id = recipient.id if recipient else None
|
||||
post.status = "blocked" if blocked else "published"
|
||||
post.prediction = result["prediction"]
|
||||
post.category = category
|
||||
post.spam_probability = result["spam_probability"]
|
||||
post.ham_probability = result["ham_probability"]
|
||||
post.confidence = result["confidence"]
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from flask import Blueprint, current_app, request
|
||||
from flask import Blueprint, current_app, request, send_file, after_this_request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from app.extensions import db
|
||||
from app.ml.naive_bayes_classifier import NaiveBayesSpamClassifier
|
||||
from app.ml.spam_categorizer import categorize_spam, get_category_label
|
||||
from app.models import DetectionConfig, SpamPredictionLog, SpamTrainingSample
|
||||
from app.utils.auth import admin_required, current_user
|
||||
from app.utils.response import fail, ok
|
||||
@@ -32,6 +33,15 @@ def _threshold() -> float:
|
||||
return float(row.spam_threshold) if row else 0.75
|
||||
|
||||
|
||||
def _adjusted_threshold(user_credit: int = 100) -> float:
|
||||
"""根据用户信誉分调整阈值"""
|
||||
base_threshold = _threshold()
|
||||
# 系数范围:0.85 - 1.15
|
||||
credit_factor = 1.0 + (user_credit - 100) * 0.0015
|
||||
credit_factor = max(0.85, min(1.15, credit_factor))
|
||||
return base_threshold * credit_factor
|
||||
|
||||
|
||||
@spam_bp.post("/predict")
|
||||
@jwt_required()
|
||||
def predict_one():
|
||||
@@ -46,13 +56,20 @@ def predict_one():
|
||||
|
||||
clf = _ensure_ready()
|
||||
result = clf.predict(text)
|
||||
threshold = _threshold()
|
||||
threshold = _adjusted_threshold(user.credit_score or 100)
|
||||
blocked = float(result["spam_probability"]) >= threshold
|
||||
|
||||
# 分类标签:仅在判定为垃圾时进行细分
|
||||
category = ""
|
||||
category_label = ""
|
||||
if blocked:
|
||||
category, category_label = categorize_spam(result["text"])
|
||||
|
||||
row = SpamPredictionLog(
|
||||
user_id=user.id,
|
||||
text=result["text"],
|
||||
prediction=result["prediction"],
|
||||
category=category,
|
||||
spam_probability=result["spam_probability"],
|
||||
ham_probability=result["ham_probability"],
|
||||
confidence=result["confidence"],
|
||||
@@ -62,7 +79,14 @@ def predict_one():
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
|
||||
return ok({**result, "log_id": row.id, "threshold": threshold, "blocked_by_threshold": blocked}, "识别成功")
|
||||
return ok({
|
||||
**result,
|
||||
"log_id": row.id,
|
||||
"threshold": threshold,
|
||||
"blocked_by_threshold": blocked,
|
||||
"category": category,
|
||||
"category_label": category_label,
|
||||
}, "识别成功")
|
||||
|
||||
|
||||
@spam_bp.post("/predict/batch")
|
||||
@@ -82,19 +106,30 @@ def predict_batch():
|
||||
clf = _ensure_ready()
|
||||
rows = []
|
||||
results = []
|
||||
threshold = _threshold()
|
||||
threshold = _adjusted_threshold(user.credit_score or 100)
|
||||
|
||||
for text in items:
|
||||
content = (text or "").strip()
|
||||
if len(content) < 2:
|
||||
continue
|
||||
result = clf.predict(content)
|
||||
result["blocked_by_threshold"] = float(result["spam_probability"]) >= threshold
|
||||
blocked = float(result["spam_probability"]) >= threshold
|
||||
result["blocked_by_threshold"] = blocked
|
||||
|
||||
# 分类标签
|
||||
category = ""
|
||||
category_label = ""
|
||||
if blocked:
|
||||
category, category_label = categorize_spam(result["text"])
|
||||
result["category"] = category
|
||||
result["category_label"] = category_label
|
||||
|
||||
rows.append(
|
||||
SpamPredictionLog(
|
||||
user_id=user.id,
|
||||
text=result["text"],
|
||||
prediction=result["prediction"],
|
||||
category=category,
|
||||
spam_probability=result["spam_probability"],
|
||||
ham_probability=result["ham_probability"],
|
||||
confidence=result["confidence"],
|
||||
@@ -332,3 +367,56 @@ def import_samples():
|
||||
|
||||
db.session.commit()
|
||||
return ok({"created": created, "updated": updated}, "样本导入完成")
|
||||
|
||||
|
||||
@spam_bp.post("/export/xlsx")
|
||||
@jwt_required()
|
||||
def export_xlsx():
|
||||
user = current_user()
|
||||
if not user:
|
||||
return fail("用户不存在", 404)
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
items = payload.get("items") or []
|
||||
if not isinstance(items, list) or not items:
|
||||
return fail("items 必须是非空数组", 400)
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import pandas as pd
|
||||
|
||||
rows = []
|
||||
for item in items:
|
||||
tokens = item.get("reason_tokens") or []
|
||||
token_str = "; ".join(t.get("token", "") for t in tokens) if isinstance(tokens, list) else ""
|
||||
prediction_text = "垃圾信息" if item.get("prediction") == "spam" else "正常信息"
|
||||
|
||||
rows.append({
|
||||
"文本": item.get("text", ""),
|
||||
"判定结果": prediction_text,
|
||||
"分类标签": item.get("category_label", ""),
|
||||
"置信度": f"{float(item.get('confidence', 0) or 0) * 100:.2f}%",
|
||||
"垃圾概率": f"{float(item.get('spam_probability', 0) or 0) * 100:.2f}%",
|
||||
"正常概率": f"{float(item.get('ham_probability', 0) or 0) * 100:.2f}%",
|
||||
"风险关键词": token_str,
|
||||
})
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False)
|
||||
tmp.close()
|
||||
df.to_excel(tmp.name, index=False, engine="openpyxl")
|
||||
|
||||
@after_this_request
|
||||
def cleanup(response):
|
||||
try:
|
||||
os.unlink(tmp.name)
|
||||
except Exception:
|
||||
pass
|
||||
return response
|
||||
|
||||
return send_file(
|
||||
tmp.name,
|
||||
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
as_attachment=True,
|
||||
download_name="batch_detect.xlsx",
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pymysql
|
||||
from pymysql import MySQLError
|
||||
|
||||
from app import create_app
|
||||
from app.extensions import db
|
||||
@@ -12,6 +13,7 @@ from app.models import DetectionConfig, SpamTrainingSample, User
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
MYSQL_CONFIG_PATH = BASE_DIR / "mysqlconfig.json"
|
||||
SPAM_SEED_PATH = BASE_DIR / "seed" / "spam_samples_seed.json"
|
||||
SQL_MIGRATIONS_DIR = BASE_DIR / "sql"
|
||||
|
||||
|
||||
def load_mysql_cfg() -> dict:
|
||||
@@ -39,6 +41,46 @@ def create_database(mysql_cfg: dict) -> None:
|
||||
conn.close()
|
||||
|
||||
|
||||
def run_sql_migrations(mysql_cfg: dict) -> list[str]:
|
||||
"""执行 sql 目录下的迁移脚本"""
|
||||
if not SQL_MIGRATIONS_DIR.exists():
|
||||
return []
|
||||
|
||||
conn = pymysql.connect(
|
||||
host=mysql_cfg.get("host", "127.0.0.1"),
|
||||
port=int(mysql_cfg.get("port", 3306)),
|
||||
user=mysql_cfg["user"],
|
||||
password=mysql_cfg["password"],
|
||||
database=mysql_cfg["database"],
|
||||
charset=mysql_cfg.get("charset", "utf8mb4"),
|
||||
autocommit=True,
|
||||
)
|
||||
|
||||
executed = []
|
||||
try:
|
||||
with conn.cursor() as cursor:
|
||||
sql_files = sorted(SQL_MIGRATIONS_DIR.glob("*.sql"))
|
||||
for sql_file in sql_files:
|
||||
sql_content = sql_file.read_text(encoding="utf-8")
|
||||
statements = [s.strip() for s in sql_content.split(";") if s.strip() and not s.strip().startswith("--")]
|
||||
for stmt in statements:
|
||||
if stmt:
|
||||
try:
|
||||
cursor.execute(stmt)
|
||||
except MySQLError as e:
|
||||
if "1060" in str(e):
|
||||
pass
|
||||
elif "1061" in str(e):
|
||||
pass
|
||||
else:
|
||||
print(f"SQL 警告 ({sql_file.name}): {e}")
|
||||
executed.append(sql_file.name)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return executed
|
||||
|
||||
|
||||
def ensure_seed_file() -> None:
|
||||
if SPAM_SEED_PATH.exists():
|
||||
return
|
||||
@@ -135,6 +177,7 @@ def main():
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
migrations = run_sql_migrations(mysql_cfg)
|
||||
created, updated = seed_samples()
|
||||
threshold = ensure_detection_config(mysql_cfg)
|
||||
admin_msg = init_admin(mysql_cfg)
|
||||
@@ -146,6 +189,8 @@ def main():
|
||||
print(f"- 初始阈值: {threshold}")
|
||||
print(f"- {admin_msg}")
|
||||
print(f"- 模型版本: {model_meta.get('version')}")
|
||||
if migrations:
|
||||
print(f"- SQL迁移: {', '.join(migrations)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"host": "192.168.2.183",
|
||||
"port": 3308,
|
||||
"host": "127.0.0.1",
|
||||
"port": 3306,
|
||||
"user": "root",
|
||||
"password": "rootroot",
|
||||
"database": "spam_nb_miniapp",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": 3306,
|
||||
"host": "192.168.2.183",
|
||||
"port": 3308,
|
||||
"user": "root",
|
||||
"password": "pk123123",
|
||||
"password": "rootroot",
|
||||
"database": "spam_nb_miniapp",
|
||||
"charset": "utf8mb4",
|
||||
"admin_init": {
|
||||
29
backend/project.config.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"appid": "wx42ba28b8e545ba14",
|
||||
"compileType": "miniprogram",
|
||||
"libVersion": "3.15.2",
|
||||
"packOptions": {
|
||||
"ignore": [],
|
||||
"include": []
|
||||
},
|
||||
"setting": {
|
||||
"coverView": true,
|
||||
"es6": true,
|
||||
"postcss": true,
|
||||
"minified": true,
|
||||
"enhance": true,
|
||||
"showShadowRootInWxmlPanel": true,
|
||||
"packNpmRelationList": [],
|
||||
"babelSetting": {
|
||||
"ignore": [],
|
||||
"disablePlugins": [],
|
||||
"outputPath": ""
|
||||
},
|
||||
"condition": false
|
||||
},
|
||||
"condition": {},
|
||||
"editorSetting": {
|
||||
"tabIndent": "tab",
|
||||
"tabSize": 4
|
||||
}
|
||||
}
|
||||
7
backend/project.private.config.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档:https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html",
|
||||
"projectname": "backend",
|
||||
"setting": {
|
||||
"compileHotReLoad": true
|
||||
}
|
||||
}
|
||||
@@ -11,3 +11,4 @@ joblib==1.4.2
|
||||
python-dotenv==1.0.1
|
||||
requests==2.32.3
|
||||
Werkzeug==3.1.3
|
||||
openpyxl==3.1.5
|
||||
|
||||
234
backend/spam_nb_miniapp.sql
Normal file
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
Navicat Premium Dump SQL
|
||||
|
||||
Source Server : 8.0.12_mysql
|
||||
Source Server Type : MySQL
|
||||
Source Server Version : 80012 (8.0.12)
|
||||
Source Host : 192.168.2.183:3308
|
||||
Source Schema : spam_nb_miniapp
|
||||
|
||||
Target Server Type : MySQL
|
||||
Target Server Version : 80012 (8.0.12)
|
||||
File Encoding : 65001
|
||||
|
||||
Date: 22/04/2026 23:17:44
|
||||
*/
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for content_posts
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `content_posts`;
|
||||
CREATE TABLE `content_posts` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(11) NOT NULL,
|
||||
`recipient_user_id` int(11) NULL DEFAULT NULL,
|
||||
`text` text CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||
`visibility` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||
`status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||
`prediction` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||
`category` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT '',
|
||||
`spam_probability` float NOT NULL,
|
||||
`ham_probability` float NOT NULL,
|
||||
`confidence` float NOT NULL,
|
||||
`threshold` float NOT NULL,
|
||||
`reason_tokens` json NULL,
|
||||
`model_version` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
|
||||
`manual_review_status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||
`manual_review_by` int(11) NULL DEFAULT NULL,
|
||||
`manual_review_note` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
|
||||
`manual_review_at` datetime NULL DEFAULT NULL,
|
||||
`appeal_status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||
`appeal_reason_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT '' COMMENT '快捷申诉理由类型',
|
||||
`appeal_reason` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
|
||||
`appeal_evidence_urls` json NULL COMMENT '证据图片URL列表',
|
||||
`appeal_admin_note` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
|
||||
`appeal_submitted_at` datetime NULL DEFAULT NULL,
|
||||
`appeal_processed_at` datetime NULL DEFAULT NULL,
|
||||
`appeal_processed_by` int(11) NULL DEFAULT NULL,
|
||||
`created_at` datetime NULL DEFAULT NULL,
|
||||
`updated_at` datetime NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
INDEX `manual_review_by`(`manual_review_by` ASC) USING BTREE,
|
||||
INDEX `appeal_processed_by`(`appeal_processed_by` ASC) USING BTREE,
|
||||
INDEX `ix_content_posts_created_at`(`created_at` ASC) USING BTREE,
|
||||
INDEX `ix_content_posts_user_id`(`user_id` ASC) USING BTREE,
|
||||
INDEX `ix_content_posts_recipient_user_id`(`recipient_user_id` ASC) USING BTREE,
|
||||
CONSTRAINT `content_posts_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT `content_posts_ibfk_2` FOREIGN KEY (`recipient_user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT `content_posts_ibfk_3` FOREIGN KEY (`manual_review_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||
CONSTRAINT `content_posts_ibfk_4` FOREIGN KEY (`appeal_processed_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin ROW_FORMAT = Dynamic;
|
||||
|
||||
-- ----------------------------
|
||||
-- Records of content_posts
|
||||
-- ----------------------------
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for detection_configs
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `detection_configs`;
|
||||
CREATE TABLE `detection_configs` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`spam_threshold` float NOT NULL,
|
||||
`updated_by` int(11) NULL DEFAULT NULL,
|
||||
`updated_at` datetime NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
INDEX `updated_by`(`updated_by` ASC) USING BTREE,
|
||||
CONSTRAINT `detection_configs_ibfk_1` FOREIGN KEY (`updated_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin ROW_FORMAT = Dynamic;
|
||||
|
||||
-- ----------------------------
|
||||
-- Records of detection_configs
|
||||
-- ----------------------------
|
||||
INSERT INTO `detection_configs` VALUES (1, 0.75, NULL, '2026-04-21 14:41:44');
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for spam_prediction_logs
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `spam_prediction_logs`;
|
||||
CREATE TABLE `spam_prediction_logs` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(11) NOT NULL,
|
||||
`text` text CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||
`prediction` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||
`category` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT '',
|
||||
`spam_probability` float NOT NULL,
|
||||
`ham_probability` float NOT NULL,
|
||||
`confidence` float NOT NULL,
|
||||
`reason_tokens` json NULL,
|
||||
`model_version` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
|
||||
`created_at` datetime NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
INDEX `ix_spam_prediction_logs_created_at`(`created_at` ASC) USING BTREE,
|
||||
INDEX `ix_spam_prediction_logs_user_id`(`user_id` ASC) USING BTREE,
|
||||
CONSTRAINT `spam_prediction_logs_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 8 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin ROW_FORMAT = Dynamic;
|
||||
|
||||
-- ----------------------------
|
||||
-- Records of spam_prediction_logs
|
||||
-- ----------------------------
|
||||
INSERT INTO `spam_prediction_logs` VALUES (1, 1, '大家好,今晚 8 点社区线上读书会,欢迎参加。', 'ham', '', 0.1688, 0.8312, 0.8312, '[\"今\", \"好\", \"会\", \"晚\", \"好,\"]', 'nb-6ec632453424290f', '2026-04-21 14:48:42');
|
||||
INSERT INTO `spam_prediction_logs` VALUES (2, 1, '恭喜中奖领取大额现金,点击链接立即到账。', 'spam', '', 0.9679, 0.0321, 0.9679, '[{\"token\": \"立\", \"weight\": 1.3951}, {\"token\": \"领\", \"weight\": 1.3879}, {\"token\": \"即\", \"weight\": 1.3423}, {\"token\": \"取\", \"weight\": 1.2808}, {\"token\": \"立即\", \"weight\": 1.2778}]', 'nb-6ec632453424290f', '2026-04-21 14:54:30');
|
||||
INSERT INTO `spam_prediction_logs` VALUES (3, 1, '哈哈哈哈哈', 'ham', '', 0.4923, 0.5077, 0.5077, '[]', 'nb-6ec632453424290f', '2026-04-21 15:30:44');
|
||||
INSERT INTO `spam_prediction_logs` VALUES (4, 1, '季姬击鸡记', 'spam', '', 0.6253, 0.3747, 0.6253, '[{\"token\": \"击\", \"weight\": 1.2081}, {\"token\": \"记\", \"weight\": 0.397}, {\"token\": \"季\", \"weight\": -0.3778}]', 'nb-6ec632453424290f', '2026-04-21 15:30:44');
|
||||
INSERT INTO `spam_prediction_logs` VALUES (5, 1, '鸡鸡棒', 'ham', '', 0.4923, 0.5077, 0.5077, '[]', 'nb-6ec632453424290f', '2026-04-21 15:30:44');
|
||||
INSERT INTO `spam_prediction_logs` VALUES (6, 1, '本周活动报名已开放,请在群里接龙。', 'ham', '', 0.3055, 0.6945, 0.6945, '[{\"token\": \"已\", \"weight\": -1.1662}, {\"token\": \"周\", \"weight\": -1.0385}, {\"token\": \"报\", \"weight\": -0.9614}, {\"token\": \"本\", \"weight\": -0.945}, {\"token\": \"动\", \"weight\": 0.8401}]', 'nb-6ec632453424290f', '2026-04-22 13:17:08');
|
||||
INSERT INTO `spam_prediction_logs` VALUES (7, 1, '高薪兼职日结,扫码进群立刻赚钱。', 'spam', 'harassment', 0.8926, 0.1074, 0.8926, '[{\"token\": \"立\", \"weight\": 1.3951}, {\"token\": \"赚\", \"weight\": 0.6924}, {\"token\": \"进群\", \"weight\": 0.6553}, {\"token\": \"扫\", \"weight\": 0.6547}, {\"token\": \"扫码\", \"weight\": 0.6547}]', 'nb-6ec632453424290f', '2026-04-22 13:17:22');
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for spam_training_samples
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `spam_training_samples`;
|
||||
CREATE TABLE `spam_training_samples` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`text` text CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||
`label` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||
`source` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
|
||||
`created_by` int(11) NULL DEFAULT NULL,
|
||||
`is_active` tinyint(1) NULL DEFAULT NULL,
|
||||
`created_at` datetime NULL DEFAULT NULL,
|
||||
`updated_at` datetime NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
INDEX `ix_spam_training_samples_created_by`(`created_by` ASC) USING BTREE,
|
||||
INDEX `ix_spam_training_samples_label`(`label` ASC) USING BTREE,
|
||||
CONSTRAINT `spam_training_samples_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 66 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin ROW_FORMAT = Dynamic;
|
||||
|
||||
-- ----------------------------
|
||||
-- Records of spam_training_samples
|
||||
-- ----------------------------
|
||||
INSERT INTO `spam_training_samples` VALUES (1, '尊敬的用户,您已获赠100元话费,点击链接立即到账', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (2, '本周五下午两点进行季度复盘,请准时参加', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (3, '最后3个名额,免费领取手机一台,回复1立即领取', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (4, '您好,合同已发送到邮箱,请查收并反馈修改意见', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (5, '你的快递因地址异常被退回,点击网址重新填写', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (6, '明天出差高铁票已订好,车次信息已同步到群里', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (7, '恭喜你成为平台幸运粉丝,马上领现金红包', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (8, '研发环境今晚22点维护,预计30分钟恢复', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (9, '内部渠道兼职,日结500,添加微信了解详情', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (10, '周报模板已更新,请使用新模板提交', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (11, '低价出售苹果手机,全新未拆封,先到先得', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (12, '客户反馈文档在共享盘,路径已发你私聊', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (13, '紧急通知:你的银行卡存在风险,请立即验证', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (14, '今天的日报我已补充到项目看板', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (15, '官方补贴发放中,输入验证码即可领取', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (16, '下午四点产品评审,麻烦准备交互稿', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (17, '无需面试,高薪在家办公,扫码进群', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (18, '发票已开具完成,纸质件今天寄出', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (19, '您的贷款已通过,点击查看额度', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (20, '会议纪要我整理好了,已上传飞书文档', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (21, '双十一秒杀提前抢,点此领隐藏优惠券', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (22, '新同事今天入职,请大家中午一起欢迎', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (23, '你有一笔退款待确认,马上处理避免失效', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (24, '设计稿第二版我已经按你建议调整完了', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (25, '点击领取年度会员,原价699现价9.9', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (26, '明天早会由我来同步上线计划', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (27, '官方通知:账号异常将被冻结,请立即解封', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (28, '请把测试环境数据库备份到指定目录', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (29, '刷单项目火热招募,宝妈学生都能做', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (30, '你发的需求我已经拆分成开发任务', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (31, '中奖通知:你获得平板电脑一台,限时领取', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (32, '客户明天下午三点会远程验收新功能', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (33, '陌生链接请勿泄露验证码,谨防被骗', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (34, '马上关注公众号领取无门槛现金券', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (35, '今天的构建失败是依赖冲突,我在修复', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (36, '免费领取课程资料,扫码后自动发放', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (37, '请确认一下下周排期是否需要调整', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (38, '特惠机票内部价,回复姓名立刻锁座', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (39, '我已经把版本回滚流程补充到Wiki', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (40, '贷款秒批到账,额度最高20万', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (41, '合同法务意见已返回,请你二次确认', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (42, '限时返现活动,点击进入马上到账', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (43, '这个bug我复现到了,定位在缓存层', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (44, '你有新的违章信息,点开链接立即处理', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (45, '早上好,今天先做性能压测再发版', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (46, '邀请码最后1小时有效,错过不再补发', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (47, '中午12点在会议室A开需求评审会', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (48, '苹果14只要1999,货到付款保真', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (49, '供应商报价单已更新到共享文件夹', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (50, '想赚外快吗?加我秒赚零花钱', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (51, '今天下午我去客户现场,晚些回公司', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (52, '官方补贴计划启动,名额有限速来登记', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (53, '测试报告已发你邮箱,包含复现步骤', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (54, '欠费停机提醒,立即充值恢复使用', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (55, '这个接口我加了幂等,避免重复提交', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (56, '点击抽取盲盒大奖,百分百中奖', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (57, '版本发布说明我已经整理成公告', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (58, '独家内部消息,股票必涨,速进群', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (59, '周一上午需要和财务对齐预算数据', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (60, '紧急!你的社保账户异常,立即核验', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (61, '我下午会把接口文档补全到OpenAPI', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (62, '游戏皮肤免费领,输入手机号立刻到账', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (63, '晚上的培训链接我刚刚发到部门群', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (64, '预约体检补贴开通,点击立即申请', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
INSERT INTO `spam_training_samples` VALUES (65, '新需求优先级调高了,请先排进本周', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for users
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `users`;
|
||||
CREATE TABLE `users` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`username` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||
`password_hash` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||
`nickname` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||
`company` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
|
||||
`title` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
|
||||
`phone` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
|
||||
`is_admin` tinyint(1) NULL DEFAULT NULL,
|
||||
`credit_score` int(11) NULL DEFAULT 100 COMMENT '用户信誉分(0-200,默认100)',
|
||||
`created_at` datetime NULL DEFAULT NULL,
|
||||
`updated_at` datetime NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
UNIQUE INDEX `ix_users_username`(`username` ASC) USING BTREE
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin ROW_FORMAT = Dynamic;
|
||||
|
||||
-- ----------------------------
|
||||
-- Records of users
|
||||
-- ----------------------------
|
||||
INSERT INTO `users` VALUES (1, 'admin', 'scrypt:32768:8:1$7CMYgcG40rYR9VdJ$f65c7ea91736f37c5a2522ac8c0f3fe18dab047dba8b6bf88c789d3da97bd91115225620e2e3eb93ed684f8720bfa09e30cd09599ba708670ddb2738801030fe', '系统管理员', '', '', '', 1, 99, '2026-04-21 14:41:44', '2026-04-22 13:17:22');
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
5
backend/sql/add_category_field.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- 添加 category 字段到 spam_prediction_logs 表
|
||||
ALTER TABLE `spam_prediction_logs` ADD COLUMN `category` VARCHAR(32) DEFAULT '' AFTER `prediction`;
|
||||
|
||||
-- 添加 category 字段到 content_posts 表
|
||||
ALTER TABLE `content_posts` ADD COLUMN `category` VARCHAR(32) DEFAULT '' AFTER `prediction`;
|
||||
10
backend/sql/update_credit_score.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- 用户信誉分字段
|
||||
-- 执行方式:mysql -u root -p database_name < update_credit_score.sql
|
||||
|
||||
-- 新增用户信誉分字段(范围 0-200,默认 100)
|
||||
ALTER TABLE users
|
||||
ADD COLUMN credit_score INT DEFAULT 100 COMMENT '用户信誉分(0-200,默认100)'
|
||||
AFTER is_admin;
|
||||
|
||||
-- 更新索引(可选,便于按信誉分排序查询)
|
||||
-- ALTER TABLE users ADD INDEX idx_credit_score (credit_score);
|
||||
@@ -1,25 +1,52 @@
|
||||
{
|
||||
"pages": [
|
||||
"pages/login/index",
|
||||
"pages/register/index",
|
||||
"pages/home/index",
|
||||
"pages/detect/index",
|
||||
"pages/batch/index",
|
||||
"pages/history/index",
|
||||
"pages/inbox/index",
|
||||
"pages/profile/index",
|
||||
"pages/admin-dashboard/index",
|
||||
"pages/admin-review/index",
|
||||
"pages/admin-users/index",
|
||||
"pages/admin-samples/index"
|
||||
"pages/login/index",
|
||||
"pages/register/index",
|
||||
"pages/batch/index"
|
||||
],
|
||||
"window": {
|
||||
"navigationBarTitleText": "内容风控平台",
|
||||
"navigationBarBackgroundColor": "#0A1A2D",
|
||||
"navigationBarTextStyle": "white",
|
||||
"backgroundTextStyle": "light",
|
||||
"backgroundColor": "#EEF3F8"
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundTextStyle": "dark",
|
||||
"backgroundColor": "#f5f5f7"
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#86868b",
|
||||
"selectedColor": "#0066cc",
|
||||
"backgroundColor": "#ffffff",
|
||||
"borderStyle": "white",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/home/index",
|
||||
"text": "发布",
|
||||
"iconPath": "assets/icons/publish.png",
|
||||
"selectedIconPath": "assets/icons/publish-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/history/index",
|
||||
"text": "历史",
|
||||
"iconPath": "assets/icons/history.png",
|
||||
"selectedIconPath": "assets/icons/history-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/inbox/index",
|
||||
"text": "私信",
|
||||
"iconPath": "assets/icons/inbox.png",
|
||||
"selectedIconPath": "assets/icons/inbox-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/profile/index",
|
||||
"text": "我的",
|
||||
"iconPath": "assets/icons/profile.png",
|
||||
"selectedIconPath": "assets/icons/profile-active.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
"style": "v2",
|
||||
"sitemapLocation": "sitemap.json"
|
||||
}
|
||||
}
|
||||
BIN
miniprogram/assets/icons/history-active.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
miniprogram/assets/icons/history.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
miniprogram/assets/icons/home-active.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
miniprogram/assets/icons/home.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
4
miniprogram/assets/icons/home.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<!-- Home Icon (Normal) -->
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 40V20L24 10L36 20V40H28V28H20V40H12Z" stroke="#86868b" stroke-width="2" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 231 B |
BIN
miniprogram/assets/icons/inbox-active.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
miniprogram/assets/icons/inbox.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
miniprogram/assets/icons/profile-active.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
miniprogram/assets/icons/profile.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
miniprogram/assets/icons/publish-active.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
miniprogram/assets/icons/publish.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
@@ -7,7 +7,9 @@ Page({
|
||||
kpis: [],
|
||||
bars: [],
|
||||
sourceDist: [],
|
||||
topKeywords: []
|
||||
topKeywords: [],
|
||||
report: null,
|
||||
reportLoading: false
|
||||
},
|
||||
|
||||
formatPercent(value, digits = 2) {
|
||||
@@ -67,5 +69,74 @@ Page({
|
||||
this.setData({ loading: false })
|
||||
if (fromPullDown) wx.stopPullDownRefresh()
|
||||
}
|
||||
},
|
||||
|
||||
async generateReport() {
|
||||
this.setData({ reportLoading: true })
|
||||
try {
|
||||
const report = await request({ url: '/admin/stats/report' })
|
||||
|
||||
// 处理趋势数据,计算进度条宽度
|
||||
const spamTrend = (report.spam_trend || []).map((item) => {
|
||||
const maxBlocked = Math.max(...(report.spam_trend || []).map((r) => r.blocked || 0), 1)
|
||||
return {
|
||||
...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.setData({
|
||||
report: {
|
||||
...report,
|
||||
spam_trend: spamTrend,
|
||||
misjudge_trend: misjudgeTrend
|
||||
}
|
||||
})
|
||||
|
||||
wx.showToast({ title: '报告已生成', icon: 'success' })
|
||||
} finally {
|
||||
this.setData({ reportLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
closeReport() {
|
||||
this.setData({ report: null })
|
||||
},
|
||||
|
||||
copyReportText() {
|
||||
const report = this.data.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')
|
||||
]
|
||||
|
||||
wx.setClipboardData({
|
||||
data: lines.join('\n'),
|
||||
success: () => wx.showToast({ title: '报告已复制', icon: 'success' })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -54,4 +54,83 @@
|
||||
<text class="tag" wx:for="{{topKeywords}}" wx:key="token">{{item.token}} × {{item.count}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 生成报告按钮 -->
|
||||
<view class="card fade-up fade-up-delay-3">
|
||||
<button class="btn btn-accent" loading="{{reportLoading}}" bindtap="generateReport">生成运营报告</button>
|
||||
</view>
|
||||
|
||||
<!-- 报告展示区域 -->
|
||||
<view class="report-modal" wx:if="{{report}}">
|
||||
<view class="report-header">
|
||||
<view class="report-title">垃圾信息运营报告</view>
|
||||
<view class="report-period">{{report.period}}</view>
|
||||
</view>
|
||||
|
||||
<view class="report-close" bindtap="closeReport">×</view>
|
||||
|
||||
<view class="report-section">
|
||||
<view class="report-section-title">汇总统计</view>
|
||||
<view class="grid-3">
|
||||
<view class="report-kpi">
|
||||
<view class="report-kpi-value">{{report.summary.total_posts}}</view>
|
||||
<view class="report-kpi-label">总发布量</view>
|
||||
</view>
|
||||
<view class="report-kpi">
|
||||
<view class="report-kpi-value">{{report.summary.total_blocked}}</view>
|
||||
<view class="report-kpi-label">拦截量</view>
|
||||
</view>
|
||||
<view class="report-kpi">
|
||||
<view class="report-kpi-value">{{report.summary.total_published}}</view>
|
||||
<view class="report-kpi-label">正常发布</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">拦截率</text>
|
||||
<text class="value">{{report.summary.blocked_ratio * 100}}%</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">平均误判率</text>
|
||||
<text class="value">{{report.summary.avg_misjudge_rate_text}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="report-section">
|
||||
<view class="report-section-title">垃圾信息数量变化(近14天)</view>
|
||||
<view class="report-trend-item" wx:for="{{report.spam_trend}}" wx:key="date">
|
||||
<view class="row">
|
||||
<text class="label">{{item.label}}</text>
|
||||
<text class="value">拦截 {{item.blocked}} / 发布 {{item.published}}</text>
|
||||
</view>
|
||||
<view class="progress-track">
|
||||
<view class="progress-fill" style="width: {{item.blocked_percent}};"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="report-section">
|
||||
<view class="report-section-title">高频风险词 Top10</view>
|
||||
<view class="chip-group">
|
||||
<text class="tag tag-danger" wx:for="{{report.topKeywords}}" wx:for-item="kw" wx:if="{{index < 10}}" wx:key="token">{{kw.token}} × {{kw.count}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="report-section">
|
||||
<view class="report-section-title">误判率趋势(近14天)</view>
|
||||
<view class="report-trend-item" wx:for="{{report.misjudge_trend}}" wx:key="date">
|
||||
<view class="row">
|
||||
<text class="label">{{item.label}}</text>
|
||||
<text class="value">{{item.misjudge_rate_text}}</text>
|
||||
</view>
|
||||
<view class="progress-track">
|
||||
<view class="progress-fill-safe" style="width: {{item.rate_percent}};"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="btn-row">
|
||||
<button class="btn btn-primary" bindtap="copyReportText">复制报告文本</button>
|
||||
<button class="btn btn-ghost" bindtap="closeReport">关闭</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -35,6 +35,13 @@ const APPEAL_STATUS_TEXT = {
|
||||
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)
|
||||
@@ -105,7 +112,8 @@ Page({
|
||||
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
|
||||
appeal_status_text: APPEAL_STATUS_TEXT[item.appeal_status] || item.appeal_status,
|
||||
category_label: CATEGORY_LABELS[item.category] || ''
|
||||
}))
|
||||
},
|
||||
|
||||
@@ -116,6 +124,7 @@ Page({
|
||||
...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 : `${serverBase}${url}`
|
||||
)
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
<view class="list-item" wx:for="{{intercepts}}" wx:key="id">
|
||||
<view class="item-title">{{item.text}}</view>
|
||||
<view class="item-sub">用户:{{item.nickname || item.username}} · 垃圾概率:{{item.spam_probability_text}}</view>
|
||||
<view class="item-sub" wx:if="{{item.category_label}}">分类标签:<text class="status-spam">{{item.category_label}}</text></view>
|
||||
<view class="item-sub">复核状态:{{item.review_status_text}} · 申诉状态:{{item.appeal_status_text}}</view>
|
||||
<view class="item-sub">发布时间:{{item.created_text}}</view>
|
||||
|
||||
@@ -100,6 +101,7 @@
|
||||
<view class="list-item" wx:for="{{appeals}}" wx:key="id">
|
||||
<view class="item-title">{{item.text}}</view>
|
||||
<view class="item-sub">申诉人:{{item.nickname || item.username}} · 当前状态:{{item.appeal_status_text}}</view>
|
||||
<view class="item-sub" wx:if="{{item.category_label}}">分类标签:<text class="status-spam">{{item.category_label}}</text></view>
|
||||
<view class="item-sub">申诉理由类型:{{item.appeal_reason_type || '未选择'}}</view>
|
||||
<view class="item-sub">申诉理由:{{item.appeal_reason || '未填写'}}</view>
|
||||
<view class="item-sub">时间:{{item.created_text}}</view>
|
||||
|
||||
@@ -12,7 +12,8 @@ Page({
|
||||
title: '',
|
||||
phone: '',
|
||||
is_admin: false,
|
||||
password: ''
|
||||
password: '',
|
||||
credit_score: 100
|
||||
},
|
||||
importText: '[{"username":"operator01","password":"123456","nickname":"运营同学","company":"示例科技公司"}]'
|
||||
},
|
||||
@@ -60,7 +61,8 @@ Page({
|
||||
title: row.title || '',
|
||||
phone: row.phone || '',
|
||||
is_admin: !!row.is_admin,
|
||||
password: ''
|
||||
password: '',
|
||||
credit_score: row.credit_score || 100
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
@@ -27,6 +27,13 @@
|
||||
<view class="list-item" wx:for="{{users}}" wx:key="id">
|
||||
<view class="item-title">{{item.nickname}}({{item.username}})</view>
|
||||
<view class="item-sub">{{item.company || '未填写公司'}} · {{item.title || '未填写岗位'}} · {{item.is_admin ? '管理员' : '普通用户'}}</view>
|
||||
<view class="row">
|
||||
<text class="label">信誉分</text>
|
||||
<view class="credit-score-bar">
|
||||
<view class="credit-fill" style="width: {{(item.credit_score || 100) / 2}}%;"></view>
|
||||
<text class="credit-value">{{item.credit_score || 100}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{editUserId === item.id}}">
|
||||
<input class="input" placeholder="昵称" value="{{editForm.nickname}}" data-field="nickname" bindinput="onEditInput" />
|
||||
@@ -34,6 +41,7 @@
|
||||
<input class="input" placeholder="岗位" value="{{editForm.title}}" data-field="title" bindinput="onEditInput" />
|
||||
<input class="input" placeholder="手机号" value="{{editForm.phone}}" data-field="phone" bindinput="onEditInput" />
|
||||
<input class="input" placeholder="新密码(可选)" password value="{{editForm.password}}" data-field="password" bindinput="onEditInput" />
|
||||
<input class="input" placeholder="信誉分(0-200)" type="number" value="{{editForm.credit_score}}" data-field="credit_score" bindinput="onEditInput" />
|
||||
|
||||
<view class="row">
|
||||
<text class="label">管理员权限</text>
|
||||
|
||||
@@ -3,6 +3,8 @@ const { request } = require('../../utils/request')
|
||||
Page({
|
||||
data: {
|
||||
inputText: '',
|
||||
fileName: '',
|
||||
lineCount: 0,
|
||||
loading: false,
|
||||
summary: null,
|
||||
items: []
|
||||
@@ -24,6 +26,43 @@ Page({
|
||||
.filter((line) => line.length >= 2)
|
||||
},
|
||||
|
||||
chooseFile() {
|
||||
wx.chooseMessageFile({
|
||||
count: 1,
|
||||
type: 'file',
|
||||
extension: ['txt'],
|
||||
success: (res) => {
|
||||
const file = res.tempFiles[0]
|
||||
const fs = wx.getFileSystemManager()
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(file.path, 'utf8')
|
||||
const lines = content
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length >= 2)
|
||||
|
||||
this.setData({
|
||||
inputText: lines.join('\n'),
|
||||
fileName: file.name,
|
||||
lineCount: lines.length
|
||||
})
|
||||
|
||||
wx.showToast({ title: `已读取 ${lines.length} 条文本`, icon: 'success' })
|
||||
} catch (err) {
|
||||
console.error('读取文件失败', err)
|
||||
wx.showToast({ title: '文件读取失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('选择文件失败', err)
|
||||
if (err.errMsg !== 'chooseMessageFile:fail cancel') {
|
||||
wx.showToast({ title: '请选择TXT文件', icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async submit() {
|
||||
if (this.data.loading) return
|
||||
const items = this.parseLines()
|
||||
@@ -79,5 +118,104 @@ Page({
|
||||
showCancel: false,
|
||||
confirmText: '关闭'
|
||||
})
|
||||
},
|
||||
|
||||
generateCSV() {
|
||||
const items = this.data.items
|
||||
if (!items.length) return ''
|
||||
|
||||
const headers = ['文本', '判定结果', '分类标签', '置信度', '垃圾概率', '正常概率', '风险关键词']
|
||||
const rows = 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('; ')
|
||||
// CSV 转义:文本中的逗号和换行需要处理
|
||||
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')
|
||||
},
|
||||
|
||||
exportXLSX() {
|
||||
const items = this.data.items
|
||||
if (!items.length) {
|
||||
wx.showToast({ title: '暂无识别结果可导出', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
wx.showLoading({ title: '生成文件中...' })
|
||||
|
||||
const app = getApp()
|
||||
const token = app.globalData.token || wx.getStorageSync('token') || ''
|
||||
const baseURL = app.globalData.baseURL || 'http://127.0.0.1:5000/api'
|
||||
|
||||
wx.request({
|
||||
url: `${baseURL}/spam/export/xlsx`,
|
||||
method: 'POST',
|
||||
data: { items },
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
responseType: 'arraybuffer',
|
||||
success(res) {
|
||||
wx.hideLoading()
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
wx.showToast({ title: '导出失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')
|
||||
const filename = `batch_detect_${timestamp}.xlsx`
|
||||
const fs = wx.getFileSystemManager()
|
||||
const tempPath = `${wx.env.USER_DATA_PATH}/${filename}`
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tempPath, res.data)
|
||||
wx.openDocument({
|
||||
filePath: tempPath,
|
||||
fileType: 'xlsx',
|
||||
showMenu: true,
|
||||
success: () => {
|
||||
wx.showToast({ title: '导出成功', icon: 'success' })
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('打开文件失败', err)
|
||||
wx.showToast({ title: '打开失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('写入文件失败', err)
|
||||
wx.showToast({ title: '导出失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
fail(err) {
|
||||
wx.hideLoading()
|
||||
console.error('导出请求失败', err)
|
||||
wx.showToast({ title: '导出失败,请检查网络', icon: 'none' })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
copyCSVToClipboard() {
|
||||
const items = this.data.items
|
||||
if (!items.length) {
|
||||
wx.showToast({ title: '暂无识别结果可复制', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const csvContent = this.generateCSV()
|
||||
wx.setClipboardData({
|
||||
data: csvContent,
|
||||
success: () => {
|
||||
wx.showToast({ title: 'CSV内容已复制到剪贴板', icon: 'success' })
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,17 +2,37 @@
|
||||
<view class="hero fade-up">
|
||||
<view class="hero-badge">BATCH SCAN</view>
|
||||
<view class="hero-title">批量文本筛查</view>
|
||||
<view class="hero-sub">每行一条文本,适用于活动文案、客服话术、私信模板的集中检测。</view>
|
||||
<view class="hero-sub">上传TXT文件或手动输入,每行一条文本,系统自动逐行检测。</view>
|
||||
</view>
|
||||
|
||||
<view class="card fade-up fade-up-delay-1">
|
||||
<view class="card-title">批量输入</view>
|
||||
<view class="card-desc">请按“每行一条”粘贴文本内容,系统会自动跳过空行。</view>
|
||||
<view class="card-title">上传文件</view>
|
||||
<view class="card-desc">支持TXT文本文件,每行一条待检测内容。</view>
|
||||
|
||||
<view class="btn-row">
|
||||
<button class="btn btn-primary" bindtap="chooseFile">选择TXT文件</button>
|
||||
</view>
|
||||
|
||||
<view class="field" wx:if="{{fileName}}">
|
||||
<view class="row">
|
||||
<text class="label">已选文件</text>
|
||||
<text class="value">{{fileName}}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">文本条数</text>
|
||||
<text class="value">{{lineCount}} 条</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card fade-up fade-up-delay-1">
|
||||
<view class="card-title">手动输入</view>
|
||||
<view class="card-desc">或直接粘贴文本内容,每行一条。</view>
|
||||
<textarea class="textarea" placeholder="示例: 点击链接领取红包 今天下午三点开会" value="{{inputText}}" bindinput="onInput" />
|
||||
|
||||
<view class="btn-row">
|
||||
<button class="btn btn-ghost" bindtap="fillDemo">填充示例</button>
|
||||
<button class="btn btn-primary" loading="{{loading}}" bindtap="submit">开始识别</button>
|
||||
<button class="btn btn-accent" loading="{{loading}}" bindtap="submit">开始识别</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -56,12 +76,20 @@
|
||||
|
||||
<view class="card fade-up fade-up-delay-3" wx:if="{{items.length}}">
|
||||
<view class="card-title">明细结果</view>
|
||||
<view class="btn-row" style="margin-bottom: 12rpx;">
|
||||
<button class="btn btn-ghost" bindtap="exportXLSX">导出Excel文件</button>
|
||||
<button class="btn btn-ghost" bindtap="copyCSVToClipboard">复制CSV内容</button>
|
||||
</view>
|
||||
<view class="list-item" wx:for="{{items}}" wx:key="index">
|
||||
<view class="item-title">{{item.text}}</view>
|
||||
<view class="row">
|
||||
<text class="label">判定结果</text>
|
||||
<text class="{{item.prediction === 'spam' ? 'status-spam' : 'status-ham'}}">{{item.prediction_text}}</text>
|
||||
</view>
|
||||
<view class="row" wx:if="{{item.category_label}}">
|
||||
<text class="label">分类标签</text>
|
||||
<text class="status-spam">{{item.category_label}}</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">置信度</text>
|
||||
<text class="value">{{item.confidence_text}}</text>
|
||||
@@ -77,4 +105,4 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -46,6 +46,11 @@
|
||||
<text class="{{result.publish_allowed ? 'status-ham' : 'status-spam'}}">{{result.publish_allowed ? '发布成功' : '已拦截,需申诉'}}</text>
|
||||
</view>
|
||||
|
||||
<view class="row" wx:if="{{result.detect.category_label}}">
|
||||
<text class="label">分类标签</text>
|
||||
<text class="status-spam">{{result.detect.category_label}}</text>
|
||||
</view>
|
||||
|
||||
<view class="row">
|
||||
<text class="label">模型判断</text>
|
||||
<text class="value">{{result.detect.prediction_text}}</text>
|
||||
|
||||
@@ -26,6 +26,13 @@ const APPEAL_STATUS_TEXT = {
|
||||
rejected: '已驳回'
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS = {
|
||||
fraud: '疑似诈骗',
|
||||
harassment: '疑似骚扰',
|
||||
advertisement: '疑似广告',
|
||||
spam: '疑似垃圾'
|
||||
}
|
||||
|
||||
const REASON_TYPE_OPTIONS = [
|
||||
{ value: '', label: '请选择申诉理由类型' },
|
||||
{ value: '正常活动文案', label: '正常活动文案' },
|
||||
@@ -86,7 +93,8 @@ Page({
|
||||
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
|
||||
appeal_status_text: APPEAL_STATUS_TEXT[item.appeal_status] || item.appeal_status,
|
||||
category_label: CATEGORY_LABELS[item.category] || ''
|
||||
}))
|
||||
this.setData({ list })
|
||||
} finally {
|
||||
@@ -206,6 +214,19 @@ Page({
|
||||
this.fetchList()
|
||||
},
|
||||
|
||||
showTokenWeight(e) {
|
||||
const token = e.currentTarget.dataset.token
|
||||
const weight = e.currentTarget.dataset.weight
|
||||
const weightNum = Number(weight || 0)
|
||||
const direction = weightNum >= 0 ? '倾向垃圾判定' : '倾向正常判定'
|
||||
wx.showModal({
|
||||
title: '关键词权重',
|
||||
content: `关键词"${token}"\n权重贡献:${weightNum >= 0 ? '+' : ''}${weightNum.toFixed(4)}\n(${direction})`,
|
||||
showCancel: false,
|
||||
confirmText: '关闭'
|
||||
})
|
||||
},
|
||||
|
||||
removeItem(e) {
|
||||
const id = Number(e.currentTarget.dataset.id)
|
||||
wx.showModal({
|
||||
|
||||