feat(mobile): add voice-to-text input to direct feedback page
- 反馈内容 label 行新增语音 chip:toggle 录音(启动/录音计时/结束中/转写 四态),转写结果以换行追加,JS 侧截断至 5000 字上限并提示 - 复用既有 services/speech.ts + /api/ai/asr 链路;表单页文字为主路径, 录音不可用或权限失败仅提示改文字输入,不做选文件降级 - voiceStopping 状态修复 stop→onCapture 竞态(重复点按不再丢录音) - onHide/onUnload 统一 teardownVoice:generation++、abort 转写、 cancel 录音、停计时,切页无迟来写回 - 渠道按钮加图标(vip/wallet/staff/eye/gear),匿名行与空历史态补图标 - 测试改用函数体提取断言,覆盖清理/竞态/截断/模板契约;150/150 单测、vue-tsc、H5 构建通过,mock API 浏览器验证通过 已经过 Codex 审查
This commit is contained in:
@@ -51,9 +51,9 @@
|
||||
- 管理端业务路由集中在 `frontend/src/router/index.ts`,覆盖首页、AI 面试、训练运营、问题榜、案例、知识空间、SOP、资料处理、大喇叭、五渠道直通反馈、系统配置、组织权限和成长激励;侧栏过滤在 `frontend/src/layout/components/Sidebar/index.vue`,不要恢复若依默认全量菜单。
|
||||
- 用户侧只改 `mobile-uni/src/pages/*`;员工、候选人、主管首页分别为 `/pages/user/today/index`、`/pages/candidate/index/index`、`/pages/supervisor/index/index`,旧 `/h5/user|candidate|supervisor|employee|admin` 只做兼容重定向,底栏固定「今日/练/问/我」。“问”统一调用 `/api/aihr/agent/messages|messages/media`,服务端规划意图并按固定策略选择既有工具,客户端不得提交 `toolCode`;旧知识查询仅作底层/兼容。登录端短期会话为 30 分钟/最新 6 轮,原文件/视频经 `/api/knowledge/resources/{attachmentId}/content` 重新鉴权。总结卡只接受当前答案中的最多 5 个 `DOCUMENT` 引用 `fragmentIds`,服务端重新鉴权后仅基于这些片段生成固定「问题/建议/标准」结构;不得二次检索,失败不得拿原文冒充结构化步骤。全网外发必须先同意,显式记忆先出确认卡,再通过不透明 `draftId` 按 `PRIVATE/COMPANY` 确认;`COMPANY` 仅为 `PENDING`,不得冒充已派单或送达。`aihr_agent_run` 不保存问题、答案或附件,外部知识 API 保持无状态。
|
||||
- 大喇叭详情追问只让客户端传 `broadcastMessageId`,消息正文、附件文本、租户和身份均由服务端重新校验并加载;撤回、跨租户或非在职员工不得继续使用消息上下文。单条消息最多绑定一个属于当前租户、当前上传人且状态为 `READY` 的公司文件;员工端只返回摘要与受控下载入口,不返回提取全文或原始 OSS 地址。消息/知识回答不得把通用建议包装成当前员工的真实待办,强行动入口只能来自服务端已授权的真实任务数据。
|
||||
- 直通车固定为总裁、财务、人力、审计、运营五个渠道;业务匿名只隐藏处理端的提交人展示,服务端仍保留内部提交账号供本人查询、幂等和审计。`direct_president/direct_finance/direct_hr/direct_audit/direct_operations` 只处理各自渠道,`superadmin` 可处理全部;当前产品边界只允许一次正式回复,不扩展为多轮聊天、工单、转派或 SLA。
|
||||
- 直通车固定为总裁、财务、人力、审计、运营五个渠道;业务匿名只隐藏处理端的提交人展示,服务端仍保留内部提交账号供本人查询、幂等和审计。`direct_president/direct_finance/direct_hr/direct_audit/direct_operations` 只处理各自渠道,`superadmin` 可处理全部;当前产品边界只允许一次正式回复,不扩展为多轮聊天、工单、转派或 SLA。员工提交页支持语音转文字输入(复用 `/api/ai/asr` 与 `services/speech.ts`;文字是主路径,录音不可用或权限失败仅提示改文字,不做选文件降级)。
|
||||
- 术语和数据边界:工作助手承载日常事实与确认式记忆;现有 `/pages/user/report/index`、`/api/aihr/work-report/**` 和 `aihr_work_report` 只承载 CASE/VIDEO/SOP/KNOWLEDGE 四类投稿审核,产品名为“成果投稿”;“今日工作成果”按员工 + 项目 + 自然日从已确认记录确定性聚合,员工页为 `/pages/user/work-results/index`,主管页为 `/pages/supervisor/work-results/index`,默认当日但允许查看历史日期且不得选择未来日期。不要把三条链路合表或自动互写。多项目交互只让用户按项目名称选择,内部项目编码必须由服务端按当前身份复核,不让用户输入编码。
|
||||
- 不要把三端概括成全模态:员工对练/每日题和问师傅支持文字/语音,问师傅图片/视频只用于本次分析,案例传音频;候选面试支持文字/语音,资料可传文档/图片但图片不参与评分;主管写文字复盘/指派并回听录音。员工对练图像/视频情境、服务录像回放和方言正式验收仍未完成;语音继续复用 `services/speech.ts` 与 `/api/ai/asr`。
|
||||
- 不要把三端概括成全模态:员工对练/每日题、问师傅和直通车反馈支持文字/语音,问师傅图片/视频只用于本次分析,案例传音频;候选面试支持文字/语音,资料可传文档/图片但图片不参与评分;主管写文字复盘/指派并回听录音。员工对练图像/视频情境、服务录像回放和方言正式验收仍未完成;语音继续复用 `services/speech.ts` 与 `/api/ai/asr`。
|
||||
- 问题榜奖励只记积分/学习学分,不得擅自增加现金、提现、预算或税务语义;选最佳答案和考试提交必须保持请求幂等。
|
||||
- 全网 AI 与企业问师傅必须分入口、分来源和分免责声明;提供方仅允许公网 HTTPS,修改地址/密钥后必须连接测试成功才能启用,未配置时明确不可用且不生成假答案。
|
||||
- 多租户知识平台的有效范围是“租户 + 调用应用绑定 + 主体授权”的交集;不信任客户端传入身份,API_TOKEN 不得进入浏览器或小程序包。生产排序规则以 `aihr_knowledge_info.tenant_id` 为基准,新表最后执行 `aihr_20260718_release_collation_compat_mysql8.sql`。
|
||||
|
||||
@@ -16,11 +16,11 @@
|
||||
| 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}`、`POST /records/{caseId}/review`、`GET /records/{caseId}/media` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录只保存 `mediaOssId` 供受保护媒体接口读取,不向客户端回传原始 `mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化,并从背景外的真实摘要项提取学习点;APP 用户必须先以认证手机号精确校验在职 `person_phone`,再由可信主体解析 `aihr_org_snapshot` 项目范围;案例 SQL 不得把 `person_phone` 与 `ext_party_id` 作为替代查询键。上传、整理、入库、列表和详情均按该项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情通过受保护媒体接口回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 |
|
||||
| 案例媒体安全 | `GET /api/knowledge/case/records/{caseId}/media` | 案例详情只返回 `mediaOssId`,不返回原始 `sys_oss.url`;媒体下载会重复执行登录、后台角色或 APP 项目范围校验,再由服务端流式读取 OSS。管理端与 `mobile-uni` 通过鉴权 blob/temp 文件播放,关闭详情页时释放本地对象 URL |
|
||||
| 问·数字师傅工作 Agent `/pages/user/sop/index` | `POST /api/aihr/agent/messages`、`POST /api/aihr/agent/messages/media`、`POST /api/aihr/agent/actions/{draftId}/confirm`、`POST /api/aihr/agent/actions/{draftId}/dismiss` | 移动端文字、ASR 转写和图片/视频统一进入 Agent。服务端根据消息规划 `KNOWLEDGE_QA/RESOURCE_DELIVERY/LIVE_MY_WORK/LIVE_TEAM_WORK/PRACTICE_COACHING/CAPTURE_FACT/MEDIA_UNDERSTANDING/WEB_RESEARCH/CLARIFY/SOCIAL`,再由固定策略校验当前登录身份和工具权限;客户端不提交 `toolCode`,模型也不能自由指定身份、SQL、URL 或任意工具。知识、训练概况、当前待办、确认式记忆、媒体分析和全网查询均复用现有领域服务;普通图片问答只做本次视觉分析,不要求先具备知识空间,用户明确问现场制度/流程时才校验授权空间并补充 SOP。全网查询先返回 `NEEDS_INPUT`,只有用户明确同意后才调用外部服务。统一响应使用结构化 `status/sourceSummary/citations/resources/actionDraft/clarification`;按钮不从答案文本推断。写入动作只接受 30 分钟有效的不透明 `draftId`,最终确认继续复用既有版本、幂等与可见性规则。每次运行只审计租户、主体、意图、工具、状态、来源类型、耗时和错误码,不保存原始问题、答案或附件。 |
|
||||
| SOP 知识底层服务 `/knowledge/sop` 与旧客户端兼容 | `POST /api/knowledge/query`、`POST /api/knowledge/query-media`、`GET /api/knowledge/resources/{attachmentId}/content`、`POST /api/knowledge/answer-feedback`、`GET /api/knowledge/position-sop`、`GET /api/aihr/mobile/onboard/tasks`、`POST /api/aihr/mobile/onboard/tasks/{id}/complete`、`GET /api/aihr/mobile/qualification`、`POST /api/knowledge/doc/upload` | Agent 按规划结果调用该层;管理端和旧客户端接口继续兼容。文字/现场附件查询走统一知识空间授权和 MySQL Fulltext + Qdrant 混合召回;内部登录端可选传 `conversationId/contextVersion`,服务端保留 30 分钟不活跃过期、最近 6 轮脱敏截断上下文,用现有 chat 模型或保守规则输出 `QA/FILE/VIDEO/DATA_TOOL` 和 `rewrittenQuery`,旧版本冲突返回 `409`。员工从银城大喇叭详情追问时只传 `broadcastMessageId`:服务端每轮按当前 APP 在职员工、当前租户和 `PUBLISHED` 状态重新取消息,响应仅回显 `{messageId,title,publishedAt}`,消息正文不写入客户端存储、会话 JSON 或查询审计;同一会话的消息来源不可改绑,撤回、跨租户或非在职员工均拒绝。该上下文只说明公司消息,不可据此捏造个人任务;上下文模式拒绝现场媒体和数据工具,外部无状态 API 不接受该参数。岗位 SOP 的项目范围复用当前 APP 已验证主体的 `projectCodes`,不得以管理端组织快照的模糊/别名查询作为后备。原文件/视频仅从当前命中且仍有授权的附件返回,内容接口每次重新计算租户、应用和主体权限;无资源时明确返回空列表,不生成假链接。搜索继续返回 `reviewId/promptVersion`,员工反馈保存评审批次;员工学习页按当前 APP 身份读取正式岗前/入职任务,资格证据无正式数据时明确返回 `NOT_CONFIGURED`。详细边界见 [专项设计](20260718/问师傅多轮会话与原始资料交付设计.md)。 |
|
||||
| SOP 知识底层服务 `/knowledge/sop` 与旧客户端兼容 | `POST /api/knowledge/query`、`POST /api/knowledge/query-media`、`GET /api/knowledge/resources/{attachmentId}/content`、`POST /api/knowledge/answer-feedback`、`GET /api/knowledge/position-sop`、`GET /api/aihr/mobile/onboard/tasks`、`POST /api/aihr/mobile/onboard/tasks/{id}/complete`、`GET /api/aihr/mobile/qualification`、`POST /api/knowledge/doc/upload` | Agent 按规划结果调用该层;管理端和旧客户端接口继续兼容。文字/现场附件查询走统一知识空间授权和 MySQL Fulltext + Qdrant 混合召回;内部登录端可选传 `conversationId/contextVersion`,服务端保留 30 分钟不活跃过期、最新 6 轮脱敏截断上下文,用现有 chat 模型或保守规则输出 `QA/FILE/VIDEO/DATA_TOOL` 和 `rewrittenQuery`,旧版本冲突返回 `409`。员工从银城大喇叭详情追问时只传 `broadcastMessageId`:服务端每轮按当前 APP 在职员工、当前租户和 `PUBLISHED` 状态重新取消息,响应仅回显 `{messageId,title,publishedAt}`,消息正文不写入客户端存储、会话 JSON 或查询审计;同一会话的消息来源不可改绑,撤回、跨租户或非在职员工均拒绝。该上下文只说明公司消息,不可据此捏造个人任务;上下文模式拒绝现场媒体和数据工具,外部无状态 API 不接受该参数。岗位 SOP 的项目范围复用当前 APP 已验证主体的 `projectCodes`,不得以管理端组织快照的模糊/别名查询作为后备。原文件/视频仅从当前命中且仍有授权的附件返回,内容接口每次重新计算租户、应用和主体权限;无资源时明确返回空列表,不生成假链接。搜索继续返回 `reviewId/promptVersion`,员工反馈保存评审批次;员工学习页按当前 APP 身份读取正式岗前/入职任务,资格证据无正式数据时明确返回 `NOT_CONFIGURED`。详细边界见 [专项设计](20260718/问师傅多轮会话与原始资料交付设计.md)。 |
|
||||
| 工作助手确认式统一采集 `/pages/user/sop/index`、`/pages/user/assistant/memories` | Agent 主入口返回 `actionDraft`,确认/忽略走 `/api/aihr/agent/actions/{draftId}/confirm|dismiss`;旧接口 `POST /api/knowledge/query`、`POST /api/knowledge/query-media`、`GET /api/aihr/personal-assistant/memory-candidates?status=DRAFT`、`POST /api/aihr/personal-assistant/memory-candidates/{id}/confirm|dismiss` 继续兼容;确认记录读写使用 `GET /api/aihr/personal-assistant/assistant-captures?limit=50`、`POST /assistant-captures/{id}/status`、`GET /assistant-captures/{id}/status-history`、`GET /assistant-captures/{id}/source`,旧项目记录继续使用 `/api/aihr/service-memories` | 文字、ASR 转写和现场媒体复用同一候选检测。项目工作必须从 `/api/aihr/mobile/me` 返回的项目名称列表选择当前项目,内部 `projectCode` 仍由服务端按当前登录主体授权收窄;个人笔记可以不绑定项目。显式“记一下”生成 `ASSISTANT_CAPTURE/DRAFT`,系统建议 `ATTENDANCE/INSPECTION/SERVICE_LEAD/RESIDENT_PROFILE/CASE/PERSONAL_NOTE/FOLLOW_UP/PROJECT_NOTE`。确认必须带 `expectedVersion + idempotencyKey + saveScope(PRIVATE|COMPANY)`;PRIVATE 仅本人可见,COMPANY 写入 PENDING 待流转。状态更新只允许 `RECORDED/PENDING/IN_PROGRESS/COMPLETED/VOID`,幂等键不得跨记录或目标状态复用。来源文件重新鉴权交付;正式外部接收端未配置前不得显示已送达。 |
|
||||
| 今日工作成果 `/pages/user/work-results/index`、`/pages/supervisor/work-results/index` | 员工 `POST /api/aihr/work-results/mine/generate?projectCode=...&workDate=YYYY-MM-DD`;主管 `GET /api/aihr/work-results/project?projectCode=...&workDate=YYYY-MM-DD` | 按员工 + 项目 + Asia/Shanghai 自然日确定性聚合已确认记录;两页默认当天并可按日期查看历史成果,客户端禁止选择未来日期。内容未变化时重复生成不增加版本,记录或状态变化后版本递增。主管只可查看本人在目标项目具备主管身份的团队结果;APP 登录身份同时兼容组织快照中的外部人员 ID 与手机号,普通员工或跨项目请求返回 403。外部接口未接通时只显示本地状态和 PENDING,不伪造派单或考勤同步成功 |
|
||||
| 银城大喇叭 | 员工 `GET /api/aihr/broadcast/unread-count`、`GET /api/aihr/broadcast/messages?pageNum=&pageSize=`、`GET /api/aihr/broadcast/messages/{id}`、`POST /api/aihr/broadcast/messages/{id}/read`、`GET /api/aihr/broadcast/attachments/{id}/content`;员工详情可经统一 `POST /api/knowledge/query` 的 `broadcastMessageId` 发起文字追问;管理 `GET /api/aihr/broadcast/admin/messages`、`POST /api/aihr/broadcast/admin/attachments`、`GET /api/aihr/broadcast/admin/attachments/{id}`、`POST /api/aihr/broadcast/messages`、`POST /api/aihr/broadcast/messages/{id}/withdraw` | 发布支持全员或按部门/岗位/人员定向、必读、单个公司文件与异步提炼。文件支持 txt/md/PDF/Word/Excel/PPT,100MB 内;先受控写入 OSS,再异步解析并复用现有模型生成摘要,只有 `READY` 且未绑定、属于当前租户和上传人的文件才可随消息发布。员工详情只返回摘要和受控下载信息,不返回提取全文或原始 OSS 地址;下载和追问每次重新校验当前 APP 在职身份、租户和消息状态,追问上下文由服务端拼接消息正文与提取文本,客户端仍只传 `broadcastMessageId`。定向目标用于“与我有关”和必读提醒,不改变全租户公开频道的可见性。发布、阅读和撤回保持既有幂等与审计约束;管理接口只允许 `superadmin` 或 `hr_operator`。当前仍不包含消息修订、撤回后补推或短信/电话强触达。 |
|
||||
| 员工直通车 `/pages/user/direct/index`、管理端 `/content/direct` | 员工 `GET /api/aihr/direct/channels`、`POST /api/aihr/direct/feedback`、`GET /api/aihr/direct/mine`、`GET /api/aihr/direct/mine/{id}`;处理端 `GET /api/aihr/direct/admin/feedback`、`POST /api/aihr/direct/admin/feedback/{id}/reply` | 员工可选择总裁、财务、人力、审计、运营并点对点提交。总裁/审计默认匿名;匿名仅表示业务处理界面不显示提交人,系统仍保存内部账号和姓名快照供本人查询、幂等与审计。`direct_president/direct_finance/direct_hr/direct_audit/direct_operations` 仅处理各自频道,`superadmin` 可处理全部;列表、详情和回复均由服务端按角色收窄。一个反馈只允许一次正式回复,员工可在“我的反馈”查看状态和回复;当前不扩展为工单 SLA、转派或多轮聊天。 |
|
||||
| 员工直通车 `/pages/user/direct/index`、管理端 `/content/direct` | 员工 `GET /api/aihr/direct/channels`、`POST /api/aihr/direct/feedback`、`GET /api/aihr/direct/mine`、`GET /api/aihr/direct/mine/{id}`;处理端 `GET /api/aihr/direct/admin/feedback`、`POST /api/aihr/direct/admin/feedback/{id}/reply` | 员工可选择总裁、财务、人力、审计、运营并点对点提交;反馈内容支持语音转文字输入(复用 `/api/ai/asr`;文字为主路径,录音不可用或权限失败仅提示改文字输入)。总裁/审计默认匿名;匿名仅表示业务处理界面不显示提交人,系统仍保存内部账号和姓名快照供本人查询、幂等与审计。`direct_president/direct_finance/direct_hr/direct_audit/direct_operations` 仅处理各自频道,`superadmin` 可处理全部;列表、详情和回复均由服务端按角色收窄。一个反馈只允许一次正式回复,员工可在“我的反馈”查看状态和回复;当前不扩展为工单 SLA、转派或多轮聊天。 |
|
||||
| 成果投稿(当前页面名“工作上报”)`/pages/user/report/index` | `POST /api/aihr/work-report/organize`、`POST /attachment`、`POST /reports`、`GET /reports/mine`;主管/运营另有列表和审核接口 | 只承载 CASE/VIDEO/SOP/KNOWLEDGE 四类投稿。会话式页面、无状态整理、附件、幂等正式提交和历史状态已部署;审核通过不自动入知识库。当前整理服务不读取图片/视频内容,只把附件名称作为不可信元数据;不得与日常工作记录或“今日工作成果”混用 |
|
||||
| 资料处理 `/knowledge/processing` | `GET /api/knowledge/processing/overview`、`POST /api/knowledge/doc/upload-async`、`GET /api/knowledge/doc/upload-items`、`POST /api/knowledge/doc/upload-items/{id}/retry` | 已接入解析任务状态聚合;页面只保留“批量导入”,接口暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化;ZIP 在 worker 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度、失败可单文件重试;不提供浏览器目录选择或服务端目录导入入口 |
|
||||
| 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee/employee_project_assignment` 快照,分页参数使用 `limit`;员工手机号只落 `person_phone` 用于移动端身份映射,不在组织列表响应暴露;岗位识别 `position/job_title/post/job_name/role/title` 等字段。`dryRun` 必须显式传入 `true`(预检)或 `false`(写入),省略或传 `null` 直接拒绝,避免空请求意外写库;默认 `replaceExisting=true`,写入前必须先用 `{"dryRun":true}`;dry-run 不访问本地快照表、不执行 DDL/写库,返回 `phoneLinked/maskedPhone/suspectText/warnings` 且 `syncedCount=0`。非 dry-run 覆盖写入遇到员工被跳过、手机号不完整、脱敏手机号或疑似乱码时默认拒绝,只有确认 dry-run 结果后显式传 `allowPartialReplace=true` 才允许覆盖。相同员工可由多条有效项目分配展开为多个项目成员行,唯一约束为 `tenant_id + project_code + ext_party_id`;只拒绝同一项目内的重复成员关系。`replaceExisting=false` 不触发不完整快照覆盖闸门,但仍拒绝同项目重复关系。2026-07-15 线上已用 `/api/open/v1` 前缀完成配置、dry-run 和覆盖同步:开放平台返回公司 17、部门 963、员工 3417;生产 `aihr_org_snapshot` 为 3417 行,`phoneLinked=3392`、`maskedPhone=25`、`suspectText=0`,在职 2943、离职 474。该统计发生在多项目成员迁移前;发布迁移后必须重新 dry-run 并核对展开后的项目成员行数。当前剩余 25 人手机号不可用属于上游数据质量问题 |
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
>
|
||||
> 2026-07-25 总结卡修复 `f545983d` 已实现、完成自动化并推送 `origin/main`:前端提交当前答案的引用片段,后端重新鉴权且不再二次检索,固定输出「问题/建议/标准」。生产已应用 DeepSeek V4 模型名和部分证据 SOP Prompt 两项配置迁移,并以认证态 H5 验证查询与总结卡;本次未发布后端/H5 代码,因此只能确认生产配置修复生效,不能把证据绑定代码写成已部署。
|
||||
>
|
||||
> 2026-07-25 直通车员工提交页新增语音转文字输入与渠道图标:语音复用既有 `/api/ai/asr` 链路,录音或权限失败仅提示改文字输入(表单页文字为主路径,不做选文件降级);本地自动化(150/150)与 mock API 浏览器验证通过,已推送 `origin/main`,未发布生产、未做真机验收。
|
||||
>
|
||||
> 本文只维护当前结论、证据层级和剩余门槛。逐提交发布流水已从当前真相文档移除,需要追溯时使用 Git 历史;不得再用 2026-07-14/15 的“尚未发布”记录判断现网。
|
||||
|
||||
## 1. 当前结论
|
||||
|
||||
@@ -35,20 +35,54 @@
|
||||
:aria-checked="form.channelCode === channel.code"
|
||||
@click="chooseChannel(channel)"
|
||||
>
|
||||
<text>{{ channel.name }}</text>
|
||||
<view class="channel-icon">
|
||||
<uni-icons
|
||||
:type="channelIcon(channel.code)"
|
||||
size="19"
|
||||
:color="form.channelCode === channel.code ? '#e60012' : '#647083'"
|
||||
/>
|
||||
</view>
|
||||
<text class="channel-name">{{ channel.name }}</text>
|
||||
<text v-if="channel.defaultAnonymous" class="channel-hint">默认匿名</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="field-head">
|
||||
<label class="field-label" for="direct-content">反馈内容</label>
|
||||
<button
|
||||
class="voice-chip"
|
||||
:class="{ recording: voiceRecording, busy: voiceTranscribing || voiceStarting || voiceStopping }"
|
||||
:disabled="voiceTranscribing || voiceStarting || voiceStopping"
|
||||
:aria-pressed="voiceRecording"
|
||||
aria-label="语音输入"
|
||||
@click="toggleVoiceCapture"
|
||||
>
|
||||
<uni-icons
|
||||
:type="voiceRecording ? 'mic-filled' : 'mic'"
|
||||
size="15"
|
||||
:color="voiceRecording ? '#ffffff' : '#e60012'"
|
||||
/>
|
||||
<text>{{ voiceChipLabel }}</text>
|
||||
</button>
|
||||
</view>
|
||||
<textarea
|
||||
id="direct-content"
|
||||
v-model="form.content"
|
||||
class="feedback-input"
|
||||
maxlength="5000"
|
||||
:maxlength="MAX_CONTENT_LENGTH"
|
||||
placeholder="请说明具体情况、影响和希望得到的处理。"
|
||||
aria-label="反馈内容"
|
||||
/>
|
||||
<view
|
||||
v-if="voiceBusy || voiceHint"
|
||||
class="voice-status"
|
||||
:class="{ recording: voiceRecording }"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<uni-icons v-if="voiceRecording" type="sound" size="13" color="#e60012" />
|
||||
<text>{{ voiceStatusText }}</text>
|
||||
</view>
|
||||
<view class="form-foot">
|
||||
<label class="anonymous-control">
|
||||
<switch
|
||||
@@ -57,17 +91,20 @@
|
||||
aria-label="匿名提交"
|
||||
@change="changeAnonymous"
|
||||
/>
|
||||
<view class="anonymous-icon">
|
||||
<uni-icons type="eye-slash" size="16" color="#925b00" />
|
||||
</view>
|
||||
<view>
|
||||
<text class="anonymous-title">匿名提交</text>
|
||||
<text class="section-note">处理端不显示提交人;系统内部仍保留审计记录。</text>
|
||||
</view>
|
||||
</label>
|
||||
<text class="counter">{{ form.content.length }}/5000</text>
|
||||
<text class="counter">{{ form.content.length }}/{{ MAX_CONTENT_LENGTH }}</text>
|
||||
</view>
|
||||
<text v-if="submitMessage" class="status-message" :class="{ error: submitFailed }" role="status" aria-live="polite">
|
||||
{{ submitMessage }}
|
||||
</text>
|
||||
<button class="primary-button submit-button" :disabled="submitting || !canSubmit" @click="submit">
|
||||
<button class="primary-button submit-button" :disabled="submitting || voiceBusy || !canSubmit" @click="submit">
|
||||
{{ submitting ? '提交中…' : '确认提交' }}
|
||||
</button>
|
||||
</view>
|
||||
@@ -85,6 +122,7 @@
|
||||
<button class="secondary-button" @click="loadMine(true)">重新加载</button>
|
||||
</view>
|
||||
<view v-else-if="!items.length" class="panel employee-card empty-card">
|
||||
<uni-icons type="chatboxes" size="36" color="#c3c9d4" />
|
||||
<text class="section-title">还没有反馈</text>
|
||||
<text class="muted">提交后会在这里显示处理状态。</text>
|
||||
</view>
|
||||
@@ -128,6 +166,8 @@ import {
|
||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { createPageRequestScope } from '@/services/request-scope';
|
||||
import { startSpeechCapture, type SpeechCapture, type SpeechCaptureSession } from '@/services/speech-capture';
|
||||
import { transcribeSpeechCapture } from '@/services/speech';
|
||||
|
||||
const path = '/pages/user/direct/index';
|
||||
const pageSize = 20;
|
||||
@@ -150,8 +190,174 @@ const form = reactive({
|
||||
content: ''
|
||||
});
|
||||
|
||||
const MAX_CONTENT_LENGTH = 5000;
|
||||
const voiceRecording = ref(false);
|
||||
const voiceStarting = ref(false);
|
||||
const voiceStopping = ref(false);
|
||||
const voiceTranscribing = ref(false);
|
||||
const voiceSeconds = ref(0);
|
||||
const voiceHint = ref('');
|
||||
let voiceCapture: SpeechCaptureSession | null = null;
|
||||
let voiceGeneration = 0;
|
||||
let abortVoiceTranscription: (() => void) | null = null;
|
||||
let voiceTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let voiceStartedAt = 0;
|
||||
|
||||
const hasMore = computed(() => items.value.length < total.value);
|
||||
const canSubmit = computed(() => Boolean(form.channelCode) && Boolean(form.content.trim()));
|
||||
const voiceBusy = computed(() => voiceStarting.value || voiceRecording.value || voiceStopping.value || voiceTranscribing.value);
|
||||
const voiceChipLabel = computed(() => {
|
||||
if (voiceStopping.value) return '结束中…';
|
||||
if (voiceRecording.value) return `停止 ${formatVoiceSeconds(voiceSeconds.value)}`;
|
||||
if (voiceTranscribing.value) return '转写中…';
|
||||
if (voiceStarting.value) return '启动中…';
|
||||
return '语音输入';
|
||||
});
|
||||
const voiceStatusText = computed(() => {
|
||||
if (voiceStopping.value) return '录音结束,正在转写';
|
||||
if (voiceRecording.value) return '正在录音,再次点击结束';
|
||||
if (voiceTranscribing.value) return '语音转写中…';
|
||||
if (voiceStarting.value) return '正在启动麦克风…';
|
||||
return voiceHint.value;
|
||||
});
|
||||
|
||||
const channelIcons: Record<DirectChannelCode, string> = {
|
||||
PRESIDENT: 'vip',
|
||||
FINANCE: 'wallet',
|
||||
HR: 'staff',
|
||||
AUDIT: 'eye',
|
||||
OPERATIONS: 'gear'
|
||||
};
|
||||
// 后端可能新增渠道 code,映射缺失时兜底 paperplane,避免图标渲染为空。
|
||||
const channelIcon = (code: DirectChannelCode) => channelIcons[code] ?? 'paperplane';
|
||||
|
||||
const formatVoiceSeconds = (total: number) => {
|
||||
const minutes = Math.floor(total / 60);
|
||||
const seconds = total % 60;
|
||||
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const stopVoiceTimer = () => {
|
||||
if (voiceTimer) clearInterval(voiceTimer);
|
||||
voiceTimer = null;
|
||||
};
|
||||
|
||||
const startVoiceTimer = () => {
|
||||
stopVoiceTimer();
|
||||
voiceStartedAt = Date.now();
|
||||
voiceSeconds.value = 0;
|
||||
voiceTimer = setInterval(() => {
|
||||
voiceSeconds.value = Math.floor((Date.now() - voiceStartedAt) / 1000);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const appendTranscript = (text: string) => {
|
||||
const current = form.content.trim() ? form.content : '';
|
||||
const joined = current ? `${current}\n${text}` : text;
|
||||
if (joined.length > MAX_CONTENT_LENGTH) {
|
||||
form.content = joined.slice(0, MAX_CONTENT_LENGTH);
|
||||
voiceHint.value = `内容已达 ${MAX_CONTENT_LENGTH} 字上限,超出部分已截断`;
|
||||
return;
|
||||
}
|
||||
form.content = joined;
|
||||
voiceHint.value = '语音已转成文字,请确认后提交';
|
||||
};
|
||||
|
||||
const handleVoiceCapture = async (capture: SpeechCapture) => {
|
||||
const generation = ++voiceGeneration;
|
||||
voiceTranscribing.value = true;
|
||||
try {
|
||||
const asr = await transcribeSpeechCapture(capture, 'direct-feedback.webm', (abort) => {
|
||||
if (generation === voiceGeneration) abortVoiceTranscription = abort;
|
||||
});
|
||||
if (generation !== voiceGeneration) return;
|
||||
const text = (asr.text || '').trim();
|
||||
if (!text) {
|
||||
voiceHint.value = '语音没有识别到文字,请再说一遍';
|
||||
return;
|
||||
}
|
||||
appendTranscript(text);
|
||||
} catch (error) {
|
||||
if (generation !== voiceGeneration) return;
|
||||
voiceHint.value = error instanceof Error && error.message ? error.message : '语音转写失败,请改用文字输入';
|
||||
} finally {
|
||||
if (generation === voiceGeneration) {
|
||||
abortVoiceTranscription = null;
|
||||
voiceTranscribing.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startVoiceCapture = async () => {
|
||||
if (voiceStarting.value || voiceRecording.value || voiceTranscribing.value) return;
|
||||
const generation = ++voiceGeneration;
|
||||
voiceStarting.value = true;
|
||||
voiceHint.value = '';
|
||||
try {
|
||||
const session = await startSpeechCapture({
|
||||
onCapture: (capture) => {
|
||||
if (generation !== voiceGeneration) return;
|
||||
voiceCapture = null;
|
||||
voiceRecording.value = false;
|
||||
voiceStopping.value = false;
|
||||
stopVoiceTimer();
|
||||
void handleVoiceCapture(capture);
|
||||
},
|
||||
onError: () => {
|
||||
if (generation !== voiceGeneration) return;
|
||||
voiceCapture = null;
|
||||
voiceRecording.value = false;
|
||||
voiceStopping.value = false;
|
||||
stopVoiceTimer();
|
||||
voiceHint.value = '麦克风不可用,请检查权限后直接输入文字';
|
||||
}
|
||||
});
|
||||
if (!session || generation !== voiceGeneration) {
|
||||
session?.cancel();
|
||||
if (generation === voiceGeneration) voiceHint.value = '麦克风不可用,请检查权限后直接输入文字';
|
||||
return;
|
||||
}
|
||||
voiceCapture = session;
|
||||
voiceRecording.value = true;
|
||||
startVoiceTimer();
|
||||
} catch {
|
||||
if (generation === voiceGeneration) voiceHint.value = '麦克风不可用,请检查权限后直接输入文字';
|
||||
} finally {
|
||||
if (generation === voiceGeneration) voiceStarting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const stopVoiceCapture = () => {
|
||||
if (!voiceRecording.value || voiceStopping.value || !voiceCapture) return;
|
||||
voiceStopping.value = true;
|
||||
stopVoiceTimer();
|
||||
voiceCapture.stop();
|
||||
};
|
||||
|
||||
const toggleVoiceCapture = () => {
|
||||
if (voiceStarting.value || voiceStopping.value || voiceTranscribing.value) return;
|
||||
if (voiceRecording.value) {
|
||||
stopVoiceCapture();
|
||||
return;
|
||||
}
|
||||
void startVoiceCapture();
|
||||
};
|
||||
|
||||
const teardownVoice = () => {
|
||||
voiceGeneration += 1;
|
||||
abortVoiceTranscription?.();
|
||||
abortVoiceTranscription = null;
|
||||
voiceTranscribing.value = false;
|
||||
voiceStarting.value = false;
|
||||
voiceStopping.value = false;
|
||||
voiceCapture?.cancel();
|
||||
voiceCapture = null;
|
||||
voiceRecording.value = false;
|
||||
stopVoiceTimer();
|
||||
voiceSeconds.value = 0;
|
||||
voiceHint.value = '';
|
||||
};
|
||||
|
||||
const createRequestId = () =>
|
||||
globalThis.crypto?.randomUUID?.() ?? `direct-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
|
||||
|
||||
@@ -205,7 +411,7 @@ const loadMine = async (reset: boolean) => {
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit.value || submitting.value || !form.channelCode) return;
|
||||
if (!canSubmit.value || submitting.value || voiceBusy.value || !form.channelCode) return;
|
||||
submitting.value = true;
|
||||
submitFailed.value = false;
|
||||
submitMessage.value = '';
|
||||
@@ -219,6 +425,7 @@ const submit = async () => {
|
||||
});
|
||||
form.requestId = '';
|
||||
form.content = '';
|
||||
voiceHint.value = '';
|
||||
submitMessage.value = '反馈已提交,可在“我的反馈”查看处理结果。';
|
||||
await loadMine(true);
|
||||
} catch (error) {
|
||||
@@ -240,8 +447,14 @@ onShow(async () => {
|
||||
loadFailed.value = true;
|
||||
}
|
||||
});
|
||||
onHide(() => requests.invalidate());
|
||||
onUnload(() => requests.invalidate());
|
||||
onHide(() => {
|
||||
requests.invalidate();
|
||||
teardownVoice();
|
||||
});
|
||||
onUnload(() => {
|
||||
requests.invalidate();
|
||||
teardownVoice();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -249,8 +462,18 @@ onUnload(() => requests.invalidate());
|
||||
.login-panel,.submit-card,.loading-card,.empty-card{display:grid;gap:12px;margin-top:18px;padding:18px}
|
||||
.section-head,.history-head,.feedback-head,.form-foot,.anonymous-control{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
||||
.section-title{display:block;color:var(--ink);font-size:18px;font-weight:900}.section-note,.muted{display:block;margin-top:4px;color:var(--muted);font-size:12px;line-height:1.6}
|
||||
.channel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.channel-button{display:grid;min-height:60px;align-content:center;gap:3px;padding:10px;border:1px solid #e6e8ec;border-radius:14px;background:#fff;color:var(--ink);font-size:14px;font-weight:900}.channel-button.active{border-color:var(--brand);background:#fff5f6;color:var(--brand)}.channel-button::after,.refresh-button::after{border:0}.channel-hint{font-size:11px;font-weight:700;opacity:.72}
|
||||
.field-label{margin-top:4px;color:var(--ink);font-size:14px;font-weight:900}.feedback-input{box-sizing:border-box;width:100%;height:150px;padding:14px;border:1px solid #dfe2e7;border-radius:14px;background:#fafafa;color:var(--ink);font-size:15px;line-height:1.6}
|
||||
.form-foot{align-items:flex-start}.anonymous-control{justify-content:flex-start;flex:1}.anonymous-title{display:block;font-size:14px;font-weight:900}.counter{color:var(--muted);font-size:12px}.submit-button{margin-top:2px}.status-message{padding:10px 12px;border-radius:10px;background:#edf8f2;color:#147a45;font-size:13px}.status-message.error{background:#fff0f1;color:#a9000d}
|
||||
.channel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.channel-button{display:grid;min-height:60px;align-content:center;justify-items:center;gap:3px;padding:10px;border:1px solid #e6e8ec;border-radius:14px;background:#fff;color:var(--ink);font-size:14px;font-weight:900;text-align:center;transition:border-color .2s ease,box-shadow .2s ease,transform .1s ease}.channel-button.active{border-color:var(--brand);background:#fff5f6;color:var(--brand);box-shadow:0 6px 16px rgba(230,0,18,.10)}.channel-button:active{transform:scale(.98)}.channel-button::after,.refresh-button::after{border:0}.channel-hint{font-size:11px;font-weight:700;opacity:.72}
|
||||
.channel-icon{display:grid;width:34px;height:34px;place-items:center;border-radius:12px;background:#f4f5f7;transition:background .2s ease}
|
||||
.channel-button.active .channel-icon{background:var(--employee-primary-soft)}
|
||||
.channel-name{line-height:1.3}
|
||||
.field-head{display:flex;margin-top:4px;align-items:center;justify-content:space-between;gap:12px}.field-label{color:var(--ink);font-size:14px;font-weight:900}.feedback-input{box-sizing:border-box;width:100%;height:150px;padding:14px;border:1px solid #dfe2e7;border-radius:14px;background:#fafafa;color:var(--ink);font-size:15px;line-height:1.6;transition:border-color .2s ease,background .2s ease}.feedback-input:focus{border-color:var(--brand);background:#fff}
|
||||
.voice-chip{display:inline-flex;flex-shrink:0;align-items:center;gap:5px;margin:0;padding:6px 12px;border:1px solid var(--employee-line);border-radius:999px;background:#fff;color:var(--brand);font-size:12px;font-weight:800;line-height:1.2;transition:background .2s ease,color .2s ease,opacity .2s ease}
|
||||
.voice-chip::after{border:0}
|
||||
.voice-chip:active{background:var(--employee-primary-soft)}
|
||||
.voice-chip.recording{border-color:var(--brand);background:var(--brand);color:#fff}
|
||||
.voice-chip.busy{opacity:.6}
|
||||
.voice-status{display:flex;margin-top:8px;align-items:center;gap:6px;padding:8px 12px;border-radius:10px;background:#f7f8fa;color:var(--muted);font-size:12px;line-height:1.5}
|
||||
.voice-status.recording{background:var(--employee-primary-soft);color:var(--brand)}
|
||||
.form-foot{align-items:flex-start}.anonymous-control{justify-content:flex-start;flex:1}.anonymous-icon{display:grid;flex-shrink:0;width:32px;height:32px;place-items:center;border-radius:10px;background:#fff7e8}.anonymous-title{display:block;font-size:14px;font-weight:900}.counter{color:var(--muted);font-size:12px}.submit-button{margin-top:2px}.status-message{padding:10px 12px;border-radius:10px;background:#edf8f2;color:#147a45;font-size:13px}.status-message.error{background:#fff0f1;color:#a9000d}
|
||||
.history-head{margin-top:28px}.refresh-button{padding:7px 12px;border-radius:999px;background:#fff0f1;color:var(--brand);font-size:13px;font-weight:800}.feedback-list{display:grid;gap:12px;margin-top:12px}.feedback-card{display:grid;gap:11px;padding:16px}.feedback-tags{display:flex;flex-wrap:wrap;gap:6px}.channel-tag,.anonymous-tag,.status-tag{padding:4px 8px;border-radius:999px;font-size:11px;font-weight:900}.channel-tag{background:#f2f3f5;color:#343b46}.anonymous-tag{background:#fff7e8;color:#925b00}.status-tag{background:#fff0f1;color:var(--brand)}.status-tag.replied{background:#edf8f2;color:#147a45}.feedback-content,.reply-content{white-space:pre-wrap;overflow-wrap:anywhere;color:var(--ink);font-size:15px;line-height:1.7}.reply-box{display:grid;gap:6px;padding:12px;border-left:3px solid var(--brand);border-radius:4px 10px 10px 4px;background:#faf6f6}.reply-label{color:var(--brand);font-size:12px;font-weight:900}.feedback-time{color:var(--muted);font-size:12px}.load-more{width:100%}
|
||||
</style>
|
||||
|
||||
@@ -643,3 +643,70 @@ test('主管学员模式只允许查询本人训练概况且登录后保留待
|
||||
assert.match(source, /const generation = \+\+knowledgeSearchGeneration/);
|
||||
assert.match(source, /if \(!loggedIn\.value\) \{[\s\S]*goLogin\(\);[\s\S]*return;[\s\S]*\}[\s\S]*uni\.removeStorageSync\(pendingToolKey\)/);
|
||||
});
|
||||
|
||||
test('直通车页支持语音输入并管控切页清理与字数上限', async () => {
|
||||
const source = await pageSource('../src/pages/user/direct/index.vue');
|
||||
const bodyOf = (pattern) => source.match(pattern)?.[1] || '';
|
||||
|
||||
// 语音链路复用既有服务,函数与关键契约存在。
|
||||
assert.match(source, /import \{[^}]*startSpeechCapture[^}]*\} from '@\/services\/speech-capture'/);
|
||||
assert.match(source, /transcribeSpeechCapture\(capture, 'direct-feedback\.webm'/);
|
||||
assert.match(source, /const toggleVoiceCapture = \(\) =>/);
|
||||
assert.match(source, /const appendTranscript = \(text: string\) =>/);
|
||||
assert.match(source, /:maxlength="MAX_CONTENT_LENGTH"/);
|
||||
|
||||
// 渠道图标映射与兜底。
|
||||
assert.match(source, /const channelIcon = \(code: DirectChannelCode\) => channelIcons\[code\] \?\? 'paperplane'/);
|
||||
assert.match(source, /PRESIDENT: 'vip'/);
|
||||
assert.match(source, /FINANCE: 'wallet'/);
|
||||
assert.match(source, /HR: 'staff'/);
|
||||
assert.match(source, /AUDIT: 'eye'/);
|
||||
assert.match(source, /OPERATIONS: 'gear'/);
|
||||
|
||||
// 转写结果以换行追加并在 JS 侧截断到字数上限(maxlength 管不住编程式赋值)。
|
||||
assert.match(source, /current \? `\$\{current\}\\n\$\{text\}` : text/);
|
||||
assert.match(source, /slice\(0, MAX_CONTENT_LENGTH\)/);
|
||||
|
||||
// stop→onCapture 异步窗口:stopping 状态禁用重复 stop,避免 App 端二次 stop 使录音丢失。
|
||||
const stopBody = bodyOf(/const stopVoiceCapture = \(\) => \{([\s\S]*?)\};\n/);
|
||||
assert.match(stopBody, /voiceStopping\.value = true/);
|
||||
const toggleBody = bodyOf(/const toggleVoiceCapture = \(\) => \{([\s\S]*?)\};\n/);
|
||||
assert.match(toggleBody, /voiceStopping\.value/);
|
||||
|
||||
// 切页清理:onHide/onUnload 各自函数体内必须直接调用 teardownVoice(不允许跨函数误匹配)。
|
||||
const onHideBody = bodyOf(/onHide\(\(\) => \{([\s\S]*?)\}\);/);
|
||||
const onUnloadBody = bodyOf(/onUnload\(\(\) => \{([\s\S]*?)\}\);/);
|
||||
assert.match(onHideBody, /requests\.invalidate\(\)/);
|
||||
assert.match(onHideBody, /teardownVoice\(\)/);
|
||||
assert.match(onUnloadBody, /requests\.invalidate\(\)/);
|
||||
assert.match(onUnloadBody, /teardownVoice\(\)/);
|
||||
|
||||
// teardown 必须同时处理:generation 递增、转写 abort、录音 cancel、状态复位、计时器停止。
|
||||
const teardownBody = bodyOf(/const teardownVoice = \(\) => \{([\s\S]*?)\};\n/);
|
||||
assert.match(teardownBody, /voiceGeneration \+= 1/);
|
||||
assert.match(teardownBody, /abortVoiceTranscription\?\.\(\)/);
|
||||
assert.match(teardownBody, /voiceCapture\?\.cancel\(\)/);
|
||||
assert.match(teardownBody, /voiceRecording\.value = false/);
|
||||
assert.match(teardownBody, /voiceStopping\.value = false/);
|
||||
assert.match(teardownBody, /stopVoiceTimer\(\)/);
|
||||
|
||||
// 计时器用 Date.now 差值,避免后台节流导致秒数漂移。
|
||||
assert.match(source, /Math\.floor\(\(Date\.now\(\) - voiceStartedAt\) \/ 1000\)/);
|
||||
|
||||
// 模板契约:语音 chip、状态条 aria、提交禁用、装饰图标。
|
||||
const chipBody = bodyOf(/<button\s+class="voice-chip"([\s\S]*?)>/);
|
||||
assert.match(chipBody, /:aria-pressed="voiceRecording"/);
|
||||
assert.match(chipBody, /aria-label="语音输入"/);
|
||||
assert.match(chipBody, /:disabled="voiceTranscribing \|\| voiceStarting \|\| voiceStopping"/);
|
||||
const statusBody = bodyOf(/<view\s+v-if="voiceBusy \|\| voiceHint"([\s\S]*?)>/);
|
||||
assert.match(statusBody, /class="voice-status"/);
|
||||
assert.match(statusBody, /aria-live="polite"/);
|
||||
assert.match(source, /:disabled="submitting \|\| voiceBusy \|\| !canSubmit"/);
|
||||
assert.match(source, /submitting\.value \|\| voiceBusy\.value/);
|
||||
assert.match(source, /type="chatboxes"/);
|
||||
assert.match(source, /type="eye-slash"/);
|
||||
|
||||
// 提交成功后清理语音提示,避免残留“语音已转成文字”。
|
||||
const submitBody = bodyOf(/const submit = async \(\) => \{([\s\S]*?)await loadMine\(true\)/);
|
||||
assert.match(submitBody, /voiceHint\.value = ''/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user