This commit is contained in:
刘正航
2026-04-21 22:45:19 +08:00
commit b5237f9038
159 changed files with 7769 additions and 0 deletions

22
miniprogram/app.js Normal file
View File

@@ -0,0 +1,22 @@
App({
globalData: {
// 真机调试时请改为电脑局域网 IP例如 http://192.168.1.10:5000/api
baseURL: 'http://127.0.0.1:5000/api',
token: wx.getStorageSync('token') || '',
user: wx.getStorageSync('user') || null
},
setAuth(token, user) {
this.globalData.token = token
this.globalData.user = user
wx.setStorageSync('token', token)
wx.setStorageSync('user', user)
},
clearAuth() {
this.globalData.token = ''
this.globalData.user = null
wx.removeStorageSync('token')
wx.removeStorageSync('user')
}
})

25
miniprogram/app.json Normal file
View File

@@ -0,0 +1,25 @@
{
"pages": [
"pages/login/index",
"pages/register/index",
"pages/home/index",
"pages/detect/index",
"pages/batch/index",
"pages/history/index",
"pages/inbox/index",
"pages/profile/index",
"pages/admin-dashboard/index",
"pages/admin-review/index",
"pages/admin-users/index",
"pages/admin-samples/index"
],
"window": {
"navigationBarTitleText": "内容风控平台",
"navigationBarBackgroundColor": "#0A1A2D",
"navigationBarTextStyle": "white",
"backgroundTextStyle": "light",
"backgroundColor": "#EEF3F8"
},
"style": "v2",
"sitemapLocation": "sitemap.json"
}

553
miniprogram/app.wxss Normal file
View File

