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

View File

@@ -142,6 +142,12 @@ class Dashboard extends BaseController
$topTestUsers = $this->buildTopTestUsers($enterpriseId, 10);
$testCatalog = $this->buildTestCatalog($enterpriseId);
$distributionMbti = $this->aggregateTestLabels($enterpriseId, 'mbti', 14);
$distributionDisc = $this->aggregateTestLabels($enterpriseId, 'disc', 12);
$distributionPdp = $this->aggregateTestLabels($enterpriseId, 'pdp', 12);
$faceSubtypeHints = $this->aggregateFaceSubtypeHints($enterpriseId, 8);
return success([
'totalUsers' => $totalUsers,
'testsCompleted' => $testsCompleted,
@@ -149,6 +155,11 @@ class Dashboard extends BaseController
'pendingReviews' => $pendingReviews,
'testTrends' => $trendData,
'topTestUsers' => $topTestUsers,
'testCatalog' => $testCatalog,
'distributionMbti' => $distributionMbti,
'distributionDisc' => $distributionDisc,
'distributionPdp' => $distributionPdp,
'faceSubtypeHints' => $faceSubtypeHints,
]);
} catch (\Exception $e) {
return error('获取统计数据失败:' . $e->getMessage(), 500);
@@ -248,6 +259,148 @@ class Dashboard extends BaseController
return $out;
}
/**
* 四类测评完成人次 / 参与人数(本企业口径)
*
* @return array<int, array{key:string,label:string,records:int,uniqueUsers:int}>
*/
private function buildTestCatalog(?int $enterpriseId): array
{
$defs = [
['key' => 'face', 'label' => '人脸分析'],
['key' => 'mbti', 'label' => 'MBTI'],
['key' => 'disc', 'label' => 'DISC'],
['key' => 'pdp', 'label' => 'PDP'],
];
$out = [];
foreach ($defs as $def) {
$tt = $def['key'];
$q = Db::name('test_results')->where('testType', $tt);
if ($enterpriseId) {
$q->where('enterpriseId', $enterpriseId);
}
$records = (int) $q->count();
$q2 = Db::name('test_results')->where('testType', $tt);
if ($enterpriseId) {
$q2->where('enterpriseId', $enterpriseId);
}
$uniqueUsers = (int) $q2->distinct(true)->count('userId');
$out[] = [
'key' => $tt,
'label' => $def['label'],
'records' => $records,
'uniqueUsers' => $uniqueUsers,
];
}
return $out;
}
/**
* 按结果标签聚合单类测评(与列表摘要同一解析逻辑)
*
* @return array<int, array{label:string,count:int}>
*/
private function aggregateTestLabels(?int $enterpriseId, string $testType, int $topN): array
{
$counts = [];
$query = Db::name('test_results')
->where('testType', $testType)
->field('id,resultData');
if ($enterpriseId) {
$query->where('enterpriseId', $enterpriseId);
}
$query->chunk(400, function ($rows) use (&$counts, $testType) {
foreach ($rows as $row) {
$raw = $row['resultData'] ?? '';
$label = $this->labelFromResultRow($testType, $raw);
if ($label === '') {
$label = '未识别';
}
$counts[$label] = ($counts[$label] ?? 0) + 1;
}
});
arsort($counts);
return $this->countsToTopNWithOther($counts, $topN);
}
/**
* 人脸结果中推测的 MBTI / DISC / PDP 标签分布(辅助「面相」侧报告)
*
* @return array{mbti:array,disc:array,pdp:array}
*/
private function aggregateFaceSubtypeHints(?int $enterpriseId, int $topN): array
{
$subMaps = ['mbti' => [], 'disc' => [], 'pdp' => []];
$query = Db::name('test_results')
->where('testType', 'face')
->field('id,resultData');
if ($enterpriseId) {
$query->where('enterpriseId', $enterpriseId);
}
$query->chunk(400, function ($rows) use (&$subMaps) {
foreach ($rows as $row) {
$raw = $row['resultData'] ?? '';
$str = is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE);
if ($str === '' || $str === 'null') {
continue;
}
foreach (['mbti', 'disc', 'pdp'] as $sub) {
$label = $this->extractFaceSubType([['testType' => 'face', 'result' => $str]], $sub);
if ($label === '') {
continue;
}
$subMaps[$sub][$label] = ($subMaps[$sub][$label] ?? 0) + 1;
}
}
});
$out = [];
foreach ($subMaps as $k => $counts) {
arsort($counts);
$out[$k] = $this->countsToTopNWithOther($counts, $topN);
}
return $out;
}
/**
* @param array<string,int> $counts
* @return array<int, array{label:string,count:int}>
*/
private function countsToTopNWithOther(array $counts, int $topN): array
{
$topN = min(max($topN, 1), 50);
$items = [];
$i = 0;
$other = 0;
foreach ($counts as $label => $c) {
$c = (int) $c;
if ($i < $topN) {
$items[] = ['label' => (string) $label, 'count' => $c];
$i++;
} else {
$other += $c;
}
}
if ($other > 0) {
$items[] = ['label' => '其他', 'count' => $other];
}
return $items;
}
private function labelFromResultRow(string $testType, $raw): string
{
$str = is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE);
if ($str === '' || $str === 'null') {
return '';
}
return $this->extractResultType([['testType' => $testType, 'result' => $str]], $testType);
}
/**
* 格式化时间
* @param int $timestamp

View File

@@ -626,6 +626,9 @@ PROMPT;
if (!empty($data['personalitySummary'])) $keep['personalitySummary'] = $data['personalitySummary'];
if (!empty($data['faceAnalysis'])) $keep['faceAnalysis'] = $data['faceAnalysis'];
if (!empty($data['boneAnalysis'])) $keep['boneAnalysis'] = $data['boneAnalysis'];
if (!empty($data['careerDevelopment'])) $keep['careerDevelopment'] = $data['careerDevelopment'];
if (!empty($data['familyParenting'])) $keep['familyParenting'] = $data['familyParenting'];
if (!empty($data['partnerCofounder'])) $keep['partnerCofounder'] = $data['partnerCofounder'];
return $keep;
case 'mbti':
@@ -917,12 +920,15 @@ PROMPT;
. "6. 骨相分析结合《冰鉴》八骨约100字\n"
. "7. 主要优势3个关键词\n"
. "8. 性格概述50字以内\n"
. "9. 人际关系与团队合作风格50字以内\n";
. "9. 人际关系与团队合作风格50字以内\n"
. "10. 职业发展方向分析80字以内结合 MBTI 给出路径与阶段建议)\n"
. "11. 家庭与亲子关系分析80字以内沟通与期待管理\n"
. "12. 寻找合伙人/合作搭档分析80字以内互补、分工与风险点\n";
if ($isEnterprise) {
$analysisItems .= "10. 职业画像核心优势3项、潜在风险2项、一句话工作风格\n"
. "11. HR视角最适合岗位3个、不适合场景2个、入职/试用/成长期预测、绩效潜力(高潜/中潜/稳健)、合规风险(低/中/高)、团队适配建议\n"
. "12. 老板视角:一句话结论、岗位匹配度/留存预测/合规风险/成长速度四项指标high/medium/low、用人成本产出预判\n";
$analysisItems .= "13. 职业画像核心优势3项、潜在风险2项、一句话工作风格\n"
. "14. HR视角最适合岗位3个、不适合场景2个、入职/试用/成长期预测、绩效潜力(高潜/中潜/稳健)、合规风险(低/中/高)、团队适配建议\n"
. "15. 老板视角:一句话结论、岗位匹配度/留存预测/合规风险/成长速度四项指标high/medium/low、用人成本产出预判\n";
}
$defaultJsonTemplate = $analysisItems;
@@ -934,7 +940,8 @@ PROMPT;
. 'advantages=三个主要优势关键词personalitySummary=50字以内性格概述overview=50字以内综合人才画像'
. 'faceAnalysis=面相五官详细描述(额头/眼睛/耳朵/鼻子/嘴巴/下巴约100字'
. 'boneAnalysis=《冰鉴》八骨骨相描述(颧骨/驿马骨/将军骨/日角骨/月角骨/龙宫骨/伏犀骨/龙角骨约100字'
. 'relationship=人际关系与团队合作风格约50字gallupTop3=盖洛普前三大优势主题名称' . "\n"
. 'relationship=人际关系与团队合作风格约50字gallupTop3=盖洛普前三大优势主题名称'
. 'careerDevelopment=职业发展方向分析约80字familyParenting=家庭与亲子关系分析约80字partnerCofounder=寻找合伙人合作搭档分析约80字。' . "\n"
. '【第一步-人脸检测】先判断图片中是否有清晰可见的人脸:'
. '若无人脸/图片模糊/非人像,只返回 {"hasFace":false},不要其他内容。'
. '若检测到清晰人脸,直接给出结论,返回以下完整 JSON所有字段必填参考示例格式'
@@ -944,7 +951,10 @@ PROMPT;
. '"faceAnalysis":"额头宽阔平整,眼神专注深邃,耳廓厚实饱满,鼻头圆润有肉,嘴唇紧闭有力,下巴方正坚毅,整体气质沉稳内敛",'
. '"boneAnalysis":"颧骨适度有权势,驿马骨平稳利于坚守,将军骨有力主领导,日角骨平整主贵气,月角骨匀称主柔韧,龙宫骨丰隆主聪慧,伏犀骨突显主谋略,龙角骨匀称主志向",'
. '"relationship":"人际关系中注重深度交流,团队中承担规划与执行角色,重承诺守规则",'
. '"gallupTop3":["执行","责任","分析"]}'
. '"gallupTop3":["执行","责任","分析"],'
. '"careerDevelopment":"职业上宜走专业纵深或项目管理路线,前几年夯实流程与协作,中期可争取主导关键模块或小团队。",'
. '"familyParenting":"亲子沟通宜多倾听少评判,给孩子清晰边界也保留讨论空间;避免把职场高标准直接迁移为对孩子的苛责。",'
. '"partnerCofounder":"合伙宜找执行力或对外沟通互补型,股权与分工尽早书面化,退出机制与决策规则写清比口头默契更稳。"}'
. "\n【重要】只返回 JSON 对象,不得有任何额外文字、注释或 markdown 代码块。";
// 企业版在个人版基础上追加 portrait / hrView / bossView / resumeHighlights 字段说明和示例
@@ -954,6 +964,7 @@ PROMPT;
. 'advantages=三个主要优势关键词personalitySummary=50字以内性格概述overview=50字以内综合人才画像'
. 'faceAnalysis=面相五官详细描述约100字boneAnalysis=《冰鉴》八骨骨相描述约100字'
. 'relationship=人际关系与团队合作风格约50字gallupTop3=盖洛普前三大优势主题名称,'
. 'careerDevelopment=职业发展方向分析约80字familyParenting=家庭与亲子关系分析约80字partnerCofounder=寻找合伙人合作搭档分析约80字'
. 'portrait=职业画像coreStrengths/coreRisks/workStyle'
. 'hrView=HR视角roleRecommend/lifecycle/performance/complianceRisk/teamFit'
. 'bossView=老板视角headline/metrics/costInsight'
@@ -968,6 +979,9 @@ PROMPT;
. '"boneAnalysis":"颧骨适度有权势,驿马骨平稳利于坚守,将军骨有力主领导,日角骨平整主贵气,月角骨匀称主柔韧,龙宫骨丰隆主聪慧,伏犀骨突显主谋略,龙角骨匀称主志向",'
. '"relationship":"人际关系中注重深度交流,团队中承担规划与执行角色,重承诺守规则",'
. '"gallupTop3":["执行","责任","分析"],'
. '"careerDevelopment":"职业上宜走专业纵深或项目管理路线,前几年夯实流程与协作,中期可争取主导关键模块或小团队。",'
. '"familyParenting":"亲子沟通宜多倾听少评判,给孩子清晰边界也保留讨论空间;避免把职场高标准直接迁移为对孩子的苛责。",'
. '"partnerCofounder":"合伙宜找执行力或对外沟通互补型,股权与分工尽早书面化,退出机制与决策规则写清比口头默契更稳。",'
. '"portrait":{"coreStrengths":["执行力强","逻辑缜密","责任心高"],"coreRisks":["灵活性不足","沟通偏封闭"],"workStyle":"偏独立作战,需要清晰目标和充分授权"},'
. '"hrView":{"roleRecommend":{"bestFit":["项目管理","运营执行","技术主管"],"notSuitable":["高创意策划","销售BD"]},'
. '"lifecycle":{"onboarding":"适应期约1-2个月建议配备清晰的工作手册","probation":"试用期执行稳定,交付质量高","growth":"6-12个月可承担独立模块负责人","retention":"核心留人因素是稳定的工作环境与晋升通道"},'
@@ -1334,6 +1348,9 @@ PROMPT;
'personalitySummary' => $p['personalitySummary'] ?? '',
'relationship' => $p['relationship'] ?? '',
'gallupTop3' => $gallupTop3,
'careerDevelopment' => isset($p['careerDevelopment']) ? trim((string) $p['careerDevelopment']) : '',
'familyParenting' => isset($p['familyParenting']) ? trim((string) $p['familyParenting']) : '',
'partnerCofounder' => isset($p['partnerCofounder']) ? trim((string) $p['partnerCofounder']) : '',
];
// 企业版额外字段AI 返回则透传,未返回则不输出

View File

@@ -281,6 +281,7 @@ class Test extends BaseController
$resultText = '';
$emoji = '';
$typeName = '';
$gallupPreview = '';
switch ($testType) {
case 'mbti':
@@ -313,7 +314,17 @@ class Test extends BaseController
break;
}
return [
if (in_array($testType, ['face', 'ai'], true)) {
$g = $data['gallupTop3'] ?? null;
if (is_array($g) && $g !== []) {
$slice = array_slice($g, 0, 3);
$gallupPreview = implode('、', array_map(static function ($x) {
return (string) $x;
}, $slice));
}
}
$out = [
'id' => (int) $row['id'],
'testType' => ($testType === 'face') ? 'ai' : $testType,
'emoji' => $emoji,
@@ -323,6 +334,11 @@ class Test extends BaseController
'isPaid' => (int) ($row['isPaid'] ?? 0),
'requiresPayment' => (int) ($row['requiresPayment'] ?? 0),
];
if ($gallupPreview !== '') {
$out['gallupPreview'] = $gallupPreview;
}
return $out;
}
/**
@@ -620,6 +636,9 @@ class Test extends BaseController
$out['hrView'] = null;
$out['bossView'] = null;
$out['resumeHighlights'] = '';
$out['careerDevelopment'] = '';
$out['familyParenting'] = '';
$out['partnerCofounder'] = '';
if (isset($out['careers'])) {
$out['careers'] = [];
}

View File

@@ -448,9 +448,185 @@ class AppUser extends BaseController
$data['faceDiscType'] = $this->extractFaceSubType($tests, 'disc');
$data['facePdpType'] = $this->extractFaceSubType($tests, 'pdp');
$data['matchingEnterprises'] = $this->buildMatchingEnterprises(
(int) $id,
(string) ($data['mbtiType'] ?? ''),
(string) ($data['pdpType'] ?? ''),
(string) ($data['discType'] ?? '')
);
return success($data);
}
/**
* 按企业测评池内与用户 MBTI/PDP/DISC 的同质比例推荐企业,并附带登记负责人联系方式。
* 仅超级管理后台使用;无测评维度时按池内活跃人数近似排序。
*
* @return array<int, array<string, mixed>>
*/
private function buildMatchingEnterprises(int $userId, string $userMbti, string $userPdp, string $userDisc): array
{
$userMbtiU = strtoupper(preg_replace('/[^A-Z]/', '', $userMbti));
$userPdpN = $this->normalizePoolTypeKey($userPdp);
$userDiscN = $this->normalizePoolTypeKey($userDisc);
try {
$entRows = Db::name('enterprises')
->whereNull('deletedAt')
->whereIn('status', ['operating', 'trial'])
->field('id,name,code,contactName,contactPhone,contactEmail,status')
->select()
->toArray();
} catch (\Throwable $e) {
return [];
}
if (!$entRows) {
return [];
}
$scored = [];
foreach ($entRows as $e) {
$eid = (int) ($e['id'] ?? 0);
if ($eid <= 0) {
continue;
}
$mbtiS = $this->enterprisePoolTypeHistogram($eid, 'mbti');
$pdpS = $this->enterprisePoolTypeHistogram($eid, 'pdp');
$discS = $this->enterprisePoolTypeHistogram($eid, 'disc');
$score = 36;
$reasons = [];
if ($userMbtiU !== '' && $mbtiS['total'] > 0) {
$hit = (int) ($mbtiS['byKey'][$userMbtiU] ?? 0);
$ratio = $hit / $mbtiS['total'];
$part = (int) round(44 * $ratio);
$score += $part;
$pct = (int) round($ratio * 100);
$reasons[] = 'MBTI 同质 ' . $pct . '%(池内 ' . $mbtiS['total'] . ' 人有效结果)';
}
if ($userPdpN !== '' && $pdpS['total'] > 0) {
$hit = (int) ($pdpS['byKey'][$userPdpN] ?? 0);
$ratio = $hit / $pdpS['total'];
$score += (int) round(12 * $ratio);
if ($ratio > 0) {
$reasons[] = 'PDP 同质 ' . (int) round($ratio * 100) . '%';
}
}
if ($userDiscN !== '' && $discS['total'] > 0) {
$hit = (int) ($discS['byKey'][$userDiscN] ?? 0);
$ratio = $hit / $discS['total'];
$score += (int) round(12 * $ratio);
if ($ratio > 0) {
$reasons[] = 'DISC 同质 ' . (int) round($ratio * 100) . '%';
}
}
$tested = max($mbtiS['total'], $pdpS['total'], $discS['total']);
if ($userMbtiU === '' && $userPdpN === '' && $userDiscN === '') {
$score = 40 + (int) min(38, $tested * 2);
$reasons[] = $tested > 0 ? '按池内测评活跃度推荐' : '暂无同质维度,展示登记企业';
}
$score = max(30, min(99, $score));
$typeLabel = '综合型';
if ($userMbtiU !== '' && $mbtiS['total'] > 0 && (($mbtiS['byKey'][$userMbtiU] ?? 0) / $mbtiS['total']) >= 0.25) {
$typeLabel = '文化相近MBTI 分布)';
} elseif ($userPdpN !== '' && $pdpS['total'] > 0) {
$typeLabel = '行为风格相近PDP 分布)';
} elseif ($userDiscN !== '' && $discS['total'] > 0) {
$typeLabel = '协作风格相近DISC 分布)';
}
$scored[] = [
'id' => $eid,
'name' => (string) ($e['name'] ?? ''),
'code' => (string) ($e['code'] ?? ''),
'contactName' => (string) ($e['contactName'] ?? ''),
'contactPhone' => (string) ($e['contactPhone'] ?? ''),
'contactEmail' => (string) ($e['contactEmail'] ?? ''),
'status' => (string) ($e['status'] ?? ''),
'matchScore' => $score,
'matchTypeLabel' => $typeLabel,
'matchReason' => $reasons ? implode('', $reasons) : '可与负责人沟通用人匹配',
'poolTestedUsers' => $tested,
];
}
usort($scored, static function ($a, $b) {
return ($b['matchScore'] ?? 0) <=> ($a['matchScore'] ?? 0);
});
return array_slice($scored, 0, 10);
}
/**
* 企业池内各用户对某测评类型的「最新一条」结果类型分布
*
* @return array{total:int,byKey:array<string,int>}
*/
private function enterprisePoolTypeHistogram(int $enterpriseId, string $testType): array
{
$targetType = strtolower($testType);
try {
$rows = Db::name('test_results')
->where('enterpriseId', $enterpriseId)
->where('testType', $testType)
->order('createdAt', 'desc')
->field('userId,resultData')
->select()
->toArray();
} catch (\Throwable $e) {
return ['total' => 0, 'byKey' => []];
}
$seen = [];
$byKey = [];
foreach ($rows as $r) {
$uid = (int) ($r['userId'] ?? 0);
if ($uid <= 0 || isset($seen[$uid])) {
continue;
}
$raw = $r['resultData'] ?? '';
$result = is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE);
$mock = [['result' => $result, 'testType' => $testType]];
$label = $this->extractResultType($mock, $testType);
if ($label === '' || ($targetType === 'face' && $label === '人脸分析')) {
continue;
}
$key = $targetType === 'mbti'
? strtoupper(preg_replace('/[^A-Z]/', '', $label))
: $this->normalizePoolTypeKey($label);
if ($key === '') {
continue;
}
$seen[$uid] = true;
$byKey[$key] = ($byKey[$key] ?? 0) + 1;
}
$totalTyped = array_sum($byKey);
return ['total' => $totalTyped, 'byKey' => $byKey];
}
private function normalizePoolTypeKey(string $s): string
{
$s = trim($s);
if ($s === '') {
return '';
}
$s = str_replace([' ', ' '], '', $s);
return mb_strtolower($s, 'UTF-8');
}
private function parseMbtiFromResult($result): string
{
if (!is_string($result)) return '';