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
+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;