@@ -0,0 +1,553 @@
/** app.wxss */
page {
--bg-1: #050c17;
--bg-2: #0a1629;
--bg-3: #102542;
--card: rgba(12, 28, 49, 0.82);
--card-soft: rgba(17, 35, 58, 0.78);
--line: rgba(139, 177, 223, 0.24);
--line-strong: rgba(139, 177, 223, 0.38);
--title: #edf4ff;
--text: #c7d8ee;
--sub: #8fa9c7;
--primary: #ff5a4f;
--primary-2: #ff7b4f;
--accent: #23a3ff;
--accent-2: #2e7dff;
--success: #2dcf95;
--danger: #ff6276;
--warning: #ffb454;
--radius: 24rpx;
--radius-sm: 16rpx;
--shadow: 0 20rpx 54rpx rgba(2, 9, 20, 0.4);
min-height: 100%;
color: var(--text);
font-family: 'HarmonyOS Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background:
radial-gradient(circle at 84% -10%, rgba(35, 163, 255, 0.22), transparent 40%),
radial-gradient(circle at -10% 14%, rgba(255, 90, 79, 0.22), transparent 34%),
linear-gradient(180deg, var(--bg-2) 0%, var(--bg-1) 100%);
}
.container {
position: relative;
padding: 26rpx;
padding-bottom: calc(48rpx + env(safe-area-inset-bottom));
}
.hero {
position: relative;
overflow: hidden;
padding: 36rpx 30rpx;
border-radius: 32rpx;
color: #fff;
background:
linear-gradient(130deg, rgba(255, 120, 96, 0.22), rgba(255, 120, 96, 0) 38%),
linear-gradient(135deg, #102848 0%, #1a4476 46%, #183357 100%);
border: 1rpx solid rgba(166, 206, 246, 0.3);
box-shadow: var(--shadow);
animation: heroGlow 6s ease-in-out infinite alternate;
}
.hero::after {
content: '';
position: absolute;
right: -120rpx;
top: -88rpx;
width: 280rpx;
height: 280rpx;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0));
pointer-events: none;
}
.hero-badge {
display: inline-flex;
align-items: center;
padding: 8rpx 16rpx;
border-radius: 999rpx;
border: 1rpx solid rgba(210, 234, 255, 0.4);
background: rgba(8, 25, 47, 0.42);
color: #dcecff;
font-size: 20rpx;
letter-spacing: 0.6rpx;
}
.hero-title {
margin-top: 14rpx;
font-size: 42rpx;
font-weight: 700;
letter-spacing: 1.2rpx;
line-height: 1.35;
}
.hero-sub {
margin-top: 12rpx;
font-size: 24rpx;
color: #d2e2f7;
line-height: 1.68;
}
.hero-meta {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
margin-top: 14rpx;
}
.hero-metric {
padding: 8rpx 14rpx;
border-radius: 999rpx;
font-size: 21rpx;
color: #dff0ff;
border: 1rpx solid rgba(186, 219, 250, 0.34);
background: rgba(7, 24, 45, 0.46);
}
.card {
margin-top: 20rpx;
padding: 24rpx;
border-radius: var(--radius);
background: var(--card);
border: 1rpx solid var(--line);
box-shadow: var(--shadow);
transition: transform 0.22s ease, border-color 0.22s ease, box-shadow 0.22s ease;
}
.card:active {
transform: scale(0.992);
border-color: var(--line-strong);
}
.card-title {
margin-bottom: 12rpx;
color: var(--title);
font-size: 30rpx;
font-weight: 700;
letter-spacing: 0.5rpx;
line-height: 1.4;
}
.card-desc {
margin-bottom: 16rpx;
color: var(--sub);
font-size: 23rpx;
line-height: 1.66;
}
.glass-divider {
margin: 18rpx 0;
height: 1rpx;
background: linear-gradient(90deg, rgba(132, 171, 214, 0), rgba(132, 171, 214, 0.42), rgba(132, 171, 214, 0));
}
.field {
margin-top: 14rpx;
}
.field-label {
display: block;
margin-bottom: 8rpx;
color: var(--sub);
font-size: 24rpx;
letter-spacing: 0.3rpx;
}
.field-help {
margin-top: 8rpx;
color: var(--sub);
font-size: 22rpx;
line-height: 1.6;
}
.input,
.textarea {
width: 100%;
min-height: 88rpx;
border-radius: 16rpx;
border: 1rpx solid rgba(125, 163, 204, 0.36);
background: var(--card-soft);
color: #eaf4ff;
padding: 0 20rpx;
font-size: 28rpx;
line-height: 88rpx;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.input:focus,
.textarea:focus {
border-color: rgba(157, 204, 255, 0.8);
box-shadow: 0 0 0 2rpx rgba(35, 163, 255, 0.2);
background: rgba(18, 39, 65, 0.9);
}
.textarea {
min-height: 220rpx;
padding-top: 16rpx;
line-height: 1.65;
}
.picker-value {
min-width: 180rpx;
text-align: right;
color: var(--title);
font-size: 26rpx;
font-weight: 600;
}
.btn-row {
display: flex;
gap: 14rpx;
margin-top: 14rpx;
}
.btn-row .btn {
flex: 1;
margin-top: 0;
}
.btn {
margin-top: 16rpx;
border: none;
border-radius: 999rpx;
font-size: 28rpx;
font-weight: 650;
padding: 16rpx 24rpx;
letter-spacing: 0.6rpx;
transform: translateY(0);
transition: transform 0.16s ease, box-shadow 0.2s ease, opacity 0.2s ease;
}
button.btn::after {
border: none;
}
.btn:active {
transform: translateY(2rpx) scale(0.985);
}
.btn[disabled] {
opacity: 0.55;
}
.btn-primary {
color: #fff;
background: linear-gradient(130deg, var(--primary), var(--primary-2));
box-shadow: 0 14rpx 30rpx rgba(255, 91, 111, 0.35);
}
.btn-accent {
color: #fff;
background: linear-gradient(130deg, var(--accent), var(--accent-2));
box-shadow: 0 14rpx 30rpx rgba(41, 145, 255, 0.34);
}
.btn-ghost {
color: #d9e9ff;
background: rgba(153, 191, 235, 0.14);
border: 1rpx solid rgba(153, 191, 235, 0.34);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
}
.row + .row {
margin-top: 14rpx;
}
.label {
color: var(--sub);
font-size: 25rpx;
}
.value {
color: var(--title);
font-size: 27rpx;
font-weight: 600;
}
.grid-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12rpx;
}
.grid-auto {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220rpx, 1fr));
gap: 12rpx;
}
.kpi {
padding: 18rpx;
border-radius: 18rpx;
background: linear-gradient(145deg, rgba(24, 47, 78, 0.68), rgba(16, 31, 53, 0.94));
border: 1rpx solid rgba(109, 152, 203, 0.24);
transition: transform 0.2s ease;
}
.kpi:active {
transform: scale(0.985);
}
.kpi-value {
font-size: 34rpx;
font-weight: 700;
color: #f2f7ff;
line-height: 1.25;
}
.kpi-label {
margin-top: 8rpx;
font-size: 22rpx;
color: var(--sub);
line-height: 1.45;
}
.module-card {
padding: 20rpx;
border-radius: 18rpx;
background: linear-gradient(150deg, rgba(19, 40, 66, 0.88), rgba(14, 28, 48, 0.92));
border: 1rpx solid rgba(122, 162, 206, 0.24);
transition: transform 0.2s ease, border-color 0.2s ease;
}
.module-card:active {
transform: translateY(2rpx) scale(0.99);
border-color: rgba(167, 206, 247, 0.44);
}
.module-name {
color: #eff6ff;
font-size: 28rpx;
font-weight: 700;
line-height: 1.35;
}
.module-desc {
margin-top: 8rpx;
color: var(--sub);
font-size: 22rpx;
line-height: 1.6;
}
.module-tag {
display: inline-block;
margin-top: 12rpx;
padding: 4rpx 12rpx;
border-radius: 999rpx;
color: #d4e9ff;
font-size: 20rpx;
border: 1rpx solid rgba(142, 183, 228, 0.34);
background: rgba(23, 61, 103, 0.44);
}
.list-item {
margin-top: 12rpx;
padding: 18rpx;
border-radius: 16rpx;
border: 1rpx solid rgba(109, 152, 203, 0.24);
background: rgba(18, 35, 58, 0.72);
transition: transform 0.2s ease, border-color 0.2s ease;
}
.list-item:active {
transform: scale(0.992);
border-color: rgba(160, 200, 244, 0.42);
}
.item-title {
color: var(--title);
font-size: 27rpx;
font-weight: 600;
line-height: 1.5;
}
.item-sub {
margin-top: 8rpx;
color: var(--sub);
font-size: 23rpx;
line-height: 1.65;
}
.tag {
display: inline-block;
margin-right: 8rpx;
margin-bottom: 8rpx;
padding: 8rpx 16rpx;
border-radius: 999rpx;
font-size: 22rpx;
background: rgba(255, 181, 71, 0.16);
border: 1rpx solid rgba(255, 181, 71, 0.4);
color: #ffd487;
}
.chip-group {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
margin-top: 12rpx;
}
.chip {
padding: 8rpx 14rpx;
border-radius: 999rpx;
color: #dbeeff;
font-size: 22rpx;
border: 1rpx solid rgba(145, 185, 228, 0.3);
background: rgba(28, 64, 103, 0.34);
}
.progress-track {
margin-top: 10rpx;
width: 100%;
height: 14rpx;
border-radius: 999rpx;
background: rgba(85, 123, 166, 0.3);
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #ff7654, #ff4f66);
}
.progress-fill-safe {
background: linear-gradient(90deg, #1fb6ff, #24d49a);
}
.status-spam,
.status-ham,
.status-warn,
.status-pending {
display: inline-block;
padding: 4rpx 14rpx;
border-radius: 999rpx;
font-weight: 700;
font-size: 22rpx;
}
.status-spam {
color: #ffdce1;
background: rgba(255, 91, 111, 0.22);
border: 1rpx solid rgba(255, 108, 128, 0.42);
}
.status-ham {
color: #d4ffe9;
background: rgba(53, 196, 140, 0.18);
border: 1rpx solid rgba(86, 220, 167, 0.34);
}
.status-warn {
color: #ffe8c7;
background: rgba(255, 181, 71, 0.2);
border: 1rpx solid rgba(255, 201, 124, 0.4);
}
.status-pending {
color: #d6e8ff;
background: rgba(78, 143, 230, 0.22);
border: 1rpx solid rgba(123, 175, 245, 0.42);
}
.pager-row {
margin-top: 16rpx;
display: flex;
justify-content: space-between;
gap: 16rpx;
}
.pager-btn {
flex: 1;
}
.muted {
color: var(--sub);
font-size: 24rpx;
line-height: 1.6;
}
.small {
font-size: 22rpx;
}
.empty {
text-align: center;
padding: 30rpx 10rpx;
color: var(--sub);
font-size: 25rpx;
line-height: 1.8;
}
.fade-up {
animation: fadeUp 0.5s ease both;
}
.fade-up-delay-1 {
animation-delay: 0.08s;
}
.fade-up-delay-2 {
animation-delay: 0.16s;
}
.fade-up-delay-3 {
animation-delay: 0.24s;
}
.pulse {
animation: pulseGlow 2.1s ease-in-out infinite;
}
@keyframes heroGlow {
0% {
box-shadow: 0 20rpx 54rpx rgba(3, 8, 19, 0.36);
}
100% {
box-shadow: 0 28rpx 72rpx rgba(3, 8, 19, 0.54);
}
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(12rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulseGlow {
0%,
100% {
box-shadow: 0 12rpx 30rpx rgba(255, 91, 111, 0.24);
}
50% {
box-shadow: 0 18rpx 36rpx rgba(255, 91, 111, 0.46);
}
}
@media (max-width: 360px) {
.hero-title {
font-size: 38rpx;
}
.grid-3 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}

View File

@@ -0,0 +1,71 @@
const { request } = require('../../utils/request')
Page({
data: {
loading: false,
stats: null,
kpis: [],
bars: [],
sourceDist: [],
topKeywords: []
},
formatPercent(value, digits = 2) {
const num = Number(value || 0)
return `${(num * 100).toFixed(digits)}%`
},
onShow() {
this.fetchStats()
},
onPullDownRefresh() {
this.fetchStats(true)
},
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(fromPullDown = false) {
this.setData({ loading: true })
try {
const stats = await request({ url: '/admin/stats' })
const normalizedStats = {
...stats,
threshold_text: stats && stats.threshold ? this.formatPercent(stats.threshold.spam_threshold, 1) : '--'
}
this.setData({
stats: normalizedStats,
kpis: this.normalizeKpis(normalizedStats),
bars: this.normalizeBars(normalizedStats.trend_7d || []),
sourceDist: normalizedStats.source_distribution || [],
topKeywords: normalizedStats.top_keywords || []
})
} finally {
this.setData({ loading: false })
if (fromPullDown) wx.stopPullDownRefresh()
}
}
})

View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "运营看板",
"enablePullDownRefresh": true
}

View File

@@ -0,0 +1,57 @@
<view class="container">
<view class="hero fade-up">
<view class="hero-badge">OPS DASHBOARD</view>
<view class="hero-title">垃圾信息运营看板</view>
<view class="hero-sub">覆盖发布、拦截、申诉、样本与模型状态,支持日常运营与风险监控。</view>
</view>
<view class="card fade-up fade-up-delay-1" wx:if="{{kpis.length}}">
<view class="card-title">核心指标</view>
<view class="grid-2">
<view class="kpi" wx:for="{{kpis}}" wx:key="label">
<view class="kpi-value">{{item.value}}</view>
<view class="kpi-label">{{item.label}}</view>
</view>
</view>
</view>
<view class="card fade-up fade-up-delay-2" wx:if="{{stats && stats.threshold}}">
<view class="card-title">检测阈值配置</view>
<view class="row"><text class="label">当前阈值</text><text class="value">{{stats.threshold_text}}</text></view>
<view class="row"><text class="label">更新时间</text><text class="value">{{stats.threshold.updated_at || '--'}}</text></view>
</view>
<view class="card fade-up fade-up-delay-2" wx:if="{{stats && stats.model_info}}">
<view class="card-title">模型信息</view>
<view class="row"><text class="label">模型版本</text><text class="value">{{stats.model_info.version || '未训练'}}</text></view>
<view class="row"><text class="label">训练时间</text><text class="value">{{stats.model_info.trained_at || '--'}}</text></view>
<view class="row"><text class="label">样本数量</text><text class="value">{{stats.model_info.sample_count || 0}}</text></view>
</view>
<view class="card fade-up fade-up-delay-3" wx:if="{{bars.length}}">
<view class="card-title">近 7 天发布趋势</view>
<view class="list-item" wx:for="{{bars}}" wx:key="date">
<view class="row">
<text class="label">{{item.label}}</text>
<text class="value">{{item.value}} 条</text>
</view>
<view class="progress-track">
<view class="progress-fill-safe" style="width: {{item.percent_text}};"></view>
</view>
</view>
</view>
<view class="card fade-up fade-up-delay-3" wx:if="{{sourceDist.length}}">
<view class="card-title">训练样本来源</view>
<view class="list-item" wx:for="{{sourceDist}}" wx:key="name">
<view class="row"><text class="item-title">{{item.name}}</text><text class="value">{{item.value}}</text></view>
</view>
</view>
<view class="card fade-up fade-up-delay-3" wx:if="{{topKeywords.length}}">
<view class="card-title">高频风险词</view>
<view class="chip-group">
<text class="tag" wx:for="{{topKeywords}}" wx:key="token">{{item.token}} × {{item.count}}</text>
</view>
</view>
</view>

View File

@@ -0,0 +1 @@
/* admin-dashboard styles use global theme */

View File

@@ -0,0 +1,152 @@
const { request } = require('../../utils/request')
const emptyForm = {
name: '',
category: '轻食',
description: '',
calories: '',
protein: '',
fat: '',
carbs: '',
fiber: '',
tagsText: ''
}
Page({
data: {
keyword: '',
recipes: [],
loading: false,
editId: null,
form: { ...emptyForm },
importText: '[{"name":"低脂鸡肉饭","category":"减脂轻食","calories":360,"protein":30,"fat":10,"carbs":34,"fiber":5,"tags":["减脂"]}]'
},
onShow() {
this.fetchRecipes()
},
onInput(e) {
const field = e.currentTarget.dataset.field
this.setData({ [field]: e.detail.value })
},
onFormInput(e) {
const field = e.currentTarget.dataset.field
this.setData({ [`form.${field}`]: e.detail.value })
},
async fetchRecipes() {
this.setData({ loading: true })
try {
const data = await request({ url: `/recipes?keyword=${encodeURIComponent(this.data.keyword)}&page=1&page_size=100` })
const recipes = (data.items || []).map((item) => ({
...item,
tags_text: (item.tags || []).join('、')
}))
this.setData({ recipes })
} catch (err) {
// handled
} finally {
this.setData({ loading: false })
}
},
startCreate() {
this.setData({ editId: null, form: { ...emptyForm } })
},
startEdit(e) {
const id = Number(e.currentTarget.dataset.id)
const row = this.data.recipes.find((item) => item.id === id)
if (!row) return
this.setData({
editId: row.id,
form: {
name: row.name || '',
category: row.category || '轻食',
description: row.description || '',
calories: String(row.calories || ''),
protein: String(row.protein || ''),
fat: String(row.fat || ''),
carbs: String(row.carbs || ''),
fiber: String(row.fiber || ''),
tagsText: (row.tags || []).join(',')
}
})
},
buildRecipePayload() {
const f = this.data.form
return {
name: f.name,
category: f.category,
description: f.description,
calories: Number(f.calories),
protein: Number(f.protein),
fat: Number(f.fat),
carbs: Number(f.carbs),
fiber: Number(f.fiber),
tags: (f.tagsText || '').split(',').map(i => i.trim()).filter(Boolean)
}
},
async saveRecipe() {
const payload = this.buildRecipePayload()
if (!payload.name) {
wx.showToast({ title: '请填写食谱名称', icon: 'none' })
return
}
try {
if (this.data.editId) {
await request({ url: `/recipes/${this.data.editId}`, method: 'PUT', data: payload })
wx.showToast({ title: '食谱已更新', icon: 'success' })
} else {
await request({ url: '/recipes', method: 'POST', data: payload })
wx.showToast({ title: '食谱已创建', icon: 'success' })
}
this.fetchRecipes()
} catch (err) {
// handled
}
},
async deleteRecipe(e) {
const id = e.currentTarget.dataset.id
wx.showModal({
title: '删除食谱',
content: `确定删除食谱 ID ${id} 吗?`,
success: async (res) => {
if (!res.confirm) return
try {
await request({ url: `/recipes/${id}`, method: 'DELETE' })
wx.showToast({ title: '删除成功', icon: 'success' })
this.fetchRecipes()
} catch (err) {
// handled
}
}
})
},
async importDefaultSeed() {
try {
await request({ url: '/recipes/import', method: 'POST', data: {} })
wx.showToast({ title: '默认食谱导入成功', icon: 'success' })
this.fetchRecipes()
} catch (err) {
// handled
}
},
async importByJSON() {
try {
const items = JSON.parse(this.data.importText)
await request({ url: '/recipes/import', method: 'POST', data: { items } })
wx.showToast({ title: '批量导入成功', icon: 'success' })
this.fetchRecipes()
} catch (err) {
wx.showToast({ title: '导入 JSON 格式错误', icon: 'none' })
}
}
})

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "管理员-食谱管理"
}

View File

@@ -0,0 +1,53 @@
<view class="container">
<view class="hero">
<view class="hero-title">轻食食谱信息管理</view>
<view class="hero-sub">支持导入 / 查找 / 编辑 / 删除食谱信息</view>
</view>
<view class="card">
<view class="card-title">食谱查找</view>
<input class="input" data-field="keyword" value="{{keyword}}" bindinput="onInput" placeholder="按名称搜索" />
<button class="btn btn-primary" loading="{{loading}}" bindtap="fetchRecipes">搜索食谱</button>
<button class="btn btn-ghost" bindtap="importDefaultSeed">导入默认食谱种子</button>
</view>
<view class="card">
<view class="card-title">{{editId ? ('编辑食谱 #' + editId) : '新增食谱'}}</view>
<input class="input" data-field="name" value="{{form.name}}" bindinput="onFormInput" placeholder="食谱名称" />
<input class="input" data-field="category" value="{{form.category}}" bindinput="onFormInput" placeholder="分类" />
<input class="input" data-field="description" value="{{form.description}}" bindinput="onFormInput" placeholder="描述" />
<input class="input" data-field="calories" type="digit" value="{{form.calories}}" bindinput="onFormInput" placeholder="热量" />
<input class="input" data-field="protein" type="digit" value="{{form.protein}}" bindinput="onFormInput" placeholder="蛋白质" />
<input class="input" data-field="fat" type="digit" value="{{form.fat}}" bindinput="onFormInput" placeholder="脂肪" />
<input class="input" data-field="carbs" type="digit" value="{{form.carbs}}" bindinput="onFormInput" placeholder="碳水" />
<input class="input" data-field="fiber" type="digit" value="{{form.fiber}}" bindinput="onFormInput" placeholder="膳食纤维" />
<input class="input" data-field="tagsText" value="{{form.tagsText}}" bindinput="onFormInput" placeholder="标签,逗号分隔" />
<button class="btn btn-primary" bindtap="saveRecipe">保存食谱</button>
<button class="btn btn-ghost" bindtap="startCreate">清空表单</button>
</view>
<view class="card">
<view class="card-title">批量导入 JSON</view>
<textarea class="input textarea" data-field="importText" value="{{importText}}" bindinput="onInput" />
<button class="btn btn-accent" bindtap="importByJSON">执行导入</button>
</view>
<view class="card">
<view class="card-title">食谱列表</view>
<view wx:if="{{!recipes.length}}" class="empty">暂无食谱</view>
<view wx:for="{{recipes}}" wx:key="id" class="item">
<view class="row">
<view class="item-title">{{item.name}}</view>
<view class="value">{{item.calories}} kcal</view>
</view>
<view class="item-sub">蛋白{{item.protein}}g / 脂肪{{item.fat}}g / 碳水{{item.carbs}}g</view>
<view class="item-sub">{{item.category}} · {{item.tags_text}}</view>
<view class="row">
<button class="btn btn-ghost" size="mini" data-id="{{item.id}}" bindtap="startEdit">编辑</button>
<button class="btn btn-accent" size="mini" data-id="{{item.id}}" bindtap="deleteRecipe">删除</button>
</view>
</view>
</view>
</view>

View File

@@ -0,0 +1,4 @@
.item .row .btn {
margin-top: 10rpx;
width: 44%;
}

View File

@@ -0,0 +1,298 @@
const { request } = require('../../utils/request')
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: '已驳回'
}
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
}
}
Page({
data: {
loading: false,
thresholdInput: '0.75',
interceptKeyword: '',
interceptStatusOptions: INTERCEPT_STATUS_OPTIONS,
interceptStatusIndex: 1,
interceptReviewStatusOptions: INTERCEPT_REVIEW_STATUS_OPTIONS,
interceptReviewStatusIndex: 1,
intercepts: [],
interceptPager: buildPager(0, 1, 10),
appealKeyword: '',
appealStatusOptions: APPEAL_STATUS_OPTIONS,
appealStatusIndex: 0,
appeals: [],
appealPager: buildPager(0, 1, 10),
reviewNoteMap: {},
appealNoteMap: {}
},
formatPercent(value, digits = 2) {
const num = Number(value || 0)
return `${(num * 100).toFixed(digits)}%`
},
onShow() {
this.bootstrap()
},
onPullDownRefresh() {
this.bootstrap(true)
},
async bootstrap(fromPullDown = false) {
this.setData({ loading: true })
try {
await Promise.all([this.fetchThreshold(), this.fetchIntercepts(), this.fetchAppeals()])
} finally {
this.setData({ loading: false })
if (fromPullDown) wx.stopPullDownRefresh()
}
},
async fetchThreshold() {
const data = await request({ url: '/admin/detection/threshold' })
this.setData({ 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
}))
},
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
}))
},
async fetchIntercepts() {
const {
interceptPager,
interceptKeyword,
interceptStatusOptions,
interceptStatusIndex,
interceptReviewStatusOptions,
interceptReviewStatusIndex
} = this.data
const status = interceptStatusOptions[interceptStatusIndex].value
const reviewStatus = interceptReviewStatusOptions[interceptReviewStatusIndex].value
const query = [
`keyword=${encodeURIComponent((interceptKeyword || '').trim())}`,
`status=${encodeURIComponent(status)}`,
`review_status=${encodeURIComponent(reviewStatus)}`,
`page=${interceptPager.page}`,
`page_size=${interceptPager.pageSize}`
].join('&')
const data = await request({ url: `/admin/intercepts?${query}` })
this.setData({
intercepts: this.normalizeIntercepts(data.items || []),
interceptPager: buildPager(data.total || 0, interceptPager.page, interceptPager.pageSize)
})
},
async fetchAppeals() {
const { appealPager, appealKeyword, appealStatusOptions, appealStatusIndex } = this.data
const status = appealStatusOptions[appealStatusIndex].value
const query = [
`keyword=${encodeURIComponent((appealKeyword || '').trim())}`,
`status=${encodeURIComponent(status)}`,
`page=${appealPager.page}`,
`page_size=${appealPager.pageSize}`
].join('&')
const data = await request({ url: `/admin/appeals?${query}` })
this.setData({
appeals: this.normalizeAppeals(data.items || []),
appealPager: buildPager(data.total || 0, appealPager.page, appealPager.pageSize)
})
},
onInput(e) {
const field = e.currentTarget.dataset.field
this.setData({ [field]: e.detail.value || '' })
},
onInterceptStatusChange(e) {
this.setData({ interceptStatusIndex: Number(e.detail.value || 0) })
},
onInterceptReviewStatusChange(e) {
this.setData({ interceptReviewStatusIndex: Number(e.detail.value || 0) })
},
onAppealStatusChange(e) {
this.setData({ appealStatusIndex: Number(e.detail.value || 0) })
},
applyInterceptFilters() {
this.setData({ interceptPager: { ...this.data.interceptPager, page: 1 } })
this.fetchIntercepts()
},
clearInterceptFilters() {
this.setData({
interceptKeyword: '',
interceptStatusIndex: 1,
interceptReviewStatusIndex: 1,
interceptPager: { ...this.data.interceptPager, page: 1 }
})
this.fetchIntercepts()
},
applyAppealFilters() {
this.setData({ appealPager: { ...this.data.appealPager, page: 1 } })
this.fetchAppeals()
},
clearAppealFilters() {
this.setData({
appealKeyword: '',
appealStatusIndex: 0,
appealPager: { ...this.data.appealPager, page: 1 }
})
this.fetchAppeals()
},
prevInterceptPage() {
if (!this.data.interceptPager.hasPrev) return
this.setData({ interceptPager: { ...this.data.interceptPager, page: this.data.interceptPager.page - 1 } })
this.fetchIntercepts()
},
nextInterceptPage() {
if (!this.data.interceptPager.hasNext) return
this.setData({ interceptPager: { ...this.data.interceptPager, page: this.data.interceptPager.page + 1 } })
this.fetchIntercepts()
},
prevAppealPage() {
if (!this.data.appealPager.hasPrev) return
this.setData({ appealPager: { ...this.data.appealPager, page: this.data.appealPager.page - 1 } })
this.fetchAppeals()
},
nextAppealPage() {
if (!this.data.appealPager.hasNext) return
this.setData({ appealPager: { ...this.data.appealPager, page: this.data.appealPager.page + 1 } })
this.fetchAppeals()
},
onReviewNoteInput(e) {
const id = e.currentTarget.dataset.id
this.setData({ [`reviewNoteMap.${id}`]: e.detail.value || '' })
},
onAppealNoteInput(e) {
const id = e.currentTarget.dataset.id
this.setData({ [`appealNoteMap.${id}`]: e.detail.value || '' })
},
async saveThreshold() {
const value = Number(this.data.thresholdInput)
if (Number.isNaN(value) || value <= 0 || value >= 1) {
wx.showToast({ title: '阈值需在 0 到 1 之间', icon: 'none' })
return
}
await request({
url: '/admin/detection/threshold',
method: 'PUT',
data: { spam_threshold: value }
})
wx.showToast({ title: '阈值更新成功', icon: 'success' })
this.fetchThreshold()
},
async reviewIntercept(e) {
const id = Number(e.currentTarget.dataset.id)
const decision = e.currentTarget.dataset.decision
const note = (this.data.reviewNoteMap[id] || '').trim()
await request({
url: `/admin/intercepts/${id}/review`,
method: 'PUT',
data: {
decision,
note: note || (decision === 'spam' ? '人工复核确认为垃圾信息' : '人工复核后解除拦截')
}
})
wx.showToast({ title: '复核完成', icon: 'success' })
this.setData({ [`reviewNoteMap.${id}`]: '' })
await Promise.all([this.fetchIntercepts(), this.fetchAppeals()])
},
async processAppeal(e) {
const id = Number(e.currentTarget.dataset.id)
const action = e.currentTarget.dataset.action
const note = (this.data.appealNoteMap[id] || '').trim()
await request({
url: `/admin/appeals/${id}/process`,
method: 'PUT',
data: {
action,
note: note || (action === 'approve' ? '申诉通过,解除拦截' : '申诉驳回,维持拦截')
}
})
wx.showToast({ title: '申诉处理完成', icon: 'success' })
this.setData({ [`appealNoteMap.${id}`]: '' })
await Promise.all([this.fetchIntercepts(), this.fetchAppeals()])
}
})

