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="")
|
title = db.Column(db.String(64), default="")
|
||||||
phone = db.Column(db.String(32), default="")
|
phone = db.Column(db.String(32), default="")
|
||||||
is_admin = db.Column(db.Boolean, default=False)
|
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)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=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,
|
"title": self.title,
|
||||||
"phone": self.phone,
|
"phone": self.phone,
|
||||||
"is_admin": self.is_admin,
|
"is_admin": self.is_admin,
|
||||||
|
"credit_score": self.credit_score,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
"updated_at": self.updated_at.isoformat() if self.updated_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)
|
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
|
||||||
text = db.Column(db.Text, nullable=False)
|
text = db.Column(db.Text, nullable=False)
|
||||||
prediction = db.Column(db.String(16), nullable=False) # spam | ham
|
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)
|
spam_probability = db.Column(db.Float, nullable=False)
|
||||||
ham_probability = db.Column(db.Float, nullable=False)
|
ham_probability = db.Column(db.Float, nullable=False)
|
||||||
confidence = 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,
|
"user_id": self.user_id,
|
||||||
"text": self.text,
|
"text": self.text,
|
||||||
"prediction": self.prediction,
|
"prediction": self.prediction,
|
||||||
|
"category": self.category or "",
|
||||||
"spam_probability": round(float(self.spam_probability), 4),
|
"spam_probability": round(float(self.spam_probability), 4),
|
||||||
"ham_probability": round(float(self.ham_probability), 4),
|
"ham_probability": round(float(self.ham_probability), 4),
|
||||||
"confidence": round(float(self.confidence), 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
|
status = db.Column(db.String(16), nullable=False, default="published") # published | blocked
|
||||||
prediction = db.Column(db.String(16), nullable=False, default="ham")
|
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)
|
spam_probability = db.Column(db.Float, nullable=False, default=0)
|
||||||
ham_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)
|
confidence = db.Column(db.Float, nullable=False, default=0)
|
||||||
@@ -161,6 +166,7 @@ class ContentPost(db.Model):
|
|||||||
"visibility": self.visibility,
|
"visibility": self.visibility,
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
"prediction": self.prediction,
|
"prediction": self.prediction,
|
||||||
|
"category": self.category or "",
|
||||||
"spam_probability": round(float(self.spam_probability), 4),
|
"spam_probability": round(float(self.spam_probability), 4),
|
||||||
"ham_probability": round(float(self.ham_probability), 4),
|
"ham_probability": round(float(self.ham_probability), 4),
|
||||||
"confidence": round(float(self.confidence), 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_bp.get("/detection/threshold")
|
||||||
@admin_required
|
@admin_required
|
||||||
def get_threshold():
|
def get_threshold():
|
||||||
@@ -309,11 +417,17 @@ def process_appeal(post_id: int):
|
|||||||
row.prediction = "ham"
|
row.prediction = "ham"
|
||||||
row.manual_review_status = "approved_ham"
|
row.manual_review_status = "approved_ham"
|
||||||
_upsert_manual_sample(row.text, "ham", admin.id if admin else None)
|
_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:
|
else:
|
||||||
row.status = "blocked"
|
row.status = "blocked"
|
||||||
row.prediction = "spam"
|
row.prediction = "spam"
|
||||||
row.manual_review_status = "confirmed_spam"
|
row.manual_review_status = "confirmed_spam"
|
||||||
_upsert_manual_sample(row.text, "spam", admin.id if admin else None)
|
_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()
|
db.session.commit()
|
||||||
return ok(_serialize_post(row), "申诉处理完成")
|
return ok(_serialize_post(row), "申诉处理完成")
|
||||||
@@ -404,6 +518,12 @@ def update_user(user_id: int):
|
|||||||
user.phone = (payload.get("phone") or "").strip()
|
user.phone = (payload.get("phone") or "").strip()
|
||||||
if "is_admin" in payload:
|
if "is_admin" in payload:
|
||||||
user.is_admin = bool(payload.get("is_admin"))
|
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 payload.get("password"):
|
||||||
if len(payload["password"]) < 6:
|
if len(payload["password"]) < 6:
|
||||||
return fail("密码至少6位", 400)
|
return fail("密码至少6位", 400)
|
||||||
@@ -427,3 +547,73 @@ def delete_user(user_id: int):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
return ok({}, "用户已删除")
|
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.extensions import db
|
||||||
from app.ml.naive_bayes_classifier import NaiveBayesSpamClassifier
|
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.models import ContentPost, DetectionConfig, SpamPredictionLog, SpamTrainingSample, User
|
||||||
from app.utils.auth import current_user
|
from app.utils.auth import current_user
|
||||||
from app.utils.response import fail, ok
|
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
|
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()
|
clf = _ensure_ready()
|
||||||
result = clf.predict(text)
|
result = clf.predict(text)
|
||||||
threshold = float(_get_config().spam_threshold)
|
base_threshold = float(_get_config().spam_threshold)
|
||||||
blocked = float(result["spam_probability"]) >= threshold
|
|
||||||
return result, threshold, blocked
|
# 信誉分影响阈值系数: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")
|
@content_bp.post("/publish")
|
||||||
@@ -103,7 +121,7 @@ def publish_text():
|
|||||||
if err:
|
if err:
|
||||||
return fail(err, 400)
|
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(
|
post = ContentPost(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
@@ -112,6 +130,7 @@ def publish_text():
|
|||||||
visibility=visibility,
|
visibility=visibility,
|
||||||
status="blocked" if blocked else "published",
|
status="blocked" if blocked else "published",
|
||||||
prediction=result["prediction"],
|
prediction=result["prediction"],
|
||||||
|
category=category,
|
||||||
spam_probability=result["spam_probability"],
|
spam_probability=result["spam_probability"],
|
||||||
ham_probability=result["ham_probability"],
|
ham_probability=result["ham_probability"],
|
||||||
confidence=result["confidence"],
|
confidence=result["confidence"],
|
||||||
@@ -125,6 +144,7 @@ def publish_text():
|
|||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
text=result["text"],
|
text=result["text"],
|
||||||
prediction=result["prediction"],
|
prediction=result["prediction"],
|
||||||
|
category=category,
|
||||||
spam_probability=result["spam_probability"],
|
spam_probability=result["spam_probability"],
|
||||||
ham_probability=result["ham_probability"],
|
ham_probability=result["ham_probability"],
|
||||||
confidence=result["confidence"],
|
confidence=result["confidence"],
|
||||||
@@ -134,16 +154,27 @@ def publish_text():
|
|||||||
|
|
||||||
db.session.add(post)
|
db.session.add(post)
|
||||||
db.session.add(detect_log)
|
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()
|
db.session.commit()
|
||||||
|
|
||||||
feedback = "发布成功" if not blocked else "疑似垃圾信息,系统已拦截,可提交申诉"
|
feedback = "发布成功" if not blocked else f"{category_label or '疑似垃圾信息'},系统已拦截,可提交申诉"
|
||||||
return ok(
|
return ok(
|
||||||
{
|
{
|
||||||
"publish_allowed": not blocked,
|
"publish_allowed": not blocked,
|
||||||
"action": "published" if not blocked else "blocked",
|
"action": "published" if not blocked else "blocked",
|
||||||
"feedback": feedback,
|
"feedback": feedback,
|
||||||
"post": _serialize_post(post),
|
"post": _serialize_post(post),
|
||||||
"detect": result,
|
"detect": {
|
||||||
|
**result,
|
||||||
|
"category": category,
|
||||||
|
"category_label": category_label,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
feedback,
|
feedback,
|
||||||
)
|
)
|
||||||
@@ -171,13 +202,14 @@ def edit_post(post_id: int):
|
|||||||
if err:
|
if err:
|
||||||
return fail(err, 400)
|
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.text = result["text"]
|
||||||
post.visibility = visibility
|
post.visibility = visibility
|
||||||
post.recipient_user_id = recipient.id if recipient else None
|
post.recipient_user_id = recipient.id if recipient else None
|
||||||
post.status = "blocked" if blocked else "published"
|
post.status = "blocked" if blocked else "published"
|
||||||
post.prediction = result["prediction"]
|
post.prediction = result["prediction"]
|
||||||
|
post.category = category
|
||||||
post.spam_probability = result["spam_probability"]
|
post.spam_probability = result["spam_probability"]
|
||||||
post.ham_probability = result["ham_probability"]
|
post.ham_probability = result["ham_probability"]
|
||||||
post.confidence = result["confidence"]
|
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 flask_jwt_extended import jwt_required
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.ml.naive_bayes_classifier import NaiveBayesSpamClassifier
|
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.models import DetectionConfig, SpamPredictionLog, SpamTrainingSample
|
||||||
from app.utils.auth import admin_required, current_user
|
from app.utils.auth import admin_required, current_user
|
||||||
from app.utils.response import fail, ok
|
from app.utils.response import fail, ok
|
||||||
@@ -32,6 +33,15 @@ def _threshold() -> float:
|
|||||||
return float(row.spam_threshold) if row else 0.75
|
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")
|
@spam_bp.post("/predict")
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
def predict_one():
|
def predict_one():
|
||||||
@@ -46,13 +56,20 @@ def predict_one():
|
|||||||
|
|
||||||
clf = _ensure_ready()
|
clf = _ensure_ready()
|
||||||
result = clf.predict(text)
|
result = clf.predict(text)
|
||||||
threshold = _threshold()
|
threshold = _adjusted_threshold(user.credit_score or 100)
|
||||||
blocked = float(result["spam_probability"]) >= threshold
|
blocked = float(result["spam_probability"]) >= threshold
|
||||||
|
|
||||||
|
# 分类标签:仅在判定为垃圾时进行细分
|
||||||
|
category = ""
|
||||||
|
category_label = ""
|
||||||
|
if blocked:
|
||||||
|
category, category_label = categorize_spam(result["text"])
|
||||||
|
|
||||||
row = SpamPredictionLog(
|
row = SpamPredictionLog(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
text=result["text"],
|
text=result["text"],
|
||||||
prediction=result["prediction"],
|
prediction=result["prediction"],
|
||||||
|
category=category,
|
||||||
spam_probability=result["spam_probability"],
|
spam_probability=result["spam_probability"],
|
||||||
ham_probability=result["ham_probability"],
|
ham_probability=result["ham_probability"],
|
||||||
confidence=result["confidence"],
|
confidence=result["confidence"],
|
||||||
@@ -62,7 +79,14 @@ def predict_one():
|
|||||||
db.session.add(row)
|
db.session.add(row)
|
||||||
db.session.commit()
|
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")
|
@spam_bp.post("/predict/batch")
|
||||||
@@ -82,19 +106,30 @@ def predict_batch():
|
|||||||
clf = _ensure_ready()
|
clf = _ensure_ready()
|
||||||
rows = []
|
rows = []
|
||||||
results = []
|
results = []
|
||||||
threshold = _threshold()
|
threshold = _adjusted_threshold(user.credit_score or 100)
|
||||||
|
|
||||||
for text in items:
|
for text in items:
|
||||||
content = (text or "").strip()
|
content = (text or "").strip()
|
||||||
if len(content) < 2:
|
if len(content) < 2:
|
||||||
continue
|
continue
|
||||||
result = clf.predict(content)
|
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(
|
rows.append(
|
||||||
SpamPredictionLog(
|
SpamPredictionLog(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
text=result["text"],
|
text=result["text"],
|
||||||
prediction=result["prediction"],
|
prediction=result["prediction"],
|
||||||
|
category=category,
|
||||||
spam_probability=result["spam_probability"],
|
spam_probability=result["spam_probability"],
|
||||||
ham_probability=result["ham_probability"],
|
ham_probability=result["ham_probability"],
|
||||||
confidence=result["confidence"],
|
confidence=result["confidence"],
|
||||||
@@ -332,3 +367,56 @@ def import_samples():
|
|||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return ok({"created": created, "updated": updated}, "样本导入完成")
|
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
|
from pathlib import Path
|
||||||
|
|
||||||
import pymysql
|
import pymysql
|
||||||
|
from pymysql import MySQLError
|
||||||
|
|
||||||
from app import create_app
|
from app import create_app
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
@@ -12,6 +13,7 @@ from app.models import DetectionConfig, SpamTrainingSample, User
|
|||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
MYSQL_CONFIG_PATH = BASE_DIR / "mysqlconfig.json"
|
MYSQL_CONFIG_PATH = BASE_DIR / "mysqlconfig.json"
|
||||||
SPAM_SEED_PATH = BASE_DIR / "seed" / "spam_samples_seed.json"
|
SPAM_SEED_PATH = BASE_DIR / "seed" / "spam_samples_seed.json"
|
||||||
|
SQL_MIGRATIONS_DIR = BASE_DIR / "sql"
|
||||||
|
|
||||||
|
|
||||||
def load_mysql_cfg() -> dict:
|
def load_mysql_cfg() -> dict:
|
||||||
@@ -39,6 +41,46 @@ def create_database(mysql_cfg: dict) -> None:
|
|||||||
conn.close()
|
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:
|
def ensure_seed_file() -> None:
|
||||||
if SPAM_SEED_PATH.exists():
|
if SPAM_SEED_PATH.exists():
|
||||||
return
|
return
|
||||||
@@ -135,6 +177,7 @@ def main():
|
|||||||
app = create_app()
|
app = create_app()
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
db.create_all()
|
db.create_all()
|
||||||
|
migrations = run_sql_migrations(mysql_cfg)
|
||||||
created, updated = seed_samples()
|
created, updated = seed_samples()
|
||||||
threshold = ensure_detection_config(mysql_cfg)
|
threshold = ensure_detection_config(mysql_cfg)
|
||||||
admin_msg = init_admin(mysql_cfg)
|
admin_msg = init_admin(mysql_cfg)
|
||||||
@@ -146,6 +189,8 @@ def main():
|
|||||||
print(f"- 初始阈值: {threshold}")
|
print(f"- 初始阈值: {threshold}")
|
||||||
print(f"- {admin_msg}")
|
print(f"- {admin_msg}")
|
||||||
print(f"- 模型版本: {model_meta.get('version')}")
|
print(f"- 模型版本: {model_meta.get('version')}")
|
||||||
|
if migrations:
|
||||||
|
print(f"- SQL迁移: {', '.join(migrations)}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"host": "192.168.2.183",
|
"host": "127.0.0.1",
|
||||||
"port": 3308,
|
"port": 3306,
|
||||||
"user": "root",
|
"user": "root",
|
||||||
"password": "rootroot",
|
"password": "rootroot",
|
||||||
"database": "spam_nb_miniapp",
|
"database": "spam_nb_miniapp",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"host": "127.0.0.1",
|
"host": "192.168.2.183",
|
||||||
"port": 3306,
|
"port": 3308,
|
||||||
"user": "root",
|
"user": "root",
|
||||||
"password": "pk123123",
|
"password": "rootroot",
|
||||||
"database": "spam_nb_miniapp",
|
"database": "spam_nb_miniapp",
|
||||||
"charset": "utf8mb4",
|
"charset": "utf8mb4",
|
||||||
"admin_init": {
|
"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
|
python-dotenv==1.0.1
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
Werkzeug==3.1.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": [
|
||||||
"pages/login/index",
|
|
||||||
"pages/register/index",
|
|
||||||
"pages/home/index",
|
"pages/home/index",
|
||||||
"pages/detect/index",
|
|
||||||
"pages/batch/index",
|
|
||||||
"pages/history/index",
|
"pages/history/index",
|
||||||
"pages/inbox/index",
|
"pages/inbox/index",
|
||||||
"pages/profile/index",
|
"pages/profile/index",
|
||||||
"pages/admin-dashboard/index",
|
"pages/login/index",
|
||||||
"pages/admin-review/index",
|
"pages/register/index",
|
||||||
"pages/admin-users/index",
|
"pages/batch/index"
|
||||||
"pages/admin-samples/index"
|
|
||||||
],
|
],
|
||||||
"window": {
|
"window": {
|
||||||
"navigationBarTitleText": "内容风控平台",
|
"navigationBarTitleText": "内容风控平台",
|
||||||
"navigationBarBackgroundColor": "#0A1A2D",
|
"navigationBarBackgroundColor": "#ffffff",
|
||||||
"navigationBarTextStyle": "white",
|
"navigationBarTextStyle": "black",
|
||||||
"backgroundTextStyle": "light",
|
"backgroundTextStyle": "dark",
|
||||||
"backgroundColor": "#EEF3F8"
|
"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",
|
"style": "v2",
|
||||||
"sitemapLocation": "sitemap.json"
|
"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: [],
|
kpis: [],
|
||||||
bars: [],
|
bars: [],
|
||||||
sourceDist: [],
|
sourceDist: [],
|
||||||
topKeywords: []
|
topKeywords: [],
|
||||||
|
report: null,
|
||||||
|
reportLoading: false
|
||||||
},
|
},
|
||||||
|
|
||||||
formatPercent(value, digits = 2) {
|
formatPercent(value, digits = 2) {
|
||||||
@@ -67,5 +69,74 @@ Page({
|
|||||||
this.setData({ loading: false })
|
this.setData({ loading: false })
|
||||||
if (fromPullDown) wx.stopPullDownRefresh()
|
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>
|
<text class="tag" wx:for="{{topKeywords}}" wx:key="token">{{item.token}} × {{item.count}}</text>
|
||||||
</view>
|
</view>
|
||||||
</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>
|
</view>
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ const APPEAL_STATUS_TEXT = {
|
|||||||
rejected: '已驳回'
|
rejected: '已驳回'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CATEGORY_LABELS = {
|
||||||
|
fraud: '疑似诈骗',
|
||||||
|
harassment: '疑似骚扰',
|
||||||
|
advertisement: '疑似广告',
|
||||||
|
spam: '疑似垃圾'
|
||||||
|
}
|
||||||
|
|
||||||
function buildPager(total, page, pageSize) {
|
function buildPager(total, page, pageSize) {
|
||||||
const totalValue = Number(total || 0)
|
const totalValue = Number(total || 0)
|
||||||
const totalPages = Math.max(Math.ceil(totalValue / pageSize), 1)
|
const totalPages = Math.max(Math.ceil(totalValue / pageSize), 1)
|
||||||
@@ -105,7 +112,8 @@ Page({
|
|||||||
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19),
|
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19),
|
||||||
review_status_text: REVIEW_STATUS_TEXT[item.manual_review_status] || item.manual_review_status,
|
review_status_text: REVIEW_STATUS_TEXT[item.manual_review_status] || item.manual_review_status,
|
||||||
spam_probability_text: this.formatPercent(item.spam_probability, 2),
|
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,
|
...item,
|
||||||
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19),
|
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19),
|
||||||
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] || '',
|
||||||
appeal_evidence_urls: (item.appeal_evidence_urls || []).map((url) =>
|
appeal_evidence_urls: (item.appeal_evidence_urls || []).map((url) =>
|
||||||
url.startsWith('http') ? url : `${serverBase}${url}`
|
url.startsWith('http') ? url : `${serverBase}${url}`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
<view class="list-item" wx:for="{{intercepts}}" wx:key="id">
|
<view class="list-item" wx:for="{{intercepts}}" wx:key="id">
|
||||||
<view class="item-title">{{item.text}}</view>
|
<view class="item-title">{{item.text}}</view>
|
||||||
<view class="item-sub">用户:{{item.nickname || item.username}} · 垃圾概率:{{item.spam_probability_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.review_status_text}} · 申诉状态:{{item.appeal_status_text}}</view>
|
||||||
<view class="item-sub">发布时间:{{item.created_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="list-item" wx:for="{{appeals}}" wx:key="id">
|
||||||
<view class="item-title">{{item.text}}</view>
|
<view class="item-title">{{item.text}}</view>
|
||||||
<view class="item-sub">申诉人:{{item.nickname || item.username}} · 当前状态:{{item.appeal_status_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_type || '未选择'}}</view>
|
||||||
<view class="item-sub">申诉理由:{{item.appeal_reason || '未填写'}}</view>
|
<view class="item-sub">申诉理由:{{item.appeal_reason || '未填写'}}</view>
|
||||||
<view class="item-sub">时间:{{item.created_text}}</view>
|
<view class="item-sub">时间:{{item.created_text}}</view>
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ Page({
|
|||||||
title: '',
|
title: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
is_admin: false,
|
is_admin: false,
|
||||||
password: ''
|
password: '',
|
||||||
|
credit_score: 100
|
||||||
},
|
},
|
||||||
importText: '[{"username":"operator01","password":"123456","nickname":"运营同学","company":"示例科技公司"}]'
|
importText: '[{"username":"operator01","password":"123456","nickname":"运营同学","company":"示例科技公司"}]'
|
||||||
},
|
},
|
||||||
@@ -60,7 +61,8 @@ Page({
|
|||||||
title: row.title || '',
|
title: row.title || '',
|
||||||
phone: row.phone || '',
|
phone: row.phone || '',
|
||||||
is_admin: !!row.is_admin,
|
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="list-item" wx:for="{{users}}" wx:key="id">
|
||||||
<view class="item-title">{{item.nickname}}({{item.username}})</view>
|
<view class="item-title">{{item.nickname}}({{item.username}})</view>
|
||||||
<view class="item-sub">{{item.company || '未填写公司'}} · {{item.title || '未填写岗位'}} · {{item.is_admin ? '管理员' : '普通用户'}}</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}}">
|
<view wx:if="{{editUserId === item.id}}">
|
||||||
<input class="input" placeholder="昵称" value="{{editForm.nickname}}" data-field="nickname" bindinput="onEditInput" />
|
<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.title}}" data-field="title" bindinput="onEditInput" />
|
||||||
<input class="input" placeholder="手机号" value="{{editForm.phone}}" data-field="phone" 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="新密码(可选)" 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">
|
<view class="row">
|
||||||
<text class="label">管理员权限</text>
|
<text class="label">管理员权限</text>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ const { request } = require('../../utils/request')
|
|||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
inputText: '',
|
inputText: '',
|
||||||
|
fileName: '',
|
||||||
|
lineCount: 0,
|
||||||
loading: false,
|
loading: false,
|
||||||
summary: null,
|
summary: null,
|
||||||
items: []
|
items: []
|
||||||
@@ -24,6 +26,43 @@ Page({
|
|||||||
.filter((line) => line.length >= 2)
|
.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() {
|
async submit() {
|
||||||
if (this.data.loading) return
|
if (this.data.loading) return
|
||||||
const items = this.parseLines()
|
const items = this.parseLines()
|
||||||
@@ -79,5 +118,104 @@ Page({
|
|||||||
showCancel: false,
|
showCancel: false,
|
||||||
confirmText: '关闭'
|
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 fade-up">
|
||||||
<view class="hero-badge">BATCH SCAN</view>
|
<view class="hero-badge">BATCH SCAN</view>
|
||||||
<view class="hero-title">批量文本筛查</view>
|
<view class="hero-title">批量文本筛查</view>
|
||||||
<view class="hero-sub">每行一条文本,适用于活动文案、客服话术、私信模板的集中检测。</view>
|
<view class="hero-sub">上传TXT文件或手动输入,每行一条文本,系统自动逐行检测。</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="card fade-up fade-up-delay-1">
|
<view class="card fade-up fade-up-delay-1">
|
||||||
<view class="card-title">批量输入</view>
|
<view class="card-title">上传文件</view>
|
||||||
<view class="card-desc">请按“每行一条”粘贴文本内容,系统会自动跳过空行。</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" />
|
<textarea class="textarea" placeholder="示例: 点击链接领取红包 今天下午三点开会" value="{{inputText}}" bindinput="onInput" />
|
||||||
|
|
||||||
<view class="btn-row">
|
<view class="btn-row">
|
||||||
<button class="btn btn-ghost" bindtap="fillDemo">填充示例</button>
|
<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>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -56,12 +76,20 @@
|
|||||||
|
|
||||||
<view class="card fade-up fade-up-delay-3" wx:if="{{items.length}}">
|
<view class="card fade-up fade-up-delay-3" wx:if="{{items.length}}">
|
||||||
<view class="card-title">明细结果</view>
|
<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="list-item" wx:for="{{items}}" wx:key="index">
|
||||||
<view class="item-title">{{item.text}}</view>
|
<view class="item-title">{{item.text}}</view>
|
||||||
<view class="row">
|
<view class="row">
|
||||||
<text class="label">判定结果</text>
|
<text class="label">判定结果</text>
|
||||||
<text class="{{item.prediction === 'spam' ? 'status-spam' : 'status-ham'}}">{{item.prediction_text}}</text>
|
<text class="{{item.prediction === 'spam' ? 'status-spam' : 'status-ham'}}">{{item.prediction_text}}</text>
|
||||||
</view>
|
</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">
|
<view class="row">
|
||||||
<text class="label">置信度</text>
|
<text class="label">置信度</text>
|
||||||
<text class="value">{{item.confidence_text}}</text>
|
<text class="value">{{item.confidence_text}}</text>
|
||||||
@@ -77,4 +105,4 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -46,6 +46,11 @@
|
|||||||
<text class="{{result.publish_allowed ? 'status-ham' : 'status-spam'}}">{{result.publish_allowed ? '发布成功' : '已拦截,需申诉'}}</text>
|
<text class="{{result.publish_allowed ? 'status-ham' : 'status-spam'}}">{{result.publish_allowed ? '发布成功' : '已拦截,需申诉'}}</text>
|
||||||
</view>
|
</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">
|
<view class="row">
|
||||||
<text class="label">模型判断</text>
|
<text class="label">模型判断</text>
|
||||||
<text class="value">{{result.detect.prediction_text}}</text>
|
<text class="value">{{result.detect.prediction_text}}</text>
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ const APPEAL_STATUS_TEXT = {
|
|||||||
rejected: '已驳回'
|
rejected: '已驳回'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CATEGORY_LABELS = {
|
||||||
|
fraud: '疑似诈骗',
|
||||||
|
harassment: '疑似骚扰',
|
||||||
|
advertisement: '疑似广告',
|
||||||
|
spam: '疑似垃圾'
|
||||||
|
}
|
||||||
|
|
||||||
const REASON_TYPE_OPTIONS = [
|
const REASON_TYPE_OPTIONS = [
|
||||||
{ value: '', label: '请选择申诉理由类型' },
|
{ value: '', label: '请选择申诉理由类型' },
|
||||||
{ value: '正常活动文案', label: '正常活动文案' },
|
{ value: '正常活动文案', label: '正常活动文案' },
|
||||||
@@ -86,7 +93,8 @@ Page({
|
|||||||
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19),
|
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19),
|
||||||
spam_probability_text: this.formatPercent(item.spam_probability, 2),
|
spam_probability_text: this.formatPercent(item.spam_probability, 2),
|
||||||
visibility_text: VIS_LABEL[item.visibility] || item.visibility,
|
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 })
|
this.setData({ list })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -206,6 +214,19 @@ Page({
|
|||||||
this.fetchList()
|
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) {
|
removeItem(e) {
|
||||||
const id = Number(e.currentTarget.dataset.id)
|
const id = Number(e.currentTarget.dataset.id)
|
||||||
wx.showModal({
|
wx.showModal({
|
||||||
|
|||||||