fix: 仪表盘与用户管理、分析/测试接口、双端结果页与个人中心展示调整

- admin:Dashboard/Users 与超管 Users 联动后端统计
- api:Dashboard、Analyze、Test、超管 AppUser 逻辑更新
- 微信/抖音小程序:result、profile 页面与样式
- 新增 admin 公共组件与需求文档截图

Made-with: Cursor
This commit is contained in:
卡若
2026-03-27 18:26:03 +08:00
parent aca2e263bb
commit 68c73f6ca9
23 changed files with 2706 additions and 437 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -19,11 +19,53 @@
</div>
</div>
<div class="dash-catalog" v-if="testCatalog.length">
<div
v-for="(row, i) in catalogRows"
:key="row.key"
class="catalog-card"
:style="{ animationDelay: `${120 + i * 40}ms` }"
>
<div :class="['catalog-icon', row.tone]">
<el-icon><component :is="row.icon" /></el-icon>
</div>
<div class="catalog-body">
<div class="catalog-label">{{ row.label }}</div>
<div class="catalog-metrics">
<span><em>{{ row.records }}</em> 人次</span>
<span class="sep">·</span>
<span><em>{{ row.uniqueUsers }}</em> </span>
</div>
</div>
</div>
</div>
<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>
<p class="panel-desc">人脸 · MBTI · PDP · DISC 完成量下方为性格结果分布与本企业测评数据一致</p>
</div>
<div v-if="hasDistribution" class="distr-band">
<div v-for="block in distributionBlocks" :key="block.key" class="distr-col">
<div class="distr-col-head">
<el-icon class="distr-head-ic"><component :is="block.icon" /></el-icon>
<span>{{ block.title }}</span>
</div>
<div class="distr-list">
<div v-for="it in block.items" :key="block.key + it.label" class="distr-row-line">
<span class="distr-lab" :title="it.label">{{ it.label }}</span>
<div class="distr-bar-track">
<div
class="distr-bar-fill"
:class="block.barClass"
:style="{ width: barWidthPct(block.max, it.count) }"
/>
</div>
<span class="distr-num">{{ it.count }}</span>
</div>
</div>
</div>
</div>
<div class="chart-box">
<VChart v-if="testTrends.length" class="trend-chart" :option="chartOption" autoresize />
@@ -87,7 +129,15 @@
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { User, Document, TrendCharts } from '@element-plus/icons-vue'
import {
User,
Document,
TrendCharts,
Camera,
Reading,
Histogram,
Medal
} from '@element-plus/icons-vue'
import { request } from '@/utils/request'
import { ElMessage } from 'element-plus'
import { use } from 'echarts/core'
@@ -125,6 +175,18 @@ 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 }>
>([])
const distributionMbti = ref<Array<{ label: string; count: number }>>([])
const distributionDisc = ref<Array<{ label: string; count: number }>>([])
const distributionPdp = ref<Array<{ label: string; count: number }>>([])
const faceSubtypeHints = ref<{
mbti: Array<{ label: string; count: number }>
disc: Array<{ label: string; count: number }>
pdp: Array<{ label: string; count: number }>
}>({ mbti: [], disc: [], pdp: [] })
const loading = ref(false)
const inviteLoading = ref(false)
const inviteQrcode = ref<string>('')
@@ -139,6 +201,69 @@ const kpiCards = computed(() => [
{ key: 'p', label: '待审核', value: stats.pendingReviews, icon: User, tone: 'orange' }
])
const catalogIconMap: Record<string, { icon: typeof Camera; tone: string }> = {
face: { icon: Camera, tone: 'teal' },
mbti: { icon: Reading, tone: 'blue' },
disc: { icon: Histogram, tone: 'indigo' },
pdp: { icon: Medal, tone: 'amber' }
}
const catalogRows = computed(() =>
testCatalog.value.map(row => {
const m = catalogIconMap[row.key] || { icon: Document, tone: 'blue' }
return {
...row,
icon: m.icon,
tone: m.tone,
records: row.records ?? 0,
uniqueUsers: row.uniqueUsers ?? 0
}
})
)
function sliceItems(items: Array<{ label: string; count: number }>, n: number) {
return (items || []).slice(0, n)
}
const distributionBlocks = computed(() => {
const mbti = sliceItems(distributionMbti.value, 6)
const disc = sliceItems(distributionDisc.value, 6)
const pdp = sliceItems(distributionPdp.value, 6)
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) => {
let k = 0
for (const it of arr || []) {
if (k >= max) break
faceMerged.push({ label: `${prefix}${it.label}`, count: it.count })
k++
}
}
pushPref('面·MBTI ', fh.mbti, 2)
pushPref('面·DISC ', fh.disc, 2)
pushPref('面·PDP ', fh.pdp, 2)
const blocks = [
{ key: 'mbti', title: 'MBTI答题', items: mbti, icon: Reading, barClass: 'bar-mbti' },
{ key: 'disc', title: 'DISC答题', items: disc, icon: Histogram, barClass: 'bar-disc' },
{ key: 'pdp', title: 'PDP答题', items: pdp, icon: Medal, barClass: 'bar-pdp' },
{ key: 'face', title: '面相推测', items: faceMerged, icon: Camera, barClass: 'bar-face' }
]
return blocks.map(b => ({
...b,
max: Math.max(1, ...b.items.map(i => i.count))
}))
})
const hasDistribution = computed(() =>
distributionBlocks.value.some(b => b.items.length > 0)
)
function barWidthPct(max: number, count: number) {
if (!max || !count) return '0%'
return `${Math.round((count / max) * 100)}%`
}
const chartOption = computed(() => {
const dates = testTrends.value.map(d => d.date.slice(5))
return {
@@ -226,6 +351,25 @@ const loadData = async () => {
stats.pendingReviews = response.data.pendingReviews || 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
: []
distributionDisc.value = Array.isArray(response.data.distributionDisc)
? response.data.distributionDisc
: []
distributionPdp.value = Array.isArray(response.data.distributionPdp)
? response.data.distributionPdp
: []
const fh = response.data.faceSubtypeHints
faceSubtypeHints.value =
fh && typeof fh === 'object'
? {
mbti: Array.isArray(fh.mbti) ? fh.mbti : [],
disc: Array.isArray(fh.disc) ? fh.disc : [],
pdp: Array.isArray(fh.pdp) ? fh.pdp : []
}
: { mbti: [], disc: [], pdp: [] }
}
} catch (error: any) {
console.error('加载数据失败:', error)
@@ -307,7 +451,94 @@ const loadInviteQrcode = async () => {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 10px;
margin-bottom: 8px;
}
.dash-catalog {
flex: 0 0 auto;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-bottom: 8px;
}
.catalog-card {
display: flex;
align-items: center;
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);
animation: dashFadeUp 0.45s ease-out both;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.06);
}
}
.catalog-icon {
width: 34px;
height: 34px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 17px;
flex-shrink: 0;
&.teal {
background: #f0fdfa;
color: #0d9488;
}
&.blue {
background: #eff6ff;
color: #3b82f6;
}
&.indigo {
background: #eef2ff;
color: #6366f1;
}
&.amber {
background: #fffbeb;
color: #d97706;
}
}
.catalog-body {
min-width: 0;
}
.catalog-label {
font-size: 13px;
font-weight: 600;
color: #111827;
margin-bottom: 2px;
}
.catalog-metrics {
font-size: 11px;
color: #6b7280;
em {
font-style: normal;
font-weight: 700;
color: #374151;
font-variant-numeric: tabular-nums;
}
.sep {
margin: 0 4px;
color: #d1d5db;
}
}
.stat-card {
@@ -396,6 +627,97 @@ const loadInviteQrcode = async () => {
min-height: 0;
}
.distr-band {
flex: 0 0 auto;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid #f3f4f6;
}
.distr-col {
min-width: 0;
background: #fafafa;
border-radius: 8px;
padding: 8px 10px;
border: 1px solid #f3f4f6;
}
.distr-col-head {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
color: #374151;
margin-bottom: 6px;
.distr-head-ic {
font-size: 14px;
color: #6b7280;
}
}
.distr-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 108px;
overflow-y: auto;
}
.distr-row-line {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(36px, 42%) 22px;
align-items: center;
gap: 6px;
font-size: 10px;
}
.distr-lab {
color: #4b5563;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.distr-bar-track {
height: 5px;
background: #e5e7eb;
border-radius: 3px;
overflow: hidden;
}
.distr-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.35s ease;
&.bar-mbti {
background: #3b82f6;
}
&.bar-disc {
background: #6366f1;
}
&.bar-pdp {
background: #f97316;
}
&.bar-face {
background: #14b8a6;
}
}
.distr-num {
text-align: right;
color: #6b7280;
font-variant-numeric: tabular-nums;
}
.panel-side {
gap: 10px;
padding: 10px;
@@ -435,7 +757,7 @@ const loadInviteQrcode = async () => {
.trend-chart {
width: 100%;
height: 100%;
min-height: 160px;
min-height: 140px;
}
.panel-empty {
@@ -524,5 +846,13 @@ const loadInviteQrcode = async () => {
.dash-kpis {
grid-template-columns: repeat(2, 1fr);
}
.dash-catalog {
grid-template-columns: repeat(2, 1fr);
}
.distr-band {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@@ -164,80 +164,13 @@
</div>
</div>
<!-- 用户详情对话框测试记录 -->
<el-dialog
<UserDetailDialog
v-model="showDetailDialog"
title="测试记录"
width="55%"
destroy-on-close
v-loading="detailLoading"
>
<template v-if="detailUser">
<div class="detail-section" v-if="testTableData.length">
<el-table :data="paginatedTests" size="small" max-height="500" class="test-table">
<el-table-column prop="createdAt" label="时间" width="140">
<template #default="{ row }">{{ formatDate(row.createdAt) }}</template>
</el-table-column>
<el-table-column prop="testType" label="类型" width="120">
<template #default="{ row }">{{ formatTestType(row.testType) }}</template>
</el-table-column>
<el-table-column prop="testScope" label="版本" width="90" align="center">
<template #default="{ row }">
<el-tag
size="small"
:type="row.testScope === 'enterprise' ? 'primary' : 'info'"
>
{{ row.testScope === 'enterprise' ? '企业版' : '个人版' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="summary" label="结果摘要" min-width="160" show-overflow-tooltip />
<el-table-column label="需付费" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.requiresPayment ? 'warning' : 'info'">
{{ row.requiresPayment ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="已付费" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.isPaid ? 'success' : 'info'">
{{ row.isPaid ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="付款金额" width="110" align="center">
<template #default="{ row }">
<span class="payment-cell">
{{ row.isPaid ? formatAmount(row.paidAmount) : '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="付款时间" width="140" align="center">
<template #default="{ row }">
<span class="time-cell">
{{ row.isPaid ? formatDate(row.paidAt) : '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button link type="primary" @click="handleViewTest(row)">查看</el-button>
</template>
</el-table-column>
</el-table>
<div class="test-pagination" v-if="testTableData.length > testPageSize">
<el-pagination
v-model:current-page="testPage"
:page-size="testPageSize"
:total="testTableData.length"
layout="prev, pager, next, total"
@current-change="handleTestPageChange"
/>
</div>
</div>
</template>
</el-dialog>
:user="detailUser"
:loading="detailLoading"
:show-enterprise-match="false"
@view-test="handleViewTest"
/>
<!-- 单次测试详情对话框 -->
<el-dialog
@@ -572,6 +505,7 @@ import { ref, computed, onMounted } from 'vue'
import { Download, Search, View } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { request } from '@/utils/request'
import UserDetailDialog from '@/components/UserDetailDialog.vue'
const loading = ref(false)
const detailLoading = ref(false)
@@ -583,8 +517,6 @@ const searchTerm = ref('')
const showDetailDialog = ref(false)
const detailUser = ref<Record<string, any> | null>(null)
const testPage = ref(1)
const testPageSize = 10
const showTestDetailDialog = ref(false)
const currentTest = ref<any | null>(null)
@@ -594,18 +526,6 @@ const rawTests = computed<any[]>(() => {
return (u.testList || []) as any[]
})
const testTableData = computed(() =>
rawTests.value.map(t => ({
...t,
summary: extractTestSummary(t)
}))
)
const paginatedTests = computed(() => {
const start = (testPage.value - 1) * testPageSize
return testTableData.value.slice(start, start + testPageSize)
})
const currentTestType = computed(() => (currentTest.value?.testType || '').toLowerCase())
const users = ref<any[]>([])
@@ -833,10 +753,6 @@ function handlePageChange() {
loadUsers()
}
function handleTestPageChange(page: number) {
testPage.value = page
}
function exportData() {
ElMessage.info('导出功能开发中')
}
@@ -874,7 +790,6 @@ async function loadDetailUser(userId: number) {
}
async function handleView(row: any) {
testPage.value = 1
detailUser.value = null
showDetailDialog.value = true
@@ -892,7 +807,6 @@ function handleViewTest(row: any) {
async function handleClickTestTag(row: any, testType: string) {
// 仅加载用户测试记录,弹出单次测试详情对话框并显示加载动画
testPage.value = 1
currentTest.value = null
showTestDetailDialog.value = true
testDetailLoading.value = true

View File

@@ -262,70 +262,13 @@
</div>
</div>
<!-- 用户详情对话框 -->
<el-dialog
<UserDetailDialog
v-model="showDetailDialog"
title="测试记录"
width="55%"
destroy-on-close
v-loading="detailLoading"
>
<template v-if="detailUser">
<div class="detail-section" v-if="testTableData.length">
<el-table :data="paginatedTests" size="small" max-height="500" class="test-table">
<el-table-column prop="createdAt" label="时间" width="140">
<template #default="{ row }">{{ formatDate(row.createdAt) }}</template>
</el-table-column>
<el-table-column prop="testType" label="类型" width="120">
<template #default="{ row }">{{ formatTestType(row.testType) }}</template>
</el-table-column>
<el-table-column prop="summary" label="结果摘要" min-width="160" show-overflow-tooltip />
<el-table-column label="需付费" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.requiresPayment ? 'warning' : 'info'">
{{ row.requiresPayment ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="已付费" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.isPaid ? 'success' : 'info'">
{{ row.isPaid ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="付款金额" width="110" align="center">
<template #default="{ row }">
<span class="payment-cell">
{{ row.isPaid ? formatAmount(row.paidAmount) : '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="付款时间" width="140" align="center">
<template #default="{ row }">
<span class="time-cell">
{{ row.isPaid ? formatDate(row.paidAt) : '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button link type="primary" @click="handleViewTest(row)">查看</el-button>
</template>
</el-table-column>
</el-table>
<div class="test-pagination" v-if="testTableData.length > testPageSize">
<el-pagination
v-model:current-page="testPage"
:page-size="testPageSize"
:total="testTableData.length"
layout="prev, pager, next, total"
@current-change="handleTestPageChange"
/>
</div>
</div>
</template>
</el-dialog>
:user="detailUser"
:loading="detailLoading"
:show-enterprise-match="true"
@view-test="handleViewTest"
/>
<!-- 单次测试详情对话框 -->
<el-dialog
@@ -654,6 +597,7 @@ import { ref, computed, onMounted } from 'vue'
import { Download, Search, View, UserFilled, User, OfficeBuilding, DataLine } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { request } from '@/utils/request'
import UserDetailDialog from '@/components/UserDetailDialog.vue'
withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
@@ -670,8 +614,6 @@ const selectedMbti = ref('')
const showDetailDialog = ref(false)
const detailUser = ref<Record<string, any> | null>(null)
const testPage = ref(1)
const testPageSize = 10
const showTestDetailDialog = ref(false)
const currentTest = ref<any | null>(null)
@@ -682,18 +624,6 @@ const rawTests = computed<any[]>(() => {
return (u.testList || []) as any[]
})
const testTableData = computed(() =>
rawTests.value.map(t => ({
...t,
summary: extractTestSummary(t)
}))
)
const paginatedTests = computed(() => {
const start = (testPage.value - 1) * testPageSize
return testTableData.value.slice(start, start + testPageSize)
})
const currentTestType = computed(() => (currentTest.value?.testType || '').toLowerCase())
const userCards = ref<Array<{ type: string; name: string; total: number; active: number; tested: number; enterpriseId?: number }>>([
@@ -988,10 +918,6 @@ function handlePageChange() {
loadUsers()
}
function handleTestPageChange(page: number) {
testPage.value = page
}
function exportData() {
ElMessage.info('导出功能开发中')
}
@@ -1042,7 +968,6 @@ async function loadDetailUser(userId: number) {
}
async function handleView(row: any) {
testPage.value = 1
detailUser.value = null
showDetailDialog.value = true
@@ -1060,7 +985,6 @@ function handleViewTest(row: any) {
async function handleClickTestTag(row: any, testType: string) {
// 仅加载用户测试记录,弹出单次测试详情对话框并显示加载动画
testPage.value = 1
currentTest.value = null
showTestDetailDialog.value = true
testDetailLoading.value = true