prototype generated
@@ -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));
|
||||
},
|
||||
};
|
||||