feat(aihr): complete uni-app role workflows
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import type { ApiPayload } from '@/types/api';
|
||||
import { clearAuth, getAuth } from './auth';
|
||||
|
||||
const apiBase = import.meta.env.VITE_API_BASE || '/dev-api';
|
||||
|
||||
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
|
||||
export interface RequestOptions {
|
||||
url: string;
|
||||
method?: HttpMethod;
|
||||
data?: unknown;
|
||||
header?: Record<string, string>;
|
||||
timeout?: number;
|
||||
auth?: boolean;
|
||||
}
|
||||
|
||||
const normalizeUrl = (url: string) => `${apiBase}${url.startsWith('/') ? url : `/${url}`}`;
|
||||
|
||||
export const apiUrl = normalizeUrl;
|
||||
|
||||
export const authHeaders = (json = true, includeAuth = true) => {
|
||||
const auth = getAuth();
|
||||
return {
|
||||
...(json ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(includeAuth && auth.token ? { Authorization: `Bearer ${auth.token}` } : {}),
|
||||
...(includeAuth && auth.clientId ? { clientid: auth.clientId } : {})
|
||||
};
|
||||
};
|
||||
|
||||
export const readPayload = <T>(statusCode: number, data: unknown): T => {
|
||||
const payload = data as ApiPayload<T>;
|
||||
const code = typeof payload?.code === 'number' ? payload.code : undefined;
|
||||
if (statusCode < 200 || statusCode >= 300 || (code !== undefined && code !== 200)) {
|
||||
if (statusCode === 401 || statusCode === 403 || code === 401 || code === 403) {
|
||||
clearAuth();
|
||||
}
|
||||
throw new Error(payload?.msg || `HTTP ${statusCode}`);
|
||||
}
|
||||
|
||||
return payload?.data as T;
|
||||
};
|
||||
|
||||
export const parsePayloadText = (statusCode: number, text: string) => {
|
||||
try {
|
||||
return JSON.parse(text || '{}');
|
||||
} catch (error) {
|
||||
if (statusCode === 401 || statusCode === 403) clearAuth();
|
||||
throw error instanceof SyntaxError
|
||||
? new Error(statusCode >= 400 ? `HTTP ${statusCode}` : '响应解析失败')
|
||||
: error;
|
||||
}
|
||||
};
|
||||
|
||||
export const readTextPayload = <T>(statusCode: number, text: string): T =>
|
||||
readPayload<T>(statusCode, parsePayloadText(statusCode, text));
|
||||
|
||||
const requestWithXhr = async <T>(options: RequestOptions): Promise<T> => {
|
||||
const method = options.method || 'GET';
|
||||
const hasBody = method !== 'GET' && options.data !== undefined;
|
||||
const response = await new Promise<{ status: number; data: unknown }>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(method, normalizeUrl(options.url));
|
||||
xhr.timeout = options.timeout || 30000;
|
||||
Object.entries({
|
||||
...authHeaders(hasBody, options.auth !== false),
|
||||
...options.header
|
||||
}).forEach(([key, value]) => xhr.setRequestHeader(key, value));
|
||||
xhr.onload = () => {
|
||||
try {
|
||||
resolve({ status: xhr.status, data: parsePayloadText(xhr.status, xhr.responseText || 'null') });
|
||||
} catch {
|
||||
reject(new Error(xhr.status >= 400 ? `HTTP ${xhr.status}` : '响应解析失败'));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('网络请求失败'));
|
||||
xhr.ontimeout = () => reject(new Error('网络请求超时'));
|
||||
xhr.send(hasBody ? JSON.stringify(options.data) : undefined);
|
||||
});
|
||||
|
||||
return readPayload<T>(response.status, response.data);
|
||||
};
|
||||
|
||||
export const apiRequest = async <T>(options: RequestOptions): Promise<T> => {
|
||||
if (typeof XMLHttpRequest !== 'undefined') {
|
||||
return requestWithXhr<T>(options);
|
||||
}
|
||||
|
||||
const response = await new Promise<any>((resolve, reject) => {
|
||||
uni.request({
|
||||
url: normalizeUrl(options.url),
|
||||
method: options.method || 'GET',
|
||||
data: options.data as string | ArrayBuffer | Record<string, unknown> | undefined,
|
||||
header: {
|
||||
...authHeaders(options.method !== 'GET', options.auth !== false),
|
||||
...options.header
|
||||
},
|
||||
timeout: options.timeout || 30000,
|
||||
success: resolve,
|
||||
fail: reject
|
||||
});
|
||||
});
|
||||
|
||||
return readPayload<T>(response.statusCode, response.data);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { LoginResult } from '@/types/api';
|
||||
|
||||
export const tokenKey = 'aihr_mobile_access_token';
|
||||
export const clientIdKey = 'aihr_mobile_client_id';
|
||||
export const phoneKey = 'aihr_mobile_phone';
|
||||
export const loginRedirectKey = 'aihr_mobile_login_redirect';
|
||||
export const phonePattern = /^1[3-9]\d{9}$/;
|
||||
|
||||
export interface AuthState {
|
||||
token: string;
|
||||
clientId: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export const getAuth = (): AuthState => ({
|
||||
token: uni.getStorageSync(tokenKey) || '',
|
||||
clientId: uni.getStorageSync(clientIdKey) || '',
|
||||
phone: uni.getStorageSync(phoneKey) || ''
|
||||
});
|
||||
|
||||
export const isLoggedIn = () => {
|
||||
const auth = getAuth();
|
||||
return Boolean(auth.token && auth.clientId && auth.phone);
|
||||
};
|
||||
|
||||
export const saveAuth = (result: LoginResult, phone: string) => {
|
||||
uni.setStorageSync(tokenKey, result.access_token);
|
||||
uni.setStorageSync(clientIdKey, result.client_id);
|
||||
uni.setStorageSync(phoneKey, phone);
|
||||
};
|
||||
|
||||
export const clearAuth = () => {
|
||||
uni.removeStorageSync(tokenKey);
|
||||
uni.removeStorageSync(clientIdKey);
|
||||
uni.removeStorageSync(phoneKey);
|
||||
uni.removeStorageSync(loginRedirectKey);
|
||||
};
|
||||
|
||||
export const rememberLoginRedirect = (url: string) => {
|
||||
uni.setStorageSync(loginRedirectKey, url);
|
||||
};
|
||||
|
||||
export const consumeLoginRedirect = () => {
|
||||
const url = uni.getStorageSync(loginRedirectKey) || '';
|
||||
uni.removeStorageSync(loginRedirectKey);
|
||||
return url;
|
||||
};
|
||||
|
||||
export const validatePhone = (phone: string) => phonePattern.test(phone);
|
||||
@@ -0,0 +1,114 @@
|
||||
import type {
|
||||
CandidateMaterial,
|
||||
CandidateSelectedFile,
|
||||
InterviewFinishResponse,
|
||||
InterviewRecord,
|
||||
InterviewStartResponse
|
||||
} from '@/types/api';
|
||||
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
|
||||
import { getAuth } from './auth';
|
||||
|
||||
const materialExtensions = ['.pdf', '.doc', '.docx', '.jpg', '.jpeg', '.png', '.webp'];
|
||||
|
||||
export const getCandidateId = () => {
|
||||
const phone = getAuth().phone.trim();
|
||||
if (!phone) {
|
||||
throw new Error('请先登录后再查看候选人进度');
|
||||
}
|
||||
return phone;
|
||||
};
|
||||
|
||||
export const getCandidateName = () => {
|
||||
const phone = getAuth().phone.trim();
|
||||
return phone ? `候选人${phone.slice(-4)}` : '候选人';
|
||||
};
|
||||
|
||||
export const getCandidateMaterials = () =>
|
||||
apiRequest<CandidateMaterial[]>({
|
||||
url: `/api/aihr/mobile/candidate/materials?candidateId=${encodeURIComponent(getCandidateId())}`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const getCandidateInterviewRecords = () =>
|
||||
apiRequest<InterviewRecord[]>({
|
||||
url: `/api/recruit/interview/records?limit=5&candidateId=${encodeURIComponent(getCandidateId())}`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const chooseCandidateFile = () =>
|
||||
new Promise<CandidateSelectedFile>((resolve, reject) => {
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
type: 'all',
|
||||
extension: materialExtensions,
|
||||
success: (result) => {
|
||||
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
|
||||
const file = (tempFiles[0] || {}) as Partial<CandidateSelectedFile>;
|
||||
const path = file.path || result.tempFilePaths?.[0] || '';
|
||||
if (!path) {
|
||||
reject(new Error('未获取到文件路径'));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
path,
|
||||
name: file.name || path.split('/').pop() || 'candidate-material',
|
||||
size: Number(file.size || 0)
|
||||
});
|
||||
},
|
||||
fail: () => reject(new Error('未选择文件'))
|
||||
});
|
||||
});
|
||||
|
||||
export const uploadCandidateMaterial = (file: CandidateSelectedFile, materialType = '补充资料') =>
|
||||
new Promise<CandidateMaterial>((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: apiUrl('/api/aihr/mobile/candidate/materials'),
|
||||
filePath: file.path,
|
||||
name: 'file',
|
||||
header: authHeaders(false),
|
||||
formData: {
|
||||
candidateId: getCandidateId(),
|
||||
candidateName: getCandidateName(),
|
||||
materialType
|
||||
},
|
||||
success: (response) => {
|
||||
try {
|
||||
resolve(readTextPayload<CandidateMaterial>(response.statusCode, response.data));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
fail: () => reject(new Error('资料上传失败'))
|
||||
});
|
||||
});
|
||||
|
||||
export const startCandidateInterview = () =>
|
||||
apiRequest<InterviewStartResponse>({
|
||||
url: '/api/recruit/interview/start',
|
||||
method: 'POST',
|
||||
data: {
|
||||
candidateId: getCandidateId(),
|
||||
candidateName: getCandidateName(),
|
||||
positionCode: 'customer-service',
|
||||
mode: 'mobile'
|
||||
},
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
export const saveInterviewAnswer = (sessionId: string, questionId: string, answerText: string) =>
|
||||
apiRequest<void>({
|
||||
url: '/api/recruit/interview/answer',
|
||||
method: 'POST',
|
||||
data: { sessionId, questionId, answerText },
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
export const finishCandidateInterview = (sessionId: string, answers: Record<string, string>) =>
|
||||
apiRequest<InterviewFinishResponse>({
|
||||
url: '/api/recruit/interview/finish',
|
||||
method: 'POST',
|
||||
data: { sessionId, answers },
|
||||
timeout: 45000
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
CaseCurateResponse,
|
||||
CaseOrganizeResponse,
|
||||
CaseRecord,
|
||||
CaseSelectedFile,
|
||||
CaseUploadResponse
|
||||
} from '@/types/api';
|
||||
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
|
||||
|
||||
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
|
||||
|
||||
export const chooseCaseAudio = () =>
|
||||
new Promise<CaseSelectedFile>((resolve, reject) => {
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
type: 'all',
|
||||
extension: audioExtensions,
|
||||
success: (result) => {
|
||||
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
|
||||
const file = (tempFiles[0] || {}) as Partial<CaseSelectedFile>;
|
||||
const path = file.path || result.tempFilePaths?.[0] || '';
|
||||
if (!path) {
|
||||
reject(new Error('未获取到文件路径'));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
path,
|
||||
name: file.name || path.split('/').pop() || 'case-audio',
|
||||
size: Number(file.size || 0)
|
||||
});
|
||||
},
|
||||
fail: () => reject(new Error('未选择音频'))
|
||||
});
|
||||
});
|
||||
|
||||
export const uploadCaseAudio = (file: CaseSelectedFile) =>
|
||||
new Promise<CaseUploadResponse>((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: apiUrl('/api/knowledge/case/upload'),
|
||||
filePath: file.path,
|
||||
name: 'file',
|
||||
header: authHeaders(false),
|
||||
formData: {},
|
||||
success: (response) => {
|
||||
try {
|
||||
resolve(readTextPayload<CaseUploadResponse>(response.statusCode, response.data));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
fail: () => reject(new Error('案例音频上传失败'))
|
||||
});
|
||||
});
|
||||
|
||||
export const organizeCase = (caseId: string) =>
|
||||
apiRequest<CaseOrganizeResponse>({
|
||||
url: '/api/knowledge/case/organize',
|
||||
method: 'POST',
|
||||
data: { caseId },
|
||||
timeout: 45000
|
||||
});
|
||||
|
||||
export const curateCase = (caseId: string) =>
|
||||
apiRequest<CaseCurateResponse>({
|
||||
url: '/api/knowledge/case/curate',
|
||||
method: 'POST',
|
||||
data: {
|
||||
caseId,
|
||||
criteria: '一线可复用培训案例',
|
||||
limit: 1
|
||||
},
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
export const getCaseRecords = () =>
|
||||
apiRequest<CaseRecord[]>({
|
||||
url: '/api/knowledge/case/records?limit=10',
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { RoleHome, RoleKey } from '@/types/api';
|
||||
import { apiRequest } from './api';
|
||||
|
||||
export const getMobileHome = (role: RoleKey) =>
|
||||
apiRequest<RoleHome>({
|
||||
url: `/api/aihr/mobile/home/${role}`,
|
||||
method: 'GET',
|
||||
auth: false,
|
||||
timeout: 15000
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type {
|
||||
AnswerFeedbackVerdict,
|
||||
KnowledgeSearchResponse,
|
||||
KnowledgeSnippet,
|
||||
SummaryCardResponse
|
||||
} from '@/types/api';
|
||||
import { apiRequest } from './api';
|
||||
|
||||
type RawKnowledgeSnippet = KnowledgeSnippet & {
|
||||
title?: string;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
const normalizeSnippet = (snippet: RawKnowledgeSnippet): KnowledgeSnippet => ({
|
||||
...snippet,
|
||||
docTitle: snippet.docTitle || snippet.title || '',
|
||||
content: snippet.content || snippet.text || ''
|
||||
});
|
||||
|
||||
export const searchKnowledge = async (queryText: string) => {
|
||||
const response = await apiRequest<KnowledgeSearchResponse>({
|
||||
url: '/api/knowledge/search',
|
||||
method: 'POST',
|
||||
data: {
|
||||
queryText,
|
||||
category: 'sop',
|
||||
limit: 5
|
||||
},
|
||||
timeout: 30000
|
||||
});
|
||||
return {
|
||||
...response,
|
||||
snippets: (response.snippets || []).map((item) => normalizeSnippet(item as RawKnowledgeSnippet))
|
||||
};
|
||||
};
|
||||
|
||||
export const submitAnswerFeedback = (params: {
|
||||
queryText: string;
|
||||
fragmentIds: number[];
|
||||
verdict: AnswerFeedbackVerdict;
|
||||
}) =>
|
||||
apiRequest<{ saved: number }>({
|
||||
url: '/api/knowledge/answer-feedback',
|
||||
method: 'POST',
|
||||
data: {
|
||||
queryText: params.queryText,
|
||||
fragmentIds: params.fragmentIds,
|
||||
verdict: params.verdict,
|
||||
category: 'sop',
|
||||
position: '生活顾问',
|
||||
source: 'mobile_uni_sop'
|
||||
},
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const generateSummaryCard = (queryText: string) =>
|
||||
apiRequest<SummaryCardResponse>({
|
||||
url: '/api/knowledge/summary-card',
|
||||
method: 'POST',
|
||||
data: {
|
||||
queryText,
|
||||
category: 'sop'
|
||||
},
|
||||
timeout: 45000
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import type {
|
||||
PracticeFinishResponse,
|
||||
PracticePrepCard,
|
||||
PracticeRecord,
|
||||
PracticeStartResponse,
|
||||
PracticeTurnResponse,
|
||||
CompetencyProfile,
|
||||
ReviewAdviceRequest,
|
||||
ReviewDetail,
|
||||
PracticeAssignment,
|
||||
PracticeAssignmentRequest,
|
||||
DailyDrillAnswerResponse,
|
||||
PracticeAlert,
|
||||
PromotionEvidence
|
||||
} from '@/types/api';
|
||||
import { apiRequest } from './api';
|
||||
import { getAuth } from './auth';
|
||||
|
||||
const defaultScenarioId = 'complaint-water';
|
||||
|
||||
const currentExtPartyId = () => {
|
||||
const phone = getAuth().phone.trim();
|
||||
if (!phone) {
|
||||
throw new Error('请先登录后再查看训练数据');
|
||||
}
|
||||
return phone;
|
||||
};
|
||||
|
||||
export const getPracticePrepCard = (scenarioId = defaultScenarioId) =>
|
||||
apiRequest<PracticePrepCard>({
|
||||
url: `/api/train/practice/scenarios/${encodeURIComponent(scenarioId)}/prep-card`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const startPractice = (scenarioId = defaultScenarioId, assignmentId?: number) =>
|
||||
apiRequest<PracticeStartResponse>({
|
||||
url: '/api/train/practice/start',
|
||||
method: 'POST',
|
||||
data: {
|
||||
extPartyId: currentExtPartyId(),
|
||||
scenarioId,
|
||||
mode: 'mobile',
|
||||
assignmentId
|
||||
},
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
export const submitPracticeTurn = (params: {
|
||||
sessionId: string;
|
||||
roundIndex: number;
|
||||
traineeText: string;
|
||||
regenerate?: boolean;
|
||||
style?: string;
|
||||
}) =>
|
||||
apiRequest<PracticeTurnResponse>({
|
||||
url: '/api/train/practice/turn',
|
||||
method: 'POST',
|
||||
data: params,
|
||||
timeout: 45000
|
||||
});
|
||||
|
||||
export const finishPractice = (sessionId: string, assignmentId?: number) =>
|
||||
apiRequest<PracticeFinishResponse>({
|
||||
url: '/api/train/practice/finish',
|
||||
method: 'POST',
|
||||
data: { sessionId, assignmentId },
|
||||
timeout: 45000
|
||||
});
|
||||
|
||||
export const getPracticeHistory = () => {
|
||||
const extPartyId = encodeURIComponent(currentExtPartyId());
|
||||
return apiRequest<PracticeRecord[]>({
|
||||
url: `/api/aihr/mobile/practice/history?extPartyId=${extPartyId}`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
};
|
||||
|
||||
export const getCompetencyProfile = () => {
|
||||
const extPartyId = encodeURIComponent(currentExtPartyId());
|
||||
return apiRequest<CompetencyProfile>({
|
||||
url: `/api/aihr/mobile/profile?extPartyId=${extPartyId}`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
};
|
||||
|
||||
export const getPromotionEvidence = () => {
|
||||
const extPartyId = encodeURIComponent(currentExtPartyId());
|
||||
return apiRequest<PromotionEvidence>({
|
||||
url: `/api/aihr/mobile/profile/promotion-evidence?extPartyId=${extPartyId}`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
};
|
||||
|
||||
export const getPracticeReviews = () =>
|
||||
apiRequest<PracticeRecord[]>({
|
||||
url: '/api/aihr/mobile/practice/reviews',
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const getPracticeReviewDetail = (id: number) =>
|
||||
apiRequest<ReviewDetail>({
|
||||
url: `/api/aihr/mobile/practice/reviews/${id}`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const markPracticeReviewed = (id: number, data: ReviewAdviceRequest) =>
|
||||
apiRequest<void>({
|
||||
url: `/api/aihr/mobile/practice/reviews/${id}/reviewed`,
|
||||
method: 'POST',
|
||||
data,
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const getPracticeAssignments = (ownOnly = false) => {
|
||||
const extPartyId = ownOnly ? encodeURIComponent(currentExtPartyId()) : '';
|
||||
return apiRequest<PracticeAssignment[]>({
|
||||
url: `/api/aihr/mobile/practice/assignments${ownOnly ? `?extPartyId=${extPartyId}` : ''}`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
};
|
||||
|
||||
export const createPracticeAssignment = (data: PracticeAssignmentRequest) =>
|
||||
apiRequest<PracticeAssignment>({
|
||||
url: '/api/aihr/mobile/practice/assignments',
|
||||
method: 'POST',
|
||||
data: {
|
||||
source: 'manual',
|
||||
scenarioId: 'fee-parking',
|
||||
...data
|
||||
},
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const answerDailyDrill = (id: number, answer: string) =>
|
||||
apiRequest<DailyDrillAnswerResponse>({
|
||||
url: `/api/aihr/mobile/practice/assignments/${id}/answer`,
|
||||
method: 'POST',
|
||||
data: { answer },
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const getPracticeAlerts = () =>
|
||||
apiRequest<PracticeAlert[]>({
|
||||
url: '/api/aihr/mobile/practice/alerts',
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
Reference in New Issue
Block a user