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