View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "复核与申诉",
"enablePullDownRefresh": true
}

View File

@@ -0,0 +1,122 @@
<view class="container">
<view class="hero fade-up">
<view class="hero-badge">REVIEW CENTER</view>
<view class="hero-title">复核与申诉后台</view>
<view class="hero-sub">支持阈值调节、拦截复核、申诉处理与分页筛选,满足商用审核流程。</view>
</view>
<view class="card fade-up fade-up-delay-1">
<view class="card-title">检测阈值调节</view>
<view class="card-desc">阈值越低拦截越严格。推荐范围0.65 - 0.85。</view>
<view class="row">
<text class="label">垃圾阈值0-1</text>
<input class="input threshold-input" type="digit" value="{{thresholdInput}}" data-field="thresholdInput" bindinput="onInput" />
</view>
<button class="btn btn-primary" bindtap="saveThreshold">更新阈值</button>
</view>
<view class="card fade-up fade-up-delay-1">
<view class="card-title">拦截记录筛选</view>
<view class="field">
<text class="field-label">关键词</text>
<input class="input" placeholder="搜索拦截文本关键词" value="{{interceptKeyword}}" data-field="interceptKeyword" bindinput="onInput" />
</view>
<view class="row">
<text class="label">发布状态</text>
<picker mode="selector" range="{{interceptStatusOptions}}" range-key="label" value="{{interceptStatusIndex}}" bindchange="onInterceptStatusChange">
<view class="picker-value">{{interceptStatusOptions[interceptStatusIndex].label}}</view>
</picker>
</view>
<view class="row">
<text class="label">复核状态</text>
<picker mode="selector" range="{{interceptReviewStatusOptions}}" range-key="label" value="{{interceptReviewStatusIndex}}" bindchange="onInterceptReviewStatusChange">
<view class="picker-value">{{interceptReviewStatusOptions[interceptReviewStatusIndex].label}}</view>
</picker>
</view>
<view class="btn-row">
<button class="btn btn-primary" bindtap="applyInterceptFilters">查询</button>
<button class="btn btn-ghost" bindtap="clearInterceptFilters">重置</button>
</view>
</view>
<view class="card fade-up fade-up-delay-2">
<view class="card-title">信息拦截人工复核</view>
<view class="muted">共 {{interceptPager.total}} 条,第 {{interceptPager.page}} / {{interceptPager.totalPages}} 页</view>
<view wx:if="{{intercepts.length}}">
<view class="list-item" wx:for="{{intercepts}}" wx:key="id">
<view class="item-title">{{item.text}}</view>
<view class="item-sub">用户:{{item.nickname || item.username}} · 垃圾概率:{{item.spam_probability_text}}</view>
<view class="item-sub">复核状态:{{item.review_status_text}} · 申诉状态:{{item.appeal_status_text}}</view>
<view class="item-sub">发布时间:{{item.created_text}}</view>
<textarea class="textarea note-textarea" placeholder="可填写复核备注(将写入处理记录)" value="{{reviewNoteMap[item.id] || ''}}" data-id="{{item.id}}" bindinput="onReviewNoteInput" />
<view class="btn-row">
<button class="btn btn-accent" data-id="{{item.id}}" data-decision="spam" bindtap="reviewIntercept">确认垃圾</button>
<button class="btn btn-ghost" data-id="{{item.id}}" data-decision="ham" bindtap="reviewIntercept">误判放行</button>
</view>
</view>
<view class="pager-row">
<button class="btn btn-ghost pager-btn" disabled="{{!interceptPager.hasPrev}}" bindtap="prevInterceptPage">上一页</button>
<button class="btn btn-ghost pager-btn" disabled="{{!interceptPager.hasNext}}" bindtap="nextInterceptPage">下一页</button>
</view>
</view>
<view wx:else class="empty">没有匹配的拦截记录。</view>
</view>
<view class="card fade-up fade-up-delay-2">
<view class="card-title">申诉记录筛选</view>
<view class="field">
<text class="field-label">关键词</text>
<input class="input" placeholder="搜索申诉文本或申诉理由" value="{{appealKeyword}}" data-field="appealKeyword" bindinput="onInput" />
</view>
<view class="row">
<text class="label">申诉状态</text>
<picker mode="selector" range="{{appealStatusOptions}}" range-key="label" value="{{appealStatusIndex}}" bindchange="onAppealStatusChange">
<view class="picker-value">{{appealStatusOptions[appealStatusIndex].label}}</view>
</picker>
</view>
<view class="btn-row">
<button class="btn btn-primary" bindtap="applyAppealFilters">查询</button>
<button class="btn btn-ghost" bindtap="clearAppealFilters">重置</button>
</view>
</view>
<view class="card fade-up fade-up-delay-3">
<view class="card-title">拦截信息申诉处理</view>
<view class="muted">共 {{appealPager.total}} 条,第 {{appealPager.page}} / {{appealPager.totalPages}} 页</view>
<view wx:if="{{appeals.length}}">
<view class="list-item" wx:for="{{appeals}}" wx:key="id">
<view class="item-title">{{item.text}}</view>
<view class="item-sub">申诉人:{{item.nickname || item.username}} · 当前状态:{{item.appeal_status_text}}</view>
<view class="item-sub">申诉理由:{{item.appeal_reason || '未填写'}}</view>
<view class="item-sub">时间:{{item.created_text}}</view>
<textarea class="textarea note-textarea" placeholder="可填写申诉处理备注" value="{{appealNoteMap[item.id] || ''}}" data-id="{{item.id}}" bindinput="onAppealNoteInput" />
<view class="btn-row">
<button class="btn btn-primary" data-id="{{item.id}}" data-action="approve" bindtap="processAppeal">通过申诉</button>
<button class="btn btn-ghost" data-id="{{item.id}}" data-action="reject" bindtap="processAppeal">驳回申诉</button>
</view>
</view>
<view class="pager-row">
<button class="btn btn-ghost pager-btn" disabled="{{!appealPager.hasPrev}}" bindtap="prevAppealPage">上一页</button>
<button class="btn btn-ghost pager-btn" disabled="{{!appealPager.hasNext}}" bindtap="nextAppealPage">下一页</button>
</view>
</view>
<view wx:else class="empty">没有匹配的申诉记录。</view>
</view>
</view>

