1
This commit is contained in:
71
miniprogram/pages/admin-dashboard/index.js
Normal file
71
miniprogram/pages/admin-dashboard/index.js
Normal 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()
|
||||
}
|
||||
}
|
||||
})
|
||||
4
miniprogram/pages/admin-dashboard/index.json
Normal file
4
miniprogram/pages/admin-dashboard/index.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "运营看板",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
57
miniprogram/pages/admin-dashboard/index.wxml
Normal file
57
miniprogram/pages/admin-dashboard/index.wxml
Normal 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>
|
||||
1
miniprogram/pages/admin-dashboard/index.wxss
Normal file
1
miniprogram/pages/admin-dashboard/index.wxss
Normal file
@@ -0,0 +1 @@
|
||||
/* admin-dashboard styles use global theme */
|
||||
152
miniprogram/pages/admin-recipes/index.js
Normal file
152
miniprogram/pages/admin-recipes/index.js
Normal 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' })
|
||||
}
|
||||
}
|
||||
})
|
||||
3
miniprogram/pages/admin-recipes/index.json
Normal file
3
miniprogram/pages/admin-recipes/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "管理员-食谱管理"
|
||||
}
|
||||
53
miniprogram/pages/admin-recipes/index.wxml
Normal file
53
miniprogram/pages/admin-recipes/index.wxml
Normal 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>
|
||||
4
miniprogram/pages/admin-recipes/index.wxss
Normal file
4
miniprogram/pages/admin-recipes/index.wxss
Normal file
@@ -0,0 +1,4 @@
|
||||
.item .row .btn {
|
||||
margin-top: 10rpx;
|
||||
width: 44%;
|
||||
}
|
||||
298
miniprogram/pages/admin-review/index.js
Normal file
298
miniprogram/pages/admin-review/index.js
Normal 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()])
|
||||
}
|
||||
})
|
||||
4
miniprogram/pages/admin-review/index.json
Normal file
4
miniprogram/pages/admin-review/index.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "复核与申诉",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
122
miniprogram/pages/admin-review/index.wxml
Normal file
122
miniprogram/pages/admin-review/index.wxml
Normal 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>
|
||||
23
miniprogram/pages/admin-review/index.wxss
Normal file
23
miniprogram/pages/admin-review/index.wxss
Normal 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;
|
||||
}
|
||||
107
miniprogram/pages/admin-samples/index.js
Normal file
107
miniprogram/pages/admin-samples/index.js
Normal 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()
|
||||
}
|
||||
})
|
||||
3
miniprogram/pages/admin-samples/index.json
Normal file
3
miniprogram/pages/admin-samples/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "样本管理"
|
||||
}
|
||||
64
miniprogram/pages/admin-samples/index.wxml
Normal file
64
miniprogram/pages/admin-samples/index.wxml
Normal 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>
|
||||
1
miniprogram/pages/admin-samples/index.wxss
Normal file
1
miniprogram/pages/admin-samples/index.wxss
Normal file
@@ -0,0 +1 @@
|
||||
/* admin-samples styles use global theme */
|
||||
112
miniprogram/pages/admin-users/index.js
Normal file
112
miniprogram/pages/admin-users/index.js
Normal 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()
|
||||
}
|
||||
})
|
||||
3
miniprogram/pages/admin-users/index.json
Normal file
3
miniprogram/pages/admin-users/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "用户管理"
|
||||
}
|
||||
55
miniprogram/pages/admin-users/index.wxml
Normal file
55
miniprogram/pages/admin-users/index.wxml
Normal 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>
|
||||
1
miniprogram/pages/admin-users/index.wxss
Normal file
1
miniprogram/pages/admin-users/index.wxss
Normal file
@@ -0,0 +1 @@
|
||||
/* admin-users styles use global theme */
|
||||
70
miniprogram/pages/batch/index.js
Normal file
70
miniprogram/pages/batch/index.js
Normal 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')
|
||||
})
|
||||
}
|
||||
})
|
||||
3
miniprogram/pages/batch/index.json
Normal file
3
miniprogram/pages/batch/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "批量识别"
|
||||
}
|
||||
74
miniprogram/pages/batch/index.wxml
Normal file
74
miniprogram/pages/batch/index.wxml
Normal 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="示例: 点击链接领取红包 今天下午三点开会" 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>
|
||||
1
miniprogram/pages/batch/index.wxss
Normal file
1
miniprogram/pages/batch/index.wxss
Normal file
@@ -0,0 +1 @@
|
||||
/* batch styles use global theme */
|
||||
102
miniprogram/pages/detect/index.js
Normal file
102
miniprogram/pages/detect/index.js
Normal 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' })
|
||||
}
|
||||
})
|
||||
3
miniprogram/pages/detect/index.json
Normal file
3
miniprogram/pages/detect/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "文本发布"
|
||||
}
|
||||
84
miniprogram/pages/detect/index.wxml
Normal file
84
miniprogram/pages/detect/index.wxml
Normal 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>
|
||||
1
miniprogram/pages/detect/index.wxss
Normal file
1
miniprogram/pages/detect/index.wxss
Normal file
@@ -0,0 +1 @@
|
||||
/* detect styles use global theme */
|
||||
86
miniprogram/pages/diet-status/index.js
Normal file
86
miniprogram/pages/diet-status/index.js
Normal 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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
3
miniprogram/pages/diet-status/index.json
Normal file
3
miniprogram/pages/diet-status/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "Diet Status"
|
||||
}
|
||||
19
miniprogram/pages/diet-status/index.wxml
Normal file
19
miniprogram/pages/diet-status/index.wxml
Normal 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>
|
||||
8
miniprogram/pages/diet-status/index.wxss
Normal file
8
miniprogram/pages/diet-status/index.wxss
Normal file
@@ -0,0 +1,8 @@
|
||||
page {
|
||||
animation: slideIn 260ms ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateY(8rpx); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
130
miniprogram/pages/history/index.js
Normal file
130
miniprogram/pages/history/index.js
Normal 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
4
miniprogram/pages/history/index.json
Normal file
4
miniprogram/pages/history/index.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "发布历史",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
78
miniprogram/pages/history/index.wxml
Normal file
78
miniprogram/pages/history/index.wxml
Normal 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>
|
||||
1
miniprogram/pages/history/index.wxss
Normal file
1
miniprogram/pages/history/index.wxss
Normal file
@@ -0,0 +1 @@
|
||||
/* history styles use global theme */
|
||||
67
miniprogram/pages/home/index.js
Normal file
67
miniprogram/pages/home/index.js
Normal 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' })
|
||||
}
|
||||
})
|
||||
3
miniprogram/pages/home/index.json
Normal file
3
miniprogram/pages/home/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "工作台"
|
||||
}
|
||||
60
miniprogram/pages/home/index.wxml
Normal file
60
miniprogram/pages/home/index.wxml
Normal 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>
|
||||
1
miniprogram/pages/home/index.wxss
Normal file
1
miniprogram/pages/home/index.wxss
Normal file
@@ -0,0 +1 @@
|
||||
/* home styles use global theme */
|
||||
31
miniprogram/pages/inbox/index.js
Normal file
31
miniprogram/pages/inbox/index.js
Normal 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()
|
||||
}
|
||||
}
|
||||
})
|
||||
4
miniprogram/pages/inbox/index.json
Normal file
4
miniprogram/pages/inbox/index.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "收件箱",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
23
miniprogram/pages/inbox/index.wxml
Normal file
23
miniprogram/pages/inbox/index.wxml
Normal 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>
|
||||
1
miniprogram/pages/inbox/index.wxss
Normal file
1
miniprogram/pages/inbox/index.wxss
Normal file
@@ -0,0 +1 @@
|
||||
/* inbox styles use global theme */
|
||||
52
miniprogram/pages/login/index.js
Normal file
52
miniprogram/pages/login/index.js
Normal 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' })
|
||||
}
|
||||
})
|
||||
3
miniprogram/pages/login/index.json
Normal file
3
miniprogram/pages/login/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "登录"
|
||||
}
|
||||
34
miniprogram/pages/login/index.wxml
Normal file
34
miniprogram/pages/login/index.wxml
Normal 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>
|
||||
1
miniprogram/pages/login/index.wxss
Normal file
1
miniprogram/pages/login/index.wxss
Normal file
@@ -0,0 +1 @@
|
||||
/* login styles use global theme */
|
||||
61
miniprogram/pages/profile/index.js
Normal file
61
miniprogram/pages/profile/index.js
Normal 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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
3
miniprogram/pages/profile/index.json
Normal file
3
miniprogram/pages/profile/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "个人中心"
|
||||
}
|
||||
39
miniprogram/pages/profile/index.wxml
Normal file
39
miniprogram/pages/profile/index.wxml
Normal 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>
|
||||
1
miniprogram/pages/profile/index.wxss
Normal file
1
miniprogram/pages/profile/index.wxss
Normal file
@@ -0,0 +1 @@
|
||||
/* profile styles use global theme */
|
||||
75
miniprogram/pages/qa/index.js
Normal file
75
miniprogram/pages/qa/index.js
Normal 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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
3
miniprogram/pages/qa/index.json
Normal file
3
miniprogram/pages/qa/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "营养问答"
|
||||
}
|
||||
49
miniprogram/pages/qa/index.wxml
Normal file
49
miniprogram/pages/qa/index.wxml
Normal 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>
|
||||
3
miniprogram/pages/qa/index.wxss
Normal file
3
miniprogram/pages/qa/index.wxss
Normal file
@@ -0,0 +1,3 @@
|
||||
.card .row {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
187
miniprogram/pages/recommend/index.js
Normal file
187
miniprogram/pages/recommend/index.js
Normal 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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
3
miniprogram/pages/recommend/index.json
Normal file
3
miniprogram/pages/recommend/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "饮食推荐"
|
||||
}
|
||||
104
miniprogram/pages/recommend/index.wxml
Normal file
104
miniprogram/pages/recommend/index.wxml
Normal 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>
|
||||
115
miniprogram/pages/recommend/index.wxss
Normal file
115
miniprogram/pages/recommend/index.wxss
Normal 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;
|
||||
}
|
||||
38
miniprogram/pages/register/index.js
Normal file
38
miniprogram/pages/register/index.js
Normal 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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
3
miniprogram/pages/register/index.json
Normal file
3
miniprogram/pages/register/index.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "注册"
|
||||
}
|
||||
44
miniprogram/pages/register/index.wxml
Normal file
44
miniprogram/pages/register/index.wxml
Normal 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>
|
||||
1
miniprogram/pages/register/index.wxss
Normal file
1
miniprogram/pages/register/index.wxss
Normal file
@@ -0,0 +1 @@
|
||||
/* register styles use global theme */
|
||||
Reference in New Issue
Block a user