Files
MBTI_wang/admin/src/views/superadmin/Users.vue
Ghost 3fdf9b6ed2 chore: 管理端与 API 批量更新(测评恢复、看板、分销与用户)
1、修复了用户详情/测评结果与人脸结果解析展示问题;管理端订单、分销及超管用户相关接口与展示异常。

2、新增了 testResultParse 统一解析工具、mbti_test_results 恢复 SQL 及生成脚本;补充 API 路由与 Dashboard/用户侧能力。

3、优化了企业/超管数据看板与分销结算逻辑;精简超管 Settings 与布局代码;完善 faceResultDetail;小程序 app.js 小幅同步。

Made-with: Cursor
2026-03-31 10:56:28 +08:00

2094 lines
60 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="page-container" :class="{ 'is-embedded': embedded }">
<div v-if="!embedded" class="page-header">
<div class="header-left">
<h2>用户总览</h2>
<p class="subtitle">按企业用户池分类查看和管理用户数据首屏为各池卡片与汇总与数据表联动</p>
</div>
<div class="header-actions">
<el-button variant="outline" size="small" @click="exportData">
<el-icon class="mr-1"><Download /></el-icon>导出数据
</el-button>
</div>
</div>
<!-- 用户数据概览卡片 -->
<div class="user-overview-grid">
<div
v-for="(card, index) in userCards"
:key="card.name + (card.enterpriseId ?? '')"
:class="['user-card', { active: selectedCard === index }]"
@click="onSelectCard(index)"
>
<div class="card-header">
<div class="card-icon">
<el-icon v-if="card.type === 'all'"><UserFilled /></el-icon>
<el-icon v-else-if="card.type === 'individual'"><User /></el-icon>
<el-icon v-else><OfficeBuilding /></el-icon>
</div>
<div class="card-name">{{ card.name }}</div>
</div>
<div class="card-stats">
<div class="stat-main">
<span class="stat-value-large">{{ card.total }}</span>
<span class="stat-label-large"></span>
</div>
<div class="stat-sub">
<span class="stat-active">{{ card.active }}活跃</span>
<span class="stat-tested">{{ card.tested }}已测试</span>
</div>
</div>
</div>
</div>
<!-- 汇总指标 -->
<div class="summary-metrics-grid">
<div class="summary-card">
<div class="summary-label">池内用户总数</div>
<div class="summary-value">{{ summary.totalUsers }}</div>
</div>
<div class="summary-card">
<div class="summary-label">活跃用户</div>
<div class="summary-value">
{{ summary.activeUsers }} <span class="summary-percent">({{ summary.activePercent }}%)</span>
</div>
</div>
<div class="summary-card">
<div class="summary-label">已完成测试</div>
<div class="summary-value">
{{ summary.testedUsers }} <span class="summary-percent">({{ summary.testedPercent }}%)</span>
</div>
</div>
</div>
<!-- MBTI类型分布 -->
<div class="mbti-distribution-card">
<div class="mbti-header">
<el-icon class="mbti-icon"><DataLine /></el-icon>
<span class="mbti-title">MBTI 类型分布</span>
</div>
<div class="mbti-tags">
<div
v-for="(type, _index) in mbtiTypes"
:key="type.type"
:class="['mbti-tag', { active: selectedMbti === type.type }]"
@click="onSelectMbti(type.type)"
>
{{ type.type }} {{ type.count }}
</div>
</div>
</div>
<!-- 搜索栏 -->
<div class="search-section">
<el-input
v-model="searchTerm"
placeholder="搜索昵称、手机号、地区、MBTI..."
clearable
class="search-input"
@clear="loadUsers"
@keyup.enter="loadUsers"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button type="primary" @click="loadUsers">搜索</el-button>
</div>
<!-- 用户列表表格 -->
<div class="table-card">
<el-table :data="users" style="width: 100%" v-loading="loading" class="user-table">
<el-table-column label="用户信息" min-width="220">
<template #default="{ row }">
<div class="user-info-cell">
<img
v-if="row.avatar"
:src="row.avatar"
class="user-avatar user-avatar-img"
referrerpolicy="no-referrer"
/>
<div
v-else
class="user-avatar user-avatar-letter"
:style="{ backgroundColor: avatarBgColor(row) }"
>
{{ avatarLetter(row) }}
</div>
<div class="user-details">
<div class="username">{{ row.username || '未设置昵称' }}</div>
<div class="openid-line">{{ row.openid || '—' }}</div>
<div class="phone-line">{{ row.phone}}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="人脸分析" width="120" align="center">
<template #default="{ row }">
<div class="test-results">
<template v-if="row.faceMbtiType || row.faceDiscType || row.facePdpType">
<el-tag
v-if="row.faceMbtiType"
size="small"
class="result-tag"
type="success"
@click.stop="handleClickTestTag(row, 'face')"
>
{{ row.faceMbtiType }}
</el-tag>
<el-tag
v-if="row.faceDiscType"
size="small"
class="result-tag"
type="info"
@click.stop="handleClickTestTag(row, 'face')"
>
{{ row.faceDiscType }}
</el-tag>
<el-tag
v-if="row.facePdpType"
size="small"
class="result-tag"
type="warning"
@click.stop="handleClickTestTag(row, 'face')"
>
{{ row.facePdpType }}
</el-tag>
</template>
<span v-else class="no-test"></span>
</div>
</template>
</el-table-column>
<el-table-column label="MBTI" width="120" align="center">
<template #default="{ row }">
<div class="test-results">
<template v-if="row.mbtiType">
<el-tag size="small" class="result-tag" @click.stop="handleClickTestTag(row, 'mbti')">
{{ row.mbtiType }}
</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">
<template v-if="row.pdpType">
<el-tag
size="small"
class="result-tag"
type="warning"
@click.stop="handleClickTestTag(row, 'pdp')"
>
{{ row.pdpType }}
</el-tag>
</template>
<span v-else class="no-test"></span>
</div>
</template>
</el-table-column>
<el-table-column label="DISC" width="120" align="center">
<template #default="{ row }">
<div class="test-results">
<template v-if="row.discType">
<el-tag
size="small"
class="result-tag"
type="info"
@click.stop="handleClickTestTag(row, 'disc')"
>
{{ row.discType }}
</el-tag>
</template>
<span v-else class="no-test"></span>
</div>
</template>
</el-table-column>
<el-table-column label="测试次数" width="120" align="center">
<template #default="{ row }">
<span class="count-cell">{{ row.testCount ?? 0 }}</span>
</template>
</el-table-column>
<el-table-column label="所属企业" min-width="160">
<template #default="{ row }">
<div class="enterprise-cell">
<el-icon class="enterprise-icon"><OfficeBuilding /></el-icon>
<span class="enterprise-name">{{ row.enterprise || '个人用户(无企业)' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row: _row }">
<el-tag type="success" size="small">正常</el-tag>
</template>
</el-table-column>
<el-table-column label="注册时间" width="150">
<template #default="{ row }">
<span class="time-cell">{{ formatDate(row.createdAt) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button link @click="handleView(row)">
<el-icon><View /></el-icon>
<span>查看</span>
</el-button>
</template>
</el-table-column>
</el-table>
<div class="empty-state" v-if="users.length === 0 && !loading">
<span>暂无用户数据</span>
</div>
<div class="pagination-container" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="total"
layout="prev, pager, next"
@current-change="handlePageChange"
/>
</div>
</div>
<UserDetailDialog
v-model="showDetailDialog"
:user="detailUser"
:loading="detailLoading"
:show-enterprise-match="true"
@view-test="handleViewTest"
/>
<!-- 单次测试详情对话框 -->
<el-dialog
v-model="showTestDetailDialog"
title="测试详情"
width="760px"
destroy-on-close
v-loading="testDetailLoading"
>
<template v-if="currentTest">
<!-- 通用头部信息 -->
<div class="detail-section test-detail-header">
<div class="test-detail-header-main">
<div class="test-type-tag">{{ formatTestType(currentTest.testType) }}</div>
<div class="test-type-summary">
<div class="test-type-title">{{ currentTestSummary || '-' }}</div>
<div class="test-type-meta">
<span>测试时间{{ formatDate(currentTest.createdAt) }}</span>
</div>
</div>
</div>
</div>
<!-- MBTI 图文详情 -->
<div v-if="currentTestType === 'mbti' && mbtiDetail" class="detail-section test-detail-mbti">
<div class="test-detail-main-card">
<div class="test-detail-main-left">
<div class="mbti-type-badge">{{ mbtiDetail.type }}</div>
<div class="mbti-name">{{ mbtiDetail.name }}</div>
<p class="mbti-desc">{{ mbtiDetail.description }}</p>
</div>
</div>
<div class="test-detail-grid">
<div class="test-detail-block" v-if="mbtiDetail.strengths?.length">
<h4 class="detail-subtitle">性格优势</h4>
<ul class="tag-list">
<li v-for="item in mbtiDetail.strengths" :key="item">{{ item }}</li>
</ul>
</div>
<div class="test-detail-block" v-if="mbtiDetail.weaknesses?.length">
<h4 class="detail-subtitle">潜在短板</h4>
<ul class="tag-list tag-list-warning">
<li v-for="item in mbtiDetail.weaknesses" :key="item">{{ item }}</li>
</ul>
</div>
<div class="test-detail-block" v-if="mbtiDetail.careers?.length">
<h4 class="detail-subtitle">适合职业</h4>
<ul class="tag-list tag-list-info">
<li v-for="item in mbtiDetail.careers" :key="item">{{ item }}</li>
</ul>
</div>
</div>
</div>
<!-- DISC 图文详情 -->
<div v-else-if="currentTestType === 'disc' && discDetail" class="detail-section test-detail-disc">
<div class="test-detail-main-card">
<div class="test-detail-main-left">
<div class="disc-type-badge">{{ discDetail.type }}</div>
<div class="disc-title">{{ discDetail.title }}</div>
<p class="disc-desc">{{ discDetail.description }}</p>
</div>
<div class="test-detail-main-right" v-if="discDetail.percentages">
<h4 class="detail-subtitle">四维占比</h4>
<div class="disc-bars">
<div
v-for="key in (['D','I','S','C'] as Array<'D' | 'I' | 'S' | 'C'>)"
:key="key"
class="disc-bar-row"
>
<span class="disc-bar-label">{{ key }}</span>
<div class="disc-bar-track">
<div
class="disc-bar-fill"
:class="'disc-bar-fill-' + key.toLowerCase()"
:style="{ width: (discDetail.percentages[key] || 0) + '%' }"
></div>
</div>
<span class="disc-bar-value">{{ discDetail.percentages[key] || 0 }}%</span>
</div>
</div>
</div>
</div>
<div class="test-detail-grid">
<div class="test-detail-block" v-if="discDetail.strengths?.length">
<h4 class="detail-subtitle">主要优势</h4>
<ul class="tag-list">
<li v-for="item in discDetail.strengths" :key="item">{{ item }}</li>
</ul>
</div>
<div class="test-detail-block" v-if="discDetail.weaknesses?.length">
<h4 class="detail-subtitle">注意事项</h4>
<ul class="tag-list tag-list-warning">
<li v-for="item in discDetail.weaknesses" :key="item">{{ item }}</li>
</ul>
</div>
<div class="test-detail-block" v-if="discDetail.careers?.length">
<h4 class="detail-subtitle">适合角色 / 职业</h4>
<ul class="tag-list tag-list-info">
<li v-for="item in discDetail.careers" :key="item">{{ item }}</li>
</ul>
</div>
</div>
</div>
<!-- PDP 图文详情 -->
<div v-else-if="currentTestType === 'pdp' && pdpDetail" class="detail-section test-detail-pdp">
<div class="test-detail-main-card">
<div class="test-detail-main-left">
<div class="pdp-emoji" v-if="pdpDetail.emoji">{{ pdpDetail.emoji }}</div>
<div class="pdp-type-line">
<span class="pdp-type">{{ pdpDetail.type }}</span>
<span class="pdp-title" v-if="pdpDetail.title">· {{ pdpDetail.title }}</span>
</div>
<p class="pdp-desc">{{ pdpDetail.description }}</p>
</div>
</div>
<div class="test-detail-grid">
<div class="test-detail-block" v-if="pdpDetail.teamRole">
<h4 class="detail-subtitle">团队角色定位</h4>
<p class="test-desc">{{ pdpDetail.teamRole }}</p>
</div>
<div class="test-detail-block" v-if="pdpDetail.strengths?.length">
<h4 class="detail-subtitle">核心优势</h4>
<ul class="tag-list">
<li v-for="item in pdpDetail.strengths" :key="item">{{ item }}</li>
</ul>
</div>
<div class="test-detail-block" v-if="pdpDetail.weaknesses?.length">
<h4 class="detail-subtitle">风险点</h4>
<ul class="tag-list tag-list-warning">
<li v-for="item in pdpDetail.weaknesses" :key="item">{{ item }}</li>
</ul>
</div>
<div class="test-detail-block" v-if="pdpDetail.careers?.length">
<h4 class="detail-subtitle">适合职业</h4>
<ul class="tag-list tag-list-info">
<li v-for="item in pdpDetail.careers" :key="item">{{ item }}</li>
</ul>
</div>
</div>
</div>
<!-- AI 人脸分析 图文详情 -->
<div v-else-if="(currentTestType === 'face' || currentTestType === 'ai') && faceDetail" class="detail-section test-detail-face">
<div class="face-layout">
<div class="face-left" v-if="faceDetail.photos && faceDetail.photos.length">
<div class="face-photos">
<el-image
v-for="(url, idx) in faceDetail.photos"
:key="url + idx"
:src="url"
fit="contain"
:preview-src-list="faceDetail.photos"
/>
</div>
</div>
<div class="face-right">
<!-- 第一组核心性格标签 (MBTI, DISC, PDP) -->
<div class="test-detail-row compact-row">
<div class="test-detail-block flex-1" v-if="faceDetail.mbti">
<h4 class="detail-subtitle">AI 识别 MBTI</h4>
<p class="test-desc">
<strong class="text-primary">{{ faceDetail.mbti.type }}</strong>
<span v-if="faceDetail.mbti.title" class="text-secondary"> · {{ faceDetail.mbti.title }}</span>
</p>
</div>
<div class="test-detail-block flex-1" v-if="faceDetail.disc">
<h4 class="detail-subtitle">AI 识别 DISC</h4>
<p class="test-desc">
主类型<strong class="text-primary">{{ faceDetail.disc.primary }}</strong>
<span v-if="faceDetail.disc.secondary" class="text-secondary">{{ faceDetail.disc.secondary }}</span>
</p>
</div>
<div class="test-detail-block flex-1" v-if="faceDetail.pdp">
<h4 class="detail-subtitle">AI 识别 PDP</h4>
<p class="test-desc">
主类型<strong class="text-primary">{{ faceDetail.pdp.primary }}</strong>
<span v-if="faceDetail.pdp.secondary" class="text-secondary">{{ faceDetail.pdp.secondary }}</span>
</p>
</div>
</div>
<!-- 第二组核心优势 (主要优势, 盖洛普) -->
<div class="test-detail-grid">
<div class="test-detail-block" v-if="faceDetail.advantages?.length">
<h4 class="detail-subtitle">主要优势</h4>
<ul class="tag-list">
<li v-for="item in faceDetail.advantages" :key="item">{{ item }}</li>
</ul>
</div>
<div class="test-detail-block" v-if="faceDetail.gallupTop3?.length">
<h4 class="detail-subtitle">盖洛普前三大优势</h4>
<ol class="face-gallup-list">
<li v-for="(g, gi) in faceDetail.gallupTop3" :key="gi">{{ g }}</li>
</ol>
</div>
</div>
<!-- 第三组综述 -->
<div class="test-detail-grid">
<div class="test-detail-block" v-if="faceDetail.overview">
<h4 class="detail-subtitle">整体气质概览</h4>
<p class="test-desc">{{ faceDetail.overview }}</p>
</div>
<div class="test-detail-block" v-if="faceDetail.personalitySummary">
<h4 class="detail-subtitle">性格总结</h4>
<p class="test-desc">{{ faceDetail.personalitySummary }}</p>
</div>
</div>
<!-- 第四组面相与骨相 -->
<div class="test-detail-grid">
<div class="test-detail-block" v-if="faceDetail.faceFeatures?.length">
<h4 class="detail-subtitle">面部特征分析</h4>
<ul class="face-feature-list">
<li v-for="(f, fi) in faceDetail.faceFeatures" :key="fi">
<strong v-if="f.label">{{ f.label }}</strong>
<span>{{ f.description }}</span>
</li>
</ul>
</div>
<div class="test-detail-block" v-else-if="faceDetail.faceAnalysisText">
<h4 class="detail-subtitle">面相分析</h4>
<p class="test-desc">{{ faceDetail.faceAnalysisText }}</p>
</div>
<div class="test-detail-block" v-if="faceDetail.boneIceSummary && (faceDetail.boneIceSummary.elementType || faceDetail.boneIceSummary.boneFleshRelation)">
<h4 class="detail-subtitle">骨相分析冰鉴</h4>
<p v-if="faceDetail.boneIceSummary.elementType" class="test-desc">
<strong>五行形相</strong>{{ faceDetail.boneIceSummary.elementType }}
</p>
<p v-if="faceDetail.boneIceSummary.boneFleshRelation" class="test-desc">
<strong>骨肉关系</strong>{{ faceDetail.boneIceSummary.boneFleshRelation }}
</p>
</div>
<div class="test-detail-block" v-if="faceDetail.boneAnalysisText">
<h4 class="detail-subtitle">骨相特征</h4>
<p class="test-desc">{{ faceDetail.boneAnalysisText }}</p>
</div>
</div>
<!-- 第五组人际与发展 -->
<div class="test-detail-grid">
<div class="test-detail-block" v-if="faceDetail.relationship">
<h4 class="detail-subtitle">人际关系与团队合作</h4>
<p class="test-desc">{{ faceDetail.relationship }}</p>
</div>
<div class="test-detail-block" v-if="faceDetail.careers?.length">
<h4 class="detail-subtitle">职业方向参考</h4>
<ul class="tag-list tag-list-info">
<li v-for="c in faceDetail.careers" :key="c">{{ c }}</li>
</ul>
</div>
</div>
<!-- 第六组生活场景 -->
<div class="test-detail-grid">
<div class="test-detail-block" v-if="faceDetail.careerDevelopment">
<h4 class="detail-subtitle">职业发展方向</h4>
<p class="test-desc">{{ faceDetail.careerDevelopment }}</p>
</div>
<div class="test-detail-block" v-if="faceDetail.familyParenting">
<h4 class="detail-subtitle">家庭亲子关系</h4>
<p class="test-desc">{{ faceDetail.familyParenting }}</p>
</div>
<div class="test-detail-block" v-if="faceDetail.partnerCofounder">
<h4 class="detail-subtitle">寻找合伙人</h4>
<p class="test-desc">{{ faceDetail.partnerCofounder }}</p>
</div>
</div>
<!-- 第七组深度职场洞察 (Portrait, Boss, HR, Resume) -->
<div class="test-detail-grid" v-if="faceDetail.portrait || faceDetail.bossView || faceDetail.hrView || faceDetail.resumeHighlights">
<template v-if="faceDetail.portrait">
<div class="test-detail-block" v-if="faceDetail.portrait.coreStrengths?.length">
<h4 class="detail-subtitle">深度·核心优势</h4>
<ul class="tag-list">
<li v-for="s in faceDetail.portrait.coreStrengths" :key="s">{{ s }}</li>
</ul>
</div>
<div class="test-detail-block" v-if="faceDetail.portrait.coreRisks?.length">
<h4 class="detail-subtitle">深度·潜在风险</h4>
<ul class="tag-list tag-list-warning">
<li v-for="r in faceDetail.portrait.coreRisks" :key="r">{{ r }}</li>
</ul>
</div>
<div class="test-detail-block" v-if="faceDetail.portrait.workStyle">
<h4 class="detail-subtitle">深度·工作风格</h4>
<p class="test-desc">{{ faceDetail.portrait.workStyle }}</p>
</div>
</template>
<div class="test-detail-block" v-if="faceDetail.resumeHighlights">
<h4 class="detail-subtitle">简历要点</h4>
<p class="test-desc">{{ faceDetail.resumeHighlights }}</p>
</div>
<template v-if="faceDetail.bossView">
<div class="test-detail-block" v-if="faceDetail.bossView.headline">
<h4 class="detail-subtitle">老板视角·一句话</h4>
<p class="test-desc">{{ faceDetail.bossView.headline }}</p>
</div>
<div class="test-detail-block" v-if="faceDetail.bossView.costInsight">
<h4 class="detail-subtitle">老板视角·成本洞察</h4>
<p class="test-desc">{{ faceDetail.bossView.costInsight }}</p>
</div>
</template>
<template v-if="faceDetail.hrView">
<div class="test-detail-block" v-if="faceDetail.hrView.roleRecommend?.bestFit?.length">
<h4 class="detail-subtitle">HR·推荐岗位</h4>
<ul class="tag-list tag-list-info">
<li v-for="r in faceDetail.hrView.roleRecommend.bestFit" :key="r">{{ r }}</li>
</ul>
</div>
<div class="test-detail-block" v-if="faceDetail.hrView.roleRecommend?.notSuitable?.length">
<h4 class="detail-subtitle">HR·不适合场景</h4>
<ul class="tag-list tag-list-warning">
<li v-for="r in faceDetail.hrView.roleRecommend.notSuitable" :key="r">{{ r }}</li>
</ul>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- 简历综合分析 v2 结构化详情 -->
<template v-else-if="currentTestType === 'resume' && resumeDetail">
<div v-if="resumeDetail.bossView" class="detail-section resume-boss-section">
<div class="resume-headline">{{ resumeDetail.bossView.headline }}</div>
<div class="resume-metrics-row">
<div
v-for="m in (resumeDetail.bossView.metrics || [])"
:key="m.label"
:class="['resume-metric-card', 'metric-' + (m.level || 'medium')]"
>
<div class="resume-metric-value">{{ m.value }}</div>
<div class="resume-metric-label">{{ m.label }}</div>
</div>
</div>
<div v-if="resumeDetail.bossView.costInsight" class="resume-cost-insight">
{{ resumeDetail.bossView.costInsight }}
</div>
</div>
<div v-if="resumeDetail.portrait" class="detail-section">
<div class="test-detail-grid">
<div class="test-detail-block" v-if="resumeDetail.portrait.coreStrengths?.length">
<h4 class="detail-subtitle">核心优势</h4>
<ul class="tag-list">
<li v-for="s in resumeDetail.portrait.coreStrengths" :key="s">{{ s }}</li>
</ul>
</div>
<div class="test-detail-block" v-if="resumeDetail.portrait.coreRisks?.length">
<h4 class="detail-subtitle">潜在风险</h4>
<ul class="tag-list tag-list-warning">
<li v-for="r in resumeDetail.portrait.coreRisks" :key="r">{{ r }}</li>
</ul>
</div>
<div class="test-detail-block" v-if="resumeDetail.portrait.workStyle">
<h4 class="detail-subtitle">工作风格</h4>
<p class="test-desc">{{ resumeDetail.portrait.workStyle }}</p>
</div>
</div>
</div>
<div v-if="resumeDetail.hrView" class="detail-section">
<div class="test-detail-grid">
<div class="test-detail-block" v-if="resumeDetail.hrView.roleRecommend?.bestFit?.length">
<h4 class="detail-subtitle">推荐岗位</h4>
<ul class="tag-list tag-list-info">
<li v-for="r in resumeDetail.hrView.roleRecommend.bestFit" :key="r">{{ r }}</li>
</ul>
</div>
<div class="test-detail-block" v-if="resumeDetail.hrView.roleRecommend?.notSuitable?.length">
<h4 class="detail-subtitle">不适合场景</h4>
<ul class="tag-list tag-list-warning">
<li v-for="r in resumeDetail.hrView.roleRecommend.notSuitable" :key="r">{{ r }}</li>
</ul>
</div>
</div>
<div class="resume-lifecycle" v-if="resumeDetail.hrView.lifecycle">
<h4 class="detail-subtitle">员工全生命周期预测</h4>
<div class="lifecycle-grid">
<div class="lifecycle-item" v-if="resumeDetail.hrView.lifecycle.onboarding">
<span class="lifecycle-label">入职</span>
<span class="lifecycle-text">{{ resumeDetail.hrView.lifecycle.onboarding }}</span>
</div>
<div class="lifecycle-item" v-if="resumeDetail.hrView.lifecycle.probation">
<span class="lifecycle-label">试用期</span>
<span class="lifecycle-text">{{ resumeDetail.hrView.lifecycle.probation }}</span>
</div>
<div class="lifecycle-item" v-if="resumeDetail.hrView.lifecycle.growth">
<span class="lifecycle-label">成长期</span>
<span class="lifecycle-text">{{ resumeDetail.hrView.lifecycle.growth }}</span>
</div>
<div class="lifecycle-item" v-if="resumeDetail.hrView.lifecycle.retention">
<span class="lifecycle-label">留存</span>
<span class="lifecycle-text">{{ resumeDetail.hrView.lifecycle.retention }}</span>
</div>
</div>
</div>
<div class="test-detail-grid" style="margin-top:12px">
<div class="test-detail-block" v-if="resumeDetail.hrView.performance">
<h4 class="detail-subtitle">绩效潜力</h4>
<p class="test-desc"><strong>{{ resumeDetail.hrView.performance.potential }}</strong></p>
<ul class="tag-list" v-if="resumeDetail.hrView.performance.drivers?.length">
<li v-for="d in resumeDetail.hrView.performance.drivers" :key="d">{{ d }}</li>
</ul>
<ul class="tag-list tag-list-warning" v-if="resumeDetail.hrView.performance.risks?.length">
<li v-for="r in resumeDetail.hrView.performance.risks" :key="r">{{ r }}</li>
</ul>
</div>
<div class="test-detail-block" v-if="resumeDetail.hrView.teamFit">
<h4 class="detail-subtitle">团队匹配 & 管理建议</h4>
<p class="test-desc" v-if="resumeDetail.hrView.teamFit.bestTeam">
<strong>最佳团队</strong>{{ resumeDetail.hrView.teamFit.bestTeam }}
</p>
<p class="test-desc" v-if="resumeDetail.hrView.teamFit.manageAdvice">
<strong>管理建议</strong>{{ resumeDetail.hrView.teamFit.manageAdvice }}
</p>
</div>
<div class="test-detail-block" v-if="resumeDetail.hrView.complianceRisk">
<h4 class="detail-subtitle">合规风险</h4>
<p class="test-desc">风险等级<strong>{{ resumeDetail.hrView.complianceRisk.level }}</strong></p>
<p class="test-desc" v-if="resumeDetail.hrView.complianceRisk.notes">{{ resumeDetail.hrView.complianceRisk.notes }}</p>
</div>
</div>
</div>
<div class="detail-section" v-if="resumeDetail.resumeHighlights">
<h4 class="detail-subtitle">简历要点</h4>
<p class="test-desc">{{ resumeDetail.resumeHighlights }}</p>
</div>
</template>
<!-- 简历旧版纯文本兼容 -->
<div v-else-if="currentTestType === 'resume' && currentTestDescription" class="detail-section test-detail-resume">
<h4 class="detail-subtitle">分析报告</h4>
<div class="resume-content">{{ currentTestDescription }}</div>
</div>
<!-- 通用补充说明人脸/AI 已在上方分块展示避免重复堆砌 -->
<div
class="detail-section"
v-if="currentTestType !== 'resume' && currentTestType !== 'face' && currentTestType !== 'ai' && currentTestDescription"
>
<h4 class="detail-subtitle">补充说明</h4>
<p class="test-desc">{{ currentTestDescription }}</p>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
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 { buildFaceDetailFromParsed } from '@/utils/faceResultDetail'
import { parseTestResultPayload } from '@/utils/testResultParse'
import UserDetailDialog from '@/components/UserDetailDialog.vue'
withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
const loading = ref(false)
const overviewLoading = ref(false)
const detailLoading = ref(false)
const testDetailLoading = ref(false)
const total = ref(0)
const currentPage = ref(1)
const pageSize = 10
const searchTerm = ref('')
const selectedCard = ref(0)
const selectedMbti = ref('')
const showDetailDialog = ref(false)
const detailUser = ref<Record<string, any> | null>(null)
const showTestDetailDialog = ref(false)
const currentTest = ref<any | null>(null)
const rawTests = computed<any[]>(() => {
const u: any = detailUser.value
if (!u) return []
// 详情接口只需要完整测试列表,不做去重或截断
return (u.testList || []) as any[]
})
const currentTestType = computed(() => (currentTest.value?.testType || '').toLowerCase())
const userCards = ref<Array<{ type: string; name: string; total: number; active: number; tested: number; enterpriseId?: number }>>([
{ type: 'all', name: '全部用户', total: 0, active: 0, tested: 0 }
])
const mbtiTypes = ref<Array<{ type: string; count: number }>>([])
const users = ref<any[]>([])
const summary = computed(() => {
const card = userCards.value[selectedCard.value]
if (!card) return { totalUsers: 0, activeUsers: 0, activePercent: 0, testedUsers: 0, testedPercent: 0 }
const activePercent = card.total > 0 ? Math.round((card.active / card.total) * 100) : 0
const testedPercent = card.total > 0 ? Math.round((card.tested / card.total) * 100) : 0
return {
totalUsers: card.total,
activeUsers: card.active,
activePercent,
testedUsers: card.tested,
testedPercent
}
})
async function loadOverview() {
overviewLoading.value = true
try {
const res: any = await request.get('/superadmin/app-users/overview')
const data = res.data ?? res
userCards.value = data.userCards ?? [{ type: 'all', name: '全部用户', total: 0, active: 0, tested: 0 }]
mbtiTypes.value = data.mbtiDistribution ?? []
} catch {
userCards.value = [{ type: 'all', name: '全部用户', total: 0, active: 0, tested: 0 }]
mbtiTypes.value = []
} finally {
overviewLoading.value = false
}
}
async function loadUsers() {
loading.value = true
try {
const card = userCards.value[selectedCard.value]
const params: Record<string, any> = {
page: currentPage.value,
pageSize,
keyword: searchTerm.value
}
if (card) {
params.pool = card.type === 'all' ? 'all' : card.type === 'individual' ? 'individual' : 'enterprise'
if (card.type === 'enterprise' && card.enterpriseId != null) params.enterpriseId = card.enterpriseId
}
if (selectedMbti.value) params.mbti = selectedMbti.value
const res: any = await request.get('/superadmin/app-users', { params })
const list = res.data?.list ?? res?.list ?? []
users.value = list.map((row: any) => ({
...row,
username: row.username ?? row.nickname ?? ('用户' + row.id)
}))
total.value = res.data?.total ?? res?.total ?? 0
} catch {
users.value = []
total.value = 0
} finally {
loading.value = false
}
}
function onSelectCard(index: number) {
selectedCard.value = index
currentPage.value = 1
loadUsers()
}
function onSelectMbti(type: string) {
selectedMbti.value = selectedMbti.value === type ? '' : type
currentPage.value = 1
loadUsers()
}
function formatPhone(phone: string) {
if (!phone) return '-'
if (phone.length === 11) return phone.substring(0, 3) + '****' + phone.substring(7)
return phone
}
/** 文字头像:根据昵称取首字 */
function avatarLetter(row: { username?: string; nickname?: string }) {
const name = (row?.username || row?.nickname || '?').trim()
return (name.charAt(0) || '?').toUpperCase()
}
const AVATAR_PALETTE = ['#6366f1', '#8b5cf6', '#ec4899', '#f43f5e', '#14b8a6', '#0ea5e9', '#3b82f6', '#eab308']
/** 文字头像:根据昵称生成固定背景色(同一昵称同色) */
function avatarBgColor(row: { username?: string; nickname?: string }) {
const name = (row?.username || row?.nickname || '?').trim()
let hash = 0
for (let i = 0; i < name.length; i++) hash += name.charCodeAt(i)
return AVATAR_PALETTE[Math.abs(hash) % AVATAR_PALETTE.length]
}
function formatDate(date: number | string | null | undefined) {
if (date == null) return '-'
if (typeof date === 'number') {
const d = new Date(date * 1000)
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
}
return String(date)
}
function formatAmount(amountFen: number | string | null | undefined) {
if (amountFen == null) return '¥0.00'
const num = typeof amountFen === 'number' ? amountFen : Number(amountFen)
if (!Number.isFinite(num) || num <= 0) return '¥0.00'
return '¥' + (num / 100).toFixed(2)
}
function formatTestType(testType: string) {
const t = (testType || '').toLowerCase()
if (!t) return '-'
if (t === 'mbti') return 'MBTI'
if (t === 'disc') return 'DISC'
if (t === 'pdp') return 'PDP'
if (t === 'face' || t === 'ai') return 'AI 人脸分析'
if (t === 'resume') return '简历综合分析'
return testType
}
function extractTestSummary(test: any): string {
const data = parseTestResultPayload(test?.result ?? test?.resultData)
if (!data) {
const raw = test?.result
return typeof raw === 'string' ? raw : ''
}
const type = (test?.testType || '').toLowerCase()
if (type === 'mbti') {
return String(data.mbtiType ?? data.type ?? data.result ?? '')
}
if (type === 'disc') {
const desc = data.description?.type
if (typeof desc === 'string' && desc) return desc
if (data.dominantType) return String(data.dominantType) + '型'
return String(data.disc ?? '')
}
if (type === 'pdp') {
const desc = data.description?.type
if (typeof desc === 'string' && desc) return desc
if (data.dominantType) return String(data.dominantType)
return String(data.pdp ?? '')
}
if (type === 'face' || type === 'ai') {
return '人脸分析'
}
if (type === 'resume') {
const c = String(data.content ?? '')
return c ? c.substring(0, 30).replace(/\n/g, ' ') + (c.length > 30 ? '...' : '') : '简历综合分析'
}
return String(data.type ?? data.result ?? '')
}
const currentTestParsed = computed<any | null>(() => parseTestResult(currentTest.value))
const currentTestSummary = computed(() =>
currentTest.value ? extractTestSummary(currentTest.value) : ''
)
const currentTestDescription = computed(() =>
extractTestDescription(currentTestParsed.value, currentTest.value?.testType ?? '')
)
const mbtiDetail = computed(() => {
const parsed = currentTestParsed.value
if (!parsed || currentTestType.value !== 'mbti') return null
const desc = parsed.description || {}
return {
type: parsed.mbtiType ?? desc.type ?? currentTestSummary.value,
name: desc.name ?? '',
description: desc.description ?? '',
strengths: Array.isArray(desc.strengths) ? desc.strengths : [],
weaknesses: Array.isArray(desc.weaknesses) ? desc.weaknesses : [],
careers: Array.isArray(desc.careers) ? desc.careers : []
}
})
const discDetail = computed(() => {
const parsed = currentTestParsed.value
if (!parsed || currentTestType.value !== 'disc') return null
const desc = parsed.description || {}
const percentages = parsed.percentages || {}
return {
type: desc.type ?? currentTestSummary.value,
title: desc.title ?? '',
description: desc.description ?? '',
strengths: Array.isArray(desc.strengths) ? desc.strengths : [],
weaknesses: Array.isArray(desc.weaknesses) ? desc.weaknesses : [],
careers: Array.isArray(desc.careers) ? desc.careers : [],
percentages: {
D: percentages.D ?? percentages.d ?? 0,
I: percentages.I ?? percentages.i ?? 0,
S: percentages.S ?? percentages.s ?? 0,
C: percentages.C ?? percentages.c ?? 0
}
}
})
const pdpDetail = computed(() => {
const parsed = currentTestParsed.value
if (!parsed || currentTestType.value !== 'pdp') return null
const desc = parsed.description || {}
return {
type: desc.type ?? currentTestSummary.value,
title: desc.title ?? '',
emoji: desc.emoji ?? '',
description: desc.description ?? '',
teamRole: desc.teamRole ?? '',
strengths: Array.isArray(desc.strengths) ? desc.strengths : [],
weaknesses: Array.isArray(desc.weaknesses) ? desc.weaknesses : [],
careers: Array.isArray(desc.careers) ? desc.careers : []
}
})
const faceDetail = computed(() => {
const parsed = currentTestParsed.value
if (!parsed || (currentTestType.value !== 'face' && currentTestType.value !== 'ai')) return null
return buildFaceDetailFromParsed(parsed)
})
const resumeDetail = computed(() => {
const parsed = currentTestParsed.value
if (!parsed || currentTestType.value !== 'resume') return null
if (parsed.version === 2 || parsed.overview) return parsed
return null
})
function parseTestResult(test: any): any {
if (!test) return null
return parseTestResultPayload(test.result ?? test.resultData)
}
function extractTestDescription(parsed: any, testType: string): string {
if (!parsed || typeof parsed !== 'object') return ''
const t = (testType || '').toLowerCase()
if (t === 'mbti') {
return String(parsed.description?.description ?? '')
}
if (t === 'disc' || t === 'pdp') {
return String(parsed.description?.description ?? '')
}
if (t === 'face' || t === 'ai') {
const parts = [
parsed.relationship,
parsed.faceAnalysis && typeof parsed.faceAnalysis === 'string' ? parsed.faceAnalysis : '',
parsed.boneAnalysis && typeof parsed.boneAnalysis === 'string' ? parsed.boneAnalysis : ''
].map((x) => String(x || '').trim()).filter(Boolean)
const extra = parts.length ? `\n\n${parts.join('\n\n')}` : ''
return String(parsed.overview ?? parsed.personalitySummary ?? '') + extra
}
if (t === 'resume') {
if (parsed.version === 2 || parsed.overview) {
return String(parsed.overview ?? '')
}
return String(parsed.content ?? '')
}
return ''
}
// 保留 parse 能力供上方图文解析使用
function handlePageChange() {
loadUsers()
}
function exportData() {
ElMessage.info('导出功能开发中')
}
function normalizeDetailUser(payload: any) {
const data = payload ?? {}
// 如果是 { user: {...}, tests: [...] } 这种旧结构,做一次扁平化
if (data.user) {
const user = data.user || {}
const extra = { ...data }
delete extra.user
const merged: any = {
...user,
...extra
}
// 兼容不同测试列表字段
merged.testList =
data.testList ?? data.tests ?? user.testList ?? user.tests ?? []
// 兼容可能存在的类型字段
merged.mbtiType = merged.mbtiType ?? user.mbtiType
merged.pdpType = merged.pdpType ?? user.pdpType
merged.discType = merged.discType ?? user.discType
merged.faceType = merged.faceType ?? user.faceType
return merged
}
return data
}
async function loadDetailUser(userId: number) {
try {
detailLoading.value = true
const res: any = await request.get(`/superadmin/app-users/${userId}`)
const raw = res.data ?? res
const normalized = normalizeDetailUser(raw)
detailUser.value = normalized
} catch {
ElMessage.error('获取用户详情失败')
throw new Error('load detail failed')
} finally {
detailLoading.value = false
}
}
async function handleView(row: any) {
detailUser.value = null
showDetailDialog.value = true
try {
await loadDetailUser(row.id)
} catch {
showDetailDialog.value = false
}
}
async function handleViewTest(row: any) {
const testId = row?.id != null ? Number(row.id) : NaN
if (!Number.isFinite(testId) || testId <= 0) {
ElMessage.warning('测试记录缺少有效 ID')
return
}
currentTest.value = null
showTestDetailDialog.value = true
testDetailLoading.value = true
try {
const res: any = await request.get(`/superadmin/test-records/${testId}`)
const payload = res?.data != null ? res.data : res
if (payload && typeof payload === 'object' && (payload.testType != null || payload.result != null)) {
currentTest.value = payload
} else {
ElMessage.warning('未获取到测试详情')
showTestDetailDialog.value = false
}
} catch {
showTestDetailDialog.value = false
} finally {
testDetailLoading.value = false
}
}
async function handleClickTestTag(row: any, testType: string) {
// 仅加载用户测试记录,弹出单次测试详情对话框并显示加载动画
currentTest.value = null
showTestDetailDialog.value = true
testDetailLoading.value = true
try {
if (!detailUser.value || detailUser.value.id !== row.id) {
await loadDetailUser(row.id)
}
const target = (testType || '').toLowerCase()
const tests = rawTests.value
if (!tests.length) return
// 人脸分析相关的标签都定位到最近一次 face 测试
const realType = target === 'face' ? 'face' : target
const found = tests.find(t => (t.testType || '').toLowerCase() === realType)
if (!found) return
currentTest.value = found
} catch {
showTestDetailDialog.value = false
} finally {
testDetailLoading.value = false
}
}
onMounted(() => {
loadOverview().then(() => loadUsers())
})
</script>
<style scoped lang="scss">
.page-container {
padding: 24px;
min-height: calc(100vh - 64px);
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 24px;
.header-left {
h2 {
font-size: 24px;
font-weight: 700;
color: #111827;
margin: 0 0 4px 0;
}
.subtitle {
font-size: 14px;
color: #6b7280;
margin: 0;
}
}
.header-actions {
display: flex;
gap: 12px;
}
}
.user-overview-grid {
display: flex;
gap: 16px;
margin-bottom: 24px;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 8px;
scrollbar-width: thin;
scrollbar-color: #d1d5db #f3f4f6;
&::-webkit-scrollbar {
height: 6px;
}
&::-webkit-scrollbar-track {
background: #f3f4f6;
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 3px;
&:hover {
background: #9ca3af;
}
}
.user-card {
flex-shrink: 0;
min-width: 180px;
width: 180px;
}
}
.user-card {
background: #fff;
border-radius: 8px;
padding: 14px;
border: 2px solid #f3f4f6;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
gap: 12px;
&:hover {
border-color: #e5e7eb;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
&.active {
border-color: #3b82f6;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2);
}
.card-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
.card-icon {
width: 20px;
height: 20px;
border-radius: 4px;
background-color: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
color: #6b7280;
font-size: 12px;
flex-shrink: 0;
}
.card-name {
font-size: 12px;
font-weight: 600;
color: #111827;
line-height: 1.4;
flex: 1;
}
}
.card-stats {
display: flex;
flex-direction: column;
gap: 8px;
.stat-main {
display: flex;
align-items: baseline;
gap: 3px;
.stat-value-large {
font-size: 24px;
font-weight: 700;
color: #111827;
line-height: 1;
}
.stat-label-large {
font-size: 14px;
color: #6b7280;
font-weight: 500;
}
}
.stat-sub {
display: flex;
gap: 12px;
align-items: center;
.stat-active {
font-size: 12px;
font-weight: 600;
color: #22c55e;
}
.stat-tested {
font-size: 12px;
font-weight: 600;
color: #a855f7;
}
}
}
}
.summary-metrics-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 24px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
.summary-card {
background: #fff;
border-radius: 8px;
padding: 16px;
border: 1px solid #f3f4f6;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
gap: 6px;
.summary-label {
font-size: 12px;
color: #6b7280;
}
.summary-value {
font-size: 18px;
font-weight: 700;
color: #111827;
.summary-percent {
font-size: 13px;
font-weight: 400;
color: #9ca3af;
margin-left: 4px;
}
}
}
}
.mbti-distribution-card {
background: #fff;
border-radius: 8px;
padding: 16px;
border: 1px solid #f3f4f6;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
margin-bottom: 20px;
.mbti-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
.mbti-icon {
font-size: 14px;
color: #6b7280;
}
.mbti-title {
font-size: 13px;
font-weight: 600;
color: #111827;
}
}
.mbti-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
.mbti-tag {
padding: 6px 12px;
font-size: 13px;
color: #7c3aed;
background-color: #f3e8ff;
border: 1px solid #e9d5ff;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
&:hover {
background-color: #e9d5ff;
border-color: #d8b4fe;
}
&.active {
background-color: #ddd6fe;
border-color: #c4b5fd;
color: #6d28d9;
font-weight: 600;
}
}
}
}
.search-section {
margin-bottom: 20px;
display: flex;
gap: 12px;
align-items: center;
.search-input {
flex: 1;
max-width: 400px;
:deep(.el-input__wrapper) {
border-radius: 8px;
background-color: #fff;
box-shadow: 0 0 0 1px #e5e7eb inset;
padding: 10px 16px;
transition: all 0.2s;
&.is-focus {
box-shadow: 0 0 0 1px #a855f7 inset, 0 0 0 3px rgba(168, 85, 247, 0.1);
}
&:hover {
box-shadow: 0 0 0 1px #d1d5db inset;
}
}
:deep(.el-input__inner) {
font-size: 14px;
color: #111827;
&::placeholder {
color: #9ca3af;
}
}
}
}
.table-card {
background: #fff;
border-radius: 10px;
border: 1px solid #f3f4f6;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
.user-table {
:deep(.el-table__header) {
th {
background-color: #f9fafb;
color: #6b7280;
font-weight: 500;
font-size: 13px;
padding: 12px 16px;
}
}
:deep(.el-table__body) {
td {
padding: 12px 16px;
}
}
.user-info-cell {
display: flex;
align-items: center;
gap: 12px;
.user-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 600;
flex-shrink: 0;
}
.user-avatar-img {
object-fit: cover;
}
.user-avatar-letter {
color: #fff;
}
.user-details {
.username {
font-weight: 600;
color: #111827;
font-size: 14px;
margin-bottom: 2px;
}
.role-label {
font-size: 12px;
color: #9ca3af;
}
}
}
.openid-line {
font-size: 11px;
color: #6b7280;
font-family: ui-monospace, monospace;
word-break: break-all;
margin-bottom: 2px;
}
.contact-cell {
.phone {
color: #111827;
font-size: 13px;
margin-bottom: 2px;
}
.email {
color: #9ca3af;
font-size: 12px;
}
}
.test-results {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
.result-tag {
font-size: 12px;
}
.no-test {
font-size: 13px;
color: #9ca3af;
}
}
.enterprise-cell {
display: flex;
align-items: center;
gap: 6px;
.enterprise-icon {
font-size: 14px;
color: #9ca3af;
}
.enterprise-name {
font-size: 13px;
color: #374151;
}
}
.time-cell {
font-size: 13px;
color: #374151;
}
.count-cell {
font-size: 13px;
color: #111827;
font-weight: 500;
}
.payment-cell {
font-size: 13px;
color: #111827;
}
.el-button {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #6b7280;
&:hover {
color: #a855f7;
}
}
}
.empty-state {
padding: 60px;
text-align: center;
color: #9ca3af;
font-size: 14px;
}
.test-table {
:deep(.el-table__header) {
th {
background-color: #f9fafb;
color: #6b7280;
font-weight: 500;
font-size: 13px;
padding: 12px 16px;
}
}
:deep(.el-table__body) {
td {
padding: 10px 16px;
font-size: 13px;
color: #111827;
}
}
}
.pagination-container {
padding: 20px;
display: flex;
justify-content: center;
}
.detail-section {
margin-bottom: 16px;
}
.detail-row {
display: flex;
margin-bottom: 10px;
font-size: 14px;
}
.detail-label {
width: 90px;
color: #6b7280;
flex-shrink: 0;
}
.detail-subtitle {
font-size: 14px;
color: #374151;
margin: 12px 0 8px 0;
}
.mr-1 {
margin-right: 4px;
}
.test-detail-header {
padding-bottom: 8px;
border-bottom: 1px solid #f3f4f6;
}
.test-detail-header-main {
display: flex;
align-items: center;
gap: 12px;
}
.test-type-tag {
padding: 4px 10px;
border-radius: 999px;
background-color: #eef2ff;
color: #4f46e5;
font-size: 12px;
font-weight: 600;
}
.test-type-title {
font-size: 18px;
font-weight: 600;
color: #111827;
}
.test-type-meta {
margin-top: 4px;
font-size: 12px;
color: #6b7280;
}
.test-detail-main-card {
background-color: #f9fafb;
border-radius: 10px;
padding: 14px 16px;
display: flex;
justify-content: space-between;
gap: 16px;
margin-top: 8px;
}
.test-detail-main-left {
flex: 1;
}
.test-detail-main-right {
width: 260px;
flex-shrink: 0;
}
.mbti-type-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 10px;
border-radius: 999px;
background-color: #eef2ff;
color: #4f46e5;
font-weight: 700;
font-size: 14px;
margin-bottom: 4px;
}
.mbti-name {
font-size: 16px;
font-weight: 600;
color: #111827;
margin-bottom: 4px;
}
.mbti-desc {
font-size: 13px;
color: #4b5563;
margin: 0;
}
.disc-type-badge {
@extend .mbti-type-badge;
background-color: #fee2e2;
color: #b91c1c;
}
.disc-title {
font-size: 15px;
font-weight: 600;
color: #111827;
margin-bottom: 4px;
}
.disc-desc {
font-size: 13px;
color: #4b5563;
margin: 0;
}
.disc-bars {
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.disc-bar-row {
display: flex;
align-items: center;
gap: 6px;
}
.disc-bar-label {
width: 16px;
font-size: 12px;
font-weight: 600;
color: #4b5563;
}
.disc-bar-track {
flex: 1;
height: 6px;
border-radius: 999px;
background-color: #e5e7eb;
overflow: hidden;
}
.disc-bar-fill {
height: 100%;
border-radius: 999px;
}
.disc-bar-fill-d {
background-color: #f97373;
}
.disc-bar-fill-i {
background-color: #facc15;
}
.disc-bar-fill-s {
background-color: #22c55e;
}
.disc-bar-fill-c {
background-color: #3b82f6;
}
.disc-bar-value {
width: 42px;
font-size: 12px;
color: #4b5563;
text-align: right;
}
.pdp-emoji {
font-size: 28px;
margin-bottom: 4px;
}
.pdp-type-line {
display: flex;
align-items: baseline;
gap: 4px;
margin-bottom: 4px;
}
.pdp-type {
font-size: 16px;
font-weight: 700;
color: #b45309;
}
.pdp-title {
font-size: 14px;
color: #92400e;
}
.pdp-desc {
font-size: 13px;
color: #4b5563;
margin: 0;
}
.test-detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 12px;
}
.test-detail-block {
background-color: #f9fafb;
border-radius: 8px;
padding: 10px 12px;
}
.tag-list {
list-style: none;
padding: 0;
margin: 4px 0 0 0;
display: flex;
flex-wrap: wrap;
gap: 6px;
li {
padding: 3px 8px;
border-radius: 999px;
background-color: #eef2ff;
color: #4f46e5;
font-size: 12px;
}
}
.tag-list-warning li {
background-color: #fef3c7;
color: #92400e;
}
.tag-list-info li {
background-color: #e0f2fe;
color: #0369a1;
}
.face-layout {
display: flex;
gap: 16px;
margin-top: 8px;
}
.face-left {
width: 280px;
flex-shrink: 0;
position: sticky;
top: 0;
height: fit-content;
}
.face-photos {
display: flex;
flex-direction: column;
gap: 10px;
:deep(.el-image) {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
border-radius: 8px;
overflow: hidden;
background: #f1f5f9;
}
:deep(.el-image__inner) {
display: block;
max-width: 100%;
max-height: min(65vh, 560px);
width: auto !important;
height: auto !important;
object-fit: contain;
}
}
.face-right {
flex: 1;
}
.test-detail-row {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.compact-row .test-detail-block {
margin-bottom: 0 !important;
padding: 12px;
background-color: #f9fafb;
border-radius: 8px;
}
.flex-1 {
flex: 1;
}
.text-primary {
color: #4f46e5;
font-weight: 600;
}
.text-secondary {
color: #6b7280;
font-weight: normal;
}
.face-gallup-list {
margin: 6px 0 0 18px;
padding: 0;
font-size: 13px;
color: #374151;
line-height: 1.6;
}
.face-feature-list {
margin: 6px 0 0;
padding-left: 18px;
font-size: 13px;
color: #4b5563;
line-height: 1.65;
li {
margin-bottom: 6px;
}
}
.test-desc {
font-size: 13px;
color: #4b5563;
margin: 4px 0 0 0;
line-height: 1.6;
}
.resume-content {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.8;
font-size: 14px;
color: #374151;
background: #f9fafb;
border-radius: 8px;
padding: 16px;
max-height: 480px;
overflow-y: auto;
}
.resume-boss-section {
background: linear-gradient(135deg, #ede9fe 0%, #e0f2fe 100%);
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 4px;
}
.resume-headline {
font-size: 15px;
font-weight: 600;
color: #1e1b4b;
margin-bottom: 12px;
}
.resume-metrics-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.resume-metric-card {
flex: 1;
min-width: 80px;
background: rgba(255,255,255,0.7);
border-radius: 10px;
padding: 10px 12px;
text-align: center;
backdrop-filter: blur(6px);
border: 1px solid rgba(255,255,255,0.9);
}
.resume-metric-value {
font-size: 18px;
font-weight: 700;
line-height: 1.2;
}
.resume-metric-label {
font-size: 11px;
color: #6b7280;
margin-top: 3px;
}
.metric-high .resume-metric-value { color: #059669; }
.metric-medium .resume-metric-value { color: #d97706; }
.metric-low .resume-metric-value { color: #dc2626; }
.resume-cost-insight {
font-size: 12px;
color: #4338ca;
background: rgba(255,255,255,0.5);
border-radius: 6px;
padding: 6px 10px;
}
.resume-lifecycle { margin-top: 12px; }
.lifecycle-grid {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.lifecycle-item {
display: flex;
gap: 10px;
align-items: flex-start;
font-size: 13px;
}
.lifecycle-label {
flex-shrink: 0;
width: 44px;
background: #e0e7ff;
color: #3730a3;
border-radius: 4px;
padding: 2px 6px;
font-size: 11px;
font-weight: 600;
text-align: center;
margin-top: 1px;
}
.lifecycle-text {
color: #374151;
line-height: 1.6;
}
.json-block {
max-height: 260px;
overflow: auto;
background-color: #0f172a;
color: #e5e7eb;
padding: 10px 12px;
border-radius: 8px;
font-size: 12px;
line-height: 1.4;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.page-container.is-embedded {
min-height: auto;
}
</style>