初始提交:三端设计文档与 dev 代码基线

docs/:系统梳理、架构优化方案、CR-02 三端功能设计修订(含 §15 三端一致性同步与平台统计分析)、公寓端/应用端/平台端分册、公寓统计分析与排名报表、公寓端小步迭代开发计划(IT-0~IT-8)

dev/:RuoYi-Vue-master(后端)与 RuoYi-Vue2-master(前端)官方上游基线,未改动
This commit is contained in:
zfjia committed 2026-11-10 13:43:05 +08:00
commit 329e27b515
723 files changed
+99917

No files matched your search

@@ -0,0 +1,143 @@
# 01 前端总览与技术栈
## 1. 系统定位
前端是“叫乘 / 公寓管理”系统的操作层,服务四类现场角色,共用一个 Vue 工程、一套构建产物:
| 界面 | 主要使用者 | 典型设备 | 入口路由 |
| --- | --- | --- | --- |
| 叫乘值班控制台 | 公寓值班员 | 值班室大屏 / 双屏 PC | `/jcxx`(登录后默认页) |
| 入寓自助终端 | 乘务员本人 | 触摸屏一体机(带摄像头、读卡器) | `/ykt_main` |
| 食堂终端 | 乘务员、厨师、保洁 | 点餐屏 / 后厨屏 / 保洁屏 | `/MealsOrdering`、`/paymentPlatform`、`/chefOrders`、`/roomService` |
| 房间维护与同步查询 | 值班员、管理员 | PC | 值班台弹窗 + `/synJcXx` |
前端不承担鉴权与公寓归属判断:**公寓 `gyid` 最终以后端按客户端 IP 解析的结果为准**。前端登录时把用户名写入 `localStorage.gyid`,主要用于 WebSocket 的 `{username}` 路径段和部分页面查询参数;两者不一致是本系统最常见的联调问题来源,详见 [02](./02-工程结构与请求链路.md) 与 [07](./07-构建部署与风险清单.md)。
## 2. 运行拓扑
```mermaid
flowchart LR
subgraph Browser["浏览器(现场终端)"]
Duty["值班控制台 JcXx.vue"]
Self["自助终端 shijinpeng/main.vue"]
Meal["食堂终端 zhengchuang/*"]
RoomUI["房间选择/维护 Room.vue / RoomLook.vue"]
end
subgraph SameOrigin["同域静态资源"]
Static["/yktgl/ 前端 dist"]
end
subgraph Backend["后端 yktgl(8081,context /yktgl)"]
API["REST /jcgl /jlgl /Meals /clean /recharge /heartBeat /excel /jwDataLog"]
WS["WebSocket /jcwebsocket /jlwebsocket /rlywebsocket /MealsWebsocket"]
SIPGW["SIP 客户端网关 :9004 /sipclient"]
end
PBX["Asterisk / AMI / 广播设备"]
Duty --> Static
Self --> Static
Meal --> Static
RoomUI --> Static
Duty -->|"axios baseURL /yktgl(生产)或 /dev-api→/yktgl(开发)"| API
Self -->|"axios 同源 /yktgl"| API
Meal -->|"axios 同源 /yktgl"| API
RoomUI -->|"axios 同源 /yktgl"| API
Duty -->|"ws://host/yktgl/jcwebsocket/{gyid}"| WS
Duty -->|"ws://host/yktgl/jlwebsocket/{gyid}"| WS
Self -->|"ws://host/yktgl/rlywebsocket/test33"| WS
Meal -->|"ws://host/yktgl/MealsWebsocket/test33"| WS
Duty -->|"ws://127.0.0.1:9004/sipclient/{gyid}"| SIPGW
SIPGW --> PBX
Backend --> PBX
```
要点:
- **静态资源与接口同域**。生产 `vue.config.js` 的 `publicPath` 是 `/yktgl/`([vue.config.js:21](../../YktVue/vue.config.js#L21)),axios 生产 `baseURL` 也是 `/yktgl`([request.js:17](../../YktVue/src/utils/request.js#L17)),依赖反向代理把 `/yktgl/*` 转到后端。
- **WebSocket 地址是硬编码相对当前主机**,没有走 `public/config.js` 里的 `getWebSocketUrl()`;协议固定 `ws://`,HTTPS 部署会失败。
- **SIP 地址固定本机 `127.0.0.1:9004`**,意味着值班台必须与 SIP 网关同机部署。
- 浏览器不直连 Asterisk,语音呼叫由后端/SIP 网关完成;前端只发“呼叫/停止”指令。
## 3. 技术栈
| 层次 | 选型 | 版本 | 说明 |
| --- | --- | --- | --- |
| 框架 | Vue | 2.6.12 | Options API,无 Composition API |
| 路由 | vue-router | 3.4.9 | hash 默认模式,守卫只判断本地登录痕迹 |
| 状态 | Vuex | 3.6.0 | 依赖已声明,但工程没有 `src/store`,实际未使用 |
| UI | Element UI | 2.15.13 | 大量内联样式覆盖主题 |
| 请求 | axios | 0.24.0 | 统一拦截器 + 两套实例(`request.js` / `fileRequest.js`) |
| 图表 | ECharts | 5.4.0 | 依赖已声明,业务页面基本未使用 |
| 构建 | Vue CLI / webpack | 4.4.6 / 4 | `--openssl-legacy-provider` 兼容新 Node |
| 样式 | Sass | 1.32.13 | `lang="scss"`,含全局大屏样式 |
| 工具 | js-cookie、file-saver、jsencrypt、quill、vuedraggable、vue-cropper | 见 package.json | 多数为 RuoYi 模板遗留 |
工程来源是 RuoYi-Vue 模板(`package.json` 的 repository 仍指向 RuoYi),但业务页面已大幅改造,模板的权限、字典、布局、Vuex 体系基本被剥离或未接线。
## 4. 四类界面与页面清单
```mermaid
flowchart TB
Login["/login 登录"] --> Duty["/jcxx 值班控制台"]
Duty -->|弹窗| Room["Room.vue 选房"]
Duty -->|弹窗| RoomLook["RoomLook.vue 房间维护"]
Duty -->|弹窗| History["历史叫乘 / 交路 / 人员 / 充值"]
Login --> Self["/ykt_main 自助终端"]
Login --> MealOrder["/MealsOrdering 点餐"]
Login --> Pay["/paymentPlatform 结算"]
Login --> Chef["/chefOrders 后厨"]
Login --> Clean["/roomService 保洁"]
Login --> Syn["/synJcXx 运安同步查询"]
```
页面规模与职责:
| 文件 | 行数 | 职责 | 详细说明 |
| --- | --- | --- | --- |
| [`src/views/JcXx.vue`](../../YktVue/src/views/JcXx.vue) | 5294 | 值班主界面:叫乘列表、全部操作、实时告警、SIP、历史、导出 | [04](./04-核心页面拆解-JcXx.md) |
| [`src/shijinpeng/main.vue`](../../YktVue/src/shijinpeng/main.vue) | 943 | 自助入寓/离寓登记、刷脸、餐补查询、录像 | [06](./06-食堂与自助终端模块.md) |
| [`src/zhengchuang/views/MealsOrdering.vue`](../../YktVue/src/zhengchuang/views/MealsOrdering.vue) | 988 | 乘务员自助点餐、刷脸支付、购物车 | [06](./06-食堂与自助终端模块.md) |
| [`src/zhengchuang/views/paymentPlatform.vue`](../../YktVue/src/zhengchuang/views/paymentPlatform.vue) | 570 | 自助结算金额录入 | [06](./06-食堂与自助终端模块.md) |
| [`src/zhengchuang/views/chefOrders.vue`](../../YktVue/src/zhengchuang/views/chefOrders.vue) | 532 | 后厨订单队列、完成订单、新单提示 | [06](./06-食堂与自助终端模块.md) |
| [`src/zhengchuang/views/roomService.vue`](../../YktVue/src/zhengchuang/views/roomService.vue) | 339 | 保洁房间卡片、清扫计时 | [06](./06-食堂与自助终端模块.md) |
| [`src/components/Room.vue`](../../YktVue/src/components/Room.vue) | 332 | 房间选择器,按楼层分组、推荐房、状态过滤 | [06](./06-食堂与自助终端模块.md) |
| [`src/components/RoomLook.vue`](../../YktVue/src/components/RoomLook.vue) | 618 | 房间维护:多选同状态房间、开始/结束保洁、停用/恢复 | [06](./06-食堂与自助终端模块.md) |
| [`src/views/SynJcXx.vue`](../../YktVue/src/views/SynJcXx.vue) | 101 | 运安同步叫乘数据只读查询 | [03](./03-路由与页面职责.md) |
| [`src/views/Login.vue`](../../YktVue/src/views/Login.vue) | 206 | 公寓账号登录 | [02](./02-工程结构与请求链路.md) |
## 5. 全局变量与页面差异
`public/config.js` 在 `index.html` 中以脚本方式先于应用加载,向 `window` 注入:
| 变量 | 值 | 用途 |
| --- | --- | --- |
| `apiRoot` | `window.location.host` | 拼接 WebSocket 地址;注意不含协议 |
| `gyid` | `localStorage.getItem('gyid') \|\| 'TLXC'` | 全局公寓标识兜底值 |
不同页面对这两个变量的使用并不一致:
| 页面 | WebSocket 地址 | 公寓标识 |
| --- | --- | --- |
| `JcXx.vue` | `ws://{apiRoot}/yktgl/{jc,jl}websocket/{this.gyid}` | `this.gyid = localStorage.gyid \|\| 全局 gyid`([JcXx.vue:1728](../../YktVue/src/views/JcXx.vue#L1728)) |
| `shijinpeng/main.vue` | `ws://{apiRoot}/yktgl/rlywebsocket/test33` | 页面内硬编码 `gyid: "SYBXC"`、`gymc: "沈阳站公寓"`([main.vue:291](../../YktVue/src/shijinpeng/main.vue#L291)) |
| `MealsOrdering.vue` / `paymentPlatform.vue` | `ws://{apiRoot}/yktgl/MealsWebsocket/test33` | 无 |
| `roomService.vue` | 无 | 硬编码 `gyid: "SYBXC"`([roomService.vue:190](../../YktVue/src/zhengchuang/views/roomService.vue#L190)) |
| `public/config.js` 注释 | — | 保留 PJXC / SJTXC / BYQXC 等多公寓 IP/gyid 手工切换示例 |
结论:**这个工程原本按“一套代码、多个公寓分别部署”的方式维护**,多公寓配置靠改 `config.js` 注释或改页面常量,而不是运行时配置中心。
## 6. 关键设计约束
1. **同域部署**:静态资源 `/yktgl/`、接口 `/yktgl/*`、WebSocket `/yktgl/*websocket` 必须由同一反向代理暴露。
2. **后端按 IP 识别公寓**:`/jcgl/*` 的大量接口不接收前端 `gyid`,后端用 `zdwhService.getGyIdByIp(request)` 解析(见 [后端 03](../backend/03-业务模块地图.md))。前端传的 `gyid` 主要用于推送寻址。
3. **WebSocket username 必须等于公寓 gyid**:`JcWebsocket` 只向 `username == gyid` 或 `all` 的会话发送(见 [后端 07](../backend/07-外部集成与定时任务.md))。因此值班员登录名通常就是公寓代码。
4. **无全局状态**:所有页面状态各自维护,跨页面协作靠 `localStorage`、`BroadcastChannel` 和 `location.reload()`。
5. **单机终端假设**:SIP、自助终端、食堂终端都存在本机地址或固定 `test33`/`SYBXC` 的硬编码,跨机部署需要先改代码。
## 7. 下一步
- 想看一次请求从点击到后端:读 [02-工程结构与请求链路](./02-工程结构与请求链路.md)。
- 想改叫乘主界面:直接读 [04-核心页面拆解-JcXx](./04-核心页面拆解-JcXx.md)。
- 想改实时告警:读 [05-实时通信与语音告警](./05-实时通信与语音告警.md)。
@@ -0,0 +1,277 @@
# 02 工程结构与请求链路
## 1. 目录结构
```text
YktVue/
├── public/
│ ├── index.html # 先加载 config.js,再加载应用
│ ├── config.js # 全局 apiRoot / gyid / getWebSocketUrl
│ └── favicon.ico
├── src/
│ ├── main.js # 仅装配 ElementUI + router
│ ├── App.vue # 仅 <router-view>
│ ├── router/index.js # 13 条路由 + 登录守卫
│ ├── api/
│ │ ├── login.js # /auth/captchaImage、/auth/logon
│ │ ├── jcgl/jcgl.js # 叫乘主业务接口(35 个)
│ │ ├── jlgl/jlgl.js # 交路管理接口(7 个)
│ │ └── zhengchuang/mealsOrdering.js # 食堂接口(9 个)
│ ├── utils/
│ │ ├── request.js # axios 主实例 + 拦截器 + download
│ │ ├── fileRequest.js # 导出专用 axios 实例
│ │ ├── auth.js # Cookie token + 清理登录信息
│ │ ├── ruoyi.js # tansParams / blobValidate 等模板工具
│ │ └── permission.js # 依赖不存在的 @/store,死代码
│ ├── plugins/ # RuoYi 插件(cache/download/modal/...),未安装
│ ├── components/
│ │ ├── Room.vue # 选房器
│ │ ├── RoomLook.vue # 房间维护
│ │ └── RoomLook copy.vue, media.vue # 遗留
│ ├── views/
│ │ ├── Login.vue
│ │ ├── JcXx.vue # 值班主界面(5294 行)
│ │ ├── SynJcXx.vue
│ │ ├── Home.vue / About.vue # 模板遗留
│ │ └── JcXx.vue.bak_mojibake # 遗留备份
│ ├── shijinpeng/main.vue # 自助登记终端
│ └── zhengchuang/
│ ├── views/ # 点餐、结算、后厨、保洁
│ ├── components/ # audioPlay / textSpeak
│ └── css/ # MealCss.css 等
├── tests/ # 两个纯 Node 测试脚本
├── vue.config.js
├── package.json
└── .env.development / .env.production / .env.staging
```
## 2. 启动装配
[`src/main.js`](../../YktVue/src/main.js) 只有 14 行:
```js
Vue.use(ElementUI)
Vue.prototype.$axios = axios
new Vue({ router, render: h => h(App) }).$mount('#app')
```
因此:
- **没有安装 Vuex**,也没有 `store` 目录;`package.json` 中的 `vuex` 只是模板残留。
- **没有安装 `src/plugins/index.js`**,所以 `this.$modal`、`this.$download`、`this.$tab` 等 RuoYi 原型方法不存在;`JcXx.vue` 的 `handleDelete` 仍调用了 `this.$modal.confirm`,该分支是死路径。
- **没有全局路由守卫之外的权限体系**;`utils/permission.js` 因为 import `@/store` 会失败,且没有被引用。
- `public/index.html:9` 先加载 `config.js`,所以页面里可以直接使用 `apiRoot`、`gyid` 这两个隐式全局变量。
## 3. 登录与路由守卫
### 3.1 登录时序
```mermaid
sequenceDiagram
participant U as 用户
participant L as Login.vue
participant R as request.js
participant B as 后端 /auth/logon
participant LS as localStorage/Cookie
U->>L: 输入用户名/密码
L->>R: GET /auth/logon?username&password
R->>B: baseURL + /auth/logon
B->>B: 按用户名查 DictData_gy,校验请求 IP 是否在该公寓 ip 列表
alt 校验通过
B-->>R: Result.success(LogEntity)
R-->>L: { code:'00000', data: LogEntity }
L->>LS: setToken('fake-token-<ts>')(响应无 token)
L->>LS: gyid=username, loginAccount=username
L->>L: $router.push('/jcxx')
else 用户/IP 不匹配
B-->>R: Result.failure(msg)
R-->>L: Promise.reject + Message.error
end
```
关键代码:
- 登录请求:`api/login.js` 的 `login()` 使用 GET `/auth/logon`([login.js:16](../../YktVue/src/api/login.js#L16))。
- 成功处理:`Login.vue` 判断 `response.code === '00000'`,没有 `response.token` 就写入 `fake-token-时间戳`,并把 **用户名当成 gyid**([Login.vue:83](../../YktVue/src/views/Login.vue#L83))。
- 后端契约:`/auth/logon` 只校验 `DictData_gy` 与 IP,写入 Session 的 `gyid`/`ip`,返回 `LogEntity`,不签发 token(见 [后端 06](../06-接口清单.md))。
### 3.2 守卫逻辑
[`src/router/index.js:80`](../../YktVue/src/router/index.js#L80):
```js
const isLoggedIn = token || loginAccount
if (to.path === '/login') {
isLoggedIn ? next('/jcxx') : next()
} else {
isLoggedIn ? next() : next('/login')
}
```
含义与风险:
- 登录态 = Cookie `Admin-Token` 或 `localStorage.loginAccount` 任一存在,**不校验 token 有效性**,也不校验后端 Session 是否过期。
- 退出登录只清本地信息(`clearLoginInfo()`),没有调用后端 `/auth/logout`。
- 只要用户手工写入 `loginAccount`,就能进入所有页面;真正的权限控制完全依赖后端接口和 IP 校验。
- 登录页在已登录时会强制跳 `/jcxx`,无法回到登录页切换账号,除非先清 localStorage/Cookie。
## 4. axios 请求链路
### 4.1 主实例
[`src/utils/request.js:14`](../../YktVue/src/utils/request.js#L14) 创建实例:
| 配置 | 值 | 说明 |
| --- | --- | --- |
| `baseURL` | 开发 `/dev-api`,其他 `/yktgl` | 硬编码,**不读取 `VUE_APP_BASE_API`**([request.js:17](../../YktVue/src/utils/request.js#L17)) |
| `timeout` | 10000 ms | 导出走 `fileRequest` 的 50 s |
| `Content-Type` | `application/json;charset=utf-8` | GET 参数由拦截器拼到 URL |
请求拦截器([request.js:24](../../YktVue/src/utils/request.js#L24)):
1. 若存在 Cookie token 且未显式 `isToken: false`,加 `Authorization: Bearer <token>`;由于 token 是假的,后端并不校验。
2. GET 请求用 `tansParams` 把 `params` 序列化到 URL,然后清空 `params`。
3. POST/PUT 的防重复提交逻辑只写不校验:`sessionObj` 写入后,真正比较的 `else` 分支被整段注释([request.js:54](../../YktVue/src/utils/request.js#L54))。
响应拦截器([request.js:75](../../YktVue/src/utils/request.js#L75)):
```js
const { code, msg } = response.data
if (code === '00000' || code === '11111' || code === 1) return response.data
if (response.data instanceof ArrayBuffer) return response
Message({ message: msg || '系统出错', type: 'error' })
return Promise.reject(new Error(msg || 'Error'))
```
注意:
- **`11111` 被当成成功返回**。业务层需要自己判断 `res.code === '11111'` 并提示错误(例如 `handleRechargeConfirm`、`handleUpdateJcXxConfirm`)。这与后端“`00000` 成功、`11111` 业务失败”的约定不一致,容易漏判。
- 二进制判断在成功码判断之后,且 axios 默认不会把 blob 转成 `ArrayBuffer`,所以 blob 分支实际很少命中;导出另走 `fileRequest`。
- 网络错误分支整体被块注释([request.js:94](../../YktVue/src/utils/request.js#L94)),**请求失败时不会弹全局错误提示**,多数页面 `.catch()` 为空或只 `console.log`,表现为“点了没反应”。
### 4.2 导出实例
[`src/utils/fileRequest.js:6`](../../YktVue/src/utils/fileRequest.js#L6) 固定 `baseURL: '/yktgl'`、`timeout: 50000`。开发环境没有 `/yktgl` 代理(只有 `/dev-api`),**本地导出会 404**;生产同域部署才可用。
### 4.3 环境变量现状
| 文件 | `VUE_APP_BASE_API` | 是否生效 |
| --- | --- | --- |
| `.env.development` | `/yktgl` | 否,`request.js` 写死 `/dev-api` |
| `.env.production` | `/prod-api` | 否,写死 `/yktgl` |
| `.env.staging` | `/stage-api` | 否,`build:stage` 也走写死值 |
改环境时不要只改 `.env`,必须同步改 `request.js`(以及需要时的 `fileRequest.js` 和 `vue.config.js` 代理)。
## 5. API 封装与后端映射
### 5.1 模块划分
| 模块 | 文件 | 接口数 | 用途 |
| --- | --- | --- | --- |
| 登录 | `src/api/login.js` | 2 | 验证码(未使用)、登录 |
| 叫乘主业务 | `src/api/jcgl/jcgl.js` | 35 | 列表、叫乘、房间、人员、导出、同步、时间规则 |
| 交路 | `src/api/jlgl/jlgl.js` | 7 | 交路增删改查、导入、确认 |
| 食堂 | `src/api/zhengchuang/mealsOrdering.js` | 9 | 菜品、订单、房间、消费、餐次 |
### 5.2 叫乘主业务接口映射
`src/api/jcgl/jcgl.js` 的函数与后端控制器对应关系(后端细节见 [06-接口清单](../backend/06-接口清单.md)):
| 前端函数 | 方法 | URL | 典型调用方 |
| --- | --- | --- | --- |
| `getJcXxList` | POST | `/jcgl/getCcJcXx` | 值班主列表、轮询 |
| `getJcXxList_Ls` | POST | `/jcgl/getCcJcXx_Ls` | 历史叫乘(分页/滚动加载) |
| `modifyJcXx` | POST | `/jcgl/modifyJcXx` | 修改叫乘信息 |
| `cancelTrainAssignment` | POST | `/jcgl/cancelTrainAssignment` | 取消叫乘 |
| `handleCheckOut` | POST | `/jcgl/handleCheckOut` | 出乘 |
| `handleStopCalling` | POST | `/jcgl/handleStopCalling` | 停止报警/暂停报警 |
| `handleTempCalling` | POST | `/jcgl/handleTempCalling` | 语音临叫(广播式) |
| `handleRoomCall` | POST | `/jcgl/handleRoomCall`(method=1 语音,=2 喊话) | 单房间临叫 |
| `handleRoomCallEnd` | POST | `/jcgl/handleRoomCallEnd` / `...End2` | 停止单房间呼叫 |
| `handleMultiRoomCall` | POST | `/jcgl/handleMultiRoomCall` | 多房间喊话 |
| `handleMultiRoomCallEnd` | POST | `/jcgl/handleMultiRoomCallEnd` | 停止多房间喊话 |
| `getRoomList` | GET | `/jcgl/getRoomList` | 选房、房间维护、终端 |
| `getRoomRecommendations` | GET | `/jcgl/getRoomRecommendations` | 选房推荐 |
| `modifyRoomStatus` | GET | `/jcgl/modifyRoomStatus` | 停用/恢复房间 |
| `startRoomCleaning` | POST | `/clean/startCleaning` | 开始保洁(`deviceCode` 固定 `"123123"`) |
| `endRoomCleaning` | POST | `/clean/endCleaning` | 结束保洁 |
| `getRyXxByRyId` | POST | `/jcgl/getRyxxByRyId` | 人员查询/维护 |
| `modifyRyXx` | POST | `/jcgl/modifyRyXx` | 人员维护保存 |
| `deleteRyXx` | POST | `/jcgl/deleteRyXx` | 删除人员 |
| `getWslRyXx` | POST | `/jcgl/getWslRyXx` | 未刷脸人员 |
| `getRyFjXx` | POST | `/jcgl/getRyFjXx` | 换房前查询同房间人员 |
| `exchangeRoom` | POST | `/jcgl/exchangeRoom` | 确认换房 |
| `getJlxxList` | GET | `/jcgl/getJlxxList` | 交路列表 |
| `getJlXxJcsjList` | POST | `/jlgl/getJlXxJcsjList` | 按开车日期取车次 |
| `addJlXx` / `modifyJlXx` / `delJlXx` / `deleteJlXx` / `confirmJlXx` | POST | `/jlgl/*` | 交路维护 |
| `getZbyList` / `getGzList` | GET | `/jcgl/getZbyList`、`/jcgl/getGzList` | 值班员/工种字典 |
| `getSjgz` | GET | `/jcgl/getSjgz` | 时间规则(150/120/30 分钟) |
| `isHoliday` | GET | `/jcgl/isHoliday` | 食堂节假日判断 |
| `getSynJcXx` | GET | `/jcgl/getSynJcXx` | 运安同步数据查询 |
| `getSynJcXxIsOffline` | POST | `/jwDataLog/getSynJcXxIsOffline` | 运安离线检测 |
| `getLatestSyncTime` | GET | `/jcgl/getLatestSyncTime` | 最近同步时间 |
| `getDeviceStatusInfo` | POST | `/heartBeat/getDeviceStatusInfo` | 设备心跳/在线状态 |
| `getRecordingConfig` | GET | `/jcgl/getRecordingConfig` | 录音回放配置 |
| `handleRecharge` | GET | `/recharge/handleRecharge` | 现金充值办理 |
| `exportJcXxExcell` | GET(blob) | `/excel/exportJcXxExcell` | 定时/手工导出 |
| `getBtje_info` / `getBtje_infoMeal3` / `updateMeal3` | GET/POST | `/jcgl/getBtje_info*`、`/jcgl/updateMeal3` | 餐补余额 |
| `submitAll` | GET | `/jcgl/submitAll` | 自助终端/值班台提交入寓登记 |
| `getJcxxByRyid` | POST | `/jcgl/getJcxxByRyid` | 按人员查叫乘 |
| `jcAlarm` | POST | `/jcgl/jcAlarm` | 设备报警 |
| `login` | GET | `/auth/login` | **未被页面使用**,登录走 `api/login.js` |
### 5.3 食堂接口映射
| 前端函数 | 方法 | URL |
| --- | --- | --- |
| `getMeals` | GET | `/Meals/getMeals` |
| `createOrder` | POST | `/Meals/createOrder` |
| `addOrders_MX` | POST | `/Meals/addOrders_MX` |
| `OrdersSearch` | GET | `/Meals/OrdersSearch` |
| `OrderMX_search` | GET | `/Meals/OrderMX_search` |
| `updateStatus` | POST | `/Meals/updateStatus` |
| `getRooms` | GET | `/Meals/getRooms` |
| `consume` | POST | `/Meals/consume` |
| `getCsLb` | GET | `/Meals/getCsLb` |
## 6. 响应契约与业务判断
后端统一返回 `Result`:
```json
{ "code": "00000", "msg": "...", "data": ..., "count": 123 }
```
前端使用约定:
- `code === '00000'`:成功,`data` 为业务数据,`count` 为分页总数。
- `code === '11111'`:业务失败;拦截器仍返回该对象,调用方必须自己处理。
- 其他:拦截器弹 `Message.error` 并 reject。
- 导出接口返回 blob,不经过统一响应判断。
## 7. 工程遗留与陷阱
| 现象 | 位置 | 影响 |
| --- | --- | --- |
| `JcXx.vue` 导入不存在的 `auditJlXx` | [JcXx.vue:1392](../../YktVue/src/views/JcXx.vue#L1392) | 构建告警;若将来使用会运行时报错 |
| `shijinpeng/main.vue` 导入不存在的 `getRyxx` | [main.vue:280](../../YktVue/src/shijinpeng/main.vue#L280) | 刷脸登记调用时 `TypeError: getRyxx is not a function` |
| `utils/permission.js` 依赖不存在的 `@/store` | `src/utils/permission.js` | 死代码,若被引入立即失败 |
| `App.vue` 结尾多一个字面量 `3` | [App.vue:32](../../YktVue/src/App.vue#L32) | 无效语句,构建噪音 |
| `Home.vue` 使用未注册的 `<test1>` | [Home.vue:6](../../YktVue/src/views/Home.vue#L6) | 访问 `/home` 时渲染告警 |
| `README.md` 命令写成 `npm run serve/build` | `YktVue/README.md` | 与 `package.json` 的 `dev/build:prod` 不符 |
| 防重复提交只写不判 | [request.js:51](../../YktVue/src/utils/request.js#L51) | 双击可能重复提交 |
| 请求失败无全局提示 | [request.js:94](../../YktVue/src/utils/request.js#L94) | 用户无感知,排障困难 |
| `build:stage` 没有对应接口前缀 | `.env.staging` | 构建产物仍请求 `/yktgl` |
## 8. 修改落点速查
| 需求 | 先看 |
| --- | --- |
| 新增一个后端接口 | `src/api/jcgl/jcgl.js` 增加封装 → 页面调用 |
| 改登录/切换账号 | `Login.vue`、`utils/auth.js`、`router/index.js` |
| 改请求超时/前缀/错误提示 | `utils/request.js`、`fileRequest.js`、`vue.config.js` |
| 改开发代理指向 | `vue.config.js` 的 `devServer.proxy` |
| 新增页面 | `router/index.js` + `src/views/` |
| 清理模板遗留 | `plugins/`、`utils/permission.js`、`views/Home.vue`、`About.vue`、`RoomLook copy.vue`、`JcXx.vue.bak_mojibake` |
+143
View File
@@ -0,0 +1,143 @@
# 03 路由与页面职责
## 1. 路由总表
路由定义在 [`src/router/index.js`](../../YktVue/src/router/index.js),全部为静态路由,无动态权限路由、无嵌套路由。
| # | 路径 | 名称 | 组件 | 状态 | 职责 |
| --- | --- | --- | --- | --- | --- |
| 1 | `/` | — | 重定向 `/login` | 可用 | 默认入口 |
| 2 | `/login` | `Login` | `views/Login.vue` | 可用 | 公寓账号 + 密码登录 |
| 3 | `/home` | `Home` | `views/Home.vue` | **模板遗留** | 展示 HelloWorld,含未注册组件 |
| 4 | `/MealsOrdering` | `MealsOrdering` | `zhengchuang/views/MealsOrdering.vue` | 可用(有缺陷) | 乘务员自助点餐 |
| 5 | `/paymentPlatform` | `paymentPlatform` | `zhengchuang/views/paymentPlatform.vue` | **半可用** | 自助结算金额录入,刷脸链路被注释 |
| 6 | `/chefOrders` | `chefOrders` | `zhengchuang/views/chefOrders.vue` | 可用 | 后厨订单队列 |
| 7 | `/roomService` | `roomService` | `zhengchuang/views/roomService.vue` | **半可用** | 保洁房间卡片与计时,完成不回写后端 |
| 8 | `/jcxx` | `JcXx` | `views/JcXx.vue` | 可用 | 值班控制台(主业务) |
| 9 | `/JcXx` | — | 重定向 `/jcxx` | 可用 | 大小写兼容 |
| 10 | `/about` | `About` | `views/About.vue` | 模板遗留 | 空壳页 |
| 11 | `/ykt_main` | `ykt_main` | `shijinpeng/main.vue` | 可用(有缺陷) | 自助入寓/离寓登记终端 |
| 12 | `/room` | `room` | `components/Room.vue` | 独立可访问 | 选房组件;正常由值班台弹窗内嵌 |
| 13 | `/synJcXx` | `synJcXx` | `views/SynJcXx.vue` | 可用 | 运安同步叫乘数据查询 |
## 2. 页面分组
```mermaid
flowchart TB
subgraph DutySide["值班侧"]
J["/jcxx 主控制台"]
S["/synJcXx 同步查询"]
end
subgraph CrewSide["乘务员自助"]
Y["/ykt_main 入寓登记"]
M["/MealsOrdering 点餐"]
P["/paymentPlatform 结算"]
end
subgraph CanteenSide["食堂/保洁"]
C["/chefOrders 后厨"]
R["/roomService 保洁"]
end
subgraph LegacySide["模板遗留"]
H["/home"]
A["/about"]
end
```
四组页面之间**没有前端共享状态**,协作方式只有三种:
1. 后端轮询 / WebSocket 推送;
2. `localStorage`(`gyid`、`loginAccount`、`Admin-Token`);
3. `BroadcastChannel`(同浏览器多标签页,见 [06](./06-食堂与自助终端模块.md))。
## 3. 页面职责与关键行为
### 3.1 `/login` 登录页
- 表单只有用户名、密码,没有验证码;`getCodeImg()` 虽已封装但未使用。
- 成功后写假 token、`gyid`、`loginAccount`,跳 `/jcxx`。
- 已登录用户访问 `/login` 会被守卫重定向回 `/jcxx`,无法直接切换账号。
- 详见 [02-登录与路由守卫](./02-工程结构与请求链路.md#3-登录与路由守卫)。
### 3.2 `/jcxx` 值班控制台
系统的核心页面,承担:
- 叫乘任务列表(机务/客运、其他人员两个 Tab,但 Tab 仅切换选中态,不改变查询);
- 入寓登记、修改叫乘、取消叫乘、出乘、换房、暂停报警、临叫、停止呼叫;
- 交路信息查看/确认/维护;
- 人员维护、现金充值、历史叫乘、设备状态;
- 实时告警音、运安离线报警、同步提醒弹窗与 TTS;
- 定时刷新列表、定时导出 Excel。
完整拆解见 [04-核心页面拆解-JcXx](./04-核心页面拆解-JcXx.md)。
### 3.3 `/ykt_main` 自助登记终端
- 无登录态输入,靠 `rlywebsocket/test33` 接收“有人靠近/刷脸”事件后弹出登记窗口。
- 页面内固定 `gyid: "SYBXC"`、`gymc: "沈阳站公寓"`,部署到其他公寓必须改代码。
- 依赖的 `getRyxx` 在 `api/jcgl/jcgl.js` 中不存在(实际函数名是 `getRyXxByRyId`),刷脸确认时会抛异常;即使修复,入寓分支也只广播、不调用 `/jcgl/submitAll`。
- 详见 [06](./06-食堂与自助终端模块.md)。
### 3.4 食堂四页
| 页面 | 正常业务闭环 | 当前状态 |
| --- | --- | --- |
| 点餐 `MealsOrdering` | 选餐 → 确认 → 刷脸 → `consume` 扣款 → `createOrder` + 明细 → 广播后厨 | 刷卡回调存在;餐次判断与定时器有缺陷 |
| 结算 `paymentPlatform` | 录入金额 → 刷脸 → 扣款 → 生成订单 | `initWebSocket()` 被注释,刷脸回调不会触发 |
| 后厨 `chefOrders` | 拉取待处理订单 → 查看明细 → 完成订单;收到 `2603` 广播刷新并响铃 | 可用 |
| 保洁 `roomService` | 房间卡片 → 开始保洁计时 → 完成 | “完成”只弹提示,不调用后端 |
详见 [06](./06-食堂与自助终端模块.md)。
### 3.5 `/synJcXx` 同步查询
- 只读列表:车次、机车号、开车时间、计划出勤地点、司机姓名、接车方式。
- 输入即查询(`@input`),带分页 `[5,10,15,20,30,40,50]`。
- 数据来源 `/jcgl/getSynJcXx`,是运安计划同步结果的查询视图,不参与叫乘状态流转。
### 3.6 `/room` 与房间维护
- `/room` 可直接访问 `Room.vue`,但正常使用方式是值班台弹窗:`JcXx.vue` 通过 `<Room>` 内嵌,选房后 `$emit('childByValue', room)` 回传。
- `RoomLook.vue` 没有独立路由,只作为值班台“房间信息”弹窗使用。
- 两者都属于“房间状态”操作面,详见 [06](./06-食堂与自助终端模块.md)。
## 4. 入口关系
```mermaid
flowchart LR
Login["/login"] -->|成功| Jcxx["/jcxx"]
Jcxx -->|房间信息| RoomLook["RoomLook 弹窗"]
Jcxx -->|入寓登记选房| Room["Room 弹窗"]
Jcxx -->|历史数据| History["历史叫乘弹窗"]
Jcxx -->|交路管理| Jl["交路弹窗"]
Jcxx -->|人员维护| Ry["人员维护弹窗"]
Jcxx -->|退出| Login
Login -.手工访问.-> Terminal["/ykt_main · /MealsOrdering · /paymentPlatform · /chefOrders · /roomService · /synJcXx"]
```
除 `/jcxx` 外,其余业务页面**没有导航菜单**,需要现场终端在浏览器书签/快捷方式中直接配置 URL。这是“终端型系统”的常见做法,但也意味着改路由会直接影响现场快捷方式。
## 5. 路由层面的注意事项
| 问题 | 影响 | 位置 |
| --- | --- | --- |
| 守卫只判断本地痕迹 | 可绕过;后端 Session 过期后页面仍可进入,接口才报错 | [router/index.js:85](../../YktVue/src/router/index.js#L85) |
| 没有 404 路由 | 未知路径会渲染空白 | `router/index.js` |
| 没有路由级 code-splitting 之外的懒加载策略 | 首屏仍会加载 Element UI 全量 | `main.js` |
| `/home`、`/about` 仍在路由表 | 可被访问,产生无意义页面/告警 | `router/index.js:19`、`:53` |
| 终端页面无登录页跳转保护 | 未登录访问会被送到 `/login`,终端无法自动恢复 | 守卫全局生效 |
## 6. 遗留文件清单
以下文件不被路由或组件引用,属于历史备份/模板残留,重构时可直接评估删除:
| 文件 | 说明 |
| --- | --- |
| `src/views/JcXx.vue.bak_mojibake` | 主页面乱码备份 |
| `src/components/RoomLook copy.vue` | 房间维护旧版 |
| `src/components/media.vue` | 媒体示例组件 |
| `src/components/HelloWorld.vue` | Vue CLI 模板组件(仅 `/home` 使用) |
| `src/views/Home.vue`、`src/views/About.vue` | Vue CLI 模板页面 |
| `src/plugins/*` | RuoYi 插件,未安装 |
| `src/utils/permission.js` | 依赖不存在的 `@/store` |
| `src/utils/jsencrypt.js`、`src/utils/validate.js` | 模板工具,当前业务未使用 |
+267
View File
@@ -0,0 +1,267 @@
# 04 核心页面拆解:JcXx 值班控制台
`src/views/JcXx.vue` 5294 行,模板 1–1389,脚本 1390–4532,样式 4533 之后。它是整个系统的操作中枢,下面按“布局 → 状态 → 列表 → 操作 → 时间规则 → 轮询 → 历史 → 陷阱”拆解。
## 1. 页面布局
```text
┌─────────────────────────────────────────────────────────────────────┐
│ 跑马灯(当前告警/呼叫提示) │ 大时钟 │ 房间信息 设备信息 人员维护 │
│ 机车号 车次 房间号 姓名 [查询][重置] │ │ 交路管理 历史数据 退出 │
│ │ │ 语音临叫 喊话临叫 停止呼叫 停止报警 │
├─────────────────────────────────────────────────────────────────────┤
│ Tab:机务人员和客运人员 / 其他人员 │
│ 叫乘列表(状态、到寓时间、单位、机车号、姓名、房间号、车次、开车/叫乘/ │
│ 复叫/应离寓时间、接车方式、暂停报警) │
├─────────────────────────────────────────────────────────────────────┤
│ 底部:在寓汇总 / 值班信息 │
└─────────────────────────────────────────────────────────────────────┘
弹窗:历史叫乘、离寓抓拍、设备信息、未刷脸、换房、交路、待确认交路、
新增/修改交路、修改叫乘、入寓登记、人员维护、现金充值、交接班、
房间选择、房间维护、多房间喊话、出乘确认、广播确认、临叫确认、
停止呼叫确认、运安离线告警、交路及叫乘提醒
```
| 区域 | 行号 | 说明 |
| --- | --- | --- |
| 顶部卡片 | 4–176 | 左:跑马灯 + 4 个查询框;中:每秒刷新的大时钟;右:功能按钮 |
| 列表卡片 | 178–241 | 两个 Tab(仅视觉)、主表格 |
| 主表格 | 183–240 | `height=600`、`row-style=setRowStyle`、单选高亮 |
| 底部栏 | 1178–1191 | 在寓人数汇总、值班信息 |
| 业务弹窗 | 244–1378 | 见第 7 节 |
主表格列:序号、状态、到寓时间、单位、机车号、姓名、房间号、出发车次、开车时间、叫乘时间、复叫时间、叫乘日期、应离寓时间、接车方式、操作(暂停报警)。
## 2. 页面状态
`data()` 从 1406 行开始,核心字段:
| 字段 | 行号 | 用途 |
| --- | --- | --- |
| `gyid` | 1453 | 当前公寓;`created()` 中取 `localStorage.gyid` 或全局 `gyid` |
| `apiRoot` | 1459 | WebSocket 主机名 |
| `jcxxList` | 1542 | 主列表数据 |
| `selectedRow` | 1470 | 当前选中行,所有行操作的入参 |
| `JcXxQueryParams` | 1546 | 查询条件:`gyid/jch/cc/fjh/rymc/begindt/enddt` |
| `LsJcXxQueryParams` | 1555 | 历史查询:增加 `historyTimeType/pageNum/pageSize` |
| `sjgzConfig` | 1631 | 时间规则 `{kjjcsjgz,zjjcsjgz,lysjgz}` |
| `dismissedAlarmEvents` | 1430 | 已处理告警事件 key 集合,防止重复响铃 |
| `alarmVoiceActive` | 1428 | 报警音是否正在播放 |
| `syncSpeechQueue` / `syncSpeechCurrent` | 1627 起 | TTS 播报队列 |
| `activeTempCalls` | 1644 | 本页面发起的临叫房间及类型(voice/speech) |
| `callListRequestVersion` | 1429 | 列表请求版本号,防止旧响应覆盖新数据 |
`created()`([JcXx.vue:1726](../../YktVue/src/views/JcXx.vue#L1726))设置 `gyid` 与查询参数,加载交路列表与当前时间;`mounted()`([:1784](../../YktVue/src/views/JcXx.vue#L1784))启动 WebSocket、SIP、字典、列表、时间规则和 4 个定时器。
## 3. 列表与状态
### 3.1 状态码映射
表格“状态”列([JcXx.vue:194](../../YktVue/src/views/JcXx.vue#L194)):
| `ryzt` | 显示 | 含义 | 行颜色 |
| --- | --- | --- | --- |
| `0` | 待叫乘 | 尚未到叫乘时间 | 黄色 `#FFF200` |
| `1` | 正在呼叫 | 正在叫乘/通话建立中 | 绿色 `#00FF00` |
| `2` | 叫乘报警 | 叫乘未应答 | 红色 `#F56C6C` |
| `3` | 待离寓 | 已叫乘完成,等待离寓 | 浅绿 `#B3E19D` |
| `4` | 离寓报警 | 超时应离寓 | 红色 `#F56C6C` |
| `5` | 正在通话 | 语音通道已建立 | 绿色 `#00FF00` |
| 其他 | 已入寓 | 正常在寓 | 默认 |
### 3.2 行样式与跑马灯
`setRowStyle(row)`([JcXx.vue:3723](../../YktVue/src/views/JcXx.vue#L3723))按以下优先级返回样式,并**在渲染函数里直接修改 `this.message`**:
1. `ryzt=0` 且 `missedCallTaskWarning=1`:黄色 + 闪烁动画,提示“叫乘时间已过”;
2. `ryzt=1/5`:绿色;
3. `ryzt=2/4`:红色;
4. 本页发起的临叫:绿色,提示“正在喊话/语音临叫”;
5. `jllx=1`:浅蓝 `#d9ecff`;
6. `ryzt=3`:浅绿;
7. `ryzt=0`:黄色;
8. 其他:默认。
问题:`setRowStyle` 是纯函数位,却在渲染期间写 `this.message`,会触发额外的渲染与响应式更新;表格行数多时可能造成卡顿或“跑马灯内容错行”。修改行样式逻辑时建议把消息计算移出渲染函数。
### 3.3 接车方式显示
`cclx` 为“站接”显示站接;“库接/库出”统一显示“库出”;其他为空。
## 4. 操作矩阵
所有行级操作都要求先选中行(`selectedRow`),点击行时还会向 SIP 发送一次注册状态([JcXx.vue:3231](../../YktVue/src/views/JcXx.vue#L3231))。
| 操作 | 入口 | 处理方法 | 后端接口 | 说明 |
| --- | --- | --- | --- | --- |
| 查询/重置 | 顶部 | `getJcXxList` / `resetForm` | `/jcgl/getCcJcXx` | 输入即查询;重置只清 4 个文本条件 |
| 取消叫乘 | 顶部 | `openCancelTrainAssignment` | `/jcgl/cancelTrainAssignment` | 二次确认;成功后停报警、刷新列表 |
| 修改叫乘 | 行/弹窗 | `handleModifyJcXx` → `handleUpdateJcXxConfirm` | `/jcgl/modifyJcXx` | 编辑表单与表格行分离,避免后端拒绝时已改表格 |
| 出乘 | 弹窗 | `handleCheckOut` → `handleCheckOutConfirm` | `/jcgl/handleCheckOut` | 确认后不可修改 |
| 暂停报警 | 行内按钮 | `handleStopCalling` | `/jcgl/handleStopCalling` | 仅 `ryzt=2/4` 有效;成功后把事件 key 加入已忽略集合 |
| 停止报警 | 顶部 | `stopCurrentAlarm` | 同上 | 先校验选中行必须是 `2/4` |
| 语音临叫 | 顶部 | `handleCalling` → `handleRoomCallConfirm` | SIP + `/jcgl/handleRoomCall` | 需 SIP 已连接;`callmethod=1` |
| 喊话临叫(单房间) | 顶部 | `handleCallingOld` → `handleRoomCallConfirm` | SIP + `/jcgl/handleRoomCall2` | 选中行时 `callmethod=2` |
| 喊话临叫(多房间) | 顶部 | `confirmMultiCall` | `/jcgl/handleMultiRoomCall` | 未选中行时弹出多选房间;记录 `lastCalledRooms` |
| 停止呼叫 | 顶部 | `handleRoomCallEnd` | `/jcgl/handleMultiRoomCallEnd` 或 `/jcgl/handleRoomCallEnd(2)` | 优先停止多房间,否则停止当前行 |
| 换房间 | 弹窗 | `handleChangeRoom` → `exchangeRoomConfirm` | `/jcgl/getRyFjXx`、`/jcgl/exchangeRoom` | 先列出同房间人员,再逐人设置新房间 |
| 入寓登记 | 弹窗 | `finalCheck` | `/jcgl/submitAll` | 校验房间/开车日期后提交;成功后关弹窗刷新 |
| 人员维护 | 顶部 | `handleUpdateRyXx` | `/jcgl/getRyxxByRyId`、`/jcgl/modifyRyXx`、`/jcgl/deleteRyXx` | 支持刷脸照片上传/替换 |
| 现金充值 | WebSocket 触发 | `handleRechargeConfirm` | `/recharge/handleRecharge` | 收到 `cz` 消息弹窗;`cz_cancel` 可按 id 关闭 |
| 交路管理 | 顶部 | `handleJlGlClick` | `/jcgl/getJlxxList` | 打开交路列表,可新增/修改/删除/导入 |
| 待确认交路 | 交路弹窗 | `handdleDqrButton` → `handleConfirmJlXxClick` | `/jlgl/confirmJlXx` | 按勾选 id 批量确认 |
| 历史数据 | 顶部 | `handleLsSjClick` → `loadLsJcXx` | `/jcgl/getCcJcXx_Ls` | 分页 + 滚动加载;可回放录音 |
| 设备信息 | 顶部 | `handleDeviceStatus` | `/heartBeat/getDeviceStatusInfo` | 离线设备整行标红 |
| 导出 Excel | 定时 | `exportJcXxExcell` | `/excel/exportJcXxExcell` | 每 10 分钟自动下载一次 |
| 退出 | 顶部 | `handleLogout` | — | 关 WS、清定时器、清本地登录信息 |
### 4.1 临叫的 SIP 前置动作
`handleRoomCallConfirm`([JcXx.vue:4263](../../YktVue/src/views/JcXx.vue#L4263))在调用后端前先向 SIP 发送:
```json
{
"reqAct": {
"type": "call_make",
"actId": "actid_12121212",
"params": ["sip:1001@10.32.100.166"]
}
}
```
- 目标 SIP 地址、`actId` 都是硬编码;
- `语音临叫` 按钮在 `sipclient.connected == 0` 时禁用,但喊话临叫没有同样保护;
- SIP 发送失败不会阻断后端调用,前后端状态可能不一致。
## 5. 时间规则与校验
### 5.1 规则来源
`getSjgzConfig()`([JcXx.vue:3782](../../YktVue/src/views/JcXx.vue#L3782))调用 `/jcgl/getSjgz`,取第一条:
| 字段 | 默认 | 含义 |
| --- | --- | --- |
| `kjjcsjgz` | 150 | 库出/库接提前叫乘分钟数 |
| `zjjcsjgz` | 120 | 站接提前叫乘分钟数 |
| `lysjgz` | 30 | 叫乘后到应离寓的分钟数 |
接口失败时页面回退到同一组默认值并提示。
### 5.2 计算关系
`test()`(修改叫乘,[JcXx.vue:3901](../../YktVue/src/views/JcXx.vue#L3901))与 `calculateJlxxTime()`(交路新增,[:3998](../../YktVue/src/views/JcXx.vue#L3998)):
```text
叫乘时间 jcsj = 开车时间 kcsj − 规则分钟(站接 zjjcsjgz,库出/库接/未选 kjjcsjgz)
复叫时间 fjsj = jcsj + 10 分钟
报警时间 jcbjsj = fjsj + 5 分钟
应离寓 ylysj = jcsj + lysjgz(默认 30 分钟)
```
注意:交路新增表单里的字段叫 `lysj`,修改叫乘表单里叫 `ylysj`,两者复用同一套计算,但字段名不同。
### 5.3 合法性校验
`validateCallDutyTimeline`([JcXx.vue:3852](../../YktVue/src/views/JcXx.vue#L3852))要求:
1. 五个时间全部存在且格式正确(`yyyy-MM-dd HH:mm[:ss]`);
2. 严格满足 `kcsj > ylysj > jcbjsj > fjsj > jcsj`;
3. `kcsj − jcsj < 24 小时`。
`handleJcsjChange`([:3870](../../YktVue/src/views/JcXx.vue#L3870))在手工修改叫乘时间时重算后三个时间,并要求当前开车时间仍然合法。
### 5.4 修改叫乘的提交
`handleUpdateJcXxConfirm`([JcXx.vue:3335](../../YktVue/src/views/JcXx.vue#L3335))先规范化 5 个时间字段、再校验时间线,最后提交;`00000` 关闭弹窗刷新列表,`11111` 只弹错误不关闭。
## 6. 轮询与自动行为
`mounted()` 注册的定时器:
| 定时器 | 周期 | 动作 | 副作用 |
| --- | --- | --- | --- |
| `latestSyncTimeTimer` | 30 s | `loadLatestSyncTime` → `/jcgl/getLatestSyncTime` | 更新底部“最近同步时间” |
| `minuteTimer` | 10 min | `getJcXxList` | 刷新列表并触发告警音/TTS 调度 |
| `fiveMinuteTimer` | 10 min | `exportJcXxExcell` | **自动下载 Excel 到浏览器** |
| `threeMinuteTimer` | 10 min | `getSynJcXxIsOffline` | 运安离线时弹窗并播放离线报警音 |
| `currentDateTimer` | 1 s | 更新 `currentDate` | 大时钟 |
`getJcXxList`([JcXx.vue:2262](../../YktVue/src/views/JcXx.vue#L2262))用 `callListRequestVersion` 防止旧响应覆盖:
```js
const requestVersion = ++this.callListRequestVersion
getJcXxList(params).then(res => {
if (requestVersion !== this.callListRequestVersion) return
this.jcxxList = res.data
this.syncAlarmVoice(this.jcxxList)
...
})
```
`handleStopCalling` 成功后主动 `++callListRequestVersion`,使所有在途响应失效,避免“刚停掉的报警又被旧数据拉响”。
退出/离开页面时 `destroyed`([JcXx.vue:1740](../../YktVue/src/views/JcXx.vue#L1740))清理全部定时器、队列和语音;但 `jlwebsocketclose()` / `jcwebsocketclose()` 被直接调用,而这两个方法内部会执行 `location.reload()`,因此**路由离开本页会触发整页刷新**(详见 05、07)。
## 7. 弹窗清单
| 弹窗 | 行号 | 绑定 | 说明 |
| --- | --- | --- | --- |
| 历史叫乘信息 | 244–413 | `visible_jcxx_ls` | 查询、分页、录音回放、离寓抓拍 |
| 刷脸离寓抓拍 | 415–426 | `visible_leaveSnapshot` | 图片预览 |
| 设备信息 | 428–484 | `visible_sbxx` | 离线设备标红 |
| 未刷脸人员 | 486–496 | `visible_wslRy` | 入口按钮当前被注释 |
| 换房间 | 498–518 | `visible_hfj` | 逐人选择新房间 |
| 交路信息 | 521–584 | `visible_jlxx` | 列表、增删改、导入、确认 |
| 待确认交路信息 | 595–642 | `visible_jlqrxx` | 批量确认 |
| 新增/修改交路 | 644–779 | `visible_jlxx_add` | 时间自动计算 |
| 修改叫乘信息 | 781–898 | `visible_jcxx` | 5 个时间 + 车次选择 |
| 入寓登记信息 | 900–988 | `visible_rydj` | 刷脸照片、房间选择 |
| 人员维护 | 990–1118 | `visible_ryxxwh` | 查询、编辑、照片 |
| 现金充值 | 1120–1176 | `visible_xjcz` | 金额 0–100,WebSocket 触发 |
| 交接班 | 1192–1205 | `visible_login` | 登录表单(当前值班台不使用) |
| 房间选择 | 1207–1210 | `visible_room` | 内嵌 `Room.vue` |
| 房间维护 | 1212–1215 | `visible_roomLook` | 内嵌 `RoomLook.vue` |
| 多房间喊话 | 1222–1258 | `dialogMultiCallVisible` | 房间多选 |
| 出乘确认 | 1260–1282 | `dialog1Visible` | 不可撤销提示 |
| 广播确认 | 1284–1301 | `dialog2Visible` | 所有公共区域呼叫 |
| 临叫确认 | 1303–1317 | `dialogCallVisible` | 语音/喊话文案动态 |
| 停止呼叫确认 | 1319–1333 | `dialogCallEndVisible` | — |
| 运安离线报警 | 1335–1344 | `visible_synJcXxIsOffline` | 同步异常提示 |
| 交路及叫乘提醒 | 1347–1378 | `syncDialogVisible` | 分组提醒、10 秒自动关闭、TTS |
## 8. 历史数据与录音
- `handleLsSjClick`([JcXx.vue:4312](../../YktVue/src/views/JcXx.vue#L4312))打开弹窗后 `getLsJcXx`。
- `loadLsJcXx(reset)`([:4326](../../YktVue/src/views/JcXx.vue#L4326))每次取 100 条,`lsJcxxList.length < count` 时允许继续加载;滚动到底部触发下一页。
- 历史查询支持按“入寓时间/离寓时间”切换,日期输入兼容 `8`、`815`、`0815`、`081530` 等简写(2300 行附近)。
- 录音:`getRecordingConfig()` 获取基地址,`splitRecordFiles` / `getRecordAudioUrl` / `playRecordVoice` 处理 `recordfile` 逗号分隔的多段录音。
- 离寓抓拍:`leaveSnapshotPreviewUrl` 显示照片,加载失败时显示占位。
## 9. 代码级陷阱
| 陷阱 | 位置 | 影响 |
| --- | --- | --- |
| `setRowStyle` 内改 `this.message` | :3723 | 渲染期副作用,列表大时卡顿/提示错乱 |
| `formatTime` 定义两次 | :2276、:3153 | 后者覆盖前者,易误用 |
| `destroyed` 调用 `*websocketclose()` | :1743–1744 | 方法内 `location.reload()`,离开页面即刷新 |
| WebSocket `onclose` 无条件 `location.reload()` | :2086、:2198 | 后端重启/网络抖动会导致页面反复刷新 |
| 自动导出每 10 分钟 | :1792 | 值班机持续产生下载文件,可能占满磁盘 |
| 临叫 SIP 目标硬编码 | :4272 | 换 SIP 账号/网段必须改代码 |
| 语音临叫依赖 SIP 连接,喊话不依赖 | :153、:157 | 两条路径的前置条件不一致 |
| `handleUpdateJcXxConfirm` 只处理 `00000/11111` | :3353 | 其他错误码无提示 |
| `handleTabClick` 空实现 | :3779 | 两个 Tab 不改变数据,仅是视觉分组 |
| `handleDelete` 使用未安装的 `$modal` | :3034 | 该删除入口不可用 |
## 10. 修改落点速查
| 需求 | 修改位置 |
| --- | --- |
| 增加列表列/状态 | 模板 183–240;状态映射 :194 |
| 改行颜色/闪烁规则 | `setRowStyle` :3723 |
| 增加行操作按钮 | 模板 234–239 + 新方法 |
| 改查询条件 | `JcXxQueryParams` :1546、顶部模板 21–46 |
| 改时间计算 | `test` :3901、`calculateJlxxTime` :3998、`buildFollowUpTimes` :3808 |
| 改临叫/停止逻辑 | `handleCalling*`、`handleRoomCall*` :4194–4310 |
| 改告警/TTS | `syncAlarmVoice` :2742、`enqueueSyncNotice` :2765、`playNextSyncSpeech` :2917 |
| 改轮询周期 | `mounted` :1790–1793 |
| 改历史查询 | `loadLsJcXx` :4326、历史弹窗 244–413 |
@@ -0,0 +1,284 @@
# 05 实时通信与语音告警
前端有 5 条长连接:4 条业务 WebSocket + 1 条本机 SIP WebSocket。它们都不走 axios,地址在代码里硬编码,连接生命周期与页面绑定。
## 1. 连接清单
| 连接 | 地址 | 页面 | 用途 | 断线行为 |
| --- | --- | --- | --- | --- |
| 叫乘推送 | `ws://{apiRoot}/yktgl/jcwebsocket/{gyid}` | `JcXx.vue` | 叫乘状态、入寓人员、充值、同步提醒 | `location.reload()` |
| 交路推送 | `ws://{apiRoot}/yktgl/jlwebsocket/{gyid}` | `JcXx.vue` | 交路更新提醒 | `location.reload()` |
| 人员/入住推送 | `ws://{apiRoot}/yktgl/rlywebsocket/test33` | `shijinpeng/main.vue` | 触发自助登记 | `location.reload()` |
| 食堂推送 | `ws://{apiRoot}/yktgl/MealsWebsocket/test33` | `MealsOrdering.vue`、`paymentPlatform.vue` | 刷脸/刷卡回调 | `location.reload()` |
| SIP 客户端 | `ws://127.0.0.1:9004/sipclient/{gyid}` | `JcXx.vue` | 注册、呼叫指令 | 仅日志,不重连 |
说明:
- `apiRoot = window.location.host`,由 `public/config.js` 注入;地址里再拼 `/yktgl` 上下文。
- **协议固定 `ws://`**,没有使用 `config.js` 的 `getWebSocketUrl()`(该函数支持 wss 但无人调用)。
- 业务 WebSocket 没有心跳、没有指数退避重连,关闭即整页刷新。
- `shijinpeng` 与食堂页面把 username 固定为 `test33`,多终端同时连接时后端广播会给所有 `test33` 会话,无法区分终端。
- 后端路由规则见 [后端 07](../07-外部集成与定时任务.md#4-websocket):`jcwebsocket` 只投递给 `username == gyid` 或 `all` 的会话;`jlwebsocket`、`rlywebsocket`、`MealsWebsocket` 为广播。
## 2. 叫乘推送协议(jcwebsocket)
消息体是 JSON 字符串,至少包含 `lx`(类型);`JcXx.vue:2097` 的 `switch` 分发:
| `lx` | 附带的字段 | 前端行为 | 当前状态 |
| --- | --- | --- | --- |
| `ry` | `content` | 打开“入寓登记信息”弹窗并填充 | 启用 |
| `cz` | `content`、`id`、`deviceCode` | 打开“现金充值”弹窗,记录申请 id | 启用 |
| `cz_cancel` | `id` | 仅当 id 与当前弹窗一致时关闭,并提示“乘务员已取消” | 启用 |
| `zcxx` | `photoBase64`、`deviceCode`、`zcid` | 打开“注册人员信息”,进入新增人员流程 | 启用 |
| `xgxx` | `content`、`newPhotoBase64` | 打开“修改人员信息”,带原照片/新照片 | 启用 |
| `refresh` | — | 重新拉取叫乘列表 | 启用 |
| `autoCallFailed` | `content`、`roomNo` | 入同步提醒队列(标题“超时未叫乘提醒”)+ 刷新列表 | 启用 |
| `missedCallWarning` | `content`、`roomNo` | 入同步提醒队列 | 启用 |
| `routeChangeWarning` | `content`、`routeStatus` | 原为交路变更提醒 | **已注释禁用** |
| `routeExpiredWarning` | `content`、`roomNo` | 原为运安交路超时提醒 | **已注释禁用** |
| `callTimeResetWarning` | `content`、`roomNo` | 原为叫乘时间重置提醒 | **已注释禁用** |
| 其他 | — | 忽略 | — |
后端文档 [06-接口清单](../06-接口清单.md#9-websocket-消息) 目前只列了前 5 类,`cz_cancel`、`autoCallFailed`、`missedCallWarning` 是前端实际已在处理、文档需补齐的消息。
### 2.1 人员登记链路
```mermaid
sequenceDiagram
participant D as 自助终端/设备
participant B as 后端
participant W as JcXx 值班台
D->>B: 刷脸/读卡事件
B->>W: {"lx":"zcxx", photoBase64, zcid, deviceCode}
W->>W: 打开注册人员弹窗,设置 ryxx_code='0'
W->>B: GET /jcgl/getRyxxByRyId?ryid=...
B-->>W: 人员信息(可能不存在)
W->>B: POST /jcgl/modifyRyXx?code=0
B-->>W: 保存结果
```
`xgxx` 走同一弹窗但 `ryxx_code='1'`,提交时 `code=1`。
## 3. 交路推送(jlwebsocket)
`jlwebsocketonmessage`([JcXx.vue:2081](../../YktVue/src/views/JcXx.vue#L2081))不解析消息内容,只做两件事:
```js
this.visible_jlxxtx = true
this.playVoice() // 播放 orderCompleteTip.mp3
```
页面右下角出现“交路信息已更新”的浮动提示(`bottom-right-div`),点击后打开交路弹窗。消息内容、变更类型完全依赖用户再查询。
## 4. 告警链路
### 4.1 报警音状态机
相关字段:
- `alarmVoiceActive`:是否正在播放报警音;
- `dismissedAlarmEvents`:已人工处理的事件 key 集合;
- `audioPlayerAlarm`:`<audio loop>`,`loop` 绑定 `alarmVoiceActive`。
`syncAlarmVoice(rows)`([JcXx.vue:2742](../../YktVue/src/views/JcXx.vue#L2742))每次列表刷新后执行:
```mermaid
flowchart TD
A["收到 jcxxList"] --> B{"存在 ryzt=2/4 的行?"}
B -- 否 --> C{"alarmVoiceActive?"}
C -- 是 --> D["stopAlarmVoice()"]
C -- 否 --> E["保持静音"]
B -- 是 --> F{"该行有 alarmEventKey?"}
F -- 是 --> G{"任一 key 未被 dismissed?"}
F -- 否 --> H{"stoped != '1'?"}
G -- 是 --> I["开始播放报警音"]
H -- 是 --> I
G -- 否 --> E
H -- 否 --> E
```
事件 key 来自后端字段 `alarmEventKey`,可能是逗号分隔的多个 key(`getAlarmEventKeys`)。人工停止报警成功后:
1. 调用 `/jcgl/handleStopCalling`;
2. 把该行所有 key 写入 `dismissedAlarmEvents`;
3. `++callListRequestVersion` 使在途列表响应失效;
4. 立即 `syncAlarmVoice(this.jcxxList)` 停音;
5. 再刷新列表。
这样即使后端状态尚未落库、旧列表又返回,也不会重新响铃。对应测试见 `tests/alarm-state.test.cjs`。
### 4.2 停止报警的入口
| 入口 | 方法 | 条件 |
| --- | --- | --- |
| 顶部“停止报警” | `stopCurrentAlarm` | 选中行必须是 `ryzt=2/4` |
| 行内“暂停报警” | `handleStopCalling(row)` | 同上 |
非报警状态点击“暂停报警”只提示“当前状态停止报警无效”。
## 5. 同步提醒:弹窗队列 + TTS 队列
`autoCallFailed`、`missedCallWarning` 等消息不直接弹窗,而是走两级队列。
### 5.1 入队与合并
`enqueueSyncNotice(title, content)`([JcXx.vue:2765](../../YktVue/src/views/JcXx.vue#L2765)):
1. 以 `title|content` 为 key 去重;
2. 已人工关闭过的 key(`syncDismissedNoticeKeys`)直接丢弃;
3. 800 ms 内的多条提醒合并到一次 `flushSyncNotices()`。
`flushSyncNotices`([:2782](../../YktVue/src/views/JcXx.vue#L2782)):
- 按 `title` 分组,生成 `sections: [{title, contents[]}]`;
- 每个分组进入 TTS 队列;
- 若当前已有提醒弹窗,则把新 section 合并进当前弹窗并重置 10 秒关闭计时;
- 否则入 `syncDialogQueue`,由 `showNextSyncDialog` 依次展示;
- 每次刷新都会调用 `getJcXxList()`。
### 5.2 弹窗行为
- 标题固定“交路及叫乘提醒”,展示所有 section;
- 10 秒无操作自动关闭(`startSyncDialogCloseTimer`);
- 用户手动关闭时,`handleSyncDialogBeforeClose` 把当前所有 section 的 key 写入 `syncDismissedNoticeKeys`;
- `@closed` 后展示队列中的下一条;
- 关闭后同一内容不会再打扰,直到页面刷新(集合只在 `destroyed` 清空)。
### 5.3 TTS 播报
`enqueueSyncSpeech` 把同一 title 下的 contents 用“;”拼成一段文本。播放调度 `playNextSyncSpeech`([JcXx.vue:2917](../../YktVue/src/views/JcXx.vue#L2917)):
- 使用浏览器 `SpeechSynthesisUtterance`,`lang=zh-CN`、`rate=0.9`;
- 单条播完 500 ms 后播下一条;
- 若 `isBusinessVoiceBusy()` 为真,则 2 秒后重试;
- 业务语音(叫乘/通话/临叫/页面音频/其他 TTS)优先:`getJcXxList` 发现忙时调用 `interruptSyncSpeechForBusinessVoice()`,把当前播报退回队首并 `cancel()`,等业务语音结束再播。
`isBusinessVoiceBusy`([:2896](../../YktVue/src/views/JcXx.vue#L2896))判定任意一条成立即忙:
- 列表存在 `ryzt=1/5`;
- `activeTempCalls` 非空(本页发起的临叫);
- 4 个 `<audio>` 中任意一个正在播放;
- 浏览器正在播其他 TTS 且不是同步提醒自己。
### 5.4 队列字段总览
| 字段 | 作用 |
| --- | --- |
| `syncNoticeQueue` / `syncNoticeTimer` | 800 ms 合并窗口 |
| `syncDialogQueue` / `syncCurrentDialog` / `syncDialogVisible` | 弹窗队列 |
| `syncDialogActive` / `syncDialogCloseTimer` | 弹窗互斥与 10 秒自动关闭 |
| `syncDismissedNoticeKeys` | 已关闭提醒去重 |
| `syncSpeechQueue` / `syncSpeechCurrent` / `syncSpeechRetryTimer` | TTS 队列与重试 |
## 6. 运安离线报警
`getSynJcXxIsOffline` 每 10 分钟调用 `/jwDataLog/getSynJcXxIsOffline`([JcXx.vue:4522](../../YktVue/src/views/JcXx.vue#L4522)):
```js
if (res.data == '1') {
this.visible_synJcXxIsOffline = true
this.playOfflineAlarmVoice()
}
```
- 离线弹窗标题“报警”,内容“运安计划数据同步异常,请联系技术人员处理!”;
- 离线报警音复用 `alarm.mp3`,但不循环、不参与 `alarmVoiceActive` 状态机;
- 只提示一次,不做持续告警,也不会自动关闭(需人工点关闭);
- 未做去重,10 分钟一次会重复播放,直到后端恢复。
## 7. SIP 语音通道
`initClientSocket`([JcXx.vue:2019](../../YktVue/src/views/JcXx.vue#L2019))连接 `ws://127.0.0.1:9004/sipclient/{gyid}`,连接成功后设置 `sipclient.connected = 1`。
前端只发送两类报文:
### 7.1 选中行时注册状态
```json
{
"regSta": {
"acc_no": 1,
"reg_act": 0,
"sta": 2,
"code": 200,
"expire": 180
}
}
```
在 `handleRowClick` 中发送([JcXx.vue:3241](../../YktVue/src/views/JcXx.vue#L3241)),目的疑似让 SIP 客户端切换/刷新注册账号。
### 7.2 发起呼叫
```json
{
"reqAct": {
"type": "call_make",
"actId": "actid_12121212",
"params": ["sip:1001@10.32.100.166"]
}
}
```
在 `handleRoomCallConfirm` 中发送([:4268](../../YktVue/src/views/JcXx.vue#L4268)),随后才调用后端 `/jcgl/handleRoomCall(2)`。
问题:
- 目标 `sip:1001@10.32.100.166` 与 `actId` 都是硬编码,换环境必须改代码;
- SIP 发送与后端调用没有事务关系,任一方失败都会造成“界面显示已呼叫/实际未呼叫”或相反;
- 停止呼叫只调后端,没有对应的 SIP `call_end` 报文;
- `sipclient.connected` 只在首次 `onopen` 设置,断线后不清零,按钮仍可点。
## 8. 食堂与自助终端的推送
| 页面 | 消息处理 | 结果 |
| --- | --- | --- |
| `MealsOrdering.vue` | `messageObj.code === '5' && icTag === 1` | 调用 `/Meals/consume` 扣款,成功后 `handleSuccess` 建单;失败提示“卡内余额不足” |
| `paymentPlatform.vue` | 同样的回调 | `initWebSocket()` 在 `mounted` 被注释,回调不会触发 |
| `shijinpeng/main.vue` | 任意消息 | 重置人员信息、显示“入寓登记,请刷脸!”、开始录像 |
| `chefOrders.vue` | 不连 WebSocket | 监听 `BroadcastChannel("myChannel")` 的 `"2603"` 消息刷新订单并响铃 |
详见 [06-食堂与自助终端模块](./06-食堂与自助终端模块.md)。
## 9. 连接生命周期与风险
```mermaid
sequenceDiagram
participant P as JcXx.vue
participant WS as 业务 WebSocket
P->>WS: mounted 建立连接
WS-->>P: onopen(仅日志)
WS-->>P: onmessage(业务消息)
alt 网络抖动/后端重启
WS-->>P: onclose
P->>P: location.reload()
P->>WS: 重新加载页面后重连
end
alt 退出登录/路由离开
P->>WS: close()
WS-->>P: onclose
P->>P: location.reload()(与方法内清理竞争)
end
```
| 风险 | 说明 |
| --- | --- |
| 刷新风暴 | 后端重启时所有值班台同时 `reload`,重连后若仍失败会继续刷新 |
| 无重连退避 | 没有 `setTimeout` 重连、没有最大重试次数 |
| 退出竞争 | `handleLogout` 先 `close()` 再清登录信息,`onclose` 的 `reload` 可能在不同时序触发 |
| 路由离开刷新 | `destroyed` 直接调用 `jlwebsocketclose()`/`jcwebsocketclose()`,方法内 `reload()` |
| wss 不支持 | HTTPS 部署时 `ws://` 被浏览器拦截 |
| 固定 username | 多终端共用 `test33`,消息无法定向 |
| 无消息校验 | `JSON.parse(e.data)` 无 try/catch,非法消息会中断回调 |
## 10. 修改落点
| 需求 | 位置 |
| --- | --- |
| 新增叫乘消息类型 | `jcwebsocketonmessage` :2097 |
| 改告警判定 | `syncAlarmVoice` :2742、`getAlarmEventKeys` :2739 |
| 改提醒弹窗样式/时长 | 模板 1347–1378、`startSyncDialogCloseTimer` :2859 |
| 改 TTS 语速/优先级 | `playNextSyncSpeech` :2917、`isBusinessVoiceBusy` :2896 |
| 改 WebSocket 地址/重连 | `initWebSocket` :2005、`initClientSocket` :2019 |
| 改 SIP 报文 | `handleRowClick` :3241、`handleRoomCallConfirm` :4263 |
| 改离线报警 | `getSynJcXxIsOffline` :4522 |
@@ -0,0 +1,221 @@
# 06 食堂与自助终端模块
本模块包含 6 个“终端型”界面:自助登记、点餐、结算、后厨、保洁,以及值班台复用的选房/房间维护组件。它们共同的特点是:**无导航、固定 URL、部分公寓/终端标识硬编码、状态靠整页刷新或广播同步**。
## 1. 自助登记终端 `/ykt_main`
文件:[`src/shijinpeng/main.vue`](../../YktVue/src/shijinpeng/main.vue)(943 行)。
### 1.1 定位与配置
- 面向乘务员本人,部署在公寓大厅触摸屏一体机。
- 页面内固定:
```js
gyid: "SYBXC"
gymc: "沈阳站公寓"
```
([main.vue:291](../../YktVue/src/shijinpeng/main.vue#L291))
- 后端查询房间时也使用该 `gyid`,换公寓必须改代码或改成运行时可配。
- 连接 `ws://{apiRoot}/yktgl/rlywebsocket/test33`([main.vue:379](../../YktVue/src/shijinpeng/main.vue#L379)),任何消息都会触发“入寓登记,请刷脸!”。
### 1.2 业务模式
| 模式 | 入口方法 | `ryzt` | 流程 |
| --- | --- | --- | --- |
| 入寓登记 | `rydj()` | `1` | 弹窗 → 刷脸 → `getRyxx(ryid)` 取人员 → `getBtje_info` 取餐补 → `finalCheck` **只广播 `channel_rly_jc` 并整页刷新,不调用后端** |
| 出乘离寓 | `ccly()` | `3` | 刷脸 → `getJcxxByRyid(ryid)` 取叫乘记录 → 合并房间信息 → `finalCheck` 调 `/jcgl/submitAll` → 成功广播并整页刷新 |
| 就餐补助 | `jcbz()` | `5` | 刷脸 → `getBtje_infoMeal3` → `updateMeal3` 领取第三餐补 |
入寓登记的关键代码在 `rydj_submit()`([main.vue:426](../../YktVue/src/shijinpeng/main.vue#L426)),但这行:
```js
import { ..., getRyxx, ... } from "@/api/jcgl/jcgl"
```
`jcgl.js` 并没有导出 `getRyxx`,实际函数名是 `getRyXxByRyId`。**刷脸确认时会抛 `TypeError: getRyxx is not a function`,入寓登记链路目前是断的。**
### 1.3 录像与刷脸
- 使用 `navigator.mediaDevices.getDisplayMedia` 录屏(`invokeGetDisplayMedia` / `captureScreen` / `startRecording`,[main.vue:751](../../YktVue/src/shijinpeng/main.vue#L751) 起)。
- 一体机需要允许屏幕捕获权限;普通浏览器会弹权限框,不适合无人值守。
- 录像在提交后未显式上传,`stopRecording` 的结果处理需结合后端设备协议确认。
### 1.4 房间选择
- 页面自行拉取 `getRoomList`,按 `row_sort` 分组,状态统计使用 `zd_roomStatus_groupName`。
- 可选房间规则与 `Room.vue` 类似:状态 `1/3/5/6`。
- 选择后 `select_room(item)` 把房间字段合并进 `jcxx`,`finalCheck` 时一并提交。
### 1.5 跨页广播
`finalCheck` 通过 `BroadcastChannel("channel_rly_jc")` 发送 JSON 字符串([main.vue:676](../../YktVue/src/shijinpeng/main.vue#L676)、[:685](../../YktVue/src/shijinpeng/main.vue#L685))。**仓库内没有任何监听 `channel_rly_jc` 的代码**,这条广播目前是空写;如果要让值班台实时感知,需要在 `JcXx.vue` 增加监听或改用 WebSocket。
更关键的是 `finalCheck` 的分支([main.vue:675](../../YktVue/src/shijinpeng/main.vue#L675)):
```js
if (this.jcxx.ryzt == '1') { // 入寓登记
channelObj.postMessage(...)
this.$router.go(0) // 没有 submitAll
}
if (this.jcxx.ryzt == '3') { // 出乘离寓
submitAll(this.jcxx)...
}
```
也就是说,**自助终端的入寓登记即使修好 `getRyxx`,也不会把登记写回后端**,只广播给一个不存在的监听者后刷新页面。这是比导入错误更根本的断链。
## 2. 点餐终端 `/MealsOrdering`
文件:[`src/zhengchuang/views/MealsOrdering.vue`](../../YktVue/src/zhengchuang/views/MealsOrdering.vue)(988 行)。
### 2.1 点餐流程
```mermaid
sequenceDiagram
participant U as 乘务员
participant P as MealsOrdering
participant B as 后端 /Meals
participant K as 后厨 chefOrders
U->>P: 选择主食/副食/小菜,加入购物车
U->>P: 点击确定
P->>P: 显示等待刷脸,30 秒倒计时
B-->>P: WS 推送 {code:'5', icName}
P->>B: POST /Meals/consume?ryid&xfje
alt 扣款成功
P->>B: POST /Meals/createOrder
loop 每个购物车项
P->>B: POST /Meals/addOrders_MX
end
opt 偏餐 mcb=3
P->>K: BroadcastChannel("myChannel").postMessage("2603")
end
P->>P: 显示成功,3 秒后关闭弹窗
else 余额不足
P->>U: MessageBox 提示“卡内余额不足”
end
```
关键点:
- 人脸/刷卡结果来自 `ws://{apiRoot}/yktgl/MealsWebsocket/test33`([MealsOrdering.vue:392](../../YktVue/src/zhengchuang/views/MealsOrdering.vue#L392))。
- **先扣款、后建单**,且建单与明细没有事务:`consume` 成功但 `createOrder` 失败时,钱已扣、订单不存在;`addOrders_MX` 失败被空 `catch` 吞掉,会出现订单少明细。
- 成功后 3 秒自动关闭弹窗(`timeCount3`),等待刷脸 30 秒超时则 `window.location.reload()`。
### 2.2 已知缺陷
| 缺陷 | 位置 | 影响 |
| --- | --- | --- |
| `isMealsClass()` 使用未定义的 `this.hours` | :262、:269 | 条件永远为 false,餐次永远是“偏餐”,`mcb` 恒为 3 |
| `setInterval(this.getMealsInfo, ...)`,但方法不存在 | :257 | 30 分钟后定时器报错;菜单不会自动刷新 |
| `queryParams` 中 `mcb` 重复声明 | :229、:235 | 后者覆盖前者,当前值 `null` |
| `consume()` 方法内调用未定义的 `updateMoney()` | :436 | 若走该路径会报错(实际扣款走 API 的 `consume`) |
| `destroyed` 调用 `websocketclose()` | :450 | 方法内 `location.reload()`,离开页面即刷新 |
| WebSocket 断开即刷新 | :440 附近 | 网络抖动导致点餐页反复重载 |
## 3. 结算终端 `/paymentPlatform`
文件:[`src/zhengchuang/views/paymentPlatform.vue`](../../YktVue/src/zhengchuang/views/paymentPlatform.vue)(570 行)。
- 复用点餐的菜品/订单 API,页面职责是“录入金额 + 刷脸结算”。
- `mounted()` 中 `this.initWebSocket()` 被注释([paymentPlatform.vue:212](../../YktVue/src/zhengchuang/views/paymentPlatform.vue#L212)),**刷脸回调不会触发**;`initWebSocket` 方法本身仍在(:376)。
- `isHoliday()`(:224)在节假日把金额锁定:早餐 10 元、正餐 20 元,否则允许手工输入。
- `handleConfirm` → 30 秒倒计时等待刷脸 → `handleSuccess` → `createOrder` + 明细。
- 该页只导入了 `getMeals/createOrder/addOrders_MX/getCsLb`,**完全没有调用 `/Meals/consume`**;即使恢复 WebSocket,也只是建单、不扣款。
- 结论:**当前结算页只能录入金额和生成订单,不能完成真实扣款闭环**,上线前必须确认是否启用。
## 4. 后厨终端 `/chefOrders`
文件:[`src/zhengchuang/views/chefOrders.vue`](../../YktVue/src/zhengchuang/views/chefOrders.vue)(532 行)。
- 拉取待处理订单:`OrdersSearch`(:341),查看明细:`OrderMX_search`(:349)。
- 完成订单:`updateStatus(order_id)`(:359),成功后 1 秒关闭弹窗并刷新。
- 新单提示:监听 `BroadcastChannel("myChannel")`,收到 `"2603"` 时刷新并播放 `newOrderTip.mp3`(:394–:406)。
- 缺陷:`beforeDestroy` 里新建了另一个 `BroadcastChannel` 实例再 `removeEventListener`(:412–:413),**监听器不会被移除**;在单页应用内多次进入会重复响应。
## 5. 保洁终端 `/roomService`
文件:[`src/zhengchuang/views/roomService.vue`](../../YktVue/src/zhengchuang/views/roomService.vue)(339 行)。
- 固定 `gyid: "SYBXC"`、`room_floor: "1"`([roomService.vue:190](../../YktVue/src/zhengchuang/views/roomService.vue#L190))。
- `northRoomSearch` / `southRoomSearch` 分别按 `room_face` 拉取南北两侧房间,生成房间卡片。
- 点击房间 → `roomClick` → `cleanStart` 打开弹窗并启动秒表。
- `cleanComplete()`(:260)**只弹“已完成清扫”并重置计时,不调用 `/clean/endCleaning`**,房间状态不会变化。
- 弹窗里的布草/备品/报修区域没有对应接口,属于未接线界面。
## 6. 选房与房间维护
### 6.1 `Room.vue` 选房器
- 数据:`getRoomList()`,按 `room_floor` 分组,统计各状态数量。
- 状态颜色:
| `room_status` | 含义 | 按钮类型 |
| --- | --- | --- |
| `1` | 空闲 | success |
| `2` | 住满 | info |
| `3` | 待清扫 | warning |
| `4` | 不能入住 | danger |
| `5` | 未满 | primary |
| `6` | 正在保洁 | 默认(紫色样式) |
- `select_room`([Room.vue:209](../../YktVue/src/components/Room.vue#L209))只允许 `1/3/5/6`;其他状态提示“仅状态为 1、3、5、6 的房间可选择入住”。
- 推荐房来自父组件传入的 `recommendations`,点击后按 `room_no` 回查真实房间再选中;推荐理由直接显示 `recommend_reason`。
- 确认时 `$emit('childByValue', room)`;父组件 `JcXx.vue` 的 `childByValue` 根据当前弹窗把房间合并进入寓表单或换房列表。
- 缺陷:状态 `6` 可以选择,但 `zd_roomStatus_statusName` 没有 `6` 分支,确认框会显示“房屋状态:undefined”。
### 6.2 `RoomLook.vue` 房间维护
- 数据:`getRoomList()`,状态统计包含 `6`。
- 支持**多选相同状态**的房间(`select_room`,[RoomLook.vue:279](../../YktVue/src/components/RoomLook.vue#L279)),混合状态会拒绝。
- 批量操作:
| 操作 | 方法 | 接口 | 前置条件 |
| --- | --- | --- | --- |
| 开始保洁 | `startCleanSelected` | `/clean/startCleaning` | 选中状态 `3`(待清扫) |
| 结束保洁 | `endCleanSelected` | `/clean/endCleaning` | 选中状态 `6`(正在保洁) |
| 设为不能入住 | `setSelectedRoomsUnavailable` | `/jcgl/modifyRoomStatus?zt=4` | 状态 `1/2/3/5` 且房间无人 |
| 恢复可入住 | `restoreSelectedRoomsAvailable` | `/jcgl/modifyRoomStatus?zt=1` | 选中状态 `4` |
- `batchRun` 用 `Promise.all` 并发调用,任一失败会整体 reject,但已成功的房间不会回滚;界面只显示成功提示(失败被 `catch` 吞掉)。
- 保洁时长由 `bjkssj` 计算,仅用于展示。
## 7. 跨页面广播矩阵
| 通道 | 写入方 | 读取方 | 消息 | 状态 |
| --- | --- | --- | --- | --- |
| `myChannel` | `MealsOrdering`(偏餐下单)、`paymentPlatform` | `chefOrders` | `"2603"` | 可用(后厨监听器移除有 bug) |
| `channel_rly_jc` | `shijinpeng/main.vue` | 无 | JSON 登记数据 | **空写,无人监听** |
| `localStorage.gyid` / `loginAccount` | `Login.vue` | 所有页面/路由守卫 | 登录痕迹 | 可用,但可被手工篡改 |
| `location.reload()` / `$router.go(0)` | 各终端 | — | 整页刷新 | 事实上的“状态同步”手段 |
## 8. 终端模块风险汇总
| 级别 | 问题 | 位置 |
| --- | --- | --- |
| P0 | 自助终端 `getRyxx` 未导出,且入寓分支不调 `submitAll`,登记链路双重断裂 | `shijinpeng/main.vue:280`、`:428`、`:675` |
| P0 | 结算页刷脸链接被注释,且未调用扣款接口,结算闭环不成立 | `paymentPlatform.vue:212`、`:314` |
| P1 | 点餐餐次判断恒为偏餐 | `MealsOrdering.vue:262` |
| P1 | 点餐 30 分钟定时器调用不存在的方法 | `MealsOrdering.vue:257` |
| P1 | 扣款与建单无事务,可能钱扣了没订单 | `MealsOrdering.vue:334` 起 |
| P1 | 保洁完成不回写后端 | `roomService.vue:260` |
| P1 | 公寓/终端标识硬编码(`SYBXC`、`test33`) | `main.vue:291`、`roomService.vue:190`、各 WS URL |
| P2 | 后厨广播监听器泄漏 | `chefOrders.vue:412` |
| P2 | `channel_rly_jc` 无人监听 | `main.vue:285` |
| P2 | 状态 6 选房确认文案 undefined | `Room.vue:229` |
| P2 | 批量操作无失败回滚/提示 | `RoomLook.vue:313` |
## 9. 修改落点
| 需求 | 位置 |
| --- | --- |
| 自助终端支持多公寓 | `main.vue` 的 `gyid`/`gymc`、WS 路径、`roomService.vue` 的 `queryParams.gyid` |
| 修复入寓登记 | `main.vue:280` 改导入 `getRyXxByRyId`,同步改 `:428` 调用 |
| 启用结算刷脸 | `paymentPlatform.vue:212` 取消注释,并补齐扣款调用 |
| 改点餐餐次 | `MealsOrdering.vue`/`paymentPlatform.vue` 的 `isMealsClass`,用 `minute` 计算而非 `hours` |
| 改后厨提示 | `chefOrders.vue:394` 的 `handleReceivedMessage` |
| 改保洁闭环 | `roomService.vue:260` 接 `/clean/endCleaning` |
| 改选房规则 | `Room.vue:210`、`RoomLook.vue:287`、`:318` 起 |
@@ -0,0 +1,193 @@
# 07 构建部署与风险清单
## 1. 构建脚本
[`package.json`](../../YktVue/package.json) 实际脚本:
| 命令 | 作用 | 说明 |
| --- | --- | --- |
| `npm run dev` | 开发服务器 | 端口 `8080`,`host 0.0.0.0`,启动后自动开浏览器 |
| `npm run build:prod` | 生产构建 | 输出 `dist/`,`publicPath=/yktgl/` |
| `npm run build:stage` | staging 构建 | 仍请求 `/yktgl`(环境变量未生效) |
| `npm run preview` | 预览 | 依赖 `build/index.js`,仓库中不存在该文件 |
| `npm run lint` | ESLint | `eslint --ext .js,.vue src` |
环境要求:
```json
"engines": { "node": ">=16 <19", "npm": ">=8 <11" }
```
实际脚本使用 `node --openssl-legacy-provider`(Vue CLI 4 / webpack 4 在新 OpenSSL 下的兼容做法)。Node 16 使用的是 OpenSSL 1.1.1,可能不识别该参数;建议统一到 Node 18 LTS,并锁定 npm 版本。
`YktVue/README.md` 仍写 `npm run serve` / `npm run build`,与 `package.json` 不一致,按上表为准。
## 2. 环境与地址矩阵
| 环境 | 启动方式 | 页面地址 | 接口 baseURL | WebSocket |
| --- | --- | --- | --- | --- |
| 开发 | `npm run dev` | `http://localhost:8080` | `/dev-api` → 代理到 `http://127.0.0.1:8081/yktgl` | `ws://localhost:8080/yktgl/...` → 开发服务器是否代理 WS 未配置 |
| 生产 | `npm run build:prod` | `http://host/yktgl/` | `/yktgl` | `ws://host/yktgl/...` |
| staging | `npm run build:stage` | 同生产 | `/yktgl` | 同生产 |
开发代理配置在 [`vue.config.js`](../../YktVue/vue.config.js):
```js
'/dev-api': {
target: 'http://127.0.0.1:8081/yktgl',
changeOrigin: true,
pathRewrite: { '^/dev-api': '' }
}
```
注意:
- 开发代理只配了 HTTP,没有 `ws: true`;**本地 WebSocket 实际连的是前端 dev server 的 `8080`,不会自动转到 8081**,需要浏览器插件/反向代理或直接改代码。
- `.env.*` 的 `VUE_APP_BASE_API` 全部未生效;`request.js` 写死 `/dev-api` 与 `/yktgl`。
- `fileRequest.js` 写死 `/yktgl`,开发环境导出 404。
## 3. 部署要点
仓库内没有 nginx、Dockerfile、部署脚本,部署依赖现场约定。按代码推断的最小拓扑:
```nginx
server {
listen 80;
server_name _;
# 前端静态资源
location /yktgl/ {
root /opt/app; # /opt/app/yktgl/index.html
try_files $uri $uri/ /yktgl/index.html;
}
# 后端接口与 WebSocket
location /yktgl/ {
proxy_pass http://127.0.0.1:8081/yktgl/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}
```
上面的两段 `location /yktgl/` 会冲突,实际部署需要按路径拆分(例如静态资源用 `/yktgl/static/`、`/yktgl/index.html`,接口用 `/yktgl/jcgl/` 等),或让后端同时托管静态资源。**上线前必须拿到现场真实 nginx 配置**,不要照抄本示例。
关键约束:
1. **静态与接口同源**:`publicPath`、`baseURL`、WebSocket 路径都假定 `/yktgl` 由同一个 host 提供。
2. **SIP 网关必须在本机 9004**:值班台写死 `ws://127.0.0.1:9004`。
3. **HTTPS 不可用**:所有 WebSocket 都是 `ws://`,页面一旦上 HTTPS 会被浏览器按混合内容拦截。
4. **浏览器权限**:自助终端使用 `getDisplayMedia` 录屏,需要现场授权;无人值守场景要配置 kiosk 模式与自动授权。
5. **多公寓**:当前产物只能通过改 `public/config.js`、`shijinpeng/main.vue`、`roomService.vue` 的常量来切换,不能一套包多公寓运行。
## 4. 测试基线
仓库没有 jest/vitest,也没有 `npm test`。现有两个纯 Node 脚本,直接在 `YktVue/` 下运行:
```bash
node tests/alarm-state.test.cjs
node tests/recharge-cancellation.cjs
```
| 脚本 | 覆盖内容 | 当前结果(2026-11-07) |
| --- | --- | --- |
| `tests/alarm-state.test.cjs` | 告警事件去重、新事件响铃、分组行、列表响应乱序、停止报警使在途响应失效 | 5 个场景通过 |
| `tests/recharge-cancellation.cjs` | 充值弹窗按 id 匹配取消、无关取消、后端拒绝、旧成功响应、网络失败 | 5 个场景通过 |
两个脚本通过正则截取 `JcXx.vue` 的方法源码再 `new Function` 执行,**重构方法名/缩进会导致测试失效**,改 `JcXx.vue` 后要同步检查。
建议补充的测试优先级:
1. 时间规则计算与 24 小时校验(`buildFollowUpTimes`、`validateCallDutyTimeline`);
2. 时间输入规范化(`8`、`815`、`0815`、`081530`);
3. 房间可选状态过滤(`Room.vue` 的 `1/3/5/6`);
4. 请求响应码分支(`11111` 的业务处理);
5. 食堂下单失败/部分明细失败的回滚策略(需要先重构)。
## 5. 风险清单
### P0:上线/运行阻断
| # | 风险 | 证据 | 影响 | 建议 |
| --- | --- | --- | --- | --- |
| P0-1 | 登录态是本地假 token,守卫可绕过 | [Login.vue:83](../../YktVue/src/views/Login.vue#L83)、[router/index.js:85](../../YktVue/src/router/index.js#L85) | 任何人写 `localStorage.loginAccount` 即可进入;后端 Session 过期后页面仍可操作,接口才失败 | 后端签发 token/Session 标识,前端守卫校验并处理 401;退出调用后端 |
| P0-2 | WebSocket 断开即整页刷新,且 `destroyed` 主动触发 | [JcXx.vue:1743](../../YktVue/src/views/JcXx.vue#L1743)、[:2086](../../YktVue/src/views/JcXx.vue#L2086)、[:2198](../../YktVue/src/views/JcXx.vue#L2198) | 后端重启/网络抖动导致值班台反复重载,告警期间可能丢失操作 | 改为退避重连;`destroyed` 只关闭连接,不调用含 `reload` 的方法 |
| P0-3 | 自助终端导入不存在的 `getRyxx`,且入寓分支不提交后端 | [main.vue:280](../../YktVue/src/shijinpeng/main.vue#L280)、[:428](../../YktVue/src/shijinpeng/main.vue#L428)、[:675](../../YktVue/src/shijinpeng/main.vue#L675) | 入寓登记刷脸必抛异常;即使修复,也只广播不落库,终端主功能不可用 | 改为 `getRyXxByRyId`,并在 `ryzt=1` 分支调用 `/jcgl/submitAll` 后联调 |
| P0-4 | 结算页刷脸链路被注释且不扣款 | [paymentPlatform.vue:212](../../YktVue/src/zhengchuang/views/paymentPlatform.vue#L212)、[:314](../../YktVue/src/zhengchuang/views/paymentPlatform.vue#L314) | 结算闭环不成立,可能“下单不扣钱” | 确认该页是否在用;启用 WS 并调用 `/Meals/consume` |
| P0-5 | 生产强依赖 `ws://` 与本机 SIP | [JcXx.vue:2007](../../YktVue/src/views/JcXx.vue#L2007)、[:2020](../../YktVue/src/views/JcXx.vue#L2020) | HTTPS 部署失败;值班台与 SIP 网关必须同机 | 统一用 `getWebSocketUrl()`;SIP 地址配置化 |
| P0-6 | 多公寓/多终端标识硬编码 | `shijinpeng/main.vue:291`、`roomService.vue:190`、各 WS 的 `test33` | 换公寓/多终端需要改代码重打包;消息无法定向 | 改为登录/URL 参数/后端下发 |
### P1:功能错误或明显数据风险
| # | 风险 | 证据 | 影响 |
| --- | --- | --- | --- |
| P1-1 | `11111` 被拦截器当成功返回 | [request.js:79](../../YktVue/src/utils/request.js#L79) | 调用方漏判时会把业务失败当成功;各页面处理不一致 |
| P1-2 | 网络错误无全局提示,`.catch()` 多为空 | [request.js:94](../../YktVue/src/utils/request.js#L94) | 用户点按钮无反应,排障困难 |
| P1-3 | 环境变量不生效、两套 baseURL | `request.js:17`、`fileRequest.js:6` | 切环境/切域名必须改代码;开发导出失败 |
| P1-4 | 自动导出每 10 分钟 | [JcXx.vue:1792](../../YktVue/src/views/JcXx.vue#L1792) | 值班机持续下载,占磁盘、干扰浏览器 |
| P1-5 | 点餐餐次判断恒为偏餐 | [MealsOrdering.vue:262](../../YktVue/src/zhengchuang/views/MealsOrdering.vue#L262) | 早餐/正餐菜单和价格取错 |
| P1-6 | 点餐定时器调用不存在的方法 | [MealsOrdering.vue:257](../../YktVue/src/zhengchuang/views/MealsOrdering.vue#L257) | 30 分钟后报错,菜单不刷新 |
| P1-7 | 扣款与建单非原子 | [MealsOrdering.vue:334](../../YktVue/src/zhengchuang/views/MealsOrdering.vue#L334) | 钱扣了没订单,或订单缺明细 |
| P1-8 | 保洁完成不回写 | [roomService.vue:260](../../YktVue/src/zhengchuang/views/roomService.vue#L260) | 房间状态永远停在“正在保洁/待清扫” |
| P1-9 | `JcXx.vue` 5294 行单体 | `src/views/JcXx.vue` | 修改风险高、测试只能截取方法、多人协作冲突 |
| P1-10 | 防重复提交只写不判 | [request.js:51](../../YktVue/src/utils/request.js#L51) | 双击/弱网重试可能重复充值、重复叫乘 |
| P1-11 | 前端 gyid 与后端 IP 解析 gyid 双源 | `JcXx.vue:1728`、后端 `getGyIdByIp` | 登录名不是公寓代码时,推送收不到/查询与推送范围不一致 |
### P2:代码质量与遗留
| # | 风险 | 证据 |
| --- | --- | --- |
| P2-1 | `setRowStyle` 渲染期修改 `this.message` | `JcXx.vue:3723` |
| P2-2 | `formatTime` 重复定义 | `JcXx.vue:2276`、`:3153` |
| P2-3 | `JcXx.vue` 导入不存在的 `auditJlXx` | `JcXx.vue:1392` |
| P2-4 | `handleDelete` 使用未安装的 `$modal` | `JcXx.vue:3034` |
| P2-5 | `plugins/*` 未安装、`utils/permission.js` 依赖不存在的 store | `src/plugins/`、`src/utils/permission.js` |
| P2-6 | `App.vue` 结尾多字面量 `3` | `App.vue:32` |
| P2-7 | `Home.vue` 使用未注册的 `<test1>` | `Home.vue:6` |
| P2-8 | 遗留文件:`JcXx.vue.bak_mojibake`、`RoomLook copy.vue`、`media.vue` | `src/views/`、`src/components/` |
| P2-9 | 后厨 `BroadcastChannel` 监听器泄漏 | `chefOrders.vue:412` |
| P2-10 | `channel_rly_jc` 无人监听 | `main.vue:285` |
| P2-11 | 状态 6 选房确认文案 undefined | `Room.vue:229` |
| P2-12 | 批量房间操作无失败回滚/提示 | `RoomLook.vue:313` |
| P2-13 | 无 404 路由 | `router/index.js` |
| P2-14 | README 命令与实际脚本不一致 | `YktVue/README.md` |
## 6. 改动落点速查
| 需求类型 | 首选落点 | 需要同步 |
| --- | --- | --- |
| 新增业务接口 | `src/api/jcgl/jcgl.js` | 页面调用、后端文档 06 |
| 改登录/鉴权 | `Login.vue`、`utils/auth.js`、`router/index.js`、`request.js` | 后端 `/auth/*`、部署 Session |
| 改 WebSocket/告警 | `JcXx.vue` 的 `initWebSocket`、`jcwebsocketonmessage`、`syncAlarmVoice` | 后端 `Jcwebsocket`、文档 05 |
| 改叫乘操作 | `JcXx.vue` 对应 `handle*` 方法 | 后端 `JcglController/Service`、文档 04 |
| 改房间状态 | `Room.vue`、`RoomLook.vue` | `/jcgl/getRoomList`、`/clean/*`、状态码表 |
| 改自助终端 | `shijinpeng/main.vue` | `rlywebsocket`、`/jcgl/submitAll`、设备录像协议 |
| 改食堂 | `zhengchuang/views/*`、`api/zhengchuang/mealsOrdering.js` | `/Meals/*`、`MealsWebsocket`、广播协议 |
| 改构建/代理 | `vue.config.js`、`request.js`、`fileRequest.js` | nginx、`.env.*`、部署文档 |
## 7. 待确认问题
以下问题无法仅从代码判断,需要业务/现场确认:
1. `paymentPlatform` 是否仍在用?如果不用,是否下线路由和页面?
2. `shijinpeng/main.vue` 的入寓登记是否还有现场依赖?`getRyxx` 是改名遗漏还是有意停用?
3. 现场是否有 HTTPS/域名计划?如果有,WebSocket 必须改 `wss`,SIP 也要确认是否支持 TLS。
4. 自动导出 Excel 是业务要求还是调试遗留?如果是要求,应改为后端定时任务或手工导出。
5. 多公寓是否仍靠“一套代码改常量分别打包”?是否有统一配置中心计划?
6. 值班台是否会在同一浏览器打开多个页面?`location.reload()` 和 `BroadcastChannel` 的假设都基于单页/单终端。
7. 后端 `jcwebsocket` 的 `cz_cancel`、`autoCallFailed`、`missedCallWarning` 消息是否已稳定?后端文档需要同步补齐。
## 8. 建议的迭代顺序
1. **先修 P0 阻断**:自助终端导入、结算闭环确认、WebSocket 重连、鉴权方案。
2. **再修 P1 数据风险**:扣款/建单一致性、自动导出、餐次判断、时间规则测试。
3. **然后做结构治理**:拆分 `JcXx.vue`(布局 / 列表 / 告警 / 操作 / 历史),统一请求层与环境配置。
4. **最后清遗留**:删除模板页面/插件/备份文件,补 404、补测试、更新 README。
## 9. 文档维护
- 本目录与后端文档同基准提交;改接口、改路由、改 WebSocket 消息时同步更新对应章节。
- 行号会随重构漂移,检索时优先用方法名/文件名。
- 新增前端文档时在 [README](./README.md) 索引中登记,并保持与 [后端文档索引](../backend/README.md) 的交叉链接。
@@ -0,0 +1,91 @@
# 08 · JcXx 值班主界面体验优化说明
> 面向:`YktVue/src/views/JcXx.vue`(`wc -l` 5294 行)
> 新版页面:`YktVue/src/views/JcXx.optimized.vue`(5603 行 / 214423 字节,保持原 CRLF 换行)
> 原则:**视觉冻结、功能冻结,只修体验**。原文件保留不动,确认后改名替换即可。
## 一、什么没变
| 项 | 结论 | 校验 |
| --- | --- | --- |
| `<script>` | 逐字节未改,109356 字节,sha256 前缀 `5b29b0f3c7b7f708` | FUN-01 |
| 功能集合 | 22 个弹窗、15 列主表、全部按钮 / 输入框 / 事件绑定 / `v-model` / `ref` / `slot-scope` 的集合与数量一致 | FUN-02 / 03 |
| 配色 | 只使用本页既有色值:深青 `#029191` / `#0b5858`、亮黄 `#fff45c`、墨 `#2c3e50`、红 `#c45656`、`#F56C6C`、绿 `#00FF00`、选中蓝 `#1990f1`、边框 `#dcdfe6` | 人工核对 |
| 字体 | 字号基准(行内 20px、吸底 24px、跑马灯 22px、按钮 13px)与字族未改,只统一原本互相打架的断点值 | 人工核对 |
| 组件形态 | 仍是 `el-card` / `el-row` / `el-col` / `el-tabs` / `el-table` / `el-dialog` / `el-form` + 内联渐变按钮 | FUN-03 |
| 信息呈现 | 表格列与顺序、字段、状态中文词、跑马灯与吸底条文案、行内状态底色规则全部原样 | FUN-02 cols |
模板只做两类改动:① 增加语义类名 `jc-opt-root` / `jc-header-*` / `jc-dialog` / `jc-wide-dialog` / `jc-st` / `jc-user-label`;② 把 `@tab_click` 修正为 `@tab-click`(Element UI 实际派发 `tab-click`;`handleTabClick` 函数体为空,行为不变)。`<style>` 原规则全部保留,仅修正 4 处错误,其余优化集中在末尾 306 行的「体验优化层」,可整段移除回退。
## 二、优化点
### 1. 布局拥挤 / 留白失衡
- 顶部 `el-row` 固定 `height:150px`,跑马灯又加 `margin-top:40px`,查询区被挤出容器 —— 改 `height:auto + min-height:150px`,跑马灯上边距 10px,查询区内外边距统一。
- 跑马灯 `padding-left:100%` + 28s 动画从 `translateX(0)` 起步,**开屏数秒整条提示是空的** —— `animation-delay:-4s` 把相位提前(实测文字落在条内居中),文案 / 配色 / 滚动速度不变;`prefers-reduced-motion` 下同时去掉 padding,否则停动画后文字彻底不可见。
- 时间卡在 6/24 列里顶部对齐,下方空出约 100px —— 该列改 flex 垂直居中,时间卡宽度按列收敛。
- 右上 5 个按钮 `space-between` + 一个纯占位 `:span=1` 空列 + `:span=5` 用户区,窄屏下「当前用户」被挤成多行 —— 统一 8px 间距并允许换行,用户标签 `white-space:nowrap`。
### 2. 对齐与间距不一致
- `.top-left-input-row` / `.top-left-action-row` 的 `margin-top: -2` **缺单位,是无效 CSS** —— 归零,改用 10px 统一节奏。
- 弹窗内 label 宽度 25% / 20% / 96px 三种混用 —— 统一 96px 居中(沿用本页 `.jc-update-form` 已有口径),配对输入框 / 下拉 `flex:1`。
- 22 个弹窗统一挂 `jc-dialog` 类名,使 `append-to-body` 后样式仍能被收口(不扩大原全局样式的影响面),4 个 1800/1700px 宽弹窗另挂 `jc-wide-dialog`。
- 弹窗表单项、底部按钮之间补齐统一间距。
### 3. 信息层级不清
- 主表表头补 700 字重 + 2px 深青下边线;tab 标签补 600 字重。
- 状态文字补字重与对比色。行内底色(`setRowStyle`)已承担语义色,因此**坚持纯文字呈现,不改成标签/徽标**:报警态在红底上用白字,其余用墨色/灰色。
### 4. 交互反馈缺失
- 原 `.el-table__body tr:hover > td { background-color: inherit !important }` 是为让 `td` 继承 `tr` 上的行内状态色,机制必须保留,但导致**整表无 hover 反馈** —— 保留机制,叠加一层 10% 深青 `background-image` 高亮,报警红/到点黄不被覆盖。
- `.el-button[style*="linear-gradient"]:hover` 把**所有**渐变按钮强制刷成蓝色渐变(橙、深青、红全变蓝)—— 改为 `filter:brightness(1.08)` + 中性阴影,保留各自语义色。
- `.el-button:hover` 在两处被重复定义成 `-1px` / `-2px` 位移 —— 统一为 `-1px`。
- 补 `:focus` / `:focus-visible` 轮廓 + 主色光晕;禁用按钮补 `not-allowed`、降饱和,且 hover 不再位移;补 `prefers-reduced-motion` 降级。
### 5. 状态提示不明确
- **选中行原先用 `#1990f1 !important` 纯蓝底覆盖,会把报警红、到点黄整行抹掉** —— 改为「继承行内状态色 + 30% 蓝叠加 + 首列 3px 蓝条」,选中态与状态色可同时辨识。
- 右下「有新的交路信息」原是 200×200 的 `position:absolute` 灰绿大块,滚动后位置漂移、易被吸底条压住、可点击却无指针提示 —— 改 `fixed` 贴边小卡、深青描边、hover 反馈;≤1200 再收成 150px 小卡,减少对表格的遮挡。
- 设备「在线 / 掉线」、历史「叫乘报警 / 离寓报警 是 / 否」补对比色。
### 6. 不同屏幕尺寸显示不佳
- 原页只有 1200 / 768 两档断点,1440~1024 区间 9/6/9 三栏严重挤压 —— 补 1440 / 1200 / 992 / 768 四档 + `max-height:900` 矮屏档。
- ≤1440 查询区独占一行、时间卡与按钮区第二行;≤992 全部纵向分区,四维输入框 2×2;≤768 输入框单列。
- 宽弹窗补 `max-width:calc(100vw - 32px)`;矮屏下弹窗 body 与 600px 固定表高改为内部可滚动,列 / 数据 / 操作不变。
- 吸底值班条的「最新同步时间」原用 `position:absolute; left:50%` 居中,与左右两段重叠 —— 改 flex 三段;页面补 `padding-bottom:62/88px`,防止 50px 吸底条盖住表格最后一行。
## 三、原样式 4 处错误修正
| 位置 | 问题 | 修正 |
| --- | --- | --- |
| `.top-left-input-row`、`.top-left-action-row` | `margin-top: -2` 缺单位,浏览器直接忽略 | `margin-top: 0`(2 处) |
| `.el-button:hover`(第 2 处) | 与前一处 `-1px` 冲突的 `-2px` 位移 | 统一 `-1px` |
| `.el-button[style*="linear-gradient"]:hover` | 用 `background: ... !important` 覆盖全部按钮配色 | 改为亮度 + 中性阴影 |
## 四、验收结果
`docs/frontend/_src/verify_jcxx.cjs` 一次跑完 **29/29 断言通过**,零失败:
- **编译**:`vue-template-compiler` 编译前后模板均 0 error;`@babel/parser` 解析脚本通过;`sass` 编译前后样式通过。
- **功能**:脚本段逐字节一致;事件 / `v-model` / 绑定 / `ref` / `slot-scope` / `v-if` 链 / 表格列 / 标签数量集合全部一致;原样式核心规则全部保留。
- **布局**:同一份静态 DOM + 优化前后两份真实 CSS,在 1920×1080 / 1440×900 / 1366×768 / 1280×800 / 1024×768 五个视口对比 —— **优化前累计 24 项问题(顶部裁切、吸底三段重叠、右下提示被压、底部无留白、宽弹窗越界),优化后 0 项**。
- **反馈**:hover 高亮、选中行保留状态色、橙色按钮 hover 不变蓝、焦点反馈、报警文字权重、提示卡片指针、减少动效降级,7 项全通过。
- 对比图 10 张见 `docs/frontend/ui/JcXx-优化前|优化后-*.png`。
## 五、使用方式
1. 备份 `YktVue/src/views/JcXx.vue`;
2. 用 `JcXx.optimized.vue` 覆盖同名文件(或先在路由里临时指向新文件对比);
3. `npm run dev` 后重点走查:主表 hover / 选中报警行、四个宽弹窗在 1366 屏的表现、吸底值班条三段、右下交路提醒、1440 与 1024 两档缩放。
可复现工具:`docs/frontend/_src/build_jcxx_optimized.py`(构建)、`verify_jcxx.cjs`(编译 + 功能集合 + 出图断言)。
## 六、边界说明
- 布局图是「同一份静态 DOM + 两份真实 CSS」的验证快照,不是组件运行时产物;`el-table` 的列宽与横向滚动由组件运行时计算,快照用 `min-width` 模拟这一行为。
- 未做:不拆分 5294 行单体、不改任何 `<script>` 逻辑 / 接口 / 定时器、不收敛原 `<style>` 的全局作用域(避免影响其它页面)、不改表格列定义与 `height` 属性、不改任何业务文案。
- 未改动原 `JcXx.vue`、后端、路由与配置;未执行 git commit / push。
+48
View File
@@ -0,0 +1,48 @@
# 叫乘管理系统 前端梳理文档
本目录是对 `YktVue/` 前端工程的结构化梳理,与后端文档 `../README.md` 配套使用。目标同样是给后续迭代一张“地图”:页面在哪里、数据从哪来、实时消息怎么走、改动会落到哪些文件。
> 适用范围:`YktVue/`(Vue 2 + Element UI)。
> 梳理基准:`main` 分支提交 `135a192`(2026-11-07),前端有效源码约 1.39 万行(不含备份/模板遗留)。
> 后端契约以 `yktgl/` 同提交为准,详见 [后端文档索引](../README.md)。
## 文档索引
| 文档 | 内容 | 适用场景 |
| --- | --- | --- |
| [01-前端总览与技术栈.md](./01-前端总览与技术栈.md) | 系统定位、使用角色、运行拓扑、技术栈、四类界面 | 新人入门、方案评审 |
| [02-工程结构与请求链路.md](./02-工程结构与请求链路.md) | 目录结构、启动装配、登录与路由守卫、axios 链路、API 封装与后端映射 | 找代码、改接口、排网络问题 |
| [03-路由与页面职责.md](./03-路由与页面职责.md) | 13 条路由、页面分组、入口关系、遗留页面 | 评估页面改动影响面 |
| [04-核心页面拆解-JcXx.md](./04-核心页面拆解-JcXx.md) | 值班主界面布局、状态与配色、全部操作、时间规则、轮询、历史数据 | 改叫乘业务前必读 |
| [05-实时通信与语音告警.md](./05-实时通信与语音告警.md) | 4 路 WebSocket + SIP、消息协议、告警状态机、弹窗队列、TTS 队列 | 改推送/告警/语音 |
| [06-食堂与自助终端模块.md](./06-食堂与自助终端模块.md) | 自助登记终端、点餐/结算/厨师/保洁终端、房间选择与房间维护 | 改终端类需求 |
| [07-构建部署与风险清单.md](./07-构建部署与风险清单.md) | 构建脚本、环境变量、部署要点、测试基线、P0/P1/P2 风险 | 排期、上线、评审 |
| [08-JcXx页面体验优化说明.md](./08-JcXx页面体验优化说明.md) | `JcXx.vue` 体验优化版:六类问题定位、4 处样式错误修正、前后对比验收与替换方式 | 主界面体验优化、上线前走查 |
## 建议阅读路径
- **第一次接触前端**:01 → 02 → 03 → 04。
- **接一个叫乘需求**:04 看页面与操作 → 05 看消息 → 02 查接口 → 07 查风险。
- **接一个终端/食堂需求**:06 → 02(API 封装)→ 07(构建与遗留问题)。
- **线上排障**:05(WebSocket/告警)→ 02(请求 baseURL/响应码)→ 07(已知缺陷)。
- **准备重构**:02(工程结构)→ 04(单体页面)→ 07(风险与落点)。
- **做主界面体验优化**:04 看现状 → 08 看改动与验收 → `YktVue/src/views/JcXx.optimized.vue`(新页面)→ `ui/JcXx-优化后-*.png`(五档视口对比图)。
## 快速事实
- 技术栈:Vue 2.6.12 + Vue Router 3.4.9 + Element UI 2.15.13 + axios 0.24.0 + ECharts 5.4.0,Vue CLI 4.4.6 / webpack 4。
- 启动脚本是 `npm run dev`(不是 README 里写的 `npm run serve`),开发端口 `8080`,代理 `/dev-api` → `http://127.0.0.1:8081/yktgl`。
- 生产 `publicPath` 为 `/yktgl/`,接口 `baseURL` 固定 `/yktgl`,按“前后端同域反向代理”部署设计。
- 登录走后端 `/auth/logon`:只校验公寓账号与来源 IP,**后端不返回 token**,前端落一个 `fake-token-*`,并把用户名同时写入 `gyid` 与 `loginAccount`。
- 主界面 `src/views/JcXx.vue` 5294 行,是绝对的单体页面:布局、状态、告警、TTS、SIP、历史、导出全在一个文件里。
- 实时通道有 4 类业务 WebSocket(叫乘/交路/人员/食堂)外加 1 路本机 SIP WebSocket(`ws://127.0.0.1:9004/sipclient/{gyid}`)。
- 前端没有 Vuex store、没有安装 `src/plugins/*`;`utils/permission.js` 与部分 RuoYi 模板代码是死代码。
- 已有两个纯 Node 测试脚本:`tests/alarm-state.test.cjs`、`tests/recharge-cancellation.cjs`,无 jest/npm test。
## 约定与说明
- 文档中的“行号”以本提交源码为准,重构后会漂移,优先按方法名/文件名检索。
- 前端很多页面依赖全局变量 `apiRoot`、`gyid`(由 `public/config.js` 注入),不是模块导入;搜索时直接搜变量名。
- 标注“已禁用/未接线”的代码表示界面上仍存在,但当前不会触发后端动作,改动前需确认是否要恢复。
- 本目录只做梳理,不对既有代码做修正;发现的问题统一收敛在 07 的风险清单。
- 08 附带的 `JcXx.optimized.vue` 是独立新文件,`<script>` 逐字节未改、原 `JcXx.vue` 未被修改;替换前请先备份。
+505
View File
@@ -0,0 +1,505 @@
# -*- coding: utf-8 -*-
"""
由 YktVue/src/views/JcXx.vue 生成 JcXx.optimized.vue。
设计约束(与用户需求一一对应):
1. <script> 段逐字节不变 —— 功能集合 100% 保留;
2. <template> 只增加语义 class、修正一处事件名拼写,不增删任何元素/指令/绑定;
3. <style> 原规则全部保留,仅修正 4 处明显错误,其余以「追加优化层」覆盖;
4. 不引入新配色 / 新字体 / 新组件 / 新字段,全部取自本页既有设计语言。
同时输出校验与布局快照所需的中间产物到 docs/frontend/_src/。
"""
import io
import os
import re
import hashlib
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
SRC = os.path.join(ROOT, 'YktVue', 'src', 'views', 'JcXx.vue')
OUT = os.path.join(ROOT, 'YktVue', 'src', 'views', 'JcXx.optimized.vue')
WORK = os.path.join(os.path.dirname(__file__))
src = io.open(SRC, encoding='utf-8').read()
script_before = src[src.index('<script>'):src.index('</script>') + len('</script>')]
s = src
def rep(text, old, new, tag, count=1):
n = text.count(old)
assert n == count, '[%s] 期望 %d 处,实际 %d 处' % (tag, n, count)
return text.replace(old, new)
# ============================================================ 1. template:语义 class
s = rep(s, '<div class="app_contianer">',
'<div class="app_contianer jc-opt-root">', '根容器 class')
s = rep(s, '<el-row style="height: 150px;" :gutter="15">',
'<el-row class="jc-header-row" style="height: 150px;" :gutter="15">',
'顶部行 class')
s = rep(s, '<el-col :span="9" style="height: 100%; display: flex; flex-direction: column; justify-content: center;">',
'<el-col class="jc-header-search" :span="9" style="height: 100%; display: flex; flex-direction: column; justify-content: center;">',
'顶部左列 class')
s = rep(s, '<el-col :span="6" style="height: 100%;">',
'<el-col class="jc-header-time" :span="6" style="height: 100%;">',
'顶部中列 class')
s = rep(s, '<el-col :span="9" style="height: 100%;">',
'<el-col class="jc-header-actions" :span="9" style="height: 100%;">',
'顶部右列 class')
s = rep(s, '<span style="font-size: 12px; color: #666;width: 45%;">当前用户: {{ loginAccount }}</span>',
'<span class="jc-user-label" style="font-size: 12px; color: #666;width: 45%;">当前用户: {{ loginAccount }}</span>',
'当前用户标签 class')
# Element UI 派发的是 tab-click;原写法 tab_click 永远不会被触发(处理函数体为空,修正后行为不变)
s = rep(s, '@tab_click=handleTabClick', '@tab-click=handleTabClick', 'tab 事件名拼写')
# 状态列:仅加 class,文本与 v-if 链完全不变(行内底色已是状态语义色,
# 这里只提升文字对比与字重,不把纯文字改成标签,保持原有信息呈现方式)
old_state = """ <el-table-column prop="zt" label="状态" min-width="7%" align="center">
<template slot-scope="scope">
<span v-if="scope.row.ryzt == '0'">待叫乘</span>
<span v-else-if="scope.row.ryzt == '1'">正在呼叫</span>
<span v-else-if="scope.row.ryzt == '5'">正在通话</span>
<span v-else-if="scope.row.ryzt == '2'">叫乘报警</span>
<span v-else-if="scope.row.ryzt == '3'">待离寓</span>
<span v-else-if="scope.row.ryzt == '4'">离寓报警</span>
<span v-else>已入寓</span>
</template>
</el-table-column>"""
new_state = """ <el-table-column prop="zt" label="状态" min-width="7%" align="center">
<template slot-scope="scope">
<span class="jc-st jc-st-normal" v-if="scope.row.ryzt == '0'">待叫乘</span>
<span class="jc-st jc-st-normal" v-else-if="scope.row.ryzt == '1'">正在呼叫</span>
<span class="jc-st jc-st-normal" v-else-if="scope.row.ryzt == '5'">正在通话</span>
<span class="jc-st jc-st-alarm" v-else-if="scope.row.ryzt == '2'">叫乘报警</span>
<span class="jc-st jc-st-normal" v-else-if="scope.row.ryzt == '3'">待离寓</span>
<span class="jc-st jc-st-alarm" v-else-if="scope.row.ryzt == '4'">离寓报警</span>
<span class="jc-st jc-st-muted" v-else>已入寓</span>
</template>
</el-table-column>"""
s = rep(s, old_state, new_state, '主表状态列 class')
old_dev = """ <el-table-column prop="heartBeatTime" label="状态" min-width="8%" align="center">
<template slot-scope="scope">
<span v-if="scope.row.isOffline == '0'">在线</span>
<span v-else-if="scope.row.isOffline == '1'">掉线</span>
</template>
</el-table-column>"""
new_dev = """ <el-table-column prop="heartBeatTime" label="状态" min-width="8%" align="center">
<template slot-scope="scope">
<span class="jc-st jc-st-normal" v-if="scope.row.isOffline == '0'">在线</span>
<span class="jc-st jc-st-alarm" v-else-if="scope.row.isOffline == '1'">掉线</span>
</template>
</el-table-column>"""
s = rep(s, old_dev, new_dev, '设备状态列 class')
old_hist = """ <el-table-column prop="is_jcbj" label="叫乘报警" min-width="4%" align="center">
<template slot-scope="scope">
<span v-if="scope.row.is_jcbj == '1'">是</span>
<span v-else>否</span>
</template>
</el-table-column>
<el-table-column prop="is_lybj" label="离寓报警" min-width="4%" align="center">
<template slot-scope="scope">
<span v-if="scope.row.is_lybj == '1'">是</span>
<span v-else>否</span>
</template>
</el-table-column>"""
new_hist = """ <el-table-column prop="is_jcbj" label="叫乘报警" min-width="4%" align="center">
<template slot-scope="scope">
<span class="jc-st jc-st-alarm" v-if="scope.row.is_jcbj == '1'">是</span>
<span class="jc-st jc-st-muted" v-else>否</span>
</template>
</el-table-column>
<el-table-column prop="is_lybj" label="离寓报警" min-width="4%" align="center">
<template slot-scope="scope">
<span class="jc-st jc-st-alarm" v-if="scope.row.is_lybj == '1'">是</span>
<span class="jc-st jc-st-muted" v-else>否</span>
</template>
</el-table-column>"""
s = rep(s, old_hist, new_hist, '历史报警列 class')
# 所有弹窗统一挂 jc-dialog(append-to-body 后仍在 body 下,必须靠自身 class 收口样式);
# 1800/1700px 的宽弹窗额外挂 jc-wide-dialog 以做视口约束。
dialog_re = re.compile(r'<el-dialog\b[^>]*?>', re.S)
dialog_tags = dialog_re.findall(s)
expected = s.count('<el-dialog')
assert len(dialog_tags) == expected, 'el-dialog 标签解析数 %d != %d' % (len(dialog_tags), expected)
def decorate_dialog(m):
tag = m.group(0)
wide = bool(re.search(r'width="(?:1800|1700)px"', tag))
extra = 'jc-dialog' + (' jc-wide-dialog' if wide else '')
cm = re.search(r'\bclass="([^"]*)"', tag)
if cm:
merged = extra + (' ' + cm.group(1) if cm.group(1).strip() else '')
return tag[:cm.start()] + 'class="' + merged + '"' + tag[cm.end():]
return tag.replace('<el-dialog', '<el-dialog class="' + extra + '"', 1)
s = dialog_re.sub(decorate_dialog, s)
print('弹窗装饰:%d 个(其中宽弹窗 %d 个)' % (
expected, s.count('jc-wide-dialog')))
# ============================================================ 2. style:4 处错误修正
# (a) margin-top: -2 缺单位,是无效声明(2 处)
s = rep(s, 'margin-top: -2;', 'margin-top: 0;', '无效负边距归零', count=2)
# (b) 同一 :hover 位移被重复定义为 -1px / -2px,统一为 -1px
s = rep(s, 'transform: translateY(-2px) !important;',
'transform: translateY(-1px) !important;', 'hover 位移统一')
# (c) 所有渐变按钮 hover 被强制刷成蓝色渐变,破坏橙/深青/红按钮的语义色
old_hover = """/* 查询按钮特殊样式 */
.el-button[style*="linear-gradient"]:hover {
background: linear-gradient(135deg, #66b3ff, #409EFF) !important;
box-shadow: 0 8px 25px rgba(64, 158, 255, 0.5) !important;
}"""
new_hover = """/* 查询按钮特殊样式(修正:保留按钮自身渐变,只做亮度与阴影反馈) */
.el-button[style*="linear-gradient"]:hover {
filter: brightness(1.08) !important;
box-shadow: 0 8px 25px rgba(11, 88, 88, 0.28) !important;
}"""
s = rep(s, old_hover, new_hover, '渐变按钮 hover 配色修正')
# ============================================================ 3. 追加体验优化层
OPT_CSS = r"""
/* ==========================================================================
JcXx.vue 体验与可用性优化层 v1
--------------------------------------------------------------------------
只修六类问题:布局拥挤/留白失衡、对齐间距不一致、信息层级、
交互反馈缺失、状态提示不明确、多尺寸适配。
· 不新增/删减/替换任何功能,<script> 逐字节未改;
· 不引入新配色:所有色值取自本页原有样式
(#029191 深青 / #0b5858 深青 / #fff45c 黄 / #2c3e50 墨 /
#c45656 红 / #606266 灰 / #dcdfe6 边框 / #1990f1 选中蓝);
· 不改动字号基准、组件形态与信息呈现方式,仅统一不一致处并补齐断点;
· 新增规则统一以 .jc-opt-root / .jc-dialog 收口,不扩大原全局样式的影响面。
原样式规则完整保留在前,本层可整段移除以回到优化前效果。
========================================================================== */
/* ---- O1 顶部:固定 150px + 40px 跑马灯上边距造成的拥挤与留白失衡 ---- */
.jc-header-row{
height:auto !important;
min-height:150px;
display:flex;
flex-wrap:wrap;
align-items:stretch;
}
.jc-header-row > .el-col{
height:auto !important;
min-height:150px;
float:none;
}
.jc-header-search{
justify-content:flex-start !important;
padding:4px 0 8px;
}
.jc-header-time{
display:flex !important;
align-items:center;
justify-content:center;
}
.jc-header-time > .el-row{ width:100%; }
.jc-header-actions{ padding:2px 0; }
.top-left-marquee-row{ margin-top:10px !important; margin-bottom:8px !important; }
.top-left-search-area{
padding:8px 14px 10px !important;
justify-content:flex-start !important;
}
.top-left-input-row{ margin-top:0 !important; margin-bottom:10px !important; }
.top-left-action-row{ margin-top:0 !important; }
.form-item-container{ padding:0 6px; }
/* 原跑马灯 padding-left:100% + translateX(0) 起步,开屏数秒内整条提示为空。
用负动画延时把滚动相位提前,文案/配色/组件/速度全部不变。 */
.marquee-content{ animation-delay:-4s; }
.center-time-section{
width:calc(100% - 30px);
box-sizing:border-box;
margin:0 15px;
}
.date-time{ width:100%; }
/* 右上按钮组:等间距、窄屏可换行,避免互相挤压与文字截断 */
.jc-header-actions .el-row > .el-col > div{
flex-wrap:wrap;
gap:8px !important;
}
.jc-header-actions .el-button{ flex:0 0 auto; }
.jc-user-label{
width:auto !important;
white-space:nowrap;
color:#606266 !important;
font-size:12px !important;
}
/* ---- O2 表单/弹窗:label 宽度 25%/20%/96px 混用导致对齐不一致 ---- */
.jc-opt-root .el-form label[style*="width:25%"],
.jc-opt-root .el-form label[style*="width:20%"],
.jc-dialog .el-form label[style*="width:25%"],
.jc-dialog .el-form label[style*="width:20%"]{
flex:0 0 96px;
width:96px !important;
min-width:96px;
text-align:center;
white-space:nowrap;
line-height:20px;
}
.jc-opt-root .el-form .el-col > .el-input,
.jc-opt-root .el-form .el-col > .el-select,
.jc-opt-root .el-form .el-col > div > .el-input,
.jc-opt-root .el-form .el-col > div > .el-select,
.jc-dialog .el-form .el-col > .el-input,
.jc-dialog .el-form .el-col > .el-select,
.jc-dialog .el-form .el-col > div > .el-input,
.jc-dialog .el-form .el-col > div > .el-select{
flex:1 1 0;
min-width:0;
}
.jc-dialog .el-form-item{ margin-bottom:16px; }
.jc-dialog .el-dialog__footer .el-button + .el-button{ margin-left:12px; }
/* 宽弹窗 1800/1700px 在 1366 及以下屏幕会横向越界 */
.jc-wide-dialog .el-dialog{
max-width:calc(100vw - 32px) !important;
}
/* ---- O3 交互反馈:焦点、hover、禁用 ---- */
.el-button:hover{ transform:translateY(-1px) !important; }
/* 焦点轮廓:element-ui 自身把 outline 压掉了,这里用本页作用域 + !important 补回 */
.jc-opt-root .el-button:focus,
.jc-opt-root .el-button:focus-visible,
.jc-opt-root .el-input__inner:focus,
.jc-opt-root .el-input__inner:focus-visible,
.jc-opt-root .el-checkbox__inner:focus,
.jc-dialog .el-button:focus,
.jc-dialog .el-button:focus-visible,
.jc-dialog .el-input__inner:focus,
.jc-dialog .el-input__inner:focus-visible,
.jc-dialog .el-checkbox__inner:focus{
outline:2px solid #029191 !important;
outline-offset:2px !important;
}
/* 部分浏览器对 input 的 outline 计算值有限制,补一层主色光晕保证焦点可见 */
.jc-opt-root .el-input__inner:focus,
.jc-opt-root .el-input__inner:focus-visible,
.jc-dialog .el-input__inner:focus,
.jc-dialog .el-input__inner:focus-visible{
box-shadow:0 0 0 3px rgba(2,145,145,.22) !important;
}
.el-button.is-disabled,
.el-button[disabled]{
cursor:not-allowed !important;
opacity:.55;
filter:grayscale(.25);
}
.el-button.is-disabled:hover,
.el-button[disabled]:hover{
transform:none !important;
box-shadow:none !important;
}
/* ---- O4 表格:保留行内状态底色,补齐 hover / 选中反馈与列层级 ----
原 .el-table__body tr:hover > td 用 background-color:inherit!important
是为了让 td 继承 tr 上的行内状态色(黄/绿/红/蓝),机制必须保留;
这里只叠加一层半透明 background-image 提供 hover 反馈,不覆盖底色。 */
.jc-opt-root .main-jcxx-table .el-table__body tr:hover > td,
.jc-dialog .el-table__body tr:hover > td{
background-image:linear-gradient(rgba(2,145,145,.10), rgba(2,145,145,.10)) !important;
}
/* 选中行原先用纯蓝底 !important 覆盖,会把报警红/到点黄整行抹掉。
改为「继承行内状态色 + 叠加蓝层 + 首列蓝条」,选中态与状态色可同时辨识。 */
.jc-opt-root .el-table__body tr.current-row > td,
.jc-dialog .el-table__body tr.current-row > td,
.jc-dialog .history-jcxx-table.el-table--striped .el-table__body tr.current-row > td,
.jc-dialog .history-jcxx-table.el-table--striped .el-table__body tr.current-row.el-table__row--striped > td{
background-color:inherit !important;
background-image:linear-gradient(rgba(25,144,241,.30), rgba(25,144,241,.30)) !important;
color:inherit !important;
}
.jc-opt-root .el-table__body tr.current-row > td:first-child,
.jc-dialog .el-table__body tr.current-row > td:first-child{
box-shadow:inset 3px 0 0 #1990f1;
}
/* 状态文字:行内底色已承担语义色,这里只提升字重与对比,不改成标签 */
.jc-st{ font-weight:700; letter-spacing:.3px; }
.jc-st-normal{ color:#2c3e50; }
.jc-st-alarm{ color:#ffffff; text-shadow:0 1px 2px rgba(0,0,0,.35); }
.jc-st-muted{ color:#606266; }
.jc-opt-root .main-jcxx-table th.el-table__cell{
font-weight:700 !important;
border-bottom:2px solid #029191 !important;
vertical-align:middle;
}
.jc-opt-root .main-jcxx-table .cell{ padding-left:6px; padding-right:6px; }
.jc-opt-root .el-tabs__item{ font-weight:600; }
/* ---- O5 吸底值班条:absolute 居中在窄屏与左右内容重叠 ---- */
.jc-opt-root{ padding-bottom:62px; } /* 防止 50px 吸底条遮住表格最后一行 */
.bottom-duty-bar{
display:flex;
align-items:center;
gap:16px;
padding:0 20px;
box-sizing:border-box;
}
.bottom-stay-summary{
flex:1 1 auto;
gap:36px;
padding-left:0;
min-width:0;
}
.latest-sync-time{
position:static !important;
transform:none !important;
flex:0 1 auto;
white-space:nowrap;
}
.bottom-duty-info{
flex:1 1 auto;
display:flex;
align-items:center;
justify-content:flex-end;
gap:16px;
min-width:0;
}
.bottom-duty-info .el-button{
position:static !important;
right:auto !important;
top:auto !important;
flex:0 0 auto;
}
.bottom-duty-info > span{
margin-right:0 !important;
position:static !important;
top:auto !important;
white-space:nowrap;
}
/* ---- O6 右下角「有新的交路信息」提示:改为固定定位并补齐点击反馈 ----
原为 200x200 的 absolute 大色块,滚动后位置漂移且易被吸底条压住。 */
.bottom-right-div{
position:fixed !important;
bottom:72px;
right:20px;
width:200px;
height:auto;
box-sizing:border-box;
padding:14px 16px;
line-height:1.5;
text-align:center;
border:2px solid #029191;
box-shadow:0 6px 18px rgba(0,0,0,.18);
z-index:1200;
cursor:pointer;
transition:transform .25s ease, box-shadow .25s ease;
}
.bottom-right-div:hover{
transform:translateY(-2px);
box-shadow:0 10px 24px rgba(0,0,0,.24);
}
/* ---- O7 响应式:原页只有 1200/768 两档,且顶部 9/6/9 在中屏严重挤压 ---- */
@media (max-width:1440px){
.jc-header-row{ min-height:0; }
.jc-header-row > .el-col{ min-height:0; }
.jc-header-row > .jc-header-search{ flex:1 1 100%; width:100%; }
.jc-header-row > .jc-header-time{ flex:0 0 320px; width:320px; }
.jc-header-row > .jc-header-actions{ flex:1 1 520px; width:auto; }
.center-time-section{ min-width:0; }
}
@media (max-width:1200px){
.bottom-stay-summary{ gap:20px; }
.bottom-stay-summary,.latest-sync-time{ font-size:20px; }
.bottom-duty-info > span{ font-size:20px !important; }
/* 窄屏下 200px 卡片会压住表格右下数据,收紧为贴边小卡 */
.bottom-right-div{
width:150px;
right:8px;
bottom:62px;
padding:8px 10px;
font-size:14px !important;
line-height:1.35;
opacity:.96;
}
}
@media (max-width:992px){
.jc-header-row > .jc-header-time,
.jc-header-row > .jc-header-actions{ flex:1 1 100%; width:100%; }
.jc-header-row .top-left-input-row .el-col-6{ width:50%; }
.bottom-duty-bar{
flex-wrap:wrap;
height:auto !important;
min-height:50px;
padding:6px 12px;
gap:4px 12px;
}
.bottom-stay-summary,.latest-sync-time,.bottom-duty-info > span{
font-size:16px !important;
line-height:1.4;
}
.latest-sync-time{ flex:1 1 100%; order:3; text-align:right; }
.jc-opt-root{ padding-bottom:88px; }
.bottom-right-div{ bottom:96px; }
}
@media (max-width:768px){
.jc-header-row .top-left-input-row .el-col-6{ width:100%; }
.bottom-right-div{ width:160px; font-size:16px !important; }
}
/* 矮屏:600px 固定表高 + 50px 吸底条会溢出,改为可滚动,不改变数据/列/操作 */
@media (max-height:900px){
.jc-opt-root .main-jcxx-table{
height:auto !important;
max-height:calc(100vh - 260px);
}
.jc-opt-root .main-jcxx-table .el-table__body-wrapper{
height:auto !important;
max-height:calc(100vh - 320px);
overflow-y:auto;
}
.jc-dialog .el-dialog__body{
max-height:calc(100vh - 170px);
overflow-y:auto;
}
}
@media (prefers-reduced-motion:reduce){
.marquee-content{
animation:none !important;
padding-left:0 !important; /* 停动画时必须去掉右推,否则整条提示不可见 */
}
.init-success-content i{ animation:none !important; }
.el-button,
.bottom-right-div{ transition:none !important; }
.el-button:hover{ transform:none !important; }
}
/* ===================== 体验优化层结束 ===================== */
"""
s = rep(s, '</style>', OPT_CSS + '</style>', '追加优化层')
# ============================================================ 4. 写出
# 原文件为 CRLF;写出时保持同一换行,避免全文件 diff / 逐字节比对误报
out_text = s.replace('\n', '\r\n')
io.open(OUT, 'w', encoding='utf-8', newline='').write(out_text)
script_after = s[s.index('<script>'):s.index('</script>') + len('</script>')]
assert script_before == script_after, '<script> 段发生变化,已中止'
print('<script> 段逐字节一致:%d 字节 sha256=%s' % (
len(script_before.encode('utf-8')),
hashlib.sha256(script_before.encode('utf-8')).hexdigest()[:16]))
# 中间产物:优化前 / 优化后的 style 源(供 sass 编译与前后对比快照使用)
def extract_style(text):
a = text.index('<style')
b = text.index('</style>')
return text[text.index('>', a) + 1:b]
io.open(os.path.join(WORK, 'style-before.scss'), 'w', encoding='utf-8').write(extract_style(src))
io.open(os.path.join(WORK, 'style-after.scss'), 'w', encoding='utf-8').write(extract_style(s))
print('输出:%s(%d 行 / %d 字节 / CRLF)' % (OUT, len(s.splitlines()), len(out_text.encode('utf-8'))))
print('原文件:%d 行 / %d 字节' % (len(src.splitlines()), os.path.getsize(SRC)))
File diff suppressed because it is too large. Load diff
+839
View File
@@ -0,0 +1,839 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
<title>JcXx 优化前</title>
<style>@charset "UTF-8";@font-face{font-family:element-icons;src:url(fonts/element-icons.woff) format("woff"),url(fonts/element-icons.ttf) format("truetype");font-weight:400;font-display:"auto";font-style:normal}[class*=" el-icon-"],[class^=el-icon-]{font-family:element-icons!important;speak:none;font-style:normal;font-weight:400;font-variant:normal;text-transform:none;line-height:1;vertical-align:baseline;display:inline-block;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.el-icon-ice-cream-round:before{content:"\e6a0"}.el-icon-ice-cream-square:before{content:"\e6a3"}.el-icon-lollipop:before{content:"\e6a4"}.el-icon-potato-strips:before{content:"\e6a5"}.el-icon-milk-tea:before{content:"\e6a6"}.el-icon-ice-drink:before{content:"\e6a7"}.el-icon-ice-tea:before{content:"\e6a9"}.el-icon-coffee:before{content:"\e6aa"}.el-icon-orange:before{content:"\e6ab"}.el-icon-pear:before{content:"\e6ac"}.el-icon-apple:before{content:"\e6ad"}.el-icon-cherry:before{content:"\e6ae"}.el-icon-watermelon:before{content:"\e6af"}.el-icon-grape:before{content:"\e6b0"}.el-icon-refrigerator:before{content:"\e6b1"}.el-icon-goblet-square-full:before{content:"\e6b2"}.el-icon-goblet-square:before{content:"\e6b3"}.el-icon-goblet-full:before{content:"\e6b4"}.el-icon-goblet:before{content:"\e6b5"}.el-icon-cold-drink:before{content:"\e6b6"}.el-icon-coffee-cup:before{content:"\e6b8"}.el-icon-water-cup:before{content:"\e6b9"}.el-icon-hot-water:before{content:"\e6ba"}.el-icon-ice-cream:before{content:"\e6bb"}.el-icon-dessert:before{content:"\e6bc"}.el-icon-sugar:before{content:"\e6bd"}.el-icon-tableware:before{content:"\e6be"}.el-icon-burger:before{content:"\e6bf"}.el-icon-knife-fork:before{content:"\e6c1"}.el-icon-fork-spoon:before{content:"\e6c2"}.el-icon-chicken:before{content:"\e6c3"}.el-icon-food:before{content:"\e6c4"}.el-icon-dish-1:before{content:"\e6c5"}.el-icon-dish:before{content:"\e6c6"}.el-icon-moon-night:before{content:"\e6ee"}.el-icon-moon:before{content:"\e6f0"}.el-icon-cloudy-and-sunny:before{content:"\e6f1"}.el-icon-partly-cloudy:before{content:"\e6f2"}.el-icon-cloudy:before{content:"\e6f3"}.el-icon-sunny:before{content:"\e6f6"}.el-icon-sunset:before{content:"\e6f7"}.el-icon-sunrise-1:before{content:"\e6f8"}.el-icon-sunrise:before{content:"\e6f9"}.el-icon-heavy-rain:before{content:"\e6fa"}.el-icon-lightning:before{content:"\e6fb"}.el-icon-light-rain:before{content:"\e6fc"}.el-icon-wind-power:before{content:"\e6fd"}.el-icon-baseball:before{content:"\e712"}.el-icon-soccer:before{content:"\e713"}.el-icon-football:before{content:"\e715"}.el-icon-basketball:before{content:"\e716"}.el-icon-ship:before{content:"\e73f"}.el-icon-truck:before{content:"\e740"}.el-icon-bicycle:before{content:"\e741"}.el-icon-mobile-phone:before{content:"\e6d3"}.el-icon-service:before{content:"\e6d4"}.el-icon-key:before{content:"\e6e2"}.el-icon-unlock:before{content:"\e6e4"}.el-icon-lock:before{content:"\e6e5"}.el-icon-watch:before{content:"\e6fe"}.el-icon-watch-1:before{content:"\e6ff"}.el-icon-timer:before{content:"\e702"}.el-icon-alarm-clock:before{content:"\e703"}.el-icon-map-location:before{content:"\e704"}.el-icon-delete-location:before{content:"\e705"}.el-icon-add-location:before{content:"\e706"}.el-icon-location-information:before{content:"\e707"}.el-icon-location-outline:before{content:"\e708"}.el-icon-location:before{content:"\e79e"}.el-icon-place:before{content:"\e709"}.el-icon-discover:before{content:"\e70a"}.el-icon-first-aid-kit:before{content:"\e70b"}.el-icon-trophy-1:before{content:"\e70c"}.el-icon-trophy:before{content:"\e70d"}.el-icon-medal:before{content:"\e70e"}.el-icon-medal-1:before{content:"\e70f"}.el-icon-stopwatch:before{content:"\e710"}.el-icon-mic:before{content:"\e711"}.el-icon-copy-document:before{content:"\e718"}.el-icon-full-screen:before{content:"\e719"}.el-icon-switch-button:before{content:"\e71b"}.el-icon-aim:before{content:"\e71c"}.el-icon-crop:before{content:"\e71d"}.el-icon-odometer:before{content:"\e71e"}.el-icon-time:before{content:"\e71f"}.el-icon-bangzhu:before{content:"\e724"}.el-icon-close-notification:before{content:"\e726"}.el-icon-microphone:before{content:"\e727"}.el-icon-turn-off-microphone:before{content:"\e728"}.el-icon-position:before{content:"\e729"}.el-icon-postcard:before{content:"\e72a"}.el-icon-message:before{content:"\e72b"}.el-icon-chat-line-square:before{content:"\e72d"}.el-icon-chat-dot-square:before{content:"\e72e"}.el-icon-chat-dot-round:before{content:"\e72f"}.el-icon-chat-square:before{content:"\e730"}.el-icon-chat-line-round:before{content:"\e731"}.el-icon-chat-round:before{content:"\e732"}.el-icon-set-up:before{content:"\e733"}.el-icon-turn-off:before{content:"\e734"}.el-icon-open:before{content:"\e735"}.el-icon-connection:before{content:"\e736"}.el-icon-link:before{content:"\e737"}.el-icon-cpu:before{content:"\e738"}.el-icon-thumb:before{content:"\e739"}.el-icon-female:before{content:"\e73a"}.el-icon-male:before{content:"\e73b"}.el-icon-guide:before{content:"\e73c"}.elLine truncated
<style>@charset "UTF-8";
.marquee-container {
white-space: nowrap;
overflow: hidden;
}
.top-left-marquee-row {
margin-top: 10px;
margin-bottom: 4px;
}
.top-left-search-area {
padding-top: 6px !important;
}
.top-left-input-row {
margin-top: -2;
}
.top-left-action-row {
margin-top: -2;
}
/* 中间时间信息区域 */
.center-time-section {
flex: 0 0 auto;
background: linear-gradient(135deg, #0078d7, #107c10);
color: white;
padding: 12px 16px;
border-radius: 8px;
margin: 0 15px;
min-width: 320px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.date-time {
display: flex;
flex-direction: column;
align-items: center;
font-size: 16px;
}
.date-time .text-24 {
font-size: 26px !important;
font-weight: 600;
margin: 0;
line-height: 1.2;
}
/* 响应式调整 */
@media (max-width: 1200px) {
.center-time-section {
min-width: 280px;
padding: 10px 14px;
}
.date-time .text-24 {
font-size: 24px !important;
}
.date-time {
font-size: 14px;
}
}
@media (max-width: 768px) {
.center-time-section {
min-width: 240px;
padding: 8px 12px;
margin: 0 10px;
}
.date-time .text-24 {
font-size: 20px !important;
}
.date-time {
font-size: 12px;
}
}
/* ============ 右下角初始化提示 ============ */
.init-success-tip {
position: fixed;
bottom: 30px;
right: 30px;
z-index: 2000;
background: linear-gradient(135deg, #67c23a, #85ce61);
border-radius: 12px;
box-shadow: 0 8px 30px rgba(103, 194, 58, 0.3);
animation: slideInUp 0.6s ease-out;
backdrop-filter: blur(10px);
}
.init-success-content {
display: flex;
align-items: center;
padding: 12px 20px;
color: white;
font-size: 14px;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.init-success-content i {
font-size: 18px;
margin-right: 8px;
animation: successPulse 2s infinite;
}
@keyframes successPulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
/* ============ 表单样式(缩小版) ============ */
.form-item-container {
padding: 0 8px;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
}
.form-label {
font-size: 14px;
font-weight: 600;
color: #2c3e50;
margin-bottom: 6px;
display: block;
text-align: center;
}
.form-item-container .el-input {
width: 100%;
}
.form-item-container .el-input__inner {
border-radius: 6px !important;
border: 1px solid #dcdfe6 !important;
padding: 8px 12px !important;
font-size: 13px !important;
transition: all 0.3s ease !important;
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
height: 32px !important;
}
.form-item-container .el-input__inner:focus {
border-color: #409EFF !important;
box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.1) !important;
}
.form-item-container .el-input__inner:hover {
border-color: #c0c4cc !important;
}
/* 按钮行样式(缩小版)*/
.el-button {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
position: relative !important;
overflow: hidden !important;
height: 32px !important;
font-size: 13px !important;
}
.el-button:hover {
transform: translateY(-1px) !important;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15) !important;
}
.el-button:active {
transform: translateY(0) !important;
}
.buttonFont-small {
font-size: 13px;
font-weight: 600;
position: relative;
z-index: 2;
}
/* 响应式表单设置 */
@media (max-width: 1200px) {
.form-label {
font-size: 13px;
}
.form-item-container .el-input__inner {
padding: 7px 10px !important;
font-size: 12px !important;
height: 30px !important;
}
.init-success-tip {
bottom: 20px;
right: 20px;
}
.init-success-content {
padding: 10px 16px;
font-size: 13px;
}
}
@media (max-width: 768px) {
.form-item-container {
padding: 0 4px;
}
.form-label {
font-size: 12px;
margin-bottom: 4px;
}
.form-item-container .el-input__inner {
padding: 6px 8px !important;
font-size: 11px !important;
height: 28px !important;
}
.el-button {
font-size: 11px !important;
height: 28px !important;
padding: 4px 12px !important;
}
.buttonFont-small {
font-size: 11px;
}
.init-success-tip {
bottom: 15px;
right: 15px;
}
.init-success-content {
padding: 8px 12px;
font-size: 12px;
}
.init-success-content i {
font-size: 14px;
margin-right: 6px;
}
}
.marquee-content {
display: inline-block;
animation: marquee 28s linear infinite;
padding-left: 100%;
color: #fff45c;
font-size: 22px;
font-weight: 800;
line-height: 36px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.45);
letter-spacing: 0;
white-space: nowrap;
}
.marquee-repeat-text {
margin-left: 160px;
}
@keyframes warningBlink {
0%, 100% {
opacity: 1;
background-color: #FFF200;
}
50% {
opacity: 1;
background-color: transparent;
}
}
@keyframes marquee {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-200%);
}
}
.el-table__body tr:hover > td {
background-color: inherit !important;
}
.main-jcxx-table th.el-table__cell {
padding-top: 6px !important;
padding-bottom: 6px !important;
}
.main-jcxx-table td.el-table__cell {
padding-top: 5px !important;
padding-bottom: 5px !important;
}
.main-jcxx-table .cell {
line-height: 1.2 !important;
}
.history-jcxx-form-row {
display: flex;
align-items: center;
flex-wrap: nowrap;
justify-content: flex-start;
width: 100%;
column-gap: 6px;
}
.history-jcxx-form-row:first-child {
column-gap: 2px;
}
.history-jcxx-field-box {
width: 226px;
display: flex;
align-items: center;
flex: 0 0 auto;
}
.history-jcxx-inline-label {
width: 58px;
padding-right: 8px;
font-size: 13px;
line-height: 30px;
text-align: right;
white-space: nowrap;
color: #606266;
box-sizing: border-box;
flex: 0 0 auto;
}
.history-jcxx-field-box .el-input {
width: 146px;
}
.history-jcxx-form-row + .history-jcxx-form-row {
margin-top: 2px;
}
.history-jcxx-query-button {
margin-left: 4px;
flex: 0 0 auto;
}
.history-jcxx-filter-box {
width: 230px;
display: flex;
align-items: center;
flex: 0 0 auto;
}
.history-jcxx-filter-label {
width: 72px;
}
.history-jcxx-filter-box .el-select {
width: 140px;
}
.history-jcxx-time-box {
width: 610px;
display: flex;
align-items: center;
flex: 0 0 auto;
}
.history-jcxx-time-label {
width: 60px;
}
.history-jcxx-time-range {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: nowrap;
width: 540px;
}
.history-jcxx-count {
min-width: 180px;
margin-left: 8px;
flex: 0 0 auto;
}
.history-jcxx-form .el-input__inner,
.history-jcxx-form .el-date-editor.el-input__inner,
.history-jcxx-form .el-date-editor .el-range-input,
.history-jcxx-form .el-date-editor .el-range-separator {
font-size: 12px !important;
}
.history-jcxx-form .el-input__inner,
.history-jcxx-form .el-date-editor.el-input__inner,
.history-jcxx-form .el-date-editor--datetimerange {
height: 30px !important;
}
.history-jcxx-form .el-date-editor .el-range__icon,
.history-jcxx-form .el-date-editor .el-range__close-icon {
line-height: 22px !important;
}
.jc-update-dialog .el-dialog {
max-width: calc(100vw - 32px);
}
.jc-update-form .el-row .el-col {
min-height: 40px;
}
.jc-update-form .el-row .el-col > label {
flex: 0 0 96px;
width: 96px !important;
min-width: 96px;
white-space: nowrap;
word-break: keep-all;
text-align: center;
line-height: 20px;
margin-right: 0;
}
.jc-update-form .el-row .el-col > .el-input,
.jc-update-form .el-row .el-col > .el-select {
flex: 1 1 0;
min-width: 0;
}
.jc-update-room {
display: flex;
align-items: center;
}
.jc-update-room > .el-button {
margin-left: 15px;
}
.history-jcxx-table th.el-table__cell {
padding-top: 5px !important;
padding-bottom: 5px !important;
}
.history-jcxx-table td.el-table__cell {
padding-top: 4px !important;
padding-bottom: 4px !important;
}
.history-jcxx-load-tip {
padding: 8px 0 2px;
text-align: center;
color: #909399;
font-size: 12px;
}
.history-jcxx-table .cell {
line-height: 1.15 !important;
}
.leave-snapshot-preview {
display: flex;
justify-content: center;
min-height: 180px;
}
.leave-snapshot-preview img {
max-width: 100%;
max-height: 560px;
object-fit: contain;
}
.leave-snapshot-empty {
padding: 60px 0;
text-align: center;
color: #909399;
}
.item1 {
margin-top: 10px;
}
.bottom-right-div {
width: 200px;
height: 200px;
position: absolute;
bottom: 20px;
right: 20px;
padding: 10px;
font-size: 20px;
background-color: #a6b8ac;
border-radius: 10px;
}
.Dialog_JcEvent .el-dialog {
position: fixed;
top: -100px;
left: 100px;
background-color: #dee2e6;
}
.Dialog_AlarmEvent .el-dialog {
background-color: #cc7b10;
}
.buttonFont {
font-size: 20px;
font-weight: 600;
}
body {
padding: 0 0 0 0;
}
.app_contianer {
padding: 0 0 0 0;
}
.bottom-duty-bar {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
height: 50px;
background-color: #029191;
display: flex;
align-items: center;
justify-content: space-between;
z-index: 1000;
}
.bottom-stay-summary {
display: flex;
align-items: center;
gap: 80px;
padding-left: 28px;
}
.bottom-stay-summary,
.latest-sync-time {
color: #fff45c;
font-size: 24px;
font-weight: 800;
line-height: 50px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.35);
white-space: nowrap;
}
.latest-sync-time {
position: absolute;
left: 50%;
transform: translateX(-50%);
text-align: center;
}
.bottom-duty-info {
flex: 1;
text-align: right;
color: #ffffff;
}
.el-table__body tr.current-row > td {
background-color: #1990f1 !important;
color: #ffffff;
}
.history-jcxx-table .el-table__body tr.current-row > td {
background-color: #0b7dda !important;
color: #ffffff !important;
}
.history-jcxx-table.el-table--striped .el-table__body tr.current-row > td,
.history-jcxx-table.el-table--striped .el-table__body tr.current-row.el-table__row--striped > td {
background-color: #0b7dda !important;
color: #ffffff !important;
}
.red-mask {
background-color: red;
}
.record-button-list {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.record-button {
margin-left: 0 !important;
width: 58px;
padding-left: 0 !important;
padding-right: 0 !important;
}
/* 合并后的交路及叫乘提醒。 */
.sync-warning-dialog .el-dialog {
min-width: 620px;
border-radius: 8px;
}
.sync-warning-dialog .el-dialog__header {
background: #fff7e6;
border-bottom: 1px solid #f5c26b;
text-align: center;
}
.sync-warning-dialog .el-dialog__title {
color: #b45309;
font-size: 22px;
font-weight: 700;
}
.sync-warning-content {
max-height: 56vh;
overflow-y: auto;
}
.sync-warning-section + .sync-warning-section {
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid #ebeef5;
}
.sync-warning-category {
margin-bottom: 8px;
color: #b45309;
font-size: 18px;
font-weight: 700;
}
.sync-warning-category i {
margin-right: 6px;
}
.sync-warning-item {
padding: 5px 0 5px 26px;
color: #303133;
font-size: 17px;
line-height: 1.55;
}
/* ============ 优化按钮样式 ============ */
.el-button {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
position: relative !important;
overflow: hidden !important;
}
.el-button:hover {
transform: translateY(-2px) !important;
box-shadow: 0 8px 20px rgba(64, 158, 255, 0.4) !important;
}
.el-button:active {
transform: translateY(0) !important;
}
/* 查询按钮特殊样式 */
.el-button[style*=linear-gradient]:hover {
background: linear-gradient(135deg, #66b3ff, #409EFF) !important;
box-shadow: 0 8px 25px rgba(64, 158, 255, 0.5) !important;
}
.el-button[style*=linear-gradient]:before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
transition: left 0.5s ease;
}
.el-button[style*=linear-gradient]:hover:before {
left: 100%;
}
/* 响应式按钮优化 */
@media (max-width: 768px) {
.el-button[style*=margin-left] {
margin-left: 20px !important;
width: 120px;
}
}
.person-photo-panel {
display: flex;
justify-content: center;
align-items: flex-start;
gap: 32px;
}
.person-photo-item {
width: 180px;
text-align: center;
}
.person-photo-title {
height: 24px;
line-height: 24px;
color: #606266;
font-size: 14px;
margin-bottom: 6px;
}
.person-photo-img {
width: 150px;
height: 200px;
object-fit: cover;
border: 1px solid #dcdfe6;
background: #f5f7fa;
}
.jl-add-dialog .el-dialog {
max-width: calc(100vw - 32px);
}
.jl-add-form .el-row .el-col > label,
.jl-add-form .el-row .el-col > div > label {
flex: 0 0 96px;
width: 96px !important;
min-width: 96px;
white-space: nowrap;
word-break: keep-all;
text-align: center;
line-height: 20px;
margin-right: 0;
}
.jl-add-form .el-row .el-col > .el-input,
.jl-add-form .el-row .el-col > .el-select,
.jl-add-form .el-row .el-col > .el-date-editor,
.jl-add-form .el-row .el-col > div > .el-input,
.jl-add-form .el-row .el-col > div > .el-select,
.jl-add-form .el-row .el-col > div > .el-date-editor {
flex: 1 1 0;
}
.jl-add-form .el-row .el-col > div {
width: 100%;
}
.jl-add-form .el-row .el-col > div > .el-input,
.jl-add-form .el-row .el-col > div > .el-select,
.jl-add-form .el-row .el-col > div > .el-date-editor,
.jl-add-form .el-row .el-col > div > .el-time-picker {
width: 100% !important;
min-width: 0;
}
.jl-add-form .el-row .el-col > div > .el-date-editor .el-input__inner {
width: 100%;
}</style>
<style>body{margin:0;background:#f3f6f5}#app{font-family:"Helvetica Neue",Helvetica,"PingFang SC","Microsoft YaHei",Arial,sans-serif}
/* 快照专用:模拟 el-table 运行时的内部横向滚动,避免 15 列被静态表格压扁 */
.main-jcxx-table .el-table__header-wrapper,
.main-jcxx-table .el-table__body-wrapper{overflow-x:auto}
.main-jcxx-table table{min-width:1500px}
</style>
</head><body><div class="app_contianer jc-opt-root" id="app">
<div class="el-card"><div class="el-card__body">
<div class="el-row jc-header-row" style="height:150px;margin-left:-7.5px;margin-right:-7.5px">
<div class="el-col el-col-9 jc-header-search" style="height:100%;display:flex;flex-direction:column;justify-content:center;padding-left:7.5px;padding-right:7.5px">
<div class="el-row top-left-marquee-row" style="height:30%;margin-top:40px">
<div class="marquee-container" style="background-color:rgb(2,145,145);height:100%">
<div class="marquee-content"><span>305 房间叫乘时间已过,请值班员关注</span><span class="marquee-repeat-text">305 房间叫乘时间已过,请值班员关注</span></div>
</div>
</div>
<div class="top-left-search-area" style="flex:1;display:flex;flex-direction:column;justify-content:center;padding:10px 20px">
<div class="el-row top-left-input-row" style="margin-bottom:15px"><div class="el-col el-col-6" style="padding-left:7.5px;padding-right:7.5px">
<div class="form-item-container"><label class="form-label">机车号</label>
<div class="el-input"><input class="el-input__inner" placeholder="请输入机车号" id="q0"></div></div></div><div class="el-col el-col-6" style="padding-left:7.5px;padding-right:7.5px">
<div class="form-item-container"><label class="form-label">车次</label>
<div class="el-input"><input class="el-input__inner" placeholder="请输入车次" id="q1"></div></div></div><div class="el-col el-col-6" style="padding-left:7.5px;padding-right:7.5px">
<div class="form-item-container"><label class="form-label">房间号</label>
<div class="el-input"><input class="el-input__inner" placeholder="请输入房间号" id="q2"></div></div></div><div class="el-col el-col-6" style="padding-left:7.5px;padding-right:7.5px">
<div class="form-item-container"><label class="form-label">姓名</label>
<div class="el-input"><input class="el-input__inner" placeholder="请输入姓名" id="q3"></div></div></div></div>
<div class="el-row top-left-action-row">
<div class="el-col el-col-24" style="display:flex;justify-content:space-between;align-items:center">
<button class="el-button el-button--small" style="background: linear-gradient(135deg, #e6a23c, #f7ba2a); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(230,162,60,.3); color: white;"><span class="buttonFont-small">取消叫乘</span></button>
<div style="display:flex;align-items:center;gap:12px"><button class="el-button el-button--small" style="background: linear-gradient(135deg, #409EFF, #66b3ff); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(64,158,255,.3); color: white;"><span class="buttonFont-small">查询</span></button><button class="el-button el-button--small" style="background: linear-gradient(135deg, #909399, #b1b3b8); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(144,147,153,.3); color: white;"><span class="buttonFont-small">重置</span></button></div>
</div>
</div>
</div>
</div>
<div class="el-col el-col-6 jc-header-time" style="height:100%;padding-left:7.5px;padding-right:7.5px">
<div class="el-row"><div class="center-time-section"><div class="date-time">
<p class="text-24 text-white">2026-11-09 星期一<br>09:03:21</p>
</div></div></div>
</div>
<div class="el-col el-col-9 jc-header-actions" style="height:100%;padding-left:7.5px;padding-right:7.5px">
<div class="el-row" style="height:50%;padding:8px 20px 8px 0px;display:flex;align-items:center">
<div class="el-col el-col-18"><div style="display:flex;gap:5px;justify-content:space-between;align-items:center"><button class="el-button el-button--small" style="background: linear-gradient(135deg, #e6a23c, #f7ba2a); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(230,162,60,.3); color: white;height:32px;"><span class="buttonFont-small">房间信息</span></button><button class="el-button el-button--small" style="background: linear-gradient(135deg, #e6a23c, #f7ba2a); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(230,162,60,.3); color: white;height:32px;"><span class="buttonFont-small">设备信息</span></button><button class="el-button el-button--small" style="background: linear-gradient(135deg, #e6a23c, #f7ba2a); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(230,162,60,.3); color: white;height:32px;"><span class="buttonFont-small">人员维护</span></button><button class="el-button el-button--small" style="background: linear-gradient(135deg, #e6a23c, #f7ba2a); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(230,162,60,.3); color: white;height:32px;"><span class="buttonFont-small">交路管理</span></button><button class="el-button el-button--small" style="background: linear-gradient(135deg, #e6a23c, #f7ba2a); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(230,162,60,.3); color: white;height:32px;"><span class="buttonFont-small">历史数据</span></button></div></div>
<div class="el-col el-col-1"></div>
<div class="el-col el-col-5"><div style="display:flex;align-items:center;gap:3px">
<span class="jc-user-label" style="font-size:12px;color:#666;width:45%">当前用户: 张值班</span>
<button class="el-button el-button--small" style="background: linear-gradient(135deg, #f56c6c, #ff7979); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(245,108,108,.3); color: white;height:32px;width:55%;"><span class="buttonFont-small">退出</span></button>
</div></div>
</div>
<div class="el-row" style="height:50%;padding:8px 20px 8px 0px;display:flex;align-items:center">
<div class="el-col el-col-7"><div style="display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-start"><button class="el-button el-button--small" style="background: linear-gradient(135deg, #0b5858, #0f6b6b); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(11,88,88,.3); color: white;height:32px;width:84px;"><span class="buttonFont-small">换乘</span></button><button class="el-button el-button--small" style="background: linear-gradient(135deg, #0b5858, #0f6b6b); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(11,88,88,.3); color: white;height:32px;width:84px;"><span class="buttonFont-small">出乘</span></button></div></div>
<div class="el-col el-col-17"><div style="display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-start;margin-left:14px"><button class="el-button el-button--small" style="background: linear-gradient(135deg, #0b5858, #0f6b6b); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(11,88,88,.3); color: white;height:32px;"><span class="buttonFont-small">语音临叫</span></button><button class="el-button el-button--small" style="background: linear-gradient(135deg, #0b5858, #0f6b6b); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(11,88,88,.3); color: white;height:32px;"><span class="buttonFont-small">喊话临叫</span></button><button class="el-button el-button--small" style="background: linear-gradient(135deg, #0b5858, #0f6b6b); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(11,88,88,.3); color: white;height:32px;"><span class="buttonFont-small">停止呼叫</span></button><button class="el-button el-button--small" style="background: linear-gradient(135deg, #0b5858, #0f6b6b); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(11,88,88,.3); color: white;height:32px;"><span class="buttonFont-small">停止报警</span></button></div></div>
</div>
</div>
</div>
</div></div>
<div class="el-card" style="margin-top:10px"><div class="el-card__body">
<div class="el-tabs el-tabs--card"><div class="el-tabs__header is-top"><div class="el-tabs__nav-wrap"><div class="el-tabs__nav-scroll">
<div class="el-tabs__nav is-top"><div class="el-tabs__item is-top is-active">机务人员和客运人员</div><div class="el-tabs__item is-top">其他人员</div></div>
</div></div></div></div>
<div class="el-table main-jcxx-table el-table--border" style="width:100%;height:600px">
<div class="el-table__header-wrapper"><table class="el-table__header" style="width:100%"><thead><tr style="height:54px"><th class="el-table__cell"><div class="cell">序号</div></th><th class="el-table__cell"><div class="cell">状态</div></th><th class="el-table__cell"><div class="cell">到寓时间</div></th><th class="el-table__cell"><div class="cell">单位</div></th><th class="el-table__cell"><div class="cell">机车号</div></th><th class="el-table__cell"><div class="cell">姓名</div></th><th class="el-table__cell"><div class="cell">房间号</div></th><th class="el-table__cell"><div class="cell">出发车次</div></th><th class="el-table__cell"><div class="cell">开车时间</div></th><th class="el-table__cell"><div class="cell">叫乘时间</div></th><th class="el-table__cell"><div class="cell">复叫时间</div></th><th class="el-table__cell"><div class="cell">叫乘日期</div></th><th class="el-table__cell"><div class="cell">应离寓时间</div></th><th class="el-table__cell"><div class="cell">接车方式</div></th><th class="el-table__cell"><div class="cell">操作</div></th></tr></thead></table></div>
<div class="el-table__body-wrapper" style="height:546px"><table class="el-table__body" style="width:100%"><tbody><tr class="el-table__row current-row" style="background-color:#F56C6C;font-size:20px;font-weight:600"><td class="el-table__cell"><div class="cell">1</div></td><td class="el-table__cell"><div class="cell"><span class="jc-st jc-st-alarm">叫乘报警</span></div></td><td class="el-table__cell"><div class="cell">11-08 21:40</div></td><td class="el-table__cell"><div class="cell">机务段</div></td><td class="el-table__cell"><div class="cell">HXD3C-0131</div></td><td class="el-table__cell"><div class="cell">冯建国</div></td><td class="el-table__cell"><div class="cell">305</div></td><td class="el-table__cell"><div class="cell">K671</div></td><td class="el-table__cell"><div class="cell">08:35</div></td><td class="el-table__cell"><div class="cell">06:35</div></td><td class="el-table__cell"><div class="cell">06:45</div></td><td class="el-table__cell"><div class="cell">11-09</div></td><td class="el-table__cell"><div class="cell">07:05</div></td><td class="el-table__cell"><div class="cell">站接</div></td><td class="el-table__cell"><div class="cell"><button class="el-button el-button--small" style="background: linear-gradient(135deg, #409EFF, #66b3ff); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(64,158,255,.3); color: white;height:28px;"><span class="buttonFont-small">暂停报警</span></button></div></td></tr><tr class="el-table__row" style="background-color:#00FF00;font-size:20px;font-weight:600"><td class="el-table__cell"><div class="cell">2</div></td><td class="el-table__cell"><div class="cell"><span class="jc-st jc-st-normal">正在呼叫</span></div></td><td class="el-table__cell"><div class="cell">11-08 22:05</div></td><td class="el-table__cell"><div class="cell">机务段</div></td><td class="el-table__cell"><div class="cell">HXD3C-0132</div></td><td class="el-table__cell"><div class="cell">蒋卫东</div></td><td class="el-table__cell"><div class="cell">517</div></td><td class="el-table__cell"><div class="cell">T118</div></td><td class="el-table__cell"><div class="cell">09:10</div></td><td class="el-table__cell"><div class="cell">07:10</div></td><td class="el-table__cell"><div class="cell">07:20</div></td><td class="el-table__cell"><div class="cell">11-09</div></td><td class="el-table__cell"><div class="cell">07:40</div></td><td class="el-table__cell"><div class="cell">库出</div></td><td class="el-table__cell"><div class="cell"><button class="el-button el-button--small" style="background: linear-gradient(135deg, #409EFF, #66b3ff); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(64,158,255,.3); color: white;height:28px;"><span class="buttonFont-small">暂停报警</span></button></div></td></tr><tr class="el-table__row" style="background-color:#FFF200;font-size:20px;font-weight:600"><td class="el-table__cell"><div class="cell">3</div></td><td class="el-table__cell"><div class="cell"><span class="jc-st jc-st-normal">待叫乘</span></div></td><td class="el-table__cell"><div class="cell">11-08 22:20</div></td><td class="el-table__cell"><div class="cell">客运段</div></td><td class="el-table__cell"><div class="cell">HXD3C-0133</div></td><td class="el-table__cell"><div class="cell">李红军</div></td><td class="el-table__cell"><div class="cell">206</div></td><td class="el-table__cell"><div class="cell">Z267</div></td><td class="el-table__cell"><div class="cell">10:05</div></td><td class="el-table__cell"><div class="cell">08:05</div></td><td class="el-table__cell"><div class="cell">08:15</div></td><td class="el-table__cell"><div class="cell">11-09</div></td><td class="el-table__cell"><div class="cell">08:35</div></td><td class="el-table__cell"><div class="cell">站接</div></td><td class="el-table__cell"><div class="cell"><button class="el-button el-button--small" style="background: linear-gradient(135deg, #409EFF, #66b3ff); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(64,158,255,.3); color: white;height:28px;"><span class="buttonFont-small">暂停报警</span></button></div></td></tr><tr class="el-table__row" style="background-color:#B3E19D;font-size:20px;font-weight:600"><td class="el-table__cell"><div class="cell">4</div></td><td class="el-table__cell"><div class="cell"><span class="jc-st jc-st-normal">待离寓</span></div></td><td class="el-table__cell"><div class="cell">11-08 20:15</div></td><td class="el-table__cell"><div class="cell">机务段</div></td><td class="el-table__cell"><div class="cell">HXD3C-0134</div></td><td class="el-table__cell"><div class="cell">王志强</div></td><td class="el-table__cell"><div class="cell">408</div></td><td class="el-table__cell"><div class="cell">K42</div></td><td class="el-table__cell"><div class="cell">07:40</div></td><td class="el-table__cell"><div class="cell">05:40</div></td><td claLine truncated
</div>
</div></div>
<div class="bottom-duty-bar">
<div class="bottom-stay-summary"><span>在寓班次:14</span><span>在寓人次:30</span></div>
<div class="latest-sync-time" role="status">最新同步时间:09:03:18</div>
<div class="bottom-duty-info">
<button class="el-button el-button--small" style="background: linear-gradient(135deg, #e6a23c, #f7ba2a); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(230,162,60,.3); color: white;position:relative;right:20px;top:2px;"><span class="buttonFont-small">交接班</span></button>
<span style="font-size:26px;margin-right:80px;position:relative;top:6px">值班员:张值班</span>
</div>
</div>
<div class="bottom-right-div" id="notice">有新的交路信息,请及时处理!</div>
<!-- 宽弹窗骨架:默认隐藏,验收时按需显示以测量视口约束 -->
<div class="el-dialog__wrapper jc-dialog jc-wide-dialog" id="wideWrap" style="display:none">
<div class="el-dialog" style="width:1800px"><div class="el-dialog__header"><span class="el-dialog__title">历史叫乘信息</span></div>
<div class="el-dialog__body"><div class="el-form history-jcxx-form"><div class="history-jcxx-form-row">
<div class="history-jcxx-field-box"><label class="history-jcxx-inline-label">机车号</label><div class="el-input"><input class="el-input__inner"></div></div>
<div class="history-jcxx-field-box"><label class="history-jcxx-inline-label">车次</label><div class="el-input"><input class="el-input__inner"></div></div>
</div></div></div>
</div>
</div>
</div></body></html>
File diff suppressed because it is too large. Load diff
+761
View File
@@ -0,0 +1,761 @@
.marquee-container {
white-space: nowrap;
overflow: hidden;
}
.top-left-marquee-row {
margin-top: 10px;
margin-bottom: 4px;
}
.top-left-search-area {
padding-top: 6px !important;
}
.top-left-input-row {
margin-top: -2;
}
.top-left-action-row {
margin-top: -2;
}
/* 中间时间信息区域 */
.center-time-section {
flex: 0 0 auto;
background: linear-gradient(135deg, #0078d7, #107c10);
color: white;
padding: 12px 16px;
border-radius: 8px;
margin: 0 15px;
min-width: 320px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.date-time {
display: flex;
flex-direction: column;
align-items: center;
font-size: 16px;
}
.date-time .text-24 {
font-size: 26px !important;
font-weight: 600;
margin: 0;
line-height: 1.2;
}
/* 响应式调整 */
@media (max-width: 1200px) {
.center-time-section {
min-width: 280px;
padding: 10px 14px;
}
.date-time .text-24 {
font-size: 24px !important;
}
.date-time {
font-size: 14px;
}
}
@media (max-width: 768px) {
.center-time-section {
min-width: 240px;
padding: 8px 12px;
margin: 0 10px;
}
.date-time .text-24 {
font-size: 20px !important;
}
.date-time {
font-size: 12px;
}
}
/* ============ 右下角初始化提示 ============ */
.init-success-tip {
position: fixed;
bottom: 30px;
right: 30px;
z-index: 2000;
background: linear-gradient(135deg, #67c23a, #85ce61);
border-radius: 12px;
box-shadow: 0 8px 30px rgba(103, 194, 58, 0.3);
animation: slideInUp 0.6s ease-out;
backdrop-filter: blur(10px);
}
.init-success-content {
display: flex;
align-items: center;
padding: 12px 20px;
color: white;
font-size: 14px;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.init-success-content i {
font-size: 18px;
margin-right: 8px;
animation: successPulse 2s infinite;
}
@keyframes successPulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
/* ============ 表单样式(缩小版) ============ */
.form-item-container {
padding: 0 8px;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
}
.form-label {
font-size: 14px;
font-weight: 600;
color: #2c3e50;
margin-bottom: 6px;
display: block;
text-align: center;
}
.form-item-container .el-input {
width: 100%;
}
.form-item-container .el-input__inner {
border-radius: 6px !important;
border: 1px solid #dcdfe6 !important;
padding: 8px 12px !important;
font-size: 13px !important;
transition: all 0.3s ease !important;
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
height: 32px !important;
}
.form-item-container .el-input__inner:focus {
border-color: #409EFF !important;
box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.1) !important;
}
.form-item-container .el-input__inner:hover {
border-color: #c0c4cc !important;
}
/* 按钮行样式(缩小版)*/
.el-button {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
position: relative !important;
overflow: hidden !important;
height: 32px !important;
font-size: 13px !important;
}
.el-button:hover {
transform: translateY(-1px) !important;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15) !important;
}
.el-button:active {
transform: translateY(0) !important;
}
.buttonFont-small {
font-size: 13px;
font-weight: 600;
position: relative;
z-index: 2;
}
/* 响应式表单设置 */
@media (max-width: 1200px) {
.form-label {
font-size: 13px;
}
.form-item-container .el-input__inner {
padding: 7px 10px !important;
font-size: 12px !important;
height: 30px !important;
}
.init-success-tip {
bottom: 20px;
right: 20px;
}
.init-success-content {
padding: 10px 16px;
font-size: 13px;
}
}
@media (max-width: 768px) {
.form-item-container {
padding: 0 4px;
}
.form-label {
font-size: 12px;
margin-bottom: 4px;
}
.form-item-container .el-input__inner {
padding: 6px 8px !important;
font-size: 11px !important;
height: 28px !important;
}
.el-button {
font-size: 11px !important;
height: 28px !important;
padding: 4px 12px !important;
}
.buttonFont-small {
font-size: 11px;
}
.init-success-tip {
bottom: 15px;
right: 15px;
}
.init-success-content {
padding: 8px 12px;
font-size: 12px;
}
.init-success-content i {
font-size: 14px;
margin-right: 6px;
}
}
.marquee-content {
display: inline-block;
animation: marquee 28s linear infinite;
padding-left: 100%;
color: #fff45c;
font-size: 22px;
font-weight: 800;
line-height: 36px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.45);
letter-spacing: 0;
white-space: nowrap;
}
.marquee-repeat-text {
margin-left: 160px;
}
@keyframes warningBlink {
0%, 100% {
opacity: 1;
background-color: #FFF200;
}
50% {
opacity: 1;
background-color: transparent;
}
}
@keyframes marquee {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-200%);
}
}
.el-table__body tr:hover > td {
background-color: inherit !important;
}
.main-jcxx-table th.el-table__cell {
padding-top: 6px !important;
padding-bottom: 6px !important;
}
.main-jcxx-table td.el-table__cell {
padding-top: 5px !important;
padding-bottom: 5px !important;
}
.main-jcxx-table .cell {
line-height: 1.2 !important;
}
.history-jcxx-form-row {
display: flex;
align-items: center;
flex-wrap: nowrap;
justify-content: flex-start;
width: 100%;
column-gap: 6px;
}
.history-jcxx-form-row:first-child {
column-gap: 2px;
}
.history-jcxx-field-box {
width: 226px;
display: flex;
align-items: center;
flex: 0 0 auto;
}
.history-jcxx-inline-label {
width: 58px;
padding-right: 8px;
font-size: 13px;
line-height: 30px;
text-align: right;
white-space: nowrap;
color: #606266;
box-sizing: border-box;
flex: 0 0 auto;
}
.history-jcxx-field-box .el-input {
width: 146px;
}
.history-jcxx-form-row + .history-jcxx-form-row {
margin-top: 2px;
}
.history-jcxx-query-button {
margin-left: 4px;
flex: 0 0 auto;
}
.history-jcxx-filter-box {
width: 230px;
display: flex;
align-items: center;
flex: 0 0 auto;
}
.history-jcxx-filter-label {
width: 72px;
}
.history-jcxx-filter-box .el-select {
width: 140px;
}
.history-jcxx-time-box {
width: 610px;
display: flex;
align-items: center;
flex: 0 0 auto;
}
.history-jcxx-time-label {
width: 60px;
}
.history-jcxx-time-range {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: nowrap;
width: 540px;
}
.history-jcxx-count {
min-width: 180px;
margin-left: 8px;
flex: 0 0 auto;
}
.history-jcxx-form .el-input__inner,
.history-jcxx-form .el-date-editor.el-input__inner,
.history-jcxx-form .el-date-editor .el-range-input,
.history-jcxx-form .el-date-editor .el-range-separator {
font-size: 12px !important;
}
.history-jcxx-form .el-input__inner,
.history-jcxx-form .el-date-editor.el-input__inner,
.history-jcxx-form .el-date-editor--datetimerange {
height: 30px !important;
}
.history-jcxx-form .el-date-editor .el-range__icon,
.history-jcxx-form .el-date-editor .el-range__close-icon {
line-height: 22px !important;
}
.jc-update-dialog .el-dialog {
max-width: calc(100vw - 32px);
}
.jc-update-form .el-row .el-col {
min-height: 40px;
}
.jc-update-form .el-row .el-col > label {
flex: 0 0 96px;
width: 96px !important;
min-width: 96px;
white-space: nowrap;
word-break: keep-all;
text-align: center;
line-height: 20px;
margin-right: 0;
}
.jc-update-form .el-row .el-col > .el-input,
.jc-update-form .el-row .el-col > .el-select {
flex: 1 1 0;
min-width: 0;
}
.jc-update-room {
display: flex;
align-items: center;
}
.jc-update-room > .el-button {
margin-left: 15px;
}
.history-jcxx-table th.el-table__cell {
padding-top: 5px !important;
padding-bottom: 5px !important;
}
.history-jcxx-table td.el-table__cell {
padding-top: 4px !important;
padding-bottom: 4px !important;
}
.history-jcxx-load-tip {
padding: 8px 0 2px;
text-align: center;
color: #909399;
font-size: 12px;
}
.history-jcxx-table .cell {
line-height: 1.15 !important;
}
.leave-snapshot-preview {
display: flex;
justify-content: center;
min-height: 180px;
}
.leave-snapshot-preview img {
max-width: 100%;
max-height: 560px;
object-fit: contain;
}
.leave-snapshot-empty {
padding: 60px 0;
text-align: center;
color: #909399;
}
.item1 {
margin-top: 10px;
}
.bottom-right-div {
width: 200px;
height: 200px;
position: absolute;
bottom: 20px;
right: 20px;
// background-color: #255e39;
padding: 10px;
font-size: 20px;
background-color: #a6b8ac;
border-radius: 10px;
}
.Dialog_JcEvent .el-dialog {
position: fixed;
top: -100px;
left: 100px;
background-color: rgb(222, 226, 230);
}
.Dialog_AlarmEvent .el-dialog {
background-color: rgb(204, 123, 16);
}
.buttonFont {
font-size: 20px;
font-weight: 600;
}
body {
padding: 0 0 0 0 ;
}
.app_contianer {
padding: 0 0 0 0;
}
.bottom-duty-bar {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
height: 50px;
background-color: rgb(2, 145, 145);
display: flex;
align-items: center;
justify-content: space-between;
z-index: 1000;
}
.bottom-stay-summary {
display: flex;
align-items: center;
gap: 80px;
padding-left: 28px;
}
.bottom-stay-summary,
.latest-sync-time {
color: #fff45c;
font-size: 24px;
font-weight: 800;
line-height: 50px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.35);
white-space: nowrap;
}
.latest-sync-time {
position: absolute;
left: 50%;
transform: translateX(-50%);
text-align: center;
}
.bottom-duty-info {
flex: 1;
text-align: right;
color: #ffffff;
}
.el-table__body tr.current-row > td {
background-color: #1990f1 !important;
color: #ffffff;
}
.history-jcxx-table .el-table__body tr.current-row > td {
background-color: #0b7dda !important;
color: #ffffff !important;
}
.history-jcxx-table.el-table--striped .el-table__body tr.current-row > td,
.history-jcxx-table.el-table--striped .el-table__body tr.current-row.el-table__row--striped > td {
background-color: #0b7dda !important;
color: #ffffff !important;
}
.red-mask {
background-color: red;
}
.record-button-list {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.record-button {
margin-left: 0 !important;
width: 58px;
padding-left: 0 !important;
padding-right: 0 !important;
}
/* 合并后的交路及叫乘提醒。 */
.sync-warning-dialog .el-dialog {
min-width: 620px;
border-radius: 8px;
}
.sync-warning-dialog .el-dialog__header {
background: #fff7e6;
border-bottom: 1px solid #f5c26b;
text-align: center;
}
.sync-warning-dialog .el-dialog__title {
color: #b45309;
font-size: 22px;
font-weight: 700;
}
.sync-warning-content {
max-height: 56vh;
overflow-y: auto;
}
.sync-warning-section + .sync-warning-section {
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid #ebeef5;
}
.sync-warning-category {
margin-bottom: 8px;
color: #b45309;
font-size: 18px;
font-weight: 700;
}
.sync-warning-category i {
margin-right: 6px;
}
.sync-warning-item {
padding: 5px 0 5px 26px;
color: #303133;
font-size: 17px;
line-height: 1.55;
}
/* ============ 优化按钮样式 ============ */
.el-button {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
position: relative !important;
overflow: hidden !important;
}
.el-button:hover {
transform: translateY(-2px) !important;
box-shadow: 0 8px 20px rgba(64, 158, 255, 0.4) !important;
}
.el-button:active {
transform: translateY(0) !important;
}
/* 查询按钮特殊样式 */
.el-button[style*="linear-gradient"]:hover {
background: linear-gradient(135deg, #66b3ff, #409EFF) !important;
box-shadow: 0 8px 25px rgba(64, 158, 255, 0.5) !important;
}
.el-button[style*="linear-gradient"]:before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
transition: left 0.5s ease;
}
.el-button[style*="linear-gradient"]:hover:before {
left: 100%;
}
/* 响应式按钮优化 */
@media (max-width: 768px) {
.el-button[style*="margin-left"] {
margin-left: 20px !important;
width: 120px;
}
}
.person-photo-panel {
display: flex;
justify-content: center;
align-items: flex-start;
gap: 32px;
}
.person-photo-item {
width: 180px;
text-align: center;
}
.person-photo-title {
height: 24px;
line-height: 24px;
color: #606266;
font-size: 14px;
margin-bottom: 6px;
}
.person-photo-img {
width: 150px;
height: 200px;
object-fit: cover;
border: 1px solid #dcdfe6;
background: #f5f7fa;
}
.jl-add-dialog .el-dialog {
max-width: calc(100vw - 32px);
}
.jl-add-form .el-row .el-col > label,
.jl-add-form .el-row .el-col > div > label {
flex: 0 0 96px;
width: 96px !important;
min-width: 96px;
white-space: nowrap;
word-break: keep-all;
text-align: center;
line-height: 20px;
margin-right: 0;
}
.jl-add-form .el-row .el-col > .el-input,
.jl-add-form .el-row .el-col > .el-select,
.jl-add-form .el-row .el-col > .el-date-editor,
.jl-add-form .el-row .el-col > div > .el-input,
.jl-add-form .el-row .el-col > div > .el-select,
.jl-add-form .el-row .el-col > div > .el-date-editor {
flex: 1 1 0;
}
.jl-add-form .el-row .el-col > div {
width: 100%;
}
.jl-add-form .el-row .el-col > div > .el-input,
.jl-add-form .el-row .el-col > div > .el-select,
.jl-add-form .el-row .el-col > div > .el-date-editor,
.jl-add-form .el-row .el-col > div > .el-time-picker {
width: 100% !important;
min-width: 0;
}
.jl-add-form .el-row .el-col > div > .el-date-editor .el-input__inner {
width: 100%;
}
+393
View File
@@ -0,0 +1,393 @@
/* ============================================================================
* JcXx.optimized.vue 验收脚本
* 1) 真实 SFC 编译:vue-template-compiler / @babel/parser / sass
* 2) 功能集合零变化:script 哈希 + 模板指令/列/按钮/弹窗集合比对
* 3) 优化前 / 优化后同一份静态 DOM + 两份真实 CSS,多视口截图与布局断言
* 用法:node verify_jcxx.cjs
* ==========================================================================*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const ROOT = path.resolve(__dirname, '..', '..', '..');
const VUE = path.join(ROOT, 'YktVue');
const SRC = path.join(VUE, 'src', 'views', 'JcXx.vue');
const OUT = path.join(VUE, 'src', 'views', 'JcXx.optimized.vue');
const UI = path.join(ROOT, 'docs', 'frontend', 'ui');
const req = p => require(path.join(VUE, 'node_modules', p));
const compiler = req('vue-template-compiler');
const babel = req('@babel/parser');
const sass = req('sass');
let chromium;
const PW = 'C:/Users/zfjia/AppData/Roaming/npm/node_modules/@playwright/cli/node_modules/playwright';
const EXE = 'C:/Users/zfjia/AppData/Local/ms-playwright/chromium_headless_shell-1243/chrome-headless-shell-win64/chrome-headless-shell.exe';
try { ({ chromium } = require(PW)); } catch (e) { ({ chromium } = require('playwright')); }
const beforeVue = fs.readFileSync(SRC, 'utf8');
const afterVue = fs.readFileSync(OUT, 'utf8');
function parts(s) {
// 注意:模板内部有大量 <template slot-scope>,必须取最后一个 </template>
const a = s.indexOf('<template>') + '<template>'.length;
const b = s.lastIndexOf('</template>');
const c = s.indexOf('<script>'), d = s.indexOf('</script>') + 9;
const e = s.indexOf('<style'), f = s.indexOf('</style>');
return {
template: s.slice(a, b),
script: s.slice(c, d),
scriptBody: s.slice(s.indexOf('>', c) + 1, d - 9),
style: s.slice(s.indexOf('>', e) + 1, f)
};
}
const B = parts(beforeVue), A = parts(afterVue);
const sha = s => crypto.createHash('sha256').update(s).digest('hex');
let pass = 0, fail = 0;
const results = [];
function T(name, cond, detail) {
if (cond) { pass++; results.push(' PASS ' + name); }
else { fail++; results.push(' FAIL ' + name + (detail ? ' ' + detail : '')); }
}
// ---------------------------------------------------------------- 1. 编译
const rcB = compiler.compile(B.template);
const rcA = compiler.compile(A.template);
T('SFC-01 优化前模板可由 vue-template-compiler 编译', rcB.errors.length === 0, JSON.stringify(rcB.errors));
T('SFC-02 优化后模板可由 vue-template-compiler 编译', rcA.errors.length === 0, JSON.stringify(rcA.errors));
let jsErr = '';
try {
babel.parse(A.scriptBody, {
sourceType: 'module',
plugins: ['objectRestSpread', 'dynamicImport', 'optionalChaining',
'nullishCoalescingOperator', 'classProperties']
});
} catch (e) { jsErr = e.message; }
T('SFC-03 优化后 <script> 通过 Babel 语法解析', !jsErr, jsErr);
const compileSass = scss => sass.renderSync({ data: scss, outputStyle: 'expanded' }).css.toString();
const cssB = compileSass(B.style);
const cssA = compileSass(A.style);
T('SFC-04 优化前 style 可通过 sass 编译', cssB.length > 10000);
T('SFC-05 优化后 style 可通过 sass 编译', cssA.length > cssB.length);
// ---------------------------------------------------------------- 2. 功能集合
T('FUN-01 <script> 段逐字节一致(功能/接口/定时器/状态机全部保留)',
B.script === A.script && sha(B.script) === sha(A.script));
function inventory(tpl) {
const pick = re => (tpl.match(re) || []).sort();
return {
events: pick(/@[a-zA-Z][\w.-]*(?:=|\s|>)/g).map(x => x.replace(/[=>\s]/g, '')).sort(),
models: pick(/v-model(?:\.\w+)?="[^"]+"/g),
binds: pick(/:[a-zA-Z][\w.-]*="[^"]+"/g),
refs: pick(/ref="[^"]+"/g),
slots: pick(/slot-scope="[^"]+"/g),
vif: pick(/v-(?:if|else-if|else|show|for|html|once)(?:="[^"]+")?/g),
cols: pick(/<el-table-column[^>]*?\b(?:prop|label)="[^"]+"/g)
.map(x => (x.match(/\bprop="([^"]+)"/) || [, ''])[1] + '|' +
(x.match(/\blabel="([^"]+)"/) || [, ''])[1]),
tags: ['el-dialog', 'el-table', 'el-table-column', 'el-button', 'el-input',
'el-select', 'el-form', 'el-form-item', 'el-tab-pane', 'el-checkbox',
'audio', 'el-result'].map(t => t + '=' + (tpl.match(new RegExp('<' + t + '\\b', 'g')) || []).length)
};
}
const ib = inventory(B.template), ia = inventory(A.template);
Object.keys(ib).forEach(k => {
if (k === 'events') {
// 唯一预期差异:tab_click -> tab-click(Element UI 实际派发 tab-click;处理函数体为空)
const nb = ib.events.map(x => x === '@tab_click' ? '@tab-click' : x).sort();
T('FUN-02 事件绑定集合一致(仅修正 tab_click 拼写)',
JSON.stringify(nb) === JSON.stringify(ia.events),
JSON.stringify({ before: ib.events.filter(x => !ia.events.includes(x)), after: ia.events.filter(x => !nb.includes(x)) }));
} else {
T('FUN-02 ' + k + ' 集合与数量一致',
JSON.stringify(ib[k]) === JSON.stringify(ia[k]),
JSON.stringify({ before: ib[k].length, after: ia[k].length }));
}
});
T('FUN-03 未新增/删除任何按钮、表格列、弹窗',
ib.tags.join('|') === ia.tags.join('|'), ib.tags.join('|'));
T('FUN-04 所有弹窗带统一 jc-dialog 作用域类名、宽弹窗带 jc-wide-dialog',
(A.template.match(/<el-dialog/g) || []).length === (A.template.match(/class="jc-dialog/g) || []).length &&
(A.template.match(/jc-wide-dialog/g) || []).length === 4);
T('FUN-05 原样式核心规则全部保留,优化层仅做追加与 4 处修正',
['.marquee-container', '.center-time-section', '.form-item-container',
'.bottom-duty-bar', '@keyframes marquee', '.history-jcxx-form-row',
'.sync-warning-content', '.person-photo-panel', '.jl-add-form']
.every(k => A.style.includes(k)) && cssA.length > cssB.length);
// ---------------------------------------------------------------- 3. 静态布局快照
const elementCss = fs.readFileSync(
path.join(VUE, 'node_modules', 'element-ui', 'lib', 'theme-chalk', 'index.css'), 'utf8');
const ORANGE = 'background: linear-gradient(135deg, #e6a23c, #f7ba2a); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(230,162,60,.3); color: white;';
const TEAL = 'background: linear-gradient(135deg, #0b5858, #0f6b6b); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(11,88,88,.3); color: white;';
const BLUE = 'background: linear-gradient(135deg, #409EFF, #66b3ff); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(64,158,255,.3); color: white;';
const GREY = 'background: linear-gradient(135deg, #909399, #b1b3b8); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(144,147,153,.3); color: white;';
const RED = 'background: linear-gradient(135deg, #f56c6c, #ff7979); border: none; border-radius: 6px; padding: 6px 16px; font-weight: 600; box-shadow: 0 3px 8px rgba(245,108,108,.3); color: white;';
const btn = (label, style, extra) =>
`<button class="el-button el-button--small" style="${style}${extra || ''}"><span class="buttonFont-small">${label}</span></button>`;
const COLS = ['序号', '状态', '到寓时间', '单位', '机车号', '姓名', '房间号', '出发车次',
'开车时间', '叫乘时间', '复叫时间', '叫乘日期', '应离寓时间', '接车方式', '操作'];
const ROWS = [
{ bg: '#F56C6C', st: 'jc-st-alarm', v: ['1', '叫乘报警', '11-08 21:40', '机务段', 'HXD3C-0131', '冯建国', '305', 'K671', '08:35', '06:35', '06:45', '11-09', '07:05', '站接'] },
{ bg: '#00FF00', st: 'jc-st-normal', v: ['2', '正在呼叫', '11-08 22:05', '机务段', 'HXD3C-0132', '蒋卫东', '517', 'T118', '09:10', '07:10', '07:20', '11-09', '07:40', '库出'] },
{ bg: '#FFF200', st: 'jc-st-normal', v: ['3', '待叫乘', '11-08 22:20', '客运段', 'HXD3C-0133', '李红军', '206', 'Z267', '10:05', '08:05', '08:15', '11-09', '08:35', '站接'] },
{ bg: '#B3E19D', st: 'jc-st-normal', v: ['4', '待离寓', '11-08 20:15', '机务段', 'HXD3C-0134', '王志强', '408', 'K42', '07:40', '05:40', '05:50', '11-09', '06:10', '库出'] },
{ bg: '', st: 'jc-st-muted', v: ['5', '已入寓', '11-09 01:10', '客运段', 'HXD3C-0135', '赵明亮', '612', 'K671', '14:20', '12:20', '12:30', '11-09', '12:50', '站接'] }
];
function makeBody() {
const search = ['机车号', '车次', '房间号', '姓名'].map((k, i) =>
`<div class="el-col el-col-6" style="padding-left:7.5px;padding-right:7.5px">
<div class="form-item-container"><label class="form-label">${k}</label>
<div class="el-input"><input class="el-input__inner" placeholder="请输入${k}" id="q${i}"></div></div></div>`).join('');
const topBtns = ['房间信息', '设备信息', '人员维护', '交路管理', '历史数据']
.map(x => btn(x, ORANGE, 'height:32px;')).join('');
const row2Btns = ['换乘', '出乘'].map(x => btn(x, TEAL, 'height:32px;width:84px;')).join('');
const row3Btns = ['语音临叫', '喊话临叫', '停止呼叫', '停止报警']
.map(x => btn(x, TEAL, 'height:32px;')).join('');
const ths = COLS.map(c => `<th class="el-table__cell"><div class="cell">${c}</div></th>`).join('');
const trs = ROWS.map((r, ri) => {
const tds = r.v.map((v, ci) => {
let inner = v;
if (ci === 1) inner = `<span class="jc-st ${r.st}">${v}</span>`;
return `<td class="el-table__cell"><div class="cell">${inner}</div></td>`;
});
tds.push(`<td class="el-table__cell"><div class="cell">${btn('暂停报警', BLUE, 'height:28px;')}</div></td>`);
return `<tr class="el-table__row${ri === 0 ? ' current-row' : ''}" style="background-color:${r.bg};font-size:20px;font-weight:600">${tds.join('')}</tr>`;
}).join('');
return `<div class="app_contianer jc-opt-root" id="app">
<div class="el-card"><div class="el-card__body">
<div class="el-row jc-header-row" style="height:150px;margin-left:-7.5px;margin-right:-7.5px">
<div class="el-col el-col-9 jc-header-search" style="height:100%;display:flex;flex-direction:column;justify-content:center;padding-left:7.5px;padding-right:7.5px">
<div class="el-row top-left-marquee-row" style="height:30%;margin-top:40px">
<div class="marquee-container" style="background-color:rgb(2,145,145);height:100%">
<div class="marquee-content"><span>305 房间叫乘时间已过,请值班员关注</span><span class="marquee-repeat-text">305 房间叫乘时间已过,请值班员关注</span></div>
</div>
</div>
<div class="top-left-search-area" style="flex:1;display:flex;flex-direction:column;justify-content:center;padding:10px 20px">
<div class="el-row top-left-input-row" style="margin-bottom:15px">${search}</div>
<div class="el-row top-left-action-row">
<div class="el-col el-col-24" style="display:flex;justify-content:space-between;align-items:center">
${btn('取消叫乘', ORANGE)}
<div style="display:flex;align-items:center;gap:12px">${btn('查询', BLUE)}${btn('重置', GREY)}</div>
</div>
</div>
</div>
</div>
<div class="el-col el-col-6 jc-header-time" style="height:100%;padding-left:7.5px;padding-right:7.5px">
<div class="el-row"><div class="center-time-section"><div class="date-time">
<p class="text-24 text-white">2026-11-09 星期一<br>09:03:21</p>
</div></div></div>
</div>
<div class="el-col el-col-9 jc-header-actions" style="height:100%;padding-left:7.5px;padding-right:7.5px">
<div class="el-row" style="height:50%;padding:8px 20px 8px 0px;display:flex;align-items:center">
<div class="el-col el-col-18"><div style="display:flex;gap:5px;justify-content:space-between;align-items:center">${topBtns}</div></div>
<div class="el-col el-col-1"></div>
<div class="el-col el-col-5"><div style="display:flex;align-items:center;gap:3px">
<span class="jc-user-label" style="font-size:12px;color:#666;width:45%">当前用户: 张值班</span>
${btn('退出', RED, 'height:32px;width:55%;')}
</div></div>
</div>
<div class="el-row" style="height:50%;padding:8px 20px 8px 0px;display:flex;align-items:center">
<div class="el-col el-col-7"><div style="display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-start">${row2Btns}</div></div>
<div class="el-col el-col-17"><div style="display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-start;margin-left:14px">${row3Btns}</div></div>
</div>
</div>
</div>
</div></div>
<div class="el-card" style="margin-top:10px"><div class="el-card__body">
<div class="el-tabs el-tabs--card"><div class="el-tabs__header is-top"><div class="el-tabs__nav-wrap"><div class="el-tabs__nav-scroll">
<div class="el-tabs__nav is-top"><div class="el-tabs__item is-top is-active">机务人员和客运人员</div><div class="el-tabs__item is-top">其他人员</div></div>
</div></div></div></div>
<div class="el-table main-jcxx-table el-table--border" style="width:100%;height:600px">
<div class="el-table__header-wrapper"><table class="el-table__header" style="width:100%"><thead><tr style="height:54px">${ths}</tr></thead></table></div>
<div class="el-table__body-wrapper" style="height:546px"><table class="el-table__body" style="width:100%"><tbody>${trs}</tbody></table></div>
</div>
</div></div>
<div class="bottom-duty-bar">
<div class="bottom-stay-summary"><span>在寓班次:14</span><span>在寓人次:30</span></div>
<div class="latest-sync-time" role="status">最新同步时间:09:03:18</div>
<div class="bottom-duty-info">
${btn('交接班', ORANGE, 'position:relative;right:20px;top:2px;')}
<span style="font-size:26px;margin-right:80px;position:relative;top:6px">值班员:张值班</span>
</div>
</div>
<div class="bottom-right-div" id="notice">有新的交路信息,请及时处理!</div>
<!-- 宽弹窗骨架:默认隐藏,验收时按需显示以测量视口约束 -->
<div class="el-dialog__wrapper jc-dialog jc-wide-dialog" id="wideWrap" style="display:none">
<div class="el-dialog" style="width:1800px"><div class="el-dialog__header"><span class="el-dialog__title">历史叫乘信息</span></div>
<div class="el-dialog__body"><div class="el-form history-jcxx-form"><div class="history-jcxx-form-row">
<div class="history-jcxx-field-box"><label class="history-jcxx-inline-label">机车号</label><div class="el-input"><input class="el-input__inner"></div></div>
<div class="history-jcxx-field-box"><label class="history-jcxx-inline-label">车次</label><div class="el-input"><input class="el-input__inner"></div></div>
</div></div></div>
</div>
</div>
</div>`;
}
function makeHtml(css, label) {
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
<title>JcXx ${label}</title>
<style>${elementCss}</style>
<style>${css}</style>
<style>body{margin:0;background:#f3f6f5}#app{font-family:"Helvetica Neue",Helvetica,"PingFang SC","Microsoft YaHei",Arial,sans-serif}
/* 快照专用:模拟 el-table 运行时的内部横向滚动,避免 15 列被静态表格压扁 */
.main-jcxx-table .el-table__header-wrapper,
.main-jcxx-table .el-table__body-wrapper{overflow-x:auto}
.main-jcxx-table table{min-width:1500px}
</style>
</head><body>${makeBody()}</body></html>`;
}
const pb = path.join(__dirname, 'preview-before.html');
const pa = path.join(__dirname, 'preview-after.html');
fs.writeFileSync(pb, makeHtml(cssB, '优化前'), 'utf8');
fs.writeFileSync(pa, makeHtml(cssA, '优化后'), 'utf8');
const URLB = require('url').pathToFileURL(pb).href;
const URLA = require('url').pathToFileURL(pa).href;
// ---------------------------------------------------------------- 4. 多视口断言
const VIEWPORTS = [
{ w: 1920, h: 1080 }, { w: 1440, h: 900 }, { w: 1366, h: 768 },
{ w: 1280, h: 800 }, { w: 1024, h: 768 }
];
const probe = () => {
const R = s => {
const e = document.querySelector(s);
if (!e) return null;
const b = e.getBoundingClientRect();
return { t: Math.round(b.top), b: Math.round(b.bottom), l: Math.round(b.left), r: Math.round(b.right), w: Math.round(b.width), h: Math.round(b.height) };
};
const root = document.querySelector('#app');
const bar = R('.bottom-duty-bar');
const wrap = document.querySelector('#wideWrap');
wrap.style.display = 'block';
const dlg = R('.jc-wide-dialog .el-dialog');
wrap.style.display = 'none';
return {
vw: window.innerWidth, vh: window.innerHeight,
docW: document.documentElement.scrollWidth,
header: R('.jc-header-row'), search: R('.top-left-search-area'),
leftCol: R('.jc-header-search'), time: R('.center-time-section'),
actions: R('.jc-header-actions'), topBtns: R('.jc-header-actions .el-col-18 > div'),
bar, left: R('.bottom-stay-summary'), mid: R('.latest-sync-time'), right: R('.bottom-duty-info'),
notice: R('.bottom-right-div'), table: R('.main-jcxx-table'),
dlg,
paddingBottom: parseFloat(getComputedStyle(root).paddingBottom) || 0,
headerH: Math.round(document.querySelector('.jc-header-row').getBoundingClientRect().height)
};
};
(async () => {
const browser = await chromium.launch({ executablePath: EXE, headless: true });
const ctx = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
const beforeIssues = {}, afterIssues = {};
for (const v of VIEWPORTS) {
const tag = `${v.w}x${v.h}`;
for (const [kind, url, bucket] of [['before', URLB, beforeIssues], ['after', URLA, afterIssues]]) {
const p = await ctx.newPage();
await p.setViewportSize({ width: v.w, height: v.h });
const errs = [];
p.on('pageerror', e => errs.push(e.message));
await p.goto(url);
await p.waitForTimeout(120);
const m = await p.evaluate(probe);
const issues = [];
if (m.docW > m.vw + 1) issues.push('横向溢出 ' + m.docW + '>' + m.vw);
if (m.search.b > m.leftCol.b + 2) issues.push('顶部查询区被裁切');
if (m.time.r > m.vw + 1) issues.push('时间卡越界');
if (m.topBtns.r > m.actions.r + 2) issues.push('右上按钮组越界');
if (m.left.r > m.mid.l + 2 || m.mid.r > m.right.l + 2) issues.push('吸底三段重叠');
if (m.notice.b > m.bar.t + 2) issues.push('右下提示被吸底条压住');
if (m.paddingBottom < 50) issues.push('底部无留白内容被遮挡');
if (m.dlg.r > m.vw + 1) issues.push('宽弹窗横向越界');
if (errs.length) issues.push('JS 错误 ' + errs.join('|'));
bucket[tag] = issues;
const shot = path.join(UI, `JcXx-${kind === 'before' ? '优化前' : '优化后'}-${tag}.png`);
await p.screenshot({ path: shot, fullPage: false });
await p.close();
}
T(`LAY-${tag} 优化后无横向溢出/裁切/重叠/遮挡/弹窗越界`,
afterIssues[tag].length === 0, JSON.stringify(afterIssues[tag]));
results.push(` └ 优化前同视口问题:${beforeIssues[tag].length ? beforeIssues[tag].join(';') : '无'}`);
}
// ------------------------------------------------------------ 交互与状态反馈
const p = await ctx.newPage();
await p.setViewportSize({ width: 1920, height: 1080 });
await p.goto(URLA);
await p.waitForTimeout(120);
await p.hover('.main-jcxx-table .el-table__body tr:nth-child(3) td');
const hoverAfter = await p.evaluate(() =>
getComputedStyle(document.querySelector('.main-jcxx-table .el-table__body tr:nth-child(3) td')).backgroundImage);
T('FB-01 表格行 hover 有可见反馈(保留行内状态底色)',
hoverAfter !== 'none' && hoverAfter.includes('gradient'), hoverAfter);
const current = await p.evaluate(() => {
const td = document.querySelector('.main-jcxx-table tr.current-row > td');
const cs = getComputedStyle(td);
return { bg: cs.backgroundColor, img: cs.backgroundImage, trBg: td.parentElement.style.backgroundColor };
});
T('FB-02 选中行不再用纯蓝底抹掉报警行状态色',
current.bg !== 'rgb(25, 144, 241)' && current.img.includes('gradient'),
JSON.stringify(current));
const grad = await p.evaluate(() => {
const b = document.querySelector('.top-left-action-row .el-button');
return getComputedStyle(b).backgroundImage;
});
await p.hover('.top-left-action-row .el-button');
const gradHover = await p.evaluate(() =>
getComputedStyle(document.querySelector('.top-left-action-row .el-button')).backgroundImage);
T('FB-03 橙色按钮 hover 不再被强制刷成蓝色渐变',
gradHover === grad && gradHover.includes('230, 162, 60'), gradHover);
const shadowBefore = await p.evaluate(() =>
getComputedStyle(document.querySelector('#q0')).boxShadow);
await p.focus('#q0');
await p.waitForTimeout(160); // headless 下 :focus 样式重算 + .3s 过渡起效
const focusFeedback = await p.evaluate(() => {
void document.body.offsetHeight; // 强制同步重排后再取计算样式
const c = getComputedStyle(document.querySelector('#q0'));
return { shadow: c.boxShadow, outlineStyle: c.outlineStyle };
});
T('FB-04 键盘焦点产生可见反馈(光晕变化 + 轮廓)',
focusFeedback.shadow !== shadowBefore && focusFeedback.outlineStyle !== 'none',
JSON.stringify({ before: shadowBefore, after: focusFeedback }));
const stateWeight = await p.evaluate(() =>
getComputedStyle(document.querySelector('.main-jcxx-table .jc-st-alarm')).fontWeight);
T('FB-05 报警状态文字权重高于普通单元格', Number(stateWeight) >= 700, stateWeight);
const noticeCursor = await p.evaluate(() =>
getComputedStyle(document.querySelector('#notice')).cursor);
T('FB-06 右下角提示有可点击指针反馈', noticeCursor === 'pointer', noticeCursor);
T('FB-07 优化层含 prefers-reduced-motion 降级', cssA.includes('prefers-reduced-motion'));
await p.close();
await browser.close();
console.log('\n================ JcXx 优化版验收 ================');
console.log(results.join('\n'));
console.log('--------------------------------------------------');
const bTotal = Object.values(beforeIssues).reduce((n, x) => n + x.length, 0);
const aTotal = Object.values(afterIssues).reduce((n, x) => n + x.length, 0);
console.log(`优化前 5 个视口累计布局问题:${bTotal} 项`);
console.log(`优化后 5 个视口累计布局问题:${aTotal} 项`);
console.log(`断言:${pass}/${pass + fail} 通过`);
console.log(`截图:${VIEWPORTS.length * 2} 张 -> ${UI}`);
console.log('==================================================');
if (fail) process.exit(1);
})().catch(e => { console.error(e); process.exit(1); });
Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB