v0.1.0: CRM/ERP 系统内测版本 - 安全加固完成

- Docker bridge 网络隔离(8000 端口封死)
- Gunicorn 4 Worker 多进程
- Alembic 数据库迁移基线
- 日志轮转 20m×3
- JWT 密钥 + DB 密码 + CORS 收紧
- 3-2-1 备份链路(NAS + R740-B 冷备)
- 连接池 pool_pre_ping + pool_recycle=3600
This commit is contained in:
hankin
2026-03-16 07:31:37 +00:00
commit 423baff73b
2578 changed files with 824643 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
---
trigger: always_on
---
Variables:

{{ATTACHED_PROJECT_CODE, APP_USE_CASE, STEP_BY_STEP_REASONING', 'USER_TASK', 'ERROR, 'DEBUG_INSTRUCTIONS', 'PROBLEMATIC_CODE', 'PREDICTIONS', 'EXPLANATION, SCRATCHPAD}}
************************
Role: You are an intelligent, thorough, and helpful software developer debugging AI. Your task is to Conduct a thorough analysis of a specific ERROR encountered when performing the USER_TASK when running the program/script displayed in the ATTACHED_PROJECT_CODE.
Generate 'PREDICTIONS' for possible causes for the error.
Perform a meticulous investigation of the ATTACHED_PROJECT_CODE to find the PROBLEMATIC_CODE, with the goal of narrowing the predictions to the most likely one.
You will show your work in the SCRATCHPAD. Brainstorm and develop the plan to identify and correct the error through STEP_BY_STEP_REASONING. Then provide a detailed EXPLANATION with comprehensive step-by-step DEBUG_INSTRUCTIONS.
Remember to focus on providing a clear, well-explained solution and debugging guide, ensuring the error is not only resolved but also understood in the context of the app's operation and development. Then write corrected code snippets and then the code you are replacing with that. You must ensure there are paragraph breaks after each XML tags and numbered lists have proper formatting in your response. Ensure response is around 8000 tokens and are comprehensive and detailed.
<attached_project_code>
<YOUR_FILE_1.py>
{{Attached to convo}}
</YOUR_FILE_1.py>


<YOUR_FILE_2.py>
{{Attached to convo}}
</YOUR_FILE_2.py>
<YOUR_FILE_3.log>
{{Attached to convo}}
</YOUR_FILE_3.log>
</attached_project_code>



<app_use_case>


{{WRITE YOUR APP/PROGRAM USE CASE}}
</app_use_case>


<script_explanations>
{{EXPLAIN ALL OF YOUR ATTACHED SCRIPTS/FILES}}
</script_explanations>



Here is the error:
<error>
{{INPUT TERMINAL ERROR OR ERROR MESSAGE}}
</error>
The error occurred while the user was performing the following task:
<user_task>
{{EXPLAIN WHAT YOU OR THE PROGRAM WAS DOING WHEN ERROR/CRASH HAPPENED}}


</user_task>








Begin by examining the error code and user task. Research common causes for this type of error and relate these to the specific user task where the error occurs.

Based on your initial assessment, generate five educated predictions regarding potential causes of the error. These predictions should consider various aspects, such as coding mistakes, dependency issues, or resource constraints.


<predictions>
{{PREDICTIONS}}
</predictions>
Dive into the ATTACHED_PROJECT_CODE with your predictions in mind. Methodically review the code segments related to the user task where the ERROR was reported. Pay special attention to recent changes or updates that might have introduced the error.

Analyze the code in the context of each prediction. Use a process of elimination to narrow down the predictions by verifying or disproving each based on code inspection and logical reasoning. Document your rationale behind retaining or discarding each prediction in the scratchpad.
<scratchpad>
{{SCRATCHPAD}}
</scratchpad>
Here is the problematic code segment:
<problematic_code>
{{PROBLEMATIC_CODE}}
</problematic_code>
Document your entire thought process from initial error assessment through prediction formulation, code analysis, and debugging strategy. This narrative should highlight logical deductions, investigative methods, and the rationale for key decisions.


<step_by_step_reasoning>
{{STEP_BY_STEP_REASONING}}
</step_by_step_reasoning>


Select the most likely cause of the error from your remaining predictions. Provide a detailed explanation of why this particular issue is the root cause, referencing specific aspects of the problematic code and how they relate to the error manifestation.


<explanation>
{{EXPLANATION}}
</explanation>


Develop comprehensive, step-by-step instructions for resolving the identified issue. These instructions should be clear and actionable, suitable for a developer with knowledge of software development but possibly unfamiliar with the specific project.


<debug_instructions>
{{DEBUG_INSTRUCTIONS}}
</debug_instructions>
Remember to focus on providing a clear, well-explained solution and debugging guide, ensuring the error is not only resolved but also understood in the context of the app's operation and development. Then write corrected code snippets and then the code you are replacing with that.
+6
View File
@@ -0,0 +1,6 @@
node_modules/
.git/
.venv/
venv/
__pycache__/
*.pyc
+29
View File
@@ -0,0 +1,29 @@
# 环境变量(含密码和密钥)
server/.env
# Python
__pycache__/
*.pyc
.venv/
# Node
node_modules/
frontend/dist/
# 上传文件
server/uploads/
# Docker
*.log
# IDE
.vscode/
.idea/
.gemini/
# 临时文件
*.tmp
*.swp
# Alembic bytecode
server/alembic/versions/__pycache__/
+29
View File
@@ -0,0 +1,29 @@
FROM python:3.12-slim
WORKDIR /app
# 系统依赖(asyncpg 编译需要 gcc
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# 先拷贝依赖清单,利用 Docker 层缓存
COPY server/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
# 拷贝全部后端代码
COPY server/ .
EXPOSE 8000
# 生产模式:Gunicorn 多进程管理 + UvicornWorker 异步处理
# -w 4: 4 个 Worker 进程充分利用多核
# --timeout 600: 兼容 AI SSE 流式长请求
# --graceful-timeout 30: 平滑重启时等待旧连接结束
CMD ["gunicorn", "app.main:app", \
"-k", "uvicorn.workers.UvicornWorker", \
"-w", "4", \
"--bind", "0.0.0.0:8000", \
"--timeout", "600", \
"--graceful-timeout", "30", \
"--access-logfile", "-"]
+29
View File
@@ -0,0 +1,29 @@
# ============================================================
# Stage 1: Build — 基于 Node 18 Alpine 构建 Vue3 前端产物
# ============================================================
FROM node:18-alpine AS builder
WORKDIR /build
# 先拷贝依赖清单,利用 Docker 层缓存
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install
# 拷贝全部前端源码并执行生产构建
COPY frontend/ .
RUN npm install -D @types/node && npm run build
# ============================================================
# Stage 2: Run — 极致轻量的 Nginx 运行时
# ============================================================
FROM nginx:alpine
# 拷贝构建产物
COPY --from=builder /build/dist /usr/share/nginx/html
# 拷贝 Nginx 网关配置(覆盖默认)
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+124
View File
@@ -0,0 +1,124 @@
---
创建时间: 2026-02-27T15:07
更新时间: 2026-02-27T15:13
tags:
- WebApp
- work
---
# 项目现状与后端开发交接说明书 (Project Handover Document)
## 1. 项目概况与技术栈 (Project Overview & Tech Stack)
**业务背景**: 本项目为专为**润滑油行业**深度定制的 B2B 企业级 ERP/CRM 综合系统。业务链路完整覆盖了“客(客户与客情)、货(产品与库存)、单(订单与发货)、财(发票与智能报销)、权(组织与权限)”五大核心中后台数字化流转。
**技术栈要求**
- **前端 (已完成 UI 占位)**Vue 3 (Composition API), TypeScript, Element Plus, Vite.
- **后端 (即将开发,请 AI 注意)**:[⚠️请在此处填入你期望的后端语言,如:Node.js (NestJS) / Java (Spring Boot) / Python (FastAPI)] + MySQL/PostgreSQL 关系型数据库 + Redis (用于 Token 鉴权与缓存)。
## 2. 已完成的前端模块全景图 (Completed Frontend Modules)
现阶段已完成全套核心前台视图(UI/UX)搭建与交互逻辑封皮,主要包含以下独立路由模块:
- **`/` 及基础框架 (Layout)**:基于 `el-container` 的经典折叠侧边栏与顶部面包屑导航,预留多级路由。
- **`/customers` (客户管理大盘)**:涵盖高级检索,实现 A/B/C 三级高净值客户的标签化表格总览。
- **`/customers/detail` (客户详情)**:深度整合 AI。左侧档案卡+AI客情健康度;右侧/下方搭载沉浸式时间轴(Timeline),及弹窗式 `AI 拜访简报生成器`
- **`/products` (产品与库存)**:采用“左树右表”布局。左侧分类树,右侧精细管理多包装规格 SKU,内置双抽屉查看详情及【出入库流水账】。
- **`/orders` (订单管理)**:业务极度复杂的交易枢纽。含【客户专属历史定价自动带出】逻辑,精准追踪发货状态(待/部分/已发货)及付款进度(进度条跟踪)。
- **`/shipping` (物流发货记录)**:与订单强关联,支持【一笔订单分批发货】,追踪承运方与物流单号。
- **`/finance` (财务发票与报销中心)**
- **大一统票据池**:上传即刻 OCR 提取,底层实行软删除(作废)。
- **智能报销申请 (草稿体系)**:左侧票据池拉取,右侧购物车封装(内置费用描述及两级票据类型转换),实时合计。
- **专属 A4 打印**:利用 CSS `@media print` 穿透重绘出带大写金额转化、极高信噪比的定制版报销单。
- **`/settings` (系统设置与权限)**:标准 RBAC 控制台。左树(部门)右表(员工);角色池分配极细粒度的“数据横向穿透权限”(全部/本部门/仅本人)及菜单按钮权限。
## 3. 核心业务数据结构 (Core Data Structures / API Contracts)
_注:这是后端建立 SQL Schema 的绝对契约基准。_
**1. 客户与日志实体 (Customer & FollowUp)**
- **Customer**: `id`, `name`, `level`, `industry`, `contact`, `phone`, `aiScore`, `address`, `status`
- **FollowUpLog**: `id`, `customerId`, `salespersonId`, `content`, `emotionType`, `nextVisitTime`, `createTime` (用于生成 AI 简报的底层饲料)
**2. 产品库存实体 (Product SKU & InventoryFlow)**
- **ProductSku**: `id`, `categoryId`, `skuCode`, `name`, `spec` (如 200L/桶), `standardPrice`, `stockQty`, `warningThreshold`, `status`
- **InventoryFlow**: `id`, `skuCode`, `changeQty` (+/-), `reason` (进货/出库/损耗), `operatorId`, `createTime`
**3. 订单交易实体 (Order)**
- **OrderMain**: `orderNo`, `customerId`, `totalAmount`, `shippingState` (pending/partial/shipped), `paymentState` (unpaid/partial/cleared), `createTime`
- **OrderItem**: `id`, `orderNo`, `skuCode`, `qty`, `unitPrice`, `subTotal`
**4. 独立发货单实体 (Shipping - 承接分批发货)**
- **ShippingRecord**: `shippingNo`, `orderNo`, `carrier` (如德邦), `trackingNo`, `status` (transit/delivered), `shipDate`
- **ShippingItem**: `id`, `shippingNo`, `skuCode`, `shippedQty` (本次实际发货量)
**5. 财务及报销实体 (Invoice & Expense)**
- **InvoicePool** (票据池): `id`, `uploaderId`, `fileUrl`, `merchantName`, `amount`, `invoiceDate`, `type` (客户发票/报销发票), `is_deleted` (防物理篡改)
- **ExpenseRecord** (报销总单): `systemNo`, `applicantId`, `totalAmount`, `status` (draft/pending/approved/rejected)
- **ExpenseDetail** (报销明细行): `id`, `systemNo`, `invoiceId`, `expenseDesc`, `originalType` (油费/招待), `offsetType` (冲顶类型), `amount`
**6. 权限角色实体 (Role & Setup)**
- **User**: `userId`, `deptId`, `roleId`, `username`, `passwordHash`, `status`
- **Role**: `roleId`, `roleName`, `dataScope` (all/dept_and_sub/self), `menuKeys`
## 4. 下一阶段开发规划 (Next Phase Planning)
前端 UI 及交互体验已接近完全体(目前皆由 `ref/reactive` 驱动的纯本地响应式黑盒)。开启新一轮对话进入后端主导阶段,强烈建议按以下顺位破冰:
1. **确定后端框架与建库方案**
- 优先依据上述实体契约搭建关系型数据库模型(含 ER 关联图设计)。
- 注意:所有财务与流水表强制引入 `is_deleted``update_time` 等审计字段。
2. **鉴权与 RBAC 拦截栈基座开发**
- 开发 JWT Token 签发与认证体系。
- 核心难点预警:必须在底层封装针对 `dataScope` 的 SQL 动态拼接过滤器,严防数据越权访问。
3. **消除前端 Mock 层联调**
- 配置前端代理环境。
- 开发通用 CRUD 接口,逐步替换 Vue 文件中的 Mock 数据。
4. **复杂流体业务 API 攻坚**
- 开发“历史专享价”带出接口。
- 开发“分批发货关联扣减订单待发数量”的事务一致性逻辑。
- 开发“购物车报销合单”的长事务锁定逻辑。
+276
View File
@@ -0,0 +1,276 @@
# 润滑油 CRM/ERP 系统 — 阶段性全栈复盘与架构白皮书
**项目代号**:天津硕博霖业财一体化 ERP(SHBL-ERP
**当前阶段**Phase 1.5(核心业务闭环 + AI 中枢接入 / 内测上线态)
**报告日期**2026 年 3 月
**系统版本**v0.1.0
**部署地址**`http://192.168.1.100`(内网单节点)
---
## 一、系统技术基座 (Tech Stack & Dependencies)
本系统采用现代化前后端分离架构,AI 能力通过 Dify + Ollama 私有化部署实现,全栈可控。
### 1. 前端架构 (Frontend)
| 技术 | 说明 |
|------|------|
| **Vue 3** (Composition API) + TypeScript | 核心框架,14 个业务页面组件 |
| **Vite** | 构建工具,极速冷启动与 HMR |
| **Element Plus** | 企业级 UI,深度定制了报销单 `@media print` A4 打印样式 |
| **Pinia** | 全局状态管理(UserInfo、Token、RBAC 权限) |
| **Axios** | 封装全局 Request/Response 拦截器,自动处理 401 登出与业务异常 |
| **SSE (EventSource)** | AI 复盘报告实时流式输出 |
### 2. 后端架构 (Backend)
| 技术 | 说明 |
|------|------|
| **Python 3.10+ & FastAPI** | 核心框架,16 个 API 路由模块,自带 OpenAPI/Swagger 文档 |
| **Uvicorn** | ASGI 异步服务器 |
| **SQLAlchemy 2.0 + Asyncpg** | 全异步 ORM,19 张业务表 |
| **JWT + Passlib (Bcrypt)** | 认证体系(Token + 密码哈希) |
| **httpx** | 异步 HTTP 客户端,用于调用 Dify API 和 Ollama |
### 3. AI 能力层
| 组件 | 说明 |
|------|------|
| **Dify** (192.168.1.88) | AI 编排平台,承载 Workflow(复盘报告生成)和 Chat(AI 悬浮球对话) |
| **Ollama — RTX 3090** (192.168.1.88:11434) | 运行 `qwen3.5:27b`,用于复盘报告、企业画像等重任务 |
| **Ollama — RTX 4060** (192.168.1.88:11435) | 运行 `qwen3.5:4b`,用于发票 OCR 解析等轻任务 |
### 4. 数据持久层 (Database)
- **核心数据库**PostgreSQL(宿主机直连,asyncpg 驱动)
- **19 张业务表**,覆盖 CRM、ERP、财务、AI、系统五大域
- **亮点特性**`JSONB` 承载 AI OCR 提取数据 (`ai_extracted_data`)、强事务并发控制(库存/发货防重)
### 5. 部署架构
```
┌────────────────────────────────────────────────┐
│ 服务器 192.168.1.100 (Ubuntu, 61GB 磁盘) │
│ │
│ Docker Compose │
│ ┌──────────────────┐ ┌──────────────────────┐ │
│ │ crm-frontend │ │ crm-backend │ │
│ │ Nginx:80 (95MB) │ │ Uvicorn:8000 (889MB) │ │
│ │ bridge 网络 │ │ host 网络 │ │
│ └──────┬───────────┘ └──────────────────────┘ │
│ │ proxy_pass ↓ │
│ └── host.docker.internal:8000 │
│ │
│ PostgreSQL (宿主机 :5432) ← 直连 │
└────────────────────────────────────────────────┘
┌─────┴─────┐
│ Dify + Ollama │ ← 192.168.1.88
│ (RTX 3090/4060)│
└───────────────┘
```
---
## 二、九大核心业务模块全景剖析
系统已点亮 **9 大核心模块 + 2 个 AI 模块**,实现从"线索"到"现金"的业财链路闭环,并接入大模型智能辅助。
### 模块 1:RBAC 权限与系统基座
- **功能**:用户登录、改密、角色管理、员工管理
- **核心亮点****`data_scope` 数据横向隔离机制** — `self`(本人)/ `dept_and_sub`(部门)/ `all`(全公司)三维度,所有业务查询均受拦截,杜绝越权
- **关键表**`sys_users``sys_roles``sys_departments`
### 模块 2CRM 客户管理
- **功能**:客户全生命周期管理(新增、编辑、归档、搜索筛选、导入/导出)
- **核心亮点**
1. 创建时自动绑定 `owner_id`,融入 `data_scope` 防线
2. **联系人管理** — 独立 `crm_contacts` 表,支持多联系人关联
3. **AI 企业画像** — 调用 Ollama 大模型生成企业分析卡片
4. **AI 客情健康度评分** — 基于交互频率自动打分
5. **批量 Excel 导入/导出** — 支持模板下载 + openpyxl 解析
### 模块 3:供应链与货品库存
- **功能**:左树(无限级分类)右表(SKU)经典布局
- **核心亮点**
1. **无限级分类树**`erp_product_categories` 自关联支持 N 级嵌套
2. **安全库存变更**:一切库存变动必须通过 `POST /api/products/inventory/flow` 提交出入库流水,后端强事务加减库存,留存 `erp_inventory_flows` 审计轨迹
3. **库存预警**`stock_qty <= warning_threshold` 自动标红提示
4. **批量 SKU 导入**Excel 模板 + SKU 编码防重
### 模块 4:订单交易枢纽
- **功能**:主子表嵌套的沉浸式开单体验,支持多 SKU 明细行
- **核心亮点**:**B2B 智能动态定价(一客一价)** — 选中 SKU 时静默调用 `/api/orders/price/calculate`,自动调取该客户历史专属拿货价
### 模块 5:物流与发货执行
- **功能**:发货单管理,指导仓库拣货出库
- **核心亮点**
1. **五步原子事务防超发**:校验 `发货量 ≤ 订单总量 - 已发量`,订单状态更新 + 库存扣减 + 发货单生成毫秒级级联
2. **数据级联预加载**:发货单透出客户名称、包装规格等
### 模块 6:财务报销与票据中心
- **功能**:三 Tab 一站式财务管理 — 统一票据池 / 新建报销 / 报销大盘
- **核心亮点**
1. **发票 AI 智能解析**:拖拽上传 PDF/JPG/PNG/MD → 调用 Ollama 4060 (qwen3.5:4b) 自动提取发票号、金额、日期等字段 → 存入 `fin_invoice_pool.ai_extracted_data` (JSONB)
2. **批量上传队列**:支持多文件并行处理,逐一显示解析进度
3. **发票状态机**:发票被报销单选中瞬间 `is_used` 锁定,驳回自动释放
4. **高级科目定制**:硬编码"天津硕博霖"专属原始种类(招待费、交通费)和冲顶类型(税务费用)
5. **A4 打印**CSS `@media print` 标准黑白审批单,带四大签字占位符
### 模块 7:销项发票管理
- **功能**:独立的销项发票(开票)管理模块
- **核心亮点**
1. 客户名称/发票号搜索 + 回款状态筛选 + 开票日期范围
2. AI 发票文本提取(与票据池共用 Ollama 解析引擎)
3. 独立 `finance_sales_invoices`
### 模块 8:销售日志
- **功能**:销售人员每日拜访/沟通记录管理
- **核心亮点**
1. 独立 `sales_logs` 表,支持关键字搜索 + 日期筛选
2. **AI 智能审阅** — 调用大模型对日志内容进行质量评分和建议
3. 数据同时作为 AI 复盘报告的原始素材
### 模块 9Dashboard 工作台
- **功能**:首页 KPI 总览 + 快捷操作入口
- **核心亮点**
1. **四大 KPI 卡片**:本月订单数 / 待出库发货 / 库存预警 SKU / 本月营收 — 后端 `GET /api/dashboard/stats` 聚合查询实时计算
2. 快捷按钮一键跳转至订单、发货、库存、日志页面
---
## 三、AI 中枢模块
### AI 模块 A:智能复盘报告(Dify Workflow + SSE
- **架构**`前端 → 后端 SSE → Dify Workflow → Ollama 3090 (27B)`
- **流程**
1. 前端选择时间范围,后端查询 `sales_logs` 并序列化
2. 通过 httpx 异步调用 Dify Workflow APIstreaming 模式)
3. Workflow 内部:HTTP 获取日志 → LLM 分析生成 → HTTP 回调存档
4. 后端 SSE 实时推送 `text_chunk` 到前端逐字渲染
5. 生成完毕自动保存至 `ai_report_drafts`,支持历史加载和再编辑
- **关键配置**httpx 600s 超时 + nginx `proxy_buffering off` + 600s 读超时
### AI 模块 B:全局 AI 助手(浮球对话)
- **架构**`前端 FloatingChat → 后端 /api/chat → Dify Chat API`
- **功能**:全页面右下角悬浮球,随时与 AI 对话,支持上下文记忆
- **底层引擎**Dify Chat 应用,对接 Ollama
---
## 四、核心数据模型约束
所有核心表均遵守以下企业级约束:
| 约束 | 说明 |
|------|------|
| **主键策略** | 全局 UUID (`uuid4`),防爬虫遍历 |
| **软删除** | 所有表包含 `is_deleted` (Boolean),严禁物理 DELETE |
| **时间戳** | `created_at` / `updated_at`SQLAlchemy 监听器自动维护 |
| **数据隔离** | 业务查询均受 `data_scope` + `owner_id` 拦截 |
### 数据库全表清单(19 张)
| 域 | 表名 | 用途 |
|----|------|------|
| CRM | `crm_customers` | 客户主表 |
| CRM | `crm_contacts` | 客户联系人 |
| ERP | `erp_product_categories` | 产品分类树 |
| ERP | `erp_product_skus` | 产品 SKU |
| ERP | `erp_inventory_flows` | 库存变动流水 |
| ERP | `erp_orders` | 订单主表 |
| ERP | `erp_order_items` | 订单明细行 |
| ERP | `erp_shipping_records` | 发货单主表 |
| ERP | `erp_shipping_items` | 发货明细行 |
| 财务 | `fin_invoice_pool` | 报销发票池 |
| 财务 | `fin_expense_records` | 报销单主表 |
| 财务 | `fin_expense_details` | 报销明细行 |
| 财务 | `finance_sales_invoices` | 销项发票 |
| 协同 | `sales_logs` | 销售日志 |
| AI | `ai_chat_sessions` | AI 对话记录 |
| AI | `ai_report_drafts` | AI 复盘报告草稿 |
| 系统 | `sys_users` | 系统用户 |
| 系统 | `sys_roles` | 角色权限 |
| 系统 | `sys_departments` | 部门组织 |
---
## 五、API 接口全景(16 个路由模块)
| 模块 | 前缀 | 核心端点示例 |
|------|------|-------------|
| auth | `/api/auth` | login, me, password |
| customers | `/api/customers` | CRUD + 归档 + AI 画像 |
| contacts | `/api/contacts` | 客户联系人 CRUD |
| orders | `/api/orders` | CRUD + 动态定价 |
| shipping | `/api/shipping` | 发货 + 原子事务 |
| products | `/api/products` | SKU + 分类树 + 库存变更 |
| finance | `/api/finance` | 票据池 + 报销 + 审批 |
| sales_invoice | `/api/sales-invoices` | 销项发票 CRUD |
| sales_logs | `/api/sales-logs` | 销售日志 CRUD + AI 审阅 |
| reports | `/api/reports` | AI 复盘 SSE 生成 + 历史 |
| dashboard | `/api/dashboard` | KPI 聚合统计 |
| chat | `/api/chat` | AI 浮球对话 |
| dify_tools | `/api/dify-tools` | Dify 回调工具端点 |
| import_export | `/api` | 模板下载 + Excel 导入导出 |
| sys_settings | `/api/settings` | 用户/角色/部门管理 |
| deps | — | 鉴权依赖注入 |
---
## 六、Phase 1 → Phase 2 演进规划
### 已完成(Phase 1 → 1.5 增量)
| 项目 | 原状态(Phase 1 | 现状态(Phase 1.5 |
|------|-------------------|---------------------|
| 发票 AI 识别 | `setTimeout` 模拟 | ✅ Ollama 4060 真实解析 |
| Dashboard KPI | 硬编码 `--` 占位符 | ✅ 后端聚合 API 实时计算 |
| AI 复盘报告 | 无 | ✅ Dify Workflow + SSE 流式输出 |
| AI 对话助手 | 无 | ✅ Dify Chat + 浮球组件 |
| 联系人管理 | 无 | ✅ 独立模块 |
| 销项发票 | 无 | ✅ 独立模块 |
| 销售日志 | 无 | ✅ 独立模块 + AI 审阅 |
### Phase 2 规划
| 优先级 | 方向 | 说明 |
|--------|------|------|
| **P0** | HTTPS | 内网 CA 自签证书,保护 JWT Token 传输安全 |
| **P0** | Alembic 迁移 | 引入数据库 schema 版本管理,告别手工 DDL |
| **P1** | 数据可视化 | ECharts 接入 Dashboard — 月度趋势折线图、报销占比饼图 |
| **P1** | 操作审计日志 | 记录关键操作(删除、审批、密码重置)的完整审计轨迹 |
| **P1** | 文件存储优化 | 对接 MinIO / 阿里云 OSS,替代本地 uploads 目录 |
| **P2** | 移动端适配 | 响应式布局或微信小程序,支持外出销售人员使用 |
| **P2** | 外网访问 | VPN / Cloudflare Tunnel 实现远程办公访问 |
| **P2** | 监控告警 | Prometheus + Grafana 仪表板 + 钉钉/微信告警推送 |
| **P3** | 多租户 | 如需扩展到集团其他子公司,引入租户隔离架构 |
---
## 七、关键架构决策记录 (ADR)
| # | 决策 | 理由 |
|---|------|------|
| ADR-001 | 后端 Docker 使用 `network_mode: host` | 直连宿主机 PostgreSQL,免改 `pg_hba.conf`,简化内网部署 |
| ADR-002 | 前端 Docker 使用 `bridge + extra_hosts` | 通过 `host.docker.internal` 解析宿主机,隔离前端网络 |
| ADR-003 | AI 引擎选择 Dify + Ollama 私有化 | 数据不出内网,满足工业企业信息安全合规要求 |
| ADR-004 | 复盘报告采用 SSE 而非 WebSocket | 单向推送场景 SSE 更轻量,nginx 配置更简单 |
| ADR-005 | 全表 UUID 主键 | 防止 ID 遍历攻击,适配分布式未来扩展 |
| ADR-006 | 库存变更仅允许流水模式 | 杜绝直接篡改库存,确保账实一致 + 审计可追溯 |
---
*本文档为 SHBL-ERP CRM 系统 Phase 1.5 阶段性存档,随系统迭代持续更新。*
+642
View File
@@ -0,0 +1,642 @@
# -*- coding: utf-8 -*-
"""
[项目配置]
文件名: app.py
版本: v1.3.7
更新日期: 2026-02-24
更新内容: 代码审查修复 - 连接泄漏/SQL注入/emoji兼容性/版本号
"""
import streamlit as st
import sqlite3
import pandas as pd
import hashlib
import plotly.express as px
from datetime import datetime, timedelta
import io
# ==========================================
# 1. 配置与常量定义
# ==========================================
DB_FILE = 'crm_data.db'
st.set_page_config(
page_title="天津硕博霖客户信息管理系统",
page_icon=None,
layout="wide",
initial_sidebar_state="expanded"
)
# 业务常量
INDUSTRIES = ["海洋船舶", "港口机械", "电力", "空气行业", "其他"]
EQUIPMENT_TYPES = ["空气压缩机", "柴油发动机", "发电机组", "车辆", "液压设备", "齿轮箱", "其他"]
STATUS_OPTIONS = ["意向客户", "报价谈判", "成交签约", "维护"]
# --- 报销相关常量 (已更新) ---
# 1. 通用基础费用类型
BASE_EXPENSE_TYPES = ["办公费", "招待费", "差旅费", "交通费", "物流费", "油费", "福利费", "其他"]
# 2. 原始票据类型 (直接使用基础类型)
EXPENSE_TYPES_ORIGINAL = BASE_EXPENSE_TYPES
# 3. 冲顶票据类型 (新增置顶项 + 基础类型)
EXPENSE_TYPES_OFFSET = ["客户费用", "税务费用", "工资提成"] + BASE_EXPENSE_TYPES
EXPENSE_STATUS = ["待审核", "已通过", "已驳回"]
# ==========================================
# 2. 数据库管理模块
# ==========================================
def get_db_connection():
conn = sqlite3.connect(DB_FILE, check_same_thread=False)
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = get_db_connection()
c = conn.cursor()
# Users 表
c.execute('''
CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY,
password_hash TEXT NOT NULL,
role TEXT NOT NULL,
permissions TEXT NOT NULL
)
''')
# Clients 表
c.execute('''
CREATE TABLE IF NOT EXISTS clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
industry TEXT,
address TEXT,
equipment_type TEXT,
status TEXT,
contact_person TEXT,
phone TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# Follow_ups 表
c.execute('''
CREATE TABLE IF NOT EXISTS follow_ups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER,
note TEXT,
operator TEXT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (client_id) REFERENCES clients (id)
)
''')
# Expenses 表
c.execute('''
CREATE TABLE IF NOT EXISTS expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
applicant TEXT,
apply_date DATE,
description TEXT,
location TEXT,
amount REAL,
invoice_type_original TEXT,
invoice_type_offset TEXT,
remarks TEXT,
status TEXT DEFAULT '待审核',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# 默认管理员
c.execute('SELECT count(*) FROM users')
if c.fetchone()[0] == 0:
default_pwd_hash = hashlib.sha256("Yu@crm123".encode()).hexdigest()
c.execute(
'INSERT INTO users (username, password_hash, role, permissions) VALUES (?, ?, ?, ?)',
('admin', default_pwd_hash, 'admin', 'view,edit,delete')
)
print("系统初始化:默认管理员账户已创建")
conn.commit()
conn.close()
init_db()
# ==========================================
# 3. 工具函数
# ==========================================
def hash_password(password):
return hashlib.sha256(password.encode()).hexdigest()
def verify_user(username, password):
conn = get_db_connection()
c = conn.cursor()
c.execute('SELECT * FROM users WHERE username = ?', (username,))
user = c.fetchone()
conn.close()
if user and user['password_hash'] == hash_password(password):
return user
return None
def run_query(query, params=(), fetch=False):
conn = get_db_connection()
try:
c = conn.cursor()
c.execute(query, params)
if fetch:
data = c.fetchall()
return data
conn.commit()
return True
except Exception as e:
st.error(f"数据库操作错误: {e}")
return False
finally:
conn.close()
def to_excel(df):
output = io.BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
df.to_excel(writer, index=False, sheet_name='Sheet1')
processed_data = output.getvalue()
return processed_data
# ==========================================
# 4. 页面功能模块
# ==========================================
def login_page():
st.markdown("<h1 style='text-align: center;'>天津硕博霖 CRM 系统登录</h1>", unsafe_allow_html=True)
col1, col2, col3 = st.columns([1, 2, 1])
with col2:
with st.form("login_form"):
username = st.text_input("用户名")
password = st.text_input("密码", type="password")
submitted = st.form_submit_button("登录", use_container_width=True)
if submitted:
user = verify_user(username, password)
if user:
st.session_state['logged_in'] = True
st.session_state['username'] = user['username']
st.session_state['role'] = user['role']
st.session_state['permissions'] = user['permissions']
st.rerun()
else:
st.error("用户名或密码错误")
def sidebar_menu():
st.sidebar.title(f"欢迎, {st.session_state['username']}")
st.sidebar.markdown("---")
menu = st.sidebar.radio("功能导航", ["客户录入", "客户列表与跟进", "经营看板", "报销管理"])
# 管理员菜单
if st.session_state['role'] == 'admin':
st.sidebar.markdown("---")
with st.sidebar.expander("管理员工具 (用户管理)"):
tab_add, tab_manage = st.tabs(["新增", "管理"])
with tab_add:
with st.form("add_user_form"):
new_u = st.text_input("用户名")
new_p = st.text_input("密码", type="password")
new_r = st.selectbox("角色", ["user", "admin"])
new_perm = st.text_input("权限", value="view,edit")
if st.form_submit_button("创建"):
if new_u and new_p:
run_query('INSERT INTO users (username, password_hash, role, permissions) VALUES (?, ?, ?, ?)',
(new_u, hash_password(new_p), new_r, new_perm))
st.success("创建成功")
with tab_manage:
conn = get_db_connection()
users_df = pd.read_sql("SELECT username FROM users", conn)
conn.close()
target_user = st.selectbox("选择用户", users_df['username'])
if target_user:
new_pwd = st.text_input("重置密码", type="password")
if st.button("保存密码"):
run_query("UPDATE users SET password_hash=? WHERE username=?", (hash_password(new_pwd), target_user))
st.success("密码已重置")
if st.button("删除用户", type="primary"):
if target_user != st.session_state['username']:
run_query("DELETE FROM users WHERE username=?", (target_user,))
st.rerun()
st.sidebar.markdown("---")
if st.sidebar.button("退出登录"):
st.session_state.clear()
st.rerun()
return menu
# --- 业务模块:客户录入 ---
def page_new_client():
st.header("新增客户录入")
with st.form("new_client"):
col1, col2 = st.columns(2)
with col1:
name = st.text_input("客户名称 (必填)")
industry = st.selectbox("所属行业", INDUSTRIES)
contact = st.text_input("联系人")
phone = st.text_input("联系电话")
with col2:
eq_type = st.selectbox("关键设备类型", EQUIPMENT_TYPES)
status = st.selectbox("目前状态", STATUS_OPTIONS)
address = st.text_input("地址")
desc = st.text_area("备注")
if st.form_submit_button("提交录入") and name:
run_query('INSERT INTO clients (name, description, industry, address, equipment_type, status, contact_person, phone) VALUES (?,?,?,?,?,?,?,?)',
(name, desc, industry, address, eq_type, status, contact, phone))
st.success(f"客户 {name} 已录入")
# --- 业务模块:客户列表与跟进 ---
def page_client_hub():
st.header("客户列表与跟进中心")
search = st.text_input("搜索客户", "")
conn = get_db_connection()
sql = "SELECT * FROM clients WHERE name LIKE ? ORDER BY created_at DESC" if search else "SELECT * FROM clients ORDER BY created_at DESC"
df = pd.read_sql(sql, conn, params=(f'%{search}%',) if search else ())
conn.close()
st.dataframe(df, use_container_width=True, height=200, hide_index=True, column_config={"id":"ID", "name":"名称", "status":"状态"})
if not df.empty:
opts = {row['id']: f"{row['name']}" for _, row in df.iterrows()}
sel_id = st.selectbox("选择客户查看详情", options=list(opts.keys()), format_func=lambda x: opts[x])
if sel_id:
tab1, tab2 = st.tabs(["基础信息", "跟进记录"])
with tab1:
conn = get_db_connection()
info = pd.read_sql("SELECT * FROM clients WHERE id=?", conn, params=(sel_id,)).iloc[0]
conn.close()
can_edit = "edit" in st.session_state['permissions'] or st.session_state['role'] == "admin"
with st.form("edit_c"):
c1, c2 = st.columns(2)
nm = c1.text_input("名称", info['name'], disabled=not can_edit)
stt = c2.selectbox("状态", STATUS_OPTIONS, index=STATUS_OPTIONS.index(info['status']) if info['status'] in STATUS_OPTIONS else 0, disabled=not can_edit)
dsc = st.text_area("备注", info['description'], disabled=not can_edit)
if can_edit:
s1, s2 = st.columns(2)
with s1:
if st.form_submit_button("保存修改"):
run_query("UPDATE clients SET name=?, status=?, description=? WHERE id=?", (nm, stt, dsc, sel_id))
st.success("已更新")
st.rerun()
with s2:
if st.session_state['role'] == 'admin':
if st.form_submit_button("删除客户", type="primary"):
run_query("DELETE FROM follow_ups WHERE client_id=?", (sel_id,))
run_query("DELETE FROM clients WHERE id=?", (sel_id,))
st.success("已删除")
st.rerun()
with tab2:
with st.form("add_f"):
note = st.text_area("新跟进")
if st.form_submit_button("添加") and note:
run_query("INSERT INTO follow_ups (client_id, note, operator) VALUES (?,?,?)", (sel_id, note, st.session_state['username']))
st.success("已添加")
st.rerun()
conn = get_db_connection()
hist = pd.read_sql("SELECT * FROM follow_ups WHERE client_id=? ORDER BY timestamp DESC", conn, params=(sel_id,))
conn.close()
for _, r in hist.iterrows():
st.info(f"{r['timestamp']} | {r['operator']}: {r['note']}")
if r['operator'] == st.session_state['username'] or st.session_state['role'] == 'admin':
if st.button("删除", key=f"del_f_{r['id']}"):
run_query("DELETE FROM follow_ups WHERE id=?", (r['id'],))
st.rerun()
# --- 业务模块:经营看板 ---
def page_dashboard():
st.header("经营看板")
conn = get_db_connection()
try:
df = pd.read_sql("SELECT status, count(*) as count FROM clients GROUP BY status", conn)
if not df.empty:
fig = px.pie(df, values='count', names='status', title='客户状态分布', hole=0.4)
st.plotly_chart(fig, use_container_width=True)
finally:
conn.close()
# ==========================================
# 5. 报销管理模块 (v1.3.7: 批量审批 + 信息完善版)
# ==========================================
def page_expenses():
st.header("销售报销管理")
is_admin = st.session_state['role'] == 'admin'
if 'expense_buffer' not in st.session_state:
st.session_state['expense_buffer'] = []
tabs = ["新建申请 (批量)", "我的记录"]
if is_admin:
tabs = ["新建申请 (批量)", "我的记录", "审批中心", "统计与全局维护"]
active_tab = st.tabs(tabs)
# --- Tab 1: 新建申请 ---
with active_tab[0]:
col_input, col_preview = st.columns([1, 1])
with col_input:
st.markdown("#### 1. 录入明细")
st.caption("填完点击“加入列表”,最后统一提交。")
with st.form("expense_entry_form", clear_on_submit=True):
r1, r2 = st.columns(2)
app_date = r1.date_input("发生日期", datetime.now())
amount = r2.number_input("金额 (元)", min_value=0.0, step=1.0)
desc = st.text_input("费用描述", placeholder="例如: 招待天津港客户")
r3_1, r3_2 = st.columns(2)
location = r3_1.text_input("消费地点")
invoice_orig = r3_1.selectbox("原始票据", EXPENSE_TYPES_ORIGINAL)
invoice_offset = st.selectbox("冲顶票据", EXPENSE_TYPES_OFFSET)
remarks = st.text_area("备注", height=60)
if st.form_submit_button("[+] 加入待提交列表"):
if amount > 0 and desc:
st.session_state['expense_buffer'].append({
"apply_date": app_date, "amount": amount, "description": desc,
"location": location, "invoice_type_original": invoice_orig,
"invoice_type_offset": invoice_offset, "remarks": remarks
})
st.success("已加入!")
st.rerun()
else:
st.warning("请补全金额和描述")
with col_preview:
st.markdown(f"#### 2. 待提交列表 ({len(st.session_state['expense_buffer'])})")
if st.session_state['expense_buffer']:
for i, item in enumerate(st.session_state['expense_buffer']):
with st.container():
c_info, c_del = st.columns([5, 1])
with c_info:
st.markdown(f"**¥{item['amount']}** | {item['description']}")
st.caption(f"{item['apply_date']} | 原:{item['invoice_type_original']} / 冲:{item['invoice_type_offset']}")
with c_del:
if st.button("删除", key=f"del_item_{i}", help="删除此条"):
st.session_state['expense_buffer'].pop(i)
st.rerun()
st.divider()
c1, c2 = st.columns([2,1])
with c1:
if st.button("批量提交所有", type="primary", use_container_width=True):
conn = get_db_connection()
c = conn.cursor()
try:
for item in st.session_state['expense_buffer']:
c.execute('''INSERT INTO expenses (applicant, apply_date, description, location, amount,
invoice_type_original, invoice_type_offset, remarks, status) VALUES (?,?,?,?,?,?,?,?,?)''',
(st.session_state['username'], item['apply_date'], item['description'], item['location'],
item['amount'], item['invoice_type_original'], item['invoice_type_offset'], item['remarks'], '待审核'))
conn.commit()
st.session_state['expense_buffer'] = []
st.success("提交成功!")
st.rerun()
finally: conn.close()
with c2:
if st.button("清空列表"):
st.session_state['expense_buffer'] = []
st.rerun()
else:
st.info("暂无待提交记录。")
# --- Tab 2: 我的记录 ---
with active_tab[1]:
conn = get_db_connection()
my_df = pd.read_sql("SELECT * FROM expenses WHERE applicant=? ORDER BY apply_date DESC", conn, params=(st.session_state['username'],))
conn.close()
st.dataframe(my_df, use_container_width=True, hide_index=True)
pending = my_df[my_df['status']=='待审核']
if not pending.empty:
st.markdown("---")
with st.expander("撤回申请 (退回修改)", expanded=True):
st.caption("撤回后,单据将退回【新建申请】列表,方便修改后重新提交。")
del_id = st.selectbox("选择要撤回的记录", pending['id'], format_func=lambda x: f"#{x} - ¥{pending[pending['id']==x]['amount'].values[0]} - {pending[pending['id']==x]['description'].values[0]}")
if st.button("撤回并修改"):
row_data = pending[pending['id'] == del_id].iloc[0]
st.session_state['expense_buffer'].append({
"apply_date": row_data['apply_date'], "amount": row_data['amount'],
"description": row_data['description'], "location": row_data['location'],
"invoice_type_original": row_data['invoice_type_original'],
"invoice_type_offset": row_data['invoice_type_offset'], "remarks": row_data['remarks']
})
run_query("DELETE FROM expenses WHERE id=?", (del_id,))
st.success(f"单据 #{del_id} 已撤回!")
st.rerun()
st.markdown("---")
st.markdown("#### 打印我的报销单")
if not my_df.empty:
my_print_ids = st.multiselect("选择要打印的明细", my_df['id'], format_func=lambda x: f"#{x} | {my_df[my_df['id']==x]['apply_date'].values[0]} | ¥{my_df[my_df['id']==x]['amount'].values[0]}", key="print_multiselect_user")
if my_print_ids:
print_df = my_df[my_df['id'].isin(my_print_ids)]
total_print_amt = print_df['amount'].sum()
html_content = """
<style>
@media print {
.stApp { visibility: hidden; height: 0; overflow: hidden; }
.invoice-box, .invoice-box * { visibility: visible; }
.invoice-box { position: absolute; left: 0; top: 0; width: 100%; margin: 0; padding: 30px; border: none; }
@page { margin: 0; }
body { margin: 1cm; }
}
body { display: flex; justify-content: center; background-color: white; }
.invoice-box { width: 750px; padding: 30px; border: 2px solid #000; font-family: 'SimHei', Arial, sans-serif; color: #000; background-color: white; margin-top: 20px; }
.header { text-align: center; margin-bottom: 25px; }
.header h2 { margin: 0; padding-bottom: 10px; border-bottom: 2px solid #000; letter-spacing: 2px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 20px; font-size: 14px; }
th { background: #f0f0f0; font-weight: bold; border: 1px solid #000; padding: 10px; text-align: center; }
td { border: 1px solid #000; padding: 8px; text-align: center; vertical-align: middle; }
.col-desc { text-align: left; }
.total-row td { border: 2px solid #000; background: #fff; font-weight: bold; font-size: 16px; }
.footer { display: flex; justify-content: space-between; margin-top: 60px; padding: 0 10px; }
.sig-line { width: 30%; border-top: 1px solid #000; text-align: center; padding-top: 10px; font-size: 15px; }
</style>
<div class="invoice-box">
<div class="header">
<h2>天津硕博霖 - 费用报销汇总单</h2>
<p style="text-align:right; margin-top:8px; font-size:14px;">打印日期: """ + datetime.now().strftime("%Y-%m-%d") + """</p>
</div>
<table>
<thead>
<tr>
<th width="10%">单号</th>
<th width="12%">申请人</th>
<th width="12%">日期</th>
<th width="36%">费用描述 / 票据类型</th>
<th width="15%">消费地点</th>
<th width="15%">金额 (元)</th>
</tr>
</thead>
<tbody>
"""
for _, row in print_df.iterrows():
html_content += f"""
<tr>
<td>#{row['id']}</td>
<td>{row['applicant']}</td>
<td>{row['apply_date']}</td>
<td class="col-desc">
{row['description']}<br>
<span style="font-size:12px; color:#555;">(原:{row['invoice_type_original']} / 冲:{row['invoice_type_offset']})</span>
</td>
<td>{row['location']}</td>
<td>¥{row['amount']}</td>
</tr>
"""
html_content += f"""
<tr class="total-row">
<td colspan="5" style="text-align:right; padding-right:15px;">合计金额 (Total)</td>
<td>¥{total_print_amt:,.2f}</td>
</tr>
</tbody>
</table>
<div class="footer">
<div class="sig-line">申请人签字</div>
<div class="sig-line">部门领导审批</div>
<div class="sig-line">主管会计审核</div>
</div>
</div>
"""
st.components.v1.html(html_content, height=900, scrolling=True)
if is_admin:
# --- Tab 3: 审批中心 (核心更新区域) ---
with active_tab[2]:
st.markdown("#### 批量审批中心")
# 1. 获取所有待审核数据
conn = get_db_connection()
# 读取所有字段
pending_df = pd.read_sql("SELECT * FROM expenses WHERE status='待审核' ORDER BY apply_date", conn)
conn.close()
if not pending_df.empty:
# 2. 增加一个 'Select' 列,默认为 False,放在第一列
pending_df.insert(0, "选择", False)
# 3. 使用 data_editor 实现可勾选的表格
st.caption("请勾选左侧复选框选择单据,然后点击下方按钮批量处理。可直接在表格中查看完整信息。")
edited_df = st.data_editor(
pending_df,
column_config={
"选择": st.column_config.CheckboxColumn(
"选择",
help="勾选以进行批量操作",
default=False,
),
"id": st.column_config.NumberColumn("单号", width="small", help="系统唯一ID"),
"applicant": st.column_config.TextColumn("申请人", width="small"),
"apply_date": st.column_config.DateColumn("日期", format="YYYY-MM-DD"),
"amount": st.column_config.NumberColumn("金额", format="¥%.2f"),
"description": st.column_config.TextColumn("费用描述", width="medium"),
"location": st.column_config.TextColumn("地点"),
"invoice_type_original": st.column_config.TextColumn("原始票据"),
"invoice_type_offset": st.column_config.TextColumn("冲顶票据"),
"remarks": st.column_config.TextColumn("备注", width="medium"),
"status": st.column_config.TextColumn("状态", disabled=True),
"created_at": st.column_config.DatetimeColumn("提交时间", format="D MMM, HH:mm", disabled=True),
},
disabled=["id", "applicant", "apply_date", "amount", "description",
"location", "invoice_type_original", "invoice_type_offset",
"remarks", "status", "created_at"], # 禁止修改数据,只允许勾选
hide_index=True,
use_container_width=True
)
# 4. 批量操作按钮
# 筛选出被勾选的行
selected_rows = edited_df[edited_df["选择"] == True]
col_approve, col_reject = st.columns([1, 1])
with col_approve:
# 批量通过按钮
if st.button(f"✅ 批量通过 ({len(selected_rows)}项)", type="primary", use_container_width=True):
if not selected_rows.empty:
id_list = selected_rows['id'].tolist()
# 批量更新数据库
conn = get_db_connection()
c = conn.cursor()
# 使用 executemany 或者循环更新,这里为了安全用参数化查询
placeholders = ', '.join(['?'] * len(id_list))
sql = f"UPDATE expenses SET status='已通过' WHERE id IN ({placeholders})"
c.execute(sql, id_list)
conn.commit()
conn.close()
st.success(f"已通过 {len(id_list)} 条申请!")
st.rerun()
else:
st.warning("请先勾选需要通过的单据。")
with col_reject:
# 批量驳回按钮
if st.button(f"❌ 批量驳回 ({len(selected_rows)}项)", use_container_width=True):
if not selected_rows.empty:
id_list = selected_rows['id'].tolist()
conn = get_db_connection()
c = conn.cursor()
placeholders = ', '.join(['?'] * len(id_list))
sql = f"UPDATE expenses SET status='已驳回' WHERE id IN ({placeholders})"
c.execute(sql, id_list)
conn.commit()
conn.close()
st.error(f"已驳回 {len(id_list)} 条申请!")
st.rerun()
else:
st.warning("请先勾选需要驳回的单据。")
else:
st.info("目前没有待审批的单据。")
# --- Tab 4: 统计与维护 ---
with active_tab[3]:
st.markdown("#### 全局统计与导出")
start_d = st.date_input("起始日期", datetime.now().replace(day=1))
conn = get_db_connection()
all_df = pd.read_sql("SELECT * FROM expenses WHERE apply_date >= ? ORDER BY apply_date DESC", conn, params=(str(start_d),))
conn.close()
if not all_df.empty:
st.metric("总金额", f"¥{all_df['amount'].sum():,.2f}")
st.download_button("导出Excel", to_excel(all_df), "所有报销.xlsx")
with st.expander("全局单据维护", expanded=True):
opts = {row['id']: f"#{row['id']} {row['applicant']} ¥{row['amount']}" for _, row in all_df.iterrows()}
mid = st.selectbox("选择单据", list(opts.keys()), format_func=lambda x: opts[x])
c1, c2 = st.columns(2)
if c1.button("重置为待审核"): run_query("UPDATE expenses SET status='待审核' WHERE id=?", (mid,)); st.rerun()
if c2.button("彻底删除", type="primary"): run_query("DELETE FROM expenses WHERE id=?", (mid,)); st.rerun()
# ==========================================
# 6. 主程序入口
# ==========================================
def main():
if 'logged_in' not in st.session_state:
st.session_state['logged_in'] = False
if not st.session_state['logged_in']:
login_page()
else:
selected_page = sidebar_menu()
if selected_page == "客户录入":
page_new_client()
elif selected_page == "客户列表与跟进":
page_client_hub()
elif selected_page == "经营看板":
page_dashboard()
elif selected_page == "报销管理":
page_expenses()
if __name__ == '__main__':
main()
+21
View File
@@ -0,0 +1,21 @@
APP_NAME=SHBL-CRM
APP_VERSION=2.0.0
DEBUG=true
# PostgreSQL 连接
DB_HOST=192.168.1.85
DB_PORT=5432
DB_USER=admin
DB_PASSWORD=admin_password_2026
DB_NAME=lubrication_crm
# JWT 密钥
SECRET_KEY=dev_secret_key_replace_in_production_64chars_minimum_length_ok
# CORS 白名单
CORS_ORIGINS=["http://localhost:5173","http://localhost:8080"]
# Dify BaaS 平台
DIFY_BASE_URL=http://192.168.1.88/v1
DIFY_LOG_APP_API_KEY=app-gMi1uhkJXjteZk1Qc27Ve8Jw
DIFY_REPORT_APP_API_KEY=app-dhEK3cgt7iqksRMaviNqUu9W
+24
View File
@@ -0,0 +1,24 @@
# ---- SHBL-CRM Backend Environment ----
# Copy to .env and fill in real values
APP_NAME=SHBL-CRM
APP_VERSION=2.0.0
DEBUG=true
# PostgreSQL
DB_HOST=127.0.0.1
DB_PORT=5432
DB_USER=crm_admin
DB_PASSWORD=change_me_in_production
DB_NAME=shbl_crm
# JWT
SECRET_KEY=REPLACE_WITH_RANDOM_64_CHAR_HEX
# CORS
CORS_ORIGINS=["http://localhost:5173","http://localhost:8080"]
# Dify BaaS (http://192.168.1.88)
DIFY_BASE_URL=http://192.168.1.88/v1
DIFY_LOG_APP_API_KEY=app-xxx
DIFY_REPORT_APP_API_KEY=app-xxx
+35
View File
@@ -0,0 +1,35 @@
import psycopg2
conn = psycopg2.connect(
host="192.168.1.85",
port=5432,
user="admin",
password="admin_password_2026",
dbname="lubrication_crm",
)
cur = conn.cursor()
# Check table structures
for t in ["users", "clients", "follow_ups", "expenses"]:
cur.execute(f"""
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = '{t}'
ORDER BY ordinal_position
""")
print(f"\n=== {t} ===")
for row in cur.fetchall():
print(f" {row[0]:20s} | {row[1]:20s} | null={row[2]} | default={row[3]}")
# Check constraints
cur.execute(f"""
SELECT conname, contype
FROM pg_constraint
JOIN pg_class ON conrelid = pg_class.oid
WHERE pg_class.relname = '{t}'
""")
constraints = cur.fetchall()
if constraints:
print(f" Constraints: {constraints}")
conn.close()
+36
View File
@@ -0,0 +1,36 @@
[alembic]
script_location = alembic
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %%(levelname)-5.5s [%%(name)s] %%(message)s
datefmt = %%H:%%M:%%S
+64
View File
@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
"""
Alembic 迁移环境配置
从 app.core.config 动态获取数据库 URL,自动检测 ORM 模型变更。
"""
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import settings
from app.core.database import Base
# 导入所有模型,确保 Alembic 能检测到它们
import app.models # noqa: F401
# Alembic Config 对象
config = context.config
# 动态注入数据库 URL (使用同步驱动,因为 Alembic 不支持异步)
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL_SYNC)
# 日志配置
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# MetaData 对象 - Alembic 通过它检测表结构变更
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""离线模式:生成 SQL 脚本而不实际连接数据库"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""在线模式:连接数据库并直接执行迁移"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+25
View File
@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,143 @@
"""initial_full_schema
Renames legacy tables to *_legacy backup, then creates
all tables with correct UUID PKs, types, and constraints.
Revision ID: 0001
Revises:
Create Date: 2026-02-24
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Legacy tables to rename (preserve data)
LEGACY_TABLES = ["users", "clients", "follow_ups", "expenses"]
def upgrade() -> None:
conn = op.get_bind()
# Step 1: Rename legacy tables to *_legacy
for table in LEGACY_TABLES:
exists = conn.execute(sa.text(
f"SELECT EXISTS (SELECT 1 FROM information_schema.tables "
f"WHERE table_schema='public' AND table_name='{table}')"
)).scalar()
if exists:
conn.execute(sa.text(
f'ALTER TABLE "{table}" RENAME TO "{table}_legacy"'
))
# Step 2: Create new tables with proper schema
# ---- users ----
conn.execute(sa.text("""
CREATE TABLE users (
id UUID PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(128) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'viewer',
permissions JSON DEFAULT '[]',
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT now(),
updated_at TIMESTAMP DEFAULT now()
);
"""))
# ---- clients ----
conn.execute(sa.text("""
CREATE TABLE clients (
id UUID PRIMARY KEY,
name VARCHAR(200) NOT NULL,
contact_person VARCHAR(100),
phone VARCHAR(30),
address VARCHAR(500),
notes TEXT,
created_at TIMESTAMP DEFAULT now(),
updated_at TIMESTAMP DEFAULT now()
);
"""))
conn.execute(sa.text(
"CREATE INDEX ix_clients_name ON clients (name);"
))
# ---- customer_logs ----
conn.execute(sa.text("""
CREATE TABLE customer_logs (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT now()
);
"""))
conn.execute(sa.text(
"CREATE INDEX ix_customer_logs_cid ON customer_logs (customer_id);"
))
# ---- customer_tags ----
conn.execute(sa.text("""
CREATE TABLE customer_tags (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
tag_name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT now()
);
"""))
conn.execute(sa.text(
"CREATE INDEX ix_customer_tags_cid ON customer_tags (customer_id);"
))
# ---- follow_up_todos ----
conn.execute(sa.text("""
CREATE TABLE follow_up_todos (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
task_desc TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at TIMESTAMP DEFAULT now()
);
"""))
conn.execute(sa.text(
"CREATE INDEX ix_follow_up_todos_cid ON follow_up_todos (customer_id);"
))
# ---- sales_opportunities ----
conn.execute(sa.text("""
CREATE TABLE sales_opportunities (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
stage VARCHAR(20) NOT NULL DEFAULT 'intent',
created_at TIMESTAMP DEFAULT now()
);
"""))
conn.execute(sa.text(
"CREATE INDEX ix_sales_opp_cid ON sales_opportunities (customer_id);"
))
def downgrade() -> None:
conn = op.get_bind()
# Drop new tables
for t in ["sales_opportunities", "follow_up_todos", "customer_tags",
"customer_logs", "clients", "users"]:
conn.execute(sa.text(f'DROP TABLE IF EXISTS "{t}" CASCADE'))
# Restore legacy tables
for table in LEGACY_TABLES:
exists = conn.execute(sa.text(
f"SELECT EXISTS (SELECT 1 FROM information_schema.tables "
f"WHERE table_schema='public' AND table_name='{table}_legacy')"
)).scalar()
if exists:
conn.execute(sa.text(
f'ALTER TABLE "{table}_legacy" RENAME TO "{table}"'
))
+1
View File
@@ -0,0 +1 @@
# SHBL-CRM Backend Application Package
View File
+40
View File
@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
"""
API 公共依赖
提供 JWT 令牌验证依赖,用于需要认证的路由。
"""
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.core.security import decode_access_token
# Bearer Token 提取器
bearer_scheme = HTTPBearer(auto_error=True)
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
) -> dict:
"""
从 Authorization: Bearer <token> 中解码 JWT,返回 payload。
用法: current_user: dict = Depends(get_current_user)
"""
payload = decode_access_token(credentials.credentials)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="令牌无效或已过期",
headers={"WWW-Authenticate": "Bearer"},
)
return payload
async def require_admin(current_user: dict = Depends(get_current_user)) -> dict:
"""仅允许 admin 角色访问,否则 403"""
if current_user.get("role") != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="权限不足,需要管理员角色",
)
return current_user
View File
+37
View File
@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
"""
认证端点
处理用户登录,签发 JWT 令牌。
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import create_access_token
from app.crud.user import authenticate_user
from app.schemas.user import Token, UserLogin
router = APIRouter()
@router.post("/login", response_model=Token, summary="用户登录", tags=["认证"])
async def login(body: UserLogin, db: AsyncSession = Depends(get_db)):
"""
校验用户名密码,成功后签发 JWT access_token。
前端后续请求需在 Authorization 头携带 Bearer <token>。
"""
user = await authenticate_user(db, body.username, body.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
headers={"WWW-Authenticate": "Bearer"},
)
token = create_access_token(subject=user.username, role=user.role)
return Token(
access_token=token,
role=user.role,
username=user.username,
)
+31
View File
@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
"""
健康检查端点
用于 Nginx/LB 探活和数据库连接状态探测。
"""
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
router = APIRouter()
@router.get("/health", summary="健康检查", tags=["系统"])
async def health_check(db: AsyncSession = Depends(get_db)):
"""
探测服务与数据库连接是否存活。
- 数据库可达 → {"status": "healthy", "database": "connected"}
- 数据库不可达 → {"status": "degraded", "database": "disconnected", "detail": "..."}
"""
try:
await db.execute(text("SELECT 1"))
return {"status": "healthy", "database": "connected"}
except Exception as e:
return {
"status": "degraded",
"database": "disconnected",
"detail": str(e),
}
+81
View File
@@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
"""
客户沟通日志 API
POST /api/v1/logs - 提交日志并触发后台 AI 标签提取
"""
import uuid
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.database import get_db
from app.models.crm_business import CustomerLog
from app.services.ai_workflow import process_log_with_ai
router = APIRouter()
# ---- 请求/响应模型 ----
class LogCreate(BaseModel):
"""提交沟通日志请求"""
customer_id: uuid.UUID = Field(..., description="关联客户 ID")
content: str = Field(..., min_length=5, max_length=5000, description="沟通日志内容")
class LogResponse(BaseModel):
"""提交成功响应"""
id: uuid.UUID
message: str = "日志已提交,AI 正在后台分析标签和待办"
# ---- 路由 ----
@router.post(
"",
response_model=LogResponse,
status_code=status.HTTP_200_OK,
summary="提交客户沟通日志",
tags=["客户日志"],
)
async def create_customer_log(
body: LogCreate,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
接收前端提交的客户沟通日志:
1. 立即存入 customer_logs 表
2. 将 AI 标签提取任务加入 BackgroundTasks 后台队列
3. 立即返回 200 OK(不等待 AI 处理完成)
AI 后台任务会:
- 调用 qwen3:14b 分析日志内容
- 自动提取最多 3 个客户标签 → customer_tags
- 自动生成 1 个跟进待办 → follow_up_todos
"""
# Step 1: 立即写入日志记录
log = CustomerLog(
customer_id=body.customer_id,
content=body.content,
)
db.add(log)
await db.flush()
await db.refresh(log)
# Step 2: 将 AI 处理加入后台队列
# *** 关键:传入 log.id / body.content / body.customer_id 三个值 ***
# process_log_with_ai 会创建独立的 DB Session,不与当前请求的 db 共享
background_tasks.add_task(
process_log_with_ai,
log_id=log.id,
content=body.content,
customer_id=body.customer_id,
)
# Step 3: 立即返回(不等待 AI)
return LogResponse(id=log.id)
+55
View File
@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
"""
销售复盘报告 API
GET /api/v1/reports/monthly - 获取当月销售复盘报告 (AI 生成)
"""
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.database import get_db
from app.services.analytics import generate_monthly_report
router = APIRouter()
# ---- 响应模型 ----
class StageMetric(BaseModel):
"""单个阶段的统计指标"""
stage: str
count: int
total_amount: float
class MonthlyReportResponse(BaseModel):
"""月度复盘报告响应"""
metrics: list[StageMetric]
report: str
# ---- 路由 ----
@router.get(
"/monthly",
response_model=MonthlyReportResponse,
summary="获取当月销售复盘报告",
tags=["数据报告"],
)
async def get_monthly_report(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
生成当月销售复盘报告:
1. SQL 预聚合统计各阶段的机会数量和金额
2. 将真实数据注入 Prompt,调用 qwen3:14b 生成分析报告
3. 同步返回结构化数据 + AI 报告文本
注意:此接口为同步等待模式(用户主动触发),
AI 生成可能需要 10-30 秒,前端应显示加载状态。
"""
result = await generate_monthly_report(db)
return MonthlyReportResponse(**result)
+22
View File
@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
"""
API v1 路由汇总
所有 v1 版本的子路由在此注册,由 main.py 统一挂载到 /api/v1 前缀。
"""
from fastapi import APIRouter
from app.api.v1.endpoints import auth, health, logs, reports
api_v1_router = APIRouter()
# 挂载各业务模块路由
api_v1_router.include_router(health.router, prefix="", tags=["系统"])
api_v1_router.include_router(auth.router, prefix="/auth", tags=["认证"])
api_v1_router.include_router(logs.router, prefix="/logs", tags=["客户日志"])
api_v1_router.include_router(reports.router, prefix="/reports", tags=["数据报告"])
# 后续新增模块在此追加,例如:
# from app.api.v1.endpoints import clients, expenses
# api_v1_router.include_router(clients.router, prefix="/clients", tags=["客户管理"])
# api_v1_router.include_router(expenses.router, prefix="/expenses", tags=["报销管理"])
View File
+65
View File
@@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
"""
核心配置模块
使用 Pydantic v2 Settings 管理所有环境变量,支持 .env 文件自动加载。
"""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""应用全局配置,所有敏感信息通过环境变量注入,禁止硬编码。"""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
# ---- 应用基础 ----
APP_NAME: str = "SHBL-CRM"
APP_VERSION: str = "2.0.0"
DEBUG: bool = False
# ---- 数据库 (PostgreSQL + asyncpg) ----
DB_HOST: str = "127.0.0.1"
DB_PORT: int = 5432
DB_USER: str = "crm_admin"
DB_PASSWORD: str = "change_me_in_production"
DB_NAME: str = "shbl_crm"
@property
def DATABASE_URL(self) -> str:
"""构造异步 PostgreSQL 连接字符串 (asyncpg 驱动)"""
return (
f"postgresql+asyncpg://{self.DB_USER}:{self.DB_PASSWORD}"
f"@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
)
@property
def DATABASE_URL_SYNC(self) -> str:
"""同步连接字符串,仅供 Alembic 迁移使用"""
return (
f"postgresql+psycopg2://{self.DB_USER}:{self.DB_PASSWORD}"
f"@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
)
# ---- JWT 安全 ----
SECRET_KEY: str = "REPLACE_WITH_RANDOM_64_CHAR_HEX"
JWT_ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 # 24小时
# ---- CORS 白名单 (严格模式,禁止 "*") ----
CORS_ORIGINS: list[str] = [
"http://localhost:5173", # Vite 开发服务器
"http://localhost:8080", # Nginx 生产前端
]
# ---- AI 服务 (Dify BaaS 平台) ----
DIFY_BASE_URL: str = "http://192.168.1.88/v1"
DIFY_LOG_APP_API_KEY: str = "" # 日志分析 App (completion)
DIFY_REPORT_APP_API_KEY: str = "" # 月度报告 App (completion)
# 全局单例,其他模块通过 from app.core.config import settings 引用
settings = Settings()
+58
View File
@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
"""
异步数据库引擎与会话管理
使用 SQLAlchemy 2.0 异步模式 + asyncpg 驱动,配置连接池参数。
提供 get_db() 依赖注入函数供 FastAPI 路由使用。
"""
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
# ---- 异步引擎 (带连接池配置) ----
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG, # DEBUG 模式下打印 SQL
pool_size=20, # 连接池常驻连接数
max_overflow=10, # 超出 pool_size 后允许的临时连接数
pool_pre_ping=True, # 每次取连接前探测存活,防止用到已断开的连接
pool_recycle=3600, # 连接最大存活时间(秒),防止数据库端主动断连
)
# ---- 异步会话工厂 ----
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False, # 提交后不过期对象属性,避免 lazy load 异常
)
# ---- ORM 基类 ----
class Base(DeclarativeBase):
"""所有 ORM 模型必须继承此基类,Alembic 通过 Base.metadata 自动检测表结构变更。"""
pass
# ---- 依赖注入:获取数据库会话 ----
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""
FastAPI Depends() 专用生成器。
每个请求获取独立会话,请求结束后自动关闭,异常时自动回滚。
用法: db: AsyncSession = Depends(get_db)
"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
+142
View File
@@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
"""
Dify BaaS API 客户端
取代原有的 OllamaClient,所有 AI 调用统一走 Dify 平台 API。
Dify 部署地址: http://192.168.1.88
文档参考: https://docs.dify.ai/guides/application-publishing/developing-with-apis
"""
import logging
import httpx
from app.core.config import settings
logger = logging.getLogger("dify_client")
class DifyClient:
"""
Dify 平台 API 客户端(异步)。
每个 Dify 应用有独立的 API Key:
- 日志分析 App → DIFY_LOG_APP_API_KEY
- 月度报告 App → DIFY_REPORT_APP_API_KEY
调用时传入对应 key 即可。
"""
def __init__(self, base_url: str = "http://192.168.1.88/v1"):
self.base_url = base_url.rstrip("/")
async def call_text_generator(
self,
api_key: str,
inputs: dict,
query: str = "",
) -> str:
"""
调用 Dify 文本生成(completion)类应用。
:param api_key: Dify App API Key (app-xxx 格式)
:param inputs: 传入变量字典,键名需与 Dify 后台配置的变量名一致
:param query: 可选的用户查询文本
:return: Dify 返回的 answer 文本,失败时返回空字符串
"""
url = f"{self.base_url}/completion-messages"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = {
"inputs": inputs,
"query": query,
"response_mode": "blocking",
"user": "crm-backend",
}
try:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(url, headers=headers, json=payload)
if response.status_code != 200:
logger.error(
"Dify API 非 200 响应: status=%d body=%s",
response.status_code,
response.text[:500],
)
return ""
data = response.json()
answer = data.get("answer", "")
logger.info(
"Dify 调用成功: %d chars (key=...%s)",
len(answer),
api_key[-6:],
)
return answer
except httpx.TimeoutException:
logger.error("Dify API 超时 (60s): url=%s key=...%s", url, api_key[-6:])
return ""
except Exception as e:
logger.error("Dify API 异常: %s (key=...%s)", e, api_key[-6:], exc_info=True)
return ""
async def call_workflow(
self,
api_key: str,
inputs: dict,
user: str = "crm-backend",
) -> dict | str:
"""
调用 Dify 工作流(workflow)类应用。
:param api_key: Dify App API Key (app-xxx 格式)
:param inputs: 传入变量字典,键名需与 Dify 后台配置的变量名一致
:param user: 用户标识
:return: Dify 工作流返回的 outputs 字典,失败时返回空字符串
"""
url = f"{self.base_url}/workflows/run"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = {
"inputs": inputs,
"response_mode": "blocking",
"user": user,
}
try:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(url, headers=headers, json=payload)
if response.status_code != 200:
logger.error(
"Dify Workflow 非 200 响应: status=%d body=%s",
response.status_code,
response.text[:500],
)
return ""
data = response.json()
outputs = data.get("data", {}).get("outputs", {})
logger.info(
"Dify Workflow 调用成功: outputs_keys=%s (key=...%s)",
list(outputs.keys()) if isinstance(outputs, dict) else "N/A",
api_key[-6:],
)
return outputs
except httpx.TimeoutException:
logger.error("Dify Workflow 超时 (60s): url=%s key=...%s", url, api_key[-6:])
return ""
except Exception as e:
logger.error("Dify Workflow 异常: %s (key=...%s)", e, api_key[-6:], exc_info=True)
return ""
# 全局单例,使用 settings 中配置的 Dify 地址
dify_client = DifyClient(base_url=settings.DIFY_BASE_URL)
+64
View File
@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
"""
安全模块:JWT 令牌签发/验证 + 密码哈希
使用 python-jose 进行 JWT 操作,bcrypt 直接进行密码哈希。
注意:passlib 已不再维护,与 bcrypt>=5.0 不兼容,故直接使用 bcrypt 库。
"""
from datetime import datetime, timedelta, timezone
import bcrypt
from jose import JWTError, jwt
from app.core.config import settings
def hash_password(plain_password: str) -> str:
"""将明文密码哈希为 bcrypt 格式存储"""
return bcrypt.hashpw(
plain_password.encode("utf-8"), bcrypt.gensalt()
).decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""校验明文密码与数据库中的哈希值是否匹配"""
return bcrypt.checkpw(
plain_password.encode("utf-8"),
hashed_password.encode("utf-8"),
)
def create_access_token(
subject: str,
role: str,
expires_delta: timedelta | None = None,
) -> str:
"""
签发 JWT 访问令牌。
:param subject: 用户标识 (通常是 username 或 user_id)
:param role: 用户角色 (admin / user),嵌入 claims 供前端和后端鉴权
:param expires_delta: 自定义过期时间,默认使用配置值
"""
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
)
payload = {
"sub": subject,
"role": role,
"exp": expire,
}
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
def decode_access_token(token: str) -> dict | None:
"""
解码并验证 JWT 令牌。
:return: payload 字典,失败返回 None
"""
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]
)
return payload
except JWTError:
return None
View File
+63
View File
@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
"""
用户 CRUD 数据访问层
封装所有用户相关的数据库操作,业务逻辑层只调用此模块,不直接写 SQL。
"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_password, verify_password
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate
async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
"""根据用户名查询用户"""
stmt = select(User).where(User.username == username)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def authenticate_user(
db: AsyncSession, username: str, password: str
) -> User | None:
"""验证用户名密码,返回用户对象或 None"""
user = await get_user_by_username(db, username)
if not user or not user.is_active:
return None
if not verify_password(password, user.password_hash):
return None
return user
async def create_user(db: AsyncSession, data: UserCreate) -> User:
"""创建新用户"""
user = User(
username=data.username,
password_hash=hash_password(data.password),
role=data.role,
permissions=data.permissions,
)
db.add(user)
await db.flush() # flush 获取自增 ID,但不提交 (由 get_db 统一提交)
await db.refresh(user)
return user
async def update_user(db: AsyncSession, user: User, data: UserUpdate) -> User:
"""部分更新用户信息"""
update_data = data.model_dump(exclude_unset=True)
if "password" in update_data:
update_data["password_hash"] = hash_password(update_data.pop("password"))
for field, value in update_data.items():
setattr(user, field, value)
await db.flush()
await db.refresh(user)
return user
async def delete_user(db: AsyncSession, user: User) -> None:
"""删除用户"""
await db.delete(user)
await db.flush()
+72
View File
@@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
"""
FastAPI 应用入口
组装中间件、CORS、路由,启动 ASGI 应用。
"""
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1.router import api_v1_router
from app.core.config import settings
from app.middleware.audit import AuditMiddleware
# ---- 日志配置 ----
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(name)-12s | %(levelname)-5s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
# ---- 生命周期管理 (替代已废弃的 on_event) ----
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用启动/关闭时执行的钩子"""
logger.info("SHBL-CRM 后端服务启动 | 版本: %s", settings.APP_VERSION)
logger.info("数据库连接: %s@%s:%s/%s",
settings.DB_USER, settings.DB_HOST, settings.DB_PORT, settings.DB_NAME)
yield
logger.info("SHBL-CRM 后端服务关闭")
# ---- 创建 FastAPI 实例 ----
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
description="天津硕博霖客户信息管理系统 - 后端 API",
docs_url="/api/docs", # Swagger UI 路径
redoc_url="/api/redoc", # ReDoc 路径
openapi_url="/api/openapi.json",
lifespan=lifespan,
)
# ---- 1. 审计中间件 (最先添加,确保拦截所有请求) ----
app.add_middleware(AuditMiddleware)
# ---- 2. CORS 跨域 (严格白名单模式,禁止 allow_origins=["*"]) ----
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS, # 仅允许配置中指定的来源
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
allow_headers=["Authorization", "Content-Type"],
)
# ---- 3. 挂载 API 路由 ----
app.include_router(api_v1_router, prefix="/api/v1")
# ---- 根路径 (可选,方便快速验证服务是否存活) ----
@app.get("/", tags=["系统"])
async def root():
return {
"service": settings.APP_NAME,
"version": settings.APP_VERSION,
"docs": "/api/docs",
}
View File
+59
View File
@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
"""
全局审计中间件
拦截所有入站 HTTP 请求,记录:方法、URL、客户端 IP、耗时、响应状态码。
日志输出到标准 logging,生产环境可对接 ELK / Loki 等日志收集系统。
"""
import logging
import time
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response
# 配置审计专用 logger,与业务日志分离
audit_logger = logging.getLogger("audit")
audit_logger.setLevel(logging.INFO)
class AuditMiddleware(BaseHTTPMiddleware):
"""
审计中间件 - 记录每个请求的关键信息。
日志格式: [AUDIT] <客户端IP> <方法> <URL> <状态码> <耗时ms>
"""
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
# 提取客户端真实 IP (优先取反向代理传递的 X-Forwarded-For)
client_ip = request.headers.get(
"X-Forwarded-For", request.client.host if request.client else "unknown"
)
method = request.method
url = str(request.url)
start_time = time.perf_counter()
try:
response = await call_next(request)
except Exception:
# 未捕获异常也要记录审计日志
elapsed_ms = (time.perf_counter() - start_time) * 1000
audit_logger.error(
"[AUDIT] %s %s %s 500 %.1fms (unhandled exception)",
client_ip, method, url, elapsed_ms,
)
raise
elapsed_ms = (time.perf_counter() - start_time) * 1000
audit_logger.info(
"[AUDIT] %s %s %s %d %.1fms",
client_ip, method, url, response.status_code, elapsed_ms,
)
# 将审计信息注入响应头 (方便调试,生产环境可移除)
response.headers["X-Request-Duration-Ms"] = f"{elapsed_ms:.1f}"
return response
+9
View File
@@ -0,0 +1,9 @@
# 在此处导入所有 ORM 模型,供 Alembic 自动检测
from app.models.user import User # noqa: F401
from app.models.client import Client # noqa: F401
from app.models.crm_business import ( # noqa: F401
CustomerLog,
CustomerTag,
FollowUpToDo,
SalesOpportunity,
)
+54
View File
@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
"""
客户主表模型
CRM 的核心实体,所有业务表 (日志/标签/待办/销售机会) 均通过外键关联到此表。
"""
import uuid
from datetime import datetime
from sqlalchemy import String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class Client(Base):
"""
客户信息表 (clients)
记录客户基本信息,作为 CRM 业务数据的核心关联实体。
"""
__tablename__ = "clients"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
)
name: Mapped[str] = mapped_column(
String(200), nullable=False, index=True,
comment="客户名称 (公司名/个人名)",
)
contact_person: Mapped[str | None] = mapped_column(
String(100), nullable=True,
comment="联系人姓名",
)
phone: Mapped[str | None] = mapped_column(
String(30), nullable=True,
comment="联系电话",
)
address: Mapped[str | None] = mapped_column(
String(500), nullable=True,
comment="地址",
)
notes: Mapped[str | None] = mapped_column(
Text, nullable=True,
comment="备注",
)
created_at: Mapped[datetime] = mapped_column(
server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
server_default=func.now(),
onupdate=func.now(),
)
+132
View File
@@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
"""
CRM 业务数据模型
定义客户沟通日志、标签、跟进待办、销售机会四张业务表。
所有主键均为 UUID,与 User/KnowledgeChunk 保持一致的 ID 策略。
"""
import uuid
from datetime import datetime
from sqlalchemy import ForeignKey, String, Text, Numeric, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class CustomerLog(Base):
"""
客户沟通日志表
记录每次与客户的沟通内容(电话/拜访/微信等),
新增日志时会触发后台 AI 任务自动提取标签和待办。
"""
__tablename__ = "customer_logs"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
)
customer_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("clients.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="关联客户 ID",
)
content: Mapped[str] = mapped_column(
Text, nullable=False, comment="沟通日志内容",
)
created_at: Mapped[datetime] = mapped_column(
server_default=func.now(), comment="记录时间",
)
class CustomerTag(Base):
"""
客户标签表
由 AI 从沟通日志中自动提取,也支持手动添加。
同一客户下的标签名唯一(通过业务逻辑控制去重)。
"""
__tablename__ = "customer_tags"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
)
customer_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("clients.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="关联客户 ID",
)
tag_name: Mapped[str] = mapped_column(
String(100), nullable=False, comment="标签名称,如'价格敏感''决策周期长'",
)
created_at: Mapped[datetime] = mapped_column(
server_default=func.now(),
)
class FollowUpToDo(Base):
"""
跟进待办表
由 AI 根据沟通日志自动生成下一步行动建议,
也可由用户手动创建。status 为简单的二态: pending / done。
"""
__tablename__ = "follow_up_todos"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
)
customer_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("clients.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="关联客户 ID",
)
task_desc: Mapped[str] = mapped_column(
Text, nullable=False, comment="待办任务描述",
)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="pending",
comment="状态: pending(待处理) / done(已完成)",
)
created_at: Mapped[datetime] = mapped_column(
server_default=func.now(),
)
class SalesOpportunity(Base):
"""
销售机会表
跟踪每个客户的销售漏斗阶段和金额,用于经营看板和复盘报告。
stage 四阶段: 意向 → 谈判 → 成交 → 流失
"""
__tablename__ = "sales_opportunities"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
)
customer_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("clients.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="关联客户 ID",
)
amount: Mapped[float] = mapped_column(
Numeric(12, 2), nullable=False, default=0,
comment="预估/实际金额 (元)",
)
stage: Mapped[str] = mapped_column(
String(20), nullable=False, default="意向",
comment="漏斗阶段: 意向 / 谈判 / 成交 / 流失",
)
created_at: Mapped[datetime] = mapped_column(
server_default=func.now(),
)
+46
View File
@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
"""
用户 ORM 模型
对应数据库 users 表,使用 SQLAlchemy 2.0 Mapped 注解风格。
"""
from datetime import datetime, timezone
from sqlalchemy import String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class User(Base):
"""用户表 - 存储账号、密码哈希、角色权限"""
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(
String(50), unique=True, nullable=False, index=True, comment="登录用户名"
)
password_hash: Mapped[str] = mapped_column(
String(255), nullable=False, comment="bcrypt 哈希密码"
)
role: Mapped[str] = mapped_column(
String(20), nullable=False, default="user", comment="角色: admin / user"
)
permissions: Mapped[str] = mapped_column(
String(200), nullable=False, default="view,edit", comment="逗号分隔权限列表"
)
is_active: Mapped[bool] = mapped_column(
default=True, comment="账户是否启用"
)
created_at: Mapped[datetime] = mapped_column(
server_default=func.now(), comment="创建时间"
)
updated_at: Mapped[datetime] = mapped_column(
server_default=func.now(),
onupdate=lambda: datetime.now(timezone.utc),
comment="最后更新时间",
)
def __repr__(self) -> str:
return f"<User(id={self.id}, username='{self.username}', role='{self.role}')>"
View File
+55
View File
@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
"""
用户相关 Pydantic v2 校验模型 (DTO)
用于请求体验证和响应序列化,与 ORM 模型解耦。
"""
from datetime import datetime
from pydantic import BaseModel, Field, ConfigDict
# ---- 请求模型 ----
class UserLogin(BaseModel):
"""登录请求"""
username: str = Field(..., min_length=2, max_length=50, examples=["admin"])
password: str = Field(..., min_length=6, max_length=128)
class UserCreate(BaseModel):
"""创建用户请求 (管理员操作)"""
username: str = Field(..., min_length=2, max_length=50)
password: str = Field(..., min_length=6, max_length=128)
role: str = Field(default="user", pattern=r"^(admin|user)$")
permissions: str = Field(default="view,edit")
class UserUpdate(BaseModel):
"""更新用户请求 (部分更新)"""
password: str | None = Field(default=None, min_length=6, max_length=128)
role: str | None = Field(default=None, pattern=r"^(admin|user)$")
permissions: str | None = None
is_active: bool | None = None
# ---- 响应模型 ----
class UserOut(BaseModel):
"""用户信息响应 (脱敏,不含密码哈希)"""
model_config = ConfigDict(from_attributes=True) # 支持从 ORM 对象自动转换
id: int
username: str
role: str
permissions: str
is_active: bool
created_at: datetime
class Token(BaseModel):
"""JWT 令牌响应"""
access_token: str
token_type: str = "bearer"
role: str
username: str
View File
+145
View File
@@ -0,0 +1,145 @@
# -*- coding: utf-8 -*-
"""
AI 工作流服务 (Dify BaaS 版本)
事件驱动的异步 AI 任务:从客户沟通日志中自动提取标签和生成跟进待办。
架构要点:
- 此函数由 FastAPI BackgroundTasks 调用,运行在独立的后台线程中
- 必须使用独立的 DB Session 生命周期,严禁与主请求共享 Session
- AI 调用通过 Dify 平台 API(非直连大模型)
- AI 解析失败时静默降级(记录日志),绝不抛出异常导致主线程崩溃
"""
import json
import logging
import uuid
from app.core.dify_client import dify_client
from app.core.database import AsyncSessionLocal
from app.models.crm_business import CustomerTag, FollowUpToDo
from app.core.config import settings
logger = logging.getLogger("ai_workflow")
async def process_log_with_ai(
log_id: uuid.UUID,
content: str,
customer_id: uuid.UUID,
) -> None:
"""
后台 AI 任务:分析沟通日志,提取标签和待办。
*** 关键约束 ***
此函数在 BackgroundTasks 中执行,必须:
1. 使用独立的 AsyncSession(不与主请求共享)
2. 内部捕获所有异常,不向外抛出
3. AI 返回格式异常时静默降级
"""
logger.info("开始 AI 处理: log_id=%s, customer_id=%s", log_id, customer_id)
if not settings.DIFY_LOG_APP_API_KEY:
logger.error("DIFY_LOG_APP_API_KEY 未配置,跳过 AI 处理")
return
# ---- 独立的 DB Session 生命周期 ----
async with AsyncSessionLocal() as db:
try:
# Step 1: 调用 Dify 日志分析 Workflow App
# *** inputs 的键名必须与 Dify 后台配置的变量名对齐 ***
# 在 Dify 后台的 Workflow 编排中,需定义输入变量 "log_content"
workflow_outputs = await dify_client.call_workflow(
api_key=settings.DIFY_LOG_APP_API_KEY,
inputs={"log_content": content},
)
if not workflow_outputs:
logger.warning("Dify 返回空响应,跳过入库 (log_id=%s)", log_id)
return
# Workflow 返回的是 dict,序列化为 JSON 字符串以兼容现有解析管线
if isinstance(workflow_outputs, dict):
raw_response = json.dumps(workflow_outputs, ensure_ascii=False)
else:
raw_response = str(workflow_outputs)
logger.debug("Dify Workflow 原始返回: %s", raw_response[:500])
# Step 2: 解析 JSON 响应 (容错)
json_str = _extract_json(raw_response)
result = json.loads(json_str)
tags: list[str] = result.get("tags", [])
next_task: str = result.get("next_task", "")
# Step 3: 写入标签 (最多 3 个)
if tags:
for tag_name in tags[:3]:
tag_name = tag_name.strip()
if not tag_name:
continue
tag = CustomerTag(
customer_id=customer_id,
tag_name=tag_name,
)
db.add(tag)
logger.info("写入 %d 个标签: %s", len(tags[:3]), tags[:3])
# Step 4: 写入跟进待办
if next_task and next_task.strip():
todo = FollowUpToDo(
customer_id=customer_id,
task_desc=next_task.strip(),
status="pending",
)
db.add(todo)
logger.info("写入待办: %s", next_task.strip()[:100])
await db.commit()
logger.info("AI 处理完成: log_id=%s", log_id)
except json.JSONDecodeError as e:
logger.error(
"Dify 返回 JSON 解析失败 (log_id=%s): %s | 原始响应: %s",
log_id, e, raw_response[:300],
)
await db.rollback()
except Exception as e:
logger.error(
"AI 后台任务异常 (log_id=%s): %s",
log_id, e, exc_info=True,
)
await db.rollback()
def _extract_json(text: str) -> str:
"""
从 Dify/LLM 响应中提取 JSON 字符串。
处理常见的"包裹"行为:
- 直接返回 JSON
- 用 ```json ... ``` 包裹
- 在 JSON 前后加解释文字
"""
text = text.strip()
# 尝试提取 ```json ... ``` 代码块
if "```json" in text:
start = text.index("```json") + len("```json")
end = text.index("```", start)
return text[start:end].strip()
if "```" in text:
start = text.index("```") + len("```")
end = text.index("```", start)
return text[start:end].strip()
# 尝试找到第一个 { 和最后一个 }
first_brace = text.find("{")
last_brace = text.rfind("}")
if first_brace != -1 and last_brace != -1 and last_brace > first_brace:
return text[first_brace:last_brace + 1]
# 原样返回,让 json.loads 报错触发容错
return text
+127
View File
@@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
"""
销售数据分析服务 (Dify BaaS 版本)
基于 SQL 预聚合的销售漏斗统计 + Dify AI 驱动的复盘报告生成。
"""
import logging
from datetime import datetime, timezone
from sqlalchemy import func, select, extract, case
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.dify_client import dify_client
from app.models.crm_business import SalesOpportunity
logger = logging.getLogger("analytics")
# ============================================================
# 1. SQL 预聚合:销售漏斗指标
# ============================================================
async def get_sales_metrics(db: AsyncSession) -> list[dict]:
"""
按当月统计各销售阶段的机会数量和总金额。
使用 SQLAlchemy GROUP BY 聚合查询,在数据库层面完成计算。
"""
now = datetime.now(timezone.utc)
stmt = (
select(
SalesOpportunity.stage,
func.count(SalesOpportunity.id).label("count"),
func.coalesce(func.sum(SalesOpportunity.amount), 0).label("total_amount"),
)
.where(
extract("year", SalesOpportunity.created_at) == now.year,
extract("month", SalesOpportunity.created_at) == now.month,
)
.group_by(SalesOpportunity.stage)
.order_by(
case(
(SalesOpportunity.stage == "意向", 1),
(SalesOpportunity.stage == "谈判", 2),
(SalesOpportunity.stage == "成交", 3),
(SalesOpportunity.stage == "流失", 4),
else_=5,
)
)
)
result = await db.execute(stmt)
rows = result.all()
metrics = [
{
"stage": row.stage,
"count": row.count,
"total_amount": float(row.total_amount),
}
for row in rows
]
logger.info("当月销售指标: %s", metrics)
return metrics
# ============================================================
# 2. Dify AI 复盘报告生成
# ============================================================
async def generate_monthly_report(db: AsyncSession) -> dict:
"""
生成当月销售复盘报告:
1. SQL 预聚合获取真实数据指标
2. 将结构化数据通过 inputs 传给 Dify 报告 App
3. 直接返回 Dify 生成的报告文本
:return: {"metrics": [...], "report": "Dify 生成的报告文本"}
"""
# Step 1: 获取真实销售数据
metrics = await get_sales_metrics(db)
if not metrics:
return {
"metrics": [],
"report": "当月暂无销售机会数据,无法生成复盘报告。",
}
# Step 2: 将数据序列化为 Dify 可消费的文本格式
# *** inputs 的键名必须与 Dify 后台配置的变量名对齐 ***
# 在 Dify 后台的报告 App 编排中,需定义输入变量 "metrics_data"
metrics_text = "\n".join(
f"- {m['stage']}: {m['count']} 个机会, 总金额 ¥{m['total_amount']:,.2f}"
for m in metrics
)
total_count = sum(m["count"] for m in metrics)
total_amount = sum(m["total_amount"] for m in metrics)
metrics_text += f"\n- 合计: {total_count} 个机会, 总金额 ¥{total_amount:,.2f}"
# Step 3: 调用 Dify 报告 App
if not settings.DIFY_REPORT_APP_API_KEY:
logger.error("DIFY_REPORT_APP_API_KEY 未配置")
return {
"metrics": metrics,
"report": "AI 报告服务未配置,请联系管理员。",
}
# 动态生成 Dify 后台所需的必填参数 report_period
now = datetime.now(timezone.utc)
report_period = f"{now.year}{now.month:02d}"
report_text = await dify_client.call_text_generator(
api_key=settings.DIFY_REPORT_APP_API_KEY,
inputs={"metrics_data": metrics_text, "report_period": report_period},
)
if not report_text:
report_text = "AI 报告生成失败,请稍后重试或检查 Dify 服务状态。"
logger.info("月度复盘报告生成完成 (%d 字)", len(report_text))
return {
"metrics": metrics,
"report": report_text,
}
+49
View File
@@ -0,0 +1,49 @@
import os
import json
import httpx
from dotenv import load_dotenv
# 加载环境变量
load_dotenv()
DIFY_LOG_APP_API_KEY = os.getenv("DIFY_LOG_APP_API_KEY")
DIFY_REPORT_APP_API_KEY = os.getenv("DIFY_REPORT_APP_API_KEY")
TARGET_URL = "http://192.168.1.88/v1/completion-messages"
def test_dify_endpoint(api_key: str, app_name: str, payload: dict):
if not api_key:
print(f"[{app_name}] Error: API Key not found in .env file.")
return
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
print(f"\n--- Testing App: {app_name} ---")
print(f"[{app_name}] Request Payload: {json.dumps(payload, ensure_ascii=False)}")
with httpx.Client(timeout=30.0) as client:
# 这里严禁 try-except 包含网络响应错误(只让它如果是网络断开直接崩就行),但能抓取到 400 等状态码
response = client.post(TARGET_URL, headers=headers, json=payload)
print(f"[{app_name}] HTTP Status Code: {response.status_code}")
print(f"[{app_name}] Raw Response Body: {response.text}")
if __name__ == "__main__":
# 测试用例 1: 日志分析应用
log_payload = {
"inputs": {"log_content": "今天拜访了张总,他对价格很敏感。"},
"response_mode": "blocking",
"user": "debug_script"
}
test_dify_endpoint(DIFY_LOG_APP_API_KEY, "Log Analysis App", log_payload)
# 测试用例 2: 月度报告应用
report_payload = {
"inputs": {"metrics_data": "测试指标数据", "report_period": "2026年02月"},
"response_mode": "blocking",
"user": "debug_script"
}
test_dify_endpoint(DIFY_REPORT_APP_API_KEY, "Monthly Report App", report_payload)
+28
View File
@@ -0,0 +1,28 @@
# SHBL-CRM Backend Dependencies
# Python 3.10+
# ---- Web 框架 ----
fastapi>=0.115.0
uvicorn[standard]>=0.34.0
# ---- 数据库 (异步 PostgreSQL) ----
sqlalchemy[asyncio]>=2.0.0
asyncpg>=0.30.0
psycopg2-binary>=2.9.0 # 仅 Alembic 同步迁移使用
alembic>=1.14.0
# ---- 数据校验 ----
pydantic>=2.0.0
pydantic-settings>=2.0.0
# ---- 安全 ----
python-jose[cryptography]>=3.3.0
bcrypt>=4.0.0
# ---- AI (Dify BaaS HTTP 客户端) ----
httpx>=0.27.0 # 异步 HTTP 客户端 (调用 Dify API)
# ---- 工具 ----
python-multipart>=0.0.9 # FastAPI 表单/文件上传支持
pandas>=2.0.0
openpyxl>=3.1.0
+35
View File
@@ -0,0 +1,35 @@
import psycopg2
import bcrypt
import json
import uuid
try:
conn = psycopg2.connect(
host="192.168.1.85",
port=5432,
user="admin",
password="admin_password_2026",
dbname="lubrication_crm"
)
cur = conn.cursor()
hash_pw = bcrypt.hashpw(b"admin123", bcrypt.gensalt()).decode("utf-8")
permissions_json = json.dumps(["view", "edit"])
new_uuid = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO users (id, username, password_hash, role, permissions, is_active)
VALUES (%s, %s, %s, %s, %s, true)
ON CONFLICT (username) DO UPDATE SET password_hash=EXCLUDED.password_hash
""",
(new_uuid, "admin", hash_pw, "admin", permissions_json)
)
conn.commit()
print("Admin user inserted/updated via SQL script.")
except Exception as e:
print(f"Database error: {e}")
finally:
if 'cur' in locals():
cur.close()
if 'conn' in locals():
conn.close()
+64
View File
@@ -0,0 +1,64 @@
import psycopg2
import uuid
from datetime import datetime
try:
conn = psycopg2.connect(
host="192.168.1.85",
port=5432,
user="admin",
password="admin_password_2026",
dbname="lubrication_crm"
)
cur = conn.cursor()
# 1. 存在测试用的客户 ID (LogEntry.vue 默认写的)
test_client_uuid = "a37dbd8b-a9c0-4deb-ad76-83a0d29bbf28"
cur.execute(
"""
INSERT INTO clients (id, name, contact_person, phone)
VALUES (%s, '自动化联调测试客户', '张经理', '13800138000')
ON CONFLICT (id) DO NOTHING
""",
(test_client_uuid,)
)
# 2. 插入一些销售机会数据 (用于展示当月的 Dashboard AI 复盘效果)
current_year = datetime.now().year
current_month = datetime.now().month
created_str = f"{current_year}-{current_month:02d}-15 10:00:00"
opportunities = [
{"stage": "意向", "amount": 50000},
{"stage": "意向", "amount": 20000},
{"stage": "谈判", "amount": 150000},
{"stage": "成交", "amount": 300000},
{"stage": "流失", "amount": 10000},
]
for opp in opportunities:
cur.execute(
"""
INSERT INTO sales_opportunities (id, customer_id, stage, amount, created_at)
VALUES (%s, %s, %s, %s, %s)
""",
(
str(uuid.uuid4()),
test_client_uuid,
opp["stage"],
opp["amount"],
created_str,
)
)
conn.commit()
print("Seed data for logging and reports inserted successfully.")
except Exception as e:
print(f"Database error: {e}")
finally:
if 'cur' in locals():
cur.close()
if 'conn' in locals():
conn.close()
+245
View File
@@ -0,0 +1,245 @@
# -*- coding: utf-8 -*-
"""
SHBL-CRM Integration Tests
Module 3: Business Logic + AI Workflow
Test 1: POST /api/v1/logs - Event-driven AI background task
Test 2: GET /api/v1/reports/monthly - SQL pre-aggregation + AI report
Prerequisites:
- Backend running: uvicorn app.main:app (port 8000)
- PostgreSQL + Alembic migration applied
- Ollama node reachable (for AI tests)
Run: pytest tests/test_api_integration.py -v -s
"""
import asyncio
import logging
import uuid
import httpx
import pytest
import pytest_asyncio
# ---- Logging setup for test output ----
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(name)-12s | %(levelname)-5s | %(message)s",
)
logger = logging.getLogger("test_integration")
# ---- Config ----
BASE_URL = "http://127.0.0.1:8000/api/v1"
# Test fixtures: pre-seeded client & auth
# Must insert a test client into DB before running,
# or use the seed script. This UUID will be used for all tests.
TEST_CLIENT_ID: str | None = None # Populated by fixture
TEST_TOKEN: str | None = None # Populated by fixture
# ============================================================
# Fixtures
# ============================================================
@pytest_asyncio.fixture(scope="module")
async def seed_test_data():
"""
Seed a test client and admin user into the database,
then authenticate to get a JWT token.
"""
global TEST_CLIENT_ID, TEST_TOKEN
# Create admin user + test client via direct DB insert
import psycopg2
import bcrypt
admin_hash = bcrypt.hashpw(b"admin123", bcrypt.gensalt()).decode("utf-8")
test_client_uuid = str(uuid.uuid4())
conn = psycopg2.connect(
host="192.168.1.85",
port=5432,
user="admin",
password="admin_password_2026",
dbname="lubrication_crm",
)
cur = conn.cursor()
# Seed admin user (idempotent)
cur.execute("""
INSERT INTO users (id, username, password_hash, role, is_active)
VALUES (%s, 'test_admin', %s, 'admin', true)
ON CONFLICT (username) DO UPDATE SET password_hash = EXCLUDED.password_hash
""", (str(uuid.uuid4()), admin_hash))
# Seed test client
cur.execute("""
INSERT INTO clients (id, name, contact_person, phone)
VALUES (%s, 'Test_Integration_Client', 'Zhang San', '13800138000')
""", (test_client_uuid,))
conn.commit()
cur.close()
conn.close()
TEST_CLIENT_ID = test_client_uuid
# Authenticate to get JWT token
async with httpx.AsyncClient(base_url=BASE_URL, timeout=10.0) as client:
resp = await client.post("/auth/login", json={
"username": "test_admin",
"password": "admin123",
})
assert resp.status_code == 200, f"Login failed: {resp.text}"
TEST_TOKEN = resp.json()["access_token"]
logger.info("Auth token acquired for test_admin")
yield
# Cleanup: remove test data
conn = psycopg2.connect(
host="192.168.1.85", port=5432, user="admin",
password="admin_password_2026", dbname="lubrication_crm",
)
cur = conn.cursor()
cur.execute("DELETE FROM customer_tags WHERE customer_id = %s", (test_client_uuid,))
cur.execute("DELETE FROM follow_up_todos WHERE customer_id = %s", (test_client_uuid,))
cur.execute("DELETE FROM customer_logs WHERE customer_id = %s", (test_client_uuid,))
cur.execute("DELETE FROM sales_opportunities WHERE customer_id = %s", (test_client_uuid,))
cur.execute("DELETE FROM clients WHERE id = %s", (test_client_uuid,))
conn.commit()
cur.close()
conn.close()
logger.info("Test data cleaned up")
# ============================================================
# Test Case 1: POST /api/v1/logs + AI Background Task
# ============================================================
@pytest.mark.asyncio
async def test_create_log_and_ai_background_task(seed_test_data):
"""
Test the event-driven AI processing pipeline:
1. POST log → 200 OK (immediate)
2. Wait for BackgroundTasks to call Ollama
3. Verify tags & todos were written to DB
"""
assert TEST_TOKEN, "Auth token missing"
assert TEST_CLIENT_ID, "Test client ID missing"
headers = {"Authorization": f"Bearer {TEST_TOKEN}"}
log_content = "今天拜访了张总,客户对价格敏感,要求下周二前给折扣方案"
# ---- Step 1: Submit log ----
async with httpx.AsyncClient(base_url=BASE_URL, timeout=15.0) as client:
resp = await client.post(
"/logs",
json={
"customer_id": TEST_CLIENT_ID,
"content": log_content,
},
headers=headers,
)
# ---- Assert 1: Immediate response ----
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}"
data = resp.json()
assert "id" in data, "Response missing 'id' field"
log_id = data["id"]
logger.info("Log submitted successfully: id=%s", log_id)
# ---- Step 2: Wait for AI background task ----
# BackgroundTasks calls Ollama (qwen3:14b), may take 5-30s
logger.info("Waiting 10s for AI background processing...")
await asyncio.sleep(10)
# ---- Assert 2: Verify DB side effects ----
import psycopg2
conn = psycopg2.connect(
host="192.168.1.85", port=5432, user="admin",
password="admin_password_2026", dbname="lubrication_crm",
)
cur = conn.cursor()
# Check customer_tags
cur.execute(
"SELECT tag_name FROM customer_tags WHERE customer_id = %s",
(TEST_CLIENT_ID,),
)
tags = [r[0] for r in cur.fetchall()]
logger.info("Generated tags: %s", tags)
# Check follow_up_todos
cur.execute(
"SELECT task_desc, status FROM follow_up_todos WHERE customer_id = %s",
(TEST_CLIENT_ID,),
)
todos = cur.fetchall()
logger.info("Generated todos: %s", todos)
cur.close()
conn.close()
# AI may or may not succeed (Ollama connectivity),
# but if it did, we should see results.
# Use soft assertion: log warning if empty, don't fail hard
# (Ollama node might be unreachable in test env)
if tags:
assert len(tags) <= 3, f"Expected at most 3 tags, got {len(tags)}"
logger.info("PASS: AI generated %d tag(s)", len(tags))
else:
logger.warning(
"WARNING: No tags generated. "
"Check Ollama connectivity and backend logs for errors."
)
if todos:
assert todos[0][1] == "pending", "Todo status should be 'pending'"
logger.info("PASS: AI generated todo: %s", todos[0][0][:80])
else:
logger.warning(
"WARNING: No todos generated. "
"Check Ollama connectivity and backend logs for errors."
)
# ============================================================
# Test Case 2: GET /api/v1/reports/monthly
# ============================================================
@pytest.mark.asyncio
async def test_monthly_sales_report_generation(seed_test_data):
"""
Test SQL pre-aggregation + AI report generation:
1. GET /reports/monthly → 200
2. Response contains metrics list + report string
"""
assert TEST_TOKEN, "Auth token missing"
headers = {"Authorization": f"Bearer {TEST_TOKEN}"}
# ---- Step 1: Request monthly report ----
# This is a synchronous wait for AI generation, set generous timeout
async with httpx.AsyncClient(base_url=BASE_URL, timeout=90.0) as client:
logger.info("Requesting monthly report (may take 30-60s for AI generation)...")
resp = await client.get("/reports/monthly", headers=headers)
# ---- Assert 1: Status code ----
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}"
data = resp.json()
logger.info("Report response received (%d bytes)", len(resp.text))
# ---- Assert 2: Response structure ----
assert "metrics" in data, "Response missing 'metrics' field"
assert "report" in data, "Response missing 'report' field"
assert isinstance(data["metrics"], list), "'metrics' should be a list"
assert isinstance(data["report"], str), "'report' should be a string"
assert len(data["report"]) > 0, "'report' should be non-empty"
logger.info("Metrics: %s", data["metrics"])
logger.info("Report preview: %s...", data["report"][:200])
logger.info("PASS: Monthly report generated (%d chars)", len(data["report"]))
BIN
View File
Binary file not shown.
+75
View File
@@ -0,0 +1,75 @@
"""
部署冒烟测试脚本
在 docker-compose up -d --build 之后运行,验证系统可用性。
"""
import time
import urllib.request
import urllib.error
import sys
def check_endpoint(url: str, expect_in_body: str | None = None, label: str = "") -> bool:
"""
轮询检查一个 HTTP 端点是否可达(最多重试 10 次,每次间隔 3 秒)。
"""
for attempt in range(1, 11):
try:
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=10) as resp:
status = resp.status
body = resp.read().decode("utf-8", errors="replace")
if status == 200:
if expect_in_body and expect_in_body not in body:
print(f" [{label}] 尝试 {attempt}/10 — 状态 200 但响应体不含 '{expect_in_body}'")
continue
print(f" ✅ [{label}] PASS — HTTP {status}")
return True
else:
print(f" [{label}] 尝试 {attempt}/10 — HTTP {status}")
except urllib.error.URLError as e:
print(f" [{label}] 尝试 {attempt}/10 — 连接失败: {e.reason}")
except Exception as e:
print(f" [{label}] 尝试 {attempt}/10 — 异常: {e}")
time.sleep(3)
print(f" ❌ [{label}] FAIL — 超过最大重试次数")
return False
def main():
print("\n" + "=" * 50)
print(" SHBL-CRM 部署冒烟测试")
print("=" * 50 + "\n")
results = []
# 验证点 1: 前端静态资源
print("[1/2] 检测前端页面 (http://localhost/) ...")
results.append(check_endpoint(
url="http://localhost/",
expect_in_body="<html",
label="前端 SPA",
))
# 验证点 2: Nginx → FastAPI 反代穿透
print("\n[2/2] 检测后端 API 反代 (http://localhost/api/docs) ...")
results.append(check_endpoint(
url="http://localhost/api/docs",
label="API 反代",
))
# 汇总
print("\n" + "-" * 50)
if all(results):
print("🎉 所有检查通过!系统已就绪。")
sys.exit(0)
else:
print("⚠️ 部分检查未通过,请排查容器日志:docker-compose logs -f")
sys.exit(1)
if __name__ == "__main__":
main()
+176
View File
@@ -0,0 +1,176 @@
app:
description: '接收销售日志内容,AI 提取客户画像特征(痛点/偏好/购买意向),通过 HTTP 回写至 ERP 客户表的 ai_persona 字段'
icon: "\U0001F4DD"
icon_background: '#E4FBCC'
mode: workflow
name: '销售日志 → 客户画像提取'
use_icon_as_answer_icon: false
kind: app
version: 0.1.3
workflow:
conversation_variables: []
environment_variables: []
features:
file_upload:
image:
enabled: false
opening_statement: ''
retriever_resource:
enabled: false
sensitive_word_avoidance:
enabled: false
suggested_questions: []
suggested_questions_after_answer:
enabled: false
text_to_speech:
enabled: false
graph:
edges:
- data:
sourceType: start
targetType: llm
id: edge-start-to-llm
source: start
target: llm-extract
type: custom
- data:
sourceType: llm
targetType: http-request
id: edge-llm-to-http
source: llm-extract
target: http-update-persona
type: custom
- data:
sourceType: http-request
targetType: end
id: edge-http-to-end
source: http-update-persona
target: end
type: custom
nodes:
# ── 开始节点 ──
- data:
desc: '接收销售日志参数'
title: 开始
type: start
variables:
- label: customer_id
max_length: 100
required: true
type: text-input
variable: customer_id
- label: content
max_length: 5000
required: true
type: paragraph
variable: content
- label: salesperson_name
max_length: 50
required: false
type: text-input
variable: salesperson_name
height: 90
id: start
position:
x: 80
y: 282
type: start
width: 244
# ── LLM 画像提取节点 ──
- data:
desc: '从销售日志中提取客户画像特征'
# ⚠️ 导入后请在此节点选择你 Dify 中已配置的模型
# 推荐选择 Qwen3.5-27B (R740-A 节点)
model:
completion_params:
temperature: 0.3
max_tokens: 1024
mode: chat
name: qwen3.5 # ← 导入后改为你实际的模型名称
provider: ollama # ← 改为你的模型供应商
prompt_template:
- role: system
text: |
你是一名 B2B 润滑油行业的客户分析专家。
根据销售人员的日志内容,提取客户特征并输出严格的 JSON 格式。
输出要求(纯 JSON,不要 markdown 代码块):
{
"pain_points": ["痛点1", "痛点2"],
"preferences": ["偏好1", "偏好2"],
"purchase_intent": "high 或 medium 或 low",
"key_decision_makers": ["决策人姓名"],
"competitor_info": ["竞品信息"],
"summary": "一句话总结本次跟进要点"
}
如果某个字段从日志中无法提取,返回空数组 [] 或 "unknown"。
- role: user
text: |
销售人员: {{#start.salesperson_name#}}
日志内容:
{{#start.content#}}
title: 'AI 画像提取'
type: llm
height: 98
id: llm-extract
position:
x: 380
y: 282
type: llm
width: 244
# ── HTTP 回写客户画像 ──
- data:
desc: '将提取的画像 JSON 写入 ERP 客户表'
title: '回写客户画像'
type: http-request
authorization:
config:
api_key: '' # ← 导入后填写 Bearer token
header: Authorization
type: bearer
type: api-key
body:
data: '{{#llm-extract.text#}}'
type: json
headers: ''
method: put
params: ''
timeout:
max_connect_timeout: 10
max_read_timeout: 30
max_write_timeout: 30
# ⚠️ 导入后修改为你的实际 ERP 地址
url: 'http://192.168.1.100:8000/api/customers/{{#start.customer_id#}}/persona'
height: 98
id: http-update-persona
position:
x: 680
y: 282
type: http-request
width: 244
# ── 结束节点 ──
- data:
desc: ''
outputs:
- value_selector:
- llm-extract
- text
variable: ai_persona_result
- value_selector:
- http-update-persona
- status_code
variable: http_status
title: 结束
type: end
height: 90
id: end
position:
x: 980
y: 282
type: end
width: 244
hash: ''
+234
View File
@@ -0,0 +1,234 @@
app:
description: '拉取指定销售的本周销售日志,AI 提炼为结构化 Markdown 周报,写入报告草稿表'
icon: "\U0001F4CA"
icon_background: '#D5F5F6'
mode: workflow
name: '周报自动生成'
use_icon_as_answer_icon: false
kind: app
version: 0.1.3
workflow:
conversation_variables: []
environment_variables: []
features:
file_upload:
image:
enabled: false
opening_statement: ''
retriever_resource:
enabled: false
sensitive_word_avoidance:
enabled: false
suggested_questions: []
suggested_questions_after_answer:
enabled: false
text_to_speech:
enabled: false
graph:
edges:
- data:
sourceType: start
targetType: http-request
id: edge-start-to-fetch
source: start
target: http-fetch-logs
type: custom
- data:
sourceType: http-request
targetType: llm
id: edge-fetch-to-llm
source: http-fetch-logs
target: llm-report
type: custom
- data:
sourceType: llm
targetType: http-request
id: edge-llm-to-save
source: llm-report
target: http-save-report
type: custom
- data:
sourceType: http-request
targetType: end
id: edge-save-to-end
source: http-save-report
target: end
type: custom
nodes:
# ── 开始节点 ──
- data:
desc: '接收用户ID和日期范围'
title: 开始
type: start
variables:
- label: user_id
max_length: 100
required: true
type: text-input
variable: user_id
- label: period_start
max_length: 20
required: true
type: text-input
variable: period_start
- label: period_end
max_length: 20
required: true
type: text-input
variable: period_end
- label: report_type
max_length: 10
required: false
type: text-input
variable: report_type
default: weekly
height: 90
id: start
position:
x: 80
y: 282
type: start
width: 244
# ── HTTP 拉取日志 ──
- data:
desc: '从 ERP 后端拉取该销售本周的所有日志'
title: '拉取销售日志'
type: http-request
authorization:
config:
api_key: '' # ← 导入后填写 Bearer token
header: Authorization
type: bearer
type: api-key
body:
data: ''
type: none
headers: ''
method: get
params: 'user_id={{#start.user_id#}}&start_date={{#start.period_start#}}&end_date={{#start.period_end#}}'
timeout:
max_connect_timeout: 10
max_read_timeout: 30
max_write_timeout: 30
# ⚠️ 导入后修改为你的实际 ERP 地址
url: 'http://192.168.1.100:8000/api/sales-logs'
height: 98
id: http-fetch-logs
position:
x: 380
y: 282
type: http-request
width: 244
# ── LLM 周报生成 ──
- data:
desc: '基于销售日志生成结构化周报'
# ⚠️ 导入后请选择你 Dify 中配置的模型
model:
completion_params:
temperature: 0.5
max_tokens: 4096
mode: chat
name: qwen3.5 # ← 导入后改为实际模型名称
provider: ollama # ← 改为实际供应商
prompt_template:
- role: system
text: |
你是一个销售管理助手,擅长撰写专业的销售周报。
请根据以下销售日志,撰写一份结构化的 Markdown 周报,包含:
## 📋 本周工作概要
(2-3 句话总结本周整体工作)
## 📊 客户跟进情况
| 客户 | 跟进方式 | 进展 | 下一步 |
|------|----------|------|--------|
(按日志整理每位客户的跟进表格)
## 🎯 重点成果
- (列出本周的关键成果,如签单、推进等)
## ⚠️ 问题与风险
- (列出遇到的困难和风险点)
## 📅 下周计划
- (根据日志中提到的下一步规划整理)
要求:语言简洁专业,数据准确。
- role: user
text: |
以下是本周({{#start.period_start#}} 至 {{#start.period_end#}})的销售日志记录:
{{#http-fetch-logs.body#}}
title: '生成周报'
type: llm
height: 98
id: llm-report
position:
x: 680
y: 282
type: llm
width: 244
# ── HTTP 保存周报草稿 ──
- data:
desc: '将生成的周报写入 ERP 报告草稿表'
title: '保存周报草稿'
type: http-request
authorization:
config:
api_key: '' # ← 导入后填写 Bearer token
header: Authorization
type: bearer
type: api-key
body:
data: |
{
"author_id": "{{#start.user_id#}}",
"report_type": "{{#start.report_type#}}",
"period_start": "{{#start.period_start#}}",
"period_end": "{{#start.period_end#}}",
"content_md": "{{#llm-report.text#}}"
}
type: json
headers: ''
method: post
params: ''
timeout:
max_connect_timeout: 10
max_read_timeout: 30
max_write_timeout: 30
# ⚠️ 导入后修改为你的实际 ERP 地址
url: 'http://192.168.1.100:8000/api/reports/drafts'
height: 98
id: http-save-report
position:
x: 980
y: 282
type: http-request
width: 244
# ── 结束节点 ──
- data:
desc: ''
outputs:
- value_selector:
- llm-report
- text
variable: report_markdown
- value_selector:
- http-save-report
- status_code
variable: save_status
title: 结束
type: end
height: 90
id: end
position:
x: 1280
y: 282
type: end
width: 244
hash: ''
+40
View File
@@ -0,0 +1,40 @@
# ── 全局日志策略:所有服务强制轮转,防磁盘爆满 ──
x-logging: &default-logging
driver: "json-file"
options:
max-size: "20m"
max-file: "3"
services:
# ---- 后端 API (Gunicorn + Uvicorn Workers) ----
backend:
build:
context: .
dockerfile: Dockerfile.backend
container_name: crm-backend
env_file:
- ./server/.env
environment:
- TZ=Asia/Shanghai
restart: always
volumes:
# 上传文件目录 — 挂载群晖 NAS (192.168.1.48)
- /mnt/nas-uploads:/app/uploads
extra_hosts:
- "host.docker.internal:host-gateway"
logging: *default-logging
# ---- 前端网关 (Nginx + Vue3 SPA) ----
frontend:
build:
context: .
dockerfile: Dockerfile.frontend
container_name: crm-frontend
ports:
- "80:80"
depends_on:
- backend
environment:
- TZ=Asia/Shanghai
restart: always
logging: *default-logging
+6
View File
@@ -0,0 +1,6 @@
node_modules/
.git/
.venv/
venv/
__pycache__/
*.pyc
+3
View File
@@ -0,0 +1,3 @@
# 开发环境 API 基础路径
# Vite 开发服务器通过 proxy 转发 /api → http://127.0.0.1:8000
VITE_API_BASE_URL=
+3
View File
@@ -0,0 +1,3 @@
# 生产环境 API 基础路径
# Nginx 统一代理,前端和后端同域,无需跨域
VITE_API_BASE_URL=
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="天津硕博霖客户信息管理系统" />
<title>SHBL-CRM</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2042
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "shbl-crm-frontend",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.ts,.tsx"
},
"dependencies": {
"vue": "^3.5.0",
"vue-router": "^4.4.0",
"pinia": "^2.2.0",
"axios": "^1.7.0",
"element-plus": "^2.9.0",
"@element-plus/icons-vue": "^2.3.0",
"pinia-plugin-persistedstate": "^3.2.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.0",
"typescript": "~5.7.0",
"vite": "^6.0.0",
"vue-tsc": "^2.2.0"
}
}
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
/**
* 根组件
* 只负责渲染 <router-view>,具体页面由路由决定。
*/
</script>
<template>
<router-view />
</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>
+23
View File
@@ -0,0 +1,23 @@
import request from './request'
// 对话历史接口
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
type?: 'text' | 'action_card'
content?: string
action?: string
card?: Record<string, any> // action_card 结构化数据
created_at?: string
metadata?: Record<string, any>
}
// 获取对话历史
export const getChatHistory = (limit: number = 50) => {
return request.get<{ data: ChatMessage[] }>('/chat/history', { params: { limit } })
}
// 获取动作卡片回调
export const sendActionCardCallback = (data: { card_type: string; action_key: string; params: Record<string, any> }) => {
return request.post('/chat/action-card/callback', data)
}
+29
View File
@@ -0,0 +1,29 @@
/**
* CRM 业务相关的 API 封装
*/
import request from './request'
// ==== 类型声明 ====
export interface LogSubmitData {
customer_id: string
content: string
}
// ==== 接口定义 ====
/**
* 提交客户沟通日志
* @param data 客户 ID 和沟通内容
*/
export const submitCustomerLog = (data: LogSubmitData): Promise<{ message: string; id: string }> => {
return request.post('/sales-logs', data)
}
/**
* 模糊搜索客户(用于远程选择器)
*/
export const searchCustomers = (q: string): Promise<any[]> => {
return request.get('/customers/search', { params: { q } })
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Axios 请求封装
* - 请求拦截器:自动携带 JWT Token
* - 响应拦截器:
* 1. 剥离后端 { code, data, message } 外壳,直接返回 data
* 2. 统一捕获业务异常并 ElMessage 弹出
* 3. 401 自动清 Token 跳登录页
*/
import axios, { type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/user'
import router from '@/router'
// 后端统一响应结构
interface ApiResponse<T = any> {
code: number
data: T
message: string
}
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL as string,
timeout: 15000,
headers: {
'Content-Type': 'application/json',
},
})
// ---- 请求拦截器:自动注入 Authorization 头 ----
request.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
},
(error) => {
return Promise.reject(error)
},
)
// ---- 响应拦截器:统一剥离外壳 + 错误处理 ----
request.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const res = response.data
// 后端返回 code !== 200 视为业务异常
if (res.code && res.code !== 200) {
ElMessage.error(res.message || '请求失败')
// 401 → 清 Token 跳登录
if (res.code === 401) {
const userStore = useUserStore()
userStore.logout()
router.push('/login')
}
return Promise.reject(new Error(res.message || '请求失败'))
}
// 成功:直接返回 data(剥离外壳)
return res.data
},
(error) => {
const status = error.response?.status
const res = error.response?.data as ApiResponse | undefined
const message = res?.message || error.response?.data?.detail || '服务器内部错误'
switch (status) {
case 401:
ElMessage.error('登录已过期,请重新登录')
{
const userStore = useUserStore()
userStore.logout()
router.push('/login')
}
break
case 403:
ElMessage.warning(message || '权限不足,无法执行此操作')
break
case 422:
ElMessage.warning(`参数错误:${message}`)
break
case 500:
ElMessage.error(`服务器错误:${message}`)
break
default:
ElMessage.error(message || '网络连接异常')
}
return Promise.reject(error)
},
)
export default request
+522
View File
@@ -0,0 +1,522 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { ChatDotRound, Select, Close } from '@element-plus/icons-vue'
import { useUserStore } from '@/store/user'
import { useChatStore } from '@/store/chat'
import { ElMessage } from 'element-plus'
const userStore = useUserStore()
const chatStore = useChatStore()
// 悬浮球状态
const isChatOpen = ref(false)
// 拖拽相关逻辑
const floatingBall = ref<HTMLElement | null>(null)
const position = ref({ x: document.documentElement.clientWidth - 80, y: document.documentElement.clientHeight - 80 })
let isDragging = false
let startX = 0
let startY = 0
let initialX = 0
let initialY = 0
const onMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return // Only left click
isDragging = false // Reset drag state initially
startX = e.clientX
startY = e.clientY
initialX = position.value.x
initialY = position.value.y
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
const onMouseMove = (e: MouseEvent) => {
const dx = e.clientX - startX
const dy = e.clientY - startY
// A small threshold to distinguish between click and drag
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
isDragging = true
}
if (isDragging && floatingBall.value) {
let newX = initialX + dx
let newY = initialY + dy
// Constraints to keep it within the viewport
const ballWidth = 60
const ballHeight = 60
const maxX = document.documentElement.clientWidth - ballWidth
const maxY = document.documentElement.clientHeight - ballHeight
position.value.x = Math.max(0, Math.min(newX, maxX))
position.value.y = Math.max(0, Math.min(newY, maxY))
}
}
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
const toggleChat = (e: Event) => {
if (!isDragging) {
isChatOpen.value = !isChatOpen.value
}
}
// ----------------
const inputMessage = ref('')
const isLoading = ref(false)
const chatBodyRef = ref<HTMLElement | null>(null)
const scrollToBottom = async () => {
await nextTick()
if (chatBodyRef.value) {
chatBodyRef.value.scrollTop = chatBodyRef.value.scrollHeight
}
}
const sendMessage = async () => {
if (!inputMessage.value.trim() || isLoading.value) return
const userMsg = inputMessage.value.trim()
inputMessage.value = ''
chatStore.appendMessage({
id: Date.now().toString(),
role: 'user',
content: userMsg,
type: 'text'
})
scrollToBottom()
isLoading.value = true
// Create an initial empty assistant message to append to
const assistantMsgId = (Date.now() + 1).toString()
chatStore.appendMessage({
id: assistantMsgId,
role: 'assistant',
content: '',
type: 'text' // Will be updated if an action_card arrives, or we handle it by appending new card objects
})
try {
const token = userStore.token
if (!token) {
ElMessage.error('未登录或 Token 失效')
isLoading.value = false
return
}
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ message: userMsg, conversation_id: chatStore.conversationId })
})
if (!response.ok) {
if(response.status === 401) {
ElMessage.error('登录状态过期,请重新登录')
userStore.logout()
// Optional: redirect to login
} else {
ElMessage.error(`请求失败: ${response.status}`)
}
isLoading.value = false
return
}
const reader = response.body?.getReader()
const decoder = new TextDecoder('utf-8')
if (!reader) {
throw new Error("No reader available")
}
let buf = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
// Process complete SSE messages separated by \n\n
let idx: number
while ((idx = buf.indexOf('\n\n')) >= 0) {
const chunk = buf.slice(0, idx)
buf = buf.slice(idx + 2)
if (chunk.startsWith('data: ')) {
const jsonStr = chunk.slice(6).trim()
if (!jsonStr) continue
try {
const data = JSON.parse(jsonStr)
// Find the current assistant message
const currentMsgIndex = chatStore.messages.findIndex(m => m.id === assistantMsgId)
if (currentMsgIndex !== -1) {
if (data.type === 'text') {
// It's a text chunk, append it
chatStore.appendTextToMessage(assistantMsgId, data.content)
} else if (data.type === 'conversation_id') {
// Dify 返回的会话 ID,存起来用于下一轮
chatStore.setConversationId(data.conversation_id)
} else if (data.type === 'action_card') {
// 工具返回的确认卡片
chatStore.appendMessage({
id: Date.now().toString() + '_card',
role: 'assistant',
type: 'action_card',
content: data.content || '请确认操作',
card: data.card // 包含 card_type, fields, actions, params
})
}
scrollToBottom()
}
} catch (e) {
console.error('JSON parse error from SSE:', e, jsonStr)
}
}
}
}
} catch (err) {
console.error('Chat error:', err)
chatStore.appendMessage({
id: Date.now().toString() + '_err',
role: 'assistant',
content: '请求发生异常:' + String(err),
type: 'text'
})
scrollToBottom()
} finally {
isLoading.value = false
}
}
// Window resize handler to ensure ball stays on screen
const onResize = () => {
const ballWidth = 60
const ballHeight = 60
if (position.value.x > document.documentElement.clientWidth - ballWidth) {
position.value.x = Math.max(0, document.documentElement.clientWidth - ballWidth)
}
if (position.value.y > document.documentElement.clientHeight - ballHeight) {
position.value.y = Math.max(0, document.documentElement.clientHeight - ballHeight)
}
}
onMounted(async () => {
window.addEventListener('resize', onResize)
if (!chatStore.isHistoryLoaded && userStore.isLoggedIn) {
await chatStore.loadHistory()
scrollToBottom()
}
})
onUnmounted(() => {
window.removeEventListener('resize', onResize)
})
const handleCardAction = async (msg: any, actionKey: string) => {
if (!msg.card) return
const token = userStore.token
if (!token) { ElMessage.error('未登录'); return }
try {
const resp = await fetch('/api/chat/action-card/callback', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({
card_type: msg.card.card_type,
action_key: actionKey,
params: msg.card.params || {},
}),
})
const result = await resp.json()
if (actionKey === 'cancel') {
ElMessage.info('操作已取消')
} else {
ElMessage.success(result.message || '操作成功')
}
// 替换卡片为结果文本
msg.type = 'text'
msg.content = actionKey === 'cancel' ? '✖️ 操作已取消' : `${result.message || '操作已确认执行'}`
} catch (e) {
ElMessage.error('回调异常: ' + String(e))
}
}
</script>
<template>
<div v-if="userStore.isLoggedIn" class="floating-chat-container">
<!-- 悬浮球 -->
<div
ref="floatingBall"
class="floating-ball"
:style="{ left: position.x + 'px', top: position.y + 'px' }"
@mousedown.prevent="onMouseDown"
@click="toggleChat"
>
<el-icon size="28" color="#fff"><ChatDotRound /></el-icon>
</div>
<!-- 侧边栏/抽屉 聊天窗口 -->
<el-drawer
v-model="isChatOpen"
title="智能助手"
size="400px"
direction="rtl"
:with-header="true"
custom-class="chat-drawer"
>
<div class="chat-layout">
<div class="chat-body" ref="chatBodyRef">
<div v-if="chatStore.messages.length === 0" class="empty-tip">
您好我是您的智能业务助手有什么我可以帮您的
</div>
<div v-for="msg in chatStore.messages" :key="msg.id" :class="['chat-bubble-wrapper', msg.role]">
<div class="avatar" v-if="msg.role === 'assistant'">🤖</div>
<div class="bubble-content">
<!-- 文本渲染 -->
<div v-if="msg.type === 'text' || !msg.type" class="text-message">
<!-- 可以按需引入 marked 渲染 Markdown这里简单用 pre-wrap 保证换行 -->
<span style="white-space: pre-wrap;">{{ msg.content }}</span>
<!-- 可以在这里加入一个打字光标的样式如果正处于加载状态且是最后一条消息 -->
<span v-if="isLoading && msg.role === 'assistant' && msg === chatStore.messages[chatStore.messages.length - 1]" class="typing-cursor"></span>
</div>
<!-- Action Card 渲染 -->
<div v-else-if="msg.type === 'action_card' && msg.card" class="action-card">
<div class="card-header">
<el-icon><Select /></el-icon>
<span>{{ msg.card.title || '操作确认' }}</span>
</div>
<div class="card-body">
<div v-for="field in (msg.card.fields || [])" :key="field.label" class="card-field">
<span class="field-label">{{ field.label }}</span>
<span class="field-value">{{ field.value }}</span>
</div>
</div>
<div class="card-footer">
<el-button v-for="action in (msg.card.actions || [])" :key="action.key"
size="small"
:type="action.style === 'primary' ? 'primary' : action.key === 'cancel' ? 'default' : 'primary'"
:plain="action.key === 'cancel'"
@click="handleCardAction(msg, action.key)"
>{{ action.label }}</el-button>
</div>
</div>
</div>
<div class="avatar" v-if="msg.role === 'user'">👤</div>
</div>
</div>
<div class="chat-footer">
<el-input
v-model="inputMessage"
placeholder="输入消息,开启智能分析..."
@keyup.enter="sendMessage"
:disabled="isLoading"
>
<template #append>
<el-button type="primary" @click="sendMessage" :disabled="!inputMessage.trim() || isLoading">
发送
</el-button>
</template>
</el-input>
</div>
</div>
</el-drawer>
</div>
</template>
<style scoped>
.floating-chat-container {
z-index: 9999;
}
.floating-ball {
position: fixed;
width: 60px;
height: 60px;
background: linear-gradient(135deg, #1890ff 0%, #0050b3 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.4);
cursor: pointer;
z-index: 9999;
user-select: none;
transition: box-shadow 0.3s ease, transform 0.2s;
}
.floating-ball:hover {
box-shadow: 0 6px 16px rgba(24, 144, 255, 0.6);
transform: scale(1.05);
}
.chat-drawer {
display: flex;
flex-direction: column;
}
.chat-layout {
display: flex;
flex-direction: column;
height: 100%;
/* 配合 element-plus 的 drawer_body 可以适当配置高度以占满整个抽屉 */
}
/* 如果要适配 v-model / :with-header 这些产生的内层 padding,需要使用 :deep() */
:deep(.el-drawer__body) {
padding: 0;
display: flex;
flex-direction: column;
}
.chat-body {
flex: 1;
overflow-y: auto;
padding: 20px;
background-color: #f7f9fc;
}
.chat-footer {
padding: 15px 20px;
background-color: #fff;
border-top: 1px solid #eba4a4; /* fallback */
border-top-color: var(--el-border-color-light);
}
.empty-tip {
text-align: center;
color: #909399;
font-size: 13px;
margin-top: 50px;
}
.chat-bubble-wrapper {
display: flex;
margin-bottom: 20px;
align-items: flex-start;
}
.chat-bubble-wrapper.user {
justify-content: flex-end;
}
.avatar {
font-size: 24px;
margin: 0 10px;
}
.bubble-content {
max-width: 80%;
font-size: 14px;
line-height: 1.5;
}
/* user texts */
.user .text-message {
background-color: var(--el-color-primary);
color: #fff;
padding: 10px 14px;
border-radius: 12px 0 12px 12px;
word-break: break-all;
}
/* assistant texts */
.assistant .text-message {
background-color: #fff;
color: var(--el-text-color-primary);
padding: 10px 14px;
border-radius: 0 12px 12px 12px;
word-break: break-all;
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
}
/* action card (mock) */
.action-card {
background-color: #fff;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
width: 260px; /* 略微定宽 */
}
.card-header {
background-color: #f0f9eb;
color: #67c23a;
padding: 10px;
font-size: 13px;
font-weight: bold;
display: flex;
align-items: center;
gap: 6px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.card-body {
padding: 12px;
font-size: 13px;
color: var(--el-text-color-regular);
}
.card-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px;
border-top: 1px solid var(--el-border-color-lighter);
background-color: #fafafa;
}
.card-field {
display: flex;
padding: 4px 0;
font-size: 13px;
line-height: 1.6;
}
.field-label {
color: #909399;
min-width: 70px;
flex-shrink: 0;
}
.field-value {
color: #303133;
font-weight: 500;
}
.typing-cursor {
display: inline-block;
width: 6px;
height: 14px;
background-color: #333;
margin-left: 2px;
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
</style>
+17
View File
@@ -0,0 +1,17 @@
/// <reference types="vite/client" />
// 声明 .vue 模块类型,使 TypeScript 能正确导入 Vue 单文件组件
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
// 声明 Vite 环境变量类型
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+363
View File
@@ -0,0 +1,363 @@
<script setup lang="ts">
import { ref, reactive, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useUserStore } from '@/store/user'
import request from '@/api/request'
import {
House,
Briefcase,
User,
Tickets,
Van,
Box,
Goods,
Money,
Wallet,
Connection,
Document,
DataAnalysis,
Setting,
Fold,
Expand,
ArrowDown
} from '@element-plus/icons-vue'
import FloatingChat from '@/components/FloatingChat.vue'
const isCollapse = ref(false)
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const toggleSidebar = () => {
isCollapse.value = !isCollapse.value
}
// ---- 修改密码 ----
const pwdDialogVisible = ref(false)
const pwdSubmitting = ref(false)
const pwdFormRef = ref<FormInstance>()
const pwdForm = reactive({ oldPassword: '', newPassword: '', confirmPassword: '' })
const pwdRules = reactive<FormRules>({
oldPassword: [{ required: true, message: '请输入旧密码', trigger: 'blur' }],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '密码至少6位', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请确认新密码', trigger: 'blur' },
{
validator: (_rule: any, value: string, callback: any) => {
if (value !== pwdForm.newPassword) callback(new Error('两次密码不一致'))
else callback()
},
trigger: 'blur',
},
],
})
const openPwdDialog = () => {
pwdDialogVisible.value = true
nextTick(() => pwdFormRef.value?.resetFields())
}
const submitChangePassword = async () => {
const valid = await pwdFormRef.value?.validate().catch(() => false)
if (!valid) return
pwdSubmitting.value = true
try {
await request.put('/api/auth/password', {
old_password: pwdForm.oldPassword,
new_password: pwdForm.newPassword,
})
ElMessage.success('密码修改成功,请重新登录')
pwdDialogVisible.value = false
// 强制重新登录
setTimeout(() => {
userStore.logout()
router.push('/login')
}, 800)
} catch {
// 已统一拦截
} finally {
pwdSubmitting.value = false
}
}
const handleCommand = (command: string) => {
if (command === 'logout') {
userStore.logout()
ElMessage.success('已安全退出')
router.push('/login')
} else if (command === 'changePassword') {
openPwdDialog()
}
}
</script>
<template>
<el-container class="layout-container">
<!-- 左侧菜单 -->
<el-aside :width="isCollapse ? '64px' : '240px'" class="aside">
<div class="logo">
<span v-show="!isCollapse" class="logo-text"><strong>SHBL-ERP</strong></span>
<span v-show="isCollapse" class="logo-icon">S</span>
</div>
<el-menu
:default-active="route.path"
class="el-menu-vertical"
:collapse="isCollapse"
router
unique-opened
background-color="#001529"
text-color="#a6adb4"
active-text-color="#fff"
>
<el-menu-item index="/">
<el-icon><House /></el-icon>
<template #title>工作台</template>
</el-menu-item>
<el-sub-menu index="sales">
<template #title>
<el-icon><Briefcase /></el-icon>
<span>业务线</span>
</template>
<el-menu-item index="/customers">
<el-icon><User /></el-icon>
<template #title>客户管理</template>
</el-menu-item>
<el-menu-item index="/orders">
<el-icon><Tickets /></el-icon>
<template #title>订单管理</template>
</el-menu-item>
<el-menu-item index="/shipping">
<el-icon><Van /></el-icon>
<template #title>发货记录</template>
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="supply-chain">
<template #title>
<el-icon><Box /></el-icon>
<span>供应链</span>
</template>
<el-menu-item index="/products">
<el-icon><Goods /></el-icon>
<template #title>产品与库存</template>
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="finance-group">
<template #title>
<el-icon><Money /></el-icon>
<span>财务管理</span>
</template>
<el-menu-item index="/finance/sales-invoices">销项发票</el-menu-item>
<el-menu-item index="/finance">报销管理</el-menu-item>
</el-sub-menu>
<el-sub-menu index="oa">
<template #title>
<el-icon><Connection /></el-icon>
<span>协同办公</span>
</template>
<el-menu-item index="/logs">
<el-icon><Document /></el-icon>
<template #title>销售日志</template>
</el-menu-item>
<el-menu-item index="/reports">
<el-icon><DataAnalysis /></el-icon>
<template #title>AI 智能复盘</template>
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="sys-settings">
<template #title>
<el-icon><Setting /></el-icon>
<span>系统设置</span>
</template>
<el-menu-item index="/settings">
<el-icon><User /></el-icon>
<template #title>权限与员工管理</template>
</el-menu-item>
</el-sub-menu>
</el-menu>
</el-aside>
<!-- 右侧容器 -->
<el-container>
<!-- 顶栏 -->
<el-header class="header">
<div class="header-left">
<el-icon class="toggle-icon" @click="toggleSidebar">
<component :is="isCollapse ? Expand : Fold" />
</el-icon>
<!-- 面包屑 -->
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>{{ (route.meta?.title as string) || route.name }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-dropdown @command="handleCommand">
<span class="user-dropdown">
<el-avatar :size="30" src="https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png" />
<span class="username">{{ userStore.realName || userStore.username || 'Admin' }}</span>
<el-icon class="el-icon--right"><arrow-down /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item :command="'changePassword'">修改密码</el-dropdown-item>
<el-dropdown-item divided :command="'logout'">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- 主体内容 -->
<el-main class="main">
<router-view v-slot="{ Component }">
<transition name="fade-transform" mode="out-in">
<component :is="Component" :key="$route.fullPath" />
</transition>
</router-view>
</el-main>
</el-container>
<!-- 修改密码弹窗 -->
<el-dialog v-model="pwdDialogVisible" title="修改密码" width="420px" destroy-on-close>
<el-form ref="pwdFormRef" :model="pwdForm" :rules="pwdRules" label-width="90px">
<el-form-item label="旧密码" prop="oldPassword">
<el-input v-model="pwdForm.oldPassword" type="password" show-password placeholder="请输入当前密码" />
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input v-model="pwdForm.newPassword" type="password" show-password placeholder="至少6位" />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="pwdForm.confirmPassword" type="password" show-password placeholder="再次输入新密码" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="pwdDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="pwdSubmitting" @click="submitChangePassword">确认修改</el-button>
</template>
</el-dialog>
<!-- 全局 AI 悬浮球 -->
<FloatingChat />
</el-container>
</template>
<style scoped>
.layout-container {
height: 100vh;
width: 100vw;
}
.aside {
background-color: #001529;
transition: width 0.3s;
display: flex;
flex-direction: column;
}
.logo {
height: 60px;
line-height: 60px;
text-align: center;
color: #fff;
background-color: #002140;
overflow: hidden;
white-space: nowrap;
}
.logo-text {
font-size: 18px;
letter-spacing: 1px;
}
.logo-icon {
font-size: 20px;
font-weight: bold;
}
.el-menu-vertical {
flex: 1;
border-right: none;
}
/* Ensure sub-menu titles have a nice color change on hover */
:deep(.el-sub-menu__title:hover) {
background-color: rgba(255, 255, 255, 0.05) !important;
}
.el-menu-item:hover {
background-color: rgba(255, 255, 255, 0.05) !important;
}
.el-menu-item.is-active {
background-color: var(--el-color-primary) !important;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #dcdfe6;
background-color: #fff;
height: 60px;
padding: 0 20px;
}
.header-left {
display: flex;
align-items: center;
}
.toggle-icon {
font-size: 20px;
cursor: pointer;
margin-right: 20px;
}
.header-right {
display: flex;
align-items: center;
}
.user-dropdown {
display: flex;
align-items: center;
cursor: pointer;
}
.username {
margin-left: 8px;
font-size: 14px;
}
.main {
background-color: #f0f2f5;
padding: 20px;
overflow-y: auto;
}
/* 页面过渡动画 */
.fade-transform-leave-active,
.fade-transform-enter-active {
transition: all 0.3s;
}
.fade-transform-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.fade-transform-leave-to {
opacity: 0;
transform: translateX(30px);
}
</style>
+29
View File
@@ -0,0 +1,29 @@
/**
* Vue 应用入口
* 初始化 Vue3 + Pinia + Router + Element Plus
*/
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import App from './App.vue'
import router from './router'
const app = createApp(App)
// 挂载 Pinia 状态管理
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
// 挂载路由
app.use(router)
// 挂载 Element Plus (中文语言包)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')
+145
View File
@@ -0,0 +1,145 @@
/**
* Vue Router 配置
* 路由守卫:
* 1. 未登录 → 跳 /login
* 2. 有 Token 无用户信息 → 调 GET /api/auth/me 拉取
* 3. 已登录访问 /login → 跳首页
*/
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useUserStore } from '@/store/user'
import Layout from '@/layout/index.vue'
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { requiresAuth: false },
},
{
path: '/',
component: Layout,
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '工作台' },
},
{
path: 'customers',
name: 'Customers',
component: () => import('@/views/customers/index.vue'),
meta: { title: '客户管理' },
},
{
path: 'customers/detail',
name: 'CustomerDetail',
component: () => import('@/views/customers/CustomerDetail.vue'),
meta: { title: '客户档案' },
},
{
path: 'orders',
name: 'Orders',
component: () => import('@/views/orders/index.vue'),
meta: { title: '订单管理' },
},
{
path: 'shipping',
name: 'Shipping',
component: () => import('@/views/shipping/index.vue'),
meta: { title: '发货记录' },
},
{
path: 'products',
name: 'Products',
component: () => import('@/views/products/index.vue'),
meta: { title: '产品与库存' },
},
{
path: 'finance',
name: 'Finance',
component: () => import('@/views/finance/index.vue'),
meta: { title: '报销大盘' },
},
{
path: 'settings',
name: 'Settings',
component: () => import('@/views/settings/index.vue'),
meta: { title: '权限与员工管理' },
},
{
path: 'logs',
name: 'SalesLogs',
component: () => import('@/views/logs/index.vue'),
meta: { title: '销售日志' },
},
{
path: 'reports',
name: 'MonthlyReport',
component: () => import('@/views/dashboard/MonthlyReport.vue'),
meta: { title: 'AI 智能复盘' },
},
{
path: 'finance/sales-invoices',
name: 'SalesInvoices',
component: () => import('@/views/finance/SalesInvoice.vue'),
meta: { title: '销项发票' },
},
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// 白名单(免鉴权路由)
const WHITE_LIST = ['Login']
// ---- 全局前置守卫 ----
router.beforeEach(async (to: any, _from: any, next: any) => {
const userStore = useUserStore()
// 已登录
if (userStore.isLoggedIn) {
if (to.name === 'Login') {
// 已登录还访问登录页 → 跳首页
next({ name: 'Dashboard' })
return
}
// 有 Token 但还没拉取用户信息 → 调 /auth/me
if (!userStore.userInfo) {
try {
await userStore.fetchUserInfo()
console.log('[Auth Guard] 用户信息已获取:', {
username: userStore.username,
dataScope: userStore.dataScope,
menuKeys: userStore.menuKeys,
})
next({ ...to, replace: true })
} catch {
// Token 过期或无效 → 清除并跳登录
userStore.logout()
next({ name: 'Login', query: { redirect: to.fullPath } })
}
return
}
next()
return
}
// 未登录
if (WHITE_LIST.includes(to.name as string)) {
next()
} else {
next({ name: 'Login', query: { redirect: to.fullPath } })
}
})
export default router
+79
View File
@@ -0,0 +1,79 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { ChatMessage } from '@/api/chat'
import { getChatHistory } from '@/api/chat'
import { useUserStore } from '@/store/user'
import { ElMessage } from 'element-plus'
export const useChatStore = defineStore(
'chat',
() => {
const messages = ref<ChatMessage[]>([])
const isHistoryLoaded = ref(false)
const conversationId = ref('') // Dify 会话 ID,用于上下文记忆
// 清除消息
const clearMessages = () => {
messages.value = []
isHistoryLoaded.value = false
conversationId.value = ''
}
const setConversationId = (id: string) => {
conversationId.value = id
}
// 重置会话(开始新对话)
const resetConversation = () => {
messages.value = []
conversationId.value = ''
}
// 从接口拉取最新历史(合并到本地)
const loadHistory = async () => {
const userStore = useUserStore()
if (!userStore.isLoggedIn) return
try {
const res: any = await getChatHistory(50)
// request 拦截器已剥离 {code, data, message} 外壳,res 直接是 data
const items = Array.isArray(res) ? res : (res?.data || [])
messages.value = items
isHistoryLoaded.value = true
} catch (e) {
console.error('Failed to load chat history:', e)
}
}
// 追加单条消息
const appendMessage = (msg: ChatMessage) => {
messages.value.push(msg)
}
// 追加文本到已有消息
const appendTextToMessage = (id: string, text: string) => {
const msg = messages.value.find(m => m.id === id)
if (msg && msg.type === 'text') {
msg.content = (msg.content || '') + text
}
}
return {
messages,
isHistoryLoaded,
conversationId,
clearMessages,
loadHistory,
appendMessage,
appendTextToMessage,
setConversationId,
resetConversation,
}
},
{
persist: {
key: 'crm-chat-history',
storage: localStorage,
}
}
)
+73
View File
@@ -0,0 +1,73 @@
/**
* 用户状态管理 (Pinia)
* Token 持久化 + /auth/me 获取完整用户上下文(含 data_scope, menu_keys)
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import request from '@/api/request'
// 用户信息类型(对应后端 CurrentUserPayload
interface UserInfo {
user_id: string
username: string
real_name: string | null
dept_id: string | null
dept_name: string | null
role_id: string | null
role_name: string | null
data_scope: string
menu_keys: string[]
}
export const useUserStore = defineStore('user', () => {
// ---- State ----
const token = ref<string>(localStorage.getItem('crm_token') || '')
const userInfo = ref<UserInfo | null>(null)
// ---- Getters ----
const isLoggedIn = computed(() => !!token.value)
const username = computed(() => userInfo.value?.username || '')
const realName = computed(() => userInfo.value?.real_name || userInfo.value?.username || '')
const dataScope = computed(() => userInfo.value?.data_scope || 'self')
const menuKeys = computed(() => userInfo.value?.menu_keys || [])
// ---- Actions ----
/** 登录:POST /api/auth/login → 拿 Token */
async function login(loginUsername: string, password: string) {
const data = await request.post('/api/auth/login', {
username: loginUsername,
password,
}) as any
token.value = data.access_token
localStorage.setItem('crm_token', data.access_token)
}
/** 获取用户信息:GET /api/auth/me → 完整 Payload */
async function fetchUserInfo() {
const data = await request.get('/api/auth/me') as unknown as UserInfo
userInfo.value = data
}
/** 登出 */
function logout() {
token.value = ''
userInfo.value = null
localStorage.removeItem('crm_token')
}
return {
token,
userInfo,
isLoggedIn,
username,
realName,
dataScope,
menuKeys,
login,
fetchUserInfo,
logout,
}
})
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
/**
* Dashboard 占位页
* 后续替换为经营看板的图表和数据。
*/
import { useUserStore } from '@/store/user'
import { useRouter } from 'vue-router'
const userStore = useUserStore()
const router = useRouter()
function handleLogout() {
userStore.logout()
router.push('/login')
}
</script>
<template>
<el-container style="min-height: 100vh">
<el-header style="display: flex; align-items: center; justify-content: space-between; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,0.08)">
<h3>SHBL-CRM 控制台</h3>
<div>
<span style="margin-right: 16px; color: #606266">
{{ userStore.realName }} ({{ userStore.userInfo?.role_name || '未分配角色' }})
</span>
<el-button size="small" @click="handleLogout">退出登录</el-button>
</div>
</el-header>
<el-main>
<el-result icon="success" title="登录成功" sub-title="系统骨架已就绪后续业务模块开发中" />
</el-main>
</el-container>
</template>
+123
View File
@@ -0,0 +1,123 @@
<script setup lang="ts">
/**
* 登录页
* POST /api/auth/login → Token → fetchUserInfo → 跳转
*/
import { ref, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { useUserStore } from '@/store/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const loading = ref(false)
const formRef = ref<FormInstance>()
const form = reactive({
username: '',
password: '',
})
const rules = reactive<FormRules>({
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少 6 位', trigger: 'blur' },
],
})
async function handleLogin() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
// 1. 登录拿 Token
await userStore.login(form.username, form.password)
// 2. 拉取用户信息(data_scope, menu_keys 等)
await userStore.fetchUserInfo()
ElMessage.success(`欢迎回来,${userStore.realName}`)
const redirect = (route.query.redirect as string) || '/'
router.push(redirect)
} catch {
// request.ts 拦截器已统一弹错误
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-container">
<el-card class="login-card" shadow="hover">
<template #header>
<h2 class="login-title">天津硕博霖 CRM 系统</h2>
<p class="login-subtitle">客户信息管理系统 v2.0</p>
</template>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="0"
size="large"
@keyup.enter="handleLogin"
>
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" />
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
prefix-icon="Lock"
show-password
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="loading"
style="width: 100%"
@click="handleLogin"
>
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 420px;
border-radius: 12px;
}
.login-title {
text-align: center;
font-size: 22px;
color: #303133;
margin-bottom: 4px;
}
.login-subtitle {
text-align: center;
font-size: 13px;
color: #909399;
}
</style>
+383
View File
@@ -0,0 +1,383 @@
<script setup lang="ts">
/**
* 销售日志列表页 (V5.0 升级)
* 支持查看所有日志,可以通过传入 customer_id 过滤特定客户的日志
* 客户关联使用异步远程搜索选择器
* V5.0: 增加联系人级联多选 + contact_ids 传参
*/
import { ref, reactive, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, type FormInstance } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import request from '@/api/request'
import { useUserStore } from '@/store/user'
const route = useRoute()
const userStore = useUserStore()
// --- 客户搜索选择器 ---
interface CustomerOption {
id: string
name: string
level: string
contact: string | null
phone: string | null
}
const customerOptions = ref<CustomerOption[]>([])
const customerSearchLoading = ref(false)
const handleCustomerSearch = async (query: string) => {
if (!query || query.length < 1) {
customerOptions.value = []
return
}
customerSearchLoading.value = true
try {
const res: any = await request.get('/api/customers/search', { params: { q: query } })
customerOptions.value = res || []
} catch {
customerOptions.value = []
} finally {
customerSearchLoading.value = false
}
}
// --- 关联联系人 ---
const contactOptions = ref<any[]>([])
const loadingContacts = ref(false)
const loadContacts = async (customerId: string) => {
if (!customerId) {
contactOptions.value = []
return
}
loadingContacts.value = true
try {
const res: any = await request.get(`/api/customers/${customerId}/contacts`)
contactOptions.value = res || []
} catch {
} finally {
loadingContacts.value = false
}
}
// --- 列表状态 ---
const loading = ref(false)
const tableData = ref<any[]>([])
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const customerIdQuery = ref<string>((route.query.customer_id as string) || '')
const customerNameQuery = ref<string>((route.query.customer_name as string) || '')
// 客户名称映射缓存(用于列表显示)
const customerNameMap = ref<Record<string, string>>({})
const fetchLogs = async () => {
loading.value = true
try {
const params: Record<string, any> = {
page: currentPage.value,
size: pageSize.value,
start_date: dateRange.value?.[0] || '',
end_date: dateRange.value?.[1] || ''
}
if (customerIdQuery.value) {
params.customer_id = customerIdQuery.value
}
const res: any = await request.get('/api/sales-logs', { params })
tableData.value = res.items || []
total.value = res.total || 0
// 获取日志中涉及的客户名称
const unknownIds = (res.items || [])
.map((item: any) => item.customer_id)
.filter((id: string | null) => id && !customerNameMap.value[id])
if (unknownIds.length > 0) {
// 批量查名称:逐个 search(数量通常很少)
for (const id of [...new Set(unknownIds)] as string[]) {
try {
const detail: any = await request.get(`/api/customers/${id}`)
if (detail?.name) {
customerNameMap.value[id] = detail.name
}
} catch {
// 客户可能被删除
customerNameMap.value[id] = '(已删除)'
}
}
}
} catch {
// 已统一处理报错
} finally {
loading.value = false
}
}
const getCustomerName = (customerId: string | null) => {
if (!customerId) return '--'
return customerNameMap.value[customerId] || customerId.slice(0, 8) + '...'
}
// 筛选时间范围
const dateRange = ref<[string, string] | null>(null)
const handleSearch = () => {
currentPage.value = 1
fetchLogs()
}
const handleReset = () => {
dateRange.value = null
customerIdQuery.value = ''
customerNameQuery.value = ''
handleSearch()
}
// --- 新建日志弹窗 ---
const dialogVisible = ref(false)
const dialogSubmitting = ref(false)
const formRef = ref<FormInstance>()
const form = reactive({
customer_id: customerIdQuery.value || '',
contact_ids: [] as string[],
content: ''
})
const formRules = {
content: [{ required: true, message: '请输入跟进日志内容', trigger: 'blur' }]
}
// watch 必须放在 form 声明之后(避免 TDZ)
watch(() => form.customer_id, (newVal) => {
if (newVal) {
loadContacts(newVal)
} else {
contactOptions.value = []
}
form.contact_ids = []
})
const openAddDialog = () => {
form.customer_id = customerIdQuery.value || ''
form.contact_ids = []
form.content = ''
// 如果有预置的客户,加载到选择器选项中
if (customerIdQuery.value && customerNameQuery.value) {
customerOptions.value = [{
id: customerIdQuery.value,
name: customerNameQuery.value,
level: '',
contact: null,
phone: null
}]
// 主动加载该客户的联系人
loadContacts(customerIdQuery.value)
}
dialogVisible.value = true
}
const submitForm = async () => {
if (!formRef.value) return
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
dialogSubmitting.value = true
try {
await request.post('/api/sales-logs', {
content: form.content,
customer_id: form.customer_id || undefined,
contact_ids: form.contact_ids.length > 0 ? form.contact_ids : undefined,
log_date: new Date().toISOString().split('T')[0]
})
ElMessage.success('写跟进成功!AI 将在后台提炼客户特征。')
dialogVisible.value = false
// 缓存已选客户名称
if (form.customer_id) {
const selected = customerOptions.value.find(c => c.id === form.customer_id)
if (selected) {
customerNameMap.value[form.customer_id] = selected.name
}
}
handleSearch()
} catch {
// ...
} finally {
dialogSubmitting.value = false
}
}
onMounted(() => {
fetchLogs()
})
</script>
<template>
<div class="sales-logs-container">
<!-- 筛选栏 -->
<el-card shadow="never" class="filter-card">
<div class="filter-bar">
<el-form :inline="true" @submit.prevent>
<el-form-item label="跟进日期">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 250px"
/>
</el-form-item>
<el-form-item label="指定客户" v-if="customerNameQuery">
<el-tag closable @close="handleReset" type="primary">{{ customerNameQuery }}</el-tag>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset" v-if="dateRange || customerIdQuery">重置</el-button>
</el-form-item>
</el-form>
<div class="actions">
<el-button type="success" :icon="Plus" @click="openAddDialog">写跟进</el-button>
</div>
</div>
</el-card>
<!-- 表格部分 -->
<el-card shadow="never" class="table-card">
<el-table :data="tableData" border stripe style="width: 100%" v-loading="loading">
<el-table-column prop="log_date" label="跟进日期" width="120" align="center" />
<el-table-column label="关联客户" width="200">
<template #default="scope">
<span>{{ getCustomerName(scope.row.customer_id) }}</span>
</template>
</el-table-column>
<el-table-column label="跟进内容" min-width="400">
<template #default="scope">
<div class="log-content-cell">{{ scope.row.content }}</div>
</template>
</el-table-column>
<el-table-column label="AI 画像状态" width="160" align="center">
<template #default="scope">
<el-tag v-if="scope.row.ai_processed" type="success" effect="light">已提取画像</el-tag>
<el-tag v-else type="info" effect="light">--</el-tag>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="fetchLogs"
/>
</div>
</el-card>
<!-- 弹窗写跟进 -->
<el-dialog v-model="dialogVisible" title="填写销售跟进记录" width="600px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="100px" label-position="top">
<el-alert
title="智能化录入"
type="info"
description="请直接记录沟通细节,后台 AI 将自动抽取出【核心痛点】、【偏好】及【购买意向】更新至客户档案。"
show-icon
style="margin-bottom: 20px"
:closable="false"
/>
<el-form-item label="关联客户">
<el-select
v-model="form.customer_id"
filterable
remote
clearable
reserve-keyword
placeholder="输入客户名称搜索..."
:remote-method="handleCustomerSearch"
:loading="customerSearchLoading"
style="width: 100%"
>
<el-option
v-for="item in customerOptions"
:key="item.id"
:label="item.name"
:value="item.id"
>
<div style="display: flex; justify-content: space-between; align-items: center">
<span>{{ item.name }}</span>
<span style="color: #999; font-size: 12px; margin-left: 12px">
{{ item.level }} {{ item.contact || '' }}
</span>
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="关联联系人" v-if="form.customer_id">
<el-select
v-model="form.contact_ids"
multiple
placeholder="(选填)选择拜访的联系人"
style="width: 100%"
:loading="loadingContacts"
>
<el-option
v-for="item in contactOptions"
:key="item.id"
:label="item.name + (item.title ? ` - ${item.title}` : '')"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="跟进纪要" prop="content">
<el-input
v-model="form.content"
type="textarea"
:rows="6"
placeholder="例如:今天下午拜访了张总,客户表示目前对价格比较敏感,需要我们下周出具一套具备更高性价比的报价方案。重点决策人是王工。"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="dialogSubmitting" @click="submitForm">提交并触发 AI 提取</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.sales-logs-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.filter-card {
border: none;
border-radius: 8px;
}
.filter-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.table-card {
border: none;
border-radius: 8px;
}
.log-content-cell {
white-space: pre-wrap;
word-break: break-all;
line-height: 1.5;
color: #303133;
}
.pagination-wrapper {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,429 @@
<script setup lang="ts">
/**
* 客户详情页 —— V5.0 升级
* routes: /customers/detail?id=xxx
* GET /api/customers/{id} → 渲染档案卡 + AI 画像 + 联系人
*/
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
Document,
ArrowLeft,
CirclePlus,
Delete
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
import request from '@/api/request'
const route = useRoute()
const router = useRouter()
const loading = ref(true)
// --- 销售跟进记录 ---
const followUpLogs = ref<any[]>([])
const loadingLogs = ref(false)
// --- 关联产品库 ---
const relatedProducts = ref<any[]>([])
const loadingProducts = ref(false)
// --- 客户档案(真实数据) ---
const customer = reactive({
id: '',
name: '',
level: '',
industry: '',
contact: '',
phone: '',
email: '',
address: '',
ai_score: 0,
owner_name: '',
status: 1,
created_at: '',
updated_at: '',
ai_persona: null as any,
})
const fetchCustomer = async () => {
const id = route.query.id as string
if (!id) {
ElMessage.warning('缺少客户 ID 参数')
router.push('/customers')
return
}
loading.value = true
try {
const data: any = await request.get(`/api/customers/${id}`)
Object.assign(customer, data)
fetchLogs(id)
fetchContacts(id)
fetchProducts(id)
} catch {
ElMessage.error('获取客户详情失败')
router.push('/customers')
} finally {
loading.value = false
}
}
// --- 联系人管理 ---
const contacts = ref<any[]>([])
const loadingContacts = ref(false)
const contactDialogVisible = ref(false)
const contactSubmitting = ref(false)
const contactFormRef = ref<FormInstance>()
const contactForm = reactive({ name: '', phone: '', title: '' })
const contactRules = {
name: [{ required: true, message: '请输入联系人姓名', trigger: 'blur' }]
}
const fetchContacts = async (customerId: string) => {
loadingContacts.value = true
try {
const res: any = await request.get(`/api/customers/${customerId}/contacts`)
contacts.value = res || []
} catch {
} finally {
loadingContacts.value = false
}
}
const openContactDialog = () => {
contactForm.name = ''
contactForm.phone = ''
contactForm.title = ''
contactDialogVisible.value = true
}
const submitContact = async () => {
if (!contactFormRef.value) return
const valid = await contactFormRef.value.validate().catch(() => false)
if (!valid) return
contactSubmitting.value = true
try {
await request.post(`/api/customers/${customer.id}/contacts`, contactForm)
ElMessage.success('添加联系人成功')
contactDialogVisible.value = false
fetchContacts(customer.id)
} catch {
} finally {
contactSubmitting.value = false
}
}
const deleteContact = (id: string, name: string) => {
ElMessageBox.confirm(`确定要删除联系人「${name}」吗?`, '警告', { type: 'warning' })
.then(async () => {
try {
await request.delete(`/api/contacts/${id}`)
ElMessage.success('已删除联系人')
fetchContacts(customer.id)
} catch {}
}).catch(() => {})
}
const fetchLogs = async (customerId: string) => {
loadingLogs.value = true
try {
const res: any = await request.get('/api/sales-logs', { params: { customer_id: customerId, size: 50 } })
followUpLogs.value = res.items || []
} catch {
// 错误已统一处理
} finally {
loadingLogs.value = false
}
}
const fetchProducts = async (customerId: string) => {
loadingProducts.value = true
try {
const res: any = await request.get(`/api/customers/${customerId}/products`)
relatedProducts.value = res || []
} catch {
} finally {
loadingProducts.value = false
}
}
// --- 级别显示映射 ---
const levelMap: Record<string, { text: string; type: string }> = {
A: { text: 'A级 · 核心客户', type: 'danger' },
B: { text: 'B级 · 重要客户', type: 'warning' },
C: { text: 'C级 · 普通客户', type: 'info' },
}
const getLevelInfo = (level: string) => levelMap[level] || { text: level, type: 'info' }
// --- 归档 ---
const archiveCustomer = () => {
ElMessageBox.confirm(`确定要归档客户「${customer.name}」吗?`, '警告', { type: 'warning' })
.then(async () => {
try {
await request.delete(`/api/customers/${customer.id}`)
ElMessage.success('客户已归档')
router.push('/customers')
} catch { /* 已统一 */ }
})
.catch(() => {})
}
// --- 写新日志 ---
const handleAddLog = () => {
router.push({ path: '/logs', query: { customer_id: customer.id, customer_name: customer.name } })
}
onMounted(fetchCustomer)
</script>
<template>
<div class="customer-detail" v-loading="loading">
<!-- 返回按钮 -->
<el-button :icon="ArrowLeft" link @click="router.push('/customers')" style="margin-bottom:10px; font-size:14px">返回客户列表</el-button>
<!-- 1. 顶部客户档案卡片 -->
<el-card shadow="never" class="profile-header" v-if="customer.id">
<div class="header-content">
<div class="info-section">
<div class="title-row">
<h2 class="customer-name">{{ customer.name }}</h2>
<el-tag :type="(getLevelInfo(customer.level).type as any)" effect="dark" class="level-tag">{{ getLevelInfo(customer.level).text }}</el-tag>
<el-tag v-if="customer.status === 0" type="info" effect="plain">已停用</el-tag>
</div>
<el-descriptions :column="2" class="desc-box">
<el-descriptions-item label="所属行业">{{ customer.industry || '-' }}</el-descriptions-item>
<el-descriptions-item label="归属人">{{ customer.owner_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系人">{{ customer.contact || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ customer.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="电子邮箱">{{ customer.email || '-' }}</el-descriptions-item>
<el-descriptions-item label="详细地址">{{ customer.address || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ customer.created_at }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ customer.updated_at }}</el-descriptions-item>
</el-descriptions>
</div>
<div class="ai-assist-section">
<div class="ai-score-container">
<el-progress
type="dashboard"
:percentage="Math.round(customer.ai_score)"
:color="[ { color: '#f56c6c', percentage: 20 }, { color: '#e6a23c', percentage: 60 }, { color: '#5cb87a', percentage: 100 } ]"
:width="100"
>
<template #default="{ percentage }">
<div class="score-value">{{ percentage }}</div>
<div class="score-label">AI 客情健康</div>
</template>
</el-progress>
</div>
<div class="action-buttons">
<el-button type="primary" :icon="Document">🤖 生成拜访简报</el-button>
<el-button type="danger" plain @click="archiveCustomer">归档客户</el-button>
</div>
</div>
</div>
</el-card>
<!-- 2. Tabs 面板 -->
<el-card shadow="never" class="tabs-card" v-if="customer.id">
<el-tabs>
<el-tab-pane label="AI 企业画像" name="persona">
<div v-if="customer.ai_persona && Object.keys(customer.ai_persona).length > 0" class="persona-container">
<!-- 兼容新老 Schema -->
<el-row :gutter="20">
<el-col :span="12">
<el-card shadow="hover" class="persona-card h-full">
<template #header><div class="card-header">🏭 企业属性 (Firmographics)</div></template>
<div v-if="customer.ai_persona.firmographics" class="desc-box">
<el-descriptions :column="1" size="small">
<el-descriptions-item label="行业">{{ customer.ai_persona.firmographics.industry || '-' }}</el-descriptions-item>
<el-descriptions-item label="规模">{{ customer.ai_persona.firmographics.scale || '-' }}</el-descriptions-item>
<el-descriptions-item label="商业模式">{{ customer.ai_persona.firmographics.business_model || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
<div v-else class="empty-text">无数据</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="hover" class="persona-card h-full">
<template #header><div class="card-header">🔥 核心痛点与意向 (Dynamic Status)</div></template>
<div v-if="customer.ai_persona.dynamic_status">
<div style="margin-bottom:10px;">
<strong>痛点: </strong>
<el-tag v-for="(item, idx) in customer.ai_persona.dynamic_status.pain_points" :key="idx" type="danger" effect="light" class="p-tag" style="margin-right:5px;margin-bottom:5px;">{{ item }}</el-tag>
</div>
<div>
<strong>意向: </strong>
<span class="summary-text">{{ customer.ai_persona.dynamic_status.purchase_intent || '未知' }}</span>
</div>
<div v-if="customer.ai_persona.dynamic_status.recent_events?.length" style="margin-top:10px;">
<strong>近期事件: </strong>
<ul><li v-for="(ev, idx) in customer.ai_persona.dynamic_status.recent_events" :key="idx" class="summary-text" style="font-size:13px">{{ev}}</li></ul>
</div>
</div>
<!-- 兼容旧版痛点 -->
<div v-else-if="customer.ai_persona.pain_points">
<el-tag v-for="(item, idx) in customer.ai_persona.pain_points" :key="idx" type="danger" effect="light" class="p-tag" style="margin-right:5px;">{{ item }}</el-tag>
</div>
<div v-else class="empty-text">无数据</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" class="mt-20" v-if="customer.ai_persona.summary">
<el-col :span="24">
<el-card shadow="hover" class="persona-card">
<template #header><div class="card-header">📝 AI 总结 (Summary)</div></template>
<div class="summary-text">{{ customer.ai_persona.summary }}</div>
</el-card>
</el-col>
</el-row>
</div>
<div v-else class="timeline-container">
<el-empty description="暂无 AI 画像数据。销售人员提交跟进日志后,AI 将自动分析提取。" />
</div>
</el-tab-pane>
<el-tab-pane label="联系人管理" name="contacts">
<div class="timeline-container" v-loading="loadingContacts">
<div style="margin-bottom: 15px; text-align: right;">
<el-button type="primary" :icon="CirclePlus" @click="openContactDialog">新增联系人</el-button>
</div>
<el-table :data="contacts" border stripe style="width: 100%">
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="title" label="职位" width="150" />
<el-table-column prop="phone" label="电话" width="150" />
<el-table-column label="AI 画像 (Buyer Persona)" min-width="250">
<template #default="{ row }">
<div v-if="row.ai_buyer_persona" class="summary-text" style="font-size: 13px;">
<template v-if="row.ai_buyer_persona.role">
<el-tag size="small" effect="plain">{{ row.ai_buyer_persona.role.decision_role }}</el-tag>
<span style="margin-left: 8px;">权限: {{ row.ai_buyer_persona.role.authority_level }}</span><br/>
</template>
<span v-if="row.ai_buyer_persona.preference?.comm_style">👩💻 沟通: {{ row.ai_buyer_persona.preference.comm_style }}<br/></span>
<span v-if="row.ai_buyer_persona.kpi?.core_goals?.length">🎯 目标: {{ row.ai_buyer_persona.kpi.core_goals.join('') }}</span>
</div>
<span v-else class="empty-text">无画像档案</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center" fixed="right">
<template #default="{ row }">
<el-button type="danger" link :icon="Delete" @click="deleteContact(row.id, row.name)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<el-tab-pane label="全景时间轴" name="timeline">
<div class="timeline-container" v-loading="loadingLogs">
<el-timeline v-if="followUpLogs.length > 0">
<el-timeline-item
v-for="(log, idx) in followUpLogs"
:key="log.id"
:timestamp="log.log_date"
placement="top"
:type="idx === 0 ? 'primary' : 'info'"
>
<el-card shadow="hover" class="timeline-card">
<div class="log-content-pre">{{ log.content }}</div>
<div class="log-meta">
<span class="meta-item">创建时间{{ log.created_at || '-' }}</span>
<el-tag v-if="log.ai_processed" type="success" size="small" effect="plain" class="ai-tag"> AI 已提取画像</el-tag>
<el-tag v-else type="info" size="small" effect="plain" class="ai-tag"> 等待 AI 提取</el-tag>
</div>
</el-card>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无跟进记录" />
</div>
</el-tab-pane>
<el-tab-pane label="关联产品库" name="products">
<div class="timeline-container" v-loading="loadingProducts">
<el-table v-if="relatedProducts.length > 0" :data="relatedProducts" border stripe style="width: 100%">
<el-table-column prop="sku_code" label="SKU 编码" width="140" />
<el-table-column prop="sku_name" label="产品名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="spec" label="规格" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.spec || '-' }}</template>
</el-table-column>
<el-table-column label="累计购买量" width="120" align="right">
<template #default="{ row }"><b>{{ row.total_qty }}</b></template>
</el-table-column>
<el-table-column prop="order_count" label="关联订单数" width="110" align="center" />
<el-table-column prop="last_order_date" label="最近购买日期" width="130" align="center">
<template #default="{ row }">{{ row.last_order_date || '-' }}</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无关联产品,该客户尚未产生订单记录" />
</div>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 联系人表单弹窗 -->
<el-dialog v-model="contactDialogVisible" title="新增联系人" width="400px" destroy-on-close>
<el-form ref="contactFormRef" :model="contactForm" :rules="contactRules" label-width="80px">
<el-form-item label="姓名" prop="name">
<el-input v-model="contactForm.name" placeholder="联系人姓名" />
</el-form-item>
<el-form-item label="职位" prop="title">
<el-input v-model="contactForm.title" placeholder="如:采购总监" />
</el-form-item>
<el-form-item label="电话" prop="phone">
<el-input v-model="contactForm.phone" placeholder="联系电话" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="contactDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="contactSubmitting" @click="submitContact">保存</el-button>
</template>
</el-dialog>
<!-- 悬浮按钮 - 写新日志 -->
<el-tooltip content="写新跟进记录" placement="left">
<el-button v-if="customer.id" type="primary" circle size="large" class="floating-btn" @click="handleAddLog" :icon="CirclePlus" />
</el-tooltip>
</div>
</template>
<style scoped>
.customer-detail { display: flex; flex-direction: column; gap: 20px; position: relative; }
.profile-header { border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); }
.header-content { display: flex; justify-content: space-between; align-items: flex-start; gap: 30px; }
.info-section { flex: 1; }
.title-row { display: flex; align-items: center; gap: 15px; margin-bottom: 20px; }
.customer-name { margin: 0; font-size: 24px; color: #303133; }
.level-tag { font-weight: bold; }
.desc-box :deep(.el-descriptions__label) { width: 100px; color: #909399; }
.desc-box :deep(.el-descriptions__content) { color: #606266; font-weight: 500; }
.ai-assist-section { display: flex; flex-direction: column; align-items: center; gap: 20px; min-width: 200px; padding-left: 30px; border-left: 1px dashed #dcdfe6; }
.ai-score-container { display: flex; justify-content: center; align-items: center; }
.score-value { font-size: 24px; font-weight: bold; color: #303133; }
.score-label { font-size: 12px; color: #909399; margin-top: 4px; }
.action-buttons { display: flex; flex-direction: column; gap: 10px; width: 100%; }
.action-buttons .el-button { margin: 0; }
.tabs-card { border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); min-height: 400px; }
.timeline-container { padding: 10px 20px; }
.floating-btn { position: fixed; right: 40px; bottom: 40px; width: 48px; height: 48px; font-size: 20px; box-shadow: 0 4px 12px rgba(64, 158, 255, 0.4); z-index: 100; }
.drawer-content { display: flex; flex-direction: column; height: 100%; }
.drawer-footer { display: flex; justify-content: flex-end; }
/* AI Persona Styles */
.persona-container { padding: 10px; }
.mt-20 { margin-top: 20px; }
.persona-card { border-radius: 8px; border: 1px solid #ebeef5; }
.h-full { height: 100%; }
.card-header { font-weight: bold; color: #303133; font-size: 14px; }
.tags-wrapper { display: flex; flex-wrap: wrap; gap: 8px; min-height: 24px; }
.p-tag { font-size: 13px; }
.empty-text { color: #909399; font-size: 13px; font-style: italic; }
.intent-wrapper { display: flex; align-items: center; justify-content: center; height: 100%; min-height: 40px; }
.summary-text { color: #606266; font-size: 14px; line-height: 1.6; }
/* Timeline Styles */
.timeline-card { margin-bottom: 10px; border-radius: 6px; }
.log-content-pre { font-size: 14px; color: #303133; white-space: pre-wrap; line-height: 1.6; margin-bottom: 12px; }
.log-meta { display: flex; align-items: center; gap: 12px; font-size: 12px; color: #909399; }
.ai-tag { margin-left: auto; }
</style>
+484
View File
@@ -0,0 +1,484 @@
<script setup lang="ts">
/**
* CRM 客户管理 真实 API 驱动
* GET /api/customers (分页 + keyword 搜索)
* POST /api/customers (新增开户)
* PUT /api/customers/{id} (编辑)
* DELETE /api/customers/{id} (软删除/归档)
*/
import { ref, reactive, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { Search, Plus, View, Edit, Box, Upload, Download } from '@element-plus/icons-vue'
import { ElMessageBox, ElMessage, type FormInstance, type FormRules } from 'element-plus'
import request from '@/api/request'
import { useUserStore } from '@/store/user'
import { computed } from 'vue'
const userStore = useUserStore()
const isAdmin = computed(() => userStore.userInfo?.data_scope === 'all' || (userStore.userInfo?.role_name || '').toLowerCase() === 'admin')
const router = useRouter()
const loading = ref(false)
// --- ---
const searchForm = reactive({
keyword: '',
level: '',
industry: '',
showArchived: false,
})
// --- ---
const customerData = ref<any[]>([])
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
// --- ---
const fetchCustomers = async () => {
loading.value = true
try {
const params: Record<string, any> = {
page: currentPage.value,
size: pageSize.value,
}
if (searchForm.keyword) params.keyword = searchForm.keyword
if (searchForm.level) params.level = searchForm.level
if (searchForm.showArchived) params.include_archived = true
const data: any = await request.get('/api/customers', { params })
customerData.value = data.items || []
total.value = data.total || 0
} catch {
//
} finally {
loading.value = false
}
}
const handleSearch = () => {
currentPage.value = 1
fetchCustomers()
}
const handlePageChange = () => {
fetchCustomers()
}
// --- ---
const addDialogVisible = ref(false)
const addFormRef = ref<FormInstance>()
const addSubmitting = ref(false)
const addForm = reactive({
name: '',
level: 'B',
industry: '',
contact: '',
phone: '',
address: '',
remark: '',
})
const addFormRules = reactive<FormRules>({
name: [{ required: true, message: '请输入客户名称', trigger: 'blur' }],
})
const handleAddCustomer = () => {
Object.assign(addForm, { name: '', level: 'B', industry: '', contact: '', phone: '', address: '', remark: '' })
addDialogVisible.value = true
nextTick(() => addFormRef.value?.clearValidate())
}
const submitAddCustomer = async () => {
const valid = await addFormRef.value?.validate().catch(() => false)
if (!valid) return
addSubmitting.value = true
try {
await request.post('/api/customers', addForm)
ElMessage.success('客户开户成功')
addDialogVisible.value = false
fetchCustomers()
} catch {
//
} finally {
addSubmitting.value = false
}
}
// --- ---
const editDialogVisible = ref(false)
const editFormRef = ref<FormInstance>()
const editSubmitting = ref(false)
const editForm = reactive({
id: '',
name: '',
level: '',
industry: '',
contact: '',
phone: '',
address: '',
remark: '',
})
const editFormRules = reactive<FormRules>({
name: [{ required: true, message: '请输入客户名称', trigger: 'blur' }],
})
const editCustomer = (row: any) => {
Object.assign(editForm, {
id: row.id,
name: row.name || '',
level: row.level || '',
industry: row.industry || '',
contact: row.contact || '',
phone: row.phone || '',
address: row.address || '',
remark: row.remark || '',
})
editDialogVisible.value = true
nextTick(() => editFormRef.value?.clearValidate())
}
const submitEditCustomer = async () => {
const valid = await editFormRef.value?.validate().catch(() => false)
if (!valid) return
editSubmitting.value = true
try {
const { id, ...payload } = editForm
await request.put(`/api/customers/${id}`, payload)
ElMessage.success('客户信息已更新')
editDialogVisible.value = false
fetchCustomers()
} catch {
//
} finally {
editSubmitting.value = false
}
}
// --- ---
const viewDetails = (row: any) => {
router.push({
path: '/customers/detail',
query: { id: row.id }
})
}
// --- () ---
const archiveCustomer = (row: any) => {
ElMessageBox.confirm(
`确定要将客户 "${row.name}" 设为归档状态吗?该操作不会彻底删除数据。`,
'归档确认',
{ confirmButtonText: '确定归档', cancelButtonText: '取消', type: 'warning' }
).then(async () => {
try {
await request.delete(`/api/customers/${row.id}`)
ElMessage.success('归档成功!')
fetchCustomers()
} catch {
//
}
}).catch(() => {})
}
const restoreCustomer = (row: any) => {
ElMessageBox.confirm(
`确定要恢复客户 "${row.name}" 吗?`,
'恢复确认',
{ confirmButtonText: '确定恢复', cancelButtonText: '取消', type: 'info' }
).then(async () => {
try {
await request.put(`/api/customers/${row.id}/restore`)
ElMessage.success('客户已恢复!')
fetchCustomers()
} catch {
//
}
}).catch(() => {})
}
// --- ---
const getLevelType = (level: string) => {
if (!level) return ''
if (level.includes('A')) return 'danger'
if (level.includes('B')) return 'warning'
if (level.includes('C')) return 'info'
return ''
}
const getLevelLabel = (level: string) => {
if (level === 'A') return 'A级重点'
if (level === 'B') return 'B级普通'
if (level === 'C') return 'C级长尾'
return level || '-'
}
// --- / ---
const importDialogVisible = ref(false)
const getUploadHeaders = () => {
return { Authorization: `Bearer ${localStorage.getItem('crm_token') || ''}` }
}
const handleImportSuccess = (response: any) => {
if (response.code === 200) {
ElMessage.success(response.message || '导入成功')
importDialogVisible.value = false
fetchCustomers()
} else {
ElMessage.error(response.message || '导入失败')
}
}
const handleImportError = () => { ElMessage.error('上传失败,请检查网络或后端服务') }
const handleExport = async () => {
try {
const res = await request.get('/api/crm/export', {
responseType: 'blob'
})
const blob = new Blob([res as any], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `customers_export_${new Date().toISOString().split('T')[0]}.xlsx`
a.click()
window.URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch {
ElMessage.error('导出失败或无权限')
}
}
// --- ---
onMounted(() => {
fetchCustomers()
})
</script>
<template>
<div class="customer-list-container">
<!-- 1. 顶部高级检索区 -->
<el-card shadow="never" class="filter-section">
<div class="filter-wrapper">
<el-form :inline="true" :model="searchForm" class="filter-form">
<el-form-item label="客户名称">
<el-input v-model="searchForm.keyword" placeholder="请输入名称/拼音" clearable @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="客户级别">
<el-select v-model="searchForm.level" placeholder="全部分级" clearable style="width: 150px">
<el-option label="A级重点" value="A" />
<el-option label="B级普通" value="B" />
<el-option label="C级长尾" value="C" />
</el-select>
</el-form-item>
<el-form-item>
<el-switch v-model="searchForm.showArchived" active-text="显示已归档" @change="handleSearch" style="margin-right: 10px" />
</el-form-item>
</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>
</el-card>
<!-- 2. 核心数据表格 -->
<el-card shadow="never" class="table-section">
<el-table :data="customerData" stripe border style="width: 100%" v-loading="loading">
<el-table-column prop="name" label="客户名称" min-width="220" show-overflow-tooltip>
<template #default="scope">
<span class="customer-name-bold">{{ scope.row.name }}</span>
</template>
</el-table-column>
<el-table-column label="客户级别" width="120" align="center">
<template #default="scope">
<el-tag :type="getLevelType(scope.row.level)" effect="light">
{{ getLevelLabel(scope.row.level) }}
</el-tag>
</template>
</el-table-column>
<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="220" fixed="right">
<template #default="scope">
<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 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>
</template>
</el-table-column>
</el-table>
<!-- 3. 分页 -->
<div class="pagination-section">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
background
layout="total, prev, pager, next, jumper"
:total="total"
@current-change="handlePageChange"
@size-change="handlePageChange"
/>
</div>
</el-card>
<!-- 4. 新增客户弹窗 -->
<el-dialog v-model="addDialogVisible" title="新增客户开户" width="560px" destroy-on-close>
<el-form ref="addFormRef" :model="addForm" :rules="addFormRules" label-width="80px">
<el-form-item label="客户名称" prop="name">
<el-input v-model="addForm.name" placeholder="请输入客户公司名称" />
</el-form-item>
<el-form-item label="客户级别">
<el-select v-model="addForm.level" style="width: 100%">
<el-option label="A级重点" value="A" />
<el-option label="B级普通" value="B" />
<el-option label="C级长尾" value="C" />
</el-select>
</el-form-item>
<el-form-item label="行业">
<el-input v-model="addForm.industry" placeholder="所属行业" />
</el-form-item>
<el-form-item label="联系人">
<el-input v-model="addForm.contact" placeholder="联系人姓名" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="addForm.phone" placeholder="联系电话" />
</el-form-item>
<el-form-item label="地址">
<el-input v-model="addForm.address" placeholder="地址" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="addForm.remark" type="textarea" :rows="2" placeholder="备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="addSubmitting" @click="submitAddCustomer">确定提交</el-button>
</template>
</el-dialog>
<!-- 5. 编辑客户弹窗 -->
<el-dialog v-model="editDialogVisible" title="编辑客户信息" width="560px" destroy-on-close>
<el-form ref="editFormRef" :model="editForm" :rules="editFormRules" label-width="80px">
<el-form-item label="客户名称" prop="name">
<el-input v-model="editForm.name" />
</el-form-item>
<el-form-item label="客户级别">
<el-select v-model="editForm.level" style="width: 100%">
<el-option label="A级重点" value="A" />
<el-option label="B级普通" value="B" />
<el-option label="C级长尾" value="C" />
</el-select>
</el-form-item>
<el-form-item label="行业">
<el-input v-model="editForm.industry" />
</el-form-item>
<el-form-item label="联系人">
<el-input v-model="editForm.contact" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="editForm.phone" />
</el-form-item>
<el-form-item label="地址">
<el-input v-model="editForm.address" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="editForm.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="editSubmitting" @click="submitEditCustomer">保存修改</el-button>
</template>
</el-dialog>
<!-- 6. 批量导入弹窗 -->
<el-dialog v-model="importDialogVisible" title="批量导入客户" width="400px" destroy-on-close>
<div style="margin-bottom: 20px; line-height: 1.6;">
<p>1. 请先下载标准模板按要求填写数据</p>
<p>2. 仅支持 .xlsx 格式文件</p>
<p style="margin-top: 10px;">
<a href="/api/templates/customer_import_template.xlsx" target="_blank" style="color: #409eff; text-decoration: none;">
点击下载客户导入模板.xlsx
</a>
</p>
</div>
<el-upload
drag
action="/api/crm/import"
:headers="getUploadHeaders()"
accept=".xlsx,.xls"
:show-file-list="false"
:on-success="handleImportSuccess"
:on-error="handleImportError"
>
<el-icon class="el-icon--upload"><Upload /></el-icon>
<div class="el-upload__text">将文件拖到此处 <em>点击上传</em></div>
</el-upload>
</el-dialog>
</div>
</template>
<style scoped>
.customer-list-container {
display: flex;
flex-direction: column;
gap: 20px;
}
/* 顶部检索区 */
.filter-section {
border-radius: 8px;
border: none;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
}
.filter-wrapper {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
align-items: flex-start;
}
.filter-form .el-form-item {
margin-bottom: 0;
margin-right: 20px;
}
.action-buttons {
display: flex;
gap: 10px;
}
/* 数据表格区 */
.table-section {
border-radius: 8px;
border: none;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
}
.customer-name-bold {
font-weight: bold;
color: #303133;
}
/* 分页 */
.pagination-section {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,316 @@
<script setup lang="ts">
/**
* AI 复盘报告页面
* - SSE 流式生成复盘报告
* - 自动存 draft 防丢失
* - 历史报告查看/编辑/存档/删除
*/
import { ref, nextTick, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete } from '@element-plus/icons-vue'
import request from '@/api/request'
import { useUserStore } from '@/store/user'
const userStore = useUserStore()
//
const now = new Date()
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1)
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0)
const fmt = (d: Date) => d.toISOString().split('T')[0]
const dateRange = ref<[string, string]>([fmt(firstDay), fmt(lastDay)])
const loading = ref(false)
const reportContent = ref('')
const isFinished = ref(false)
const confirmLoading = ref(false)
const isConfirmed = ref(false)
const isEditing = ref(false)
const reportContainerRef = ref<HTMLDivElement>()
const currentReportId = ref('')
// --- ---
const historyReports = ref<any[]>([])
const loadingHistory = ref(false)
const fetchHistory = async () => {
loadingHistory.value = true
try {
const res: any = await request.get('/api/reports/history', { params: { size: 50 } })
historyReports.value = res?.items || []
} catch { }
finally { loadingHistory.value = false }
}
onMounted(() => { fetchHistory() })
//
const loadHistoryReport = (report: any) => {
reportContent.value = report.content_md || ''
currentReportId.value = report.id
isFinished.value = true
isConfirmed.value = report.status === 'confirmed'
isEditing.value = false
dateRange.value = [report.period_start, report.period_end]
}
//
const enterEdit = () => {
isEditing.value = true
isConfirmed.value = false
}
const deleteHistoryReport = (report: any) => {
ElMessageBox.confirm('确定要删除这份复盘报告吗?', '删除确认', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
await request.delete(`/api/reports/${report.id}`)
ElMessage.success('已删除')
if (currentReportId.value === report.id) {
reportContent.value = ''
currentReportId.value = ''
isFinished.value = false
}
fetchHistory()
}).catch(() => {})
}
//
const handleGenerate = async () => {
if (!dateRange.value?.[0] || !dateRange.value?.[1]) {
ElMessage.warning('请选择时间范围')
return
}
loading.value = true
reportContent.value = ''
isFinished.value = false
isConfirmed.value = false
isEditing.value = false
currentReportId.value = ''
try {
const token = userStore.token || localStorage.getItem('crm_token') || ''
const res = await fetch('/api/reports/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
start_date: dateRange.value[0],
end_date: dateRange.value[1],
}),
})
if (!res.ok) {
ElMessage.error(`请求失败: ${res.status}`)
loading.value = false
return
}
const reader = res.body?.getReader()
if (!reader) {
ElMessage.error('无法读取 SSE 流')
loading.value = false
return
}
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
while (buffer.includes('\n\n')) {
const [eventBlock, rest] = buffer.split('\n\n', 2)
buffer = rest || ''
const dataLine = eventBlock.split('\n').find(l => l.startsWith('data: '))
if (!dataLine) continue
try {
const event = JSON.parse(dataLine.slice(6))
if (event.type === 'text') {
reportContent.value += event.content
await nextTick()
if (reportContainerRef.value) {
reportContainerRef.value.scrollTop = reportContainerRef.value.scrollHeight
}
} else if (event.type === 'done') {
isFinished.value = true
}
} catch { }
}
}
isFinished.value = true
// draft
if (reportContent.value.trim()) {
try {
const draftRes: any = await request.post('/api/reports/confirm', {
start_date: dateRange.value[0],
end_date: dateRange.value[1],
content_md: reportContent.value,
report_type: 'monthly',
})
if (draftRes?.id) currentReportId.value = draftRes.id
fetchHistory()
} catch { }
}
} catch (err: any) {
ElMessage.error(`生成失败: ${err.message || err}`)
} finally {
loading.value = false
}
}
// /
const handleConfirm = async () => {
if (!reportContent.value.trim()) {
ElMessage.warning('无报告内容可存档')
return
}
confirmLoading.value = true
try {
if (currentReportId.value) {
await request.put(`/api/reports/${currentReportId.value}`, {
content_md: reportContent.value,
status: 'confirmed',
})
} else {
const res: any = await request.post('/api/reports/confirm', {
start_date: dateRange.value[0],
end_date: dateRange.value[1],
content_md: reportContent.value,
report_type: 'monthly',
})
if (res?.id) currentReportId.value = res.id
}
ElMessage.success('复盘报告已确认存档')
isConfirmed.value = true
isEditing.value = false
fetchHistory()
} catch { }
finally { confirmLoading.value = false }
}
</script>
<template>
<div class="report-page">
<!-- 顶部操作栏 -->
<el-card shadow="never" class="action-card">
<div class="action-bar">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 280px"
/>
<el-button type="primary" size="large" @click="handleGenerate" :loading="loading">
{{ loading ? 'AI 正在生成...' : '生成复盘报告' }}
</el-button>
</div>
</el-card>
<!-- 报告内容区域 -->
<el-card shadow="never" class="report-card" v-if="reportContent">
<template #header>
<div class="card-header">
<span>AI 智能复盘报告</span>
<div class="header-actions" v-if="isFinished">
<template v-if="isEditing">
<el-button type="success" @click="handleConfirm" :loading="confirmLoading">保存并存档</el-button>
<el-button @click="isEditing = false">取消编辑</el-button>
</template>
<template v-else>
<el-button v-if="!isConfirmed" type="success" @click="handleConfirm" :loading="confirmLoading">确认存档</el-button>
<el-button type="warning" plain @click="enterEdit">编辑</el-button>
<el-tag v-if="isConfirmed" type="success" effect="dark" style="margin-left: 8px">已存档</el-tag>
</template>
</div>
</div>
</template>
<!-- 编辑模式textarea -->
<el-input
v-if="isEditing"
v-model="reportContent"
type="textarea"
:autosize="{ minRows: 10, maxRows: 30 }"
placeholder="编辑复盘报告内容..."
class="report-editor"
/>
<!-- 查看模式格式化显示 -->
<div v-else ref="reportContainerRef" class="report-body">
<template v-for="(paragraph, index) in reportContent.split('\n')" :key="index">
<p v-if="paragraph.trim()" class="report-paragraph">{{ paragraph }}</p>
<br v-else-if="index > 0" />
</template>
<span v-if="loading" class="typing-cursor"></span>
</div>
</el-card>
<!-- 空状态 -->
<el-empty
v-if="!reportContent && !loading"
description="选择时间范围,点击按钮生成 AI 复盘报告"
class="empty-state"
/>
<!-- 历史报告列表 -->
<el-card shadow="never" class="history-card" v-if="historyReports.length > 0 || loadingHistory">
<template #header>
<div class="card-header">
<span>📋 历史复盘报告</span>
<el-tag type="info" size="small"> {{ historyReports.length }} </el-tag>
</div>
</template>
<div v-loading="loadingHistory">
<div v-for="report in historyReports" :key="report.id" class="history-item"
:class="{ active: currentReportId === report.id }">
<div class="history-info" @click="loadHistoryReport(report)">
<span class="history-period">{{ report.period_start }} ~ {{ report.period_end }}</span>
<el-tag :type="report.status === 'confirmed' ? 'success' : 'warning'" size="small" effect="plain">
{{ report.status === 'confirmed' ? '已确认' : '草稿' }}
</el-tag>
<span class="history-time">{{ report.created_at?.slice(0, 16)?.replace('T', ' ') }}</span>
</div>
<el-button :icon="Delete" type="danger" link size="small" @click.stop="deleteHistoryReport(report)" />
</div>
</div>
</el-card>
</div>
</template>
<style scoped>
.report-page { display: flex; flex-direction: column; gap: 20px; }
.action-card { border: none; border-radius: 8px; }
.action-bar { display: flex; align-items: center; gap: 16px; justify-content: flex-end; }
.report-card { border: none; border-radius: 8px; }
.card-header { display: flex; justify-content: space-between; align-items: center; font-weight: bold; font-size: 16px; }
.header-actions { display: flex; align-items: center; gap: 8px; }
.report-body { line-height: 1.8; color: #333; font-size: 15px; max-height: calc(100vh - 350px); overflow-y: auto; padding-right: 8px; }
.report-paragraph { margin-bottom: 8px; }
.report-editor :deep(.el-textarea__inner) { font-size: 15px; line-height: 1.8; font-family: inherit; }
.typing-cursor { animation: blink 1s step-end infinite; color: #409eff; }
@keyframes blink { 50% { opacity: 0; } }
.empty-state { margin-top: 40px; background: white; padding: 40px; border-radius: 8px; }
.history-card { border: none; border-radius: 8px; }
.history-item { display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; border-bottom: 1px solid #f0f0f0; cursor: pointer; transition: background 0.2s; border-radius: 4px; }
.history-item:hover { background: #f5f7fa; }
.history-item.active { background: #ecf5ff; border-left: 3px solid #409eff; }
.history-item:last-child { border-bottom: none; }
.history-info { display: flex; align-items: center; gap: 12px; flex: 1; }
.history-period { font-weight: 500; color: #303133; font-size: 14px; }
.history-time { color: #909399; font-size: 12px; }
</style>
+138
View File
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Plus, Van, Box, EditPen } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import request from '@/api/request'
const router = useRouter()
const loading = ref(true)
const stats = ref({
orders_count: 0,
pending_shipping: 0,
warning_skus: 0,
monthly_revenue: 0,
})
const fetchStats = async () => {
loading.value = true
try {
const data: any = await request.get('/api/dashboard/stats')
if (data) Object.assign(stats.value, data)
} catch {}
finally { loading.value = false }
}
const formatMoney = (v: number) => v >= 10000 ? `${(v / 10000).toFixed(1)}` : `¥${v.toLocaleString()}`
onMounted(fetchStats)
</script>
<template>
<div class="dashboard-container">
<!-- 顶部快捷操作 -->
<div class="quick-actions">
<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="warning" :icon="Box" @click="router.push('/products')">库存入库</el-button>
<el-button type="info" :icon="EditPen" @click="router.push('/logs')">写销售日志</el-button>
</div>
<!-- 中部核心数据 KPI -->
<el-row :gutter="20" class="kpi-cards">
<el-col :span="6">
<el-card shadow="hover" class="kpi-card" v-loading="loading">
<div class="kpi-title">本月新增订单</div>
<div class="kpi-value">{{ stats.orders_count }} <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: #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>
<!-- 底部最新动态 -->
<el-card shadow="never" class="recent-activities">
<template #header>
<div class="card-header">
<span>近期业务动态</span>
</div>
</template>
<el-empty description="暂无业务动态数据,请先录入订单和日志" />
</el-card>
</div>
</template>
<style scoped>
.dashboard-container {
display: flex;
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>
+561
View File
@@ -0,0 +1,561 @@
<script setup lang="ts">
/**
* 销项发票管理页 (AR)
* 多条件查询 + 回款标记 + 新增 + 导出
*/
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
import { Plus, Search, Download } from '@element-plus/icons-vue'
import request from '@/api/request'
import { useUserStore } from '@/store/user'
import { UploadFilled } from '@element-plus/icons-vue'
import type { UploadFile } from 'element-plus'
const userStore = useUserStore()
const isAdmin = computed(() => userStore.userInfo?.data_scope === 'all' || (userStore.userInfo?.role_name || '').toLowerCase() === 'admin')
// --- ---
interface CustomerOption { id: string; name: string; level: string; contact: string | null }
const customerOptions = ref<CustomerOption[]>([])
const customerSearchLoading = ref(false)
const handleCustomerSearch = async (query: string) => {
if (!query) { customerOptions.value = []; return }
customerSearchLoading.value = true
try {
const res: any = await request.get('/api/customers/search', { params: { q: query } })
customerOptions.value = res || []
} catch { customerOptions.value = [] }
finally { customerSearchLoading.value = false }
}
// --- ---
const loading = ref(false)
const tableData = ref<any[]>([])
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
const filters = reactive({
customer_name: '',
invoice_number: '',
payment_status: '',
dateRange: null as [string, string] | null,
})
const fetchList = async () => {
loading.value = true
try {
const params: Record<string, any> = {
page: currentPage.value,
size: pageSize.value,
}
if (filters.customer_name) params.customer_name = filters.customer_name
if (filters.invoice_number) params.invoice_number = filters.invoice_number
if (filters.payment_status) params.payment_status = filters.payment_status
if (filters.dateRange?.[0]) params.start_date = filters.dateRange[0]
if (filters.dateRange?.[1]) params.end_date = filters.dateRange[1]
const res: any = await request.get('/api/finance/sales-invoices', { params })
tableData.value = res.items || []
total.value = res.total || 0
} catch {
//
} finally {
loading.value = false
}
}
const handleSearch = () => { currentPage.value = 1; fetchList() }
const handleReset = () => {
filters.customer_name = ''
filters.invoice_number = ''
filters.payment_status = ''
filters.dateRange = null
handleSearch()
}
// --- ---
const addDialogVisible = ref(false)
const addSubmitting = ref(false)
const addFormRef = ref<FormInstance>()
const addForm = reactive({
issuer: '',
receiver_customer_id: '',
invoice_number: '',
amount: 0,
billing_date: '',
remark: '',
})
const addFormRules = {
issuer: [{ required: true, message: '请输入开票方', trigger: 'blur' }],
receiver_customer_id: [{ required: true, message: '请选择受票客户', trigger: 'change' }],
invoice_number: [{ required: true, message: '请输入发票号', trigger: 'blur' }],
amount: [{ required: true, message: '请输入票面金额', trigger: 'blur' }],
billing_date: [{ required: true, message: '请选择开票日期', trigger: 'change' }],
}
const openAddDialog = () => {
addForm.issuer = ''
addForm.receiver_customer_id = ''
addForm.invoice_number = ''
addForm.amount = 0
addForm.billing_date = ''
addForm.remark = ''
customerOptions.value = []
addDialogVisible.value = true
}
const submitAdd = async () => {
if (!addFormRef.value) return
const valid = await addFormRef.value.validate().catch(() => false)
if (!valid) return
addSubmitting.value = true
try {
await request.post('/api/finance/sales-invoices', addForm)
ElMessage.success('销项发票创建成功')
addDialogVisible.value = false
handleSearch()
} catch {
//
} finally {
addSubmitting.value = false
}
}
// --- OCR ---
const uploadProcessing = ref(false)
const aiParsing = ref(false)
let uploadAbortController: AbortController | null = null
interface OcrQueueItem {
name: string
status: 'waiting' | 'processing' | 'success' | 'error'
message: string
}
const ocrQueue = ref<OcrQueueItem[]>([])
const handleOCRUpload = async (_file: UploadFile, fileList: any[]) => {
const rawFiles = fileList.filter((f: any) => f.raw).map((f: any) => f.raw as File)
if (!rawFiles.length) return
//
if (rawFiles.length === 1) {
const file = rawFiles[0]
uploadProcessing.value = true
aiParsing.value = true
uploadAbortController = new AbortController()
try {
const formData = new FormData()
formData.append('file', file)
formData.append('scene', 'invoice')
const token = userStore.token || localStorage.getItem('crm_token') || ''
const response = await fetch('/api/finance/ocr', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData,
signal: uploadAbortController.signal,
})
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
const result = await response.json()
if (result.code !== 200) throw new Error(result.message || 'OCR 识别失败')
const aiData = result.data?.ocr_data || {}
ElMessage.success('🤖 AI 识别成功,数据已尝试填入表单')
addForm.issuer = aiData.seller_name || aiData.merchant || aiData.merchant_name || addForm.issuer
addForm.invoice_number = aiData.invoice_num || aiData.invoice_number || addForm.invoice_number
addForm.amount = parseFloat(aiData.amount) || addForm.amount
addForm.billing_date = aiData.date || addForm.billing_date
const buyerName = (aiData.buyer_name || aiData.customer_name || '').replace(/[\r\n\t]/g, '').trim()
if (buyerName) {
await handleCustomerSearch(buyerName)
if (customerOptions.value.length > 0) {
addForm.receiver_customer_id = customerOptions.value[0].id
ElMessage.success(`已自动匹配并选中受票客户: ${customerOptions.value[0].name}`)
} else {
ElMessage.warning(`AI提取受票方为「${buyerName}」,但系统中未找到该客户。`)
}
}
} catch (e: any) {
if (e.name === 'AbortError') return
ElMessage.warning('AI 解析失败或服务不可用,请手动填写。')
} finally {
uploadProcessing.value = false
aiParsing.value = false
uploadAbortController = null
}
return
}
// OCR +
ocrQueue.value = rawFiles.map(f => ({ name: f.name, status: 'waiting' as const, message: '' }))
uploadProcessing.value = true
uploadAbortController = new AbortController()
const signal = uploadAbortController.signal
for (let i = 0; i < rawFiles.length; i++) {
if (signal.aborted) break
const file = rawFiles[i]
ocrQueue.value[i].status = 'processing'
ocrQueue.value[i].message = '🤖 解析中...'
try {
const formData = new FormData()
formData.append('file', file)
formData.append('scene', 'invoice')
const token = userStore.token || localStorage.getItem('crm_token') || ''
const response = await fetch('/api/finance/ocr', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData,
signal,
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const result = await response.json()
if (result.code !== 200) throw new Error(result.message || 'OCR 失败')
const aiData = result.data?.ocr_data || {}
const issuer = aiData.seller_name || aiData.merchant || aiData.merchant_name || ''
const invoiceNumber = aiData.invoice_num || aiData.invoice_number || ''
const amount = parseFloat(aiData.amount) || 0
const billingDate = aiData.date || new Date().toISOString().split('T')[0]
const buyerName = (aiData.buyer_name || aiData.customer_name || '').replace(/[\r\n\t]/g, '').trim()
//
let customerId = ''
if (buyerName) {
await handleCustomerSearch(buyerName)
if (customerOptions.value.length > 0) customerId = customerOptions.value[0].id
}
if (issuer && invoiceNumber && amount > 0 && customerId) {
await request.post('/api/finance/sales-invoices', {
issuer, receiver_customer_id: customerId, invoice_number: invoiceNumber,
amount, billing_date: billingDate, remark: 'AI批量导入',
})
ocrQueue.value[i].status = 'success'
ocrQueue.value[i].message = `${invoiceNumber}`
} else {
ocrQueue.value[i].status = 'error'
ocrQueue.value[i].message = '⚠️ 数据不完整,请手动创建'
}
} catch (e: any) {
if (e.name === 'AbortError') break
ocrQueue.value[i].status = 'error'
ocrQueue.value[i].message = `${e.message || '失败'}`
}
}
uploadProcessing.value = false
uploadAbortController = null
if (!signal.aborted) {
const ok = ocrQueue.value.filter(q => q.status === 'success').length
ElMessage.success(`批量处理完成:${ok}/${rawFiles.length} 自动创建成功`)
handleSearch()
}
setTimeout(() => { ocrQueue.value = [] }, 5000)
}
// --- Dialog ---
const handleDialogClose = (done: () => void) => {
if (uploadProcessing.value) {
ElMessageBox.confirm(
'OCR 批量处理正在进行中,关闭将中断所有未完成的任务。确定关闭?',
'提示',
{ confirmButtonText: '确认关闭', cancelButtonText: '继续等待', type: 'warning' }
).then(() => {
if (uploadAbortController) { uploadAbortController.abort(); uploadAbortController = null }
uploadProcessing.value = false
ocrQueue.value = []
done()
}).catch(() => {})
} else {
done()
}
}
// --- ---
const paymentDialogVisible = ref(false)
const paymentSubmitting = ref(false)
const paymentFormRef = ref<FormInstance>()
const paymentForm = reactive({
id: '',
payment_status: '已结清' as string,
payment_amount: 0,
payment_date: '',
})
const openPaymentDialog = (row: any) => {
paymentForm.id = row.id
paymentForm.payment_status = row.payment_status === '已结清' ? '已结清' : '已结清'
paymentForm.payment_amount = row.amount
paymentForm.payment_date = new Date().toISOString().split('T')[0]
paymentDialogVisible.value = true
}
const submitPayment = async () => {
paymentSubmitting.value = true
try {
await request.put(`/api/finance/sales-invoices/${paymentForm.id}`, {
payment_status: paymentForm.payment_status,
payment_amount: paymentForm.payment_amount,
payment_date: paymentForm.payment_date,
})
ElMessage.success('回款状态更新成功')
paymentDialogVisible.value = false
fetchList()
} catch {
//
} finally {
paymentSubmitting.value = false
}
}
// --- ---
const handleExport = async () => {
try {
const params: any = {}
if (filters.customer_name) params.customer_name = filters.customer_name
if (filters.invoice_number) params.invoice_number = filters.invoice_number
if (filters.payment_status) params.payment_status = filters.payment_status
if (filters.dateRange?.[0]) params.start_date = filters.dateRange[0]
if (filters.dateRange?.[1]) params.end_date = filters.dateRange[1]
const res = await request.get('/api/finance/sales-invoices/export', {
params,
responseType: 'blob'
})
const blob = new Blob([res as any], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `sales_invoices_${new Date().toISOString().split('T')[0]}.xlsx`
a.click()
window.URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch {
ElMessage.error('导出失败')
}
}
// --- ---
const statusTag = (status: string) => {
if (status === '已结清') return 'success'
if (status === '部分回款') return 'warning'
return 'danger'
}
onMounted(fetchList)
</script>
<template>
<div class="sales-invoice-container">
<!-- 筛选栏 -->
<el-card shadow="never" class="filter-card">
<div class="filter-bar">
<el-form :inline="true" @submit.prevent>
<el-form-item label="客户名称">
<el-input v-model="filters.customer_name" placeholder="客户名称" clearable style="width: 160px" />
</el-form-item>
<el-form-item label="发票号">
<el-input v-model="filters.invoice_number" placeholder="发票号" clearable style="width: 160px" />
</el-form-item>
<el-form-item label="回款状态">
<el-select v-model="filters.payment_status" placeholder="全部" clearable style="width: 120px">
<el-option label="未回款" value="未回款" />
<el-option label="部分回款" value="部分回款" />
<el-option label="已结清" value="已结清" />
</el-select>
</el-form-item>
<el-form-item label="开票日期">
<el-date-picker
v-model="filters.dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始"
end-placeholder="结束"
value-format="YYYY-MM-DD"
style="width: 240px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<div class="actions">
<el-button type="success" :icon="Plus" @click="openAddDialog">新增发票</el-button>
<el-button v-if="isAdmin" type="warning" :icon="Download" @click="handleExport">导出</el-button>
</div>
</div>
</el-card>
<!-- 表格 -->
<el-card shadow="never" class="table-card">
<el-table :data="tableData" border stripe style="width: 100%" v-loading="loading">
<el-table-column prop="invoice_number" label="发票号" width="180" show-overflow-tooltip />
<el-table-column prop="issuer" label="开票方" width="180" show-overflow-tooltip />
<el-table-column prop="customer_name" label="受票客户" width="180" show-overflow-tooltip />
<el-table-column prop="amount" label="票面金额" width="120" align="right">
<template #default="{ row }">¥ {{ Number(row.amount).toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="billing_date" label="开票日期" width="120" align="center" />
<el-table-column prop="payment_status" label="回款状态" width="110" align="center">
<template #default="{ row }">
<el-tag :type="statusTag(row.payment_status)" effect="light">{{ row.payment_status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="payment_amount" label="已回款" width="120" align="right">
<template #default="{ row }">¥ {{ Number(row.payment_amount || 0).toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="payment_date" label="回款日期" width="120" align="center" />
<el-table-column label="操作" width="120" align="center" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.payment_status !== '已结清'"
type="primary"
link
size="small"
@click="openPaymentDialog(row)"
>回款标记</el-button>
<span v-else style="color: #67c23a; font-size: 12px">已结清</span>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
layout="total, sizes, prev, pager, next"
:page-sizes="[10, 20, 50]"
@current-change="fetchList"
@size-change="handleSearch"
/>
</div>
</el-card>
<!-- 新增发票弹窗 -->
<el-dialog v-model="addDialogVisible" title="发票智能录入" width="600px" destroy-on-close :before-close="handleDialogClose">
<div style="margin-bottom: 20px" v-loading="uploadProcessing || aiParsing" :element-loading-text="uploadProcessing ? '文件上传中...' : '🤖 AI 正在识别发票表单字段...'">
<el-upload
drag
:auto-upload="false"
:show-file-list="false"
accept=".pdf,.jpg,.jpeg,.png"
multiple
@change="handleOCRUpload"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
将发票原件(PDF/图片)拖到此处 <em>点击上传进行AI填单</em>
</div>
<div class="el-upload__tip" style="font-size:12px;color:#909399">支持 PDF/图片/MD 文件单文件自动填入表单多文件自动批量创建发票</div>
</el-upload>
<!-- 批量处理队列 -->
<div v-if="ocrQueue.length" style="margin-top: 10px">
<div v-for="(item, idx) in ocrQueue" :key="idx" style="display:flex; justify-content:space-between; align-items:center; padding: 4px 8px; border-bottom: 1px solid #f0f0f0; font-size: 13px">
<span style="color:#606266; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:280px">{{ item.name }}</span>
<el-tag v-if="item.status === 'waiting'" size="small" type="info" effect="plain">等待中</el-tag>
<el-tag v-else-if="item.status === 'processing'" size="small" type="warning" effect="dark">处理中</el-tag>
<el-tag v-else-if="item.status === 'success'" size="small" type="success" effect="dark">{{ item.message }}</el-tag>
<el-tag v-else size="small" type="danger" effect="dark">{{ item.message }}</el-tag>
</div>
</div>
</div>
<el-form ref="addFormRef" :model="addForm" :rules="addFormRules" label-width="100px">
<el-form-item label="开票方" prop="issuer">
<el-input v-model="addForm.issuer" placeholder="我方公司名称" />
</el-form-item>
<el-form-item label="受票客户" prop="receiver_customer_id">
<el-select
v-model="addForm.receiver_customer_id"
filterable remote clearable reserve-keyword
placeholder="输入客户名称搜索..."
:remote-method="handleCustomerSearch"
:loading="customerSearchLoading"
style="width: 100%"
>
<el-option
v-for="item in customerOptions"
:key="item.id"
:label="item.name"
:value="item.id"
>
<span>{{ item.name }}</span>
<span style="color: #999; font-size: 12px; margin-left: 8px">{{ item.level }}</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="发票号" prop="invoice_number">
<el-input v-model="addForm.invoice_number" placeholder="INV-20260312-001" />
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="票面金额" prop="amount">
<el-input-number v-model="addForm.amount" :min="0.01" :precision="2" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="开票日期" prop="billing_date">
<el-date-picker v-model="addForm.billing_date" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注">
<el-input v-model="addForm.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="addSubmitting" @click="submitAdd">确认创建</el-button>
</template>
</el-dialog>
<!-- 回款标记弹窗 -->
<el-dialog v-model="paymentDialogVisible" title="回款标记" width="450px" destroy-on-close>
<el-form ref="paymentFormRef" :model="paymentForm" label-width="100px">
<el-form-item label="回款状态">
<el-select v-model="paymentForm.payment_status" style="width: 100%">
<el-option label="部分回款" value="部分回款" />
<el-option label="已结清" value="已结清" />
</el-select>
</el-form-item>
<el-form-item label="回款金额">
<el-input-number v-model="paymentForm.payment_amount" :min="0" :precision="2" style="width: 100%" />
</el-form-item>
<el-form-item label="回款日期">
<el-date-picker v-model="paymentForm.payment_date" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="paymentDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="paymentSubmitting" @click="submitPayment">确认</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.sales-invoice-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.filter-card { border: none; border-radius: 8px; }
.filter-bar {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 12px;
}
.actions { display: flex; gap: 8px; }
.table-card { border: none; border-radius: 8px; }
.pagination-wrapper {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>
+767
View File
@@ -0,0 +1,767 @@
<script setup lang="ts">
/**
* 财务票据中心 灵魂级 UX 重构
* Tab 1: 统一票据池报销/客户分流 + 拖拽上传 + AI 解析
* Tab 2: 购物车式新建报销AI 一键生成草稿
* Tab 3: 报销大盘与审批A4 打印 + Excel 导出 + 审批态只读
*/
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
import { Search, Plus, Delete, View, Check, Close, RefreshLeft, Download, Printer, Link } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules, UploadFile } from 'element-plus'
import request from '@/api/request'
import { useUserStore } from '@/store/user'
import { useRoute } from 'vue-router'
const userStore = useUserStore()
const route = useRoute()
// /finance?tab=dashboard
const activeTab = ref((route.query.tab as string) || 'pool')
//
// Tab 1: / Tab
//
const invCategory = ref('expense')
const invLoading = ref(false)
const invList = ref<any[]>([])
const invTotal = ref(0)
const invPage = ref(1)
const invSize = ref(20)
const invUsedFilter = ref('' as '' | 'true' | 'false')
const fetchInvoices = async () => {
invLoading.value = true
try {
const params: Record<string, any> = { page: invPage.value, size: invSize.value, category: invCategory.value }
if (invUsedFilter.value !== '') params.is_used = invUsedFilter.value
const data: any = await request.get('/api/finance/invoices', { params })
invList.value = data?.items || []
invTotal.value = data?.total || 0
} catch {}
finally { invLoading.value = false }
}
watch(invCategory, () => { invPage.value = 1; fetchInvoices() })
// + AI
const uploadProcessing = ref(false)
const aiParsing = ref(false)
interface QueueItem {
name: string
status: 'waiting' | 'processing' | 'success' | 'error'
message: string
}
const uploadQueue = ref<QueueItem[]>([])
let uploadAbortController: AbortController | null = null
const handleUploadChange = async (_file: UploadFile, fileList: any[]) => {
// raw
const rawFiles = fileList.filter((f: any) => f.raw).map((f: any) => f.raw as File)
if (!rawFiles.length) return
//
uploadQueue.value = rawFiles.map(f => ({ name: f.name, status: 'waiting' as const, message: '' }))
uploadProcessing.value = true
uploadAbortController = new AbortController()
const signal = uploadAbortController.signal
for (let i = 0; i < rawFiles.length; i++) {
if (signal.aborted) break
const file = rawFiles[i]
uploadQueue.value[i].status = 'processing'
uploadQueue.value[i].message = '🤖 AI 正在解析...'
try {
const formData = new FormData()
formData.append('file', file)
formData.append('scene', 'invoice')
const response = await fetch('/api/finance/ocr', {
method: 'POST',
headers: { 'Authorization': `Bearer ${userStore.token}` },
body: formData,
signal,
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const result = await response.json()
if (result.code !== 200) throw new Error(result.message || 'OCR 识别失败')
const ocrResult = result.data || {}
const aiData = ocrResult.ocr_data || {}
const fileUrl = ocrResult.file_url || `/uploads/${file.name}`
const ocrSuccess = !!(aiData.merchant || aiData.merchant_name || aiData.amount)
const merchant = aiData.merchant || aiData.merchant_name || 'AI 未识别)'
const amount = parseFloat(aiData.amount) || 0
const invoiceDate = aiData.date || new Date().toISOString().slice(0, 10)
await request.post('/api/finance/invoices', {
merchant_name: merchant, amount, invoice_date: invoiceDate,
type: invCategory.value, file_url: fileUrl, ai_extracted_data: aiData,
})
uploadQueue.value[i].status = 'success'
uploadQueue.value[i].message = ocrSuccess
? `${merchant}${amount}`
: '⚠️ 已上传,AI未能识别'
} catch (e: any) {
if (e.name === 'AbortError') break
uploadQueue.value[i].status = 'error'
uploadQueue.value[i].message = `${e.message || '处理失败'}`
}
}
uploadProcessing.value = false
uploadAbortController = null
if (!signal.aborted) {
const successCount = uploadQueue.value.filter(q => q.status === 'success').length
ElMessage.success(`批量处理完成:${successCount}/${rawFiles.length} 成功`)
fetchInvoices()
}
// 3
setTimeout(() => { uploadQueue.value = [] }, 3000)
}
//
const invDialogVisible = ref(false)
const invSubmitting = ref(false)
const invFormRef = ref<FormInstance>()
const invForm = reactive({ merchant_name: '', amount: 0, invoice_date: '', ai_json: '' })
const invRules = reactive<FormRules>({
merchant_name: [{ required: true, message: '请输入开票方', trigger: 'blur' }],
amount: [{ required: true, message: '请输入金额', trigger: 'blur' }],
})
const openAddInvoice = () => {
Object.assign(invForm, { merchant_name: '', amount: 0, invoice_date: '', ai_json: '' })
invDialogVisible.value = true
nextTick(() => invFormRef.value?.clearValidate())
}
const submitInvoice = async () => {
const valid = await invFormRef.value?.validate().catch(() => false)
if (!valid) return
invSubmitting.value = true
try {
let aiData = {}
if (invForm.ai_json.trim()) {
try { aiData = JSON.parse(invForm.ai_json) } catch { ElMessage.error('JSON 格式错误'); invSubmitting.value = false; return }
}
await request.post('/api/finance/invoices', {
merchant_name: invForm.merchant_name, amount: invForm.amount,
invoice_date: invForm.invoice_date || null, type: invCategory.value,
ai_extracted_data: aiData,
})
ElMessage.success('发票录入成功'); invDialogVisible.value = false; fetchInvoices()
} catch {}
finally { invSubmitting.value = false }
}
const voidInvoice = (row: any) => {
ElMessageBox.confirm(`确定作废「${row.merchant_name}」(¥${row.amount})`, '作废确认', { type: 'warning' })
.then(async () => { try { await request.delete(`/api/finance/invoices/${row.id}`); ElMessage.success('已作废'); fetchInvoices() } catch {} }).catch(() => {})
}
const linkOrder = () => { ElMessage.info('🔗 关联订单功能为远期规划,敬请期待') }
//
// Tab 2: AI 稿
//
const availableInvLoading = ref(false)
const availableInvList = ref<any[]>([])
const leftSelection = ref<any[]>([])
const fetchAvailableInvoices = async () => {
availableInvLoading.value = true
try {
const data: any = await request.get('/api/finance/invoices', { params: { page: 1, size: 100, is_used: false, category: 'expense' } })
availableInvList.value = data?.items || []
} catch {}
finally { availableInvLoading.value = false }
}
interface CartItem {
invoice_id: string; merchant_name: string; invoice_amount: number
expense_desc: string; expense_date: string; original_type: string; offset_type: string; amount: number
}
const cart = ref<CartItem[]>([])
const expRemark = ref('')
const expSubmitting = ref(false)
const cartTotal = computed(() => cart.value.reduce((s, r) => s + r.amount, 0))
const originalTypes = [
{ label: '办公费', value: '办公费' }, { label: '招待费', value: '招待费' },
{ label: '差旅费', value: '差旅费' }, { label: '交通费', value: '交通费' },
{ label: '物流费', value: '物流费' }, { label: '油费', value: '油费' },
{ label: '其他', value: '其他' },
]
const offsetTypes = [
{ label: '客户费用', value: '客户费用' }, { label: '税务费用', value: '税务费用' },
{ label: '工资提成', value: '工资提成' }, { label: '办公费', value: '办公费' },
{ label: '招待费', value: '招待费' }, { label: '差旅费', value: '差旅费' },
{ label: '交通费', value: '交通费' }, { label: '物流费', value: '物流费' },
{ label: '油费', value: '油费' }, { label: '福利费', value: '福利费' },
{ label: '其他', value: '其他' },
]
// AI 稿
const aiGenerating = ref(false)
const aiGenerateDraft = async () => {
if (!leftSelection.value.length) { ElMessage.warning('请先勾选需要报销的发票'); return }
aiGenerating.value = true
await new Promise(r => setTimeout(r, 800))
for (const inv of leftSelection.value) {
if (cart.value.find(c => c.invoice_id === inv.id)) continue
const ai = inv.ai_extracted_data || {}
cart.value.push({
invoice_id: inv.id,
merchant_name: ai.merchant || inv.merchant_name || '-',
invoice_amount: ai.amount || inv.amount,
expense_desc: '',
expense_date: ai.date || inv.invoice_date || new Date().toISOString().slice(0, 10),
original_type: '办公费',
offset_type: '客户费用',
amount: ai.amount || inv.amount,
})
}
aiGenerating.value = false
ElMessage.success(`🤖 已智能生成 ${leftSelection.value.length} 条报销草稿`)
}
const removeCartItem = (idx: number) => { cart.value.splice(idx, 1) }
const submitExpense = async () => {
if (!cart.value.length) { ElMessage.warning('请先勾选发票生成草稿'); return }
if (cart.value.some(r => r.amount <= 0)) { ElMessage.warning('报销金额必须大于 0'); return }
try { await ElMessageBox.confirm(`确认提交?总金额 ¥${cartTotal.value.toFixed(2)}`, '提交确认', { type: 'info' }) } catch { return }
expSubmitting.value = true
try {
await request.post('/api/finance/expenses', {
total_amount: cartTotal.value, remark: expRemark.value || null,
items: cart.value.map(r => ({
invoice_id: r.invoice_id, expense_desc: r.expense_desc || null,
expense_date: r.expense_date || null,
original_type: r.original_type, offset_type: r.offset_type, amount: r.amount,
})),
})
ElMessage.success('报销单提交成功!'); cart.value = []; expRemark.value = ''
fetchAvailableInvoices(); activeTab.value = 'expenses'; fetchExpenses()
} catch {}
finally { expSubmitting.value = false }
}
//
// Tab 3: + +
//
const expLoading = ref(false)
const expList = ref<any[]>([])
const expTotal = ref(0)
const expPage = ref(1)
const expSize = ref(20)
const expStatusFilter = ref('')
const fetchExpenses = async () => {
expLoading.value = true
try {
const params: Record<string, any> = { page: expPage.value, size: expSize.value }
if (expStatusFilter.value) params.status = expStatusFilter.value
const data: any = await request.get('/api/finance/expenses', { params })
expList.value = data?.items || []
expTotal.value = data?.total || 0
} catch {}
finally { expLoading.value = false }
}
const expDetailVisible = ref(false)
const expDetailLoading = ref(false)
const currentExp = ref<any>({})
const openExpDetail = async (row: any) => {
expDetailVisible.value = true; expDetailLoading.value = true
try { const data: any = await request.get(`/api/finance/expenses/${row.id}`); currentExp.value = data } catch {}
finally { expDetailLoading.value = false }
}
const statusLabel = (s: string) => ({ submitted: '待审批', approved: '已通过', rejected: '已驳回', voided: '已撤回' }[s] || s)
const statusTagType = (s: string) => ({ submitted: 'warning', approved: 'success', rejected: 'danger', voided: 'info' }[s] || 'info')
const canWithdraw = (row: any) => row.status === 'submitted' && row.applicant_id === userStore.userInfo?.user_id
const canApprove = (row: any) => row.status === 'submitted' && (userStore.dataScope === 'all' || userStore.dataScope === 'dept_and_sub')
const isApprover = computed(() => userStore.dataScope === 'all' || userStore.dataScope === 'dept_and_sub')
const doAction = async (expenseId: string, action: string, label: string) => {
let reason: string | null = null
try {
if (action === 'reject') {
const res: any = await ElMessageBox.prompt('请输入驳回原因', '驳回', { confirmButtonText: '确认驳回', cancelButtonText: '取消', type: 'warning' })
reason = res.value
} else {
await ElMessageBox.confirm(`确定${label}`, '操作确认', { type: action === 'approve' ? 'success' : 'warning' })
}
} catch { return }
try {
await request.put(`/api/finance/expenses/${expenseId}/status`, { action, reason })
ElMessage.success(`${label}成功`); fetchExpenses()
if (expDetailVisible.value && currentExp.value.id === expenseId) openExpDetail({ id: expenseId })
} catch {}
}
// A4 Iframe
const printExpense = async (row?: any) => {
let expData = currentExp.value
if (row && row.id && row.id !== currentExp.value.id) {
try {
const data: any = await request.get(`/api/finance/expenses/${row.id}`)
expData = data
} catch { return }
} else if (!expData.id && row && row.id) {
// maybe list row is passed directly but detail isn't open
try {
const data: any = await request.get(`/api/finance/expenses/${row.id}`)
expData = data
} catch { return }
}
if (!expData || !expData.id) {
ElMessage.warning('未能获取打印数据')
return
}
const fCurrency = (v: number) => `${v?.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`;
const sLabel = (s: string) => ({ submitted: '待审批', approved: '已通过', rejected: '已驳回', voided: '已撤回' }[s] || s);
const rowsHtml = (expData.details || []).map((d: any, i: number) => `
<tr>
<td>${i + 1}</td>
<td style="text-align:left">${d.invoice_merchant || ''}</td>
<td>${fCurrency(d.invoice_amount || 0)}</td>
<td style="text-align:left">${d.expense_desc || '-'}</td>
<td>${d.expense_date || '-'}</td>
<td>${d.original_type || '-'}</td>
<td>${d.offset_type || '-'}</td>
<td>${fCurrency(d.amount)}</td>
</tr>
`).join('');
const html = `<!DOCTYPE html>
<html>
<head>
<title>打印报销单</title>
<style>
@page { size: A4; margin: 15mm; }
body { font-family: 'SimSun', 'Songti SC', serif; color: #000; margin: 0; padding: 0; }
.print-header { text-align: center; margin-bottom: 20px; }
.print-header h1 { font-size: 24px; margin: 0 0 8px; letter-spacing: 6px; }
.print-meta { text-align: center; font-size: 13px; color: #333; margin-bottom: 20px; }
.print-meta span { margin: 0 16px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 16px; font-size: 13px; }
th, td { border: 1px solid #000; padding: 6px 10px; text-align: center; }
.info-table td:nth-child(odd) { background: #f0f0f0; font-weight: bold; width: 90px; }
th { background: #e0e0e0; }
tfoot td { border-top: 2px solid #000; }
.signatures { display: flex; justify-content: space-between; margin-top: 50px; font-weight: bold; font-size: 14px; }
</style>
</head>
<body>
<div class="print-header">
<h1>天津硕博霖报销单</h1>
<div class="print-meta">
<span>单号${expData.system_no}</span>
<span>日期${expData.created_at?.slice(0, 10)}</span>
</div>
</div>
<table class="info-table">
<tr>
<td>申请人</td><td>${expData.applicant_name}</td>
<td>报销总额</td><td>${fCurrency(expData.total_amount)}</td>
</tr>
<tr>
<td>审批状态</td><td>${sLabel(expData.status)}</td>
<td>备注</td><td>${expData.remark || '-'}</td>
</tr>
</table>
<table>
<thead>
<tr><th>#</th><th>开票方</th><th>发票金额</th><th>费用描述</th><th>发生时间</th><th>原始种类</th><th>冲顶类型</th><th>报销金额</th></tr>
</thead>
<tbody>
${rowsHtml}
</tbody>
<tfoot>
<tr>
<td colspan="7" style="text-align:right; font-weight:bold">合计</td>
<td style="font-weight:bold">${fCurrency(expData.total_amount)}</td>
</tr>
</tfoot>
</table>
<div class="signatures">
<span>本人签字___________</span>
<span>部门领导签字___________</span>
<span>财务签字___________</span>
<span>总经理签字___________</span>
</div>
</body>
</html>`;
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
document.body.appendChild(iframe);
const iframeDoc = iframe.contentWindow?.document;
if (iframeDoc) {
iframeDoc.open();
iframeDoc.write(html);
iframeDoc.close();
setTimeout(() => {
iframe.contentWindow?.focus();
iframe.contentWindow?.print();
setTimeout(() => document.body.removeChild(iframe), 1000);
}, 200);
}
}
const exportExcel = async () => {
if (!expList.value.length) { ElMessage.warning('暂无数据可导出'); return }
ElMessage.info('正在拉取明细数据,请稍候...')
const BOM = '\uFEFF'
const header = '报销单号,申请人,报销总金额,状态,提交时间,备注,明细-开票方,明细-发票金额,明细-用途,明细-发生时间,明细-原始种类,明细-冲顶类型,明细-报销额\n'
let rows = ''
for (const exp of expList.value) {
try {
const detailRes: any = await request.get(`/api/finance/expenses/${exp.id}`)
const baseInfo = `${exp.system_no},${exp.applicant_name},${exp.total_amount},${statusLabel(exp.status)},${exp.created_at},${detailRes.remark || ''}`
if (detailRes.details && detailRes.details.length > 0) {
for (const d of detailRes.details) {
rows += `${baseInfo},${d.invoice_merchant || ''},${d.invoice_amount || 0},${d.expense_desc || ''},${d.expense_date || ''},${d.original_type || ''},${d.offset_type || ''},${d.amount}\n`
}
} else {
rows += `${baseInfo},,,,,,,\n`
}
} catch {
rows += `${exp.system_no},${exp.applicant_name},${exp.total_amount},${statusLabel(exp.status)},${exp.created_at},获取明细失败,,,,,,,\n`
}
}
const blob = new Blob([BOM + header + rows], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = `报销大盘_${new Date().toISOString().slice(0, 10)}.csv`; a.click()
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
}
const formatCurrency = (v: number) => `${v?.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`
onMounted(() => { fetchInvoices() })
onBeforeUnmount(() => {
if (uploadAbortController) { uploadAbortController.abort(); uploadAbortController = null }
})
watch(activeTab, (tab) => {
if (tab === 'create') fetchAvailableInvoices()
if (tab === 'expenses') fetchExpenses()
})
</script>
<template>
<div class="finance-wrapper">
<el-card shadow="never" class="main-card hide-on-print">
<el-tabs v-model="activeTab" class="finance-tabs">
<!-- Tab 1: 统一票据池 -->
<el-tab-pane label="🎫 统一票据池" name="pool">
<!-- 报销票据池 -->
<div class="inv-sub-header" style="margin-bottom: 8px; font-size: 14px; color: #606266; font-weight: 500;">📄 报销发票池</div>
<!-- 拖拽上传区支持多文件 -->
<div class="upload-zone">
<el-upload drag :auto-upload="false" :show-file-list="false" accept=".pdf,.jpg,.jpeg,.png"
multiple @change="handleUploadChange">
<div class="upload-inner">
<el-icon style="font-size:40px; color:#409eff"><Plus /></el-icon>
<div class="upload-text">拖拽发票文件到此处 <em>点击上传</em></div>
<div class="upload-hint">支持 PDF / JPG / PNG / MD可一次选择多个文件批量上传AI 自动排队解析</div>
</div>
</el-upload>
<!-- 批量上传队列状态 -->
<div v-if="uploadQueue.length" class="upload-queue">
<div v-for="(item, idx) in uploadQueue" :key="idx" class="queue-item">
<span class="queue-name">{{ item.name }}</span>
<el-tag v-if="item.status === 'waiting'" size="small" type="info" effect="plain">等待中</el-tag>
<el-tag v-else-if="item.status === 'processing'" size="small" type="warning" effect="dark">处理中...</el-tag>
<el-tag v-else-if="item.status === 'success'" size="small" type="success" effect="dark">{{ item.message }}</el-tag>
<el-tag v-else size="small" type="danger" effect="dark">{{ item.message }}</el-tag>
</div>
</div>
<el-button v-if="!uploadQueue.length" type="primary" plain size="small" style="margin-top:8px" @click="openAddInvoice">
手动录入
</el-button>
</div>
<!-- 筛选 + 列表 -->
<div class="tab-toolbar">
<el-form :inline="true" class="filter-form">
<el-form-item label="使用状态">
<el-select v-model="invUsedFilter" clearable placeholder="全部" style="width:120px" @change="fetchInvoices">
<el-option label="未使用" value="false" /><el-option label="已报销" value="true" />
</el-select>
</el-form-item>
<el-form-item><el-button type="primary" :icon="Search" @click="fetchInvoices">查询</el-button></el-form-item>
</el-form>
</div>
<el-table :data="invList" v-loading="invLoading" stripe border style="width:100%" height="calc(100vh - 480px)">
<el-table-column prop="merchant_name" label="开票方" min-width="180" show-overflow-tooltip />
<el-table-column label="票面金额" width="130" align="right">
<template #default="{ row }"><b style="color:#e6a23c">{{ formatCurrency(row.amount) }}</b></template>
</el-table-column>
<el-table-column prop="invoice_date" label="开票日期" width="110" align="center" />
<el-table-column label="使用状态" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.is_used ? 'danger' : 'success'" effect="dark">{{ row.is_used ? '已报销' : '未使用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="AI 提取" width="180" show-overflow-tooltip>
<template #default="{ row }">
<el-popover v-if="row.ai_extracted_data && Object.keys(row.ai_extracted_data).length" trigger="hover" width="320" placement="left">
<template #reference>
<el-tag size="small" type="success" effect="plain" style="cursor:pointer">🤖 查看 AI 数据</el-tag>
</template>
<pre style="font-size:12px; white-space:pre-wrap; max-height:300px; overflow:auto">{{ JSON.stringify(row.ai_extracted_data, null, 2) }}</pre>
</el-popover>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column prop="uploader_name" label="上传人" width="90" align="center" />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button v-if="!row.is_used" type="danger" link :icon="Delete" @click="voidInvoice(row)">作废</el-button>
<span v-if="row.is_used" style="color:#c0c4cc; font-size:12px">-</span>
</template>
</el-table-column>
</el-table>
<el-pagination style="margin-top:12px; justify-content:flex-end" background
layout="total, sizes, prev, pager, next" :total="invTotal" :page-size="invSize" :current-page="invPage"
:page-sizes="[10, 20, 50]" @current-change="(p: number) => { invPage = p; fetchInvoices() }"
@size-change="(s: number) => { invSize = s; invPage = 1; fetchInvoices() }" />
</el-tab-pane>
<!-- Tab 2: 新建报销AI 一键生成草稿 -->
<el-tab-pane label="📝 新建报销" name="create">
<el-row :gutter="20">
<el-col :span="11">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px">
<h4>📋 可用发票未使用</h4>
<el-button type="success" :loading="aiGenerating" @click="aiGenerateDraft">
🤖 AI 一键生成报销草稿
</el-button>
</div>
<el-table :data="availableInvList" v-loading="availableInvLoading" stripe border height="450px"
style="width:100%" @selection-change="(val: any[]) => { leftSelection = val }">
<el-table-column type="selection" width="42" />
<el-table-column prop="merchant_name" label="开票方" min-width="140" show-overflow-tooltip />
<el-table-column label="金额" width="110" align="right">
<template #default="{ row }"><b>{{ formatCurrency(row.amount) }}</b></template>
</el-table-column>
<el-table-column prop="invoice_date" label="日期" width="100" align="center" />
<el-table-column label="AI" width="60" align="center">
<template #default="{ row }">
<el-tag v-if="row.ai_extracted_data && Object.keys(row.ai_extracted_data).length" size="small" type="success" effect="plain"></el-tag>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
</el-table>
</el-col>
<el-col :span="13">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px">
<h4>🛒 报销草稿</h4>
<el-statistic title="总金额" :value="cartTotal" :precision="2" prefix="¥" style="text-align:right" />
</div>
<el-table :data="cart" border height="340px" style="width:100%">
<el-table-column type="index" label="#" width="40" />
<el-table-column prop="merchant_name" label="开票方" min-width="110" show-overflow-tooltip />
<el-table-column label="票面" width="90" align="right">
<template #default="{ row }">{{ formatCurrency(row.invoice_amount) }}</template>
</el-table-column>
<el-table-column label="费用描述" min-width="110">
<template #default="{ row }">
<el-input v-model="row.expense_desc" size="small" placeholder="用途说明" />
</template>
</el-table-column>
<el-table-column label="发生时间" width="130">
<template #default="{ row }">
<el-date-picker v-model="row.expense_date" type="date" value-format="YYYY-MM-DD" placeholder="日期" size="small" style="width:100%" />
</template>
</el-table-column>
<el-table-column label="原始种类" width="110">
<template #default="{ row }">
<el-select v-model="row.original_type" size="small" style="width:100%">
<el-option v-for="t in originalTypes" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
</template>
</el-table-column>
<el-table-column label="冲顶类型" width="110">
<template #default="{ row }">
<el-select v-model="row.offset_type" size="small" style="width:100%">
<el-option v-for="t in offsetTypes" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
</template>
</el-table-column>
<el-table-column label="报销额" width="100">
<template #default="{ row }">
<el-input-number v-model="row.amount" :min="0.01" :max="row.invoice_amount" :precision="2" size="small" style="width:85px" />
</template>
</el-table-column>
<el-table-column label="" width="45">
<template #default="{ $index }">
<el-button type="danger" link :icon="Delete" size="small" @click="removeCartItem($index)" />
</template>
</el-table-column>
</el-table>
<el-input v-model="expRemark" type="textarea" :rows="2" placeholder="报销备注(非必填)" style="margin-top:10px" />
<el-button type="primary" size="large" :loading="expSubmitting" :disabled="!cart.length"
style="width:100%; margin-top:10px" @click="submitExpense">
提交报销{{ formatCurrency(cartTotal) }}
</el-button>
</el-col>
</el-row>
</el-tab-pane>
<!-- Tab 3: 报销大盘与审批 -->
<el-tab-pane label="📊 报销大盘" name="expenses">
<div class="tab-toolbar">
<el-form :inline="true" class="filter-form">
<el-form-item label="审批状态">
<el-select v-model="expStatusFilter" clearable placeholder="全部" style="width:130px"
@change="() => { expPage = 1; fetchExpenses() }">
<el-option label="待审批" value="submitted" /><el-option label="已通过" value="approved" />
<el-option label="已驳回" value="rejected" /><el-option label="已撤回" value="voided" />
</el-select>
</el-form-item>
<el-form-item><el-button type="primary" :icon="Search" @click="fetchExpenses">查询</el-button></el-form-item>
</el-form>
<el-button type="success" plain :icon="Download" @click="exportExcel">导出 Excel</el-button>
</div>
<el-table :data="expList" v-loading="expLoading" stripe border style="width:100%" height="calc(100vh - 350px)">
<el-table-column prop="system_no" label="报销单号" width="200" fixed="left">
<template #default="{ row }"><span class="exp-id-bold">{{ row.system_no }}</span></template>
</el-table-column>
<el-table-column prop="applicant_name" label="申请人" width="110" align="center" />
<el-table-column label="报销金额" width="130" align="right">
<template #default="{ row }"><b style="color:#e6a23c">{{ formatCurrency(row.total_amount) }}</b></template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="(statusTagType(row.status) as any)" effect="dark" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="提交时间" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button type="primary" link :icon="View" @click="openExpDetail(row)">详情</el-button>
<el-button type="primary" link :icon="Printer" @click="printExpense(row)">打印</el-button>
<el-button v-if="canWithdraw(row)" type="info" link :icon="RefreshLeft" @click="doAction(row.id, 'withdraw', '撤回')">撤回</el-button>
<el-button v-if="canApprove(row)" type="success" link :icon="Check" @click="doAction(row.id, 'approve', '审批通过')">通过</el-button>
<el-button v-if="canApprove(row)" type="danger" link :icon="Close" @click="doAction(row.id, 'reject', '驳回')">驳回</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination style="margin-top:12px; justify-content:flex-end" background
layout="total, sizes, prev, pager, next" :total="expTotal" :page-size="expSize" :current-page="expPage"
:page-sizes="[10, 20, 50]" @current-change="(p: number) => { expPage = p; fetchExpenses() }"
@size-change="(s: number) => { expSize = s; expPage = 1; fetchExpenses() }" />
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 手动录入发票弹窗 -->
<el-dialog v-model="invDialogVisible" title="手动录入发票" width="480px" destroy-on-close class="hide-on-print">
<el-form ref="invFormRef" :model="invForm" :rules="invRules" label-width="80px">
<el-form-item label="开票方" prop="merchant_name"><el-input v-model="invForm.merchant_name" /></el-form-item>
<el-form-item label="票面金额" prop="amount"><el-input-number v-model="invForm.amount" :min="0" :precision="2" style="width:100%" /></el-form-item>
<el-form-item label="开票日期"><el-date-picker v-model="invForm.invoice_date" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
<el-form-item label="AI 数据"><el-input v-model="invForm.ai_json" type="textarea" :rows="2" placeholder='JSON(可选)' /></el-form-item>
</el-form>
<template #footer>
<el-button @click="invDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="invSubmitting" @click="submitInvoice">录入</el-button>
</template>
</el-dialog>
<!-- 报销单详情 Drawer -->
<el-drawer v-model="expDetailVisible" title="报销单详情" size="700px">
<div v-loading="expDetailLoading">
<template v-if="currentExp.id">
<el-descriptions :column="2" border>
<el-descriptions-item label="报销单号"><span class="exp-id-bold">{{ currentExp.system_no }}</span></el-descriptions-item>
<el-descriptions-item label="申请人">{{ currentExp.applicant_name }}</el-descriptions-item>
<el-descriptions-item label="报销金额"><b style="color:#e6a23c">{{ formatCurrency(currentExp.total_amount) }}</b></el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="(statusTagType(currentExp.status) as any)" effect="dark" size="small">{{ statusLabel(currentExp.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="审批人">{{ currentExp.approver_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="审批时间">{{ currentExp.approved_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ currentExp.remark || '-' }}</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">📋 报销明细</el-divider>
<el-table :data="currentExp.details || []" border stripe style="width:100%">
<el-table-column prop="invoice_merchant" label="开票方" min-width="130" show-overflow-tooltip />
<el-table-column label="发票金额" width="100" align="right">
<template #default="{ row }">{{ formatCurrency(row.invoice_amount || 0) }}</template>
</el-table-column>
<el-table-column prop="original_type" label="原始种类" width="100" align="center" />
<el-table-column prop="offset_type" label="冲顶类型" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row.offset_type && row.offset_type !== '常规报销'" type="warning" size="small" effect="dark">{{ row.offset_type }}</el-tag>
<span v-else>{{ row.offset_type || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="expense_desc" label="说明" min-width="100" show-overflow-tooltip>
<template #default="{ row }">{{ row.expense_desc || '-' }}</template>
</el-table-column>
<el-table-column prop="expense_date" label="发生时间" width="110" align="center">
<template #default="{ row }">{{ row.expense_date || '-' }}</template>
</el-table-column>
<el-table-column label="报销金额" width="100" align="right">
<template #default="{ row }"><b style="color:#e6a23c">{{ formatCurrency(row.amount) }}</b></template>
</el-table-column>
</el-table>
<div style="margin-top:16px; display:flex; justify-content:space-between">
<el-button :icon="Printer" @click="printExpense">🖨 打印报销汇总单</el-button>
<div>
<el-button v-if="canWithdraw(currentExp)" :icon="RefreshLeft" @click="doAction(currentExp.id, 'withdraw', '撤回')">撤回</el-button>
<el-button v-if="canApprove(currentExp)" type="success" :icon="Check" @click="doAction(currentExp.id, 'approve', '审批通过')">通过</el-button>
<el-button v-if="canApprove(currentExp)" type="danger" :icon="Close" @click="doAction(currentExp.id, 'reject', '驳回')">驳回</el-button>
</div>
</div>
</template>
</div>
</el-drawer>
</div>
</template>
<style scoped>
.finance-wrapper { height: 100%; }
.main-card { height: calc(100vh - 100px); border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0,21,41,0.08); }
.finance-tabs { height: 100%; }
.inv-sub-tabs { margin-bottom: 8px; }
.upload-zone { background: #f5f7fa; border-radius: 8px; padding: 16px; margin-bottom: 12px; text-align: center; }
.upload-inner { padding: 12px 0; }
.upload-text { margin-top: 8px; font-size: 14px; color: #606266; }
.upload-text em { color: #409eff; font-style: normal; cursor: pointer; }
.upload-hint { font-size: 12px; color: #909399; margin-top: 4px; }
.tab-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.filter-form .el-form-item { margin-bottom: 0; }
.exp-id-bold { font-family: Consolas, 'Courier New', monospace; font-weight: bold; color: #409eff; }
.upload-queue { margin-top: 10px; text-align: left; }
.queue-item { display: flex; justify-content: space-between; align-items: center; padding: 6px 12px; border-bottom: 1px solid #f0f0f0; }
.queue-item:last-child { border-bottom: none; }
.queue-name { font-size: 13px; color: #606266; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 300px; }
</style>
+124
View File
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Search, Document, Plus, Refresh } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import request from '@/api/request'
import { useRouter } from 'vue-router'
const router = useRouter()
const loading = ref(false)
const logs = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const size = ref(20)
const keyword = ref('')
const dateRange = ref<string[]>([])
const fetchLogs = async () => {
loading.value = true
try {
const params: Record<string, any> = { page: page.value, size: size.value }
if (keyword.value) params.keyword = keyword.value
if (dateRange.value?.length === 2) {
params.start_date = dateRange.value[0]
params.end_date = dateRange.value[1]
}
const data: any = await request.get('/api/sales-logs', { params })
logs.value = data?.items || []
total.value = data?.total || 0
} catch {
ElMessage.error('销售日志加载失败')
} finally {
loading.value = false
}
}
const handleSearch = () => { page.value = 1; fetchLogs() }
const handlePageChange = (p: number) => { page.value = p; fetchLogs() }
onMounted(fetchLogs)
</script>
<template>
<div class="sales-logs-container">
<!-- 搜索栏 -->
<el-card shadow="never" class="filter-card">
<el-row :gutter="12" align="middle">
<el-col :span="8">
<el-input
v-model="keyword"
placeholder="搜索日志内容..."
:prefix-icon="Search"
clearable
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
</el-col>
<el-col :span="8">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
@change="handleSearch"
style="width: 100%"
/>
</el-col>
<el-col :span="8">
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button :icon="Refresh" @click="keyword = ''; dateRange = []; handleSearch()">重置</el-button>
<el-button type="success" :icon="Plus" @click="router.push('/logs?action=new')">写日志</el-button>
</el-col>
</el-row>
</el-card>
<!-- 日志列表 -->
<el-card shadow="never" class="list-card">
<el-table :data="logs" v-loading="loading" stripe>
<el-table-column prop="log_date" label="日期" width="120" />
<el-table-column prop="customer_name" label="关联客户" width="180">
<template #default="{ row }">
{{ row.customer_name || '未关联' }}
</template>
</el-table-column>
<el-table-column prop="content" label="日志内容" show-overflow-tooltip />
<el-table-column prop="author_name" label="记录人" width="100" />
<el-table-column prop="created_at" label="创建时间" width="160">
<template #default="{ row }">
{{ row.created_at?.slice(0, 16)?.replace('T', ' ') }}
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > size">
<el-pagination
layout="total, prev, pager, next"
:total="total"
:page-size="size"
:current-page="page"
@current-change="handlePageChange"
/>
</div>
</el-card>
</div>
</template>
<style scoped>
.sales-logs-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.filter-card, .list-card {
border-radius: 8px;
border: none;
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
+541
View File
@@ -0,0 +1,541 @@
<script setup lang="ts">
/**
* 订单管理 全真实 API 驱动
* 列表 + 开单 + 详情含发货记录 timeline+ 安排发货弹窗防超发
*/
import { ref, reactive, computed, onMounted, nextTick, watch } from 'vue'
import { Search, Plus, View, Delete, Van } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/api/request'
//
const loading = ref(false)
const orderList = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const searchForm = reactive({ keyword: '', shipping_state: '', payment_state: '' })
const fetchOrders = async () => {
loading.value = true
try {
const params: Record<string, any> = { page: page.value, size: pageSize.value }
if (searchForm.keyword) params.keyword = searchForm.keyword
if (searchForm.shipping_state) params.shipping_state = searchForm.shipping_state
if (searchForm.payment_state) params.payment_state = searchForm.payment_state
const data: any = await request.get('/api/orders', { params })
orderList.value = data?.items || []
total.value = data?.total || 0
} catch {}
finally { loading.value = false }
}
const handleSearch = () => { page.value = 1; fetchOrders() }
const handlePageChange = (p: number) => { page.value = p; fetchOrders() }
const handleSizeChange = (s: number) => { pageSize.value = s; page.value = 1; fetchOrders() }
const shippingTagType = (s: string) => ({ pending: 'info', partial: 'warning', shipped: 'success' }[s] || 'info')
const shippingLabel = (s: string) => ({ pending: '待发货', partial: '部分发货', shipped: '已发货' }[s] || s)
const paymentTagType = (s: string) => ({ unpaid: 'danger', partial: 'warning', cleared: 'success' }[s] || 'info')
const paymentLabel = (s: string) => ({ unpaid: '未收款', partial: '部分收款', cleared: '已结清' }[s] || s)
const formatCurrency = (v: number) => `${v?.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`
//
const detailVisible = ref(false)
const detailLoading = ref(false)
const currentOrder = ref<any>({})
const activeDetailTab = ref('items')
const shippingHistory = ref<any[]>([])
const shippingHistoryLoading = ref(false)
const openDetail = async (row: any) => {
activeDetailTab.value = 'items'
detailVisible.value = true
detailLoading.value = true
shippingHistory.value = []
try {
const data: any = await request.get(`/api/orders/${row.id}`)
currentOrder.value = data
//
fetchShippingHistory(row.id)
} catch {}
finally { detailLoading.value = false }
}
const fetchShippingHistory = async (orderId: string) => {
shippingHistoryLoading.value = true
try {
const data: any = await request.get(`/api/shipping/order/${orderId}`)
shippingHistory.value = data?.shipments || []
} catch {}
finally { shippingHistoryLoading.value = false }
}
//
const createVisible = ref(false)
const createSubmitting = ref(false)
const createFormRef = ref<FormInstance>()
const customerOptions = ref<any[]>([])
const customerLoading = ref(false)
const fetchCustomers = async (q?: string) => {
customerLoading.value = true
try {
const params: Record<string, any> = { page: 1, size: 50 }
if (q) params.keyword = q
const data: any = await request.get('/api/customers', { params })
customerOptions.value = data?.items || []
} catch {}
finally { customerLoading.value = false }
}
const skuOptions = ref<any[]>([])
const fetchSkus = async () => {
try {
const data: any = await request.get('/api/products/skus', { params: { page: 1, size: 100 } })
skuOptions.value = data?.items || []
} catch {}
}
interface OrderRow {
sku_id: string; sku_name: string; spec: string; unit_price: number
qty: number; subtotal: number; price_source: string; price_hint: string
}
const orderForm = reactive({ customer_id: '', remark: '', items: [] as OrderRow[] })
const createRules = reactive<FormRules>({ customer_id: [{ required: true, message: '请选择客户', trigger: 'change' }] })
const totalAmount = computed(() => orderForm.items.reduce((s, r) => s + r.subtotal, 0))
const openCreateOrder = async () => {
orderForm.customer_id = ''; orderForm.remark = ''; orderForm.items = []
addRow()
createVisible.value = true
await fetchCustomers(); await fetchSkus()
nextTick(() => createFormRef.value?.clearValidate())
}
const addRow = () => {
orderForm.items.push({ sku_id: '', sku_name: '', spec: '', unit_price: 0, qty: 1, subtotal: 0, price_source: '', price_hint: '' })
}
const removeRow = (idx: number) => {
if (orderForm.items.length <= 1) { ElMessage.warning('至少保留一个明细行'); return }
orderForm.items.splice(idx, 1)
}
const handleSkuChange = async (skuId: string, idx: number) => {
const row = orderForm.items[idx]
const sku = skuOptions.value.find((s: any) => s.id === skuId)
if (sku) { row.sku_name = sku.name; row.spec = sku.spec || '' }
if (!orderForm.customer_id || !skuId) {
row.unit_price = sku?.standard_price || 0; row.price_source = 'standard'; row.price_hint = '指导价(未选客户)'
updateSubtotal(idx); return
}
try {
const data: any = await request.get('/api/orders/price/calculate', { params: { customer_id: orderForm.customer_id, sku_id: skuId } })
row.unit_price = data.unit_price; row.price_source = data.price_source
row.price_hint = data.price_source === 'history' ? `历史专享价(来自 ${data.last_order_no}` : '标准指导价'
} catch { row.unit_price = sku?.standard_price || 0; row.price_source = 'standard'; row.price_hint = '指导价(定价服务异常)' }
updateSubtotal(idx)
}
const handleCustomerChange = () => { orderForm.items.forEach((r, i) => { if (r.sku_id) handleSkuChange(r.sku_id, i) }) }
const updateSubtotal = (idx: number) => { const r = orderForm.items[idx]; r.subtotal = Math.round(r.qty * r.unit_price * 100) / 100 }
const submitOrder = async () => {
const valid = await createFormRef.value?.validate().catch(() => false)
if (!valid) return
if (!orderForm.items.some(r => r.sku_id)) { ElMessage.warning('请至少添加一个产品明细行'); return }
if (orderForm.items.some(r => !r.sku_id)) { ElMessage.warning('有未选择产品的明细行'); return }
createSubmitting.value = true
try {
await request.post('/api/orders', {
customer_id: orderForm.customer_id, remark: orderForm.remark || null,
items: orderForm.items.map(r => ({ sku_id: r.sku_id, qty: r.qty, unit_price: r.unit_price })),
})
ElMessage.success('订单创建成功!'); createVisible.value = false; fetchOrders()
} catch {}
finally { createSubmitting.value = false }
}
//
const shipDialogVisible = ref(false)
const shipSubmitting = ref(false)
const shipFormRef = ref<FormInstance>()
const shipOrder = ref<any>({})
interface ShipRow {
order_item_id: string; sku_id: string; sku_code: string; sku_name: string
qty: number; shipped_qty: number; remaining: number; ship_qty: number
overLimit: boolean
}
const shipForm = reactive({
carrier: '',
tracking_no: '',
remark: '',
items: [] as ShipRow[],
})
const canSubmitShip = computed(() => {
return shipForm.items.some(r => r.ship_qty > 0) && !shipForm.items.some(r => r.overLimit)
})
const openShipDialog = async (row: any) => {
// items
try {
const data: any = await request.get(`/api/orders/${row.id}`)
shipOrder.value = data
shipForm.carrier = ''
shipForm.tracking_no = ''
shipForm.remark = ''
shipForm.items = (data.items || []).map((item: any) => {
const remaining = item.qty - (item.shipped_qty || 0)
return {
order_item_id: item.id,
sku_id: item.sku_id,
sku_code: item.sku_code,
sku_name: item.sku_name,
qty: item.qty,
shipped_qty: item.shipped_qty || 0,
remaining,
ship_qty: remaining > 0 ? remaining : 0,
overLimit: false,
}
})
shipDialogVisible.value = true
} catch {}
}
const validateShipQty = (idx: number) => {
const row = shipForm.items[idx]
row.overLimit = row.ship_qty > row.remaining || row.ship_qty < 0
}
const submitShipping = async () => {
const activeItems = shipForm.items.filter(r => r.ship_qty > 0)
if (!activeItems.length) { ElMessage.warning('请至少填写一行发货数量'); return }
if (shipForm.items.some(r => r.overLimit)) { ElMessage.error('存在超发行,请检查'); return }
try {
await ElMessageBox.confirm(
`确认对订单「${shipOrder.value.order_no}」执行发货?(${activeItems.length} 个明细行)`,
'发货确认', { type: 'warning' }
)
} catch { return }
shipSubmitting.value = true
try {
await request.post('/api/shipping', {
order_id: shipOrder.value.id,
carrier: shipForm.carrier || null,
tracking_no: shipForm.tracking_no || null,
remark: shipForm.remark || null,
items: activeItems.map(r => ({
order_item_id: r.order_item_id,
sku_id: r.sku_id,
shipped_qty: r.ship_qty,
})),
})
ElMessage.success('发货成功,库存已同步扣减')
shipDialogVisible.value = false
fetchOrders()
//
if (detailVisible.value && currentOrder.value.id === shipOrder.value.id) {
openDetail(shipOrder.value)
}
} catch {}
finally { shipSubmitting.value = false }
}
// Init
onMounted(fetchOrders)
</script>
<template>
<div class="order-management-wrapper">
<!-- 1. 顶部筛选 -->
<el-card shadow="never" class="filter-card">
<div class="filter-wrapper">
<el-form :inline="true" :model="searchForm" class="filter-form">
<el-form-item label="订单号">
<el-input v-model="searchForm.keyword" placeholder="模糊搜索" clearable style="width:180px" @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="发货状态">
<el-select v-model="searchForm.shipping_state" clearable placeholder="全部" style="width:120px">
<el-option label="待发货" value="pending" /><el-option label="部分发货" value="partial" /><el-option label="已发货" value="shipped" />
</el-select>
</el-form-item>
<el-form-item label="收款状态">
<el-select v-model="searchForm.payment_state" clearable placeholder="全部" style="width:120px">
<el-option label="未收款" value="unpaid" /><el-option label="部分收款" value="partial" /><el-option label="已结清" value="cleared" />
</el-select>
</el-form-item>
<el-form-item><el-button type="primary" :icon="Search" @click="handleSearch">检索</el-button></el-form-item>
</el-form>
<el-button type="primary" :icon="Plus" size="large" @click="openCreateOrder">新建订单</el-button>
</div>
</el-card>
<!-- 2. 订单列表 -->
<el-card shadow="never" class="table-card">
<el-table :data="orderList" v-loading="loading" stripe border style="width:100%" height="calc(100vh - 290px)">
<el-table-column prop="order_no" label="订单编号" width="200" fixed="left">
<template #default="{ row }"><span class="order-id-bold">{{ row.order_no }}</span></template>
</el-table-column>
<el-table-column prop="customer_name" label="客户名称" min-width="200" show-overflow-tooltip />
<el-table-column label="订单金额" width="140" align="right">
<template #default="{ row }"><b style="color:#e6a23c">{{ formatCurrency(row.total_amount) }}</b></template>
</el-table-column>
<el-table-column label="发货状态" width="120" align="center">
<template #default="{ row }">
<el-tag :type="(shippingTagType(row.shipping_state) as any)" effect="dark" size="small">{{ shippingLabel(row.shipping_state) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="收款状态" width="120" align="center">
<template #default="{ row }">
<el-tag :type="(paymentTagType(row.payment_state) as any)" size="small">{{ paymentLabel(row.payment_state) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="order_date" label="下单日期" width="120" align="center" />
<el-table-column prop="salesperson_name" label="业务员" width="100" align="center" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="primary" link :icon="View" @click="openDetail(row)">详情</el-button>
<el-button type="success" link :icon="Van" @click="openShipDialog(row)" :disabled="row.shipping_state === 'shipped'">发货</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination style="margin-top:16px; justify-content:flex-end" background layout="total, sizes, prev, pager, next"
:total="total" :page-size="pageSize" :current-page="page" :page-sizes="[10, 20, 50]"
@current-change="handlePageChange" @size-change="handleSizeChange" />
</el-card>
<!-- 3. 沉浸式开单抽屉 -->
<el-drawer v-model="createVisible" title="✨ 新建订单" size="85%" destroy-on-close>
<el-form ref="createFormRef" :model="orderForm" :rules="createRules" label-width="80px">
<el-row :gutter="20">
<el-col :span="10">
<el-form-item label="选择客户" prop="customer_id">
<el-select v-model="orderForm.customer_id" filterable remote reserve-keyword
:remote-method="fetchCustomers" :loading="customerLoading" placeholder="搜索客户名称"
style="width:100%" @change="handleCustomerChange">
<el-option v-for="c in customerOptions" :key="c.id" :label="c.name" :value="c.id">
<span>{{ c.name }}</span><span style="float:right; color:#909399; font-size:12px">{{ c.level }}</span>
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="10">
<el-form-item label="备注"><el-input v-model="orderForm.remark" placeholder="订单备注(非必填)" /></el-form-item>
</el-col>
<el-col :span="4" style="text-align:right; padding-top:2px">
<el-statistic title="订单总额" :value="totalAmount" :precision="2" prefix="¥" />
</el-col>
</el-row>
<el-divider content-position="left">📦 订单明细</el-divider>
<el-table :data="orderForm.items" border style="width:100%">
<el-table-column type="index" label="#" width="50" />
<el-table-column label="选择产品" min-width="260">
<template #default="{ row, $index }">
<el-select v-model="row.sku_id" filterable placeholder="搜索 SKU / 产品名" style="width:100%"
@change="(val: string) => handleSkuChange(val, $index)">
<el-option v-for="s in skuOptions" :key="s.id" :label="`${s.sku_code} — ${s.name}`" :value="s.id">
<span>{{ s.sku_code }}</span><span style="margin-left:8px; color:#606266">{{ s.name }}</span>
<span style="float:right; color:#909399; font-size:12px">库存:{{ s.stock_qty }}</span>
</el-option>
</el-select>
</template>
</el-table-column>
<el-table-column label="规格" width="100"><template #default="{ row }">{{ row.spec || '-' }}</template></el-table-column>
<el-table-column label="单价" width="160">
<template #default="{ row, $index }">
<el-input-number v-model="row.unit_price" :min="0" :precision="2" size="small" style="width:120px" @change="updateSubtotal($index)" />
<el-tooltip v-if="row.price_hint" :content="row.price_hint" placement="top">
<el-tag :type="row.price_source === 'history' ? 'success' : 'info'" size="small" effect="plain" style="margin-left:4px; cursor:help">
{{ row.price_source === 'history' ? '专享' : '指导' }}
</el-tag>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="数量" width="120">
<template #default="{ row, $index }">
<el-input-number v-model="row.qty" :min="0.01" :precision="2" size="small" style="width:100px" @change="updateSubtotal($index)" />
</template>
</el-table-column>
<el-table-column label="小计" width="120" align="right">
<template #default="{ row }"><b style="color:#e6a23c">{{ formatCurrency(row.subtotal) }}</b></template>
</el-table-column>
<el-table-column label="" width="60">
<template #default="{ $index }"><el-button type="danger" link :icon="Delete" @click="removeRow($index)" /></template>
</el-table-column>
</el-table>
<el-button type="primary" plain :icon="Plus" style="width:100%; margin-top:12px" @click="addRow">添加产品行</el-button>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" size="large" :loading="createSubmitting" @click="submitOrder">
确认开单总额 {{ formatCurrency(totalAmount) }}
</el-button>
</template>
</el-drawer>
<!-- 4. 订单详情抽屉含发货轨迹 tab -->
<el-drawer v-model="detailVisible" title="订单全景详情" size="750px">
<div v-loading="detailLoading">
<template v-if="currentOrder.id">
<el-descriptions :column="2" border>
<el-descriptions-item label="订单编号"><span class="order-id-bold">{{ currentOrder.order_no }}</span></el-descriptions-item>
<el-descriptions-item label="客户">{{ currentOrder.customer_name }}</el-descriptions-item>
<el-descriptions-item label="业务员">{{ currentOrder.salesperson_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="下单日期">{{ currentOrder.order_date }}</el-descriptions-item>
<el-descriptions-item label="订单金额"><b style="color:#e6a23c">{{ formatCurrency(currentOrder.total_amount) }}</b></el-descriptions-item>
<el-descriptions-item label="已收款">{{ formatCurrency(currentOrder.paid_amount || 0) }}</el-descriptions-item>
<el-descriptions-item label="发货状态">
<el-tag :type="(shippingTagType(currentOrder.shipping_state) as any)" effect="dark" size="small">{{ shippingLabel(currentOrder.shipping_state) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="收款状态">
<el-tag :type="(paymentTagType(currentOrder.payment_state) as any)" size="small">{{ paymentLabel(currentOrder.payment_state) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ currentOrder.remark || '-' }}</el-descriptions-item>
</el-descriptions>
<el-tabs v-model="activeDetailTab" style="margin-top:16px">
<!-- Tab 1: 商品明细 -->
<el-tab-pane label="📦 商品明细" name="items">
<el-table :data="currentOrder.items || []" border stripe style="width:100%">
<el-table-column prop="sku_code" label="SKU" width="180"><template #default="{ row }"><span class="sku-bold">{{ row.sku_code }}</span></template></el-table-column>
<el-table-column prop="sku_name" label="产品名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="spec" label="规格" width="100" />
<el-table-column label="单价" width="110" align="right"><template #default="{ row }">{{ formatCurrency(row.unit_price) }}</template></el-table-column>
<el-table-column prop="qty" label="订购" width="70" align="center" />
<el-table-column label="已发" width="70" align="center">
<template #default="{ row }">
<span :style="{ color: row.shipped_qty >= row.qty ? '#67c23a' : '#e6a23c', fontWeight: 'bold' }">{{ row.shipped_qty }}</span>
</template>
</el-table-column>
<el-table-column label="小计" width="110" align="right"><template #default="{ row }"><b>{{ formatCurrency(row.sub_total) }}</b></template></el-table-column>
</el-table>
</el-tab-pane>
<!-- Tab 2: 发货记录 -->
<el-tab-pane label="🚛 发货记录" name="shipping">
<div v-loading="shippingHistoryLoading">
<el-empty v-if="!shippingHistory.length" description="暂无发货记录" />
<el-timeline v-else>
<el-timeline-item
v-for="ship in shippingHistory" :key="ship.id"
:timestamp="ship.ship_date" placement="top"
:color="ship.status === 'delivered' ? '#67c23a' : '#409eff'"
>
<el-card shadow="hover" class="timeline-card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px">
<span class="order-id-bold">{{ ship.shipping_no }}</span>
<el-tag size="small" :type="ship.status === 'delivered' ? 'success' : 'primary'" effect="dark">
{{ ship.status === 'delivered' ? '已签收' : '运输中' }}
</el-tag>
</div>
<p style="margin:4px 0; font-size:13px; color:#606266">物流{{ ship.carrier || '-' }} | 单号{{ ship.tracking_no || '-' }}</p>
<p style="margin:4px 0; font-size:13px; color:#606266">操作人{{ ship.operator_name || '-' }}</p>
<el-table :data="ship.items" border size="small" style="margin-top:8px">
<el-table-column prop="sku_code" label="SKU" width="140" />
<el-table-column prop="sku_name" label="产品" min-width="130" />
<el-table-column prop="spec" label="规格" width="100">
<template #default="{ row }">{{ row.spec || '-' }}</template>
</el-table-column>
<el-table-column label="发货数量" width="100" align="center">
<template #default="{ row }">{{ row.shipped_qty }} {{ row.unit || '' }}</template>
</el-table-column>
</el-table>
</el-card>
</el-timeline-item>
</el-timeline>
</div>
</el-tab-pane>
</el-tabs>
<!-- 详情页快速发货按钮 -->
<div v-if="currentOrder.shipping_state !== 'shipped'" style="margin-top:16px; text-align:right">
<el-button type="success" :icon="Van" @click="openShipDialog(currentOrder)">安排发货</el-button>
</div>
</template>
</div>
</el-drawer>
<!-- 5. 安排发货弹窗防超发 -->
<el-dialog v-model="shipDialogVisible" title="🚛 安排发货" width="750px" destroy-on-close>
<el-alert
:title="`订单 ${shipOrder.order_no} — ${shipOrder.customer_name}`"
type="info" show-icon :closable="false" style="margin-bottom:16px"
/>
<el-form label-width="80px">
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="物流公司"><el-input v-model="shipForm.carrier" placeholder="如 德邦物流" /></el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="物流单号"><el-input v-model="shipForm.tracking_no" placeholder="快递/物流单号" /></el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="备注"><el-input v-model="shipForm.remark" placeholder="非必填" /></el-form-item>
</el-col>
</el-row>
</el-form>
<el-divider content-position="left">📦 发货明细防超发校验</el-divider>
<el-table :data="shipForm.items" border style="width:100%">
<el-table-column prop="sku_code" label="SKU" width="160">
<template #default="{ row }"><span class="sku-bold">{{ row.sku_code }}</span></template>
</el-table-column>
<el-table-column prop="sku_name" label="产品" min-width="160" show-overflow-tooltip />
<el-table-column label="购买量" width="80" align="center"><template #default="{ row }">{{ row.qty }}</template></el-table-column>
<el-table-column label="已发" width="70" align="center">
<template #default="{ row }"><span style="color:#67c23a; font-weight:bold">{{ row.shipped_qty }}</span></template>
</el-table-column>
<el-table-column label="可发余量" width="80" align="center">
<template #default="{ row }"><span style="color:#e6a23c; font-weight:bold">{{ row.remaining }}</span></template>
</el-table-column>
<el-table-column label="本次发货" width="130">
<template #default="{ row, $index }">
<el-input-number
v-model="row.ship_qty"
:min="0" :max="row.remaining" :precision="2"
size="small" style="width:110px"
:class="{ 'over-limit': row.overLimit }"
@change="validateShipQty($index)"
/>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.remaining <= 0" type="success" size="small">已发完</el-tag>
<el-tag v-else-if="row.overLimit" type="danger" size="small">超发!</el-tag>
<el-tag v-else-if="row.ship_qty > 0" type="primary" size="small" effect="plain">待发</el-tag>
<el-tag v-else type="info" size="small" effect="plain">跳过</el-tag>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="shipDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="shipSubmitting" :disabled="!canSubmitShip" @click="submitShipping">
确认发货
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.order-management-wrapper { display: flex; flex-direction: column; gap: 16px; height: 100%; }
.filter-card { border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0,21,41,0.08); }
.filter-wrapper { display: flex; justify-content: space-between; align-items: center; }
.filter-form .el-form-item { margin-bottom: 0; }
.table-card { flex: 1; border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0,21,41,0.08); }
.order-id-bold { font-family: Consolas, 'Courier New', monospace; font-weight: bold; color: #409eff; }
.sku-bold { font-family: Consolas, 'Courier New', monospace; font-weight: bold; color: #409eff; }
.timeline-card { border-radius: 6px; }
.timeline-card p { line-height: 1.4; }
.over-limit :deep(.el-input__inner) { color: #f56c6c !important; border-color: #f56c6c !important; }
</style>
+594
View File
@@ -0,0 +1,594 @@
<script setup lang="ts">
/**
* 产品与库存管理 全真实 API 驱动
* 左树右表 + 分类 CRUD + 新增/编辑 SKU + 库存变更原子事务
*/
import { ref, reactive, onMounted, nextTick } from 'vue'
import { Search, Plus, View, Box, Edit, Delete, CirclePlus, Upload } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/api/request'
//
const treeData = ref<any[]>([])
const currentCategoryId = ref<string | null>(null)
const treeProps = { children: 'children', label: 'name' }
const fetchCategoryTree = async () => {
try {
const data: any = await request.get('/api/products/categories/tree')
treeData.value = Array.isArray(data) ? data : []
} catch { /* 已统一 */ }
}
const handleNodeClick = (data: any) => {
currentCategoryId.value = data.id
fetchSkus()
}
const clearCategoryFilter = () => {
currentCategoryId.value = null
fetchSkus()
}
// CRUD
const catDialogVisible = ref(false)
const catDialogTitle = ref('新建分类')
const catSubmitting = ref(false)
const catFormRef = ref<FormInstance>()
const isCatEdit = ref(false)
const editCatId = ref('')
const catForm = reactive({ name: '', parent_id: null as string | null })
const catRules = reactive<FormRules>({
name: [{ required: true, message: '请输入分类名称', trigger: 'blur' }],
})
const openAddTopCategory = () => {
isCatEdit.value = false
catDialogTitle.value = '新建顶级分类'
editCatId.value = ''
catForm.name = ''
catForm.parent_id = null
catDialogVisible.value = true
nextTick(() => catFormRef.value?.clearValidate())
}
const openAddChildCategory = (parentNode: any) => {
isCatEdit.value = false
catDialogTitle.value = `新建子分类(上级:${parentNode.name}`
editCatId.value = ''
catForm.name = ''
catForm.parent_id = parentNode.id
catDialogVisible.value = true
nextTick(() => catFormRef.value?.clearValidate())
}
const openEditCategory = (node: any) => {
isCatEdit.value = true
catDialogTitle.value = '编辑分类'
editCatId.value = node.id
catForm.name = node.name
catForm.parent_id = node.parent_id || null
catDialogVisible.value = true
nextTick(() => catFormRef.value?.clearValidate())
}
const submitCategory = async () => {
const valid = await catFormRef.value?.validate().catch(() => false)
if (!valid) return
catSubmitting.value = true
try {
if (isCatEdit.value) {
await request.put(`/api/products/categories/${editCatId.value}`, { name: catForm.name })
ElMessage.success('分类已更新')
} else {
const payload: any = { name: catForm.name }
if (catForm.parent_id) payload.parent_id = catForm.parent_id
await request.post('/api/products/categories', payload)
ElMessage.success('分类创建成功')
}
catDialogVisible.value = false
await fetchCategoryTree()
} catch { /* 已统一 */ }
finally { catSubmitting.value = false }
}
const deleteCategory = (node: any) => {
ElMessageBox.confirm(`确定删除分类「${node.name}」吗?`, '删除确认', { type: 'warning' })
.then(async () => {
try {
await request.delete(`/api/products/categories/${node.id}`)
ElMessage.success('分类已删除')
if (currentCategoryId.value === node.id) {
currentCategoryId.value = null
fetchSkus()
}
await fetchCategoryTree()
} catch { /* 已统一 */ }
})
.catch(() => {})
}
// SKU
const loading = ref(false)
const skuList = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const keyword = ref('')
const fetchSkus = async () => {
loading.value = true
try {
const params: Record<string, any> = { page: page.value, size: pageSize.value }
if (currentCategoryId.value) params.category_id = currentCategoryId.value
if (keyword.value.trim()) params.keyword = keyword.value.trim()
const data: any = await request.get('/api/products/skus', { params })
skuList.value = data?.items || []
total.value = data?.total || 0
} catch { /* 已统一 */ }
finally { loading.value = false }
}
const handleSearch = () => { page.value = 1; fetchSkus() }
const handlePageChange = (p: number) => { page.value = p; fetchSkus() }
const handleSizeChange = (s: number) => { pageSize.value = s; page.value = 1; fetchSkus() }
// / SKU
const skuDialogVisible = ref(false)
const skuDialogTitle = ref('新增产品')
const skuSubmitting = ref(false)
const skuFormRef = ref<FormInstance>()
const isEditMode = ref(false)
const editSkuId = ref('')
const skuForm = reactive({
sku_code: '',
name: '',
category_id: '' as string | null,
spec: '',
standard_price: 0,
stock_qty: 0,
warning_threshold: 0,
unit: '桶',
status: 1,
})
const skuRules = reactive<FormRules>({
sku_code: [{ required: true, message: '请输入 SKU 编码', trigger: 'blur' }],
name: [{ required: true, message: '请输入产品名称', trigger: 'blur' }],
})
const openAddSku = () => {
isEditMode.value = false
skuDialogTitle.value = '新增产品'
editSkuId.value = ''
Object.assign(skuForm, {
sku_code: '', name: '', category_id: currentCategoryId.value || null,
spec: '', standard_price: 0, stock_qty: 0, warning_threshold: 0, unit: '桶', status: 1
})
skuDialogVisible.value = true
nextTick(() => skuFormRef.value?.clearValidate())
}
const openEditSku = (row: any) => {
isEditMode.value = true
skuDialogTitle.value = '编辑产品'
editSkuId.value = row.id
Object.assign(skuForm, {
sku_code: row.sku_code, name: row.name, category_id: row.category_id || null,
spec: row.spec || '', standard_price: row.standard_price, stock_qty: row.stock_qty,
warning_threshold: row.warning_threshold, unit: row.unit || '桶', status: row.status
})
skuDialogVisible.value = true
nextTick(() => skuFormRef.value?.clearValidate())
}
const submitSku = async () => {
const valid = await skuFormRef.value?.validate().catch(() => false)
if (!valid) return
skuSubmitting.value = true
try {
if (isEditMode.value) {
// stock_qty sku_code
const { stock_qty, sku_code, ...editData } = skuForm
await request.put(`/api/products/skus/${editSkuId.value}`, editData)
ElMessage.success('产品信息已更新')
} else {
await request.post('/api/products/skus', skuForm)
ElMessage.success('产品创建成功')
}
skuDialogVisible.value = false
fetchSkus()
} catch { /* 已统一 */ }
finally { skuSubmitting.value = false }
}
//
const detailVisible = ref(false)
const currentDetail = ref<any>({})
const openDetail = (row: any) => { currentDetail.value = row; detailVisible.value = true }
// SKU
const importDialogVisible = ref(false)
const getUploadHeaders = () => {
return { Authorization: `Bearer ${localStorage.getItem('crm_token') || ''}` }
}
const handleImportSuccess = (response: any) => {
if (response.code === 200) {
ElMessage.success(response.message || '导入成功')
importDialogVisible.value = false
fetchSkus()
} else {
ElMessage.error(response.message || '导入失败')
}
}
const handleImportError = () => { ElMessage.error('上传失败,请检查网络或后端服务') }
//
const invDialogVisible = ref(false)
const invSubmitting = ref(false)
const invFormRef = ref<FormInstance>()
const currentInvSku = ref<any>({})
const invForm = reactive({
direction: 'in' as 'in' | 'out',
qty: 1,
reason: 'purchase',
remark: '',
})
const invRules = reactive<FormRules>({
qty: [{ required: true, message: '请输入变更数量', trigger: 'blur' }],
reason: [{ required: true, message: '请选择变动原因', trigger: 'change' }],
})
const inReasons = [
{ label: '厂家进货', value: 'purchase' },
{ label: '盘点盘盈', value: 'adjust' },
{ label: '客户退货', value: 'return' },
]
const outReasons = [
{ label: '发货出库', value: 'shipment' },
{ label: '送样损耗', value: 'loss' },
{ label: '盘点盘亏', value: 'adjust' },
]
const openInventory = (row: any) => {
currentInvSku.value = row
Object.assign(invForm, { direction: 'in', qty: 1, reason: 'purchase', remark: '' })
invDialogVisible.value = true
nextTick(() => invFormRef.value?.clearValidate())
}
const submitInventory = async () => {
const valid = await invFormRef.value?.validate().catch(() => false)
if (!valid) return
const changeQty = invForm.direction === 'in' ? invForm.qty : -invForm.qty
try {
await ElMessageBox.confirm(
`确认对「${currentInvSku.value.name}${invForm.direction === 'in' ? '入库' : '出库'} ${invForm.qty} ${currentInvSku.value.unit || '件'}`,
'库存变更确认', { type: 'warning' }
)
} catch { return }
invSubmitting.value = true
try {
await request.post('/api/products/inventory/flow', {
sku_id: currentInvSku.value.id,
change_qty: changeQty,
reason: invForm.reason,
remark: invForm.remark || null,
})
ElMessage.success('库存变更成功')
invDialogVisible.value = false
fetchSkus()
} catch { /* 已统一 */ }
finally { invSubmitting.value = false }
}
// Init
onMounted(async () => {
await fetchCategoryTree()
await fetchSkus()
})
</script>
<template>
<div class="product-management-wrapper">
<el-row :gutter="20" class="page-row">
<!-- 1. 左侧分类树 -->
<el-col :span="5" class="left-col">
<el-card shadow="never" class="tree-card">
<template #header>
<div class="card-header">
<span class="tree-title">产品目录</span>
<el-button type="primary" :icon="Plus" link @click="openAddTopCategory">新建</el-button>
</div>
</template>
<el-button v-if="currentCategoryId" link type="primary" size="small" style="margin-bottom:8px" @click="clearCategoryFilter">
清除分类筛选
</el-button>
<el-tree
:data="treeData"
:props="treeProps"
node-key="id"
@node-click="handleNodeClick"
default-expand-all
highlight-current
class="filter-tree"
>
<template #default="{ data }">
<span class="tree-node">
<span class="tree-node-label">{{ data.name }}</span>
<span class="tree-node-actions">
<el-icon class="tree-action-icon" @click.stop="openAddChildCategory(data)"><CirclePlus /></el-icon>
<el-icon class="tree-action-icon" @click.stop="openEditCategory(data)"><Edit /></el-icon>
<el-icon class="tree-action-icon danger" @click.stop="deleteCategory(data)"><Delete /></el-icon>
</span>
</span>
</template>
</el-tree>
<el-empty v-if="!treeData.length" description="暂无分类,请先新建" :image-size="60" />
</el-card>
</el-col>
<!-- 2 & 3. 右侧主体区 -->
<el-col :span="19" class="right-col">
<!-- 检索区 -->
<el-card shadow="never" class="filter-card">
<div class="filter-wrapper">
<el-form :inline="true" class="filter-form">
<el-form-item label="模糊搜索">
<el-input v-model="keyword" placeholder="SKU编码 / 产品名称" clearable style="width:280px" @keyup.enter="handleSearch" />
</el-form-item>
</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">导入SKU</el-button>
<el-button type="success" :icon="Plus" @click="openAddSku">新增产品</el-button>
</div>
</div>
</el-card>
<!-- 表格区 -->
<el-card shadow="never" class="table-card">
<el-table :data="skuList" v-loading="loading" stripe border height="calc(100vh - 320px)" style="width:100%">
<el-table-column prop="sku_code" label="产品编码 (SKU)" width="180" fixed="left">
<template #default="{ row }">
<span class="sku-bold">{{ row.sku_code }}</span>
</template>
</el-table-column>
<el-table-column prop="name" label="中文名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="category_name" label="分类" width="130" />
<el-table-column prop="spec" label="规格" width="120" align="center" />
<el-table-column label="指导价" width="120" align="right">
<template #default="{ row }">
{{ row.standard_price?.toFixed(2) }}
</template>
</el-table-column>
<el-table-column label="当前库存" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row.stock_qty <= row.warning_threshold && row.warning_threshold > 0" type="danger" effect="dark" size="small">
<b>{{ row.stock_qty }}</b>
</el-tag>
<span v-else class="normal-stock">{{ row.stock_qty }}</span>
</template>
</el-table-column>
<el-table-column prop="unit" label="单位" width="70" align="center" />
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button type="primary" link :icon="View" @click="openDetail(row)">详情</el-button>
<el-button type="warning" link :icon="Box" @click="openInventory(row)">库存变更</el-button>
<el-button type="primary" link :icon="Edit" @click="openEditSku(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top:16px; justify-content:flex-end"
background layout="total, sizes, prev, pager, next"
:total="total" :page-size="pageSize" :current-page="page" :page-sizes="[10, 20, 50]"
@current-change="handlePageChange" @size-change="handleSizeChange"
/>
</el-card>
</el-col>
</el-row>
<!-- 分类新增/编辑弹窗 -->
<el-dialog v-model="catDialogVisible" :title="catDialogTitle" width="420px" destroy-on-close>
<el-form ref="catFormRef" :model="catForm" :rules="catRules" label-width="80px">
<el-form-item label="分类名称" prop="name">
<el-input v-model="catForm.name" placeholder="如 工业润滑油" />
</el-form-item>
<el-form-item label="上级分类">
<el-tree-select
v-model="catForm.parent_id"
:data="treeData"
:props="{ children: 'children', label: 'name', value: 'id' }"
node-key="id"
check-strictly
clearable
placeholder="不选则为顶级分类"
style="width:100%"
:disabled="!isCatEdit"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="catDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="catSubmitting" @click="submitCategory">
{{ isCatEdit ? '保存' : '新建' }}
</el-button>
</template>
</el-dialog>
<!-- 新增/编辑 SKU 弹窗 -->
<el-dialog v-model="skuDialogVisible" :title="skuDialogTitle" width="560px" destroy-on-close>
<el-form ref="skuFormRef" :model="skuForm" :rules="skuRules" label-width="100px">
<el-form-item label="SKU 编码" prop="sku_code">
<el-input v-model="skuForm.sku_code" :disabled="isEditMode" placeholder="如 LUB-HYD-46-200L" />
</el-form-item>
<el-form-item label="产品名称" prop="name">
<el-input v-model="skuForm.name" placeholder="如 长城卓力 AE 46 号抗磨液压油" />
</el-form-item>
<el-form-item label="所属分类">
<el-tree-select
v-model="skuForm.category_id"
:data="treeData"
:props="{ children: 'children', label: 'name', value: 'id' }"
node-key="id"
check-strictly
clearable
placeholder="请选择分类"
style="width:100%"
/>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="包装规格">
<el-input v-model="skuForm.spec" placeholder="如 200L/桶" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="计量单位">
<el-input v-model="skuForm.unit" placeholder="桶 / 件 / KG" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="指导价">
<el-input-number v-model="skuForm.standard_price" :min="0" :precision="2" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="预警阈值">
<el-input-number v-model="skuForm.warning_threshold" :min="0" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item v-if="!isEditMode" label="初始库存">
<el-input-number v-model="skuForm.stock_qty" :min="0" style="width:100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="skuDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="skuSubmitting" @click="submitSku">
{{ isEditMode ? '保存修改' : '确认新增' }}
</el-button>
</template>
</el-dialog>
<!-- 详情抽屉 -->
<el-drawer v-model="detailVisible" title="产品详细档案" size="420px">
<el-descriptions :column="1" border direction="vertical">
<el-descriptions-item label="产品编码 (SKU)">
<span class="sku-bold">{{ currentDetail.sku_code }}</span>
</el-descriptions-item>
<el-descriptions-item label="中文名称">{{ currentDetail.name }}</el-descriptions-item>
<el-descriptions-item label="所属分类">{{ currentDetail.category_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="包装规格">{{ currentDetail.spec || '-' }}</el-descriptions-item>
<el-descriptions-item label="标准指导价">
<b>{{ currentDetail.standard_price?.toFixed(2) }}</b>
</el-descriptions-item>
<el-descriptions-item label="当前库存">
{{ currentDetail.stock_qty }} {{ currentDetail.unit }}
</el-descriptions-item>
<el-descriptions-item label="预警阈值">{{ currentDetail.warning_threshold }} {{ currentDetail.unit }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ currentDetail.created_at }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ currentDetail.updated_at }}</el-descriptions-item>
</el-descriptions>
</el-drawer>
<!-- 库存变更弹窗 -->
<el-dialog v-model="invDialogVisible" title="库存变更" width="500px" destroy-on-close>
<el-alert
:title="`当前操作:${currentInvSku.name}${currentInvSku.sku_code})— 现有库存 ${currentInvSku.stock_qty} ${currentInvSku.unit || '件'}`"
type="info" show-icon :closable="false" style="margin-bottom:20px"
/>
<el-form ref="invFormRef" :model="invForm" :rules="invRules" label-width="90px">
<el-form-item label="变更方向">
<el-radio-group v-model="invForm.direction" @change="invForm.reason = invForm.direction === 'in' ? 'purchase' : 'shipment'">
<el-radio-button value="in">📦 入库</el-radio-button>
<el-radio-button value="out">📤 出库</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="变动原因" prop="reason">
<el-select v-model="invForm.reason" style="width:100%">
<template v-if="invForm.direction === 'in'">
<el-option v-for="r in inReasons" :key="r.value" :label="r.label" :value="r.value" />
</template>
<template v-else>
<el-option v-for="r in outReasons" :key="r.value" :label="r.label" :value="r.value" />
</template>
</el-select>
</el-form-item>
<el-form-item label="变更数量" prop="qty">
<el-input-number v-model="invForm.qty" :min="1" :max="99999" controls-position="right" style="width:100%" />
</el-form-item>
<el-form-item label="操作备注">
<el-input v-model="invForm.remark" type="textarea" :rows="2" placeholder="非必填,记录特殊单号或说明" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="invDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="invSubmitting" @click="submitInventory">确认变更</el-button>
</template>
</el-dialog>
<!-- 批量导入弹窗 -->
<el-dialog v-model="importDialogVisible" title="批量导入产品 SKU" width="400px" destroy-on-close>
<div style="margin-bottom: 20px; line-height: 1.6;">
<p>1. 请先下载标准模板按要求填写数据</p>
<p>2. 仅支持 .xlsx 格式文件</p>
<p>3. 系统将根据 SKU 编码自动防重</p>
<p style="margin-top: 10px;">
<a href="/api/templates/product_import_template.xlsx" target="_blank" style="color: #409eff; text-decoration: none;">
点击下载产品导入模板.xlsx
</a>
</p>
</div>
<el-upload
drag
action="/api/products/import"
:headers="getUploadHeaders()"
accept=".xlsx,.xls"
:show-file-list="false"
:on-success="handleImportSuccess"
:on-error="handleImportError"
>
<el-icon class="el-icon--upload"><Upload /></el-icon>
<div class="el-upload__text">将文件拖到此处 <em>点击上传</em></div>
</el-upload>
</el-dialog>
</div>
</template>
<style scoped>
.product-management-wrapper { height: 100%; }
.page-row { height: 100%; }
.tree-card { height: calc(100vh - 100px); border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0,21,41,0.08); overflow-y: auto; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
.card-header .tree-title { font-weight: bold; font-size: 15px; color: #303133; }
.filter-tree { margin-top: -10px; }
/* 树节点 — 悬浮显示操作按钮 */
.tree-node { display: flex; align-items: center; justify-content: space-between; width: 100%; padding-right: 4px; }
.tree-node-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tree-node-actions { display: none; flex-shrink: 0; margin-left: 6px; }
.tree-node:hover .tree-node-actions { display: inline-flex; gap: 4px; }
.tree-action-icon { font-size: 14px; cursor: pointer; color: #909399; transition: color 0.2s; }
.tree-action-icon:hover { color: #409eff; }
.tree-action-icon.danger:hover { color: #f56c6c; }
.right-col { display: flex; flex-direction: column; gap: 15px; }
.filter-card { border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0,21,41,0.08); }
.filter-wrapper { display: flex; justify-content: space-between; align-items: center; }
.filter-form .el-form-item { margin-bottom: 0; }
.table-card { flex: 1; border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0,21,41,0.08); }
.sku-bold { font-family: Consolas, 'Courier New', monospace; font-weight: bold; color: #409eff; }
.normal-stock { font-weight: bold; color: #606266; }
</style>
+517
View File
@@ -0,0 +1,517 @@
<script setup lang="ts">
/**
* 系统设置页 完整 CRUD 交互
* Tab1: 部门树 + 员工管理新增/编辑弹窗重置密码启停
* Tab2: 角色管理新增/编辑弹窗 + 权限分配面板 + JSONB menu_keys
*/
import { ref, reactive, onMounted, nextTick } from 'vue'
import { Plus, Edit, Key, Lock, Setting, User, Operation, Check } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import request from '@/api/request'
const activeTab = ref('users')
const loading = ref(false)
// ==========================================
// Tab 1:
// ==========================================
// --- ---
const orgTreeData = ref<any[]>([])
const defaultProps = { children: 'children', label: 'name' }
const currentDeptId = ref<string | null>(null)
const currentDeptName = ref('全部部门')
const fetchDeptTree = async () => {
try {
const data = await request.get('/api/settings/departments/tree')
orgTreeData.value = Array.isArray(data) ? data : []
} catch (e) { console.warn('[Settings] fetchDeptTree error:', e) }
}
const handleNodeClick = (data: any) => {
currentDeptId.value = data.id
currentDeptName.value = data.name
fetchUsers()
}
// --- el-select 使---
const flatDepts = ref<any[]>([])
const flattenDepts = (nodes: any[], result: any[] = [], depth = 0) => {
for (const n of nodes) {
result.push({ id: n.id, name: ' '.repeat(depth) + n.name })
if (n.children?.length) flattenDepts(n.children, result, depth + 1)
}
return result
}
// --- ---
const userSearchInput = ref('')
const userList = ref<any[]>([])
const userTotal = ref(0)
const userPage = ref(1)
const userPageSize = ref(20)
const fetchUsers = async () => {
loading.value = true
try {
const params: Record<string, any> = { page: userPage.value, size: userPageSize.value }
if (currentDeptId.value) params.dept_id = currentDeptId.value
if (userSearchInput.value) params.keyword = userSearchInput.value
const data: any = await request.get('/api/settings/users', { params })
userList.value = (data?.items || []).map((u: any) => ({ ...u, status: u.status === 1 }))
userTotal.value = data?.total || 0
} catch (e) { console.warn('[Settings] fetchUsers error:', e) }
finally { loading.value = false }
}
const handleUserSearch = () => { userPage.value = 1; fetchUsers() }
const toggleUserStatus = async (row: any) => {
try {
const newStatus = row.status ? 1 : 0
await request.put(`/api/settings/users/${row.id}`, { status: newStatus })
ElMessage.success(`账号 [${row.username}] 已${row.status ? '启用' : '停用'}`)
} catch { row.status = !row.status }
}
const resetPassword = (row: any) => {
ElMessageBox.confirm(
`确定要将员工 [${row.real_name || row.username}] 的密码重置为默认密码 (123456) 吗?`, '警告', { type: 'warning' }
).then(async () => {
try {
await request.put(`/api/settings/users/${row.id}/reset-password`, { new_password: '123456' })
ElMessage.success(`员工 [${row.real_name || row.username}] 密码已重置!`)
} catch { /* 已统一 */ }
}).catch(() => {})
}
// --- / ---
const userDialogVisible = ref(false)
const userDialogTitle = ref('新增员工')
const userFormRef = ref<FormInstance>()
const userSubmitting = ref(false)
const userForm = reactive({
id: '' as string,
username: '',
password: '',
real_name: '',
phone: '',
email: '',
dept_id: null as string | null,
role_id: null as string | null,
})
const isEditUser = ref(false)
const userFormRules = reactive<FormRules>({
username: [{ required: true, message: '请输入登录账号', trigger: 'blur' }],
password: [{ required: true, message: '请输入初始密码', trigger: 'blur' }, { min: 6, message: '密码至少6位', trigger: 'blur' }],
real_name: [{ required: true, message: '请输入员工姓名', trigger: 'blur' }],
})
const openAddUser = () => {
isEditUser.value = false
userDialogTitle.value = '新增员工'
Object.assign(userForm, { id: '', username: '', password: '', real_name: '', phone: '', email: '', dept_id: null, role_id: null })
userDialogVisible.value = true
nextTick(() => userFormRef.value?.clearValidate())
}
const openEditUser = (row: any) => {
isEditUser.value = true
userDialogTitle.value = '编辑员工'
Object.assign(userForm, {
id: row.id,
username: row.username,
password: '',
real_name: row.real_name || '',
phone: row.phone || '',
email: row.email || '',
dept_id: row.dept_id || null,
role_id: row.role_id || null,
})
userDialogVisible.value = true
nextTick(() => userFormRef.value?.clearValidate())
}
const submitUser = async () => {
const valid = await userFormRef.value?.validate().catch(() => false)
if (!valid) return
userSubmitting.value = true
try {
if (isEditUser.value) {
const { id, password, username, ...payload } = userForm
await request.put(`/api/settings/users/${id}`, payload)
ElMessage.success('员工信息已更新')
} else {
await request.post('/api/settings/users', userForm)
ElMessage.success('员工账号创建成功')
}
userDialogVisible.value = false
fetchUsers()
} catch { /* 已统一 */ }
finally { userSubmitting.value = false }
}
// ==========================================
// Tab 2:
// ==========================================
const rolesList = ref<any[]>([])
const currentRole = ref<any>({})
const fetchRoles = async () => {
try {
const data = await request.get('/api/settings/roles')
const arr = Array.isArray(data) ? data : []
rolesList.value = arr.map((r: any) => ({
id: r.id, name: r.role_name, desc: r.description || '',
data_scope: r.data_scope, menu_keys: r.menu_keys || [], status: r.status,
}))
if (rolesList.value.length > 0 && !currentRole.value?.id) {
selectRole(rolesList.value[0])
}
} catch (e) { console.warn('[Settings] fetchRoles error:', e) }
}
const selectRole = (role: any) => {
currentRole.value = role
dataScope.value = role.data_scope || 'self'
// menu_keys 访
nextTick(() => {
try { menuTreeRef.value?.setCheckedKeys(role.menu_keys || [], false) } catch { /* tree not ready */ }
})
}
// --- ---
const dataScopeOptions = [
{ label: '全部数据 (最高权限)', value: 'all' },
{ label: '本部门及下属部门数据', value: 'dept_and_sub' },
{ label: '仅本人数据', value: 'self' }
]
const dataScope = ref('all')
const menuPermissionsData = [
{ id: 'dashboard', label: '🏠 工作台 (Dashboard)' },
{
id: 'sales', label: '💼 业务线 (Sales)',
children: [
{ id: 'CustomerList', label: '👥 客户管理' },
{ id: 'OrderList', label: '📄 订单管理' },
{ id: 'ShippingList', label: '🚚 发货记录' },
]
},
{ id: 'supply', label: '📦 供应链 (Supply)', children: [{ id: 'ProductList', label: '📦 产品与库存' }] },
{ id: 'finance', label: '💰 财务管理 (Finance)', children: [{ id: 'FinanceList', label: '🎫 发票与报销' }] },
{ id: 'settings', label: '⚙️ 系统设置 (Settings)' },
]
const menuTreeRef = ref<any>(null)
const savePermissions = async () => {
if (!currentRole.value?.id) return
try {
const checkedKeys = menuTreeRef.value?.getCheckedKeys(false) || []
await request.put(`/api/settings/roles/${currentRole.value.id}`, {
data_scope: dataScope.value,
menu_keys: checkedKeys,
})
currentRole.value.data_scope = dataScope.value
currentRole.value.menu_keys = checkedKeys
ElMessage.success(`角色 [${currentRole.value.name}] 的权限配置保存成功!`)
} catch { /* 已统一 */ }
}
// --- / ---
const roleDialogVisible = ref(false)
const roleDialogTitle = ref('新增角色')
const roleFormRef = ref<FormInstance>()
const roleSubmitting = ref(false)
const roleForm = reactive({
id: '' as string,
role_name: '',
data_scope: 'self',
description: '',
})
const isEditRole = ref(false)
const roleFormRules = reactive<FormRules>({
role_name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
})
const openAddRole = () => {
isEditRole.value = false
roleDialogTitle.value = '新增角色'
Object.assign(roleForm, { id: '', role_name: '', data_scope: 'self', description: '' })
roleDialogVisible.value = true
nextTick(() => roleFormRef.value?.clearValidate())
}
const openEditRole = () => {
if (!currentRole.value?.id) return
isEditRole.value = true
roleDialogTitle.value = '编辑角色'
Object.assign(roleForm, {
id: currentRole.value.id,
role_name: currentRole.value.name,
data_scope: currentRole.value.data_scope || 'self',
description: currentRole.value.desc || '',
})
roleDialogVisible.value = true
nextTick(() => roleFormRef.value?.clearValidate())
}
const submitRole = async () => {
const valid = await roleFormRef.value?.validate().catch(() => false)
if (!valid) return
roleSubmitting.value = true
try {
if (isEditRole.value) {
const { id, ...payload } = roleForm
await request.put(`/api/settings/roles/${id}`, payload)
ElMessage.success('角色信息已更新')
} else {
const { id, ...payload } = roleForm
await request.post('/api/settings/roles', payload)
ElMessage.success('角色创建成功')
}
roleDialogVisible.value = false
await fetchRoles()
} catch { /* 已统一 */ }
finally { roleSubmitting.value = false }
}
// ==========================================
//
// ==========================================
onMounted(async () => {
// await
await fetchDeptTree()
flatDepts.value = flattenDepts(orgTreeData.value)
await fetchUsers()
await fetchRoles()
})
</script>
<template>
<div class="settings-wrapper">
<el-card shadow="never" class="main-card">
<el-tabs v-model="activeTab" class="settings-tabs">
<!-- ============================================== -->
<!-- Tab 1: 部门与员工管理 -->
<!-- ============================================== -->
<el-tab-pane name="users">
<template #label>
<span class="custom-tabs-label"><el-icon><User /></el-icon><span>部门与员工管理</span></span>
</template>
<el-row :gutter="20">
<!-- 左侧部门树 -->
<el-col :span="5">
<el-card shadow="never" class="tree-card">
<template #header><div class="card-header-bold">组织架构</div></template>
<el-tree :data="orgTreeData" :props="defaultProps" default-expand-all highlight-current node-key="id" @node-click="handleNodeClick" />
</el-card>
</el-col>
<!-- 右侧员工表 -->
<el-col :span="19">
<el-card shadow="never" class="user-table-card">
<div class="table-toolbar">
<div class="toolbar-left">
<span class="dept-title">{{ currentDeptName }} 员工名单</span>
</div>
<div class="toolbar-right">
<el-input v-model="userSearchInput" placeholder="姓名 / 手机号" prefix-icon="Search" style="width: 200px; margin-right: 15px;" clearable @keyup.enter="handleUserSearch" @clear="handleUserSearch" />
<el-button type="primary" :icon="Plus" @click="openAddUser">新增员工</el-button>
</div>
</div>
<el-table :data="userList" stripe border style="width: 100%" v-loading="loading">
<el-table-column prop="real_name" label="员工姓名" width="120">
<template #default="scope"><b>{{ scope.row.real_name || '-' }}</b></template>
</el-table-column>
<el-table-column prop="username" label="登录账号" width="140" />
<el-table-column prop="dept_name" label="所属部门" width="150" />
<el-table-column label="分配角色" min-width="160">
<template #default="scope">
<el-tag :type="scope.row.role_name?.includes('管理') ? 'danger' : 'primary'" effect="plain" v-if="scope.row.role_name">{{ scope.row.role_name }}</el-tag>
<span v-else style="color: #909399;">未分配</span>
</template>
</el-table-column>
<el-table-column label="数据权限" width="130">
<template #default="scope">
<el-tag :type="scope.row.data_scope === 'all' ? 'danger' : scope.row.data_scope === 'dept_and_sub' ? 'warning' : 'info'" size="small" v-if="scope.row.data_scope">
{{ scope.row.data_scope === 'all' ? '全部' : scope.row.data_scope === 'dept_and_sub' ? '本部门' : '仅本人' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="登录状态" width="100" align="center">
<template #default="scope">
<el-switch v-model="scope.row.status" style="--el-switch-on-color: #13ce66; --el-switch-off-color: #ff4949" @change="toggleUserStatus(scope.row)" />
</template>
</el-table-column>
<el-table-column label="安全与操作" width="220" align="center" fixed="right">
<template #default="scope">
<el-button type="primary" link :icon="Edit" @click="openEditUser(scope.row)">编辑</el-button>
<el-button type="danger" link :icon="Key" @click="resetPassword(scope.row)">重置密码</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination v-if="userTotal > userPageSize" style="margin-top: 16px; justify-content: flex-end;" background layout="total, prev, pager, next" :total="userTotal" :page-size="userPageSize" v-model:current-page="userPage" @current-change="fetchUsers" />
</el-card>
</el-col>
</el-row>
</el-tab-pane>
<!-- ============================================== -->
<!-- Tab 2: 角色与权限控制 -->
<!-- ============================================== -->
<el-tab-pane name="roles">
<template #label>
<span class="custom-tabs-label"><el-icon><Lock /></el-icon><span>角色与权限控制 (RBAC)</span></span>
</template>
<el-row :gutter="20">
<!-- 左侧角色列表 -->
<el-col :span="6">
<el-card shadow="never" class="roles-card">
<template #header>
<div style="display:flex; justify-content:space-between; align-items:center;">
<span class="card-header-bold">平台运营角色</span>
<el-button type="primary" link :icon="Plus" @click="openAddRole">新增角色</el-button>
</div>
</template>
<div class="role-list">
<div v-for="role in rolesList" :key="role.id" class="role-item" :class="{ 'is-active': currentRole.id === role.id }" @click="selectRole(role)">
<div class="role-name">{{ role.name }}</div>
<div class="role-desc">{{ role.desc }}</div>
</div>
<el-empty v-if="rolesList.length === 0" description="暂无角色" :image-size="60" />
</div>
</el-card>
</el-col>
<!-- 右侧权限分配面板 -->
<el-col :span="18">
<el-card shadow="never" class="perm-card">
<template #header>
<div class="perm-header">
<div class="card-header-bold">
角色配置<span style="color:#409EFF">{{ currentRole.name || '请选择角色' }}</span>
<el-button v-if="currentRole.id" type="primary" link :icon="Edit" style="margin-left: 10px;" @click="openEditRole">编辑基础信息</el-button>
</div>
<el-button type="success" :icon="Check" @click="savePermissions" :disabled="!currentRole.id">保存当前权限设置</el-button>
</div>
</template>
<div class="perm-section">
<div class="section-title"><el-icon><Operation /></el-icon> 数据权限穿透范围 (Data Scope)</div>
<el-form label-position="top">
<el-form-item>
<el-select v-model="dataScope" style="width: 400px">
<el-option v-for="item in dataScopeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<div class="tip-text">控制该角色在查看客户订单发票等数据时的横向与纵向范围限制</div>
</el-form-item>
</el-form>
</div>
<el-divider />
<div class="perm-section">
<div class="section-title"><el-icon><Setting /></el-icon> 侧边栏模块与按钮级访问权限 (Menu Permissions)</div>
<el-tree ref="menuTreeRef" :data="menuPermissionsData" show-checkbox default-expand-all node-key="id" :default-checked-keys="currentRole.menu_keys || []" class="perm-tree" />
</div>
</el-card>
</el-col>
</el-row>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- ============================================== -->
<!-- 弹窗新增/编辑员工 -->
<!-- ============================================== -->
<el-dialog v-model="userDialogVisible" :title="userDialogTitle" width="520px" destroy-on-close>
<el-form ref="userFormRef" :model="userForm" :rules="userFormRules" label-width="80px">
<el-form-item label="登录账号" prop="username">
<el-input v-model="userForm.username" placeholder="用于登录系统" :disabled="isEditUser" />
</el-form-item>
<el-form-item label="初始密码" prop="password" v-if="!isEditUser">
<el-input v-model="userForm.password" type="password" show-password placeholder="至少6位" />
</el-form-item>
<el-form-item label="员工姓名" prop="real_name">
<el-input v-model="userForm.real_name" placeholder="真实姓名" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="userForm.phone" placeholder="手机号码" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="userForm.email" placeholder="邮箱地址" />
</el-form-item>
<el-form-item label="所属部门">
<el-select v-model="userForm.dept_id" placeholder="选择部门" clearable style="width: 100%">
<el-option v-for="d in flatDepts" :key="d.id" :label="d.name" :value="d.id" />
</el-select>
</el-form-item>
<el-form-item label="分配角色">
<el-select v-model="userForm.role_id" placeholder="选择角色" clearable style="width: 100%">
<el-option v-for="r in rolesList" :key="r.id" :label="r.name" :value="r.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="userDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="userSubmitting" @click="submitUser">{{ isEditUser ? '保存修改' : '确定创建' }}</el-button>
</template>
</el-dialog>
<!-- ============================================== -->
<!-- 弹窗新增/编辑角色 -->
<!-- ============================================== -->
<el-dialog v-model="roleDialogVisible" :title="roleDialogTitle" width="480px" destroy-on-close>
<el-form ref="roleFormRef" :model="roleForm" :rules="roleFormRules" label-width="80px">
<el-form-item label="角色名称" prop="role_name">
<el-input v-model="roleForm.role_name" placeholder="如:工业油销售总监" />
</el-form-item>
<el-form-item label="数据权限">
<el-select v-model="roleForm.data_scope" style="width: 100%">
<el-option v-for="item in dataScopeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="角色描述">
<el-input v-model="roleForm.description" type="textarea" :rows="2" placeholder="可选描述" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="roleDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="roleSubmitting" @click="submitRole">{{ isEditRole ? '保存修改' : '确定创建' }}</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.settings-wrapper { height: 100%; }
.main-card { border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); min-height: calc(100vh - 100px); }
.settings-tabs :deep(.el-tabs__item) { font-size: 15px; height: 50px; line-height: 50px; }
.custom-tabs-label { display: flex; align-items: center; gap: 6px; font-weight: bold; }
.card-header-bold { font-weight: bold; font-size: 15px; color: #303133; }
.tree-card, .user-table-card, .roles-card, .perm-card { border-radius: 6px; min-height: 600px; }
.table-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.dept-title { font-size: 16px; font-weight: bold; color: #409EFF; border-left: 4px solid #409EFF; padding-left: 10px; }
.toolbar-right { display: flex; align-items: center; }
.role-list { display: flex; flex-direction: column; gap: 10px; }
.role-item { padding: 15px; border: 1px solid #EBEEF5; border-radius: 4px; cursor: pointer; transition: all 0.3s; }
.role-item:hover { box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1); }
.role-item.is-active { border-color: #409EFF; background-color: #ecf5ff; }
.role-name { font-weight: bold; font-size: 14px; margin-bottom: 6px; color: #303133; }
.role-desc { font-size: 12px; color: #909399; }
.perm-header { display: flex; justify-content: space-between; align-items: center; }
.section-title { font-size: 15px; font-weight: bold; margin-bottom: 15px; display: flex; align-items: center; gap: 8px; color: #303133; }
.tip-text { font-size: 12px; color: #909399; margin-top: 8px; }
.perm-tree { margin-top: 10px; background: #f8f9fa; padding: 15px; border-radius: 4px; border: 1px solid #ebeef5; }
</style>
+164
View File
@@ -0,0 +1,164 @@
<script setup lang="ts">
/**
* 发货大盘 全真实 API 驱动
* GET /api/shipping 列表 + 详情查看
*/
import { ref, reactive, onMounted } from 'vue'
import { Search, View } from '@element-plus/icons-vue'
import request from '@/api/request'
//
const loading = ref(false)
const shippingList = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const searchForm = reactive({ order_no: '', tracking_no: '' })
const fetchList = async () => {
loading.value = true
try {
const params: Record<string, any> = { page: page.value, size: pageSize.value }
if (searchForm.order_no) params.order_no = searchForm.order_no
if (searchForm.tracking_no) params.tracking_no = searchForm.tracking_no
const data: any = await request.get('/api/shipping', { params })
shippingList.value = data?.items || []
total.value = data?.total || 0
} catch {}
finally { loading.value = false }
}
const handleSearch = () => { page.value = 1; fetchList() }
const handlePageChange = (p: number) => { page.value = p; fetchList() }
const handleSizeChange = (s: number) => { pageSize.value = s; page.value = 1; fetchList() }
const statusTagType = (s: string) => ({ transit: 'primary', delivered: 'success', pending: 'info' }[s] || 'info')
const statusLabel = (s: string) => ({ transit: '运输中', delivered: '已签收', pending: '待揽收' }[s] || s)
//
const detailVisible = ref(false)
const detailLoading = ref(false)
const currentShip = ref<any>({})
const openDetail = async (row: any) => {
detailVisible.value = true
detailLoading.value = true
try {
//
const data: any = await request.get(`/api/shipping/order/${row.order_id}`)
const matched = (data?.shipments || []).find((s: any) => s.id === row.id)
currentShip.value = matched || row
} catch {}
finally { detailLoading.value = false }
}
// Init
onMounted(fetchList)
</script>
<template>
<div class="shipping-wrapper">
<!-- 1. 筛选 -->
<el-card shadow="never" class="filter-card">
<div class="filter-wrapper">
<el-form :inline="true" :model="searchForm" class="filter-form">
<el-form-item label="订单号">
<el-input v-model="searchForm.order_no" placeholder="模糊搜索" clearable style="width:180px" @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="物流单号">
<el-input v-model="searchForm.tracking_no" placeholder="搜索快递单号" clearable style="width:180px" @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">检索</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
<!-- 2. 发货单列表 -->
<el-card shadow="never" class="table-card">
<el-table :data="shippingList" v-loading="loading" stripe border style="width:100%" height="calc(100vh - 250px)">
<el-table-column prop="shipping_no" label="发货单号" width="200" fixed="left">
<template #default="{ row }"><span class="shipping-id-bold">{{ row.shipping_no }}</span></template>
</el-table-column>
<el-table-column prop="order_no" label="关联订单" width="200">
<template #default="{ row }"><span class="order-id">{{ row.order_no }}</span></template>
</el-table-column>
<el-table-column prop="customer_name" label="客户名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="carrier" label="物流公司" width="140">
<template #default="{ row }">{{ row.carrier || '-' }}</template>
</el-table-column>
<el-table-column prop="tracking_no" label="物流单号" width="180">
<template #default="{ row }">{{ row.tracking_no || '-' }}</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="(statusTagType(row.status) as any)" effect="dark" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="ship_date" label="发货日期" width="120" align="center" />
<el-table-column prop="operator_name" label="操作人" width="100" align="center" />
<el-table-column prop="created_at" label="创建时间" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button type="primary" link :icon="View" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
style="margin-top:16px; justify-content:flex-end" background
layout="total, sizes, prev, pager, next"
:total="total" :page-size="pageSize" :current-page="page" :page-sizes="[10, 20, 50]"
@current-change="handlePageChange" @size-change="handleSizeChange"
/>
</el-card>
<!-- 3. 发货详情 Drawer -->
<el-drawer v-model="detailVisible" title="发货单详情" size="550px">
<div v-loading="detailLoading">
<template v-if="currentShip.id">
<el-descriptions :column="2" border>
<el-descriptions-item label="发货单号"><span class="shipping-id-bold">{{ currentShip.shipping_no }}</span></el-descriptions-item>
<el-descriptions-item label="关联订单">{{ currentShip.order_no || '-' }}</el-descriptions-item>
<el-descriptions-item label="客户名称">{{ currentShip.customer_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="物流公司">{{ currentShip.carrier || '-' }}</el-descriptions-item>
<el-descriptions-item label="物流单号">{{ currentShip.tracking_no || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="(statusTagType(currentShip.status) as any)" effect="dark" size="small">{{ statusLabel(currentShip.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="发货日期">{{ currentShip.ship_date }}</el-descriptions-item>
<el-descriptions-item label="操作人">{{ currentShip.operator_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ currentShip.remark || '-' }}</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">📦 发货明细</el-divider>
<el-table :data="currentShip.items || []" border stripe style="width:100%">
<el-table-column prop="sku_code" label="SKU" width="180">
<template #default="{ row }"><span class="sku-bold">{{ row.sku_code }}</span></template>
</el-table-column>
<el-table-column prop="sku_name" label="产品名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="spec" label="包装规格" width="120">
<template #default="{ row }">{{ row.spec || '-' }}</template>
</el-table-column>
<el-table-column label="发货数量" width="110" align="center">
<template #default="{ row }">{{ row.shipped_qty }} {{ row.unit || '' }}</template>
</el-table-column>
</el-table>
</template>
</div>
</el-drawer>
</div>
</template>
<style scoped>
.shipping-wrapper { display: flex; flex-direction: column; gap: 16px; height: 100%; }
.filter-card { border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0,21,41,0.08); }
.filter-wrapper { display: flex; justify-content: space-between; align-items: center; }
.filter-form .el-form-item { margin-bottom: 0; }
.table-card { flex: 1; border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0,21,41,0.08); }
.shipping-id-bold { font-family: Consolas, 'Courier New', monospace; font-weight: bold; color: #67c23a; }
.order-id { font-family: Consolas, 'Courier New', monospace; color: #409eff; }
.sku-bold { font-family: Consolas, 'Courier New', monospace; font-weight: bold; color: #409eff; }
</style>
+35
View File
@@ -0,0 +1,35 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
}
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.vue"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"composite": true,
"declaration": true,
"declarationMap": true,
"strict": true,
"skipLibCheck": true
},
"include": [
"vite.config.ts"
]
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'), // @ 指向 src 目录
},
},
server: {
port: 5173,
// 开发环境代理:将 /api 请求转发到后端,避免 CORS 问题
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
},
},
})
+57
View File
@@ -0,0 +1,57 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# ---------- 前端静态资源 ----------
location / {
try_files $uri $uri/ /index.html;
# HTML 文件不缓存
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Pragma "no-cache" always;
}
# ---------- SSE 长连接专用(AI 复盘报告等)----------
location /api/reports/generate {
proxy_pass http://backend:8000/api/reports/generate;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection '';
# SSE 必须: 禁用缓冲
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
# LLM 生成可能需要 5-10 分钟
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
# ---------- 后端 API 反向代理 ----------
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 放宽超时
proxy_read_timeout 300s;
proxy_send_timeout 120s;
# 文件上传大小限制
client_max_body_size 50m;
}
# ---------- 静态资源缓存(带 hash 的 JS/CSS 长缓存) ----------
location /assets/ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}
+111
View File
@@ -0,0 +1,111 @@
# ==========================================================
# SHBL-CRM Nginx 反向代理配置
# 功能:前端静态资源服务 + 后端 API 转发 + 审计日志
# ==========================================================
# ---- 自定义审计日志格式 ----
# 完整记录:客户端真实 IP、时间、方法、URI、状态码、请求体大小、User-Agent
log_format audit_log '$http_x_forwarded_for - $remote_user [$time_local] '
'"$request_method $request_uri $server_protocol" '
'$status $body_bytes_sent '
'"$http_user_agent" '
'req_time=$request_time';
# ---- 后端上游服务 ----
upstream crm_backend {
server 127.0.0.1:8000; # FastAPI (uvicorn)
# 如需多实例负载均衡,在此追加:
# server 127.0.0.1:8001;
# server 127.0.0.1:8002;
}
server {
listen 8080;
server_name localhost;
charset utf-8;
# ---- 审计日志输出 ----
access_log /var/log/nginx/crm_audit.log audit_log;
error_log /var/log/nginx/crm_error.log warn;
# ---- IP 白名单 (取消注释以启用) ----
# 仅允许内网访问,外网请求直接拒绝
# allow 192.168.1.0/24; # 办公室局域网
# allow 10.0.0.0/8; # VPN 网段
# allow 127.0.0.1; # 本机
# deny all; # 拒绝其他所有 IP
# ---- 安全响应头 ----
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# ---- 前端静态资源 ----
# Vite 构建输出目录:frontend/dist/
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
# index.html 不缓存,确保每次获取最新版本
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate" always;
add_header Pragma "no-cache" always;
expires 0;
}
}
# ---- SSE 长连接专用代理(AI 复盘报告等)----
location /api/reports/generate {
proxy_pass http://crm_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection '';
# SSE 必须: 禁用缓冲,实时推送事件
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
# LLM 生成可能需要 5-10 分钟
proxy_connect_timeout 10s;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
client_max_body_size 1m;
}
# ---- 后端 API 反向代理 ----
location /api/ {
proxy_pass http://crm_backend;
proxy_http_version 1.1;
# 传递客户端真实信息
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 支持
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# 超时配置
proxy_connect_timeout 10s;
proxy_read_timeout 300s;
proxy_send_timeout 60s;
# 请求体大小限制 (报销单附件上传)
client_max_body_size 50m;
}
# ---- 静态资源缓存策略 ----
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}
BIN
View File
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
@echo off
:: 1. 强制切换到 F 盘并进入文件夹 (/d 参数非常关键)
cd /d F:\SHBLCRM
:: 2. 激活虚拟环境
call venv\Scripts\activate
:: 3. 启动系统
echo 正在启动硕博霖 CRM 系统...
echo 请勿关闭此窗口
echo.
streamlit run app.py --server.address=0.0.0.0 --server.port=8501
:: 4. 如果出错,暂停显示错误信息,而不是直接闪退
pause
+150
View File
@@ -0,0 +1,150 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
# database URL — 由 env.py 从 .env 动态读取
# sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+93
View File
@@ -0,0 +1,93 @@
"""
Alembic env.py 异步 PostgreSQL 迁移环境
.env 读取 DATABASE_URL自动发现项目所有 ORM 模型
"""
import asyncio
import os
import sys
from logging.config import fileConfig
from alembic import context
from dotenv import load_dotenv
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
# 确保项目根目录在 sys.path 中
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
# 加载 .env
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(__file__)), ".env"))
# Alembic Config
config = context.config
# 从 .env 动态设置 sqlalchemy.urlasyncpg → psycopg2 用于迁移)
db_url = os.getenv("DATABASE_URL", "")
# Alembic 需要同步驱动,将 asyncpg 替换为 psycopg2
sync_url = db_url.replace("+asyncpg", "")
config.set_main_option("sqlalchemy.url", sync_url)
# 日志配置
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# 导入所有模型,确保 Alembic 能检测到所有表
from app.models.base import Base
from app.models import crm, erp, order, shipping, finance, ai, sys as sys_models
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""离线迁移(仅生成 SQL 脚本)"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations():
"""在线迁移(异步引擎)"""
from sqlalchemy.ext.asyncio import create_async_engine
connectable = create_async_engine(
os.getenv("DATABASE_URL", ""),
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""在线迁移入口"""
# 如果是 asyncpg 则走异步迁移
db_url = os.getenv("DATABASE_URL", "")
if "asyncpg" in db_url:
asyncio.run(run_async_migrations())
else:
from sqlalchemy import engine_from_config
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
do_run_migrations(connection)
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
File diff suppressed because it is too large Load Diff
View File
View File
+100
View File
@@ -0,0 +1,100 @@
"""
Auth 路由 /api/auth/login & /api/auth/me
"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.exceptions import BizException, UnauthorizedException
from app.core.security import create_access_token, hash_password, verify_password
from app.db.database import get_db
from app.models.sys import SysUser
from app.schemas.auth import (
CurrentUserPayload,
LoginRequest,
TokenResponse,
UpdatePasswordRequest,
)
from app.schemas.response import ok
router = APIRouter(prefix="/auth", tags=["鉴权"])
@router.post("/login", summary="账号密码登录,签发 JWT")
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)) -> dict:
# 1. 查询用户
stmt = select(SysUser).where(
SysUser.username == body.username,
SysUser.is_deleted.is_(False),
)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if user is None:
raise BizException(code=401, message="用户名或密码错误")
# 2. 校验密码
if not verify_password(body.password, user.password_hash):
raise BizException(code=401, message="用户名或密码错误")
# 3. 检查账号状态
if user.status != 1:
raise BizException(code=403, message="账号已被禁用,请联系管理员")
# 4. 签发 Tokensub 存 user_id 字符串)
access_token = create_access_token(data={"sub": str(user.id)})
# 5. 刷新最后登录时间
await db.execute(
update(SysUser)
.where(SysUser.id == user.id)
.values(last_login_at=datetime.utcnow())
)
await db.commit()
return ok(
data=TokenResponse(access_token=access_token).model_dump(),
message="登录成功",
)
@router.get("/me", summary="获取当前登录用户信息(验证 Token 有效性)")
async def get_me(
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
return ok(data=current_user.model_dump(mode="json"))
@router.put("/password", summary="当前用户修改密码")
async def change_password(
body: UpdatePasswordRequest,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
# 1. 查出用户记录
stmt = select(SysUser).where(SysUser.id == current_user.user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if user is None:
raise BizException(code=404, message="用户不存在")
# 2. 校验旧密码
if not verify_password(body.old_password, user.password_hash):
raise BizException(code=400, message="旧密码错误")
# 3. 哈希新密码并更新
await db.execute(
update(SysUser)
.where(SysUser.id == current_user.user_id)
.values(password_hash=hash_password(body.new_password), updated_at=datetime.utcnow())
)
await db.commit()
return ok(message="密码修改成功,请重新登录")

Some files were not shown because too many files have changed in this diff Show More