feat: 同步今日小程序与后台迭代版本
集中提交今日 API、管理端、小程序与部署文档调整,确保 Gitea 主分支与本地最新开发版本一致。 Made-with: Cursor
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
109
admin/src/components/TopTestUsersPanel.vue
Normal file
109
admin/src/components/TopTestUsersPanel.vue
Normal 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>
|
||||
278
admin/src/components/UserJourneyPanel.vue
Normal file
278
admin/src/components/UserJourneyPanel.vue
Normal 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>
|
||||
273
admin/src/components/UserRfmPanel.vue
Normal file
273
admin/src/components/UserRfmPanel.vue
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: '智能分析'
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">小程序提审模式 · 隐藏神仙 AI(miniprogramAuditMode)</p>
|
||||
<p class="switch-desc">隐藏底部「神仙 AI」Tab、对话/简历/深度报告入口;后端同步关闭对话与报告接口。同时关闭虚拟商品链路:不下发深度套餐与报告付费标记、拦截支付下单、隐藏「了解自己」购买页入口。用于微信「深度合成」与「虚拟支付/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;
|
||||
|
||||
@@ -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 条用户消息抽检一次(2~3 即约每两三句一轮)</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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user