feat: restore learning feedback and knowledge capabilities
This commit is contained in:
@@ -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());
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
};
|
||||
@@ -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)
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user