feat(ui): refresh crm shell and customer workspace
This commit is contained in:
@@ -51,6 +51,8 @@ logs/
|
|||||||
# ========================
|
# ========================
|
||||||
tmp/
|
tmp/
|
||||||
.cache/
|
.cache/
|
||||||
|
.tsbuild/
|
||||||
|
*.tsbuildinfo
|
||||||
*.tmp
|
*.tmp
|
||||||
*.swp
|
*.swp
|
||||||
*~
|
*~
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
# SHBL-ERP CRM 小版本升级审查与执行计划
|
||||||
|
|
||||||
|
生成日期: 2026-05-11
|
||||||
|
当前分支: `ui-change`
|
||||||
|
适用范围: 下一个小版本迭代,重点是 UI 改进、测试流程补强、安全加固,以及为后续去除 Dify 依赖做架构准备。
|
||||||
|
|
||||||
|
## 1. 目标与边界
|
||||||
|
|
||||||
|
本轮小版本不建议直接替换 Dify,也不建议一次性重构所有业务页面。当前系统已经存在可运行的业务骨架,下一步更适合先把基础质量稳住,再做可见的 UI 改进。
|
||||||
|
|
||||||
|
本轮目标:
|
||||||
|
|
||||||
|
- 建立更可信的测试与 CI 流程,避免失败被吞掉。
|
||||||
|
- 修复 AI 回调和工具接口中明显的鉴权风险。
|
||||||
|
- 建立前端设计基座,让后续页面改版有统一标准。
|
||||||
|
- 优先重做首页、客户、订单、财务等高频页面的视觉和交互体验。
|
||||||
|
- 抽出 AI Provider 适配层,让 Dify 仍可用,同时为后续 OpenAI 标准接口迁移铺路。
|
||||||
|
|
||||||
|
本轮不做:
|
||||||
|
|
||||||
|
- 不立即删除 Dify。
|
||||||
|
- 不做数据库大迁移。
|
||||||
|
- 不大规模改动所有业务逻辑。
|
||||||
|
- 不把旧页面全部一次性重写。
|
||||||
|
|
||||||
|
### UI 参考包审查结论: Aura Finance CRM
|
||||||
|
|
||||||
|
参考资产: `stitch_aura_finance_crm.zip`。该包包含 `code.html`、`DESIGN.md`、`screen.png` 三个文件,可作为小版本 UI 改进的视觉方向参考。
|
||||||
|
|
||||||
|
结论: 该方案部分符合本系统改版目标,适合作为“清爽、高端、低噪音、数据表格优先”的视觉标杆,但不能直接作为生产实现照搬。
|
||||||
|
|
||||||
|
可采纳内容:
|
||||||
|
|
||||||
|
- 浅色工作台背景、低饱和蓝色主色、深色文字和弱边框的整体基调。
|
||||||
|
- 左侧导航、顶部搜索、页面标题、主操作按钮、表格卡片的页面组织方式。
|
||||||
|
- 状态标签、金额右对齐、客户名称加辅助信息的表格扫描体验。
|
||||||
|
- `DESIGN.md` 中的色彩、间距、字号、阴影、表格密度等 token 思路。
|
||||||
|
- `screen.png` 中客户列表页的干净层级,可作为客户、订单、财务列表页的首批改版参考。
|
||||||
|
|
||||||
|
必须调整内容:
|
||||||
|
|
||||||
|
- 当前项目是 Vue 3、Vite、Element Plus 技术栈,不能引入 Tailwind CDN 作为生产依赖,也不要直接复制 `code.html`。
|
||||||
|
- 不能依赖 Google Fonts、Material Symbols、`lh3.googleusercontent.com` 等远程字体和图片资源;图标优先使用 `@element-plus/icons-vue`,字体使用本地系统字体或经批准的本地字体方案。
|
||||||
|
- 参考包是英文静态样张,实际页面必须使用 SHBL CRM 的中文业务文案、现有路由和真实字段。
|
||||||
|
- 样张大量使用 `h-screen`、固定侧边栏、固定顶部栏和 12 列表格,必须补充窄屏笔记本和低分辨率下的响应式策略。
|
||||||
|
- `DESIGN.md` 建议 16px 到 20px 容器圆角,和本项目业务系统的紧凑风格略有冲突;小版本默认收敛为 8px 主圆角,少量重点容器最高 12px。
|
||||||
|
- Glassmorphism 和大阴影只能轻量使用,避免影响性能、可读性和长时间办公体验。
|
||||||
|
- 表格必须补齐真实系统需要的加载态、空态、错误态、分页、批量选择、权限控制和操作列,而不是只保留静态展示。
|
||||||
|
|
||||||
|
## 2. 当前主要问题
|
||||||
|
|
||||||
|
| 优先级 | 模块 | 现象与证据 | 风险 | 建议 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| P0 | AI 回调安全 | `server/app/api/dify_tools.py` 中 Authorization 目前可选,且注释仍提示生产需要补 Dify secret;`ai_coaching.py`、`reports.py`、`customers.py` 中多个 Dify 回调可以写入业务数据。 | 外部请求可能伪造回调,写入客户画像、AI 辅导、报告草稿等数据。 | 所有写入型回调必须增加共享密钥、签名校验或内部网关校验,并记录调用来源。 |
|
||||||
|
| P0 | CI 可信度 | `.gitea/workflows/test.yml` 中后端测试命令使用 `pytest ... || echo ...`,测试失败仍可能继续通过;workflow 触发分支未覆盖当前 `ui-change`。 | 真实测试失败不会阻断合并,后续 UI 或接口调整风险放大。 | 去掉吞错逻辑,明确测试失败即失败;补充分支触发规则。 |
|
||||||
|
| P1 | Dify 耦合 | `chat.py` 直接调用 Dify chat/workflow 接口,`intent_service.py` 中路由目标硬编码为 `dify_agent`,同时前端 action card、persona、report 等流程依赖 Dify 回调。 | 后续替换 OpenAI 时会牵动前后端多个入口,迁移成本高。 | 先抽 `AIProvider`、`WorkflowRunner`、`ToolAdapter`,保持 Dify 为默认实现。 |
|
||||||
|
| P1 | 前端视觉与体验 | `frontend/src/views` 中大量页面使用内联样式,表格高度多处使用固定 `calc(100vh - ...)`,首页和业务页缺少统一布局、间距、色彩和响应式策略。 | 页面观感不统一,窄屏体验差,后续每个页面都要重复修样式。 | 先建立设计变量、页面壳、工具栏、数据表格容器、KPI 卡片等基础组件。 |
|
||||||
|
| P1 | 构建环境不一致 | CI 使用 Node 20,`Dockerfile.frontend` 使用 Node 18;Docker 使用 `npm install`,CI 使用 `npm ci`。 | 本地、CI、Docker 构建结果可能不一致。 | 统一 Node 版本和依赖安装方式,优先使用锁文件驱动的 `npm ci`。 |
|
||||||
|
| P2 | 配置默认值偏生产风险 | `server/app/core/config.py` 默认 JWT secret、默认 DEBUG 为 true,并写有内部 Ollama 地址;`server/app/main.py` 中 CORS 地址固定。 | 新环境部署时容易沿用不安全默认配置。 | 敏感配置必须由环境变量提供,生产缺失时启动失败。 |
|
||||||
|
| P2 | 项目入口历史包袱 | 根目录存在 `app.py`,同时有 `backend/` 和 `server/`,当前 Docker 与 CI 主要使用 `server/`。 | 新开发者容易误判真实后端入口,维护成本上升。 | 文档明确活跃入口;后续小步清理或归档旧入口。 |
|
||||||
|
|
||||||
|
## 3. 小版本迭代范围建议
|
||||||
|
|
||||||
|
建议把下一个小版本命名为 `v0.3.0-ui-foundation` 或 `v0.2.1-quality-ui`。如果希望强调 UI 改进,使用 `v0.3.0-ui-foundation` 更清晰。
|
||||||
|
|
||||||
|
推荐包含 5 条工作线:
|
||||||
|
|
||||||
|
1. 安全与 CI 加固
|
||||||
|
2. 前端设计基座
|
||||||
|
3. 高频页面 UI 改进
|
||||||
|
4. AI 调用适配层准备
|
||||||
|
5. 测试、验收和发布流程
|
||||||
|
|
||||||
|
## 4. 分阶段执行计划
|
||||||
|
|
||||||
|
### 阶段 0: 基线确认
|
||||||
|
|
||||||
|
目标: 确认当前分支、依赖、构建、测试和页面现状,形成可比较基线。
|
||||||
|
|
||||||
|
任务:
|
||||||
|
|
||||||
|
- 确认 `ui-change` 分支可正常拉取、提交和推送。
|
||||||
|
- 分别执行后端基础检查、前端构建检查。
|
||||||
|
- 记录当前首页、客户、订单、财务页面截图,作为 UI 改版对比材料。
|
||||||
|
- 将 `stitch_aura_finance_crm.zip` 中的 `screen.png` 作为客户列表视觉参考,但以本项目业务字段和组件体系重新实现。
|
||||||
|
- 明确当前部署入口以 `server/`、`frontend/`、`docker-compose.yml` 为准。
|
||||||
|
|
||||||
|
交付物:
|
||||||
|
|
||||||
|
- 一组基线截图。
|
||||||
|
- 一份当前测试结果记录。
|
||||||
|
- 确认可复现的本地启动步骤。
|
||||||
|
|
||||||
|
### 阶段 1: 安全与 CI 加固
|
||||||
|
|
||||||
|
目标: 让明显风险先收敛,让自动化测试结果可信。
|
||||||
|
|
||||||
|
任务:
|
||||||
|
|
||||||
|
- 修改 `.gitea/workflows/test.yml`,去掉后端测试失败后的吞错逻辑。
|
||||||
|
- workflow 触发规则覆盖 `ui-change` 或后续约定的 feature 分支。
|
||||||
|
- 统一前端 Node 版本,建议 Docker 与 CI 使用同一大版本。
|
||||||
|
- Docker 前端构建改为基于锁文件的 `npm ci`。
|
||||||
|
- 为 Dify 回调类接口增加鉴权策略:
|
||||||
|
- 请求头共享密钥。
|
||||||
|
- 时间戳与签名。
|
||||||
|
- 失败请求记录审计日志。
|
||||||
|
- 生产环境中强制要求 `SECRET_KEY`、Dify secret、数据库连接等关键配置显式提供。
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- 后端测试失败时 CI 必须失败。
|
||||||
|
- 前端构建失败时 CI 必须失败。
|
||||||
|
- 未携带合法凭证的写入型 AI 回调返回 401 或 403。
|
||||||
|
- 生产模式缺少关键 secret 时服务拒绝启动。
|
||||||
|
|
||||||
|
### 阶段 2: 前端设计基座
|
||||||
|
|
||||||
|
目标: 先统一 UI 语言,再改具体页面。
|
||||||
|
|
||||||
|
建议新增或梳理:
|
||||||
|
|
||||||
|
- `frontend/src/styles/tokens.css`: 色彩、字号、间距、阴影、圆角、边框、状态色。
|
||||||
|
- `frontend/src/styles/layout.css`: 页面宽度、内容区、响应式网格、表格区高度策略。
|
||||||
|
- `frontend/src/components/PageShell.vue`: 页面标题、操作区、筛选区、主体区。
|
||||||
|
- `frontend/src/components/DataTableShell.vue`: 表格外壳、加载态、空态、分页布局。
|
||||||
|
- `frontend/src/components/KpiCard.vue`: 首页和统计页使用的统一指标卡。
|
||||||
|
- `frontend/src/components/SectionHeader.vue`: 业务分区标题和辅助操作。
|
||||||
|
- Element Plus 主题覆盖文件: 将 Aura 参考包的蓝色、浅色背景、状态色和输入框 focus 效果转译为 Element Plus 变量。
|
||||||
|
|
||||||
|
设计方向:
|
||||||
|
|
||||||
|
- 以 Aura Finance CRM 样张为视觉参考,但保留 SHBL CRM 的中文业务系统属性。
|
||||||
|
- 保持业务系统的安静、清晰、可扫描,不做营销页风格。
|
||||||
|
- 降低深色大面积侧边栏的压迫感,调整为更现代的浅色或低饱和导航。
|
||||||
|
- 表格、筛选、操作按钮保持密集但有秩序。
|
||||||
|
- 页面内少用嵌套卡片,避免视觉碎片化。
|
||||||
|
- 支持至少两档宽度: 桌面端和窄屏笔记本。
|
||||||
|
- 主色优先参考 `#0066CC` 和 `#004E9F`,背景参考 `#F5F5F7`、`#F9F9FF`,文字参考深 slate;避免整站变成单一蓝色主题。
|
||||||
|
- 主容器圆角以 8px 为默认,重点弹窗或浮层可放宽到 12px;不要使用大面积 16px 到 20px 圆角卡片堆叠。
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- 新页面不再依赖大量内联样式。
|
||||||
|
- 高频页面共享同一组布局组件。
|
||||||
|
- 主要按钮、表格、筛选区、状态标签风格一致。
|
||||||
|
- 1366px、1440px、1920px 宽度下无明显遮挡和错位。
|
||||||
|
- 不引入 Tailwind CDN、外部 Google 字体、远程样张图片或与当前 Vue/Element Plus 栈冲突的 UI 依赖。
|
||||||
|
|
||||||
|
### 阶段 3: 高频页面 UI 改进
|
||||||
|
|
||||||
|
建议顺序:
|
||||||
|
|
||||||
|
1. 首页 Dashboard
|
||||||
|
2. 客户列表与客户详情
|
||||||
|
3. 订单列表与订单详情
|
||||||
|
4. 财务相关页面
|
||||||
|
5. 产品与设置页面
|
||||||
|
|
||||||
|
Dashboard 改进方向:
|
||||||
|
|
||||||
|
- 使用统一 KPI 卡片展示关键指标。
|
||||||
|
- 将当前固定 4 列布局改为响应式网格。
|
||||||
|
- 把趋势、提醒、待办和最近活动拆成清晰分区。
|
||||||
|
- 减少装饰性元素,突出经营信息。
|
||||||
|
|
||||||
|
客户页面改进方向:
|
||||||
|
|
||||||
|
- 客户列表优先参考 Aura 样张的结构: 左侧导航、顶部搜索、页面标题、筛选按钮、主操作按钮、表格卡片和底部分页。
|
||||||
|
- 强化搜索、筛选、状态、负责人、最近跟进等字段的扫描效率。
|
||||||
|
- 客户详情页采用信息区、联系人、跟进记录、AI 画像等分区。
|
||||||
|
- AI 画像内容要有更新时间和来源提示。
|
||||||
|
- 样张中的 `Customer Index`、`Leads`、`Total Spend` 等英文与金融字段必须映射为本项目真实中文字段,例如客户名称、客户类型、联系人、负责人、订单金额、最近跟进、状态和操作。
|
||||||
|
|
||||||
|
订单与财务页面改进方向:
|
||||||
|
|
||||||
|
- 表格操作列更紧凑,金额、状态、日期字段更易读。
|
||||||
|
- 固定高度表格改为更稳定的内容区布局。
|
||||||
|
- 批量操作和导出入口统一放入工具栏。
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- 至少完成 Dashboard 和一个核心业务列表页的新版 UI。
|
||||||
|
- 新旧页面交互流程保持兼容。
|
||||||
|
- 表格分页、筛选、查看详情、创建或编辑入口可用。
|
||||||
|
- 通过桌面和窄屏截图检查。
|
||||||
|
- 客户列表新版截图需要与 Aura 参考图做并排验收: 允许业务字段不同,但层级、留白、表格扫描效率和按钮状态必须达到同一质量级别。
|
||||||
|
|
||||||
|
### 阶段 4: AI 适配层准备
|
||||||
|
|
||||||
|
目标: 不移除 Dify,但把未来替换成本降下来。
|
||||||
|
|
||||||
|
建议后端增加抽象:
|
||||||
|
|
||||||
|
- `AIProvider`: 统一 chat、stream、structured output 的入口。
|
||||||
|
- `WorkflowRunner`: 统一报告生成、客户画像、销售辅导等流程调用。
|
||||||
|
- `ToolAdapter`: 统一 CRM 内部工具调用和 action card 生成。
|
||||||
|
- `ConversationStore`: 保存会话 ID、provider、外部 conversation ID 的映射关系。
|
||||||
|
|
||||||
|
第一步实现:
|
||||||
|
|
||||||
|
- `DifyProvider` 作为默认实现,保持现有行为。
|
||||||
|
- `OpenAIProvider` 先提供最小可运行骨架,可通过环境变量关闭。
|
||||||
|
- 前端 `FloatingChat` 的响应结构保持稳定,例如 `text`、`conversation_id`、`action_card`。
|
||||||
|
|
||||||
|
未来大版本迁移方向:
|
||||||
|
|
||||||
|
- 使用 OpenAI Responses API 作为 chat 和工具编排入口。
|
||||||
|
- 使用 OpenAI function calling 映射 CRM 工具能力。
|
||||||
|
- 把 Dify workflow 的报告、画像、辅导流程迁移为内部 job 或后端 orchestrator。
|
||||||
|
- RAG 或知识库能力可以评估 OpenAI file search、向量库或自建检索方案。
|
||||||
|
- 保留 provider 切换能力,避免再次绑定到单一平台。
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- Dify 仍为默认 provider,现有聊天流程不回退。
|
||||||
|
- 新增 provider 接口后,业务代码不再直接散落调用 Dify URL。
|
||||||
|
- OpenAI provider 可以在测试环境完成最小 chat smoke test。
|
||||||
|
|
||||||
|
### 阶段 5: 验收、发布与回归
|
||||||
|
|
||||||
|
目标: 形成每次小版本都能复用的检查清单。
|
||||||
|
|
||||||
|
必须执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd server
|
||||||
|
python -m py_compile app/main.py
|
||||||
|
python -m compileall app
|
||||||
|
python -m pytest tests/ -v --tb=short
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
建议执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器回归范围:
|
||||||
|
|
||||||
|
- 登录页。
|
||||||
|
- 首页 Dashboard。
|
||||||
|
- 客户列表和详情。
|
||||||
|
- 订单列表和详情。
|
||||||
|
- 财务列表。
|
||||||
|
- 悬浮 AI 聊天入口。
|
||||||
|
- 文件导入模板下载。
|
||||||
|
- 客户列表 Aura 风格改版页。
|
||||||
|
|
||||||
|
截图检查尺寸:
|
||||||
|
|
||||||
|
- 1366 x 768
|
||||||
|
- 1440 x 900
|
||||||
|
- 1920 x 1080
|
||||||
|
- 390 x 844,仅用于确认不会严重溢出,不作为完整移动端目标。
|
||||||
|
|
||||||
|
UI 参考对比:
|
||||||
|
|
||||||
|
- 使用 `stitch_aura_finance_crm.zip` 中的 `screen.png` 作为目标风格参考。
|
||||||
|
- 新版客户列表需要输出同尺寸或近似比例截图,检查导航、搜索、标题、表格、分页、状态标签和主按钮是否达到参考图的秩序感。
|
||||||
|
- 若参考图与业务可用性冲突,优先满足业务效率和真实数据完整性。
|
||||||
|
|
||||||
|
## 5. 测试流程建议
|
||||||
|
|
||||||
|
### 后端测试
|
||||||
|
|
||||||
|
基础检查:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd server
|
||||||
|
python -m py_compile app/main.py
|
||||||
|
python -m compileall app
|
||||||
|
```
|
||||||
|
|
||||||
|
单元与接口测试:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd server
|
||||||
|
python -m pytest tests/ -v --tb=short
|
||||||
|
```
|
||||||
|
|
||||||
|
重点补测:
|
||||||
|
|
||||||
|
- AI 回调鉴权失败场景。
|
||||||
|
- AI 回调鉴权成功但 payload 异常场景。
|
||||||
|
- 客户画像写入。
|
||||||
|
- 报告草稿写入。
|
||||||
|
- 导入模板下载。
|
||||||
|
- 登录与 token 校验。
|
||||||
|
|
||||||
|
### 前端测试
|
||||||
|
|
||||||
|
基础检查:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
建议补充:
|
||||||
|
|
||||||
|
- 引入 Playwright smoke test,覆盖登录、首页、客户列表、订单列表、AI 聊天入口。
|
||||||
|
- 对新版 Dashboard 和客户页保留截图,用于后续 UI 回归对比。
|
||||||
|
|
||||||
|
### 集成测试
|
||||||
|
|
||||||
|
建议通过 Docker Compose 做一次完整启动:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
检查项:
|
||||||
|
|
||||||
|
- 前端能访问后端 API。
|
||||||
|
- CORS 配置符合当前环境。
|
||||||
|
- 数据库连接正常。
|
||||||
|
- AI provider 配置缺失时有明确错误提示。
|
||||||
|
- 生产环境 secret 缺失时服务拒绝启动。
|
||||||
|
|
||||||
|
## 6. 建议拆分 PR
|
||||||
|
|
||||||
|
建议按风险从小到大拆分:
|
||||||
|
|
||||||
|
| PR | 名称 | 内容 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `ci-test-hardening` | 修正 CI 吞错、统一 Node 版本、补充分支触发。 |
|
||||||
|
| 2 | `security-ai-callback-auth` | 为 Dify 回调和工具接口增加鉴权、审计日志和测试。 |
|
||||||
|
| 3 | `ui-foundation` | 增加设计变量、页面壳、通用表格容器和 KPI 卡片。 |
|
||||||
|
| 4 | `dashboard-refresh` | 重做首页 Dashboard,建立 UI 改版标杆。 |
|
||||||
|
| 5 | `customer-page-refresh` | 重做客户列表和详情的关键布局。 |
|
||||||
|
| 6 | `ai-provider-interface` | 抽出 AI provider 接口,Dify 作为默认实现,OpenAI provider 保留最小骨架。 |
|
||||||
|
|
||||||
|
## 7. 完成定义
|
||||||
|
|
||||||
|
本小版本达到以下条件后可以进入发布候选:
|
||||||
|
|
||||||
|
- CI 能真实反映后端测试和前端构建结果。
|
||||||
|
- 所有写入型 AI 回调都有鉴权。
|
||||||
|
- Docker 和 CI 的前端 Node 版本一致。
|
||||||
|
- 至少 Dashboard 和一个核心业务页面完成新版 UI。
|
||||||
|
- 新 UI 使用统一组件和样式变量。
|
||||||
|
- Dify 仍可正常工作,同时后端已有 provider 抽象入口。
|
||||||
|
- 后端测试、前端构建、Docker 构建通过。
|
||||||
|
- 关键页面完成截图验收。
|
||||||
|
|
||||||
|
## 8. 推荐先做的第一批任务
|
||||||
|
|
||||||
|
第一周建议只做基础质量和 UI 基座,不急着改所有页面:
|
||||||
|
|
||||||
|
1. 修 CI,让失败能失败。
|
||||||
|
2. 修 AI 回调鉴权,先堵住 P0 风险。
|
||||||
|
3. 统一 Node 和依赖安装流程。
|
||||||
|
4. 建立 `tokens.css`、`PageShell.vue`、`DataTableShell.vue`、`KpiCard.vue`。
|
||||||
|
5. 将 Aura Finance CRM 的视觉 token 转译为本项目 Element Plus 主题和基础 CSS 变量。
|
||||||
|
6. 先重做客户列表页,验证 Aura 风格能否适配真实业务字段。
|
||||||
|
7. 重做 Dashboard,形成后续页面参考样式。
|
||||||
|
8. 写 3 到 5 个最小 Playwright smoke test。
|
||||||
|
|
||||||
|
这样做的收益是: 小版本能立刻提升安全性、可维护性和视觉一致性,同时不会把后续 OpenAI 迁移和当前 UI 改版强行绑成一个高风险大改。
|
||||||
@@ -9,6 +9,13 @@ test.describe('客户管理', () => {
|
|||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('新版客户页信息架构可见', async ({ page }) => {
|
||||||
|
await expect(page.getByTestId('customers-page')).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.getByRole('heading', { name: '客户管理' })).toBeVisible();
|
||||||
|
await expect(page.getByTestId('customers-summary')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('customers-table-card')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('客户列表正确加载', async ({ page }) => {
|
test('客户列表正确加载', async ({ page }) => {
|
||||||
await expect(page.locator('.el-table').first()).toBeVisible({ timeout: 5000 });
|
await expect(page.locator('.el-table').first()).toBeVisible({ timeout: 5000 });
|
||||||
await expect(page.getByRole('button', { name: '新增客户' })).toBeVisible();
|
await expect(page.getByRole('button', { name: '新增客户' })).toBeVisible();
|
||||||
|
|||||||
@@ -8,18 +8,3 @@
|
|||||||
<template>
|
<template>
|
||||||
<router-view />
|
<router-view />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
|
||||||
/* 全局基础样式重置 */
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Microsoft YaHei', 'PingFang SC', -apple-system, sans-serif;
|
|
||||||
background-color: #f5f7fa;
|
|
||||||
color: #303133;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="crm-table-shell crm-glass-card">
|
||||||
|
<header v-if="title || description || $slots.header" class="crm-table-shell__header">
|
||||||
|
<div v-if="title || description">
|
||||||
|
<h2 v-if="title" class="crm-table-shell__title">{{ title }}</h2>
|
||||||
|
<p v-if="description" class="crm-table-shell__description">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.header" class="crm-table-shell__header-side">
|
||||||
|
<slot name="header" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="crm-table-shell__body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer v-if="$slots.footer" class="crm-table-shell__footer">
|
||||||
|
<slot name="footer" />
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.crm-table-shell {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-table-shell__header,
|
||||||
|
.crm-table-shell__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-table-shell__header {
|
||||||
|
border-bottom: 1px solid var(--crm-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-table-shell__footer {
|
||||||
|
border-top: 1px solid var(--crm-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-table-shell__title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--crm-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-table-shell__description {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--crm-text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-table-shell__body {
|
||||||
|
padding: 10px 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.crm-table-shell__header,
|
||||||
|
.crm-table-shell__footer {
|
||||||
|
padding: 18px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-table-shell__body {
|
||||||
|
padding: 8px 8px 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
helper?: string
|
||||||
|
tone?: 'primary' | 'success' | 'warning' | 'danger'
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="crm-kpi-card crm-glass-card" :data-tone="tone || 'primary'">
|
||||||
|
<p class="crm-kpi-card__label">{{ label }}</p>
|
||||||
|
<div class="crm-kpi-card__value">{{ value }}</div>
|
||||||
|
<p v-if="helper" class="crm-kpi-card__helper">{{ helper }}</p>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.crm-kpi-card {
|
||||||
|
min-height: 132px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-kpi-card__label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--crm-text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-kpi-card__value {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: clamp(28px, 3vw, 38px);
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--crm-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-kpi-card__helper {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--crm-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-kpi-card[data-tone='primary'] .crm-kpi-card__value {
|
||||||
|
color: var(--crm-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-kpi-card[data-tone='success'] .crm-kpi-card__value {
|
||||||
|
color: var(--crm-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-kpi-card[data-tone='warning'] .crm-kpi-card__value {
|
||||||
|
color: var(--crm-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-kpi-card[data-tone='danger'] .crm-kpi-card__value {
|
||||||
|
color: var(--crm-danger);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="crm-page-shell crm-page">
|
||||||
|
<header class="crm-page-shell__header">
|
||||||
|
<div class="crm-page-shell__copy">
|
||||||
|
<p class="crm-page-shell__eyebrow">SHBL CRM Workspace</p>
|
||||||
|
<h1 class="crm-page-shell__title">{{ title }}</h1>
|
||||||
|
<p v-if="description" class="crm-page-shell__description">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.actions" class="crm-page-shell__actions">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section v-if="$slots.stats" class="crm-page-shell__stats">
|
||||||
|
<slot name="stats" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="$slots.filters" class="crm-page-shell__filters crm-glass-card">
|
||||||
|
<slot name="filters" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="crm-page-shell__body">
|
||||||
|
<slot />
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.crm-page-shell__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-page-shell__copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-page-shell__eyebrow {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--crm-text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-page-shell__title {
|
||||||
|
font-size: clamp(30px, 4vw, 48px);
|
||||||
|
line-height: 1.04;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--crm-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-page-shell__description {
|
||||||
|
max-width: 680px;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--crm-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-page-shell__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-page-shell__stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-page-shell__filters {
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-page-shell__body {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.crm-page-shell__header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-page-shell__actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-page-shell__stats {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.crm-page-shell__stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
+184
-49
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, nextTick } from 'vue'
|
import { ref, reactive, nextTick, computed } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
@@ -32,6 +32,8 @@ const isCollapse = ref(false)
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const routeTitle = computed(() => (route.meta?.title as string) || '工作台')
|
||||||
|
const userInitial = computed(() => (userStore.realName || userStore.username || 'A').trim().charAt(0).toUpperCase())
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
const toggleSidebar = () => {
|
||||||
isCollapse.value = !isCollapse.value
|
isCollapse.value = !isCollapse.value
|
||||||
@@ -112,20 +114,21 @@ const handleSwitchCompany = (companyId: string) => {
|
|||||||
<template>
|
<template>
|
||||||
<el-container class="layout-container">
|
<el-container class="layout-container">
|
||||||
<!-- 左侧菜单 -->
|
<!-- 左侧菜单 -->
|
||||||
<el-aside :width="isCollapse ? '64px' : '240px'" class="aside">
|
<el-aside :width="isCollapse ? '84px' : '272px'" class="aside">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<span v-show="!isCollapse" class="logo-text"><strong>SHBL-ERP</strong></span>
|
<div class="logo-mark">S</div>
|
||||||
|
<span v-show="!isCollapse" class="logo-copy">
|
||||||
|
<span class="logo-kicker">SHBL Workspace</span>
|
||||||
|
<strong class="logo-text">SHBL CRM</strong>
|
||||||
|
</span>
|
||||||
<span v-show="isCollapse" class="logo-icon">S</span>
|
<span v-show="isCollapse" class="logo-icon">S</span>
|
||||||
</div>
|
</div>
|
||||||
<el-menu
|
<el-menu
|
||||||
:default-active="route.path"
|
:default-active="route.path"
|
||||||
class="el-menu-vertical"
|
class="el-menu-vertical crm-aside-menu"
|
||||||
:collapse="isCollapse"
|
:collapse="isCollapse"
|
||||||
router
|
router
|
||||||
unique-opened
|
unique-opened
|
||||||
background-color="#001529"
|
|
||||||
text-color="#a6adb4"
|
|
||||||
active-text-color="#fff"
|
|
||||||
>
|
>
|
||||||
<el-menu-item index="/">
|
<el-menu-item index="/">
|
||||||
<el-icon><House /></el-icon>
|
<el-icon><House /></el-icon>
|
||||||
@@ -212,14 +215,13 @@ const handleSwitchCompany = (companyId: string) => {
|
|||||||
<!-- 顶栏 -->
|
<!-- 顶栏 -->
|
||||||
<el-header class="header">
|
<el-header class="header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<el-icon class="toggle-icon" @click="toggleSidebar">
|
<button class="toggle-button" type="button" @click="toggleSidebar">
|
||||||
<component :is="isCollapse ? Expand : Fold" />
|
<component :is="isCollapse ? Expand : Fold" />
|
||||||
</el-icon>
|
</button>
|
||||||
<!-- 面包屑 -->
|
<div class="header-copy">
|
||||||
<el-breadcrumb separator="/">
|
<span class="header-kicker">当前页面</span>
|
||||||
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
<strong class="header-title">{{ routeTitle }}</strong>
|
||||||
<el-breadcrumb-item>{{ (route.meta?.title as string) || route.name }}</el-breadcrumb-item>
|
</div>
|
||||||
</el-breadcrumb>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<!-- 公司视角切换 -->
|
<!-- 公司视角切换 -->
|
||||||
@@ -252,7 +254,7 @@ const handleSwitchCompany = (companyId: string) => {
|
|||||||
<!-- 用户头像 -->
|
<!-- 用户头像 -->
|
||||||
<el-dropdown @command="handleCommand">
|
<el-dropdown @command="handleCommand">
|
||||||
<span class="user-dropdown">
|
<span class="user-dropdown">
|
||||||
<el-avatar :size="30" src="https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png" />
|
<el-avatar :size="34" class="user-avatar">{{ userInitial }}</el-avatar>
|
||||||
<span class="username">{{ userStore.realName || userStore.username || 'Admin' }}</span>
|
<span class="username">{{ userStore.realName || userStore.username || 'Admin' }}</span>
|
||||||
<el-icon class="el-icon--right"><arrow-down /></el-icon>
|
<el-icon class="el-icon--right"><arrow-down /></el-icon>
|
||||||
</span>
|
</span>
|
||||||
@@ -304,110 +306,223 @@ const handleSwitchCompany = (companyId: string) => {
|
|||||||
.layout-container {
|
.layout-container {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aside {
|
.aside {
|
||||||
background-color: #001529;
|
|
||||||
transition: width 0.3s;
|
transition: width 0.3s;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
padding: 18px 14px 18px 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
border-right: 1px solid var(--crm-border);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
box-shadow: 20px 0 50px rgba(15, 23, 42, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
height: 60px;
|
display: flex;
|
||||||
line-height: 60px;
|
align-items: center;
|
||||||
text-align: center;
|
gap: 14px;
|
||||||
color: #fff;
|
min-height: 72px;
|
||||||
background-color: #002140;
|
margin-bottom: 18px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: linear-gradient(135deg, rgba(0, 95, 184, 0.12), rgba(255, 255, 255, 0.75));
|
||||||
|
border: 1px solid rgba(0, 95, 184, 0.12);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
}
|
||||||
|
|
||||||
|
.logo-mark,
|
||||||
|
.logo-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(135deg, var(--crm-primary), var(--crm-primary-strong));
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 14px 24px rgba(0, 95, 184, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-kicker {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--crm-text-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-text {
|
.logo-text {
|
||||||
font-size: 18px;
|
font-size: 22px;
|
||||||
letter-spacing: 1px;
|
line-height: 1.1;
|
||||||
}
|
color: var(--crm-text);
|
||||||
|
|
||||||
.logo-icon {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-menu-vertical {
|
.el-menu-vertical {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
border-right: none;
|
border-right: none;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure sub-menu titles have a nice color change on hover */
|
:deep(.crm-aside-menu .el-menu) {
|
||||||
:deep(.el-sub-menu__title:hover) {
|
background: transparent;
|
||||||
background-color: rgba(255, 255, 255, 0.05) !important;
|
border-right: none;
|
||||||
}
|
|
||||||
.el-menu-item:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.05) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-menu-item.is-active {
|
:deep(.crm-aside-menu .el-sub-menu__title),
|
||||||
background-color: var(--el-color-primary) !important;
|
:deep(.crm-aside-menu .el-menu-item) {
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: var(--crm-text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.crm-aside-menu .el-sub-menu__title:hover),
|
||||||
|
:deep(.crm-aside-menu .el-menu-item:hover) {
|
||||||
|
color: var(--crm-text);
|
||||||
|
background: rgba(255, 255, 255, 0.72) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.crm-aside-menu .el-menu-item.is-active) {
|
||||||
|
color: var(--crm-primary) !important;
|
||||||
|
background: rgba(0, 95, 184, 0.1) !important;
|
||||||
|
box-shadow: inset 3px 0 0 var(--crm-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.crm-aside-menu .el-sub-menu .el-menu-item) {
|
||||||
|
min-width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: 1px solid #dcdfe6;
|
border-bottom: 1px solid rgba(86, 99, 122, 0.12);
|
||||||
background-color: #fff;
|
background: rgba(255, 255, 255, 0.76);
|
||||||
height: 60px;
|
backdrop-filter: blur(18px);
|
||||||
padding: 0 20px;
|
height: 76px;
|
||||||
|
padding: 0 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-icon {
|
.toggle-button {
|
||||||
font-size: 20px;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border: 1px solid var(--crm-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
color: var(--crm-text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-right: 20px;
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-button:hover {
|
||||||
|
color: var(--crm-primary);
|
||||||
|
border-color: rgba(0, 95, 184, 0.25);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-kicker {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--crm-text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--crm-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-right {
|
.header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-dropdown {
|
.user-dropdown {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
.username {
|
.username {
|
||||||
margin-left: 8px;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
color: var(--crm-text);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.company-dropdown {
|
.company-dropdown {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #606266;
|
color: var(--crm-text-muted);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
border: 1px solid var(--crm-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.company-dropdown:hover {
|
.company-dropdown:hover {
|
||||||
color: var(--el-color-primary);
|
color: var(--el-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.company-name {
|
.company-name {
|
||||||
margin: 0 4px;
|
max-width: 180px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
background: linear-gradient(135deg, var(--crm-primary), #2f80d1);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.is-active-company {
|
.is-active-company {
|
||||||
color: var(--el-color-primary);
|
color: var(--el-color-primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
background-color: #f0f2f5;
|
background: transparent;
|
||||||
padding: 20px;
|
padding: var(--crm-page-padding);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,4 +541,24 @@ const handleSwitchCompany = (companyId: string) => {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(30px);
|
transform: translateX(30px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.header {
|
||||||
|
height: auto;
|
||||||
|
min-height: 76px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-name,
|
||||||
|
.username {
|
||||||
|
max-width: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
|||||||
import ElementPlus from 'element-plus'
|
import ElementPlus from 'element-plus'
|
||||||
import 'element-plus/dist/index.css'
|
import 'element-plus/dist/index.css'
|
||||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
import './styles/tokens.css'
|
||||||
|
import './styles/base.css'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--crm-font-sans);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(0, 95, 184, 0.08), transparent 28%),
|
||||||
|
linear-gradient(180deg, #f8f8fb 0%, #f3f4f7 100%);
|
||||||
|
color: var(--crm-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--crm-gap-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-glass-card {
|
||||||
|
background: var(--crm-surface);
|
||||||
|
border: 1px solid var(--crm-border);
|
||||||
|
border-radius: var(--crm-radius-lg);
|
||||||
|
box-shadow: var(--crm-shadow-sm);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-pill.is-primary {
|
||||||
|
color: var(--crm-primary);
|
||||||
|
background: var(--crm-primary-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-pill.is-warning {
|
||||||
|
color: var(--crm-warning);
|
||||||
|
background: var(--crm-warning-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-pill.is-danger {
|
||||||
|
color: var(--crm-danger);
|
||||||
|
background: var(--crm-danger-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-pill.is-success {
|
||||||
|
color: var(--crm-success);
|
||||||
|
background: var(--crm-success-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button--primary {
|
||||||
|
box-shadow: 0 10px 20px rgba(0, 95, 184, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input__wrapper,
|
||||||
|
.el-textarea__inner,
|
||||||
|
.el-select__wrapper {
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input__wrapper.is-focus,
|
||||||
|
.el-select__wrapper.is-focused,
|
||||||
|
.el-textarea__inner:focus {
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 95, 184, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-card {
|
||||||
|
border-radius: var(--crm-radius-lg);
|
||||||
|
border: 1px solid var(--crm-border);
|
||||||
|
box-shadow: var(--crm-shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table {
|
||||||
|
--el-table-border-color: rgba(86, 99, 122, 0.12);
|
||||||
|
--el-table-header-bg-color: rgba(248, 249, 252, 0.85);
|
||||||
|
--el-table-row-hover-bg-color: rgba(0, 95, 184, 0.04);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-dialog {
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
:root {
|
||||||
|
--crm-page-padding: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
:root {
|
||||||
|
--crm-page-padding: 16px;
|
||||||
|
--crm-gap-lg: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--crm-font-sans: "PingFang SC", "Microsoft YaHei", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--crm-bg: #f5f5f7;
|
||||||
|
--crm-bg-soft: #fbfbfd;
|
||||||
|
--crm-surface: rgba(255, 255, 255, 0.9);
|
||||||
|
--crm-surface-solid: #ffffff;
|
||||||
|
--crm-surface-muted: #f2f4f8;
|
||||||
|
--crm-surface-alt: #eceff5;
|
||||||
|
--crm-border: rgba(86, 99, 122, 0.15);
|
||||||
|
--crm-border-strong: rgba(78, 92, 116, 0.24);
|
||||||
|
--crm-text: #191c22;
|
||||||
|
--crm-text-muted: #5f6776;
|
||||||
|
--crm-text-subtle: #7d8594;
|
||||||
|
--crm-primary: #005fb8;
|
||||||
|
--crm-primary-strong: #004991;
|
||||||
|
--crm-primary-soft: rgba(0, 95, 184, 0.1);
|
||||||
|
--crm-success: #0c8f63;
|
||||||
|
--crm-success-soft: rgba(12, 143, 99, 0.12);
|
||||||
|
--crm-warning: #a76600;
|
||||||
|
--crm-warning-soft: rgba(167, 102, 0, 0.12);
|
||||||
|
--crm-danger: #c13f32;
|
||||||
|
--crm-danger-soft: rgba(193, 63, 50, 0.12);
|
||||||
|
--crm-shadow-sm: 0 10px 30px rgba(15, 23, 42, 0.04);
|
||||||
|
--crm-shadow-md: 0 20px 45px rgba(15, 23, 42, 0.08);
|
||||||
|
--crm-radius-sm: 8px;
|
||||||
|
--crm-radius-md: 12px;
|
||||||
|
--crm-radius-lg: 18px;
|
||||||
|
--crm-page-padding: 28px;
|
||||||
|
--crm-gap-sm: 12px;
|
||||||
|
--crm-gap-md: 18px;
|
||||||
|
--crm-gap-lg: 24px;
|
||||||
|
--el-color-primary: var(--crm-primary);
|
||||||
|
--el-color-primary-light-3: #2d7fd0;
|
||||||
|
--el-color-primary-light-5: #5e9ddd;
|
||||||
|
--el-color-primary-light-7: #9fc4e9;
|
||||||
|
--el-color-primary-light-8: #c4daf1;
|
||||||
|
--el-color-primary-light-9: #e8f1fa;
|
||||||
|
--el-color-primary-dark-2: var(--crm-primary-strong);
|
||||||
|
--el-bg-color: var(--crm-surface-solid);
|
||||||
|
--el-bg-color-page: var(--crm-bg);
|
||||||
|
--el-fill-color-blank: var(--crm-surface-solid);
|
||||||
|
--el-border-color: rgba(86, 99, 122, 0.18);
|
||||||
|
--el-border-color-light: rgba(86, 99, 122, 0.12);
|
||||||
|
--el-text-color-primary: var(--crm-text);
|
||||||
|
--el-text-color-regular: var(--crm-text-muted);
|
||||||
|
--el-text-color-secondary: var(--crm-text-subtle);
|
||||||
|
--el-font-family: var(--crm-font-sans);
|
||||||
|
--el-border-radius-base: var(--crm-radius-sm);
|
||||||
|
--el-border-radius-small: 6px;
|
||||||
|
--el-border-radius-round: 999px;
|
||||||
|
--el-box-shadow-light: var(--crm-shadow-sm);
|
||||||
|
--el-mask-color-extra-light: rgba(245, 245, 247, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -6,13 +6,15 @@
|
|||||||
* PUT /api/customers/{id} (编辑)
|
* PUT /api/customers/{id} (编辑)
|
||||||
* DELETE /api/customers/{id} (软删除/归档)
|
* DELETE /api/customers/{id} (软删除/归档)
|
||||||
*/
|
*/
|
||||||
import { ref, reactive, onMounted, nextTick } from 'vue'
|
import { ref, reactive, onMounted, nextTick, computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Search, Plus, View, Edit, Box, Upload, Download, Sort } from '@element-plus/icons-vue'
|
import { Search, Plus, View, Edit, Box, Upload, Download, Sort, RefreshRight, FolderOpened } from '@element-plus/icons-vue'
|
||||||
import { ElMessageBox, ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessageBox, ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import request from '@/api/request'
|
import request from '@/api/request'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import { computed } from 'vue'
|
import PageShell from '@/components/PageShell.vue'
|
||||||
|
import DataTableShell from '@/components/DataTableShell.vue'
|
||||||
|
import KpiCard from '@/components/KpiCard.vue'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const isAdmin = computed(() => userStore.userInfo?.data_scope === 'all' || (userStore.userInfo?.role_name || '').toLowerCase() === 'admin')
|
const isAdmin = computed(() => userStore.userInfo?.data_scope === 'all' || (userStore.userInfo?.role_name || '').toLowerCase() === 'admin')
|
||||||
@@ -33,6 +35,38 @@ const customerData = ref<any[]>([])
|
|||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
const summaryCards = computed(() => {
|
||||||
|
const archivedCount = customerData.value.filter((item) => item.is_deleted).length
|
||||||
|
const keyCount = customerData.value.filter((item) => item.level === 'A').length
|
||||||
|
const activeCount = customerData.value.length - archivedCount
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: '结果总数',
|
||||||
|
value: total.value,
|
||||||
|
helper: searchForm.keyword ? `关键词:${searchForm.keyword}` : '当前筛选范围内的客户总量',
|
||||||
|
tone: 'primary' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '当前页活跃客户',
|
||||||
|
value: activeCount,
|
||||||
|
helper: '排除已归档客户后的可跟进对象',
|
||||||
|
tone: 'success' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'A级重点客户',
|
||||||
|
value: keyCount,
|
||||||
|
helper: '优先关注大客户与关键机会',
|
||||||
|
tone: 'warning' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '已归档客户',
|
||||||
|
value: archivedCount,
|
||||||
|
helper: searchForm.showArchived ? '当前结果已包含归档记录' : '打开归档筛选后可查看',
|
||||||
|
tone: 'danger' as const,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
// --- 拉取客户列表 ---
|
// --- 拉取客户列表 ---
|
||||||
const fetchCustomers = async () => {
|
const fetchCustomers = async () => {
|
||||||
@@ -236,6 +270,19 @@ const getLevelLabel = (level: string) => {
|
|||||||
return level || '-'
|
return level || '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatCreatedAt = (value?: string) => {
|
||||||
|
if (!value) return '-'
|
||||||
|
return value.replace('T', ' ').slice(0, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCustomerStatusLabel = (row: any) => {
|
||||||
|
return row.is_deleted ? '已归档' : '活跃'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCustomerStatusClass = (row: any) => {
|
||||||
|
return row.is_deleted ? 'is-danger' : 'is-primary'
|
||||||
|
}
|
||||||
|
|
||||||
// --- 导入 / 导出 ---
|
// --- 导入 / 导出 ---
|
||||||
const importDialogVisible = ref(false)
|
const importDialogVisible = ref(false)
|
||||||
|
|
||||||
@@ -319,87 +366,159 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="customer-list-container">
|
<PageShell
|
||||||
<!-- 1. 顶部高级检索区 -->
|
title="客户管理"
|
||||||
<el-card shadow="never" class="filter-section">
|
description="围绕客户全生命周期组织线索、沟通、成交和归档信息,让销售、财务与管理层都能更快读懂客户状态。"
|
||||||
<div class="filter-wrapper">
|
data-testid="customers-page"
|
||||||
<el-form :inline="true" :model="searchForm" class="filter-form">
|
>
|
||||||
<el-form-item label="客户名称">
|
<template #actions>
|
||||||
<el-input v-model="searchForm.keyword" placeholder="请输入名称/拼音" clearable @keyup.enter="handleSearch" />
|
<el-button :icon="RefreshRight" @click="fetchCustomers">刷新</el-button>
|
||||||
</el-form-item>
|
<el-button type="warning" :icon="Upload" @click="importDialogVisible = true">导入客户</el-button>
|
||||||
|
<el-button v-if="isAdmin" :icon="Download" @click="handleExport">导出</el-button>
|
||||||
|
<el-button type="primary" :icon="Plus" @click="handleAddCustomer">新增客户</el-button>
|
||||||
|
</template>
|
||||||
|
|
||||||
<el-form-item label="客户级别">
|
<template #stats>
|
||||||
<el-select v-model="searchForm.level" placeholder="全部分级" clearable style="width: 150px">
|
<div class="customers-summary" data-testid="customers-summary">
|
||||||
|
<KpiCard
|
||||||
|
v-for="item in summaryCards"
|
||||||
|
:key="item.label"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
:helper="item.helper"
|
||||||
|
:tone="item.tone"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #filters>
|
||||||
|
<div class="customers-toolbar">
|
||||||
|
<div class="customers-toolbar__copy">
|
||||||
|
<p class="customers-toolbar__title">客户索引</p>
|
||||||
|
<p class="customers-toolbar__subtitle">优先处理重点客户、活跃跟进和归档恢复,不再把关键信息埋在表格里。</p>
|
||||||
|
</div>
|
||||||
|
<el-form :model="searchForm" class="customers-filters" @submit.prevent>
|
||||||
|
<el-form-item>
|
||||||
|
<el-input
|
||||||
|
v-model="searchForm.keyword"
|
||||||
|
placeholder="搜索客户名称、联系人或拼音"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-select v-model="searchForm.level" placeholder="全部分级" clearable>
|
||||||
<el-option label="A级重点" value="A" />
|
<el-option label="A级重点" value="A" />
|
||||||
<el-option label="B级普通" value="B" />
|
<el-option label="B级普通" value="B" />
|
||||||
<el-option label="C级长尾" value="C" />
|
<el-option label="C级长尾" value="C" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item class="customers-filters__switch">
|
||||||
<el-switch v-model="searchForm.showArchived" active-text="显示已归档" @change="handleSearch" style="margin-right: 10px" />
|
<el-switch v-model="searchForm.showArchived" active-text="包含已归档" @change="handleSearch" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item class="customers-filters__action">
|
||||||
|
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<div class="action-buttons">
|
|
||||||
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
|
|
||||||
<el-button type="warning" :icon="Upload" @click="importDialogVisible = true">导入客户</el-button>
|
|
||||||
<el-button v-if="isAdmin" type="info" :icon="Download" @click="handleExport">导出</el-button>
|
|
||||||
<el-button type="success" :icon="Plus" @click="handleAddCustomer">新增客户</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</template>
|
||||||
|
|
||||||
<!-- 2. 核心数据表格 -->
|
<DataTableShell
|
||||||
<el-card shadow="never" class="table-section">
|
title="客户列表"
|
||||||
<el-table :data="customerData" stripe border style="width: 100%" v-loading="loading">
|
description="保留现有业务操作链路,同时把浏览、筛选、识别重点客户的效率做上去。"
|
||||||
<el-table-column prop="name" label="客户名称" min-width="220" show-overflow-tooltip>
|
data-testid="customers-table-card"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="customers-table-meta">
|
||||||
|
<span class="crm-pill is-primary">
|
||||||
|
<el-icon><FolderOpened /></el-icon>
|
||||||
|
当前页 {{ customerData.length }} 条
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-table :data="customerData" style="width: 100%" v-loading="loading" empty-text="暂无客户数据">
|
||||||
|
<el-table-column prop="name" label="客户信息" min-width="280" show-overflow-tooltip>
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<span class="customer-name-bold">{{ scope.row.name }}</span>
|
<div class="customer-cell">
|
||||||
|
<div class="customer-cell__avatar">{{ (scope.row.name || '客').slice(0, 1) }}</div>
|
||||||
|
<div class="customer-cell__copy">
|
||||||
|
<span class="customer-cell__name">{{ scope.row.name || '-' }}</span>
|
||||||
|
<span class="customer-cell__meta">{{ scope.row.contact || '暂无联系人' }} · {{ scope.row.phone || '未录入电话' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="客户级别" width="120" align="center">
|
<el-table-column label="状态" width="120" align="center">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-tag :type="getLevelType(scope.row.level)" effect="light">
|
<span class="crm-pill" :class="getCustomerStatusClass(scope.row)">
|
||||||
|
{{ getCustomerStatusLabel(scope.row) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="客户级别" width="130" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag :type="getLevelType(scope.row.level)" effect="light" round>
|
||||||
{{ getLevelLabel(scope.row.level) }}
|
{{ getLevelLabel(scope.row.level) }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column prop="industry" label="所属行业" min-width="160" show-overflow-tooltip />
|
<el-table-column prop="industry" label="所属行业" min-width="160" show-overflow-tooltip>
|
||||||
|
|
||||||
<el-table-column prop="contact" label="联系人" width="120" />
|
|
||||||
<el-table-column prop="phone" label="联系电话" width="140" />
|
|
||||||
<el-table-column prop="address" label="地址" min-width="180" show-overflow-tooltip />
|
|
||||||
<el-table-column prop="created_at" label="创建时间" width="170" />
|
|
||||||
|
|
||||||
<el-table-column label="操作" width="280" fixed="right">
|
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button type="primary" link :icon="View" @click="viewDetails(scope.row)">查看档案</el-button>
|
<span class="table-muted">{{ scope.row.industry || '未填写行业' }}</span>
|
||||||
<template v-if="!scope.row.is_deleted">
|
</template>
|
||||||
<el-button type="primary" link :icon="Edit" @click="editCustomer(scope.row)">编辑</el-button>
|
</el-table-column>
|
||||||
<el-button v-if="isAdmin" type="warning" link :icon="Sort" @click="openTransferDialog(scope.row)">转移</el-button>
|
|
||||||
<el-button type="danger" link :icon="Box" @click="archiveCustomer(scope.row)">归档</el-button>
|
<el-table-column prop="address" label="所在地区" min-width="190" show-overflow-tooltip>
|
||||||
</template>
|
<template #default="scope">
|
||||||
<el-button v-else type="success" link @click="restoreCustomer(scope.row)">恢复</el-button>
|
<span class="table-muted">{{ scope.row.address || '未填写地址' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="created_at" label="创建时间" width="170">
|
||||||
|
<template #default="scope">
|
||||||
|
<span class="table-muted">{{ formatCreatedAt(scope.row.created_at) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="操作" width="280" fixed="right" align="right">
|
||||||
|
<template #default="scope">
|
||||||
|
<div class="row-actions">
|
||||||
|
<el-button type="primary" link :icon="View" @click="viewDetails(scope.row)">查看档案</el-button>
|
||||||
|
<template v-if="!scope.row.is_deleted">
|
||||||
|
<el-button type="primary" link :icon="Edit" @click="editCustomer(scope.row)">编辑</el-button>
|
||||||
|
<el-button v-if="isAdmin" type="warning" link :icon="Sort" @click="openTransferDialog(scope.row)">转移</el-button>
|
||||||
|
<el-button type="danger" link :icon="Box" @click="archiveCustomer(scope.row)">归档</el-button>
|
||||||
|
</template>
|
||||||
|
<el-button v-else type="success" link @click="restoreCustomer(scope.row)">恢复</el-button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<!-- 3. 分页 -->
|
<template #footer>
|
||||||
<div class="pagination-section">
|
<div class="customers-pagination">
|
||||||
<el-pagination
|
<span class="customers-pagination__summary">显示第 {{ customerData.length ? (currentPage - 1) * pageSize + 1 : 0 }} 到 {{ (currentPage - 1) * pageSize + customerData.length }} 条,共 {{ total }} 条</span>
|
||||||
v-model:current-page="currentPage"
|
<el-pagination
|
||||||
v-model:page-size="pageSize"
|
v-model:current-page="currentPage"
|
||||||
:page-sizes="[10, 20, 50]"
|
v-model:page-size="pageSize"
|
||||||
background
|
:page-sizes="[10, 20, 50]"
|
||||||
layout="total, prev, pager, next, jumper"
|
background
|
||||||
:total="total"
|
layout="prev, pager, next, jumper"
|
||||||
@current-change="handlePageChange"
|
:total="total"
|
||||||
@size-change="handlePageChange"
|
@current-change="handlePageChange"
|
||||||
/>
|
@size-change="handlePageChange"
|
||||||
</div>
|
/>
|
||||||
</el-card>
|
</div>
|
||||||
|
</template>
|
||||||
|
</DataTableShell>
|
||||||
|
|
||||||
<!-- 4. 新增客户弹窗 -->
|
<!-- 4. 新增客户弹窗 -->
|
||||||
<el-dialog v-model="addDialogVisible" title="新增客户开户" width="560px" destroy-on-close>
|
<el-dialog v-model="addDialogVisible" title="新增客户开户" width="560px" destroy-on-close>
|
||||||
@@ -582,56 +701,158 @@ onMounted(() => {
|
|||||||
<el-button type="primary" :loading="transferSubmitting" @click="submitTransfer">确认转移</el-button>
|
<el-button type="primary" :loading="transferSubmitting" @click="submitTransfer">确认转移</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</PageShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.customer-list-container {
|
.customers-summary {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customers-toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.4fr);
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 顶部检索区 */
|
.customers-toolbar__copy {
|
||||||
.filter-section {
|
min-width: 0;
|
||||||
border-radius: 8px;
|
|
||||||
border: none;
|
|
||||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-wrapper {
|
.customers-toolbar__title {
|
||||||
display: flex;
|
font-size: 20px;
|
||||||
justify-content: space-between;
|
font-weight: 700;
|
||||||
flex-wrap: wrap;
|
color: var(--crm-text);
|
||||||
align-items: flex-start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-form .el-form-item {
|
.customers-toolbar__subtitle {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--crm-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customers-filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(240px, 1.3fr) 160px auto auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.customers-filters .el-form-item) {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
margin-right: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-buttons {
|
.customers-filters__switch {
|
||||||
|
justify-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customers-filters__action {
|
||||||
|
justify-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customers-table-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 数据表格区 */
|
.customer-cell {
|
||||||
.table-section {
|
display: flex;
|
||||||
border-radius: 8px;
|
align-items: center;
|
||||||
border: none;
|
gap: 14px;
|
||||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.customer-name-bold {
|
.customer-cell__avatar {
|
||||||
font-weight: bold;
|
display: inline-flex;
|
||||||
color: #303133;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(135deg, rgba(0, 95, 184, 0.12), rgba(0, 95, 184, 0.04));
|
||||||
|
color: var(--crm-primary);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 1px solid rgba(0, 95, 184, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 分页 */
|
.customer-cell__copy {
|
||||||
.pagination-section {
|
display: flex;
|
||||||
margin-top: 20px;
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-cell__name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--crm-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-cell__meta,
|
||||||
|
.table-muted {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--crm-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customers-pagination {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customers-pagination__summary {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--crm-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table .el-table__cell) {
|
||||||
|
padding-top: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.customers-toolbar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customers-filters {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.customers-filters__action {
|
||||||
|
justify-self: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.customers-summary {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.customers-pagination {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.customers-summary,
|
||||||
|
.customers-filters {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { Plus, Van, Box, EditPen } from '@element-plus/icons-vue'
|
import { Plus, Van, Box, EditPen } from '@element-plus/icons-vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import request from '@/api/request'
|
import request from '@/api/request'
|
||||||
|
import PageShell from '@/components/PageShell.vue'
|
||||||
|
import KpiCard from '@/components/KpiCard.vue'
|
||||||
|
import DataTableShell from '@/components/DataTableShell.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -29,110 +32,34 @@ onMounted(fetchStats)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="dashboard-container">
|
<PageShell
|
||||||
<!-- 顶部快捷操作 -->
|
title="工作台"
|
||||||
<div class="quick-actions">
|
description="把订单、发货、库存、收入和最近动态拉到同一视角里,优先帮助业务负责人快速判断今天要推进什么。"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
<el-button type="primary" :icon="Plus" @click="router.push('/orders')">新建订单</el-button>
|
<el-button type="primary" :icon="Plus" @click="router.push('/orders')">新建订单</el-button>
|
||||||
<el-button type="success" :icon="Van" @click="router.push('/shipping')">安排发货</el-button>
|
<el-button type="success" :icon="Van" @click="router.push('/shipping')">安排发货</el-button>
|
||||||
<el-button type="warning" :icon="Box" @click="router.push('/products')">库存入库</el-button>
|
<el-button type="warning" :icon="Box" @click="router.push('/products')">库存入库</el-button>
|
||||||
<el-button type="info" :icon="EditPen" @click="router.push('/logs')">写销售日志</el-button>
|
<el-button :icon="EditPen" @click="router.push('/logs')">写销售日志</el-button>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- 中部核心数据 KPI -->
|
<template #stats>
|
||||||
<el-row :gutter="20" class="kpi-cards">
|
<KpiCard label="本月新增订单" :value="`${stats.orders_count} 单`" helper="聚焦新增成交和推进节奏" tone="primary" />
|
||||||
<el-col :span="6">
|
<KpiCard label="待出库发货" :value="`${stats.pending_shipping} 单`" helper="交付风险和履约节奏" tone="warning" />
|
||||||
<el-card shadow="hover" class="kpi-card" v-loading="loading">
|
<KpiCard label="库存预警 SKU" :value="`${stats.warning_skus} 个`" helper="优先处理潜在断货项" tone="danger" />
|
||||||
<div class="kpi-title">本月新增订单</div>
|
<KpiCard label="本月预计营收" :value="formatMoney(stats.monthly_revenue)" helper="结合当前订单的收入预估" tone="success" />
|
||||||
<div class="kpi-value">{{ stats.orders_count }} <span class="kpi-unit">单</span></div>
|
</template>
|
||||||
</el-card>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="6">
|
|
||||||
<el-card shadow="hover" class="kpi-card" v-loading="loading">
|
|
||||||
<div class="kpi-title">待出库发货</div>
|
|
||||||
<div class="kpi-value" style="color: #e6a23c;">{{ stats.pending_shipping }} <span class="kpi-unit">单</span></div>
|
|
||||||
</el-card>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="6">
|
|
||||||
<el-card shadow="hover" class="kpi-card" v-loading="loading">
|
|
||||||
<div class="kpi-title">库存预警 SKU</div>
|
|
||||||
<div class="kpi-value" style="color: #f56c6c;">{{ stats.warning_skus }} <span class="kpi-unit">个</span></div>
|
|
||||||
</el-card>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="6">
|
|
||||||
<el-card shadow="hover" class="kpi-card" v-loading="loading">
|
|
||||||
<div class="kpi-title">本月预计营收</div>
|
|
||||||
<div class="kpi-value" style="color: #67c23a;">{{ formatMoney(stats.monthly_revenue) }}</div>
|
|
||||||
</el-card>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
|
|
||||||
<!-- 底部最新动态 -->
|
<DataTableShell title="近期业务动态" description="后续这里可以继续挂接订单进展、客户跟进和 AI 复盘提醒。">
|
||||||
<el-card shadow="never" class="recent-activities">
|
<div class="dashboard-empty" v-loading="loading">
|
||||||
<template #header>
|
<el-empty description="暂无业务动态数据,请先录入订单和日志" />
|
||||||
<div class="card-header">
|
</div>
|
||||||
<span>近期业务动态</span>
|
</DataTableShell>
|
||||||
</div>
|
</PageShell>
|
||||||
</template>
|
|
||||||
<el-empty description="暂无业务动态数据,请先录入订单和日志" />
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dashboard-container {
|
.dashboard-empty {
|
||||||
display: flex;
|
min-height: 240px;
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
background-color: #fff;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpi-cards .el-col {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpi-card {
|
|
||||||
height: 120px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpi-title {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #909399;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpi-value {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #303133;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpi-unit {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: normal;
|
|
||||||
color: #606266;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recent-activities {
|
|
||||||
border-radius: 8px;
|
|
||||||
border: none;
|
|
||||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
|
"noEmit": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
"composite": true,
|
"composite": true,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
|
"outDir": "./.tsbuild/node",
|
||||||
|
"tsBuildInfoFile": "./.tsbuild/tsconfig.node.tsbuildinfo",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import { resolve } from 'path'
|
|
||||||
|
const srcPath = decodeURIComponent(new URL('./src', import.meta.url).pathname).replace(/^\/([A-Za-z]:)/, '$1')
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': resolve(__dirname, 'src'), // @ 指向 src 目录
|
'@': srcPath, // @ 指向 src 目录
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
Reference in New Issue
Block a user