View File

@@ -0,0 +1,23 @@
/* admin-review styles use global theme */
.threshold-input {
width: 240rpx;
margin-top: 0;
text-align: right;
font-weight: 700;
}
.note-textarea {
min-height: 120rpx;
background: rgba(12, 23, 38, 0.82);
}
.pager-row {
margin-top: 16rpx;
display: flex;
justify-content: space-between;
gap: 16rpx;
}
.pager-btn {
flex: 1;
}

View File

@@ -0,0 +1,107 @@
const { request } = require('../../utils/request')
Page({
data: {
keyword: '',
label: '',
loading: false,
samples: [],
form: {
text: '',
label: 'spam'
},
importText: '[{"text":"点击领取限时现金券","label":"spam"},{"text":"今天下午发布会彩排","label":"ham"}]'
},
onShow() {
this.fetchSamples()
},
onInput(e) {
const field = e.currentTarget.dataset.field
this.setData({ [field]: e.detail.value || '' })
},
onFormInput(e) {
const field = e.currentTarget.dataset.field
this.setData({ [`form.${field}`]: e.detail.value || '' })
},
onLabelChange(e) {
this.setData({ label: e.detail.value || '' })
},
onFormLabelChange(e) {
this.setData({ 'form.label': e.detail.value || 'spam' })
},
async fetchSamples() {
this.setData({ loading: true })
try {
const url = `/spam/samples?keyword=${encodeURIComponent(this.data.keyword)}&label=${encodeURIComponent(this.data.label)}&page=1&page_size=80`
const data = await request({ url })
this.setData({ samples: data.items || [] })
} finally {
this.setData({ loading: false })
}
},
async createSample() {
const payload = {
text: (this.data.form.text || '').trim(),
label: this.data.form.label || 'spam'
}
if (payload.text.length < 2) {
wx.showToast({ title: '样本文本至少 2 个字符', icon: 'none' })
return
}
await request({ url: '/spam/samples', method: 'POST', data: payload })
wx.showToast({ title: '新增成功', icon: 'success' })
this.setData({ 'form.text': '' })
this.fetchSamples()
},
async deleteSample(e) {
const id = Number(e.currentTarget.dataset.id)
wx.showModal({
title: '删除样本',
content: `确认删除样本 ID ${id} 吗?`,
success: async (res) => {
if (!res.confirm) return
await request({ url: `/spam/samples/${id}`, method: 'DELETE' })
wx.showToast({ title: '删除成功', icon: 'success' })
this.fetchSamples()
}
})
},
async toggleActive(e) {
const id = Number(e.currentTarget.dataset.id)
const active = !!e.detail.value
await request({
url: `/spam/samples/${id}`,
method: 'PUT',
data: { is_active: active }
})
wx.showToast({ title: '状态已更新', icon: 'success' })
},
async retrain() {
await request({ url: '/spam/train', method: 'POST', data: {} })
wx.showToast({ title: '模型重训完成', icon: 'success' })
},
async importSamples() {
let items = []
try {
items = JSON.parse(this.data.importText)
} catch (err) {
wx.showToast({ title: 'JSON 格式错误', icon: 'none' })
return
}
await request({ url: '/spam/samples/import', method: 'POST', data: { items } })
wx.showToast({ title: '导入完成', icon: 'success' })
this.fetchSamples()
}
})

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "样本管理"
}

View File

@@ -0,0 +1,64 @@
<view class="container">
<view class="hero fade-up">
<view class="hero-badge">SAMPLE LAB</view>
<view class="hero-title">训练样本管理</view>
<view class="hero-sub">支持增删样本、批量导入、启停样本与一键重训,持续提升识别准确率。</view>
</view>
<view class="card fade-up fade-up-delay-1">
<view class="card-title">筛选样本</view>
<view class="field">
<text class="field-label">关键词</text>
<input class="input" placeholder="输入关键词" value="{{keyword}}" data-field="keyword" bindinput="onInput" />
</view>
<radio-group class="row" bindchange="onLabelChange">
<label class="label"><radio value="" checked="{{label === ''}}" />全部</label>
<label class="label"><radio value="spam" checked="{{label === 'spam'}}" />垃圾</label>
<label class="label"><radio value="ham" checked="{{label === 'ham'}}" />正常</label>
</radio-group>
<button class="btn btn-primary" loading="{{loading}}" bindtap="fetchSamples">查询</button>
</view>
<view class="card fade-up fade-up-delay-1">
<view class="card-title">新增样本</view>
<textarea class="textarea" placeholder="输入样本文本" value="{{form.text}}" data-field="text" bindinput="onFormInput" />
<radio-group class="row" bindchange="onFormLabelChange">
<label class="label"><radio value="spam" checked="{{form.label === 'spam'}}" />垃圾</label>
<label class="label"><radio value="ham" checked="{{form.label === 'ham'}}" />正常</label>
</radio-group>
<button class="btn btn-accent" bindtap="createSample">提交样本</button>
</view>
<view class="card fade-up fade-up-delay-2">
<view class="card-title">批量导入</view>
<view class="card-desc">支持 JSON 数组导入。导入后可直接重训模型。</view>
<textarea class="textarea" value="{{importText}}" data-field="importText" bindinput="onInput" />
<view class="btn-row">
<button class="btn btn-primary" bindtap="importSamples">执行导入</button>
<button class="btn btn-ghost" bindtap="retrain">一键重训模型</button>
</view>
</view>
<view class="card fade-up fade-up-delay-3" wx:if="{{samples.length}}">
<view class="card-title">样本列表</view>
<view class="list-item" wx:for="{{samples}}" wx:key="id">
<view class="item-title">{{item.text}}</view>
<view class="item-sub">标签:{{item.label === 'spam' ? '垃圾' : '正常'}} · 来源:{{item.source}} · ID{{item.id}}</view>
<view class="row">
<text class="label">参与训练</text>
<switch data-id="{{item.id}}" checked="{{item.is_active}}" bindchange="toggleActive" />
</view>
<button class="btn btn-ghost" data-id="{{item.id}}" bindtap="deleteSample">删除样本</button>
</view>
</view>
</view>

View File

@@ -0,0 +1 @@
/* admin-samples styles use global theme */

View File

@@ -0,0 +1,112 @@
const { request } = require('../../utils/request')
Page({
data: {
keyword: '',
loading: false,
users: [],
editUserId: null,
editForm: {
nickname: '',
company: '',
title: '',
phone: '',
is_admin: false,
password: ''
},
importText: '[{"username":"operator01","password":"123456","nickname":"运营同学","company":"示例科技公司"}]'
},
onShow() {
this.fetchUsers()
},
onInput(e) {
const field = e.currentTarget.dataset.field
this.setData({ [field]: e.detail.value || '' })
},
onEditInput(e) {
const field = e.currentTarget.dataset.field
this.setData({ [`editForm.${field}`]: e.detail.value || '' })
},
onAdminSwitch(e) {
this.setData({ 'editForm.is_admin': !!e.detail.value })
},
async fetchUsers() {
this.setData({ loading: true })
try {
const data = await request({
url: `/admin/users?keyword=${encodeURIComponent(this.data.keyword)}&page=1&page_size=80`
})
this.setData({ users: data.items || [] })
} finally {
this.setData({ loading: false })
}
},
startEdit(e) {
const id = Number(e.currentTarget.dataset.id)
const row = this.data.users.find((item) => item.id === id)
if (!row) return
this.setData({
editUserId: row.id,
editForm: {
nickname: row.nickname || '',
company: row.company || '',
title: row.title || '',
phone: row.phone || '',
is_admin: !!row.is_admin,
password: ''
}
})
},
cancelEdit() {
this.setData({ editUserId: null })
},
async saveEdit() {
const id = this.data.editUserId
if (!id) return
const payload = { ...this.data.editForm }
if (!payload.password) delete payload.password
await request({ url: `/admin/users/${id}`, method: 'PUT', data: payload })
wx.showToast({ title: '用户已更新', icon: 'success' })
this.setData({ editUserId: null })
this.fetchUsers()
},
removeUser(e) {
const id = Number(e.currentTarget.dataset.id)
wx.showModal({
title: '删除用户',
content: `确认删除用户 ID ${id} 吗?`,
success: async (res) => {
if (!res.confirm) return
await request({ url: `/admin/users/${id}`, method: 'DELETE' })
wx.showToast({ title: '删除成功', icon: 'success' })
this.fetchUsers()
}
})
},
async importUsers() {
let items = []
try {
items = JSON.parse(this.data.importText)
} catch (err) {
wx.showToast({ title: 'JSON 格式错误', icon: 'none' })
return
}
await request({ url: '/admin/users/import', method: 'POST', data: { items } })
wx.showToast({ title: '导入完成', icon: 'success' })
this.fetchUsers()
}
})

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "用户管理"
}

View File

@@ -0,0 +1,55 @@
<view class="container">
<view class="hero fade-up">
<view class="hero-badge">USER ADMIN</view>
<view class="hero-title">用户与权限管理</view>
<view class="hero-sub">支持账号查询、权限调整、批量导入,适用于企业商用场景。</view>
</view>
<view class="card fade-up fade-up-delay-1">
<view class="card-title">搜索用户</view>
<view class="field">
<text class="field-label">关键词</text>
<input class="input" placeholder="输入用户名或昵称" value="{{keyword}}" data-field="keyword" bindinput="onInput" />
</view>
<button class="btn btn-primary" loading="{{loading}}" bindtap="fetchUsers">查询</button>
</view>
<view class="card fade-up fade-up-delay-1">
<view class="card-title">批量导入</view>
<view class="card-desc">粘贴 JSON 数组,支持批量新增或更新用户信息。</view>
<textarea class="textarea" value="{{importText}}" data-field="importText" bindinput="onInput" />
<button class="btn btn-accent" bindtap="importUsers">执行导入</button>
</view>
<view class="card fade-up fade-up-delay-2" wx:if="{{users.length}}">
<view class="card-title">用户列表</view>
<view class="list-item" wx:for="{{users}}" wx:key="id">
<view class="item-title">{{item.nickname}}{{item.username}}</view>
<view class="item-sub">{{item.company || '未填写公司'}} · {{item.title || '未填写岗位'}} · {{item.is_admin ? '管理员' : '普通用户'}}</view>
<view wx:if="{{editUserId === item.id}}">
<input class="input" placeholder="昵称" value="{{editForm.nickname}}" data-field="nickname" bindinput="onEditInput" />
<input class="input" placeholder="公司" value="{{editForm.company}}" data-field="company" bindinput="onEditInput" />
<input class="input" placeholder="岗位" value="{{editForm.title}}" data-field="title" bindinput="onEditInput" />
<input class="input" placeholder="手机号" value="{{editForm.phone}}" data-field="phone" bindinput="onEditInput" />
<input class="input" placeholder="新密码(可选)" password value="{{editForm.password}}" data-field="password" bindinput="onEditInput" />
<view class="row">
<text class="label">管理员权限</text>
<switch checked="{{editForm.is_admin}}" bindchange="onAdminSwitch" />
</view>
<view class="btn-row">
<button class="btn btn-primary" bindtap="saveEdit">保存</button>
<button class="btn btn-ghost" bindtap="cancelEdit">取消</button>
</view>
</view>
<view wx:else class="btn-row">
<button class="btn btn-ghost" data-id="{{item.id}}" bindtap="startEdit">编辑</button>
<button class="btn btn-accent" data-id="{{item.id}}" bindtap="removeUser">删除</button>
</view>
</view>
</view>
</view>

