feat: restore learning feedback and knowledge capabilities

This commit is contained in:
2026-07-18 00:50:11 +08:00
parent 876a23a123
commit 6bdc86e3fc
152 changed files with 30337 additions and 953 deletions
+31
View File
@@ -0,0 +1,31 @@
export type AppFontSize = 'standard' | 'large' | 'extra-large';
export const appFontSizeKey = 'aihr_mobile_font_size';
export const appFontSizeOptions: Array<{ value: AppFontSize; label: string }> = [
{ value: 'standard', label: '标准' },
{ value: 'large', label: '大字' },
{ value: 'extra-large', label: '超大字' }
];
const normalizeAppFontSize = (value: unknown): AppFontSize => {
if (value === 'large' || value === 'extra-large') return value;
return 'standard';
};
export const getAppFontSize = (): AppFontSize =>
normalizeAppFontSize(uni.getStorageSync(appFontSizeKey));
export const applyAppFontSize = (value: AppFontSize) => {
if (typeof document === 'undefined') return;
document.documentElement.setAttribute('data-aihr-font-size', value);
};
export const setAppFontSize = (value: AppFontSize) => {
const normalized = normalizeAppFontSize(value);
uni.setStorageSync(appFontSizeKey, normalized);
applyAppFontSize(normalized);
return normalized;
};
export const applyStoredFontSize = () => applyAppFontSize(getAppFontSize());
+39 -6
View File
@@ -5,10 +5,12 @@ export const clientIdKey = 'aihr_mobile_client_id';
export const phoneKey = 'aihr_mobile_phone';
export const loginRedirectKey = 'aihr_mobile_login_redirect';
export const positionKey = 'aihr_mobile_position';
export const positionSourceKey = 'aihr_mobile_position_source';
export const roleKey = 'aihr_mobile_role';
export const supervisorLearnerModeKey = 'aihr_mobile_supervisor_learner_mode';
export const phonePattern = /^1[3-9]\d{9}$/;
export const userPositionOptions = ['生活顾问', '保安', '保洁', '保修', '客服'] as const;
export const userPositionOptions = ['生活顾问', '保安', '保洁', '保修', '客服', '主管'] as const;
export type UserPosition = typeof userPositionOptions[number];
@@ -56,6 +58,12 @@ const positionProfiles: Record<UserPosition, UserPositionProfile> = {
heroTitle: '服务前台',
quickQuestions: ['来访接待', '投诉登记', '工单跟进'],
tip: '今日小贴士:复杂诉求先复述确认,再记录责任人与时限。'
},
主管: {
value: '主管',
heroTitle: '项目管理',
quickQuestions: ['团队沟通', '现场协调', '投诉复盘'],
tip: '今日小贴士:先确认事实与责任人,再明确动作、时限和复盘节点。'
}
};
@@ -71,10 +79,13 @@ export const normalizePosition = (value: unknown): UserPosition | '' => {
if (text.includes('保洁') || text.includes('环境')) return '保洁';
if (text.includes('保修') || text.includes('维修') || text.includes('工程')) return '保修';
if (text.includes('客服') || text.includes('前台')) return '客服';
if (text.includes('主管') || text.includes('经理') || text.includes('负责人') || text.includes('组长')) return '主管';
return (userPositionOptions as readonly string[]).includes(text) ? (text as UserPosition) : '';
};
export const hasSelectedPosition = () => Boolean(normalizePosition(uni.getStorageSync(positionKey)));
export const hasOrgPosition = () =>
uni.getStorageSync(positionSourceKey) === 'org'
&& Boolean(normalizePosition(uni.getStorageSync(positionKey)));
export const getSelectedPosition = (): UserPosition =>
normalizePosition(uni.getStorageSync(positionKey)) || '生活顾问';
@@ -84,13 +95,19 @@ export const getPositionProfile = (position: unknown) =>
export const getSelectedPositionProfile = () => getPositionProfile(getSelectedPosition());
export const saveSelectedPosition = (position: unknown) => {
export const saveOrgPosition = (position: unknown) => {
const normalized = normalizePosition(position);
if (!normalized) return '';
uni.setStorageSync(positionKey, normalized);
uni.setStorageSync(positionSourceKey, 'org');
return normalized;
};
export const clearOrgPosition = () => {
uni.removeStorageSync(positionKey);
uni.removeStorageSync(positionSourceKey);
};
export const getAuth = (): AuthState => ({
token: uni.getStorageSync(tokenKey) || '',
clientId: uni.getStorageSync(clientIdKey) || '',
@@ -103,11 +120,26 @@ export const isLoggedIn = () => {
return Boolean(auth.token && auth.clientId && auth.phone);
};
export const isSupervisorLearnerMode = () =>
uni.getStorageSync(roleKey) === 'supervisor'
&& uni.getStorageSync(supervisorLearnerModeKey) === '1';
export const enterSupervisorLearnerMode = () => {
if (uni.getStorageSync(roleKey) !== 'supervisor') return false;
uni.setStorageSync(supervisorLearnerModeKey, '1');
return true;
};
export const leaveSupervisorLearnerMode = () => {
uni.removeStorageSync(supervisorLearnerModeKey);
};
export const saveAuth = (result: LoginResult, phone: string) => {
const previousPhone = uni.getStorageSync(phoneKey) || '';
if (previousPhone && previousPhone !== phone) {
uni.removeStorageSync(positionKey);
if (previousPhone !== phone) {
clearOrgPosition();
uni.removeStorageSync(roleKey);
leaveSupervisorLearnerMode();
}
uni.setStorageSync(tokenKey, result.access_token);
uni.setStorageSync(clientIdKey, result.client_id);
@@ -118,8 +150,9 @@ export const clearAuth = () => {
uni.removeStorageSync(tokenKey);
uni.removeStorageSync(clientIdKey);
uni.removeStorageSync(phoneKey);
uni.removeStorageSync(positionKey);
clearOrgPosition();
uni.removeStorageSync(roleKey);
leaveSupervisorLearnerMode();
uni.removeStorageSync(loginRedirectKey);
};
+151
View File
@@ -0,0 +1,151 @@
import type { ExamDetail, ExamResultItem, ExamSubmitResult, ExamSummary, SupervisorExamDraft } from '@/types/api';
import { apiRequest } from './api';
type RawExamQuestion = {
id: number;
questionType: string;
stem: string;
options?: string[];
correctAnswers?: string[];
score: number;
explanation?: string;
sortOrder?: number;
};
type RawExamSummary = {
id: number;
title: string;
projectCode?: string;
positionCode?: string;
status: string;
totalScore: number;
passScore: number;
dueDate?: string | null;
questionCount: number;
targetCount: number;
submissionCount: number;
bestScore?: number | null;
passed?: boolean | null;
};
type RawExamDetail = Omit<RawExamSummary, 'questionCount' | 'targetCount' | 'submissionCount' | 'bestScore' | 'passed'> & {
questions: RawExamQuestion[];
targets: ExamDetail['targets'];
};
const normalizeSummary = (item: RawExamSummary): ExamSummary => ({
...item,
passingScore: item.passScore
});
const normalizeDetail = (item: RawExamDetail): ExamDetail => ({
...item,
passingScore: item.passScore,
questionCount: item.questions?.length || 0,
targetCount: item.targets?.length || 0,
submissionCount: 0,
bestScore: null,
passed: null,
questions: (item.questions || []).map((question) => ({
id: question.id,
format: question.questionType,
stem: question.stem,
options: question.options || [],
score: question.score,
answer: question.correctAnswers || [],
explanation: question.explanation || ''
})),
targets: item.targets || []
});
const serializeDraft = (draft: SupervisorExamDraft) => ({
title: draft.title,
projectCode: draft.projectCode,
positionCode: draft.positionCode,
passScore: draft.passScore,
dueDate: draft.dueDate,
targets: draft.targets,
questions: draft.questions.map((question, index) => ({
id: question.id > 0 && question.id < 1_000_000_000 ? question.id : null,
questionType: question.format,
stem: question.stem,
options: question.options,
correctAnswers: Array.isArray(question.answer) ? question.answer : [String(question.answer || '')],
score: question.score,
explanation: question.explanation || '',
sortOrder: index + 1
}))
});
export const listMyExams = async () =>
(await apiRequest<RawExamSummary[]>({
url: '/api/aihr/mobile/exams',
method: 'GET',
timeout: 15000
})).map(normalizeSummary);
export const getMyExam = async (id: number) =>
normalizeDetail(await apiRequest<RawExamDetail>({
url: `/api/aihr/mobile/exams/${id}`,
method: 'GET',
timeout: 15000
}));
export const submitMyExam = (id: number, answers: Record<string, string | string[]>, requestId: string) =>
apiRequest<ExamSubmitResult>({
url: `/api/aihr/mobile/exams/${id}/submit`,
method: 'POST',
data: {
requestId,
answers: Object.entries(answers).map(([questionId, value]) => ({
questionId: Number(questionId),
answers: Array.isArray(value) ? value : [String(value)]
}))
},
timeout: 30000
});
export const listSupervisorExams = async () =>
(await apiRequest<RawExamSummary[]>({
url: '/api/aihr/mobile/exams/supervisor',
method: 'GET',
timeout: 15000
})).map(normalizeSummary);
export const getSupervisorExam = async (id: number) =>
normalizeDetail(await apiRequest<RawExamDetail>({
url: `/api/aihr/mobile/exams/supervisor/${id}`,
method: 'GET',
timeout: 15000
}));
export const saveSupervisorExam = async (draft: SupervisorExamDraft) =>
normalizeDetail(await apiRequest<RawExamDetail>({
url: '/api/aihr/mobile/exams/supervisor',
method: 'POST',
data: serializeDraft(draft),
timeout: 30000
}));
export const updateSupervisorExam = async (id: number, draft: SupervisorExamDraft) =>
normalizeDetail(await apiRequest<RawExamDetail>({
url: `/api/aihr/mobile/exams/supervisor/${id}`,
method: 'PUT',
data: serializeDraft(draft),
timeout: 30000
}));
export const publishSupervisorExam = async (id: number, requestId: string) =>
normalizeDetail(await apiRequest<RawExamDetail>({
url: `/api/aihr/mobile/exams/supervisor/${id}/publish`,
method: 'POST',
data: { requestId },
timeout: 30000
}));
export const getSupervisorExamResults = (id: number) =>
apiRequest<ExamResultItem[]>({
url: `/api/aihr/mobile/exams/supervisor/${id}/results`,
method: 'GET',
timeout: 15000
});
+104 -5
View File
@@ -1,11 +1,13 @@
import type {
AnswerFeedbackVerdict,
KnowledgeSearchResponse,
KnowledgeMediaSelectedFile,
UnifiedKnowledgeResponse,
KnowledgeSnippet,
PositionSopSummary,
SummaryCardResponse
} from '@/types/api';
import { apiRequest } from './api';
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
import { getSelectedPosition } from './auth';
type RawKnowledgeSnippet = KnowledgeSnippet & {
@@ -13,6 +15,38 @@ type RawKnowledgeSnippet = KnowledgeSnippet & {
text?: string;
};
const mediaExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v'];
const videoExtensions = new Set(['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v']);
export const chooseKnowledgeMedia = () =>
new Promise<KnowledgeMediaSelectedFile>((resolve, reject) => {
uni.chooseFile({
count: 1,
type: 'all',
extension: mediaExtensions,
success: (result) => {
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
const file = (tempFiles[0] || {}) as { path?: string; name?: string; size?: number };
const path = file.path || result.tempFilePaths?.[0] || '';
const name = file.name || path.split('/').pop() || '现场附件';
const extension = `.${name.split('.').pop()?.toLowerCase() || ''}`;
const kind = videoExtensions.has(extension) ? 'video' : 'image';
const size = Number(file.size || 0);
const maxSize = kind === 'video' ? 100 * 1024 * 1024 : 10 * 1024 * 1024;
if (!path) {
reject(new Error('未获取到附件文件'));
return;
}
if (size > maxSize) {
reject(new Error(kind === 'video' ? '视频不能超过 100MB' : '图片不能超过 10MB'));
return;
}
resolve({ path, name, size, kind });
},
fail: () => reject(new Error('未选择附件'))
});
});
const normalizeSnippet = (snippet: RawKnowledgeSnippet): KnowledgeSnippet => ({
...snippet,
docTitle: snippet.docTitle || snippet.title || '',
@@ -20,8 +54,8 @@ const normalizeSnippet = (snippet: RawKnowledgeSnippet): KnowledgeSnippet => ({
});
export const searchKnowledge = async (queryText: string, position = getSelectedPosition()) => {
const response = await apiRequest<KnowledgeSearchResponse>({
url: '/api/knowledge/search',
const response = await apiRequest<UnifiedKnowledgeResponse>({
url: '/api/knowledge/query',
method: 'POST',
data: {
queryText,
@@ -31,9 +65,74 @@ export const searchKnowledge = async (queryText: string, position = getSelectedP
},
timeout: 30000
});
return normalizeUnifiedResponse(response);
};
export const searchKnowledgeWithMedia = (
file: KnowledgeMediaSelectedFile,
queryText: string,
position = getSelectedPosition(),
onUploadTask?: (task: ReturnType<typeof uni.uploadFile> | null) => void
) => new Promise<KnowledgeSearchResponse>((resolve, reject) => {
const task = uni.uploadFile({
url: apiUrl('/api/knowledge/query-media'),
filePath: file.path,
name: 'file',
header: authHeaders(false),
formData: { queryText, position },
timeout: 360000,
success: (response) => {
try {
const payload = readTextPayload<UnifiedKnowledgeResponse>(response.statusCode, response.data);
resolve(normalizeUnifiedResponse(payload));
} catch (error) {
reject(error);
}
},
fail: () => reject(new Error(file.kind === 'video' ? '视频上传或分析超时' : '图片上传失败')),
complete: () => onUploadTask?.(null)
});
onUploadTask?.(task);
});
export const queryKnowledgeTool = async (
toolCode: 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY',
queryText: string
) => {
const response = await apiRequest<UnifiedKnowledgeResponse>({
url: '/api/knowledge/query',
method: 'POST',
data: { queryText, source: 'mobile_uni_tool', limit: 5, toolCode },
timeout: 30000
});
return normalizeUnifiedResponse(response);
};
const normalizeUnifiedResponse = (response: UnifiedKnowledgeResponse): KnowledgeSearchResponse => {
const legacy = response.legacy || ({} as KnowledgeSearchResponse);
const citationSnippets = (response.citations || []).map((item) => normalizeSnippet({
docTitle: item.title,
content: item.snippet,
fragmentId: item.fragmentId,
spaceCode: item.spaceCode,
sourceType: item.sourceType
}));
return {
...response,
snippets: (response.snippets || []).map((item) => normalizeSnippet(item as RawKnowledgeSnippet))
...legacy,
requestId: response.requestId,
queryText: response.queryText,
category: legacy.category || 'sop',
answer: response.answer,
reference: legacy.reference || (response.citations || []).map((item) => item.title).join('、'),
docs: legacy.docs || [],
snippets: (legacy.snippets?.length ? legacy.snippets : citationSnippets).map((item) => normalizeSnippet(item as RawKnowledgeSnippet)),
training: legacy.training || [],
records: legacy.records || [],
citations: response.citations || [],
usedSpaceCodes: response.usedSpaceCodes || [],
noEvidence: response.noEvidence,
promptVersion: response.promptVersion,
data: response.data
};
};
+13 -6
View File
@@ -1,10 +1,14 @@
import type { MobileIdentity, RoleKey } from '@/types/api';
import { apiRequest } from './api';
import {
hasSelectedPosition,
clearOrgPosition,
getSelectedPosition,
hasOrgPosition,
isSupervisorLearnerMode,
leaveSupervisorLearnerMode,
normalizePosition,
roleKey,
saveSelectedPosition,
saveOrgPosition,
type UserPosition
} from './auth';
@@ -20,7 +24,7 @@ export const isUserRoute = (url: string) => url.startsWith('/pages/user/');
export const isSupervisorRoute = (url: string) => url.startsWith('/pages/supervisor/');
export const routeForMobileRole = (role: RoleKey, requested: string) => {
if (role === 'supervisor' && (isUserRoute(requested) || isSupervisorRoute(requested))) {
if (role === 'supervisor' && isUserRoute(requested) && !isSupervisorLearnerMode()) {
return '/pages/supervisor/index/index';
}
if (role === 'user' && isSupervisorRoute(requested)) {
@@ -39,6 +43,7 @@ export const hydrateMobileRole = async (): Promise<RoleKey> => {
});
const role = identity.role === 'supervisor' ? 'supervisor' : 'user';
uni.setStorageSync(roleKey, role);
if (role !== 'supervisor') leaveSupervisorLearnerMode();
return role;
} catch {
// ponytail: role lookup failure fails closed; never reuse a stale supervisor cache.
@@ -66,9 +71,11 @@ export const hydratePositionFromOrg = async (): Promise<UserPosition | ''> => {
timeout: 15000,
clearAuthOnUnauthorized: false
});
return saveSelectedPosition(identity.positionName);
const officialPosition = saveOrgPosition(identity.positionName);
if (!officialPosition) clearOrgPosition();
return officialPosition;
} catch {
return '';
return hasOrgPosition() ? getSelectedPosition() : '';
}
};
@@ -78,7 +85,7 @@ export const nextUserRouteAfterLogin = async (redirect: string) => {
if (roleRoute !== redirect) return roleRoute;
if (!isUserRoute(redirect)) return redirect;
const officialPosition = await hydratePositionFromOrg();
if (normalizePosition(officialPosition) || hasSelectedPosition()) return redirect;
if (normalizePosition(officialPosition) || hasOrgPosition()) return redirect;
return positionSetupUrl(redirect);
};
+63 -1
View File
@@ -13,10 +13,14 @@ import type {
OnboardTask,
QualificationSummary,
PracticeAssignmentRequest,
PracticeAssignmentBatchRequest,
PracticeAssignmentBatchResponse,
DailyDrillAnswerResponse,
PracticeAlert,
PromotionEvidence,
PracticeScenarioOption
PracticeScenarioOption,
PracticeDifficulty,
PracticeQuestionFeedback
} from '@/types/api';
import { apiRequest } from './api';
import { getAuth, getSelectedPosition } from './auth';
@@ -206,6 +210,14 @@ export const createPracticeAssignment = (data: PracticeAssignmentRequest) =>
timeout: 15000
});
export const createPracticeAssignmentsBatch = (data: PracticeAssignmentBatchRequest) =>
apiRequest<PracticeAssignmentBatchResponse>({
url: '/api/aihr/mobile/practice/assignments/batch',
method: 'POST',
data,
timeout: 30000
});
export const answerDailyDrill = (id: number, answer: string, audioUrl?: string, audioOssId?: number) =>
apiRequest<DailyDrillAnswerResponse>({
url: `/api/aihr/mobile/practice/assignments/${id}/answer`,
@@ -220,3 +232,53 @@ export const getPracticeAlerts = () =>
method: 'GET',
timeout: 15000
});
export const submitPracticeQuestionFeedback = (
assignmentId: number,
verdict: PracticeQuestionFeedback['verdict'],
comment = ''
) =>
apiRequest<PracticeQuestionFeedback>({
url: `/api/aihr/mobile/practice/assignments/${assignmentId}/feedback`,
method: 'POST',
data: { verdict, comment },
timeout: 15000
});
export const getPracticeDifficulties = async (windowDays = 30, position = ''): Promise<PracticeDifficulty[]> => {
const rows = await apiRequest<Array<{
scenarioId: string;
scenarioName: string;
position?: string;
attempts: number;
mistakeCount: number;
affectedPeople: number;
averageScore: number;
difficultyScore: number;
latestTime: string;
}>>({
url: `/api/aihr/mobile/practice/difficulties?windowDays=${windowDays}${position ? `&position=${encodeURIComponent(position)}` : ''}`,
method: 'GET',
timeout: 15000
});
return rows.map((item) => ({
...item,
cause: item.mistakeCount >= Math.max(2, Math.ceil(item.attempts / 2)) ? '高频错题或低分' : '近期低分记录',
count: item.mistakeCount
}));
};
export const assignPracticeDifficulty = (data: {
scenarioId: string;
cause: string;
dueDate: string;
requestId: string;
windowDays: number;
position?: string;
}) =>
apiRequest<PracticeAssignmentBatchResponse>({
url: '/api/aihr/mobile/practice/difficulties/assign',
method: 'POST',
data,
timeout: 30000
});
+155
View File
@@ -0,0 +1,155 @@
import type {
IncentiveBalance,
IncentiveLedgerItem,
QuestionBoardAnswer,
QuestionBoardItem,
QuestionBoardTab
} from '@/types/api';
import { apiRequest } from './api';
type RawAnswer = {
id: number;
questionId: number;
authorName: string;
content: string;
status: string;
best: boolean;
mine: boolean;
createdAt: string;
};
type RawQuestion = {
id: number;
type: string;
category: string;
title: string;
content: string;
authorName: string;
rewardPoints: number;
rewardAccountType: string;
ruleCode: string;
deadline?: string | null;
status: string;
bestAnswerId?: number | null;
answerCount: number;
mine: boolean;
createdAt: string;
answers?: RawAnswer[];
};
type RawPoints = {
balance: number;
pointsBalance: number;
studyCreditBalance: number;
entries: Array<{
id: number;
type: string;
businessId: number;
points: number;
accountType: 'POINTS' | 'STUDY_CREDIT' | string;
reason: string;
createdAt: string;
}>;
};
const normalizeAnswer = (item: RawAnswer): QuestionBoardAnswer => ({
id: item.id,
questionId: item.questionId,
content: item.content,
authorName: item.authorName,
status: item.status,
best: item.best,
mine: item.mine,
recommended: false,
createTime: item.createdAt
});
const normalizeQuestion = (item: RawQuestion): QuestionBoardItem => ({
id: item.id,
type: item.type,
title: item.title,
content: item.content,
category: item.category || '工作求助',
authorName: item.authorName,
status: item.status,
rewardPoints: item.rewardPoints || 0,
rewardAccountType: item.rewardAccountType || 'POINTS',
answerCount: item.answerCount || 0,
deadline: item.deadline || null,
createTime: item.createdAt,
bestAnswerId: item.bestAnswerId || null,
mine: item.mine,
answers: (item.answers || []).map(normalizeAnswer)
});
export const listQuestions = async (tab: QuestionBoardTab) => {
const rows = await apiRequest<RawQuestion[]>({
url: `/api/aihr/community/questions${tab === 'MINE' ? '?mine=true' : ''}`,
method: 'GET',
timeout: 15000
});
const normalized = rows.map(normalizeQuestion);
if (tab === 'UNANSWERED') return normalized.filter((item) => item.answerCount === 0);
if (tab === 'REWARDED') return normalized.filter((item) => item.rewardPoints > 0);
return normalized;
};
export const getQuestion = async (id: number) =>
normalizeQuestion(await apiRequest<RawQuestion>({
url: `/api/aihr/community/questions/${id}`,
method: 'GET',
timeout: 15000
}));
export const publishQuestion = (data: { title: string; content: string; category: string }) =>
apiRequest<RawQuestion>({
url: '/api/aihr/community/questions',
method: 'POST',
data: data,
timeout: 15000
}).then(normalizeQuestion);
export const answerQuestion = (questionId: number, content: string) =>
apiRequest<RawAnswer>({
url: `/api/aihr/community/questions/${questionId}/answers`,
method: 'POST',
data: { content },
timeout: 15000
}).then(normalizeAnswer);
export const chooseBestAnswer = (questionId: number, answerId: number) =>
apiRequest<unknown>({
url: `/api/aihr/community/questions/${questionId}/best-answer`,
method: 'POST',
data: { answerId },
timeout: 15000
});
export const getMyIncentiveBalances = async (): Promise<IncentiveBalance[]> => {
const result = await apiRequest<RawPoints>({
url: '/api/aihr/community/points/me',
method: 'GET',
timeout: 15000
});
return [
{ accountType: 'POINTS', displayName: '奖励积分', balance: result.pointsBalance ?? result.balance ?? 0, unitName: '积分' },
{ accountType: 'STUDY_CREDIT', displayName: '学习学分', balance: result.studyCreditBalance || 0, unitName: '学分' }
];
};
export const getMyIncentiveLedger = async (): Promise<IncentiveLedgerItem[]> => {
const result = await apiRequest<RawPoints>({
url: '/api/aihr/community/points/me',
method: 'GET',
timeout: 15000
});
return (result.entries || []).map((item) => ({
id: item.id,
accountType: item.accountType,
points: item.points,
direction: item.points < 0 ? 'REVERSE' : 'EARN',
sourceType: item.type,
remark: item.reason,
createTime: item.createdAt
}));
};
+70
View File
@@ -0,0 +1,70 @@
import { getAuth } from './auth';
export interface RequestScopeTicket {
accountKey: string;
pageGeneration: number;
lane: string;
laneGeneration: number;
}
export interface PageRequestScope {
activate: () => void;
begin: (lane: string) => RequestScopeTicket;
invalidate: () => void;
isCurrent: (ticket: RequestScopeTicket) => boolean;
}
const tokenFingerprint = (token: string) => {
let hash = 2166136261;
for (let index = 0; index < token.length; index += 1) {
hash ^= token.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(16).padStart(8, '0');
};
export const currentAccountKey = () => {
const auth = getAuth();
return `${auth.phone}|${auth.clientId}|${tokenFingerprint(auth.token)}`;
};
export const createPageRequestScope = (): PageRequestScope => {
let active = false;
let accountKey = '';
let pageGeneration = 0;
const laneGenerations = new Map<string, number>();
const activate = () => {
active = true;
accountKey = currentAccountKey();
pageGeneration += 1;
laneGenerations.clear();
};
const begin = (lane: string): RequestScopeTicket => {
const nextAccountKey = currentAccountKey();
if (active && nextAccountKey !== accountKey) {
accountKey = nextAccountKey;
pageGeneration += 1;
laneGenerations.clear();
}
const laneGeneration = (laneGenerations.get(lane) || 0) + 1;
laneGenerations.set(lane, laneGeneration);
return { accountKey, pageGeneration, lane, laneGeneration };
};
return {
activate,
begin,
invalidate: () => {
active = false;
pageGeneration += 1;
laneGenerations.clear();
},
isCurrent: (ticket) => active
&& ticket.pageGeneration === pageGeneration
&& ticket.accountKey === accountKey
&& ticket.accountKey === currentAccountKey()
&& ticket.laneGeneration === laneGenerations.get(ticket.lane)
};
};
+152 -6
View File
@@ -1,5 +1,5 @@
import type { AsrResponse, SpeechSelectedFile } from '@/types/api';
import { apiUrl, authHeaders, readTextPayload } from './api';
import type { AsrResponse, SpeechPlaybackStatus, SpeechSelectedFile, TtsResponse } from '@/types/api';
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
@@ -27,9 +27,11 @@ export const chooseSpeechAudio = () =>
});
});
export const transcribeSpeechFile = (file: SpeechSelectedFile) =>
export type SpeechAbortRegistrar = (abort: (() => void) | null) => void;
export const transcribeSpeechFile = (file: SpeechSelectedFile, registerAbort?: SpeechAbortRegistrar) =>
new Promise<AsrResponse>((resolve, reject) => {
uni.uploadFile({
const task = uni.uploadFile({
url: apiUrl('/api/ai/asr'),
filePath: file.path,
name: 'file',
@@ -41,11 +43,17 @@ export const transcribeSpeechFile = (file: SpeechSelectedFile) =>
reject(error);
}
},
fail: () => reject(new Error('语音上传失败'))
fail: () => reject(new Error('语音上传失败')),
complete: () => registerAbort?.(null)
});
registerAbort?.(() => task.abort());
});
export const transcribeSpeechBlob = (blob: Blob, filename = 'practice-audio.webm') =>
export const transcribeSpeechBlob = (
blob: Blob,
filename = 'practice-audio.webm',
registerAbort?: SpeechAbortRegistrar
) =>
new Promise<AsrResponse>((resolve, reject) => {
const form = new FormData();
form.append('file', blob, filename);
@@ -62,6 +70,144 @@ export const transcribeSpeechBlob = (blob: Blob, filename = 'practice-audio.webm
}
};
xhr.onerror = () => reject(new Error('语音上传失败'));
xhr.onabort = () => reject(new Error('语音转写已取消'));
xhr.ontimeout = () => reject(new Error('语音转写超时'));
xhr.onloadend = () => registerAbort?.(null);
registerAbort?.(() => xhr.abort());
xhr.send(form);
});
export const synthesizeSpeech = (text: string) =>
apiRequest<TtsResponse>({
url: '/api/ai/tts',
method: 'POST',
data: { text: text.trim().slice(0, 300) },
timeout: 60000
});
export interface SpeechPlaybackSnapshot {
key: string;
status: SpeechPlaybackStatus;
}
export const createSpeechPlaybackController = (
onState: (snapshot: SpeechPlaybackSnapshot) => void,
onError: (message: string) => void
) => {
let audio: ReturnType<typeof uni.createInnerAudioContext> | null = null;
let browserUtterance: SpeechSynthesisUtterance | null = null;
let activeKey = '';
let generation = 0;
const sourceCache = new Map<string, string>();
const maxCachedSources = 4;
const publish = (key = '', status: SpeechPlaybackStatus = 'idle') => {
activeKey = key;
onState({ key, status });
};
const stop = () => {
generation += 1;
if (audio) {
audio.stop();
audio.destroy();
audio = null;
}
if (typeof window !== 'undefined' && window.speechSynthesis && browserUtterance) {
window.speechSynthesis.cancel();
browserUtterance = null;
}
publish();
};
const playWithBrowserSpeech = (key: string, value: string, currentGeneration: number) => {
if (
typeof window === 'undefined'
|| !window.speechSynthesis
|| typeof window.SpeechSynthesisUtterance !== 'function'
) return false;
const utterance = new window.SpeechSynthesisUtterance(value.slice(0, 600));
utterance.lang = 'zh-CN';
utterance.rate = 0.95;
utterance.onend = () => {
if (currentGeneration === generation && activeKey === key) stop();
};
utterance.onerror = () => {
if (currentGeneration !== generation || activeKey !== key) return;
stop();
onError('当前设备语音播报不可用');
};
browserUtterance = utterance;
publish(key, 'playing');
window.speechSynthesis.speak(utterance);
return true;
};
const playAudioSource = (key: string, source: string, cacheKey: string, currentGeneration: number) => {
const context = uni.createInnerAudioContext();
audio = context;
const isCurrent = () => currentGeneration === generation && audio === context && activeKey === key;
context.onPlay(() => {
if (isCurrent()) publish(key, 'playing');
});
context.onEnded(() => {
if (isCurrent()) stop();
});
context.onStop(() => {
if (isCurrent()) publish();
});
context.onError(() => {
if (!isCurrent()) return;
sourceCache.delete(cacheKey);
stop();
onError('语音播放失败,请稍后重试');
});
context.src = source;
context.play();
};
const toggle = async (key: string, text: string) => {
if (activeKey === key) {
stop();
return;
}
stop();
const value = text.trim();
if (!value) return;
const currentGeneration = generation;
publish(key, 'loading');
try {
const cachedSource = sourceCache.get(value);
if (cachedSource) {
sourceCache.delete(value);
sourceCache.set(value, cachedSource);
playAudioSource(key, cachedSource, value, currentGeneration);
return;
}
const result = await synthesizeSpeech(value);
if (currentGeneration !== generation || activeKey !== key) return;
const source = result.inlineAudioUrl || result.audioUrl;
if (!source) throw new Error('语音合成未返回音频');
sourceCache.set(value, source);
while (sourceCache.size > maxCachedSources) {
const oldestKey = sourceCache.keys().next().value as string | undefined;
if (!oldestKey) break;
sourceCache.delete(oldestKey);
}
playAudioSource(key, source, value, currentGeneration);
} catch (error) {
if (currentGeneration !== generation) return;
if (playWithBrowserSpeech(key, value, currentGeneration)) return;
stop();
onError(error instanceof Error ? error.message : '语音生成失败');
}
};
const destroy = () => {
stop();
sourceCache.clear();
};
return { toggle, stop, destroy };
};
@@ -0,0 +1,128 @@
import type { SummaryCardResponse } from '@/types/api';
const WIDTH = 1080;
const PADDING = 84;
const CONTENT_WIDTH = WIDTH - PADDING * 2;
const safeFileName = (value: string) =>
(value || 'SOP总结卡').replace(/[\\/:*?"<>|\r\n]+/g, '-').slice(0, 48);
const wrapText = (context: CanvasRenderingContext2D, text: string, maxWidth: number) => {
const lines: string[] = [];
let current = '';
for (const char of String(text || '').trim()) {
const next = current + char;
if (current && context.measureText(next).width > maxWidth) {
lines.push(current);
current = char;
} else {
current = next;
}
}
if (current) lines.push(current);
return lines.length ? lines : [''];
};
export const downloadSummaryCardImage = async (card: SummaryCardResponse) => {
if (typeof document === 'undefined' || typeof URL === 'undefined') {
throw new Error('当前终端暂不支持下载图片');
}
if (document.fonts?.ready) await document.fonts.ready;
const canvas = document.createElement('canvas');
canvas.width = WIDTH;
canvas.height = 1;
const measureContext = canvas.getContext('2d');
if (!measureContext) throw new Error('图片生成失败');
measureContext.font = '800 56px system-ui, sans-serif';
const titleLines = wrapText(measureContext, card.title || '工作总结卡', CONTENT_WIDTH);
const sectionRows = [
['核心步骤', (card.steps || []).map((item) => `${item.label}:${item.desc}`)],
['推荐话术', card.scripts || []],
['常见异议与回应', (card.objections || []).map((item) => `${item.concern};回应:${item.response}`)],
['师傅提醒', card.reminders || []]
] as const;
measureContext.font = '400 28px system-ui, sans-serif';
const sections = sectionRows
.filter(([, rows]) => rows.length)
.map(([title, rows]) => ({
title,
rows: rows.map((row) => wrapText(measureContext, row, CONTENT_WIDTH - 64))
}));
let measuredY = 118 + 80 + titleLines.length * 76 + 14 + 70;
sections.forEach((section) => {
measuredY += 48;
section.rows.forEach((lines) => {
measuredY += Math.max(54, lines.length * 44 + 18);
});
measuredY += 36;
});
const height = Math.max(720, measuredY + 100);
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) throw new Error('图片生成失败');
context.fillStyle = '#f7f9fc';
context.fillRect(0, 0, WIDTH, height);
context.fillStyle = '#ffffff';
context.fillRect(36, 36, WIDTH - 72, height - 72);
let y = 118;
context.fillStyle = '#e60012';
context.font = '700 28px system-ui, sans-serif';
context.fillText('银城数字师傅 · SOP 总结卡', PADDING, y);
y += 80;
context.fillStyle = '#172033';
context.font = '800 56px system-ui, sans-serif';
titleLines.forEach((line) => {
context.fillText(line, PADDING, y);
y += 76;
});
y += 14;
context.fillStyle = '#e60012';
context.fillRect(PADDING, y, 88, 8);
y += 70;
sections.forEach((section) => {
context.fillStyle = '#172033';
context.font = '800 34px system-ui, sans-serif';
context.fillText(section.title, PADDING, y);
y += 48;
section.rows.forEach((lines, index) => {
context.fillStyle = '#fff0f1';
context.beginPath();
context.arc(PADDING + 18, y + 19, 18, 0, Math.PI * 2);
context.fill();
context.fillStyle = '#e60012';
context.font = '800 20px system-ui, sans-serif';
context.textAlign = 'center';
context.fillText(String(index + 1), PADDING + 18, y + 27);
context.textAlign = 'left';
context.fillStyle = '#3f4a5c';
context.font = '400 28px system-ui, sans-serif';
lines.forEach((line, lineIndex) => context.fillText(line, PADDING + 56, y + 28 + lineIndex * 44));
y += Math.max(54, lines.length * 44 + 18);
});
y += 36;
});
context.fillStyle = '#8a94a5';
context.font = '400 22px system-ui, sans-serif';
context.fillText('内容由数字师傅基于已授权 SOP 生成,请结合现场情况执行。', PADDING, y + 10);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((value) => value ? resolve(value) : reject(new Error('图片生成失败')), 'image/png');
});
canvas.width = 1;
canvas.height = 1;
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${safeFileName(card.title)}.png`;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
};
+64
View File
@@ -0,0 +1,64 @@
import type { WebAiCapabilities, WebAiResponse } from '@/types/api';
import { apiRequest } from './api';
type RawCapabilities = {
enabled: boolean;
provider: string;
requiresConsent: boolean;
maxQuestionLength: number;
notice: string;
};
type RawWebAiResponse = {
mode: string;
status: string;
answer: string;
sources?: Array<{ title: string; url: string; snippet: string; score?: number }>;
provider: string;
notice: string;
redacted: boolean;
requiresConsent: boolean;
};
const domainOf = (value: string) => {
try { return new URL(value).hostname; } catch { return ''; }
};
export const getWebAiCapabilities = async (): Promise<WebAiCapabilities> => {
const raw = await apiRequest<RawCapabilities>({
url: '/api/aihr/web-ai/capabilities',
method: 'GET',
timeout: 15000
});
return {
enabled: raw.enabled,
requiresConsent: raw.requiresConsent,
providerName: raw.provider,
maxQuestionLength: raw.maxQuestionLength,
notice: raw.notice
};
};
export const queryWebAi = async (question: string, consent: boolean): Promise<WebAiResponse> => {
const raw = await apiRequest<RawWebAiResponse>({
url: '/api/aihr/web-ai/query',
method: 'POST',
data: { question, consent },
timeout: 45000
});
return {
requestId: `web:${Date.now()}`,
status: raw.status,
provider: raw.provider,
answer: raw.answer || '',
citations: (raw.sources || []).map((source) => ({
title: source.title,
url: source.url,
domain: domainOf(source.url),
snippet: source.snippet
})),
noEvidence: raw.status !== 'OK',
warning: raw.notice,
redacted: raw.redacted
};
};