Compare commits
14 Commits
f5b706d892
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0f7a758eb | ||
|
|
b8acc8be43 | ||
|
|
829599bc17 | ||
|
|
f3c0c44f27 | ||
|
|
eaa5a27370 | ||
|
|
200a0ae2e4 | ||
|
|
83618bd714 | ||
|
|
1978326724 | ||
|
|
49c946dd55 | ||
|
|
f342fdc9b4 | ||
|
|
25fd25005a | ||
|
|
45bfa93e85 | ||
|
|
00ead01cb8 | ||
|
|
f7fdc635c7 |
23
.gitignore
vendored
@@ -1 +1,24 @@
|
|||||||
/backend/venv/
|
/backend/venv/
|
||||||
|
|
||||||
|
# Python cache
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
/backend/uploads/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Archives
|
||||||
|
*.zip
|
||||||
|
*.tar.gz
|
||||||
|
*.7z
|
||||||
|
|||||||
23
admin-web/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
/dist
|
||||||
|
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
24
admin-web/README.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# admin-web
|
||||||
|
|
||||||
|
## Project setup
|
||||||
|
```
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compiles and hot-reloads for development
|
||||||
|
```
|
||||||
|
yarn serve
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compiles and minifies for production
|
||||||
|
```
|
||||||
|
yarn build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lints and fixes files
|
||||||
|
```
|
||||||
|
yarn lint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Customize configuration
|
||||||
|
See [Configuration Reference](https://cli.vuejs.org/config/).
|
||||||
5
admin-web/babel.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: [
|
||||||
|
'@vue/cli-plugin-babel/preset'
|
||||||
|
]
|
||||||
|
}
|
||||||
19
admin-web/jsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"module": "esnext",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lib": [
|
||||||
|
"esnext",
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"scripthost"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
48
admin-web/package.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "admin-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"serve": "vue-cli-service serve",
|
||||||
|
"build": "vue-cli-service build",
|
||||||
|
"lint": "vue-cli-service lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"core-js": "^3.8.3",
|
||||||
|
"vue": "^2.6.14",
|
||||||
|
"vue-router": "^3.6.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.12.16",
|
||||||
|
"@babel/eslint-parser": "^7.12.16",
|
||||||
|
"@vue/cli-plugin-babel": "~5.0.0",
|
||||||
|
"@vue/cli-plugin-eslint": "~5.0.0",
|
||||||
|
"@vue/cli-service": "~5.0.0",
|
||||||
|
"eslint": "^7.32.0",
|
||||||
|
"eslint-plugin-vue": "^8.0.3",
|
||||||
|
"vue-template-compiler": "^2.6.14"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"root": true,
|
||||||
|
"env": {
|
||||||
|
"node": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"plugin:vue/essential",
|
||||||
|
"eslint:recommended"
|
||||||
|
],
|
||||||
|
"parserOptions": {
|
||||||
|
"parser": "@babel/eslint-parser"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"no-unused-vars": "warn",
|
||||||
|
"vue/multi-word-component-names": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"browserslist": [
|
||||||
|
"> 1%",
|
||||||
|
"last 2 versions",
|
||||||
|
"not dead"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
admin-web/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
16
admin-web/public/index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||||
|
<title>内容风控管理后台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<strong>需要启用 JavaScript 才能正常使用本系统。</strong>
|
||||||
|
</noscript>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
9
admin-web/src/App.vue
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'App'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
58
admin-web/src/components/ConfirmHost.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-if="visible" class="modal-mask" @click.self="handleCancel">
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-title">{{ title }}</div>
|
||||||
|
<div class="modal-content">{{ content }}</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button v-if="showCancel" class="btn btn-ghost" type="button" @click="handleCancel">{{ cancelText }}</button>
|
||||||
|
<button class="btn btn-primary" type="button" @click="handleConfirm">{{ confirmText }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'ConfirmHost',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
visible: false,
|
||||||
|
title: '提示',
|
||||||
|
content: '',
|
||||||
|
confirmText: '确定',
|
||||||
|
cancelText: '取消',
|
||||||
|
showCancel: true,
|
||||||
|
onConfirm: null,
|
||||||
|
onCancel: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
open(opts) {
|
||||||
|
this.title = opts.title || '提示'
|
||||||
|
this.content = opts.content || ''
|
||||||
|
this.confirmText = opts.confirmText || '确定'
|
||||||
|
this.cancelText = opts.cancelText || '取消'
|
||||||
|
this.showCancel = opts.showCancel !== false
|
||||||
|
this.onConfirm = opts.onConfirm
|
||||||
|
this.onCancel = opts.onCancel
|
||||||
|
this.visible = true
|
||||||
|
},
|
||||||
|
handleConfirm() {
|
||||||
|
this.visible = false
|
||||||
|
const fn = this.onConfirm
|
||||||
|
this.onConfirm = null
|
||||||
|
this.onCancel = null
|
||||||
|
if (typeof fn === 'function') fn()
|
||||||
|
},
|
||||||
|
handleCancel() {
|
||||||
|
this.visible = false
|
||||||
|
const fn = this.onCancel
|
||||||
|
this.onConfirm = null
|
||||||
|
this.onCancel = null
|
||||||
|
if (typeof fn === 'function') fn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
57
admin-web/src/components/ImagePreviewHost.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-if="visible" class="preview-mask" @click.self="close">
|
||||||
|
<div class="preview-stage">
|
||||||
|
<div class="preview-close" @click="close">×</div>
|
||||||
|
<div v-if="urls.length > 1" class="preview-nav preview-prev" @click="prev">‹</div>
|
||||||
|
<img class="preview-img" :src="currentUrl" alt="preview" />
|
||||||
|
<div v-if="urls.length > 1" class="preview-nav preview-next" @click="next">›</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'ImagePreviewHost',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
visible: false,
|
||||||
|
urls: [],
|
||||||
|
index: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
currentUrl() {
|
||||||
|
return this.urls[this.index] || ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
open({ urls, current }) {
|
||||||
|
this.urls = Array.isArray(urls) ? urls.slice() : [urls]
|
||||||
|
const idx = this.urls.indexOf(current)
|
||||||
|
this.index = idx >= 0 ? idx : 0
|
||||||
|
this.visible = true
|
||||||
|
document.addEventListener('keydown', this.handleKey)
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
this.visible = false
|
||||||
|
document.removeEventListener('keydown', this.handleKey)
|
||||||
|
},
|
||||||
|
prev() {
|
||||||
|
this.index = (this.index - 1 + this.urls.length) % this.urls.length
|
||||||
|
},
|
||||||
|
next() {
|
||||||
|
this.index = (this.index + 1) % this.urls.length
|
||||||
|
},
|
||||||
|
handleKey(e) {
|
||||||
|
if (e.key === 'Escape') this.close()
|
||||||
|
else if (e.key === 'ArrowLeft') this.prev()
|
||||||
|
else if (e.key === 'ArrowRight') this.next()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
document.removeEventListener('keydown', this.handleKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
34
admin-web/src/components/ToastHost.vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<template>
|
||||||
|
<div class="toast-host">
|
||||||
|
<div
|
||||||
|
v-for="item in list"
|
||||||
|
:key="item.id"
|
||||||
|
class="toast-item"
|
||||||
|
:class="{ 'is-error': item.type === 'error', 'is-success': item.type === 'success' }"
|
||||||
|
>
|
||||||
|
{{ item.message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let uid = 0
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ToastHost',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
list: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
push({ message, type = 'info', duration = 2000 }) {
|
||||||
|
const id = ++uid
|
||||||
|
this.list.push({ id, message, type })
|
||||||
|
setTimeout(() => {
|
||||||
|
this.list = this.list.filter((item) => item.id !== id)
|
||||||
|
}, duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
148
admin-web/src/layouts/AppLayout.vue
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-inner">
|
||||||
|
<div class="topbar-brand">
|
||||||
|
<div class="topbar-brand-mark">VB</div>
|
||||||
|
<span>内容风控平台</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="topbar-nav">
|
||||||
|
<router-link
|
||||||
|
v-for="tab in userTabs"
|
||||||
|
:key="tab.path"
|
||||||
|
:to="tab.path"
|
||||||
|
class="topbar-tab"
|
||||||
|
active-class="is-active"
|
||||||
|
:exact="tab.exact"
|
||||||
|
>{{ tab.label }}</router-link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<div v-if="isAdminUser" class="topbar-admin" v-click-outside="closeAdminMenu">
|
||||||
|
<div
|
||||||
|
class="topbar-admin-trigger"
|
||||||
|
:class="{ 'is-active': adminMenuOpen || isAdminRoute }"
|
||||||
|
@click="toggleAdminMenu"
|
||||||
|
>管理后台 <span style="font-size: 10px;">▾</span></div>
|
||||||
|
<div v-if="adminMenuOpen" class="topbar-admin-menu">
|
||||||
|
<router-link
|
||||||
|
v-for="item in adminTabs"
|
||||||
|
:key="item.path"
|
||||||
|
:to="item.path"
|
||||||
|
class="topbar-admin-item"
|
||||||
|
@click.native="closeAdminMenu"
|
||||||
|
>{{ item.label }}</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topbar-user">
|
||||||
|
<span class="topbar-avatar">{{ avatarText }}</span>
|
||||||
|
<span>{{ userName }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-ghost btn-sm" type="button" @click="handleLogout">退出</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { store, isAdmin, logout, refreshUser } from '@/store'
|
||||||
|
import { confirm, toast } from '@/utils/feedback'
|
||||||
|
|
||||||
|
const userTabs = [
|
||||||
|
{ path: '/', label: '首页', exact: true },
|
||||||
|
{ path: '/detect', label: '发布' },
|
||||||
|
{ path: '/history', label: '历史' },
|
||||||
|
{ path: '/inbox', label: '私信' },
|
||||||
|
{ path: '/batch', label: '批量' },
|
||||||
|
{ path: '/profile', label: '我的' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const adminTabs = [
|
||||||
|
{ path: '/admin/dashboard', label: '运营看板' },
|
||||||
|
{ path: '/admin/review', label: '复核与申诉' },
|
||||||
|
{ path: '/admin/samples', label: '样本管理' },
|
||||||
|
{ path: '/admin/users', label: '用户管理' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'AppLayout',
|
||||||
|
directives: {
|
||||||
|
clickOutside: {
|
||||||
|
bind(el, binding) {
|
||||||
|
el.__clickOutsideHandler__ = (event) => {
|
||||||
|
if (!(el === event.target || el.contains(event.target))) {
|
||||||
|
binding.value(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('click', el.__clickOutsideHandler__)
|
||||||
|
},
|
||||||
|
unbind(el) {
|
||||||
|
document.removeEventListener('click', el.__clickOutsideHandler__)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
adminMenuOpen: false,
|
||||||
|
userTabs,
|
||||||
|
adminTabs
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
storeRef() {
|
||||||
|
return store
|
||||||
|
},
|
||||||
|
isAdminUser() {
|
||||||
|
return isAdmin()
|
||||||
|
},
|
||||||
|
userName() {
|
||||||
|
const u = store.user
|
||||||
|
return (u && (u.nickname || u.username)) || '未登录'
|
||||||
|
},
|
||||||
|
avatarText() {
|
||||||
|
const name = this.userName
|
||||||
|
if (!name) return 'U'
|
||||||
|
return name.slice(0, 1).toUpperCase()
|
||||||
|
},
|
||||||
|
isAdminRoute() {
|
||||||
|
return /^\/admin\//.test(this.$route.path)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
$route() {
|
||||||
|
this.adminMenuOpen = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
refreshUser()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toggleAdminMenu() {
|
||||||
|
this.adminMenuOpen = !this.adminMenuOpen
|
||||||
|
},
|
||||||
|
closeAdminMenu() {
|
||||||
|
this.adminMenuOpen = false
|
||||||
|
},
|
||||||
|
async handleLogout() {
|
||||||
|
const { confirm: ok } = await confirm({
|
||||||
|
title: '退出登录',
|
||||||
|
content: '确定要退出当前账号吗?',
|
||||||
|
confirmText: '退出',
|
||||||
|
cancelText: '取消'
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
logout()
|
||||||
|
toast('已退出登录', 'success')
|
||||||
|
this.$router.replace('/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
28
admin-web/src/main.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './styles/theme.css'
|
||||||
|
|
||||||
|
Vue.config.productionTip = false
|
||||||
|
|
||||||
|
Vue.config.errorHandler = (err, vm, info) => {
|
||||||
|
console.error('[vue error]', info, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
Vue.config.warnHandler = (msg, vm, trace) => {
|
||||||
|
console.warn('[vue warn]', msg, trace)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
console.warn('[unhandled rejection]', event.reason)
|
||||||
|
event.preventDefault()
|
||||||
|
})
|
||||||
|
|
||||||
|
window.addEventListener('error', (event) => {
|
||||||
|
console.error('[window error]', event.error || event.message)
|
||||||
|
})
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
router,
|
||||||
|
render: (h) => h(App)
|
||||||
|
}).$mount('#app')
|
||||||
102
admin-web/src/router/index.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
import VueRouter from 'vue-router'
|
||||||
|
import { store, isLoggedIn, isAdmin } from '@/store'
|
||||||
|
|
||||||
|
Vue.use(VueRouter)
|
||||||
|
|
||||||
|
const Login = () => import('@/views/Login.vue')
|
||||||
|
const Register = () => import('@/views/Register.vue')
|
||||||
|
const AppLayout = () => import('@/layouts/AppLayout.vue')
|
||||||
|
|
||||||
|
const Home = () => import('@/views/Home.vue')
|
||||||
|
const Detect = () => import('@/views/Detect.vue')
|
||||||
|
const History = () => import('@/views/History.vue')
|
||||||
|
const Inbox = () => import('@/views/Inbox.vue')
|
||||||
|
const Batch = () => import('@/views/Batch.vue')
|
||||||
|
const Profile = () => import('@/views/Profile.vue')
|
||||||
|
|
||||||
|
const AdminDashboard = () => import('@/views/admin/Dashboard.vue')
|
||||||
|
const AdminReview = () => import('@/views/admin/Review.vue')
|
||||||
|
const AdminSamples = () => import('@/views/admin/Samples.vue')
|
||||||
|
const AdminUsers = () => import('@/views/admin/Users.vue')
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{ path: '/login', name: 'login', component: Login, meta: { public: true } },
|
||||||
|
{ path: '/register', name: 'register', component: Register, meta: { public: true } },
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: AppLayout,
|
||||||
|
children: [
|
||||||
|
{ path: '', name: 'home', component: Home, meta: { title: '首页' } },
|
||||||
|
{ path: 'detect', name: 'detect', component: Detect, meta: { title: '信息发布' } },
|
||||||
|
{ path: 'history', name: 'history', component: History, meta: { title: '发布历史' } },
|
||||||
|
{ path: 'inbox', name: 'inbox', component: Inbox, meta: { title: '私信收件' } },
|
||||||
|
{ path: 'batch', name: 'batch', component: Batch, meta: { title: '批量识别' } },
|
||||||
|
{ path: 'profile', name: 'profile', component: Profile, meta: { title: '个人中心' } },
|
||||||
|
{
|
||||||
|
path: 'admin/dashboard',
|
||||||
|
name: 'admin-dashboard',
|
||||||
|
component: AdminDashboard,
|
||||||
|
meta: { title: '运营看板', requiresAdmin: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/review',
|
||||||
|
name: 'admin-review',
|
||||||
|
component: AdminReview,
|
||||||
|
meta: { title: '复核与申诉', requiresAdmin: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/samples',
|
||||||
|
name: 'admin-samples',
|
||||||
|
component: AdminSamples,
|
||||||
|
meta: { title: '样本管理', requiresAdmin: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/users',
|
||||||
|
name: 'admin-users',
|
||||||
|
component: AdminUsers,
|
||||||
|
meta: { title: '用户管理', requiresAdmin: true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ path: '*', redirect: '/' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = new VueRouter({
|
||||||
|
mode: 'hash',
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
if (to.meta && to.meta.public) {
|
||||||
|
if (isLoggedIn()) {
|
||||||
|
next({ path: '/' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
next({ path: '/login' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.matched.some((record) => record.meta && record.meta.requiresAdmin)) {
|
||||||
|
if (!isAdmin()) {
|
||||||
|
next({ path: '/' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
router.afterEach((to) => {
|
||||||
|
const title = (to.meta && to.meta.title) || ''
|
||||||
|
document.title = title ? `${title} · 内容风控管理后台` : '内容风控管理后台'
|
||||||
|
// touch store so admin guard re-evaluates when user data changes
|
||||||
|
void store.user
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
40
admin-web/src/store/index.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
import { getToken, setToken, getUser, setUser, clearAuth } from '@/utils/auth'
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
|
||||||
|
export const store = Vue.observable({
|
||||||
|
token: getToken(),
|
||||||
|
user: getUser()
|
||||||
|
})
|
||||||
|
|
||||||
|
export function isLoggedIn() {
|
||||||
|
return !!store.token
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAdmin() {
|
||||||
|
return !!(store.user && store.user.is_admin)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAuth(token, user) {
|
||||||
|
store.token = token || ''
|
||||||
|
store.user = user || null
|
||||||
|
setToken(token)
|
||||||
|
setUser(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout() {
|
||||||
|
store.token = ''
|
||||||
|
store.user = null
|
||||||
|
clearAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshUser() {
|
||||||
|
try {
|
||||||
|
const user = await request({ url: '/auth/me' })
|
||||||
|
store.user = user
|
||||||
|
setUser(user)
|
||||||
|
return user
|
||||||
|
} catch (err) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
1140
admin-web/src/styles/theme.css
Normal file
36
admin-web/src/utils/auth.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
const TOKEN_KEY = 'admin_web_token'
|
||||||
|
const USER_KEY = 'admin_web_user'
|
||||||
|
|
||||||
|
export function getToken() {
|
||||||
|
return localStorage.getItem(TOKEN_KEY) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(token) {
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUser() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(USER_KEY)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
} catch (err) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUser(user) {
|
||||||
|
if (user) {
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(user))
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(USER_KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuth() {
|
||||||
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
localStorage.removeItem(USER_KEY)
|
||||||
|
}
|
||||||
98
admin-web/src/utils/feedback.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
import ToastHost from '@/components/ToastHost.vue'
|
||||||
|
import ConfirmHost from '@/components/ConfirmHost.vue'
|
||||||
|
import ImagePreviewHost from '@/components/ImagePreviewHost.vue'
|
||||||
|
|
||||||
|
let toastInstance = null
|
||||||
|
let confirmInstance = null
|
||||||
|
let previewInstance = null
|
||||||
|
|
||||||
|
function mountInstance(Component) {
|
||||||
|
const Ctor = Vue.extend(Component)
|
||||||
|
const instance = new Ctor()
|
||||||
|
instance.$mount()
|
||||||
|
document.body.appendChild(instance.$el)
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureToast() {
|
||||||
|
if (!toastInstance) toastInstance = mountInstance(ToastHost)
|
||||||
|
return toastInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureConfirm() {
|
||||||
|
if (!confirmInstance) confirmInstance = mountInstance(ConfirmHost)
|
||||||
|
return confirmInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePreview() {
|
||||||
|
if (!previewInstance) previewInstance = mountInstance(ImagePreviewHost)
|
||||||
|
return previewInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toast(message, type = 'info', duration = 2000) {
|
||||||
|
if (!message) return
|
||||||
|
ensureToast().push({ message, type, duration })
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success = (message, duration) => toast(message, 'success', duration)
|
||||||
|
toast.error = (message, duration) => toast(message, 'error', duration)
|
||||||
|
toast.info = (message, duration) => toast(message, 'info', duration)
|
||||||
|
|
||||||
|
export function confirm({ title = '提示', content = '', confirmText = '确定', cancelText = '取消', showCancel = true } = {}) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
ensureConfirm().open({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
confirmText,
|
||||||
|
cancelText,
|
||||||
|
showCancel,
|
||||||
|
onConfirm: () => resolve({ confirm: true }),
|
||||||
|
onCancel: () => resolve({ confirm: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewImage(urls, current) {
|
||||||
|
if (!urls || !urls.length) return
|
||||||
|
ensurePreview().open({ urls, current })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyText(text) {
|
||||||
|
if (text === undefined || text === null) return false
|
||||||
|
const value = String(text)
|
||||||
|
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value)
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[copyText] navigator.clipboard 不可用,回退到 execCommand', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = value
|
||||||
|
textarea.setAttribute('readonly', '')
|
||||||
|
textarea.style.position = 'fixed'
|
||||||
|
textarea.style.top = '0'
|
||||||
|
textarea.style.left = '0'
|
||||||
|
textarea.style.width = '1px'
|
||||||
|
textarea.style.height = '1px'
|
||||||
|
textarea.style.opacity = '0'
|
||||||
|
textarea.style.pointerEvents = 'none'
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
|
||||||
|
textarea.focus()
|
||||||
|
textarea.select()
|
||||||
|
textarea.setSelectionRange(0, value.length)
|
||||||
|
|
||||||
|
const ok = document.execCommand('copy')
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
return !!ok
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[copyText] execCommand 也失败了', err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
125
admin-web/src/utils/request.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { getToken, clearAuth } from '@/utils/auth'
|
||||||
|
|
||||||
|
const BASE_URL = '/api'
|
||||||
|
|
||||||
|
const instance = axios.create({
|
||||||
|
baseURL: BASE_URL,
|
||||||
|
timeout: 30000
|
||||||
|
})
|
||||||
|
|
||||||
|
instance.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = getToken()
|
||||||
|
if (token) {
|
||||||
|
config.headers = config.headers || {}
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(err) => Promise.reject(err)
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleUnauthorized(url) {
|
||||||
|
console.warn('[auth] 登录已过期,自动退出登录', url || '')
|
||||||
|
clearAuth()
|
||||||
|
setTimeout(() => {
|
||||||
|
if (location.hash !== '#/login') {
|
||||||
|
location.hash = '#/login'
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
instance.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(err) => {
|
||||||
|
if (err && err.response && err.response.status === 401) {
|
||||||
|
handleUnauthorized(err.config && err.config.url)
|
||||||
|
}
|
||||||
|
return Promise.reject(err)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export function request({ url, method = 'GET', data, params, headers, responseType } = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
instance({ url, method, data, params, headers, responseType })
|
||||||
|
.then((res) => {
|
||||||
|
if (responseType === 'blob' || responseType === 'arraybuffer') {
|
||||||
|
resolve(res)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = res.data || {}
|
||||||
|
if (body.code === 0) {
|
||||||
|
resolve(body.data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = body.message || '请求失败'
|
||||||
|
console.error('[request]', method, url, '业务失败:', message, body)
|
||||||
|
reject(new Error(message))
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err && err.response && err.response.status === 401) {
|
||||||
|
reject(new Error('Unauthorized'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const msg = (err && err.message) || '网络异常'
|
||||||
|
console.error('[request]', method, url, '请求异常:', msg, err)
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadFile(file) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
instance({
|
||||||
|
url: '/upload/image',
|
||||||
|
method: 'POST',
|
||||||
|
data: formData,
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
const body = res.data || {}
|
||||||
|
if (body.code === 0) {
|
||||||
|
resolve(body.data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const message = body.message || '上传失败'
|
||||||
|
console.error('[upload] 业务失败:', message, body)
|
||||||
|
reject(new Error(message))
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err && err.response && err.response.status === 401) {
|
||||||
|
reject(new Error('Unauthorized'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const msg = (err && err.message) || '上传失败'
|
||||||
|
console.error('[upload] 请求异常:', msg, err)
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadBlob({ url, method = 'POST', data, filename }) {
|
||||||
|
return request({ url, method, data, responseType: 'blob' }).then((res) => {
|
||||||
|
const blob = new Blob([res.data])
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = URL.createObjectURL(blob)
|
||||||
|
link.download = filename || 'download'
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
setTimeout(() => {
|
||||||
|
URL.revokeObjectURL(link.href)
|
||||||
|
document.body.removeChild(link)
|
||||||
|
}, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServerBase() {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export default instance
|
||||||
295
admin-web/src/views/Batch.vue
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<section class="hero fade-up">
|
||||||
|
<div class="hero-badge">BATCH SCAN</div>
|
||||||
|
<h1 class="hero-title">批量文本筛查</h1>
|
||||||
|
<p class="hero-sub">上传 TXT 文件或手动输入,每行一条文本,系统自动逐行检测。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-1">
|
||||||
|
<div class="card-title">上传文件</div>
|
||||||
|
<div class="card-desc">支持 TXT 文本文件,每行一条待检测内容。</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<label class="btn btn-primary" style="position: relative;">
|
||||||
|
选择 TXT 文件
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".txt,text/plain"
|
||||||
|
style="position: absolute; inset: 0; opacity: 0; cursor: pointer;"
|
||||||
|
@change="onChooseFile"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field" v-if="fileName">
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">已选文件</span>
|
||||||
|
<span class="value">{{ fileName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">文本条数</span>
|
||||||
|
<span class="value">{{ lineCount }} 条</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-1">
|
||||||
|
<div class="card-title">手动输入</div>
|
||||||
|
<div class="card-desc">或直接粘贴文本内容,每行一条。</div>
|
||||||
|
<textarea
|
||||||
|
class="textarea"
|
||||||
|
v-model="inputText"
|
||||||
|
rows="8"
|
||||||
|
placeholder="示例: 点击链接领取红包 今天下午三点开会"
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-ghost" @click="fillDemo">填充示例</button>
|
||||||
|
<button class="btn btn-accent" :disabled="loading" @click="submit">
|
||||||
|
{{ loading ? '识别中...' : '开始识别' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-2" v-if="summary">
|
||||||
|
<div class="card-title">识别汇总</div>
|
||||||
|
<div class="grid-3">
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-value">{{ summary.total }}</div>
|
||||||
|
<div class="kpi-label">总条数</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-value">{{ summary.spam_count }}</div>
|
||||||
|
<div class="kpi-label">垃圾信息</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-value">{{ summary.ham_count }}</div>
|
||||||
|
<div class="kpi-label">正常信息</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">垃圾占比</span>
|
||||||
|
<span class="value">{{ summary.spam_ratio_text }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-track">
|
||||||
|
<div class="progress-fill" :style="{ width: summary.spam_ratio_text }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">拦截占比</span>
|
||||||
|
<span class="value">{{ summary.blocked_ratio_text }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-track">
|
||||||
|
<div class="progress-fill-safe" :style="{ width: summary.blocked_ratio_text }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-3" v-if="items.length">
|
||||||
|
<div class="card-title">明细结果</div>
|
||||||
|
<div class="btn-row" style="margin-bottom: 12px;">
|
||||||
|
<button class="btn btn-ghost" :disabled="exporting" @click="exportXLSX">
|
||||||
|
{{ exporting ? '导出中...' : '导出 Excel 文件' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost" @click="copyCSV">复制 CSV 内容</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list-item" v-for="(item, idx) in items" :key="idx">
|
||||||
|
<div class="item-title">{{ item.text }}</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">判定结果</span>
|
||||||
|
<span :class="item.prediction === 'spam' ? 'status-spam' : 'status-ham'">{{ item.prediction_text }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" v-if="item.category_label">
|
||||||
|
<span class="label">分类标签</span>
|
||||||
|
<span class="status-spam">{{ item.category_label }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">置信度</span>
|
||||||
|
<span class="value">{{ item.confidence_text }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-track">
|
||||||
|
<div
|
||||||
|
:class="item.prediction === 'spam' ? 'progress-fill' : 'progress-fill-safe'"
|
||||||
|
:style="{ width: item.confidence_text }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="field" v-if="item.reason_tokens && item.reason_tokens.length">
|
||||||
|
<span class="field-label">风险关键词</span>
|
||||||
|
<div class="chip-group">
|
||||||
|
<span
|
||||||
|
v-for="(tk, i) in item.reason_tokens"
|
||||||
|
:key="i"
|
||||||
|
class="tag tag-danger"
|
||||||
|
@click="showTokenWeight(tk)"
|
||||||
|
>{{ tk.token }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import { toast, confirm, copyText } from '@/utils/feedback'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'BatchView',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
inputText: '',
|
||||||
|
fileName: '',
|
||||||
|
lineCount: 0,
|
||||||
|
loading: false,
|
||||||
|
exporting: false,
|
||||||
|
summary: null,
|
||||||
|
items: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatPercent(value, digits = 2) {
|
||||||
|
return `${(Number(value || 0) * 100).toFixed(digits)}%`
|
||||||
|
},
|
||||||
|
parseLines() {
|
||||||
|
return (this.inputText || '')
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length >= 2)
|
||||||
|
},
|
||||||
|
onChooseFile(e) {
|
||||||
|
const file = (e.target.files || [])[0]
|
||||||
|
if (!file) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
const content = String(reader.result || '')
|
||||||
|
const lines = content
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length >= 2)
|
||||||
|
this.inputText = lines.join('\n')
|
||||||
|
this.fileName = file.name
|
||||||
|
this.lineCount = lines.length
|
||||||
|
toast(`已读取 ${lines.length} 条文本`, 'success')
|
||||||
|
}
|
||||||
|
reader.onerror = (e) => {
|
||||||
|
console.error('[batch] 文件读取失败', e)
|
||||||
|
toast('文件读取失败', 'error')
|
||||||
|
}
|
||||||
|
reader.readAsText(file, 'utf-8')
|
||||||
|
e.target.value = ''
|
||||||
|
},
|
||||||
|
async submit() {
|
||||||
|
if (this.loading) return
|
||||||
|
const items = this.parseLines()
|
||||||
|
if (!items.length) {
|
||||||
|
toast('请至少输入一条有效文本', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
const data = await request({ url: '/spam/predict/batch', method: 'POST', data: { items } })
|
||||||
|
const sum = data.summary || {}
|
||||||
|
this.summary = {
|
||||||
|
...sum,
|
||||||
|
spam_ratio_text: this.formatPercent(sum.spam_ratio, 2),
|
||||||
|
blocked_ratio_text: this.formatPercent(sum.blocked_ratio, 2)
|
||||||
|
}
|
||||||
|
this.items = (data.items || []).map((item) => ({
|
||||||
|
...item,
|
||||||
|
confidence_text: this.formatPercent(item.confidence, 2)
|
||||||
|
}))
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fillDemo() {
|
||||||
|
this.inputText = [
|
||||||
|
'点击链接领取购物补贴,名额有限。',
|
||||||
|
'明天下午三点上线前演练。',
|
||||||
|
'高薪兼职日结,扫码进群。',
|
||||||
|
'测试报告我已经同步到项目群。'
|
||||||
|
].join('\n')
|
||||||
|
},
|
||||||
|
showTokenWeight(tk) {
|
||||||
|
const w = Number(tk.weight || 0)
|
||||||
|
const direction = w >= 0 ? '倾向垃圾判定' : '倾向正常判定'
|
||||||
|
confirm({
|
||||||
|
title: '关键词权重',
|
||||||
|
content: `关键词「${tk.token}」\n权重贡献:${w >= 0 ? '+' : ''}${w.toFixed(4)}\n(${direction})`,
|
||||||
|
showCancel: false,
|
||||||
|
confirmText: '关闭'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
generateCSV() {
|
||||||
|
if (!this.items.length) return ''
|
||||||
|
const headers = ['文本', '判定结果', '分类标签', '置信度', '垃圾概率', '正常概率', '风险关键词']
|
||||||
|
const rows = this.items.map((item) => {
|
||||||
|
const prediction = item.prediction === 'spam' ? '垃圾信息' : '正常信息'
|
||||||
|
const categoryLabel = item.category_label || ''
|
||||||
|
const confidence = item.confidence_text || '0%'
|
||||||
|
const spamProb = this.formatPercent(item.spam_probability, 4)
|
||||||
|
const hamProb = this.formatPercent(item.ham_probability, 4)
|
||||||
|
const tokens = (item.reason_tokens || []).map((t) => t.token || t).join('; ')
|
||||||
|
const text = (item.text || '').replace(/"/g, '""')
|
||||||
|
const tokensEscaped = tokens.replace(/"/g, '""')
|
||||||
|
return `"${text}","${prediction}","${categoryLabel}","${confidence}","${spamProb}","${hamProb}","${tokensEscaped}"`
|
||||||
|
})
|
||||||
|
return [headers.join(','), ...rows].join('\n')
|
||||||
|
},
|
||||||
|
async exportXLSX() {
|
||||||
|
if (!this.items.length) {
|
||||||
|
toast('暂无识别结果可导出', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.exporting = true
|
||||||
|
try {
|
||||||
|
const res = await request({
|
||||||
|
url: '/spam/export/xlsx',
|
||||||
|
method: 'POST',
|
||||||
|
data: { items: this.items },
|
||||||
|
responseType: 'blob'
|
||||||
|
})
|
||||||
|
const blob = new Blob([res.data], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
})
|
||||||
|
const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')
|
||||||
|
const filename = `batch_detect_${ts}.xlsx`
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = URL.createObjectURL(blob)
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
setTimeout(() => {
|
||||||
|
URL.revokeObjectURL(link.href)
|
||||||
|
document.body.removeChild(link)
|
||||||
|
}, 0)
|
||||||
|
toast('导出成功', 'success')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[batch] 导出失败', err)
|
||||||
|
toast('导出失败', 'error')
|
||||||
|
} finally {
|
||||||
|
this.exporting = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async copyCSV() {
|
||||||
|
if (!this.items.length) {
|
||||||
|
toast('暂无识别结果可复制', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const csv = this.generateCSV()
|
||||||
|
const ok = await copyText(csv)
|
||||||
|
if (ok) {
|
||||||
|
toast('CSV 内容已复制到剪贴板', 'success')
|
||||||
|
} else {
|
||||||
|
console.error('[batch] 复制失败,CSV 内容打印到控制台供手动复制\n' + csv)
|
||||||
|
toast('复制失败,请手动选择文本', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
189
admin-web/src/views/Detect.vue
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<section class="hero fade-up">
|
||||||
|
<div class="hero-badge">REAL-TIME CHECK</div>
|
||||||
|
<h1 class="hero-title">文本信息发布</h1>
|
||||||
|
<p class="hero-sub">支持公开发布、私有发布与用户私信,提交时自动执行垃圾信息识别。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-1">
|
||||||
|
<div class="card-title">发布内容</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">内容文本</label>
|
||||||
|
<textarea class="textarea" v-model="text" placeholder="请输入要发布的文本信息" rows="4"></textarea>
|
||||||
|
<div class="field-help">当前字数:{{ text.length }},建议不少于 2 个字符。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">发布类型</label>
|
||||||
|
<select class="select" v-model="visibility">
|
||||||
|
<option v-for="opt in visibilityOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field" v-if="visibility === 'direct'">
|
||||||
|
<label class="field-label">接收人用户名</label>
|
||||||
|
<input class="input" v-model.trim="recipientUsername" placeholder="私信发送时必填" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" :disabled="loading" @click="publish">
|
||||||
|
{{ loading ? '检测中...' : '提交发布' }}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-2">
|
||||||
|
<div class="card-title">快捷示例</div>
|
||||||
|
<div class="chip-group">
|
||||||
|
<span
|
||||||
|
v-for="(item, idx) in quickTexts"
|
||||||
|
:key="idx"
|
||||||
|
class="chip"
|
||||||
|
@click="text = item"
|
||||||
|
>{{ item }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-3" v-if="result">
|
||||||
|
<div class="card-title">识别反馈</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">发布结果</span>
|
||||||
|
<span :class="result.publish_allowed ? 'status-ham' : 'status-spam'">
|
||||||
|
{{ result.publish_allowed ? '发布成功' : '已拦截,需申诉' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" v-if="result.detect && result.detect.category_label">
|
||||||
|
<span class="label">分类标签</span>
|
||||||
|
<span class="status-spam">{{ result.detect.category_label }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">模型判断</span>
|
||||||
|
<span class="value">{{ result.detect && result.detect.prediction_text }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">垃圾概率</span>
|
||||||
|
<span class="value">{{ result.detect_spam_probability_text }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-track">
|
||||||
|
<div class="progress-fill" :style="{ width: result.detect_spam_probability_text }"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">检测置信度</span>
|
||||||
|
<span class="value">{{ result.detect.confidence_text }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-track">
|
||||||
|
<div class="progress-fill-safe" :style="{ width: result.detect.confidence_text }"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">本次阈值</span>
|
||||||
|
<span class="value">{{ result.post_threshold_text }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field" v-if="result.detect.reason_tokens && result.detect.reason_tokens.length">
|
||||||
|
<span class="field-label">风险关键词</span>
|
||||||
|
<div class="chip-group">
|
||||||
|
<span
|
||||||
|
v-for="(tk, i) in result.detect.reason_tokens"
|
||||||
|
:key="i"
|
||||||
|
class="tag tag-danger"
|
||||||
|
@click="showTokenWeight(tk)"
|
||||||
|
>{{ tk.token }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-ghost" @click="$router.push('/history')">查看发布历史</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import { toast, confirm } from '@/utils/feedback'
|
||||||
|
|
||||||
|
const QUICK_TEXTS = [
|
||||||
|
'大家好,今晚 8 点社区线上读书会,欢迎参加。',
|
||||||
|
'恭喜中奖领取大额现金,点击链接立即到账。',
|
||||||
|
'本周活动报名已开放,请在群里接龙。',
|
||||||
|
'高薪兼职日结,扫码进群立刻赚钱。'
|
||||||
|
]
|
||||||
|
|
||||||
|
const VISIBILITY_OPTIONS = [
|
||||||
|
{ value: 'public', label: '公开信息发布' },
|
||||||
|
{ value: 'private', label: '私有信息发布' },
|
||||||
|
{ value: 'direct', label: '用户私信发布' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'DetectView',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
text: '',
|
||||||
|
loading: false,
|
||||||
|
result: null,
|
||||||
|
quickTexts: QUICK_TEXTS,
|
||||||
|
visibilityOptions: VISIBILITY_OPTIONS,
|
||||||
|
visibility: 'public',
|
||||||
|
recipientUsername: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatPercent(value, digits = 2) {
|
||||||
|
const num = Number(value || 0)
|
||||||
|
return `${(num * 100).toFixed(digits)}%`
|
||||||
|
},
|
||||||
|
async publish() {
|
||||||
|
if (this.loading) return
|
||||||
|
const text = (this.text || '').trim()
|
||||||
|
if (text.length < 2) {
|
||||||
|
toast('请输入至少 2 个字符', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload = { text, visibility: this.visibility }
|
||||||
|
if (this.visibility === 'direct') {
|
||||||
|
const receiver = (this.recipientUsername || '').trim()
|
||||||
|
if (!receiver) {
|
||||||
|
toast('私信请填写接收人用户名', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload.recipient_username = receiver
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
const result = await request({ url: '/content/publish', method: 'POST', data: payload })
|
||||||
|
this.result = {
|
||||||
|
...result,
|
||||||
|
detect: {
|
||||||
|
...(result.detect || {}),
|
||||||
|
confidence_text: this.formatPercent((result.detect || {}).confidence, 2)
|
||||||
|
},
|
||||||
|
post_threshold_text: this.formatPercent((result.post || {}).threshold, 1),
|
||||||
|
detect_spam_probability_text: this.formatPercent((result.detect || {}).spam_probability, 2)
|
||||||
|
}
|
||||||
|
toast(result.publish_allowed ? '发布成功' : '已拦截,可申诉', result.publish_allowed ? 'success' : 'error')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[detect] 发布失败', err)
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showTokenWeight(tk) {
|
||||||
|
const weight = Number(tk.weight || 0)
|
||||||
|
const direction = weight >= 0 ? '倾向垃圾判定' : '倾向正常判定'
|
||||||
|
confirm({
|
||||||
|
title: '关键词权重',
|
||||||
|
content: `关键词「${tk.token}」\n权重贡献:${weight >= 0 ? '+' : ''}${weight.toFixed(4)}\n(${direction})`,
|
||||||
|
showCancel: false,
|
||||||
|
confirmText: '关闭'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
297
admin-web/src/views/History.vue
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<section class="hero fade-up">
|
||||||
|
<div class="hero-badge">PUBLISH HISTORY</div>
|
||||||
|
<h1 class="hero-title">个人发布历史</h1>
|
||||||
|
<p class="hero-sub">查看公开/私有/私信发布结果,被拦截内容可在线提交申诉。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-1">
|
||||||
|
<div class="card-title">筛选条件</div>
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">发布状态</label>
|
||||||
|
<select class="select" v-model="statusFilter" @change="fetchList()">
|
||||||
|
<option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">发布类型</label>
|
||||||
|
<select class="select" v-model="visibilityFilter" @change="fetchList()">
|
||||||
|
<option v-for="opt in visibilityOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-2" v-if="list.length">
|
||||||
|
<div class="card-title">历史记录</div>
|
||||||
|
|
||||||
|
<div class="list-item" v-for="item in list" :key="item.id">
|
||||||
|
<div class="item-title">{{ item.text }}</div>
|
||||||
|
<div class="item-sub">类型:{{ item.visibility_text }} · 时间:{{ item.created_text }}</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">发布状态</span>
|
||||||
|
<span :class="item.status === 'blocked' ? 'status-spam' : 'status-ham'">
|
||||||
|
{{ item.status === 'blocked' ? '已拦截' : '已发布' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" v-if="item.category_label">
|
||||||
|
<span class="label">分类标签</span>
|
||||||
|
<span class="status-spam">{{ item.category_label }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">垃圾概率</span>
|
||||||
|
<span class="value">{{ item.spam_probability_text }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-track">
|
||||||
|
<div class="progress-fill" :style="{ width: item.spam_probability_text }"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">申诉状态</span>
|
||||||
|
<span class="value">{{ item.appeal_status_text }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field" v-if="item.reason_tokens && item.reason_tokens.length">
|
||||||
|
<span class="field-label">风险关键词</span>
|
||||||
|
<div class="chip-group">
|
||||||
|
<span
|
||||||
|
v-for="(tk, i) in item.reason_tokens"
|
||||||
|
:key="i"
|
||||||
|
class="tag tag-danger"
|
||||||
|
@click="showTokenWeight(tk)"
|
||||||
|
>{{ tk.token }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="item.status === 'blocked' && item.appeal_status !== 'pending' && appealPostId !== item.id"
|
||||||
|
>
|
||||||
|
<button class="btn btn-accent" @click="startAppeal(item.id)">发起申诉</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="appealPostId === item.id" style="margin-top: 12px;">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">申诉理由类型</label>
|
||||||
|
<select class="select" v-model="appealReasonTypeValue" @change="onReasonTypeChange">
|
||||||
|
<option v-for="opt in appealReasonTypeOptions" :key="opt.label" :value="opt.value">{{ opt.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
class="textarea"
|
||||||
|
v-model="appealReason"
|
||||||
|
placeholder="可补充申诉理由(选择快捷理由后可省略)"
|
||||||
|
></textarea>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">证据截图(最多 3 张)</label>
|
||||||
|
<div class="evidence-grid">
|
||||||
|
<div class="evidence-item" v-for="(file, idx) in appealEvidenceFiles" :key="idx">
|
||||||
|
<img class="evidence-thumb" :src="file.preview" alt="evidence" />
|
||||||
|
<div class="evidence-remove" @click="removeEvidence(idx)">×</div>
|
||||||
|
</div>
|
||||||
|
<label class="evidence-add" v-if="appealEvidenceFiles.length < 3">
|
||||||
|
<span class="evidence-add-icon">+</span>
|
||||||
|
<input type="file" accept="image/*" @change="onPickEvidence" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-primary" :disabled="appealSubmitting" @click="submitAppeal">
|
||||||
|
{{ appealSubmitting ? '提交中...' : '提交申诉' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost" @click="cancelAppeal">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-ghost btn-sm" style="margin-top: 12px;" @click="removeItem(item.id)">删除记录</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-2" v-else>
|
||||||
|
<div class="empty">{{ loading ? '加载中...' : '暂无发布记录,先去「发布」页面提交文本。' }}</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { request, uploadFile } from '@/utils/request'
|
||||||
|
import { toast, confirm } from '@/utils/feedback'
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: '', label: '全部状态' },
|
||||||
|
{ value: 'published', label: '发布成功' },
|
||||||
|
{ value: 'blocked', label: '已拦截' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const VISIBILITY_OPTIONS = [
|
||||||
|
{ value: '', label: '全部类型' },
|
||||||
|
{ value: 'public', label: '公开' },
|
||||||
|
{ value: 'private', label: '私有' },
|
||||||
|
{ value: 'direct', label: '私信' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const VIS_LABEL = { public: '公开信息', private: '私有信息', direct: '用户私信' }
|
||||||
|
const APPEAL_STATUS_TEXT = { none: '未发起', pending: '处理中', approved: '已通过', rejected: '已驳回' }
|
||||||
|
const CATEGORY_LABELS = { fraud: '疑似诈骗', harassment: '疑似骚扰', advertisement: '疑似广告', spam: '疑似垃圾' }
|
||||||
|
|
||||||
|
const REASON_TYPE_OPTIONS = [
|
||||||
|
{ value: '', label: '请选择申诉理由类型' },
|
||||||
|
{ value: '正常活动文案', label: '正常活动文案' },
|
||||||
|
{ value: '正常社区通知', label: '正常社区通知' },
|
||||||
|
{ value: '私信沟通内容', label: '私信沟通内容' },
|
||||||
|
{ value: '__other__', label: '其他(需手动填写)' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'HistoryView',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
list: [],
|
||||||
|
statusOptions: STATUS_OPTIONS,
|
||||||
|
visibilityOptions: VISIBILITY_OPTIONS,
|
||||||
|
appealReasonTypeOptions: REASON_TYPE_OPTIONS,
|
||||||
|
statusFilter: '',
|
||||||
|
visibilityFilter: '',
|
||||||
|
appealPostId: null,
|
||||||
|
appealReasonTypeValue: '',
|
||||||
|
appealReason: '',
|
||||||
|
appealEvidenceFiles: [],
|
||||||
|
appealSubmitting: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.fetchList()
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.appealEvidenceFiles.forEach((f) => f.preview && URL.revokeObjectURL(f.preview))
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatPercent(value, digits = 2) {
|
||||||
|
return `${(Number(value || 0) * 100).toFixed(digits)}%`
|
||||||
|
},
|
||||||
|
async fetchList() {
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
const data = await request({
|
||||||
|
url: '/content/posts/history',
|
||||||
|
params: {
|
||||||
|
status: this.statusFilter,
|
||||||
|
visibility: this.visibilityFilter,
|
||||||
|
page: 1,
|
||||||
|
page_size: 80
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.list = (data.items || []).map((item) => ({
|
||||||
|
...item,
|
||||||
|
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19),
|
||||||
|
spam_probability_text: this.formatPercent(item.spam_probability, 2),
|
||||||
|
visibility_text: VIS_LABEL[item.visibility] || item.visibility,
|
||||||
|
appeal_status_text: APPEAL_STATUS_TEXT[item.appeal_status] || item.appeal_status,
|
||||||
|
category_label: CATEGORY_LABELS[item.category] || ''
|
||||||
|
}))
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
startAppeal(postId) {
|
||||||
|
this.cleanEvidencePreviews()
|
||||||
|
this.appealPostId = postId
|
||||||
|
this.appealReasonTypeValue = ''
|
||||||
|
this.appealReason = ''
|
||||||
|
this.appealEvidenceFiles = []
|
||||||
|
},
|
||||||
|
cancelAppeal() {
|
||||||
|
this.cleanEvidencePreviews()
|
||||||
|
this.appealPostId = null
|
||||||
|
this.appealReasonTypeValue = ''
|
||||||
|
this.appealReason = ''
|
||||||
|
this.appealEvidenceFiles = []
|
||||||
|
},
|
||||||
|
onReasonTypeChange() {
|
||||||
|
// "其他" 不写入 reason_type 字段,让用户手动填写理由
|
||||||
|
},
|
||||||
|
cleanEvidencePreviews() {
|
||||||
|
this.appealEvidenceFiles.forEach((f) => f.preview && URL.revokeObjectURL(f.preview))
|
||||||
|
},
|
||||||
|
onPickEvidence(e) {
|
||||||
|
const files = Array.from(e.target.files || [])
|
||||||
|
const remaining = 3 - this.appealEvidenceFiles.length
|
||||||
|
const toAdd = files.slice(0, remaining).map((file) => ({
|
||||||
|
file,
|
||||||
|
preview: URL.createObjectURL(file)
|
||||||
|
}))
|
||||||
|
this.appealEvidenceFiles = [...this.appealEvidenceFiles, ...toAdd]
|
||||||
|
e.target.value = ''
|
||||||
|
},
|
||||||
|
removeEvidence(idx) {
|
||||||
|
const item = this.appealEvidenceFiles[idx]
|
||||||
|
if (item && item.preview) URL.revokeObjectURL(item.preview)
|
||||||
|
this.appealEvidenceFiles.splice(idx, 1)
|
||||||
|
},
|
||||||
|
async uploadAllEvidence() {
|
||||||
|
const urls = []
|
||||||
|
for (const item of this.appealEvidenceFiles) {
|
||||||
|
try {
|
||||||
|
const res = await uploadFile(item.file)
|
||||||
|
if (res && res.url) urls.push(res.url)
|
||||||
|
} catch (err) {
|
||||||
|
// toast already shown by uploadFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return urls
|
||||||
|
},
|
||||||
|
async submitAppeal() {
|
||||||
|
const postId = this.appealPostId
|
||||||
|
if (!postId) return
|
||||||
|
const rt = this.appealReasonTypeValue
|
||||||
|
const reasonType = rt && rt !== '__other__' ? rt : ''
|
||||||
|
const reason = (this.appealReason || '').trim()
|
||||||
|
if (!reasonType && reason.length < 2) {
|
||||||
|
toast('申诉理由至少 2 个字符', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.appealSubmitting = true
|
||||||
|
try {
|
||||||
|
const evidenceUrls = await this.uploadAllEvidence()
|
||||||
|
await request({
|
||||||
|
url: `/content/posts/${postId}/appeal`,
|
||||||
|
method: 'POST',
|
||||||
|
data: { reason_type: reasonType, reason, evidence_urls: evidenceUrls }
|
||||||
|
})
|
||||||
|
toast('申诉提交成功', 'success')
|
||||||
|
this.cancelAppeal()
|
||||||
|
this.fetchList()
|
||||||
|
} finally {
|
||||||
|
this.appealSubmitting = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showTokenWeight(tk) {
|
||||||
|
const w = Number(tk.weight || 0)
|
||||||
|
const direction = w >= 0 ? '倾向垃圾判定' : '倾向正常判定'
|
||||||
|
confirm({
|
||||||
|
title: '关键词权重',
|
||||||
|
content: `关键词「${tk.token}」\n权重贡献:${w >= 0 ? '+' : ''}${w.toFixed(4)}\n(${direction})`,
|
||||||
|
showCancel: false,
|
||||||
|
confirmText: '关闭'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async removeItem(id) {
|
||||||
|
const { confirm: ok } = await confirm({
|
||||||
|
title: '删除确认',
|
||||||
|
content: '确定删除这条发布记录吗?',
|
||||||
|
confirmText: '删除',
|
||||||
|
cancelText: '取消'
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
await request({ url: `/content/posts/${id}`, method: 'DELETE' })
|
||||||
|
toast('删除成功', 'success')
|
||||||
|
this.fetchList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
138
admin-web/src/views/Home.vue
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<section class="hero fade-up">
|
||||||
|
<div class="hero-badge">CONTROL CENTER</div>
|
||||||
|
<h1 class="hero-title">{{ user ? '欢迎,' + (user.nickname || user.username) : '社区内容风控工作台' }}</h1>
|
||||||
|
<p class="hero-sub">发布内容将实时进入朴素贝叶斯识别流程,疑似垃圾信息自动拦截并支持申诉。</p>
|
||||||
|
<div class="hero-meta" v-if="modelInfo">
|
||||||
|
<span class="hero-metric">版本 {{ modelInfo.version || '未训练' }}</span>
|
||||||
|
<span class="hero-metric">阈值 {{ thresholdText }}</span>
|
||||||
|
<span class="hero-metric">样本 {{ modelInfo.sample_count || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-1" v-if="modelInfo">
|
||||||
|
<div class="card-title">检测引擎状态</div>
|
||||||
|
<div class="grid-4">
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-label">模型版本</div>
|
||||||
|
<div class="kpi-value">{{ modelInfo.version || '未训练' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-label">训练样本</div>
|
||||||
|
<div class="kpi-value">{{ modelInfo.sample_count || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-label">垃圾阈值</div>
|
||||||
|
<div class="kpi-value">{{ thresholdText }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-label">最近训练</div>
|
||||||
|
<div class="kpi-value small">{{ modelInfo.trained_at || '--' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-2">
|
||||||
|
<div class="card-title">常用功能</div>
|
||||||
|
<div class="card-desc">日常使用的发布、检测与管理入口。</div>
|
||||||
|
<div class="grid-3">
|
||||||
|
<div
|
||||||
|
v-for="item in userModules"
|
||||||
|
:key="item.path"
|
||||||
|
class="module-card"
|
||||||
|
@click="goto(item.path)"
|
||||||
|
>
|
||||||
|
<div class="module-name">{{ item.name }}</div>
|
||||||
|
<div class="module-desc">{{ item.desc }}</div>
|
||||||
|
<div class="module-tag">{{ item.tag }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-3" v-if="isAdminUser">
|
||||||
|
<div class="card-title">管理员功能</div>
|
||||||
|
<div class="card-desc">支持阈值调节、复核处理、样本维护和用户管理。</div>
|
||||||
|
<div class="grid-2">
|
||||||
|
<div
|
||||||
|
v-for="item in adminModules"
|
||||||
|
:key="item.path"
|
||||||
|
class="module-card"
|
||||||
|
@click="goto(item.path)"
|
||||||
|
>
|
||||||
|
<div class="module-name">{{ item.name }}</div>
|
||||||
|
<div class="module-desc">{{ item.desc }}</div>
|
||||||
|
<div class="module-tag">{{ item.tag }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import { store, isAdmin, refreshUser } from '@/store'
|
||||||
|
|
||||||
|
const USER_MODULES = [
|
||||||
|
{ name: '信息发布', desc: '公开/私有/私信内容发布与实时检测', tag: '内容发布', path: '/detect' },
|
||||||
|
{ name: '发布历史', desc: '查看历史发布并发起申诉', tag: '历史追溯', path: '/history' },
|
||||||
|
{ name: '私信收件', desc: '查看通过检测的私信内容', tag: '收件箱', path: '/inbox' },
|
||||||
|
{ name: '批量识别', desc: '多条文本批量检测并给出风险汇总', tag: '批量筛查', path: '/batch' },
|
||||||
|
{ name: '个人中心', desc: '维护身份资料,修改密码', tag: '账号设置', path: '/profile' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const ADMIN_MODULES = [
|
||||||
|
{ name: '运营看板', desc: '监控发布、拦截、样本和模型状态', tag: '数据概览', path: '/admin/dashboard' },
|
||||||
|
{ name: '复核与申诉', desc: '处理拦截复核和用户申诉', tag: '审核处理', path: '/admin/review' },
|
||||||
|
{ name: '样本管理', desc: '维护训练样本并触发模型重训', tag: '模型迭代', path: '/admin/samples' },
|
||||||
|
{ name: '用户管理', desc: '编辑用户信息和权限', tag: '权限管理', path: '/admin/users' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'HomeView',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: true,
|
||||||
|
modelInfo: null,
|
||||||
|
userModules: USER_MODULES,
|
||||||
|
adminModules: ADMIN_MODULES
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
user() {
|
||||||
|
return store.user
|
||||||
|
},
|
||||||
|
isAdminUser() {
|
||||||
|
return isAdmin()
|
||||||
|
},
|
||||||
|
thresholdText() {
|
||||||
|
const t = this.modelInfo && this.modelInfo.threshold
|
||||||
|
if (t === null || t === undefined) return '--'
|
||||||
|
return `${(Number(t) * 100).toFixed(1)}%`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.bootstrap()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async bootstrap() {
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
await refreshUser()
|
||||||
|
const modelInfo = await request({ url: '/spam/model/info' }).catch((err) => {
|
||||||
|
console.warn('[home] 模型信息加载失败', err)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (modelInfo) this.modelInfo = modelInfo
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[home] bootstrap 异常', err)
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
goto(path) {
|
||||||
|
this.$router.push(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
56
admin-web/src/views/Inbox.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<section class="hero fade-up">
|
||||||
|
<div class="hero-badge">INBOX</div>
|
||||||
|
<h1 class="hero-title">用户私信收件箱</h1>
|
||||||
|
<p class="hero-sub">仅展示通过检测后成功送达的私信内容。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-1" v-if="list.length">
|
||||||
|
<div class="card-title">私信列表</div>
|
||||||
|
<div class="list-item" v-for="item in list" :key="item.id">
|
||||||
|
<div class="item-title">{{ item.text }}</div>
|
||||||
|
<div class="item-sub">发送人:{{ item.nickname || item.username }}({{ item.username }})</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">发送时间</span>
|
||||||
|
<span class="value">{{ item.created_text }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-1" v-else>
|
||||||
|
<div class="empty">{{ loading ? '加载中...' : '暂无私信内容。' }}</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'InboxView',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
list: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.fetchList()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async fetchList() {
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
const data = await request({ url: '/content/posts/inbox', params: { page: 1, page_size: 80 } })
|
||||||
|
this.list = (data.items || []).map((item) => ({
|
||||||
|
...item,
|
||||||
|
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19)
|
||||||
|
}))
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
86
admin-web/src/views/Login.vue
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<section class="hero fade-up">
|
||||||
|
<div class="hero-badge">SECURE ACCESS</div>
|
||||||
|
<h1 class="hero-title">垃圾信息识别平台</h1>
|
||||||
|
<p class="hero-sub">登录后即可使用文本检测、发布拦截、申诉处理和管理看板能力。</p>
|
||||||
|
<div class="hero-meta">
|
||||||
|
<span class="hero-metric">实时风控</span>
|
||||||
|
<span class="hero-metric">闭环审核</span>
|
||||||
|
<span class="hero-metric">模型迭代</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-1" style="max-width: 460px; margin: 24px auto;">
|
||||||
|
<div class="card-title">账号登录</div>
|
||||||
|
<div class="card-desc">请输入用户名和密码,进入你的工作台。</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">用户名</label>
|
||||||
|
<input class="input" v-model.trim="username" placeholder="请输入用户名" @keyup.enter="submit" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">密码</label>
|
||||||
|
<input class="input" type="password" v-model.trim="password" placeholder="请输入密码" @keyup.enter="submit" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" :disabled="loading" @click="submit" style="width: 100%;">
|
||||||
|
{{ loading ? '登录中...' : '立即登录' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-ghost" type="button" @click="fillDemo">填充演示账号</button>
|
||||||
|
<button class="btn btn-accent" type="button" @click="goRegister">注册新账号</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import { setAuth } from '@/store'
|
||||||
|
import { toast } from '@/utils/feedback'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'LoginView',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
loading: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
fillDemo() {
|
||||||
|
this.username = 'admin'
|
||||||
|
this.password = 'Admin@123456'
|
||||||
|
},
|
||||||
|
goRegister() {
|
||||||
|
this.$router.push('/register')
|
||||||
|
},
|
||||||
|
async submit() {
|
||||||
|
if (this.loading) return
|
||||||
|
if (!this.username || !this.password) {
|
||||||
|
toast('请输入用户名和密码', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
const data = await request({
|
||||||
|
url: '/auth/login',
|
||||||
|
method: 'POST',
|
||||||
|
data: { username: this.username, password: this.password }
|
||||||
|
})
|
||||||
|
setAuth(data.token, data.user)
|
||||||
|
toast('登录成功', 'success')
|
||||||
|
setTimeout(() => this.$router.replace('/'), 200)
|
||||||
|
} catch (err) {
|
||||||
|
// toast already triggered
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
145
admin-web/src/views/Profile.vue
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<section class="hero fade-up">
|
||||||
|
<div class="hero-badge">PROFILE</div>
|
||||||
|
<h1 class="hero-title">个人中心</h1>
|
||||||
|
<p class="hero-sub">完善你的身份信息,便于审计追踪和团队协同。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-1">
|
||||||
|
<div class="card-title">账号资料</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">昵称</label>
|
||||||
|
<input class="input" v-model.trim="form.nickname" placeholder="请输入昵称" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">手机号</label>
|
||||||
|
<input class="input" v-model.trim="form.phone" placeholder="请输入手机号" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">公司</label>
|
||||||
|
<input class="input" v-model.trim="form.company" placeholder="请输入公司名称" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">岗位</label>
|
||||||
|
<input class="input" v-model.trim="form.title" placeholder="请输入岗位" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">新密码</label>
|
||||||
|
<input class="input" type="password" v-model.trim="form.password" placeholder="留空表示不修改" />
|
||||||
|
<div class="field-help">密码长度需不少于 6 位。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" :disabled="loading" @click="save">
|
||||||
|
{{ loading ? '保存中...' : '保存资料' }}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-2" v-if="isAdminUser">
|
||||||
|
<div class="card-title">管理员入口</div>
|
||||||
|
<div class="card-desc">管理后台功能快速访问。</div>
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="module-card" @click="$router.push('/admin/dashboard')">
|
||||||
|
<div class="module-name">运营看板</div>
|
||||||
|
<div class="module-desc">监控发布、拦截、样本和模型状态</div>
|
||||||
|
<div class="module-tag">数据概览</div>
|
||||||
|
</div>
|
||||||
|
<div class="module-card" @click="$router.push('/admin/review')">
|
||||||
|
<div class="module-name">复核与申诉</div>
|
||||||
|
<div class="module-desc">处理拦截复核和用户申诉</div>
|
||||||
|
<div class="module-tag">审核处理</div>
|
||||||
|
</div>
|
||||||
|
<div class="module-card" @click="$router.push('/admin/samples')">
|
||||||
|
<div class="module-name">样本管理</div>
|
||||||
|
<div class="module-desc">维护训练样本并触发模型重训</div>
|
||||||
|
<div class="module-tag">模型迭代</div>
|
||||||
|
</div>
|
||||||
|
<div class="module-card" @click="$router.push('/admin/users')">
|
||||||
|
<div class="module-name">用户管理</div>
|
||||||
|
<div class="module-desc">编辑用户信息和权限</div>
|
||||||
|
<div class="module-tag">权限管理</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-3">
|
||||||
|
<button class="btn btn-ghost" style="width: 100%;" @click="handleLogout">退出登录</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import { store, isAdmin, logout, refreshUser, setAuth } from '@/store'
|
||||||
|
import { toast, confirm } from '@/utils/feedback'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ProfileView',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
form: {
|
||||||
|
nickname: '',
|
||||||
|
company: '',
|
||||||
|
title: '',
|
||||||
|
phone: '',
|
||||||
|
password: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
user() {
|
||||||
|
return store.user
|
||||||
|
},
|
||||||
|
isAdminUser() {
|
||||||
|
return isAdmin()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.loadProfile()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async loadProfile() {
|
||||||
|
await refreshUser()
|
||||||
|
const profile = await request({ url: '/user/profile' })
|
||||||
|
this.form = {
|
||||||
|
nickname: profile.nickname || '',
|
||||||
|
company: profile.company || '',
|
||||||
|
title: profile.title || '',
|
||||||
|
phone: profile.phone || '',
|
||||||
|
password: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
if (this.loading) return
|
||||||
|
const payload = { ...this.form }
|
||||||
|
if (!payload.password) delete payload.password
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
const user = await request({ url: '/user/profile', method: 'PUT', data: payload })
|
||||||
|
setAuth(store.token, user)
|
||||||
|
toast('保存成功', 'success')
|
||||||
|
this.form.password = ''
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async handleLogout() {
|
||||||
|
const { confirm: ok } = await confirm({
|
||||||
|
title: '退出登录',
|
||||||
|
content: '确定要退出当前账号吗?',
|
||||||
|
confirmText: '退出',
|
||||||
|
cancelText: '取消'
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
logout()
|
||||||
|
toast('已退出登录', 'success')
|
||||||
|
this.$router.replace('/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
94
admin-web/src/views/Register.vue
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<section class="hero fade-up">
|
||||||
|
<div class="hero-badge">CREATE ACCOUNT</div>
|
||||||
|
<h1 class="hero-title">注册新账号</h1>
|
||||||
|
<p class="hero-sub">填写基础信息后即可进入内容风控平台。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-1" style="max-width: 520px; margin: 24px auto;">
|
||||||
|
<div class="card-title">账号信息</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">用户名 *</label>
|
||||||
|
<input class="input" v-model.trim="form.username" placeholder="登录用户名" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">密码 *</label>
|
||||||
|
<input class="input" type="password" v-model.trim="form.password" placeholder="不少于 6 位" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">昵称</label>
|
||||||
|
<input class="input" v-model.trim="form.nickname" placeholder="展示昵称" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">公司</label>
|
||||||
|
<input class="input" v-model.trim="form.company" placeholder="所属公司" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">岗位</label>
|
||||||
|
<input class="input" v-model.trim="form.title" placeholder="岗位名称" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">手机号</label>
|
||||||
|
<input class="input" v-model.trim="form.phone" placeholder="联系方式" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" :disabled="loading" style="width: 100%;" @click="submit">
|
||||||
|
{{ loading ? '提交中...' : '提交注册' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-ghost" type="button" style="width: 100%;" @click="goBack">返回登录</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import { toast } from '@/utils/feedback'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'RegisterView',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
form: {
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
nickname: '',
|
||||||
|
company: '',
|
||||||
|
title: '',
|
||||||
|
phone: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
goBack() {
|
||||||
|
this.$router.push('/login')
|
||||||
|
},
|
||||||
|
async submit() {
|
||||||
|
if (this.loading) return
|
||||||
|
if (!this.form.username || !this.form.password) {
|
||||||
|
toast('用户名和密码必填', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
await request({ url: '/auth/register', method: 'POST', data: this.form })
|
||||||
|
toast('注册成功,请登录', 'success')
|
||||||
|
setTimeout(() => this.$router.push('/login'), 300)
|
||||||
|
} catch (err) {
|
||||||
|
// toast already shown
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
270
admin-web/src/views/admin/Dashboard.vue
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<section class="hero fade-up">
|
||||||
|
<div class="hero-badge">OPS DASHBOARD</div>
|
||||||
|
<h1 class="hero-title">垃圾信息运营看板</h1>
|
||||||
|
<p class="hero-sub">覆盖发布、拦截、申诉、样本与模型状态,支持日常运营与风险监控。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-1" v-if="kpis.length">
|
||||||
|
<div class="card-title">核心指标</div>
|
||||||
|
<div class="grid-3">
|
||||||
|
<div class="kpi" v-for="item in kpis" :key="item.label">
|
||||||
|
<div class="kpi-value">{{ item.value }}</div>
|
||||||
|
<div class="kpi-label">{{ item.label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<section class="card fade-up fade-up-delay-2" v-if="stats && stats.threshold">
|
||||||
|
<div class="card-title">检测阈值配置</div>
|
||||||
|
<div class="row"><span class="label">当前阈值</span><span class="value">{{ stats.threshold_text }}</span></div>
|
||||||
|
<div class="row"><span class="label">更新时间</span><span class="value">{{ stats.threshold.updated_at || '--' }}</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-2" v-if="stats && stats.model_info">
|
||||||
|
<div class="card-title">模型信息</div>
|
||||||
|
<div class="row"><span class="label">模型版本</span><span class="value">{{ stats.model_info.version || '未训练' }}</span></div>
|
||||||
|
<div class="row"><span class="label">训练时间</span><span class="value">{{ stats.model_info.trained_at || '--' }}</span></div>
|
||||||
|
<div class="row"><span class="label">样本数量</span><span class="value">{{ stats.model_info.sample_count || 0 }}</span></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-3" v-if="bars.length">
|
||||||
|
<div class="card-title">近 7 天发布趋势</div>
|
||||||
|
<div class="list-item" v-for="item in bars" :key="item.date">
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">{{ item.label }}</span>
|
||||||
|
<span class="value">{{ item.value }} 条</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-track">
|
||||||
|
<div class="progress-fill-safe" :style="{ width: item.percent_text }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<section class="card fade-up fade-up-delay-3" v-if="sourceDist.length">
|
||||||
|
<div class="card-title">训练样本来源</div>
|
||||||
|
<div class="list-item" v-for="item in sourceDist" :key="item.name">
|
||||||
|
<div class="row"><span class="item-title">{{ item.name }}</span><span class="value">{{ item.value }}</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-3" v-if="topKeywords.length">
|
||||||
|
<div class="card-title">高频风险词</div>
|
||||||
|
<div class="chip-group">
|
||||||
|
<span class="tag" v-for="kw in topKeywords" :key="kw.token">{{ kw.token }} × {{ kw.count }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-3">
|
||||||
|
<button class="btn btn-accent" :disabled="reportLoading" @click="generateReport">
|
||||||
|
{{ reportLoading ? '生成中...' : '生成运营报告' }}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-if="report" class="modal-mask" @click.self="closeReport">
|
||||||
|
<div class="modal-card" style="width: min(720px, 96vw); max-height: 88vh; overflow-y: auto;">
|
||||||
|
<div class="modal-title" style="text-align: center;">垃圾信息运营报告</div>
|
||||||
|
<div class="muted" style="text-align: center;">{{ report.period }}</div>
|
||||||
|
|
||||||
|
<div class="report-section">
|
||||||
|
<div class="report-section-title">汇总统计</div>
|
||||||
|
<div class="grid-3">
|
||||||
|
<div class="report-kpi">
|
||||||
|
<div class="report-kpi-value">{{ report.summary.total_posts }}</div>
|
||||||
|
<div class="report-kpi-label">总发布量</div>
|
||||||
|
</div>
|
||||||
|
<div class="report-kpi">
|
||||||
|
<div class="report-kpi-value">{{ report.summary.total_blocked }}</div>
|
||||||
|
<div class="report-kpi-label">拦截量</div>
|
||||||
|
</div>
|
||||||
|
<div class="report-kpi">
|
||||||
|
<div class="report-kpi-value">{{ report.summary.total_published }}</div>
|
||||||
|
<div class="report-kpi-label">正常发布</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top: 10px;">
|
||||||
|
<span class="label">拦截率</span>
|
||||||
|
<span class="value">{{ formatPercent(report.summary.blocked_ratio, 2) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">平均误判率</span>
|
||||||
|
<span class="value">{{ report.summary.avg_misjudge_rate_text || '0%' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="report-section">
|
||||||
|
<div class="report-section-title">垃圾信息数量变化(近 14 天)</div>
|
||||||
|
<div v-for="item in report.spam_trend" :key="'s' + item.date" style="margin-top: 6px;">
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">{{ item.label }}</span>
|
||||||
|
<span class="value">拦截 {{ item.blocked }} / 发布 {{ item.published }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-track">
|
||||||
|
<div class="progress-fill" :style="{ width: item.blocked_percent }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="report-section">
|
||||||
|
<div class="report-section-title">高频风险词 Top10</div>
|
||||||
|
<div class="chip-group">
|
||||||
|
<span
|
||||||
|
class="tag tag-danger"
|
||||||
|
v-for="(kw, i) in (report.top_keywords || []).slice(0, 10)"
|
||||||
|
:key="kw.token"
|
||||||
|
>{{ kw.token }} × {{ kw.count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="report-section">
|
||||||
|
<div class="report-section-title">误判率趋势(近 14 天)</div>
|
||||||
|
<div v-for="item in report.misjudge_trend" :key="'m' + item.date" style="margin-top: 6px;">
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">{{ item.label }}</span>
|
||||||
|
<span class="value">{{ item.misjudge_rate_text }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-track">
|
||||||
|
<div class="progress-fill-safe" :style="{ width: item.rate_percent }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-primary" @click="copyReportText">复制报告文本</button>
|
||||||
|
<button class="btn btn-ghost" @click="closeReport">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import { toast, copyText } from '@/utils/feedback'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'AdminDashboard',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
stats: null,
|
||||||
|
kpis: [],
|
||||||
|
bars: [],
|
||||||
|
sourceDist: [],
|
||||||
|
topKeywords: [],
|
||||||
|
report: null,
|
||||||
|
reportLoading: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.fetchStats()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatPercent(value, digits = 2) {
|
||||||
|
return `${(Number(value || 0) * 100).toFixed(digits)}%`
|
||||||
|
},
|
||||||
|
normalizeKpis(stats) {
|
||||||
|
return [
|
||||||
|
{ label: '系统用户', value: stats.user_count || 0 },
|
||||||
|
{ label: '发布总量', value: stats.post_count || 0 },
|
||||||
|
{ label: '拦截总量', value: stats.blocked_count || 0 },
|
||||||
|
{ label: '待处理申诉', value: stats.pending_appeal_count || 0 },
|
||||||
|
{ label: '训练样本', value: stats.sample_count || 0 },
|
||||||
|
{ label: '近 7 天拦截率', value: this.formatPercent(stats.blocked_ratio_7d, 2) }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
normalizeBars(trend) {
|
||||||
|
const rows = Array.isArray(trend) ? trend : []
|
||||||
|
const maxVal = Math.max(1, ...rows.map((r) => Number(r.post_count || 0)))
|
||||||
|
return rows.map((row) => {
|
||||||
|
const value = Number(row.post_count || 0)
|
||||||
|
const ratio = value / maxVal
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
value,
|
||||||
|
percent_text: `${Math.max(6, Math.round(ratio * 100))}%`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async fetchStats() {
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
const stats = await request({ url: '/admin/stats' })
|
||||||
|
const normalized = {
|
||||||
|
...stats,
|
||||||
|
threshold_text: stats && stats.threshold ? this.formatPercent(stats.threshold.spam_threshold, 1) : '--'
|
||||||
|
}
|
||||||
|
this.stats = normalized
|
||||||
|
this.kpis = this.normalizeKpis(normalized)
|
||||||
|
this.bars = this.normalizeBars(normalized.trend_7d || [])
|
||||||
|
this.sourceDist = normalized.source_distribution || []
|
||||||
|
this.topKeywords = normalized.top_keywords || []
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async generateReport() {
|
||||||
|
this.reportLoading = true
|
||||||
|
try {
|
||||||
|
const report = await request({ url: '/admin/stats/report' })
|
||||||
|
const maxBlocked = Math.max(...(report.spam_trend || []).map((r) => r.blocked || 0), 1)
|
||||||
|
const spamTrend = (report.spam_trend || []).map((item) => ({
|
||||||
|
...item,
|
||||||
|
blocked_percent: `${Math.max(4, Math.round(((item.blocked || 0) / maxBlocked) * 100))}%`
|
||||||
|
}))
|
||||||
|
const misjudgeTrend = (report.misjudge_trend || []).map((item) => ({
|
||||||
|
...item,
|
||||||
|
rate_percent: `${Math.round((item.misjudge_rate || 0) * 100)}%`
|
||||||
|
}))
|
||||||
|
this.report = { ...report, spam_trend: spamTrend, misjudge_trend: misjudgeTrend }
|
||||||
|
toast('报告已生成', 'success')
|
||||||
|
} finally {
|
||||||
|
this.reportLoading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
closeReport() {
|
||||||
|
this.report = null
|
||||||
|
},
|
||||||
|
async copyReportText() {
|
||||||
|
const report = this.report
|
||||||
|
if (!report) return
|
||||||
|
const summary = report.summary || {}
|
||||||
|
const lines = [
|
||||||
|
`【垃圾信息运营报告】`,
|
||||||
|
`报告周期:${report.period}`,
|
||||||
|
`生成时间:${(report.report_date || '').replace('T', ' ').slice(0, 19)}`,
|
||||||
|
'',
|
||||||
|
`【汇总统计】`,
|
||||||
|
`总发布量:${summary.total_posts || 0} 条`,
|
||||||
|
`拦截量:${summary.total_blocked || 0} 条`,
|
||||||
|
`正常发布:${summary.total_published || 0} 条`,
|
||||||
|
`拦截率:${this.formatPercent(summary.blocked_ratio, 2)}`,
|
||||||
|
`复核总数:${summary.total_reviews || 0} 次`,
|
||||||
|
`误判放行:${summary.total_approved || 0} 次`,
|
||||||
|
`平均误判率:${summary.avg_misjudge_rate_text || '0%'}`,
|
||||||
|
'',
|
||||||
|
`【高频风险词 Top10】`,
|
||||||
|
(report.top_keywords || []).slice(0, 10).map((k) => `${k.token}(${k.count}次)`).join('、'),
|
||||||
|
'',
|
||||||
|
`【近 7 日趋势】`,
|
||||||
|
(report.spam_trend || []).slice(-7).map((t) => `${t.label}: 拦截${t.blocked}条,发布${t.published}条`).join('\n')
|
||||||
|
]
|
||||||
|
const content = lines.join('\n')
|
||||||
|
const ok = await copyText(content)
|
||||||
|
if (ok) {
|
||||||
|
toast('报告已复制到剪贴板', 'success')
|
||||||
|
} else {
|
||||||
|
console.error('[dashboard] 复制失败,报告内容打印到控制台供手动复制\n' + content)
|
||||||
|
toast('复制失败,请手动选择', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
353
admin-web/src/views/admin/Review.vue
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<section class="hero fade-up">
|
||||||
|
<div class="hero-badge">REVIEW CENTER</div>
|
||||||
|
<h1 class="hero-title">复核与申诉后台</h1>
|
||||||
|
<p class="hero-sub">支持阈值调节、拦截复核、申诉处理与分页筛选,满足商用审核流程。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-1">
|
||||||
|
<div class="card-title">检测阈值调节</div>
|
||||||
|
<div class="card-desc">阈值越低,拦截越严格。推荐范围:0.65 - 0.85。</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">垃圾阈值(0-1)</span>
|
||||||
|
<input class="input threshold-input" type="number" step="0.01" v-model="thresholdInput" />
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" @click="saveThreshold">更新阈值</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-1">
|
||||||
|
<div class="card-title">拦截记录筛选</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">关键词</label>
|
||||||
|
<input class="input" v-model="interceptKeyword" placeholder="搜索拦截文本关键词" />
|
||||||
|
</div>
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">发布状态</label>
|
||||||
|
<select class="select" v-model="interceptStatus">
|
||||||
|
<option v-for="opt in interceptStatusOptions" :key="opt.label" :value="opt.value">{{ opt.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">复核状态</label>
|
||||||
|
<select class="select" v-model="interceptReviewStatus">
|
||||||
|
<option v-for="opt in interceptReviewStatusOptions" :key="opt.label" :value="opt.value">{{ opt.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-primary" @click="applyInterceptFilters">查询</button>
|
||||||
|
<button class="btn btn-ghost" @click="clearInterceptFilters">重置</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-2">
|
||||||
|
<div class="card-title">信息拦截人工复核</div>
|
||||||
|
<div class="muted">共 {{ interceptPager.total }} 条,第 {{ interceptPager.page }} / {{ interceptPager.totalPages }} 页</div>
|
||||||
|
|
||||||
|
<div v-if="intercepts.length">
|
||||||
|
<div class="list-item" v-for="item in intercepts" :key="item.id">
|
||||||
|
<div class="item-title">{{ item.text }}</div>
|
||||||
|
<div class="item-sub">用户:{{ item.nickname || item.username }} · 垃圾概率:{{ item.spam_probability_text }}</div>
|
||||||
|
<div class="item-sub" v-if="item.category_label">分类标签:<span class="status-spam">{{ item.category_label }}</span></div>
|
||||||
|
<div class="item-sub">复核状态:{{ item.review_status_text }} · 申诉状态:{{ item.appeal_status_text }}</div>
|
||||||
|
<div class="item-sub">发布时间:{{ item.created_text }}</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
class="textarea note-textarea"
|
||||||
|
v-model="reviewNoteMap[item.id]"
|
||||||
|
placeholder="可填写复核备注(将写入处理记录)"
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-accent" @click="reviewIntercept(item.id, 'spam')">确认垃圾</button>
|
||||||
|
<button class="btn btn-ghost" @click="reviewIntercept(item.id, 'ham')">误判放行</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pager-row">
|
||||||
|
<button class="btn btn-ghost pager-btn" :disabled="!interceptPager.hasPrev" @click="changeInterceptPage(-1)">上一页</button>
|
||||||
|
<button class="btn btn-ghost pager-btn" :disabled="!interceptPager.hasNext" @click="changeInterceptPage(1)">下一页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="empty">没有匹配的拦截记录。</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-2">
|
||||||
|
<div class="card-title">申诉记录筛选</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">关键词</label>
|
||||||
|
<input class="input" v-model="appealKeyword" placeholder="搜索申诉文本或申诉理由" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">申诉状态</label>
|
||||||
|
<select class="select" v-model="appealStatus">
|
||||||
|
<option v-for="opt in appealStatusOptions" :key="opt.label" :value="opt.value">{{ opt.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-primary" @click="applyAppealFilters">查询</button>
|
||||||
|
<button class="btn btn-ghost" @click="clearAppealFilters">重置</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-3">
|
||||||
|
<div class="card-title">拦截信息申诉处理</div>
|
||||||
|
<div class="muted">共 {{ appealPager.total }} 条,第 {{ appealPager.page }} / {{ appealPager.totalPages }} 页</div>
|
||||||
|
|
||||||
|
<div v-if="appeals.length">
|
||||||
|
<div class="list-item" v-for="item in appeals" :key="item.id">
|
||||||
|
<div class="item-title">{{ item.text }}</div>
|
||||||
|
<div class="item-sub">申诉人:{{ item.nickname || item.username }} · 当前状态:{{ item.appeal_status_text }}</div>
|
||||||
|
<div class="item-sub" v-if="item.category_label">分类标签:<span class="status-spam">{{ item.category_label }}</span></div>
|
||||||
|
<div class="item-sub">申诉理由类型:{{ item.appeal_reason_type || '未选择' }}</div>
|
||||||
|
<div class="item-sub">申诉理由:{{ item.appeal_reason || '未填写' }}</div>
|
||||||
|
<div class="item-sub">时间:{{ item.created_text }}</div>
|
||||||
|
|
||||||
|
<div class="field" v-if="item.appeal_evidence_urls && item.appeal_evidence_urls.length">
|
||||||
|
<span class="field-label">证据截图</span>
|
||||||
|
<div class="evidence-grid">
|
||||||
|
<div
|
||||||
|
class="evidence-item"
|
||||||
|
v-for="url in item.appeal_evidence_urls"
|
||||||
|
:key="url"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="evidence-thumb evidence-clickable"
|
||||||
|
:src="url"
|
||||||
|
@click="previewEvidence(item.appeal_evidence_urls, url)"
|
||||||
|
alt="evidence"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
class="textarea note-textarea"
|
||||||
|
v-model="appealNoteMap[item.id]"
|
||||||
|
placeholder="可填写申诉处理备注"
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-primary" @click="processAppeal(item.id, 'approve')">通过申诉</button>
|
||||||
|
<button class="btn btn-ghost" @click="processAppeal(item.id, 'reject')">驳回申诉</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pager-row">
|
||||||
|
<button class="btn btn-ghost pager-btn" :disabled="!appealPager.hasPrev" @click="changeAppealPage(-1)">上一页</button>
|
||||||
|
<button class="btn btn-ghost pager-btn" :disabled="!appealPager.hasNext" @click="changeAppealPage(1)">下一页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="empty">没有匹配的申诉记录。</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import { toast, previewImage } from '@/utils/feedback'
|
||||||
|
|
||||||
|
const INTERCEPT_STATUS_OPTIONS = [
|
||||||
|
{ value: '', label: '全部发布状态' },
|
||||||
|
{ value: 'blocked', label: '已拦截' },
|
||||||
|
{ value: 'published', label: '已发布' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const INTERCEPT_REVIEW_STATUS_OPTIONS = [
|
||||||
|
{ value: '', label: '全部复核状态' },
|
||||||
|
{ value: 'pending', label: '待复核' },
|
||||||
|
{ value: 'confirmed_spam', label: '确认垃圾' },
|
||||||
|
{ value: 'approved_ham', label: '误判放行' },
|
||||||
|
{ value: 'none', label: '未进入复核' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const APPEAL_STATUS_OPTIONS = [
|
||||||
|
{ value: 'pending', label: '待处理申诉' },
|
||||||
|
{ value: '', label: '全部申诉' },
|
||||||
|
{ value: 'approved', label: '已通过申诉' },
|
||||||
|
{ value: 'rejected', label: '已驳回申诉' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const REVIEW_STATUS_TEXT = { none: '无', pending: '待复核', confirmed_spam: '确认垃圾', approved_ham: '误判放行' }
|
||||||
|
const APPEAL_STATUS_TEXT = { none: '无', pending: '待处理', approved: '已通过', rejected: '已驳回' }
|
||||||
|
const CATEGORY_LABELS = { fraud: '疑似诈骗', harassment: '疑似骚扰', advertisement: '疑似广告', spam: '疑似垃圾' }
|
||||||
|
|
||||||
|
function buildPager(total, page, pageSize) {
|
||||||
|
const totalValue = Number(total || 0)
|
||||||
|
const totalPages = Math.max(Math.ceil(totalValue / pageSize), 1)
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total: totalValue,
|
||||||
|
totalPages,
|
||||||
|
hasPrev: page > 1,
|
||||||
|
hasNext: page < totalPages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'AdminReview',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
thresholdInput: '0.75',
|
||||||
|
interceptKeyword: '',
|
||||||
|
interceptStatus: 'blocked',
|
||||||
|
interceptReviewStatus: 'pending',
|
||||||
|
interceptStatusOptions: INTERCEPT_STATUS_OPTIONS,
|
||||||
|
interceptReviewStatusOptions: INTERCEPT_REVIEW_STATUS_OPTIONS,
|
||||||
|
intercepts: [],
|
||||||
|
interceptPager: buildPager(0, 1, 10),
|
||||||
|
|
||||||
|
appealKeyword: '',
|
||||||
|
appealStatus: 'pending',
|
||||||
|
appealStatusOptions: APPEAL_STATUS_OPTIONS,
|
||||||
|
appeals: [],
|
||||||
|
appealPager: buildPager(0, 1, 10),
|
||||||
|
|
||||||
|
reviewNoteMap: {},
|
||||||
|
appealNoteMap: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.bootstrap()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatPercent(value, digits = 2) {
|
||||||
|
return `${(Number(value || 0) * 100).toFixed(digits)}%`
|
||||||
|
},
|
||||||
|
async bootstrap() {
|
||||||
|
await Promise.all([this.fetchThreshold(), this.fetchIntercepts(), this.fetchAppeals()])
|
||||||
|
},
|
||||||
|
async fetchThreshold() {
|
||||||
|
const data = await request({ url: '/admin/detection/threshold' })
|
||||||
|
this.thresholdInput = String(data.spam_threshold || 0.75)
|
||||||
|
},
|
||||||
|
normalizeIntercepts(rows = []) {
|
||||||
|
return rows.map((item) => ({
|
||||||
|
...item,
|
||||||
|
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19),
|
||||||
|
review_status_text: REVIEW_STATUS_TEXT[item.manual_review_status] || item.manual_review_status,
|
||||||
|
spam_probability_text: this.formatPercent(item.spam_probability, 2),
|
||||||
|
appeal_status_text: APPEAL_STATUS_TEXT[item.appeal_status] || item.appeal_status,
|
||||||
|
category_label: CATEGORY_LABELS[item.category] || ''
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
normalizeAppeals(rows = []) {
|
||||||
|
return rows.map((item) => ({
|
||||||
|
...item,
|
||||||
|
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19),
|
||||||
|
appeal_status_text: APPEAL_STATUS_TEXT[item.appeal_status] || item.appeal_status,
|
||||||
|
category_label: CATEGORY_LABELS[item.category] || '',
|
||||||
|
appeal_evidence_urls: (item.appeal_evidence_urls || []).map((url) =>
|
||||||
|
url.startsWith('http') ? url : url
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
async fetchIntercepts() {
|
||||||
|
const data = await request({
|
||||||
|
url: '/admin/intercepts',
|
||||||
|
params: {
|
||||||
|
keyword: (this.interceptKeyword || '').trim(),
|
||||||
|
status: this.interceptStatus,
|
||||||
|
review_status: this.interceptReviewStatus,
|
||||||
|
page: this.interceptPager.page,
|
||||||
|
page_size: this.interceptPager.pageSize
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.intercepts = this.normalizeIntercepts(data.items || [])
|
||||||
|
this.interceptPager = buildPager(data.total || 0, this.interceptPager.page, this.interceptPager.pageSize)
|
||||||
|
},
|
||||||
|
async fetchAppeals() {
|
||||||
|
const data = await request({
|
||||||
|
url: '/admin/appeals',
|
||||||
|
params: {
|
||||||
|
keyword: (this.appealKeyword || '').trim(),
|
||||||
|
status: this.appealStatus,
|
||||||
|
page: this.appealPager.page,
|
||||||
|
page_size: this.appealPager.pageSize
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.appeals = this.normalizeAppeals(data.items || [])
|
||||||
|
this.appealPager = buildPager(data.total || 0, this.appealPager.page, this.appealPager.pageSize)
|
||||||
|
},
|
||||||
|
applyInterceptFilters() {
|
||||||
|
this.interceptPager = buildPager(0, 1, this.interceptPager.pageSize)
|
||||||
|
this.fetchIntercepts()
|
||||||
|
},
|
||||||
|
clearInterceptFilters() {
|
||||||
|
this.interceptKeyword = ''
|
||||||
|
this.interceptStatus = 'blocked'
|
||||||
|
this.interceptReviewStatus = 'pending'
|
||||||
|
this.interceptPager = buildPager(0, 1, this.interceptPager.pageSize)
|
||||||
|
this.fetchIntercepts()
|
||||||
|
},
|
||||||
|
changeInterceptPage(delta) {
|
||||||
|
const next = this.interceptPager.page + delta
|
||||||
|
if (next < 1 || next > this.interceptPager.totalPages) return
|
||||||
|
this.interceptPager = { ...this.interceptPager, page: next }
|
||||||
|
this.fetchIntercepts()
|
||||||
|
},
|
||||||
|
applyAppealFilters() {
|
||||||
|
this.appealPager = buildPager(0, 1, this.appealPager.pageSize)
|
||||||
|
this.fetchAppeals()
|
||||||
|
},
|
||||||
|
clearAppealFilters() {
|
||||||
|
this.appealKeyword = ''
|
||||||
|
this.appealStatus = 'pending'
|
||||||
|
this.appealPager = buildPager(0, 1, this.appealPager.pageSize)
|
||||||
|
this.fetchAppeals()
|
||||||
|
},
|
||||||
|
changeAppealPage(delta) {
|
||||||
|
const next = this.appealPager.page + delta
|
||||||
|
if (next < 1 || next > this.appealPager.totalPages) return
|
||||||
|
this.appealPager = { ...this.appealPager, page: next }
|
||||||
|
this.fetchAppeals()
|
||||||
|
},
|
||||||
|
async saveThreshold() {
|
||||||
|
const value = Number(this.thresholdInput)
|
||||||
|
if (Number.isNaN(value) || value <= 0 || value >= 1) {
|
||||||
|
toast('阈值需在 0 到 1 之间', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await request({ url: '/admin/detection/threshold', method: 'PUT', data: { spam_threshold: value } })
|
||||||
|
toast('阈值更新成功', 'success')
|
||||||
|
this.fetchThreshold()
|
||||||
|
},
|
||||||
|
async reviewIntercept(id, decision) {
|
||||||
|
const note = (this.reviewNoteMap[id] || '').trim()
|
||||||
|
await request({
|
||||||
|
url: `/admin/intercepts/${id}/review`,
|
||||||
|
method: 'PUT',
|
||||||
|
data: {
|
||||||
|
decision,
|
||||||
|
note: note || (decision === 'spam' ? '人工复核确认为垃圾信息' : '人工复核后解除拦截')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
toast('复核完成', 'success')
|
||||||
|
this.$set(this.reviewNoteMap, id, '')
|
||||||
|
await Promise.all([this.fetchIntercepts(), this.fetchAppeals()])
|
||||||
|
},
|
||||||
|
async processAppeal(id, action) {
|
||||||
|
const note = (this.appealNoteMap[id] || '').trim()
|
||||||
|
await request({
|
||||||
|
url: `/admin/appeals/${id}/process`,
|
||||||
|
method: 'PUT',
|
||||||
|
data: {
|
||||||
|
action,
|
||||||
|
note: note || (action === 'approve' ? '申诉通过,解除拦截' : '申诉驳回,维持拦截')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
toast('申诉处理完成', 'success')
|
||||||
|
this.$set(this.appealNoteMap, id, '')
|
||||||
|
await Promise.all([this.fetchIntercepts(), this.fetchAppeals()])
|
||||||
|
},
|
||||||
|
previewEvidence(urls, current) {
|
||||||
|
previewImage(urls, current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
174
admin-web/src/views/admin/Samples.vue
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<section class="hero fade-up">
|
||||||
|
<div class="hero-badge">SAMPLE LAB</div>
|
||||||
|
<h1 class="hero-title">训练样本管理</h1>
|
||||||
|
<p class="hero-sub">支持增删样本、批量导入、启停样本与一键重训,持续提升识别准确率。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-1">
|
||||||
|
<div class="card-title">筛选样本</div>
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">关键词</label>
|
||||||
|
<input class="input" v-model="keyword" placeholder="输入关键词" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">标签</label>
|
||||||
|
<div>
|
||||||
|
<label class="radio-row"><input type="radio" v-model="label" value="" />全部</label>
|
||||||
|
<label class="radio-row"><input type="radio" v-model="label" value="spam" />垃圾</label>
|
||||||
|
<label class="radio-row"><input type="radio" v-model="label" value="ham" />正常</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" :disabled="loading" @click="fetchSamples">
|
||||||
|
{{ loading ? '查询中...' : '查询' }}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-1">
|
||||||
|
<div class="card-title">新增样本</div>
|
||||||
|
<textarea class="textarea" v-model="form.text" placeholder="输入样本文本" rows="3"></textarea>
|
||||||
|
<div class="field">
|
||||||
|
<label class="radio-row"><input type="radio" v-model="form.label" value="spam" />垃圾</label>
|
||||||
|
<label class="radio-row"><input type="radio" v-model="form.label" value="ham" />正常</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-accent" @click="createSample">提交样本</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-2">
|
||||||
|
<div class="card-title">批量导入</div>
|
||||||
|
<div class="card-desc">支持 JSON 数组导入。导入后可直接重训模型。</div>
|
||||||
|
<textarea class="textarea" v-model="importText" rows="4"></textarea>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-primary" @click="importSamples">执行导入</button>
|
||||||
|
<button class="btn btn-ghost" @click="retrain">一键重训模型</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-3" v-if="samples.length">
|
||||||
|
<div class="card-title">样本列表</div>
|
||||||
|
<div class="list-item" v-for="item in samples" :key="item.id">
|
||||||
|
<div class="item-title">{{ item.text }}</div>
|
||||||
|
<div class="item-sub">标签:{{ item.label === 'spam' ? '垃圾' : '正常' }} · 来源:{{ item.source }} · ID:{{ item.id }}</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">参与训练</span>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" :checked="item.is_active" @change="toggleActive(item, $event)" />
|
||||||
|
<span class="switch-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="deleteSample(item.id)">删除样本</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-3" v-else-if="!loading">
|
||||||
|
<div class="empty">暂无样本,先去新增或导入。</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import { toast, confirm } from '@/utils/feedback'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'AdminSamples',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
keyword: '',
|
||||||
|
label: '',
|
||||||
|
loading: false,
|
||||||
|
samples: [],
|
||||||
|
form: {
|
||||||
|
text: '',
|
||||||
|
label: 'spam'
|
||||||
|
},
|
||||||
|
importText: '[{"text":"点击领取限时现金券","label":"spam"},{"text":"今天下午发布会彩排","label":"ham"}]'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.fetchSamples()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async fetchSamples() {
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
const data = await request({
|
||||||
|
url: '/spam/samples',
|
||||||
|
params: {
|
||||||
|
keyword: this.keyword,
|
||||||
|
label: this.label,
|
||||||
|
page: 1,
|
||||||
|
page_size: 80
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.samples = data.items || []
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async createSample() {
|
||||||
|
const text = (this.form.text || '').trim()
|
||||||
|
if (text.length < 2) {
|
||||||
|
toast('样本文本至少 2 个字符', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await request({
|
||||||
|
url: '/spam/samples',
|
||||||
|
method: 'POST',
|
||||||
|
data: { text, label: this.form.label || 'spam' }
|
||||||
|
})
|
||||||
|
toast('新增成功', 'success')
|
||||||
|
this.form.text = ''
|
||||||
|
this.fetchSamples()
|
||||||
|
},
|
||||||
|
async deleteSample(id) {
|
||||||
|
const { confirm: ok } = await confirm({
|
||||||
|
title: '删除样本',
|
||||||
|
content: `确认删除样本 ID ${id} 吗?`,
|
||||||
|
confirmText: '删除',
|
||||||
|
cancelText: '取消'
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
await request({ url: `/spam/samples/${id}`, method: 'DELETE' })
|
||||||
|
toast('删除成功', 'success')
|
||||||
|
this.fetchSamples()
|
||||||
|
},
|
||||||
|
async toggleActive(item, e) {
|
||||||
|
const active = !!e.target.checked
|
||||||
|
try {
|
||||||
|
await request({
|
||||||
|
url: `/spam/samples/${item.id}`,
|
||||||
|
method: 'PUT',
|
||||||
|
data: { is_active: active }
|
||||||
|
})
|
||||||
|
item.is_active = active
|
||||||
|
toast('状态已更新', 'success')
|
||||||
|
} catch (err) {
|
||||||
|
e.target.checked = !active
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async retrain() {
|
||||||
|
await request({ url: '/spam/train', method: 'POST', data: {} })
|
||||||
|
toast('模型重训完成', 'success')
|
||||||
|
},
|
||||||
|
async importSamples() {
|
||||||
|
let items = []
|
||||||
|
try {
|
||||||
|
items = JSON.parse(this.importText)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[samples] JSON 格式错误', err)
|
||||||
|
toast('JSON 格式错误', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await request({ url: '/spam/samples/import', method: 'POST', data: { items } })
|
||||||
|
toast('导入完成', 'success')
|
||||||
|
this.fetchSamples()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
192
admin-web/src/views/admin/Users.vue
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<section class="hero fade-up">
|
||||||
|
<div class="hero-badge">USER ADMIN</div>
|
||||||
|
<h1 class="hero-title">用户与权限管理</h1>
|
||||||
|
<p class="hero-sub">支持账号查询、权限调整、批量导入,适用于企业商用场景。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-1">
|
||||||
|
<div class="card-title">搜索用户</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">关键词</label>
|
||||||
|
<input class="input" v-model="keyword" placeholder="输入用户名或昵称" @keyup.enter="fetchUsers" />
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" :disabled="loading" @click="fetchUsers">
|
||||||
|
{{ loading ? '查询中...' : '查询' }}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-1">
|
||||||
|
<div class="card-title">批量导入</div>
|
||||||
|
<div class="card-desc">粘贴 JSON 数组,支持批量新增或更新用户信息。</div>
|
||||||
|
<textarea class="textarea" v-model="importText" rows="4"></textarea>
|
||||||
|
<button class="btn btn-accent" @click="importUsers">执行导入</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-2" v-if="users.length">
|
||||||
|
<div class="card-title">用户列表</div>
|
||||||
|
|
||||||
|
<div class="list-item" v-for="item in users" :key="item.id">
|
||||||
|
<div class="item-title">{{ item.nickname }}({{ item.username }})</div>
|
||||||
|
<div class="item-sub">{{ item.company || '未填写公司' }} · {{ item.title || '未填写岗位' }} · {{ item.is_admin ? '管理员' : '普通用户' }}</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">信誉分</span>
|
||||||
|
<div class="credit-score-bar">
|
||||||
|
<div class="credit-fill" :style="{ width: ((item.credit_score || 100) / 2) + '%' }"></div>
|
||||||
|
<span class="credit-value">{{ item.credit_score || 100 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="editUserId === item.id" style="margin-top: 10px;">
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">昵称</label>
|
||||||
|
<input class="input" v-model="editForm.nickname" placeholder="昵称" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">手机号</label>
|
||||||
|
<input class="input" v-model="editForm.phone" placeholder="手机号" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">公司</label>
|
||||||
|
<input class="input" v-model="editForm.company" placeholder="公司" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">岗位</label>
|
||||||
|
<input class="input" v-model="editForm.title" placeholder="岗位" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">新密码(可选)</label>
|
||||||
|
<input class="input" type="password" v-model="editForm.password" placeholder="留空表示不修改" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">信誉分(0-200)</label>
|
||||||
|
<input class="input" type="number" v-model.number="editForm.credit_score" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<span class="label">管理员权限</span>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" v-model="editForm.is_admin" />
|
||||||
|
<span class="switch-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-primary" @click="saveEdit">保存</button>
|
||||||
|
<button class="btn btn-ghost" @click="cancelEdit">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="btn-row">
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="startEdit(item)">编辑</button>
|
||||||
|
<button class="btn btn-danger btn-sm" @click="removeUser(item.id)">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card fade-up fade-up-delay-2" v-else-if="!loading">
|
||||||
|
<div class="empty">暂无用户。</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
import { toast, confirm } from '@/utils/feedback'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'AdminUsers',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
keyword: '',
|
||||||
|
loading: false,
|
||||||
|
users: [],
|
||||||
|
editUserId: null,
|
||||||
|
editForm: {
|
||||||
|
nickname: '',
|
||||||
|
company: '',
|
||||||
|
title: '',
|
||||||
|
phone: '',
|
||||||
|
is_admin: false,
|
||||||
|
password: '',
|
||||||
|
credit_score: 100
|
||||||
|
},
|
||||||
|
importText: '[{"username":"operator01","password":"123456","nickname":"运营同学","company":"示例科技公司"}]'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.fetchUsers()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async fetchUsers() {
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
const data = await request({
|
||||||
|
url: '/admin/users',
|
||||||
|
params: {
|
||||||
|
keyword: this.keyword,
|
||||||
|
page: 1,
|
||||||
|
page_size: 80
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.users = data.items || []
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
startEdit(row) {
|
||||||
|
this.editUserId = row.id
|
||||||
|
this.editForm = {
|
||||||
|
nickname: row.nickname || '',
|
||||||
|
company: row.company || '',
|
||||||
|
title: row.title || '',
|
||||||
|
phone: row.phone || '',
|
||||||
|
is_admin: !!row.is_admin,
|
||||||
|
password: '',
|
||||||
|
credit_score: row.credit_score || 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancelEdit() {
|
||||||
|
this.editUserId = null
|
||||||
|
},
|
||||||
|
async saveEdit() {
|
||||||
|
const id = this.editUserId
|
||||||
|
if (!id) return
|
||||||
|
const payload = { ...this.editForm }
|
||||||
|
if (!payload.password) delete payload.password
|
||||||
|
await request({ url: `/admin/users/${id}`, method: 'PUT', data: payload })
|
||||||
|
toast('用户已更新', 'success')
|
||||||
|
this.editUserId = null
|
||||||
|
this.fetchUsers()
|
||||||
|
},
|
||||||
|
async removeUser(id) {
|
||||||
|
const { confirm: ok } = await confirm({
|
||||||
|
title: '删除用户',
|
||||||
|
content: `确认删除用户 ID ${id} 吗?`,
|
||||||
|
confirmText: '删除',
|
||||||
|
cancelText: '取消'
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
await request({ url: `/admin/users/${id}`, method: 'DELETE' })
|
||||||
|
toast('删除成功', 'success')
|
||||||
|
this.fetchUsers()
|
||||||
|
},
|
||||||
|
async importUsers() {
|
||||||
|
let items = []
|
||||||
|
try {
|
||||||
|
items = JSON.parse(this.importText)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[users] JSON 格式错误', err)
|
||||||
|
toast('JSON 格式错误', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await request({ url: '/admin/users/import', method: 'POST', data: { items } })
|
||||||
|
toast('导入完成', 'success')
|
||||||
|
this.fetchUsers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
30
admin-web/vue.config.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
const { defineConfig } = require('@vue/cli-service')
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
transpileDependencies: true,
|
||||||
|
lintOnSave: false,
|
||||||
|
devServer: {
|
||||||
|
port: 8080,
|
||||||
|
host: '0.0.0.0',
|
||||||
|
historyApiFallback: true,
|
||||||
|
client: {
|
||||||
|
overlay: false,
|
||||||
|
logging: 'warn'
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:5000',
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: false,
|
||||||
|
logLevel: 'debug',
|
||||||
|
onError(err, req, res) {
|
||||||
|
console.error('[proxy error]', req && req.url, err && err.message)
|
||||||
|
if (res && !res.headersSent) {
|
||||||
|
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' })
|
||||||
|
res.end(JSON.stringify({ code: -1, message: '后端代理失败,请确认 Flask 已在 5000 端口运行:' + (err && err.message) }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
6262
admin-web/yarn.lock
Normal file
@@ -1,4 +1,4 @@
|
|||||||
from flask import Blueprint, current_app, request
|
from flask import Blueprint, current_app, request, send_file, after_this_request
|
||||||
from flask_jwt_extended import jwt_required
|
from flask_jwt_extended import jwt_required
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
@@ -367,3 +367,56 @@ def import_samples():
|
|||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return ok({"created": created, "updated": updated}, "样本导入完成")
|
return ok({"created": created, "updated": updated}, "样本导入完成")
|
||||||
|
|
||||||
|
|
||||||
|
@spam_bp.post("/export/xlsx")
|
||||||
|
@jwt_required()
|
||||||
|
def export_xlsx():
|
||||||
|
user = current_user()
|
||||||
|
if not user:
|
||||||
|
return fail("用户不存在", 404)
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
items = payload.get("items") or []
|
||||||
|
if not isinstance(items, list) or not items:
|
||||||
|
return fail("items 必须是非空数组", 400)
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for item in items:
|
||||||
|
tokens = item.get("reason_tokens") or []
|
||||||
|
token_str = "; ".join(t.get("token", "") for t in tokens) if isinstance(tokens, list) else ""
|
||||||
|
prediction_text = "垃圾信息" if item.get("prediction") == "spam" else "正常信息"
|
||||||
|
|
||||||
|
rows.append({
|
||||||
|
"文本": item.get("text", ""),
|
||||||
|
"判定结果": prediction_text,
|
||||||
|
"分类标签": item.get("category_label", ""),
|
||||||
|
"置信度": f"{float(item.get('confidence', 0) or 0) * 100:.2f}%",
|
||||||
|
"垃圾概率": f"{float(item.get('spam_probability', 0) or 0) * 100:.2f}%",
|
||||||
|
"正常概率": f"{float(item.get('ham_probability', 0) or 0) * 100:.2f}%",
|
||||||
|
"风险关键词": token_str,
|
||||||
|
})
|
||||||
|
|
||||||
|
df = pd.DataFrame(rows)
|
||||||
|
tmp = tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False)
|
||||||
|
tmp.close()
|
||||||
|
df.to_excel(tmp.name, index=False, engine="openpyxl")
|
||||||
|
|
||||||
|
@after_this_request
|
||||||
|
def cleanup(response):
|
||||||
|
try:
|
||||||
|
os.unlink(tmp.name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return response
|
||||||
|
|
||||||
|
return send_file(
|
||||||
|
tmp.name,
|
||||||
|
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
as_attachment=True,
|
||||||
|
download_name="batch_detect.xlsx",
|
||||||
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 3306,
|
"port": 3306,
|
||||||
"user": "root",
|
"user": "root",
|
||||||
"password": "123456",
|
"password": "rootroot",
|
||||||
"database": "spam_nb_miniapp",
|
"database": "spam_nb_miniapp",
|
||||||
"charset": "utf8mb4",
|
"charset": "utf8mb4",
|
||||||
"admin_init": {
|
"admin_init": {
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ joblib==1.4.2
|
|||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
Werkzeug==3.1.3
|
Werkzeug==3.1.3
|
||||||
|
openpyxl==3.1.5
|
||||||
|
|||||||
@@ -1,25 +1,52 @@
|
|||||||
{
|
{
|
||||||
"pages": [
|
"pages": [
|
||||||
"pages/login/index",
|
|
||||||
"pages/register/index",
|
|
||||||
"pages/home/index",
|
"pages/home/index",
|
||||||
"pages/detect/index",
|
|
||||||
"pages/batch/index",
|
|
||||||
"pages/history/index",
|
"pages/history/index",
|
||||||
"pages/inbox/index",
|
"pages/inbox/index",
|
||||||
"pages/profile/index",
|
"pages/profile/index",
|
||||||
"pages/admin-dashboard/index",
|
"pages/login/index",
|
||||||
"pages/admin-review/index",
|
"pages/register/index",
|
||||||
"pages/admin-users/index",
|
"pages/batch/index"
|
||||||
"pages/admin-samples/index"
|
|
||||||
],
|
],
|
||||||
"window": {
|
"window": {
|
||||||
"navigationBarTitleText": "内容风控平台",
|
"navigationBarTitleText": "内容风控平台",
|
||||||
"navigationBarBackgroundColor": "#0A1A2D",
|
"navigationBarBackgroundColor": "#ffffff",
|
||||||
"navigationBarTextStyle": "white",
|
"navigationBarTextStyle": "black",
|
||||||
"backgroundTextStyle": "light",
|
"backgroundTextStyle": "dark",
|
||||||
"backgroundColor": "#EEF3F8"
|
"backgroundColor": "#f5f5f7"
|
||||||
|
},
|
||||||
|
"tabBar": {
|
||||||
|
"color": "#86868b",
|
||||||
|
"selectedColor": "#0066cc",
|
||||||
|
"backgroundColor": "#ffffff",
|
||||||
|
"borderStyle": "white",
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"pagePath": "pages/home/index",
|
||||||
|
"text": "发布",
|
||||||
|
"iconPath": "assets/icons/publish.png",
|
||||||
|
"selectedIconPath": "assets/icons/publish-active.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pagePath": "pages/history/index",
|
||||||
|
"text": "历史",
|
||||||
|
"iconPath": "assets/icons/history.png",
|
||||||
|
"selectedIconPath": "assets/icons/history-active.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pagePath": "pages/inbox/index",
|
||||||
|
"text": "私信",
|
||||||
|
"iconPath": "assets/icons/inbox.png",
|
||||||
|
"selectedIconPath": "assets/icons/inbox-active.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pagePath": "pages/profile/index",
|
||||||
|
"text": "我的",
|
||||||
|
"iconPath": "assets/icons/profile.png",
|
||||||
|
"selectedIconPath": "assets/icons/profile-active.png"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"style": "v2",
|
"style": "v2",
|
||||||
"sitemapLocation": "sitemap.json"
|
"sitemapLocation": "sitemap.json"
|
||||||
}
|
}
|
||||||
@@ -1,74 +1,99 @@
|
|||||||
/** app.wxss - xAI Brutalist Design System */
|
/** app.wxss - Apple Design System (Compact) */
|
||||||
|
|
||||||
page {
|
page {
|
||||||
--bg-dark: #1f2228;
|
/* Background Colors */
|
||||||
--bg-surface: rgba(255, 255, 255, 0.03);
|
--bg-canvas: #ffffff;
|
||||||
--bg-hover: rgba(255, 255, 255, 0.08);
|
--bg-parchment: #f5f5f7;
|
||||||
|
--bg-pearl: #fafafc;
|
||||||
|
--bg-nav: #000000;
|
||||||
|
|
||||||
--text-primary: #ffffff;
|
/* Text Colors */
|
||||||
--text-secondary: rgba(255, 255, 255, 0.7);
|
--text-primary: #1d1d1f;
|
||||||
--text-muted: rgba(255, 255, 255, 0.5);
|
--text-secondary: #86868b;
|
||||||
--text-disabled: rgba(255, 255, 255, 0.3);
|
--text-muted: #cccccc;
|
||||||
|
--text-dark-surface: #ffffff;
|
||||||
|
--text-fine-print: #7a7a7a;
|
||||||
|
|
||||||
--border-default: rgba(255, 255, 255, 0.1);
|
/* Accent Colors */
|
||||||
--border-strong: rgba(255, 255, 255, 0.2);
|
--accent-primary: #0066cc;
|
||||||
|
--accent-focus: #0071e3;
|
||||||
|
--accent-link: #2997ff;
|
||||||
|
|
||||||
--focus-ring: rgb(59, 130, 246);
|
/* Status Colors (Apple System) */
|
||||||
|
--status-spam: #ff3b30;
|
||||||
|
--status-ham: #34c759;
|
||||||
|
--status-warn: #ff9500;
|
||||||
|
--status-pending: #8e8e93;
|
||||||
|
|
||||||
|
/* Border Colors */
|
||||||
|
--border-light: #f0f0f0;
|
||||||
|
--border-medium: #e0e0e0;
|
||||||
|
|
||||||
|
/* Radius */
|
||||||
--radius-none: 0rpx;
|
--radius-none: 0rpx;
|
||||||
--radius-subtle: 8rpx;
|
--radius-sm: 12rpx;
|
||||||
|
--radius-md: 16rpx;
|
||||||
|
--radius-lg: 24rpx;
|
||||||
|
--radius-pill: 9999rpx;
|
||||||
|
|
||||||
|
/* Spacing (Compact) */
|
||||||
--spacing-xs: 8rpx;
|
--spacing-xs: 8rpx;
|
||||||
--spacing-sm: 16rpx;
|
--spacing-sm: 16rpx;
|
||||||
--spacing-md: 24rpx;
|
--spacing-md: 24rpx;
|
||||||
--spacing-lg: 48rpx;
|
--spacing-lg: 48rpx;
|
||||||
--spacing-xl: 96rpx;
|
--spacing-xl: 64rpx;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-display: 'PingFang SC', 'SF Pro Display', -apple-system, sans-serif;
|
||||||
|
--font-text: 'PingFang SC', 'SF Pro Text', -apple-system, sans-serif;
|
||||||
|
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: 'PingFang SC', 'Microsoft YaHei', -apple-system, sans-serif;
|
font-family: var(--font-text);
|
||||||
background: var(--bg-dark);
|
font-size: 28rpx;
|
||||||
|
line-height: 1.4;
|
||||||
|
background: var(--bg-parchment);
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: var(--spacing-md);
|
padding: var(--spacing-sm);
|
||||||
padding-bottom: calc(var(--spacing-lg) + env(safe-area-inset-bottom));
|
padding-bottom: calc(var(--spacing-md) + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hero Section */
|
/* Hero Section */
|
||||||
.hero {
|
.hero {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: var(--spacing-lg) 0;
|
padding: var(--spacing-md) 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-bottom: 1px solid var(--border-default);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-badge {
|
.hero-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: var(--spacing-xs) var(--spacing-sm);
|
padding: 6rpx 12rpx;
|
||||||
border: 1px solid var(--border-strong);
|
border-radius: var(--radius-pill);
|
||||||
color: var(--text-primary);
|
background: var(--bg-pearl);
|
||||||
font-family: 'SF Mono', 'Consolas', monospace;
|
color: var(--accent-primary);
|
||||||
font-size: 22rpx;
|
font-family: var(--font-text);
|
||||||
letter-spacing: 1rpx;
|
font-size: 20rpx;
|
||||||
text-transform: uppercase;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-title {
|
.hero-title {
|
||||||
margin-top: var(--spacing-md);
|
margin-top: var(--spacing-sm);
|
||||||
font-size: 56rpx;
|
font-family: var(--font-display);
|
||||||
font-weight: 300;
|
font-size: 48rpx;
|
||||||
letter-spacing: 2rpx;
|
font-weight: 600;
|
||||||
|
letter-spacing: -1rpx;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
line-height: 1.3;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-sub {
|
.hero-sub {
|
||||||
margin-top: var(--spacing-sm);
|
margin-top: var(--spacing-xs);
|
||||||
font-size: 28rpx;
|
font-size: 26rpx;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
max-width: 600rpx;
|
max-width: 600rpx;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
@@ -77,68 +102,67 @@ page {
|
|||||||
.hero-meta {
|
.hero-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: var(--spacing-xs);
|
gap: 8rpx;
|
||||||
margin-top: var(--spacing-md);
|
margin-top: var(--spacing-sm);
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-metric {
|
.hero-metric {
|
||||||
padding: var(--spacing-xs) var(--spacing-sm);
|
padding: 6rpx 12rpx;
|
||||||
font-size: 24rpx;
|
font-size: 22rpx;
|
||||||
color: var(--text-muted);
|
color: var(--text-secondary);
|
||||||
border: 1px solid var(--border-default);
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Cards */
|
/* Cards */
|
||||||
.card {
|
.card {
|
||||||
margin-top: var(--spacing-md);
|
margin-top: var(--spacing-sm);
|
||||||
padding: var(--spacing-md);
|
padding: var(--spacing-sm);
|
||||||
background: transparent;
|
background: var(--bg-canvas);
|
||||||
border: 1px solid var(--border-default);
|
border: 1rpx solid var(--border-medium);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card:active {
|
|
||||||
border-color: var(--border-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
margin-bottom: var(--spacing-sm);
|
margin-bottom: var(--spacing-xs);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-display);
|
||||||
font-size: 32rpx;
|
font-size: 32rpx;
|
||||||
font-weight: 400;
|
font-weight: 600;
|
||||||
letter-spacing: 0.5rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-desc {
|
.card-desc {
|
||||||
margin-bottom: var(--spacing-sm);
|
margin-bottom: var(--spacing-xs);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 26rpx;
|
font-size: 24rpx;
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Divider */
|
/* Divider */
|
||||||
.glass-divider {
|
.glass-divider {
|
||||||
margin: var(--spacing-sm) 0;
|
margin: var(--spacing-xs) 0;
|
||||||
height: 1px;
|
height: 1rpx;
|
||||||
background: var(--border-default);
|
background: var(--border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fields */
|
/* Fields */
|
||||||
.field {
|
.field {
|
||||||
margin-top: var(--spacing-sm);
|
margin-top: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-label {
|
.field-label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: var(--spacing-xs);
|
margin-bottom: 6rpx;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 26rpx;
|
font-size: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-help {
|
.field-help {
|
||||||
margin-top: var(--spacing-xs);
|
margin-top: 6rpx;
|
||||||
color: var(--text-muted);
|
color: var(--text-secondary);
|
||||||
font-size: 24rpx;
|
font-size: 22rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inputs */
|
/* Inputs */
|
||||||
@@ -146,39 +170,40 @@ page {
|
|||||||
.textarea {
|
.textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-height: 80rpx;
|
min-height: 72rpx;
|
||||||
padding: 0 var(--spacing-sm);
|
padding: 0 var(--spacing-sm);
|
||||||
background: transparent;
|
background: var(--bg-parchment);
|
||||||
border: 1px solid var(--border-strong);
|
border: 1rpx solid var(--border-light);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 32rpx;
|
font-size: 28rpx;
|
||||||
line-height: 80rpx;
|
line-height: 72rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:focus,
|
.input:focus,
|
||||||
.textarea:focus {
|
.textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--focus-ring);
|
border-color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.textarea {
|
.textarea {
|
||||||
min-height: 200rpx;
|
min-height: 160rpx;
|
||||||
padding-top: var(--spacing-sm);
|
padding-top: var(--spacing-xs);
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-value {
|
.picker-value {
|
||||||
min-width: 180rpx;
|
min-width: 140rpx;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
color: var(--text-primary);
|
color: var(--accent-primary);
|
||||||
font-size: 28rpx;
|
font-size: 26rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
.btn-row {
|
.btn-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-xs);
|
||||||
margin-top: var(--spacing-sm);
|
margin-top: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-row .btn {
|
.btn-row .btn {
|
||||||
@@ -187,17 +212,16 @@ page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
margin-top: var(--spacing-sm);
|
margin-top: var(--spacing-xs);
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
padding: 16rpx 32rpx;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: var(--radius-pill);
|
||||||
font-family: 'SF Mono', 'Consolas', monospace;
|
font-family: var(--font-text);
|
||||||
font-size: 28rpx;
|
font-size: 26rpx;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
letter-spacing: 2rpx;
|
color: var(--text-dark-surface);
|
||||||
text-transform: uppercase;
|
background: var(--accent-primary);
|
||||||
color: var(--bg-dark);
|
transition: transform 0.1s ease;
|
||||||
background: var(--text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button.btn::after {
|
button.btn::after {
|
||||||
@@ -205,34 +229,45 @@ button.btn::after {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn:active {
|
.btn:active {
|
||||||
opacity: 0.9;
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn[disabled] {
|
.btn[disabled] {
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary Button */
|
||||||
|
.btn-primary {
|
||||||
|
color: var(--text-dark-surface);
|
||||||
|
background: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ghost Button */
|
/* Ghost Button */
|
||||||
.btn-ghost {
|
.btn-ghost {
|
||||||
color: var(--text-primary);
|
color: var(--accent-primary);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--border-strong);
|
border: 1rpx solid var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-ghost:active {
|
.btn-ghost:active {
|
||||||
background: var(--bg-hover);
|
background: rgba(0, 102, 204, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost[disabled] {
|
||||||
|
opacity: 0.3;
|
||||||
|
border-color: var(--text-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Accent Button */
|
/* Accent Button */
|
||||||
.btn-accent {
|
.btn-accent {
|
||||||
color: var(--text-primary);
|
color: var(--accent-primary);
|
||||||
background: transparent;
|
background: var(--bg-pearl);
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-accent:active {
|
.btn-accent:active {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-parchment);
|
||||||
border-color: var(--text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rows */
|
/* Rows */
|
||||||
@@ -240,21 +275,21 @@ button.btn::after {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.row + .row {
|
.row + .row {
|
||||||
margin-top: var(--spacing-sm);
|
margin-top: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 26rpx;
|
font-size: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.value {
|
.value {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 28rpx;
|
font-size: 26rpx;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,158 +297,163 @@ button.btn::after {
|
|||||||
.grid-2 {
|
.grid-2 {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-3 {
|
.grid-3 {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-auto {
|
.grid-auto {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220rpx, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(180rpx, 1fr));
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* KPI Cards */
|
/* KPI Cards */
|
||||||
.kpi {
|
.kpi {
|
||||||
padding: var(--spacing-sm);
|
padding: var(--spacing-xs);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-parchment);
|
||||||
border: 1px solid var(--border-default);
|
border-radius: var(--radius-md);
|
||||||
}
|
text-align: center;
|
||||||
|
|
||||||
.kpi:active {
|
|
||||||
border-color: var(--border-strong);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi-value {
|
.kpi-value {
|
||||||
font-size: 40rpx;
|
font-family: var(--font-display);
|
||||||
font-weight: 300;
|
font-size: 36rpx;
|
||||||
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi-label {
|
.kpi-label {
|
||||||
margin-top: var(--spacing-xs);
|
margin-top: 4rpx;
|
||||||
font-size: 24rpx;
|
font-size: 22rpx;
|
||||||
color: var(--text-muted);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Module Cards */
|
/* Module Cards */
|
||||||
.module-card {
|
.module-card {
|
||||||
padding: var(--spacing-sm);
|
padding: var(--spacing-xs);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-canvas);
|
||||||
border: 1px solid var(--border-default);
|
border: 1rpx solid var(--border-medium);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: transform 0.1s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-card:active {
|
.module-card:active {
|
||||||
border-color: var(--border-strong);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-name {
|
.module-name {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 30rpx;
|
font-size: 28rpx;
|
||||||
font-weight: 400;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-desc {
|
.module-desc {
|
||||||
margin-top: var(--spacing-xs);
|
margin-top: 4rpx;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 24rpx;
|
font-size: 22rpx;
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-tag {
|
.module-tag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: var(--spacing-sm);
|
margin-top: var(--spacing-xs);
|
||||||
padding: var(--spacing-xs) var(--spacing-sm);
|
padding: 4rpx 10rpx;
|
||||||
color: var(--text-muted);
|
color: var(--accent-primary);
|
||||||
font-size: 22rpx;
|
font-size: 20rpx;
|
||||||
font-family: 'SF Mono', 'Consolas', monospace;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
background: rgba(0, 102, 204, 0.1);
|
||||||
letter-spacing: 1rpx;
|
border-radius: var(--radius-pill);
|
||||||
border: 1px solid var(--border-default);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* List Items */
|
/* List Items */
|
||||||
.list-item {
|
.list-item {
|
||||||
margin-top: var(--spacing-sm);
|
margin-top: var(--spacing-xs);
|
||||||
padding: var(--spacing-sm);
|
padding: var(--spacing-xs);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-canvas);
|
||||||
border: 1px solid var(--border-default);
|
border: 1rpx solid var(--border-light);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-item:active {
|
.list-item:active {
|
||||||
border-color: var(--border-strong);
|
background: var(--bg-parchment);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-title {
|
.item-title {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
font-weight: 400;
|
font-weight: 600;
|
||||||
line-height: 1.4;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-sub {
|
.item-sub {
|
||||||
margin-top: var(--spacing-xs);
|
margin-top: 4rpx;
|
||||||
color: var(--text-muted);
|
color: var(--text-secondary);
|
||||||
font-size: 24rpx;
|
font-size: 22rpx;
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tags & Chips */
|
/* Tags & Chips */
|
||||||
.tag {
|
.tag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-right: var(--spacing-xs);
|
margin-right: 6rpx;
|
||||||
margin-bottom: var(--spacing-xs);
|
margin-bottom: 6rpx;
|
||||||
padding: var(--spacing-xs) var(--spacing-sm);
|
padding: 4rpx 10rpx;
|
||||||
border: 1px solid var(--border-strong);
|
border-radius: var(--radius-sm);
|
||||||
font-size: 24rpx;
|
font-size: 22rpx;
|
||||||
font-family: 'SF Mono', 'Consolas', monospace;
|
font-family: var(--font-text);
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1rpx;
|
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: transparent;
|
background: var(--bg-parchment);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-danger {
|
.tag-danger {
|
||||||
border-color: rgba(255, 91, 111, 0.5);
|
color: var(--status-spam);
|
||||||
color: rgba(255, 91, 111, 0.9);
|
background: rgba(255, 59, 48, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip-group {
|
.chip-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: var(--spacing-xs);
|
gap: 6rpx;
|
||||||
margin-top: var(--spacing-sm);
|
margin-top: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip {
|
.chip {
|
||||||
padding: var(--spacing-xs) var(--spacing-sm);
|
padding: 6rpx 12rpx;
|
||||||
border: 1px solid var(--border-default);
|
border-radius: var(--radius-pill);
|
||||||
font-size: 24rpx;
|
font-size: 22rpx;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: transparent;
|
background: var(--bg-parchment);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Progress Track - Minimalist */
|
.chip:active {
|
||||||
|
background: var(--bg-pearl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Track */
|
||||||
.progress-track {
|
.progress-track {
|
||||||
margin-top: var(--spacing-xs);
|
margin-top: 6rpx;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 4rpx;
|
height: 6rpx;
|
||||||
background: var(--border-default);
|
background: var(--border-light);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-fill {
|
.progress-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: rgba(255, 91, 111, 0.8);
|
background: var(--status-spam);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-fill-safe {
|
.progress-fill-safe {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--text-muted);
|
background: var(--status-ham);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status Labels */
|
/* Status Labels */
|
||||||
@@ -422,40 +462,39 @@ button.btn::after {
|
|||||||
.status-warn,
|
.status-warn,
|
||||||
.status-pending {
|
.status-pending {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: var(--spacing-xs) var(--spacing-sm);
|
padding: 4rpx 12rpx;
|
||||||
font-size: 24rpx;
|
font-size: 22rpx;
|
||||||
font-family: 'SF Mono', 'Consolas', monospace;
|
font-family: var(--font-text);
|
||||||
text-transform: uppercase;
|
font-weight: 600;
|
||||||
letter-spacing: 1rpx;
|
border-radius: var(--radius-pill);
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-spam {
|
.status-spam {
|
||||||
color: rgba(255, 91, 111, 0.9);
|
color: var(--text-dark-surface);
|
||||||
border-color: rgba(255, 91, 111, 0.5);
|
background: var(--status-spam);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-ham {
|
.status-ham {
|
||||||
color: rgba(45, 207, 149, 0.9);
|
color: var(--text-dark-surface);
|
||||||
border-color: rgba(45, 207, 149, 0.5);
|
background: var(--status-ham);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-warn {
|
.status-warn {
|
||||||
color: rgba(255, 180, 84, 0.9);
|
color: var(--text-dark-surface);
|
||||||
border-color: rgba(255, 180, 84, 0.5);
|
background: var(--status-warn);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-pending {
|
.status-pending {
|
||||||
color: var(--text-muted);
|
color: var(--text-primary);
|
||||||
border-color: var(--border-default);
|
background: var(--bg-parchment);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pager */
|
/* Pager */
|
||||||
.pager-row {
|
.pager-row {
|
||||||
margin-top: var(--spacing-sm);
|
margin-top: var(--spacing-xs);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pager-btn {
|
.pager-btn {
|
||||||
@@ -464,44 +503,44 @@ button.btn::after {
|
|||||||
|
|
||||||
/* Muted & Small */
|
/* Muted & Small */
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--text-muted);
|
color: var(--text-secondary);
|
||||||
font-size: 26rpx;
|
font-size: 22rpx;
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.small {
|
.small {
|
||||||
font-size: 24rpx;
|
font-size: 20rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: var(--spacing-lg);
|
padding: var(--spacing-md);
|
||||||
color: var(--text-muted);
|
color: var(--text-secondary);
|
||||||
font-size: 26rpx;
|
font-size: 24rpx;
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animations */
|
/* Animations */
|
||||||
.fade-up {
|
.fade-up {
|
||||||
animation: fadeUp 0.4s ease both;
|
animation: fadeUp 0.3s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-up-delay-1 {
|
.fade-up-delay-1 {
|
||||||
animation-delay: 0.08s;
|
animation-delay: 0.05s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-up-delay-2 {
|
.fade-up-delay-2 {
|
||||||
animation-delay: 0.16s;
|
animation-delay: 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-up-delay-3 {
|
.fade-up-delay-3 {
|
||||||
animation-delay: 0.24s;
|
animation-delay: 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeUp {
|
@keyframes fadeUp {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(16rpx);
|
transform: translateY(12rpx);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -513,15 +552,15 @@ button.btn::after {
|
|||||||
.evidence-grid {
|
.evidence-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-xs);
|
||||||
margin-top: var(--spacing-sm);
|
margin-top: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.evidence-item {
|
.evidence-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 120rpx;
|
width: 100rpx;
|
||||||
height: 120rpx;
|
height: 100rpx;
|
||||||
border: 1px solid var(--border-strong);
|
border-radius: var(--radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,132 +578,135 @@ button.btn::after {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: -4rpx;
|
top: -4rpx;
|
||||||
right: -4rpx;
|
right: -4rpx;
|
||||||
width: 32rpx;
|
width: 28rpx;
|
||||||
height: 32rpx;
|
height: 28rpx;
|
||||||
background: rgba(255, 91, 111, 0.9);
|
background: var(--status-spam);
|
||||||
color: var(--text-primary);
|
color: var(--text-dark-surface);
|
||||||
font-size: 24rpx;
|
font-size: 20rpx;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 32rpx;
|
line-height: 28rpx;
|
||||||
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.evidence-add {
|
.evidence-add {
|
||||||
width: 120rpx;
|
width: 100rpx;
|
||||||
height: 120rpx;
|
height: 100rpx;
|
||||||
border: 1px solid var(--border-strong);
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-parchment);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.evidence-add-icon {
|
.evidence-add-icon {
|
||||||
font-size: 48rpx;
|
font-size: 36rpx;
|
||||||
color: var(--text-muted);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Credit Score Bar */
|
/* Credit Score Bar */
|
||||||
.credit-score-bar {
|
.credit-score-bar {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 180rpx;
|
width: 140rpx;
|
||||||
height: 28rpx;
|
height: 20rpx;
|
||||||
background: var(--border-default);
|
background: var(--border-light);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
}
|
}
|
||||||
|
|
||||||
.credit-fill {
|
.credit-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: rgba(45, 207, 149, 0.6);
|
background: var(--status-ham);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
}
|
}
|
||||||
|
|
||||||
.credit-value {
|
.credit-value {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: var(--spacing-xs);
|
right: 8rpx;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
font-size: 22rpx;
|
font-size: 18rpx;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Report Modal */
|
/* Report Modal */
|
||||||
.report-modal {
|
.report-modal {
|
||||||
margin-top: var(--spacing-md);
|
margin-top: var(--spacing-sm);
|
||||||
padding: var(--spacing-md);
|
padding: var(--spacing-sm);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-canvas);
|
||||||
border: 1px solid var(--border-strong);
|
border: 1rpx solid var(--border-medium);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-header {
|
.report-header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: var(--spacing-sm);
|
margin-bottom: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-title {
|
.report-title {
|
||||||
font-size: 36rpx;
|
font-size: 32rpx;
|
||||||
font-weight: 300;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: 'SF Mono', 'Consolas', monospace;
|
font-family: var(--font-display);
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-period {
|
.report-period {
|
||||||
font-size: 24rpx;
|
font-size: 22rpx;
|
||||||
color: var(--text-muted);
|
color: var(--text-secondary);
|
||||||
margin-top: var(--spacing-xs);
|
margin-top: 4rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-close {
|
.report-close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: var(--spacing-sm);
|
top: var(--spacing-xs);
|
||||||
right: var(--spacing-sm);
|
right: var(--spacing-xs);
|
||||||
width: 40rpx;
|
width: 36rpx;
|
||||||
height: 40rpx;
|
height: 36rpx;
|
||||||
color: rgba(255, 91, 111, 0.9);
|
color: var(--status-spam);
|
||||||
font-size: 32rpx;
|
font-size: 28rpx;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 40rpx;
|
line-height: 36rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-section {
|
.report-section {
|
||||||
margin-top: var(--spacing-sm);
|
margin-top: var(--spacing-xs);
|
||||||
padding: var(--spacing-sm);
|
padding: var(--spacing-xs);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-top: 1px solid var(--border-default);
|
border-top: 1rpx solid var(--border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-section-title {
|
.report-section-title {
|
||||||
font-size: 28rpx;
|
font-size: 24rpx;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin-bottom: var(--spacing-sm);
|
margin-bottom: var(--spacing-xs);
|
||||||
font-family: 'SF Mono', 'Consolas', monospace;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-kpi {
|
.report-kpi {
|
||||||
padding: var(--spacing-sm);
|
padding: var(--spacing-xs);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-parchment);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border: 1px solid var(--border-default);
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-kpi-value {
|
.report-kpi-value {
|
||||||
font-size: 32rpx;
|
font-size: 28rpx;
|
||||||
font-weight: 300;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-kpi-label {
|
.report-kpi-label {
|
||||||
font-size: 22rpx;
|
font-size: 20rpx;
|
||||||
color: var(--text-muted);
|
color: var(--text-secondary);
|
||||||
margin-top: var(--spacing-xs);
|
margin-top: 4rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-trend-item {
|
.report-trend-item {
|
||||||
margin-top: var(--spacing-xs);
|
margin-top: 4rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Note textarea */
|
/* Note textarea */
|
||||||
.note-textarea {
|
.note-textarea {
|
||||||
min-height: 120rpx;
|
min-height: 100rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Media Query */
|
/* Media Query */
|
||||||
|
|||||||
BIN
miniprogram/assets/icons/history-active.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
miniprogram/assets/icons/history.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
miniprogram/assets/icons/home-active.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
miniprogram/assets/icons/home.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
4
miniprogram/assets/icons/home.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<!-- Home Icon (Normal) -->
|
||||||
|
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 40V20L24 10L36 20V40H28V28H20V40H12Z" stroke="#86868b" stroke-width="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 231 B |
BIN
miniprogram/assets/icons/inbox-active.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
miniprogram/assets/icons/inbox.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
miniprogram/assets/icons/profile-active.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
miniprogram/assets/icons/profile.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
miniprogram/assets/icons/publish-active.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
miniprogram/assets/icons/publish.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
@@ -3,6 +3,8 @@ const { request } = require('../../utils/request')
|
|||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
inputText: '',
|
inputText: '',
|
||||||
|
fileName: '',
|
||||||
|
lineCount: 0,
|
||||||
loading: false,
|
loading: false,
|
||||||
summary: null,
|
summary: null,
|
||||||
items: []
|
items: []
|
||||||
@@ -24,6 +26,43 @@ Page({
|
|||||||
.filter((line) => line.length >= 2)
|
.filter((line) => line.length >= 2)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
chooseFile() {
|
||||||
|
wx.chooseMessageFile({
|
||||||
|
count: 1,
|
||||||
|
type: 'file',
|
||||||
|
extension: ['txt'],
|
||||||
|
success: (res) => {
|
||||||
|
const file = res.tempFiles[0]
|
||||||
|
const fs = wx.getFileSystemManager()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(file.path, 'utf8')
|
||||||
|
const lines = content
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length >= 2)
|
||||||
|
|
||||||
|
this.setData({
|
||||||
|
inputText: lines.join('\n'),
|
||||||
|
fileName: file.name,
|
||||||
|
lineCount: lines.length
|
||||||
|
})
|
||||||
|
|
||||||
|
wx.showToast({ title: `已读取 ${lines.length} 条文本`, icon: 'success' })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('读取文件失败', err)
|
||||||
|
wx.showToast({ title: '文件读取失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('选择文件失败', err)
|
||||||
|
if (err.errMsg !== 'chooseMessageFile:fail cancel') {
|
||||||
|
wx.showToast({ title: '请选择TXT文件', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
async submit() {
|
async submit() {
|
||||||
if (this.data.loading) return
|
if (this.data.loading) return
|
||||||
const items = this.parseLines()
|
const items = this.parseLines()
|
||||||
@@ -102,46 +141,66 @@ Page({
|
|||||||
return [headers.join(','), ...rows].join('\n')
|
return [headers.join(','), ...rows].join('\n')
|
||||||
},
|
},
|
||||||
|
|
||||||
exportCSV() {
|
exportXLSX() {
|
||||||
const items = this.data.items
|
const items = this.data.items
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
wx.showToast({ title: '暂无识别结果可导出', icon: 'none' })
|
wx.showToast({ title: '暂无识别结果可导出', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const csvContent = this.generateCSV()
|
wx.showLoading({ title: '生成文件中...' })
|
||||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')
|
|
||||||
const filename = `batch_detect_${timestamp}.csv`
|
|
||||||
|
|
||||||
// 写入临时文件
|
const app = getApp()
|
||||||
const fs = wx.getFileSystemManager()
|
const token = app.globalData.token || wx.getStorageSync('token') || ''
|
||||||
const tempPath = `${wx.env.USER_DATA_PATH}/${filename}`
|
const baseURL = app.globalData.baseURL || 'http://127.0.0.1:5000/api'
|
||||||
|
|
||||||
try {
|
wx.request({
|
||||||
fs.writeFileSync(tempPath, csvContent, 'utf8')
|
url: `${baseURL}/spam/export/xlsx`,
|
||||||
wx.showModal({
|
method: 'POST',
|
||||||
title: '导出成功',
|
data: { items },
|
||||||
content: `CSV文件已生成,是否打开查看?\n文件名:${filename}`,
|
header: {
|
||||||
confirmText: '打开',
|
'Content-Type': 'application/json',
|
||||||
cancelText: '关闭',
|
Authorization: `Bearer ${token}`
|
||||||
success: (res) => {
|
},
|
||||||
if (res.confirm) {
|
responseType: 'arraybuffer',
|
||||||
wx.openDocument({
|
success(res) {
|
||||||
filePath: tempPath,
|
wx.hideLoading()
|
||||||
fileType: 'csv',
|
|
||||||
showMenu: true,
|
if (res.statusCode !== 200) {
|
||||||
fail: (err) => {
|
wx.showToast({ title: '导出失败', icon: 'none' })
|
||||||
console.error('打开文件失败', err)
|
return
|
||||||
wx.showToast({ title: '打开失败,请检查文件管理器', icon: 'none' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
} catch (err) {
|
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')
|
||||||
console.error('写入文件失败', err)
|
const filename = `batch_detect_${timestamp}.xlsx`
|
||||||
wx.showToast({ title: '导出失败', icon: 'none' })
|
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() {
|
copyCSVToClipboard() {
|
||||||
|
|||||||
@@ -2,17 +2,37 @@
|
|||||||
<view class="hero fade-up">
|
<view class="hero fade-up">
|
||||||
<view class="hero-badge">BATCH SCAN</view>
|
<view class="hero-badge">BATCH SCAN</view>
|
||||||
<view class="hero-title">批量文本筛查</view>
|
<view class="hero-title">批量文本筛查</view>
|
||||||
<view class="hero-sub">每行一条文本,适用于活动文案、客服话术、私信模板的集中检测。</view>
|
<view class="hero-sub">上传TXT文件或手动输入,每行一条文本,系统自动逐行检测。</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="card fade-up fade-up-delay-1">
|
<view class="card fade-up fade-up-delay-1">
|
||||||
<view class="card-title">批量输入</view>
|
<view class="card-title">上传文件</view>
|
||||||
<view class="card-desc">请按“每行一条”粘贴文本内容,系统会自动跳过空行。</view>
|
<view class="card-desc">支持TXT文本文件,每行一条待检测内容。</view>
|
||||||
|
|
||||||
|
<view class="btn-row">
|
||||||
|
<button class="btn btn-primary" bindtap="chooseFile">选择TXT文件</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="field" wx:if="{{fileName}}">
|
||||||
|
<view class="row">
|
||||||
|
<text class="label">已选文件</text>
|
||||||
|
<text class="value">{{fileName}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="row">
|
||||||
|
<text class="label">文本条数</text>
|
||||||
|
<text class="value">{{lineCount}} 条</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card fade-up fade-up-delay-1">
|
||||||
|
<view class="card-title">手动输入</view>
|
||||||
|
<view class="card-desc">或直接粘贴文本内容,每行一条。</view>
|
||||||
<textarea class="textarea" placeholder="示例: 点击链接领取红包 今天下午三点开会" value="{{inputText}}" bindinput="onInput" />
|
<textarea class="textarea" placeholder="示例: 点击链接领取红包 今天下午三点开会" value="{{inputText}}" bindinput="onInput" />
|
||||||
|
|
||||||
<view class="btn-row">
|
<view class="btn-row">
|
||||||
<button class="btn btn-ghost" bindtap="fillDemo">填充示例</button>
|
<button class="btn btn-ghost" bindtap="fillDemo">填充示例</button>
|
||||||
<button class="btn btn-primary" loading="{{loading}}" bindtap="submit">开始识别</button>
|
<button class="btn btn-accent" loading="{{loading}}" bindtap="submit">开始识别</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -57,7 +77,7 @@
|
|||||||
<view class="card fade-up fade-up-delay-3" wx:if="{{items.length}}">
|
<view class="card fade-up fade-up-delay-3" wx:if="{{items.length}}">
|
||||||
<view class="card-title">明细结果</view>
|
<view class="card-title">明细结果</view>
|
||||||
<view class="btn-row" style="margin-bottom: 12rpx;">
|
<view class="btn-row" style="margin-bottom: 12rpx;">
|
||||||
<button class="btn btn-ghost" bindtap="exportCSV">导出CSV文件</button>
|
<button class="btn btn-ghost" bindtap="exportXLSX">导出Excel文件</button>
|
||||||
<button class="btn btn-ghost" bindtap="copyCSVToClipboard">复制CSV内容</button>
|
<button class="btn btn-ghost" bindtap="copyCSVToClipboard">复制CSV内容</button>
|
||||||
</view>
|
</view>
|
||||||
<view class="list-item" wx:for="{{items}}" wx:key="index">
|
<view class="list-item" wx:for="{{items}}" wx:key="index">
|
||||||
@@ -85,4 +105,4 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -214,6 +214,19 @@ Page({
|
|||||||
this.fetchList()
|
this.fetchList()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
showTokenWeight(e) {
|
||||||
|
const token = e.currentTarget.dataset.token
|
||||||
|
const weight = e.currentTarget.dataset.weight
|
||||||
|
const weightNum = Number(weight || 0)
|
||||||
|
const direction = weightNum >= 0 ? '倾向垃圾判定' : '倾向正常判定'
|
||||||
|
wx.showModal({
|
||||||
|
title: '关键词权重',
|
||||||
|
content: `关键词"${token}"\n权重贡献:${weightNum >= 0 ? '+' : ''}${weightNum.toFixed(4)}\n(${direction})`,
|
||||||
|
showCancel: false,
|
||||||
|
confirmText: '关闭'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
removeItem(e) {
|
removeItem(e) {
|
||||||
const id = Number(e.currentTarget.dataset.id)
|
const id = Number(e.currentTarget.dataset.id)
|
||||||
wx.showModal({
|
wx.showModal({
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
<view class="field" wx:if="{{item.reason_tokens && item.reason_tokens.length}}">
|
<view class="field" wx:if="{{item.reason_tokens && item.reason_tokens.length}}">
|
||||||
<text class="field-label">风险关键词</text>
|
<text class="field-label">风险关键词</text>
|
||||||
<view class="chip-group">
|
<view class="chip-group">
|
||||||
<text class="tag" wx:for="{{item.reason_tokens}}" wx:key="*this">{{item}}</text>
|
<text class="tag tag-danger" wx:for="{{item.reason_tokens}}" wx:for-item="tokenItem" wx:key="token" data-token="{{tokenItem.token}}" data-weight="{{tokenItem.weight}}" bindtap="showTokenWeight">{{tokenItem.token}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
const { request } = require('../../utils/request')
|
const { request } = require('../../utils/request')
|
||||||
|
|
||||||
const USER_MODULES = [
|
const QUICK_TEXTS = [
|
||||||
{ name: '信息发布', desc: '发布公开 / 私有 / 私信文本并实时检测', tag: '发布检测', path: '/pages/detect/index' },
|
'大家好,今晚 8 点社区线上读书会,欢迎参加。',
|
||||||
{ name: '批量识别', desc: '多条文本批量检测并给出风险汇总', tag: '批量筛查', path: '/pages/batch/index' },
|
'恭喜中奖领取大额现金,点击链接立即到账。',
|
||||||
{ name: '发布历史', desc: '查看发布状态、概率和申诉进度', tag: '历史追踪', path: '/pages/history/index' },
|
'本周活动报名已开放,请在群里接龙。',
|
||||||
{ name: '私信收件箱', desc: '查看通过检测后成功送达的私信', tag: '私信查看', path: '/pages/inbox/index' },
|
'高薪兼职日结,扫码进群立刻赚钱。'
|
||||||
{ name: '个人中心', desc: '维护个人资料与密码设置', tag: '账号设置', path: '/pages/profile/index' }
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const ADMIN_MODULES = [
|
const VISIBILITY_OPTIONS = [
|
||||||
{ name: '运营看板', desc: '监控发布、拦截、样本和模型状态', tag: '数据概览', path: '/pages/admin-dashboard/index' },
|
{ value: 'public', label: '公开信息发布' },
|
||||||
{ name: '复核与申诉', desc: '处理拦截复核和用户申诉', tag: '审核处理', path: '/pages/admin-review/index' },
|
{ value: 'private', label: '私有信息发布' },
|
||||||
{ name: '样本管理', desc: '维护训练样本并触发模型重训', tag: '模型迭代', path: '/pages/admin-samples/index' },
|
{ value: 'direct', label: '用户私信发布' }
|
||||||
{ name: '用户管理', desc: '编辑用户信息和权限', tag: '权限管理', path: '/pages/admin-users/index' }
|
|
||||||
]
|
]
|
||||||
|
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
loading: true,
|
loading: false,
|
||||||
user: null,
|
user: null,
|
||||||
modelInfo: null,
|
text: '',
|
||||||
threshold: null,
|
result: null,
|
||||||
thresholdText: '--',
|
quickTexts: QUICK_TEXTS,
|
||||||
userModules: USER_MODULES,
|
visibilityOptions: VISIBILITY_OPTIONS,
|
||||||
adminModules: ADMIN_MODULES
|
visibilityIndex: 0,
|
||||||
|
visibility: 'public',
|
||||||
|
recipientUsername: ''
|
||||||
},
|
},
|
||||||
|
|
||||||
onShow() {
|
onShow() {
|
||||||
@@ -37,29 +37,110 @@ Page({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setData({ loading: true })
|
|
||||||
try {
|
try {
|
||||||
const [user, modelInfo] = await Promise.all([
|
const user = await request({ url: '/auth/me' })
|
||||||
request({ url: '/auth/me' }),
|
|
||||||
request({ url: '/spam/model/info' })
|
|
||||||
])
|
|
||||||
|
|
||||||
app.globalData.user = user
|
app.globalData.user = user
|
||||||
wx.setStorageSync('user', user)
|
wx.setStorageSync('user', user)
|
||||||
const threshold = modelInfo.threshold || null
|
this.setData({ user })
|
||||||
const thresholdText = threshold === null || threshold === undefined ? '--' : `${(Number(threshold) * 100).toFixed(1)}%`
|
} catch (e) {
|
||||||
this.setData({ user, modelInfo, threshold, thresholdText })
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
formatPercent(value, digits = 2) {
|
||||||
|
const num = Number(value || 0)
|
||||||
|
return `${(num * 100).toFixed(digits)}%`
|
||||||
|
},
|
||||||
|
|
||||||
|
onInput(e) {
|
||||||
|
const field = e.currentTarget.dataset.field || 'text'
|
||||||
|
this.setData({ [field]: e.detail.value || '' })
|
||||||
|
},
|
||||||
|
|
||||||
|
fillQuick(e) {
|
||||||
|
this.setData({ text: e.currentTarget.dataset.text || '' })
|
||||||
|
},
|
||||||
|
|
||||||
|
onVisibilityChange(e) {
|
||||||
|
const idx = Number(e.detail.value)
|
||||||
|
const row = this.data.visibilityOptions[idx] || this.data.visibilityOptions[0]
|
||||||
|
this.setData({ visibilityIndex: idx, visibility: row.value })
|
||||||
|
},
|
||||||
|
|
||||||
|
async publish() {
|
||||||
|
if (this.data.loading) return
|
||||||
|
const text = (this.data.text || '').trim()
|
||||||
|
if (text.length < 2) {
|
||||||
|
wx.showToast({ title: '请输入至少 2 个字符', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
text,
|
||||||
|
visibility: this.data.visibility
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.data.visibility === 'direct') {
|
||||||
|
const receiver = (this.data.recipientUsername || '').trim()
|
||||||
|
if (!receiver) {
|
||||||
|
wx.showToast({ title: '私信请填写接收人用户名', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload.recipient_username = receiver
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setData({ loading: true })
|
||||||
|
try {
|
||||||
|
const result = await request({
|
||||||
|
url: '/content/publish',
|
||||||
|
method: 'POST',
|
||||||
|
data: payload
|
||||||
|
})
|
||||||
|
|
||||||
|
this.setData({
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
wx.showToast({
|
||||||
|
title: result.publish_allowed ? '发布成功' : '已拦截,可申诉',
|
||||||
|
icon: result.publish_allowed ? 'success' : 'none'
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
this.setData({ loading: false })
|
this.setData({ loading: false })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
goBatch() {
|
||||||
|
wx.navigateTo({ url: '/pages/batch/index' })
|
||||||
|
},
|
||||||
|
|
||||||
goto(e) {
|
goto(e) {
|
||||||
const path = e.currentTarget.dataset.path
|
const path = e.currentTarget.dataset.path
|
||||||
if (!path) return
|
if (!path) return
|
||||||
wx.navigateTo({ url: path })
|
wx.navigateTo({ url: path })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
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: '关闭'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
logout() {
|
logout() {
|
||||||
getApp().clearAuth()
|
getApp().clearAuth()
|
||||||
wx.reLaunch({ url: '/pages/login/index' })
|
wx.reLaunch({ url: '/pages/login/index' })
|
||||||
|
|||||||
@@ -3,58 +3,79 @@
|
|||||||
<view class="hero-badge">CONTROL CENTER</view>
|
<view class="hero-badge">CONTROL CENTER</view>
|
||||||
<view class="hero-title">{{user ? ('欢迎,' + user.nickname) : '社区内容风控工作台'}}</view>
|
<view class="hero-title">{{user ? ('欢迎,' + user.nickname) : '社区内容风控工作台'}}</view>
|
||||||
<view class="hero-sub">发布内容将实时进入朴素贝叶斯识别流程,疑似垃圾信息自动拦截并支持申诉。</view>
|
<view class="hero-sub">发布内容将实时进入朴素贝叶斯识别流程,疑似垃圾信息自动拦截并支持申诉。</view>
|
||||||
<view class="hero-meta" wx:if="{{modelInfo}}">
|
|
||||||
<text class="hero-metric">版本 {{modelInfo.version || '未训练'}}</text>
|
|
||||||
<text class="hero-metric">阈值 {{thresholdText}}</text>
|
|
||||||
<text class="hero-metric">样本 {{modelInfo.sample_count || 0}}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="card fade-up fade-up-delay-1" wx:if="{{modelInfo}}">
|
<view class="card fade-up fade-up-delay-1">
|
||||||
<view class="card-title">检测引擎状态</view>
|
<view class="card-title">发布信息</view>
|
||||||
<view class="grid-2">
|
|
||||||
<view class="kpi">
|
<view class="field">
|
||||||
<view class="kpi-label">模型版本</view>
|
<text class="field-label">内容文本</text>
|
||||||
<view class="kpi-value">{{modelInfo.version || '未训练'}}</view>
|
<textarea class="textarea" placeholder="请输入要发布的文本信息" value="{{text}}" data-field="text" bindinput="onInput" />
|
||||||
</view>
|
<view class="field-help">当前字数:{{text.length}},建议不少于 2 个字符。</view>
|
||||||
<view class="kpi">
|
</view>
|
||||||
<view class="kpi-label">训练样本</view>
|
|
||||||
<view class="kpi-value">{{modelInfo.sample_count || 0}}</view>
|
<view class="field">
|
||||||
</view>
|
<view class="row">
|
||||||
<view class="kpi">
|
<text class="field-label">发布类型</text>
|
||||||
<view class="kpi-label">垃圾阈值</view>
|
<picker mode="selector" range="{{visibilityOptions}}" range-key="label" value="{{visibilityIndex}}" bindchange="onVisibilityChange">
|
||||||
<view class="kpi-value">{{thresholdText}}</view>
|
<view class="picker-value">{{visibilityOptions[visibilityIndex].label}}</view>
|
||||||
</view>
|
</picker>
|
||||||
<view class="kpi">
|
|
||||||
<view class="kpi-label">最近训练</view>
|
|
||||||
<view class="kpi-value small">{{modelInfo.trained_at || '--'}}</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="card fade-up fade-up-delay-2">
|
<view class="field" wx:if="{{visibility === 'direct'}}">
|
||||||
<view class="card-title">用户功能</view>
|
<text class="field-label">接收人用户名</text>
|
||||||
<view class="card-desc">常用操作入口,覆盖发布、检测、历史和账号设置。</view>
|
<input class="input" placeholder="私信发送时必填" value="{{recipientUsername}}" data-field="recipientUsername" bindinput="onInput" />
|
||||||
<view class="grid-2">
|
</view>
|
||||||
<view class="module-card" wx:for="{{userModules}}" wx:key="name" data-path="{{item.path}}" bindtap="goto">
|
|
||||||
<view class="module-name">{{item.name}}</view>
|
<view class="field" wx:if="{{result}}">
|
||||||
<view class="module-desc">{{item.desc}}</view>
|
<text class="field-label">识别反馈</text>
|
||||||
<view class="module-tag">{{item.tag}}</view>
|
<view class="row">
|
||||||
|
<text class="label">发布结果</text>
|
||||||
|
<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>
|
||||||
|
<view class="row">
|
||||||
|
<text class="label">垃圾概率</text>
|
||||||
|
<text class="value">{{result.detect_spam_probability_text}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="progress-track">
|
||||||
|
<view class="progress-fill" style="width: {{result.detect_spam_probability_text}};"></view>
|
||||||
|
</view>
|
||||||
|
<view class="row">
|
||||||
|
<text class="label">检测置信度</text>
|
||||||
|
<text class="value">{{result.detect.confidence_text}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="progress-track">
|
||||||
|
<view class="progress-fill-safe" style="width: {{result.detect.confidence_text}};"></view>
|
||||||
|
</view>
|
||||||
|
<view class="row">
|
||||||
|
<text class="label">本次阈值</text>
|
||||||
|
<text class="value">{{result.post_threshold_text}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="field" wx:if="{{result.detect.reason_tokens && result.detect.reason_tokens.length}}">
|
||||||
|
<text class="field-label">风险关键词</text>
|
||||||
|
<view class="chip-group">
|
||||||
|
<text class="tag tag-danger" wx:for="{{result.detect.reason_tokens}}" wx:key="token" data-token="{{item.token}}" data-weight="{{item.weight}}" bindtap="showTokenWeight">{{item.token}}</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="card fade-up fade-up-delay-3" wx:if="{{user && user.is_admin}}">
|
<view class="field" wx:if="{{!result}}">
|
||||||
<view class="card-title">管理员功能</view>
|
<text class="field-label">快捷示例</text>
|
||||||
<view class="card-desc">支持阈值调节、复核处理、样本维护和用户管理。</view>
|
<view class="chip-group">
|
||||||
<view class="grid-2">
|
<view class="chip" wx:for="{{quickTexts}}" wx:key="*this" data-text="{{item}}" bindtap="fillQuick">{{item}}</view>
|
||||||
<view class="module-card" wx:for="{{adminModules}}" wx:key="name" data-path="{{item.path}}" bindtap="goto">
|
|
||||||
<view class="module-name">{{item.name}}</view>
|
|
||||||
<view class="module-desc">{{item.desc}}</view>
|
|
||||||
<view class="module-tag">{{item.tag}}</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
|
||||||
|
|
||||||
<button class="btn btn-ghost fade-up fade-up-delay-3" bindtap="logout">退出登录</button>
|
<button class="btn btn-primary" loading="{{loading}}" bindtap="publish">提交发布</button>
|
||||||
|
<button class="btn btn-ghost" style="margin-top: 12rpx;" bindtap="goBatch">批量识别</button>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const { request } = require('../../utils/request')
|
|||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
loading: false,
|
loading: false,
|
||||||
|
user: null,
|
||||||
form: {
|
form: {
|
||||||
nickname: '',
|
nickname: '',
|
||||||
company: '',
|
company: '',
|
||||||
@@ -17,6 +18,9 @@ Page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
async loadProfile() {
|
async loadProfile() {
|
||||||
|
const app = getApp()
|
||||||
|
const user = app.globalData.user || wx.getStorageSync('user')
|
||||||
|
this.setData({ user })
|
||||||
const profile = await request({ url: '/user/profile' })
|
const profile = await request({ url: '/user/profile' })
|
||||||
this.setData({
|
this.setData({
|
||||||
form: {
|
form: {
|
||||||
@@ -29,6 +33,12 @@ Page({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
goto(e) {
|
||||||
|
const path = e.currentTarget.dataset.path
|
||||||
|
if (!path) return
|
||||||
|
wx.navigateTo({ url: path })
|
||||||
|
},
|
||||||
|
|
||||||
onInput(e) {
|
onInput(e) {
|
||||||
const field = e.currentTarget.dataset.field
|
const field = e.currentTarget.dataset.field
|
||||||
this.setData({ [`form.${field}`]: (e.detail.value || '').trim() })
|
this.setData({ [`form.${field}`]: (e.detail.value || '').trim() })
|
||||||
@@ -57,5 +67,18 @@ Page({
|
|||||||
} finally {
|
} finally {
|
||||||
this.setData({ loading: false })
|
this.setData({ loading: false })
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
wx.showModal({
|
||||||
|
title: '退出登录',
|
||||||
|
content: '确定要退出登录吗?',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
getApp().clearAuth()
|
||||||
|
wx.reLaunch({ url: '/pages/login/index' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<view class="container">
|
<view class="container">
|
||||||
<view class="hero fade-up">
|
<view class="hero fade-up">
|
||||||
<view class="hero-badge">PROFILE</view>
|
<view class="hero-badge">PROFILE</view>
|
||||||
<view class="hero-title">个人资料设置</view>
|
<view class="hero-title">个人中心</view>
|
||||||
<view class="hero-sub">完善你的身份信息,便于审计追踪和团队协同。</view>
|
<view class="hero-sub">完善你的身份信息,便于审计追踪和团队协同。</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -36,4 +36,8 @@
|
|||||||
|
|
||||||
<button class="btn btn-primary" loading="{{loading}}" bindtap="save">保存资料</button>
|
<button class="btn btn-primary" loading="{{loading}}" bindtap="save">保存资料</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
|
||||||
|
<view class="card fade-up fade-up-delay-3">
|
||||||
|
<button class="btn btn-ghost" bindtap="logout">退出登录</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
438
设计风格.md
@@ -1,257 +1,287 @@
|
|||||||
# Design System Inspired by xAI
|
## Overview
|
||||||
|
|
||||||
## 1. Visual Theme & Atmosphere
|
Apple's web presence is a masterclass in **reverent product photography framed by near-invisible UI**. Every page is a stack of edge-to-edge product "tiles" — alternating light and dark canvases, each centered on a hero headline, a one-line tagline, two tiny blue pill CTAs, and an impossibly crisp product render. Nothing competes with the product. Typography is confident but quiet; color is either pure white, an off-white parchment, or a near-black tile; interactive elements are a single, quiet blue.
|
||||||
|
|
||||||
xAI's website is a masterclass in dark-first, monospace-driven brutalist minimalism -- a design system that feels like it was built by engineers who understand that restraint is the ultimate form of sophistication. The entire experience is anchored to an almost-black background (`#1f2228`) with pure white text (`#ffffff`), creating a high-contrast, terminal-inspired aesthetic that signals deep technical credibility. There are no gradients, no decorative illustrations, no color accents competing for attention. This is a site that communicates through absence.
|
Density is unusually low even by contemporary SaaS standards. Each tile occupies roughly one viewport, and there is no decorative chrome — no borders, no gradients, no decorative frames, no shadows on headlines. Elevation appears only when a product image rests on a surface (a single soft `rgba(0, 0, 0, 0.22) 3px 5px 30px` drop for visual weight). The result is a catalog that feels more like a museum gallery: the wall disappears and the artifact takes over.
|
||||||
|
|
||||||
The typographic system is split between two carefully chosen typefaces. `GeistMono` (Vercel's monospace font) handles display-level headlines at an extraordinary 320px with weight 300, and also serves as the button typeface in uppercase with tracked-out letter-spacing (1.4px). `universalSans` handles all body and secondary heading text with a clean, geometric sans-serif voice. The monospace-as-display-font choice is the defining aesthetic decision -- it positions xAI not as a consumer product but as infrastructure, as something built by people who live in terminals.
|
Store and shop surfaces retain the same chassis but switch modes. The product configurator (iPhone 17 Pro, accessories grid) introduces a tight grid of white utility cards at `{rounded.lg}` (18px) radius with a thin border, paired with a persistent thin sub-nav strip. The environment page leans darker and more editorial. Across all five surfaces the typographic system, spacing rhythm, and the single blue accent are consistent — this is one design language expressed at different volumes.
|
||||||
|
|
||||||
The spacing system operates on an 8px base grid with values concentrated at the small end (4px, 8px, 24px, 48px), reflecting a dense, information-focused layout philosophy. Border radius is minimal -- the site barely rounds anything, maintaining sharp, architectural edges. There are no decorative shadows, no gradients, no layered elevation. Depth is communicated purely through contrast and whitespace.
|
|
||||||
|
|
||||||
**Key Characteristics:**
|
**Key Characteristics:**
|
||||||
- Pure dark theme: `#1f2228` background with `#ffffff` text -- no gray middle ground
|
- Photography-first presentation; UI recedes so the product can speak.
|
||||||
- GeistMono at extreme display sizes (320px, weight 300) -- monospace as luxury
|
- Alternating full-bleed tile sections: white/parchment ↔ near-black, with the color change itself acting as the section divider.
|
||||||
- Uppercase monospace buttons with 1.4px letter-spacing -- technical, commanding
|
- Single blue accent (`{colors.primary}` — #0066cc) carries every interactive element. No second brand color exists.
|
||||||
- universalSans for body text at 16px/1.5 and headings at 30px/1.2 -- clean contrast
|
- Two button grammars: tiny blue pill CTAs (`{rounded.pill}`) and compact utility rects (`{rounded.sm}`).
|
||||||
- Zero decorative elements: no shadows, no gradients, no colored accents
|
- SF Pro Display + SF Pro Text — negative letter-spacing at display sizes for the signature "Apple tight" headline feel.
|
||||||
- 8px spacing grid with a sparse, deliberate scale
|
- Whisper-soft elevation used only when a product image needs to breathe — exactly one drop-shadow in the entire system.
|
||||||
- Heroicons SVG icon system -- minimal, functional
|
- Tight two-row nav: slim `{component.global-nav}` + product-specific `{component.sub-nav-frosted}` with persistent right-aligned primary CTA.
|
||||||
- Tailwind CSS with arbitrary values -- utility-first engineering approach
|
- Section rhythm across multiple pages: light hero → dark product tile → light utility tile → dark tile → parchment footer — a predictable pulse.
|
||||||
|
|
||||||
## 2. Color Palette & Roles
|
## Colors
|
||||||
|
|
||||||
### Primary
|
> **Source pages analyzed:** homepage, environment, store, iPhone 17 Pro buy page, accessories index. The color system is identical across all five surfaces; only the surface-mode mix differs.
|
||||||
- **Pure White** (`#ffffff`): The singular text color, link color, and all foreground elements. In xAI's system, white is not a background -- it is the voice.
|
|
||||||
- **Dark Background** (`#1f2228`): The canvas. A warm near-black with a subtle blue undertone (not pure black, not neutral gray). This specific hue prevents the harsh eye strain of `#000000` while maintaining deep darkness.
|
|
||||||
|
|
||||||
### Interactive
|
### Brand & Accent
|
||||||
- **White Default** (`#ffffff`): Link and interactive element color in default state.
|
- **Action Blue** (`{colors.primary}` — #0066cc): The single brand-level interactive color. All text links, all blue pill CTAs ("Learn more", "Buy"), and the focus ring root. This is Apple's quiet but universal "click me" signal. Press state shifts to a slightly darker variant via the active scale transform rather than a hex change.
|
||||||
- **White Muted** (`rgba(255, 255, 255, 0.5)`): Hover state for links -- a deliberate dimming rather than brightening, which is unusual and distinctive.
|
- **Focus Blue** (`{colors.primary-focus}` — #0071e3): A marginally brighter sibling of Action Blue, reserved for the keyboard focus ring on buttons (`outline: 2px solid`).
|
||||||
- **White Subtle** (`rgba(255, 255, 255, 0.2)`): Borders, dividers, and subtle surface treatments.
|
- **Sky Link Blue** (`{colors.primary-on-dark}` — #2997ff): A brighter blue used on dark surfaces for in-copy links and inline callouts, where Action Blue would disappear against the tile background.
|
||||||
- **Ring Blue** (`rgb(59, 130, 246) / 0.5`): Tailwind's default focus ring color (`--tw-ring-color`), used for keyboard accessibility focus states.
|
|
||||||
|
|
||||||
### Surface & Borders
|
### Surface
|
||||||
- **Surface Elevated** (`rgba(255, 255, 255, 0.05)`): Subtle card backgrounds and hover surfaces -- barely visible lift.
|
- **Pure White** (`{colors.canvas}` — #ffffff): The dominant canvas. Content, utility cards, store tiles, configurator grids.
|
||||||
- **Surface Hover** (`rgba(255, 255, 255, 0.08)`): Slightly more visible hover state for interactive containers.
|
- **Parchment** (`{colors.canvas-parchment}` — #f5f5f7): The signature Apple off-white. Used for alternating light tiles, footer region, and the default page canvas in store utility sections. Just different enough from white to create rhythm.
|
||||||
- **Border Default** (`rgba(255, 255, 255, 0.1)`): Standard border for cards, dividers, and containers.
|
- **Pearl Button** (`{colors.surface-pearl}` — #fafafc): A near-white used as the fill for secondary "ghost" buttons — lighter than the parchment canvas so the button still reads as a button against `{colors.canvas-parchment}`.
|
||||||
- **Border Strong** (`rgba(255, 255, 255, 0.2)`): Emphasized borders for active states and button outlines.
|
- **Near-Black Tile 1** (`{colors.surface-tile-1}` — #272729): The primary dark-tile surface on the homepage product grid.
|
||||||
|
- **Near-Black Tile 2** (`{colors.surface-tile-2}` — #2a2a2c): A micro-step lighter — used where a dark tile sits directly above or below Tile 1 to create the faintest separation.
|
||||||
|
- **Near-Black Tile 3** (`{colors.surface-tile-3}` — #252527): A micro-step darker — used at the bottom of the stack and in embedded video/player frames.
|
||||||
|
- **Pure Black** (`{colors.surface-black}` — #000000): Reserved for true void — video player backgrounds, edge-to-edge photographic overlays, the global nav bar background.
|
||||||
|
- **Translucent Chip Gray** (`{colors.surface-chip-translucent}` — #d2d2d7): The base hex of the translucent gray chip used over photography for circular control buttons. In production, applied at ~64% alpha as `rgba(210, 210, 215, 0.64)`.
|
||||||
|
|
||||||
### Functional
|
### Text
|
||||||
- **Text Primary** (`#ffffff`): All headings, body text, labels.
|
- **Near-Black Ink** (`{colors.ink}` — #1d1d1f): The voice of every headline, every body paragraph, and the dark utility button's fill. Chosen instead of pure black to keep the page feeling photographic rather than printed.
|
||||||
- **Text Secondary** (`rgba(255, 255, 255, 0.7)`): Descriptions, captions, supporting text.
|
- **Body** (`{colors.body}` — #1d1d1f): Same hex as ink — Apple uses one near-black tone for all text on light surfaces.
|
||||||
- **Text Tertiary** (`rgba(255, 255, 255, 0.5)`): Muted labels, placeholder text, timestamps.
|
- **Body On Dark** (`{colors.body-on-dark}` — #ffffff): All text on dark tiles and on the global nav bar.
|
||||||
- **Text Quaternary** (`rgba(255, 255, 255, 0.3)`): Disabled text, very subtle annotations.
|
- **Body Muted** (`{colors.body-muted}` — #cccccc): Secondary copy on dark tiles where pure white would be too loud.
|
||||||
|
- **Ink Muted 80** (`{colors.ink-muted-80}` — #333333): Body text on the white Pearl Button surface — slightly softer than pure black.
|
||||||
|
- **Ink Muted 48** (`{colors.ink-muted-48}` — #7a7a7a): Disabled button text and legal fine-print.
|
||||||
|
|
||||||
## 3. Typography Rules
|
### Hairlines & Borders
|
||||||
|
- **Divider Soft** (`{colors.divider-soft}` — #f0f0f0): The "border" tone on secondary buttons — functions as a ring shadow rather than a hard line. In production, often applied as `rgba(0, 0, 0, 0.04)`.
|
||||||
|
- **Hairline** (`{colors.hairline}` — #e0e0e0): The 1px hairline border on store utility cards and configurator chips.
|
||||||
|
|
||||||
|
### Brand Gradient
|
||||||
|
**No decorative gradients.** Atmospheric depth on product photography (the iPhone 17 Pro camera plate, the Apple Watch bands, AirPods reflections) is inherent to the imagery, not a CSS gradient overlay. The environment page's hero uses photographic atmosphere (mountain vista at dawn) but no gradient tokens are defined. Apple is the rare luxury-brand site with zero gradient-based design tokens.
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
### Font Family
|
### Font Family
|
||||||
- **Display / Buttons**: `GeistMono`, with fallback: `ui-monospace, SFMono-Regular, Roboto Mono, Menlo, Monaco, Liberation Mono, DejaVu Sans Mono, Courier New`
|
- **Display**: `SF Pro Display, system-ui, -apple-system, sans-serif` — Apple's proprietary display face, optimized for sizes ≥ 19px. Defines the voice of every headline.
|
||||||
- **Body / Headings**: `universalSans`, with fallback: `universalSans Fallback`
|
- **Body / UI**: `SF Pro Text, system-ui, -apple-system, sans-serif` — the text-optimized variant used for body copy, captions, buttons, and links below 20px.
|
||||||
|
- **OpenType features**: `font-variant-numeric: numerator` is enabled on numeric links (pricing tables, spec sheets). Display sizes rely on tight tracking rather than contextual ligatures.
|
||||||
|
|
||||||
### Hierarchy
|
### Hierarchy
|
||||||
|
|
||||||
| Role | Font | Size | Weight | Line Height | Letter Spacing | Transform | Notes |
|
| Token | Size | Weight | Line Height | Letter Spacing | Use |
|
||||||
|------|------|------|--------|-------------|----------------|-----------|-------|
|
|---|---|---|---|---|---|
|
||||||
| Display Hero | GeistMono | 320px (20rem) | 300 | 1.50 | normal | none | Extreme scale, monospace luxury |
|
| `{typography.hero-display}` | 56px | 600 | 1.07 | -0.28px | Hero headline; the signature "Apple tight" tracking |
|
||||||
| Section Heading | universalSans | 30px (1.88rem) | 400 | 1.20 (tight) | normal | none | Clean sans-serif contrast |
|
| `{typography.display-lg}` | 40px | 600 | 1.10 | 0 | Tile headlines atop every product tile |
|
||||||
| Body | universalSans | 16px (1rem) | 400 | 1.50 | normal | none | Standard reading text |
|
| `{typography.display-md}` | 34px | 600 | 1.47 | -0.374px | Section heads (SF Pro Text at display proportions) |
|
||||||
| Button | GeistMono | 14px (0.88rem) | 400 | 1.43 | 1.4px | uppercase | Tracked monospace, commanding |
|
| `{typography.lead}` | 28px | 400 | 1.14 | 0.196px | Product tile subcopy |
|
||||||
| Label / Caption | universalSans | 14px (0.88rem) | 400 | 1.50 | normal | none | Supporting text |
|
| `{typography.lead-airy}` | 24px | 300 | 1.5 | 0 | Environment-page lead paragraphs (the rare weight 300) |
|
||||||
| Small / Meta | universalSans | 12px (0.75rem) | 400 | 1.50 | normal | none | Timestamps, footnotes |
|
| `{typography.tagline}` | 21px | 600 | 1.19 | 0.231px | Sub-tile tagline; sub-nav category name |
|
||||||
|
| `{typography.body-strong}` | 17px | 600 | 1.24 | -0.374px | Inline strong emphasis |
|
||||||
|
| `{typography.body}` | 17px | 400 | 1.47 | -0.374px | Default paragraph |
|
||||||
|
| `{typography.dense-link}` | 17px | 400 | 2.41 | 0 | Footer / store utility link lists (relaxed leading) |
|
||||||
|
| `{typography.caption}` | 14px | 400 | 1.43 | -0.224px | Secondary captions, button text |
|
||||||
|
| `{typography.caption-strong}` | 14px | 600 | 1.29 | -0.224px | Emphasized captions |
|
||||||
|
| `{typography.button-large}` | 18px | 300 | 1.0 | 0 | Store hero CTAs (the rare weight 300) |
|
||||||
|
| `{typography.button-utility}` | 14px | 400 | 1.29 | -0.224px | Utility/nav button labels |
|
||||||
|
| `{typography.fine-print}` | 12px | 400 | 1.0 | -0.12px | Fine-print, footer body |
|
||||||
|
| `{typography.micro-legal}` | 10px | 400 | 1.3 | -0.08px | Micro legal disclaimers |
|
||||||
|
| `{typography.nav-link}` | 12px | 400 | 1.0 | -0.12px | Global nav menu items |
|
||||||
|
|
||||||
### Principles
|
### Principles
|
||||||
- **Monospace as display**: GeistMono at 320px is not a gimmick -- it is the brand statement. The fixed-width characters at extreme scale create a rhythmic, architectural quality that no proportional font can achieve.
|
|
||||||
- **Light weight at scale**: Weight 300 for the 320px headline prevents the monospace from feeling heavy or brutish at extreme sizes. It reads as precise, not overwhelming.
|
|
||||||
- **Uppercase buttons**: All button text is uppercase GeistMono with 1.4px letter-spacing. This creates a distinctly technical, almost command-line aesthetic for interactive elements.
|
|
||||||
- **Sans-serif for reading**: universalSans at 16px/1.5 provides excellent readability for body content, creating a clean contrast against the monospace display elements.
|
|
||||||
- **Two-font clarity**: The system uses exactly two typefaces with clear roles -- monospace for impact and interaction, sans-serif for information and reading. No overlap, no ambiguity.
|
|
||||||
|
|
||||||
## 4. Component Stylings
|
- **Negative letter-spacing at display sizes.** Every headline at 17px and up carries a slight tracking tighten (`-0.12 → -0.374px`). This produces the iconic "Apple tight" headline cadence. Never used at 12px or below.
|
||||||
|
- **Body copy at 17px, not 16px.** Apple breaks the SaaS convention and runs paragraph text at 17px. The extra pixel gives the page an unmistakable "reading, not scanning" pace.
|
||||||
|
- **Weight 300 is real and rare.** Used deliberately on a handful of large-size reads (`{typography.button-large}` at 18px/300 and `{typography.lead-airy}` at 24px/300). It's not an accident — it's a light-atmosphere cue reserved for moments where the content should feel airy.
|
||||||
|
- **Weight 600, not 700, for headlines.** Apple's headlines sit at weight 600. Weight 700 is used sparingly for `{typography.tagline}` (21px) when a touch more assertion is needed.
|
||||||
|
- **Line-height is context-specific.** Display sizes use 1.07–1.19 (tight). Body uses 1.47. Utility link stacks in the footer/store use an unusually relaxed 2.41 (`{typography.dense-link}`). The 2.41 is not a bug — it's how the footer's dense link columns breathe.
|
||||||
|
- **Weight 500 is deliberately absent.** The ladder is 300 / 400 / 600 / 700. Mid-weight readings always use 600.
|
||||||
|
|
||||||
|
### Note on Font Substitutes
|
||||||
|
SF Pro is Apple's proprietary system font. When building off-system:
|
||||||
|
|
||||||
|
- Use `system-ui, -apple-system, BlinkMacSystemFont` as the first stack entry — on macOS/iOS/Safari this resolves to the real SF Pro.
|
||||||
|
- For non-Apple platforms, **Inter** (Google Fonts, variable) is the closest open-source equivalent. Inter at weight 600 with `font-feature-settings: "ss03"` approximates SF Pro's rounded "a" character.
|
||||||
|
- Nudge `letter-spacing` down by `-0.01em` on display sizes to re-create the Apple tight feel; Inter's default tracking runs slightly wider than SF Pro.
|
||||||
|
- For body text, tighten line-height by `0.03` (from 1.47 → 1.44) when substituting Inter — Inter's taller x-height needs less leading.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
### Spacing System
|
||||||
|
- **Base unit:** 8px. Sub-base values (2, 4, 5, 6, 7) are used for tight typographic adjustments; structural layout snaps to 8/12/16/20/24.
|
||||||
|
- **Tokens:** `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.md}` 17px · `{spacing.lg}` 24px · `{spacing.xl}` 32px · `{spacing.xxl}` 48px · `{spacing.section}` 80px.
|
||||||
|
- **Section vertical padding:** `{spacing.section}` (80px) inside a product tile; tiles stack edge-to-edge with 0 gap (the color change provides the break).
|
||||||
|
- **Card padding:** `{spacing.lg}` (24px) inside utility grid cards.
|
||||||
|
- **Button padding:** 8–11px vertical, 15–22px horizontal.
|
||||||
|
- **Universal rhythm constants:** the 17px body line-height multiplier (~25px line) and 21px tagline size show up on every analyzed page.
|
||||||
|
|
||||||
|
### Grid & Container
|
||||||
|
- **Max content width:** ~980px on text-heavy sections (environment), ~1440px on product grids (store, accessories), full-bleed for product tiles (homepage).
|
||||||
|
- **Column patterns:** 3 to 5 column utility card grid on store/accessories; 2-column side-by-side tiles on homepage occasional sections; single-column centered stack on product tile heroes.
|
||||||
|
- **Gutters:** 20–24px between cards in a utility grid.
|
||||||
|
|
||||||
|
### Whitespace Philosophy
|
||||||
|
Apple's whitespace is the product's pedestal. Every tile begins with at least 64px of air above its headline and 48–64px below. Product renders are never crowded; the nearest content to a product image is at least 40px away. The footer is the only area that breaks this — there, Apple goes deliberately dense to make the full information architecture visible at a glance.
|
||||||
|
|
||||||
|
## Elevation & Depth
|
||||||
|
|
||||||
|
| Level | Treatment | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| Flat | No shadow, no border | Full-bleed tiles, global nav, footer, body sections |
|
||||||
|
| Soft hairline | 1px `rgba(0, 0, 0, 0.08)` border | Utility cards, sub-nav frosted-glass separator |
|
||||||
|
| Backdrop blur | `backdrop-filter: blur(N)` on Parchment 80% | Sub-nav and the iPhone buy floating sticky bar |
|
||||||
|
| Product shadow | `rgba(0, 0, 0, 0.22) 3px 5px 30px 0` | Product renders resting on a surface (the only true "shadow" in the system) |
|
||||||
|
|
||||||
|
**Shadow philosophy.** Apple uses **exactly one** drop-shadow, and it is applied to photographic product imagery — never to cards, never to buttons, never to text. Elevation in the UI comes from (a) surface-color change (light tile ↔ dark tile) and (b) backdrop-blur on sticky bars. The single shadow is about giving the product weight, not about UI hierarchy.
|
||||||
|
|
||||||
|
### Decorative Depth
|
||||||
|
- **Atmospheric imagery** on the environment page (photographic vista) supplies mood; no CSS gradient involved.
|
||||||
|
- **Edge-to-edge tile alternation** creates rhythm without borders or shadows — the color change itself is the divider.
|
||||||
|
- **Backdrop-filter blur** on `{component.sub-nav-frosted}` and `{component.floating-sticky-bar}` creates a "floating over content" effect that's functional, not decorative.
|
||||||
|
|
||||||
|
## Shapes
|
||||||
|
|
||||||
|
### Border Radius Scale
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `{rounded.none}` | 0px | Full-bleed product tiles (no corner rounding) |
|
||||||
|
| `{rounded.xs}` | 5px | Inline links when styled as subtle chips (rare) |
|
||||||
|
| `{rounded.sm}` | 8px | Dark utility buttons (Sign In, Bag), inline card imagery |
|
||||||
|
| `{rounded.md}` | 11px | White Pearl Button capsules |
|
||||||
|
| `{rounded.lg}` | 18px | Store utility cards, accessories grid cards |
|
||||||
|
| `{rounded.pill}` | 9999px | Primary blue pill CTAs, sub-nav buy button, configurator option chips, search input — the signature Apple pill |
|
||||||
|
| `{rounded.full}` | 9999px / 50% | Circular control chips floating over photography |
|
||||||
|
|
||||||
|
### Photography Geometry
|
||||||
|
- **Hero imagery**: full-bleed, 21:9 or taller on the homepage; 16:9 on environment and shop pages. Product renders are photographic-realistic, often shot on a tinted surface that becomes the tile background.
|
||||||
|
- **Product renders**: PNG/WebP with transparency; rest on a surface tile and pick up the system shadow.
|
||||||
|
- **Accessory grid**: square 1:1 crops at `{rounded.lg}` (18px) radius, light neutral backgrounds, product centered with 20–40px internal padding.
|
||||||
|
- **No rounded imagery in hero tiles** — images are full-bleed rectangular. Rounding (`{rounded.sm}`, `{rounded.lg}`) appears only on inline card imagery.
|
||||||
|
- Lazy-loading via responsive `srcset` and `sizes` across all breakpoints; CDN-optimized WebP.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Top Navigation
|
||||||
|
|
||||||
|
**`global-nav`** — Persistent, ultra-thin black nav bar pinned to the top of every page. Background `{colors.surface-black}`, height 44px, text `{colors.on-dark}` in `{typography.nav-link}` (12px / 400 / -0.12px tracking). Links are quiet, spaced ~20px apart, running edge-to-edge across the top. Right-aligned cluster: Search, Bag icons — always visible. On mobile, collapses to hamburger at ~834px and the Apple logo centers.
|
||||||
|
|
||||||
|
**`sub-nav-frosted`** — Surface-specific nav that sticks below the global nav. Background `{colors.canvas-parchment}` at 80% opacity with backdrop-filter blur, creating a frosted-glass effect. Height 52px. Content on left: product category name ("iPhone", "Store", "Accessories") in `{typography.tagline}` (21px / 600). Content right: inline nav links in `{typography.button-utility}` (14px), ending in a persistent `{component.button-primary}` ("Buy") or a utility link.
|
||||||
|
|
||||||
### Buttons
|
### Buttons
|
||||||
|
|
||||||
**Primary (White on Dark)**
|
**`button-primary`** — The signature Apple action. Background `{colors.primary}` (Action Blue #0066cc), text `{colors.on-primary}` in `{typography.body}` (SF Pro Text 17px / 400), rounded `{rounded.pill}` (full pill — capsule-shaped), padding 11px × 22px. The full-pill radius IS the brand action signal.
|
||||||
- Background: `#ffffff`
|
- Active state: `{component.button-primary-active}` — `transform: scale(0.95)` (the system-wide micro-interaction).
|
||||||
- Text: `#1f2228`
|
- Focus state: `{component.button-primary-focus}` — 2px solid `{colors.primary-focus}` outline.
|
||||||
- Padding: 12px 24px
|
|
||||||
- Radius: 0px (sharp corners)
|
|
||||||
- Font: GeistMono 14px weight 400, uppercase, letter-spacing 1.4px
|
|
||||||
- Hover: `rgba(255, 255, 255, 0.9)` background
|
|
||||||
- Use: Primary CTA ("TRY GROK", "GET STARTED")
|
|
||||||
|
|
||||||
**Ghost / Outlined**
|
**`button-secondary-pill`** — Used as the second CTA when two blue pills appear together ("Learn more" / "Buy"). Background transparent, text `{colors.primary}`, 1px solid `{colors.primary}` border, rounded `{rounded.pill}`, padding 11px × 22px. Reads as a "ghost pill."
|
||||||
- Background: transparent
|
|
||||||
- Text: `#ffffff`
|
|
||||||
- Padding: 12px 24px
|
|
||||||
- Radius: 0px
|
|
||||||
- Border: `1px solid rgba(255, 255, 255, 0.2)`
|
|
||||||
- Font: GeistMono 14px weight 400, uppercase, letter-spacing 1.4px
|
|
||||||
- Hover: `rgba(255, 255, 255, 0.05)` background
|
|
||||||
- Use: Secondary actions ("LEARN MORE", "VIEW API")
|
|
||||||
|
|
||||||
**Text Link**
|
**`button-dark-utility`** — Global nav actions (Sign In, Bag, language selector). Background `{colors.ink}` (#1d1d1f), text `{colors.on-dark}` in `{typography.button-utility}` (14px / 400 / -0.224px tracking), rounded `{rounded.sm}` (8px), padding 8px × 15px. Active state shrinks via `transform: scale(0.95)`.
|
||||||
- Background: none
|
|
||||||
- Text: `#ffffff`
|
**`button-pearl-capsule`** — Product-card secondary button. Background `{colors.surface-pearl}` (#fafafc), text `{colors.ink-muted-80}` in `{typography.caption}` (14px), 3px solid `{colors.divider-soft}` border (functions as a soft ring rather than a visible line), rounded `{rounded.md}` (11px), padding 8px × 14px.
|
||||||
- Font: universalSans 16px weight 400
|
|
||||||
- Hover: `rgba(255, 255, 255, 0.5)` -- dims on hover
|
**`button-store-hero`** — A larger primary CTA used on store hero surfaces. Same Action Blue + Paper White as `{component.button-primary}`, but with `{typography.button-large}` (18px / 300 — note the rare weight 300) and slightly more padding (14px × 28px). Used sparingly on the store landing.
|
||||||
- Use: Inline links, navigation items
|
|
||||||
|
**`button-icon-circular`** — Floats over photography. 44 × 44px, background `{colors.surface-chip-translucent}` at ~64% alpha, icon in `{colors.ink}`, rounded `{rounded.full}`. Used for carousel controls, close buttons, and in-image controls (product image thumbnails on the iPhone buy page).
|
||||||
|
|
||||||
|
**`text-link`** — Inline body links in `{colors.primary}` (Action Blue). Underlined or non-underlined per context.
|
||||||
|
|
||||||
|
**`text-link-on-dark`** — Inline body links on dark tiles in `{colors.primary-on-dark}` (Sky Link Blue #2997ff) — Action Blue would disappear against `{colors.surface-tile-1}`.
|
||||||
|
|
||||||
### Cards & Containers
|
### Cards & Containers
|
||||||
- Background: `rgba(255, 255, 255, 0.03)` or transparent
|
|
||||||
- Border: `1px solid rgba(255, 255, 255, 0.1)`
|
|
||||||
- Radius: 0px (sharp) or 4px (subtle)
|
|
||||||
- Shadow: none -- xAI does not use box shadows
|
|
||||||
- Hover: border shifts to `rgba(255, 255, 255, 0.2)`
|
|
||||||
|
|
||||||
### Navigation
|
**`product-tile-light`** — Full-bleed light tile. Background `{colors.canvas}` (white), text `{colors.ink}`, rounded `{rounded.none}` (0 — tiles touch edges), vertical padding `{spacing.section}` (80px). Centered stack: product name in `{typography.display-lg}` (40px / 600) → one-line tagline in `{typography.lead}` (28px / 400) → two `{component.button-primary}` CTAs ("Learn more" / "Buy") → product render resting on the surface with the system shadow.
|
||||||
- Dark background matching page (`#1f2228`)
|
|
||||||
- Brand logotype: white text, left-aligned
|
|
||||||
- Links: universalSans 14px weight 400, `#ffffff` text
|
|
||||||
- Hover: `rgba(255, 255, 255, 0.5)` text color
|
|
||||||
- CTA: white primary button, right-aligned
|
|
||||||
- Mobile: hamburger toggle
|
|
||||||
|
|
||||||
### Badges / Tags
|
**`product-tile-parchment`** — Same as `{component.product-tile-light}` but on `{colors.canvas-parchment}` (#f5f5f7). Used to break two consecutive white tiles.
|
||||||
**Monospace Tag**
|
|
||||||
- Background: transparent
|
**`product-tile-dark`** — Full-bleed dark tile. Background `{colors.surface-tile-1}` (#272729), text `{colors.on-dark}`, rounded `{rounded.none}`, vertical padding `{spacing.section}` (80px). Same content stack as the light tile but with `{component.text-link-on-dark}` for inline copy and `{component.button-primary}` (Action Blue still works on the dark surface). Used on the homepage product grid as the alternating dark band.
|
||||||
- Text: `#ffffff`
|
|
||||||
- Padding: 4px 8px
|
**`product-tile-dark-2`** — Variant on `{colors.surface-tile-2}` (#2a2a2c). Used where a dark tile sits directly above or below `{component.product-tile-dark}` to create the faintest separation through micro-step lightness change.
|
||||||
- Border: `1px solid rgba(255, 255, 255, 0.2)`
|
|
||||||
- Radius: 0px
|
**`product-tile-dark-3`** — Variant on `{colors.surface-tile-3}` (#252527). Used at the bottom of the stack and in embedded video/player frames.
|
||||||
- Font: GeistMono 12px uppercase, letter-spacing 1px
|
|
||||||
|
**`store-utility-card`** — Used in store grid and accessories grid. Background `{colors.canvas}` (white), 1px solid `{colors.hairline}` border, rounded `{rounded.lg}` (18px), padding `{spacing.lg}` (24px). Top: product image (1:1 crop with `{rounded.sm}` (8px) inner image radius). Below: product name in `{typography.body-strong}` (17px / 600), price in `{typography.body}` (17px / 400), and a `{component.text-link}` ("Buy" or "Learn more"). No shadow by default; product render itself carries the system product-shadow.
|
||||||
|
|
||||||
|
**`configurator-option-chip`** — Pill-shaped tappable cell used in the iPhone 17 Pro buy page. Background `{colors.canvas}`, text `{colors.ink}` in `{typography.caption}`, rounded `{rounded.pill}`, padding 12px × 16px. Contains a small product thumbnail + label + price delta. Arranged in a grid of 4–5 options per row.
|
||||||
|
|
||||||
|
**`configurator-option-chip-selected`** — Selected state. Border upgrades to 2px solid `{colors.primary-focus}`. Same shape, same content.
|
||||||
|
|
||||||
|
**`environment-quote-card`** — A photographic-canvas hero specific to the environment page. Dark photographic backdrop (mountain vista at dawn) with `{colors.surface-tile-1}` as the fallback color, centered white-text headline in `{typography.display-lg}` (40px), small green "Apple 2030" pictographic logo above the headline, single `{component.button-primary}` below. Padding `{spacing.section}` (80px).
|
||||||
|
|
||||||
|
**`floating-sticky-bar`** — Floats at the bottom of the viewport on the iPhone 17 Pro buy page during scroll. Background `{colors.canvas-parchment}` at 80% opacity with `backdrop-filter: blur(N)`, height 64px, padding 12px × 32px. Left: running price total in `{typography.body}`. Right: `{component.button-primary}` ("Add to Bag").
|
||||||
|
|
||||||
### Inputs & Forms
|
### Inputs & Forms
|
||||||
- Background: transparent or `rgba(255, 255, 255, 0.05)`
|
|
||||||
- Border: `1px solid rgba(255, 255, 255, 0.2)`
|
|
||||||
- Radius: 0px
|
|
||||||
- Focus: ring with `rgb(59, 130, 246) / 0.5`
|
|
||||||
- Text: `#ffffff`
|
|
||||||
- Placeholder: `rgba(255, 255, 255, 0.3)`
|
|
||||||
- Label: `rgba(255, 255, 255, 0.7)`, universalSans 14px
|
|
||||||
|
|
||||||
## 5. Layout Principles
|
**`search-input`** — The accessories search input. Background `{colors.canvas}`, text `{colors.ink}` in `{typography.body}` (17px), 1px solid `rgba(0, 0, 0, 0.08)` border, rounded `{rounded.pill}` (full pill — search is also pill-shaped, matching the CTA grammar), padding 12px × 20px, height 44px. Leading icon: search glyph at 14px, muted tint.
|
||||||
|
|
||||||
### Spacing System
|
Error and validation states were not surfaced in the analyzed pages.
|
||||||
- Base unit: 8px
|
|
||||||
- Scale: 4px, 8px, 24px, 48px
|
|
||||||
- The scale is deliberately sparse -- xAI avoids granular spacing distinctions, preferring large jumps that create clear visual hierarchy through whitespace alone
|
|
||||||
|
|
||||||
### Grid & Container
|
### Footer
|
||||||
- Max content width: approximately 1200px
|
|
||||||
- Hero: full-viewport height with massive centered monospace headline
|
|
||||||
- Feature sections: simple vertical stacking with generous section padding (48px-96px)
|
|
||||||
- Two-column layouts for feature descriptions at desktop
|
|
||||||
- Full-width dark sections maintain the single dark background throughout
|
|
||||||
|
|
||||||
### Whitespace Philosophy
|
**`footer`** — Background `{colors.canvas-parchment}` (#f5f5f7), text `{colors.ink-muted-80}`. Link columns in `{typography.dense-link}` (17px / 400 / 2.41 line-height — the relaxed leading is what makes the dense columns scannable). Column headings in `{typography.caption-strong}` (14px / 600). Legal row at the very bottom in `{typography.fine-print}` (12px / 400) with `{colors.ink-muted-48}` text. Vertical padding 64px.
|
||||||
- **Extreme generosity**: xAI uses vast amounts of whitespace. The 320px headline with 48px+ surrounding padding creates a sense of emptiness that is itself a design statement -- the content is so important it needs room to breathe.
|
|
||||||
- **Vertical rhythm over horizontal density**: Content stacks vertically with large gaps between sections rather than packing horizontally. This creates a scroll-driven experience that feels deliberate and cinematic.
|
|
||||||
- **No visual noise**: The absence of decorative elements, borders between sections, and color variety means whitespace is the primary structural tool.
|
|
||||||
|
|
||||||
### Breakpoints
|
## Do's and Don'ts
|
||||||
- 2000px, 1536px, 1280px, 1024px, 1000px, 768px, 640px
|
|
||||||
- Tailwind responsive modifiers drive breakpoint behavior
|
|
||||||
|
|
||||||
### Border Radius Scale
|
|
||||||
- Sharp (0px): Primary treatment for buttons, cards, inputs -- the default
|
|
||||||
- Subtle (4px): Occasional softening on secondary containers
|
|
||||||
- The near-zero radius philosophy is core to the brand's brutalist identity
|
|
||||||
|
|
||||||
## 6. Depth & Elevation
|
|
||||||
|
|
||||||
| Level | Treatment | Use |
|
|
||||||
|-------|-----------|-----|
|
|
||||||
| Flat (Level 0) | No shadow, no border | Page background, body content |
|
|
||||||
| Surface (Level 1) | `rgba(255,255,255,0.03)` background | Subtle card surfaces |
|
|
||||||
| Bordered (Level 2) | `1px solid rgba(255,255,255,0.1)` border | Cards, containers, dividers |
|
|
||||||
| Active (Level 3) | `1px solid rgba(255,255,255,0.2)` border | Hover states, active elements |
|
|
||||||
| Focus (Accessibility) | `ring` with `rgb(59,130,246)/0.5` | Keyboard focus indicator |
|
|
||||||
|
|
||||||
**Elevation Philosophy**: xAI rejects the conventional shadow-based elevation system entirely. There are no box-shadows anywhere on the site. Instead, depth is communicated through three mechanisms: (1) opacity-based borders that brighten on interaction, creating a sense of elements "activating" rather than lifting; (2) extremely subtle background opacity shifts (`0.03` to `0.08`) that create barely-perceptible surface differentiation; and (3) the massive scale contrast between the 320px display type and 16px body text, which creates typographic depth. This is elevation through contrast and opacity, not through simulated light and shadow.
|
|
||||||
|
|
||||||
## 7. Do's and Don'ts
|
|
||||||
|
|
||||||
### Do
|
### Do
|
||||||
- Use `#1f2228` as the universal background -- never pure black `#000000`
|
- Use `{colors.primary}` (Action Blue #0066cc) for every interactive element — links, pill CTAs, focus signals — and nothing else. The single accent is non-negotiable.
|
||||||
- Use GeistMono for all display headlines and button text -- monospace IS the brand
|
- Set headlines in `{typography.hero-display}` or `{typography.display-lg}` with negative letter-spacing (`-0.28 → -0.374px`) to get the signature "Apple tight" cadence.
|
||||||
- Apply uppercase + 1.4px letter-spacing to all button labels
|
- Run body copy at `{typography.body}` (17px / 400 / 1.47 / -0.374px) — not 16px. The extra pixel defines the brand's reading pace.
|
||||||
- Use weight 300 for the massive display headline (320px)
|
- Alternate `{component.product-tile-light}` (or parchment) and `{component.product-tile-dark}` for full-bleed section rhythm. The color change IS the divider.
|
||||||
- Keep borders at `rgba(255, 255, 255, 0.1)` -- barely visible, not absent
|
- Reserve `{rounded.pill}` for the primary blue CTA and any other element that should read as an "action" (configurator chips, search input, sticky bar CTA).
|
||||||
- Dim interactive elements on hover to `rgba(255, 255, 255, 0.5)` -- the reverse of convention
|
- Apply the single product-shadow (`rgba(0, 0, 0, 0.22) 3px 5px 30px`) only to product renders resting on a surface — never on cards, buttons, or text.
|
||||||
- Maintain sharp corners (0px radius) as the default -- brutalist precision
|
- Use `transform: scale(0.95)` as the active/press state on every button — it's the system-wide micro-interaction.
|
||||||
- Use universalSans for all body and reading text at 16px/1.5
|
- Keep the global nav `{colors.surface-black}` (true black) — it's the only place pure black appears on most pages.
|
||||||
|
|
||||||
### Don't
|
### Don't
|
||||||
- Don't use box-shadows -- xAI has zero shadow elevation
|
- Don't introduce a second accent color; every "click me" signal is `{colors.primary}` (Action Blue).
|
||||||
- Don't introduce color accents beyond white and the dark background -- the monochromatic palette is sacred
|
- Don't add shadows to cards, buttons, or text — shadow is reserved for product imagery.
|
||||||
- Don't use large border-radius (8px+, pill shapes) -- the sharp edge is intentional
|
- Don't use gradients as decorative backgrounds; atmosphere comes from photography.
|
||||||
- Don't use bold weights (600-700) for headlines -- weight 300-400 only
|
- Don't set body copy at weight 500 — Apple's ladder is 300 / 400 / 600 / 700, with 500 deliberately absent. Body is always 400; strong inline is 600; display is 600.
|
||||||
- Don't brighten elements on hover -- xAI dims to `0.5` opacity instead
|
- Don't round full-bleed tiles — tiles are rectangular and edge-to-edge; the color change is the divider.
|
||||||
- Don't add decorative gradients, illustrations, or color blocks
|
- Don't tighten line-height below 1.47 for body copy — the editorial leading is part of the brand.
|
||||||
- Don't use proportional fonts for buttons -- GeistMono uppercase is mandatory
|
- Don't mix radii grammars — use `{rounded.sm}` for compact utility, `{rounded.lg}` for utility cards, `{rounded.pill}` for pills, and nothing in between (except the rare `{rounded.md}` Pearl Button).
|
||||||
- Don't use colored status indicators unless absolutely necessary -- keep everything in the white/dark spectrum
|
- Don't use `{colors.primary-on-dark}` (Sky Link Blue) on light surfaces — it's the dark-tile-only variant. Action Blue is for light surfaces.
|
||||||
|
|
||||||
## 8. Responsive Behavior
|
## Responsive Behavior
|
||||||
|
|
||||||
### Breakpoints
|
### Breakpoints
|
||||||
|
|
||||||
| Name | Width | Key Changes |
|
| Name | Width | Key Changes |
|
||||||
|------|-------|-------------|
|
|---|---|---|
|
||||||
| Mobile | <640px | Single column, hero headline scales dramatically down |
|
| Small phone | ≤ 419px | Single-column tiles; sub-nav collapses to category name + primary CTA only; hero typography drops to 28px |
|
||||||
| Small Tablet | 640-768px | Slight increase in padding |
|
| Phone | 420–640px | Single-column stack; product renders scale to 80% of tile width; hero h1 drops to 34px |
|
||||||
| Tablet | 768-1024px | Two-column layouts begin, heading sizes increase |
|
| Large phone | 641–735px | Tiles transition to tighter padding (48px vertical vs 80px); fine-print wraps |
|
||||||
| Desktop | 1024-1280px | Full layout, generous whitespace |
|
| Tablet portrait | 736–833px | Global nav collapses to hamburger; sub-nav hides category chips, keeps primary CTA |
|
||||||
| Large | 1280-1536px | Wider containers, more breathing room |
|
| Tablet landscape | 834–1023px | Global nav returns fully expanded; 3-column utility grids become 2-column |
|
||||||
| Extra Large | 1536-2000px | Maximum content width, centered |
|
| Small desktop | 1024–1068px | Product tiles use 2/3 width with margin gutters; hero h1 stays at 40px |
|
||||||
| Ultra | >2000px | Content stays centered, extreme margins |
|
| Desktop | 1069–1440px | Full layout; 4–5 column store grids; 1440px content max |
|
||||||
|
| Wide desktop | ≥ 1441px | Content locks at 1440px, margins absorb extra width |
|
||||||
|
|
||||||
|
The structural breakpoints that matter for agents: 1440px (content lock), 1068px (small-desktop), 833px (tablet landscape switch), 734px (tablet portrait), 640px (phone), 480px (small phone).
|
||||||
|
|
||||||
### Touch Targets
|
### Touch Targets
|
||||||
- Buttons use 12px 24px padding for comfortable touch
|
- Minimum 44 × 44px. `{component.button-primary}` lands at ~44 × 100px (with the full-pill radius making the visible hit area more generous than the label suggests).
|
||||||
- Navigation links spaced with 24px gaps
|
- `{component.button-icon-circular}` is exactly 44 × 44px.
|
||||||
- Minimum tap target: 44px height
|
- Global nav utility links are smaller (~32 × 80px) — they deliberately sit at a tighter target because they're precision desktop actions, and the mobile hamburger replaces them at ≤ 833px.
|
||||||
- Mobile: full-width buttons for easy thumb reach
|
|
||||||
|
|
||||||
### Collapsing Strategy
|
### Collapsing Strategy
|
||||||
- Hero: 320px monospace headline scales down dramatically (to ~48px-64px on mobile)
|
- **Global nav**: full horizontal link row on desktop → collapses to Apple logo + hamburger + bag icon at 834px and below.
|
||||||
- Navigation: horizontal links collapse to hamburger menu
|
- **Sub-nav**: category name + inline links + primary CTA → category name + primary CTA only at mobile; inline links move into a hamburger tray.
|
||||||
- Feature sections: two-column to single-column stacking
|
- **Product tiles**: stack from 2-column to 1-column at 834px; vertical padding tightens from 80px → 48px at small-phone.
|
||||||
- Section padding: 96px -> 48px -> 24px across breakpoints
|
- **Utility grids** (store, accessories): 5-col → 4-col (1440px) → 3-col (1068px) → 2-col (834px) → 1-col (640px).
|
||||||
- Massive display type is the first thing to resize -- it must remain impactful but not overflow
|
- **Hero typography**: `{typography.hero-display}` (56px) → `{typography.display-lg}` (40px) at 1068px → 34px at 640px → 28px at 419px.
|
||||||
|
|
||||||
### Image Behavior
|
### Image Behavior
|
||||||
- Minimal imagery -- the site relies on typography and whitespace
|
- All product imagery uses responsive `srcset` with breakpoint-matched crops.
|
||||||
- Any product screenshots maintain sharp corners
|
- Hero photography may switch art direction at mobile (e.g., the environment page's vista crops to a taller aspect ratio on mobile, framing the subject differently).
|
||||||
- Full-width media scales proportionally with viewport
|
- Product renders maintain their 1:1 or 4:3 aspect ratios across breakpoints; only scale changes.
|
||||||
|
- Lazy-loading is default; the above-fold hero loads eagerly.
|
||||||
|
|
||||||
## 9. Agent Prompt Guide
|
## Iteration Guide
|
||||||
|
|
||||||
### Quick Color Reference
|
1. Focus on ONE component at a time. Reference its YAML key directly (`{component.product-tile-dark}`, `{component.search-input}`).
|
||||||
- Background: Dark (`#1f2228`)
|
2. Variants of an existing component (`-active`, `-focus`, `-2`, `-3`) live as separate entries in `components:`.
|
||||||
- Text Primary: White (`#ffffff`)
|
3. Use `{token.refs}` everywhere — never inline hex.
|
||||||
- Text Secondary: White 70% (`rgba(255, 255, 255, 0.7)`)
|
4. Never document hover. Default and Active/Pressed states only.
|
||||||
- Text Muted: White 50% (`rgba(255, 255, 255, 0.5)`)
|
5. Display headlines stay SF Pro Display 600 with negative letter-spacing. Body stays SF Pro Text 400 at 17px. The boundary is unbreakable.
|
||||||
- Text Disabled: White 30% (`rgba(255, 255, 255, 0.3)`)
|
6. The single drop-shadow (`rgba(0, 0, 0, 0.22) 3px 5px 30px`) is reserved for product photography only.
|
||||||
- Border Default: White 10% (`rgba(255, 255, 255, 0.1)`)
|
7. When in doubt about emphasis: alternate surface (light → dark tile) before adding chrome.
|
||||||
- Border Strong: White 20% (`rgba(255, 255, 255, 0.2)`)
|
|
||||||
- Surface Subtle: White 3% (`rgba(255, 255, 255, 0.03)`)
|
|
||||||
- Surface Hover: White 8% (`rgba(255, 255, 255, 0.08)`)
|
|
||||||
- Focus Ring: Blue (`rgb(59, 130, 246)` at 50% opacity)
|
|
||||||
- Button Primary BG: White (`#ffffff`), text Dark (`#1f2228`)
|
|
||||||
|
|
||||||
### Example Component Prompts
|
## Known Gaps
|
||||||
- "Create a hero section on #1f2228 background. Headline in GeistMono at 72px weight 300, color #ffffff, centered. Subtitle in universalSans 18px weight 400, rgba(255,255,255,0.7), max-width 600px centered. Two buttons: primary (white bg, #1f2228 text, 0px radius, GeistMono 14px uppercase, 1.4px letter-spacing, 12px 24px padding) and ghost (transparent bg, 1px solid rgba(255,255,255,0.2), white text, same font treatment)."
|
|
||||||
- "Design a card: transparent or rgba(255,255,255,0.03) background, 1px solid rgba(255,255,255,0.1) border, 0px radius, 24px padding. No shadow. Title in universalSans 22px weight 400, #ffffff. Body in universalSans 16px weight 400, rgba(255,255,255,0.7), line-height 1.5. Hover: border changes to rgba(255,255,255,0.2)."
|
|
||||||
- "Build navigation: #1f2228 background, full-width. Brand text left (GeistMono 14px uppercase). Links in universalSans 14px #ffffff with hover to rgba(255,255,255,0.5). White primary button right-aligned (GeistMono 14px uppercase, 1.4px letter-spacing)."
|
|
||||||
- "Create a form: dark background #1f2228. Label in universalSans 14px rgba(255,255,255,0.7). Input with transparent bg, 1px solid rgba(255,255,255,0.2) border, 0px radius, white text 16px universalSans. Focus: blue ring rgb(59,130,246)/0.5. Placeholder: rgba(255,255,255,0.3)."
|
|
||||||
- "Design a monospace tag/badge: transparent bg, 1px solid rgba(255,255,255,0.2), 0px radius, GeistMono 12px uppercase, 1px letter-spacing, white text, 4px 8px padding."
|
|
||||||
|
|
||||||
### Iteration Guide
|
- Form validation and error states were not surfaced on the analyzed pages; only the neutral search input is documented.
|
||||||
1. Always start with `#1f2228` background -- never use pure black or gray backgrounds
|
- The homepage's embedded video/player frame uses `{colors.surface-black}`; interior player controls are not documented (they're a platform widget, not a web-design token).
|
||||||
2. GeistMono for display and buttons, universalSans for everything else -- never mix these roles
|
- Some component imagery is dynamic (rotating product hero) and its specific copy varies per surface — component specs name the structure, not the rotating content.
|
||||||
3. All buttons must be GeistMono uppercase with 1.4px letter-spacing -- this is non-negotiable
|
- Dark-mode counterparts for store and accessories utility cards were not surfaced on the analyzed pages; the system documented is the daytime/light-dominant variant Apple ships by default.
|
||||||
4. No shadows, ever -- depth comes from border opacity and background opacity only
|
- Atmospheric photography (environment page mountain vista) is a content asset, not a design token; the documented `{component.environment-quote-card}` describes the structural surface only.
|
||||||
5. Borders are always white with low opacity (0.1 default, 0.2 for emphasis)
|
- The exact backdrop-filter blur radius on `{component.sub-nav-frosted}` and `{component.floating-sticky-bar}` is platform-dependent; production CSS uses `saturate(180%) blur(20px)` as a typical baseline but the value isn't formalized as a token.
|
||||||
6. Hover behavior dims to 0.5 opacity rather than brightening -- the reverse of most systems
|
|
||||||
7. Sharp corners (0px) by default -- only use 4px for specific secondary containers
|
|
||||||
8. Body text at 16px universalSans with 1.5 line-height for comfortable reading
|
|
||||||
9. Generous section padding (48px-96px) -- let content breathe in the darkness
|
|
||||||
10. The monochromatic white-on-dark palette is absolute -- resist adding color unless critical for function
|
|
||||||
|
|||||||