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:
@@ -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.
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.git/
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
+29
@@ -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__/
|
||||
@@ -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", "-"]
|
||||
@@ -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;"]
|
||||
@@ -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 攻坚**:
|
||||
|
||||
- 开发“历史专享价”带出接口。
|
||||
|
||||
- 开发“分批发货关联扣减订单待发数量”的事务一致性逻辑。
|
||||
|
||||
- 开发“购物车报销合单”的长事务锁定逻辑。
|
||||
@@ -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`
|
||||
|
||||
### 模块 2:CRM 客户管理
|
||||
|
||||
- **功能**:客户全生命周期管理(新增、编辑、归档、搜索筛选、导入/导出)
|
||||
- **核心亮点**:
|
||||
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 复盘报告的原始素材
|
||||
|
||||
### 模块 9:Dashboard 工作台
|
||||
|
||||
- **功能**:首页 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 API(streaming 模式)
|
||||
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 阶段性存档,随系统迭代持续更新。*
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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}"'
|
||||
))
|
||||
@@ -0,0 +1 @@
|
||||
# SHBL-CRM Backend Application Package
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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=["报销管理"])
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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}')>"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
Binary file not shown.
@@ -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()
|
||||
@@ -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: ''
|
||||
@@ -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: ''
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.git/
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,3 @@
|
||||
# 开发环境 API 基础路径
|
||||
# Vite 开发服务器通过 proxy 转发 /api → http://127.0.0.1:8000
|
||||
VITE_API_BASE_URL=
|
||||
@@ -0,0 +1,3 @@
|
||||
# 生产环境 API 基础路径
|
||||
# Nginx 统一代理,前端和后端同域,无需跨域
|
||||
VITE_API_BASE_URL=
|
||||
@@ -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>
|
||||
Generated
+2042
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 } })
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
Vendored
+17
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Binary file not shown.
+15
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
@@ -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.url(asyncpg → 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()
|
||||
@@ -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
@@ -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. 签发 Token(sub 存 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
Reference in New Issue
Block a user