View File

@@ -0,0 +1 @@
/* admin-users styles use global theme */

View File

@@ -0,0 +1,70 @@
const { request } = require('../../utils/request')
Page({
data: {
inputText: '',
loading: false,
summary: null,
items: []
},
formatPercent(value, digits = 2) {
const num = Number(value || 0)
return `${(num * 100).toFixed(digits)}%`
},
onInput(e) {
this.setData({ inputText: e.detail.value || '' })
},
parseLines() {
return (this.data.inputText || '')
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length >= 2)
},
async submit() {
if (this.data.loading) return
const items = this.parseLines()
if (!items.length) {
wx.showToast({ title: '请至少输入一条有效文本', icon: 'none' })
return
}
this.setData({ loading: true })
try {
const data = await request({
url: '/spam/predict/batch',
method: 'POST',
data: { items }
})
const summary = {
...(data.summary || {}),
spam_ratio_text: this.formatPercent((data.summary || {}).spam_ratio, 2),
blocked_ratio_text: this.formatPercent((data.summary || {}).blocked_ratio, 2)
}
const normalizedItems = (data.items || []).map((item) => ({
...item,
confidence_text: this.formatPercent(item.confidence, 2)
}))
this.setData({ summary, items: normalizedItems })
} finally {
this.setData({ loading: false })
}
},
fillDemo() {
this.setData({
inputText: [
'点击链接领取购物补贴,名额有限。',
'明天下午三点上线前演练。',
'高薪兼职日结,扫码进群。',
'测试报告我已经同步到项目群。'
].join('\n')
})
}
})

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "批量识别"
}

View File

@@ -0,0 +1,74 @@
<view class="container">
<view class="hero fade-up">
<view class="hero-badge">BATCH SCAN</view>
<view class="hero-title">批量文本筛查</view>
<view class="hero-sub">每行一条文本,适用于活动文案、客服话术、私信模板的集中检测。</view>
</view>
<view class="card fade-up fade-up-delay-1">
<view class="card-title">批量输入</view>
<view class="card-desc">请按“每行一条”粘贴文本内容,系统会自动跳过空行。</view>
<textarea class="textarea" placeholder="示例:&#10;点击链接领取红包&#10;今天下午三点开会" value="{{inputText}}" bindinput="onInput" />
<view class="btn-row">
<button class="btn btn-ghost" bindtap="fillDemo">填充示例</button>
<button class="btn btn-primary" loading="{{loading}}" bindtap="submit">开始识别</button>
</view>
</view>
<view class="card fade-up fade-up-delay-2" wx:if="{{summary}}">
<view class="card-title">识别汇总</view>
<view class="grid-3">
<view class="kpi">
<view class="kpi-value">{{summary.total}}</view>
<view class="kpi-label">总条数</view>
</view>
<view class="kpi">
<view class="kpi-value">{{summary.spam_count}}</view>
<view class="kpi-label">垃圾信息</view>
</view>
<view class="kpi">
<view class="kpi-value">{{summary.ham_count}}</view>
<view class="kpi-label">正常信息</view>
</view>
</view>
<view class="field">
<view class="row">
<text class="label">垃圾占比</text>
<text class="value">{{summary.spam_ratio_text}}</text>
</view>
<view class="progress-track">
<view class="progress-fill" style="width: {{summary.spam_ratio_text}};"></view>
</view>
</view>
<view class="field">
<view class="row">
<text class="label">拦截占比</text>
<text class="value">{{summary.blocked_ratio_text}}</text>
</view>
<view class="progress-track">
<view class="progress-fill-safe" style="width: {{summary.blocked_ratio_text}};"></view>
</view>
</view>
</view>
<view class="card fade-up fade-up-delay-3" wx:if="{{items.length}}">
<view class="card-title">明细结果</view>
<view class="list-item" wx:for="{{items}}" wx:key="index">
<view class="item-title">{{item.text}}</view>
<view class="row">
<text class="label">判定结果</text>
<text class="{{item.prediction === 'spam' ? 'status-spam' : 'status-ham'}}">{{item.prediction_text}}</text>
</view>
<view class="row">
<text class="label">置信度</text>
<text class="value">{{item.confidence_text}}</text>
</view>
<view class="progress-track">
<view class="{{item.prediction === 'spam' ? 'progress-fill' : 'progress-fill-safe'}}" style="width: {{item.confidence_text}};"></view>
</view>
</view>
</view>
</view>

View File

@@ -0,0 +1 @@
/* batch styles use global theme */

View File

@@ -0,0 +1,102 @@
const { request } = require('../../utils/request')
const QUICK_TEXTS = [
'大家好,今晚 8 点社区线上读书会,欢迎参加。',
'恭喜中奖领取大额现金,点击链接立即到账。',
'本周活动报名已开放,请在群里接龙。',
'高薪兼职日结,扫码进群立刻赚钱。'
]
const VISIBILITY_OPTIONS = [
{ value: 'public', label: '公开信息发布' },
{ value: 'private', label: '私有信息发布' },
{ value: 'direct', label: '用户私信发布' }
]
Page({
data: {
text: '',
loading: false,
result: null,
quickTexts: QUICK_TEXTS,
visibilityOptions: VISIBILITY_OPTIONS,
visibilityIndex: 0,
visibility: 'public',
recipientUsername: ''
},
formatPercent(value, digits = 2) {
const num = Number(value || 0)
return `${(num * 100).toFixed(digits)}%`
},
onInput(e) {
const field = e.currentTarget.dataset.field
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 {
this.setData({ loading: false })
}
},
gotoHistory() {
wx.navigateTo({ url: '/pages/history/index' })
}
})

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "文本发布"
}

View File

@@ -0,0 +1,84 @@
<view class="container">
<view class="hero fade-up">
<view class="hero-badge">REAL-TIME CHECK</view>
<view class="hero-title">文本信息发布</view>
<view class="hero-sub">支持公开发布、私有发布与用户私信,提交时自动执行垃圾信息识别。</view>
</view>
<view class="card fade-up fade-up-delay-1">
<view class="card-title">发布内容</view>
<view class="field">
<text class="field-label">内容文本</text>
<textarea class="textarea" placeholder="请输入要发布的文本信息" value="{{text}}" data-field="text" bindinput="onInput" />
<view class="field-help">当前字数:{{text.length}},建议不少于 2 个字符。</view>
</view>
<view class="field">
<view class="row">
<text class="field-label">发布类型</text>
<picker mode="selector" range="{{visibilityOptions}}" range-key="label" value="{{visibilityIndex}}" bindchange="onVisibilityChange">
<view class="picker-value">{{visibilityOptions[visibilityIndex].label}}</view>
</picker>
</view>
</view>
<view class="field" wx:if="{{visibility === 'direct'}}">
<text class="field-label">接收人用户名</text>
<input class="input" placeholder="私信发送时必填" value="{{recipientUsername}}" data-field="recipientUsername" bindinput="onInput" />
</view>
<button class="btn btn-primary" loading="{{loading}}" bindtap="publish">提交发布</button>
</view>
<view class="card fade-up fade-up-delay-2">
<view class="card-title">快捷示例</view>
<view class="chip-group">
<view class="chip" wx:for="{{quickTexts}}" wx:key="*this" data-text="{{item}}" bindtap="fillQuick">{{item}}</view>
</view>
</view>
<view class="card fade-up fade-up-delay-3" wx:if="{{result}}">
<view class="card-title">识别反馈</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">
<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" wx:for="{{result.detect.reason_tokens}}" wx:key="*this">{{item}}</text>
</view>
</view>
<button class="btn btn-ghost" bindtap="gotoHistory">查看发布历史</button>
</view>
</view>

View File

@@ -0,0 +1 @@
/* detect styles use global theme */

View File

@@ -0,0 +1,86 @@
const { request } = require('../../utils/request')
Page({
data: {
form: {
weight: '',
body_fat: '',
exercise_kcal: '',
intake_kcal: '',
sleep_hours: '7',
note: ''
},
latestId: null,
loading: false
},
onShow() {
this.loadLatest()
},
async loadLatest() {
try {
const latest = await request({ url: '/diet/status/latest' })
if (latest && latest.id) {
this.setData({
latestId: latest.id,
form: {
weight: String(latest.weight),
body_fat: String(latest.body_fat),
exercise_kcal: String(latest.exercise_kcal),
intake_kcal: String(latest.intake_kcal),
sleep_hours: String(latest.sleep_hours || 7),
note: latest.note || ''
}
})
}
} catch (err) {
// ignore
}
},
onInput(e) {
const field = e.currentTarget.dataset.field
this.setData({ [`form.${field}`]: e.detail.value })
},
buildPayload() {
const f = this.data.form
return {
weight: Number(f.weight),
body_fat: Number(f.body_fat),
exercise_kcal: Number(f.exercise_kcal),
intake_kcal: Number(f.intake_kcal),
sleep_hours: Number(f.sleep_hours),
note: f.note
}
},
async saveNew() {
if (this.data.loading) return
this.setData({ loading: true })
try {
await request({ url: '/diet/status', method: 'POST', data: this.buildPayload() })
wx.showToast({ title: '已新增状态', icon: 'success' })
this.loadLatest()
} catch (err) {
// handled
} finally {
this.setData({ loading: false })
}
},
async updateLatest() {
if (this.data.loading) return
this.setData({ loading: true })
try {
await request({ url: '/diet/status/latest', method: 'PUT', data: this.buildPayload() })
wx.showToast({ title: '已更新最新状态', icon: 'success' })
this.loadLatest()
} catch (err) {
// handled
} finally {
this.setData({ loading: false })
}
}
})

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "Diet Status"
}

View File

@@ -0,0 +1,19 @@
<view class="container">
<view class="hero">
<view class="hero-title">编辑当前饮食状态</view>
<view class="hero-sub">用于随机森林推荐与历史回溯</view>
</view>
<view class="card">
<view class="card-title">今日状态</view>
<input class="input" data-field="weight" type="digit" placeholder="当前体重(kg)" value="{{form.weight}}" bindinput="onInput" />
<input class="input" data-field="body_fat" type="digit" placeholder="体脂率(%)" value="{{form.body_fat}}" bindinput="onInput" />
<input class="input" data-field="exercise_kcal" type="digit" placeholder="每天运动消耗(kcal)" value="{{form.exercise_kcal}}" bindinput="onInput" />
<input class="input" data-field="intake_kcal" type="digit" placeholder="每天饮食摄入(kcal)" value="{{form.intake_kcal}}" bindinput="onInput" />
<input class="input" data-field="sleep_hours" type="digit" placeholder="睡眠时长(小时)" value="{{form.sleep_hours}}" bindinput="onInput" />
<textarea class="input textarea" data-field="note" placeholder="备注(可选)" value="{{form.note}}" bindinput="onInput" />
<button class="btn btn-primary" loading="{{loading}}" bindtap="saveNew">新增今日记录</button>
<button class="btn btn-ghost" loading="{{loading}}" bindtap="updateLatest">修改最新记录</button>
</view>
</view>

View File

@@ -0,0 +1,8 @@
page {
animation: slideIn 260ms ease;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(8rpx); }
to { opacity: 1; transform: translateY(0); }
}

View File

