feat: 同步今日小程序与后台迭代版本

集中提交今日 API、管理端、小程序与部署文档调整,确保 Gitea 主分支与本地最新开发版本一致。

Made-with: Cursor
This commit is contained in:
卡若
2026-04-20 11:07:55 +08:00
parent 7108f28280
commit ce55c48c64
180 changed files with 11659 additions and 6078 deletions

View File

@@ -1205,3 +1205,80 @@
color: #92400E !important;
}
/* ==========================================================================
统一 tabs · pill-tabs 外观
覆盖 Distribution / Pricing / OrdersHub / Finance(superadmin) 等页面里
历史遗留的 .custom-tabs-container / .custom-tabs / .tab-item scoped 样式
使 admin / superadmin 所有内部子标签视觉一致
========================================================================== */
.admin-layout .custom-tabs-container,
.superadmin-layout .custom-tabs-container {
background-color: #f1f5f9 !important;
padding: 4px !important;
border-radius: 10px !important;
display: inline-flex !important;
max-width: 100% !important;
overflow-x: auto !important;
-webkit-overflow-scrolling: touch !important;
margin-bottom: 20px !important;
width: auto !important;
box-shadow: none !important;
border: 0 !important;
}
.admin-layout .custom-tabs-container .custom-tabs,
.superadmin-layout .custom-tabs-container .custom-tabs {
display: inline-flex !important;
gap: 2px !important;
width: auto !important;
min-width: min-content !important;
}
.admin-layout .custom-tabs-container .tab-item,
.superadmin-layout .custom-tabs-container .tab-item {
flex: 0 0 auto !important;
border: 0 !important;
background: transparent !important;
color: #64748b !important;
font-size: 13px !important;
font-weight: 500 !important;
padding: 7px 18px !important;
border-radius: 6px !important;
cursor: pointer !important;
white-space: nowrap !important;
transition: all 0.18s !important;
line-height: 1.4 !important;
text-align: center !important;
box-shadow: none !important;
}
.admin-layout .custom-tabs-container .tab-item:hover,
.superadmin-layout .custom-tabs-container .tab-item:hover {
color: #0f172a !important;
background: transparent !important;
}
.admin-layout .custom-tabs-container .tab-item.active,
.superadmin-layout .custom-tabs-container .tab-item.active {
background: #ffffff !important;
color: #0f172a !important;
font-weight: 600 !important;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08) !important;
}
/* ==========================================================================
统一 page-header · admin/superadmin 页面标题区
把 Finance / Settings / Users / Orders / Distribution 等 h2 + .subtitle
的视觉尺度收敛到 token不改源文件结构
========================================================================== */
.admin-layout .page-header h2,
.admin-layout .page-header .title {
font-size: 20px !important;
font-weight: 700 !important;
color: var(--admin-text) !important;
letter-spacing: -0.01em !important;
margin: 0 0 4px !important;
}
.admin-layout .page-header .subtitle {
font-size: 13px !important;
color: var(--admin-text-sub) !important;
margin: 0 !important;
line-height: 1.55 !important;
}

View File

