feat(ui): refresh crm shell and customer workspace
CRM/ERP CI / Backend Tests (Python) (push) Successful in 4m12s
CRM/ERP CI / Frontend Build (Vue3 + Vite) (push) Successful in 1m49s
CRM/ERP CI / Docker Compose Build (push) Successful in 1m27s

This commit is contained in:
2026-05-11 21:42:04 +08:00
parent 0fe88c1eb8
commit 7df1045e77
16 changed files with 1374 additions and 290 deletions
+2
View File
@@ -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 18Docker 使用 `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 改版强行绑成一个高风险大改。
+7
View File
@@ -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();
-15
View File
@@ -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>
+61
View File
@@ -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>
+115
View File
@@ -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
View File
@@ -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>
+2
View File
@@ -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'
+122
View File
@@ -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;
}
}
+55
View File
@@ -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);
}
+308 -87
View File
@@ -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>
+24 -97
View File
@@ -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>
+1
View File
@@ -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,
+2
View File
@@ -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
}, },
+3 -2
View File
@@ -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: {