@@ -0,0 +1,130 @@
const { request } = require('../../utils/request')
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: '已驳回'
}
Page({
data: {
loading: false,
list: [],
statusOptions: STATUS_OPTIONS,
statusIndex: 0,
visibilityOptions: VISIBILITY_OPTIONS,
visibilityIndex: 0,
appealPostId: null,
appealReason: ''
},
formatPercent(value, digits = 2) {
const num = Number(value || 0)
return `${(num * 100).toFixed(digits)}%`
},
onShow() {
this.fetchList()
},
onPullDownRefresh() {
this.fetchList(true)
},
onStatusChange(e) {
this.setData({ statusIndex: Number(e.detail.value || 0) })
this.fetchList()
},
onVisibilityChange(e) {
this.setData({ visibilityIndex: Number(e.detail.value || 0) })
this.fetchList()
},
async fetchList(fromPullDown = false) {
this.setData({ loading: true })
try {
const status = this.data.statusOptions[this.data.statusIndex].value
const visibility = this.data.visibilityOptions[this.data.visibilityIndex].value
const query = `status=${encodeURIComponent(status)}&visibility=${encodeURIComponent(visibility)}&page=1&page_size=80`
const data = await request({ url: `/content/posts/history?${query}` })
const 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
}))
this.setData({ list })
} finally {
this.setData({ loading: false })
if (fromPullDown) wx.stopPullDownRefresh()
}
},
startAppeal(e) {
const postId = Number(e.currentTarget.dataset.id)
this.setData({ appealPostId: postId, appealReason: '' })
},
onAppealInput(e) {
this.setData({ appealReason: e.detail.value || '' })
},
cancelAppeal() {
this.setData({ appealPostId: null, appealReason: '' })
},
async submitAppeal() {
const postId = this.data.appealPostId
if (!postId) return
const reason = (this.data.appealReason || '').trim()
if (reason.length < 2) {
wx.showToast({ title: '申诉理由至少 2 个字符', icon: 'none' })
return
}
await request({
url: `/content/posts/${postId}/appeal`,
method: 'POST',
data: { reason }
})
wx.showToast({ title: '申诉提交成功', icon: 'success' })
this.setData({ appealPostId: null, appealReason: '' })
this.fetchList()
},
removeItem(e) {
const id = Number(e.currentTarget.dataset.id)
wx.showModal({
title: '删除确认',
content: '确定删除这条发布记录吗?',
success: async (res) => {
if (!res.confirm) return
await request({ url: `/content/posts/${id}`, method: 'DELETE' })
wx.showToast({ title: '删除成功', icon: 'success' })
this.fetchList()
}
})
}
})

View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "发布历史",
"enablePullDownRefresh": true
}

View File

@@ -0,0 +1,78 @@
<view class="container">
<view class="hero fade-up">
<view class="hero-badge">PUBLISH HISTORY</view>
<view class="hero-title">个人发布历史</view>
<view class="hero-sub">查看公开/私有/私信发布结果,被拦截内容可在线提交申诉。</view>
</view>
<view class="card fade-up fade-up-delay-1">
<view class="card-title">筛选条件</view>
<view class="row">
<text class="label">发布状态</text>
<picker mode="selector" range="{{statusOptions}}" range-key="label" value="{{statusIndex}}" bindchange="onStatusChange">
<view class="picker-value">{{statusOptions[statusIndex].label}}</view>
</picker>
</view>
<view class="row">
<text class="label">发布类型</text>
<picker mode="selector" range="{{visibilityOptions}}" range-key="label" value="{{visibilityIndex}}" bindchange="onVisibilityChange">
<view class="picker-value">{{visibilityOptions[visibilityIndex].label}}</view>
</picker>
</view>
</view>
<view class="card fade-up fade-up-delay-2" wx:if="{{list.length}}">
<view class="card-title">历史记录</view>
<view class="list-item" wx:for="{{list}}" wx:key="id">
<view class="item-title">{{item.text}}</view>
<view class="item-sub">类型:{{item.visibility_text}} · 时间:{{item.created_text}}</view>
<view class="row">
<text class="label">发布状态</text>
<text class="{{item.status === 'blocked' ? 'status-spam' : 'status-ham'}}">{{item.status === 'blocked' ? '已拦截' : '已发布'}}</text>
</view>
<view class="row">
<text class="label">垃圾概率</text>
<text class="value">{{item.spam_probability_text}}</text>
</view>
<view class="progress-track">
<view class="progress-fill" style="width: {{item.spam_probability_text}};"></view>
</view>
<view class="row">
<text class="label">申诉状态</text>
<text class="value">{{item.appeal_status_text}}</text>
</view>
<view class="field" wx:if="{{item.reason_tokens && item.reason_tokens.length}}">
<text class="field-label">风险关键词</text>
<view class="chip-group">
<text class="tag" wx:for="{{item.reason_tokens}}" wx:key="*this">{{item}}</text>
</view>
</view>
<view wx:if="{{item.status === 'blocked' && item.appeal_status !== 'pending' && appealPostId !== item.id}}">
<button class="btn btn-accent" data-id="{{item.id}}" bindtap="startAppeal">发起申诉</button>
</view>
<view wx:if="{{appealPostId === item.id}}">
<textarea class="textarea" placeholder="请输入申诉理由(至少 2 个字符)" value="{{appealReason}}" bindinput="onAppealInput" />
<view class="btn-row">
<button class="btn btn-primary" bindtap="submitAppeal">提交申诉</button>
<button class="btn btn-ghost" bindtap="cancelAppeal">取消</button>
</view>
</view>
<button class="btn btn-ghost" data-id="{{item.id}}" bindtap="removeItem">删除记录</button>
</view>
</view>
<view class="card fade-up fade-up-delay-2" wx:if="{{!list.length}}">
<view class="empty">暂无发布记录,先去“信息发布”页面提交文本。</view>
</view>
</view>

View File

@@ -0,0 +1 @@
/* history styles use global theme */

View File

@@ -0,0 +1,67 @@
const { request } = require('../../utils/request')
const USER_MODULES = [
{ name: '信息发布', desc: '发布公开 / 私有 / 私信文本并实时检测', tag: '发布检测', path: '/pages/detect/index' },
{ 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 = [
{ name: '运营看板', desc: '监控发布、拦截、样本和模型状态', tag: '数据概览', path: '/pages/admin-dashboard/index' },
{ name: '复核与申诉', desc: '处理拦截复核和用户申诉', tag: '审核处理', path: '/pages/admin-review/index' },
{ name: '样本管理', desc: '维护训练样本并触发模型重训', tag: '模型迭代', path: '/pages/admin-samples/index' },
{ name: '用户管理', desc: '编辑用户信息和权限', tag: '权限管理', path: '/pages/admin-users/index' }
]
Page({
data: {
loading: true,
user: null,
modelInfo: null,
threshold: null,
thresholdText: '--',
userModules: USER_MODULES,
adminModules: ADMIN_MODULES
},
onShow() {
this.bootstrap()
},
async bootstrap() {
const app = getApp()
if (!app.globalData.token) {
wx.reLaunch({ url: '/pages/login/index' })
return
}
this.setData({ loading: true })
try {
const [user, modelInfo] = await Promise.all([
request({ url: '/auth/me' }),
request({ url: '/spam/model/info' })
])
app.globalData.user = user
wx.setStorageSync('user', user)
const threshold = modelInfo.threshold || null
const thresholdText = threshold === null || threshold === undefined ? '--' : `${(Number(threshold) * 100).toFixed(1)}%`
this.setData({ user, modelInfo, threshold, thresholdText })
} finally {
this.setData({ loading: false })
}
},
goto(e) {
const path = e.currentTarget.dataset.path
if (!path) return
wx.navigateTo({ url: path })
},
logout() {
getApp().clearAuth()
wx.reLaunch({ url: '/pages/login/index' })
}
})

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "工作台"
}

View File

@@ -0,0 +1,60 @@
<view class="container">
<view class="hero fade-up">
<view class="hero-badge">CONTROL CENTER</view>
<view class="hero-title">{{user ? ('欢迎,' + user.nickname) : '社区内容风控工作台'}}</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 class="card fade-up fade-up-delay-1" wx:if="{{modelInfo}}">
<view class="card-title">检测引擎状态</view>
<view class="grid-2">
<view class="kpi">
<view class="kpi-label">模型版本</view>
<view class="kpi-value">{{modelInfo.version || '未训练'}}</view>
</view>
<view class="kpi">
<view class="kpi-label">训练样本</view>
<view class="kpi-value">{{modelInfo.sample_count || 0}}</view>
</view>
<view class="kpi">
<view class="kpi-label">垃圾阈值</view>
<view class="kpi-value">{{thresholdText}}</view>
</view>
<view class="kpi">
<view class="kpi-label">最近训练</view>
<view class="kpi-value small">{{modelInfo.trained_at || '--'}}</view>
</view>
</view>
</view>
<view class="card fade-up fade-up-delay-2">
<view class="card-title">用户功能</view>
<view class="card-desc">常用操作入口,覆盖发布、检测、历史和账号设置。</view>
<view class="grid-2">
<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="module-desc">{{item.desc}}</view>
<view class="module-tag">{{item.tag}}</view>
</view>
</view>
</view>
<view class="card fade-up fade-up-delay-3" wx:if="{{user && user.is_admin}}">
<view class="card-title">管理员功能</view>
<view class="card-desc">支持阈值调节、复核处理、样本维护和用户管理。</view>
<view class="grid-2">
<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>
<button class="btn btn-ghost fade-up fade-up-delay-3" bindtap="logout">退出登录</button>
</view>

View File

@@ -0,0 +1 @@
/* home styles use global theme */

View File

@@ -0,0 +1,31 @@
const { request } = require('../../utils/request')
Page({
data: {
loading: false,
list: []
},
onShow() {
this.fetchList()
},
onPullDownRefresh() {
this.fetchList(true)
},
async fetchList(fromPullDown = false) {
this.setData({ loading: true })
try {
const data = await request({ url: '/content/posts/inbox?page=1&page_size=80' })
const list = (data.items || []).map((item) => ({
...item,
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19)
}))
this.setData({ list })
} finally {
this.setData({ loading: false })
if (fromPullDown) wx.stopPullDownRefresh()
}
}
})

View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "收件箱",
"enablePullDownRefresh": true
}

View File

@@ -0,0 +1,23 @@
<view class="container">
<view class="hero fade-up">
<view class="hero-badge">INBOX</view>
<view class="hero-title">用户私信收件箱</view>
<view class="hero-sub">仅展示通过检测后成功送达的私信内容。</view>
</view>
<view class="card fade-up fade-up-delay-1" wx:if="{{list.length}}">
<view class="card-title">私信列表</view>
<view class="list-item" wx:for="{{list}}" wx:key="id">
<view class="item-title">{{item.text}}</view>
<view class="item-sub">发送人:{{item.nickname || item.username}}{{item.username}}</view>
<view class="row">
<text class="label">发送时间</text>
<text class="value">{{item.created_text}}</text>
</view>
</view>
</view>
<view class="card fade-up fade-up-delay-1" wx:if="{{!list.length}}">
<view class="empty">暂无私信内容。</view>
</view>
</view>

View File

@@ -0,0 +1 @@
/* inbox styles use global theme */

View File

@@ -0,0 +1,52 @@
const { request } = require('../../utils/request')
Page({
data: {
username: '',
password: '',
loading: false
},
onLoad() {
const app = getApp()
if (app.globalData.token) {
wx.reLaunch({ url: '/pages/home/index' })
}
},
onInput(e) {
const field = e.currentTarget.dataset.field
this.setData({ [field]: (e.detail.value || '').trim() })
},
fillDemo() {
this.setData({ username: 'admin', password: 'Admin@123456' })
},
async submit() {
if (this.data.loading) return
const { username, password } = this.data
if (!username || !password) {
wx.showToast({ title: '请输入用户名和密码', icon: 'none' })
return
}
this.setData({ loading: true })
try {
const data = await request({
url: '/auth/login',
method: 'POST',
data: { username, password }
})
getApp().setAuth(data.token, data.user)
wx.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => wx.reLaunch({ url: '/pages/home/index' }), 250)
} finally {
this.setData({ loading: false })
}
},
goRegister() {
wx.navigateTo({ url: '/pages/register/index' })
}
})

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "登录"
}

View File

@@ -0,0 +1,34 @@
<view class="container">
<view class="hero fade-up">
<view class="hero-badge">SECURE ACCESS</view>
<view class="hero-title">垃圾信息识别平台</view>
<view class="hero-sub">登录后即可使用文本检测、发布拦截、申诉处理和管理看板能力。</view>
<view class="hero-meta">
<text class="hero-metric">实时风控</text>
<text class="hero-metric">闭环审核</text>
<text class="hero-metric">模型迭代</text>
</view>
</view>
<view class="card fade-up fade-up-delay-1">
<view class="card-title">账号登录</view>
<view class="card-desc">请输入用户名和密码,进入你的工作台。</view>
<view class="field">
<text class="field-label">用户名</text>
<input class="input" placeholder="请输入用户名" value="{{username}}" data-field="username" bindinput="onInput" />
</view>
<view class="field">
<text class="field-label">密码</text>
<input class="input" placeholder="请输入密码" password value="{{password}}" data-field="password" bindinput="onInput" />
</view>
<button class="btn btn-primary pulse" loading="{{loading}}" bindtap="submit">立即登录</button>
<view class="btn-row">
<button class="btn btn-ghost" bindtap="fillDemo">填充演示账号</button>
<button class="btn btn-accent" bindtap="goRegister">注册新账号</button>
</view>
</view>
</view>

