feat(mobile): 重做员工端首页、问答和训练页视觉

统一员工端 tabBar、共享卡片和三大核心页面的视觉基础,同时保持现有移动端业务流程不变。补充 H5 输入兼容、页面结构和训练回滚行为的回归测试。
This commit is contained in:
2026-07-11 01:13:14 +08:00
parent ebbc69eed6
commit ee396d1e7a
19 changed files with 2171 additions and 439 deletions
@@ -0,0 +1,450 @@
# Mobile Uni Employee Pages Visual Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将员工端 `today / sop / practice` 三页及其强相关的 `tabBar / 共享卡片样式` 调整为接近设计基准稿的统一视觉,同时保持现有业务交互和接口行为不变。
**Architecture:** 采用“视觉优先、业务最小改动”的改法:先建立员工端共享视觉 token 与基础样式,再按首页、问师傅页、训练页的顺序逐页重排结构和样式。页面内部函数、接口调用与路由保留,主要改模板层级、类名、样式组织和少量静态资源引用。
**Tech Stack:** uni-app Vue3、Vite、TypeScript、CSS、Node 原生测试、H5 构建校验
---
### Task 1: 建立员工端共享视觉基座
**Files:**
- Modify: `mobile-uni/src/styles/base.css`
- Modify: `mobile-uni/src/pages.json`
- Test: `mobile-uni/tests/mobile-user-pages.test.mjs`
- [x] **Step 1: 写一个失败测试,锁住 Vue3 manifest 与员工端视觉约束继续有效**
```js
test('tabBar 继续保留员工端四个固定入口', async () => {
const source = await pageSource('../src/pages.json');
assert.match(source, /pages\/user\/today\/index/);
assert.match(source, /pages\/user\/practice\/index/);
assert.match(source, /pages\/user\/sop\/index/);
assert.match(source, /pages\/user\/profile\/index/);
});
```
- [x] **Step 2: 运行测试,确认新增断言先失败或当前文件尚未覆盖该约束**
Run: `node --test tests/mobile-user-pages.test.mjs`
Expected: 新增测试如果还没加入会缺失;加入后在修改前至少能作为后续回归校验入口存在。
- [x] **Step 3: 在 `base.css` 中整理员工端共享 token,不扩大到全站侵入式重置**
```css
:root {
--employee-bg: #f4f6fb;
--employee-surface: #ffffff;
--employee-primary: #2f6bff;
--employee-primary-soft: #eaf1ff;
--employee-success: #34b368;
--employee-warning: #ff8a1f;
--employee-danger: #ff5b4d;
--employee-text: #182230;
--employee-muted: #6b7280;
--employee-card-radius: 24px;
--employee-card-shadow: 0 12px 32px rgba(47, 107, 255, 0.08);
}
.employee-card {
border-radius: var(--employee-card-radius);
background: var(--employee-surface);
box-shadow: var(--employee-card-shadow);
}
.employee-section-title {
color: var(--employee-text);
font-size: 20px;
font-weight: 900;
}
```
- [x] **Step 4: 调整 `pages.json` 的员工端 tabBar 视觉参数,保留四 tab 结构和路径**
```json
"tabBar": {
"color": "#7b8494",
"selectedColor": "#2f6bff",
"backgroundColor": "#ffffff",
"borderStyle": "white",
"height": "66px",
"fontSize": "12px",
"iconWidth": "25px",
"spacing": "3px",
"list": [
{ "pagePath": "pages/user/today/index", "text": "今日" },
{ "pagePath": "pages/user/practice/index", "text": "练" },
{ "pagePath": "pages/user/sop/index", "text": "问" },
{ "pagePath": "pages/user/profile/index", "text": "我" }
]
}
```
- [x] **Step 5: 更新回归测试,验证 tabBar 四入口与共享样式关键 token 存在**
```js
test('共享视觉基座包含员工端主色和卡片圆角 token', async () => {
const source = await pageSource('../src/styles/base.css');
assert.match(source, /--employee-primary:/);
assert.match(source, /--employee-card-radius:/);
});
```
- [x] **Step 6: 运行测试验证通过**
Run: `node --test tests/mobile-user-pages.test.mjs`
Expected: PASS,且已有输入兼容与 manifest 测试继续通过。
- [ ] **Step 7: 提交这一阶段改动**
```bash
git add mobile-uni/src/styles/base.css mobile-uni/src/pages.json mobile-uni/tests/mobile-user-pages.test.mjs
git commit -m "feat: add employee visual foundation"
```
### Task 2: 重做员工端首页视觉
**Files:**
- Modify: `mobile-uni/src/pages/user/today/index.vue`
- Modify: `mobile-uni/src/styles/base.css`
- Test: `mobile-uni/tests/mobile-user-pages.test.mjs`
- [x] **Step 1: 为首页结构写失败测试,锁住设计稿要求的核心区块**
```js
test('员工首页保留师傅区、语音条、今日安排、我的地图四段结构', async () => {
const source = await pageSource('../src/pages/user/today/index.vue');
assert.match(source, /mentor-hero/);
assert.match(source, /voice-panel/);
assert.match(source, /今日安排/);
assert.match(source, /我的地图/);
});
```
- [x] **Step 2: 运行测试确认结构断言可作为改版护栏**
Run: `node --test tests/mobile-user-pages.test.mjs`
Expected: 失败或在旧结构下不足以覆盖新样式目标,后续补全后通过。
- [x] **Step 3: 重排首页模板,保留原有交互函数但调整为设计稿节奏**
```vue
<view class="mentor-hero employee-hero">
<view class="hero-copy">
<text class="hero-role">{{ positionProfile.value }}</text>
<text class="hero-title">{{ positionProfile.heroTitle }}</text>
<text class="hero-sub">有问题,随时问老师傅</text>
</view>
<image class="mentor-image" src="/static/mentor.png" mode="aspectFit" />
<view class="mentor-bubble">
<text>{{ mentorBubbleText }}</text>
</view>
</view>
<view class="voice-panel employee-card">
<button class="voice-primary" @click="goSop">...</button>
<button class="voice-secondary" @click="goSop">...</button>
</view>
<view class="section-card employee-card" v-else-if="hasSchedule">...</view>
<view class="section-card employee-card" v-if="loggedIn">...</view>
```
- [x] **Step 4: 用 scoped 样式还原首页视觉,控制人物占屏不超过首屏约五分之一**
```css
.today-page {
background: radial-gradient(circle at top, #eef5ff 0%, #f7f9fc 38%, #f4f6fb 100%);
}
.mentor-hero {
min-height: 220px;
padding: 28px 24px 0;
}
.mentor-image {
right: 24px;
bottom: 0;
width: 108px;
height: 148px;
}
.voice-panel {
margin-top: -28px;
grid-template-columns: 1.4fr 1fr;
}
.map-tile {
border-radius: 22px;
min-height: 136px;
}
```
- [x] **Step 5: 更新测试,验证首页地图主标题继续显示真实 `tile.title`,避免回退成单字标题**
```js
test('员工首页地图主标题继续显示真实工具名称', async () => {
const source = await pageSource('../src/pages/user/today/index.vue');
assert.match(source, /<text class="map-title">\{\{ tile\.title \}\}<\/text>/);
});
```
- [x] **Step 6: 运行测试验证首页改版没有破坏护栏**
Run: `node --test tests/mobile-user-pages.test.mjs`
Expected: PASS。
- [ ] **Step 7: 提交首页改版**
```bash
git add mobile-uni/src/pages/user/today/index.vue mobile-uni/src/styles/base.css mobile-uni/tests/mobile-user-pages.test.mjs
git commit -m "feat: redesign employee home page"
```
### Task 3: 重做问师傅页视觉与总结卡展现
**Files:**
- Modify: `mobile-uni/src/pages/user/sop/index.vue`
- Modify: `mobile-uni/src/styles/base.css`
- Test: `mobile-uni/tests/mobile-user-pages.test.mjs`
- [x] **Step 1: 为问师傅页写失败测试,锁住搜索主导与总结卡入口仍然存在**
```js
test('问师傅页保留搜索框、答案区和总结卡入口', async () => {
const source = await pageSource('../src/pages/user/sop/index.vue');
assert.match(source, /query-panel/);
assert.match(source, /answer-panel/);
assert.match(source, /summary-panel/);
});
```
- [x] **Step 2: 运行测试确认护栏建立成功**
Run: `node --test tests/mobile-user-pages.test.mjs`
Expected: 断言可执行,作为后续视觉重排的结构保护。
- [x] **Step 3: 收紧问师傅页头部,改为“轻标题 + 强搜索框”的布局**
```vue
<view class="page sop-page">
<view class="sop-toolbar">
<text class="page-title">查SOP</text>
<text class="page-subtitle">直接搜 SOP、案例和常用话术</text>
</view>
<view class="panel query-panel employee-card">
<textarea v-model="query" class="textarea sop-query-textarea" ... />
<view class="chip-row">...</view>
<button class="primary-button query-submit" ...>问老师傅</button>
</view>
</view>
```
- [x] **Step 4: 调整答案卡、引用依据和总结卡为更接近设计稿的卡片层级与底部浮层感**
```css
.sop-page {
background: linear-gradient(180deg, #f7f9fc 0%, #f4f6fb 100%);
}
.query-panel {
border-radius: 22px;
padding: 18px;
}
.answer-panel {
border-left: 0;
border-radius: 22px;
}
.modal-card {
border-radius: 28px 28px 0 0;
max-width: 430px;
width: calc(100% - 16px);
}
```
- [x] **Step 5: 保持 H5 输入兼容测试存在,防止误把 `textarea` 再次改成 `border-box`**
```js
test('SOP 查询框不覆盖 textarea 的 box-sizing', async () => {
const source = await pageSource('../src/pages/user/sop/index.vue');
const queryPanelStyle = source.match(/\.query-panel \.textarea \{[\s\S]*?\n\}/)?.[0] || '';
assert.equal(queryPanelStyle.includes('box-sizing'), false);
});
```
- [x] **Step 6: 运行测试验证通过**
Run: `node --test tests/mobile-user-pages.test.mjs`
Expected: PASS。
- [ ] **Step 7: 提交问师傅页改版**
```bash
git add mobile-uni/src/pages/user/sop/index.vue mobile-uni/src/styles/base.css mobile-uni/tests/mobile-user-pages.test.mjs
git commit -m "feat: redesign employee sop page"
```
### Task 4: 重做训练页视觉但保留训练逻辑
**Files:**
- Modify: `mobile-uni/src/pages/user/practice/index.vue`
- Modify: `mobile-uni/src/styles/base.css`
- Test: `mobile-uni/tests/mobile-user-pages.test.mjs`
- [x] **Step 1: 为训练页写失败测试,锁住训练关键区块仍存在**
```js
test('训练页保留场景区、对话区、回复区和结果区', async () => {
const source = await pageSource('../src/pages/user/practice/index.vue');
assert.match(source, /scenario-card/);
assert.match(source, /dialogue-card/);
assert.match(source, /reply-card/);
assert.match(source, /result-card/);
});
```
- [x] **Step 2: 运行测试确认结构护栏存在**
Run: `node --test tests/mobile-user-pages.test.mjs`
Expected: 断言可运行,用于防止视觉重排时误删功能区块。
- [x] **Step 3: 调整训练页模板,让头部、场景卡、老师傅提示更接近设计稿的训练面板结构**
```vue
<view class="practice-head">
<text class="practice-eyebrow">数字师傅 · 场景训练</text>
<text class="page-title">{{ activeScenarioName }}</text>
<text class="page-subtitle">与业主、老师傅完成一轮真实工作对话</text>
</view>
<view v-if="loggedIn" class="panel scenario-card employee-card">...</view>
<view v-if="turns.length" class="panel dialogue-card employee-card">...</view>
<view v-if="status === 'active' || status === 'submitting'" class="panel reply-card employee-card">...</view>
```
- [x] **Step 4: 用样式强化三类角色区分与老师傅提示卡,但不引入每轮真实评分**
```css
.turn-row.customer {
background: linear-gradient(180deg, #fff8f1 0%, #ffffff 100%);
}
.turn-row.trainee {
background: linear-gradient(180deg, #f2fbf6 0%, #ffffff 100%);
}
.turn-row.coach {
background: linear-gradient(180deg, #eef5ff 0%, #ffffff 100%);
}
.reply-card .primary-button {
min-height: 52px;
border-radius: 18px;
}
.help-card,
.mentor-rewrite {
border-radius: 22px;
}
```
- [x] **Step 5: 保留并复用提交失败回滚测试,避免训练视觉改版时误伤已有状态修复**
```js
test('训练页提交失败会回滚本次乐观追加的对话', async () => {
const source = await pageSource('../src/pages/user/practice/index.vue');
assert.match(source, /const previousTurns = turns\.value\.slice\(\)/);
assert.match(source, /turns\.value = previousTurns/);
});
```
- [x] **Step 6: 运行测试验证通过**
Run: `node --test tests/mobile-user-pages.test.mjs`
Expected: PASS。
- [ ] **Step 7: 提交训练页改版**
```bash
git add mobile-uni/src/pages/user/practice/index.vue mobile-uni/src/styles/base.css mobile-uni/tests/mobile-user-pages.test.mjs
git commit -m "feat: redesign employee practice page"
```
### Task 5: 全量验证与收尾
**Files:**
- Verify: `mobile-uni/src/pages/user/today/index.vue`
- Verify: `mobile-uni/src/pages/user/sop/index.vue`
- Verify: `mobile-uni/src/pages/user/practice/index.vue`
- Verify: `mobile-uni/src/styles/base.css`
- Verify: `mobile-uni/src/pages.json`
- Test: `mobile-uni/tests/mobile-user-pages.test.mjs`
- [x] **Step 1: 运行员工端页面回归测试**
Run: `node --test tests/mobile-user-pages.test.mjs`
Expected: PASS。
- [x] **Step 2: 运行全量单测**
Run: `npm run test:unit`
Expected: 全部 PASS;允许保留既有 WebSocket 噪音,只要没有失败。
- [x] **Step 3: 运行类型检查**
Run: `npm run typecheck`
Expected: PASS。
- [x] **Step 4: 运行 H5 构建**
Run: `npm run build:h5`
Expected: `Build complete.`
- [ ] **Step 5: 本地打开首页进行人工快速核对**
Run: `open "http://localhost:5175/h5/#/pages/user/today/index"`
Expected: 能看到新的首页首屏结构、语音条、今日安排、我的地图和统一 tabBar。
- [ ] **Step 6: 提交最终整体验证结果**
```bash
git add mobile-uni/src/pages/user/today/index.vue mobile-uni/src/pages/user/sop/index.vue mobile-uni/src/pages/user/practice/index.vue mobile-uni/src/styles/base.css mobile-uni/src/pages.json mobile-uni/tests/mobile-user-pages.test.mjs
git commit -m "feat: unify employee mobile page visuals"
```
## 计划自检
### Spec 覆盖检查
- 三页范围:Task 2、Task 3、Task 4 覆盖 `today / sop / practice`。
- 共享样式与 tabBar:Task 1 覆盖。
- 老师傅仅复用现有素材、不扩展业务:Task 2 与 Task 4 的模板调整限制在现有资源与逻辑内。
- SOP 不伪造能力:Task 3 明确保留真实能力,不新增假 tab 行为。
- 训练页不做每轮真实评分:Task 4 明确只改视觉分层,不新增该模块。
- 验证要求:Task 5 覆盖页面测试、全量单测、类型检查、H5 构建。
### Placeholder 扫描
- 无 `TODO`、`TBD`、`适当处理` 等空泛描述。
- 每个任务都包含具体文件、命令和代码片段。
### 类型与命名一致性
- 继续沿用现有页面类名:`scenario-card`、`dialogue-card`、`reply-card`、`summary-panel`、`voice-panel` 等。
- 复用现有关键回归点:`tile.title`、`previousTurns`、`box-sizing`、`vueVersion`。
@@ -0,0 +1,241 @@
# 员工端三页视觉改版设计
## 背景
当前 `mobile-uni` 员工端首页、问师傅页、训练页虽然已经有一轮样式调整,但整体仍然存在以下问题:
- 三页视觉语言不统一,像是独立拼接出来的页面,而不是同一套员工端产品。
- 信息层级和卡片节奏偏乱,重点入口不够聚焦,页面看起来“重、散、旧”。
- 与 `docs/feedback/2026070423/design/` 中已经采纳的 P1 视觉基准仍有明显差距。
- 当前诉求是优先解决“页面丑陋”的观感问题,而不是重写业务交互。
本次改版以视觉还原为主,业务交互保持最小改动。
## 目标
### 主要目标
- 统一员工端 `today / sop / practice` 三页视觉气质。
- 尽量向已采纳设计稿靠拢,优先还原布局、卡片层级、按钮形态、留白和色彩关系。
- 保持现有业务逻辑、接口调用、页面跳转、训练流程与登录流程不变。
- 允许联动调整员工端 `tabBar` 和少量共享基础样式,使三页不再割裂。
### 非目标
- 不新增后端字段或伪造后端能力。
- 不重写训练页状态机或 SOP 检索逻辑。
- 不做候选人端、主管端的同步视觉改版。
- 不在本次引入新的正式人物素材来源。
## 设计依据与约束
### 设计依据
- 首页视觉基准:`docs/feedback/2026070423/design/h5-redesign-employee-home.png`
- SOP 结果与总结卡基准:`docs/feedback/2026070423/design/h5-feedback-summary-modal.png`
- 训练页对手戏基准:`docs/feedback/2026070423/design/h5-feedback-voice-roleplay.png`
- 综合评审约束:`docs/feedback/2026070423/design/REVIEW.md`
### 必须遵守的约束
- 老师傅形象不照抄首页参考图中的女性写实形象,统一使用现有本地素材做“蓝工装插画风男性师傅”的近似表达。
- 首页人物只承担氛围与陪伴感,不大面积占屏;视觉占比控制在首屏约五分之一以内。
- 训练页不实现每轮真实评分,只保留轻提示或教练辅助信息。
- SOP 页如果设计稿出现“全部/SOP/案例/话术”类型 tab,在后端维度未落地前不得伪造成功能型筛选。
- 遵守 uni-app H5 输入兼容性规则,不对 `input` / `textarea` 设置 `box-sizing: border-box`。
## 范围
### 页面范围
- `src/pages/user/today/index.vue`
- `src/pages/user/sop/index.vue`
- `src/pages/user/practice/index.vue`
### 允许联动的共享部分
- `src/styles/base.css`
- `src/pages.json` 中员工端 `tabBar` 视觉配置
- 员工端页面使用的现有本地静态素材引用方式
### 明确不改的部分
- 页面路由结构
- 接口地址、参数与返回处理
- 训练开始、提交、结束、SOP 查询、总结卡生成等业务行为
- 登录与岗位确认流程
## 总体方案
采用“视觉优先还原型”方案:
- 保留现有业务结构和页面职责,不移动关键交互入口的语义。
- 对三页的首屏结构、标题区、功能卡、按钮区、辅助信息区进行重排和统一。
- 在共享样式层建立一套仅服务员工端三页的轻量视觉 token,包括背景、卡片、主色、辅助色、圆角、阴影、标题字号与留白节奏。
- 对 `tabBar` 做有限统一,让页面主视觉与底部导航形成同一套产品感知。
这条方案的核心是:让用户明显感觉“这是一套被设计过的产品”,而不是“旧页面换了点颜色”。
## 共享视觉系统
### 色彩
- 主蓝:用于首页主入口、主要行动按钮、SOP 核心操作。
- 辅橙:用于复盘、提醒、异议、次强调动作。
- 辅绿:用于师傅、建议、可执行正向反馈。
- 辅红:用于风险、命中红线、强提醒。
- 大面积背景:使用偏暖的浅灰白底,避免纯白页面带来的生硬感。
### 形态
- 卡片统一使用大圆角、轻阴影、弱边框。
- 主操作按钮统一做成更饱满的胶囊或大圆角矩形。
- 标签、chips、状态 badge 使用明显但不过分抢眼的弱底色。
- 页面内图文块尽量减少硬线分割,多用留白和卡片分区建立节奏。
### 文本层级
- 页面标题使用大号高对比字重。
- 模块标题明显强于正文,但弱于页面标题。
- 辅助说明统一压低饱和度,避免整页“处处都是重点”。
### TabBar
- 保留“今日 / 练 / 问 / 我”结构不变。
- 调整选中态、阴影、顶部边界和留白,让其更接近设计稿中的轻质底栏。
- 不改动页面跳转与当前 tab 逻辑。
## 分页面设计
### 1. 员工端首页 `today`
#### 目标
首页要尽量贴近 `h5-redesign-employee-home.png`:先建立“老师傅陪练”的第一印象,再把语音入口、今日安排、我的地图作为三个连续层级展开。
#### 结构
- 顶部使用浅背景氛围图区。
- 左侧为岗位与欢迎信息,右下为较小的老师傅形象与气泡。
- 头图区下方是大白卡语音入口,主入口明显、次入口收敛。
- 再下方是快捷 chips。
- 然后依次是“今日安排”“我的地图”“今日小贴士”。
#### 关键调整
- 头图不再追求“人物大图压场”,而是营造轻陪伴感。
- “问老师傅”作为首要入口,视觉上高于“打字问”。
- “今日安排”卡更像日程卡,而不是普通列表。
- “我的地图”保留四色功能块,但主标题显示真实业务名,单字仅作装饰符号。
- 整页通过更清晰的上下节奏建立首屏完成度。
### 2. 问师傅页 `sop`
#### 目标
页内重点从“普通查询页”升级为“搜索驱动的知识助手页”,并尽量贴近 `h5-feedback-summary-modal.png` 的结构氛围。
#### 结构
- 顶部收成更轻的标题与搜索区。
- 搜索框作为首屏主元素。
- 查询结果区按“直给答案 -> 要点 -> 提醒 -> 引用依据”分层。
- 总结卡入口保持单独区域。
- 总结卡展示形态从普通 modal 调整为更贴近设计稿的底部浮层信息图风格。
#### 关键调整
- 减少当前大块头图区的存在感,把注意力转回搜索行为。
- 查询按钮、快捷问题、答案卡的主次关系更明确。
- 总结卡内部采用更强的信息图分组感,但不新增保存、听一遍、跟读等未实现业务按钮。
- 若现阶段无法正确承载分类 tab,则不强行做功能性顶部分类。
### 3. 训练页 `practice`
#### 目标
把页面从“功能堆叠页”调整为更像训练对话面板的页面,同时遵守“不做每轮真实评分”的约束。
#### 结构
- 顶部为训练标题、场景标签和老师傅陪伴状态。
- 场景预习卡保留,但视觉上并入训练前准备区域。
- 对话区强化三类角色的视觉差异:业主、我、老师傅。
- 回复区的录音、转写、求助、提交按钮重新排版,但功能不变。
- 教练提示与 SOP 提示更像辅助卡,而不是和主流程抢层级。
- 结果卡在完成后仍独立呈现。
#### 关键调整
- 参考 `h5-feedback-voice-roleplay.png` 的对话氛围与角色卡感,但不照搬其每轮评分模块。
- 让“老师傅提醒”成为视觉上可信赖的辅助块。
- 保持现有训练逻辑顺序,不改开始、提交、结束时机。
## 组件与样式边界
本次优先在现有页面文件内完成视觉重排,不额外拆出大量新组件,理由如下:
- 用户当前诉求是快速改善三页视觉,而不是先做前端组件体系建设。
- 三页虽然要统一语言,但内容结构差异仍较大,过早抽象容易制造样式耦合。
- 共享部分只抽到 `base.css` 的 token 和少量通用类,避免引入新的复杂度。
允许的抽象上限:
- 共享 token
- 通用卡片/标题/按钮类
- 少量重复且完全稳定的视觉辅助类
不建议在本次新增多层组件封装。
## 验收标准
### 视觉验收
- 三页首屏都明显接近参考设计稿的节奏与气质。
- 员工端首页、SOP 页、训练页能够被一眼识别为同一套产品。
- 老师傅、语音入口、核心行动按钮、模块标题的层级明显优于当前版本。
- `tabBar` 与页面主视觉不再割裂。
### 行为验收
- 现有路由、接口、登录、训练、SOP 查询行为不回归。
- 不引入假交互、不新增无后端支持的视觉功能。
- 输入框与文本域在 H5 仍可正常聚焦、输入。
### 技术验收
- 至少完成类型检查、单测和 H5 构建验证。
- 如新增与视觉约束有关的轻量回归测试,应优先覆盖容易再次被误改的关键点。
## 风险与处理
### 风险 1:现有本地素材不足以完全贴近设计稿
处理:优先还原布局、比例、色彩和氛围;人物素材先做近似表达,不因素材不足阻塞页面改版。
### 风险 2:为了追视觉而误伤业务逻辑
处理:限定本次为视觉优先、交互最小改动,不重写流程函数和接口组织。
### 风险 3:共享样式改动波及其它页面
处理:共享样式只服务员工端相关页面,避免做全局侵入式 reset。
### 风险 4:SOP 设计稿含未落地能力
处理:只保留当前真实能力,视觉上做近似,不伪造功能。
## 实施顺序建议
1. 先整理员工端共享 token 和 tabBar 视觉基座。
2. 完成员工端首页改版,建立整套视觉语言。
3. 以首页风格为基准改 SOP 页。
4. 最后改训练页,避免其复杂交互拖慢整体节奏。
5. 跑类型检查、单测和 H5 构建验证。
## 规格自检结论
- 无占位符、TODO 或未定义术语。
- 方案范围清晰,聚焦三页与少量共享视觉层。
- 与用户已确认方向一致:优先还原视觉,业务交互最小改动。
- 与项目评审约束一致:人物统一、首页人物缩量、训练页不做每轮真评分、SOP 不伪造分类能力。
+1
View File
@@ -2,6 +2,7 @@
"name": "银城员工端",
"appid": "__UNI__AIHR",
"description": "物业AI人力资源系统用户侧",
"vueVersion": "3",
"versionName": "0.1.0",
"versionCode": "100",
"uniStatistics": {
+26 -8
View File
@@ -1,4 +1,10 @@
{
"easycom": {
"autoscan": true,
"custom": {
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
}
},
"pages": [
{
"path": "pages/user/today/index",
@@ -120,30 +126,42 @@
"navigationStyle": "custom",
"navigationBarTextStyle": "black",
"navigationBarTitleText": "银城员工端",
"navigationBarBackgroundColor": "#f8fafc",
"backgroundColor": "#f5f7fb"
"navigationBarBackgroundColor": "#f3f5f7",
"backgroundColor": "#f3f5f7"
},
"tabBar": {
"color": "#64748b",
"selectedColor": "#0f172a",
"color": "#7b8494",
"selectedColor": "#2f6bff",
"backgroundColor": "#ffffff",
"borderStyle": "white",
"height": "66px",
"fontSize": "12px",
"iconWidth": "25px",
"spacing": "3px",
"list": [
{
"pagePath": "pages/user/today/index",
"text": "今日"
"text": "今日",
"iconPath": "static/tabbar/today-normal.png",
"selectedIconPath": "static/tabbar/today-active.png"
},
{
"pagePath": "pages/user/practice/index",
"text": "练"
"text": "练",
"iconPath": "static/tabbar/practice-normal.png",
"selectedIconPath": "static/tabbar/practice-active.png"
},
{
"pagePath": "pages/user/sop/index",
"text": "问"
"text": "问",
"iconPath": "static/tabbar/ask-normal.png",
"selectedIconPath": "static/tabbar/ask-active.png"
},
{
"pagePath": "pages/user/profile/index",
"text": "我"
"text": "我",
"iconPath": "static/tabbar/profile-normal.png",
"selectedIconPath": "static/tabbar/profile-active.png"
}
]
}
+299 -109
View File
@@ -1,13 +1,15 @@
<template>
<view class="page practice-page">
<view class="page practice-page employee-page-shell">
<view class="practice-head">
<text class="page-title">场景练</text>
<text class="page-subtitle">按当前场景完成三角色对练,结束后自动评分并写入训练记录。</text>
<text class="employee-kicker practice-eyebrow">数字师傅 · 场景训练</text>
<text class="page-title">场景模拟</text>
<text class="page-subtitle">与业主、老师傅完成一轮真实工作对话,结束后自动写入训练记录。</text>
<text class="practice-tip">先看预习卡,再开始练习;红线命中时按老师傅建议重说本轮。</text>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<view v-if="!loggedIn" class="panel login-panel">
<view v-if="!loggedIn" class="panel login-panel employee-card">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后查看今日任务、开始练习并保存训练记录。</text>
@@ -15,41 +17,12 @@
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<view v-if="loggedIn" class="panel assignment-panel">
<view class="assignment-head">
<text class="panel-title">今日任务</text>
<button class="ghost-button refresh-button" :disabled="assignmentLoading" @click="loadAssignments">刷新</button>
<view v-if="loggedIn" class="panel scenario-card employee-card">
<view class="scenario-headline">
<text class="scenario-badge">今日小训</text>
<text class="scenario-title">{{ activeScenarioName }}</text>
<text class="scenario-desc">{{ scenarioDescription }}</text>
</view>
<view v-if="assignments.length" class="assignment-list">
<view v-for="item in assignments" :key="item.id" class="assignment-row" :class="{ highlighted: highlightedAssignmentId === item.id }">
<view class="assignment-copy">
<text class="assignment-badge">{{ item.source === 'daily' ? '每日三题' : '主管专项' }}</text>
<text class="assignment-title">{{ item.question || item.scenarioName }}</text>
<text v-if="assignmentDesc(item)" class="assignment-desc">{{ assignmentDesc(item) }}</text>
<text v-if="item.status === '已完成' && item.referenceAnswer" class="assignment-reference">参考:{{ item.referenceAnswer }}</text>
</view>
<view v-if="item.source === 'daily'" class="daily-answer">
<textarea
v-model="drillAnswers[item.id]"
class="textarea daily-textarea"
maxlength="220"
:disabled="item.status === '已完成'"
placeholder="写一句你的处理话术"
/>
<button class="primary-button small-button" :disabled="Boolean(answeringId) || item.status === '已完成'" @click="submitDaily(item)">
{{ item.status === '已完成' ? '已完成' : '提交' }}
</button>
</view>
<button v-else class="secondary-button small-button" :disabled="!canStartPractice" @click="startAssignment(item)">开始专项</button>
</view>
</view>
<view v-else class="empty">暂无今日任务</view>
</view>
<view v-if="loggedIn" class="panel scenario-card">
<text class="scenario-badge">今日小训</text>
<text class="scenario-title">{{ activeScenarioName }}</text>
<text class="scenario-desc">{{ scenarioDescription }}</text>
<view v-if="prepCard" class="prep-card">
<view class="prep-head">
<text>训练前预习卡</text>
@@ -78,9 +51,43 @@
</button>
</view>
<view v-if="turns.length" class="panel dialogue-card">
<view v-if="loggedIn" class="panel assignment-panel">
<view class="assignment-head">
<text class="panel-title">今日任务</text>
<button class="ghost-button refresh-button" :disabled="assignmentLoading" @click="loadAssignments">刷新</button>
</view>
<view v-if="assignments.length" class="assignment-list">
<view v-for="item in assignments" :key="item.id" class="assignment-row" :class="{ highlighted: highlightedAssignmentId === item.id }">
<view class="assignment-copy">
<text class="assignment-badge" :class="{ daily: item.source === 'daily' }">{{ item.source === 'daily' ? '每日三题' : '主管专项' }}</text>
<text class="assignment-title">{{ item.question || item.scenarioName }}</text>
<text v-if="assignmentDesc(item)" class="assignment-desc">{{ assignmentDesc(item) }}</text>
<text v-if="item.status === '已完成' && item.referenceAnswer" class="assignment-reference">参考:{{ item.referenceAnswer }}</text>
</view>
<view v-if="item.source === 'daily'" class="daily-answer">
<textarea
v-model="drillAnswers[item.id]"
class="textarea daily-textarea"
maxlength="220"
:disabled="item.status === '已完成'"
placeholder="写一句你的处理话术"
/>
<button class="primary-button small-button" :disabled="Boolean(answeringId) || item.status === '已完成'" @click="submitDaily(item)">
{{ item.status === '已完成' ? '已完成' : '提交' }}
</button>
</view>
<button v-else class="secondary-button small-button" :disabled="!canStartPractice" @click="startAssignment(item)">开始专项</button>
</view>
</view>
<view v-else class="empty">暂无今日任务</view>
</view>
<view v-if="turns.length" class="panel dialogue-card employee-card">
<view class="dialogue-head">
<text class="panel-title">对话</text>
<view class="dialogue-heading-block">
<text class="panel-title">对话</text>
<text class="dialogue-caption">保持承接情绪、说明动作、给出反馈节点。</text>
</view>
<text class="round-label">第 {{ Math.min(roundIndex + 1, 3) }} 轮</text>
</view>
<view v-if="hasCoachState" class="coach-state">
@@ -117,16 +124,16 @@
</view>
</view>
<view v-if="status === 'active' || status === 'submitting'" class="panel reply-card">
<view v-if="status === 'active' || status === 'submitting'" class="panel reply-card employee-card">
<text class="panel-title">你的回应</text>
<textarea
v-model="draft"
class="textarea"
class="textarea practice-textarea"
maxlength="500"
placeholder="先承接业主情绪,再说明处理动作和反馈时间。"
/>
<view class="reply-actions">
<button class="secondary-button" :disabled="busy || transcribing" @click="toggleRecording">
<button class="secondary-button" :class="{ 'recording-active': recording }" :disabled="busy || transcribing" @click="toggleRecording">
{{ recording ? '停止录音' : '录音转写' }}
</button>
<button class="ghost-button" :disabled="busy || recording || transcribing" @click="pickAudio">
@@ -143,7 +150,7 @@
<text v-if="draftAudioUrl" class="voice-state">已附带语音回放</text>
</view>
<view v-if="helpAnswer || helpRefs.length" class="panel help-card">
<view v-if="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">
@@ -151,7 +158,7 @@
</view>
</view>
<view v-if="result" class="panel result-card">
<view v-if="result" class="panel result-card employee-card">
<text class="result-label">本次评分</text>
<text class="result-score">{{ result.totalScore }}</text>
<text class="result-comment">{{ result.aiComment }}</text>
@@ -625,6 +632,7 @@ const submit = async () => {
message.value = '';
const audioUrl = draftAudioUrl.value || undefined;
const audioOssId = draftAudioOssId.value;
const previousTurns = turns.value.slice();
draft.value = '';
clearDraftAudio();
const retryingRedFlag = redFlagActive.value && lastCoachRoundIndex.value === roundIndex.value;
@@ -659,6 +667,7 @@ const submit = async () => {
status.value = 'active';
} catch (error) {
status.value = 'active';
turns.value = previousTurns;
draft.value = text;
draftAudioUrl.value = audioUrl || '';
draftAudioOssId.value = audioOssId;
@@ -704,21 +713,114 @@ onShow(() => {
<style scoped>
.practice-page {
padding-bottom: 92px;
background: #f4f7fb;
padding: 0 18px 96px;
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
}
.practice-head {
padding-top: 8px;
position: relative;
margin: 0 -18px;
padding: 30px 28px 24px;
overflow: hidden;
background: linear-gradient(180deg, #eef4ff 0%, #f5f8fd 100%);
}
.practice-head::after {
position: absolute;
right: 28px;
bottom: 24px;
width: 56px;
height: 3px;
background: #e70014;
content: '';
}
.practice-eyebrow {
display: inline-flex;
margin-bottom: 12px;
}
.practice-head .page-title {
color: #1f2b3d;
font-size: 31px;
line-height: 1.12;
}
.practice-head .page-subtitle {
max-width: 274px;
margin-top: 10px;
color: #607086;
font-size: 14px;
line-height: 1.55;
}
.practice-tip {
display: block;
max-width: 298px;
margin-top: 14px;
color: #315b8f;
font-size: 12px;
line-height: 1.5;
}
.login-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-top: 22px;
border-left: 4px solid #e70014;
box-shadow: var(--employee-card-shadow);
}
.login-panel .muted {
display: block;
max-width: 218px;
margin-top: 6px;
font-size: 13px;
line-height: 1.5;
}
.login-panel .login-button {
min-width: 84px;
min-height: 46px;
padding: 0 12px;
}
.scenario-card,
.dialogue-card,
.reply-card,
.help-card,
.result-card {
display: flex;
flex-direction: column;
gap: 12px;
gap: 14px;
}
.scenario-card {
margin-top: 22px;
padding: 20px;
border-color: #d7e5dc;
border-left: 4px solid #168243;
background: #ffffff;
box-shadow: var(--employee-card-shadow);
}
.scenario-headline {
display: flex;
flex-direction: column;
gap: 10px;
}
.scenario-card .primary-button {
width: 100%;
margin-top: 2px;
background: #168243;
box-shadow: 0 7px 14px rgba(22, 130, 67, 0.2);
}
.scenario-card .primary-button:active {
background: #106b35;
}
.assignment-head,
@@ -734,39 +836,71 @@ onShow(() => {
min-width: 82px;
}
.assignment-panel {
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
.assignment-head {
margin-top: 26px;
}
.assignment-head .panel-title {
color: #25282d;
font-size: 20px;
}
.assignment-head .refresh-button {
min-height: 36px;
padding: 0 12px;
border: 1px solid #dedee1;
border-radius: 18px;
background: #ffffff;
color: #66696f;
font-size: 13px;
}
.assignment-list {
margin-top: 12px;
margin-top: 14px;
}
.assignment-row {
margin-top: 10px;
padding: 12px;
border: 1px solid transparent;
border-radius: 14px;
background: #f8fafc;
margin-top: 12px;
padding: 16px;
border: 1px solid #deebff;
border-radius: 20px;
background: #ffffff;
box-shadow: 0 10px 26px rgba(47, 107, 255, 0.08);
}
.assignment-row.highlighted {
border-color: #2563eb;
background: #eff6ff;
border-color: #e70014;
background: #fff7f7;
}
.assignment-badge {
display: inline-flex;
width: fit-content;
padding: 4px 8px;
border-radius: 999px;
background: #e0f2fe;
color: #0369a1;
border-radius: 5px;
background: #fff0f1;
color: #bd0010;
font-size: 12px;
font-weight: 800;
}
.assignment-badge.daily {
background: #fff5df;
color: #a86100;
}
.assignment-title {
display: block;
margin-top: 8px;
color: #0f172a;
font-size: 15px;
color: #25282d;
font-size: 16px;
font-weight: 900;
line-height: 1.45;
}
@@ -775,7 +909,7 @@ onShow(() => {
.assignment-reference {
display: block;
margin-top: 6px;
color: #64748b;
color: #6d7076;
font-size: 13px;
line-height: 1.45;
}
@@ -792,21 +926,21 @@ onShow(() => {
.scenario-badge {
width: fit-content;
padding: 5px 10px;
border-radius: 999px;
background: #dbeafe;
color: #1d4ed8;
border-radius: 5px;
background: #edf7f0;
color: #168243;
font-size: 13px;
font-weight: 900;
}
.scenario-title {
color: #0f172a;
font-size: 24px;
color: #25282d;
font-size: 25px;
font-weight: 900;
}
.scenario-desc {
color: #64748b;
color: #696c72;
font-size: 15px;
line-height: 1.55;
}
@@ -814,10 +948,10 @@ onShow(() => {
.prep-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
border-radius: 14px;
background: #f8fafc;
gap: 12px;
padding: 14px 0 0;
border-top: 1px solid #dfe8e2;
background: transparent;
}
.prep-head {
@@ -829,13 +963,13 @@ onShow(() => {
.prep-head text:first-child,
.prep-label {
color: #0f172a;
color: #34373c;
font-size: 13px;
font-weight: 900;
}
.prep-head text:last-child {
color: #94a3b8;
color: #898c92;
font-size: 12px;
}
@@ -846,7 +980,7 @@ onShow(() => {
}
.prep-line {
color: #475569;
color: #5e6167;
font-size: 13px;
line-height: 1.45;
}
@@ -858,26 +992,53 @@ onShow(() => {
gap: 10px;
}
.dialogue-head {
justify-content: space-between;
}
.dialogue-heading-block {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 6px;
}
.dialogue-caption {
color: #6d7a8d;
font-size: 12px;
line-height: 1.45;
}
.dialogue-card {
border-color: #e1e2e5;
box-shadow: var(--employee-card-shadow);
}
.reply-actions {
flex-wrap: wrap;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.reply-actions button {
min-height: 48px;
min-width: 92px;
flex: 1;
min-width: 0;
padding: 0 8px;
white-space: nowrap;
word-break: keep-all;
}
.reply-actions .primary-button {
grid-column: span 2;
}
.voice-state {
color: #2563eb;
color: #168243;
font-size: 13px;
font-weight: 800;
}
.round-label {
color: #64748b;
color: #6d7076;
font-size: 13px;
}
@@ -893,7 +1054,7 @@ onShow(() => {
align-items: center;
grid-template-columns: 72px 1fr;
gap: 10px;
color: #475569;
color: #5f6268;
font-size: 13px;
font-weight: 800;
}
@@ -901,28 +1062,30 @@ onShow(() => {
.state-bar {
height: 8px;
overflow: hidden;
border-radius: 999px;
background: #e2e8f0;
border-radius: 4px;
background: #e3e4e6;
}
.state-fill {
height: 100%;
border-radius: 999px;
transition: width 0.3s ease-in-out;
}
.emotion-fill {
background: #f97316;
background: #d98200;
}
.trust-fill {
background: #16a34a;
background: #168243;
}
.red-flag {
padding: 9px 10px;
border-radius: 12px;
background: #fef2f2;
color: #b91c1c;
border-left: 3px solid #e70014;
border-radius: 4px;
background: #fff2f3;
color: #bd0010;
font-size: 13px;
font-weight: 800;
line-height: 1.45;
@@ -939,27 +1102,42 @@ onShow(() => {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
border-radius: 14px;
background: #f8fafc;
padding: 14px;
border: 1px solid #e5edf7;
border-left: 3px solid #9ca0a6;
border-radius: 18px;
background: #f8fbff;
}
.turn-row.customer {
border-left-color: #315b8f;
background: #f0f4f8;
}
.turn-row.trainee {
background: #eff6ff;
border-left-color: #e70014;
background: #fff6f6;
}
.turn-row.coach {
background: #fff7ed;
border-left-color: #168243;
background: #edf7f0;
}
.reply-actions button.recording-active {
border-color: #e70014;
background: #fff5f5;
color: #e70014;
}
.turn-role {
color: #334155;
color: #42454b;
font-size: 13px;
font-weight: 900;
}
.turn-text {
color: #172033;
color: #282b30;
font-size: 15px;
line-height: 1.55;
}
@@ -973,14 +1151,25 @@ onShow(() => {
.style-chip {
width: auto;
border-color: #c9d9ce;
background: #ffffff;
color: #168243;
}
.reply-actions button {
flex: 1;
.reply-card {
border-top: 0;
box-shadow: var(--employee-card-shadow);
}
.practice-textarea {
min-height: 124px;
border-color: #d6deea;
border-radius: 18px;
background: #f9fbff;
}
.help-answer {
color: #334155;
color: #45484e;
font-size: 15px;
line-height: 1.6;
}
@@ -993,21 +1182,21 @@ onShow(() => {
.help-ref {
padding: 5px 8px;
border-radius: 999px;
background: #e0f2fe;
color: #0369a1;
border-radius: 5px;
background: #edf7f0;
color: #168243;
font-size: 12px;
font-weight: 800;
}
.result-label {
color: #64748b;
color: #6d7076;
font-size: 14px;
font-weight: 800;
}
.result-score {
color: #1d4ed8;
color: #2f6bff;
font-size: 44px;
font-weight: 900;
line-height: 1;
@@ -1015,7 +1204,7 @@ onShow(() => {
.result-comment,
.mentor-rewrite text:last-child {
color: #334155;
color: #45484e;
font-size: 15px;
line-height: 1.55;
}
@@ -1025,12 +1214,13 @@ onShow(() => {
flex-direction: column;
gap: 6px;
padding: 12px;
border-radius: 12px;
background: #f8fafc;
border-left: 3px solid #d98200;
border-radius: 6px;
background: #fff9ed;
}
.mentor-rewrite text:first-child {
color: #0f172a;
color: #855000;
font-weight: 900;
}
</style>
+534 -60
View File
@@ -1,27 +1,32 @@
<template>
<view class="page sop-page">
<text class="page-title">问师傅</text>
<text class="page-subtitle">直接问工作中的 SOP、话术和流程。答案来自知识库检索,引用在后面。</text>
<view class="page sop-page employee-page-shell">
<view class="sop-head">
<text class="employee-kicker sop-eyebrow">SOP · 话术 · 工作流程</text>
<text class="page-title">问师傅</text>
<text class="page-subtitle">直接问工作中的 SOP、话术和流程。答案附带可追溯的知识库依据。</text>
<text class="toolbar-tip">先把问题问清楚,再按需生成总结卡,不伪造分类能力。</text>
</view>
<view class="panel query-panel">
<view class="panel query-panel employee-card">
<textarea
v-model="query"
class="textarea"
class="textarea sop-query-textarea"
maxlength="200"
:placeholder="quickQuestionPlaceholder"
/>
<text class="query-caption">常见问题可以直接点下面快捷问法,结果会保留引用依据。</text>
<view class="chip-row">
<button v-for="item in quickQuestions" :key="item" class="chip" :disabled="loading" @click="useQuickQuestion(item)">
{{ item }}
</button>
</view>
<button class="primary-button" :disabled="loading" @click="search">
{{ loading ? '检索中' : '查询' }}
<button class="primary-button query-submit" :disabled="loading" @click="search">
{{ loading ? '检索中' : '问老师傅' }}
</button>
<text v-if="message" class="status-message">{{ message }}</text>
</view>
<view v-if="!loggedIn" class="panel login-panel">
<view v-if="!loggedIn" class="panel login-panel employee-card">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后才能查询 SOP、生成总结卡并反馈答案。</text>
@@ -29,7 +34,7 @@
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<view v-if="result" class="panel answer-panel">
<view v-if="result" class="panel answer-panel employee-card">
<text class="panel-title">直给答案</text>
<text class="answer-text">{{ result.answer || '未生成直接答案,请查看下方引用。' }}</text>
@@ -54,16 +59,16 @@
</button>
<button
class="secondary-button"
:class="{ active: feedbackVerdict === 'down' }"
:class="{ active: feedbackVerdict === 'down', negative: feedbackVerdict === 'down' }"
:disabled="feedbackSubmitting || Boolean(feedbackVerdict)"
@click="feedback('down')"
>
不准
需要复核
</button>
</view>
</view>
<view v-if="result?.snippets?.length" class="panel">
<view v-if="result?.snippets?.length" class="panel sources-panel">
<text class="panel-title">引用依据</text>
<view v-for="(snippet, index) in result.snippets" :key="`${snippet.docTitle}-${index}`" class="snippet">
<text class="snippet-title">{{ snippet.docTitle }}</text>
@@ -72,35 +77,81 @@
</view>
</view>
<view v-if="result" class="panel summary-panel">
<view v-if="result" class="panel summary-panel employee-card">
<text class="panel-title">总结卡</text>
<text class="muted">需要时再生成,避免每次查询都调用模型。</text>
<text class="muted summary-copy">需要时再生成,适合把一段 SOP 整理成步骤卡、话术卡和提醒卡。</text>
<button class="ghost-button" :disabled="summaryLoading" @click="buildSummaryCard">
{{ summaryLoading ? '生成中' : '生成总结卡' }}
</button>
</view>
<view v-if="summaryCard" class="panel">
<text class="panel-title">{{ summaryCard.title }}</text>
<view class="summary-block">
<text class="summary-heading">步骤</text>
<text v-for="(step, index) in summaryCard.steps" :key="`${step.label}-${index}`" class="summary-line">
{{ index + 1 }}. {{ step.label }}: {{ step.desc }}
</text>
</view>
<view v-if="summaryCard.scripts.length" class="summary-block">
<text class="summary-heading">可说话术</text>
<text v-for="item in summaryCard.scripts" :key="item" class="summary-line">{{ item }}</text>
</view>
<view v-if="summaryCard.objections.length" class="summary-block">
<text class="summary-heading">异议回应</text>
<text v-for="item in summaryCard.objections" :key="item.concern" class="summary-line">
{{ item.concern }}: {{ item.response }}
</text>
</view>
<view v-if="summaryCard.reminders.length" class="summary-block">
<text class="summary-heading">提醒</text>
<text v-for="item in summaryCard.reminders" :key="item" class="summary-line">{{ item }}</text>
<view v-if="summaryCard" class="modal-mask" @click="closeSummary">
<view class="modal-card" @click.stop>
<view class="modal-header">
<text class="modal-title">{{ summaryCard.title }}</text>
<button class="modal-close" @click="closeSummary">×</button>
</view>
<scroll-view scroll-y class="modal-body">
<view v-if="summaryCard.steps?.length" class="info-section">
<view class="section-title-row">
<text class="badge-num">1</text>
<text class="info-section-title">核心步骤</text>
</view>
<view class="timeline">
<view v-for="(step, index) in summaryCard.steps" :key="index" class="timeline-item">
<view class="timeline-node">{{ index + 1 }}</view>
<view class="timeline-content">
<text class="timeline-label">{{ step.label }}</text>
<text class="timeline-desc">{{ step.desc }}</text>
</view>
</view>
</view>
</view>
<view v-if="summaryCard.scripts?.length" class="info-section speech-section">
<view class="section-title-row">
<text class="badge-num">2</text>
<text class="info-section-title">推荐话术</text>
</view>
<view class="speech-bubbles">
<view v-for="(script, index) in summaryCard.scripts" :key="index" class="speech-bubble">
<text class="quote-mark">“</text>
<text class="speech-text">{{ script }}</text>
</view>
</view>
</view>
<view v-if="summaryCard.objections?.length" class="info-section objection-section">
<view class="section-title-row">
<text class="badge-num">3</text>
<text class="info-section-title">常见异议与回应</text>
</view>
<view class="objection-list">
<view v-for="(obj, index) in summaryCard.objections" :key="index" class="objection-card">
<view class="objection-q">
<text class="q-badge">问</text>
<text class="q-text">{{ obj.concern }}</text>
</view>
<view class="objection-a">
<text class="a-badge">答</text>
<text class="a-text">{{ obj.response }}</text>
</view>
</view>
</view>
</view>
<view v-if="summaryCard.reminders?.length" class="info-section reminder-section">
<view class="section-title-row">
<text class="badge-num">4</text>
<text class="info-section-title">师傅提醒</text>
</view>
<view class="reminder-list">
<view v-for="(rem, index) in summaryCard.reminders" :key="index" class="reminder-item">
<text class="reminder-text">{{ rem }}</text>
</view>
</view>
</view>
</scroll-view>
<view class="modal-footer">
<button class="primary-button footer-btn" @click="closeSummary">我知道了</button>
</view>
</view>
</view>
</view>
@@ -259,6 +310,10 @@ const buildSummaryCard = async () => {
}
};
const closeSummary = () => {
summaryCard.value = null;
};
const goLogin = () => {
rememberLoginRedirect('/pages/user/sop/index');
uni.navigateTo({ url: '/pages/auth/login/index' });
@@ -297,7 +352,53 @@ onShow(() => {
<style scoped>
.sop-page {
padding-bottom: 48px;
padding: 0 18px 96px;
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
}
.sop-head {
position: relative;
margin: 0 -18px;
padding: 30px 28px 24px;
background: linear-gradient(180deg, #eef4ff 0%, #f5f8fd 100%);
}
.sop-head::after {
position: absolute;
right: 28px;
bottom: 24px;
width: 56px;
height: 3px;
background: #e70014;
content: '';
}
.sop-eyebrow {
display: inline-flex;
margin-bottom: 12px;
}
.sop-head .page-title {
color: #1f2b3d;
font-size: 31px;
line-height: 1.12;
}
.sop-head .page-subtitle {
max-width: 292px;
margin-top: 10px;
color: #607086;
font-size: 14px;
line-height: 1.55;
}
.toolbar-tip {
display: block;
max-width: 286px;
margin-top: 14px;
color: #315b8f;
font-size: 12px;
line-height: 1.5;
}
.query-panel,
@@ -308,30 +409,103 @@ onShow(() => {
gap: 14px;
}
.query-panel {
margin-top: 22px;
padding: 20px;
border-top: 0;
box-shadow: var(--employee-card-shadow);
}
.sop-query-textarea {
min-height: 112px;
border-color: #d6deea;
background: #f9fbff;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
.query-caption {
color: #6f7b8f;
font-size: 12px;
line-height: 1.5;
}
.query-panel .textarea {
min-height: 104px;
height: 104px;
border-color: #d9dade;
border-radius: 7px;
background: #fbfbfb;
color: #25282d;
}
.query-panel .chip-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px;
}
.query-panel .chip {
min-height: 38px;
padding: 0 10px;
border-color: #dbe5f4;
border-radius: 999px;
background: #f7faff;
color: #47607f;
font-size: 13px;
white-space: nowrap;
}
.query-submit {
width: 100%;
margin-top: 2px;
min-height: 50px;
border-radius: 18px;
background: linear-gradient(135deg, #2f6bff, #5a8cff);
box-shadow: 0 14px 28px rgba(47, 107, 255, 0.22);
}
.login-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
gap: 14px;
margin-top: 22px;
border-left: 4px solid #e70014;
box-shadow: var(--employee-card-shadow);
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
font-size: 13px;
line-height: 1.5;
}
.login-button {
min-width: 84px;
width: 96px;
min-width: 96px;
flex: 0 0 96px;
padding: 0 8px;
white-space: nowrap;
}
.answer-text {
color: #0f172a;
color: #243246;
font-size: 18px;
font-weight: 700;
line-height: 1.65;
}
.answer-panel {
border-color: #d9e7de;
border-left: 4px solid #168243;
box-shadow: var(--employee-card-shadow);
}
.answer-panel .panel-title {
color: #168243;
}
.keypoints {
display: flex;
flex-direction: column;
@@ -340,9 +514,10 @@ onShow(() => {
.keypoint {
padding: 10px 12px;
border-radius: 10px;
background: #f8fafc;
color: #334155;
border-left: 3px solid #168243;
border-radius: 5px;
background: #f2f8f4;
color: #45484e;
font-size: 15px;
line-height: 1.5;
}
@@ -352,9 +527,10 @@ onShow(() => {
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border-radius: 10px;
background: #fff7ed;
color: #9a3412;
border-left: 3px solid #d98200;
border-radius: 5px;
background: #fff8e9;
color: #855000;
font-size: 13px;
line-height: 1.45;
}
@@ -366,9 +542,29 @@ onShow(() => {
}
.feedback-actions .active {
border-color: #16a34a;
background: #dcfce7;
color: #166534;
border-color: #168243;
background: #edf7f0;
color: #168243;
}
.feedback-actions .negative {
border-color: #e70014;
background: #fff1f2;
color: #bd0010;
}
.sources-panel {
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
.sources-panel .panel-title {
display: block;
margin-top: 26px;
color: #25282d;
font-size: 20px;
}
.snippet {
@@ -376,24 +572,26 @@ onShow(() => {
margin-top: 12px;
flex-direction: column;
gap: 6px;
padding: 12px;
border-radius: 12px;
background: #f8fafc;
padding: 14px;
border: 1px solid #e3e4e7;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 5px 14px rgba(37, 40, 45, 0.04);
}
.snippet-title {
color: #0f172a;
color: #34373c;
font-weight: 800;
}
.snippet-content {
color: #475569;
color: #60636a;
font-size: 14px;
line-height: 1.55;
}
.snippet-id {
color: #94a3b8;
color: #96999f;
font-size: 12px;
}
@@ -404,14 +602,290 @@ onShow(() => {
gap: 8px;
}
.summary-heading {
color: #1d4ed8;
.summary-panel {
border-color: #eadfbd;
background: #fffdf7;
box-shadow: var(--employee-card-shadow);
}
.summary-panel .muted {
font-size: 13px;
line-height: 1.5;
}
.summary-copy {
max-width: 278px;
}
.summary-panel .ghost-button {
min-height: 48px;
border: 1px solid #d7e1f5;
border-radius: 18px;
background: #f6f9ff;
color: #315b8f;
}
/* Modal mask overlay */
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
padding: 20px;
}
.modal-card {
width: 100%;
max-width: 400px;
max-height: 80vh;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
overflow: hidden;
border-top: 5px solid #168243;
}
.modal-header {
padding: 16px 20px;
border-bottom: 1px solid #f1f5f9;
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-title {
font-size: 18px;
font-weight: 900;
color: #0f172a;
}
.modal-close {
background: transparent;
border: none;
font-size: 24px;
color: #94a3b8;
cursor: pointer;
padding: 0;
line-height: 1;
width: auto;
min-height: auto;
}
.modal-body {
flex: 1;
padding: 20px;
overflow-y: auto;
max-height: 55vh;
}
.modal-footer {
padding: 16px 20px;
border-top: 1px solid #f1f5f9;
}
.footer-btn {
width: 100%;
background: #168243;
color: #ffffff;
}
/* Infographic Sections */
.info-section {
margin-bottom: 24px;
}
.section-title-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.badge-num {
width: 20px;
height: 20px;
background: #168243;
color: #ffffff;
border-radius: 50%;
font-size: 11px;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
}
.info-section-title {
font-size: 15px;
font-weight: 900;
color: #1e293b;
}
/* Steps Timeline */
.timeline {
position: relative;
padding-left: 14px;
}
.timeline::before {
content: '';
position: absolute;
left: 5px;
top: 6px;
bottom: 6px;
width: 2px;
background: #e2e8f0;
}
.timeline-item {
position: relative;
padding-bottom: 16px;
display: flex;
gap: 12px;
}
.timeline-item:last-child {
padding-bottom: 0;
}
.timeline-node {
position: absolute;
left: -14px;
width: 12px;
height: 12px;
background: #168243;
color: #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 8px;
font-weight: 800;
}
.summary-line {
.timeline-content {
flex: 1;
}
.timeline-label {
display: block;
font-size: 14px;
font-weight: 800;
color: #334155;
font-size: 15px;
line-height: 1.55;
margin-bottom: 4px;
}
.timeline-desc {
display: block;
font-size: 12px;
color: #64748b;
line-height: 1.45;
}
/* Speech bubbles/scripts */
.speech-bubble {
position: relative;
background: #f2f8f4;
border-left: 3px solid #168243;
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 10px;
}
.quote-mark {
position: absolute;
left: 6px;
top: -2px;
font-size: 24px;
color: rgba(22, 130, 67, 0.15);
font-family: Georgia, serif;
line-height: 1;
}
.speech-text {
font-size: 13.5px;
color: #1e293b;
line-height: 1.5;
position: relative;
z-index: 1;
}
/* Objections Q&A */
.objection-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px;
margin-bottom: 10px;
}
.objection-q,
.objection-a {
display: flex;
gap: 8px;
}
.objection-q {
margin-bottom: 8px;
}
.q-badge,
.a-badge {
flex: 0 0 18px;
height: 18px;
font-size: 10px;
font-weight: 800;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.q-badge {
background: #fee2e2;
color: #b91c1c;
}
.a-badge {
background: #dcfce7;
color: #166534;
}
.q-text {
font-size: 13px;
font-weight: 800;
color: #334155;
line-height: 1.4;
}
.a-text {
font-size: 13px;
color: #475569;
line-height: 1.45;
}
/* Reminders alert box */
.reminder-item {
display: flex;
gap: 8px;
background: #fffbeb;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 8px;
align-items: flex-start;
border-left: 3px solid #d97706;
}
.reminder-text {
font-size: 13px;
color: #78350f;
line-height: 1.45;
}
</style>
+367 -199
View File
@@ -1,9 +1,12 @@
<template>
<view class="page today-page">
<view class="page today-page employee-page-shell">
<view v-if="message" class="status-message">{{ message }}</view>
<view class="mentor-hero">
<image class="hero-scene" src="/static/mentor.png" mode="aspectFill" />
<view class="hero-scene-scrim" />
<view class="hero-copy">
<text class="employee-kicker hero-kicker">数字师傅 · 今日带练</text>
<text class="hero-role">{{ positionProfile.value }}</text>
<text class="hero-title">{{ positionProfile.heroTitle }}</text>
<text class="hero-sub">有问题,随时问老师傅</text>
@@ -16,8 +19,13 @@
<view class="voice-panel">
<button class="voice-primary" @click="goSop">
<text class="voice-mark">问</text>
<text>问老师傅</text>
<view class="voice-emblem">
<text class="voice-mark">问</text>
</view>
<view class="voice-copy">
<text>问老师傅</text>
<text>语音或文字都可以</text>
</view>
</button>
<button class="voice-secondary" @click="goSop">
<text class="keyboard-mark">键</text>
@@ -33,7 +41,7 @@
</view>
</view>
<view class="section-card login-section" v-if="!loggedIn">
<view class="section-card employee-card login-section" v-if="!loggedIn">
<view>
<text class="login-title">登录后查看今日安排</text>
<text class="login-desc">训练任务、复习提醒和成长数据会按你的手机号展示。</text>
@@ -41,11 +49,14 @@
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<view class="section-card" v-else-if="hasSchedule">
<view class="section-card employee-card" v-else-if="hasSchedule">
<view class="section-head">
<view class="section-title">
<text class="section-icon">日</text>
<text>今日安排</text>
<view class="section-heading-block">
<view class="section-title">
<text class="section-icon">日</text>
<text>今日安排</text>
</view>
<text class="section-subtitle">先完成训练,再补一轮复习,今天的学习节奏就完整了。</text>
</view>
<button class="section-link" @click="openTool('训练')">查看全部</button>
</view>
@@ -69,11 +80,14 @@
</view>
</view>
<view v-if="loggedIn" class="section-card">
<view v-if="loggedIn" class="section-card employee-card">
<view class="section-head">
<view class="section-title">
<text class="section-icon">图</text>
<text>{{ loggedIn ? '我的地图' : '常用入口' }}</text>
<view class="section-heading-block">
<view class="section-title">
<text class="section-icon">图</text>
<text>{{ loggedIn ? '我的地图' : '常用入口' }}</text>
</view>
<text class="section-subtitle">把今天最常用的问、练、学、报都放在手边。</text>
</view>
</view>
<view v-if="mapTiles.length" class="map-grid">
@@ -84,7 +98,7 @@
:class="tile.tone"
@click="openTool(tile.title)"
>
<image class="map-icon" :src="tile.icon" mode="aspectFit" />
<text class="map-symbol">{{ tile.shortTitle }}</text>
<view class="map-copy">
<text class="map-title">{{ tile.title }}</text>
<text class="map-desc">{{ tile.desc }}</text>
@@ -123,13 +137,6 @@ const primaryTipQuestion = computed(() => quickQuestions.value[0] || '岗位SOP'
const pendingSopQueryKey = 'aihr_sop_pending_query';
const pendingAssignmentKey = 'aihr_pending_assignment_id';
const iconFor = (title: string) => {
if (title.includes('SOP') || title.includes('查')) return '/static/map-icons/ask.png';
if (title.includes('练')) return '/static/map-icons/practice.png';
if (title.includes('学') || title.includes('案例')) return '/static/map-icons/learn.png';
return '/static/map-icons/report.png';
};
const toneFor = (title: string) => {
if (title.includes('SOP') || title.includes('查')) return 'ask';
if (title.includes('练')) return 'practice';
@@ -137,14 +144,28 @@ const toneFor = (title: string) => {
return 'report';
};
const shortTitleFor = (tone: string) => {
if (tone === 'ask') return '问';
if (tone === 'practice') return '练';
if (tone === 'learn') return '学';
return '报';
};
const toolOrder = ['ask', 'practice', 'learn', 'report'];
const mapTiles = computed(() => {
if (!home.value?.tools?.length) return [];
return home.value.tools.map((item) => ({
title: item.title,
desc: item.desc.replace(/\n/g, ' / '),
tone: toneFor(item.title),
icon: iconFor(item.title)
}));
return home.value.tools
.map((item) => {
const tone = toneFor(item.title);
return {
title: item.title,
shortTitle: shortTitleFor(tone),
desc: item.desc.replace(/\n/g, ' / '),
tone
};
})
.sort((left, right) => toolOrder.indexOf(left.tone) - toolOrder.indexOf(right.tone));
});
const primaryAssignment = computed(() =>
@@ -261,169 +282,261 @@ onShow(() => {
</script>
<style scoped>
/* Homepage composition follows the approved employee-home visual reference. */
.today-page {
min-height: 100vh;
padding: 0 14px 86px;
background: #f4f7fb;
padding: 0 18px 96px;
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
}
.mentor-hero {
position: relative;
height: 224px;
margin: 0 -14px;
height: 244px;
margin: 0 -18px;
overflow: hidden;
background:
linear-gradient(180deg, rgba(237, 247, 255, 0.72), rgba(244, 247, 251, 0.98)),
linear-gradient(135deg, #d9ecff 0%, #f7fbff 45%, #dfeff2 100%);
border: 0;
background: #f0f1ee;
}
.mentor-hero::before {
display: none;
}
.hero-scene,
.hero-scene-scrim {
position: absolute;
right: -36px;
bottom: 12px;
width: 190px;
height: 96px;
border-radius: 30px;
background: rgba(255, 255, 255, 0.46);
content: '';
inset: -18px;
width: calc(100% + 36px);
height: calc(100% + 36px);
}
.hero-scene {
object-fit: cover;
opacity: 0.18;
filter: blur(13px) saturate(0.34) brightness(1.28);
transform: scale(1.12);
}
.hero-scene-scrim {
background: rgba(247, 247, 248, 0.76);
}
.hero-copy {
position: absolute;
left: 22px;
left: 28px;
top: 34px;
z-index: 2;
display: flex;
flex-direction: column;
}
.hero-kicker {
margin-bottom: 12px;
}
.hero-role,
.hero-title {
color: #11235a;
font-size: 22px;
font-weight: 900;
line-height: 1.08;
color: #25282d;
font-size: 29px;
line-height: 1.04;
letter-spacing: 0;
}
.hero-sub {
margin-top: 8px;
color: #1e3a8a;
font-size: 13px;
font-weight: 700;
margin-top: 12px;
color: #6a6d72;
font-size: 15px;
line-height: 1.35;
}
.mentor-image {
position: absolute;
right: 88px;
bottom: 14px;
right: 105px;
bottom: 0;
z-index: 1;
width: 104px;
height: 138px;
border-radius: 24px;
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.14);
width: 132px;
height: 176px;
border-radius: 0;
filter: drop-shadow(0 8px 8px rgba(37, 40, 45, 0.16));
-webkit-clip-path: ellipse(44% 56% at 50% 48%);
clip-path: ellipse(44% 56% at 50% 48%);
-webkit-mask-image: radial-gradient(ellipse 68% 61% at 50% 42%, #000 58%, transparent 100%);
mask-image: radial-gradient(ellipse 68% 61% at 50% 42%, #000 58%, transparent 100%);
}
.mentor-bubble {
position: absolute;
right: 16px;
top: 80px;
right: 18px;
top: 96px;
z-index: 3;
width: 128px;
padding: 12px 14px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.12);
width: 142px;
padding: 14px 16px;
border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: 22px;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 10px 24px rgba(37, 40, 45, 0.14);
}
.mentor-bubble text {
color: #111827;
color: #1c2940;
font-size: 17px;
font-weight: 700;
line-height: 1.45;
font-weight: 800;
line-height: 1.4;
}
.voice-panel {
position: relative;
z-index: 4;
display: grid;
margin: -34px 8px 0;
padding: 16px;
grid-template-columns: 1fr 1fr;
gap: 14px;
border-radius: 22px;
min-height: 106px;
margin: -42px 10px 0;
padding: 15px;
grid-template-columns: minmax(0, 1fr) 112px;
gap: 0;
border: 1px solid rgba(47, 74, 117, 0.08);
border-radius: 25px;
background: #ffffff;
box-shadow: 0 14px 38px rgba(15, 23, 42, 0.12);
box-shadow: 0 14px 36px rgba(47, 107, 255, 0.12);
}
.voice-primary,
.voice-secondary {
display: flex;
min-height: 64px;
min-height: 76px;
align-items: center;
justify-content: center;
gap: 10px;
border-radius: 18px;
border-radius: 0;
background: transparent;
color: #1d4ed8;
font-size: 20px;
font-weight: 800;
color: #e70014;
}
.voice-secondary {
color: #4b5563;
background: #f1f5f9;
.voice-primary {
justify-content: flex-start;
gap: 14px;
padding: 0 16px 0 4px;
border-right: 1px solid #e7e7e9;
}
.voice-primary text:last-child,
.voice-secondary text:last-child {
white-space: nowrap;
}
.voice-mark,
.keyboard-mark {
.voice-emblem {
display: flex;
width: 46px;
height: 46px;
width: 60px;
height: 60px;
flex: none;
align-items: center;
justify-content: center;
border-radius: 50%;
background: linear-gradient(135deg, #2563eb, #1d9bf0);
background: #e70014;
box-shadow: 0 0 0 9px #fff0f1, 0 7px 14px rgba(231, 0, 20, 0.22);
}
.voice-mark {
width: auto;
height: auto;
border-radius: 0;
background: transparent;
color: #ffffff;
font-size: 18px;
font-size: 26px;
}
.voice-copy {
display: flex;
min-width: 0;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.voice-copy text:first-child {
font-size: 21px;
font-weight: 900;
white-space: nowrap;
}
.voice-copy text:last-child {
color: #777a80;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.voice-secondary {
gap: 7px;
padding: 0 8px;
border-radius: 24px;
background: #f1f1f2;
color: #45474d;
font-size: 17px;
}
.keyboard-mark {
background: #e5e7eb;
color: #4b5563;
display: flex;
width: 30px;
height: 30px;
flex: none;
align-items: center;
justify-content: center;
border-radius: 9px;
background: #dedee1;
color: #45474d;
font-size: 14px;
}
.mentor-chips {
margin-top: 14px;
margin: 22px 0 0;
}
.mentor-chips .chip-row {
justify-content: space-between;
gap: 8px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.mentor-chips .chip {
min-height: 42px;
flex: 1;
border: 1px solid rgba(203, 213, 225, 0.7);
position: relative;
display: flex;
min-height: 48px;
align-items: center;
justify-content: center;
gap: 5px;
padding: 0 6px;
border: 0;
border-radius: 24px;
background: #ffffff;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
color: #1f2937;
font-size: 15px;
box-shadow: 0 7px 18px rgba(37, 40, 45, 0.08);
color: #34373c;
font-size: 14px;
font-weight: 800;
white-space: nowrap;
}
.mentor-chips .chip::before {
width: 8px;
height: 8px;
flex: none;
border-radius: 50%;
background: #e70014;
content: '';
}
.mentor-chips .chip::after {
content: none;
}
.mentor-chips .chip:nth-child(2)::before {
background: #d98200;
}
.mentor-chips .chip:nth-child(3)::before {
background: #168243;
}
.section-card {
margin-top: 18px;
margin-top: 24px;
padding: 18px;
border-radius: 18px;
border: 1px solid rgba(47, 74, 117, 0.08);
border-radius: var(--employee-card-radius);
background: #ffffff;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.07);
box-shadow: var(--employee-card-shadow);
}
.login-section {
@@ -431,6 +544,7 @@ onShow(() => {
align-items: center;
justify-content: space-between;
gap: 12px;
border-left: 4px solid #e70014;
}
.login-title,
@@ -439,74 +553,84 @@ onShow(() => {
}
.login-title {
color: #111827;
font-size: 18px;
font-weight: 900;
font-size: 19px;
}
.login-desc {
margin-top: 6px;
color: #64748b;
font-size: 13px;
line-height: 1.4;
max-width: 190px;
font-size: 14px;
line-height: 1.48;
}
.today-page .primary-button,
.login-button {
flex: 0 0 84px;
min-width: 84px;
white-space: nowrap;
width: 96px;
min-width: 96px;
min-height: 50px;
flex: 0 0 96px;
border-radius: 14px;
background: #e70014;
box-shadow: 0 8px 16px rgba(231, 0, 20, 0.2);
}
.section-head,
.section-title {
display: flex;
align-items: center;
gap: 10px;
color: #25282d;
font-size: 22px;
}
.section-head {
justify-content: space-between;
.section-heading-block {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 8px;
}
.section-title {
gap: 9px;
color: #111827;
font-size: 21px;
font-weight: 900;
.section-subtitle {
color: #707682;
font-size: 13px;
line-height: 1.5;
}
.section-icon {
width: 28px;
height: 28px;
border-radius: 9px;
background: #fff0f1;
color: #e70014;
font-size: 14px;
}
.section-head {
display: flex;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
border-radius: 8px;
background: #dbeafe;
color: #2563eb;
font-size: 13px;
font-weight: 900;
justify-content: space-between;
}
.section-link {
color: #64748b;
font-size: 14px;
flex: none;
font-size: 15px;
}
.schedule-list {
display: flex;
margin-top: 16px;
margin-top: 18px;
flex-direction: column;
gap: 10px;
gap: 12px;
}
.schedule-row {
display: flex;
min-height: 82px;
min-height: 94px;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 15px;
background: #f8fafc;
padding: 14px;
border: 0;
border-radius: 18px;
background: #f4f4f5;
}
.step-dot {
@@ -523,11 +647,11 @@ onShow(() => {
}
.step-dot.blue {
background: #2563eb;
background: #e70014;
}
.step-dot.orange {
background: #f97316;
background: #d98200;
}
.schedule-copy {
@@ -539,26 +663,22 @@ onShow(() => {
}
.schedule-title {
color: #111827;
font-size: 16px;
font-weight: 900;
line-height: 1.25;
font-size: 17px;
}
.schedule-desc {
color: #64748b;
font-size: 13px;
line-height: 1.35;
font-size: 14px;
}
.schedule-action {
display: flex;
width: 68px;
min-height: 42px;
width: 76px;
min-height: 48px;
flex: none;
align-items: center;
justify-content: center;
border-radius: 12px;
border-radius: 14px;
box-shadow: 0 7px 14px rgba(231, 0, 20, 0.2);
color: #ffffff;
font-size: 16px;
font-weight: 900;
@@ -566,117 +686,165 @@ onShow(() => {
}
.blue-action {
background: #2563eb;
background: #e70014;
}
.orange-action {
background: #f97316;
background: #d98200;
box-shadow: 0 7px 14px rgba(217, 130, 0, 0.18);
}
.map-grid {
display: grid;
margin-top: 16px;
margin-top: 18px;
grid-template-columns: 1fr 1fr;
gap: 10px;
gap: 12px;
}
.map-tile {
position: relative;
display: flex;
min-height: 108px;
display: grid;
min-height: 128px;
grid-template-columns: 42px minmax(0, 1fr) 14px;
align-items: center;
gap: 12px;
gap: 9px;
padding: 16px 12px;
border-radius: 17px;
border: 0;
border-radius: 19px;
color: #ffffff;
overflow: hidden;
text-align: left;
}
.map-tile.ask {
background: linear-gradient(135deg, #1d4ed8, #2682ff);
background: linear-gradient(135deg, #c90012, #f02135);
}
.map-tile.practice {
background: linear-gradient(135deg, #16a34a, #22c55e);
background: linear-gradient(135deg, #0d7137, #239954);
}
.map-tile.learn {
background: linear-gradient(135deg, #f59e0b, #fb923c);
background: linear-gradient(135deg, #ba6d00, #e89410);
}
.map-tile.report {
background: linear-gradient(135deg, #ef4444, #f97316);
background: linear-gradient(135deg, #5a5d63, #85888d);
}
.map-icon {
width: 42px;
height: 42px;
flex: none;
filter: brightness(0) invert(1);
.map-symbol {
display: flex;
width: 40px;
height: 40px;
align-items: center;
justify-content: center;
border: 2px solid rgba(255, 255, 255, 0.76);
border-radius: 14px;
color: #ffffff;
font-size: 22px;
font-weight: 900;
}
.map-title {
color: #ffffff;
font-size: 26px;
line-height: 1;
}
.map-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}
.map-title {
color: #ffffff;
font-size: 24px;
font-weight: 900;
line-height: 1.1;
}
.map-desc {
margin-top: 6px;
color: rgba(255, 255, 255, 0.9);
margin-top: 7px;
color: rgba(255, 255, 255, 0.92);
font-size: 13px;
line-height: 1.35;
line-height: 1.28;
}
.map-arrow {
color: #ffffff;
font-size: 28px;
font-weight: 500;
font-size: 25px;
}
.tip-card {
display: flex;
margin-top: 18px;
min-height: 58px;
margin-top: 22px;
min-height: 64px;
align-items: center;
gap: 10px;
padding: 0 14px;
border-radius: 16px;
background: #ffffff;
color: #475569;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.06);
padding: 0 16px;
border: 1px solid rgba(255, 179, 64, 0.18);
border-radius: 20px;
background: #fff7eb;
color: #64666b;
box-shadow: 0 10px 24px rgba(255, 155, 61, 0.08);
}
.tip-mark {
display: flex;
width: 28px;
height: 28px;
width: 30px;
height: 30px;
flex: none;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #fef3c7;
color: #d97706;
font-size: 13px;
font-weight: 900;
border-radius: 10px;
background: #fff0c4;
color: #bf7000;
}
.tip-card text:nth-child(2) {
flex: 1;
font-size: 14px;
line-height: 1.45;
font-size: 15px;
}
.tip-arrow {
color: #94a3b8;
color: #85878c;
font-size: 24px;
}
@media (max-width: 350px) {
.hero-role,
.hero-title {
font-size: 26px;
}
.mentor-image {
right: 63px;
width: 122px;
height: 164px;
}
.mentor-bubble {
right: 12px;
width: 128px;
padding: 12px;
}
.voice-panel {
grid-template-columns: minmax(0, 1fr) 98px;
margin-right: 4px;
margin-left: 4px;
padding: 12px;
}
.voice-primary {
gap: 10px;
padding-right: 10px;
}
.voice-emblem {
width: 52px;
height: 52px;
}
.voice-copy text:first-child {
font-size: 18px;
}
.mentor-chips .chip {
font-size: 13px;
}
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -0,0 +1,16 @@
Lucide icons
Copyright (c) 2020 Lucide Contributors
ISC License
Permission to use, copy, modify, and distribute this software for any purpose
with or without fee is hereby granted, provided that the above copyright notice
and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

+138 -63
View File
@@ -1,17 +1,41 @@
page {
min-height: 100%;
background: #f5f7fb;
color: #172033;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f3f5f7;
color: #182230;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Segoe UI", sans-serif;
}
:root {
--mobile-shell-max-width: 430px;
--surface: #ffffff;
--canvas: #f3f5f7;
--ink: #182230;
--muted: #647083;
--line: #dfe4ea;
--brand: #e70014;
--brand-pressed: #bd0010;
--knowledge: #315b8f;
--success: #168243;
--warning: #a86100;
--radius: 8px;
--employee-bg: #f4f6fb;
--employee-bg-soft: #f8fbff;
--employee-surface: #ffffff;
--employee-primary: #2f6bff;
--employee-primary-soft: #eaf1ff;
--employee-success: #34b368;
--employee-warning: #ff9b3d;
--employee-danger: #ff6b5f;
--employee-text: #182230;
--employee-muted: #6b7280;
--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);
}
view,
text,
input,
button {
box-sizing: border-box;
}
@@ -19,9 +43,9 @@ button {
button {
margin: 0;
border: 0;
border-radius: 12px;
border-radius: var(--radius);
font-size: 16px;
line-height: 1.2;
line-height: 1.25;
}
button::after {
@@ -30,29 +54,63 @@ button::after {
.page {
min-height: 100vh;
padding: 20px 16px 32px;
padding: 22px 16px 96px;
}
.employee-page-shell {
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
}
.employee-card {
border: 1px solid var(--employee-line);
border-radius: var(--employee-card-radius);
background: var(--employee-surface);
box-shadow: var(--employee-card-shadow);
}
.employee-kicker {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--employee-primary);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
}
.employee-section-title {
color: var(--employee-text);
font-size: 20px;
font-weight: 900;
line-height: 1.2;
}
.employee-section-subtitle {
color: var(--employee-muted);
font-size: 14px;
line-height: 1.55;
}
.page-title {
display: block;
font-size: 28px;
font-weight: 800;
line-height: 1.15;
color: #0f172a;
font-size: 26px;
font-weight: 900;
line-height: 1.2;
color: var(--ink);
}
.page-subtitle {
display: block;
margin-top: 8px;
font-size: 15px;
line-height: 1.6;
color: #64748b;
margin-top: 6px;
font-size: 14px;
line-height: 1.55;
color: var(--muted);
}
.subpage-header {
display: flex;
gap: 12px;
align-items: flex-start;
align-items: center;
}
.subpage-heading {
@@ -62,66 +120,68 @@ button::after {
.subpage-back-button {
display: flex;
flex: 0 0 44px;
width: 44px;
min-width: 44px;
height: 44px;
min-height: 44px;
flex: 0 0 40px;
width: 40px;
min-width: 40px;
height: 40px;
min-height: 40px;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #ffffff;
color: #1e293b;
font-size: 24px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--ink);
font-size: 22px;
font-weight: 700;
line-height: 1;
}
.panel {
margin-top: 16px;
padding: 18px;
border: 1px solid #e2e8f0;
border-radius: 14px;
background: #ffffff;
padding: 16px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
box-shadow: 0 1px 2px rgba(24, 34, 48, 0.04);
}
.panel-title {
font-size: 18px;
font-weight: 800;
color: #1e293b;
font-size: 17px;
font-weight: 900;
color: var(--ink);
}
.muted {
color: #64748b;
color: var(--muted);
}
.primary-button,
.secondary-button,
.ghost-button {
display: flex;
min-height: 48px;
min-height: 46px;
align-items: center;
justify-content: center;
padding: 0 16px;
font-weight: 700;
font-weight: 800;
}
.primary-button {
background: #e60012;
background: var(--brand);
color: #ffffff;
box-shadow: 0 4px 10px rgba(231, 0, 20, 0.16);
}
.secondary-button {
border: 1px solid #cbd5e1;
background: #ffffff;
color: #1e293b;
border: 1px solid #c9d1db;
background: var(--surface);
color: var(--ink);
}
.ghost-button {
background: #eff6ff;
color: #1d4ed8;
background: #eef3f9;
color: #38506c;
}
.primary-button[disabled],
@@ -132,12 +192,12 @@ button::after {
.input {
width: 100%;
min-height: 48px;
min-height: 46px;
padding: 0 14px;
border: 1px solid #cbd5e1;
border-radius: 12px;
background: #ffffff;
color: #0f172a;
border: 1px solid #c9d1db;
border-radius: var(--radius);
background: var(--surface);
color: var(--ink);
font-size: 16px;
}
@@ -145,10 +205,10 @@ button::after {
width: 100%;
min-height: 112px;
padding: 12px 14px;
border: 1px solid #cbd5e1;
border-radius: 12px;
background: #ffffff;
color: #0f172a;
border: 1px solid #c9d1db;
border-radius: var(--radius);
background: var(--surface);
color: var(--ink);
font-size: 16px;
line-height: 1.5;
}
@@ -165,26 +225,30 @@ button::after {
}
.chip {
min-height: 34px;
min-height: 36px;
padding: 0 12px;
border-radius: 999px;
background: #f1f5f9;
color: #334155;
border: 1px solid #dbe1e8;
border-radius: var(--radius);
background: #f8fafc;
color: #3d4b5f;
font-size: 14px;
font-weight: 700;
}
.status-message {
margin-top: 12px;
padding: 10px 12px;
border-radius: 10px;
background: #fff7ed;
color: #9a3412;
border: 1px solid #f0d7a7;
border-left: 3px solid #d38a13;
border-radius: var(--radius);
background: #fffaf0;
color: #80520f;
font-size: 14px;
line-height: 1.5;
}
.supervisor-access-panel {
border-left: 4px solid #e60012;
border-left: 4px solid var(--brand);
}
.supervisor-access-copy {
@@ -209,11 +273,22 @@ button::after {
}
.empty {
padding: 24px 12px;
color: #64748b;
padding: 28px 12px;
color: var(--muted);
text-align: center;
}
.primary-button:active {
background: var(--brand-pressed);
}
uni-tabbar.uni-tabbar-bottom,
uni-tabbar.uni-tabbar-bottom .uni-tabbar {
border-top: 1px solid var(--line) !important;
background: rgba(255, 255, 255, 0.98) !important;
box-shadow: 0 -10px 30px rgba(47, 107, 255, 0.08);
}
@media (min-width: 520px) {
:root {
--mobile-shell-side-offset: calc((100vw - var(--mobile-shell-max-width)) / 2);
@@ -234,8 +309,8 @@ button::after {
uni-app {
min-height: 100vh;
background: #f5f7fb;
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.06);
background: var(--canvas);
box-shadow: 0 0 0 1px rgba(24, 34, 48, 0.07), 0 16px 42px rgba(24, 34, 48, 0.1);
}
uni-tabbar.uni-tabbar-bottom {
@@ -0,0 +1,99 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const pageSource = async (path) => readFile(new URL(path, import.meta.url), 'utf8');
test('全局基础样式不对输入控件设置 border-box', async () => {
const source = await pageSource('../src/styles/base.css');
assert.doesNotMatch(source, /view,\s*\ntext,\s*\ninput,/);
assert.doesNotMatch(source, /view,\s*\ntext,\s*\ntextarea,/);
assert.doesNotMatch(source, /(^|\n)input\b[\s\S]*?box-sizing:\s*border-box/m);
assert.doesNotMatch(source, /(^|\n)textarea\b[\s\S]*?box-sizing:\s*border-box/m);
});
test('SOP 查询框不再覆盖 textarea 的 box-sizing', async () => {
const source = await pageSource('../src/pages/user/sop/index.vue');
const queryPanelStyle = source.match(/\.query-panel \.textarea \{[\s\S]*?\n\}/)?.[0] || '';
assert.equal(queryPanelStyle.includes('box-sizing'), false);
});
test('员工首页地图主标题保留真实工具名称', async () => {
const source = await pageSource('../src/pages/user/today/index.vue');
assert.match(source, /<text class="map-symbol">\{\{ tile\.shortTitle \}\}<\/text>/);
assert.match(source, /<text class="map-title">\{\{ tile\.title \}\}<\/text>/);
});
test('训练页提交失败会回滚本次乐观追加的对话', async () => {
const source = await pageSource('../src/pages/user/practice/index.vue');
assert.match(source, /const previousTurns = turns\.value\.slice\(\)/);
assert.match(source, /turns\.value = previousTurns/);
});
test('manifest 明确声明为 uni-app Vue3 CLI 工程', async () => {
const source = await pageSource('../src/manifest.json');
assert.match(source, /"vueVersion"\s*:\s*"3"/);
});
test('员工端 tabBar 继续保留四个固定入口', async () => {
const source = await pageSource('../src/pages.json');
assert.match(source, /"selectedColor"\s*:\s*"#2f6bff"/);
assert.match(source, /pages\/user\/today\/index/);
assert.match(source, /pages\/user\/practice\/index/);
assert.match(source, /pages\/user\/sop\/index/);
assert.match(source, /pages\/user\/profile\/index/);
});
test('共享视觉基座包含员工端主色和卡片圆角 token', async () => {
const source = await pageSource('../src/styles/base.css');
assert.match(source, /--employee-primary:\s*#2f6bff/);
assert.match(source, /--employee-card-radius:\s*24px/);
assert.match(source, /\.employee-card\s*\{/);
});
test('员工端三页接入共享页面和卡片基座', async () => {
const [todaySource, sopSource, practiceSource] = await Promise.all([
pageSource('../src/pages/user/today/index.vue'),
pageSource('../src/pages/user/sop/index.vue'),
pageSource('../src/pages/user/practice/index.vue')
]);
assert.match(todaySource, /employee-page-shell/);
assert.match(todaySource, /section-card employee-card/);
assert.match(sopSource, /query-panel employee-card/);
assert.match(practiceSource, /scenario-card employee-card/);
});
test('员工首页保留师傅区、语音条、今日安排、我的地图四段结构', async () => {
const source = await pageSource('../src/pages/user/today/index.vue');
assert.match(source, /mentor-hero/);
assert.match(source, /voice-panel/);
assert.match(source, /今日安排/);
assert.match(source, /我的地图/);
});
test('问师傅页保留搜索框、答案区和总结卡入口', async () => {
const source = await pageSource('../src/pages/user/sop/index.vue');
assert.match(source, /query-panel/);
assert.match(source, /answer-panel/);
assert.match(source, /summary-panel/);
});
test('训练页保留任务卡、对话区、回复区和结果区', async () => {
const source = await pageSource('../src/pages/user/practice/index.vue');
assert.match(source, /scenario-card/);
assert.match(source, /assignment-panel/);
assert.match(source, /dialogue-card/);
assert.match(source, /reply-card/);
assert.match(source, /result-card/);
});