prototype generated
@@ -0,0 +1,14 @@
|
|||||||
|
name = "luna"
|
||||||
|
description = "低成本实现智能体,负责需求明确的功能、测试、局部重构和明确缺陷修复。"
|
||||||
|
model = "gpt-5.6-luna"
|
||||||
|
model_reasoning_effort = "max"
|
||||||
|
sandbox_mode = "workspace-write"
|
||||||
|
developer_instructions = """
|
||||||
|
只有在主智能体已经明确目标、涉及文件或子系统、约束、验收标准和验证目标后,才作为实现执行者开展工作。
|
||||||
|
负责边界清晰的功能实现、单元测试补齐、局部重构、机械性修改、明确缺陷修复和局部代码审查。
|
||||||
|
保持上下文精简,严格遵循既定方案;未经明确授权不得修改接口或数据结构,并始终实施最小且正确的改动。
|
||||||
|
使用针对性测试验证要求的行为,返回简洁总结,不要堆积原始探索日志。
|
||||||
|
不得负责开放式根因分析、仅在生产环境出现的疑难问题、架构审查,或需要在多个子系统间持续演进假设的调试工作。
|
||||||
|
需求含糊、证据与既定方案冲突或工作范围扩展到多个模块时,必须停止并向主智能体报告阻碍;应建议交由高级执行智能体处理,不得继续猜测性探索。
|
||||||
|
代码库中还有其他协作者:不得撤销他人的修改,并应主动适配并行产生的变更。
|
||||||
|
"""
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
name = "spark"
|
||||||
|
description = "快速探索与微小修改智能体,负责代码搜索、结构定位、简单验证和边界明确的小改动。"
|
||||||
|
model = "gpt-5.3-codex-spark"
|
||||||
|
model_reasoning_effort = "high"
|
||||||
|
sandbox_mode = "workspace-write"
|
||||||
|
developer_instructions = """
|
||||||
|
优先快速完成代码搜索、仓库结构定位、符号与调用点查找、简单验证以及明确的微小修改。
|
||||||
|
只进行有针对性的搜索和文件读取,返回提炼后的结论,不要堆积原始日志。
|
||||||
|
修改代码时只做能够成立的最小改动,避免无关重构和大范围清理。
|
||||||
|
使用范围最小且有效的测试、类型检查、静态检查或构建命令,只验证本次修改影响的行为。
|
||||||
|
不得负责模糊的根因调查、跨模块实现或架构决策。
|
||||||
|
任务超出小而明确的范围时必须停止,将证据上报主智能体,并建议交由高级执行智能体继续处理。
|
||||||
|
代码库中还有其他协作者:不得撤销他人的修改,并应主动适配并行产生的变更。
|
||||||
|
"""
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
name = "terra"
|
||||||
|
description = "高级执行智能体,负责复杂修改、跨模块调试和深度代码审查。"
|
||||||
|
model = "gpt-5.6-terra"
|
||||||
|
model_reasoning_effort = "high"
|
||||||
|
sandbox_mode = "workspace-write"
|
||||||
|
developer_instructions = """
|
||||||
|
作为主智能体下属的高级工程执行者开展工作。
|
||||||
|
负责复杂实现、跨模块修改、需要持续修正假设的调试,以及从调查到验证的深度正确性审查。
|
||||||
|
在多次工具调用中持续维护任务的系统约束,追踪真实执行路径,并明确区分证据与假设。
|
||||||
|
优先采用能够解决根因的最小可维护方案,保持无关行为不变,并按需新增或更新针对性测试。
|
||||||
|
执行与风险相称的检查,返回便于决策的简洁总结,包括根因、修改文件、验证结果和剩余风险。
|
||||||
|
涉及架构、产品范围或不可逆设计决策时,必须上报主智能体,不得擅自决定。
|
||||||
|
代码库中还有其他协作者:不得撤销他人的修改,并应主动适配并行产生的变更。
|
||||||
|
"""
|
||||||
@@ -61,6 +61,9 @@ cover/
|
|||||||
local_settings.py
|
local_settings.py
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
db.sqlite3-journal
|
db.sqlite3-journal
|
||||||
|
deprecated/backend/risk.db
|
||||||
|
deprecated/backend/risk.db.*.bak
|
||||||
|
deprecated/backend/tests/test-risk.db
|
||||||
|
|
||||||
# Flask stuff:
|
# Flask stuff:
|
||||||
instance/
|
instance/
|
||||||
@@ -217,6 +220,7 @@ build/Release
|
|||||||
|
|
||||||
# Dependency directories
|
# Dependency directories
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.logs/
|
||||||
jspm_packages/
|
jspm_packages/
|
||||||
|
|
||||||
# Snowpack dependency directory (https://snowpack.dev/)
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
@@ -312,3 +316,5 @@ dist
|
|||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
# project
|
||||||
|
deprecated/
|
||||||
@@ -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`。
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 风险防控工单域
|
||||||
|
|
||||||
|
本上下文统一 12398 风险防控工单在派工、调查、审核、回访和报告环节中的业务语言,避免将角色、责任类型与流程状态混用。
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
**12398 工单**:
|
||||||
|
由 12398 渠道取得并导入系统、关联 95598 处理记录的核心业务对象。
|
||||||
|
_Avoid_: 投诉单、事件单、任务单
|
||||||
|
|
||||||
|
**服务监督主管**:
|
||||||
|
负责判定责任类型并完成主管派工的业务角色,也是全面调查的一级审核人。
|
||||||
|
_Avoid_: 主管、监督主管
|
||||||
|
|
||||||
|
**服务监督员**:
|
||||||
|
派工后承担全面调查、回访结果确认、调查报告和成果物输出的业务角色。
|
||||||
|
_Avoid_: 服务监督人员、调查员
|
||||||
|
|
||||||
|
**责任类型**:
|
||||||
|
服务监督主管在派工阶段对工单作出的主责或协查判定;该判定是后续前置条件和审核分支的业务事实。
|
||||||
|
_Avoid_: 责任状态、工单类型
|
||||||
|
|
||||||
|
**主责**:
|
||||||
|
需要同时完成首联和全部并行初查,业务部主任审核通过后进入回访的责任类型。
|
||||||
|
|
||||||
|
**协查**:
|
||||||
|
不产生首联任务、派工后直接进入多部门并行事件初查,业务部主任审核通过后直接进入调查报告的责任类型。
|
||||||
|
|
||||||
|
**首联**:
|
||||||
|
主责工单中客服班组首次联系客户形成的业务记录。
|
||||||
|
_Avoid_: 首次回访、回访
|
||||||
|
|
||||||
|
**事件初查**:
|
||||||
|
一个或多个被派部门并行完成的初步调查任务集合。
|
||||||
|
_Avoid_: 全面调查、调查结果
|
||||||
|
|
||||||
|
**全面调查**:
|
||||||
|
服务监督员在派工后即可先行填报并保存草稿、满足责任类型对应前置条件后才能提交的完整调查结果。
|
||||||
|
_Avoid_: 事件初查、业务审核
|
||||||
|
|
||||||
|
**业务部主任审核**:
|
||||||
|
全面调查的二级审核;审核通过后依据派工阶段的责任类型创建唯一后续分支。
|
||||||
|
_Avoid_: 主管审核、调查审核
|
||||||
|
|
||||||
|
**回访**:
|
||||||
|
仅主责工单进入的客户联系与结果确认环节;结果使用“同意”或“不同意或客户主动要求”。
|
||||||
|
_Avoid_: 首联、满意度调查
|
||||||
|
|
||||||
|
**调查报告**:
|
||||||
|
基于已确认调查数据及主责回访数据形成的受控输出版本;协查报告不依赖首联或回访。
|
||||||
|
_Avoid_: 全面调查、成果物
|
||||||
@@ -1,2 +1,7 @@
|
|||||||
# risk-management-process
|
# risk-management-process
|
||||||
|
|
||||||
|
## 工作说明
|
||||||
|
|
||||||
|
当前项目聚焦于 `frontend/`:直接依据原型界面设计复现前端,由外部团队实现后端,本项目负责接口规范设计、前端界面实现和前后端联调。
|
||||||
|
|
||||||
|
此前探索的完整系统实现、需求方案文档和构建产物已迁移至 `deprecated/`,仅作历史参考,不再作为当前实现依据。
|
||||||
|
|||||||
@@ -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 冲突,必须明确指出冲突及理由,不得静默覆盖既有决策。
|
||||||
@@ -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 添加摘要和链接
|
||||||
@@ -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>` 中携带。
|
||||||
|
|
||||||
|
---
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fund=false
|
||||||
|
audit=false
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"d1": null,
|
||||||
|
"r2": null
|
||||||
|
}
|
||||||
@@ -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`.
|
||||||
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 305 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 117 KiB |
@@ -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
|
||||||
|
After Width: | Height: | Size: 701 KiB |
|
After Width: | Height: | Size: 660 KiB |
|
After Width: | Height: | Size: 406 KiB |
|
After Width: | Height: | Size: 946 KiB |
|
After Width: | Height: | Size: 744 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 960 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 655 KiB |
|
After Width: | Height: | Size: 873 KiB |
|
After Width: | Height: | Size: 933 KiB |
|
After Width: | Height: | Size: 979 KiB |
|
After Width: | Height: | Size: 613 KiB |
|
After Width: | Height: | Size: 993 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 449 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 136 KiB |
@@ -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>
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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 调查报告编写。
|
||||||
|
After Width: | Height: | Size: 1.8 MiB |
@@ -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");
|
||||||
@@ -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>支持 Excel、CSV,单个文件不超过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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
createApp(App).mount("#app");
|
||||||
@@ -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: ["客服一班 / 王小明", "客服二班 / 李小红", "客服三班 / 赵强"],
|
||||||
|
};
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
@@ -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()],
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
},
|
||||||
|
};
|
||||||