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

@@ -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 '';