@@ -0,0 +1,109 @@
<template>
<div class="top-panel" v-loading="loading">
<div class="top-head">
<div>
<h3 class="top-title">测评 Top 20</h3>
<p class="top-desc">按完成次数排序 · 本企业数据</p>
</div>
<el-button type="primary" link size="small" @click="load">刷新</el-button>
</div>
<el-table v-if="rows.length" :data="rows" size="small" stripe class="top-table">
<el-table-column label="#" width="44">
<template #default="{ $index }">{{ $index + 1 }}</template>
</el-table-column>
<el-table-column label="用户" min-width="100" show-overflow-tooltip>
<template #default="{ row }">{{ row.username || '未命名' }}</template>
</el-table-column>
<el-table-column prop="testCount" label="次数" width="56" align="center" />
<el-table-column label="测评摘要" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ summarizeTypes(row) }}</template>
</el-table-column>
</el-table>
<div v-else-if="!loading" class="top-empty">暂无测试记录</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { request } from '@/utils/request'
interface TopUserRow {
id: number
username: string
testCount: number
mbtiType: string
pdpType: string
discType: string
faceMbtiType: string
faceDiscType: string
facePdpType: string
}
const loading = ref(false)
const rows = ref<TopUserRow[]>([])
function summarizeTypes(row: TopUserRow) {
const parts: string[] = []
if (row.mbtiType) parts.push(row.mbtiType)
if (row.pdpType) parts.push(row.pdpType)
if (row.discType) parts.push(row.discType)
const faceBits = [row.faceMbtiType, row.facePdpType, row.faceDiscType].filter(Boolean)
if (faceBits.length) parts.push('面:' + faceBits.join('/'))
return parts.length ? parts.join(' · ') : '—'
}
async function load() {
loading.value = true
try {
const res: any = await request.get('/admin/dashboard')
const list = res?.data?.topTestUsers
rows.value = Array.isArray(list) ? list : []
} catch {
rows.value = []
} finally {
loading.value = false
}
}
onMounted(() => {
void load()
})
</script>
<style scoped lang="scss">
.top-panel {
background: #fff;
border-radius: 10px;
border: 1px solid #f3f4f6;
padding: 20px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.top-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.top-title {
margin: 0 0 4px;
font-size: 17px;
font-weight: 700;
color: #111827;
}
.top-desc {
margin: 0;
font-size: 12px;
color: #6b7280;
line-height: 1.45;
}
.top-empty {
padding: 28px;
text-align: center;
color: #9ca3af;
font-size: 13px;
}
.top-table {
width: 100%;
}
</style>

View File

@@ -0,0 +1,278 @@
<template>
<div class="journey-panel">
<div class="journey-head">
<div>
<h3 class="journey-title">用户旅程漏斗</h3>
<p class="journey-desc">
注册 绑手机 完成测评 手机号登录看报告 分享 付费解锁 复测
<span class="journey-beta">前端占位版 · 待后端接口上线</span>
</p>
</div>
<el-button type="primary" link size="small" :icon="Refresh" @click="refresh">刷新</el-button>
</div>
<div class="funnel-wrap">
<div
v-for="(stage, idx) in stages"
:key="stage.key"
class="funnel-row"
:style="{ animationDelay: `${idx * 40}ms` }"
>
<div class="funnel-meta">
<span class="funnel-idx">{{ idx + 1 }}</span>
<div class="funnel-meta-body">
<span class="funnel-label">{{ stage.label }}</span>
<span class="funnel-note">{{ stage.note }}</span>
</div>
</div>
<div class="funnel-bar-track">
<div class="funnel-bar-fill" :style="{ width: barWidth(stage.count) }"></div>
</div>
<div class="funnel-num">
<span class="funnel-count">{{ stage.count }}</span>
<span class="funnel-percent" v-if="idx > 0 && stages[0].count > 0">
{{ ((stage.count / stages[0].count) * 100).toFixed(1) }}%
</span>
</div>
</div>
</div>
<div class="journey-hint">
<el-icon><InfoFilled /></el-icon>
<span>
这里先以前端 mock 展示漏斗形态后端接口
<code>GET /api/admin/users/journey-stats</code> 上线后自动切真数据参考 <em>开发文档/1需求/修改/用户运营参考Soul规则.md</em>
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Refresh, InfoFilled } from '@element-plus/icons-vue'
// import { request } from '@/utils/request'
/**
* 旅程阶段(与 Soul 永平仓库的 journey-stats 字段对齐,按 mbti 业务裁剪):
* - register: 注册
* - bind_phone: 授权手机号
* - tested: 完成任意测评
* - viewed_full: 通过手机号登录看到完整报告
* - shared: 发起过分享
* - paid: 付费解锁
* - repeat: 复测(>=2 次)
*/
interface StageRow {
key: string
label: string
note: string
count: number
}
const mock = (): StageRow[] => [
{ key: 'register', label: '注册', note: '进入小程序并静默登录', count: 1280 },
{ key: 'bind_phone', label: '授权手机号', note: 'getPhoneNumber 成功', count: 842 },
{ key: 'tested', label: '完成任意测评', note: 'MBTI / DISC / PDP / SBTI', count: 716 },
{ key: 'viewed_full', label: '看完整报告', note: '结果页手机号登录解锁', count: 498 },
{ key: 'shared', label: '发起分享', note: '好友 / 朋友圈', count: 263 },
{ key: 'paid', label: '付费解锁', note: '人脸或其他收费项', count: 134 },
{ key: 'repeat', label: '复测', note: '>= 2 次完成记录', count: 86 }
]
const stages = ref<StageRow[]>(mock())
const topCount = computed(() => Math.max(1, ...stages.value.map(s => s.count)))
function barWidth(count: number) {
return `${Math.round((count / topCount.value) * 100)}%`
}
async function refresh() {
// TODO: 后端上线后替换为:
// const res: any = await request.get('/admin/users/journey-stats')
// stages.value = mapResponseToStages(res.data)
stages.value = mock()
}
</script>
<style scoped lang="scss">
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.journey-panel {
background: #fff;
border-radius: 12px;
border: 1px solid #e2e8f0;
padding: 20px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 4px 12px rgba(15, 23, 42, 0.03);
}
.journey-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.journey-title {
margin: 0 0 4px;
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.journey-desc {
margin: 0;
color: #64748b;
font-size: 12.5px;
line-height: 1.55;
}
.journey-beta {
display: inline-block;
margin-left: 8px;
padding: 1px 8px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 600;
color: #b45309;
background: #fffbeb;
border: 1px solid #fde68a;
vertical-align: middle;
}
.funnel-wrap {
display: flex;
flex-direction: column;
gap: 10px;
padding: 8px 0 6px;
}
.funnel-row {
display: grid;
grid-template-columns: minmax(200px, 230px) minmax(0, 1fr) minmax(86px, 92px);
gap: 14px;
align-items: center;
animation: fadeInUp 0.3s ease-out both;
}
.funnel-meta {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.funnel-idx {
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 999px;
background: #eef2ff;
color: #4f46e5;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
}
.funnel-meta-body {
display: flex;
flex-direction: column;
min-width: 0;
}
.funnel-label {
font-size: 13px;
font-weight: 600;
color: #0f172a;
line-height: 1.2;
}
.funnel-note {
font-size: 11.5px;
color: #94a3b8;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.funnel-bar-track {
height: 12px;
background: #f1f5f9;
border-radius: 6px;
overflow: hidden;
}
.funnel-bar-fill {
height: 100%;
border-radius: 6px;
background: linear-gradient(90deg, #4f46e5 0%, #6366f1 100%);
transition: width 0.45s ease;
}
.funnel-num {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.funnel-count {
font-size: 15px;
font-weight: 700;
color: #0f172a;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.funnel-percent {
font-size: 11.5px;
color: #10b981;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.journey-hint {
margin-top: 18px;
padding: 10px 12px;
background: #f8fafc;
border: 1px dashed #e2e8f0;
border-radius: 8px;
color: #64748b;
font-size: 12px;
line-height: 1.55;
display: flex;
align-items: flex-start;
gap: 8px;
code {
background: #eef2ff;
color: #4f46e5;
padding: 1px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
font-size: 11.5px;
}
em {
font-style: normal;
color: #334155;
font-weight: 500;
}
}
@media (max-width: 900px) {
.funnel-row {
grid-template-columns: minmax(140px, 160px) minmax(0, 1fr) minmax(70px, 76px);
gap: 10px;
}
.funnel-note { display: none; }
}
</style>

View File

@@ -0,0 +1,273 @@
<template>
<div class="rfm-panel">
<div class="rfm-head">
<div>
<h3 class="rfm-title">RFM 价值分层</h3>
<p class="rfm-desc">
R 最近付费 · F 付费频次 · M 累计金额 · 合并推荐/轨迹/资料按档位 S/A/B/C/D 排序
<span class="rfm-beta">前端占位版 · 待后端接口上线</span>
</p>
</div>
<el-button type="primary" link size="small" :icon="Refresh" @click="refresh">刷新</el-button>
</div>
<div class="rfm-kpi-row">
<div v-for="bucket in buckets" :key="bucket.level" class="rfm-kpi" :class="`level-${bucket.level.toLowerCase()}`">
<div class="rfm-kpi-level">{{ bucket.level }}</div>
<div class="rfm-kpi-count">{{ bucket.count }}</div>
<div class="rfm-kpi-label">{{ bucket.label }}</div>
</div>
</div>
<div class="rfm-table-wrap">
<el-table :data="rows" size="small" class="rfm-table">
<el-table-column label="#" width="44">
<template #default="{ $index }">{{ $index + 1 }}</template>
</el-table-column>
<el-table-column label="档位" width="72">
<template #default="{ row }">
<span class="rfm-level-tag" :class="`level-${row.level.toLowerCase()}`">{{ row.level }}</span>
</template>
</el-table-column>
<el-table-column prop="nickname" label="用户" min-width="120" show-overflow-tooltip />
<el-table-column prop="phone" label="手机号" width="136" />
<el-table-column label="R" width="64" align="center">
<template #default="{ row }">{{ row.r }}</template>
</el-table-column>
<el-table-column label="F" width="64" align="center">
<template #default="{ row }">{{ row.f }}</template>
</el-table-column>
<el-table-column label="M (¥)" width="88" align="right">
<template #default="{ row }">{{ row.m.toFixed(2) }}</template>
</el-table-column>
<el-table-column label="综合分" width="96" align="center">
<template #default="{ row }">
<strong class="rfm-score">{{ row.score }}</strong>
</template>
</el-table-column>
</el-table>
</div>
<div class="rfm-hint">
<el-icon><InfoFilled /></el-icon>
<span>
数据为示例后端
<code>GET /api/admin/users/rfm</code>
上线后自动替换口径参考 Soul 永平仓库
<em>算法-RFM用户价值分层.md</em>mbti 版本在列表与本排行都采用同一套档位规则避免列表与排行分不一致
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Refresh, InfoFilled } from '@element-plus/icons-vue'
// import { request } from '@/utils/request'
interface RfmRow {
id: number
nickname: string
phone: string
r: number
f: number
m: number
score: number
level: 'S' | 'A' | 'B' | 'C' | 'D'
}
const mockRows: RfmRow[] = [
{ id: 1, nickname: '徐先生', phone: '138****2103', r: 92, f: 88, m: 648.00, score: 89, level: 'S' },
{ id: 2, nickname: '卡若夏', phone: '139****8812', r: 86, f: 72, m: 366.00, score: 78, level: 'A' },
{ id: 3, nickname: 'Anita', phone: '186****0091', r: 68, f: 40, m: 188.50, score: 65, level: 'B' },
{ id: 4, nickname: '小陈同学', phone: '131****6602', r: 55, f: 28, m: 92.00, score: 51, level: 'B' },
{ id: 5, nickname: '林可可', phone: '159****7730', r: 42, f: 12, m: 36.80, score: 36, level: 'C' },
{ id: 6, nickname: '周周', phone: '185****4461', r: 20, f: 4, m: 9.90, score: 22, level: 'D' }
]
const rows = ref<RfmRow[]>(mockRows)
const buckets = computed(() => {
const levels: RfmRow['level'][] = ['S', 'A', 'B', 'C', 'D']
const labels: Record<RfmRow['level'], string> = {
S: '忠实高价值',
A: '重点维护',
B: '潜力用户',
C: '普通用户',
D: '流失风险'
}
return levels.map((lv) => ({
level: lv,
label: labels[lv],
count: rows.value.filter((r) => r.level === lv).length
}))
})
async function refresh() {
// TODO: 后端上线后替换为:
// const res: any = await request.get('/admin/users/rfm', { params: { limit: 20 } })
// rows.value = res.data?.list || []
rows.value = [...mockRows]
}
</script>
<style scoped lang="scss">
.rfm-panel {
background: #fff;
border-radius: 12px;
border: 1px solid #e2e8f0;
padding: 20px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 4px 12px rgba(15, 23, 42, 0.03);
}
.rfm-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.rfm-title {
margin: 0 0 4px;
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.rfm-desc {
margin: 0;
color: #64748b;
font-size: 12.5px;
line-height: 1.55;
}
.rfm-beta {
display: inline-block;
margin-left: 8px;
padding: 1px 8px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 600;
color: #b45309;
background: #fffbeb;
border: 1px solid #fde68a;
vertical-align: middle;
}
.rfm-kpi-row {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
margin-bottom: 16px;
}
.rfm-kpi {
padding: 12px 14px;
border-radius: 10px;
background: #f8fafc;
border: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
gap: 4px;
transition: all 0.18s;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.05);
}
}
.rfm-kpi-level {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
color: #64748b;
}
.rfm-kpi-count {
font-size: 22px;
font-weight: 700;
color: #0f172a;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.rfm-kpi-label {
font-size: 11.5px;
color: #94a3b8;
}
.rfm-kpi.level-s { background: #eef2ff; border-color: #c7d2fe; }
.rfm-kpi.level-s .rfm-kpi-level { color: #4f46e5; }
.rfm-kpi.level-a { background: #ecfdf5; border-color: #a7f3d0; }
.rfm-kpi.level-a .rfm-kpi-level { color: #10b981; }
.rfm-kpi.level-b { background: #e0f2fe; border-color: #bae6fd; }
.rfm-kpi.level-b .rfm-kpi-level { color: #0ea5e9; }
.rfm-kpi.level-c { background: #fffbeb; border-color: #fde68a; }
.rfm-kpi.level-c .rfm-kpi-level { color: #f59e0b; }
.rfm-kpi.level-d { background: #fef2f2; border-color: #fecaca; }
.rfm-kpi.level-d .rfm-kpi-level { color: #ef4444; }
.rfm-table-wrap {
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow: hidden;
}
.rfm-level-tag {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11.5px;
font-weight: 700;
letter-spacing: 0.04em;
}
.rfm-level-tag.level-s { background: #eef2ff; color: #4f46e5; }
.rfm-level-tag.level-a { background: #ecfdf5; color: #10b981; }
.rfm-level-tag.level-b { background: #e0f2fe; color: #0284c7; }
.rfm-level-tag.level-c { background: #fffbeb; color: #b45309; }
.rfm-level-tag.level-d { background: #fef2f2; color: #dc2626; }
.rfm-score {
color: #4f46e5;
font-variant-numeric: tabular-nums;
}
.rfm-hint {
margin-top: 18px;
padding: 10px 12px;
background: #f8fafc;
border: 1px dashed #e2e8f0;
border-radius: 8px;
color: #64748b;
font-size: 12px;
line-height: 1.55;
display: flex;
align-items: flex-start;
gap: 8px;
code {
background: #eef2ff;
color: #4f46e5;
padding: 1px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
font-size: 11.5px;
}
em {
font-style: normal;
color: #334155;
font-weight: 500;
}
}
@media (max-width: 900px) {
.rfm-kpi-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>

View File

@@ -174,7 +174,7 @@ const handleLogout = async () => {
width: 34px;
height: 34px;
border-radius: 8px;
background-color: #7c3aed;
background-color: var(--admin-primary, #4F46E5);
display: flex;
align-items: center;
justify-content: center;
@@ -273,8 +273,8 @@ const handleLogout = async () => {
}
&.active {
background-color: #f5f3ff;
color: #7c3aed;
background-color: var(--admin-primary-soft, #EEF2FF);
color: var(--admin-primary, #4F46E5);
font-weight: 500;
&::before {
@@ -284,11 +284,11 @@ const handleLogout = async () => {
top: 0;
bottom: 0;
width: 3px;
background-color: #7c3aed;
background-color: var(--admin-primary, #4F46E5);
}
.nav-icon {
color: #7c3aed;
color: var(--admin-primary, #4F46E5);
}
}

View File

@@ -1,49 +1,16 @@
<template>
<div class="dashboard-viewport" v-loading="loading">
<header class="dash-head">
<h1 class="dash-title">企业概览</h1>
<p class="dash-tagline">实时掌握余额扣款冻结佣金与补充建议</p>
<div class="dash-head-left">
<h1 class="dash-title">企业概览</h1>
<p class="dash-tagline">用户规模与测评完成情况 · 分布与近 14 日趋势</p>
</div>
<div class="dash-head-right">
<span v-if="lastUpdatedText" class="dash-updated">{{ lastUpdatedText }}</span>
<el-button size="small" :icon="Refresh" @click="refreshAll" :loading="loading">刷新</el-button>
</div>
</header>
<div class="wallet-strip">
<div class="wallet-card wallet-card--primary">
<div class="wallet-info">
<div class="wallet-label">当前余额</div>
<div class="wallet-value">¥{{ fenToYuan(finance.balanceFen) }}</div>
<div class="wallet-foot">历史充值 ¥{{ fenToYuan(finance.manualRechargeFen) }}</div>
</div>
<div class="wallet-icon"><el-icon><Wallet /></el-icon></div>
<el-button type="primary" size="small" class="wallet-action" @click="openRechargeDialog">立即充值</el-button>
</div>
<div class="wallet-card wallet-card--consume">
<div class="wallet-info">
<div class="wallet-label">本月已扣款</div>
<div class="wallet-value">¥{{ fenToYuan(finance.monthConsumeFen) }}</div>
<div class="wallet-foot">日均 ¥{{ fenToYuan(finance.avgDailyConsumeFen) }} · 今日 ¥{{ fenToYuan(finance.todayConsumeFen) }}</div>
</div>
<div class="wallet-icon"><el-icon><TrendCharts /></el-icon></div>
</div>
<div class="wallet-card wallet-card--frozen">
<div class="wallet-info">
<div class="wallet-label">冻结佣金</div>
<div class="wallet-value">¥{{ fenToYuan(finance.frozenCommissionFen) }}</div>
<div class="wallet-foot">余额补足后自动解冻</div>
</div>
<div class="wallet-icon"><el-icon><Lock /></el-icon></div>
</div>
<div class="wallet-card wallet-card--suggest">
<div class="wallet-info">
<div class="wallet-label">建议充值</div>
<div class="wallet-value">
<template v-if="finance.suggestRechargeFen > 0">¥{{ fenToYuan(finance.suggestRechargeFen) }}</template>
<template v-else></template>
</div>
<div class="wallet-foot">{{ suggestHint }}</div>
</div>
<div class="wallet-icon"><el-icon><WarningFilled /></el-icon></div>
</div>
</div>
<div class="dash-kpis">
<div v-for="(card, i) in kpiCards" :key="card.key" class="stat-card" :style="{ animationDelay: `${i * 45}ms` }">
<div class="stat-info">
@@ -56,42 +23,6 @@
</div>
</div>
<el-dialog v-model="rechargeVisible" title="企业余额充值" width="420px" :close-on-click-modal="false" destroy-on-close>
<div class="recharge-dialog">
<el-form :model="rechargeForm" label-width="80px" size="default">
<el-form-item label="金额(元)">
<el-input-number
v-model="rechargeForm.amountYuan"
:min="1"
:max="100000"
:step="100"
controls-position="right"
style="width:100%"
/>
</el-form-item>
<el-form-item v-if="quickAmounts.length" label="快速">
<div class="recharge-quick">
<el-button
v-for="a in quickAmounts"
:key="a"
size="small"
:type="rechargeForm.amountYuan === a ? 'primary' : 'default'"
@click="rechargeForm.amountYuan = a"
>¥{{ a }}</el-button>
</div>
</el-form-item>
</el-form>
<div class="recharge-action">
<el-button type="primary" :loading="rechargeLoading" @click="generateRechargeQr">生成充值码</el-button>
<span class="recharge-tip">用微信扫码在小程序完成支付</span>
</div>
<div v-if="rechargeQrcode" class="recharge-qr">
<img :src="rechargeQrcode" alt="充值二维码" />
<div class="recharge-qr-text">扫码支付 ¥{{ rechargeAmountYuanDisplay }}</div>
</div>
</div>
</el-dialog>
<div class="dash-catalog" v-if="testCatalog.length">
<div
v-for="(row, i) in catalogRows"
@@ -115,9 +46,9 @@
<div class="dash-main">
<section class="panel panel-chart">
<div class="panel-head">
<h2 class="panel-title"> 14 日测试趋势</h2>
<p class="panel-desc">人脸 · MBTI · PDP · DISC 完成量下方为性格结果分布与本企业测评数据一致</p>
<div class="panel-section-head">
<h2 class="panel-title">测评结果分布</h2>
<span class="panel-meta">各类型高频结果TOP</span>
</div>
<div v-if="hasDistribution" class="distr-band">
<div v-for="block in distributionBlocks" :key="block.key" class="distr-col">
@@ -140,6 +71,37 @@
</div>
</div>
</div>
<div v-else class="panel-empty tight">暂无分布数据</div>
<div class="panel-section-head panel-section-head--chart">
<h2 class="panel-title"> 14 · 测评完成趋势</h2>
<div class="chart-legend" role="tablist">
<button
v-for="key in chartModeOptions"
:key="key.value"
class="chart-mode-btn"
:class="{ 'is-active': chartMode === key.value }"
@click="chartMode = key.value"
>
{{ key.label }}
</button>
</div>
</div>
<div class="chart-sub-meta">
<span class="chart-meta-item">
<em class="chart-meta-dot bar-mbti"></em>MBTI
</span>
<span class="chart-meta-item">
<em class="chart-meta-dot bar-pdp"></em>PDP
</span>
<span class="chart-meta-item">
<em class="chart-meta-dot bar-disc"></em>DISC
</span>
<span class="chart-meta-item">
<em class="chart-meta-dot bar-face"></em>人脸
</span>
<span class="chart-meta-sum" v-if="trendTotalsText">累计 {{ trendTotalsText }}</span>
</div>
<div class="chart-box">
<VChart v-if="testTrends.length" class="trend-chart" :option="chartOption" autoresize />
<div v-else class="panel-empty">暂无趋势数据</div>
@@ -147,43 +109,10 @@
</section>
<aside class="panel panel-side">
<div class="side-block side-users">
<div class="panel-head row">
<div>
<h2 class="panel-title">测试 Top 20</h2>
<p class="panel-desc">按完成次数 · 本企业口径</p>
</div>
<el-button type="primary" link size="small" @click="router.push('/admin/users')">全部用户</el-button>
</div>
<div ref="tableWrapRef" class="table-wrap">
<el-table
v-if="topTestUsers.length"
:data="topTestUsers"
size="small"
stripe
class="compact-table"
:height="sideTableHeight"
>
<el-table-column label="#" width="42">
<template #default="{ $index }">{{ $index + 1 }}</template>
</el-table-column>
<el-table-column label="用户" min-width="88" show-overflow-tooltip>
<template #default="{ row }">{{ row.username || '未命名' }}</template>
</el-table-column>
<el-table-column prop="testCount" label="次数" width="52" align="center" />
<el-table-column label="摘要" min-width="100" show-overflow-tooltip>
<template #default="{ row }">{{ summarizeTypes(row) }}</template>
</el-table-column>
</el-table>
<div v-else class="panel-empty tight">暂无测试记录</div>
</div>
</div>
<div class="side-block side-invite">
<div class="panel-head row">
<div>
<h2 class="panel-title">邀请小程序码</h2>
<p class="panel-desc">企业版进企业测评个人版进小程序首页</p>
</div>
<el-button size="small" type="primary" @click="loadInviteQrcode" :loading="inviteLoading">
{{ inviteQrcodeEnterprise || inviteQrcodePersonal ? '刷新' : '生成' }}
@@ -209,8 +138,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, computed, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { ref, reactive, onMounted, computed } from 'vue'
import {
User,
Document,
@@ -219,35 +147,17 @@ import {
Reading,
Histogram,
Medal,
Wallet,
Lock,
WarningFilled
Refresh
} from '@element-plus/icons-vue'
import { request } from '@/utils/request'
import { ElMessage } from 'element-plus'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart } from 'echarts/charts'
import { LineChart, BarChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
import VChart from 'vue-echarts'
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent, LegendComponent])
const router = useRouter()
interface TopUserRow {
id: number
username: string
phone: string
testCount: number
lastTestAt: number | null
mbtiType: string
pdpType: string
discType: string
faceMbtiType: string
faceDiscType: string
facePdpType: string
}
use([CanvasRenderer, LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent])
const stats = reactive({
totalUsers: 0,
@@ -255,70 +165,9 @@ const stats = reactive({
activeToday: 0
})
/** 企业钱包概览(分),来自 /admin/finance/overview */
const finance = reactive({
balanceFen: 0,
manualRechargeFen: 0,
frozenCommissionFen: 0,
monthConsumeFen: 0,
todayConsumeFen: 0,
avgDailyConsumeFen: 0,
suggestRechargeFen: 0,
})
const fenToYuan = (fen: number) => (Number(fen || 0) / 100).toFixed(2)
const suggestHint = computed(() => {
if (finance.avgDailyConsumeFen <= 0) return '暂无消耗,按需充值即可'
if (finance.suggestRechargeFen <= 0) return '余额可支撑 > 14 日'
return '≥ 2 周使用量'
})
const rechargeVisible = ref(false)
const rechargeLoading = ref(false)
const rechargeForm = reactive({ amountYuan: 500 })
const rechargeQrcode = ref('')
const quickAmounts = [100, 500, 1000, 3000, 5000]
const rechargeAmountYuanDisplay = computed(() => Number(rechargeForm.amountYuan || 0).toFixed(2))
function openRechargeDialog() {
rechargeQrcode.value = ''
if (finance.suggestRechargeFen > 0) {
rechargeForm.amountYuan = Math.max(100, Math.round(finance.suggestRechargeFen / 100))
} else if (!rechargeForm.amountYuan) {
rechargeForm.amountYuan = 500
}
rechargeVisible.value = true
}
async function generateRechargeQr() {
if (rechargeLoading.value) return
const amountFen = Math.round(Number(rechargeForm.amountYuan || 0) * 100)
if (amountFen <= 0) {
ElMessage.warning('请先输入充值金额')
return
}
rechargeLoading.value = true
rechargeQrcode.value = ''
try {
const res: any = await request.post('/admin/finance/recharge-qrcode', { amountFen })
const qr = res?.data?.qrcode || res?.qrcode
if (typeof qr === 'string' && qr) {
rechargeQrcode.value = qr
} else {
ElMessage.error(res?.message || '二维码生成失败')
}
} catch (e: any) {
ElMessage.error(e?.message || '二维码生成失败')
} finally {
rechargeLoading.value = false
}
}
const testTrends = ref<
Array<{ date: string; face: number; mbti: number; pdp: number; disc: number; total: number }>
>([])
const topTestUsers = ref<TopUserRow[]>([])
const testCatalog = ref<
Array<{ key: string; label: string; records: number; uniqueUsers: number }>
>([])
@@ -336,22 +185,33 @@ const inviteLoading = ref(false)
const inviteQrcodeEnterprise = ref<string>('')
const inviteQrcodePersonal = ref<string>('')
const inviteLoadError = ref<string>('')
const lastUpdatedAt = ref<number>(0)
/** 侧栏表格高度:随 `.table-wrap` 可用空间变化(适配 Top 20避免固定 220px 导致大片留白) */
const tableWrapRef = ref<HTMLElement | null>(null)
const sideTableHeight = ref(360)
let tableWrapResizeObserver: ResizeObserver | null = null
const chartModeOptions = [
{ label: '堆叠', value: 'stack' as const },
{ label: '折线', value: 'line' as const }
]
const chartMode = ref<'stack' | 'line'>('stack')
function updateSideTableHeight() {
const el = tableWrapRef.value
if (!el) {
return
const lastUpdatedText = computed(() => {
if (!lastUpdatedAt.value) return ''
const d = new Date(lastUpdatedAt.value)
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return `更新 ${hh}:${mm}`
})
const trendTotalsText = computed(() => {
const sum = { face: 0, mbti: 0, pdp: 0, disc: 0 }
for (const d of testTrends.value) {
sum.face += Number(d.face) || 0
sum.mbti += Number(d.mbti) || 0
sum.pdp += Number(d.pdp) || 0
sum.disc += Number(d.disc) || 0
}
const h = Math.floor(el.getBoundingClientRect().height)
if (h >= 120) {
sideTableHeight.value = h
}
}
const total = sum.face + sum.mbti + sum.pdp + sum.disc
return total ? `${total} 人次` : ''
})
const kpiCards = computed(() => [
{ key: 'u', label: '总用户数', value: stats.totalUsers, icon: User, tone: 'blue' },
@@ -359,10 +219,6 @@ const kpiCards = computed(() => [
{ key: 'a', label: '今日活跃', value: stats.activeToday, icon: TrendCharts, tone: 'purple' }
])
function goEnterpriseRecharge() {
openRechargeDialog()
}
const catalogIconMap: Record<string, { icon: typeof Camera; tone: string }> = {
face: { icon: Camera, tone: 'teal' },
mbti: { icon: Reading, tone: 'blue' },
@@ -388,9 +244,9 @@ function sliceItems(items: Array<{ label: string; count: number }>, n: number) {
}
const distributionBlocks = computed(() => {
const mbti = sliceItems(distributionMbti.value, 6)
const disc = sliceItems(distributionDisc.value, 6)
const pdp = sliceItems(distributionPdp.value, 6)
const mbti = sliceItems(distributionMbti.value, 8)
const disc = sliceItems(distributionDisc.value, 8)
const pdp = sliceItems(distributionPdp.value, 8)
const faceMerged: Array<{ label: string; count: number }> = []
const fh = faceSubtypeHints.value || { mbti: [], disc: [], pdp: [] }
const pushPref = (prefix: string, arr: Array<{ label: string; count: number }>, max: number) => {
@@ -401,9 +257,9 @@ const distributionBlocks = computed(() => {
k++
}
}
pushPref('面·MBTI ', fh.mbti, 2)
pushPref('面·DISC ', fh.disc, 2)
pushPref('面·PDP ', fh.pdp, 2)
pushPref('面·MBTI ', fh.mbti, 3)
pushPref('面·DISC ', fh.disc, 3)
pushPref('面·PDP ', fh.pdp, 3)
const blocks = [
{ key: 'mbti', title: 'MBTI答题', items: mbti, icon: Reading, barClass: 'bar-mbti' },
@@ -426,114 +282,110 @@ function barWidthPct(max: number, count: number) {
return `${Math.round((count / max) * 100)}%`
}
const seriesColors: Record<'face' | 'mbti' | 'pdp' | 'disc', { color: string; fill: string }> = {
mbti: { color: '#4F46E5', fill: 'rgba(79, 70, 229, 0.16)' },
pdp: { color: '#F59E0B', fill: 'rgba(245, 158, 11, 0.16)' },
disc: { color: '#0EA5E9', fill: 'rgba(14, 165, 233, 0.16)' },
face: { color: '#10B981', fill: 'rgba(16, 185, 129, 0.16)' }
}
const chartOption = computed(() => {
const dates = testTrends.value.map(d => d.date.slice(5))
const rows = testTrends.value
const mode = chartMode.value
const buildSeries = (name: string, key: 'face' | 'mbti' | 'pdp' | 'disc') => {
const c = seriesColors[key]
if (mode === 'stack') {
return {
name,
type: 'bar' as const,
stack: 'total',
barMaxWidth: 22,
itemStyle: {
color: c.color,
borderRadius: key === 'face' ? [4, 4, 0, 0] : 0
},
emphasis: { focus: 'series' as const },
data: rows.map(d => d[key])
}
}
return {
name,
type: 'line' as const,
smooth: 0.25,
showSymbol: false,
lineStyle: { width: 2.4, color: c.color },
itemStyle: { color: c.color },
areaStyle: { color: c.fill },
emphasis: { focus: 'series' as const },
data: rows.map(d => d[key])
}
}
return {
animationDuration: 480,
animationEasing: 'cubicOut',
tooltip: { trigger: 'axis' },
legend: {
data: ['人脸', 'MBTI', 'PDP', 'DISC'],
top: 0,
textStyle: { fontSize: 11, color: '#6b7280' }
animationEasing: 'cubicOut' as const,
color: [seriesColors.mbti.color, seriesColors.pdp.color, seriesColors.disc.color, seriesColors.face.color],
tooltip: {
trigger: 'axis',
axisPointer: { type: mode === 'stack' ? 'shadow' : 'line', lineStyle: { color: '#cbd5e1' } },
backgroundColor: '#ffffff',
borderColor: '#e2e8f0',
borderWidth: 1,
padding: [8, 12],
textStyle: { color: '#0f172a', fontSize: 12 },
extraCssText: 'box-shadow:0 6px 16px rgba(15,23,42,0.08);border-radius:10px;',
formatter: (params: any) => {
const arr = Array.isArray(params) ? params : params ? [params] : []
if (!arr.length) return ''
const first = arr[0]
let html = `<div style="font-weight:600;margin-bottom:6px;color:#0f172a">${first.axisValueLabel ?? first.axisValue ?? ''}</div>`
let sum = 0
for (const p of arr) {
const v = Number(p.data) || 0
sum += v
html += `<div style="display:flex;align-items:center;gap:6px;margin:2px 0;font-size:12px;color:#475569">${p.marker || ''}<span style="flex:1">${p.seriesName}</span><b style="color:#0f172a;font-variant-numeric:tabular-nums">${v}</b></div>`
}
html += `<div style="margin-top:6px;padding-top:6px;border-top:1px dashed #e2e8f0;font-size:12px;color:#64748b">当日合计 <b style="color:#0f172a">${sum}</b> 人次</div>`
return html
}
},
grid: { left: 36, right: 12, top: 36, bottom: 24 },
grid: { left: 44, right: 16, top: 16, bottom: 28 },
xAxis: {
type: 'category',
data: dates,
boundaryGap: false,
axisLine: { lineStyle: { color: '#e5e7eb' } },
axisLabel: { color: '#9ca3af', fontSize: 10 }
boundaryGap: mode === 'stack',
axisLine: { lineStyle: { color: '#e2e8f0' } },
axisTick: { show: false },
axisLabel: { color: '#64748b', fontSize: 11, margin: 10 }
},
yAxis: {
type: 'value',
minInterval: 1,
splitLine: { lineStyle: { color: '#f3f4f6' } },
axisLabel: { color: '#9ca3af', fontSize: 10 }
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { color: '#f1f5f9', type: 'dashed' } },
axisLabel: { color: '#94a3b8', fontSize: 11 }
},
series: [
{
name: '人脸',
type: 'line',
smooth: 0.35,
showSymbol: false,
lineStyle: { width: 2 },
itemStyle: { color: '#22c55e' },
data: testTrends.value.map(d => d.face)
},
{
name: 'MBTI',
type: 'line',
smooth: 0.35,
showSymbol: false,
lineStyle: { width: 2 },
itemStyle: { color: '#3b82f6' },
data: testTrends.value.map(d => d.mbti)
},
{
name: 'PDP',
type: 'line',
smooth: 0.35,
showSymbol: false,
lineStyle: { width: 2 },
itemStyle: { color: '#f97316' },
data: testTrends.value.map(d => d.pdp)
},
{
name: 'DISC',
type: 'line',
smooth: 0.35,
showSymbol: false,
lineStyle: { width: 2 },
itemStyle: { color: '#6366f1' },
data: testTrends.value.map(d => d.disc)
}
buildSeries('MBTI', 'mbti'),
buildSeries('PDP', 'pdp'),
buildSeries('DISC', 'disc'),
buildSeries('人脸', 'face')
]
}
})
function summarizeTypes(row: TopUserRow) {
const parts: string[] = []
if (row.mbtiType) parts.push(row.mbtiType)
if (row.pdpType) parts.push(row.pdpType)
if (row.discType) parts.push(row.discType)
const faceBits = [row.faceMbtiType, row.facePdpType, row.faceDiscType].filter(Boolean)
if (faceBits.length) parts.push('面:' + faceBits.join('/'))
return parts.length ? parts.join(' · ') : '—'
}
async function loadFinanceBalance() {
try {
const res: any = await request.get('/admin/finance/overview')
const d = res?.data || {}
finance.balanceFen = Number(d.balanceFen ?? 0)
finance.manualRechargeFen = Number(d.manualRechargeFen ?? 0)
finance.frozenCommissionFen = Number(d.frozenCommissionFen ?? 0)
finance.monthConsumeFen = Number(d.monthConsumeFen ?? 0)
finance.todayConsumeFen = Number(d.todayConsumeFen ?? 0)
finance.avgDailyConsumeFen = Number(d.avgDailyConsumeFen ?? 0)
finance.suggestRechargeFen = Number(d.suggestRechargeFen ?? 0)
} catch {
finance.balanceFen = 0
finance.manualRechargeFen = 0
finance.frozenCommissionFen = 0
finance.monthConsumeFen = 0
finance.todayConsumeFen = 0
finance.avgDailyConsumeFen = 0
finance.suggestRechargeFen = 0
}
}
const loadData = async () => {
loading.value = true
try {
const [response] = await Promise.all([request.get('/admin/dashboard'), loadFinanceBalance()])
const response: any = await request.get('/admin/dashboard')
if (response.code === 200 && response.data) {
stats.totalUsers = response.data.totalUsers || 0
stats.testsCompleted = response.data.testsCompleted || 0
stats.activeToday = response.data.activeToday || 0
testTrends.value = response.data.testTrends || []
topTestUsers.value = Array.isArray(response.data.topTestUsers) ? response.data.topTestUsers : []
testCatalog.value = Array.isArray(response.data.testCatalog) ? response.data.testCatalog : []
distributionMbti.value = Array.isArray(response.data.distributionMbti)
? response.data.distributionMbti
@@ -553,35 +405,26 @@ const loadData = async () => {
pdp: Array.isArray(fh.pdp) ? fh.pdp : []
}
: { mbti: [], disc: [], pdp: [] }
lastUpdatedAt.value = Date.now()
}
} catch (error: any) {
console.error('加载数据失败:', error)
ElMessage.error(error.message || '加载数据失败')
} finally {
loading.value = false
void nextTick(() => updateSideTableHeight())
}
}
const refreshAll = async () => {
await Promise.all([loadData(), loadInviteQrcode()])
ElMessage.success('数据已刷新')
}
onMounted(() => {
void nextTick(() => {
updateSideTableHeight()
if (tableWrapRef.value && typeof ResizeObserver !== 'undefined') {
tableWrapResizeObserver = new ResizeObserver(() => updateSideTableHeight())
tableWrapResizeObserver.observe(tableWrapRef.value)
}
})
window.addEventListener('resize', updateSideTableHeight)
void loadData()
void loadInviteQrcode()
})
onUnmounted(() => {
tableWrapResizeObserver?.disconnect()
tableWrapResizeObserver = null
window.removeEventListener('resize', updateSideTableHeight)
})
const loadInviteQrcode = async () => {
if (inviteLoading.value) return
inviteLoading.value = true
@@ -637,6 +480,19 @@ const loadInviteQrcode = async () => {
flex: 0 0 auto;
margin-bottom: 10px;
animation: dashFadeUp 0.4s ease-out both;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.dash-head-left { min-width: 0; }
.dash-head-right { display: flex; align-items: center; gap: 10px; }
.dash-updated {
font-size: 11px;
color: #94a3b8;
font-variant-numeric: tabular-nums;
}
.dash-title {
@@ -739,17 +595,19 @@ const loadInviteQrcode = async () => {
gap: 10px;
padding: 10px 12px;
background: #fff;
border-radius: 10px;
border: 1px solid #e5e7eb;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
border-radius: 12px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
animation: dashFadeUp 0.45s ease-out both;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
box-shadow 0.2s ease,
border-color 0.2s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.06);
border-color: #c7d2fe;
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.08);
}
}
@@ -764,23 +622,23 @@ const loadInviteQrcode = async () => {
flex-shrink: 0;
&.teal {
background: #f0fdfa;
color: #0d9488;
background: #ecfdf5;
color: #10b981;
}
&.blue {
background: #eff6ff;
color: #3b82f6;
background: #eef2ff;
color: #4f46e5;
}
&.indigo {
background: #eef2ff;
color: #6366f1;
background: #e0f2fe;
color: #0ea5e9;
}
&.amber {
background: #fffbeb;
color: #d97706;
color: #f59e0b;
}
}
@@ -856,21 +714,23 @@ const loadInviteQrcode = async () => {
.stat-card {
background: #fff;
border-radius: 10px;
padding: 12px 14px;
border-radius: 12px;
padding: 14px 16px;
display: flex;
justify-content: space-between;
align-items: center;
border: 1px solid #e5e7eb;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
border: 1px solid #e2e8f0;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
animation: dashFadeUp 0.45s ease-out both;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
box-shadow 0.2s ease,
border-color 0.2s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.08);
border-color: #c7d2fe;
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.08);
}
.stat-label {
@@ -898,16 +758,16 @@ const loadInviteQrcode = async () => {
font-size: 18px;
&.blue {
background: #eff6ff;
color: #3b82f6;
background: #eef2ff;
color: #4f46e5;
}
&.green {
background: #f0fdf4;
color: #22c55e;
background: #ecfdf5;
color: #10b981;
}
&.purple {
background: #faf5ff;
color: #a855f7;
background: #f5f3ff;
color: #7c3aed;
}
&.orange {
background: #fffbeb;
@@ -920,17 +780,17 @@ const loadInviteQrcode = async () => {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 1fr minmax(280px, 30%);
gap: 10px;
grid-template-columns: 1fr minmax(280px, 32%);
gap: 12px;
animation: dashFadeUp 0.5s ease-out 0.08s both;
}
.panel {
background: #fff;
border-radius: 10px;
border: 1px solid #e5e7eb;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
padding: 12px 14px;
border-radius: 12px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
padding: 14px 16px;
display: flex;
flex-direction: column;
min-height: 0;
@@ -1009,22 +869,88 @@ const loadInviteQrcode = async () => {
transition: width 0.35s ease;
&.bar-mbti {
background: #3b82f6;
background: #4f46e5;
}
&.bar-disc {
background: #6366f1;
background: #0ea5e9;
}
&.bar-pdp {
background: #f97316;
background: #f59e0b;
}
&.bar-face {
background: #14b8a6;
background: #10b981;
}
}
.chart-legend {
display: inline-flex;
background: #f1f5f9;
padding: 3px;
border-radius: 8px;
gap: 2px;
}
.chart-mode-btn {
border: 0;
background: transparent;
color: #64748b;
font-size: 12px;
padding: 4px 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.18s;
font-weight: 500;
&:hover {
color: #0f172a;
}
&.is-active {
background: #ffffff;
color: #0f172a;
font-weight: 600;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
}
}
.chart-sub-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 14px;
margin-bottom: 8px;
font-size: 11.5px;
color: #64748b;
}
.chart-meta-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.chart-meta-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 3px;
&.bar-mbti { background: #4f46e5; }
&.bar-pdp { background: #f59e0b; }
&.bar-disc { background: #0ea5e9; }
&.bar-face { background: #10b981; }
}
.chart-meta-sum {
margin-left: auto;
color: #475569;
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.distr-num {
text-align: right;
color: #6b7280;
@@ -1036,6 +962,28 @@ const loadInviteQrcode = async () => {
padding: 10px;
}
.panel-section-head {
flex: 0 0 auto;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 10px;
padding-bottom: 6px;
border-bottom: 1px solid #f3f4f6;
&--chart {
margin-top: 14px;
}
}
.panel-meta {
font-size: 11px;
color: #9ca3af;
font-weight: 500;
}
.panel-head {
flex: 0 0 auto;
margin-bottom: 8px;
@@ -1049,18 +997,12 @@ const loadInviteQrcode = async () => {
}
.panel-title {
margin: 0 0 2px;
font-size: 14px;
margin: 0;
font-size: 15px;
font-weight: 700;
color: #111827;
}
.panel-desc {
margin: 0;
font-size: 11px;
color: #9ca3af;
}
.chart-box {
flex: 1;
min-height: 0;
@@ -1070,7 +1012,7 @@ const loadInviteQrcode = async () => {
.trend-chart {
width: 100%;
height: 100%;
min-height: 140px;
min-height: 220px;
}
.panel-empty {

View File

@@ -134,7 +134,7 @@
<el-input v-model="distSearch" placeholder="搜索用户名..." class="search-input">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button type="primary" color="#7c3aed" @click="loadDistributors">搜索</el-button>
<el-button type="primary" @click="loadDistributors">搜索</el-button>
</div>
<el-table :data="distributors" style="width: 100%" class="custom-table" v-loading="loading">
<el-table-column label="分销商" min-width="160">
@@ -326,7 +326,7 @@
</div>
<div class="save-actions">
<el-button type="primary" color="#7c3aed" class="save-btn" @click="saveSettings" :loading="loading">保存配置</el-button>
<el-button type="primary" class="save-btn" @click="saveSettings" :loading="loading">保存配置</el-button>
</div>
</div>
</div>
@@ -827,45 +827,7 @@ onMounted(() => {
}
}
.custom-tabs-container {
background-color: #f3f4f6;
padding: 4px;
border-radius: 8px;
display: flex;
margin-bottom: 24px;
width: 100%;
.custom-tabs {
display: flex;
gap: 4px;
width: 100%;
.tab-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 8px 0;
font-size: 13px;
color: #6b7280;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
white-space: nowrap;
&:hover {
color: #111827;
}
&.active {
background-color: #fff;
color: #111827;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
}
}
}
/* .custom-tabs-container 视觉已统一在 admin-theme.css 的 .admin-layout .custom-tabs-container */
.stats-grid {
display: grid;

View File

@@ -31,7 +31,7 @@
</p>
</div>
<div class="save-actions">
<el-button type="primary" color="#7c3aed" class="save-btn" :loading="loading" @click="save">
<el-button type="primary" class="save-btn" :loading="loading" @click="save">
保存配置
</el-button>
</div>

View File

@@ -3,42 +3,70 @@
<!-- 嵌入系统设置企业余额时单独展示操作区独立页仍有完整 page-header -->
<div v-if="embedded" class="embedded-toolbar">
<div class="embedded-toolbar-actions">
<el-button @click="loadAll" :loading="loading">刷新</el-button>
<el-button type="primary" color="#7c3aed" @click="openRechargeDialog">生成充值码</el-button>
<el-button :icon="Refresh" @click="loadAll" :loading="loading">刷新</el-button>
<el-button type="primary" :icon="Coin" @click="openRechargeDialog">生成充值码</el-button>
</div>
</div>
<div v-if="!embedded" class="page-header">
<div>
<h2>企业余额</h2>
<p class="subtitle">{{ overview.enterpriseName || '当前企业' }}的余额测试收入充值流水</p>
<h2>财务管理</h2>
<p class="subtitle">{{ overview.enterpriseName || '当前企业' }}的余额消耗测试收入充值流水</p>
</div>
<div class="header-actions">
<el-button @click="loadAll" :loading="loading">刷新</el-button>
<el-button type="primary" color="#7c3aed" @click="openRechargeDialog">生成充值码</el-button>
<el-button :icon="Refresh" @click="loadAll" :loading="loading">刷新</el-button>
<el-button type="primary" :icon="Coin" @click="openRechargeDialog">生成充值码</el-button>
</div>
</div>
<div class="stats-grid" v-loading="loading">
<div class="stat-card">
<div class="stat-label">当前余额</div>
<div class="stats-grid stats-grid--8" v-loading="loading">
<div class="stat-card stat-card--emph">
<div class="stat-label">当前余额可用</div>
<div class="stat-value">¥{{ fenToYuan(overview.balanceFen) }}</div>
<div class="stat-desc">可用于企业佣金结算</div>
<div class="stat-desc">佣金结算与平台扣费共用</div>
</div>
<div class="stat-card">
<div class="stat-label">本月已扣款</div>
<div class="stat-value stat-value--out">¥{{ fenToYuan(overview.monthConsumeFen) }}</div>
<div class="stat-desc">平台扣费 + 佣金等出账</div>
</div>
<div class="stat-card">
<div class="stat-label">今日扣款</div>
<div class="stat-value stat-value--out">¥{{ fenToYuan(overview.todayConsumeFen) }}</div>
<div class="stat-desc">当日 0 点至今</div>
</div>
<div class="stat-card">
<div class="stat-label">本月日均消耗</div>
<div class="stat-value stat-value--muted">¥{{ fenToYuan(overview.avgDailyConsumeFen) }}</div>
<div class="stat-desc">按本月已过天数估算</div>
</div>
<div class="stat-card">
<div class="stat-label">建议充值参考</div>
<div class="stat-value">
<template v-if="(overview.suggestRechargeFen || 0) > 0">¥{{ fenToYuan(overview.suggestRechargeFen) }}</template>
<template v-else></template>
</div>
<div class="stat-desc">余额低于约 2 周用量时估算</div>
</div>
<div class="stat-card">
<div class="stat-label">历史手动充值累计</div>
<div class="stat-value">¥{{ fenToYuan(overview.manualRechargeFen) }}</div>
<div class="stat-desc">扫码充值入账合计</div>
</div>
<div class="stat-card">
<div class="stat-label">今日测试收入</div>
<div class="stat-value">¥{{ fenToYuan(overview.todayIncomeFen) }}</div>
<div class="stat-desc">仅统计 face / MBTI / DISC / PDP</div>
<div class="stat-desc">face / MBTI / DISC / PDP / SBTI </div>
</div>
<div class="stat-card">
<div class="stat-label">本月测试收入</div>
<div class="stat-value">¥{{ fenToYuan(overview.monthIncomeFen) }}</div>
<div class="stat-desc">企业用户支付后自动入账</div>
<div class="stat-desc">用户支付后入账</div>
</div>
<div class="stat-card">
<div class="stat-label">冻结佣金</div>
<div class="stat-value">¥{{ fenToYuan(overview.frozenCommissionFen) }}</div>
<div class="stat-desc">余额后自动解冻</div>
<div class="stat-desc">余额补足后自动解冻</div>
</div>
</div>
@@ -98,7 +126,7 @@
</el-form>
<template #footer>
<el-button @click="rechargeDialogVisible = false">取消</el-button>
<el-button type="primary" color="#7c3aed" :loading="rechargeLoading" @click="generateRechargeQrcode">生成小程序码</el-button>
<el-button type="primary" :loading="rechargeLoading" @click="generateRechargeQrcode">生成小程序码</el-button>
</template>
</el-dialog>
</div>
@@ -107,6 +135,7 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh, Coin } from '@element-plus/icons-vue'
import { request } from '@/utils/request'
withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
@@ -126,7 +155,11 @@ const overview = reactive({
totalIncomeFen: 0,
manualRechargeFen: 0,
frozenCommissionFen: 0,
paidOrderCount: 0
paidOrderCount: 0,
monthConsumeFen: 0,
todayConsumeFen: 0,
avgDailyConsumeFen: 0,
suggestRechargeFen: 0
})
const records = ref<any[]>([])
@@ -256,34 +289,71 @@ onMounted(() => {
margin-bottom: 20px;
}
.stats-grid--8 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@media (min-width: 1200px) {
.stats-grid--8 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.stat-card--emph {
grid-column: span 1;
border: 1px solid #c7d2fe !important;
background: linear-gradient(135deg, #eef2ff 0%, #ffffff 60%);
}
.stat-card--emph .stat-value {
color: #3730a3;
}
.stat-value--out {
color: #b45309;
}
.stat-value--muted {
color: #64748b;
font-size: 26px !important;
}
.stat-card,
.content-card {
background: #fff;
border-radius: 18px;
border: 1px solid #eef2f7;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
border-radius: 12px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 4px 12px rgba(15, 23, 42, 0.03);
}
.stat-card {
padding: 20px;
padding: 18px 20px;
transition: transform 0.18s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.stat-card:hover {
transform: translateY(-1px);
border-color: #c7d2fe;
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.08);
}
.stat-label {
font-size: 14px;
color: #6b7280;
font-size: 13px;
color: #64748b;
margin-bottom: 8px;
font-weight: 500;
}
.stat-value {
font-size: 30px;
font-size: 26px;
font-weight: 700;
color: #111827;
line-height: 1.2;
color: #0f172a;
line-height: 1.15;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
.stat-desc {
margin-top: 10px;
font-size: 13px;
font-size: 12px;
color: #94a3b8;
}

View File

@@ -23,7 +23,7 @@
</div>
<div class="form-item">
<label>开始按钮个人版</label>
<el-input v-model="miniprogramConfig.textConfig.startButtonText" placeholder="默认:开始面相测试" class="w-full" />
<el-input v-model="miniprogramConfig.textConfig.startButtonText" placeholder="默认:30秒测出你的性格" class="w-full" />
</div>
<div class="form-item">
<label>开始按钮企业版</label>
@@ -41,7 +41,7 @@
</div>
</div>
<div class="save-actions">
<el-button type="primary" color="#7c3aed" class="save-btn" @click="saveMiniprogramConfig" :loading="miniprogramLoading">
<el-button type="primary" class="save-btn" @click="saveMiniprogramConfig" :loading="miniprogramLoading">
保存小程序配置
</el-button>
</div>
@@ -58,7 +58,7 @@ const miniprogramConfig = reactive({
miniprogramName: '神仙团队AI性格测试',
textConfig: {
analyzingTitle: '正在分析中',
startButtonText: '开始面相测试',
startButtonText: '30秒测出你的性格',
startButtonEnterprise: '开始面部测试',
reportTitle: '分析报告',
aiAnalysisText: '智能分析'

View File

@@ -108,50 +108,7 @@ onMounted(() => {
}
}
.custom-tabs-container {
background-color: #f3f4f6;
padding: 4px;
border-radius: 8px;
margin-bottom: 20px;
width: 100%;
&.tabs-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.custom-tabs {
display: flex;
gap: 4px;
min-width: min-content;
&.tabs-many .tab-item {
flex: 0 0 auto;
padding: 8px 16px;
font-size: 13px;
}
.tab-item {
cursor: pointer;
border-radius: 6px;
white-space: nowrap;
color: #6b7280;
padding: 8px 18px;
transition: all 0.2s;
&:hover {
color: #111827;
}
&.active {
background: #fff;
color: #111827;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
}
}
}
/* .custom-tabs-container 视觉已统一在 admin-theme.css */
.hub-body.flat {
width: 100%;

View File

@@ -26,7 +26,7 @@
<div class="toolbar-right">
<el-button size="small" :disabled="!selectedId" @click="selectedId = null">取消选择</el-button>
<el-button size="small" @click="loadConfig">重置</el-button>
<el-button size="small" type="primary" color="#7c3aed" :loading="saving" @click="saveConfig">
<el-button size="small" type="primary" :loading="saving" @click="saveConfig">
保存配置
</el-button>
</div>

View File

@@ -52,7 +52,7 @@
<span>当前使用超管默认定价保存后将创建您的个人版专属配置</span>
</div>
<div class="save-actions">
<el-button type="primary" color="#7c3aed" class="save-btn" @click="savePersonal" :loading="loading">
<el-button type="primary" class="save-btn" @click="savePersonal" :loading="loading">
保存个人版价格
</el-button>
</div>
@@ -89,7 +89,7 @@
<span>当前使用超管默认企业定价保存后将创建您的企业版专属配置</span>
</div>
<div class="save-actions">
<el-button type="primary" color="#7c3aed" class="save-btn" @click="saveEnterprise" :loading="loading">
<el-button type="primary" class="save-btn" @click="saveEnterprise" :loading="loading">
保存企业版价格
</el-button>
</div>
@@ -209,41 +209,7 @@ onMounted(() => {
}
}
.custom-tabs-container {
background-color: #f3f4f6;
padding: 4px;
border-radius: 8px;
display: flex;
margin-bottom: 20px;
width: 100%;
.custom-tabs {
display: flex;
gap: 4px;
width: 100%;
.tab-item {
flex: 1;
padding: 6px 16px;
font-size: 13px;
color: #6b7280;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
white-space: nowrap;
text-align: center;
&:hover { color: #111827; }
&.active {
background-color: #fff;
color: #111827;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
}
}
}
/* .custom-tabs-container 视觉已统一在 admin-theme.css */
.pricing-content {
display: flex;

View File

@@ -40,7 +40,6 @@
<el-switch v-model="replayForce" />
<el-button
type="primary"
color="#7c3aed"
class="manual-test-btn"
:loading="replayLoading"
@click="replayTestResult"
@@ -86,7 +85,7 @@
</div>
</div>
<div class="save-actions save-actions-row">
<el-button type="primary" color="#7c3aed" class="save-btn" :loading="loading" @click="save">
<el-button type="primary" class="save-btn" :loading="loading" @click="save">
保存配置
</el-button>
</div>

View File

@@ -63,7 +63,7 @@
@change="handleImport"
style="display: none"
/>
<el-button size="small" type="primary" color="#7c3aed" style="color:#fff" @click="openAdd">
<el-button size="small" type="primary" @click="openAdd">
<el-icon class="mr-1"><Plus /></el-icon>新增题目
</el-button>
<el-button size="small" @click="triggerImport">
@@ -179,7 +179,7 @@
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" color="#7c3aed" :loading="saving" @click="saveQuestion">
<el-button type="primary" :loading="saving" @click="saveQuestion">
{{ isEditing ? '保存修改' : '创建题目' }}
</el-button>
</template>

View File

@@ -2,26 +2,44 @@
<div class="page-container" v-loading="loading">
<div class="page-header">
<div class="header-left">
<h2>系统设置</h2>
<p class="subtitle">管理员账号与企业余额小程序文案与海报请在用户运营中配置</p>
<h2>企业设置</h2>
<p class="subtitle">财务管理 · 终端展示小程序/海报· 账号与推送 · 功能开关</p>
</div>
</div>
<div class="settings-content">
<div class="custom-tabs-container">
<div class="custom-tabs">
<div
v-for="tab in tabs"
:key="tab.value"
:class="['tab-item', { active: activeTab === tab.value }]"
@click="selectTab(tab.value)"
>
{{ tab.label }}
</div>
</div>
<div class="pill-tabs" role="tablist">
<button
v-for="tab in tabs"
:key="tab.value"
type="button"
class="pill-tab"
:class="{ 'is-active': activeTab === tab.value }"
@click="selectTab(tab.value)"
>
{{ tab.label }}
</button>
</div>
<div class="tab-content-card" :class="{ 'flat-embed': activeTab === 'finance' }">
<div
class="tab-content-card"
:class="{ 'flat-embed': activeTab === 'finance' || activeTab === 'terminal' }"
>
<div v-if="activeTab === 'terminal'" class="terminal-wrap">
<div class="terminal-section">
<h3 class="terminal-h3">小程序展示与 TabBar</h3>
<p class="terminal-desc">文案Tab 配置等与小程序端 runtime 同步保存后建议在真机预览验收</p>
<MiniprogramConfigPanel ref="miniRef" />
</div>
<div class="terminal-section terminal-section--poster">
<h3 class="terminal-h3">分销海报</h3>
<p class="terminal-desc">推广海报素材与分销入口共用企业上下文</p>
<div class="poster-embed">
<PosterEditor />
</div>
</div>
</div>
<div v-if="activeTab === 'features'" class="tab-content" v-loading="permLoading">
<div class="content-header">
<h3>终端功能开关</h3>
@@ -39,7 +57,7 @@
<el-switch v-model="adminPerms[p.key]" />
</div>
<div class="save-actions">
<el-button type="primary" color="#7c3aed" class="save-btn" @click="saveAdminPermissions" :loading="permSaving">
<el-button type="primary" class="save-btn" @click="saveAdminPermissions" :loading="permSaving">
保存功能开关
</el-button>
</div>
@@ -70,11 +88,11 @@
active-text="测试完即上报"
inactive-text="付款后才上报"
inline-prompt
style="--el-switch-on-color: #7c3aed; --el-switch-off-color: #909399"
style="--el-switch-on-color: #4f46e5; --el-switch-off-color: #94a3b8"
/>
</div>
<div class="save-actions">
<el-button type="primary" color="#7c3aed" class="save-btn" :loading="cunkebaoSaving" @click="saveCunkebaoKeys">
<el-button type="primary" class="save-btn" :loading="cunkebaoSaving" @click="saveCunkebaoKeys">
保存存客宝设置
</el-button>
</div>
@@ -127,7 +145,7 @@
</div>
<div class="save-actions">
<el-button type="primary" color="#7c3aed" class="save-btn" @click="saveAccountSettings" :loading="loading">
<el-button type="primary" class="save-btn" @click="saveAccountSettings" :loading="loading">
<el-icon><DocumentCopy /></el-icon>
<span>保存凭据</span>
</el-button>
@@ -155,8 +173,10 @@ import { request } from '@/utils/request'
import { getAdminRole } from '@/utils/authStorage'
import Finance from './Finance.vue'
import PushHookConfigPanel from './PushHookConfigPanel.vue'
import MiniprogramConfigPanel from './MiniprogramConfigPanel.vue'
import PosterEditor from './PosterEditor.vue'
const TAB_IDS = ['account', 'pushhook', 'features', 'finance'] as const
const TAB_IDS = ['finance', 'terminal', 'account', 'pushhook', 'features'] as const
type TabId = (typeof TAB_IDS)[number]
function isTabId(s: string): s is TabId {
@@ -165,8 +185,9 @@ function isTabId(s: string): s is TabId {
const route = useRoute()
const router = useRouter()
const activeTab = ref<TabId>('account')
const activeTab = ref<TabId>('finance')
const loading = ref(false)
const miniRef = ref<InstanceType<typeof MiniprogramConfigPanel> | null>(null)
const isEnterpriseAdmin = () => getAdminRole() === 'enterprise_admin'
@@ -178,13 +199,14 @@ const canConfigureCunkebaoKeys = () => {
const tabs = computed(() => {
const rows: { label: string; value: TabId }[] = [
{ label: '财务管理', value: 'finance' },
{ label: '终端展示', value: 'terminal' },
{ label: '账号设置', value: 'account' },
{ label: '出站推送', value: 'pushhook' }
]
if (isEnterpriseAdmin() || canConfigureCunkebaoKeys()) {
rows.push({ label: '功能开关', value: 'features' })
}
rows.push({ label: '企业余额', value: 'finance' })
return rows
})
@@ -196,12 +218,12 @@ const applyRouteTab = () => {
}
if (typeof t === 'string' && isTabId(t)) {
if (t === 'features' && !isEnterpriseAdmin() && !canConfigureCunkebaoKeys()) {
activeTab.value = 'account'
activeTab.value = 'finance'
} else {
activeTab.value = t
}
} else {
activeTab.value = 'account'
activeTab.value = 'finance'
}
}
@@ -213,7 +235,7 @@ const selectTab = (tab: TabId) => {
q[k] = Array.isArray(v) ? String(v[0]) : String(v)
}
})
if (tab !== 'account') {
if (tab !== 'finance') {
q.tab = tab
}
router.replace({ path: '/admin/settings', query: Object.keys(q).length ? q : {} })
@@ -245,8 +267,7 @@ const adminPerms = reactive<Record<string, boolean>>(defaultAdminPermissions())
/** 测评类存客宝:共用一对 Key + 统一上报时机 */
const cunkebaoUnified = reactive({
enterprise: '',
personal: '',
apiKey: '',
reportTiming: 'after_paid' as 'after_paid' | 'after_test'
})
@@ -394,6 +415,9 @@ watch(
loadCunkebaoKeys()
}
}
if (tab === 'terminal') {
miniRef.value?.loadMiniprogramConfig?.()
}
}
)
@@ -408,6 +432,9 @@ onMounted(() => {
loadCunkebaoKeys()
}
}
if (activeTab.value === 'terminal') {
miniRef.value?.loadMiniprogramConfig?.()
}
})
</script>
@@ -490,50 +517,46 @@ onMounted(() => {
color: #374151;
}
.custom-tabs-container {
background-color: #f3f4f6;
.pill-tabs {
display: inline-flex;
background: #f1f5f9;
padding: 4px;
border-radius: 8px;
display: flex;
border-radius: 10px;
gap: 2px;
margin-bottom: 20px;
width: 100%;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.custom-tabs {
display: flex;
gap: 4px;
width: 100%;
.pill-tab {
border: 0;
background: transparent;
color: #64748b;
font-size: 13px;
font-weight: 500;
padding: 7px 18px;
border-radius: 6px;
cursor: pointer;
white-space: nowrap;
transition: all 0.18s;
.tab-item {
flex: 1;
padding: 6px 20px;
font-size: 13px;
color: #6b7280;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
white-space: nowrap;
text-align: center;
&:hover { color: #0f172a; }
&:hover {
color: #111827;
}
&.active {
background-color: #fff;
color: #111827;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
}
&.is-active {
background: #ffffff;
color: #0f172a;
font-weight: 600;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
}
}
.tab-content-card {
background: #fff;
border-radius: 10px;
border: 1px solid #f3f4f6;
padding: 32px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
border-radius: 12px;
border: 1px solid #e2e8f0;
padding: 28px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 4px 12px rgba(15, 23, 42, 0.03);
&.flat-embed {
background: transparent;
@@ -547,6 +570,39 @@ onMounted(() => {
width: 100%;
}
.terminal-wrap {
display: flex;
flex-direction: column;
gap: 28px;
}
.terminal-section {
background: #fafafa;
border-radius: 12px;
padding: 20px;
border: 1px solid #f3f4f6;
}
.terminal-section--poster {
padding-bottom: 12px;
}
.terminal-h3 {
margin: 0 0 6px;
font-size: 17px;
font-weight: 700;
color: #111827;
}
.terminal-desc {
margin: 0 0 16px;
font-size: 13px;
color: #6b7280;
line-height: 1.55;
}
.poster-embed {
background: #fff;
border-radius: 10px;
padding: 12px;
border: 1px solid #eef2f7;
}
.tab-content {
.content-header {
margin-bottom: 32px;

View File

@@ -3,7 +3,7 @@
<div class="page-header">
<div class="header-left">
<h2>用户测试画像</h2>
<p class="subtitle">以用户为核心 · 一行看清面容分析MBTI / DISC / PDP 结果</p>
<p class="subtitle">以用户为核心 · 面容分析MBTI / SBTI / DISC / PDP 分类展示点标签进对应测评记录</p>
</div>
<div class="header-actions">
<el-button variant="outline" size="small" @click="exportData">
@@ -12,22 +12,22 @@
</div>
</div>
<!-- 画像汇总条 -->
<div class="profile-summary">
<div class="profile-summary profile-summary--five">
<div class="summary-card">
<div class="summary-ic ic-blue">👥</div>
<div class="summary-body">
<div class="summary-label">用户数</div>
<div class="summary-value">{{ total }}</div>
<div class="summary-label">用户数</div>
<div class="summary-value">{{ pageUserCount }}</div>
<div class="summary-foot">符合条件的用户共 <strong>{{ total }}</strong> </div>
</div>
</div>
<div class="summary-card">
<div class="summary-ic ic-teal">🪞</div>
<div class="summary-body">
<div class="summary-label">已面容分析</div>
<div class="summary-label">已面容 / 面相</div>
<div class="summary-value">
{{ profileStats.faceCount }}
<span class="summary-sub">/ 本页 {{ users.length }}</span>
<span class="summary-sub">/ {{ pageUserCount }}</span>
</div>
</div>
</div>
@@ -37,7 +37,17 @@
<div class="summary-label">已完成 MBTI</div>
<div class="summary-value">
{{ profileStats.mbtiCount }}
<span class="summary-sub">/ 本页 {{ users.length }}</span>
<span class="summary-sub">/ {{ pageUserCount }}</span>
</div>
</div>
</div>
<div class="summary-card">
<div class="summary-ic ic-violet">🃏</div>
<div class="summary-body">
<div class="summary-label">已完成 SBTI</div>
<div class="summary-value">
{{ profileStats.sbtiCount }}
<span class="summary-sub">/ {{ pageUserCount }}</span>
</div>
</div>
</div>
@@ -47,7 +57,7 @@
<div class="summary-label">至少一项测试</div>
<div class="summary-value">
{{ profileStats.anyTestCount }}
<span class="summary-sub">/ 本页 {{ users.length }}</span>
<span class="summary-sub">/ {{ pageUserCount }}</span>
</div>
</div>
</div>
@@ -128,12 +138,13 @@
<template #default="{ row }">
<div class="test-results">
<el-tag v-if="row.mbtiType" size="small" class="result-tag tag-mbti" @click.stop="handleClickTestTag(row, 'mbti')">MBTI · {{ row.mbtiType }}</el-tag>
<el-tag v-if="row.sbtiType" size="small" class="result-tag tag-sbti" @click.stop="handleClickTestTag(row, 'sbti')">SBTI · {{ row.sbtiType }}</el-tag>
<el-tag v-if="row.discType" size="small" class="result-tag tag-disc" type="info" @click.stop="handleClickTestTag(row, 'disc')">DISC · {{ row.discType }}</el-tag>
<el-tag v-if="row.pdpType" size="small" class="result-tag tag-pdp" type="warning" @click.stop="handleClickTestTag(row, 'pdp')">PDP · {{ row.pdpType }}</el-tag>
<el-tag v-if="row.faceMbtiType" size="small" class="result-tag tag-face" type="success" @click.stop="handleClickTestTag(row, 'face')">·{{ row.faceMbtiType }}</el-tag>
<el-tag v-if="row.faceDiscType" size="small" class="result-tag tag-face" type="success" effect="plain" @click.stop="handleClickTestTag(row, 'face')">·{{ row.faceDiscType }}</el-tag>
<el-tag v-if="row.facePdpType" size="small" class="result-tag tag-face" type="success" effect="plain" @click.stop="handleClickTestTag(row, 'face')">·{{ row.facePdpType }}</el-tag>
<span v-if="!row.mbtiType && !row.discType && !row.pdpType && !row.faceMbtiType && !row.faceDiscType && !row.facePdpType" class="no-test">暂无测评</span>
<span v-if="!row.mbtiType && !row.sbtiType && !row.discType && !row.pdpType && !row.faceMbtiType && !row.faceDiscType && !row.facePdpType" class="no-test">暂无测评</span>
</div>
</template>
</el-table-column>
@@ -172,7 +183,7 @@
v-model:current-page="currentPage"
:page-size="pageSize"
:total="total"
layout="prev, pager, next"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
</div>
@@ -731,22 +742,33 @@ const currentTestType = computed(() => (currentTest.value?.testType || '').toLow
const users = ref<any[]>([])
/** 页内画像汇总 */
const pageUserCount = computed(() => (users.value || []).length)
/** 页内画像汇总(严格只遍历当前表格 users与分页接口返回的本页 list 一致) */
const profileStats = computed(() => {
const list = users.value || []
let faceCount = 0
let mbtiCount = 0
let sbtiCount = 0
let anyTestCount = 0
for (const u of list) {
const hasFace = !!(u.faceMbtiType || u.faceDiscType || u.facePdpType)
const hasFace = !!(
u.faceMbtiType ||
u.faceDiscType ||
u.facePdpType ||
u.faceType ||
u.coldFaceLevel
)
const hasMbti = !!u.mbtiType
const hasSbti = !!u.sbtiType
const hasDisc = !!u.discType
const hasPdp = !!u.pdpType
if (hasFace) faceCount++
if (hasMbti) mbtiCount++
if (hasFace || hasMbti || hasDisc || hasPdp) anyTestCount++
if (hasSbti) sbtiCount++
if (hasFace || hasMbti || hasSbti || hasDisc || hasPdp) anyTestCount++
}
return { faceCount, mbtiCount, anyTestCount }
return { faceCount, mbtiCount, sbtiCount, anyTestCount }
})
async function loadUsers() {
@@ -761,12 +783,13 @@ async function loadUsers() {
params.coldFaceLevel = coldFaceFilter.value.join(',')
}
const res: any = await request.get('/admin/app-users', { params })
const list = res.data?.list ?? res?.list ?? []
const payload = res.data ?? res
const list = Array.isArray(payload?.list) ? payload.list : Array.isArray(payload) ? payload : []
users.value = list.map((row: any) => ({
...row,
username: row.username ?? row.nickname ?? ('用户' + row.id)
}))
total.value = res.data?.total ?? res?.total ?? 0
total.value = Number(payload?.total ?? 0) || 0
} catch {
users.value = []
total.value = 0
@@ -1212,6 +1235,13 @@ onMounted(() => {
gap: 12px;
margin-bottom: 16px;
&.profile-summary--five {
grid-template-columns: repeat(5, minmax(0, 1fr));
@media (max-width: 1100px) {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 900px) {
grid-template-columns: repeat(2, 1fr);
}
@@ -1246,6 +1276,7 @@ onMounted(() => {
&.ic-blue { background: #eff6ff; }
&.ic-teal { background: #f0fdfa; }
&.ic-indigo { background: #eef2ff; }
&.ic-violet { background: #f5f3ff; }
&.ic-amber { background: #fffbeb; }
}
@@ -1273,6 +1304,14 @@ onMounted(() => {
margin-left: 6px;
}
}
.summary-foot {
margin-top: 8px;
font-size: 12px;
color: #6b7280;
line-height: 1.4;
font-weight: 400;
}
}
.search-section {
@@ -1408,6 +1447,12 @@ onMounted(() => {
cursor: pointer;
}
:deep(.tag-sbti.el-tag) {
--el-tag-bg-color: #f5f3ff;
--el-tag-border-color: #ddd6fe;
--el-tag-text-color: #5b21b6;
}
.no-test {
font-size: 13px;
color: #9ca3af;

View File

@@ -2,31 +2,29 @@
<div class="users-hub">
<div class="hub-header">
<h2>用户运营</h2>
<p class="hub-subtitle">测试用户数据小程序展示文案与分销海报一站式维护</p>
</div>
<div class="custom-tabs-container tabs-scroll">
<div class="custom-tabs tabs-many">
<div
v-for="t in innerTabs"
:key="t.value"
:class="['tab-item', { active: activeTab === t.value }]"
@click="selectTab(t.value)"
>
{{ t.label }}
</div>
</div>
<div class="pill-tabs" role="tablist">
<button
v-for="t in innerTabs"
:key="t.value"
type="button"
class="pill-tab"
:class="{ 'is-active': activeTab === t.value }"
@click="selectTab(t.value)"
>
{{ t.label }}
</button>
</div>
<div
class="hub-card"
:class="{ 'no-pad': activeTab === 'poster', 'flat-embed': activeTab === 'users' }"
:class="{ 'flat-embed': activeTab === 'users' || activeTab === 'journey' || activeTab === 'rfm' }"
>
<Users v-if="activeTab === 'users'" embedded />
<MiniprogramConfigPanel v-if="activeTab === 'miniprogram'" ref="miniRef" />
<div v-if="activeTab === 'poster'" class="poster-wrap">
<PosterEditor />
</div>
<TopTestUsersPanel v-else-if="activeTab === 'top20'" />
<UserJourneyPanel v-else-if="activeTab === 'journey'" />
<UserRfmPanel v-else-if="activeTab === 'rfm'" />
</div>
</div>
</template>
@@ -35,10 +33,11 @@
import { ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Users from './Users.vue'
import PosterEditor from './PosterEditor.vue'
import MiniprogramConfigPanel from './MiniprogramConfigPanel.vue'
import TopTestUsersPanel from '@/components/TopTestUsersPanel.vue'
import UserJourneyPanel from '@/components/UserJourneyPanel.vue'
import UserRfmPanel from '@/components/UserRfmPanel.vue'
const TAB_IDS = ['users', 'miniprogram', 'poster'] as const
const TAB_IDS = ['users', 'journey', 'rfm', 'top20'] as const
type TabId = (typeof TAB_IDS)[number]
function isTabId(s: string): s is TabId {
@@ -48,16 +47,21 @@ function isTabId(s: string): s is TabId {
const route = useRoute()
const router = useRouter()
const activeTab = ref<TabId>('users')
const miniRef = ref<InstanceType<typeof MiniprogramConfigPanel> | null>(null)
const innerTabs: { label: string; value: TabId }[] = [
{ label: '用户列表', value: 'users' },
{ label: '小程序配置', value: 'miniprogram' },
{ label: '海报配置', value: 'poster' }
{ label: '旅程漏斗', value: 'journey' },
{ label: 'RFM 价值分层', value: 'rfm' },
{ label: '测评 Top 20', value: 'top20' }
]
const applyRouteTab = () => {
const t = route.query.tab
if (t === 'miniprogram' || t === 'poster') {
router.replace({ path: '/admin/settings', query: { tab: 'terminal' } })
activeTab.value = 'users'
return
}
if (typeof t === 'string' && isTabId(t)) {
activeTab.value = t
} else {
@@ -83,23 +87,11 @@ watch(
() => route.query.tab,
() => {
applyRouteTab()
if (activeTab.value === 'miniprogram') {
miniRef.value?.loadMiniprogramConfig?.()
}
}
)
watch(activeTab, (tab) => {
if (tab === 'miniprogram') {
miniRef.value?.loadMiniprogramConfig?.()
}
})
onMounted(() => {
applyRouteTab()
if (activeTab.value === 'miniprogram') {
miniRef.value?.loadMiniprogramConfig?.()
}
})
</script>
@@ -114,77 +106,55 @@ onMounted(() => {
margin-bottom: 20px;
h2 {
margin: 0 0 6px;
margin: 0;
font-size: 22px;
font-weight: 700;
color: #111827;
}
.hub-subtitle {
margin: 0;
font-size: 14px;
color: #6b7280;
}
}
.custom-tabs-container {
background-color: #f3f4f6;
.pill-tabs {
display: inline-flex;
background: #f1f5f9;
padding: 4px;
border-radius: 8px;
border-radius: 10px;
gap: 2px;
margin-bottom: 20px;
width: 100%;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
&.tabs-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
.pill-tab {
border: 0;
background: transparent;
color: #64748b;
font-size: 13px;
font-weight: 500;
padding: 7px 18px;
border-radius: 6px;
cursor: pointer;
white-space: nowrap;
transition: all 0.18s;
&:hover {
color: #0f172a;
}
.custom-tabs {
display: flex;
gap: 4px;
min-width: min-content;
&.tabs-many .tab-item {
flex: 0 0 auto;
padding: 8px 16px;
font-size: 13px;
}
.tab-item {
flex: 1;
padding: 8px 18px;
font-size: 14px;
color: #6b7280;
cursor: pointer;
border-radius: 6px;
white-space: nowrap;
text-align: center;
transition: all 0.2s;
&:hover {
color: #111827;
}
&.active {
background: #fff;
color: #111827;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
}
&.is-active {
background: #ffffff;
color: #0f172a;
font-weight: 600;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
}
}
.hub-card {
background: #fff;
border-radius: 10px;
border: 1px solid #f3f4f6;
padding: 28px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
&.no-pad {
padding: 16px;
}
border-radius: 12px;
border: 1px solid #e2e8f0;
padding: 24px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 4px 12px rgba(15, 23, 42, 0.03);
&.flat-embed {
background: transparent;
@@ -193,8 +163,4 @@ onMounted(() => {
padding: 0;
}
}
.poster-wrap {
min-height: 720px;
}
</style>

View File

@@ -21,6 +21,13 @@
</div>
</div>
<el-alert type="info" :closable="false" class="alert-notification" style="margin-bottom: 16px">
<template #title>神仙 AI 对话说明</template>
小程序神仙 AI<strong>不设每日对话条数上限</strong>由本页启用的服务商直接回复若用户端仍提示今日对话次数已用完请将服务器上的
<code>api/app/controller/api/AiChat.php</code> <code>api/app/controller/api/AppConfig.php</code>
更新为仓库最新版并重启 PHP
</el-alert>
<!-- 余额告警通知 -->
<el-alert
v-if="alerts.length > 0"
@@ -176,7 +183,6 @@
<div class="card-actions">
<el-button
type="primary"
color="#a855f7"
size="small"
@click="handleSave(provider.id)"
:loading="saving === provider.id"
@@ -340,7 +346,6 @@
<!-- 保存展开区的配置 -->
<el-button
type="primary"
color="#a855f7"
size="small"
@click="handleSave(provider.id)"
:loading="saving === provider.id"

View File

@@ -6,7 +6,7 @@
<p class="subtitle">管理数据库连接备份和恢复</p>
</div>
<div class="header-actions">
<el-button type="primary" color="#ef4444" @click="handleBackup">
<el-button type="primary" @click="handleBackup">
<el-icon class="mr-1"><DocumentCopy /></el-icon>备份数据库
</el-button>
</div>

View File

@@ -257,7 +257,7 @@
<div class="form-grid">
<div class="form-item">
<label>最低提现金额 ()</label>
<el-input-number v-model="minWithdraw" :min="1" :max="200" :precision="2" class="w-full" />
<el-input-number v-model="minWithdraw" :min="0.01" :max="200" :precision="2" class="w-full" />
</div>
<div class="form-item">
<label>最高提现金额 ()</label>
@@ -315,7 +315,7 @@
</div>
</div>
<div class="save-actions">
<el-button type="primary" color="#7c3aed" class="save-btn" @click="saveSettings" :loading="loading">保存配置</el-button>
<el-button type="primary" class="save-btn" @click="saveSettings" :loading="loading">保存配置</el-button>
</div>
</div>
</div>
@@ -386,7 +386,7 @@ const loadingTopAgents = ref(false)
const distEnabled = ref(true)
const promoCenterTitle = ref('推广中心')
const minWithdraw = ref(1)
const minWithdraw = ref(0.01)
const maxWithdraw = ref(0)
const requireAudit = ref(true)
const withdrawFee = ref(0)
@@ -540,7 +540,7 @@ const loadSettings = async () => {
const d = res.data
distEnabled.value = d.enabled ?? true
promoCenterTitle.value = d.promoCenterTitle ?? '推广中心'
minWithdraw.value = d.minWithdraw ?? 1
minWithdraw.value = d.minWithdraw ?? 0.01
maxWithdraw.value = d.maxWithdraw ?? 0
requireAudit.value = d.requireAudit !== false
withdrawFee.value = d.withdrawFee ?? 0
@@ -617,21 +617,7 @@ onMounted(() => { loadOverview(); loadTopAgents() })
}
}
.custom-tabs-container {
background-color: #f3f4f6; padding: 4px;
border-radius: 8px; display: flex; margin-bottom: 24px; width: 100%;
.custom-tabs {
display: flex; gap: 4px; width: 100%;
.tab-item {
flex: 1; display: flex; align-items: center; justify-content: center;
padding: 8px 0; font-size: 13px; color: #6b7280;
cursor: pointer; border-radius: 6px; transition: all 0.2s;
white-space: nowrap;
&:hover { color: #111827; }
&.active { background: #fff; color: #111827; font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.05); }
}
}
}
/* .custom-tabs-container 视觉已统一在 admin-theme.css */
.overview-section {
.stats-grid {

View File

@@ -9,7 +9,7 @@
<el-button variant="outline" @click="handleRefresh">
<el-icon class="mr-1"><Refresh /></el-icon>刷新
</el-button>
<el-button type="primary" color="#3b82f6" @click="showCreateDialog = true">
<el-button type="primary" @click="showCreateDialog = true">
<el-icon class="mr-1"><Plus /></el-icon>新建企业
</el-button>
</div>
@@ -46,7 +46,7 @@
<el-button variant="outline" @click="handleRefresh">
<el-icon class="mr-1"><Refresh /></el-icon>刷新
</el-button>
<el-button type="primary" color="#3b82f6" @click="showCreateDialog = true">
<el-button type="primary" @click="showCreateDialog = true">
<el-icon class="mr-1"><Plus /></el-icon>新建企业
</el-button>
</div>
@@ -254,7 +254,7 @@
<template #footer>
<div class="dialog-footer">
<el-button @click="showCreateDialog = false" class="cancel-btn">取消</el-button>
<el-button type="primary" color="#ef4444" @click="handleCreateEnterprise" class="submit-btn" :loading="creating">
<el-button type="primary" @click="handleCreateEnterprise" class="submit-btn" :loading="creating">
立即创建
</el-button>
</div>
@@ -336,7 +336,7 @@
<template #footer>
<div class="dialog-footer">
<el-button @click="showEditDialog = false" class="cancel-btn">取消</el-button>
<el-button type="primary" color="#ef4444" @click="handleSaveEdit" class="submit-btn">
<el-button type="primary" @click="handleSaveEdit" class="submit-btn">
保存修改
</el-button>
</div>
@@ -362,7 +362,7 @@
<div v-loading="viewLoading" class="enterprise-detail-content">
<div v-if="viewEnterpriseData" class="ud-wrap">
<!-- 左侧企业概览与用户详情弹窗 ud-side 一致 -->
<!-- 左侧企业数据摘要与用户详情弹窗 ud-side 一致 -->
<aside class="ud-side">
<div class="ud-avatar-block">
<div class="ud-avatar-letter">{{ enterpriseAvatarLetter }}</div>
@@ -452,7 +452,7 @@
<div class="ud-roles">
<div class="ud-role">
<span class="ud-role-n">已支付</span>
<el-progress :percentage="orderPaidProgressPct" :stroke-width="6" :show-text="false" color="#7c3aed" />
<el-progress :percentage="orderPaidProgressPct" :stroke-width="6" :show-text="false" color="var(--sa-primary)" />
<span class="ud-role-p">{{ viewEnterpriseData.orderStats?.paidCount ?? 0 }}</span>
</div>
</div>
@@ -468,12 +468,12 @@
<div class="ud-roles">
<div class="ud-role">
<span class="ud-role-n">事件</span>
<el-progress :percentage="analyticsEventProgressPct" :stroke-width="6" :show-text="false" color="#7c3aed" />
<el-progress :percentage="analyticsEventProgressPct" :stroke-width="6" :show-text="false" color="var(--sa-primary)" />
<span class="ud-role-p">{{ viewEnterpriseData.analyticsStats?.eventTotal ?? 0 }}</span>
</div>
<div class="ud-role">
<span class="ud-role-n">page_view</span>
<el-progress :percentage="analyticsPvProgressPct" :stroke-width="6" :show-text="false" color="#a855f7" />
<el-progress :percentage="analyticsPvProgressPct" :stroke-width="6" :show-text="false" color="var(--sa-accent)" />
<span class="ud-role-p">{{ viewEnterpriseData.analyticsStats?.pageViewCount ?? 0 }}</span>
</div>
</div>
@@ -736,7 +736,6 @@
<el-button @click="showInviteQrcodeDialog = false" class="cancel-btn">关闭</el-button>
<el-button
type="primary"
color="#3b82f6"
:loading="inviteQrcodeLoading"
@click="loadInviteQrcodeForDialog"
>

View File

@@ -637,43 +637,7 @@ watch(activeTab, (tab) => {
gap: 0;
}
.custom-tabs-container {
background-color: #f3f4f6;
padding: 4px;
border-radius: 8px;
display: flex;
margin-bottom: 20px;
width: 100%;
.custom-tabs {
display: flex;
gap: 4px;
width: 100%;
.tab-item {
flex: 1;
padding: 6px 20px;
font-size: 13px;
color: #6b7280;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
white-space: nowrap;
text-align: center;
&:hover {
color: #111827;
}
&.active {
background-color: #fff;
color: #111827;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
}
}
}
/* .custom-tabs-container 视觉已统一在 admin-theme.css */
.tab-content-card {
background: #fff;

View File

@@ -26,7 +26,7 @@
<div class="toolbar-right">
<el-button size="small" :disabled="!selectedId" @click="selectedId = null">取消选择</el-button>
<el-button size="small" @click="loadConfig">重置</el-button>
<el-button size="small" type="primary" color="#6366f1" :loading="saving" @click="saveConfig">
<el-button size="small" type="primary" :loading="saving" @click="saveConfig">
保存配置
</el-button>
</div>

View File

@@ -87,7 +87,7 @@
</div>
</div>
<div class="save-actions">
<el-button type="primary" color="#ef4444" class="save-btn" @click="savePersonal">
<el-button type="primary" class="save-btn" @click="savePersonal">
保存个人版价格设置
</el-button>
</div>
@@ -160,7 +160,7 @@
</div>
</div>
<div class="save-actions">
<el-button type="primary" color="#ef4444" class="save-btn" @click="saveEnterprise">
<el-button type="primary" class="save-btn" @click="saveEnterprise">
保存企业版价格设置
</el-button>
</div>
@@ -251,7 +251,7 @@
<span v-else>可通过上方列表自由新增/修改/下线个人版与企业版深度服务类目保存后小程序开通会员页会自动同步</span>
</div>
<div class="save-actions">
<el-button type="primary" color="#ef4444" class="save-btn" @click="saveDeep">
<el-button type="primary" class="save-btn" @click="saveDeep">
保存深度服务配置
</el-button>
</div>
@@ -285,7 +285,11 @@
</div>
<div class="deep-form-item">
<label>价格单位</label>
<el-input v-model="personalDraft.priceUnit" placeholder="/次" />
<el-input v-model="personalDraft.priceUnit" placeholder="如 /次、/小时、/2小时" />
</div>
<div class="deep-form-item deep-form-item-full" v-if="personalDraft.actionType === 'buy'">
<label>购买按钮文案</label>
<el-input v-model="personalDraft.purchaseButtonText" placeholder="空则小程序默认「了解自己并付款」;例:了解并付款" />
</div>
<div class="deep-form-item deep-form-item-full">
<label>副标题</label>
@@ -329,8 +333,8 @@
<el-input v-model="personalDraft.productKey" placeholder="如 personal_insight" />
</div>
<div class="deep-form-item">
<label>客服微信</label>
<el-input v-model="personalDraft.serviceWechat" placeholder="展示给用户的客服微信号" />
<label>客服微信备档</label>
<el-input v-model="personalDraft.serviceWechat" placeholder="已不再在小程序成功弹窗展示;线索以存客宝+飞书群通知为准" />
</div>
<div class="deep-form-item">
<label>存客宝KEY</label>
@@ -404,8 +408,8 @@
<el-input v-model="enterpriseDraft.buttonText" placeholder="如 申请咨询" />
</div>
<div class="deep-form-item">
<label>客服微信</label>
<el-input v-model="enterpriseDraft.serviceWechat" placeholder="展示给用户的客服微信号" />
<label>客服微信备档</label>
<el-input v-model="enterpriseDraft.serviceWechat" placeholder="已不再在小程序成功弹窗展示;线索以存客宝+飞书群通知为准" />
</div>
<div class="deep-form-item">
<label>存客宝KEY</label>
@@ -486,6 +490,8 @@ interface PersonalCategory {
subtitle: string
featuresText: string
actionType: 'buy' | 'consult'
/** 小程序「立即购买」主按钮文案,空则接口默认「了解自己并付款」 */
purchaseButtonText: string
productKey: string
serviceWechat: string
consultWechat: string
@@ -517,6 +523,7 @@ function createPersonalCategory (): PersonalCategory {
subtitle: '',
featuresText: '',
actionType: 'buy',
purchaseButtonText: '',
productKey: ts,
serviceWechat: '',
consultWechat: '',
@@ -581,6 +588,7 @@ const loadPricing = async () => {
subtitle: c.subtitle || '',
featuresText: Array.isArray(c.features) ? c.features.join('\n') : '',
actionType: (c.actionType === 'consult' ? 'consult' : 'buy'),
purchaseButtonText: c.purchaseButtonText ?? '',
productKey: c.productKey || 'personal_insight',
serviceWechat: c.serviceWechat ?? '',
consultWechat: c.consultWechat ?? '',
@@ -750,6 +758,7 @@ const saveDeep = async () => {
? item.featuresText.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)
: [],
actionType: item.actionType || 'buy',
purchaseButtonText: (item.purchaseButtonText ?? '').trim(),
productKey: item.productKey || 'personal_insight',
serviceWechat: item.serviceWechat ?? '',
consultWechat: item.consultWechat ?? '',
@@ -831,43 +840,7 @@ onMounted(() => {
gap: 0;
}
.custom-tabs-container {
background-color: #f3f4f6;
padding: 4px;
border-radius: 8px;
display: flex;
margin-bottom: 20px;
width: 100%;
.custom-tabs {
display: flex;
gap: 4px;
width: 100%;
.tab-item {
flex: 1;
padding: 6px 20px;
font-size: 13px;
color: #6b7280;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
white-space: nowrap;
text-align: center;
&:hover {
color: #111827;
}
&.active {
background-color: #fff;
color: #111827;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
}
}
}
/* .custom-tabs-container 视觉已统一在 admin-theme.css */
.tab-content-card {
background: #fff;

View File

@@ -66,7 +66,7 @@
@change="handleImport"
style="display: none"
/>
<el-button size="small" type="primary" color="#ef4444" style="color:#fff" @click="openAdd">
<el-button size="small" type="primary" @click="openAdd">
<el-icon class="mr-1"><Plus /></el-icon>新增题目
</el-button>
<el-button size="small" @click="triggerImport">
@@ -193,7 +193,7 @@
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" color="#ef4444" :loading="saving" @click="saveQuestion">
<el-button type="primary" :loading="saving" @click="saveQuestion">
{{ isEditing ? '保存修改' : '创建题目' }}
</el-button>
</template>

View File

@@ -49,30 +49,78 @@
<el-icon class="header-icon"><Document /></el-icon>
<span>小程序审核模式</span>
</div>
<p class="header-description">提交微信审核前开启审核通过后关闭开启后小程序将隐藏所有AI相关功能</p>
<p class="header-description">提交微信审核前按需开启面相审核与神仙 AI提审可分开控制避免误关问卷类功能</p>
</div>
</template>
<div class="card-content">
<div class="review-mode-alert" :class="{ active: systemConfig.maintenanceMode }">
<div class="review-mode-alert" :class="{ active: systemConfig.maintenanceMode || systemConfig.miniprogramAuditMode }">
<div class="review-mode-status">
<span class="review-dot" :class="{ on: systemConfig.maintenanceMode }"></span>
<span class="review-status-text">{{ systemConfig.maintenanceMode ? '审核模式已开启' : '审核模式已关闭' }}</span>
<span class="review-dot" :class="{ on: systemConfig.maintenanceMode || systemConfig.miniprogramAuditMode }"></span>
<span class="review-status-text">{{
systemConfig.maintenanceMode || systemConfig.miniprogramAuditMode ? '审核/提审开关已开启(至少一项)' : '审核与提审均已关闭'
}}</span>
</div>
<p class="review-mode-desc">{{ systemConfig.maintenanceMode ? '当前小程序处于审核状态AI面相分析功能已隐藏' : '当前小程序正常运行,所有功能可用' }}</p>
<p class="review-mode-desc">
面相审核{{ systemConfig.maintenanceMode ? '开' : '关' }} · 神仙 AI 提审隐藏{{ systemConfig.miniprogramAuditMode ? '开' : '关' }}
</p>
</div>
<div class="switch-section">
<div class="switch-item">
<div class="switch-info">
<p class="switch-title">开启审核模式</p>
<p class="switch-desc">开启后小程序将执行以下变更</p>
<p class="switch-title">面相 / 拍摄审核模式maintenanceMode</p>
<p class="switch-desc">隐藏拍摄 Tab面相入口与部分AI文案保留问卷测试</p>
</div>
<el-switch
v-model="systemConfig.maintenanceMode"
active-color="#ef4444"
inactive-color="#d1d5db"
style="--el-switch-on-color: var(--sa-accent); --el-switch-off-color: #cbd5e1"
/>
</div>
<div class="switch-item" style="margin-top: 16px">
<div class="switch-info">
<p class="switch-title">小程序提审模式 · 隐藏神仙 AIminiprogramAuditMode</p>
<p class="switch-desc">隐藏底部神仙 AITab对话/简历/深度报告入口后端同步关闭对话与报告接口同时关闭虚拟商品链路不下发深度套餐与报告付费标记拦截支付下单隐藏了解自己购买页入口用于微信深度合成虚拟支付/iOS类审核意见</p>
</div>
<el-switch
v-model="systemConfig.miniprogramAuditMode"
style="--el-switch-on-color: var(--sa-accent); --el-switch-off-color: #cbd5e1"
/>
</div>
<div class="switch-item" style="margin-top: 16px">
<div class="switch-info">
<p class="switch-title">跟随微信代码审核自动切换提审隐藏</p>
<p class="switch-desc">
开启后打开本页时会按约 5 分钟节流调用微信最新审核状态审核中(2)/延后(4)自动打开提审隐藏成功/拒绝/撤回或无审核单则自动关闭不影响面相审核开关
</p>
</div>
<el-switch
v-model="systemConfig.wechatAuditAutoMiniprogramMode"
style="--el-switch-on-color: var(--sa-success); --el-switch-off-color: #cbd5e1"
/>
</div>
<div v-if="systemConfig.wechatLastAuditSyncedAt" class="wechat-audit-meta">
<p class="meta-line">
<span class="meta-k">上次同步</span>
{{ formatAuditSyncedAt(systemConfig.wechatLastAuditSyncedAt) }}
</p>
<p class="meta-line">
<span class="meta-k">微信审核</span>
{{ wechatAuditStatusLabel(systemConfig.wechatLastAuditStatus) }}
<span class="meta-sub">errcode {{ systemConfig.wechatLastAuditErrcode ?? '—' }}</span>
</p>
<p v-if="systemConfig.wechatLastAuditReason" class="meta-line reason">
{{ systemConfig.wechatLastAuditReason }}
</p>
</div>
<div class="wechat-sync-actions">
<el-button
type="default"
:loading="wechatSyncLoading"
@click="handleWechatAuditSync"
>
立即同步微信审核状态
</el-button>
</div>
</div>
<div class="review-changes-list">
@@ -94,7 +142,11 @@
</div>
<div class="review-change-item">
<span class="change-icon hide">隐藏</span>
<span class="change-text">所有含AI字样的文案</span>
<span class="change-text">所有含AI字样的文案仅当面相审核开启时</span>
</div>
<div class="review-change-item">
<span class="change-icon hide">隐藏</span>
<span class="change-text">提审模式神仙 AI Tab测试列表AI 对话对话内简历与深度报告</span>
</div>
<div class="review-change-item">
<span class="change-icon show">保留</span>
@@ -111,17 +163,16 @@
</div>
<div class="review-tip">
<strong>使用流程</strong>开启审核模式 提交小程序代码审核 审核通过后关闭审核模式
<strong>使用流程</strong>开启跟随微信审核自动切换提审隐藏或手动开关面相审核与提审隐藏相互独立通过后按需关闭即可
</div>
<el-button
type="primary"
:color="systemConfig.maintenanceMode ? '#ef4444' : '#6366f1'"
class="save-button"
@click="handleSave('review')"
>
<el-icon class="mr-1"><Document /></el-icon>
{{ systemConfig.maintenanceMode ? '保存并开启审核模式' : '保存并关闭审核模式' }}
保存审核与提审开关
</el-button>
</div>
</el-card>
@@ -222,7 +273,7 @@
<el-input
v-model="textConfig.startButtonText"
class="form-input"
placeholder="默认:开始面相测试"
placeholder="默认:30秒测出你的性格"
/>
<span class="form-hint">开始AI面相测试</span>
</div>
@@ -260,7 +311,6 @@
<el-button
type="primary"
color="#6366f1"
class="save-button"
@click="handleSave('system')"
>
@@ -307,7 +357,6 @@
</div>
<el-button
type="primary"
color="#6366f1"
class="save-button"
@click="handleSave('prompts')"
>
@@ -394,7 +443,6 @@
<el-button
type="primary"
color="#6366f1"
class="save-button"
@click="handleSave('credentials')"
>
@@ -501,11 +549,59 @@ const systemConfig = reactive({
siteDescription: '专业的AI性格测试平台',
miniprogramName: '神仙团队AI性格测试',
maintenanceMode: false,
miniprogramAuditMode: false,
wechatAuditAutoMiniprogramMode: true,
wechatLastAuditErrcode: null as number | null,
wechatLastAuditStatus: null as number | null,
wechatLastAuditReason: '',
wechatLastAuditSyncedAt: null as number | null,
maxTestsPerDay: 100,
trialTestCount: 10,
defaultEnterpriseId: null as number | null,
})
const wechatSyncLoading = ref(false)
function wechatAuditStatusLabel(st: number | null | undefined): string {
const m: Record<number, string> = {
0: '审核成功',
1: '审核被拒绝',
2: '审核中',
3: '已撤回',
4: '审核延后'
}
if (st == null || Number.isNaN(Number(st))) return '—'
return m[Number(st)] ?? `未知(${st})`
}
function formatAuditSyncedAt(ts: number | null | undefined): string {
if (ts == null || !Number.isFinite(Number(ts))) return '—'
const d = new Date(Number(ts) * 1000)
if (Number.isNaN(d.getTime())) return '—'
return d.toLocaleString('zh-CN', { hour12: false })
}
async function handleWechatAuditSync() {
wechatSyncLoading.value = true
try {
const res: any = await request.post('/superadmin/settings/wechat-audit-sync')
if (res.code === 200) {
const w = res.data?.wechat
const applied = res.data?.applied
await loadSettings()
ElMessage.success(
applied
? '已根据微信状态更新提审隐藏开关'
: (w?.errcode !== 0 ? '已拉取微信返回(未改开关,见下方 errcode' : '已同步,当前无需改开关')
)
}
} catch (e: any) {
ElMessage.error(e?.message || '同步失败')
} finally {
wechatSyncLoading.value = false
}
}
/** 下拉:企业管理中的企业列表 */
const enterpriseOptions = ref<Array<{ id: number; name: string }>>([])
/** 与选项加载顺序配合,避免 el-select 在 options 为空时绑定值导致刷新后不显示 */
@@ -522,7 +618,7 @@ const credentials = reactive({
// 小程序文案配置(分析中提示、按钮、报告标题等)
const textConfig = reactive({
analyzingTitle: '正在分析中',
startButtonText: '开始面相测试',
startButtonText: '30秒测出你的性格',
startButtonEnterprise: '开始面部测试',
reportTitle: '分析报告',
aiAnalysisText: '智能分析',
@@ -544,6 +640,21 @@ const loadSettings = async () => {
if (response.data.system) {
Object.assign(systemConfig, response.data.system)
systemConfig.maintenanceMode = !!systemConfig.maintenanceMode
systemConfig.miniprogramAuditMode = !!systemConfig.miniprogramAuditMode
systemConfig.wechatAuditAutoMiniprogramMode =
response.data.system.wechatAuditAutoMiniprogramMode !== false
const wl = response.data.system.wechatLastAuditSyncedAt
systemConfig.wechatLastAuditSyncedAt =
wl != null && wl !== '' ? Number(wl) : null
const wst = response.data.system.wechatLastAuditStatus
systemConfig.wechatLastAuditStatus =
wst != null && wst !== '' ? Number(wst) : null
const we = response.data.system.wechatLastAuditErrcode
systemConfig.wechatLastAuditErrcode =
we != null && we !== '' ? Number(we) : null
systemConfig.wechatLastAuditReason = String(
response.data.system.wechatLastAuditReason ?? ''
)
const de = response.data.system.defaultEnterpriseId
systemConfig.defaultEnterpriseId =
de != null && de !== '' && Number(de) > 0 ? Number(de) : null
@@ -607,11 +718,13 @@ const handleSave = async (section: string) => {
switch (section) {
case 'review':
response = await request.put('/superadmin/settings/system', {
maintenanceMode: !!systemConfig.maintenanceMode
maintenanceMode: !!systemConfig.maintenanceMode,
miniprogramAuditMode: !!systemConfig.miniprogramAuditMode,
wechatAuditAutoMiniprogramMode: !!systemConfig.wechatAuditAutoMiniprogramMode
})
if (response.code === 200) {
await loadSettings()
ElMessage.success(systemConfig.maintenanceMode ? '审核模式已开启小程序将隐藏AI功能' : '审核模式已关闭AI功能已恢复')
ElMessage.success('审核与提审开关已保存')
saveSuccess.value = section
setTimeout(() => { saveSuccess.value = null }, 3000)
}
@@ -631,6 +744,7 @@ const handleSave = async (section: string) => {
siteDescription: saved.siteDescription ?? systemConfig.siteDescription,
miniprogramName: saved.miniprogramName ?? systemConfig.miniprogramName,
maintenanceMode: !!saved.maintenanceMode,
miniprogramAuditMode: !!saved.miniprogramAuditMode,
maxTestsPerDay: Number(saved.maxTestsPerDay ?? systemConfig.maxTestsPerDay),
trialTestCount: Number(saved.trialTestCount ?? systemConfig.trialTestCount)
})
@@ -730,63 +844,8 @@ const handleSave = async (section: string) => {
gap: 0;
}
.custom-tabs-container {
background-color: #f3f4f6;
padding: 4px;
border-radius: 8px;
display: flex;
margin-bottom: 20px;
width: 100%;
&.tabs-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.custom-tabs {
display: flex;
gap: 4px;
width: 100%;
min-width: min-content;
&.tabs-many .tab-item {
flex: 0 0 auto;
padding: 6px 12px;
font-size: 12px;
}
.tab-item {
flex: 1;
padding: 6px 20px;
font-size: 13px;
color: #6b7280;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
white-space: nowrap;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
.tab-icon {
font-size: 14px;
}
&:hover {
color: #111827;
}
&.active {
background-color: #fff;
color: #111827;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
}
}
}
/* .custom-tabs-container 视觉已统一在 admin-theme.css本页 .tab-icon 仍生效admin-theme 不限制图标) */
.custom-tabs-container .tab-item .tab-icon { font-size: 14px; }
.tab-content-card {
background: #fff;
@@ -987,6 +1046,41 @@ const handleSave = async (section: string) => {
}
}
.wechat-audit-meta {
margin-top: 8px;
padding: 12px;
background: #ecfeff;
border: 1px solid #a5f3fc;
border-radius: 8px;
font-size: 12px;
color: #0f766e;
.meta-line {
margin: 0 0 6px 0;
line-height: 1.45;
&:last-child {
margin-bottom: 0;
}
&.reason {
color: #115e59;
word-break: break-word;
}
}
.meta-k {
font-weight: 600;
margin-right: 8px;
color: #134e4a;
}
.meta-sub {
color: #5eead4;
margin-left: 4px;
}
}
.wechat-sync-actions {
margin-top: 12px;
}
.warning-box {
display: flex;
align-items: flex-start;

View File

@@ -5,7 +5,7 @@
<h2>Soul 引流文章</h2>
<p class="subtitle">
采集由服务端<strong>仅通过 HTTPS</strong>请求一场 soul 创业实验开放接口<strong>不使用 SSH</strong>
当前推荐至多 3 需在下方开启<strong>神仙 AI </strong><strong>我的页推荐条</strong>小程序对应位置才会展示排序第 1 篇用于我的底部灰字链接
当前推荐至多 3 默认开启神仙 AI 顶部列表可在下方小程序 · 推荐文章展示关闭或改条数我的底部推荐条默认关需单独打开
</p>
</div>
<div class="header-actions">
@@ -26,6 +26,15 @@
</div>
</div>
<el-alert type="info" show-icon :closable="false" class="sync-hint-alert">
<template #title>自动拉取 · 与手动采集</template>
<p class="sync-hint-p">
用户打开神仙 AI或拉推荐接口且已开启展示时服务端会按配置
<code>soul_article_auto_sync</code> 的间隔自动请求 Soul 公网 API 更新候选池
采集最新 10 搜索并添加用于运营<strong>立即补池</strong>二者并行不冲突
</p>
</el-alert>
<!-- 神仙 AI 健康小条 -->
<el-card v-if="health.loaded" class="health-card" shadow="never" :class="{ 'health-card--warn': health.hasAlert }">
<div class="health-bar">
@@ -74,6 +83,62 @@
<el-switch v-model="aiChatDisplay.sectionExpandedDefault" />
<span class="form-hint-inline">关闭则默认收起用户点击精选推荐后展开</span>
</el-form-item>
<el-divider content-position="left">精选推荐 · 跳转其他小程序</el-divider>
<el-form-item v-if="aiChatDisplay.enabled" label="目标小程序 AppID">
<el-input
v-model="aiChatDisplay.recoJumpMiniAppId"
clearable
placeholder="默认与「一场 soul」一致wxb8bbb2b10dec74aa留空则点击仍走原文 webview"
style="max-width: 420px"
/>
</el-form-item>
<el-form-item v-if="aiChatDisplay.enabled" label="打开路径 path">
<el-input
v-model="aiChatDisplay.recoJumpMiniPath"
placeholder="如 pages/index/index勿以 / 开头;可带 query"
style="max-width: 420px"
/>
</el-form-item>
<el-form-item v-if="aiChatDisplay.enabled" label="环境版本">
<el-radio-group v-model="aiChatDisplay.recoJumpMiniEnvVersion">
<el-radio label="release">正式版 release</el-radio>
<el-radio label="trial">体验版 trial</el-radio>
<el-radio label="develop">开发版 develop</el-radio>
</el-radio-group>
<div class="form-hint-block">
用户点击推荐卡片时将调用 wx.navigateToMiniProgram需在小程序管理后台与目标小程序完成关联且本小程序
app.json 已声明 navigateToMiniProgramAppIdList
</div>
</el-form-item>
<el-divider content-position="left">对话内 · 精选推荐抽检文章 / 功能卡</el-divider>
<el-form-item v-if="aiChatDisplay.enabled" label="从第几条用户消息起">
<el-input-number v-model="aiChatDisplay.inlineRecoMinUserTurns" :min="1" :max="10" :step="1" controls-position="right" />
<span class="form-hint-inline">达到该条数后才开始抽检默认 2即第二条用户发言起的回复可能带推荐</span>
</el-form-item>
<el-form-item v-if="aiChatDisplay.enabled" label="间隔 N再每几条">
<el-input-number v-model="aiChatDisplay.inlineRecoInterval" :min="2" :max="10" :step="1" controls-position="right" />
<span class="form-hint-inline">起始条数之后每再 N 条用户消息抽检一次23 即约每两三句一轮</span>
</el-form-item>
<el-form-item v-if="aiChatDisplay.enabled" label="抽检出现概率">
<el-slider v-model="aiChatDisplay.inlineRecoRollPercent" :min="5" :max="100" show-input />
<div class="form-hint-block">命中间隔后实际是否插入推荐由概率决定100% 表示只要抽检必尝试仍受意图与正文匹配限制</div>
</el-form-item>
<el-form-item v-if="aiChatDisplay.enabled" label="角标图标个数">
<el-radio-group v-model="aiChatDisplay.inlineRecoIconCount">
<el-radio :label="1">1 </el-radio>
<el-radio :label="2">2 </el-radio>
<el-radio :label="3">3 </el-radio>
</el-radio-group>
<span class="form-hint-inline">每条推荐卡上方 emoji 数量不超过 3</span>
</el-form-item>
<el-form-item v-if="aiChatDisplay.enabled" label="图标 emoji">
<el-input
v-model="aiChatDisplay.inlineRecoIconsStr"
placeholder="逗号分隔,如 ✨,💬,📌;求职类会在前端自动前置 💼"
style="max-width: 480px"
clearable
/>
</el-form-item>
<el-divider content-position="left">我的 · 底部推荐条</el-divider>
<el-form-item label="我的页展示首条">
<el-switch v-model="aiChatDisplay.profileRecoEnabled" />
@@ -84,7 +149,7 @@
v-model="aiChatDisplay.profileSectionLabel"
maxlength="32"
show-word-limit
placeholder="如:一场创业实验、精选阅读"
placeholder="默认:我的由来(可改成任意短标题)"
style="max-width: 360px"
/>
</el-form-item>
@@ -100,7 +165,7 @@
<el-card class="reco-card" shadow="hover">
<template #header>
<div class="card-header">
<span>当前推荐至多 3 · 需在上方开启小程序展示后按条数展示</span>
<span>当前推荐至多 3 · 默认在神仙 AI 展示可在小程序 · 推荐文章展示关闭</span>
<div class="reco-head-actions">
<span class="reco-count">{{ recommended.length }} / 3</span>
<el-button size="small" @click="onNormalizeOrder">一键归一排序</el-button>
@@ -256,11 +321,19 @@ const dateRange = ref<string[]>([])
const health = ref<any>({ loaded: false, providers: [], lastAlert: null, hasAlert: false })
const aiChatDisplay = ref({
enabled: false,
maxShow: 1,
sectionExpandedDefault: false,
enabled: true,
maxShow: 3,
sectionExpandedDefault: true,
profileRecoEnabled: false,
profileSectionLabel: '推荐阅读'
profileSectionLabel: '我的由来',
recoJumpMiniAppId: 'wxb8bbb2b10dec74aa',
recoJumpMiniPath: 'pages/index/index',
recoJumpMiniEnvVersion: 'release' as 'release' | 'trial' | 'develop',
inlineRecoMinUserTurns: 2,
inlineRecoInterval: 3,
inlineRecoRollPercent: 50,
inlineRecoIconCount: 3,
inlineRecoIconsStr: '✨,💬,📌'
})
const savingAcDisplay = ref(false)
@@ -438,12 +511,25 @@ const fetchAiChatDisplay = async () => {
const res: any = await request.get('/superadmin/soul-articles/ai-chat-display')
if (res && res.code === 200 && res.data) {
const d = res.data
const envRaw = String(d.recoJumpMiniEnvVersion || 'release').toLowerCase()
const env =
envRaw === 'trial' || envRaw === 'develop' || envRaw === 'release' ? envRaw : 'release'
const roll = typeof d.inlineRecoRoll === 'number' && !Number.isNaN(d.inlineRecoRoll) ? d.inlineRecoRoll : 0.5
const iconsArr = Array.isArray(d.inlineRecoIcons) ? d.inlineRecoIcons : []
aiChatDisplay.value = {
enabled: !!d.enabled,
maxShow: Math.max(1, Math.min(3, Number(d.maxShow) || 1)),
sectionExpandedDefault: !!d.sectionExpandedDefault,
profileRecoEnabled: !!d.profileRecoEnabled,
profileSectionLabel: String(d.profileSectionLabel || '推荐阅读').slice(0, 32)
profileSectionLabel: String(d.profileSectionLabel || '我的由来').slice(0, 32),
recoJumpMiniAppId: d.recoJumpMiniAppId != null ? String(d.recoJumpMiniAppId).trim() : '',
recoJumpMiniPath: d.recoJumpMiniPath != null ? String(d.recoJumpMiniPath).trim() : 'pages/index/index',
recoJumpMiniEnvVersion: env as 'release' | 'trial' | 'develop',
inlineRecoMinUserTurns: Math.max(1, Math.min(10, Number(d.inlineRecoMinUserTurns) || 2)),
inlineRecoInterval: Math.max(2, Math.min(10, Number(d.inlineRecoInterval) || 3)),
inlineRecoRollPercent: Math.max(5, Math.min(100, Math.round(roll * 100))),
inlineRecoIconCount: Math.max(1, Math.min(3, Number(d.inlineRecoIconCount) || 3)),
inlineRecoIconsStr: iconsArr.length ? iconsArr.map((x: any) => String(x)).join(',') : '✨,💬,📌'
}
}
} catch (e) {}
@@ -452,12 +538,25 @@ const fetchAiChatDisplay = async () => {
const saveAiChatDisplay = async () => {
savingAcDisplay.value = true
try {
const rawIcons = String(aiChatDisplay.value.inlineRecoIconsStr || '')
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean)
.slice(0, 10)
const res: any = await request.post('/superadmin/soul-articles/ai-chat-display', {
enabled: aiChatDisplay.value.enabled,
maxShow: aiChatDisplay.value.maxShow,
sectionExpandedDefault: aiChatDisplay.value.sectionExpandedDefault,
profileRecoEnabled: aiChatDisplay.value.profileRecoEnabled,
profileSectionLabel: aiChatDisplay.value.profileSectionLabel
profileSectionLabel: aiChatDisplay.value.profileSectionLabel,
recoJumpMiniAppId: aiChatDisplay.value.recoJumpMiniAppId,
recoJumpMiniPath: aiChatDisplay.value.recoJumpMiniPath,
recoJumpMiniEnvVersion: aiChatDisplay.value.recoJumpMiniEnvVersion,
inlineRecoMinUserTurns: aiChatDisplay.value.inlineRecoMinUserTurns,
inlineRecoInterval: aiChatDisplay.value.inlineRecoInterval,
inlineRecoRoll: Math.max(0.05, Math.min(1, (Number(aiChatDisplay.value.inlineRecoRollPercent) || 50) / 100)),
inlineRecoIconCount: aiChatDisplay.value.inlineRecoIconCount,
inlineRecoIcons: rawIcons.length ? rawIcons : ['✨', '💬', '📌']
})
if (res && res.code === 200) {
ElMessage.success('展示设置已保存')
@@ -523,6 +622,17 @@ onMounted(() => {
flex-wrap: wrap;
}
.sync-hint-alert {
margin-bottom: 16px;
}
.sync-hint-p {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.65;
color: #4b5563;
}
.header-left h2 {
margin: 0 0 8px 0;
font-size: 22px;
@@ -558,6 +668,14 @@ onMounted(() => {
vertical-align: middle;
}
.form-hint-block {
margin-top: 8px;
font-size: 12px;
line-height: 1.55;
color: #6B6894;
max-width: 560px;
}
.health-card {
margin-bottom: 16px;
border: 1px solid #E5E7EB;

View File

@@ -174,6 +174,23 @@
</template>
</el-table-column>
<el-table-column label="SBTI" width="120" align="center">
<template #default="{ row }">
<div class="test-results">
<template v-if="row.sbtiType">
<el-tag
size="small"
class="result-tag tag-sbti"
@click.stop="handleClickTestTag(row, 'sbti')"
>
{{ row.sbtiType }}
</el-tag>
</template>
<span v-else class="no-test"></span>
</div>
</template>
</el-table-column>
<el-table-column label="PDP" width="120" align="center">
<template #default="{ row }">
<div class="test-results">
@@ -1671,6 +1688,12 @@ onMounted(() => {
font-size: 12px;
}
:deep(.tag-sbti.el-tag) {
--el-tag-bg-color: #f5f3ff;
--el-tag-border-color: #ddd6fe;
--el-tag-text-color: #5b21b6;
}
.no-test {
font-size: 13px;
color: #9ca3af;