Compare commits

...

24 Commits

Author SHA1 Message Date
刘正航
a0f7a758eb feat: 首页发布增加发布类型选择(公开/私有/私信)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 21:20:57 +08:00
刘正航
b8acc8be43 feat: admin-web新增feedback工具和配置优化
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 21:15:27 +08:00
刘正航
829599bc17 1 2026-05-14 17:49:23 +08:00
刘正航
f3c0c44f27 feat: tabbar移除首页,发布tab指向合并后的首页
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:25:57 +08:00
刘正航
eaa5a27370 feat: 首页整合发布信息功能,移除检测引擎状态
将发布信息与检测反馈直接放在首页,加入快捷示例和批量识别入口

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:21:39 +08:00
刘正航
200a0ae2e4 数据库密码恢复 2026-05-14 13:50:30 +08:00
刘正航
83618bd714 数据库密码恢复 2026-05-14 13:50:27 +08:00
刘正航
1978326724 chore: 添加.gitignore并清理已跟踪的缓存文件
忽略__pycache__、.idea、uploads等非源码文件

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 13:49:43 +08:00
刘正航
49c946dd55 feat: 小程序移除管理后台入口,新增admin-web前端项目
将管理后台功能从微信小程序中剥离,独立为Vue.js前端项目admin-web

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 13:49:07 +08:00
刘正航
f342fdc9b4 1 2026-05-11 16:11:41 +08:00
刘正航
25fd25005a 改成导excel 2026-05-11 16:09:44 +08:00
刘正航
45bfa93e85 修复pages/history/index 2026-05-11 15:57:53 +08:00
刘正航
00ead01cb8 feat: 批量检测支持上传TXT文件
- 新增文件选择功能,支持TXT格式
- 自动读取文件内容并逐行拆解
- 显示已选文件名和文本条数
- 保留手动输入方式作为备选

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-01 17:02:08 +08:00
刘正航
f7fdc635c7 feat: 小程序UI重构为Apple设计系统并添加tabBar
- 全局样式重构为Apple Design System风格
- 添加底部tabBar导航(首页/发布/历史/私信/我的)
- 更紧凑的spacing和更小的字体尺寸
- pill圆角按钮和状态标签
- Action Blue (#0066cc) 单一accent色
- 添加tabBar图标资源

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-01 12:06:37 +08:00
1123
f5b706d892 1 2026-04-28 10:40:13 +08:00
1123
7f2036fbb2 1 2026-04-28 10:38:23 +08:00
刘正航
38cb9345d6 feat: init_db自动执行SQL迁移脚本
数据库初始化时自动执行sql目录下的迁移脚本,支持幂等执行:
- 遍历sql/*.sql文件按顺序执行
- 忽略重复字段/索引错误(MySQL 1060/1061)
- 输出执行的迁移文件列表

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 21:52:25 +08:00
刘正航
2dcd7ce9f6 feat: 小程序前端显示分类标签
各页面增加垃圾信息分类标签显示:
- 检测结果页显示分类标签
- 批量识别页和CSV导出增加分类标签列
- 历史记录页显示分类标签
- 管理后台审核页显示分类标签

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 21:52:17 +08:00
刘正航
cedfd066c4 feat: 垃圾信息分类标签功能
新增垃圾信息细分类标签,在朴素贝叶斯二分类基础上对spam进行细分:
- 新增 spam_categorizer.py 分类模块(诈骗/骚扰/广告)
- SpamPredictionLog 和 ContentPost 模型添加 category 字段
- content_routes 和 spam_routes 接口返回分类标签

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 21:52:08 +08:00
刘正航
84f0943578 style: 重构前端UI为xAI brutalist设计系统
- 深色背景 #1f2228,纯白文字
- 等宽字体(SF Mono/Consolas)用于标题和按钮
- 锐角边框(0rpx radius),无阴影
- 白色透明度分层(0.7/0.5/0.3)表达层级
- 大写按钮 + letter-spacing
- 进度条、标签、卡片等组件统一风格

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 00:18:41 +08:00
刘正航
8efd86968f fix: 修复文本输入框超出容器边界
- input/textarea 添加 box-sizing: border-box
- card 添加 overflow: hidden 防止内容溢出

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 00:10:34 +08:00
刘正航
385ebe25e7 feat: 运营报告生成功能
- 后端新增 /admin/stats/report 接口,生成14天运营数据报告
- 报告内容:垃圾信息变化趋势、高频风险词Top10、误判率趋势
- 前端运营看板增加"生成报告"按钮,展示完整报告
- 支持复制报告文本到剪贴板

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 00:07:07 +08:00
刘正航
6d62120443 feat: 用户行为信誉分系统
- User 新增 credit_score 字段(0-200,默认100)
- 信誉分影响检测阈值系数:高分降低敏感度,低分提高敏感度
- 发布成功+1分,被拦截-2分;申诉通过+10分,驳回-5分
- 新增手动调整和批量重算信誉分接口
- admin-users 页面显示信誉分进度条,支持编辑调整

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 23:52:47 +08:00
刘正航
5279816452 feat: 批量识别结果导出CSV
- 新增导出CSV文件功能,包含文本、判定结果、置信度、风险关键词
- 新增复制CSV内容到剪贴板功能
- CSV字段:文本、判定结果、置信度、垃圾概率、正常概率、风险关键词

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 23:31:49 +08:00
108 changed files with 12762 additions and 512 deletions

24
.gitignore vendored Normal file
View 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
View File

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

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

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

View File

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

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

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

28
admin-web/src/main.js Normal file
View 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')

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,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
}
}

View 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

View 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="示例:&#10;点击链接领取红包&#10;今天下午三点开会"
></textarea>
<div class="btn-row">
<button class="btn btn-ghost" @click="fillDemo">填充示例</button>
<button class="btn btn-accent" :disabled="loading" @click="submit">
{{ loading ? '识别中...' : '开始识别' }}
</button>
</div>
</section>
<section class="card fade-up fade-up-delay-2" v-if="summary">
<div class="card-title">识别汇总</div>
<div class="grid-3">
<div class="kpi">
<div class="kpi-value">{{ summary.total }}</div>
<div class="kpi-label">总条数</div>
</div>
<div class="kpi">
<div class="kpi-value">{{ summary.spam_count }}</div>
<div class="kpi-label">垃圾信息</div>
</div>
<div class="kpi">
<div class="kpi-value">{{ summary.ham_count }}</div>
<div class="kpi-label">正常信息</div>
</div>
</div>
<div class="field">
<div class="row">
<span class="label">垃圾占比</span>
<span class="value">{{ summary.spam_ratio_text }}</span>
</div>
<div class="progress-track">
<div class="progress-fill" :style="{ width: summary.spam_ratio_text }"></div>
</div>
</div>
<div class="field">
<div class="row">
<span class="label">拦截占比</span>
<span class="value">{{ summary.blocked_ratio_text }}</span>
</div>
<div class="progress-track">
<div class="progress-fill-safe" :style="{ width: summary.blocked_ratio_text }"></div>
</div>
</div>
</section>
<section class="card fade-up fade-up-delay-3" v-if="items.length">
<div class="card-title">明细结果</div>
<div class="btn-row" style="margin-bottom: 12px;">
<button class="btn btn-ghost" :disabled="exporting" @click="exportXLSX">
{{ exporting ? '导出中...' : '导出 Excel 文件' }}
</button>
<button class="btn btn-ghost" @click="copyCSV">复制 CSV 内容</button>
</div>
<div class="list-item" v-for="(item, idx) in items" :key="idx">
<div class="item-title">{{ item.text }}</div>
<div class="row">
<span class="label">判定结果</span>
<span :class="item.prediction === 'spam' ? 'status-spam' : 'status-ham'">{{ item.prediction_text }}</span>
</div>
<div class="row" v-if="item.category_label">
<span class="label">分类标签</span>
<span class="status-spam">{{ item.category_label }}</span>
</div>
<div class="row">
<span class="label">置信度</span>
<span class="value">{{ item.confidence_text }}</span>
</div>
<div class="progress-track">
<div
:class="item.prediction === 'spam' ? 'progress-fill' : 'progress-fill-safe'"
:style="{ width: item.confidence_text }"
></div>
</div>
<div class="field" v-if="item.reason_tokens && item.reason_tokens.length">
<span class="field-label">风险关键词</span>
<div class="chip-group">
<span
v-for="(tk, i) in item.reason_tokens"
:key="i"
class="tag tag-danger"
@click="showTokenWeight(tk)"
>{{ tk.token }}</span>
</div>
</div>
</div>
</section>
</div>
</template>
<script>
import { request } from '@/utils/request'
import { toast, confirm, 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>

View 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>

View File

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

View File

@@ -0,0 +1,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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>

View File

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

View File

@@ -0,0 +1,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>

View 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
View 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

File diff suppressed because it is too large Load Diff

View 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, "")

View File

@@ -16,6 +16,7 @@ class User(db.Model):
title = db.Column(db.String(64), default="")
phone = db.Column(db.String(32), default="")
is_admin = db.Column(db.Boolean, default=False)
credit_score = db.Column(db.Integer, default=100) # 信誉分 0-200默认100
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -40,6 +41,7 @@ class User(db.Model):
"title": self.title,
"phone": self.phone,
"is_admin": self.is_admin,
"credit_score": self.credit_score,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
@@ -77,6 +79,7 @@ class SpamPredictionLog(db.Model):
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
text = db.Column(db.Text, nullable=False)
prediction = db.Column(db.String(16), nullable=False) # spam | ham
category = db.Column(db.String(32), default="") # fraud | harassment | advertisement | spam | 空
spam_probability = db.Column(db.Float, nullable=False)
ham_probability = db.Column(db.Float, nullable=False)
confidence = db.Column(db.Float, nullable=False)
@@ -90,6 +93,7 @@ class SpamPredictionLog(db.Model):
"user_id": self.user_id,
"text": self.text,
"prediction": self.prediction,
"category": self.category or "",
"spam_probability": round(float(self.spam_probability), 4),
"ham_probability": round(float(self.ham_probability), 4),
"confidence": round(float(self.confidence), 4),
@@ -128,6 +132,7 @@ class ContentPost(db.Model):
status = db.Column(db.String(16), nullable=False, default="published") # published | blocked
prediction = db.Column(db.String(16), nullable=False, default="ham")
category = db.Column(db.String(32), default="") # fraud | harassment | advertisement | spam | 空
spam_probability = db.Column(db.Float, nullable=False, default=0)
ham_probability = db.Column(db.Float, nullable=False, default=0)
confidence = db.Column(db.Float, nullable=False, default=0)
@@ -161,6 +166,7 @@ class ContentPost(db.Model):
"visibility": self.visibility,
"status": self.status,
"prediction": self.prediction,
"category": self.category or "",
"spam_probability": round(float(self.spam_probability), 4),
"ham_probability": round(float(self.ham_probability), 4),
"confidence": round(float(self.confidence), 4),

View File

@@ -147,6 +147,114 @@ def stats():
)
@admin_bp.get("/stats/report")
@admin_required
def generate_report():
"""生成运营报告:垃圾信息变化、风险词排名、误判率趋势"""
now = datetime.utcnow()
week_ago = now - timedelta(days=13) # 近14天
# 1. 垃圾信息数量变化近14天
blocked_trend_rows = (
db.session.query(func.date(ContentPost.created_at), func.count(ContentPost.id))
.filter(ContentPost.created_at >= week_ago, ContentPost.status == "blocked")
.group_by(func.date(ContentPost.created_at))
.all()
)
published_trend_rows = (
db.session.query(func.date(ContentPost.created_at), func.count(ContentPost.id))
.filter(ContentPost.created_at >= week_ago, ContentPost.status == "published")
.group_by(func.date(ContentPost.created_at))
.all()
)
blocked_map = {_day_key(day): int(count or 0) for day, count in blocked_trend_rows}
published_map = {_day_key(day): int(count or 0) for day, count in published_trend_rows}
spam_trend = []
today = now.date()
for offset in range(13, -1, -1):
day = today - timedelta(days=offset)
key = day.isoformat()
spam_trend.append({
"date": key,
"label": day.strftime("%m-%d"),
"blocked": blocked_map.get(key, 0),
"published": published_map.get(key, 0),
"total": blocked_map.get(key, 0) + published_map.get(key, 0)
})
# 2. 高频风险词排名近14天
blocked_logs = (
ContentPost.query.filter(ContentPost.created_at >= week_ago, ContentPost.status == "blocked")
.order_by(ContentPost.id.desc())
.limit(1000)
.all()
)
token_counter = Counter()
for row in blocked_logs:
token_counter.update(_tokenize(row.text))
top_keywords = [{"token": token, "count": count} for token, count in token_counter.most_common(20)]
# 3. 误判率趋势近14天基于人工复核
review_trend_rows = (
db.session.query(func.date(ContentPost.manual_review_at), func.count(ContentPost.id))
.filter(ContentPost.manual_review_at >= week_ago, ContentPost.manual_review_status != "none")
.group_by(func.date(ContentPost.manual_review_at))
.all()
)
approved_trend_rows = (
db.session.query(func.date(ContentPost.manual_review_at), func.count(ContentPost.id))
.filter(ContentPost.manual_review_at >= week_ago, ContentPost.manual_review_status == "approved_ham")
.group_by(func.date(ContentPost.manual_review_at))
.all()
)
review_map = {_day_key(day): int(count or 0) for day, count in review_trend_rows}
approved_map = {_day_key(day): int(count or 0) for day, count in approved_trend_rows}
misjudge_trend = []
for offset in range(13, -1, -1):
day = today - timedelta(days=offset)
key = day.isoformat()
reviewed = review_map.get(key, 0)
approved = approved_map.get(key, 0)
misjudge_rate = round(approved / reviewed, 4) if reviewed > 0 else 0
misjudge_trend.append({
"date": key,
"label": day.strftime("%m-%d"),
"reviewed": reviewed,
"approved": approved,
"misjudge_rate": misjudge_rate,
"misjudge_rate_text": f"{misjudge_rate * 100:.1f}%"
})
# 4. 汇总统计
total_blocked_14d = sum(blocked_map.values())
total_published_14d = sum(published_map.values())
total_reviews_14d = sum(review_map.values())
total_approved_14d = sum(approved_map.values())
avg_misjudge_rate = round(total_approved_14d / total_reviews_14d, 4) if total_reviews_14d > 0 else 0
return ok({
"report_date": now.isoformat(),
"period": "近14天",
"spam_trend": spam_trend,
"top_keywords": top_keywords,
"misjudge_trend": misjudge_trend,
"summary": {
"total_blocked": total_blocked_14d,
"total_published": total_published_14d,
"total_posts": total_blocked_14d + total_published_14d,
"blocked_ratio": round(total_blocked_14d / (total_blocked_14d + total_published_14d), 4) if (total_blocked_14d + total_published_14d) > 0 else 0,
"total_reviews": total_reviews_14d,
"total_approved": total_approved_14d,
"avg_misjudge_rate": avg_misjudge_rate,
"avg_misjudge_rate_text": f"{avg_misjudge_rate * 100:.1f}%"
}
})
@admin_bp.get("/detection/threshold")
@admin_required
def get_threshold():
@@ -309,11 +417,17 @@ def process_appeal(post_id: int):
row.prediction = "ham"
row.manual_review_status = "approved_ham"
_upsert_manual_sample(row.text, "ham", admin.id if admin else None)
# 申诉通过,增加用户信誉分
if row.author:
row.author.credit_score = min(200, (row.author.credit_score or 100) + 10)
else:
row.status = "blocked"
row.prediction = "spam"
row.manual_review_status = "confirmed_spam"
_upsert_manual_sample(row.text, "spam", admin.id if admin else None)
# 申诉驳回,减少用户信誉分
if row.author:
row.author.credit_score = max(0, (row.author.credit_score or 100) - 5)
db.session.commit()
return ok(_serialize_post(row), "申诉处理完成")
@@ -404,6 +518,12 @@ def update_user(user_id: int):
user.phone = (payload.get("phone") or "").strip()
if "is_admin" in payload:
user.is_admin = bool(payload.get("is_admin"))
if "credit_score" in payload:
try:
credit = int(payload.get("credit_score", 100))
user.credit_score = max(0, min(200, credit))
except Exception:
pass
if payload.get("password"):
if len(payload["password"]) < 6:
return fail("密码至少6位", 400)
@@ -427,3 +547,73 @@ def delete_user(user_id: int):
db.session.commit()
return ok({}, "用户已删除")
@admin_bp.put("/users/<int:user_id>/credit")
@admin_required
def update_user_credit(user_id: int):
"""手动调整用户信誉分"""
user = User.query.get(user_id)
if not user:
return fail("用户不存在", 404)
payload = request.get_json(silent=True) or {}
try:
credit = int(payload.get("credit_score", user.credit_score or 100))
credit = max(0, min(200, credit))
except Exception:
return fail("信誉分必须是0-200之间的整数", 400)
user.credit_score = credit
db.session.commit()
return ok(user.to_dict(), "信誉分已更新")
@admin_bp.post("/users/recalculate-credit")
@admin_required
def recalculate_all_credit():
"""根据用户发布历史和申诉通过率重新计算信誉分"""
users = User.query.filter_by(is_admin=False).all()
updated_count = 0
for user in users:
posts = ContentPost.query.filter_by(user_id=user.id).all()
if not posts:
continue
# 计算发布成功率
published_count = sum(1 for p in posts if p.status == "published")
blocked_count = sum(1 for p in posts if p.status == "blocked")
total_count = len(posts)
if total_count == 0:
continue
publish_ratio = published_count / total_count
# 计算申诉通过率
appeals = [p for p in posts if p.appeal_status != "none"]
approved_appeals = sum(1 for p in appeals if p.appeal_status == "approved")
appeal_ratio = approved_appeals / len(appeals) if appeals else 0
# 基础信誉分:发布成功率贡献
base_score = 100
if publish_ratio >= 0.9:
base_score += 30 # 90%以上发布成功,+30
elif publish_ratio >= 0.7:
base_score += 15 # 70%以上,+15
elif publish_ratio < 0.5:
base_score -= 20 # 低于50%-20
# 申诉通过率贡献
if appeal_ratio >= 0.8 and len(appeals) >= 3:
base_score += 20 # 80%以上申诉通过且有3次以上申诉+20
elif appeal_ratio >= 0.5 and len(appeals) >= 2:
base_score += 10
# 限制范围
user.credit_score = max(0, min(200, base_score))
updated_count += 1
db.session.commit()
return ok({"updated_count": updated_count}, "信誉分批量重算完成")

View File

@@ -5,6 +5,7 @@ from flask_jwt_extended import jwt_required
from app.extensions import db
from app.ml.naive_bayes_classifier import NaiveBayesSpamClassifier
from app.ml.spam_categorizer import categorize_spam, get_category_label
from app.models import ContentPost, DetectionConfig, SpamPredictionLog, SpamTrainingSample, User
from app.utils.auth import current_user
from app.utils.response import fail, ok
@@ -77,12 +78,29 @@ def _resolve_recipient(payload: dict, visibility: str, current_user_id: int):
return recipient, None
def _predict_and_decide(text: str) -> tuple[dict, float, bool]:
def _predict_and_decide(text: str, user_credit: int = 100) -> tuple[dict, float, bool, str, str]:
"""根据用户信誉分调整阈值系数。信誉分越高,阈值越高(降低敏感度)"""
clf = _ensure_ready()
result = clf.predict(text)
threshold = float(_get_config().spam_threshold)
blocked = float(result["spam_probability"]) >= threshold
return result, threshold, blocked
base_threshold = float(_get_config().spam_threshold)
# 信誉分影响阈值系数credit 0-200默认100
# credit > 100阈值提高降低敏感度减少误判
# credit < 100阈值降低提高敏感度加强拦截
# 系数范围0.85 - 1.15
credit_factor = 1.0 + (user_credit - 100) * 0.0015 # 每10分变化1.5%
credit_factor = max(0.85, min(1.15, credit_factor))
adjusted_threshold = base_threshold * credit_factor
blocked = float(result["spam_probability"]) >= adjusted_threshold
# 分类标签
category = ""
category_label = ""
if blocked:
category, category_label = categorize_spam(result["text"])
return result, adjusted_threshold, blocked, category, category_label
@content_bp.post("/publish")
@@ -103,7 +121,7 @@ def publish_text():
if err:
return fail(err, 400)
result, threshold, blocked = _predict_and_decide(text)
result, threshold, blocked, category, category_label = _predict_and_decide(text, user.credit_score or 100)
post = ContentPost(
user_id=user.id,
@@ -112,6 +130,7 @@ def publish_text():
visibility=visibility,
status="blocked" if blocked else "published",
prediction=result["prediction"],
category=category,
spam_probability=result["spam_probability"],
ham_probability=result["ham_probability"],
confidence=result["confidence"],
@@ -125,6 +144,7 @@ def publish_text():
user_id=user.id,
text=result["text"],
prediction=result["prediction"],
category=category,
spam_probability=result["spam_probability"],
ham_probability=result["ham_probability"],
confidence=result["confidence"],
@@ -134,16 +154,27 @@ def publish_text():
db.session.add(post)
db.session.add(detect_log)
# 发布成功(未被拦截),小幅增加信誉分;被拦截则小幅减少
if not blocked:
user.credit_score = min(200, (user.credit_score or 100) + 1)
else:
user.credit_score = max(0, (user.credit_score or 100) - 2)
db.session.commit()
feedback = "发布成功" if not blocked else "疑似垃圾信息,系统已拦截,可提交申诉"
feedback = "发布成功" if not blocked else f"{category_label or '疑似垃圾信息'},系统已拦截,可提交申诉"
return ok(
{
"publish_allowed": not blocked,
"action": "published" if not blocked else "blocked",
"feedback": feedback,
"post": _serialize_post(post),
"detect": result,
"detect": {
**result,
"category": category,
"category_label": category_label,
},
},
feedback,
)
@@ -171,13 +202,14 @@ def edit_post(post_id: int):
if err:
return fail(err, 400)
result, threshold, blocked = _predict_and_decide(text)
result, threshold, blocked, category, category_label = _predict_and_decide(text, user.credit_score or 100)
post.text = result["text"]
post.visibility = visibility
post.recipient_user_id = recipient.id if recipient else None
post.status = "blocked" if blocked else "published"
post.prediction = result["prediction"]
post.category = category
post.spam_probability = result["spam_probability"]
post.ham_probability = result["ham_probability"]
post.confidence = result["confidence"]

View File

@@ -1,8 +1,9 @@
from flask import Blueprint, current_app, request
from flask import Blueprint, current_app, request, send_file, after_this_request
from flask_jwt_extended import jwt_required
from app.extensions import db
from app.ml.naive_bayes_classifier import NaiveBayesSpamClassifier
from app.ml.spam_categorizer import categorize_spam, get_category_label
from app.models import DetectionConfig, SpamPredictionLog, SpamTrainingSample
from app.utils.auth import admin_required, current_user
from app.utils.response import fail, ok
@@ -32,6 +33,15 @@ def _threshold() -> float:
return float(row.spam_threshold) if row else 0.75
def _adjusted_threshold(user_credit: int = 100) -> float:
"""根据用户信誉分调整阈值"""
base_threshold = _threshold()
# 系数范围0.85 - 1.15
credit_factor = 1.0 + (user_credit - 100) * 0.0015
credit_factor = max(0.85, min(1.15, credit_factor))
return base_threshold * credit_factor
@spam_bp.post("/predict")
@jwt_required()
def predict_one():
@@ -46,13 +56,20 @@ def predict_one():
clf = _ensure_ready()
result = clf.predict(text)
threshold = _threshold()
threshold = _adjusted_threshold(user.credit_score or 100)
blocked = float(result["spam_probability"]) >= threshold
# 分类标签:仅在判定为垃圾时进行细分
category = ""
category_label = ""
if blocked:
category, category_label = categorize_spam(result["text"])
row = SpamPredictionLog(
user_id=user.id,
text=result["text"],
prediction=result["prediction"],
category=category,
spam_probability=result["spam_probability"],
ham_probability=result["ham_probability"],
confidence=result["confidence"],
@@ -62,7 +79,14 @@ def predict_one():
db.session.add(row)
db.session.commit()
return ok({**result, "log_id": row.id, "threshold": threshold, "blocked_by_threshold": blocked}, "识别成功")
return ok({
**result,
"log_id": row.id,
"threshold": threshold,
"blocked_by_threshold": blocked,
"category": category,
"category_label": category_label,
}, "识别成功")
@spam_bp.post("/predict/batch")
@@ -82,19 +106,30 @@ def predict_batch():
clf = _ensure_ready()
rows = []
results = []
threshold = _threshold()
threshold = _adjusted_threshold(user.credit_score or 100)
for text in items:
content = (text or "").strip()
if len(content) < 2:
continue
result = clf.predict(content)
result["blocked_by_threshold"] = float(result["spam_probability"]) >= threshold
blocked = float(result["spam_probability"]) >= threshold
result["blocked_by_threshold"] = blocked
# 分类标签
category = ""
category_label = ""
if blocked:
category, category_label = categorize_spam(result["text"])
result["category"] = category
result["category_label"] = category_label
rows.append(
SpamPredictionLog(
user_id=user.id,
text=result["text"],
prediction=result["prediction"],
category=category,
spam_probability=result["spam_probability"],
ham_probability=result["ham_probability"],
confidence=result["confidence"],
@@ -332,3 +367,56 @@ def import_samples():
db.session.commit()
return ok({"created": created, "updated": updated}, "样本导入完成")
@spam_bp.post("/export/xlsx")
@jwt_required()
def export_xlsx():
user = current_user()
if not user:
return fail("用户不存在", 404)
payload = request.get_json(silent=True) or {}
items = payload.get("items") or []
if not isinstance(items, list) or not items:
return fail("items 必须是非空数组", 400)
import os
import tempfile
import pandas as pd
rows = []
for item in items:
tokens = item.get("reason_tokens") or []
token_str = "; ".join(t.get("token", "") for t in tokens) if isinstance(tokens, list) else ""
prediction_text = "垃圾信息" if item.get("prediction") == "spam" else "正常信息"
rows.append({
"文本": item.get("text", ""),
"判定结果": prediction_text,
"分类标签": item.get("category_label", ""),
"置信度": f"{float(item.get('confidence', 0) or 0) * 100:.2f}%",
"垃圾概率": f"{float(item.get('spam_probability', 0) or 0) * 100:.2f}%",
"正常概率": f"{float(item.get('ham_probability', 0) or 0) * 100:.2f}%",
"风险关键词": token_str,
})
df = pd.DataFrame(rows)
tmp = tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False)
tmp.close()
df.to_excel(tmp.name, index=False, engine="openpyxl")
@after_this_request
def cleanup(response):
try:
os.unlink(tmp.name)
except Exception:
pass
return response
return send_file(
tmp.name,
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
as_attachment=True,
download_name="batch_detect.xlsx",
)

View File

@@ -2,6 +2,7 @@
from pathlib import Path
import pymysql
from pymysql import MySQLError
from app import create_app
from app.extensions import db
@@ -12,6 +13,7 @@ from app.models import DetectionConfig, SpamTrainingSample, User
BASE_DIR = Path(__file__).resolve().parent
MYSQL_CONFIG_PATH = BASE_DIR / "mysqlconfig.json"
SPAM_SEED_PATH = BASE_DIR / "seed" / "spam_samples_seed.json"
SQL_MIGRATIONS_DIR = BASE_DIR / "sql"
def load_mysql_cfg() -> dict:
@@ -39,6 +41,46 @@ def create_database(mysql_cfg: dict) -> None:
conn.close()
def run_sql_migrations(mysql_cfg: dict) -> list[str]:
"""执行 sql 目录下的迁移脚本"""
if not SQL_MIGRATIONS_DIR.exists():
return []
conn = pymysql.connect(
host=mysql_cfg.get("host", "127.0.0.1"),
port=int(mysql_cfg.get("port", 3306)),
user=mysql_cfg["user"],
password=mysql_cfg["password"],
database=mysql_cfg["database"],
charset=mysql_cfg.get("charset", "utf8mb4"),
autocommit=True,
)
executed = []
try:
with conn.cursor() as cursor:
sql_files = sorted(SQL_MIGRATIONS_DIR.glob("*.sql"))
for sql_file in sql_files:
sql_content = sql_file.read_text(encoding="utf-8")
statements = [s.strip() for s in sql_content.split(";") if s.strip() and not s.strip().startswith("--")]
for stmt in statements:
if stmt:
try:
cursor.execute(stmt)
except MySQLError as e:
if "1060" in str(e):
pass
elif "1061" in str(e):
pass
else:
print(f"SQL 警告 ({sql_file.name}): {e}")
executed.append(sql_file.name)
finally:
conn.close()
return executed
def ensure_seed_file() -> None:
if SPAM_SEED_PATH.exists():
return
@@ -135,6 +177,7 @@ def main():
app = create_app()
with app.app_context():
db.create_all()
migrations = run_sql_migrations(mysql_cfg)
created, updated = seed_samples()
threshold = ensure_detection_config(mysql_cfg)
admin_msg = init_admin(mysql_cfg)
@@ -146,6 +189,8 @@ def main():
print(f"- 初始阈值: {threshold}")
print(f"- {admin_msg}")
print(f"- 模型版本: {model_meta.get('version')}")
if migrations:
print(f"- SQL迁移: {', '.join(migrations)}")
if __name__ == "__main__":

View File

@@ -1,6 +1,6 @@
{
"host": "192.168.2.183",
"port": 3308,
"host": "127.0.0.1",
"port": 3306,
"user": "root",
"password": "rootroot",
"database": "spam_nb_miniapp",

View File

@@ -1,8 +1,8 @@
{
"host": "127.0.0.1",
"port": 3306,
"host": "192.168.2.183",
"port": 3308,
"user": "root",
"password": "pk123123",
"password": "rootroot",
"database": "spam_nb_miniapp",
"charset": "utf8mb4",
"admin_init": {

View 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
}
}

View File

@@ -0,0 +1,7 @@
{
"description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html",
"projectname": "backend",
"setting": {
"compileHotReLoad": true
}
}

View File

@@ -11,3 +11,4 @@ joblib==1.4.2
python-dotenv==1.0.1
requests==2.32.3
Werkzeug==3.1.3
openpyxl==3.1.5

234
backend/spam_nb_miniapp.sql Normal file
View 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;

View 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`;

View 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);

View File

@@ -1,25 +1,52 @@
{
"pages": [
"pages/login/index",
"pages/register/index",
"pages/home/index",
"pages/detect/index",
"pages/batch/index",
"pages/history/index",
"pages/inbox/index",
"pages/profile/index",
"pages/admin-dashboard/index",
"pages/admin-review/index",
"pages/admin-users/index",
"pages/admin-samples/index"
"pages/login/index",
"pages/register/index",
"pages/batch/index"
],
"window": {
"navigationBarTitleText": "内容风控平台",
"navigationBarBackgroundColor": "#0A1A2D",
"navigationBarTextStyle": "white",
"backgroundTextStyle": "light",
"backgroundColor": "#EEF3F8"
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"backgroundTextStyle": "dark",
"backgroundColor": "#f5f5f7"
},
"tabBar": {
"color": "#86868b",
"selectedColor": "#0066cc",
"backgroundColor": "#ffffff",
"borderStyle": "white",
"list": [
{
"pagePath": "pages/home/index",
"text": "发布",
"iconPath": "assets/icons/publish.png",
"selectedIconPath": "assets/icons/publish-active.png"
},
{
"pagePath": "pages/history/index",
"text": "历史",
"iconPath": "assets/icons/history.png",
"selectedIconPath": "assets/icons/history-active.png"
},
{
"pagePath": "pages/inbox/index",
"text": "私信",
"iconPath": "assets/icons/inbox.png",
"selectedIconPath": "assets/icons/inbox-active.png"
},
{
"pagePath": "pages/profile/index",
"text": "我的",
"iconPath": "assets/icons/profile.png",
"selectedIconPath": "assets/icons/profile-active.png"
}
]
},
"style": "v2",
"sitemapLocation": "sitemap.json"
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -7,7 +7,9 @@ Page({
kpis: [],
bars: [],
sourceDist: [],
topKeywords: []
topKeywords: [],
report: null,
reportLoading: false
},
formatPercent(value, digits = 2) {
@@ -67,5 +69,74 @@ Page({
this.setData({ loading: false })
if (fromPullDown) wx.stopPullDownRefresh()
}
},
async generateReport() {
this.setData({ reportLoading: true })
try {
const report = await request({ url: '/admin/stats/report' })
// 处理趋势数据,计算进度条宽度
const spamTrend = (report.spam_trend || []).map((item) => {
const maxBlocked = Math.max(...(report.spam_trend || []).map((r) => r.blocked || 0), 1)
return {
...item,
blocked_percent: `${Math.max(4, Math.round((item.blocked || 0) / maxBlocked * 100))}%`
}
})
const misjudgeTrend = (report.misjudge_trend || []).map((item) => ({
...item,
rate_percent: `${Math.round((item.misjudge_rate || 0) * 100)}%`
}))
this.setData({
report: {
...report,
spam_trend: spamTrend,
misjudge_trend: misjudgeTrend
}
})
wx.showToast({ title: '报告已生成', icon: 'success' })
} finally {
this.setData({ reportLoading: false })
}
},
closeReport() {
this.setData({ report: null })
},
copyReportText() {
const report = this.data.report
if (!report) return
const summary = report.summary || {}
const lines = [
`【垃圾信息运营报告】`,
`报告周期:${report.period}`,
`生成时间:${(report.report_date || '').replace('T', ' ').slice(0, 19)}`,
'',
`【汇总统计】`,
`总发布量:${summary.total_posts || 0}`,
`拦截量:${summary.total_blocked || 0}`,
`正常发布:${summary.total_published || 0}`,
`拦截率:${this.formatPercent(summary.blocked_ratio, 2)}`,
`复核总数:${summary.total_reviews || 0}`,
`误判放行:${summary.total_approved || 0}`,
`平均误判率:${summary.avg_misjudge_rate_text || '0%'}`,
'',
`【高频风险词 Top10】`,
(report.top_keywords || []).slice(0, 10).map((k) => `${k.token}(${k.count}次)`).join('、'),
'',
`【近7日趋势】`,
(report.spam_trend || []).slice(-7).map((t) => `${t.label}: 拦截${t.blocked}条,发布${t.published}`).join('\n')
]
wx.setClipboardData({
data: lines.join('\n'),
success: () => wx.showToast({ title: '报告已复制', icon: 'success' })
})
}
})

View File

@@ -54,4 +54,83 @@
<text class="tag" wx:for="{{topKeywords}}" wx:key="token">{{item.token}} × {{item.count}}</text>
</view>
</view>
<!-- 生成报告按钮 -->
<view class="card fade-up fade-up-delay-3">
<button class="btn btn-accent" loading="{{reportLoading}}" bindtap="generateReport">生成运营报告</button>
</view>
<!-- 报告展示区域 -->
<view class="report-modal" wx:if="{{report}}">
<view class="report-header">
<view class="report-title">垃圾信息运营报告</view>
<view class="report-period">{{report.period}}</view>
</view>
<view class="report-close" bindtap="closeReport">×</view>
<view class="report-section">
<view class="report-section-title">汇总统计</view>
<view class="grid-3">
<view class="report-kpi">
<view class="report-kpi-value">{{report.summary.total_posts}}</view>
<view class="report-kpi-label">总发布量</view>
</view>
<view class="report-kpi">
<view class="report-kpi-value">{{report.summary.total_blocked}}</view>
<view class="report-kpi-label">拦截量</view>
</view>
<view class="report-kpi">
<view class="report-kpi-value">{{report.summary.total_published}}</view>
<view class="report-kpi-label">正常发布</view>
</view>
</view>
<view class="row">
<text class="label">拦截率</text>
<text class="value">{{report.summary.blocked_ratio * 100}}%</text>
</view>
<view class="row">
<text class="label">平均误判率</text>
<text class="value">{{report.summary.avg_misjudge_rate_text}}</text>
</view>
</view>
<view class="report-section">
<view class="report-section-title">垃圾信息数量变化近14天</view>
<view class="report-trend-item" wx:for="{{report.spam_trend}}" wx:key="date">
<view class="row">
<text class="label">{{item.label}}</text>
<text class="value">拦截 {{item.blocked}} / 发布 {{item.published}}</text>
</view>
<view class="progress-track">
<view class="progress-fill" style="width: {{item.blocked_percent}};"></view>
</view>
</view>
</view>
<view class="report-section">
<view class="report-section-title">高频风险词 Top10</view>
<view class="chip-group">
<text class="tag tag-danger" wx:for="{{report.topKeywords}}" wx:for-item="kw" wx:if="{{index < 10}}" wx:key="token">{{kw.token}} × {{kw.count}}</text>
</view>
</view>
<view class="report-section">
<view class="report-section-title">误判率趋势近14天</view>
<view class="report-trend-item" wx:for="{{report.misjudge_trend}}" wx:key="date">
<view class="row">
<text class="label">{{item.label}}</text>
<text class="value">{{item.misjudge_rate_text}}</text>
</view>
<view class="progress-track">
<view class="progress-fill-safe" style="width: {{item.rate_percent}};"></view>
</view>
</view>
</view>
<view class="btn-row">
<button class="btn btn-primary" bindtap="copyReportText">复制报告文本</button>
<button class="btn btn-ghost" bindtap="closeReport">关闭</button>
</view>
</view>
</view>

View File

@@ -35,6 +35,13 @@ const APPEAL_STATUS_TEXT = {
rejected: '已驳回'
}
const CATEGORY_LABELS = {
fraud: '疑似诈骗',
harassment: '疑似骚扰',
advertisement: '疑似广告',
spam: '疑似垃圾'
}
function buildPager(total, page, pageSize) {
const totalValue = Number(total || 0)
const totalPages = Math.max(Math.ceil(totalValue / pageSize), 1)
@@ -105,7 +112,8 @@ Page({
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19),
review_status_text: REVIEW_STATUS_TEXT[item.manual_review_status] || item.manual_review_status,
spam_probability_text: this.formatPercent(item.spam_probability, 2),
appeal_status_text: APPEAL_STATUS_TEXT[item.appeal_status] || item.appeal_status
appeal_status_text: APPEAL_STATUS_TEXT[item.appeal_status] || item.appeal_status,
category_label: CATEGORY_LABELS[item.category] || ''
}))
},
@@ -116,6 +124,7 @@ Page({
...item,
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19),
appeal_status_text: APPEAL_STATUS_TEXT[item.appeal_status] || item.appeal_status,
category_label: CATEGORY_LABELS[item.category] || '',
appeal_evidence_urls: (item.appeal_evidence_urls || []).map((url) =>
url.startsWith('http') ? url : `${serverBase}${url}`
)

View File

@@ -51,6 +51,7 @@
<view class="list-item" wx:for="{{intercepts}}" wx:key="id">
<view class="item-title">{{item.text}}</view>
<view class="item-sub">用户:{{item.nickname || item.username}} · 垃圾概率:{{item.spam_probability_text}}</view>
<view class="item-sub" wx:if="{{item.category_label}}">分类标签:<text class="status-spam">{{item.category_label}}</text></view>
<view class="item-sub">复核状态:{{item.review_status_text}} · 申诉状态:{{item.appeal_status_text}}</view>
<view class="item-sub">发布时间:{{item.created_text}}</view>
@@ -100,6 +101,7 @@
<view class="list-item" wx:for="{{appeals}}" wx:key="id">
<view class="item-title">{{item.text}}</view>
<view class="item-sub">申诉人:{{item.nickname || item.username}} · 当前状态:{{item.appeal_status_text}}</view>
<view class="item-sub" wx:if="{{item.category_label}}">分类标签:<text class="status-spam">{{item.category_label}}</text></view>
<view class="item-sub">申诉理由类型:{{item.appeal_reason_type || '未选择'}}</view>
<view class="item-sub">申诉理由:{{item.appeal_reason || '未填写'}}</view>
<view class="item-sub">时间:{{item.created_text}}</view>

View File

@@ -12,7 +12,8 @@ Page({
title: '',
phone: '',
is_admin: false,
password: ''
password: '',
credit_score: 100
},
importText: '[{"username":"operator01","password":"123456","nickname":"运营同学","company":"示例科技公司"}]'
},
@@ -60,7 +61,8 @@ Page({
title: row.title || '',
phone: row.phone || '',
is_admin: !!row.is_admin,
password: ''
password: '',
credit_score: row.credit_score || 100
}
})
},

View File

@@ -27,6 +27,13 @@
<view class="list-item" wx:for="{{users}}" wx:key="id">
<view class="item-title">{{item.nickname}}{{item.username}}</view>
<view class="item-sub">{{item.company || '未填写公司'}} · {{item.title || '未填写岗位'}} · {{item.is_admin ? '管理员' : '普通用户'}}</view>
<view class="row">
<text class="label">信誉分</text>
<view class="credit-score-bar">
<view class="credit-fill" style="width: {{(item.credit_score || 100) / 2}}%;"></view>
<text class="credit-value">{{item.credit_score || 100}}</text>
</view>
</view>
<view wx:if="{{editUserId === item.id}}">
<input class="input" placeholder="昵称" value="{{editForm.nickname}}" data-field="nickname" bindinput="onEditInput" />
@@ -34,6 +41,7 @@
<input class="input" placeholder="岗位" value="{{editForm.title}}" data-field="title" bindinput="onEditInput" />
<input class="input" placeholder="手机号" value="{{editForm.phone}}" data-field="phone" bindinput="onEditInput" />
<input class="input" placeholder="新密码(可选)" password value="{{editForm.password}}" data-field="password" bindinput="onEditInput" />
<input class="input" placeholder="信誉分0-200" type="number" value="{{editForm.credit_score}}" data-field="credit_score" bindinput="onEditInput" />
<view class="row">
<text class="label">管理员权限</text>

View File

@@ -3,6 +3,8 @@ const { request } = require('../../utils/request')
Page({
data: {
inputText: '',
fileName: '',
lineCount: 0,
loading: false,
summary: null,
items: []
@@ -24,6 +26,43 @@ Page({
.filter((line) => line.length >= 2)
},
chooseFile() {
wx.chooseMessageFile({
count: 1,
type: 'file',
extension: ['txt'],
success: (res) => {
const file = res.tempFiles[0]
const fs = wx.getFileSystemManager()
try {
const content = fs.readFileSync(file.path, 'utf8')
const lines = content
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length >= 2)
this.setData({
inputText: lines.join('\n'),
fileName: file.name,
lineCount: lines.length
})
wx.showToast({ title: `已读取 ${lines.length} 条文本`, icon: 'success' })
} catch (err) {
console.error('读取文件失败', err)
wx.showToast({ title: '文件读取失败', icon: 'none' })
}
},
fail: (err) => {
console.error('选择文件失败', err)
if (err.errMsg !== 'chooseMessageFile:fail cancel') {
wx.showToast({ title: '请选择TXT文件', icon: 'none' })
}
}
})
},
async submit() {
if (this.data.loading) return
const items = this.parseLines()
@@ -79,5 +118,104 @@ Page({
showCancel: false,
confirmText: '关闭'
})
},
generateCSV() {
const items = this.data.items
if (!items.length) return ''
const headers = ['文本', '判定结果', '分类标签', '置信度', '垃圾概率', '正常概率', '风险关键词']
const rows = items.map((item) => {
const prediction = item.prediction === 'spam' ? '垃圾信息' : '正常信息'
const categoryLabel = item.category_label || ''
const confidence = item.confidence_text || '0%'
const spamProb = this.formatPercent(item.spam_probability, 4)
const hamProb = this.formatPercent(item.ham_probability, 4)
const tokens = (item.reason_tokens || []).map((t) => t.token || t).join('; ')
// CSV 转义:文本中的逗号和换行需要处理
const text = (item.text || '').replace(/"/g, '""')
const tokensEscaped = tokens.replace(/"/g, '""')
return `"${text}","${prediction}","${categoryLabel}","${confidence}","${spamProb}","${hamProb}","${tokensEscaped}"`
})
return [headers.join(','), ...rows].join('\n')
},
exportXLSX() {
const items = this.data.items
if (!items.length) {
wx.showToast({ title: '暂无识别结果可导出', icon: 'none' })
return
}
wx.showLoading({ title: '生成文件中...' })
const app = getApp()
const token = app.globalData.token || wx.getStorageSync('token') || ''
const baseURL = app.globalData.baseURL || 'http://127.0.0.1:5000/api'
wx.request({
url: `${baseURL}/spam/export/xlsx`,
method: 'POST',
data: { items },
header: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
responseType: 'arraybuffer',
success(res) {
wx.hideLoading()
if (res.statusCode !== 200) {
wx.showToast({ title: '导出失败', icon: 'none' })
return
}
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')
const filename = `batch_detect_${timestamp}.xlsx`
const fs = wx.getFileSystemManager()
const tempPath = `${wx.env.USER_DATA_PATH}/${filename}`
try {
fs.writeFileSync(tempPath, res.data)
wx.openDocument({
filePath: tempPath,
fileType: 'xlsx',
showMenu: true,
success: () => {
wx.showToast({ title: '导出成功', icon: 'success' })
},
fail: (err) => {
console.error('打开文件失败', err)
wx.showToast({ title: '打开失败', icon: 'none' })
}
})
} catch (err) {
console.error('写入文件失败', err)
wx.showToast({ title: '导出失败', icon: 'none' })
}
},
fail(err) {
wx.hideLoading()
console.error('导出请求失败', err)
wx.showToast({ title: '导出失败,请检查网络', icon: 'none' })
}
})
},
copyCSVToClipboard() {
const items = this.data.items
if (!items.length) {
wx.showToast({ title: '暂无识别结果可复制', icon: 'none' })
return
}
const csvContent = this.generateCSV()
wx.setClipboardData({
data: csvContent,
success: () => {
wx.showToast({ title: 'CSV内容已复制到剪贴板', icon: 'success' })
}
})
}
})

View File

@@ -2,17 +2,37 @@
<view class="hero fade-up">
<view class="hero-badge">BATCH SCAN</view>
<view class="hero-title">批量文本筛查</view>
<view class="hero-sub">每行一条文本,适用于活动文案、客服话术、私信模板的集中检测。</view>
<view class="hero-sub">上传TXT文件或手动输入每行一条文本系统自动逐行检测。</view>
</view>
<view class="card fade-up fade-up-delay-1">
<view class="card-title">批量输入</view>
<view class="card-desc">请按“每行一条”粘贴文本内容,系统会自动跳过空行。</view>
<view class="card-title">上传文件</view>
<view class="card-desc">支持TXT文本文件每行一条待检测内容。</view>
<view class="btn-row">
<button class="btn btn-primary" bindtap="chooseFile">选择TXT文件</button>
</view>
<view class="field" wx:if="{{fileName}}">
<view class="row">
<text class="label">已选文件</text>
<text class="value">{{fileName}}</text>
</view>
<view class="row">
<text class="label">文本条数</text>
<text class="value">{{lineCount}} 条</text>
</view>
</view>
</view>
<view class="card fade-up fade-up-delay-1">
<view class="card-title">手动输入</view>
<view class="card-desc">或直接粘贴文本内容,每行一条。</view>
<textarea class="textarea" placeholder="示例:&#10;点击链接领取红包&#10;今天下午三点开会" value="{{inputText}}" bindinput="onInput" />
<view class="btn-row">
<button class="btn btn-ghost" bindtap="fillDemo">填充示例</button>
<button class="btn btn-primary" loading="{{loading}}" bindtap="submit">开始识别</button>
<button class="btn btn-accent" loading="{{loading}}" bindtap="submit">开始识别</button>
</view>
</view>
@@ -56,12 +76,20 @@
<view class="card fade-up fade-up-delay-3" wx:if="{{items.length}}">
<view class="card-title">明细结果</view>
<view class="btn-row" style="margin-bottom: 12rpx;">
<button class="btn btn-ghost" bindtap="exportXLSX">导出Excel文件</button>
<button class="btn btn-ghost" bindtap="copyCSVToClipboard">复制CSV内容</button>
</view>
<view class="list-item" wx:for="{{items}}" wx:key="index">
<view class="item-title">{{item.text}}</view>
<view class="row">
<text class="label">判定结果</text>
<text class="{{item.prediction === 'spam' ? 'status-spam' : 'status-ham'}}">{{item.prediction_text}}</text>
</view>
<view class="row" wx:if="{{item.category_label}}">
<text class="label">分类标签</text>
<text class="status-spam">{{item.category_label}}</text>
</view>
<view class="row">
<text class="label">置信度</text>
<text class="value">{{item.confidence_text}}</text>
@@ -77,4 +105,4 @@
</view>
</view>
</view>
</view>
</view>

View File

@@ -46,6 +46,11 @@
<text class="{{result.publish_allowed ? 'status-ham' : 'status-spam'}}">{{result.publish_allowed ? '发布成功' : '已拦截,需申诉'}}</text>
</view>
<view class="row" wx:if="{{result.detect.category_label}}">
<text class="label">分类标签</text>
<text class="status-spam">{{result.detect.category_label}}</text>
</view>
<view class="row">
<text class="label">模型判断</text>
<text class="value">{{result.detect.prediction_text}}</text>

View File

@@ -26,6 +26,13 @@ const APPEAL_STATUS_TEXT = {
rejected: '已驳回'
}
const CATEGORY_LABELS = {
fraud: '疑似诈骗',
harassment: '疑似骚扰',
advertisement: '疑似广告',
spam: '疑似垃圾'
}
const REASON_TYPE_OPTIONS = [
{ value: '', label: '请选择申诉理由类型' },
{ value: '正常活动文案', label: '正常活动文案' },
@@ -86,7 +93,8 @@ Page({
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19),
spam_probability_text: this.formatPercent(item.spam_probability, 2),
visibility_text: VIS_LABEL[item.visibility] || item.visibility,
appeal_status_text: APPEAL_STATUS_TEXT[item.appeal_status] || item.appeal_status
appeal_status_text: APPEAL_STATUS_TEXT[item.appeal_status] || item.appeal_status,
category_label: CATEGORY_LABELS[item.category] || ''
}))
this.setData({ list })
} finally {
@@ -206,6 +214,19 @@ Page({
this.fetchList()
},
showTokenWeight(e) {
const token = e.currentTarget.dataset.token
const weight = e.currentTarget.dataset.weight
const weightNum = Number(weight || 0)
const direction = weightNum >= 0 ? '倾向垃圾判定' : '倾向正常判定'
wx.showModal({
title: '关键词权重',
content: `关键词"${token}"\n权重贡献:${weightNum >= 0 ? '+' : ''}${weightNum.toFixed(4)}\n(${direction})`,
showCancel: false,
confirmText: '关闭'
})
},
removeItem(e) {
const id = Number(e.currentTarget.dataset.id)
wx.showModal({

Some files were not shown because too many files have changed in this diff Show More