fix: 仪表盘与用户管理、分析/测试接口、双端结果页与个人中心展示调整
- admin:Dashboard/Users 与超管 Users 联动后端统计 - api:Dashboard、Analyze、Test、超管 AppUser 逻辑更新 - 微信/抖音小程序:result、profile 页面与样式 - 新增 admin 公共组件与需求文档截图 Made-with: Cursor
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 返回则透传,未返回则不输出
|
||||
|
||||
@@ -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'] = [];
|
||||
}
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
Reference in New Issue
Block a user