prototype generated

This commit is contained in:
2026-08-13 08:51:04 +08:00
parent 2dbf1f7952
commit 64a27a20c4
83 changed files with 6112 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
name = "luna"
description = "低成本实现智能体,负责需求明确的功能、测试、局部重构和明确缺陷修复。"
model = "gpt-5.6-luna"
model_reasoning_effort = "max"
sandbox_mode = "workspace-write"
developer_instructions = """
只有在主智能体已经明确目标、涉及文件或子系统、约束、验收标准和验证目标后,才作为实现执行者开展工作。
负责边界清晰的功能实现、单元测试补齐、局部重构、机械性修改、明确缺陷修复和局部代码审查。
保持上下文精简,严格遵循既定方案;未经明确授权不得修改接口或数据结构,并始终实施最小且正确的改动。
使用针对性测试验证要求的行为,返回简洁总结,不要堆积原始探索日志。
不得负责开放式根因分析、仅在生产环境出现的疑难问题、架构审查,或需要在多个子系统间持续演进假设的调试工作。
需求含糊、证据与既定方案冲突或工作范围扩展到多个模块时,必须停止并向主智能体报告阻碍;应建议交由高级执行智能体处理,不得继续猜测性探索。
代码库中还有其他协作者:不得撤销他人的修改,并应主动适配并行产生的变更。
"""
+14
View File
@@ -0,0 +1,14 @@
name = "spark"
description = "快速探索与微小修改智能体,负责代码搜索、结构定位、简单验证和边界明确的小改动。"
model = "gpt-5.3-codex-spark"
model_reasoning_effort = "high"
sandbox_mode = "workspace-write"
developer_instructions = """
优先快速完成代码搜索、仓库结构定位、符号与调用点查找、简单验证以及明确的微小修改。
只进行有针对性的搜索和文件读取,返回提炼后的结论,不要堆积原始日志。
修改代码时只做能够成立的最小改动,避免无关重构和大范围清理。
使用范围最小且有效的测试、类型检查、静态检查或构建命令,只验证本次修改影响的行为。
不得负责模糊的根因调查、跨模块实现或架构决策。
任务超出小而明确的范围时必须停止,将证据上报主智能体,并建议交由高级执行智能体继续处理。
代码库中还有其他协作者:不得撤销他人的修改,并应主动适配并行产生的变更。
"""
+14
View File
@@ -0,0 +1,14 @@
name = "terra"
description = "高级执行智能体,负责复杂修改、跨模块调试和深度代码审查。"
model = "gpt-5.6-terra"
model_reasoning_effort = "high"
sandbox_mode = "workspace-write"
developer_instructions = """
作为主智能体下属的高级工程执行者开展工作。
负责复杂实现、跨模块修改、需要持续修正假设的调试,以及从调查到验证的深度正确性审查。
在多次工具调用中持续维护任务的系统约束,追踪真实执行路径,并明确区分证据与假设。
优先采用能够解决根因的最小可维护方案,保持无关行为不变,并按需新增或更新针对性测试。
执行与风险相称的检查,返回便于决策的简洁总结,包括根因、修改文件、验证结果和剩余风险。
涉及架构、产品范围或不可逆设计决策时,必须上报主智能体,不得擅自决定。
代码库中还有其他协作者:不得撤销他人的修改,并应主动适配并行产生的变更。
"""
+6
View File
@@ -61,6 +61,9 @@ cover/
local_settings.py
db.sqlite3
db.sqlite3-journal
deprecated/backend/risk.db
deprecated/backend/risk.db.*.bak
deprecated/backend/tests/test-risk.db
# Flask stuff:
instance/
@@ -217,6 +220,7 @@ build/Release
# Dependency directories
node_modules/
.logs/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
@@ -312,3 +316,5 @@ dist
.yarn/install-state.gz
.pnp.*
# project
deprecated/
+73
View File
@@ -0,0 +1,73 @@
# 项目协作原则
## 核心原则
- 保持简单:优先选择最小、清晰、可维护的方案。
- 先读后改:以实际代码、配置和文档为依据,不凭经验猜测。
- 聚焦目标:不做无关重构,不扩大任务范围。
- 保持兼容:未经确认,不改变公开接口、数据结构、认证方式或既有行为。
- 如实验证:没有实际执行的检查,不得声称通过。
- 默认使用中文编写说明和项目文档;代码标识符遵循技术规范。
## 项目现状
- 本项目是风险防控管理系统,目前处于需求与方案整理阶段。
- `deprecated/docs/前期讨论/` 仅作为参考输入,不代表最终需求。
- 技术栈尚未确定,不得臆造目录、依赖、构建或测试命令。
## 智能体分工
- 主智能体:理解需求、制定方案、拆分任务、集成结果和最终审查。
- `spark`:代码搜索、结构定位、简单验证和微小修改。
- `luna`:执行目标、范围和验收标准均已明确的局部实现与测试。
- `terra`:复杂实现、跨模块调试、深度审查和根因分析。
- 升级顺序:`spark``luna``terra` → 主智能体。
## 协作规则
- 简单任务由主智能体直接完成;仅在子任务独立且可验证时使用多个智能体。
- 委派时必须说明目标、负责范围、禁止修改范围、验收标准和验证方式。
- 并行修改必须划分文件或模块所有权,不得让两个智能体同时修改同一处代码。
- 不得撤销、覆盖或整理用户及其他智能体的修改。
- 子智能体发现任务超出职责时立即上报,不得继续猜测性探索。
- 主智能体负责审查子智能体的修改和验证证据。
## 开发要求
- 修改前检查仓库状态并阅读相关文件。
- 优先复用现有模式,实施能够解决问题的最小改动。
- 未经允许,不新增生产依赖,不修改数据库结构,不扩大权限。
- 修复缺陷应处理根因,并补充必要的回归验证。
- 涉及风险状态流转时,检查权限、审计、幂等、并发、回滚和非法状态跃迁。
- 不得泄露或提交密码、令牌、个人敏感信息和内部安全数据。
- 对于Windows系统,必须在WSL环境中进行开发。中间件推荐使用WSL中的docker运行。Python使用WSL中的venv虚拟环境进行开发与测试。
- 在尚未完成1.0版本发布之前,不需要考虑数据库迁移问题,直接重构数据库即可。
## 验证与交付
- 从项目实际配置中识别测试、检查和构建命令。
- 先运行与修改直接相关的最小验证,再按风险扩大范围。
- 无法验证时,说明原因和剩余风险。
- 最终说明应包含:完成内容、修改文件、验证结果和未解决问题。
- 未经用户明确要求,不创建分支、不提交、不推送。
## 完成标准
- 用户要求已经满足。
- 没有无关修改或临时内容。
- 相关验证已通过,或限制与风险已明确说明。
- 主智能体已审查最终差异。
## Agent skills
### Issue tracker
本仓库使用 `.scratch/<feature>/` 下的本地 Markdown 文件管理需求、规格和任务。详见 `docs/agents/issue-tracker.md`
### Triage labels
本仓库使用五类默认 triage 状态:`needs-triage``needs-info``ready-for-agent``ready-for-human``wontfix`。详见 `docs/agents/triage-labels.md`
### Domain docs
本仓库采用单上下文领域文档结构:根目录使用 `CONTEXT.md`,架构决策记录放置于 `docs/adr/`。详见 `docs/agents/domain.md`
+51
View File
@@ -0,0 +1,51 @@
# 风险防控工单域
本上下文统一 12398 风险防控工单在派工、调查、审核、回访和报告环节中的业务语言,避免将角色、责任类型与流程状态混用。
## Language
**12398 工单**:
由 12398 渠道取得并导入系统、关联 95598 处理记录的核心业务对象。
_Avoid_: 投诉单、事件单、任务单
**服务监督主管**:
负责判定责任类型并完成主管派工的业务角色,也是全面调查的一级审核人。
_Avoid_: 主管、监督主管
**服务监督员**:
派工后承担全面调查、回访结果确认、调查报告和成果物输出的业务角色。
_Avoid_: 服务监督人员、调查员
**责任类型**:
服务监督主管在派工阶段对工单作出的主责或协查判定;该判定是后续前置条件和审核分支的业务事实。
_Avoid_: 责任状态、工单类型
**主责**:
需要同时完成首联和全部并行初查,业务部主任审核通过后进入回访的责任类型。
**协查**:
不产生首联任务、派工后直接进入多部门并行事件初查,业务部主任审核通过后直接进入调查报告的责任类型。
**首联**:
主责工单中客服班组首次联系客户形成的业务记录。
_Avoid_: 首次回访、回访
**事件初查**:
一个或多个被派部门并行完成的初步调查任务集合。
_Avoid_: 全面调查、调查结果
**全面调查**:
服务监督员在派工后即可先行填报并保存草稿、满足责任类型对应前置条件后才能提交的完整调查结果。
_Avoid_: 事件初查、业务审核
**业务部主任审核**:
全面调查的二级审核;审核通过后依据派工阶段的责任类型创建唯一后续分支。
_Avoid_: 主管审核、调查审核
**回访**:
仅主责工单进入的客户联系与结果确认环节;结果使用“同意”或“不同意或客户主动要求”。
_Avoid_: 首联、满意度调查
**调查报告**:
基于已确认调查数据及主责回访数据形成的受控输出版本;协查报告不依赖首联或回访。
_Avoid_: 全面调查、成果物
+5
View File
@@ -1,2 +1,7 @@
# risk-management-process
## 工作说明
当前项目聚焦于 `frontend/`:直接依据原型界面设计复现前端,由外部团队实现后端,本项目负责接口规范设计、前端界面实现和前后端联调。
此前探索的完整系统实现、需求方案文档和构建产物已迁移至 `deprecated/`,仅作历史参考,不再作为当前实现依据。
+41
View File
@@ -0,0 +1,41 @@
# 领域文档
本文件说明工程技能在分析和修改仓库前,应如何读取领域文档。
## 开始分析前
按需读取:
- 根目录的 `CONTEXT.md`
- 如果将来出现 `CONTEXT-MAP.md`,由它定位当前主题对应的领域上下文
- `docs/adr/` 中与当前修改相关的架构决策记录
文件不存在时静默继续,不需要预先创建空文件。领域词汇和架构决策应在实际形成结论时按需补充。
## 当前布局
本仓库采用单上下文结构:
```text
/
├── CONTEXT.md
└── docs/
└── adr/
```
`CONTEXT.md` 只保存领域术语和统一语言;`docs/adr/` 保存具有长期影响的架构决策。
## 使用统一领域语言
Issue 标题、规格、重构建议、测试名称和实现说明应使用 `CONTEXT.md` 中定义的术语,不应改用其中明确要求避免的同义词。
如果需要使用的概念尚未出现在词汇表中,应先判断:
1. 是否正在引入项目并不存在的新术语;
2. 是否确实发现领域词汇缺口。
真实的领域词汇缺口应通过领域建模流程补充。
## 发现 ADR 冲突时
如果计划或实现与现有 ADR 冲突,必须明确指出冲突及理由,不得静默覆盖既有决策。
+32
View File
@@ -0,0 +1,32 @@
# Issue tracker:本地 Markdown
本仓库的需求、规格和实施任务使用 `.scratch/` 下的 Markdown 文件管理。
## 约定
- 每个功能使用独立目录:`.scratch/<feature-slug>/`
- 功能规格为:`.scratch/<feature-slug>/spec.md`
- 实施任务存放于:`.scratch/<feature-slug>/issues/<NN>-<slug>.md`
- 任务从 `01` 开始编号,每个任务使用独立文件,不创建合并的 tickets 文件
- Triage 状态记录在任务文件顶部附近的 `Status:`
- 评论和讨论记录追加到文件底部的 `## Comments` 小节
## 发布到 Issue Tracker
当技能要求“发布到 Issue Tracker”时,在 `.scratch/<feature-slug>/` 下创建对应文件;目录不存在时可以创建。
## 获取任务
当技能要求获取相关任务时,读取用户提供的任务路径或任务编号对应的文件。
## Wayfinding 约定
- 地图:`.scratch/<effort>/map.md`
- 子任务:`.scratch/<effort>/issues/NN-<slug>.md`
- 子任务通过 `Type:` 记录类型:`research``prototype``grilling``task`
- 子任务通过 `Status:` 记录 `claimed``resolved`
- 依赖关系使用 `Blocked by: NN, NN`
- 所有依赖任务为 `resolved` 后,当前任务才解除阻塞
- 可执行任务按编号顺序领取
- 领取时先将状态设为 `claimed` 并保存
- 完成时在 `## Answer` 下记录结果,将状态改为 `resolved`,并向 `map.md` 的 Decisions-so-far 添加摘要和链接
+13
View File
@@ -0,0 +1,13 @@
# Triage Labels
工程技能使用五类标准 triage 角色。本文件记录标准角色与本仓库实际状态字符串的对应关系。
| 标准角色 | 本仓库状态 | 含义 |
| --- | --- | --- |
| `needs-triage` | `needs-triage` | 等待维护者评估 |
| `needs-info` | `needs-info` | 等待报告人补充信息 |
| `ready-for-agent` | `ready-for-agent` | 需求完整,可由智能体独立实施 |
| `ready-for-human` | `ready-for-human` | 需要人工实施或决策 |
| `wontfix` | `wontfix` | 确定不处理 |
当技能使用某类 triage 角色时,应采用本表右侧对应的状态字符串。
@@ -0,0 +1,294 @@
# 标题:风险防控管理系统_接口文档
> 版本:版本号:3.9.2
> 服务地址:http://localhost:8080
> 生成说明:基于 Swagger OpenAPI 3.1 自动提取(规范共 155 个接口路径,本文档仅整理以下 7 个目标接口)。该文档由后端团队提供,仅提供基础用户登录相关API。其他API尚待设计。
## 认证方式
- 方案 `apikey`:请求头 `Authorization: Bearer <token>`scheme=Bearer
- 除「验证码」「用户登录」外,其它接口均需在请求头携带该令牌。
## 通用返回结构说明
本项目所有业务接口统一返回 `AjaxResult`(列表接口返回 `TableDataInfo`)。
> 注意:本 Swagger 规范中 `AjaxResult` 仅声明了 `error/success/warn/empty` 四个布尔字段,
> 未建模真实的 `code / msg / data` 字段。以下为运行时实际返回结构,编写调用代码请以实际返回为准。
**AjaxResult(单对象 / 操作结果):**
```json
{
"code": 200,
"msg": "操作成功",
"data": {}
}
```
**TableDataInfo(分页列表):**
```json
{
"total": 0,
"rows": [
{}
],
"code": 200,
"msg": "查询成功"
}
```
- `code`:状态码(200 成功,其它为错误码)
- `msg`:提示信息
- `data`:业务数据对象(列表接口的 `rows` 为该对象数组)
## 接口总览
| 序号 | 接口 | 方法 | 路径 | 分组 |
| --- | --- | --- | --- | --- |
| 1 | 用户列表 | GET | `/system/user/list` | 系统管理 |
| 2 | 部门树 | GET | `/system/user/deptTree` | 系统管理 |
| 3 | 用户信息-任意人员 | GET | `/system/user/{userId}` | 系统管理 |
| 4 | 用户信息-个人信息 | GET | `/system/user/profile` | 系统管理 |
| 5 | 修改密码 | PUT | `/system/user/profile/updatePwd` | 系统管理 |
| 6 | 验证码 | GET | `/captchaImage` | 公共 |
| 7 | 用户登陆 | POST | `/login` | 公共 |
## 1. 用户列表
- **路径**`GET /system/user/list`
- **分组(Tag**sys-user-controller
### 请求参数
查询通过对象参数 `user`(类型 `SysUser`,必填:是)绑定,以下字段以同名 `query` 参数传入:
| 字段(参数名) | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| createBy | string | 否 | |
| createTime | string | 否 | |
| updateBy | string | 否 | |
| updateTime | string | 否 | |
| remark | string | 否 | |
| params | object | 否 | |
| userId | integer(int64) | 否 | |
| deptId | integer(int64) | 否 | |
| userName | string | 否 | |
| nickName | string | 否 | |
| email | string | 否 | |
| phonenumber | string | 否 | |
| sex | string | 否 | |
| avatar | string | 否 | |
| password | string | 否 | |
| status | string | 否 | |
| delFlag | string | 否 | |
| loginIp | string | 否 | |
| loginDate | string | 否 | |
| pwdUpdateDate | string | 否 | |
| dept | SysDept | 否 | |
| roles | array<SysRole> | 否 | |
| roleIds | array<integer> | 否 | |
| postIds | array<integer> | 否 | |
| roleId | integer(int64) | 否 | |
| admin | boolean | 否 | |
> 分页参数 `pageNum`、`pageSize` 由分页拦截器自动读取(不体现在方法签名中),示例:`?pageNum=1&pageSize=10`。
### 响应(HTTP 200
响应结构(`TableDataInfo`)字段(按本规范建模):
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| total | integer(int64) | 否 | |
| rows | array<object> | 否 | |
| code | integer(int32) | 否 | |
| msg | string | 否 | |
> 实际返回结构见上文「通用返回结构说明」(`code/msg/data` 等字段未在规范中建模)。
> 业务数据说明:`rows` 为 `SysUser` 对象数组,`total` 为总记录数。
---
## 2. 部门树
- **路径**`GET /system/user/deptTree`
- **分组(Tag**sys-user-controller
### 请求参数
查询通过对象参数 `dept`(类型 `SysDept`,必填:是)绑定,以下字段以同名 `query` 参数传入:
| 字段(参数名) | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| createBy | string | 否 | |
| createTime | string | 否 | |
| updateBy | string | 否 | |
| updateTime | string | 否 | |
| remark | string | 否 | |
| params | object | 否 | |
| deptId | integer(int64) | 否 | |
| parentId | integer(int64) | 否 | |
| ancestors | string | 否 | |
| deptLevel | integer(int32) | 否 | |
| deptName | string | 否 | |
| orderNum | integer(int32) | 否 | |
| leader | string | 否 | |
| phone | string | 否 | |
| email | string | 否 | |
| deptType | string | 否 | |
| status | string | 否 | |
| delFlag | string | 否 | |
| parentName | string | 否 | |
| children | array<SysDept> | 否 | |
### 响应(HTTP 200
响应结构(`AjaxResult`)字段(按本规范建模):
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| error | boolean | 否 | |
| success | boolean | 否 | |
| warn | boolean | 否 | |
| empty | boolean | 否 | |
> 实际返回结构见上文「通用返回结构说明」(`code/msg/data` 等字段未在规范中建模)。
---
## 3. 用户信息-任意人员
- **路径**`GET /system/user/{userId}`
- **分组(Tag**sys-user-controller
### 请求参数
- `userId`path,类型 `integer(int64)`,必填:是):
### 响应(HTTP 200
响应结构(`AjaxResult`)字段(按本规范建模):
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| error | boolean | 否 | |
| success | boolean | 否 | |
| warn | boolean | 否 | |
| empty | boolean | 否 | |
> 实际返回结构见上文「通用返回结构说明」(`code/msg/data` 等字段未在规范中建模)。
> 业务数据说明:`data` 返回指定 userId 对应用户的 `SysUser` 信息对象。
> 备注:路径变量在本文档中命名为 `userId`,即需求中的 `{id}`(按用户 ID 查询任意人员信息)。
---
## 4. 用户信息-个人信息
- **路径**`GET /system/user/profile`
- **分组(Tag**sys-profile-controller
### 响应(HTTP 200
响应结构(`AjaxResult`)字段(按本规范建模):
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| error | boolean | 否 | |
| success | boolean | 否 | |
| warn | boolean | 否 | |
| empty | boolean | 否 | |
> 实际返回结构见上文「通用返回结构说明」(`code/msg/data` 等字段未在规范中建模)。
> 业务数据说明:`data` 返回当前登录用户的 `SysUser` 信息对象。
---
## 5. 修改密码
- **路径**`PUT /system/user/profile/updatePwd`
- **分组(Tag**sys-profile-controller
### 请求体(application/json
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| oldPassword | string | 是 | 原密码 |
| newPassword | string | 是 | 新密码 |
> 注:OpenAPI 中该请求体被建模为自由对象(`Map<String,String>`),上表为实际使用的字段。
### 响应(HTTP 200
响应结构(`AjaxResult`)字段(按本规范建模):
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| error | boolean | 否 | |
| success | boolean | 否 | |
| warn | boolean | 否 | |
| empty | boolean | 否 | |
> 实际返回结构见上文「通用返回结构说明」(`code/msg/data` 等字段未在规范中建模)。
---
## 6. 验证码
- **路径**`GET /captchaImage`
- **分组(Tag**captcha-controller
### 响应(HTTP 200
响应结构(`AjaxResult`)字段(按本规范建模):
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| error | boolean | 否 | |
| success | boolean | 否 | |
| warn | boolean | 否 | |
| empty | boolean | 否 | |
> 实际返回结构见上文「通用返回结构说明」(`code/msg/data` 等字段未在规范中建模)。
> 业务数据说明:成功时 `data` 返回 `{"uuid":"...","img":"data:image/png;base64,..."}``uuid` 与 `img` 用于登录时校验验证码。
---
## 7. 用户登陆
- **路径**`POST /login`
- **分组(Tag**sys-login-controller
### 请求体(application/json
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| username | string | 是 | 用户名 |
| password | string | 是 | 密码 |
| code | string | 是 | 验证码(由 /captchaImage 返回) |
| uuid | string | 是 | 验证码唯一标识(由 /captchaImage 返回) |
> 注:OpenAPI 中该请求体被建模为自由对象(`Map<String,String>`),上表为实际使用的字段。
### 响应(HTTP 200
响应结构(`AjaxResult`)字段(按本规范建模):
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| error | boolean | 否 | |
| success | boolean | 否 | |
| warn | boolean | 否 | |
| empty | boolean | 否 | |
> 实际返回结构见上文「通用返回结构说明」(`code/msg/data` 等字段未在规范中建模)。
> 业务数据说明:登录成功时 `data` 返回 `"token"` 字符串,后续请求须在请求头 `Authorization: Bearer <token>` 中携带。
---
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
fund=false
audit=false
+4
View File
@@ -0,0 +1,4 @@
{
"d1": null,
"r2": null
}
+9
View File
@@ -0,0 +1,9 @@
# Prototype Instructions
Run the local server yourself and open the preview in the browser available to this environment. Do not give the user server-start instructions when you can run it.
Before making substantial visual changes, use the Product Design plugin's `get-context` skill when the visual source is unclear or no longer matches the current goal. When the user gives durable prototype-specific design feedback, preferences, or decisions, record them in `AGENTS.md`.
When implementing from a selected generated mock, treat that image as the source of truth for layout, component anatomy, density, spacing, color, typography, visible content, and hierarchy.
Build app UI in `src/`. Keep `.openai/hosting.json`, `worker/index.js`, `scripts/prepare-sites-build.mjs`, and `tests/sites-worker.test.mjs` intact so the same local prototype can be handed to Sites. Before a Sites handoff, run `npm run build` and `npm run test:sites`; the build must leave `dist/client/index.html`, `dist/server/index.js`, and `dist/.openai/hosting.json`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

+84
View File
@@ -0,0 +1,84 @@
# 20260813 高保真修复设计 QA
## 登录后应用工作台
### 结论
基于用户选定并修订的第一套视觉方案,完成“风险防控处置系统”登录后应用工作台。页面已移除“全部应用”左侧分类导航,全部应用使用三列完整内容宽度;登录、应用搜索、未接入应用提示、进入 12398 工单系统及返回应用工作台均已验证。未发现 P0、P1 或 P2 问题。
### 比较基准
- 修订视觉稿:`design-qa/20260813-app-workbench/workbench-reference.png`
- 浏览器实现:`design-qa/20260813-app-workbench/workbench-1440x1024.png`
- 同屏对照:`design-qa/20260813-app-workbench/workbench-comparison.png`
- 对照视口:1440 × 1024,设备像素比 1。
### 设计与交互检查
- 系统名称统一为“风险防控处置系统”,顶部保留统一身份、角色、通知与设置入口。
- 门户层只承担应用选择:最近使用、应用搜索、全部应用和权限状态,不堆叠具体业务指标。
- 12398 工单系统为可用状态;风险事件管理、合规检查以“即将接入”呈现,点击提供明确反馈。
- 点击最近使用或全部应用中的 12398 工单系统可进入现有业务界面;点击业务系统左上角品牌可返回应用工作台。
- 视觉稿中的专用应用图标由项目现有 Ant Design Vue 图标替代,尺寸、层级与语义一致,属于可接受的 P3 差异。
### 工程验证
- `npm run build` 通过,Vite 转换 1616 个模块。
- `npm run test:sites` 通过,4 项测试全部成功。
## 结论
本轮以 `deprecated/build/20260812 prototype` 为最新视觉真值,针对主任务检查中确认的登录页、服务监督主管审核弹窗和客服部主管首联分派弹窗完成高保真修复。经同尺寸对照、浏览器交互检查、生产构建和现有流程测试复核,未发现遗留的 P0、P1 或 P2 问题。
## 比较基准
- 登录页:`../deprecated/build/20260812 prototype/preview/icons/页面-1-1-登录页2/登录页2@0.5x.png`
- 服务监督主管审核:`../deprecated/build/20260812 prototype/preview/icons/页面-1-4-服务监督主管-工单处理(审核弹窗)/服务监督主管-工单处理(审核弹窗)@0.5x.png`
- 客服部主管首联分派:`../deprecated/build/20260812 prototype/preview/icons/页面-1-32-客服部主管-待办工单-首联分派/客服部主管-待办工单-首联分派@0.5x.png`
- 浏览器终稿截图:`design-qa/20260813-fidelity-fix/10-login-fixed-final.png``design-qa/20260813-fidelity-fix/04-supervisor-review-fixed-final.png``design-qa/20260813-fidelity-fix/06-first-assignment-fixed-final.png`
- 同屏对照:`design-qa/20260813-fidelity-fix/login-comparison-final.png``design-qa/20260813-fidelity-fix/supervisor-review-comparison.png``design-qa/20260813-fidelity-fix/first-assignment-comparison.png`
## 尺寸与归一化
- 浏览器 CSS 视口:1920 × 1080。
- 原型基准图:960 × 540`@0.5x`)。
- 对照时将实现截图缩放至 960 × 540;比较图左侧为原型基准,右侧为实现终稿。
- 服务监督主管审核弹窗终稿:1080 × 731,顶部约 145;内部面板 1000 × 504。
- 首联分派弹窗终稿:1080 × 492,顶部 194;内部面板 1000 × 265。
## 本轮修复
1. 登录页
- 标题颜色顺序恢复为深蓝主标题、青绿色强调语。
- 放大主副标题并禁止不符合设计的换行,恢复原型的横向构图。
- 登录表单默认保持空白,移除原型中不存在的输入框前置图标。
- 校准登录卡片宽度、内边距、字段高度、验证码和按钮样式。
2. 服务监督主管审核弹窗
- 校准弹窗宽度、垂直位置、内面板高度、字段高度、驳回理由区域和底部按钮尺寸。
- 保持审核结果、驳回理由及回访分派的既有交互和条件显示。
3. 客服部主管首联分派弹窗
- 将弹窗整体上移 100px,使外框、内面板和操作按钮与原型基准对齐。
- 移动端断点取消位移,避免窄屏内容越界。
## 五项保真检查
- 字体与排版:登录标题、弹窗标题、字段标签和按钮层级与原型一致,无异常换行或截断。
- 间距与布局:三处重点页面的容器尺寸、垂直位置、内边距和按钮组已按同尺寸对照校准。
- 色彩:保持原型的深蓝标题、青绿色强调色、浅青弹窗背景、白色内容面板和灰色遮罩。
- 图片与图标:继续复用原型登录背景和既有 Ant Design 图标;登录输入框未保留基准图中不存在的装饰图标。
- 文案与内容:重点页面标题、字段、占位提示和按钮文案与 20260812 原型一致。
## 交互与工程验证
- 浏览器交互:登录、角色切换、首联分派筛选、首联分派弹窗打开、服务监督主管审核弹窗打开和取消均正常。
- 生产构建:`npm run build` 通过,Vite 共转换 1616 个模块。
- 流程测试:`npm run test:sites` 通过,4 项测试全部成功。
## 已确认的有意差异
- 派工主体仍为服务监督主管。
- 主责事件必须等待首联及全部初查完成;协查事件从主管判定和派工进入 04 多部门并行事件初查。
- 业务部主任审核通过后,主责进入回访分支,协查直接进入 11 调查报告编写。
- 以上差异来自已确认业务规则,优先级高于静态画板,不计入视觉缺陷,也未在本轮改动中回退。
final result: passed
Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 946 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 873 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 979 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 613 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 993 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>12398 工单系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1310
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build && node scripts/prepare-sites-build.mjs",
"preview": "vite preview",
"test:sites": "node --test tests/sites-worker.test.mjs"
},
"dependencies": {
"@ant-design/icons-vue": "7.0.1",
"@vitejs/plugin-vue": "6.0.1",
"vite": "6.4.2",
"vue": "3.5.24"
},
"devDependencies": {}
}
+62
View File
@@ -0,0 +1,62 @@
# 20260812 原型板覆盖矩阵
本矩阵以 `build/20260812 prototype/preview` 的 41 张画板为范围。弹窗态和同一业务页的条件状态复用 Vue 组件,但均可从对应角色工作台进入。
| 编号 | 最新原型板 | Vue 对应入口/状态 | 覆盖结果 |
| --- | --- | --- | --- |
| 1-1 | 登录页 | 登录页 | 已覆盖 |
| 1-2 | 工单导入预览 | 服务监督主管 → 工单导入 → 导入预览 | 已覆盖 |
| 1-3 | 工单导入明细 | 服务监督主管 → 工单导入 → 导入明细 | 已覆盖 |
| 1-4 | 服务监督主管审核弹窗 | 服务监督主管 → 待审核 → 审核 | 已覆盖;审核结果、驳回理由,主责时显示回访部门 |
| 1-5 | 服务监督主管待审核 | 服务监督主管 → 待审核统计卡 | 已覆盖 |
| 1-6 | 智能派工规则配置 | 规则配置 → 智能派工规则配置 | 已覆盖,保持原型空白内容区 |
| 1-7 | 流程时限管理 | 规则配置 → 流程时限管理 | 已覆盖 |
| 1-8 | 统计分析 | 统计分析 | 已覆盖,保持原型空白内容区 |
| 1-9 | 服务监督主管完整审核 | 审核页面状态 | 已覆盖 |
| 1-10 | 待分配派工列表 | 服务监督主管 → 待分配派工 | 已覆盖 |
| 1-11 | 待分派详情 | 待分配派工 → 派工 | 已覆盖;派工主体、主责/协查和前置条件保持业务修订 |
| 1-12 | 服务监督主管工单处理 | 服务监督主管 → 工单处理 | 已覆盖 |
| 1-13 | 调查结果填报(业务类型) | 服务监督人员 → 调查结果填报 → 处理 | 已覆盖 |
| 1-14 | 调查报告编写(不同意) | 服务监督人员 → 调查报告编写 → 选择“不同意或客户主动要求” | 已覆盖;内联显示答复书和送达凭证 |
| 1-15 | 调查报告编写(同意) | 服务监督人员 → 调查报告编写 → 选择“同意” | 已覆盖;无附件区,支持确认下载 |
| 1-16 | 成果物输出 | 服务监督人员 → 成果物输出 | 已覆盖 |
| 1-17 | 回访结果不同意上传 | 回访结果选择“不同意或客户主动要求” | 已覆盖;附件直接显示于表单,不再使用二次弹窗 |
| 1-18 | 业务部主任审核弹窗 | 业务部主任 → 待审核 → 审核 | 已覆盖;主责选择回访部门,协查直接进入 11 调查报告 |
| 1-19 | 业务部主任完整审核 | 业务部主任 → 待审核 → 工单审核状态 | 已覆盖 |
| 1-20 | 回访结果填写 | 服务监督人员 → 回访结果填报 → 处理 | 已覆盖;同意/不同意两种状态 |
| 1-21 | 调查结果填报 | 服务监督人员 → 调查结果填报 → 处理 | 已覆盖 |
| 1-22 | 服务监督人员待办 | 切换角色 → 服务监督人员 | 已覆盖 |
| 1-23 | 总值班经理初查 | 总值班经理 → 待调查 → 处理 | 已覆盖 |
| 1-24 | 智能运营团队初查 | 智能运营团队 → 待调查 → 处理 | 已覆盖 |
| 1-25 | 客服部主管待调研 | 客服部主管对应调查任务 | 已覆盖 |
| 1-26 | 客服部班长待办备份 | 客服部班长 → 待首联 | 已覆盖 |
| 1-27 | 业务部主任待办 | 切换角色 → 业务部主任 | 已覆盖;待审核/已完成两类统计 |
| 1-28 | 总值班经理待办 | 切换角色 → 总值班经理 | 已覆盖 |
| 1-29 | 智能运营团队待办 | 切换角色 → 智能运营团队 | 已覆盖 |
| 1-30 | 客服部班长待办 | 切换角色 → 客服部班长 | 已覆盖 |
| 1-31 | 客服部主管回访分派 | 客服部主管 → 回访分派 → 分派弹窗 | 已覆盖 |
| 1-32 | 客服部主管首联分派 | 客服部主管 → 首联分派 → 分派弹窗 | 已覆盖 |
| 1-33 | 客服部主管待办 | 切换角色 → 客服部主管 | 已覆盖;任务总数、首联分派、回访分派三类统计 |
| 1-34 | 业务部主任 | 业务部主任审核详情 | 已覆盖 |
| 1-35 | 总值班经理工单查询 | 总值班经理 → 工单查询 | 已覆盖 |
| 1-36 | 智能运营团队工单查询 | 智能运营团队 → 工单查询 | 已覆盖 |
| 1-37 | 客服部班长工单查询 | 客服部班长 → 工单查询 | 已覆盖 |
| 1-38 | 客服部主管工单查询 | 客服部主管 → 工单查询 | 已覆盖 |
| 1-39 | 服务监督人员工单查询 | 服务监督人员 → 工单查询 | 已覆盖 |
| 1-40 | 服务监督主管工单查询 | 服务监督主管 → 工单查询 | 已覆盖 |
| 1-41 | 工单导入入口 | 服务监督主管 → 工单导入 | 已覆盖 |
## 20260812 关键变化
- 审核弹窗统一为选择式审核结果、驳回理由和条件化回访部门;未选择审核结果不可提交。
- 业务部主任审核成为独立可操作弹窗;主责审核通过进入回访,协查审核通过直接进入 11 调查报告编写。
- 调查报告页不再提供自定义报告编辑器,改为展示调查及回访信息,并以“确认下载”完成报告输出。
- 回访结果统一为“同意 / 不同意或客户主动要求”;不同意时答复书和送达凭证在当前页面直接出现并执行双附件校验。
- 客服部主管待办统计调整为任务总数、首联分派、回访分派;两类分派弹窗均使用班组/人员单选。
## 保留的业务约束
- 派工主体为服务监督主管。
- 主责工单允许服务监督员先保存草稿,但必须等待首联和全部初查完成后提交。
- 协查工单派工后直接进入 04 多部门并行事件初查,全部初查完成后才能提交调查。
- 07 业务部主任审核通过后,根据前序责任类型分支:主责进入回访,协查直接进入 11 调查报告编写。
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env node
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const dist = path.join(root, "dist");
const index = path.join(dist, "client", "index.html");
const worker = path.join(root, "worker", "index.js");
const hosting = path.join(root, ".openai", "hosting.json");
for (const file of [index, worker, hosting]) {
if (!existsSync(file)) throw new Error("Missing Sites build input: " + file);
}
mkdirSync(path.join(dist, "server"), { recursive: true });
mkdirSync(path.join(dist, ".openai"), { recursive: true });
copyFileSync(worker, path.join(dist, "server", "index.js"));
copyFileSync(hosting, path.join(dist, ".openai", "hosting.json"));
console.log("Prepared Sites build: dist/server/index.js and dist/.openai/hosting.json");
+659
View File
@@ -0,0 +1,659 @@
<script setup>
import { computed, reactive, ref } from "vue";
import {
AppstoreOutlined,
BarChartOutlined,
BellFilled,
CheckCircleFilled,
ClockCircleOutlined,
CloseOutlined,
CloudUploadOutlined,
DatabaseOutlined,
DownOutlined,
FileDoneOutlined,
FileSearchOutlined,
FormOutlined,
ImportOutlined,
LeftOutlined,
LogoutOutlined,
MenuFoldOutlined,
NotificationFilled,
PlayCircleFilled,
ReloadOutlined,
RightOutlined,
SafetyCertificateFilled,
SearchOutlined,
SettingFilled,
SlidersOutlined,
TeamOutlined,
UserOutlined,
} from "@ant-design/icons-vue";
import { dictionary, importBatches, roles, serviceRecords, taskSummary, workOrders } from "./mock/data.js";
import FilterPanel from "./components/FilterPanel.vue";
import InvestigationFields from "./components/InvestigationFields.vue";
import OrderContext from "./components/OrderContext.vue";
import OrderTable from "./components/OrderTable.vue";
const loggedIn = ref(false);
const currentApp = ref("");
const loginLoading = ref(false);
const currentRoleId = ref("supervisor");
const currentPage = ref("tasks");
const selectedOrder = ref(workOrders[0]);
const activeTaskType = ref("");
const showRoleMenu = ref(false);
const showNotice = ref(false);
const showAssignment = ref(false);
const showReview = ref(false);
const toast = ref("");
const importPreview = ref(false);
const importFileName = ref("");
const transcriptExpanded = ref(true);
const appSearch = ref("");
const microApps = [
{ id: "work-orders", name: "12398 工单系统", description: "受理与处理客户工单,跟踪进度,提升服务效率", status: "可用", icon: FormOutlined, available: true },
{ id: "risk-events", name: "风险事件管理", description: "风险事件上报、评估、处置与跟踪,降低风险影响", status: "即将接入", icon: SafetyCertificateFilled, available: false },
{ id: "compliance", name: "合规检查", description: "检查计划与执行、问题整改与跟踪,强化合规管理", status: "即将接入", icon: CheckCircleFilled, available: false },
];
const loginForm = reactive({ username: "", password: "", code: "" });
const filters = reactive({ no: "", stage: "", source: "", calledAt: "", transferredAt: "", customer: "", phone: "", address: "", region: "", province: "", eventType: "", transferCategory: "", problemCategory: "" });
const dispatchForm = reactive({
responsibilityType: "主责",
preliminaryDepartments: ["运营部"],
firstContactDepartment: "客服一部",
supervisor: "监督员A(张某)",
escalationDepartments: [],
});
const assignmentForm = reactive({ member: "", department: "客服一部" });
const assignmentMode = ref("first-contact");
const investigationForm = reactive({
firstContactAt: "2026-08-12 14:32",
firstContactNo: "2026-08-06-001",
firstContactContent: "已联系客户核实电压波动情况,并告知后续调查安排。",
preliminaryContent: "经初查,台区监测记录存在短时电压波动,已安排现场核验。",
importedContent: "客户反映近期电压不稳,设备频繁重启,要求尽快处理。",
externalExposureRisk: "否",
internalAppealIssued: "否",
customerAppealSummary: "客户要求核查电压不稳原因并尽快恢复稳定供电。",
escalationReason: "客户多次反映后问题仍偶发,期望提高处理时效。",
investigationProcess: "调取95598历史记录、台区电压监测数据,并组织属地单位开展现场检查。",
localResponsibility: "是",
handlingSituation: "已完成线路及设备检查,针对波动时段持续开展监测。",
accountable: "否",
hasHandlingBasis: "是",
handlingMethod: "调整台区运行参数,增加重点时段监测频次,并向客户说明处理情况。",
handlingResult: "当前电压监测数据已恢复正常,后续继续跟踪。",
respondentName: "监督员A(张某)",
repliedAt: "2026-08-10 10:44",
replyPhone: "131****7777",
replySituation: "",
callbackResult: "同意",
});
const callbackUploads = reactive({ replyLetter: false, deliveryProof: false });
const reportForm = reactive({ title: "关于客户反映电压不稳问题的调查报告", summary: "", rootCause: "", measures: "", conclusion: "" });
const deliverableFiles = ref(["12398工单调查报告.pdf", "客户答复书.pdf"]);
const reviewForm = reactive({ result: "", reason: "", callbackDepartment: "" });
const sla = reactive({ total: 188, dispatch: 60, firstContact: 60, preliminary: 120, investigation: 180, review: 60, callback: 120, report: 240 });
const currentRole = computed(() => roles.find((role) => role.id === currentRoleId.value) || roles[0]);
const visibleMicroApps = computed(() => {
const keyword = appSearch.value.trim();
return keyword ? microApps.filter((app) => app.name.includes(keyword) || app.description.includes(keyword)) : microApps;
});
const navItems = computed(() => {
if (currentRoleId.value === "supervisor") {
return [
{ id: "import", label: "工单导入", icon: ImportOutlined },
{ id: "query", label: "工单查询", icon: DatabaseOutlined },
{ id: "tasks", label: "工单处理", icon: FormOutlined },
{ id: "rules", label: "规则配置", icon: SlidersOutlined, children: [{ id: "smart-rules", label: "智能派工规则配置" }, { id: "rules", label: "流程时限管理" }] },
{ id: "analytics", label: "统计分析", icon: BarChartOutlined },
];
}
return [
{ id: "query", label: "工单查询", icon: DatabaseOutlined },
{ id: "tasks", label: "待办工单", icon: FormOutlined },
];
});
const pageTitle = computed(() => ({
import: "12398 工单导入",
"import-preview": "12398 工单导入",
"import-history": "12398 工单导入",
query: "工单查询",
tasks: currentRoleId.value === "supervisor" ? "工单处理" : "待办工单",
dispatch: "派单处理",
detail: "工单详情",
investigation: "工单处理",
callback: "工单处理",
report: "工单处理",
deliverable: "工单处理",
"review-detail": "工单处理",
"callback-assignment": "回访分配",
"smart-rules": "智能派工规则配置",
rules: "流程时限管理",
analytics: "统计分析",
}[currentPage.value] || "工单处理"));
const investigationSubmitReady = computed(() => {
if (currentRoleId.value !== "officer") return true;
const preliminaryReady = selectedOrder.value.preliminaryCompletedCount >= selectedOrder.value.preliminaryRequired;
if (selectedOrder.value.responsibilityType === "协查") return preliminaryReady;
return selectedOrder.value.firstContactCompleted && preliminaryReady;
});
const preliminaryProgressText = computed(() => `${selectedOrder.value.preliminaryCompletedCount || 0}/${selectedOrder.value.preliminaryRequired || 1}`);
const taskPages = ["dispatch", "detail", "investigation", "callback", "report", "deliverable", "review-detail", "callback-assignment"];
const summaryIcon = (label) => label.includes("待") ? ClockCircleOutlined : label.includes("完成") ? CheckCircleFilled : label.includes("报告") || label.includes("成果") ? FileDoneOutlined : AppstoreOutlined;
const filteredOrders = computed(() => workOrders.filter((order) => {
return (!filters.no || order.no.includes(filters.no))
&& (!filters.stage || order.stage === filters.stage)
&& (!filters.source || order.source === filters.source)
&& (!filters.calledAt || order.calledAt === filters.calledAt)
&& (!filters.transferredAt || order.transferredAt === filters.transferredAt)
&& (!filters.customer || order.customerName.includes(filters.customer))
&& (!filters.phone || order.phone.includes(filters.phone))
&& (!filters.address || order.address.includes(filters.address))
&& (!filters.region || order.region.includes(filters.region))
&& (!filters.province || order.province === filters.province)
&& (!filters.eventType || order.eventType === filters.eventType)
&& (!filters.transferCategory || order.transferCategory === filters.transferCategory)
&& (!filters.problemCategory || order.problemCategory === filters.problemCategory);
}));
const taskOrders = computed(() => {
const source = filteredOrders.value;
if (!activeTaskType.value && currentRoleId.value === "business-director") return source.filter((order) => order.stage === "待业务部主任审核");
if (!activeTaskType.value || activeTaskType.value === "任务总数") return source;
if (activeTaskType.value === "待审核") {
const stage = currentRoleId.value === "business-director" ? "待业务部主任审核" : "待审核";
return source.filter((order) => order.stage === stage);
}
const stageMap = {
"待分配派工": ["待分配派工"], "已分派完成": ["事件调查", "首联与多部门并行初查", "多部门并行事件初查"],
"调查结果填报": ["事件调查", "调查结果填报", "首联与多部门并行初查", "多部门并行事件初查"], "调查报告编写": ["调查报告编写"], "回访结果填报": ["回访结果填报"], "成果物输出": ["成果物输出"], "已完成": ["已办结"],
"首联分派": ["首联分派", "首联与多部门并行初查"], "待调查": ["事件调查", "多部门并行事件初查", "首联与多部门并行初查"], "回访分派": ["回访分派"], "待首联": ["首联分派", "首联与多部门并行初查"],
};
const stages = stageMap[activeTaskType.value];
return stages ? source.filter((order) => stages.includes(order.stage)) : source;
});
function login() {
loginLoading.value = true;
window.setTimeout(() => {
loginLoading.value = false;
loggedIn.value = true;
currentApp.value = "";
notify("登录成功,欢迎进入风险防控处置系统");
}, 450);
}
function enterApp(app) {
if (!app.available) {
notify(`${app.name}即将接入`);
return;
}
currentApp.value = app.id;
currentPage.value = "tasks";
activeTaskType.value = "";
notify(`已进入${app.name}`);
}
function returnToWorkbench() {
currentApp.value = "";
showRoleMenu.value = false;
showNotice.value = false;
}
function logout() {
currentApp.value = "";
loggedIn.value = false;
showRoleMenu.value = false;
showNotice.value = false;
}
function notify(message) {
toast.value = message;
window.setTimeout(() => { if (toast.value === message) toast.value = ""; }, 2400);
}
function changeRole(roleId) {
currentRoleId.value = roleId;
currentPage.value = "tasks";
activeTaskType.value = "";
showRoleMenu.value = false;
notify(`已切换为${currentRole.value.name}视角`);
}
function go(page) {
currentPage.value = page;
activeTaskType.value = "";
}
function resetFilters() {
Object.assign(filters, { no: "", stage: "", source: "", calledAt: "", transferredAt: "", customer: "", phone: "", address: "", region: "", province: "", eventType: "", transferCategory: "", problemCategory: "" });
activeTaskType.value = "";
}
function openOrder(order, action = "detail") {
selectedOrder.value = order;
if (action === "dispatch") currentPage.value = "dispatch";
else if (action === "callback-assignment") currentPage.value = "callback-assignment";
else if (action === "assign") {
assignmentMode.value = activeTaskType.value.includes("回访") || order.stage.includes("回访") ? "callback" : "first-contact";
showAssignment.value = true;
}
else if (action === "review") {
reviewForm.result = "";
reviewForm.reason = "";
reviewForm.callbackDepartment = "";
showReview.value = true;
}
else if (action === "handle" && currentRoleId.value === "officer" && activeTaskType.value.includes("回访")) currentPage.value = "callback";
else if (action === "handle" && currentRoleId.value === "officer" && activeTaskType.value.includes("报告")) currentPage.value = "report";
else if (action === "handle" && currentRoleId.value === "officer" && activeTaskType.value.includes("成果")) currentPage.value = "deliverable";
else if (action === "handle") currentPage.value = "investigation";
else currentPage.value = "detail";
}
function primaryAction(order) {
if (activeTaskType.value === "已完成") return "detail";
if (currentRoleId.value === "supervisor") return order.stage === "待审核" ? "review" : order.stage === "待回访分配" ? "callback-assignment" : "dispatch";
if (currentRoleId.value === "customer-manager") return activeTaskType.value === "待调查" || order.stage.includes("调查") || order.stage.includes("初查") ? "handle" : "assign";
if (currentRoleId.value === "business-director") return "review";
return "handle";
}
function primaryActionLabel(order) {
if (activeTaskType.value === "已完成") return "查看";
if (currentRoleId.value === "supervisor") return order.stage === "待审核" ? "审核" : order.stage === "待回访分配" ? "回访分配" : "派工";
if (currentRoleId.value === "customer-manager") return activeTaskType.value === "待调查" || order.stage.includes("调查") || order.stage.includes("初查") ? "处理" : activeTaskType.value.includes("回访") || order.stage.includes("回访") ? "回访分派" : "首联分派";
if (currentRoleId.value === "business-director") return "审核";
return "处理";
}
function confirmDispatch() {
if (!dispatchForm.preliminaryDepartments.length || !dispatchForm.supervisor) {
notify("请选择初查部门和服务监督员");
return;
}
if (dispatchForm.responsibilityType === "主责" && !dispatchForm.firstContactDepartment) {
notify("主责工单必须选择首联部门");
return;
}
selectedOrder.value.responsibilityType = dispatchForm.responsibilityType;
selectedOrder.value.firstContactCompleted = false;
selectedOrder.value.preliminaryCompleted = false;
selectedOrder.value.preliminaryRequired = dispatchForm.preliminaryDepartments.length;
selectedOrder.value.preliminaryCompletedCount = 0;
selectedOrder.value.preliminarySubmittedBy = [];
selectedOrder.value.stage = dispatchForm.responsibilityType === "协查" ? "多部门并行事件初查" : "首联与多部门并行初查";
notify(`${dispatchForm.responsibilityType}工单派发成功,监督员已可先行填报调查草稿`);
currentPage.value = "tasks";
}
function saveDraft() {
notify("草稿已保存;前置任务完成后可提交全面调查");
}
function submitForm() {
if (!investigationSubmitReady.value) {
notify(selectedOrder.value.responsibilityType === "协查" ? "全部初查尚未完成,当前只能保存调查草稿" : "首联或初查尚未全部完成,当前只能保存调查草稿");
return;
}
if (currentRoleId.value === "team-leader") selectedOrder.value.firstContactCompleted = true;
if (["operation", "duty-manager", "customer-manager"].includes(currentRoleId.value)) {
const submitted = selectedOrder.value.preliminarySubmittedBy || (selectedOrder.value.preliminarySubmittedBy = []);
if (!submitted.includes(currentRoleId.value)) {
submitted.push(currentRoleId.value);
selectedOrder.value.preliminaryCompletedCount = Math.min(selectedOrder.value.preliminaryRequired, (selectedOrder.value.preliminaryCompletedCount || 0) + 1);
}
selectedOrder.value.preliminaryCompleted = selectedOrder.value.preliminaryCompletedCount >= selectedOrder.value.preliminaryRequired;
}
if (currentRoleId.value === "officer") selectedOrder.value.stage = "待审核";
const submitMessage = currentRoleId.value === "team-leader" ? "首联信息已提交" : currentRoleId.value === "officer" ? "调查结果已提交服务监督主管审核" : `初查内容已提交,当前进度 ${preliminaryProgressText.value}`;
notify(submitMessage);
currentPage.value = "tasks";
}
function confirmReview() {
if (!reviewForm.result) {
notify("请选择审核结果");
return;
}
if (reviewForm.result === "不同意" && !reviewForm.reason.trim()) {
notify("审核不同意时必须填写驳回理由");
return;
}
const isAssist = selectedOrder.value.responsibilityType === "协查";
if (reviewForm.result === "同意" && !isAssist && !reviewForm.callbackDepartment) {
notify("主责工单审核通过时请选择回访部门");
return;
}
showReview.value = false;
if (currentRoleId.value === "supervisor") {
selectedOrder.value.stage = reviewForm.result === "同意" ? "待业务部主任审核" : "调查结果填报";
notify(reviewForm.result === "同意" ? "服务监督主管审核通过,进入业务部主任审核" : "已驳回至调查结果填报");
currentPage.value = "tasks";
activeTaskType.value = "待审核";
return;
}
const branch = isAssist ? "直接进入 11 调查报告编写" : "进入回访分配";
if (reviewForm.result === "同意") selectedOrder.value.stage = isAssist ? "调查报告编写" : "待回访分配";
else selectedOrder.value.stage = "调查结果填报";
notify(reviewForm.result === "同意" ? `审核通过,${branch}` : "已驳回至调查结果填报");
currentPage.value = "tasks";
activeTaskType.value = "待审核";
}
function openFullReview() {
showReview.value = false;
currentPage.value = "review-detail";
}
function previewImport(event) {
const file = event.target.files?.[0];
if (!file) return;
importFileName.value = file.name;
importPreview.value = true;
currentPage.value = "import-preview";
notify("文件解析完成,请核对预览数据");
}
function submitCallback() {
if (investigationForm.callbackResult !== "同意" && (!callbackUploads.replyLetter || !callbackUploads.deliveryProof)) {
notify("请完整上传答复书和送达凭证");
return;
}
selectedOrder.value.stage = "调查报告编写";
notify("回访结果已提交,进入调查报告编写");
currentPage.value = "tasks";
}
function generateReport() {
reportForm.summary = investigationForm.customerAppealSummary;
reportForm.rootCause = "经核查,客户所在台区在负荷波动时段出现短时电压偏差。";
reportForm.measures = investigationForm.handlingMethod;
reportForm.conclusion = investigationForm.handlingResult;
notify("已生成报告草稿,可继续编辑");
}
function submitReport() {
selectedOrder.value.stage = "成果物输出";
notify("调查报告已生成并下载,进入成果物输出");
currentPage.value = "tasks";
}
function finishOrder() {
selectedOrder.value.stage = "已办结";
selectedOrder.value.status = "已结束";
notify("成果物已提交,工单已办结");
currentPage.value = "tasks";
}
function addMockDeliverable() {
const next = `补充成果物-${deliverableFiles.value.length + 1}.pdf`;
deliverableFiles.value.push(next);
notify(`${next} 上传成功`);
}
</script>
<template>
<div v-if="!loggedIn" class="login-screen">
<section class="login-panel">
<div class="login-heading">
<div class="login-kicker">智慧客服引领 <span>未来客服革命</span></div>
<p>提升效率推动智能客服持续发展</p>
</div>
<form class="login-card" @submit.prevent="login">
<h1>欢迎登录 风险防控处置系统</h1>
<label class="login-field"><input v-model="loginForm.username" placeholder="请输入您的账号" /></label>
<label class="login-field"><input v-model="loginForm.password" type="password" placeholder="请输入您的密码" /></label>
<div class="captcha-row">
<label class="login-field"><input v-model="loginForm.code" placeholder="请输入验证码" /></label>
<button class="captcha" type="button">TB5K</button>
</div>
<button class="login-button" :disabled="loginLoading">{{ loginLoading ? "正在登录…" : "登录" }}</button>
</form>
</section>
</div>
<div v-else-if="!currentApp" class="portal-shell">
<header class="portal-header">
<div class="portal-brand"><SafetyCertificateFilled /><strong>风险防控处置系统</strong></div>
<div class="portal-header-spacer"></div>
<div class="clock"><span>2026年08月08日</span><strong>09:48:16</strong></div>
<div class="divider"></div>
<div class="role-switcher">
<button class="role-button" @click="showRoleMenu = !showRoleMenu"><span>您好</span><strong>{{ currentRole.name }}</strong><DownOutlined /></button>
<div v-if="showRoleMenu" class="role-menu portal-role-menu">
<button v-for="role in roles" :key="role.id" :class="{ active: role.id === currentRoleId }" @click="changeRole(role.id)">{{ role.name }}</button>
<button class="logout" @click="logout"><LogoutOutlined />退出登录</button>
</div>
</div>
<button class="icon-button"><UserOutlined /></button>
<button class="icon-button notice-button" @click="showNotice = !showNotice"><BellFilled /><i></i></button>
<button class="icon-button"><SettingFilled /></button>
<div v-if="showNotice" class="notice-popover portal-notice"><strong>待办提醒</strong><p>您有 47 条工单待处理</p><p>进入 12398 工单系统查看详情</p></div>
</header>
<main class="portal-content">
<section class="portal-intro">
<div><h1>上午好{{ currentRole.name }}</h1><p>选择一个应用开始今天的工作</p></div>
<label class="portal-search"><SearchOutlined /><input v-model="appSearch" placeholder="搜索应用名称" /></label>
</section>
<section class="portal-section">
<h2>最近使用</h2>
<button class="recent-app" @click="enterApp(microApps[0])">
<span class="portal-app-icon"><FormOutlined /></span>
<span class="recent-copy"><strong>12398 工单系统</strong><em>受理与处理客户工单跟踪进度提升服务效率</em></span>
<span class="recent-todo"><em>今日待办</em><strong>47</strong></span>
<span class="portal-enter outline">进入应用</span><RightOutlined class="recent-arrow" />
</button>
</section>
<section class="portal-section all-apps-section">
<h2>全部应用</h2>
<div v-if="visibleMicroApps.length" class="micro-app-grid">
<button v-for="app in visibleMicroApps" :key="app.id" class="micro-app-card" :class="{ disabled: !app.available }" @click="enterApp(app)">
<span class="portal-app-icon"><component :is="app.icon" /></span>
<span class="micro-app-copy"><span><strong>{{ app.name }}</strong><i :class="{ available: app.available }">{{ app.status }}</i></span><em>{{ app.description }}</em></span>
<span class="micro-app-action">{{ app.available ? '进入应用' : '即将接入' }} <RightOutlined v-if="app.available" /></span>
</button>
</div>
<div v-else class="portal-empty">没有找到匹配的应用</div>
</section>
</main>
<footer class="portal-footer"><SafetyCertificateFilled /><span>统一身份认证与权限管理保障应用访问安全合规</span><i></i><button>了解更多 <RightOutlined /></button></footer>
<transition name="toast"><div v-if="toast" class="toast"><CheckCircleFilled />{{ toast }}</div></transition>
</div>
<div v-else class="app-shell">
<aside class="sidebar">
<button class="brand app-home-return" title="返回应用工作台" @click="returnToWorkbench"><SafetyCertificateFilled /><strong>风险防控处置系统</strong></button>
<nav class="main-nav">
<div v-for="item in navItems" :key="item.id" class="nav-block">
<button class="nav-item" :class="{ active: currentPage === item.id || (item.id === 'tasks' && taskPages.includes(currentPage)) || (item.id === 'import' && currentPage.startsWith('import-')) || (item.id === 'rules' && currentPage === 'smart-rules') }" @click="go(item.id)">
<component :is="item.icon" />
<span>{{ item.label }}</span>
<DownOutlined v-if="item.children" class="nav-arrow expanded" />
<RightOutlined v-else class="nav-arrow" />
</button>
<div v-if="item.children" class="sub-nav">
<button :class="{ active: currentPage === child.id }" v-for="child in item.children" :key="child.id" @click="go(child.id)">{{ child.label }}</button>
</div>
</div>
</nav>
<div class="sidebar-foot">Mock 原型 · V0.1</div>
</aside>
<section class="app-main">
<header class="topbar">
<button class="icon-button ghost"><MenuFoldOutlined /></button>
<div class="topbar-spacer"></div>
<div class="clock"><span>2026年08月08日</span><strong>09:48:16</strong></div>
<div class="divider"></div>
<div class="role-switcher">
<button class="role-button" @click="showRoleMenu = !showRoleMenu">
<span>您好</span><strong>{{ currentRole.name }}</strong><DownOutlined />
</button>
<div v-if="showRoleMenu" class="role-menu">
<button v-for="role in roles" :key="role.id" :class="{ active: role.id === currentRoleId }" @click="changeRole(role.id)">{{ role.name }}</button>
<button class="logout" @click="logout"><LogoutOutlined />退出登录</button>
</div>
</div>
<button class="icon-button"><UserOutlined /></button>
<button class="icon-button notice-button" @click="showNotice = !showNotice"><BellFilled /><i></i></button>
<button class="icon-button"><SettingFilled /></button>
<div v-if="showNotice" class="notice-popover">
<strong>待办提醒</strong>
<p> 22 条工单等待分配派工</p>
<p> 11 条工单等待回访分配</p>
</div>
</header>
<main class="workspace">
<section class="page-card">
<div class="page-heading">
<h1>{{ pageTitle }}</h1>
<button v-if="taskPages.includes(currentPage)" class="outline-button" @click="go('tasks')"><LeftOutlined />返回列表</button>
</div>
<template v-if="currentPage === 'import'">
<section class="import-box import-main">
<div class="section-title">12398 工单导入</div>
<div class="form-row"><label>导入方式</label><select class="field"><option>Excel / CSV文件导入</option></select></div>
<div class="form-row"><label>导入批次</label><input class="field" value="20260806-B01" /></div>
<div class="file-drop"><CloudUploadOutlined /><div><strong>{{ importFileName || '将文件拖拽到此处,或点击选择文件' }}</strong><span>支持 ExcelCSV单个文件不超过20MB</span></div><input type="file" accept=".xlsx,.xls,.csv" @change="previewImport" /></div>
<div class="button-row"><button class="outline-button" @click="go('import-history')">导入明细</button><button class="primary-button" @click="go('import-preview')">导入预览</button></div>
</section>
</template>
<template v-else-if="currentPage === 'import-preview'">
<section class="preview-panel"><div class="section-title"><span>导入预览</span><button class="text-button" @click="go('import')"><CloseOutlined />返回导入</button></div><OrderTable :orders="workOrders.slice(0,8)" :readonly="true" @open="openOrder" /><div class="button-row"><button class="outline-button" @click="go('import')">取消</button><button class="primary-button" @click="notify('确认导入成功,共生成 18 条工单'); go('import-history')">确认导入</button></div></section>
</template>
<template v-else-if="currentPage === 'import-history'">
<section class="preview-panel"><div class="section-title"><span>导入明细</span><button class="text-button" @click="go('import')"><LeftOutlined />返回导入</button></div><table class="data-table compact"><thead><tr><th>序号</th><th>导入时间</th><th>导入批次</th><th>导入条数</th><th>导入人员</th></tr></thead><tbody><tr v-for="batch in importBatches" :key="batch.id"><td>{{ batch.id }}</td><td>{{ batch.importedAt }}</td><td>{{ batch.batchNo }}</td><td>{{ batch.count }}</td><td>{{ batch.importedBy }}</td></tr></tbody></table></section>
</template>
<template v-else-if="currentPage === 'query'">
<FilterPanel v-model="filters" @reset="resetFilters" />
<div class="section-title">工单详情 <span class="result-count"> {{ filteredOrders.length }} </span></div>
<OrderTable :orders="filteredOrders" :readonly="true" @open="openOrder" />
</template>
<template v-else-if="currentPage === 'tasks'">
<div class="summary-grid">
<button v-for="item in taskSummary[currentRoleId]" :key="item[0]" :class="{ active: activeTaskType === item[0] }" @click="activeTaskType = activeTaskType === item[0] ? '' : item[0]">
<span class="summary-icon"><component :is="summaryIcon(item[0])" /></span><span class="summary-copy"><span>{{ item[0] }}</span><strong>{{ item[1] }}</strong><em></em></span>
</button>
</div>
<FilterPanel v-model="filters" simple @reset="resetFilters" />
<OrderTable :orders="taskOrders.slice(0,8)" :action-label="primaryActionLabel" @open="openOrder" @action="(order) => openOrder(order, primaryAction(order))" />
</template>
<template v-else-if="taskPages.includes(currentPage)">
<OrderContext :order="selectedOrder" :records="serviceRecords" v-model:expanded="transcriptExpanded" />
<section v-if="currentPage === 'dispatch'" class="detail-section form-section">
<div class="section-title">派单选择信息</div>
<div class="wide-form">
<div class="form-row"><label>责任类型</label><div class="radio-group"><label><input v-model="dispatchForm.responsibilityType" type="radio" value="主责" />主责</label><label><input v-model="dispatchForm.responsibilityType" type="radio" value="协查" />协查</label></div></div>
<div class="form-row"><label>派工主体</label><div class="static-value">服务监督主管</div></div>
<div class="flow-tip"><NotificationFilled /><span v-if="dispatchForm.responsibilityType === '协查'">协查派工后直接进入 <strong>04 多部门并行事件初查</strong>服务监督员可先行填报调查草稿全部初查完成后方可提交</span><span v-else>主责派工后同步进入首联和 <strong>04 多部门并行事件初查</strong>服务监督员可先行填报调查草稿首联及全部初查完成后方可提交</span></div>
<div class="form-row"><label>初查部门</label><div class="checkbox-select"><label v-for="dept in dictionary.departments" :key="dept"><input v-model="dispatchForm.preliminaryDepartments" type="checkbox" :value="dept" />{{ dept }}</label></div></div>
<div v-if="dispatchForm.responsibilityType === '主责'" class="form-row"><label>首联部门</label><select v-model="dispatchForm.firstContactDepartment" class="field"><option v-for="dept in dictionary.firstContactDepartments" :key="dept">{{ dept }}</option></select></div>
<div class="form-row"><label>服务监督员</label><select v-model="dispatchForm.supervisor" class="field"><option v-for="person in dictionary.supervisors" :key="person">{{ person }}</option></select></div>
<div class="form-row"><label>提级督办</label><div class="checkbox-select"><label v-for="dept in dictionary.departments" :key="dept"><input v-model="dispatchForm.escalationDepartments" type="checkbox" :value="dept" />{{ dept }}</label></div></div>
</div>
<div class="button-row"><button class="outline-button" @click="go('tasks')">取消</button><button class="primary-button" @click="confirmDispatch">确认派发</button></div>
</section>
<section v-else-if="currentPage === 'investigation'" class="detail-section form-section">
<div v-if="currentRoleId === 'team-leader'" class="wide-form">
<div class="section-title">首联信息</div>
<div class="form-row"><label>首联时间</label><input v-model="investigationForm.firstContactAt" class="field" type="datetime-local" /></div>
<div class="form-row"><label>首联工单号</label><input v-model="investigationForm.firstContactNo" class="field" placeholder="请输入首联工单号" /></div>
<div class="form-row"><label>首联内容</label><textarea v-model="investigationForm.firstContactContent" class="field" placeholder="请填写首联内容"></textarea></div>
</div>
<div v-else-if="['operation','duty-manager','customer-manager'].includes(currentRoleId)" class="wide-form">
<div class="section-title">处置信息填写</div>
<div class="form-row"><label>初查内容</label><textarea v-model="investigationForm.preliminaryContent" class="field large" placeholder="请输入初查内容"></textarea></div>
</div>
<InvestigationFields v-else :form="investigationForm" show-tip show-imported-content :progress-text="preliminaryProgressText" :show-first-contact="selectedOrder.responsibilityType === '主责'" />
<div class="button-row"><button v-if="currentRoleId === 'officer'" class="outline-button" @click="saveDraft">保存草稿</button><button class="primary-button" :disabled="!investigationSubmitReady" @click="submitForm">{{ investigationSubmitReady ? '确认提交' : '等待前置任务完成' }}</button></div>
</section>
<section v-else-if="currentPage === 'callback'" class="detail-section form-section">
<InvestigationFields :form="investigationForm" readonly :show-first-contact="selectedOrder.responsibilityType === '主责'" />
<div class="wide-form">
<div class="section-title">回访结果</div>
<div class="form-row"><label>回复人</label><input v-model="investigationForm.respondentName" class="field" /></div>
<div class="form-row"><label>回复时间</label><input v-model="investigationForm.repliedAt" class="field" /></div>
<div class="form-row"><label>回复电话号码</label><input v-model="investigationForm.replyPhone" class="field" /></div>
<div class="form-row"><label>回复情况</label><textarea v-model="investigationForm.replySituation" class="field" placeholder="请填写回复情况"></textarea></div>
<div class="form-row"><label>回访结果</label><select v-model="investigationForm.callbackResult" class="field"><option>同意</option><option>不同意或客户主动要求</option></select></div>
<div v-if="investigationForm.callbackResult !== '同意'" class="inline-callback-files">
<div class="upload-file-row"><strong>上传答复书</strong><button class="upload-square" :class="{ uploaded: callbackUploads.replyLetter }" @click="callbackUploads.replyLetter = true"><CloudUploadOutlined /></button><button class="upload-file-name" @click="callbackUploads.replyLetter = true">{{ callbackUploads.replyLetter ? '客户答复书.pdf' : '点击选择答复书' }}</button></div>
<div class="upload-file-row"><strong>上传送达凭证</strong><button class="upload-square" :class="{ uploaded: callbackUploads.deliveryProof }" @click="callbackUploads.deliveryProof = true"><CloudUploadOutlined /></button><button class="upload-file-name" @click="callbackUploads.deliveryProof = true">{{ callbackUploads.deliveryProof ? '送达凭证.jpg' : '点击选择送达凭证' }}</button></div>
</div>
</div>
<div class="button-row"><button class="outline-button" @click="go('tasks')">取消</button><button class="primary-button" @click="submitCallback">确认审核</button></div>
</section>
<section v-else-if="currentPage === 'report'" class="detail-section form-section">
<InvestigationFields :form="investigationForm" readonly :show-first-contact="selectedOrder.responsibilityType === '主责'" />
<div class="wide-form">
<div class="form-row"><label>回复人</label><input v-model="investigationForm.respondentName" class="field" readonly /></div>
<div class="form-row"><label>回复时间</label><input v-model="investigationForm.repliedAt" class="field" readonly /></div>
<div class="form-row"><label>回复电话号码</label><input v-model="investigationForm.replyPhone" class="field" readonly /></div>
<div class="form-row"><label>回复情况</label><textarea v-model="investigationForm.replySituation" class="field" readonly placeholder="请填写回复情况"></textarea></div>
<div class="form-row"><label>回访结果</label><select v-model="investigationForm.callbackResult" class="field"><option>同意</option><option>不同意或客户主动要求</option></select></div>
<div v-if="investigationForm.callbackResult !== '同意'" class="inline-callback-files">
<div class="upload-file-row"><strong>上传答复书</strong><button class="upload-square uploaded"><CloudUploadOutlined /></button><button class="upload-file-name">客户答复书.pdf</button></div>
<div class="upload-file-row"><strong>上传送达凭证</strong><button class="upload-square uploaded"><CloudUploadOutlined /></button><button class="upload-file-name">送达凭证.jpg</button></div>
</div>
</div>
<div class="button-row"><button class="outline-button" @click="go('tasks')">取消</button><button class="primary-button" @click="submitReport">确认下载</button></div>
</section>
<section v-else-if="currentPage === 'deliverable'" class="detail-section form-section">
<InvestigationFields :form="investigationForm" readonly :show-first-contact="selectedOrder.responsibilityType === '主责'" />
<div class="wide-form">
<div class="section-title">成果物清单</div>
<div class="deliverable-list"><article v-for="(file,index) in deliverableFiles" :key="file"><FileDoneOutlined /><div><strong>{{ file }}</strong><span>2026-08-11 16:2{{ index }} · 监督员A(张某)</span></div><button class="text-button" @click="deliverableFiles.splice(index,1)">移除</button></article><button class="add-deliverable" @click="addMockDeliverable"><CloudUploadOutlined /><span>上传成果物</span></button></div>
<div class="tip"><CheckCircleFilled />报告及必备成果物齐全确认提交后工单进入办结校验</div>
</div>
<div class="button-row"><button class="outline-button" @click="go('tasks')">取消</button><button class="primary-button" @click="finishOrder">确认提交并办结</button></div>
</section>
<section v-else-if="currentPage === 'review-detail'" class="detail-section form-section">
<InvestigationFields :form="investigationForm" readonly :show-first-contact="selectedOrder.responsibilityType === '主责'" />
<div class="wide-form review-actions"><div class="section-title">审核意见</div><div class="form-row"><label>审核结果</label><div class="radio-group"><label><input v-model="reviewForm.result" type="radio" value="同意" />同意</label><label><input v-model="reviewForm.result" type="radio" value="不同意" />不同意</label></div></div><div class="form-row"><label>审核意见</label><textarea v-model="reviewForm.reason" class="field" placeholder="请输入审核意见"></textarea></div><div class="branch-tip"><NotificationFilled /><span v-if="currentRoleId === 'supervisor'">服务监督主管审核通过后进入业务部主任审核</span><span v-else>业务部主任审核通过后主责进入回访协查直接进入调查报告编写</span></div></div>
<div class="button-row"><button class="outline-button" @click="go('tasks')">取消</button><button class="primary-button" @click="confirmReview">确认审核</button></div>
</section>
<section v-else-if="currentPage === 'callback-assignment'" class="detail-section form-section">
<div class="wide-form"><div class="section-title">回访分配</div><div class="form-row"><label>回访部门</label><select v-model="assignmentForm.department" class="field"><option v-for="dept in dictionary.firstContactDepartments" :key="dept">{{ dept }}</option></select></div></div><div class="button-row"><button class="outline-button" @click="go('tasks')">取消</button><button class="primary-button" @click="selectedOrder.stage = '回访分派'; notify('回访部门分配成功'); go('tasks')">确认派发</button></div>
</section>
</template>
<template v-else-if="currentPage === 'rules'">
<div class="rules-layout single"><section class="sla-card"><div class="section-title">环节时限配置</div><div class="total-limit"><span>整体时限</span><input v-model="sla.total" type="number" /><em>分钟</em></div><div class="sla-grid"><label>办理派工<input v-model="sla.dispatch" type="number" /><span>分钟</span></label><label>首联<input v-model="sla.firstContact" type="number" /><span>分钟</span></label><label>事件调查<input v-model="sla.preliminary" type="number" /><span>分钟</span></label><label>调查结果<input v-model="sla.investigation" type="number" /><span>分钟</span></label><label>调查审核<input v-model="sla.review" type="number" /><span>分钟</span></label><label>回访<input v-model="sla.callback" type="number" /><span>分钟</span></label><label>调查报告<input v-model="sla.report" type="number" /><span>分钟</span></label></div><div class="button-row"><button class="outline-button">取消</button><button class="primary-button" @click="notify('流程时限配置已保存')">保存时限</button></div></section></div>
</template>
<template v-else-if="currentPage === 'smart-rules' || currentPage === 'analytics'"><div class="prototype-blank"></div></template>
</section>
</main>
</section>
<div v-if="showAssignment" class="modal-mask" @click.self="showAssignment = false"><section class="modal assignment-modal"><button class="modal-close" @click="showAssignment = false"><CloseOutlined /></button><h2>{{ assignmentMode === 'callback' ? '回访分派' : '首联分派' }}</h2><div class="modal-inner-panel"><div class="section-title">分派选择信息</div><div class="form-row"><label>班组</label><select v-model="assignmentForm.member" class="field"><option value="" disabled>请选择班组/人员</option><option v-for="person in dictionary.teamMembers" :key="person">{{ person }}</option></select></div></div><div class="button-row"><button class="outline-button" @click="showAssignment = false">取消</button><button class="primary-button" @click="showAssignment = false; selectedOrder.stage = assignmentMode === 'callback' ? '回访结果填报' : '首联分派'; notify('分派成功')">确认分派</button></div></section></div>
<div v-if="showReview" class="modal-mask" @click.self="showReview = false"><section class="modal review-modal-v2"><button class="modal-close" @click="showReview = false"><CloseOutlined /></button><h2>审核工单信息</h2><div class="modal-inner-panel"><div class="section-title">审核结果</div><div class="form-row"><label>审核结果</label><select v-model="reviewForm.result" class="field"><option value="" disabled>请选择审核结果(同意/不同意)</option><option>同意</option><option>不同意</option></select></div><div class="section-title">驳回理由</div><textarea v-model="reviewForm.reason" class="field review-reason" placeholder="请输入驳回理由"></textarea><template v-if="selectedOrder.responsibilityType === '主责'"><div class="section-title">回访分派</div><div class="form-row"><label>回访分派</label><select v-model="reviewForm.callbackDepartment" class="field"><option value="" disabled>选择部门(单选)</option><option v-for="dept in dictionary.firstContactDepartments" :key="dept">{{ dept }}</option></select></div></template><div v-if="currentRoleId === 'business-director' && selectedOrder.responsibilityType === '协查'" class="branch-tip"><NotificationFilled />协查工单审核通过后直接进入 11 调查报告编写。</div></div><div class="button-row"><button class="outline-button" @click="showReview = false">取消</button><button class="primary-button" @click="confirmReview">确认审核</button></div></section></div>
<transition name="toast"><div v-if="toast" class="toast"><CheckCircleFilled />{{ toast }}</div></transition>
</div>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup>
import { ReloadOutlined, SearchOutlined } from "@ant-design/icons-vue";
defineProps({ modelValue: { type: Object, required: true }, simple: Boolean });
defineEmits(["reset"]);
</script>
<template>
<section class="filter-panel">
<div class="filter-grid">
<label><span>工单编号</span><input v-model="modelValue.no" placeholder="如20260806-B01" /></label>
<label><span>工单环节</span><select v-model="modelValue.stage"><option value="">选择工单环节</option><option>待分配派工</option><option>事件调查</option><option>待审核</option><option>回访结果填报</option></select></label>
<label v-if="!simple"><span>信息来源</span><select v-model="modelValue.source"><option value="">选择信息来源</option><option>12398 电话</option></select></label>
<label v-if="!simple"><span>致电日期</span><input v-model="modelValue.calledAt" type="date" /></label>
<label v-if="!simple"><span>转办日期</span><input v-model="modelValue.transferredAt" type="date" /></label>
<label><span>客户名称</span><input v-model="modelValue.customer" placeholder="输入客户名称" /></label>
<label><span>联系方式</span><input v-model="modelValue.phone" placeholder="输入联系方式" /></label>
<label v-if="!simple"><span>用户地址</span><input v-model="modelValue.address" placeholder="输入用户地址" /></label>
<label v-if="!simple"><span>客户所在地区</span><input v-model="modelValue.region" placeholder="输入省市地区" /></label>
<label v-if="!simple"><span>省份</span><select v-model="modelValue.province"><option value="">选择省份</option><option>天津</option></select></label>
<label v-if="!simple"><span>事件类型</span><select v-model="modelValue.eventType"><option value="">选择事件类型</option><option>投诉事项</option></select></label>
<label v-if="!simple"><span>转办事项分类</span><select v-model="modelValue.transferCategory"><option value="">选择转办事项分类</option><option>投诉事项</option></select></label>
<label v-if="!simple"><span>问题分类</span><select v-model="modelValue.problemCategory"><option value="">选择问题分类</option><option>安全隐患</option></select></label>
</div>
<div class="filter-actions"><button class="outline-button" @click="$emit('reset')"><ReloadOutlined />重置</button><button class="primary-button"><SearchOutlined />查询</button></div>
</section>
</template>
@@ -0,0 +1,41 @@
<script setup>
import { ClockCircleOutlined } from "@ant-design/icons-vue";
defineProps({
form: { type: Object, required: true },
readonly: Boolean,
showTip: Boolean,
progressText: { type: String, default: "" },
showImportedContent: Boolean,
showFirstContact: { type: Boolean, default: true },
});
</script>
<template>
<div class="wide-form investigation-form">
<template v-if="showFirstContact">
<div class="section-title">首联信息</div>
<div class="form-row"><label>首联时间</label><input v-model="form.firstContactAt" class="field" :readonly="readonly" type="text" placeholder="请选择日期" /></div>
<div class="form-row"><label>首联工单号</label><input v-model="form.firstContactNo" class="field" :readonly="readonly" placeholder="请输入首联工单号" /></div>
<div class="form-row"><label>首联内容</label><input v-model="form.firstContactContent" class="field" :readonly="readonly" placeholder="请填写首联内容" /></div>
</template>
<div class="section-title">调查结果</div>
<div v-if="showTip" class="tip"><ClockCircleOutlined /><span>派工后可先行填报并保存草稿;主责需等待首联及全部初查完成,协查需等待全部初查完成后才能提交。<strong v-if="progressText">当前初查进度{{ progressText }}</strong></span></div>
<div class="form-row"><label>调查结果</label><textarea v-model="form.preliminaryContent" class="field" :readonly="readonly" placeholder="请填写调查结果"></textarea></div>
<div class="section-title">派单选择信息</div>
<div v-if="showImportedContent" class="form-row"><label>12398受理内容</label><textarea v-model="form.importedContent" class="field" :readonly="readonly" placeholder="导入的12398投诉内容"></textarea></div>
<div class="form-row long-label"><label>致电时客户是否表达向外部渠道或监管机构反映倾向</label><div class="radio-group"><label><input v-model="form.externalExposureRisk" type="radio" value="是" :disabled="readonly" />是</label><label><input v-model="form.externalExposureRisk" type="radio" value="否" :disabled="readonly" /></label></div></div>
<div class="form-row long-label"><label>是否派发内部诉求工单</label><div class="radio-group"><label><input v-model="form.internalAppealIssued" type="radio" value="是" :disabled="readonly" />是</label><label><input v-model="form.internalAppealIssued" type="radio" value="否" :disabled="readonly" /></label></div></div>
<div class="form-row"><label>客户诉求概况</label><textarea v-model="form.customerAppealSummary" class="field" :readonly="readonly" placeholder="请填写客户诉求概况"></textarea></div>
<div class="form-row"><label>客户升级主要原因</label><textarea v-model="form.escalationReason" class="field" :readonly="readonly" placeholder="请填写客户升级主要原因"></textarea></div>
<div class="form-row"><label>本期诉求调查过程</label><textarea v-model="form.investigationProcess" class="field large" :readonly="readonly" placeholder="请填写调查过程"></textarea></div>
<div class="form-row"><label>是否本单位事项</label><div class="radio-group"><label><input v-model="form.localResponsibility" type="radio" value="是" :disabled="readonly" />是</label><label><input v-model="form.localResponsibility" type="radio" value="否" :disabled="readonly" /></label></div></div>
<div class="form-row"><label>办理情况</label><textarea v-model="form.handlingSituation" class="field" :readonly="readonly" placeholder="请填写办理情况"></textarea></div>
<div class="form-row"><label>是否有责</label><div class="radio-group"><label><input v-model="form.accountable" type="radio" value="是" :disabled="readonly" />是</label><label><input v-model="form.accountable" type="radio" value="否" :disabled="readonly" /></label></div></div>
<div class="form-row"><label>是否有处理依据</label><div class="radio-group"><label><input v-model="form.hasHandlingBasis" type="radio" value="是" :disabled="readonly" />是</label><label><input v-model="form.hasHandlingBasis" type="radio" value="否" :disabled="readonly" /></label></div></div>
<div class="form-row"><label>处理方式</label><textarea v-model="form.handlingMethod" class="field" :readonly="readonly" placeholder="请填写处理方式"></textarea></div>
<div class="form-row"><label>处理结果</label><textarea v-model="form.handlingResult" class="field" :readonly="readonly" placeholder="请填写处理结果"></textarea></div>
</div>
</template>
+13
View File
@@ -0,0 +1,13 @@
<script setup>
import { PlayCircleFilled } from "@ant-design/icons-vue";
defineProps({ order: { type: Object, required: true }, records: { type: Array, default: () => [] }, expanded: Boolean });
defineEmits(["update:expanded"]);
</script>
<template>
<div>
<section class="detail-section"><div class="section-title">12398 工单基础信息</div><div class="info-grid"><div><span>12398工单编号</span><strong>{{ order.no }}</strong></div><div><span>工单状态</span><strong>{{ order.status }}</strong></div><div><span>信息来源</span><strong>{{ order.source }}</strong></div><div><span>客户致电12398日期</span><strong>{{ order.calledAt }}</strong></div><div><span>转办日期</span><strong>{{ order.transferredAt }}</strong></div><div><span>客户名称</span><strong>{{ order.customerName }}</strong></div><div><span>联系方式</span><strong>{{ order.phone }}</strong></div><div><span>用户地址</span><strong>{{ order.address }}</strong></div><div><span>客户所在地区()</span><strong>{{ order.region }}</strong></div><div><span>省份</span><strong>{{ order.province }}</strong></div><div><span>事件类型</span><strong>{{ order.eventType }}</strong></div><div><span>转办事项分类</span><strong>{{ order.transferCategory }}</strong></div><div><span>问题分类</span><strong>{{ order.problemCategory }}</strong></div><div><span>责任类型</span><strong>{{ order.responsibilityType }}</strong></div></div></section>
<section class="detail-section"><div class="section-title service-title"><span>95598 业务处理信息</span><button class="text-button" @click="$emit('update:expanded',!expanded)">{{ expanded ? '收起' : '展开' }}</button></div><div v-if="expanded" class="table-wrap"><table class="data-table service-table"><thead><tr><th>序号</th><th>工单编号</th><th>受理内容</th><th>业务类型</th><th>受理时间</th><th>供电单位</th><th>录音</th><th>转译文本</th></tr></thead><tbody><tr v-for="record in records" :key="record.id"><td>{{ record.id }}</td><td>{{ record.no }}</td><td>{{ record.content }}</td><td>{{ record.type }}</td><td>{{ record.acceptedAt }}</td><td>{{ record.company }}</td><td><button class="audio-button"><PlayCircleFilled /><span>{{ record.duration }}</span></button></td><td>{{ record.transcript }}</td></tr></tbody></table></div></section>
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup>
defineProps({ orders: { type: Array, default: () => [] }, readonly: Boolean, actionLabel: Function });
defineEmits(["open", "action"]);
</script>
<template>
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>序号</th><th>12398工单编号</th><th>工单环节</th><th>信息来源</th><th>客户致电12398日期</th><th>转办日期</th><th>客户名称</th><th>联系方式</th><th>用户地址</th><th v-if="!readonly">操作</th></tr></thead>
<tbody>
<tr v-for="(order,index) in orders" :key="order.id"><td>{{ index + 1 }}</td><td><button class="link-button" @click="$emit('open',order)">{{ order.no }}</button></td><td><span class="status" :class="order.stage === '已办结' ? 'done' : order.stage.includes('待') || order.stage.includes('分派') ? 'pending' : 'progress'">{{ order.stage }}</span></td><td>{{ order.source }}</td><td>{{ order.calledAt }}</td><td>{{ order.transferredAt }}</td><td>{{ order.customerName }}</td><td>{{ order.phone }}</td><td class="address">{{ order.address }}</td><td v-if="!readonly"><button class="action-button" @click="$emit('action',order)">{{ actionLabel ? actionLabel(order) : '处理' }}</button></td></tr>
<tr v-if="!orders.length"><td :colspan="readonly ? 9 : 10" class="empty-cell">暂无符合条件的工单</td></tr>
</tbody>
</table>
<div class="pagination"><span> {{ orders.length }} </span><button disabled></button><button class="active">1</button><button>2</button><button></button></div>
</div>
</template>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./styles.css";
createApp(App).mount("#app");
+91
View File
@@ -0,0 +1,91 @@
export const roles = [
{ id: "supervisor", name: "服务监督主管" },
{ id: "officer", name: "服务监督人员" },
{ id: "customer-manager", name: "客服部主管" },
{ id: "team-leader", name: "客服部班长" },
{ id: "operation", name: "智能运营团队" },
{ id: "duty-manager", name: "总值班经理" },
{ id: "business-director", name: "业务部主任" },
];
const mockStages = [
"待分配派工", "事件调查", "待审核", "回访结果填报", "首联分派", "多部门并行事件初查",
"待审核", "待回访分配", "调查报告编写", "成果物输出", "已办结", "待业务部主任审核",
"首联与多部门并行初查", "调查结果填报", "回访结果填报", "调查报告编写", "成果物输出", "待分配派工",
];
export const workOrders = Array.from({ length: 18 }, (_, index) => ({
id: `wo-${index + 1}`,
no: `2026-08-06-${String(index + 1).padStart(3, "0")}`,
status: mockStages[index] === "已办结" ? "已结束" : /待|分派/.test(mockStages[index]) ? "待分派" : "处理中",
stage: mockStages[index],
source: "12398 电话",
calledAt: "2026-08-06",
transferredAt: "2026-08-06",
customerName: index % 2 ? "张某某" : "某某有限公司",
phone: index % 2 ? "137****1111" : "138****8888",
address: "XX市XX区XX路88号",
region: "XX省XX市",
province: "天津",
eventType: "投诉事项",
transferCategory: "投诉事项",
problemCategory: "安全隐患",
responsibilityType: [5, 6, 8, 11, 15].includes(index) ? "协查" : "主责",
firstContactCompleted: ![0, 4, 12, 17].includes(index),
preliminaryRequired: index % 2 === 0 ? 2 : 3,
preliminaryCompletedCount: [0, 1, 5, 12, 13, 17].includes(index) ? index % 2 : (index % 2 === 0 ? 2 : 3),
preliminarySubmittedBy: [],
preliminaryCompleted: ![0, 1, 5, 12, 13, 17].includes(index),
}));
export const serviceRecords = Array.from({ length: 5 }, (_, index) => ({
id: index + 1,
no: "2026-08-06-001",
content: "客户反映近期电压不稳,设备频繁重启。",
type: "投诉事项",
acceptedAt: "2026-08-06",
company: "国网天津市电力公司",
duration: "06:24",
transcript: "客户反映近期电压不稳,设备频繁重启,要求尽快处理。",
}));
export const importBatches = Array.from({ length: 5 }, (_, index) => ({
id: index + 1,
importedAt: `2026-08-0${index + 2} 12:00`,
batchNo: `2026080${index + 2}-B01`,
count: 157 - index * 8,
importedBy: index % 2 ? "李工" : "张工",
}));
export const taskSummary = {
supervisor: [
["任务总数", 103],
["待分配派工", 22],
["已分派完成", 63],
["待审核", 47],
],
officer: [
["任务总数", 103],
["调查结果填报", 34],
["调查报告编写", 12],
["回访结果填报", 56],
["成果物输出", 60],
["已完成", 69],
],
"customer-manager": [
["任务总数", 103],
["首联分派", 69],
["回访分派", 69],
],
"team-leader": [["待首联", 103], ["已完成", 69]],
operation: [["待调查", 83], ["已完成", 69]],
"duty-manager": [["待调查", 83], ["已完成", 69]],
"business-director": [["待审核", 83], ["已完成", 69]],
};
export const dictionary = {
departments: ["客服一部", "客服二部", "客服三部", "客服五部", "业务部", "运营部"],
firstContactDepartments: ["客服一部", "客服二部", "客服三部", "客服五部"],
supervisors: ["监督员A(张某)", "监督员B(李某)", "监督员C(王某)"],
teamMembers: ["客服一班 / 王小明", "客服二班 / 李小红", "客服三班 / 赵强"],
};
+430
View File
@@ -0,0 +1,430 @@
:root {
font-family: "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
color: #303838;
background: #f3f6f6;
font-synthesis: none;
--brand: #2a8b7f;
--brand-dark: #176a61;
--brand-soft: #e5f5f2;
--line: #dbe3e2;
--muted: #84908f;
--page: #f4f6f7;
--danger: #d85c5c;
}
* { box-sizing: border-box; }
html, body, #app { margin: 0; min-width: 1180px; min-height: 100%; }
body { min-height: 100vh; overflow-x: auto; -webkit-font-smoothing: antialiased; }
button, input, select, textarea { font: inherit; }
button { cursor: pointer; }
.login-screen {
min-height: 100vh;
background-color: #dceeed;
background-image: url("/assets/login-bg.png");
background-repeat: no-repeat;
background-position: center;
background-size: cover;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 14vw;
}
.login-panel { width: 500px; transform: translateY(2px); }
.login-heading { text-align: center; margin-bottom: 75px; }
.login-kicker { color: #111d38; font-size: 52px; font-weight: 800; letter-spacing: 1px; white-space: nowrap; }
.login-kicker span { margin-left: 10px; color: #26887d; }
.login-heading p { color: #51706d; font-size: 24px; margin: 18px 0 0; letter-spacing: 2px; white-space: nowrap; }
.login-card { background: rgba(255,255,255,.96); padding: 46px 42px 71px; border-radius: 14px; box-shadow: 0 24px 70px rgba(35,86,80,.16); }
.login-card h1 { font-size: 34px; text-align: center; margin: 0 0 39px; color: #111d38; }
.login-field { height: 58px; border: 1px solid #4ca69c; border-radius: 4px; display: flex; align-items: center; gap: 14px; padding: 0 18px; margin-bottom: 32px; color: #67a79f; background: #fff; transition: border-color .2s, box-shadow .2s; }
.login-field:focus-within { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(42,139,127,.12); }
.login-field input { flex: 1; border: 0; outline: 0; font-size: 16px; color: #33403f; min-width: 0; }
.captcha-row { display: grid; grid-template-columns: 1fr 160px; gap: 20px; }
.captcha { height: 58px; border: 0; border-radius: 4px; background: #64aaa2; color: #fff; font-size: 24px; font-weight: 800; font-style: italic; letter-spacing: 5px; }
.login-button, .primary-button { color: #fff; border: 1px solid var(--brand); background: var(--brand); border-radius: 7px; font-weight: 600; }
.login-button { width: 100%; height: 58px; font-size: 18px; margin-top: 0; border-radius: 4px; }
.login-button:hover, .primary-button:hover { background: var(--brand-dark); border-color: var(--brand-dark); }
.login-button:disabled { opacity: .7; cursor: wait; }
.primary-button:disabled { opacity: .48; cursor: not-allowed; background: #7fa7a2; border-color: #7fa7a2; }
.portal-shell { min-height: 100vh; background: #f7f9f9; color: #111d38; display: flex; flex-direction: column; }
.portal-header { height: 92px; padding: 0 32px; background: #fff; border-bottom: 1px solid #dfe7e6; display: flex; align-items: center; gap: 14px; position: relative; z-index: 20; }
.portal-brand { display: flex; align-items: center; gap: 15px; font-size: 26px; }
.portal-brand svg { color: var(--brand); font-size: 42px; }
.portal-brand strong { white-space: nowrap; }
.portal-header-spacer { flex: 1; }
.portal-header .role-button { min-width: 154px; }
.portal-role-menu { top: 61px; }
.portal-notice { top: 76px; right: 54px; }
.portal-content { width: min(1328px, calc(100% - 80px)); margin: 0 auto; padding: 62px 0 64px; flex: 1; }
.portal-intro { display: flex; align-items: center; justify-content: space-between; gap: 60px; margin-bottom: 58px; }
.portal-intro h1 { margin: 0; font-size: 40px; line-height: 1.3; letter-spacing: .5px; }
.portal-intro p { margin: 16px 0 0; color: #6d7776; font-size: 18px; }
.portal-search { width: 440px; height: 54px; flex: 0 0 440px; background: #fff; border: 1px solid #bfcfcd; border-radius: 6px; display: flex; align-items: center; gap: 13px; padding: 0 18px; color: #697573; }
.portal-search:focus-within { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(42,139,127,.1); }
.portal-search input { flex: 1; min-width: 0; border: 0; outline: 0; color: #273332; background: transparent; font-size: 16px; }
.portal-section { margin-top: 0; }
.portal-section + .portal-section { margin-top: 42px; }
.portal-section h2 { margin: 0 0 20px; font-size: 20px; color: #18243e; }
.recent-app { width: 100%; height: 148px; padding: 0 28px; background: #fff; border: 1px solid #ccd9d7; border-radius: 8px; display: flex; align-items: center; text-align: left; color: inherit; transition: border-color .18s, box-shadow .18s, transform .18s; }
.recent-app:hover { border-color: var(--brand); box-shadow: 0 10px 28px rgba(31,85,78,.08); transform: translateY(-1px); }
.portal-app-icon { width: 78px; height: 78px; flex: 0 0 78px; border-radius: 10px; background: var(--brand); color: #fff; display: grid; place-items: center; }
.portal-app-icon svg { font-size: 40px; }
.recent-copy { margin-left: 30px; min-width: 0; display: flex; flex-direction: column; gap: 13px; flex: 1; }
.recent-copy strong { font-size: 24px; }
.recent-copy em, .micro-app-copy em { color: #6d7776; font-style: normal; line-height: 1.65; }
.recent-todo { width: 210px; min-height: 82px; padding-left: 30px; border-left: 1px solid #d9e2e1; display: flex; flex-direction: column; justify-content: center; gap: 6px; }
.recent-todo em { color: #6d7776; font-style: normal; }
.recent-todo strong { color: var(--brand); font-size: 31px; }
.portal-enter { min-width: 124px; height: 48px; margin-left: 18px; border: 1px solid var(--brand); border-radius: 6px; color: var(--brand); font-weight: 700; display: inline-flex; align-items: center; justify-content: center; }
.recent-arrow { margin-left: 26px; color: var(--brand); font-size: 18px; }
.micro-app-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
.micro-app-card { min-height: 276px; padding: 30px 26px 26px; border: 1px solid #ccd9d7; border-radius: 8px; background: #fff; color: inherit; text-align: left; display: grid; grid-template-columns: 72px minmax(0, 1fr); grid-template-rows: 1fr auto; gap: 0 22px; transition: border-color .18s, box-shadow .18s, transform .18s; }
.micro-app-card:hover:not(.disabled) { border-color: var(--brand); box-shadow: 0 10px 28px rgba(31,85,78,.08); transform: translateY(-2px); }
.micro-app-card .portal-app-icon { width: 72px; height: 72px; flex-basis: 72px; }
.micro-app-card .portal-app-icon svg { font-size: 36px; }
.micro-app-card.disabled { cursor: default; }
.micro-app-card.disabled .portal-app-icon { background: #64aaa2; }
.micro-app-copy { display: flex; flex-direction: column; gap: 18px; min-width: 0; }
.micro-app-copy > span { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.micro-app-copy strong { font-size: 22px; white-space: nowrap; }
.micro-app-copy i { padding: 5px 12px; border-radius: 16px; background: #f0f1f2; color: #7e8585; font-style: normal; font-size: 13px; white-space: nowrap; }
.micro-app-copy i.available { color: #278778; background: #e3f5ef; }
.micro-app-action { grid-column: 1 / 3; align-self: end; color: #6f7776; font-weight: 700; display: inline-flex; align-items: center; gap: 8px; }
.micro-app-card:not(.disabled) .micro-app-action { color: var(--brand); }
.portal-empty { height: 160px; border: 1px dashed #ccd8d6; border-radius: 8px; background: #fff; color: #8a9492; display: grid; place-items: center; }
.portal-footer { min-height: 88px; padding: 18px 32px; border-top: 1px solid #dfe7e6; background: #fff; color: #53605e; display: flex; align-items: center; justify-content: center; gap: 12px; }
.portal-footer > svg { color: #34465f; font-size: 20px; }
.portal-footer i { width: 1px; height: 26px; background: #d7dfde; margin: 0 14px; }
.portal-footer button { border: 0; background: transparent; color: var(--brand); font-weight: 700; display: inline-flex; align-items: center; gap: 6px; }
.app-shell { min-height: 100vh; display: flex; background: var(--page); }
.sidebar { position: fixed; z-index: 30; inset: 0 auto 0 0; width: 234px; background: #fff; border-right: 1px solid #e7eceb; padding: 0 18px; display: flex; flex-direction: column; }
.brand { height: 70px; display: flex; align-items: center; gap: 11px; font-size: 22px; color: #151b1b; border-bottom: 1px solid #eef1f1; }
.brand svg { color: var(--brand); font-size: 32px; }
.brand strong { white-space: nowrap; }
.app-home-return { width: 100%; padding: 0; background: transparent; border: 0; text-align: left; }
.main-nav { padding: 16px 0; flex: 1; }
.nav-block { margin-bottom: 7px; }
.nav-item { width: 100%; height: 54px; border: 0; border-radius: 8px; background: transparent; display: grid; grid-template-columns: 25px 1fr 16px; align-items: center; text-align: left; gap: 8px; padding: 0 13px; color: #2f3636; font-size: 16px; font-weight: 600; }
.nav-item > svg:first-child { font-size: 20px; color: #5c6968; }
.nav-item:hover { background: #f0f7f6; }
.nav-item.active { background: var(--brand); color: #fff; }
.nav-item.active > svg { color: #fff; }
.nav-arrow { font-size: 12px; justify-self: end; }
.nav-arrow.expanded { transform: rotate(180deg); }
.sub-nav { margin: 4px 0 6px 23px; border-left: 1px solid #d9e0df; padding-left: 12px; }
.sub-nav button { width: 100%; height: 42px; border: 0; background: transparent; color: #5f6968; text-align: left; font-size: 14px; }
.sub-nav button:hover, .sub-nav button.active { color: var(--brand); font-weight: 700; }
.sidebar-foot { padding: 18px 0; color: #a6afae; font-size: 12px; text-align: center; }
.app-main { width: calc(100% - 234px); margin-left: 234px; min-height: 100vh; }
.topbar { height: 70px; position: sticky; top: 0; z-index: 20; background: #e4f4f2; display: flex; align-items: center; padding: 0 27px; gap: 12px; border-bottom: 1px solid #d4e9e6; }
.topbar-spacer { flex: 1; }
.icon-button { width: 36px; height: 36px; border: 0; border-radius: 50%; color: #33897f; background: #fff; display: inline-flex; align-items: center; justify-content: center; font-size: 18px; }
.icon-button.ghost { background: transparent; color: #6b7a78; }
.clock { text-align: right; color: #273130; line-height: 1.15; min-width: 130px; }
.clock span { display: block; font-size: 14px; }
.clock strong { display: block; font-size: 15px; margin-top: 4px; letter-spacing: 1px; }
.divider { height: 34px; width: 1px; background: #b9d2cf; margin: 0 2px; }
.role-switcher { position: relative; }
.role-button { border: 0; background: transparent; min-width: 138px; text-align: right; color: #273130; display: grid; grid-template-columns: 1fr auto 13px; column-gap: 5px; align-items: end; }
.role-button span { grid-column: 1 / 3; font-size: 12px; }
.role-button strong { grid-row: 2; grid-column: 1 / 3; font-size: 14px; }
.role-button svg { grid-row: 2; grid-column: 3; font-size: 10px; align-self: center; }
.role-menu, .notice-popover { position: absolute; top: 58px; right: 68px; background: #fff; box-shadow: 0 14px 40px rgba(28,61,58,.18); border: 1px solid #e1e7e6; border-radius: 9px; padding: 8px; z-index: 50; }
.role-menu { right: 0; width: 190px; }
.role-menu button { width: 100%; height: 38px; border: 0; background: #fff; text-align: left; padding: 0 12px; border-radius: 5px; color: #465150; }
.role-menu button:hover, .role-menu button.active { background: var(--brand-soft); color: var(--brand); }
.role-menu .logout { border-top: 1px solid #edf0f0; color: #b25050; margin-top: 4px; padding-top: 4px; }
.notice-button { position: relative; }
.notice-button i { position: absolute; width: 7px; height: 7px; border-radius: 50%; background: #e63232; right: 3px; top: 2px; }
.notice-popover { width: 270px; right: 62px; top: 62px; padding: 18px; }
.notice-popover strong { font-size: 16px; }
.notice-popover p { margin: 12px 0 0; color: #6b7675; font-size: 13px; }
.workspace { padding: 18px 20px 32px; }
.page-card { min-height: calc(100vh - 120px); background: #fff; border-radius: 10px; border: 1px solid #edf0f0; overflow: hidden; }
.page-heading { min-height: 62px; padding: 0 22px; display: flex; align-items: center; border-bottom: 1px solid #e8ecec; }
.page-heading h1 { margin: 0; font-size: 19px; color: #252e2d; padding-left: 14px; position: relative; }
.page-heading h1::before { content: ""; width: 5px; height: 23px; border-radius: 3px; background: var(--brand); position: absolute; left: 0; top: 0; }
.page-heading .outline-button { margin-left: auto; }
.section-title { margin: 20px 20px 14px; font-weight: 700; font-size: 16px; color: #35403f; display: flex; align-items: center; gap: 8px; }
.section-title::before { content: ""; width: 7px; height: 7px; background: var(--brand); border-radius: 50%; }
.result-count { color: #87918f; font-size: 13px; font-weight: 400; margin-left: auto; }
.primary-button, .outline-button { min-width: 104px; height: 40px; padding: 0 19px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; }
.outline-button { color: var(--brand); background: #fff; border: 1px solid var(--brand); border-radius: 7px; font-weight: 600; }
.outline-button:hover { background: #f0f8f7; }
.text-button, .link-button, .action-button { border: 0; background: transparent; color: var(--brand); }
.text-button { margin-left: auto; display: inline-flex; gap: 5px; align-items: center; }
.button-row { display: flex; justify-content: center; gap: 18px; padding: 28px 0 10px; }
.filter-panel { margin: 20px; padding: 20px; background: #f8faf9; border: 1px solid #e8edec; border-radius: 8px; display: flex; gap: 22px; }
.filter-grid { flex: 1; display: grid; grid-template-columns: repeat(3, minmax(210px, 1fr)); gap: 16px 20px; }
.filter-grid label { display: grid; grid-template-columns: 96px 1fr; align-items: center; gap: 8px; color: #596361; font-size: 14px; }
.filter-grid input, .filter-grid select, .field { width: 100%; height: 40px; padding: 0 12px; border: 1px solid #cfd7d6; border-radius: 5px; background: #fff; color: #394442; outline: none; }
.filter-grid input:focus, .filter-grid select:focus, .field:focus { border-color: var(--brand); box-shadow: 0 0 0 2px rgba(42,139,127,.1); }
.field[readonly], textarea.field[readonly] { background: #f1f8f7; color: #596663; }
.filter-actions { align-self: end; display: flex; gap: 10px; }
.summary-grid { display: flex; padding: 20px; gap: 15px; overflow-x: auto; }
.summary-grid button { min-width: 160px; flex: 1; height: 88px; border: 1px solid #d9e5e3; border-radius: 8px; background: #fff; padding: 13px 16px; text-align: left; position: relative; transition: transform .2s, border-color .2s; display: flex; align-items: center; gap: 14px; }
.summary-grid button:hover { transform: translateY(-2px); border-color: var(--brand); }
.summary-grid button.active { background: var(--brand-soft); border-color: var(--brand); }
.summary-grid span { display: block; color: #687371; }
.summary-grid .summary-icon { flex: 0 0 48px; width: 48px; height: 48px; border-radius: 7px; display: grid; place-items: center; color: #fff; background: linear-gradient(145deg, #65c6ba, #168b7e); box-shadow: 0 4px 10px rgba(22,139,126,.16); }
.summary-grid .summary-icon svg { font-size: 23px; }
.summary-grid .summary-copy > span { margin-bottom: 5px; white-space: nowrap; }
.summary-grid strong { color: var(--brand); font-size: 25px; }
.summary-grid em { margin-left: 4px; font-style: normal; font-size: 12px; color: #86908f; }
.table-wrap { margin: 0 20px 18px; overflow-x: auto; }
.data-table { width: 100%; min-width: 1180px; border-collapse: collapse; font-size: 13px; }
.data-table th { height: 48px; background: #dcefed; color: #273332; font-weight: 700; white-space: nowrap; border-bottom: 2px solid #72b8b0; padding: 0 12px; }
.data-table td { height: 52px; text-align: center; padding: 8px 12px; color: #45504e; border-bottom: 1px solid #edf1f0; }
.data-table tbody tr:nth-child(even) { background: #f2f9f8; }
.data-table tbody tr:hover { background: #eaf5f3; }
.data-table .address { min-width: 170px; }
.data-table.compact { min-width: 700px; }
.data-table.compact td { height: 46px; }
.link-button { text-decoration: underline; text-underline-offset: 3px; }
.action-button { padding: 5px 10px; border: 1px solid #8ec7c0; border-radius: 4px; }
.status { padding: 4px 9px; border-radius: 12px; font-size: 12px; }
.status.pending { background: #fff1d9; color: #a76a08; }
.status.progress { background: #e1f1ff; color: #3474a8; }
.status.done { background: #e3f5e9; color: #368051; }
.empty-cell { height: 150px !important; color: #929c9a !important; }
.pagination { display: flex; justify-content: flex-end; align-items: center; gap: 6px; padding-top: 16px; color: #7c8785; }
.pagination button { min-width: 30px; height: 30px; border: 1px solid #d6dddc; background: #fff; border-radius: 4px; color: #596462; }
.pagination button.active { background: var(--brand); color: #fff; border-color: var(--brand); }
.import-layout { display: grid; grid-template-columns: 1.1fr 1fr; gap: 24px; padding: 0 20px; }
.import-box, .batch-panel, .preview-panel, .sla-card, .rule-card, .chart-grid section { border: 1px solid #e1e8e7; border-radius: 8px; background: #fff; }
.import-box { padding: 0 20px 20px; }
.import-main { width: min(1500px, calc(100% - 40px)); margin: 22px auto; border: 0; }
.import-main > .form-row { width: min(1160px, 100%); margin-left: auto; margin-right: auto; }
.import-main .file-drop { margin: 42px 0 4px; min-height: 360px; }
.batch-panel { overflow: hidden; }
.batch-panel .table-wrap, .preview-panel .table-wrap { margin-bottom: 20px; }
.file-drop { position: relative; min-height: 92px; border: 1px dashed #79bcb4; border-radius: 7px; flex: 1; display: flex; align-items: center; justify-content: center; gap: 15px; color: var(--brand); background: #f5fbfa; }
.file-drop > svg { font-size: 30px; }
.file-drop div { display: flex; flex-direction: column; gap: 7px; }
.file-drop span { color: #8a9492; font-size: 12px; }
.file-drop input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.preview-panel { margin: 22px 20px; }
.detail-section { margin: 0 20px 22px; }
.info-grid { border: 1px solid #8fc6bf; background: #f0f9f8; border-radius: 8px; padding: 23px 28px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 24px 34px; }
.info-grid div { display: grid; grid-template-columns: 130px 1fr; gap: 10px; align-items: center; }
.info-grid span { color: #85908f; text-align: right; }
.info-grid strong { color: #37413f; }
.service-title { margin-left: 0; margin-right: 0; }
.detail-section .table-wrap { margin: 0; }
.service-table th:nth-child(3), .service-table th:nth-child(8) { min-width: 220px; }
.audio-button { border: 0; background: transparent; color: #67b2aa; display: inline-flex; flex-direction: column; gap: 2px; align-items: center; }
.audio-button svg { font-size: 23px; }
.audio-button span { font-size: 11px; color: #899290; }
.form-section { border-top: 1px solid #eef1f0; padding-top: 2px; }
.wide-form { width: min(1060px, 86%); margin: 0 auto; }
.form-row { display: flex; align-items: flex-start; gap: 20px; margin: 18px 0; }
.form-row > label:first-child { width: 150px; text-align: right; padding-top: 10px; color: #414b49; flex: none; }
.form-row .field { flex: 1; }
textarea.field { min-height: 78px; resize: vertical; padding: 11px 12px; line-height: 1.6; }
textarea.field.large { min-height: 130px; }
.radio-group { min-height: 40px; display: flex; align-items: center; gap: 34px; }
.radio-group label, .checkbox-select label { display: inline-flex; align-items: center; gap: 7px; color: #4d5856; }
input[type="radio"], input[type="checkbox"] { accent-color: var(--brand); width: 16px; height: 16px; }
.checkbox-select { flex: 1; min-height: 42px; border: 1px solid #d4dcdb; border-radius: 6px; padding: 10px 15px; display: flex; flex-wrap: wrap; gap: 12px 28px; }
.long-label > label:first-child { width: 360px; line-height: 1.6; padding-top: 0; }
.tip, .branch-tip, .flow-tip { background: #eff8f7; border: 1px solid #cce6e2; border-radius: 6px; color: #47716c; padding: 12px 15px; display: flex; align-items: center; gap: 9px; font-size: 13px; }
.branch-tip { margin: 18px 0; }
.flow-tip { margin: 4px 0 18px 142px; line-height: 1.65; }
.static-value { color: #253833; line-height: 38px; }
.required-files { margin: 20px 0; }
.review-actions { margin-top: 20px; border-top: 1px solid #edf1f0; padding-top: 2px; }
.upload-list { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; margin: 20px 0; }
.upload-list > div { display: grid; gap: 10px; }
.upload-slot { min-height: 110px; border: 1px dashed #78b9b1; background: #f6fbfa; color: var(--brand); border-radius: 7px; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 8px; }
.upload-slot svg { font-size: 26px; }
.upload-slot.uploaded { border-style: solid; background: #eaf7f4; }
.upload-file-row { min-height: 118px; display: grid; grid-template-columns: 190px 92px minmax(0, 1fr); align-items: center; gap: 22px; }
.upload-file-row > strong { text-align: right; }
.upload-square { width: 92px; height: 92px; border: 1px solid #c7d2d0; border-radius: 7px; background: #fff; color: #8c9795; display: grid; place-items: center; font-size: 30px; }
.upload-square.uploaded { color: var(--brand); border-color: var(--brand); background: #eff8f7; }
.upload-file-name { justify-self: start; border: 0; border-bottom: 1px solid #8d9997; background: transparent; color: #74817f; padding: 4px 0; }
.upload-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-top: 16px; }
.upload-grid button { min-height: 116px; border: 1px dashed #78b9b1; background: #f6fbfa; color: var(--brand); border-radius: 7px; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 6px; }
.upload-grid button > svg { font-size: 26px; }
.upload-grid button span { font-size: 12px; color: #8a9492; }
.report-editor { max-width: 1120px; }
.deliverable-list { display: grid; gap: 12px; margin-bottom: 18px; }
.deliverable-list article { min-height: 70px; border: 1px solid #dce5e3; background: #fff; border-radius: 7px; display: flex; align-items: center; gap: 14px; padding: 12px 16px; }
.deliverable-list article > svg { color: var(--brand); font-size: 26px; }
.deliverable-list article div { display: flex; flex-direction: column; gap: 5px; flex: 1; }
.deliverable-list article span { color: #8b9493; font-size: 12px; }
.add-deliverable { min-height: 76px; border: 1px dashed #76b6ae; background: #f6fbfa; color: var(--brand); border-radius: 7px; display: flex; align-items: center; justify-content: center; gap: 9px; }
.rules-layout { display: grid; grid-template-columns: 1.15fr .85fr; gap: 24px; padding: 20px; }
.rules-layout.single { grid-template-columns: 1fr; max-width: 980px; margin: 0 auto; }
.prototype-blank { min-height: calc(100vh - 185px); }
.sla-card, .rule-card { padding: 0 20px 22px; }
.total-limit { display: flex; align-items: center; gap: 12px; padding: 16px 20px; border-radius: 7px; background: #f2f8f7; }
.total-limit input { width: 120px; height: 42px; border: 1px solid #b9cfcc; border-radius: 5px; padding: 0 10px; color: var(--brand); font-weight: 700; font-size: 20px; }
.total-limit em, .sla-grid span { color: #8b9593; font-style: normal; }
.sla-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px 22px; margin-top: 22px; }
.sla-grid label { display: grid; grid-template-columns: 110px 1fr 42px; align-items: center; gap: 8px; color: #4d5755; }
.sla-grid input { height: 40px; border: 1px solid #ced7d5; border-radius: 5px; padding: 0 10px; }
.rule-empty { min-height: 380px; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #82908d; text-align: center; }
.rule-empty > svg { font-size: 52px; color: #83bfb8; }
.rule-empty strong { color: #394542; font-size: 19px; margin-top: 16px; }
.rule-empty p { max-width: 320px; line-height: 1.7; }
.analytics-grid { padding: 20px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; }
.analytics-grid article { border: 1px solid #e1e9e7; border-radius: 8px; padding: 22px; box-shadow: 0 5px 18px rgba(37,91,84,.05); }
.analytics-grid article span { color: #74807e; }
.analytics-grid article strong { display: block; color: var(--brand); font-size: 30px; margin: 14px 0 8px; }
.analytics-grid article em { color: #5f8f74; font-size: 12px; font-style: normal; }
.chart-grid { padding: 0 20px 22px; display: grid; grid-template-columns: 1.4fr 1fr; gap: 18px; }
.bar-chart { height: 310px; padding: 30px 30px 20px; display: flex; align-items: flex-end; justify-content: space-around; border-top: 1px solid #eef1f0; }
.bar-chart > div { height: 240px; width: 9%; display: flex; flex-direction: column; justify-content: flex-end; align-items: center; gap: 9px; }
.bar-chart span { width: 100%; background: #76bdb4; border-radius: 5px 5px 0 0; }
.bar-chart em { color: #899391; font-size: 11px; font-style: normal; white-space: nowrap; }
.donut-wrap { height: 310px; border-top: 1px solid #eef1f0; display: flex; align-items: center; justify-content: center; gap: 42px; }
.donut { width: 160px; height: 160px; border: 30px solid #68b4ab; border-right-color: #a2d0cb; border-bottom-color: #d3e9e6; border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.donut strong { font-size: 23px; }
.donut span { color: #8b9492; font-size: 12px; }
.donut-wrap ul { list-style: none; margin: 0; padding: 0; }
.donut-wrap li { margin: 13px 0; color: #66716f; font-size: 13px; }
.donut-wrap li i { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: #68b4ab; margin-right: 8px; }
.donut-wrap li i.c2 { background: #86c3bc; }.donut-wrap li i.c3 { background: #a2d0cb; }.donut-wrap li i.c4 { background: #d3e9e6; }
.modal-mask { position: fixed; z-index: 100; inset: 0; background: rgba(24,35,34,.52); display: flex; align-items: center; justify-content: center; padding: 30px; }
.modal { width: min(760px, 90vw); background: #f4fbfa; border-radius: 18px; padding: 30px 40px 34px; box-shadow: 0 20px 80px rgba(0,0,0,.22); position: relative; }
.small-modal { width: min(1080px, 90vw); }
.assignment-modal { width: min(1080px, 90vw); transform: translateY(-100px); }
.review-modal-v2 { width: min(1080px, 90vw); transform: translateY(-30px); }
.modal-inner-panel { min-height: 265px; background: #fff; border: 1px solid #d5dfdd; border-radius: 8px; padding: 32px 40px 36px; }
.review-modal-v2 .modal-inner-panel { min-height: 504px; }
.modal-inner-panel .section-title { margin-left: 0; }
.modal-inner-panel .form-row { max-width: 520px; margin: 22px 0; }
.modal-inner-panel .form-row > label:first-child { width: 75px; }
.modal-inner-panel .form-row .field { height: 58px; }
.modal-inner-panel .section-title { font-size: 18px; margin-top: 24px; margin-bottom: 16px; }
.modal-inner-panel .section-title:first-child { margin-top: 0; }
.modal-inner-panel textarea.review-reason { min-height: 112px; resize: vertical; }
.inline-callback-files { margin-left: 170px; padding: 8px 0 12px; }
.modal h2 { margin: 0 0 28px; font-size: 28px; }
.modal-close { position: absolute; right: 24px; top: 22px; border: 0; background: transparent; color: #879390; font-size: 22px; }
.modal .section-title { margin-left: 0; }
.modal > .button-row { gap: 20px; padding-top: 30px; }
.modal > .button-row .primary-button, .modal > .button-row .outline-button { min-width: 168px; height: 58px; font-size: 17px; }
.review-order { background: #fff; border: 1px solid #d6e3e1; border-radius: 7px; display: grid; grid-template-columns: 130px 1fr 100px 1fr 100px 1fr; padding: 16px; gap: 8px; font-size: 13px; }
.review-order span { color: #86908e; }.review-order strong { color: #3d4846; }
.toast { position: fixed; z-index: 200; left: 50%; top: 95px; transform: translateX(-50%); background: #234f49; color: #fff; padding: 13px 20px; border-radius: 7px; box-shadow: 0 10px 35px rgba(0,0,0,.2); display: flex; gap: 9px; align-items: center; }
.toast-enter-active, .toast-leave-active { transition: opacity .2s, transform .2s; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translate(-50%,-10px); }
@media (max-width: 1360px) {
.portal-content { width: calc(100% - 48px); }
.portal-intro { margin-bottom: 44px; }
.micro-app-copy strong { font-size: 19px; }
.filter-grid { grid-template-columns: repeat(2, minmax(210px, 1fr)); }
.info-grid { grid-template-columns: repeat(3, 1fr); }
.info-grid div { grid-template-columns: 110px 1fr; }
.summary-grid button { min-width: 145px; }
}
@media (max-width: 900px) {
html, body, #app { min-width: 0; }
body { overflow-x: hidden; }
.portal-header { height: 70px; padding: 0 16px; }
.portal-brand { font-size: 20px; }
.portal-brand svg { font-size: 32px; }
.portal-header .clock, .portal-header .divider { display: none; }
.portal-content { width: calc(100% - 32px); padding: 34px 0 42px; }
.portal-intro { align-items: stretch; flex-direction: column; gap: 26px; margin-bottom: 36px; }
.portal-intro h1 { font-size: 30px; }
.portal-search { width: 100%; flex-basis: 52px; }
.recent-app { height: auto; min-height: 190px; padding: 24px; flex-wrap: wrap; }
.recent-copy { flex-basis: calc(100% - 110px); }
.recent-todo { width: auto; min-height: 64px; margin: 20px 0 0 100px; padding: 0 22px 0 0; border-left: 0; }
.portal-enter { margin-top: 20px; }
.micro-app-grid { grid-template-columns: 1fr; }
.micro-app-card { min-height: 210px; }
.portal-footer { text-align: center; }
.sidebar { display: none; }
.app-shell, .app-main, .workspace, .page-card { min-width: 0; }
.app-main { width: 100%; margin-left: 0; }
.topbar { padding: 0 12px; gap: 8px; }
.clock, .divider { display: none; }
.workspace { padding: 10px 8px 24px; }
.page-heading { padding: 0 14px; }
.page-heading h1 { font-size: 17px; }
.summary-grid { padding: 12px; gap: 10px; }
.summary-grid button { flex: 0 0 178px; }
.filter-panel { margin: 12px; padding: 14px; flex-direction: column; }
.filter-grid { grid-template-columns: 1fr; gap: 12px; }
.filter-grid label { grid-template-columns: 82px minmax(0, 1fr); }
.filter-actions { align-self: stretch; justify-content: flex-end; }
.table-wrap { margin: 0 12px 14px; max-width: calc(100vw - 42px); }
.detail-section { margin: 0 12px 18px; }
.info-grid { grid-template-columns: 1fr; gap: 12px; padding: 16px; }
.info-grid div { grid-template-columns: 112px minmax(0, 1fr); }
.info-grid span { text-align: left; }
.import-layout, .rules-layout, .chart-grid { grid-template-columns: 1fr; padding: 0 12px; }
.analytics-grid { grid-template-columns: repeat(2, 1fr); padding: 12px; }
.wide-form { width: 100%; }
.form-row { flex-direction: column; gap: 7px; margin: 14px 0; }
.form-row > label:first-child, .long-label > label:first-child { width: auto; text-align: left; padding-top: 0; }
.form-row .field, .checkbox-select { width: 100%; }
.flow-tip { margin: 4px 0 16px; }
.upload-grid { grid-template-columns: 1fr; }
.upload-list { grid-template-columns: 1fr; }
.upload-file-row { grid-template-columns: 1fr; gap: 8px; padding: 12px 0; }
.upload-file-row > strong { text-align: left; }
.upload-square { width: 100%; height: 72px; }
.review-order { grid-template-columns: 110px 1fr; }
.modal-mask { padding: 12px; }
.modal { width: 100%; max-height: 92vh; overflow-y: auto; padding: 25px 20px 28px; }
.review-modal-v2 { transform: none; }
.assignment-modal { transform: none; }
}
@media (max-width: 520px) {
.portal-header > .icon-button, .portal-header .role-switcher { display: none; }
.portal-brand { font-size: 18px; }
.portal-content { width: calc(100% - 24px); }
.portal-intro h1 { font-size: 26px; }
.portal-intro p { font-size: 15px; }
.recent-app { display: grid; grid-template-columns: 66px 1fr; gap: 16px; }
.recent-app .portal-app-icon { width: 66px; height: 66px; flex-basis: 66px; }
.recent-copy { margin-left: 0; }
.recent-copy strong { font-size: 20px; }
.recent-todo { margin: 14px 0 0; padding: 0; }
.portal-enter { min-width: 112px; margin: 14px 0 0; }
.recent-arrow { display: none; }
.micro-app-card { grid-template-columns: 60px minmax(0, 1fr); padding: 22px 18px; gap: 0 16px; }
.micro-app-card .portal-app-icon { width: 60px; height: 60px; }
.micro-app-copy strong { white-space: normal; }
.portal-footer i, .portal-footer button { display: none; }
.topbar > .icon-button:not(.ghost) { display: none; }
.role-button { min-width: 132px; }
.login-screen { justify-content: center; padding: 24px 16px; background-position: 38% center; }
.login-panel { width: 100%; transform: none; }
.login-heading { margin-bottom: 24px; }
.login-kicker { font-size: 25px; }
.login-kicker span { display: block; margin: 8px 0 0; }
.login-heading p { font-size: 13px; }
.login-card { padding: 30px 22px 34px; }
.login-card h1 { font-size: 21px; }
.captcha-row { grid-template-columns: minmax(0, 1fr) 96px; }
.captcha-row .login-field { min-width: 0; }
.analytics-grid, .sla-grid { grid-template-columns: 1fr; }
.donut-wrap { flex-direction: column; height: auto; padding: 28px 0; }
.button-row { flex-wrap: wrap; padding-left: 8px; padding-right: 8px; }
}
+68
View File
@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { access } from "node:fs/promises";
import test from "node:test";
import worker from "../worker/index.js";
test("serves existing static assets without a fallback", async () => {
const calls = [];
const response = await worker.fetch(new Request("https://example.test/assets/app.js"), {
ASSETS: {
fetch: async (request) => {
calls.push(new URL(request.url).pathname);
return new Response("asset", { status: 200 });
},
},
});
assert.equal(response.status, 200);
assert.deepEqual(calls, ["/assets/app.js"]);
});
test("falls back to index.html for an unknown app route", async () => {
const calls = [];
const response = await worker.fetch(
new Request("https://example.test/flow/step-two?source=share", {
headers: { accept: "text/html" },
}),
{
ASSETS: {
fetch: async (request) => {
const url = new URL(request.url);
calls.push(url.pathname + url.search);
return new Response(url.pathname === "/index.html" ? "app" : "missing", {
status: url.pathname === "/index.html" ? 200 : 404,
});
},
},
},
);
assert.equal(response.status, 200);
assert.deepEqual(calls, ["/flow/step-two?source=share", "/index.html"]);
});
test("does not turn missing API or write requests into the app shell", async () => {
for (const request of [
new Request("https://example.test/api/missing", { headers: { accept: "application/json" } }),
new Request("https://example.test/flow", { method: "POST", headers: { accept: "text/html" } }),
]) {
let calls = 0;
const response = await worker.fetch(request, {
ASSETS: {
fetch: async () => {
calls += 1;
return new Response("missing", { status: 404 });
},
},
});
assert.equal(response.status, 404);
assert.equal(calls, 1);
}
});
test("emits the files required by Sites packaging", async () => {
await access(new URL("../dist/client/index.html", import.meta.url));
await access(new URL("../dist/server/index.js", import.meta.url));
await access(new URL("../dist/.openai/hosting.json", import.meta.url));
});
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
build: {
outDir: "dist/client",
},
optimizeDeps: {
include: ["vue", "@ant-design/icons-vue"],
},
server: {
host: "0.0.0.0",
allowedHosts: ["terminal.local"],
warmup: {
clientFiles: ["./src/main.js", "./src/App.vue"],
},
},
plugins: [vue()],
});
+15
View File
@@ -0,0 +1,15 @@
export default {
async fetch(request, env) {
const response = await env.ASSETS.fetch(request);
const acceptsHtml = request.headers.get("accept")?.includes("text/html");
if (response.status !== 404 || !acceptsHtml || !["GET", "HEAD"].includes(request.method)) {
return response;
}
const indexUrl = new URL(request.url);
indexUrl.pathname = "/index.html";
indexUrl.search = "";
return env.ASSETS.fetch(new Request(indexUrl, request));
},
};