View File

@@ -0,0 +1 @@
/* login styles use global theme */

View File

@@ -0,0 +1,61 @@
const { request } = require('../../utils/request')
Page({
data: {
loading: false,
form: {
nickname: '',
company: '',
title: '',
phone: '',
password: ''
}
},
onShow() {
this.loadProfile()
},
async loadProfile() {
const profile = await request({ url: '/user/profile' })
this.setData({
form: {
nickname: profile.nickname || '',
company: profile.company || '',
title: profile.title || '',
phone: profile.phone || '',
password: ''
}
})
},
onInput(e) {
const field = e.currentTarget.dataset.field
this.setData({ [`form.${field}`]: (e.detail.value || '').trim() })
},
async save() {
if (this.data.loading) return
this.setData({ loading: true })
const payload = { ...this.data.form }
if (!payload.password) {
delete payload.password
}
try {
const user = await request({
url: '/user/profile',
method: 'PUT',
data: payload
})
const app = getApp()
app.globalData.user = user
wx.setStorageSync('user', user)
wx.showToast({ title: '保存成功', icon: 'success' })
this.setData({ 'form.password': '' })
} finally {
this.setData({ loading: false })
}
}
})

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "个人中心"
}

View File

@@ -0,0 +1,39 @@
<view class="container">
<view class="hero fade-up">
<view class="hero-badge">PROFILE</view>
<view class="hero-title">个人资料设置</view>
<view class="hero-sub">完善你的身份信息,便于审计追踪和团队协同。</view>
</view>
<view class="card fade-up fade-up-delay-1">
<view class="card-title">账号资料</view>
<view class="field">
<text class="field-label">昵称</text>
<input class="input" placeholder="请输入昵称" value="{{form.nickname}}" data-field="nickname" bindinput="onInput" />
</view>
<view class="field">
<text class="field-label">公司</text>
<input class="input" placeholder="请输入公司名称" value="{{form.company}}" data-field="company" bindinput="onInput" />
</view>
<view class="field">
<text class="field-label">岗位</text>
<input class="input" placeholder="请输入岗位" value="{{form.title}}" data-field="title" bindinput="onInput" />
</view>
<view class="field">
<text class="field-label">手机号</text>
<input class="input" placeholder="请输入手机号" value="{{form.phone}}" data-field="phone" bindinput="onInput" />
</view>
<view class="field">
<text class="field-label">新密码</text>
<input class="input" placeholder="留空表示不修改" password value="{{form.password}}" data-field="password" bindinput="onInput" />
<view class="field-help">密码长度需不少于 6 位。</view>
</view>
<button class="btn btn-primary" loading="{{loading}}" bindtap="save">保存资料</button>
</view>
</view>

View File

@@ -0,0 +1 @@
/* profile styles use global theme */

View File

@@ -0,0 +1,75 @@
const { request } = require('../../utils/request')
Page({
data: {
question: '',
answer: '',
provider: 'fastgpt',
mode: 'auto',
hits: [],
adviceType: 'general',
loading: false,
providerOptions: ['fastgpt', 'dify', 'local'],
modeOptions: ['auto', 'local', 'llm'],
adviceOptions: ['general', 'gain_muscle', 'lose_fat', 'keto', 'nutritionist']
},
onInput(e) {
const field = e.currentTarget.dataset.field
this.setData({ [field]: e.detail.value })
},
onPickerChange(e) {
const field = e.currentTarget.dataset.field
const options = this.data[`${field}Options`]
this.setData({ [field]: options[e.detail.value] })
},
async askQuestion() {
if (!this.data.question.trim()) {
wx.showToast({ title: '请输入问题', icon: 'none' })
return
}
this.setData({ loading: true })
try {
const provider = this.data.provider === 'local' ? 'fastgpt' : this.data.provider
const mode = this.data.provider === 'local' ? 'local' : this.data.mode
const data = await request({
url: '/qa/ask',
method: 'POST',
data: {
question: this.data.question,
provider,
mode
}
})
this.setData({ answer: data.answer, hits: data.items || [] })
} catch (err) {
// handled
} finally {
this.setData({ loading: false })
}
},
async getAdvice() {
this.setData({ loading: true })
try {
const provider = this.data.provider === 'local' ? 'dify' : this.data.provider
const data = await request({
url: '/qa/advice',
method: 'POST',
data: {
advice_type: this.data.adviceType,
question: this.data.question || '请给我本周饮食建议',
provider
}
})
this.setData({ answer: data.answer, hits: data.knowledge_hits || [] })
} catch (err) {
// handled
} finally {
this.setData({ loading: false })
}
}
})

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "营养问答"
}

View File

@@ -0,0 +1,49 @@
<view class="container">
<view class="hero">
<view class="hero-title">营养学科普问答</view>
<view class="hero-sub">RAG 本地知识库 + FastGPT / Dify 工作流</view>
</view>
<view class="card">
<view class="card-title">问答配置</view>
<textarea class="input textarea" data-field="question" placeholder="输入问题,如:减脂期晚餐怎么吃?" value="{{question}}" bindinput="onInput" />
<view class="row">
<text class="label">模型来源</text>
<picker mode="selector" range="{{providerOptions}}" data-field="provider" bindchange="onPickerChange">
<text class="value">{{provider}}</text>
</picker>
</view>
<view class="row">
<text class="label">问答模式</text>
<picker mode="selector" range="{{modeOptions}}" data-field="mode" bindchange="onPickerChange">
<text class="value">{{mode}}</text>
</picker>
</view>
<view class="row">
<text class="label">功能化建议</text>
<picker mode="selector" range="{{adviceOptions}}" data-field="adviceType" bindchange="onPickerChange">
<text class="value">{{adviceType}}</text>
</picker>
</view>
<button class="btn btn-primary" loading="{{loading}}" bindtap="askQuestion">提问(科普问答)</button>
<button class="btn btn-accent" loading="{{loading}}" bindtap="getAdvice">生成个性化建议</button>
</view>
<view class="card" wx:if="{{answer}}">
<view class="card-title">回答结果</view>
<view class="item-sub">{{answer}}</view>
</view>
<view class="card" wx:if="{{hits && hits.length}}">
<view class="card-title">RAG 命中文档</view>
<view class="item" wx:for="{{hits}}" wx:key="index">
<view class="item-title">{{item.question}}</view>
<view class="item-sub">{{item.answer}}</view>
<view class="item-sub">score: {{item.score}} | source: {{item.source}}</view>
</view>
</view>
</view>

View File

@@ -0,0 +1,3 @@
.card .row {
margin-top: 8rpx;
}

View File

@@ -0,0 +1,187 @@
const { request } = require('../../utils/request')
const REC_TYPES = [
{
key: 'current',
label: '当前状态推荐',
desc: '基于你最近一次饮食状态,用随机森林给出当前最匹配的食谱。',
scene: '日常实时决策',
requirement: '建议先填写今天的体重、体脂、摄入和消耗'
},
{
key: 'health',
label: '健康状态推荐',
desc: '按体脂、摄入和运动平衡进行规则评分,优先推荐营养结构更均衡的方案。',
scene: '想吃得更健康',
requirement: '推荐先有基础饮食状态数据'
},
{
key: 'history',
label: '历史计划推荐',
desc: '根据近一段时间的体重和摄入趋势,给出偏减脂或偏增肌的策略型推荐。',
scene: '需要阶段性调整',
requirement: '至少有 3 天历史记录'
},
{
key: 'goal',
label: '未来目标推荐',
desc: '围绕你的目标(减脂/增肌/生酮/维持)进行宏量营养匹配。',
scene: '目标导向饮食',
requirement: '可跟随个人资料,也可手动指定目标'
},
{
key: 'occupation',
label: '职业行业推荐',
desc: '根据职业活动强度和作息特点,推荐更贴近日常能量需求的食谱。',
scene: '工作日场景优化',
requirement: '可跟随个人资料,也可手动指定职业'
}
]
const TOP_K_OPTIONS = [
{ value: 3, label: '3 条' },
{ value: 5, label: '5 条' },
{ value: 10, label: '10 条' }
]
const GOAL_OPTIONS = [
{ value: '', label: '跟随个人资料(推荐)' },
{ value: 'lose_fat', label: '减脂lose_fat' },
{ value: 'gain_muscle', label: '增肌gain_muscle' },
{ value: 'keto', label: '生酮keto' },
{ value: 'maintain', label: '维持maintain' }
]
const OCCUPATION_OPTIONS = [
{ value: '', label: '跟随个人资料(推荐)' },
{ value: 'office', label: '办公室office' },
{ value: 'developer', label: '程序员developer' },
{ value: 'fitness', label: '健身行业fitness' },
{ value: 'teacher', label: '教师teacher' },
{ value: 'student', label: '学生student' },
{ value: 'manual', label: '体力劳动manual' }
]
Page({
data: {
recType: 'current',
recTypes: REC_TYPES,
activeType: REC_TYPES[0],
selectedLabel: REC_TYPES[0].label,
topK: 5,
topKLabel: '5 条',
topKOptions: TOP_K_OPTIONS,
goalValue: '',
goalLabel: GOAL_OPTIONS[0].label,
goalOptions: GOAL_OPTIONS,
occupationValue: '',
occupationLabel: OCCUPATION_OPTIONS[0].label,
occupationOptions: OCCUPATION_OPTIONS,
loading: false,
payload: null,
summary: null,
errorText: '',
lastUpdatedAt: ''
},
onShow() {
this.fetchRecommend()
},
onTypeChange(e) {
const idx = Number(e.detail.value)
const item = this.data.recTypes[idx]
if (!item) return
this.setData({
recType: item.key,
selectedLabel: item.label,
activeType: item
})
this.fetchRecommend()
},
onTopKChange(e) {
const idx = Number(e.detail.value)
const item = this.data.topKOptions[idx]
if (!item) return
this.setData({ topK: item.value, topKLabel: item.label })
this.fetchRecommend()
},
onGoalChange(e) {
const idx = Number(e.detail.value)
const item = this.data.goalOptions[idx]
if (!item) return
this.setData({ goalValue: item.value, goalLabel: item.label })
this.fetchRecommend()
},
onOccupationChange(e) {
const idx = Number(e.detail.value)
const item = this.data.occupationOptions[idx]
if (!item) return
this.setData({ occupationValue: item.value, occupationLabel: item.label })
this.fetchRecommend()
},
buildUrl() {
const topK = this.data.topK || 5
const type = this.data.recType
if (type === 'current') return `/recommend/current?top_k=${topK}`
if (type === 'health') return `/recommend/health?top_k=${topK}`
if (type === 'history') return `/recommend/plan/history?top_k=${topK}`
if (type === 'goal') {
const extra = this.data.goalValue ? `&goal=${encodeURIComponent(this.data.goalValue)}` : ''
return `/recommend/plan/goal?top_k=${topK}${extra}`
}
const occ = this.data.occupationValue ? `&occupation=${encodeURIComponent(this.data.occupationValue)}` : ''
return `/recommend/plan/occupation?top_k=${topK}${occ}`
},
buildSummary(items = []) {
if (!items.length) return null
const totalCalories = items.reduce((sum, row) => sum + Number(row.calories || 0), 0)
const totalProtein = items.reduce((sum, row) => sum + Number(row.protein || 0), 0)
return {
count: items.length,
avgCalories: Math.round((totalCalories / items.length) * 10) / 10,
avgProtein: Math.round((totalProtein / items.length) * 10) / 10
}
},
formatNow() {
const d = new Date()
const pad = (n) => String(n).padStart(2, '0')
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
},
async fetchRecommend() {
this.setData({ loading: true, errorText: '' })
try {
const payload = await request({ url: this.buildUrl() })
const items = payload && Array.isArray(payload.items) ? payload.items : []
const normalizedItems = items.map((row, idx) => {
const score = Number(row.rf_score ?? row.score ?? 0)
return {
...row,
rank: idx + 1,
displayScore: score.toFixed(1)
}
})
this.setData({
payload: { ...payload, items: normalizedItems },
summary: this.buildSummary(normalizedItems),
lastUpdatedAt: this.formatNow(),
errorText: ''
})
} catch (err) {
this.setData({
payload: null,
summary: null,
errorText: (err && err.message) || '推荐请求失败,请稍后重试'
})
} finally {
this.setData({ loading: false })
}
}
})

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "饮食推荐"
}

View File

@@ -0,0 +1,104 @@
<view class="container">
<view class="hero">
<view class="hero-title">饮食推荐管理</view>
<view class="hero-sub">更清晰的推荐类型说明和结果解读,帮助你快速做决策</view>
</view>
<view class="card">
<view class="card-title">推荐参数</view>
<view class="row">
<text class="label">推荐类型</text>
<picker mode="selector" range="{{recTypes}}" range-key="label" bindchange="onTypeChange">
<text class="value">{{selectedLabel}}</text>
</picker>
</view>
<view class="type-intro" wx:if="{{activeType}}">
<view class="type-intro-title">{{activeType.label}}</view>
<view class="type-intro-text">{{activeType.desc}}</view>
<view class="type-intro-sub">适用场景:{{activeType.scene}}</view>
<view class="type-intro-sub">数据要求:{{activeType.requirement}}</view>
</view>
<view class="row">
<text class="label">推荐数量</text>
<picker mode="selector" range="{{topKOptions}}" range-key="label" bindchange="onTopKChange">
<text class="value">{{topKLabel}}</text>
</picker>
</view>
<view class="row" wx:if="{{recType === 'goal'}}">
<text class="label">目标设定</text>
<picker mode="selector" range="{{goalOptions}}" range-key="label" bindchange="onGoalChange">
<text class="value">{{goalLabel}}</text>
</picker>
</view>
<view class="row" wx:if="{{recType === 'occupation'}}">
<text class="label">职业设定</text>
<picker mode="selector" range="{{occupationOptions}}" range-key="label" bindchange="onOccupationChange">
<text class="value">{{occupationLabel}}</text>
</picker>
</view>
<view class="selection-tags">
<text class="selection-tag">类型:{{selectedLabel}}</text>
<text class="selection-tag">数量:{{topKLabel}}</text>
<text class="selection-tag" wx:if="{{recType === 'goal'}}">目标:{{goalLabel}}</text>
<text class="selection-tag" wx:if="{{recType === 'occupation'}}">职业:{{occupationLabel}}</text>
</view>
<button class="btn btn-primary" loading="{{loading}}" bindtap="fetchRecommend">刷新推荐</button>
</view>
<view class="card" wx:if="{{errorText}}">
<view class="card-title">提示</view>
<view class="warn">{{errorText}}</view>
</view>
<view class="card" wx:if="{{payload}}">
<view class="card-title">推荐结果</view>
<view class="muted" wx:if="{{payload.strategy}}">策略:{{payload.strategy}}</view>
<view class="muted" wx:if="{{lastUpdatedAt}}">更新时间:{{lastUpdatedAt}}</view>
<view class="summary-grid" wx:if="{{summary}}">
<view class="summary-item">
<view class="summary-label">命中结果</view>
<view class="summary-value">{{summary.count}} 条</view>
</view>
<view class="summary-item">
<view class="summary-label">平均热量</view>
<view class="summary-value">{{summary.avgCalories}} kcal</view>
</view>
<view class="summary-item">
<view class="summary-label">平均蛋白</view>
<view class="summary-value">{{summary.avgProtein}} g</view>
</view>
</view>
<view class="metrics-tags" wx:if="{{payload.metrics}}">
<text class="metric-tag">平均体重 {{payload.metrics.avg_weight}} kg</text>
<text class="metric-tag">体重趋势 {{payload.metrics.weight_trend}} kg</text>
<text class="metric-tag">平均摄入 {{payload.metrics.avg_intake}} kcal</text>
<text class="metric-tag">平均运动 {{payload.metrics.avg_exercise}} kcal</text>
</view>
<view class="score-tip">评分越高,表示与当前策略和用户画像匹配度越高。</view>
<view wx:if="{{!payload.items || !payload.items.length}}" class="empty">暂无推荐结果</view>
<view wx:for="{{payload.items}}" wx:key="id" wx:for-item="recipe" class="item">
<view class="item-head">
<view class="rank-badge">#{{recipe.rank}}</view>
<view class="item-title">{{recipe.name}}</view>
<view class="value">{{recipe.displayScore}}</view>
</view>
<view class="item-sub">{{recipe.category}} · {{recipe.calories}} kcal · 蛋白{{recipe.protein}}g</view>
<view class="item-sub">{{recipe.reason}}</view>
<view class="item-sub" wx:if="{{recipe.tags && recipe.tags.length}}">
<text wx:for="{{recipe.tags}}" wx:key="index" wx:for-item="tag" class="pill">{{tag}}</text>
</view>
</view>
</view>
</view>

View File

@@ -0,0 +1,115 @@
.type-intro {
margin-top: 14rpx;
padding: 18rpx;
border-radius: 16rpx;
background: #f3f8ff;
border: 1rpx solid rgba(13, 59, 102, 0.16);
}
.type-intro-title {
font-size: 28rpx;
font-weight: 600;
color: #134773;
}
.type-intro-text {
margin-top: 10rpx;
font-size: 24rpx;
line-height: 1.55;
color: #2b4b69;
}
.type-intro-sub {
margin-top: 8rpx;
font-size: 22rpx;
color: #5e7489;
}
.selection-tags {
margin-top: 16rpx;
display: flex;
flex-wrap: wrap;
gap: 10rpx;
}
.selection-tag {
padding: 8rpx 16rpx;
border-radius: 999rpx;
background: #edf3fa;
color: #34516e;
font-size: 22rpx;
}
.summary-grid {
margin-top: 14rpx;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12rpx;
}
.summary-item {
padding: 14rpx;
border-radius: 14rpx;
background: #f8fbff;
border: 1rpx solid rgba(13, 59, 102, 0.1);
}
.summary-label {
font-size: 22rpx;
color: #60768b;
}
.summary-value {
margin-top: 8rpx;
font-size: 28rpx;
color: #143d62;
font-weight: 600;
}
.metrics-tags {
margin-top: 12rpx;
display: flex;
flex-wrap: wrap;
gap: 10rpx;
}
.metric-tag {
padding: 8rpx 14rpx;
border-radius: 12rpx;
background: rgba(31, 157, 114, 0.1);
color: #1a7e5e;
font-size: 22rpx;
}
.score-tip {
margin-top: 12rpx;
padding: 12rpx 14rpx;
border-left: 6rpx solid #f4a261;
background: #fff7ee;
color: #7a4f22;
font-size: 23rpx;
line-height: 1.55;
border-radius: 8rpx;
}
.item-head {
display: flex;
align-items: center;
gap: 12rpx;
}
.rank-badge {
min-width: 54rpx;
height: 40rpx;
line-height: 40rpx;
text-align: center;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #f4a261, #e76f51);
}
.item-head .item-title {
flex: 1;
}

View File

@@ -0,0 +1,38 @@
const { request } = require('../../utils/request')
Page({
data: {
form: {
username: '',
password: '',
nickname: '',
company: '',
title: '',
phone: ''
},
loading: false
},
onInput(e) {
const field = e.currentTarget.dataset.field
this.setData({ [`form.${field}`]: (e.detail.value || '').trim() })
},
async submit() {
if (this.data.loading) return
const { username, password } = this.data.form
if (!username || !password) {
wx.showToast({ title: '用户名和密码必填', icon: 'none' })
return
}
this.setData({ loading: true })
try {
await request({ url: '/auth/register', method: 'POST', data: this.data.form })
wx.showToast({ title: '注册成功', icon: 'success' })
setTimeout(() => wx.navigateBack(), 350)
} finally {
this.setData({ loading: false })
}
}
})

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "注册"
}

View File

@@ -0,0 +1,44 @@
<view class="container">
<view class="hero fade-up">
<view class="hero-badge">CREATE ACCOUNT</view>
<view class="hero-title">创建新账号</view>
<view class="hero-sub">完成基础信息即可开始使用垃圾信息识别与发布管理能力。</view>
</view>
<view class="card fade-up fade-up-delay-1">
<view class="card-title">基础信息</view>
<view class="card-desc">用户名和密码为必填,其余信息可后续在个人中心完善。</view>
<view class="field">
<text class="field-label">用户名</text>
<input class="input" placeholder="至少 3 位" value="{{form.username}}" data-field="username" bindinput="onInput" />
</view>
<view class="field">
<text class="field-label">密码</text>
<input class="input" placeholder="至少 6 位" password value="{{form.password}}" data-field="password" bindinput="onInput" />
</view>
<view class="field">
<text class="field-label">昵称</text>
<input class="input" placeholder="用于页面展示" value="{{form.nickname}}" data-field="nickname" bindinput="onInput" />
</view>
<view class="field">
<text class="field-label">公司</text>
<input class="input" placeholder="选填" value="{{form.company}}" data-field="company" bindinput="onInput" />
</view>
<view class="field">
<text class="field-label">岗位</text>
<input class="input" placeholder="选填" value="{{form.title}}" data-field="title" bindinput="onInput" />
</view>
<view class="field">
<text class="field-label">手机号</text>
<input class="input" placeholder="选填" value="{{form.phone}}" data-field="phone" bindinput="onInput" />
</view>
<button class="btn btn-primary" loading="{{loading}}" bindtap="submit">提交注册</button>
</view>
</view>

View File

@@ -0,0 +1 @@
/* register styles use global theme */

View File

@@ -0,0 +1,35 @@
{
"setting": {
"es6": true,
"postcss": true,
"minified": true,
"uglifyFileName": false,
"enhance": true,
"packNpmRelationList": [],
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"useCompilerPlugins": false,
"minifyWXML": true,
"compileWorklet": false,
"uploadWithSourceMap": true,
"packNpmManually": false,
"minifyWXSS": true,
"localPlugins": false,
"disableUseStrict": false,
"condition": false,
"swc": false,
"disableSWC": true
},
"compileType": "miniprogram",
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [],
"include": []
},
"appid": "wx42ba28b8e545ba14",
"editorSetting": {},
"libVersion": "3.15.0"
}

View File

@@ -0,0 +1,21 @@
{
"libVersion": "3.15.0",
"projectname": "miniprogram",
"setting": {
"urlCheck": true,
"coverView": true,
"lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false,
"preloadBackgroundData": false,
"autoAudits": false,
"showShadowRootInWxmlPanel": true,
"compileHotReLoad": true,
"useApiHook": true,
"useStaticServer": false,
"useLanDebug": false,
"showES6CompileOption": false,
"checkInvalidKey": true,
"ignoreDevUnusedFiles": true,
"bigPackageSizeSupport": false
}
}

9
miniprogram/sitemap.json Normal file
View File

@@ -0,0 +1,9 @@
{
"desc": "Sitemap for mini program indexing",
"rules": [
{
"action": "allow",
"page": "*"
}
]
}

View File

@@ -0,0 +1,58 @@
const app = getApp()
function getBaseURL() {
return app.globalData.baseURL || 'http://127.0.0.1:5000/api'
}
function getToken() {
return app.globalData.token || wx.getStorageSync('token') || ''
}
function request({ url, method = 'GET', data = {}, header = {} }) {
return new Promise((resolve, reject) => {
const token = getToken()
const headers = {
'Content-Type': 'application/json',
...header
}
if (token) {
headers.Authorization = `Bearer ${token}`
}
wx.request({
url: `${getBaseURL()}${url}`,
method,
data,
header: headers,
success: (res) => {
if (res.statusCode === 401) {
app.clearAuth()
wx.showToast({ title: '登录已过期,请重新登录', icon: 'none' })
setTimeout(() => wx.reLaunch({ url: '/pages/login/index' }), 600)
reject(new Error('Unauthorized'))
return
}
const body = res.data || {}
if (body.code === 0) {
resolve(body.data)
return
}
const message = body.message || '请求失败'
wx.showToast({ title: message, icon: 'none' })
reject(new Error(message))
},
fail: (err) => {
const msg = (err && err.errMsg) || '网络异常,请检查后端地址'
wx.showToast({ title: msg, icon: 'none' })
reject(err)
}
})
})
}
module.exports = {
request
}