feat(mobile): align employee practice screens for release

This commit is contained in:
2026-07-15 11:23:08 +08:00
parent 3541aab3fe
commit eacf1a0aea
16 changed files with 307 additions and 191 deletions
+1 -1
View File
@@ -59,7 +59,7 @@
- MVP 阶段侧栏只保留“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置”。过滤逻辑在 `frontend/src/layout/components/Sidebar/index.vue`,不要在未明确要求时恢复若依默认全量菜单。
- 移动端三端首页:`mobile-uni/src/pages/*`,员工端 `/h5/#/pages/user/today/index`、候选人端 `/h5/#/pages/candidate/index/index`、主管端 `/h5/#/pages/supervisor/index/index`;`/h5/user`、`/h5/candidate`、`/h5/supervisor`、`/h5/employee`、`/h5/admin` 仅作为旧链接兼容重定向,移动端不要把主管端等同后台系统管理员。员工端首页是「师傅区→今日安排→我的地图→我的成长摘要」(视觉基准 `docs/feedback/2026070423/design/` + REVIEW.md 修正项,修正项优先于图面);问答卡两侧图片容器统一 `64×64`,横向内容垂直居中;员工端训练页是 `/h5/#/pages/user/practice/index`,首屏必须先给「场景模拟/开始练习/最近练过」,每日三题后置;点击「开始练习」进入训练对话区域,不能在旧 `mobile/src/App.vue` 上继续堆锚点。师傅四状态(idle/listening/thinking/speaking)只从 `sopRecording/sopLoading/sopSpeaking` 推导,**不要新造状态源**;员工端底部 tab 钉死「今日/练/问/我」(visibleTabs 强制,不跟随后端 home 旧文案),整页互斥切换,**不要把面板改回插入首页长页面的叠加渲染**;未实现入口进「建设中」整页(openBuildingPage),不留无反应按钮。查 SOP 页面调 `/api/knowledge/search`(category='sop' 全库),关闭后回到进入前 tab;「生成总结卡」按需调 `/api/knowledge/summary-card`,失败明确报错可重试,不兜假数据。
- 移动端首页接口:`GET /api/aihr/mobile/home/{role}`,当前 `@SaIgnore` 公开只读 seed,用于 H5 首屏 API 优先 + 本地 fallback;后续确定小程序登录后再接移动端 token,不复用管理后台登录态。
- 移动端手机号登录:验证码发送走 `GET /resource/sms/code?phonenumber=...`,登录走 `POST /auth/mobile/sms-login`;短信发送复用 `sms.blends.config1` 阿里云配置。手机号不存在时自动注册 `app_user`,备注为“移动端短信自动注册”。模板 ID 用 `AIHR_SMS_LOGIN_TEMPLATE_ID`,AccessKey/Secret/签名用 `ALIYUN_SMS_ACCESS_KEY_ID`、`ALIYUN_SMS_ACCESS_KEY_SECRET`、`ALIYUN_SMS_SIGN_NAME`,本地放 `.env.local`。dev 兜底:`aihr.sms.dev-fixed-code` 非空则验证码固定(dev 默认 `123456`)、不真发短信;prod profile 代码级强制失效,不要移除该闸门。
- 移动端手机号登录:验证码发送走 `GET /resource/sms/code?phonenumber=...`,登录走 `POST /auth/mobile/sms-login`;短信发送复用 `sms.blends.config1` 阿里云配置。手机号不存在时自动注册 `app_user`,备注为“移动端短信自动注册”。模板 ID 用 `AIHR_SMS_LOGIN_TEMPLATE_ID`,AccessKey/Secret/签名用 `ALIYUN_SMS_ACCESS_KEY_ID`、`ALIYUN_SMS_ACCESS_KEY_SECRET`、`ALIYUN_SMS_SIGN_NAME`,本地放 `.env.local`。`aihr.sms.dev-fixed-code` 非空则验证码固定(dev 默认 `123456`)、不真发短信;prod 默认关闭,试点期必须同时显式设置 `AIHR_SMS_DEV_FIXED_CODE` 与 `AIHR_SMS_PROD_FIXED_CODE_ENABLED=true`,停用时删除固定码并关闭生产开关。
- 移动端员工训练闭环:员工端首页“开始训练”复用 `POST /api/train/practice/start`、`/turn`、`/finish`,请求必须带移动端登录返回的 `Authorization` 与 `clientid`;`mode=mobile` 完成后写入 `aihr_practice_session`,并更新主管端首页完训率、“待复盘对练”、员工训练历史和能力画像。主管端复盘详情查 `GET /api/aihr/mobile/practice/reviews/{id}`,标记复盘用 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`。
- 正式试点 CSV 走 `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD`,起止日必填;训练、校准和 SOP 评审必须按同一窗口、唯一在职组织身份统计,完训定义为每人至少 10 次。CSV 应保留原始校准命中/SOP 可用计数,严格预检用 `AIHR_PILOT_START_DATE`、`AIHR_PILOT_END_DATE` 与 `AIHR_PILOT_STRICT=true ./scripts/demo-check.sh`;不允许用历史 seed、开发身份或四舍五入比率伪造正式试点通过。
- 移动端候选人闭环:候选人端“开始面试/面试练习”复用 `POST /api/recruit/interview/start`、`/answer`、`/finish`;“补充资料”走认证接口 `POST /api/aihr/mobile/candidate/materials`(multipart `file` + `candidateId/candidateName/materialType`)和 `GET /api/aihr/mobile/candidate/materials`,文件先写 `sys_oss`/MinIO,再写 `aihr_candidate_material` 状态 `待审核`。HR 审核复用管理端 `/recruit/interview` 页,接口为 `GET /api/aihr/hr/candidate/materials` 和 `POST /api/aihr/hr/candidate/materials/{id}/review`,状态只用 `待审核/已通过/已驳回`。上传接口不要放进 class-level `@SaIgnore` 的 `AihrMobileController`。
+4
View File
@@ -18,6 +18,7 @@
- 正式试点:管理端可按起止日期导出正式试点 CSV,严格门禁只统计唯一映射到在职组织的训练、校准和 SOP 评审。工程已验证;当前上游仅提供 1 个可用手机号,尚未满足至少 20 名正式试点人员的业务门槛,详见 [演示与试点验收](docs/DEMO_ACCEPTANCE.md)。
- 侧栏:MVP 阶段只展示“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置”,其余若依默认菜单暂时隐藏。
- 本地短信:真实阿里云短信参数放根目录 `.env.local`,由 `scripts/dev-backend.sh` 自动加载;不要把密钥写入 `application-dev.yml`。dev 未配短信时验证码固定 `123456`(`aihr.sms.dev-fixed-code`,prod 强制失效)。
- 生产发布:先执行 `scripts/release-preflight.sh`,再按 [BRD 生产迁移 Runbook](docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md) 处理 schema、内置内容、备份、回滚和发布后回归;本地构建或 strict gate 通过,都不能单独证明线上已发布。
## 快速启动
@@ -46,10 +47,13 @@
| [文档目录](docs/README.md) | docs 区完整索引 |
| [Figma 设计资产清单](docs/FIGMA_DESIGN_INVENTORY.md) | 79 个页面、71 张正式画板的节点索引、批次范围和一致性审计结果 |
| [Figma 视觉系统使用规范](docs/FIGMA_DESIGN_SYSTEM_GUIDE.md) | 通用视觉元素、业务元素、状态映射和 LLM 组页规则 |
| [Figma 页面实现矩阵](docs/FIGMA_IMPLEMENTATION_MATRIX.md) | 70 张正式画板与代码/线上实现状态、二期边界和剩余动作 |
| [Figma 与线上 H5 差异矩阵](docs/FIGMA_ONLINE_DELTA.md) | 线上真实页面与 Figma 目标的差异、调整顺序和验收口径 |
| [本地运行手册](docs/DEV_SETUP.md) | 端口、账号、启动和基础验证 |
| [MVP 演示与验收清单](docs/DEMO_ACCEPTANCE.md) | MVP 演示脚本、录屏兜底和验收口径 |
| [后端 API 对接指南](docs/API_INTEGRATION.md) | 后端模块落点、正式试点 CSV、移动端训练/复盘、候选资料上传/审核和 SOP 上传解析接口 |
| [BRD 功能审查记录](docs/BRD_IMPLEMENTATION_AUDIT.md) | BRD 功能、权限、迁移、线上资源和未完成项的证据记录 |
| [BRD 生产迁移 Runbook](docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md) | 生产 schema/内置内容迁移、只读预检、发布和回滚边界 |
| [ruoyi-ai 能力分片迁移计划](docs/RUOYI_AI_INCREMENTAL_MIGRATION.md) | 知识库、模型能力、文档解析、RAG、chat 的渐进迁移边界 |
| [项目规划方案与 AI 接口说明](docs/AI人力资源系统项目规划方案与AI接口说明.md) | 对外会议版口径、数字师傅定位和 AI 接口规划 |
| [AI 陪练二期开发推进计划](docs/AI陪练二期开发推进计划.md) | 总纲阶段一执行文档:从 MVP 演示闭环推进到管家(生活顾问)岗位试点可用 |
@@ -62,11 +62,16 @@ public class CaptchaController {
/**
* 演示/开发兜底:配置后不真发短信,验证码固定为该值,把短信通道从演示关键路径摘掉。
* prod profile 代码级禁用固定验证码,不接受配置开关绕过。
*/
@Value("${aihr.sms.dev-fixed-code:}")
private String smsDevFixedCode;
/**
* 生产固定码必须显式开启,默认关闭,便于试点期按需启停。
*/
@Value("${aihr.sms.prod-fixed-code-enabled:false}")
private boolean smsProdFixedCodeEnabled;
private final Environment environment;
/**
@@ -80,15 +85,19 @@ public class CaptchaController {
@NotBlank(message = "{user.phonenumber.not.blank}")
@Pattern(regexp = RegexConstants.MOBILE, message = "{user.mobile.phone.number.not.valid}") String phonenumber) {
String key = GlobalConstants.CAPTCHA_CODE_KEY + phonenumber;
boolean fixedCodeEnabled = StringUtils.isNotBlank(smsDevFixedCode)
&& !environment.acceptsProfiles(Profiles.of("prod"));
boolean prodProfile = environment.acceptsProfiles(Profiles.of("prod"));
boolean fixedCodeEnabled = shouldUseFixedSmsCode(
smsDevFixedCode,
prodProfile,
smsProdFixedCodeEnabled
);
if (fixedCodeEnabled) {
log.warn("aihr.sms.dev-fixed-code 已启用,短信验证码走固定码,未真实发送短信");
RedisUtils.setCacheObject(key, smsDevFixedCode, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
return R.ok();
}
if (StringUtils.isBlank(smsLoginTemplateId)) {
return R.fail("短信模板未配置:请设置 aihr.sms.login-template-id 或开发环境设置 aihr.sms.dev-fixed-code");
return R.fail("短信模板未配置:请设置 aihr.sms.login-template-id 或启用固定验证码配置");
}
String code = RandomUtil.randomNumbers(4);
LinkedHashMap<String, String> map = new LinkedHashMap<>(1);
@@ -103,6 +112,10 @@ public class CaptchaController {
return R.ok();
}
static boolean shouldUseFixedSmsCode(String fixedCode, boolean prodProfile, boolean prodFixedCodeEnabled) {
return StringUtils.isNotBlank(fixedCode) && (!prodProfile || prodFixedCodeEnabled);
}
/**
* 邮箱验证码
*
@@ -164,6 +164,9 @@ aihr:
store-display-fields: false
sms:
login-template-id: ${AIHR_SMS_LOGIN_TEMPLATE_ID:}
# 试点期固定验证码:生产默认关闭,需同时显式配置固定码与开关
dev-fixed-code: ${AIHR_SMS_DEV_FIXED_CODE:}
prod-fixed-code-enabled: ${AIHR_SMS_PROD_FIXED_CODE_ENABLED:false}
sms:
# 配置源类型用于标定配置来源(interface,yaml)
config-type: yaml
@@ -3,9 +3,6 @@ package org.dromara.web.controller;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -13,15 +10,10 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class CaptchaControllerSecurityTest {
@Test
void fixedSmsCodeIsDisabledInProdWithoutOverride() throws Exception {
Path source = Path.of("src/main/java/org/dromara/web/controller/CaptchaController.java");
if (!Files.exists(source)) {
source = Path.of("ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaController.java");
}
String code = Files.readString(source);
assertTrue(code.contains("!environment.acceptsProfiles(Profiles.of(\"prod\"))"));
assertFalse(code.contains("smsDemoFixedCodeEnabled"));
assertFalse(code.contains("demo-fixed-code-enabled"));
void fixedSmsCodeRequiresExplicitProductionOptIn() {
assertTrue(CaptchaController.shouldUseFixedSmsCode("123456", false, false));
assertFalse(CaptchaController.shouldUseFixedSmsCode("123456", true, false));
assertTrue(CaptchaController.shouldUseFixedSmsCode("123456", true, true));
assertFalse(CaptchaController.shouldUseFixedSmsCode("", true, true));
}
}
+47
View File
@@ -0,0 +1,47 @@
# Figma implementation QA — employee Today and Practice
## Scope and evidence
- Source visual truth: Figma file `30hFiKtieGYraOZvJPJcPd`, frames `23:3` (Today), `53:2` (Practice prep), `54:2` (Practice active).
- Source screenshots: `.tmp/audit/2026-07-15-figma-online/figma/01-today.png`, `02-practice-prep.png`, `03-practice-active.png`.
- Deployed implementation: `https://peilian.njzhmj.top/h5/`.
- Implementation screenshots: `.tmp/audit/2026-07-15-figma-online/online/07-today-authenticated-final.png`, `08-practice-prep-final.png`, `09-practice-active-round2-final.png`.
- Combined comparison evidence: `.tmp/audit/2026-07-15-figma-online/qa-online-today-final.png`, `qa-online-practice-prep-final.png`, `qa-online-practice-active-final.png`.
- Viewport: 400 × 900 CSS pixels, device scale factor 1.
- State: authenticated employee `13900000103`; Today with real production assignments; Practice prep; Practice active at round 2/3.
- Primary interactions tested: fixed-code login, open Practice, start a session, submit one response, receive customer/coach turns.
- Browser checks: `innerWidth=400`, document/body `scrollWidth=400`, no captured runtime or console errors on all three final online captures.
Focused region comparisons were not needed: all typography, controls, icons, copy, and state cards were legible in the 800px-wide combined images.
## Comparison history
1. Initial online audit reported horizontal clipping from screenshots made with Chrome `--window-size=400`. Verification showed headless Chrome had actually laid out at its 500px minimum width and cropped the output. A real CDP mobile emulation pass measured `innerWidth=400` and `scrollWidth=400`; the reported overflow was withdrawn and no global overflow workaround was added.
2. The corrected comparison found P1 drift on Today: oversized mentor hero, blue primary actions, multicolor full-bleed map tiles, and loose vertical rhythm. The hero was reduced to a contained card, the primary system was changed to Silver City red, map tiles became light outlined cards with semantic icon accents, and spacing was tightened.
3. Practice prep used a large white wrapper and did not fit the Figma hierarchy. It was changed to separate red/white lightweight cards with a compact scenario pill and a single-screen CTA.
4. Interaction QA found that starting Practice left the active dialogue below Recent and Daily sections. Prep, active, and result states now render mutually exclusively, and the page heading changes with the state. The final active-state capture shows round 2/3 immediately after the heading.
## Required fidelity surfaces
- Fonts and typography: sizes, weights, wrapping, and hierarchy now match the source closely. The app intentionally retains the system Chinese font stack instead of loading `LXGW WenKai TC`; this avoids a large or network-dependent font payload while preserving readable metrics.
- Spacing and layout rhythm: hero, cards, chips, prep sections, CTA, and fixed tab bar align to the 400px frame without horizontal scroll. Real production Today data contains two tasks, so the map starts lower than the one-task Figma mock.
- Colors and tokens: employee and supervisor shared primary tokens, selected tab color, cards, buttons, progress bars, and coach states use `#e60012` with red soft surfaces. Green/orange/gray remain only as semantic accents.
- Image quality and assets: the supplied mentor raster, brand mark, tab icons, and `uni-icons` assets are used directly; no placeholder or generated replacement was introduced.
- Copy and content: implementation uses real API scenario/task/customer/coach copy. Differences from the mock are data-driven rather than placeholder drift.
## Findings
No actionable P0, P1, or P2 findings remain in the implemented employee Today, Practice prep, and Practice active scope.
## Accepted differences
- Production Today has two real tasks while the Figma frame contains one; the extra row is preserved instead of hiding business data.
- The active conversation may contain more turns than the fixed mock, so the response composer can move below the first viewport after longer exchanges.
- `LXGW WenKai TC` is not bundled; system typography is the deliberate production choice.
## Outside this pass
- Practice result was not persisted solely for a screenshot, so frame `55:2` remains an authenticated visual follow-up.
- Production phone `13900000202` currently returns the real “无主管权限” state, so the authenticated supervisor dashboard frame `57:2` remains blocked by production organization mapping rather than frontend rendering.
final result: passed
+1 -1
View File
@@ -16,7 +16,7 @@
| SOP知识库 `/knowledge/sop` | `POST /api/knowledge/search`、`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` | 已接入 MySQL Fulltext + Qdrant 混合召回、岗位学习适配摘要、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析和 embedding 写入,失败回退 seed;搜索返回 `reviewId` 与 `promptVersion`,员工反馈回传并保存该评审批次,SOP 人工评审记录同时保留答案生成提示词版本,管理端可继续复核;员工学习页按当前 APP 身份读取正式岗前/入职任务,员工只能将本人处于“待完成/进行中”的任务确认完成,服务端按组织快照/手机号归属更新 `status/completed_time`,不接受前端身份参数;资格证据无正式数据时明确返回 `NOT_CONFIGURED`,不以 AI 分数代替上岗资格;`position-sop` 仍保留一期生活顾问学习导航语义 |
| 资料处理 `/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`、`POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 已接入解析任务状态聚合;**批量上传走异步队列**:接口只暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化;ZIP 在 worker 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度、失败可单文件重试;服务端目录导入、进度轮询和任务取消保留,失败回退 seed |
| 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页参数使用 `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` 才允许覆盖;重复 `ext_party_id` 始终拒绝写入,因为数据库唯一键会折叠重复身份;`replaceExisting=false` 不触发不完整快照覆盖闸门,但仍拒绝重复身份。2026-07-10 源接口实测 3474 人仅 1 个可用手机号、3473 个脱敏手机号、8 条疑似乱码,因此未执行覆盖同步,正式试点需上游先开放至少 20 名试点人员手机号 |
| 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;短信发送成功后才写 Redis 验证码;手机号不存在时自动注册 `app_user`;`aihr.sms.dev-fixed-code` 非空时不真发短信、验证码固定(dev 默认 `123456`,prod profile 代码级强制失效) |
| 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;短信发送成功后才写 Redis 验证码;手机号不存在时自动注册 `app_user`;`aihr.sms.dev-fixed-code` 非空时不真发短信、验证码固定(dev 默认 `123456`)。prod 默认关闭,试点期只有同时设置 `AIHR_SMS_DEV_FIXED_CODE` 与 `AIHR_SMS_PROD_FIXED_CODE_ENABLED=true` 才启用固定码。 |
| 用户侧三端首页 `mobile-uni` hash 路由;旧 `/h5/user`、`/h5/candidate`、`/h5/supervisor` 兼容重定向 | `GET /api/aihr/mobile/home/{role}` | 未登录请求只返回不读取租户业务统计的公开首屏 seed;已登录移动端请求自动携带 `Authorization/clientid`,才返回员工/主管真实统计;移动端本地 fallback 保演示 |
| 移动端登录后角色识别 | `GET /api/aihr/mobile/me` | 认证后按手机号匹配组织快照;`position_level` 为“主管/项目经理”时进入主管端,否则进入员工端;接口失败回退员工端 |
| 移动端员工训练与主管复盘闭环 | 员工复用 `POST /api/train/practice/start`、`/turn`、`/finish`,查询 `GET /api/aihr/mobile/practice/history`、`/practice/mistakes`、`/profile`;训练完成后提交 `POST /api/aihr/mobile/practice/satisfaction`;主管查询 `GET /api/aihr/mobile/practice/team`、`/practice/alerts`、`/practice/reviews`、`/practice/reviews/{id}`,标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`,指派 `POST /api/aihr/mobile/practice/assignments` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`。满意度接口只接受本人已完成训练的 1-5 分,意见脱敏后落库,未填写不补默认值。错题本按员工本人聚合低分/红线回合,并关联已有 `retry` assignment,不伪造错题结论。主管 `/practice/team` 在同一项目权限范围内额外返回 `mistakes` 聚合,按场景/归因统计次数、影响人数、平均分和最近发生时间;普通员工返回“无主管权限”。主管接口以后端当前登录手机号映射在职组织快照,仅允许岗位为“主管/项目经理”的账号,并按租户和项目范围返回真实成员、全状态训练记录及非 daily 专项。复盘标记只允许首次 `待复盘 -> 已复盘` 创建后续专项,并发重复提交幂等;可带 `incentivePoint` 写入贡献度。每日三题正式只对 `hire_date` 在当前日期前三个月内的在职员工派发;没有入职日期时不使用训练次数推断,开发 Demo 的旧回退只有在 `dev/local` profile 且由 `aihr.practice.allow-legacy-daily-drill-fallback` 显式开启时生效,生产 profile 强制关闭。 |
+3 -1
View File
@@ -63,8 +63,10 @@ AIHR_SMS_LOGIN_TEMPLATE_ID=SMS_xxxxxx
ALIYUN_SMS_ACCESS_KEY_ID=xxx
ALIYUN_SMS_ACCESS_KEY_SECRET=xxx
ALIYUN_SMS_SIGN_NAME=物业AI助手
# 演示兜底:非空则不真发短信,验证码固定为该值;dev 默认 123456,prod profile 代码级强制失效
# 演示兜底:非空则不真发短信,验证码固定为该值;dev 默认 123456
AIHR_SMS_DEV_FIXED_CODE=123456
# prod 默认 false;试点期仅在明确接受固定码风险时与上一项同时开启
AIHR_SMS_PROD_FIXED_CODE_ENABLED=false
AIHR_ORG_SYNC_BASE_URL=https://wuye.meihe.cc/api/open/v1
AIHR_ORG_SYNC_ACCESS_TOKEN=
AIHR_ORG_SYNC_CLIENT_ID=
+3 -1
View File
@@ -156,5 +156,7 @@
- [x] 896 个精确匹配颜色补充变量绑定,整体填充变量绑定率约 50%
- [x] 品牌主红统一为 `#E60012`,员工端与管理端变量已对齐
- [x] 正式画板和设计规范中的可见文字最低 12px,字号调整后无裁切和越界
- [x] 员工端「页面|今日」已完成首页高保真复核:问答卡横向内容垂直居中,左右图片容器统一为 `64×64`,右侧聊天气泡保持居中且文案不溢出
- [x] 员工端「页面|今日」已完成首页高保真复核:按「参考稿|今日」收口为紧凑纵向结构,保留品牌头部、师傅 Hero、问答卡、今日安排、我的地图和底部 Tab
- [x] 问答卡横向内容垂直居中,左右图片容器统一为 `64×64`,右侧聊天气泡保持居中且文案不溢出
- [x] 师傅 Hero 的提示气泡复用组件库 `Communication/Chat Bubble`;品牌标记复用现有品牌资源,不为页面重复绘制图标
- [ ] 代码实现阶段验证真实对比度、键盘焦点、状态播报和读屏顺序
+2
View File
@@ -85,6 +85,8 @@
「问老师傅」和「打字问」是同一问答卡中的并列入口,属于页面组合,不新增业务组件。两侧图片容器统一使用 `64×64`,横向内容行使用垂直居中;图形本身可按图标复杂度保持内部留白,但不能再出现一侧 `80×80`、另一侧 `40×40` 的视觉层级错位。
员工端「页面|今日」对齐「参考稿|今日」时,使用组件库提供的品牌头部、卡片、按钮、聊天气泡和底部 Tab 作为基础,再由页面组合承载「生活顾问 / 客服管家」、今日安排和我的地图等业务元素。参考稿中的师傅提示气泡属于通用沟通组件的页面实例,不应把“今天先处理投诉”等文案固化进组件;缺少图标时优先复用代码已有品牌资源或图标组件,只有确实没有可复用资产时才新增图形资源。
## 二、状态映射规则
业务状态只负责提供语义,视觉组件负责提供表现。
+4
View File
@@ -9,6 +9,7 @@
| [DEV_SETUP.md](DEV_SETUP.md) | 本地端口、账号、启动步骤、基础链路验证、移动端 H5 静态发布 |
| [FIGMA_DESIGN_INVENTORY.md](FIGMA_DESIGN_INVENTORY.md) | Figma 页面节点索引、设计批次范围、取消项和全文件一致性审计结果 |
| [FIGMA_DESIGN_SYSTEM_GUIDE.md](FIGMA_DESIGN_SYSTEM_GUIDE.md) | 员工端通用视觉元素、业务元素、状态映射和 LLM 组页规则 |
| [FIGMA_IMPLEMENTATION_MATRIX.md](FIGMA_IMPLEMENTATION_MATRIX.md) | 70 张正式画板与代码/线上实现状态、二期边界和剩余动作 |
| [FIGMA_ONLINE_DELTA.md](FIGMA_ONLINE_DELTA.md) | Figma 目标与线上 H5 实测差异矩阵、当前实施边界和验收口径 |
| [DEMO_ACCEPTANCE.md](DEMO_ACCEPTANCE.md) | 一期 MVP 演示脚本、录屏兜底、MVP 演示流验收清单 |
| [API_INTEGRATION.md](API_INTEGRATION.md) | 后端 API 对接顺序、正式试点 CSV、移动端训练记录/复盘详情、候选资料上传/审核、SOP 上传解析接口 |
@@ -18,9 +19,11 @@
| [个人AI助理阶段二专项TechSpec.md](个人AI助理阶段二专项TechSpec.md) | 员工个人知识空间的物理分域、数据模型、API、检索融合、权限、安全、删除和测试规格 |
| [个人AI助理阶段二开发推进计划.md](个人AI助理阶段二开发推进计划.md) | 个人 AI 助理阶段二按 P0/P1 拆分的逐任务实施、测试、提交和试点闸门 |
| [AI陪练二期开发推进计划.md](AI陪练二期开发推进计划.md) | 总纲阶段一执行文档:AI 陪练从 MVP 演示闭环推进到管家(生活顾问)岗位试点可用;区分工程就绪与正式业务试点证据 |
| [BRD_IMPLEMENTATION_AUDIT.md](BRD_IMPLEMENTATION_AUDIT.md) | BRD 功能、权限、迁移、线上资源和未完成项的证据记录;不替代正式发布验收 |
| [BRD_PRODUCTION_MIGRATION_RUNBOOK.md](BRD_PRODUCTION_MIGRATION_RUNBOOK.md) | AIHR BRD 生产 schema/内置内容迁移顺序、只读核验和发布后回归口径 |
| [20260708/uni-app用户侧前端重建计划.md](20260708/uni-app用户侧前端重建计划.md) | 当前用户侧 `mobile-uni/` 重建计划、三端页面拆分、portless 入口、真实浏览器验证和验收进展 |
| [20260708/数字师傅学练问报整合方案.md](20260708/数字师傅学练问报整合方案.md) | 产品定义(学练问报+问题榜+学分晋升+个人 AI 助理)、半年会发布会方案分层及客户需求逐项处置对照 |
| [20260708/发布会演示候选题单.md](20260708/发布会演示候选题单.md) | 2026-07-08 发布会演示候选题单,作为产品讨论留档,不作为阶段一开发入口 |
| [基于AI的银城员工端APP项目立项与开发规划会议-2026年07月07日-来自【Get 笔记】.md](基于AI的银城员工端APP项目立项与开发规划会议-2026年07月07日-来自【Get%20笔记】.md) | 2026-07-07 立项会纪要原文(总纲的输入源,只作留档) |
| [20260708/企业内部AI培训学习APP产品需求与发布会内容讨论(纪要)](20260708/) | 2026-07-08 产品讨论纪要原文及客户"第三版"docx(整合方案的输入源,只作留档) |
| [open-org-sync-api-design-v1.md](open-org-sync-api-design-v1.md) | 开放组织同步系统 `/open/v1` 接口设计,`POST /api/aihr/org/sync` 的外部契约 |
@@ -29,6 +32,7 @@
| [BACKLOG.md](BACKLOG.md) | 需求池与延后事项(B1–B8:数据权限/人设语音/生成图片/生成视频/员工画像/视觉检索/数字师傅) |
| [H5_REDESIGN.md](H5_REDESIGN.md) | H5 前端重构设计蓝本(随身师傅定位、双引擎首页、答案卡、岗位化、路线图) |
| [AI陪练功能优化与缺口分析(修订).md](AI陪练功能优化与缺口分析(修订).md) | 陪练功能缺口 24 项核实修订与排期建议(原 docx 的代码实况校对版,排期以此为准) |
| [PC管理端功能差异TODO.md](PC管理端功能差异TODO.md) | 阶段一管理端差异与阶段二/三延后项;不作为当前阶段一完成证明 |
| [feedback/](feedback/) | 用户反馈原始截图与解读(按日期归档,含 ANALYSIS.md) |
| [CODEBASE_ORIGINS.md](CODEBASE_ORIGINS.md) | 代码库来源与上游仓库说明 |
| [prototypes/ai-hr-dashboard.png](prototypes/ai-hr-dashboard.png) | 后台首页高保真目标图 |
+1 -1
View File
@@ -143,7 +143,7 @@
},
"tabBar": {
"color": "#7b8494",
"selectedColor": "#2f6bff",
"selectedColor": "#e60012",
"backgroundColor": "#ffffff",
"borderStyle": "white",
"height": "66px",
+103 -63
View File
@@ -1,8 +1,8 @@
<template>
<view class="page practice-page employee-page-shell">
<view class="practice-head">
<text class="page-title">训练前预习</text>
<text class="page-subtitle">先掌握要点,再开始场景练习</text>
<text class="page-title">{{ practicePageTitle }}</text>
<text class="page-subtitle">{{ practicePageSubtitle }}</text>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
@@ -15,7 +15,7 @@
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<view v-if="loggedIn" class="panel scenario-card employee-card">
<view v-if="loggedIn && practiceView === 'prep'" class="panel scenario-card employee-card">
<view class="scenario-headline">
<text class="scenario-title">{{ activeScenarioName }}</text>
<text class="scenario-desc">{{ positionLabel }} · 业主服务场景</text>
@@ -53,7 +53,7 @@
</button>
</view>
<view v-if="loggedIn" class="panel assignment-panel recent-panel">
<view v-if="loggedIn && practiceView === 'prep'" class="panel assignment-panel recent-panel">
<view class="assignment-head">
<text class="panel-title">最近练过</text>
<text class="recent-link">全部 ›</text>
@@ -74,7 +74,7 @@
<view v-else class="empty">暂无最近练习</view>
</view>
<view v-if="loggedIn" class="panel assignment-panel daily-panel">
<view v-if="loggedIn && practiceView === 'prep'" class="panel assignment-panel daily-panel">
<view class="assignment-head">
<view><text class="panel-title">每日三题</text><text class="daily-subtitle">今日安排</text></view>
<text class="daily-action-label">去作答 ›</text>
@@ -123,13 +123,13 @@
</view>
</view>
<view v-if="loggedIn" class="capability-strip">
<view v-if="loggedIn && practiceView === 'prep'" class="capability-strip">
<view><text>情绪</text><text>识别与安抚</text></view>
<view><text>信任</text><text>建立与维护</text></view>
<view><text>红线纠偏</text><text>识别与规避</text></view>
</view>
<view v-if="turns.length" class="panel dialogue-card employee-card">
<view v-if="practiceView === 'active' && turns.length" class="panel dialogue-card employee-card">
<view class="dialogue-head">
<view class="dialogue-heading-block">
<text class="panel-title">对话</text>
@@ -197,7 +197,7 @@
<text v-if="draftAudioUrl" class="voice-state">已附带语音回放</text>
</view>
<view v-if="helpAnswer || helpRefs.length" class="panel help-card employee-card">
<view v-if="practiceView === 'active' && (helpAnswer || helpRefs.length)" class="panel help-card employee-card">
<text class="panel-title">SOP 提示</text>
<text v-if="helpAnswer" class="help-answer">{{ helpAnswer }}</text>
<view v-if="helpRefs.length" class="help-refs">
@@ -248,6 +248,11 @@
</button>
<text v-else class="satisfaction-thanks">感谢反馈,已记录本次训练体验。</text>
</view>
<view v-if="result" class="result-actions">
<button class="secondary-button" @click="openPracticeHistory">查看训练记录</button>
<button class="primary-button" @click="start(selectedScenarioId)">再练一次</button>
</view>
</view>
</template>
@@ -327,6 +332,19 @@ const draftAudioOssId = ref<number | undefined>();
const mediaRecorder = ref<MediaRecorder | null>(null);
const mediaStream = ref<MediaStream | null>(null);
const busy = computed(() => status.value === 'starting' || status.value === 'submitting');
const practiceView = computed<'prep' | 'active' | 'result'>(() => {
if (result.value) return 'result';
return status.value === 'active' || status.value === 'submitting' ? 'active' : 'prep';
});
const practicePageTitle = computed(() => {
if (practiceView.value === 'result') return '训练完成';
return practiceView.value === 'active' ? '场景对练' : '训练前预习';
});
const practicePageSubtitle = computed(() => {
if (practiceView.value === 'result') return `${activeScenarioName.value} · ${Math.max(roundIndex.value, 1)} 轮对话`;
if (practiceView.value === 'active') return `${activeScenarioName.value} · 第 ${Math.min(roundIndex.value + 1, 3)}/3 轮`;
return '先掌握要点,再开始场景练习';
});
const canStartPractice = computed(() => status.value === 'idle' || status.value === 'finished');
const hasTraineeReply = computed(() => turns.value.some((item) => item.role === 'trainee'));
const canFinishPractice = computed(() => hasTraineeReply.value && !redFlagActive.value);
@@ -643,6 +661,10 @@ const openRecentItem = (item: RecentPracticeItem) => {
uni.navigateTo({ url: '/pages/user/profile/index' });
};
const openPracticeHistory = () => {
uni.navigateTo({ url: '/pages/user/profile/index' });
};
const consumePendingAssignment = () => {
const raw = uni.getStorageSync(pendingAssignmentKey);
if (!raw) return;
@@ -1244,7 +1266,7 @@ onShow(() => {
}
.dialogue-card {
border-color: #dbe7ff;
border-color: #f3dadd;
box-shadow: var(--employee-card-shadow);
}
@@ -1306,11 +1328,11 @@ onShow(() => {
}
.emotion-fill {
background: #6f9cff;
background: #e60012;
}
.trust-fill {
background: #2f6bff;
background: #8c929a;
}
.red-flag {
@@ -1348,19 +1370,19 @@ onShow(() => {
}
.turn-row.trainee {
border-left-color: #2f6bff;
background: #eef4ff;
border-left-color: #e60012;
background: #fff0f1;
}
.turn-row.coach {
border-left-color: #8bb1ff;
background: #f4f7ff;
border-left-color: #ef8992;
background: #fff7f7;
}
.reply-actions button.recording-active {
border-color: #2f6bff;
background: #eef4ff;
color: #2f6bff;
border-color: #e60012;
background: #fff0f1;
color: #e60012;
}
.turn-role {
@@ -1384,9 +1406,9 @@ onShow(() => {
.style-chip {
width: auto;
border-color: #c9d9ff;
border-color: #f0c7cb;
background: #ffffff;
color: #2f6bff;
color: #e60012;
}
.reply-card {
@@ -1396,7 +1418,7 @@ onShow(() => {
.practice-textarea {
min-height: 124px;
border-color: #dbe7ff;
border-color: #f3dadd;
border-radius: 18px;
background: #f9fbff;
}
@@ -1416,8 +1438,8 @@ onShow(() => {
.help-ref {
padding: 5px 8px;
border-radius: 5px;
background: #eef4ff;
color: #2f6bff;
background: #fff0f1;
color: #e60012;
font-size: 12px;
font-weight: 800;
}
@@ -1429,7 +1451,7 @@ onShow(() => {
}
.result-score {
color: #2f6bff;
color: #e60012;
font-size: 44px;
font-weight: 900;
line-height: 1;
@@ -1447,13 +1469,13 @@ onShow(() => {
flex-direction: column;
gap: 6px;
padding: 12px;
border-left: 3px solid #2f6bff;
border-left: 3px solid #e60012;
border-radius: 6px;
background: #eef4ff;
background: #fff0f1;
}
.mentor-rewrite text:first-child {
color: #2f6bff;
color: #e60012;
font-weight: 900;
}
@@ -1481,9 +1503,9 @@ onShow(() => {
}
.satisfaction-option.selected {
border-color: #2f6bff;
background: #eef4ff;
color: #2f6bff;
border-color: #e60012;
background: #fff0f1;
color: #e60012;
}
.satisfaction-textarea {
@@ -1496,6 +1518,18 @@ onShow(() => {
font-weight: 800;
}
.result-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 14px;
}
.result-actions button {
width: 100%;
min-width: 0;
}
/* 练页入口态:与员工端蓝白轻卡片体系保持一致 */
.practice-page {
padding: 0 16px 108px;
@@ -1504,16 +1538,16 @@ onShow(() => {
.practice-head {
margin: 0 -16px;
padding: 30px 20px 24px;
border-bottom: 1px solid var(--employee-line);
background: linear-gradient(180deg, #eef4ff 0%, #f5f8fd 100%);
text-align: left;
padding: 28px 20px 16px;
border-bottom: 0;
background: #f7f7f7;
text-align: center;
}
.practice-head::after,
.practice-eyebrow,
.practice-tip {
display: block;
display: none;
}
.practice-head .page-title {
@@ -1665,9 +1699,9 @@ onShow(() => {
}
.scenario-card .primary-button {
margin-top: 18px;
background: linear-gradient(135deg, #2f6bff, #5a8cff);
box-shadow: 0 8px 16px rgba(47, 107, 255, 0.18);
margin-top: 2px;
background: var(--employee-primary);
box-shadow: 0 8px 16px rgba(230, 0, 18, 0.18);
}
.login-panel {
@@ -1675,12 +1709,12 @@ onShow(() => {
}
.assignment-row {
border-color: #dbe7ff;
border-color: #f3dadd;
}
.assignment-row.highlighted {
border-color: #9bbaff;
background: #f4f7ff;
border-color: #ef8992;
background: #fff7f7;
}
.assignment-badge,
@@ -1803,8 +1837,8 @@ onShow(() => {
}
.daily-row .small-button {
background: linear-gradient(135deg, #2f6bff, #5a8cff);
box-shadow: 0 6px 12px rgba(47, 107, 255, 0.16);
background: linear-gradient(135deg, #ed1f28, #ff4b4d);
box-shadow: 0 6px 12px rgba(230, 0, 18, 0.16);
}
.capability-strip {
@@ -1843,7 +1877,7 @@ onShow(() => {
.practice-head .page-title {
color: #1f2024;
font-size: 29px;
font-size: 30px;
letter-spacing: 0.5px;
}
@@ -1852,36 +1886,43 @@ onShow(() => {
}
.scenario-card {
padding: 20px 16px 16px;
border-radius: 24px;
background: #ffffff;
box-shadow: 0 10px 26px rgba(24, 28, 36, 0.06);
padding: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.scenario-headline {
max-width: none;
min-height: 42px;
flex-direction: row;
align-items: center;
gap: 6px;
padding: 0 14px;
border: 1px solid #ffd1d5;
border-radius: 22px;
background: #fff7f7;
}
.scenario-title {
color: #25282d;
font-size: 27px;
color: var(--employee-primary);
font-size: 14px;
}
.scenario-desc {
color: #8a8d93;
font-size: 14px;
color: var(--employee-primary);
font-size: 13px;
}
.scenario-persona {
display: flex;
align-items: center;
gap: 12px;
margin-top: 2px;
margin-top: 14px;
padding: 14px;
border: 1px solid #ffd9dc;
border-radius: 16px;
background: #fff8f8;
border: 0;
border-radius: 20px;
background: #ffffff;
}
.persona-avatar {
@@ -1930,15 +1971,15 @@ onShow(() => {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 2px;
margin-top: 14px;
}
.prep-section {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
border: 1px solid #f0f1f3;
gap: 7px;
padding: 15px 16px;
border: 0;
border-radius: 20px;
background: #ffffff;
}
@@ -1950,8 +1991,7 @@ onShow(() => {
}
.prep-section-redline {
border-color: #ffe0e2;
background: #fffafa;
background: #ffffff;
}
.prep-section-redline .prep-section-title,
@@ -1964,7 +2004,7 @@ onShow(() => {
margin-top: 0;
color: #858990;
font-size: 13px;
line-height: 1.5;
line-height: 1.42;
}
.prep-mark {
+102 -100
View File
@@ -18,6 +18,7 @@
<text class="hero-role">{{ positionProfile.value }}</text>
<text class="hero-title">{{ positionProfile.heroTitle }}</text>
<text class="hero-sub">有问题,随时问老师傅</text>
<text class="hero-tip">{{ mentorBubbleText }}</text>
</view>
<view class="mentor-stage">
<view class="mentor-bubble">
@@ -95,7 +96,7 @@
<view class="schedule-list">
<view class="schedule-row" v-if="primaryScheduleTitle">
<view class="schedule-icon-wrap">
<uni-icons class="schedule-icon" type="compose" size="26" color="#2f6bff" />
<uni-icons class="schedule-icon" type="compose" size="26" color="#e60012" />
</view>
<view class="schedule-copy">
<text v-if="primaryAssignmentSourceLabel" class="schedule-source" :class="primaryAssignmentSourceClass">
@@ -190,8 +191,7 @@ const pendingAssignmentKey = 'aihr_pending_assignment_id';
const chipIcons = ['chatbubble', 'help', 'wallet'];
const chipIcon = (index: number) => chipIcons[index] || 'chatbubble';
const chipColors = ['#2f6bff', '#ff9b3d', '#34b368'];
const chipColor = (index: number) => chipColors[index] || '#2f6bff';
const chipColor = (_index: number) => '#e60012';
const mapTiles = computed(() => [
{
@@ -390,18 +390,15 @@ onShow(() => {
<style scoped>
.today-page {
min-height: 100vh;
padding: 0 24px 112px;
--employee-primary: #2f6bff;
--employee-primary-soft: #eaf1ff;
--employee-card-shadow: 0 16px 40px rgba(47, 107, 255, 0.1);
background: linear-gradient(180deg, #f4f8ff 0%, #f7faff 58%, #f3f5f7 100%);
padding: 0 16px 104px;
background: #f7f7f7;
}
.brand-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: calc(env(safe-area-inset-top) + 20px) 2px 10px;
padding: calc(env(safe-area-inset-top) + 12px) 2px 8px;
}
.brand-left {
@@ -411,8 +408,8 @@ onShow(() => {
}
.brand-logo {
width: 34px;
height: 34px;
width: 24px;
height: 24px;
}
.brand-name {
@@ -458,11 +455,13 @@ onShow(() => {
.mentor-hero {
position: relative;
display: flex;
min-height: 232px;
min-height: 196px;
align-items: flex-start;
justify-content: space-between;
margin-top: 2px;
padding: 6px 0 0;
padding: 0;
border-radius: 20px;
background: #ffffff;
overflow: hidden;
}
@@ -470,50 +469,58 @@ onShow(() => {
position: relative;
z-index: 2;
display: flex;
max-width: 45%;
width: 52%;
max-width: 52%;
flex-direction: column;
padding-top: 11px;
padding: 22px 0 0 20px;
}
.hero-role,
.hero-title {
color: #111111;
font-size: 42px;
font-weight: 900;
line-height: 1.12;
font-size: 30px;
font-weight: 500;
line-height: 1.5;
letter-spacing: 0;
}
.hero-sub {
margin-top: 18px;
margin-top: 2px;
color: #8a8d93;
font-size: 17px;
font-size: 14px;
white-space: nowrap;
line-height: 1.4;
}
.hero-tip {
margin-top: 7px;
color: var(--employee-primary);
font-size: 13px;
line-height: 1.4;
white-space: nowrap;
}
.mentor-stage {
position: relative;
z-index: 1;
width: 240px;
height: 227px;
width: 178px;
height: 196px;
flex: none;
}
.mentor-image {
position: absolute;
right: -20px;
bottom: 0;
width: 240px;
height: 240px;
transform: translateY(50px);
right: 4px;
bottom: 4px;
width: 140px;
height: 140px;
filter: drop-shadow(0 10px 16px rgba(20, 24, 32, 0.12));
}
.mentor-bubble {
position: absolute;
top: 38px;
right: 130px;
top: 10px;
right: 84px;
z-index: 3;
width: 112px;
padding: 10px 12px;
@@ -544,8 +551,8 @@ onShow(() => {
.ask-card {
position: relative;
z-index: 4;
margin-top: 0;
padding: 16px 14px 25px;
margin-top: 10px;
padding: 12px 14px 10px;
border: 0;
border-radius: 20px;
background: #ffffff;
@@ -557,7 +564,6 @@ onShow(() => {
align-items: center;
min-height: 82px;
gap: 10px;
transform: translateY(7px);
}
.voice-primary,
@@ -579,46 +585,37 @@ onShow(() => {
}
.voice-primary {
flex: 1.7;
min-height: 74px;
gap: 14px;
padding: 0 12px;
border-radius: 16px;
background: #2f6bff;
box-shadow: 0 10px 20px rgba(47, 107, 255, 0.22);
flex: 1.45;
min-height: 70px;
gap: 10px;
padding: 0;
}
.voice-secondary {
flex: 1;
gap: 10px;
min-height: 74px;
padding: 0 8px;
border: 1px solid #e4eaf4;
border-radius: 16px;
background: #ffffff;
min-height: 70px;
padding: 0;
}
.voice-divider {
width: 1px;
height: 42px;
flex: none;
background: #ececee;
display: none;
}
.voice-emblem {
display: flex;
width: 52px;
height: 52px;
width: 64px;
height: 64px;
flex: none;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #ffffff;
background: var(--employee-primary);
box-shadow: none;
}
.voice-mark {
color: #2f6bff;
color: #ffffff;
font-size: 24px;
font-weight: 900;
line-height: 1;
@@ -640,7 +637,7 @@ onShow(() => {
}
.voice-primary .voice-title {
color: #ffffff;
color: #1a1a1a;
}
.secondary-title {
@@ -656,7 +653,7 @@ onShow(() => {
}
.voice-primary .voice-desc {
color: rgba(255, 255, 255, 0.78);
color: #9a9da3;
}
.secondary-copy {
@@ -671,12 +668,12 @@ onShow(() => {
.keyboard-mark {
display: flex;
width: 40px;
height: 40px;
width: 64px;
height: 64px;
flex: none;
align-items: center;
justify-content: center;
border-radius: 50%;
border-radius: 16px;
background: #f3f4f6;
}
@@ -686,8 +683,8 @@ onShow(() => {
}
.mentor-chips {
margin-top: 14px;
padding-top: 12px;
margin-top: 4px;
padding-top: 8px;
border-top: 1px solid #f0f1f3;
}
@@ -709,17 +706,17 @@ onShow(() => {
border: 1px solid #ececee;
border-radius: 12px;
background: #ffffff;
color: #2f6bff;
color: var(--employee-primary);
font-size: 13px;
font-weight: 700;
}
.chip-1 {
color: #ff9b3d;
color: var(--employee-primary);
}
.chip-2 {
color: #34b368;
color: var(--employee-primary);
}
.chip-icon {
@@ -741,8 +738,8 @@ onShow(() => {
}
.section-card {
margin-top: 12px;
padding: 22px 18px;
margin-top: 10px;
padding: 16px;
border: 0;
border-radius: 22px;
background: #ffffff;
@@ -789,7 +786,7 @@ onShow(() => {
color: #ffffff;
font-size: 15px;
font-weight: 800;
box-shadow: 0 8px 16px rgba(47, 107, 255, 0.2);
box-shadow: 0 8px 16px rgba(230, 0, 18, 0.18);
}
.section-head {
@@ -804,7 +801,7 @@ onShow(() => {
align-items: center;
gap: 8px;
color: #1a1a1a;
font-size: 22px;
font-size: 20px;
font-weight: 800;
}
@@ -812,7 +809,7 @@ onShow(() => {
width: 4px;
height: 22px;
border-radius: 2px;
background: #2f6bff;
background: var(--employee-primary);
}
.section-link {
@@ -839,21 +836,21 @@ onShow(() => {
.schedule-row {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 12px;
border-radius: 18px;
background: #f8fbff;
gap: 12px;
padding: 12px 10px;
border-radius: 16px;
background: #fafafa;
}
.schedule-icon-wrap {
display: flex;
width: 60px;
height: 60px;
width: 52px;
height: 52px;
flex: none;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #eaf1ff;
background: var(--employee-primary-soft);
}
.review-icon {
@@ -874,7 +871,7 @@ onShow(() => {
.schedule-title {
color: #1a1a1a;
font-size: 19px;
font-size: 17px;
font-weight: 800;
line-height: 1.25;
}
@@ -933,8 +930,8 @@ onShow(() => {
align-items: center;
justify-content: center;
border-radius: 12px;
background: #2f6bff;
box-shadow: 0 8px 16px rgba(47, 107, 255, 0.22);
background: var(--employee-primary);
box-shadow: 0 8px 16px rgba(230, 0, 18, 0.18);
color: #ffffff;
font-size: 18px;
font-weight: 800;
@@ -953,12 +950,12 @@ onShow(() => {
.map-tile {
display: flex;
min-height: 108px;
min-height: 70px;
align-items: center;
gap: 10px;
padding: 16px 14px;
border: 0;
border-radius: 16px;
padding: 10px 8px;
border: 1px solid #e2e4e8;
border-radius: 14px;
background: #ffffff;
text-align: left;
overflow: hidden;
@@ -968,46 +965,51 @@ onShow(() => {
.map-tile.practice,
.map-tile.learn,
.map-tile.report {
color: #ffffff;
border-color: transparent;
box-shadow: 0 10px 18px rgba(24, 28, 36, 0.08);
color: #25282d;
background: #ffffff;
box-shadow: none;
}
.map-tile.ask { background: #2f6bff; }
.map-tile.practice { background: #2ab673; }
.map-tile.learn { background: #ff961f; }
.map-tile.report { background: #ff6357; }
.map-tile.ask,
.map-symbol.ask { --map-accent: #e60012; }
.map-tile.practice,
.map-symbol.practice { --map-accent: #16a052; }
.map-tile.learn,
.map-symbol.learn { --map-accent: #f28b00; }
.map-tile.report,
.map-symbol.report { --map-accent: #526075; }
.map-symbol {
display: flex;
width: 56px;
height: 56px;
width: 44px;
height: 44px;
flex: none;
align-items: center;
justify-content: center;
border-radius: 16px;
border-radius: 50%;
background: var(--map-accent);
color: #ffffff;
font-size: 24px;
font-weight: 900;
}
.map-symbol.ask {
background: rgba(255, 255, 255, 0.2);
background: var(--map-accent);
box-shadow: none;
}
.map-symbol.practice {
background: rgba(255, 255, 255, 0.2);
background: var(--map-accent);
box-shadow: none;
}
.map-symbol.learn {
background: rgba(255, 255, 255, 0.2);
background: var(--map-accent);
box-shadow: none;
}
.map-symbol.report {
background: rgba(255, 255, 255, 0.2);
background: var(--map-accent);
box-shadow: none;
}
@@ -1020,21 +1022,21 @@ onShow(() => {
}
.map-title {
color: #ffffff;
font-size: 18px;
color: #25282d;
font-size: 16px;
font-weight: 800;
line-height: 1.2;
}
.map-desc {
color: rgba(255, 255, 255, 0.82);
font-size: 13px;
color: #8a8d93;
font-size: 11px;
line-height: 1.35;
white-space: pre-line;
}
.map-arrow {
color: #ffffff;
color: #9da1a8;
font-size: 18px;
flex: none;
}
+3 -3
View File
@@ -21,8 +21,8 @@ page {
--employee-bg: #f4f6fb;
--employee-bg-soft: #f8fbff;
--employee-surface: #ffffff;
--employee-primary: #2f6bff;
--employee-primary-soft: #eaf1ff;
--employee-primary: #e60012;
--employee-primary-soft: #fff0f1;
--employee-success: #34b368;
--employee-warning: #ff9b3d;
--employee-danger: #ff6b5f;
@@ -31,7 +31,7 @@ page {
--employee-line: rgba(47, 74, 117, 0.08);
--employee-card-radius: 24px;
--employee-chip-radius: 18px;
--employee-card-shadow: 0 16px 40px rgba(47, 107, 255, 0.1);
--employee-card-shadow: 0 16px 40px rgba(230, 0, 18, 0.08);
}
view,
+8 -3
View File
@@ -46,7 +46,7 @@ test('manifest 明确声明为 uni-app Vue3 CLI 工程', async () => {
test('员工端 tabBar 继续保留四个固定入口', async () => {
const source = await pageSource('../src/pages.json');
assert.match(source, /"selectedColor"\s*:\s*"#2f6bff"/);
assert.match(source, /"selectedColor"\s*:\s*"#e60012"/);
assert.match(source, /pages\/user\/today\/index/);
assert.match(source, /pages\/user\/practice\/index/);
assert.match(source, /pages\/user\/sop\/index/);
@@ -66,7 +66,7 @@ test('登录页按高保真原型保留品牌结构和紧凑验证码行', async
test('共享视觉基座包含员工端主色和卡片圆角 token', async () => {
const source = await pageSource('../src/styles/base.css');
assert.match(source, /--employee-primary:\s*#2f6bff/);
assert.match(source, /--employee-primary:\s*#e60012/);
assert.match(source, /--employee-card-radius:\s*24px/);
assert.match(source, /\.employee-card\s*\{/);
});
@@ -143,7 +143,7 @@ test('问师傅页保留搜索框、答案区和总结卡入口', async () => {
assert.match(source, /summary-panel/);
});
test('训练页保留任务卡、对话区、回复区和结果区', async () => {
test('训练页保留任务卡、对话区、回复区和结果区并互斥切换', async () => {
const source = await pageSource('../src/pages/user/practice/index.vue');
assert.match(source, /scenario-card/);
@@ -151,6 +151,11 @@ test('训练页保留任务卡、对话区、回复区和结果区', async () =>
assert.match(source, /dialogue-card/);
assert.match(source, /reply-card/);
assert.match(source, /result-card/);
assert.match(source, /class="result-actions"/);
assert.match(source, />再练一次<\/button>/);
assert.match(source, /practiceView === 'prep'/);
assert.match(source, /practiceView === 'active'/);
assert.match(source, /practiceView\.value === 'result'/);
});
test('员工练习页显示当前岗位而不是固定生活顾问', async () => {