Merge branch 'codex/brd-release-20260715' into codex/multi-tenant-knowledge-platform

# Conflicts:
#	AGENTS.md
#	README.md
#	backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java
#	backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java
#	backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java
#	backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java
#	backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrOrgSyncServiceTest.java
#	backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java
#	backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrUploadQueueServiceTest.java
#	docs/20260708/uni-app用户侧前端重建计划.md
#	docs/API_INTEGRATION.md
#	docs/BRD_IMPLEMENTATION_AUDIT.md
#	docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md
#	docs/DEMO_ACCEPTANCE.md
#	docs/DEV_SETUP.md
#	docs/FIGMA_IMPLEMENTATION_MATRIX.md
#	docs/README.md
#	frontend/src/views/knowledge/processing.vue
#	mobile-uni/src/pages/supervisor/assign/index.vue
#	mobile-uni/src/pages/supervisor/index/index.vue
#	mobile-uni/src/pages/supervisor/profile/index.vue
#	mobile-uni/src/pages/supervisor/review/index.vue
#	mobile-uni/src/pages/supervisor/reviews/index.vue
#	mobile-uni/src/pages/supervisor/team/index.vue
#	mobile-uni/src/pages/user/cases/index.vue
#	mobile-uni/src/pages/user/practice/index.vue
#	mobile-uni/src/pages/user/profile/index.vue
#	mobile-uni/src/pages/user/sop/index.vue
#	mobile-uni/src/pages/user/today/index.vue
#	mobile-uni/src/types/api.ts
#	scripts/tests/aihr-schema-migrations.test.sh
This commit is contained in:
2026-07-22 08:41:49 +08:00
27 changed files with 1011 additions and 38 deletions
+7
View File
@@ -11,6 +11,7 @@
"@dcloudio/uni-app": "3.0.0-alpha-5020120260706001",
"@dcloudio/uni-h5": "3.0.0-alpha-5020120260706001",
"@dcloudio/uni-ui": "^1.5.12",
"lxgw-wenkai-tc-webfont": "^1.0.0",
"vue": "3.5.30"
},
"devDependencies": {
@@ -5816,6 +5817,12 @@
"yallist": "^3.0.2"
}
},
"node_modules/lxgw-wenkai-tc-webfont": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/lxgw-wenkai-tc-webfont/-/lxgw-wenkai-tc-webfont-1.0.0.tgz",
"integrity": "sha512-LOGglSMNw6Nufn+ULaSjliwNwtkCE1cwXuayN09pg+KMDGD5XxxGrAbca1Ap+YdP4NLotdBDKi5wmUXbgqTxHg==",
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.11",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.11.tgz",
+1
View File
@@ -15,6 +15,7 @@
"@dcloudio/uni-app": "3.0.0-alpha-5020120260706001",
"@dcloudio/uni-h5": "3.0.0-alpha-5020120260706001",
"@dcloudio/uni-ui": "^1.5.12",
"lxgw-wenkai-tc-webfont": "^1.0.0",
"vue": "3.5.30"
},
"devDependencies": {
@@ -0,0 +1,91 @@
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+31 -8
View File
@@ -21,6 +21,7 @@
<template v-else-if="detail">
<view class="panel summary-panel">
<text class="section-kicker">训练摘要</text>
<view class="summary-head">
<view>
<text class="detail-title">{{ detail.scene }}</text>
@@ -33,17 +34,17 @@
<view v-if="detail.scoreItems?.length" class="panel">
<text class="panel-title">评分明细</text>
<view v-for="item in detail.scoreItems" :key="item.label" class="score-row">
<view>
<view class="score-summary-grid">
<view v-for="item in detail.scoreItems" :key="item.label" class="score-row">
<text class="score-label">{{ item.label }}</text>
<text class="score-value">{{ item.value }}</text>
<text class="score-note">{{ item.note }}</text>
</view>
<text class="score-value">{{ item.value }}</text>
</view>
</view>
<view v-if="detail.dialogue?.length" class="panel">
<text class="panel-title">对话记录</text>
<text class="panel-title">录音与逐句复盘</text>
<view v-for="(turn, index) in detail.dialogue" :key="`${turn.role}-${index}`" class="dialogue-row">
<text class="dialogue-label">{{ turn.label }}</text>
<text class="dialogue-text">{{ turn.text }}</text>
@@ -58,7 +59,7 @@
</view>
<view v-if="detail.annotations?.length || detail.mentorRewrite" class="panel">
<text class="panel-title">复盘建议</text>
<text class="panel-title">SOP 依据与改进建议</text>
<view v-for="item in detail.annotations" :key="`${item.turnIndex}-${item.issue}`" class="annotation-row">
<text class="annotation-issue">{{ item.issue }}</text>
<text class="annotation-rewrite">建议:{{ item.rewrite }}</text>
@@ -264,11 +265,24 @@ onUnload(releaseAudio);
border-top: 1px solid #ececee;
}
.score-summary-grid {
display: grid;
margin-top: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.score-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 96px;
align-items: flex-start;
justify-content: center;
flex-direction: column;
gap: 4px;
padding: 12px;
border: 0;
border-radius: 12px;
background: #f7f8fa;
}
.score-note,
@@ -346,4 +360,13 @@ onUnload(releaseAudio);
.login-button {
margin-top: 14px;
}
.section-kicker {
display: block;
margin-bottom: 10px;
color: #e60012;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.08em;
}
</style>
+3
View File
@@ -234,3 +234,6 @@ export const consumeLoginRedirect = () => {
};
export const validatePhone = (phone: string) => phonePattern.test(phone);
export const maskPhone = (phone: string) =>
/^\d{8,}$/.test(phone) ? `${phone.slice(0, 3)}****${phone.slice(-4)}` : phone;
+5 -1
View File
@@ -214,6 +214,8 @@ export const submitAnswerFeedback = (params: {
fragmentIds: number[];
verdict: AnswerFeedbackVerdict;
reviewId?: number | null;
reasonCodes?: string[];
comment?: string;
}) =>
apiRequest<{ saved: number }>({
url: '/api/knowledge/answer-feedback',
@@ -225,7 +227,9 @@ export const submitAnswerFeedback = (params: {
category: 'sop',
position: getSelectedPosition(),
source: 'mobile_uni_sop',
reviewId: params.reviewId ?? null
reviewId: params.reviewId ?? null,
reasonCodes: params.reasonCodes || [],
comment: params.comment || ''
},
timeout: 15000
});
+31
View File
@@ -14,6 +14,37 @@ export const resetPageScroll = () => {
setTimeout(scrollTop, 240);
};
export const scrollPageToSelector = (selector: string, offset = 16) => {
const scrollToTarget = () => {
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
const element = document.querySelector<HTMLElement>(selector);
if (element) {
const scrollTop = Math.max(0, window.scrollY + element.getBoundingClientRect().top - offset);
uni.pageScrollTo({ scrollTop, duration: 0 });
window.scrollTo({ top: scrollTop, left: 0, behavior: 'auto' });
document.documentElement.scrollTop = scrollTop;
document.body.scrollTop = scrollTop;
return;
}
}
uni.createSelectorQuery()
.select(selector)
.boundingClientRect((rect) => {
const target = Array.isArray(rect) ? rect[0] : rect;
if (target && typeof target.top === 'number') {
uni.pageScrollTo({ scrollTop: Math.max(0, target.top - offset), duration: 0 });
}
})
.exec();
};
scrollToTarget();
setTimeout(scrollToTarget, 0);
setTimeout(scrollToTarget, 80);
setTimeout(scrollToTarget, 240);
};
const switchToToday = () => {
uni.switchTab({ url: '/pages/user/today/index' });
};
+59 -3
View File
@@ -15,6 +15,12 @@ export interface SupervisorMetric {
primary?: boolean;
}
export interface TrainingTrendPoint {
key: string;
label: string;
value: number;
}
export interface TeamProfile {
key: string;
extPartyId: string;
@@ -98,8 +104,28 @@ export const manualPracticeAssignments = (assignments: PracticeAssignment[]) =>
export const actionablePracticeAlerts = (alerts: PracticeAlert[]) =>
alerts.filter((item) => item.type !== 'stable');
export const buildTrainingTrend = (records: PracticeRecord[]): TrainingTrendPoint[] =>
records
.filter((item) => Number.isFinite(item.score))
.sort((a, b) => practiceTimeValue(b.time) - practiceTimeValue(a.time))
.slice(0, 5)
.reverse()
.map((item, index) => ({
key: `${item.id || item.sessionId || item.time}-${index}`,
label: trainingTrendLabel(item.time, index),
value: Math.max(0, Math.min(100, item.score))
}));
export const isReviewEditable = (status: string | null | undefined) => status === '待复盘';
export type ReviewFilter = 'all' | 'low' | 'pending';
export const filterReviewRecords = (reviews: PracticeRecord[], filter: ReviewFilter) => {
if (filter === 'low') return reviews.filter((item) => item.score < 80);
if (filter === 'pending') return reviews.filter((item) => isReviewEditable(item.status));
return reviews;
};
export const isSupervisorAccessDenied = (error: unknown) => {
const message = error instanceof Error
? error.message
@@ -131,14 +157,14 @@ export const buildSupervisorMetrics = (
return [
{
key: 'pendingReviews',
title: '当前待复盘',
title: '待复盘',
value: pendingReviews,
desc: pendingReviews ? '优先处理低分和待处理记录' : '暂无待复盘记录',
primary: true
},
{
key: 'pendingAssignments',
title: '当前待训练',
title: '待训练',
value: pendingAssignments,
desc: pendingAssignments ? '仍有专项训练待完成' : '暂无未完成专项'
},
@@ -271,12 +297,42 @@ export const buildTeamProfiles = (
|| (a.latestScore ?? 100) - (b.latestScore ?? 100));
};
export const filterTeamProfiles = <T extends Pick<TeamProfile, 'displayName' | 'extPartyId'>>(
profiles: T[],
query: string
) => {
const keyword = query.trim().toLowerCase();
if (!keyword) return profiles;
return profiles.filter((item) =>
item.displayName.toLowerCase().includes(keyword)
|| item.extPartyId.toLowerCase().includes(keyword));
};
export const paginateTeamProfiles = <T>(profiles: T[], requestedPage: number, pageSize: number) => {
const safeSize = Math.max(1, Math.floor(pageSize));
const pageCount = Math.max(1, Math.ceil(profiles.length / safeSize));
const page = Math.min(pageCount, Math.max(1, Math.floor(requestedPage)));
return {
items: profiles.slice((page - 1) * safeSize, page * safeSize),
page,
pageCount
};
};
function identitySuffix(value: string) {
if (/^1[3-9]\d{9}$/.test(value)) return `尾号${value.slice(-4)}`;
if (value.length > 8) return `末尾${value.slice(-6)}`;
return value;
}
function trainingTrendLabel(value: string, index: number) {
const parts = value.match(/\d+/g) || [];
const [first = '', second = '', third = ''] = parts;
if (parts.length >= 3 && first.length === 4) return `${second.padStart(2, '0')}-${third.padStart(2, '0')}`;
if (parts.length >= 2) return `${first.padStart(2, '0')}-${second.padStart(2, '0')}`;
return `第${index + 1}次`;
}
function practiceTimeValue(value: string) {
const parts = value.match(/\d+/g)?.map(Number) || [];
if (parts.length < 4 || parts.some((part) => !Number.isFinite(part))) {
@@ -284,6 +340,6 @@ function practiceTimeValue(value: string) {
}
const normalized = parts.length >= 5 ? parts.slice(0, 6) : [0, ...parts.slice(0, 5)];
while (normalized.length < 6) normalized.push(0);
const [year, month, day, hour, minute, second] = normalized;
const [year = 0, month = 0, day = 0, hour = 0, minute = 0, second = 0] = normalized;
return (((((year * 13 + month) * 32 + day) * 24 + hour) * 60 + minute) * 60) + second;
}
+12 -1
View File
@@ -1,8 +1,11 @@
@import 'lxgw-wenkai-tc-webfont/lxgwwenkaitc-regular.css';
@import 'lxgw-wenkai-tc-webfont/lxgwwenkaitc-bold.css';
page {
min-height: 100%;
background: #f3f5f7;
color: #182230;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Segoe UI", sans-serif;
font-family: "LXGW WenKai TC", -apple-system, BlinkMacSystemFont, "PingFang SC", "Segoe UI", sans-serif;
}
:root {
@@ -32,6 +35,7 @@ page {
--employee-card-radius: 24px;
--employee-chip-radius: 18px;
--employee-card-shadow: 0 16px 40px rgba(230, 0, 18, 0.08);
--app-bottom-nav-space: calc(82px + env(safe-area-inset-bottom));
}
html[data-aihr-font-size='standard'] {
@@ -303,6 +307,13 @@ textarea:focus-visible {
background: var(--brand-pressed);
}
.tabbar-safe-action {
position: sticky;
bottom: var(--app-bottom-nav-space);
z-index: 12;
box-shadow: 0 10px 24px rgba(230, 0, 18, 0.2);
}
uni-tabbar.uni-tabbar-bottom,
uni-tabbar.uni-tabbar-bottom .uni-tabbar {
border-top: 1px solid var(--line) !important;
@@ -40,6 +40,36 @@ test('训练页提交失败会回滚本次乐观追加的对话', async () => {
assert.match(source, /uni\.showToast\(\{ title: message\.value \|\| '提交失败', icon: 'none'/);
});
test('训练结果回到顶部,SOP 答案生成后定位到回答区', async () => {
const [practiceSource, sopSource, navigationSource] = await Promise.all([
pageSource('../src/pages/user/practice/index.vue'),
pageSource('../src/pages/user/sop/index.vue'),
pageSource('../src/services/navigation.ts')
]);
assert.match(practiceSource, /result\.value = await finishPractice\([\s\S]*?resetPageScroll\(\)/);
assert.match(sopSource, /import \{ resetPageScroll, scrollPageToSelector \} from '@\/services\/navigation';/);
assert.match(sopSource, /result\.value = await searchKnowledge\(value\);\s*scrollPageToSelector\('\.answer-panel'/);
assert.match(navigationSource, /export const scrollPageToSelector = \(selector: string/);
});
test('训练历史按摘要、逐句复盘、SOP 依据和行动建议分层', async () => {
const source = await pageSource('../src/pages/user/history/index.vue');
assert.match(source, /训练摘要/);
assert.match(source, /录音与逐句复盘/);
assert.match(source, /SOP 依据与改进建议/);
assert.match(source, /推荐话术/);
});
test('案例页为无权限和无案例提供明确空状态', async () => {
const source = await pageSource('../src/pages/user/cases/index.vue');
assert.match(source, /class="case-empty-state"/);
assert.match(source, /暂时还没有可学习案例/);
assert.match(source, /提交权限由组织角色决定/);
});
test('manifest 明确声明为 uni-app Vue3 CLI 工程', async () => {
const source = await pageSource('../src/manifest.json');
@@ -69,6 +99,8 @@ test('登录页按高保真原型保留品牌结构和紧凑验证码行', async
test('共享视觉基座包含员工端主色和卡片圆角 token', async () => {
const source = await pageSource('../src/styles/base.css');
assert.match(source, /lxgw-wenkai-tc-webfont/);
assert.match(source, /font-family:\s*"LXGW WenKai TC"/);
assert.match(source, /--employee-primary:\s*#e60012/);
assert.match(source, /--employee-card-radius:\s*24px/);
assert.match(source, /\.employee-card\s*\{/);
@@ -190,6 +222,21 @@ test('问师傅页保留搜索框、答案区和总结卡入口', async () => {
assert.match(source, /TEAM_PRACTICE_SUMMARY/);
});
test('SOP 没解决先收集结构化原因再提交', async () => {
const [source, service] = await Promise.all([
pageSource('../src/pages/user/sop/index.vue'),
pageSource('../src/services/knowledge.ts')
]);
assert.match(source, /feedbackReasonOptions/);
assert.match(source, /negative-feedback-panel/);
assert.match(source, /submitNegativeFeedback/);
assert.match(source, /reasonCodes:\s*selectedFeedbackReasons\.value/);
assert.match(source, /comment:\s*feedbackComment\.value\.trim\(\)/);
assert.match(service, /reasonCodes\?:\s*string\[\]/);
assert.match(service, /comment\?:\s*string/);
});
test('训练页保留任务卡、对话区、回复区和结果区并互斥切换', async () => {
const source = await pageSource('../src/pages/user/practice/index.vue');
@@ -203,6 +250,23 @@ test('训练页保留任务卡、对话区、回复区和结果区并互斥切
assert.match(source, /practiceView === 'prep'/);
assert.match(source, /practiceView === 'active'/);
assert.match(source, /practiceView\.value === 'result'/);
assert.match(source, /result\.score\?\.length/);
assert.match(source, /class="result-dimensions"/);
});
test('底部固定导航页面复用安全操作区', async () => {
const [base, practice, assign, review] = await Promise.all([
pageSource('../src/styles/base.css'),
pageSource('../src/pages/user/practice/index.vue'),
pageSource('../src/pages/supervisor/assign/index.vue'),
pageSource('../src/pages/supervisor/review/index.vue')
]);
assert.match(base, /--app-bottom-nav-space/);
assert.match(base, /\.tabbar-safe-action\s*\{/);
assert.match(practice, /primary-button tabbar-safe-action/);
assert.match(assign, /primary-button submit-button tabbar-safe-action/);
assert.match(review, /primary-button submit-button tabbar-safe-action/);
});
test('员工练习页显示当前岗位而不是固定生活顾问', async () => {
+98 -1
View File
@@ -21,13 +21,18 @@ const {
actionablePracticeAlerts,
buildSupervisorMetrics,
buildTeamProfiles,
buildTrainingTrend,
createAsyncGenerationGuard,
filterReviewRecords,
filterTeamProfiles,
inferSupervisorScenarioId,
isReviewEditable,
isSupervisorAccessDenied,
paginateTeamProfiles,
reviewCompletionAction,
supervisorScenarioOptions
} = supervisorView;
const { maskPhone } = await vite.ssrLoadModule('/src/services/auth.ts');
const assignPageSource = await readFile(
new URL('../src/pages/supervisor/assign/index.vue', import.meta.url),
@@ -152,6 +157,20 @@ test('低于 80 分为高风险,有待复盘为中风险,其余为低风险'
assert.equal(profiles.find((item) => item.extPartyId === 'low')?.risk, 'low');
});
test('待复盘列表支持全部、低分和待处理筛选', () => {
const rows = [
review({ id: 1, score: 68, status: '待复盘' }),
review({ id: 2, score: 88, status: '待复盘' }),
review({ id: 3, score: 92, status: '已复盘' })
];
assert.equal(filterReviewRecords(rows, 'all').length, 3);
assert.deepEqual(filterReviewRecords(rows, 'low').map((item) => item.id), [1]);
assert.deepEqual(filterReviewRecords(rows, 'pending').map((item) => item.id), [1, 2]);
assert.match(reviewsPageSource, /reviewFilters/);
assert.match(reviewsPageSource, /visibleReviews/);
});
test('主管页面共享三个业务场景及文本推荐规则', () => {
assert.deepEqual(supervisorScenarioOptions.map((item) => item.id), [
'fee-parking',
@@ -159,7 +178,8 @@ test('主管页面共享三个业务场景及文本推荐规则', () => {
'repair-elevator-delay'
]);
assert.match(practiceServiceSource, /getPracticeScenarios/);
assert.match(assignPageSource, /v-for="item in scenarioOptions"/);
assert.match(assignPageSource, /v-for="item in filteredScenarioOptions"/);
assert.match(assignPageSource, /scenarioSearch/);
assert.match(assignPageSource, /暂无可派发的已启用场景/);
assert.match(reviewPageSource, /scenarioOptions/);
assert.match(reviewPageSource, /scenarioOptions\.value = \[\]/);
@@ -207,6 +227,20 @@ test('stable 提示不计入团队预警指标', () => {
]).find((item) => item.key === 'alerts')?.value, 0);
});
test('主管工作台展示真实训练趋势并压缩预警首屏密度', () => {
const trend = buildTrainingTrend([
review({ id: 1, time: '2026-07-15 09:00:00', score: 70 }),
review({ id: 2, time: '2026-07-16 09:00:00', score: 82 }),
review({ id: 3, time: '2026-07-17 09:00:00', score: 91 })
]);
assert.deepEqual(trend.map((item) => item.value), [70, 82, 91]);
assert.match(dashboardPageSource, /class="panel training-trend-panel"/);
assert.match(dashboardPageSource, /trainingTrend/);
assert.match(dashboardPageSource, /visibleAlerts/);
assert.doesNotMatch(dashboardPageSource, /<text class="metric-desc"/);
});
test('画像按时间显示最新训练,但任一未复盘低分仍保持高风险', () => {
const profiles = buildTeamProfiles([
review({
@@ -245,6 +279,51 @@ test('组织快照中的稳定成员即使没有训练记录也保留在画像
assert.equal(profiles[0].risk, 'low');
});
test('团队画像搜索和分页不把 2316 名成员一次性渲染', () => {
const profiles = Array.from({ length: 2316 }, (_, index) => ({
key: `member-${index}`,
extPartyId: `employee-${index}`,
displayName: `员工 ${index}`
}));
assert.equal(paginateTeamProfiles(profiles, 1, 20).items.length, 20);
assert.equal(paginateTeamProfiles(profiles, 1, 20).pageCount, 116);
assert.deepEqual(
filterTeamProfiles(profiles, '员工 2315').map((item) => item.key),
['member-2315']
);
assert.match(teamPageSource, /v-for="member in visibleTeamProfiles"/);
assert.match(teamPageSource, /v-if="selectedMember" class="panel member-detail-panel"/);
assert.match(teamPageSource, /<template v-else>[\s\S]*class="panel team-panel"/);
});
test('员工和主管个人页只展示脱敏手机号', async () => {
const [employeeProfile, supervisorProfile] = await Promise.all([
readFile(new URL('../src/pages/user/profile/index.vue', import.meta.url), 'utf8'),
readFile(new URL('../src/pages/supervisor/profile/index.vue', import.meta.url), 'utf8')
]);
assert.equal(maskPhone('181368856126'), '181****6126');
assert.equal(maskPhone('invalid'), 'invalid');
assert.match(employeeProfile, /displayPhone/);
assert.match(supervisorProfile, /displayPhone/);
assert.doesNotMatch(employeeProfile, /\{\{ phone \|\|/);
assert.doesNotMatch(supervisorProfile, /\{\{ phone \|\|/);
});
test('主管个人工作台展示真实指标和三项业务入口', async () => {
const source = await readFile(new URL('../src/pages/supervisor/profile/index.vue', import.meta.url), 'utf8');
assert.match(source, /\{\{ loading \? '--' : teamCount \}\}/);
assert.match(source, /\{\{ loading \? '--' : pendingReviewCount \}\}/);
assert.match(source, /\{\{ loading \? '--' : alertCount \}\}/);
assert.match(source, /主管业务入口/);
assert.match(source, /\/pages\/supervisor\/team\/index/);
assert.match(source, /\/pages\/supervisor\/reviews\/index/);
assert.match(source, /\/pages\/supervisor\/assign\/index/);
assert.match(source, /getPracticeTeamSnapshot/);
});
test('组织快照姓名不可读时用稳定身份标识展示', () => {
const profiles = buildTeamProfiles([], [], [
{ extPartyId: '13900001111', name: '???????' },
@@ -297,6 +376,10 @@ test('复盘详情只允许待复盘提交并保留未保存草稿', () => {
assert.match(reviewPageSource, /v-else class="panel review-complete"/);
assert.match(reviewPageSource, /if \(!editable\.value\)[\s\S]*该记录已完成复盘/);
assert.match(reviewPageSource, /onShow\(\(\) => \{[\s\S]*refreshAuthState\(\);[\s\S]*if \(!loggedIn\.value\) \{[\s\S]*accessDenied\.value = false;[\s\S]*clearDetail\(\);\s*return;\s*\}[\s\S]*if \(!detail\.value && !loading\.value\)[\s\S]*loadDetail\(\)/);
assert.match(reviewPageSource, /训练摘要/);
assert.match(reviewPageSource, /录音与逐句复盘/);
assert.match(reviewPageSource, /SOP 依据与改进建议/);
assert.match(reviewPageSource, /下一步行动/);
});
test('复盘详情登录和完成导航不堆叠页面', () => {
@@ -361,6 +444,20 @@ test('指派页通过团队快照在进入时校验主管权限', () => {
assert.doesNotMatch(assignPageSource, /getPracticeAssignments/);
});
test('指派表单和派发进度使用同页分段视图避免堆成长页', () => {
assert.match(assignPageSource, /assignView/);
assert.match(assignPageSource, /v-if="assignView === 'create'" class="panel assignment-form"/);
assert.match(assignPageSource, /v-if="assignView === 'progress'" class="panel history-panel"/);
assert.match(assignPageSource, /class="assign-tabs"/);
assert.match(assignPageSource, /memberSuggestions/);
assert.match(assignPageSource, /selectTarget/);
assert.match(assignPageSource, /class="target-suggestions"/);
assert.match(assignPageSource, /assignmentDueTime/);
assert.match(assignPageSource, /assignmentGoal/);
assert.match(assignPageSource, /assignmentRequirements/);
assert.match(assignPageSource, /item\.dueTime/);
});
test('指派页保留 H5 查询参数二次解码', () => {
assert.match(assignPageSource, /decodeURIComponent\(value\)\.trim\(\)/);
});