feat(work-report): add voice-first reporting flow

This commit is contained in:
2026-07-19 12:17:11 +08:00
parent 24da833fa6
commit 925b48e436
15 changed files with 2813 additions and 451 deletions
File diff suppressed because it is too large Load Diff
+51 -3
View File
@@ -23,6 +23,35 @@ export interface WorkReportAttachment {
fileName: string;
}
export interface WorkReportDraft {
suggestedType: WorkReportType;
title: string;
content: string;
}
export interface OrganizeResponse extends WorkReportDraft {
missingQuestions: string[];
privacyWarnings: string[];
confidence: number;
notice?: string;
}
export const stableWorkReportRequestId = (current: string, create: () => string) => current || create();
export const isDefinitiveWorkReportRejection = (error: { statusCode?: number; bizCode?: number }) => {
const status = Number(error.statusCode || 0);
const bizCode = Number(error.bizCode || 0);
const isDefinitive = (code: number) => code >= 400 && code < 500 && ![408, 409, 425, 429].includes(code);
return isDefinitive(status) || isDefinitive(bizCode);
};
export const isCurrentWorkReportRequest = (
generation: number,
currentGeneration: number,
accountKey: string,
currentAccountKey: string
) => generation === currentGeneration && accountKey === currentAccountKey;
export const workReportTypes: Array<{ value: WorkReportType; label: string; desc: string; icon: string }> = [
{ value: 'CASE', label: '优秀案例', desc: '现场处理得好的真实案例', icon: '⭐' },
{ value: 'VIDEO', label: '操作视频', desc: '值得推广的操作演示视频', icon: '🎬' },
@@ -31,6 +60,7 @@ export const workReportTypes: Array<{ value: WorkReportType; label: string; desc
];
export const submitWorkReport = (params: {
requestId: string;
type: WorkReportType;
title: string;
content: string;
@@ -44,6 +74,19 @@ export const submitWorkReport = (params: {
timeout: 20000
});
export const organizeWorkReport = (params: {
transcript: string;
currentDraft?: WorkReportDraft;
attachmentOssId?: number | string;
attachmentName?: string;
}) =>
apiRequest<OrganizeResponse>({
url: '/api/aihr/work-report/organize',
method: 'POST',
data: params,
timeout: 45000
});
export const listMyWorkReports = () =>
apiRequest<WorkReportRecord[]>({
url: '/api/aihr/work-report/reports/mine',
@@ -66,9 +109,12 @@ export const reviewWorkReport = (id: number, decision: 'APPROVE' | 'REJECT', not
timeout: 20000
});
export const uploadWorkReportAttachment = (filePath: string) =>
export const uploadWorkReportAttachment = (
filePath: string,
onUploadTask?: (task: ReturnType<typeof uni.uploadFile> | null) => void
) =>
new Promise<WorkReportAttachment>((resolve, reject) => {
uni.uploadFile({
const task = uni.uploadFile({
url: apiUrl('/api/aihr/work-report/attachment'),
filePath,
name: 'file',
@@ -80,6 +126,8 @@ export const uploadWorkReportAttachment = (filePath: string) =>
reject(error);
}
},
fail: () => reject(new Error('附件上传失败'))
fail: () => reject(new Error('附件上传失败')),
complete: () => onUploadTask?.(null)
});
onUploadTask?.(task);
});
+173
View File
@@ -0,0 +1,173 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const source = (path) => readFile(new URL(path, import.meta.url), 'utf8');
test('工作上报首屏是语音优先会话,不再暴露传统表单主路径', async () => {
const page = await source('../src/pages/user/report/index.vue');
assert.match(page, /工作上报/);
assert.match(page, /数字师傅帮你整理/);
assert.match(page, /我的上报/);
assert.match(page, /今天处理了什么?按住说,也可以发照片或视频。/);
assert.match(page, /<ChatComposer/);
assert.doesNotMatch(page, /选择上报类型/);
assert.doesNotMatch(page, /class="type-grid"/);
assert.doesNotMatch(page, /placeholder="一句话说清上报内容"/);
});
test('工作上报为 ChatComposer 提供自己的照片视频和音频面板', async () => {
const page = await source('../src/pages/user/report/index.vue');
assert.match(page, /:panel-items="composerPanelItems"/);
assert.match(page, /label: '照片\/视频'/);
assert.match(page, /label: '选择音频'/);
assert.match(page, /@panel="handlePanelAction"/);
assert.doesNotMatch(page, /仅用于本次现场分析/);
});
test('语音转写、文字和附件补充都进入无状态整理', async () => {
const page = await source('../src/pages/user/report/index.vue');
assert.match(page, /语音转文字/);
assert.match(page, /transcribeSpeechBlob/);
assert.match(page, /transcribeSpeechFile/);
assert.match(page, /organizeWorkReport\(/);
assert.match(page, /const previousDraft = draftPayload\(\)/);
assert.match(page, /currentDraft:\s*previousDraft/);
assert.match(page, /attachmentOssId:\s*attachment\.value\?\.ossId/);
assert.match(page, /void organizeInput\(text, \{ voice: false \}\)/);
assert.match(page, /void addMediaAttachment\(\)/);
});
test('短追问最多两条,文字直接可见并只提供播放', async () => {
const page = await source('../src/pages/user/report/index.vue');
assert.match(page, /missingQuestions\.slice\(0, 2\)/);
assert.match(page, /class="question-text"/);
assert.match(page, /toggleQuestionSpeech/);
assert.match(page, /aria-label="播放追问"/);
assert.doesNotMatch(page, />转文字<\/button>/);
assert.doesNotMatch(page, /收起文字/);
});
test('确认卡允许修改最终类型标题正文且不承诺自动脱敏', async () => {
const page = await source('../src/pages/user/report/index.vue');
assert.match(page, /建议类型/);
assert.match(page, /changeDraftType/);
assert.match(page, /v-model="currentDraft\.title"/);
assert.match(page, /v-model="currentDraft\.content"/);
assert.match(page, /检测到具体房号,请修改后再提交/);
assert.match(page, /const hasRoomPrivacyWarning = computed/);
assert.match(page, /请处理上述敏感信息后再提交/);
assert.match(page, /const finishDraftEditing = async/);
assert.match(page, /manuallyConfirmedType\.value\s*\|\|/);
assert.match(page, /manuallyConfirmedType\.value = selected\.value/);
assert.match(page, /requestId:\s*submitRequestId\.value[\s\S]*type:\s*draft\.suggestedType/);
assert.match(page, /transcript:\s*''/);
assert.match(page, /if \(privacyReviewRequired\.value\)/);
assert.doesNotMatch(page, /privacyReviewed/);
assert.match(page, /editingDraft \? '完成修改' : '修改'/);
assert.match(page, /@click="confirmSubmit"/);
assert.match(page, /'确认上报'/);
assert.doesNotMatch(page, /自动隐藏/);
assert.doesNotMatch(page, />继续补充<\/button>/);
});
test('正式提交只发生在确认动作并复用稳定幂等键', async () => {
const page = await source('../src/pages/user/report/index.vue');
const submitCalls = page.match(/submitWorkReport\(/g) || [];
assert.equal(submitCalls.length, 1);
assert.match(page, /const confirmSubmit = async \(\) =>/);
assert.match(page, /if \(submitActionDisabled\.value \|\| editingDraft\.value\)/);
assert.match(page, /stableWorkReportRequestId\(submitRequestId\.value, createRequestId\)/);
assert.match(page, /requestId:\s*submitRequestId\.value/);
assert.match(page, /const draftLocked = computed/);
assert.match(page, /\|\| Boolean\(submitRequestId\.value\)/);
assert.match(page, /isDefinitiveWorkReportRejection/);
assert.match(page, /if \(rejected\) submitRequestId\.value = ''/);
assert.match(page, /const record = await submitWorkReport/);
assert.match(page, /submittedReport\.value = record/);
assert.match(page, /await loadMine\(true\)/);
assert.match(page, /PENDING/);
assert.match(page, /待审核/);
assert.match(page, /submittedReport\.status\.toLowerCase\(\)/);
assert.match(page, /submittedReport\.statusLabel/);
assert.match(page, /@click="startNewReport">再报一条/);
});
test('异步请求具备 generation、上传取消和离页 teardown', async () => {
const page = await source('../src/pages/user/report/index.vue');
assert.match(page, /let organizeGeneration = 0/);
assert.match(page, /isCurrentWorkReportRequest/);
assert.match(page, /currentAccountKey\(\)/);
assert.match(page, /visibleAccountKey/);
assert.match(page, /mediaSelecting\.value/);
assert.match(page, /abortVoiceTranscription\?\.\(\)/);
assert.match(page, /activeUploadTask\?\.abort\(\)/);
assert.match(page, /!previousDraft \|\| submitActionDisabled\.value \|\| draftLocked\.value/);
assert.match(page, /:disabled="draftLocked \|\| submitActionDisabled"/);
assert.match(page, /composerRef\.value\?\.teardown\(\)/);
assert.match(page, /speechPlayback\.stop\(\)/);
assert.match(page, /onHide\(teardown\)/);
assert.match(page, /onUnload/);
});
test('AI 长草稿不被前端静默截断,改由确认时明确校验', async () => {
const page = await source('../src/pages/user/report/index.vue');
assert.doesNotMatch(page, /String\(result\.content[\s\S]*?\.slice\(0, 2000\)/);
assert.match(page, /content\.length > 2000/);
assert.match(page, /上报内容不能超过2000字/);
});
test('工作上报服务声明整理契约并在正式提交携带 requestId', async () => {
const service = await source('../src/services/work-report.ts');
assert.match(service, /export interface WorkReportDraft/);
assert.match(service, /suggestedType:\s*WorkReportType/);
assert.match(service, /export interface OrganizeResponse extends WorkReportDraft/);
assert.match(service, /missingQuestions:\s*string\[\]/);
assert.match(service, /privacyWarnings:\s*string\[\]/);
assert.match(service, /export const organizeWorkReport/);
assert.match(service, /\/api\/aihr\/work-report\/organize/);
assert.match(service, /requestId:\s*string/);
assert.match(service, /data:\s*params/);
});
test('幂等键与异步账号守卫是可执行行为,不只是源码字符串', async (context) => {
const { createServer } = await import('vite');
const root = new URL('../', import.meta.url).pathname;
const server = await createServer({
root,
configFile: false,
appType: 'custom',
logLevel: 'silent',
resolve: { alias: { '@': new URL('../src', import.meta.url).pathname } },
server: { middlewareMode: true }
});
context.after(() => server.close());
const state = await server.ssrLoadModule('/src/services/work-report.ts');
let created = 0;
const create = () => `request-${++created}`;
const first = state.stableWorkReportRequestId('', create);
const retry = state.stableWorkReportRequestId(first, create);
assert.equal(first, retry);
assert.equal(created, 1);
assert.equal(state.isDefinitiveWorkReportRejection({ statusCode: 400 }), true);
assert.equal(state.isDefinitiveWorkReportRejection({ bizCode: 409 }), false);
assert.equal(state.isDefinitiveWorkReportRejection({ statusCode: 408 }), false);
assert.equal(state.isDefinitiveWorkReportRejection({ statusCode: 429 }), false);
assert.equal(state.isDefinitiveWorkReportRejection({ statusCode: 500 }), false);
assert.equal(state.isDefinitiveWorkReportRejection({}), false);
assert.equal(state.isCurrentWorkReportRequest(3, 3, 'account-a', 'account-a'), true);
assert.equal(state.isCurrentWorkReportRequest(2, 3, 'account-a', 'account-a'), false);
assert.equal(state.isCurrentWorkReportRequest(3, 3, 'account-a', 'account-b'), false);
});