Initial commit: 智能项目定价模型

This commit is contained in:
kuzma
2026-01-31 21:33:06 +08:00
commit ef0824303f
174 changed files with 31705 additions and 0 deletions

86
.gitignore vendored Normal file
View File

@@ -0,0 +1,86 @@
# 智能项目定价模型 - Git 忽略配置
# ============ 环境配置文件(敏感信息)============
.env
.env.*
!.env.example
!env.example
!env.dev.example
# ============ Python ============
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual Environment
venv/
ENV/
env/
.venv/
# pytest
.pytest_cache/
.coverage
htmlcov/
# mypy
.mypy_cache/
# ============ Node.js ============
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.pnpm-store/
# Build output
dist/
dist-ssr/
*.local
# ============ IDE ============
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store
# ============ Docker ============
# 数据卷不提交
data/
# ============ 日志 ============
*.log
logs/
# ============ 临时文件 ============
tmp/
temp/
*.tmp
*.temp
# ============ SSL 证书 ============
ssl/
*.pem
*.key
*.crt

262
DEPLOYMENT_CHECKLIST.md Normal file
View File

@@ -0,0 +1,262 @@
# 智能项目定价模型 - 部署检查清单
> 部署前请逐项检查,确保所有条件满足
---
## 一、环境准备检查
### 1.1 服务器环境
- [ ] 操作系统Linux (Ubuntu 22.04 / Debian 12 推荐)
- [ ] CPU≥ 2 核(推荐 4 核)
- [ ] 内存:≥ 4 GB推荐 8 GB
- [ ] 磁盘:≥ 40 GB推荐 100 GB
### 1.2 软件依赖
- [ ] Docker 已安装(版本 24.0+
```bash
docker --version
```
- [ ] Docker Compose 已安装(版本 2.20+
```bash
docker-compose --version
```
- [ ] Git 已安装
```bash
git --version
```
### 1.3 网络要求
- [ ] 可访问外网(拉取镜像)
- [ ] 可访问门户系统内网AI 配置)
- [ ] 80/443 端口未被占用或已规划
---
## 二、配置检查
### 2.1 环境变量
- [ ] 已创建 `.env` 文件
```bash
cp env.example .env
```
- [ ] 已修改数据库密码
- [ ] `MYSQL_ROOT_PASSWORD`
- [ ] `MYSQL_PASSWORD`
- [ ] 已设置 `SECRET_KEY`32位以上随机字符串
- [ ] 已配置 `PORTAL_CONFIG_API`(门户系统地址)
- [ ] 已配置 `CORS_ORIGINS`(生产域名)
- [ ] `.env` 文件权限为 600
```bash
chmod 600 .env
ls -la .env # 应显示 -rw-------
```
### 2.2 Nginx 配置
- [ ] 已修改 `nginx.conf` 中的域名
- [ ] SSL 证书已准备或计划使用 Let's Encrypt
### 2.3 Docker 网络
- [ ] `scrm_network` 网络已创建(如需连接门户系统)
```bash
docker network ls | grep scrm_network
# 如不存在则创建
docker network create scrm_network
```
---
## 三、部署执行
### 3.1 首次部署
```bash
# 1. 进入项目目录
cd /opt/pricing-model # 或实际路径
# 2. 检查配置
cat .env | grep -E "MYSQL|SECRET|PORTAL"
# 3. 执行部署
./scripts/deploy.sh deploy
# 4. 查看状态
./scripts/deploy.sh status
```
### 3.2 部署后验证
- [ ] 所有容器运行正常
```bash
docker-compose ps
# 应显示 3 个服务均为 Up 状态
```
- [ ] 后端健康检查通过
```bash
curl http://localhost:8000/health
# 应返回 {"status": "healthy", ...}
```
- [ ] 数据库连接正常
```bash
docker exec pricing-mysql mysqladmin ping -h localhost
# 应返回 mysqld is alive
```
---
## 四、SSL 证书配置
### 4.1 使用 Let's Encrypt
```bash
DOMAIN=pricing.yourcompany.com \
EMAIL=admin@yourcompany.com \
./scripts/setup-ssl.sh request
```
### 4.2 使用已有证书
```bash
# 复制证书
mkdir -p /etc/nginx/ssl
cp your_certificate.pem /etc/nginx/ssl/pricing.yourcompany.com.pem
cp your_private_key.key /etc/nginx/ssl/pricing.yourcompany.com.key
chmod 600 /etc/nginx/ssl/*.key
```
### 4.3 验证 SSL
- [ ] HTTPS 访问正常
- [ ] HTTP 自动重定向到 HTTPS
---
## 五、Nginx 反向代理
### 5.1 配置 Nginx
```bash
# 1. 复制配置
cp nginx.conf /etc/nginx/sites-available/pricing.conf
# 2. 修改域名和证书路径
vim /etc/nginx/sites-available/pricing.conf
# 3. 启用配置
ln -s /etc/nginx/sites-available/pricing.conf /etc/nginx/sites-enabled/
# 4. 测试配置
nginx -t
# 5. 重载 Nginx
nginx -s reload
```
### 5.2 验证访问
- [ ] 可通过域名访问前端页面
- [ ] API 接口 `/api/v1/health` 可访问
- [ ] WebSocket 连接正常AI 流式输出)
---
## 六、定时任务配置
```bash
crontab -e
```
添加以下任务:
```cron
# 每日凌晨 2 点备份
0 2 * * * /opt/pricing-model/scripts/backup.sh backup >> /var/log/pricing-backup.log 2>&1
# 每 5 分钟健康检查
*/5 * * * * /opt/pricing-model/scripts/monitor.sh quick >> /var/log/pricing-monitor.log 2>&1
```
---
## 七、安全检查
### 7.1 文件权限
- [ ] `.env` 文件权限为 600
- [ ] 脚本文件可执行
```bash
chmod +x scripts/*.sh
```
### 7.2 端口暴露
- [ ] 仅 Nginx 暴露 80/443 端口
- [ ] 后端 8000 端口仅内网访问
- [ ] MySQL 3306 端口仅内网访问
### 7.3 密钥安全
- [ ] 未使用默认密码
- [ ] SECRET_KEY 为随机字符串
- [ ] API Key 从门户系统获取(未硬编码)
---
## 八、功能验证
### 8.1 核心功能
- [ ] 用户可登录系统
- [ ] 基础数据管理正常(分类、耗材、设备等)
- [ ] 项目成本计算正确
- [ ] 市场分析功能正常
- [ ] AI 定价建议可生成
- [ ] 利润模拟计算正确
- [ ] 仪表盘数据显示正常
### 8.2 性能验证
- [ ] 页面加载时间 < 2 秒
- [ ] API 响应时间 < 500ms
- [ ] AI 接口响应 < 10 秒
---
## 九、交付确认
### 9.1 文档
- [ ] README.md 已更新
- [ ] 用户操作手册已提供
- [ ] 系统管理手册已提供
### 9.2 培训
- [ ] 用户培训已完成(或文档已提供)
- [ ] 运维培训已完成(或文档已提供)
### 9.3 签收
- [ ] 客户/产品验收通过
- [ ] 上线通知已发送
---
## 十、问题处理
如遇问题,请参考:
1. 查看容器日志:`docker-compose logs -f`
2. 运行监控检查:`./scripts/monitor.sh report`
3. 参考《系统管理手册》故障排查章节
4. 联系瑞小美技术团队
---
*瑞小美技术团队 · 2026-01-20*

267
README.md Normal file
View File

@@ -0,0 +1,267 @@
# 智能项目定价模型
医美行业智能项目定价系统,帮助机构精准核算成本、分析市场行情、智能生成定价建议。
## 功能特性
- **成本核算**:精准计算项目成本,明确最低成本线
- **市场行情**:收集竞品价格,分析市场定价区间
- **智能定价**AI 智能分析,生成定价建议
- **利润模拟**:模拟不同定价的利润情况,敏感性分析
## 技术栈
### 后端
- Python 3.11 + FastAPI
- SQLAlchemy + MySQL 8.0
- 遵循瑞小美 AI 接入规范
### 前端
- Vue 3 + TypeScript + Vite
- Element Plus + Tailwind CSS
- Pinia + Axios
- ESLint已配置
### 部署
- Docker + Docker Compose
- Nginx 反向代理
- Let's Encrypt SSL
## 快速开始
### 开发环境
1. 复制环境配置文件
```bash
cp env.dev.example .env.dev
```
2. 启动开发环境
```bash
docker-compose -f docker-compose.dev.yml up -d
```
3. 访问应用
- 前端http://localhost:3000支持热重载
- 后端 APIhttp://localhost:8000
- API 文档http://localhost:8000/docs
### 生产环境部署
#### 方式一:使用部署脚本(推荐)
1. 配置环境变量
```bash
cp env.example .env
# 编辑 .env 文件,修改数据库密码、密钥等
vim .env
chmod 600 .env
```
2. 执行部署
```bash
./scripts/deploy.sh deploy
```
3. 配置 SSL 证书
```bash
DOMAIN=pricing.yourcompany.com EMAIL=admin@yourcompany.com ./scripts/setup-ssl.sh request
```
#### 方式二:手动部署
1. 复制并修改环境配置
```bash
cp env.example .env
# 编辑 .env 文件
vim .env
# 修改以下配置:
# - MYSQL_ROOT_PASSWORD: 数据库 root 密码
# - MYSQL_PASSWORD: 应用数据库密码
# - SECRET_KEY: 应用密钥32位以上随机字符串
# - CORS_ORIGINS: 允许的跨域来源
chmod 600 .env
```
2. 创建外部网络(如未创建)
```bash
docker network create scrm_network
```
3. 启动服务
```bash
docker-compose up -d
```
4. 配置 Nginx 反向代理
-`nginx.conf` 添加到主机 Nginx 配置中
- 修改域名为实际域名
- 配置 SSL 证书
5. 刷新 Nginx DNS 缓存
```bash
docker exec nginx_proxy nginx -s reload
```
## 运维管理
### 服务管理
```bash
# 查看状态
./scripts/deploy.sh status
# 重启服务
./scripts/deploy.sh restart
# 停止服务
./scripts/deploy.sh stop
# 查看日志
./scripts/deploy.sh logs
```
### 数据库备份
```bash
# 执行备份
./scripts/backup.sh backup
# 查看备份列表
./scripts/backup.sh list
# 恢复备份
./scripts/backup.sh restore <备份文件>
```
### 监控检查
```bash
# 完整监控报告
./scripts/monitor.sh report
# 快速检查
./scripts/monitor.sh quick
```
### 定时任务(建议配置)
```bash
# 每日凌晨 2 点备份
0 2 * * * /opt/pricing-model/scripts/backup.sh backup
# 每 5 分钟健康检查
*/5 * * * * /opt/pricing-model/scripts/monitor.sh quick
```
## 项目结构
```
智能项目定价模型/
├── 后端服务/ # FastAPI 后端
│ ├── app/
│ │ ├── main.py # 应用入口
│ │ ├── config.py # 配置管理
│ │ ├── database.py # 数据库连接
│ │ ├── models/ # SQLAlchemy 模型
│ │ ├── schemas/ # Pydantic 模型
│ │ ├── routers/ # API 路由
│ │ ├── services/ # 业务逻辑
│ │ └── middleware/ # 中间件
│ ├── prompts/ # AI 提示词
│ ├── tests/ # 单元测试
│ ├── Dockerfile
│ └── requirements.txt
├── 前端应用/ # Vue 3 前端
│ ├── src/
│ │ ├── views/ # 页面组件
│ │ ├── components/ # 通用组件
│ │ ├── stores/ # Pinia 状态
│ │ ├── api/ # API 封装
│ │ └── router/ # 路由配置
│ ├── eslint.config.js # ESLint 配置
│ ├── Dockerfile
│ └── package.json
├── scripts/ # 运维脚本
│ ├── deploy.sh # 部署脚本
│ ├── backup.sh # 备份脚本
│ ├── setup-ssl.sh # SSL 配置
│ └── monitor.sh # 监控脚本
├── docs/ # 文档
│ ├── 用户操作手册.md
│ └── 系统管理手册.md
├── docker-compose.yml # 生产环境编排
├── docker-compose.dev.yml # 开发环境编排
├── nginx.conf # Nginx 反向代理配置
└── init.sql # 数据库初始化脚本
```
## 功能模块
| 阶段 | 模块 | 状态 |
|------|------|------|
| M1 | 基础搭建(框架、基础数据管理) | ✅ 已完成 |
| M2 | 核心功能(成本核算、市场行情) | ✅ 已完成 |
| M3 | 智能功能(智能定价、利润模拟) | ✅ 已完成 |
| M4 | 测试优化(单元测试、性能优化) | ✅ 已完成 |
| M5 | 上线部署(部署脚本、文档) | ✅ 已完成 |
## API 文档
启动后端服务后访问:
- Swagger UIhttp://localhost:8000/docs
- ReDochttp://localhost:8000/redoc
> 生产环境默认关闭 API 文档,如需开启请设置 `DEBUG=true`
## 文档
- [用户操作手册](docs/用户操作手册.md) - 功能使用指南
- [系统管理手册](docs/系统管理手册.md) - 部署运维指南
## 规范遵循
本项目严格遵循:
- 《瑞小美系统技术栈标准与字符标准》
- 《瑞小美 AI 接入规范》
## 环境要求
### 硬件
| 资源 | 最低配置 | 推荐配置 |
|------|----------|----------|
| CPU | 2 核 | 4 核 |
| 内存 | 4 GB | 8 GB |
| 磁盘 | 40 GB | 100 GB |
### 软件
- Docker 24.0+
- Docker Compose 2.20+
- Linux (推荐 Ubuntu 22.04 / Debian 12)
## 安全注意事项
- `.env` 文件权限必须设置为 600
- 禁止将 `.env` 文件提交到 Git
- 生产环境必须修改默认密码
- API Key 从门户系统获取,禁止硬编码
## License
Copyright © 2026 瑞小美技术团队
---
*瑞小美技术团队 · 2026*

84
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,84 @@
# 智能项目定价模型 - 开发环境 Docker Compose 配置
# 支持热重载
# 注意Docker Compose V2 已弃用 version 字段
name: pricing-model-dev
services:
# ============ 前端服务(开发模式)============
pricing-frontend:
build:
context: ./前端应用
dockerfile: Dockerfile.dev
container_name: pricing-frontend-dev
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- ./前端应用:/app
- /app/node_modules
environment:
- NODE_ENV=development
networks:
- pricing_network
command: pnpm dev --host
# ============ 后端服务(开发模式)============
pricing-backend:
build:
context: ./后端服务
dockerfile: Dockerfile.dev
container_name: pricing-backend-dev
restart: unless-stopped
ports:
- "8000:8000"
volumes:
- ./后端服务:/app
env_file:
- .env.dev
environment:
- APP_ENV=development
- DEBUG=true
depends_on:
pricing-mysql:
condition: service_healthy
networks:
- pricing_network
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
# ============ 数据库服务 ============
pricing-mysql:
image: mysql:8.0.36
container_name: pricing-mysql-dev
restart: unless-stopped
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --default-time-zone=+08:00
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: root123
MYSQL_DATABASE: pricing_model
MYSQL_USER: pricing_user
MYSQL_PASSWORD: pricing123
volumes:
- pricing_mysql_data_dev:/var/lib/mysql
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
networks:
- pricing_network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-proot123"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks:
pricing_network:
driver: bridge
name: pricing_network_dev
volumes:
pricing_mysql_data_dev:
name: pricing_mysql_data_dev

133
docker-compose.yml Normal file
View File

@@ -0,0 +1,133 @@
# 智能项目定价模型 - Docker Compose 配置
# 遵循瑞小美部署规范
name: pricing-model
services:
# ============ 前端服务 ============
pricing-frontend:
build:
context: ./前端应用
dockerfile: Dockerfile
container_name: pricing-frontend
restart: unless-stopped
networks:
- pricing_network
- ai_strategy_network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
deploy:
resources:
limits:
cpus: '0.5'
memory: 256M
reservations:
cpus: '0.1'
memory: 64M
# ============ 后端服务 ============
pricing-backend:
build:
context: ./后端服务
dockerfile: Dockerfile
container_name: pricing-backend
restart: unless-stopped
env_file:
- .env
environment:
- DATABASE_URL=${DATABASE_URL}
- PORTAL_CONFIG_API=${PORTAL_CONFIG_API}
- APP_ENV=${APP_ENV:-production}
- DEBUG=${DEBUG:-false}
- SECRET_KEY=${SECRET_KEY}
depends_on:
pricing-mysql:
condition: service_healthy
networks:
- pricing_network
- scrm_network
- ai_strategy_network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
deploy:
resources:
limits:
cpus: '1'
memory: 512M
reservations:
cpus: '0.25'
memory: 128M
# ============ 数据库服务 ============
pricing-mysql:
image: mysql:8.0.36
container_name: pricing-mysql
restart: unless-stopped
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --default-time-zone=+08:00
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: pricing_model
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- pricing_mysql_data:/var/lib/mysql
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
networks:
- pricing_network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
deploy:
resources:
limits:
cpus: '1'
memory: 1G
reservations:
cpus: '0.25'
memory: 256M
# ============ 网络配置 ============
networks:
pricing_network:
driver: bridge
name: pricing_network
scrm_network:
external: true
name: scrm_network
ai_strategy_network:
external: true
name: ai_ai-strategy-network
# ============ 数据卷 ============
volumes:
pricing_mysql_data:
name: pricing_mysql_data

488
docs/用户操作手册.md Normal file
View File

@@ -0,0 +1,488 @@
# 智能项目定价模型 - 用户操作手册
> **版本**v1.0
> **更新日期**2026-01-20
> **适用对象**:运营总监、财务人员、市场人员、店长
---
## 目录
1. [系统概述](#1-系统概述)
2. [登录与导航](#2-登录与导航)
3. [基础数据管理](#3-基础数据管理)
4. [成本核算](#4-成本核算)
5. [市场行情](#5-市场行情)
6. [智能定价](#6-智能定价)
7. [利润模拟](#7-利润模拟)
8. [仪表盘](#8-仪表盘)
9. [常见问题](#9-常见问题)
---
## 1. 系统概述
### 1.1 系统功能
智能项目定价模型是一套帮助医美机构科学定价的智能系统,主要功能包括:
| 功能模块 | 描述 | 主要用户 |
|----------|------|----------|
| **成本核算** | 精准计算项目成本,明确最低成本线 | 财务人员 |
| **市场行情** | 收集竞品价格,分析市场定价区间 | 市场人员 |
| **智能定价** | AI 智能分析,生成定价建议 | 运营总监 |
| **利润模拟** | 模拟不同定价的利润情况 | 运营总监、店长 |
### 1.2 浏览器要求
- Chrome 90+(推荐)
- Firefox 88+
- Edge 90+
- Safari 14+
建议使用 1280px 及以上宽度的屏幕。
---
## 2. 登录与导航
### 2.1 系统登录
1. 打开浏览器,访问系统地址
2. 使用瑞小美 SCRM 账号登录
3. 登录成功后进入系统首页(仪表盘)
### 2.2 主导航菜单
| 菜单 | 功能 |
|------|------|
| 仪表盘 | 系统概览、关键指标展示 |
| 成本核算 → 服务项目 | 项目成本明细管理 |
| 市场行情 → 竞品管理 | 竞品机构与价格管理 |
| 市场行情 → 标杆价格 | 标杆机构价格参考 |
| 市场行情 → 市场分析 | 市场价格分析 |
| 智能定价 | AI 定价建议与方案管理 |
| 利润模拟 | 利润测算与敏感性分析 |
| 基础设置 | 分类、耗材、设备等基础数据 |
---
## 3. 基础数据管理
基础数据是系统运行的基础,请在使用核心功能前先维护好基础数据。
### 3.1 项目分类管理
**路径**:基础设置 → 项目分类
用于对服务项目进行分类,如:皮肤管理、注射类、光电类、手术类。
**操作步骤**
1. 点击「新增分类」按钮
2. 填写分类名称
3. 选择父分类(可选,用于创建子分类)
4. 设置排序号
5. 点击「确定」保存
### 3.2 耗材管理
**路径**:基础设置 → 耗材管理
维护项目所需的耗材信息。
**操作步骤**
1. 点击「新增耗材」
2. 填写信息:
- **耗材编码**:唯一标识,如 MAT001
- **耗材名称**:如"冷凝胶"
- **单位**:如"ml"、"支"、"个"
- **单价**:单位采购价格
- **类型**:耗材/针剂/产品
- **供应商**:供应商名称(可选)
3. 点击「确定」保存
**批量导入**
1. 点击「导入」按钮
2. 下载 Excel 模板
3. 按模板格式填写数据
4. 上传文件完成导入
### 3.3 设备管理
**路径**:基础设置 → 设备管理
维护设备信息及折旧计算。
**关键字段**
| 字段 | 说明 |
|------|------|
| 设备原值 | 购买价格 |
| 残值率 | 设备报废时的残余价值比例,默认 5% |
| 预计使用年限 | 设备使用寿命 |
| 预计使用次数 | 整个生命周期的使用次数 |
| 单次折旧成本 | 自动计算:(原值 - 残值) / 总次数 |
### 3.4 人员级别
**路径**:基础设置 → 人员级别
配置不同岗位的时薪标准。
| 级别 | 示例时薪 |
|------|----------|
| 初级美容师 | 30 元/小时 |
| 中级美容师 | 50 元/小时 |
| 高级美容师 | 80 元/小时 |
| 主治医师 | 200 元/小时 |
### 3.5 固定成本
**路径**:基础设置 → 固定成本
录入每月固定成本,用于分摊到项目成本中。
**成本类型**
- 房租
- 水电费
- 物业费
- 其他
**分摊方式**
| 方式 | 说明 |
|------|------|
| 按项目数量 | 固定成本平均分摊到每个项目 |
| 按营收占比 | 根据项目营收比例分摊 |
| 按时长占比 | 根据项目操作时长比例分摊 |
---
## 4. 成本核算
### 4.1 创建服务项目
**路径**:成本核算 → 服务项目 → 新增项目
1. 点击「新增项目」
2. 填写基本信息:
- **项目编码**:唯一标识
- **项目名称**:如"光子嫩肤"
- **所属分类**:选择分类
- **操作时长**:单次服务时长(分钟)
- **项目描述**:可选
3. 点击「确定」创建
### 4.2 配置成本明细
创建项目后,需要配置具体的成本构成。
**添加耗材成本**
1. 在项目列表点击「成本明细」
2. 切换到「耗材成本」标签
3. 点击「添加耗材」
4. 选择耗材,填写用量
5. 系统自动计算:总成本 = 单价 × 用量
**添加设备折旧**
1. 切换到「设备折旧」标签
2. 点击「添加设备」
3. 选择设备,填写使用次数(通常为 1
4. 系统自动使用设备的单次折旧成本
**配置人工成本**
1. 切换到「人工成本」标签
2. 点击「添加人工」
3. 选择人员级别
4. 填写操作时长(分钟)
5. 系统自动计算:人工成本 = 时长 × 时薪
### 4.3 计算成本汇总
配置完所有成本项后:
1. 点击「计算成本」按钮
2. 选择固定成本分摊方式
3. 系统计算并显示成本汇总:
- 耗材成本
- 设备折旧成本
- 人工成本
- 固定成本分摊
- **最低成本线**(总成本)
> **提示**:最低成本线是项目定价的下限,低于此价格销售将亏损。
### 4.4 成本分析
成本汇总页面提供:
- **成本构成饼图**:直观展示各类成本占比
- **成本明细表**:详细的成本项列表
- **成本建议**:针对成本结构的优化建议
---
## 5. 市场行情
### 5.1 竞品机构管理
**路径**:市场行情 → 竞品管理
**添加竞品机构**
1. 点击「新增竞品」
2. 填写信息:
- **机构名称**:竞品机构名称
- **地址**:机构地址
- **距离**:与本店距离(公里)
- **定位**:高端/中端/大众
- **是否重点关注**:标记重点竞品
3. 点击「确定」保存
### 5.2 竞品价格录入
**录入价格**
1. 在竞品列表点击「价格管理」
2. 点击「添加价格」
3. 填写信息:
- **项目名称**:竞品的项目名称
- **关联本店项目**:可选,便于对比
- **原价**:标价
- **促销价**:活动价格(可选)
- **会员价**:会员价格(可选)
- **价格来源**:官网/美团/大众点评/实地调研
- **采集日期**:价格获取日期
4. 点击「确定」保存
> **建议**:定期更新竞品价格,保持数据时效性。
### 5.3 标杆价格
**路径**:市场行情 → 标杆价格
维护行业标杆机构的价格参考。
**添加标杆价格**
1. 点击「新增标杆」
2. 填写标杆机构名称
3. 选择项目分类
4. 填写价格区间(最低价、最高价、均价)
5. 选择价格档位(低端/中端/高端/奢华)
6. 设置生效日期
### 5.4 市场分析
**路径**:市场行情 → 市场分析
**执行分析**
1. 选择要分析的项目
2. 选择参与分析的竞品(可多选)
3. 点击「执行分析」
4. 查看分析结果:
- **价格统计**:最低价、最高价、均价、中位价
- **价格分布**:低/中/高价位占比
- **建议区间**:市场定价参考区间
- **竞品对比**:各竞品价格详情
---
## 6. 智能定价
### 6.1 创建定价方案
**路径**:智能定价
**操作步骤**
1. 点击「新建方案」
2. 选择项目
3. 填写方案名称(如"2026年Q1定价方案"
4. 选择定价策略:
- **引流款**:低价吸引新客,利润率 10%-20%
- **利润款**:日常定价,利润率 40%-60%
- **高端款**:高端定位,利润率 60%+
5. 设置目标毛利率
### 6.2 获取 AI 定价建议
1. 在定价方案中点击「AI 建议」
2. 系统自动整合:
- 项目成本数据
- 市场行情数据
- 目标利润率
3. AI 分析并生成建议,包括:
- **建议价格区间**
- **推荐定价及理由**
- **不同策略的建议价格**
- **风险提示**
- **优化建议**
### 6.3 策略模拟
对比不同定价策略的效果:
| 策略 | 定价逻辑 | 适用场景 |
|------|----------|----------|
| 引流款 | 成本 + 10%-20% 利润 | 新店开业、淡季促销 |
| 利润款 | 成本 + 40%-60% 利润 | 日常经营 |
| 高端款 | 市场高位或成本 + 高利润 | 高端客群、稀缺项目 |
### 6.4 确定最终定价
1. 参考 AI 建议和策略模拟结果
2. 填写最终定价
3. 保存方案
4. 可导出定价报告PDF/Excel
---
## 7. 利润模拟
### 7.1 创建利润模拟
**路径**:利润模拟
**操作步骤**
1. 选择定价方案
2. 点击「新建模拟」
3. 填写模拟参数:
- **模拟名称**:如"乐观预期"
- **模拟价格**:可使用方案价格或自定义
- **预估客量**:预期服务人数
- **周期类型**:日/周/月
4. 点击「计算」
### 7.2 查看模拟结果
| 指标 | 说明 |
|------|------|
| 预估收入 | 价格 × 客量 |
| 预估成本 | 单位成本 × 客量 |
| 预估利润 | 收入 - 成本 |
| 利润率 | 利润 / 收入 × 100% |
| 盈亏平衡客量 | 达到盈亏平衡需要的最小客量 |
| 安全边际 | 当前客量超出盈亏平衡点的数量 |
### 7.3 敏感性分析
分析价格变动对利润的影响:
1. 点击「敏感性分析」
2. 系统自动计算价格 ±5%、±10%、±15%、±20% 时的利润变化
3. 查看分析结果:
- **敏感性表格**:不同价格下的利润
- **利润曲线图**:可视化价格-利润关系
- **风险评估**:价格弹性分析
### 7.4 多场景对比
创建多个模拟方案进行对比:
- **乐观预期**:客量较高
- **正常预期**:客量适中
- **保守预期**:客量较低
通过对比帮助制定更稳健的定价决策。
---
## 8. 仪表盘
**路径**:仪表盘(首页)
### 8.1 概览数据
| 指标 | 说明 |
|------|------|
| 项目总数 | 系统管理的服务项目数量 |
| 已定价项目 | 已创建定价方案的项目数 |
| 平均成本 | 所有项目的平均成本 |
| 跟踪竞品数 | 监控的竞品机构数量 |
### 8.2 成本趋势
展示项目成本的变化趋势,帮助发现成本波动。
### 8.3 市场趋势
展示市场价格的变化趋势,了解行业动态。
### 8.4 最近活动
显示系统最近的操作记录,如定价方案创建、成本更新等。
---
## 9. 常见问题
### Q1: 成本计算结果与预期不符?
**可能原因**
- 耗材用量填写错误
- 设备折旧参数不准确
- 人员时薪未及时更新
- 固定成本数据过期
**解决方法**
1. 检查各项成本明细数据
2. 确认基础数据是否最新
3. 重新计算成本汇总
### Q2: AI 定价建议响应慢?
**说明**AI 分析需要综合大量数据,通常需要 5-10 秒。
**建议**
- 耐心等待,系统会显示加载状态
- 如超过 30 秒无响应,刷新页面重试
### Q3: 如何导出定价报告?
1. 进入定价方案详情页
2. 点击右上角「导出」按钮
3. 选择格式PDF 或 Excel
4. 下载保存
### Q4: 竞品价格数据从哪里获取?
建议来源:
- 竞品官网
- 美团/大众点评等平台
- 实地调研
- 行业报告
录入时请标注价格来源,便于评估数据可靠性。
### Q5: 固定成本分摊方式如何选择?
| 方式 | 适用场景 |
|------|----------|
| 按项目数量 | 项目差异小,适合均摊 |
| 按营收占比 | 高价值项目承担更多成本 |
| 按时长占比 | 长时间项目承担更多成本 |
建议根据机构实际情况选择,或咨询财务人员。
---
## 技术支持
如遇问题,请联系:
- **系统管理员**[联系方式]
- **技术支持**:瑞小美技术团队
---
*瑞小美技术团队 · 2026-01-20*

620
docs/系统管理手册.md Normal file
View File

@@ -0,0 +1,620 @@
# 智能项目定价模型 - 系统管理手册
> **版本**v1.0
> **更新日期**2026-01-20
> **适用对象**:系统管理员、运维人员
---
## 目录
1. [系统架构](#1-系统架构)
2. [部署指南](#2-部署指南)
3. [配置管理](#3-配置管理)
4. [日常运维](#4-日常运维)
5. [备份与恢复](#5-备份与恢复)
6. [监控与告警](#6-监控与告警)
7. [故障排查](#7-故障排查)
8. [安全规范](#8-安全规范)
9. [附录](#9-附录)
---
## 1. 系统架构
### 1.1 技术栈
| 组件 | 技术 | 版本 |
|------|------|------|
| 后端 | Python + FastAPI | 3.11 |
| 前端 | Vue 3 + TypeScript | 3.x |
| 数据库 | MySQL | 8.0 |
| 容器 | Docker + Docker Compose | 24.x |
| 反向代理 | Nginx | 1.25 |
### 1.2 服务架构
```
用户浏览器
│ HTTPS (443)
┌───────────────┐
│ Nginx │ (nginx_proxy)
│ 反向代理 │ 端口: 80, 443
└───────┬───────┘
┌───────────────┴───────────────┐
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ pricing-frontend│ │ pricing-backend │
│ Vue 3 SPA │ │ FastAPI │
│ 端口: 80 │ │ 端口: 8000 │
└─────────────────┘ └────────┬────────┘
┌────────┴────────┐
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│pricing-mysql│ │ 门户系统 │
│ MySQL 8.0 │ │ AI 配置 │
└─────────────┘ └─────────────┘
```
### 1.3 网络配置
| 网络 | 用途 |
|------|------|
| `pricing_network` | 定价系统内部通信 |
| `scrm_network` | 与门户系统通信(获取 AI 配置) |
### 1.4 数据卷
| 卷名 | 用途 |
|------|------|
| `pricing_mysql_data` | MySQL 数据持久化 |
---
## 2. 部署指南
### 2.1 环境要求
**硬件要求**
| 资源 | 最低配置 | 推荐配置 |
|------|----------|----------|
| CPU | 2 核 | 4 核 |
| 内存 | 4 GB | 8 GB |
| 磁盘 | 40 GB | 100 GB |
**软件要求**
- Docker 24.0+
- Docker Compose 2.20+
- Linux (推荐 Ubuntu 22.04 / Debian 12)
### 2.2 首次部署
#### 步骤 1获取代码
```bash
git clone <repository_url> /opt/pricing-model
cd /opt/pricing-model
```
#### 步骤 2配置环境变量
```bash
# 复制配置模板
cp env.example .env
# 编辑配置(修改数据库密码、密钥等)
vim .env
# 设置文件权限(重要!)
chmod 600 .env
```
**必须修改的配置项**
```bash
# 数据库密码(请使用强密码)
MYSQL_ROOT_PASSWORD=your_strong_root_password
MYSQL_PASSWORD=your_strong_password
# 应用密钥32位以上随机字符串
SECRET_KEY=your_random_secret_key_at_least_32_chars
# 门户系统 API确保网络可达
PORTAL_CONFIG_API=http://portal-backend:8000/api/ai/internal/config
```
#### 步骤 3创建外部网络
```bash
# 如果 scrm_network 不存在
docker network create scrm_network
```
#### 步骤 4执行部署
```bash
./scripts/deploy.sh deploy
```
#### 步骤 5配置 Nginx 反向代理
`nginx.conf` 添加到主机 Nginx 配置:
```bash
# 复制配置到 Nginx
cp nginx.conf /etc/nginx/sites-available/pricing.conf
# 修改域名
vim /etc/nginx/sites-available/pricing.conf
# 将 pricing.example.com 替换为实际域名
# 启用配置
ln -s /etc/nginx/sites-available/pricing.conf /etc/nginx/sites-enabled/
# 测试配置
nginx -t
# 重载 Nginx
nginx -s reload
```
#### 步骤 6配置 SSL 证书
```bash
# 使用 Let's Encrypt
DOMAIN=pricing.yourcompany.com EMAIL=admin@yourcompany.com ./scripts/setup-ssl.sh request
```
### 2.3 升级部署
```bash
cd /opt/pricing-model
# 拉取最新代码
git pull
# 备份数据库(重要!)
./scripts/backup.sh backup
# 重新部署
./scripts/deploy.sh deploy
```
### 2.4 开发环境
```bash
# 使用开发配置
cp env.dev.example .env.dev
# 启动开发环境
docker-compose -f docker-compose.dev.yml up -d
# 访问
# 前端: http://localhost:3000 (热重载)
# 后端: http://localhost:8000
# API 文档: http://localhost:8000/docs
```
---
## 3. 配置管理
### 3.1 环境变量说明
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `APP_ENV` | 运行环境 | production |
| `DEBUG` | 调试模式 | false |
| `SECRET_KEY` | 应用密钥 | 必须配置 |
| `DATABASE_URL` | 数据库连接 | 必须配置 |
| `MYSQL_ROOT_PASSWORD` | MySQL root 密码 | 必须配置 |
| `MYSQL_USER` | MySQL 用户名 | pricing_user |
| `MYSQL_PASSWORD` | MySQL 密码 | 必须配置 |
| `PORTAL_CONFIG_API` | 门户系统 API | http://portal-backend:8000/api/ai/internal/config |
| `CORS_ORIGINS` | 允许的跨域来源 | ["https://pricing.example.com"] |
| `DB_POOL_SIZE` | 数据库连接池大小 | 5 |
| `DB_MAX_OVERFLOW` | 连接池溢出上限 | 10 |
### 3.2 Nginx 配置
主要配置项:
```nginx
# 域名
server_name pricing.yourcompany.com;
# SSL 证书路径
ssl_certificate /etc/nginx/ssl/pricing.yourcompany.com.pem;
ssl_certificate_key /etc/nginx/ssl/pricing.yourcompany.com.key;
# 上传文件大小限制
client_max_body_size 10M;
# AI 接口超时(较长)
proxy_read_timeout 120s;
```
### 3.3 Docker 资源限制
```yaml
# docker-compose.yml 中的资源限制
deploy:
resources:
limits:
cpus: '1'
memory: 512M # 后端
reservations:
cpus: '0.25'
memory: 128M
```
建议配置:
| 服务 | 内存限制 | CPU 限制 |
|------|----------|----------|
| 前端 | 256M | 0.5 |
| 后端 | 512M | 1.0 |
| MySQL | 1G | 1.0 |
---
## 4. 日常运维
### 4.1 服务管理
```bash
# 查看服务状态
./scripts/deploy.sh status
# 重启所有服务
./scripts/deploy.sh restart
# 停止服务
./scripts/deploy.sh stop
# 查看日志
./scripts/deploy.sh logs
# 查看特定服务日志
docker-compose logs -f pricing-backend
docker-compose logs -f pricing-mysql
```
### 4.2 容器管理
```bash
# 进入后端容器
docker exec -it pricing-backend /bin/bash
# 进入 MySQL 容器
docker exec -it pricing-mysql mysql -u root -p
# 重启单个服务
docker-compose restart pricing-backend
```
### 4.3 数据库管理
```bash
# 连接数据库
docker exec -it pricing-mysql mysql -u root -p pricing_model
# 常用 SQL
-- 查看表
SHOW TABLES;
-- 查看项目数量
SELECT COUNT(*) FROM projects;
-- 查看最近操作日志
SELECT * FROM operation_logs ORDER BY created_at DESC LIMIT 10;
```
### 4.4 日志管理
日志位置:
```bash
# Docker 日志
/var/lib/docker/containers/<container_id>/<container_id>-json.log
# 查看日志大小
docker system df -v
```
清理日志:
```bash
# 清理停止的容器
docker container prune
# 清理未使用的镜像
docker image prune
# 清理所有未使用资源
docker system prune
```
---
## 5. 备份与恢复
### 5.1 自动备份
配置定时备份:
```bash
# 编辑 crontab
crontab -e
# 添加每日备份任务(每天凌晨 2 点)
0 2 * * * /opt/pricing-model/scripts/backup.sh backup >> /var/log/pricing-backup.log 2>&1
```
### 5.2 手动备份
```bash
# 执行备份
./scripts/backup.sh backup
# 查看备份列表
./scripts/backup.sh list
# 清理旧备份
./scripts/backup.sh cleanup
```
备份文件位置:`/data/backups/pricing_model/`
### 5.3 恢复数据
```bash
# 恢复指定备份
./scripts/backup.sh restore pricing_model_20260120_020000.sql.gz
# 或指定完整路径
./scripts/backup.sh restore /data/backups/pricing_model/pricing_model_20260120_020000.sql.gz
```
> **警告**:恢复操作会覆盖当前数据,请谨慎操作!
### 5.4 备份策略建议
| 备份类型 | 频率 | 保留期 |
|----------|------|--------|
| 数据库全量 | 每日 | 7 天 |
| 配置文件 | 变更时 | 长期 |
| 代码 | Git 管理 | 长期 |
---
## 6. 监控与告警
### 6.1 健康检查
```bash
# 运行监控检查
./scripts/monitor.sh report
# 快速检查(适合 cron
./scripts/monitor.sh quick
```
### 6.2 监控指标
| 指标 | 检查方式 | 告警阈值 |
|------|----------|----------|
| 容器状态 | docker ps | 非 running |
| 健康检查 | /health API | 响应失败 |
| 磁盘使用 | df -h | > 80% 警告, > 90% 严重 |
| 内存使用 | free | > 80% 警告, > 90% 严重 |
| API 响应 | curl | > 2s 警告 |
### 6.3 配置定时监控
```bash
# 每 5 分钟检查一次
*/5 * * * * /opt/pricing-model/scripts/monitor.sh quick >> /var/log/pricing-monitor.log 2>&1
```
### 6.4 告警配置
编辑 `scripts/monitor.sh` 配置告警方式:
```bash
# 邮件告警
ALERT_EMAIL=admin@yourcompany.com
# 企业微信/钉钉 webhook
WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx
```
---
## 7. 故障排查
### 7.1 服务无法启动
**检查步骤**
```bash
# 1. 检查 Docker 服务
systemctl status docker
# 2. 检查容器日志
docker-compose logs pricing-backend
docker-compose logs pricing-mysql
# 3. 检查端口占用
netstat -tlnp | grep -E '8000|3306'
# 4. 检查磁盘空间
df -h
```
### 7.2 数据库连接失败
```bash
# 1. 检查 MySQL 容器状态
docker ps | grep pricing-mysql
# 2. 检查数据库日志
docker logs pricing-mysql
# 3. 测试数据库连接
docker exec pricing-mysql mysqladmin ping -h localhost
# 4. 检查 DATABASE_URL 配置
cat .env | grep DATABASE_URL
```
### 7.3 API 响应慢
```bash
# 1. 检查后端容器资源
docker stats pricing-backend
# 2. 检查数据库慢查询
docker exec -it pricing-mysql mysql -e "SHOW PROCESSLIST;"
# 3. 检查 AI 服务连接
curl -sf http://portal-backend:8000/api/ai/internal/config
```
### 7.4 前端页面空白
```bash
# 1. 检查前端容器
docker logs pricing-frontend
# 2. 检查 Nginx 配置
nginx -t
# 3. 检查静态文件
docker exec pricing-frontend ls -la /usr/share/nginx/html/
```
### 7.5 常见错误及解决
| 错误 | 可能原因 | 解决方案 |
|------|----------|----------|
| `Connection refused` | 服务未启动 | 重启服务 |
| `Access denied` | 数据库密码错误 | 检查 .env 配置 |
| `Network unreachable` | 网络配置错误 | 检查 Docker 网络 |
| `No space left` | 磁盘满 | 清理日志/扩容 |
| `Out of memory` | 内存不足 | 增加内存/优化限制 |
---
## 8. 安全规范
### 8.1 敏感信息管理
- `.env` 文件权限必须为 600
- 禁止将 `.env` 提交到 Git
- 定期轮换数据库密码
- API Key 从门户系统获取,禁止硬编码
### 8.2 网络安全
- 仅 Nginx 暴露公网端口80/443
- 后端服务仅内网访问
- 数据库端口禁止外部访问
- 启用 HTTPSHTTP 自动重定向
### 8.3 访问控制
- 使用 OAuth 统一认证
- 敏感操作记录审计日志
- 定期审查用户权限
### 8.4 安全检查清单
- [ ] .env 文件权限为 600
- [ ] 已修改默认密码
- [ ] SECRET_KEY 使用随机字符串
- [ ] HTTPS 已启用
- [ ] 数据库端口未暴露公网
- [ ] 定期备份已配置
- [ ] 监控告警已启用
---
## 9. 附录
### 9.1 目录结构
```
/opt/pricing-model/
├── 后端服务/ # 后端代码
├── 前端应用/ # 前端代码
├── scripts/ # 运维脚本
│ ├── deploy.sh # 部署脚本
│ ├── backup.sh # 备份脚本
│ ├── setup-ssl.sh # SSL 配置
│ └── monitor.sh # 监控脚本
├── docs/ # 文档
├── docker-compose.yml # 生产环境配置
├── docker-compose.dev.yml# 开发环境配置
├── nginx.conf # Nginx 配置
├── init.sql # 数据库初始化
├── .env # 环境变量(不提交)
└── .env.example # 环境变量模板
```
### 9.2 端口说明
| 端口 | 服务 | 说明 |
|------|------|------|
| 80 | Nginx | HTTP重定向到 HTTPS |
| 443 | Nginx | HTTPS |
| 8000 | 后端 | API 服务(内网) |
| 3306 | MySQL | 数据库(内网) |
### 9.3 常用命令速查
```bash
# 部署
./scripts/deploy.sh deploy
# 重启
./scripts/deploy.sh restart
# 查看日志
./scripts/deploy.sh logs
# 备份
./scripts/backup.sh backup
# 监控
./scripts/monitor.sh report
# 进入容器
docker exec -it pricing-backend /bin/bash
docker exec -it pricing-mysql mysql -u root -p
```
### 9.4 相关文档
- 《瑞小美系统技术栈标准与字符标准》
- 《瑞小美 AI 接入规范》
- 《智能项目定价模型 - 产品需求文档》
- 《智能项目定价模型 - API 接口文档》
---
## 技术支持
如遇问题,请联系瑞小美技术团队。
---
*瑞小美技术团队 · 2026-01-20*

46
env.dev.example Normal file
View File

@@ -0,0 +1,46 @@
# 智能项目定价模型 - 开发环境配置
# 复制此文件为 .env.dev
# ============================================================
# 应用配置
# ============================================================
APP_NAME=智能项目定价模型
APP_VERSION=1.0.0
APP_ENV=development
DEBUG=true
SECRET_KEY=dev-secret-key-not-for-production
# ============================================================
# 数据库配置
# ============================================================
DATABASE_URL=mysql+aiomysql://pricing_user:pricing123@pricing-mysql:3306/pricing_model?charset=utf8mb4
# MySQL 容器配置
MYSQL_ROOT_PASSWORD=root123
MYSQL_USER=pricing_user
MYSQL_PASSWORD=pricing123
# ============================================================
# 门户系统配置(开发环境可使用测试 Key
# ============================================================
PORTAL_CONFIG_API=http://portal-backend:8000/api/ai/internal/config
# ============================================================
# AI 服务配置
# ============================================================
AI_MODULE_CODE=pricing_model
# ============================================================
# 时区配置
# ============================================================
TIMEZONE=Asia/Shanghai
# ============================================================
# CORS 配置(开发环境允许所有源)
# ============================================================
CORS_ORIGINS=["*"]
# ============================================================
# API 配置
# ============================================================
API_V1_PREFIX=/api/v1

57
env.example Normal file
View File

@@ -0,0 +1,57 @@
# 智能项目定价模型 - 环境变量配置模板
# 复制此文件为 .env 并修改配置值
# 重要:.env 文件权限应设置为 600 (chmod 600 .env)
# ============================================================
# 应用配置
# ============================================================
APP_NAME=智能项目定价模型
APP_VERSION=1.0.0
APP_ENV=production
DEBUG=false
# 密钥(生产环境请修改为随机字符串)
SECRET_KEY=your-secret-key-change-in-production-use-random-string
# ============================================================
# 数据库配置
# MySQL 8.0, utf8mb4, utf8mb4_unicode_ci
# ============================================================
DATABASE_URL=mysql+aiomysql://pricing_user:your_password@pricing-mysql:3306/pricing_model?charset=utf8mb4
# MySQL 容器配置
MYSQL_ROOT_PASSWORD=your_root_password
MYSQL_USER=pricing_user
MYSQL_PASSWORD=your_password
# 连接池配置
DB_POOL_SIZE=5
DB_MAX_OVERFLOW=10
DB_POOL_RECYCLE=3600
# ============================================================
# 门户系统配置
# AI Key 从门户系统获取(禁止硬编码)
# ============================================================
PORTAL_CONFIG_API=http://portal-backend:8000/api/ai/internal/config
# ============================================================
# AI 服务配置
# ============================================================
AI_MODULE_CODE=pricing_model
# ============================================================
# 时区配置
# ============================================================
TIMEZONE=Asia/Shanghai
# ============================================================
# CORS 配置
# 生产环境应限制为具体域名
# ============================================================
CORS_ORIGINS=["https://pricing.example.com"]
# ============================================================
# API 配置
# ============================================================
API_V1_PREFIX=/api/v1

224
init.sql Normal file
View File

@@ -0,0 +1,224 @@
-- 智能项目定价模型 - 数据库初始化脚本
-- 遵循瑞小美字符标准utf8mb4, utf8mb4_unicode_ci
-- 设置字符集
SET NAMES utf8mb4;
SET CHARACTER SET utf8mb4;
-- 使用数据库
USE pricing_model;
-- ============================================================
-- M2 核心功能 - 成本核算模块表
-- ============================================================
-- 项目成本明细表(耗材/设备)
CREATE TABLE IF NOT EXISTS project_cost_items (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT NOT NULL,
item_type VARCHAR(20) NOT NULL COMMENT 'material-耗材, equipment-设备',
item_id BIGINT NOT NULL,
quantity DECIMAL(10,4) NOT NULL,
unit_cost DECIMAL(12,4) NOT NULL,
total_cost DECIMAL(12,2) NOT NULL COMMENT '= quantity * unit_cost',
remark VARCHAR(200),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_project_id (project_id),
INDEX idx_item_type (item_type),
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 项目人工成本表
CREATE TABLE IF NOT EXISTS project_labor_costs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT NOT NULL,
staff_level_id BIGINT NOT NULL,
duration_minutes INT NOT NULL,
hourly_rate DECIMAL(10,2) NOT NULL COMMENT '记录时的时薪快照',
labor_cost DECIMAL(12,2) NOT NULL COMMENT '= duration/60 * hourly_rate',
remark VARCHAR(200),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_project_id (project_id),
INDEX idx_staff_level_id (staff_level_id),
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (staff_level_id) REFERENCES staff_levels(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 项目成本汇总表
CREATE TABLE IF NOT EXISTS project_cost_summaries (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT NOT NULL UNIQUE,
material_cost DECIMAL(12,2) NOT NULL DEFAULT 0.00,
equipment_cost DECIMAL(12,2) NOT NULL DEFAULT 0.00,
labor_cost DECIMAL(12,2) NOT NULL DEFAULT 0.00,
fixed_cost_allocation DECIMAL(12,2) NOT NULL DEFAULT 0.00,
total_cost DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '最低成本线',
calculated_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_project_id (project_id),
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- M2 核心功能 - 市场行情模块表
-- ============================================================
-- 竞品机构表
CREATE TABLE IF NOT EXISTS competitors (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
competitor_name VARCHAR(100) NOT NULL,
address VARCHAR(200),
distance_km DECIMAL(5,2),
positioning VARCHAR(20) NOT NULL DEFAULT 'medium' COMMENT 'high-高端, medium-中端, budget-大众',
contact VARCHAR(50),
is_key_competitor TINYINT(1) NOT NULL DEFAULT 0,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_positioning (positioning),
INDEX idx_is_key (is_key_competitor)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 竞品价格表
CREATE TABLE IF NOT EXISTS competitor_prices (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
competitor_id BIGINT NOT NULL,
project_id BIGINT COMMENT '关联本店项目',
project_name VARCHAR(100) NOT NULL COMMENT '竞品项目名称',
original_price DECIMAL(12,2) NOT NULL,
promo_price DECIMAL(12,2),
member_price DECIMAL(12,2),
price_source VARCHAR(20) NOT NULL COMMENT 'official-官网, meituan-美团, dianping-大众点评, survey-实地调研',
collected_at DATE NOT NULL,
remark VARCHAR(200),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_competitor_id (competitor_id),
INDEX idx_project_id (project_id),
INDEX idx_collected_at (collected_at),
FOREIGN KEY (competitor_id) REFERENCES competitors(id) ON DELETE CASCADE,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 标杆价格表
CREATE TABLE IF NOT EXISTS benchmark_prices (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
benchmark_name VARCHAR(100) NOT NULL,
category_id BIGINT,
min_price DECIMAL(12,2) NOT NULL,
max_price DECIMAL(12,2) NOT NULL,
avg_price DECIMAL(12,2) NOT NULL,
price_tier VARCHAR(20) NOT NULL DEFAULT 'medium' COMMENT 'low-低端, medium-中端, high-高端, premium-奢华',
effective_date DATE NOT NULL,
remark VARCHAR(200),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_category_id (category_id),
INDEX idx_effective_date (effective_date),
FOREIGN KEY (category_id) REFERENCES categories(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 市场分析结果表
CREATE TABLE IF NOT EXISTS market_analysis_results (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT NOT NULL,
analysis_date DATE NOT NULL,
competitor_count INT NOT NULL,
market_min_price DECIMAL(12,2) NOT NULL,
market_max_price DECIMAL(12,2) NOT NULL,
market_avg_price DECIMAL(12,2) NOT NULL,
market_median_price DECIMAL(12,2) NOT NULL,
suggested_range_min DECIMAL(12,2) NOT NULL,
suggested_range_max DECIMAL(12,2) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_project_id (project_id),
INDEX idx_analysis_date (analysis_date),
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- M3 智能功能 - 智能定价与利润模拟模块表
-- ============================================================
-- 定价方案表
CREATE TABLE IF NOT EXISTS pricing_plans (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT NOT NULL,
plan_name VARCHAR(100) NOT NULL,
strategy_type VARCHAR(20) NOT NULL DEFAULT 'profit' COMMENT 'traffic-引流款, profit-利润款, premium-高端款',
base_cost DECIMAL(12,2) NOT NULL COMMENT '基础成本(快照)',
target_margin DECIMAL(5,2) NOT NULL COMMENT '目标毛利率 %',
suggested_price DECIMAL(12,2) NOT NULL COMMENT 'AI建议价格',
final_price DECIMAL(12,2) COMMENT '最终定价',
ai_advice TEXT COMMENT 'AI定价建议原文',
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_by BIGINT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_project_id (project_id),
INDEX idx_strategy_type (strategy_type),
INDEX idx_is_active (is_active),
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 利润模拟表
CREATE TABLE IF NOT EXISTS profit_simulations (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
pricing_plan_id BIGINT NOT NULL,
simulation_name VARCHAR(100) NOT NULL,
price DECIMAL(12,2) NOT NULL COMMENT '模拟价格',
estimated_volume INT NOT NULL COMMENT '预估客量',
period_type VARCHAR(20) NOT NULL DEFAULT 'monthly' COMMENT 'daily-日, weekly-周, monthly-月, yearly-年',
estimated_revenue DECIMAL(14,2) NOT NULL COMMENT '预估收入',
estimated_cost DECIMAL(14,2) NOT NULL COMMENT '预估总成本',
estimated_profit DECIMAL(14,2) NOT NULL COMMENT '预估利润',
profit_margin DECIMAL(5,2) NOT NULL COMMENT '利润率 %',
breakeven_volume INT NOT NULL COMMENT '盈亏平衡客量',
created_by BIGINT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_pricing_plan_id (pricing_plan_id),
INDEX idx_period_type (period_type),
FOREIGN KEY (pricing_plan_id) REFERENCES pricing_plans(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 敏感性分析表
CREATE TABLE IF NOT EXISTS sensitivity_analyses (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
simulation_id BIGINT NOT NULL,
price_change_rate DECIMAL(5,2) NOT NULL COMMENT '价格变动幅度 %',
adjusted_price DECIMAL(12,2) NOT NULL COMMENT '调整后价格',
adjusted_profit DECIMAL(14,2) NOT NULL COMMENT '调整后利润',
profit_change_rate DECIMAL(6,2) NOT NULL COMMENT '利润变动幅度 %',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_simulation_id (simulation_id),
FOREIGN KEY (simulation_id) REFERENCES profit_simulations(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- 初始化数据
-- ============================================================
-- 初始化人员级别数据
INSERT INTO staff_levels (level_code, level_name, hourly_rate, is_active, created_at, updated_at) VALUES
('L1', '初级美容师', 30.00, 1, NOW(), NOW()),
('L2', '中级美容师', 50.00, 1, NOW(), NOW()),
('L3', '高级美容师', 80.00, 1, NOW(), NOW()),
('L4', '资深美容师', 120.00, 1, NOW(), NOW()),
('D1', '主治医师', 200.00, 1, NOW(), NOW()),
('D2', '副主任医师', 350.00, 1, NOW(), NOW()),
('D3', '主任医师', 500.00, 1, NOW(), NOW())
ON DUPLICATE KEY UPDATE updated_at = NOW();
-- 初始化项目分类数据
INSERT INTO categories (category_name, parent_id, sort_order, is_active, created_at, updated_at) VALUES
('皮肤管理', NULL, 1, 1, NOW(), NOW()),
('注射类', NULL, 2, 1, NOW(), NOW()),
('光电类', NULL, 3, 1, NOW(), NOW()),
('手术类', NULL, 4, 1, NOW(), NOW())
ON DUPLICATE KEY UPDATE updated_at = NOW();

94
nginx.conf Normal file
View File

@@ -0,0 +1,94 @@
# 智能项目定价模型 - Nginx 反向代理配置
# 遵循瑞小美部署规范:仅暴露 80/443 端口SSL 终止
# HTTP 重定向到 HTTPS
server {
listen 80;
server_name pricing.example.com;
# Let's Encrypt 验证路径
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# 其他请求重定向到 HTTPS
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS 配置
server {
listen 443 ssl http2;
server_name pricing.example.com;
# SSL 证书配置
ssl_certificate /etc/nginx/ssl/pricing.example.com.pem;
ssl_certificate_key /etc/nginx/ssl/pricing.example.com.key;
# SSL 安全配置
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
# 安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# 启用 gzip 压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss application/atom+xml image/svg+xml;
# 请求体大小限制(用于文件上传)
client_max_body_size 10M;
# 前端静态资源
location / {
proxy_pass http://pricing-frontend:80;
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_cache_bypass $http_upgrade;
}
# 后端 API
location /api/ {
proxy_pass http://pricing-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;
# 超时配置AI 接口可能较慢)
proxy_connect_timeout 60s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
# WebSocket 支持(用于 AI 流式输出)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# 健康检查
location /health {
proxy_pass http://pricing-backend:8000/health;
proxy_set_header Host $host;
access_log off;
}
# 禁止访问敏感文件
location ~ /\. {
deny all;
}
}

178
scripts/backup.sh Executable file
View File

@@ -0,0 +1,178 @@
#!/bin/bash
# 智能项目定价模型 - 数据库备份脚本
# 遵循瑞小美部署规范
set -e
# 配置
BACKUP_DIR="/data/backups/pricing_model"
RETENTION_DAYS=7
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="pricing_model_${DATE}.sql.gz"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() {
echo -e "${GREEN}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"
}
# 加载环境变量
load_env() {
cd "$(dirname "$0")/.."
if [ -f ".env" ]; then
source .env
else
log_error ".env 文件不存在"
exit 1
fi
}
# 创建备份目录
create_backup_dir() {
if [ ! -d "$BACKUP_DIR" ]; then
mkdir -p "$BACKUP_DIR"
chmod 700 "$BACKUP_DIR"
fi
}
# 执行备份
do_backup() {
log_info "开始备份数据库..."
# 使用 docker exec 执行 mysqldump
docker exec pricing-mysql mysqldump \
-u root \
-p"${MYSQL_ROOT_PASSWORD}" \
--single-transaction \
--routines \
--triggers \
--databases pricing_model \
2>/dev/null | gzip > "${BACKUP_DIR}/${BACKUP_FILE}"
if [ $? -eq 0 ]; then
local size=$(du -h "${BACKUP_DIR}/${BACKUP_FILE}" | cut -f1)
log_info "备份完成: ${BACKUP_FILE} (${size})"
else
log_error "备份失败"
rm -f "${BACKUP_DIR}/${BACKUP_FILE}"
exit 1
fi
}
# 清理旧备份
cleanup_old_backups() {
log_info "清理 ${RETENTION_DAYS} 天前的备份..."
local deleted=0
while IFS= read -r file; do
rm -f "$file"
deleted=$((deleted + 1))
done < <(find "$BACKUP_DIR" -name "pricing_model_*.sql.gz" -mtime +${RETENTION_DAYS} -type f)
if [ $deleted -gt 0 ]; then
log_info "已删除 ${deleted} 个旧备份"
fi
}
# 列出备份
list_backups() {
log_info "备份列表:"
echo ""
ls -lh "${BACKUP_DIR}"/pricing_model_*.sql.gz 2>/dev/null || echo "暂无备份"
echo ""
}
# 恢复备份
restore_backup() {
local backup_file="$1"
if [ -z "$backup_file" ]; then
log_error "请指定备份文件"
list_backups
exit 1
fi
if [ ! -f "$backup_file" ]; then
# 尝试在备份目录中查找
backup_file="${BACKUP_DIR}/${backup_file}"
if [ ! -f "$backup_file" ]; then
log_error "备份文件不存在: $backup_file"
exit 1
fi
fi
log_warn "即将恢复数据库,当前数据将被覆盖!"
read -p "确认恢复? (yes/no): " confirm
if [ "$confirm" != "yes" ]; then
log_info "取消恢复"
exit 0
fi
log_info "开始恢复数据库..."
gunzip -c "$backup_file" | docker exec -i pricing-mysql mysql \
-u root \
-p"${MYSQL_ROOT_PASSWORD}" \
2>/dev/null
if [ $? -eq 0 ]; then
log_info "数据库恢复完成"
else
log_error "恢复失败"
exit 1
fi
}
# 主函数
main() {
local action="${1:-backup}"
load_env
create_backup_dir
case $action in
backup)
do_backup
cleanup_old_backups
;;
restore)
restore_backup "$2"
;;
list)
list_backups
;;
cleanup)
cleanup_old_backups
;;
*)
echo "用法: $0 {backup|restore <file>|list|cleanup}"
echo ""
echo "命令:"
echo " backup 执行备份"
echo " restore <file> 恢复指定备份"
echo " list 列出所有备份"
echo " cleanup 清理旧备份"
exit 1
;;
esac
}
main "$@"

246
scripts/deploy.sh Executable file
View File

@@ -0,0 +1,246 @@
#!/bin/bash
# 智能项目定价模型 - 生产环境部署脚本
# 遵循瑞小美部署规范
set -e
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# 检查必要命令
check_requirements() {
log_info "检查环境依赖..."
local requirements=("docker" "docker-compose")
for cmd in "${requirements[@]}"; do
if ! command -v $cmd &> /dev/null; then
log_error "$cmd 未安装"
exit 1
fi
done
log_info "环境检查通过"
}
# 检查 .env 文件
check_env_file() {
log_info "检查环境配置..."
if [ ! -f ".env" ]; then
log_error ".env 文件不存在,请从 env.example 复制并配置"
log_info "执行: cp env.example .env && chmod 600 .env"
exit 1
fi
# 检查 .env 文件权限
local perms=$(stat -c %a .env 2>/dev/null || stat -f %OLp .env 2>/dev/null)
if [ "$perms" != "600" ]; then
log_warn ".env 文件权限不是 600正在修复..."
chmod 600 .env
fi
# 检查必要配置项
local required_vars=("DATABASE_URL" "MYSQL_ROOT_PASSWORD" "MYSQL_PASSWORD" "SECRET_KEY")
source .env
for var in "${required_vars[@]}"; do
if [ -z "${!var}" ]; then
log_error "缺少必要配置: $var"
exit 1
fi
done
# 检查是否修改了默认密码
if [[ "$SECRET_KEY" == *"change-in-production"* ]]; then
log_error "请修改 SECRET_KEY 为随机字符串"
exit 1
fi
log_info "配置检查通过"
}
# 检查网络
check_network() {
log_info "检查 Docker 网络..."
# 检查 scrm_network 是否存在
if ! docker network ls | grep -q "scrm_network"; then
log_info "创建 scrm_network 网络..."
docker network create scrm_network
fi
log_info "网络检查通过"
}
# 拉取/构建镜像
build_images() {
log_info "构建 Docker 镜像..."
docker-compose build --no-cache
log_info "镜像构建完成"
}
# 停止旧服务
stop_services() {
log_info "停止旧服务..."
docker-compose down --remove-orphans 2>/dev/null || true
log_info "旧服务已停止"
}
# 启动服务
start_services() {
log_info "启动服务..."
docker-compose up -d
log_info "服务启动中..."
}
# 等待服务就绪
wait_for_services() {
log_info "等待服务就绪..."
local max_attempts=30
local attempt=0
# 等待后端健康检查
while [ $attempt -lt $max_attempts ]; do
if docker-compose exec -T pricing-backend curl -sf http://localhost:8000/health > /dev/null 2>&1; then
log_info "后端服务就绪"
break
fi
attempt=$((attempt + 1))
echo -n "."
sleep 2
done
if [ $attempt -eq $max_attempts ]; then
log_error "服务启动超时"
docker-compose logs --tail=50
exit 1
fi
echo ""
}
# 刷新 Nginx DNS 缓存
refresh_nginx() {
log_info "刷新 Nginx DNS 缓存..."
# 检查 nginx_proxy 容器是否存在
if docker ps | grep -q "nginx_proxy"; then
docker exec nginx_proxy nginx -s reload 2>/dev/null || log_warn "Nginx reload 失败,请手动执行"
else
log_warn "nginx_proxy 容器未运行,请确保 Nginx 配置正确"
fi
}
# 显示服务状态
show_status() {
log_info "服务状态:"
echo ""
docker-compose ps
echo ""
log_info "健康检查:"
curl -sf http://localhost:8000/health 2>/dev/null && echo "" || log_warn "后端服务不可访问"
echo ""
log_info "部署完成!"
echo ""
echo "访问地址:"
echo " - 前端: https://pricing.example.com (需配置 Nginx)"
echo " - 后端 API: http://localhost:8000"
echo " - API 文档: http://localhost:8000/docs (仅开发环境)"
}
# 回滚
rollback() {
log_warn "执行回滚..."
docker-compose down
# 如果有备份,恢复
if [ -f ".env.backup" ]; then
mv .env.backup .env
fi
log_info "回滚完成,请检查日志排查问题"
}
# 主函数
main() {
local action="${1:-deploy}"
cd "$(dirname "$0")/.."
case $action in
deploy)
log_info "开始部署智能项目定价模型..."
echo ""
check_requirements
check_env_file
check_network
build_images
stop_services
start_services
wait_for_services
refresh_nginx
show_status
;;
restart)
log_info "重启服务..."
docker-compose restart
wait_for_services
refresh_nginx
show_status
;;
stop)
log_info "停止服务..."
docker-compose down
log_info "服务已停止"
;;
status)
show_status
;;
logs)
docker-compose logs -f --tail=100
;;
rollback)
rollback
;;
*)
echo "用法: $0 {deploy|restart|stop|status|logs|rollback}"
exit 1
;;
esac
}
# 捕获错误
trap 'log_error "部署失败"; exit 1' ERR
main "$@"

318
scripts/monitor.sh Executable file
View File

@@ -0,0 +1,318 @@
#!/bin/bash
# 智能项目定价模型 - 监控检查脚本
# 遵循瑞小美部署规范
set -e
# 配置
ALERT_EMAIL="${ALERT_EMAIL:-admin@example.com}"
WEBHOOK_URL="${WEBHOOK_URL:-}" # 企业微信/钉钉 webhook
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() {
echo -e "${GREEN}[OK]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# 发送告警
send_alert() {
local title="$1"
local message="$2"
local level="${3:-warning}" # warning, error
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
local full_message="[$timestamp] [智能项目定价模型] $title: $message"
# 控制台输出
if [ "$level" = "error" ]; then
log_error "$full_message"
else
log_warn "$full_message"
fi
# 发送企业微信/钉钉通知
if [ -n "$WEBHOOK_URL" ]; then
curl -s -X POST "$WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d "{\"msgtype\":\"text\",\"text\":{\"content\":\"$full_message\"}}" \
> /dev/null 2>&1 || true
fi
# 发送邮件(如果配置了 sendmail
if [ -n "$ALERT_EMAIL" ] && command -v sendmail &> /dev/null; then
echo -e "Subject: [告警] 智能项目定价模型 - $title\n\n$full_message" | \
sendmail "$ALERT_EMAIL" 2>/dev/null || true
fi
}
# 检查 Docker 容器状态
check_containers() {
echo "检查容器状态..."
echo ""
local containers=("pricing-frontend" "pricing-backend" "pricing-mysql")
local all_healthy=true
for container in "${containers[@]}"; do
local status=$(docker inspect --format='{{.State.Status}}' "$container" 2>/dev/null || echo "not_found")
local health=$(docker inspect --format='{{.State.Health.Status}}' "$container" 2>/dev/null || echo "unknown")
if [ "$status" = "running" ]; then
if [ "$health" = "healthy" ] || [ "$health" = "unknown" ]; then
log_info "$container: $status (health: $health)"
else
log_warn "$container: $status (health: $health)"
all_healthy=false
fi
else
log_error "$container: $status"
send_alert "容器异常" "$container 状态异常: $status" "error"
all_healthy=false
fi
done
echo ""
return $([ "$all_healthy" = true ] && echo 0 || echo 1)
}
# 检查服务健康
check_health() {
echo "检查服务健康..."
echo ""
# 后端健康检查
local backend_health=$(curl -sf http://localhost:8000/health 2>/dev/null)
if [ $? -eq 0 ]; then
log_info "后端服务: 健康"
echo " 响应: $backend_health"
else
log_error "后端服务: 不可访问"
send_alert "服务异常" "后端服务健康检查失败" "error"
fi
# 前端健康检查(通过 Nginx
local frontend_health=$(curl -sf http://localhost/health 2>/dev/null)
if [ $? -eq 0 ]; then
log_info "前端服务: 健康"
else
log_warn "前端服务: 不可访问(可能需要通过 Nginx 代理)"
fi
echo ""
}
# 检查数据库连接
check_database() {
echo "检查数据库..."
echo ""
local db_status=$(docker exec pricing-mysql mysqladmin ping -h localhost 2>&1 || echo "failed")
if [[ "$db_status" == *"alive"* ]]; then
log_info "MySQL: 连接正常"
# 检查表数量
local table_count=$(docker exec pricing-mysql mysql -N -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='pricing_model'" 2>/dev/null || echo "0")
echo " 数据库表数量: $table_count"
else
log_error "MySQL: 连接失败"
send_alert "数据库异常" "MySQL 连接失败" "error"
fi
echo ""
}
# 检查磁盘空间
check_disk() {
echo "检查磁盘空间..."
echo ""
# 检查根目录
local disk_usage=$(df -h / | awk 'NR==2 {print $5}' | tr -d '%')
if [ "$disk_usage" -lt 80 ]; then
log_info "磁盘使用率: ${disk_usage}%"
elif [ "$disk_usage" -lt 90 ]; then
log_warn "磁盘使用率: ${disk_usage}% (警告)"
send_alert "磁盘空间不足" "磁盘使用率达到 ${disk_usage}%" "warning"
else
log_error "磁盘使用率: ${disk_usage}% (危险)"
send_alert "磁盘空间严重不足" "磁盘使用率达到 ${disk_usage}%" "error"
fi
# 检查 Docker 卷
local docker_disk=$(docker system df --format '{{.Size}}' 2>/dev/null | head -1)
echo " Docker 磁盘占用: $docker_disk"
echo ""
}
# 检查内存
check_memory() {
echo "检查内存使用..."
echo ""
local mem_usage=$(free | awk 'NR==2 {printf "%.0f", $3*100/$2}')
if [ "$mem_usage" -lt 80 ]; then
log_info "内存使用率: ${mem_usage}%"
elif [ "$mem_usage" -lt 90 ]; then
log_warn "内存使用率: ${mem_usage}% (警告)"
send_alert "内存不足" "内存使用率达到 ${mem_usage}%" "warning"
else
log_error "内存使用率: ${mem_usage}% (危险)"
send_alert "内存严重不足" "内存使用率达到 ${mem_usage}%" "error"
fi
# 容器内存使用
echo " 容器内存使用:"
docker stats --no-stream --format " {{.Name}}: {{.MemUsage}}" 2>/dev/null | grep pricing || true
echo ""
}
# 检查日志错误
check_logs() {
echo "检查最近错误日志..."
echo ""
# 检查后端错误日志(最近 100 行)
local error_count=$(docker logs pricing-backend --tail 100 2>&1 | grep -c -i "error" || echo "0")
if [ "$error_count" -eq 0 ]; then
log_info "后端日志: 无错误"
elif [ "$error_count" -lt 10 ]; then
log_warn "后端日志: 发现 $error_count 个错误"
else
log_error "后端日志: 发现 $error_count 个错误"
send_alert "日志错误过多" "后端日志发现 $error_count 个错误" "warning"
fi
echo ""
}
# 检查 API 响应时间
check_api_performance() {
echo "检查 API 性能..."
echo ""
local start_time=$(date +%s%N)
local response=$(curl -sf -o /dev/null -w '%{http_code}' http://localhost:8000/health 2>/dev/null || echo "000")
local end_time=$(date +%s%N)
local latency=$(( (end_time - start_time) / 1000000 ))
if [ "$response" = "200" ]; then
if [ "$latency" -lt 500 ]; then
log_info "健康检查 API: ${latency}ms"
elif [ "$latency" -lt 2000 ]; then
log_warn "健康检查 API: ${latency}ms (较慢)"
else
log_error "健康检查 API: ${latency}ms (过慢)"
send_alert "API 响应过慢" "健康检查 API 响应时间 ${latency}ms" "warning"
fi
else
log_error "健康检查 API: 请求失败 (HTTP $response)"
fi
echo ""
}
# 生成报告
generate_report() {
echo "=========================================="
echo " 智能项目定价模型 - 监控报告"
echo " $(date '+%Y-%m-%d %H:%M:%S')"
echo "=========================================="
echo ""
check_containers
check_health
check_database
check_disk
check_memory
check_logs
check_api_performance
echo "=========================================="
echo " 检查完成"
echo "=========================================="
}
# 主函数
main() {
local action="${1:-report}"
cd "$(dirname "$0")/.."
case $action in
report)
generate_report
;;
containers)
check_containers
;;
health)
check_health
;;
database)
check_database
;;
disk)
check_disk
;;
memory)
check_memory
;;
logs)
check_logs
;;
quick)
# 快速检查(适合 cron
check_containers || exit 1
check_database || exit 1
check_disk || exit 1
;;
*)
echo "智能项目定价模型 - 监控检查脚本"
echo ""
echo "用法: $0 {report|containers|health|database|disk|memory|logs|quick}"
echo ""
echo "命令:"
echo " report 完整监控报告"
echo " containers 检查容器状态"
echo " health 检查服务健康"
echo " database 检查数据库"
echo " disk 检查磁盘空间"
echo " memory 检查内存使用"
echo " logs 检查错误日志"
echo " quick 快速检查(适合 cron"
echo ""
echo "环境变量:"
echo " ALERT_EMAIL 告警邮箱"
echo " WEBHOOK_URL 企业微信/钉钉 webhook"
;;
esac
}
main "$@"

235
scripts/setup-ssl.sh Executable file
View File

@@ -0,0 +1,235 @@
#!/bin/bash
# 智能项目定价模型 - SSL 证书配置脚本
# 使用 Let's Encrypt 申请免费 SSL 证书
# 遵循瑞小美部署规范
set -e
# 配置
DOMAIN="${DOMAIN:-pricing.example.com}"
EMAIL="${EMAIL:-admin@example.com}"
WEBROOT="/var/www/certbot"
CERT_DIR="/etc/letsencrypt/live/${DOMAIN}"
NGINX_SSL_DIR="/etc/nginx/ssl"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# 检查 certbot
check_certbot() {
if ! command -v certbot &> /dev/null; then
log_info "安装 certbot..."
# Debian/Ubuntu
if command -v apt-get &> /dev/null; then
apt-get update
apt-get install -y certbot
# CentOS/RHEL
elif command -v yum &> /dev/null; then
yum install -y epel-release
yum install -y certbot
else
log_error "不支持的系统,请手动安装 certbot"
exit 1
fi
fi
log_info "certbot 版本: $(certbot --version)"
}
# 创建 webroot 目录
create_webroot() {
if [ ! -d "$WEBROOT" ]; then
mkdir -p "$WEBROOT"
chmod 755 "$WEBROOT"
fi
}
# 申请证书
request_certificate() {
log_info "申请 SSL 证书: $DOMAIN"
# 确保 webroot 可访问
create_webroot
# 申请证书
certbot certonly \
--webroot \
--webroot-path="$WEBROOT" \
--email "$EMAIL" \
--agree-tos \
--no-eff-email \
-d "$DOMAIN"
if [ $? -eq 0 ]; then
log_info "证书申请成功"
else
log_error "证书申请失败"
exit 1
fi
}
# 复制证书到 Nginx 目录
copy_certificates() {
log_info "复制证书到 Nginx 配置目录..."
if [ ! -d "$NGINX_SSL_DIR" ]; then
mkdir -p "$NGINX_SSL_DIR"
fi
# Let's Encrypt 证书路径
if [ -d "$CERT_DIR" ]; then
cp "${CERT_DIR}/fullchain.pem" "${NGINX_SSL_DIR}/${DOMAIN}.pem"
cp "${CERT_DIR}/privkey.pem" "${NGINX_SSL_DIR}/${DOMAIN}.key"
chmod 600 "${NGINX_SSL_DIR}/${DOMAIN}.key"
log_info "证书已复制到 ${NGINX_SSL_DIR}/"
else
log_error "证书目录不存在: $CERT_DIR"
exit 1
fi
}
# 配置自动续期
setup_auto_renewal() {
log_info "配置证书自动续期..."
# 创建续期后的钩子脚本
local hook_script="/etc/letsencrypt/renewal-hooks/post/pricing-ssl-renewal.sh"
cat > "$hook_script" << 'EOF'
#!/bin/bash
# SSL 证书续期后自动更新
DOMAIN="pricing.example.com"
NGINX_SSL_DIR="/etc/nginx/ssl"
CERT_DIR="/etc/letsencrypt/live/${DOMAIN}"
cp "${CERT_DIR}/fullchain.pem" "${NGINX_SSL_DIR}/${DOMAIN}.pem"
cp "${CERT_DIR}/privkey.pem" "${NGINX_SSL_DIR}/${DOMAIN}.key"
chmod 600 "${NGINX_SSL_DIR}/${DOMAIN}.key"
# 重载 Nginx
docker exec nginx_proxy nginx -s reload 2>/dev/null || nginx -s reload 2>/dev/null || true
EOF
chmod +x "$hook_script"
# 测试续期
certbot renew --dry-run
log_info "自动续期已配置(每天自动检查)"
}
# 显示证书信息
show_certificate_info() {
log_info "证书信息:"
echo ""
if [ -f "${NGINX_SSL_DIR}/${DOMAIN}.pem" ]; then
openssl x509 -in "${NGINX_SSL_DIR}/${DOMAIN}.pem" -noout -subject -dates
else
log_warn "证书文件不存在"
fi
echo ""
}
# 手动证书配置说明
show_manual_instructions() {
cat << EOF
===============================================
手动配置 SSL 证书说明
===============================================
如果您已有 SSL 证书(购买的或其他方式获取),请按以下步骤配置:
1. 将证书文件复制到 Nginx SSL 目录:
mkdir -p /etc/nginx/ssl
cp your_certificate.pem /etc/nginx/ssl/${DOMAIN}.pem
cp your_private_key.key /etc/nginx/ssl/${DOMAIN}.key
chmod 600 /etc/nginx/ssl/${DOMAIN}.key
2. 确保 nginx.conf 中的证书路径正确:
ssl_certificate /etc/nginx/ssl/${DOMAIN}.pem;
ssl_certificate_key /etc/nginx/ssl/${DOMAIN}.key;
3. 重载 Nginx:
docker exec nginx_proxy nginx -s reload
===============================================
EOF
}
# 主函数
main() {
local action="${1:-help}"
case $action in
request)
check_certbot
request_certificate
copy_certificates
setup_auto_renewal
show_certificate_info
;;
renew)
certbot renew
copy_certificates
docker exec nginx_proxy nginx -s reload 2>/dev/null || log_warn "请手动重载 Nginx"
;;
copy)
copy_certificates
;;
info)
show_certificate_info
;;
manual)
show_manual_instructions
;;
*)
echo "智能项目定价模型 - SSL 证书配置"
echo ""
echo "用法: DOMAIN=your.domain.com EMAIL=your@email.com $0 {request|renew|copy|info|manual}"
echo ""
echo "命令:"
echo " request 申请 Let's Encrypt 证书"
echo " renew 续期证书"
echo " copy 复制证书到 Nginx 目录"
echo " info 显示证书信息"
echo " manual 显示手动配置说明"
echo ""
echo "环境变量:"
echo " DOMAIN 域名 (默认: pricing.example.com)"
echo " EMAIL 管理员邮箱 (默认: admin@example.com)"
echo ""
echo "示例:"
echo " DOMAIN=pricing.mycompany.com EMAIL=admin@mycompany.com $0 request"
;;
esac
}
main "$@"

49
前端应用/Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
# 智能项目定价模型 - 前端 Dockerfile
# 遵循瑞小美部署规范:使用具体版本号,配置阿里云镜像源
# 构建阶段
FROM node:20.11-alpine AS builder
# 设置工作目录
WORKDIR /app
# 配置阿里云 npm 源
RUN npm config set registry https://registry.npmmirror.com
# 安装 pnpm
RUN npm install -g pnpm
RUN pnpm config set registry https://registry.npmmirror.com
# 复制依赖文件
COPY package.json pnpm-lock.yaml* ./
# 安装依赖
RUN pnpm install
# 复制源代码
COPY . .
# 构建
RUN pnpm build
# 运行阶段
FROM nginx:1.25.3-alpine AS runner
# 复制构建产物
COPY --from=builder /app/dist /usr/share/nginx/html
# 复制 nginx 配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 设置时区
ENV TZ=Asia/Shanghai
# 暴露端口
EXPOSE 80
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost/ || exit 1
# 启动 nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,23 @@
# 开发环境 Dockerfile
FROM node:20.11-alpine
WORKDIR /app
# 配置阿里云 npm 源
RUN npm config set registry https://registry.npmmirror.com
# 安装 pnpm
RUN npm install -g pnpm
RUN pnpm config set registry https://registry.npmmirror.com
# 安装依赖
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install
# 设置时区
ENV TZ=Asia/Shanghai
EXPOSE 3000
CMD ["pnpm", "dev", "--host"]

View File

@@ -0,0 +1,73 @@
/**
* ESLint 配置
* 遵循瑞小美代码规范(必须配置)
*/
import js from '@eslint/js'
import vue from 'eslint-plugin-vue'
import typescript from '@typescript-eslint/eslint-plugin'
import typescriptParser from '@typescript-eslint/parser'
import vueParser from 'vue-eslint-parser'
export default [
js.configs.recommended,
...vue.configs['flat/recommended'],
{
files: ['**/*.{ts,tsx,vue}'],
languageOptions: {
parser: vueParser,
parserOptions: {
parser: typescriptParser,
ecmaVersion: 'latest',
sourceType: 'module',
},
globals: {
// Vue 3 编译器宏
defineProps: 'readonly',
defineEmits: 'readonly',
defineExpose: 'readonly',
withDefaults: 'readonly',
// 浏览器全局变量
window: 'readonly',
document: 'readonly',
console: 'readonly',
setTimeout: 'readonly',
setInterval: 'readonly',
clearTimeout: 'readonly',
clearInterval: 'readonly',
fetch: 'readonly',
FormData: 'readonly',
File: 'readonly',
Blob: 'readonly',
URL: 'readonly',
URLSearchParams: 'readonly',
},
},
plugins: {
'@typescript-eslint': typescript,
},
rules: {
// Vue 规则
'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'off',
'vue/require-default-prop': 'off',
'vue/require-explicit-emits': 'error',
'vue/v-on-event-hyphenation': ['error', 'always'],
// TypeScript 规则
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/explicit-function-return-type': 'off',
// 通用规则
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-unused-vars': 'off', // 使用 @typescript-eslint/no-unused-vars
'prefer-const': 'error',
'no-var': 'error',
},
},
{
ignores: ['dist/**', 'node_modules/**', '*.d.ts'],
},
]

13
前端应用/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>智能项目定价模型</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

80
前端应用/nginx.conf Normal file
View File

@@ -0,0 +1,80 @@
# 前端容器内部 nginx 配置
# 遵循瑞小美系统技术栈标准 - 性能优化版
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# 启用 gzip 压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss application/atom+xml image/svg+xml;
# 启用预压缩文件(.gz
gzip_static on;
# 静态资源缓存 - 带哈希的文件长期缓存
location /assets {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
# 安全头
add_header X-Content-Type-Options nosniff;
}
# JS/CSS 文件缓存
location ~* \.(js|css)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# 图片资源缓存
location ~* \.(ico|gif|jpg|jpeg|png|webp|svg)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# 字体文件缓存
location ~* \.(woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000";
add_header Access-Control-Allow-Origin "*";
}
# HTML 文件不缓存SPA 入口)
location = /index.html {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
}
# SPA 路由支持
location / {
try_files $uri $uri/ /index.html;
# 安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
}
# 健康检查
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# 禁止访问隐藏文件
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}

43
前端应用/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "pricing-model-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx",
"lint:fix": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx --fix",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"vue": "^3.4.15",
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"axios": "^1.6.7",
"element-plus": "^2.5.5",
"@element-plus/icons-vue": "^2.3.1",
"echarts": "^5.4.3",
"vue-echarts": "^6.6.8",
"dayjs": "^1.11.10"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.3",
"vite": "^5.0.12",
"vite-plugin-compression": "^0.5.1",
"vue-tsc": "^1.8.27",
"typescript": "^5.3.3",
"@types/node": "^20.11.16",
"eslint": "^8.56.0",
"@eslint/js": "^8.56.0",
"eslint-plugin-vue": "^9.21.1",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"tailwindcss": "^3.4.1",
"postcss": "^8.4.35",
"autoprefixer": "^10.4.17",
"unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^0.26.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

16
前端应用/src/App.vue Normal file
View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
/**
* 应用根组件
*/
</script>
<template>
<router-view />
</template>
<style>
#app {
width: 100%;
height: 100vh;
}
</style>

View File

@@ -0,0 +1,91 @@
/**
* 标杆价格管理 API
*/
import { request, PaginatedData } from './request'
// 价格带枚举
export type PriceTier = 'low' | 'medium' | 'high' | 'premium'
// 标杆价格接口类型
export interface BenchmarkPrice {
id: number
benchmark_name: string
category_id: number | null
category_name: string | null
min_price: number
max_price: number
avg_price: number
price_tier: PriceTier
effective_date: string
remark: string | null
created_at: string
updated_at: string
}
export interface BenchmarkPriceCreate {
benchmark_name: string
category_id?: number | null
min_price: number
max_price: number
avg_price: number
price_tier?: PriceTier
effective_date: string
remark?: string | null
}
export interface BenchmarkPriceUpdate {
benchmark_name?: string
category_id?: number | null
min_price?: number
max_price?: number
avg_price?: number
price_tier?: PriceTier
effective_date?: string
remark?: string | null
}
export interface BenchmarkPriceQuery {
page?: number
page_size?: number
category_id?: number
}
// API 方法
export const benchmarkPriceApi = {
/**
* 获取标杆价格列表
*/
getList(params?: BenchmarkPriceQuery) {
return request.get<PaginatedData<BenchmarkPrice>>('/benchmark-prices', { params })
},
/**
* 创建标杆价格
*/
create(data: BenchmarkPriceCreate) {
return request.post<BenchmarkPrice>('/benchmark-prices', data)
},
/**
* 更新标杆价格
*/
update(id: number, data: BenchmarkPriceUpdate) {
return request.put<BenchmarkPrice>(`/benchmark-prices/${id}`, data)
},
/**
* 删除标杆价格
*/
delete(id: number) {
return request.delete(`/benchmark-prices/${id}`)
},
}
// 价格带选项
export const priceTierOptions = [
{ value: 'low', label: '低端' },
{ value: 'medium', label: '中端' },
{ value: 'high', label: '高端' },
{ value: 'premium', label: '奢华' },
]

View File

@@ -0,0 +1,83 @@
/**
* 项目分类 API
*/
import { request, PaginatedData } from './request'
// 分类接口类型
export interface Category {
id: number
category_name: string
parent_id: number | null
sort_order: number
is_active: boolean
created_at: string
updated_at: string
children?: Category[]
}
export interface CategoryCreate {
category_name: string
parent_id?: number | null
sort_order?: number
is_active?: boolean
}
export interface CategoryUpdate {
category_name?: string
parent_id?: number | null
sort_order?: number
is_active?: boolean
}
export interface CategoryQuery {
page?: number
page_size?: number
parent_id?: number | null
is_active?: boolean
}
// API 方法
export const categoryApi = {
/**
* 获取分类列表
*/
getList(params?: CategoryQuery) {
return request.get<PaginatedData<Category>>('/categories', { params })
},
/**
* 获取分类树
*/
getTree(isActive?: boolean) {
return request.get<Category[]>('/categories/tree', { params: { is_active: isActive } })
},
/**
* 获取单个分类
*/
getById(id: number) {
return request.get<Category>(`/categories/${id}`)
},
/**
* 创建分类
*/
create(data: CategoryCreate) {
return request.post<Category>('/categories', data)
},
/**
* 更新分类
*/
update(id: number, data: CategoryUpdate) {
return request.put<Category>(`/categories/${id}`, data)
},
/**
* 删除分类
*/
delete(id: number) {
return request.delete(`/categories/${id}`)
},
}

View File

@@ -0,0 +1,178 @@
/**
* 竞品机构管理 API
*/
import { request, PaginatedData } from './request'
// 机构定位枚举
export type Positioning = 'high' | 'medium' | 'budget'
// 价格来源枚举
export type PriceSource = 'official' | 'meituan' | 'dianping' | 'survey'
// 竞品机构接口类型
export interface Competitor {
id: number
competitor_name: string
address: string | null
distance_km: number | null
positioning: Positioning
contact: string | null
is_key_competitor: boolean
is_active: boolean
price_count: number
last_price_update: string | null
created_at: string
updated_at: string
}
export interface CompetitorCreate {
competitor_name: string
address?: string | null
distance_km?: number | null
positioning?: Positioning
contact?: string | null
is_key_competitor?: boolean
is_active?: boolean
}
export interface CompetitorUpdate {
competitor_name?: string
address?: string | null
distance_km?: number | null
positioning?: Positioning
contact?: string | null
is_key_competitor?: boolean
is_active?: boolean
}
export interface CompetitorQuery {
page?: number
page_size?: number
positioning?: Positioning
is_key_competitor?: boolean
keyword?: string
}
// 竞品价格
export interface CompetitorPrice {
id: number
competitor_id: number
competitor_name: string | null
project_id: number | null
project_name: string
original_price: number
promo_price: number | null
member_price: number | null
price_source: PriceSource
collected_at: string
remark: string | null
created_at: string
updated_at: string
}
export interface CompetitorPriceCreate {
project_id?: number | null
project_name: string
original_price: number
promo_price?: number | null
member_price?: number | null
price_source: PriceSource
collected_at: string
remark?: string | null
}
export interface CompetitorPriceUpdate {
project_id?: number | null
project_name?: string
original_price?: number
promo_price?: number | null
member_price?: number | null
price_source?: PriceSource
collected_at?: string
remark?: string | null
}
// API 方法
export const competitorApi = {
/**
* 获取竞品机构列表
*/
getList(params?: CompetitorQuery) {
return request.get<PaginatedData<Competitor>>('/competitors', { params })
},
/**
* 获取单个竞品机构
*/
getById(id: number) {
return request.get<Competitor>(`/competitors/${id}`)
},
/**
* 创建竞品机构
*/
create(data: CompetitorCreate) {
return request.post<Competitor>('/competitors', data)
},
/**
* 更新竞品机构
*/
update(id: number, data: CompetitorUpdate) {
return request.put<Competitor>(`/competitors/${id}`, data)
},
/**
* 删除竞品机构
*/
delete(id: number) {
return request.delete(`/competitors/${id}`)
},
// ============ 竞品价格 ============
/**
* 获取竞品价格列表
*/
getPrices(competitorId: number, projectId?: number) {
const params = projectId ? { project_id: projectId } : undefined
return request.get<CompetitorPrice[]>(`/competitors/${competitorId}/prices`, { params })
},
/**
* 添加竞品价格
*/
addPrice(competitorId: number, data: CompetitorPriceCreate) {
return request.post<CompetitorPrice>(`/competitors/${competitorId}/prices`, data)
},
/**
* 更新竞品价格
*/
updatePrice(priceId: number, data: CompetitorPriceUpdate) {
return request.put<CompetitorPrice>(`/competitor-prices/${priceId}`, data)
},
/**
* 删除竞品价格
*/
deletePrice(priceId: number) {
return request.delete(`/competitor-prices/${priceId}`)
},
}
// 定位选项
export const positioningOptions = [
{ value: 'high', label: '高端' },
{ value: 'medium', label: '中端' },
{ value: 'budget', label: '大众' },
]
// 价格来源选项
export const priceSourceOptions = [
{ value: 'official', label: '官网' },
{ value: 'meituan', label: '美团' },
{ value: 'dianping', label: '大众点评' },
{ value: 'survey', label: '实地调研' },
]

View File

@@ -0,0 +1,121 @@
/**
* 仪表盘 API
*/
import { request } from './request'
// 项目概览
export interface ProjectOverview {
total_projects: number
active_projects: number
projects_with_pricing: number
}
// 成本项目信息
export interface CostProjectInfo {
id: number
name: string
cost: number
}
// 成本概览
export interface CostOverview {
avg_project_cost: number
highest_cost_project: CostProjectInfo | null
lowest_cost_project: CostProjectInfo | null
}
// 市场概览
export interface MarketOverview {
competitors_tracked: number
price_records_this_month: number
avg_market_price: number | null
}
// 策略分布
export interface StrategiesDistribution {
traffic: number
profit: number
premium: number
}
// 定价概览
export interface PricingOverview {
pricing_plans_count: number
avg_target_margin: number | null
strategies_distribution: StrategiesDistribution
}
// AI 使用概览
export interface AIUsageOverview {
total_calls: number
total_tokens: number
total_cost_usd: number
provider_distribution: Record<string, number>
}
// 最近活动
export interface RecentActivity {
type: string
project_name: string
user: string | null
time: string
}
// 仪表盘概览响应
export interface DashboardSummaryResponse {
project_overview: ProjectOverview
cost_overview: CostOverview
market_overview: MarketOverview
pricing_overview: PricingOverview
ai_usage_this_month: AIUsageOverview | null
recent_activities: RecentActivity[]
}
// 趋势数据点
export interface TrendDataPoint {
date: string
value: number
}
// 成本趋势响应
export interface CostTrendResponse {
period: string
data: TrendDataPoint[]
avg_cost: number
}
// 市场趋势响应
export interface MarketTrendResponse {
period: string
data: TrendDataPoint[]
avg_price: number
}
// API 方法
export const dashboardApi = {
/**
* 获取仪表盘概览数据
*/
getSummary() {
return request.get<DashboardSummaryResponse>('/dashboard/summary')
},
/**
* 获取成本趋势
*/
getCostTrend(period: 'week' | 'month' | 'quarter' = 'month') {
return request.get<CostTrendResponse>('/dashboard/cost-trend', {
params: { period },
})
},
/**
* 获取市场价格趋势
*/
getMarketTrend(period: 'week' | 'month' | 'quarter' = 'month') {
return request.get<MarketTrendResponse>('/dashboard/market-trend', {
params: { period },
})
},
}

View File

@@ -0,0 +1,88 @@
/**
* 设备管理 API
*/
import { request, PaginatedData } from './request'
// 设备接口类型
export interface Equipment {
id: number
equipment_code: string
equipment_name: string
original_value: number
residual_rate: number
service_years: number
estimated_uses: number
depreciation_per_use: number
purchase_date: string | null
is_active: boolean
created_at: string
updated_at: string
}
export interface EquipmentCreate {
equipment_code: string
equipment_name: string
original_value: number
residual_rate?: number
service_years: number
estimated_uses: number
purchase_date?: string | null
is_active?: boolean
}
export interface EquipmentUpdate {
equipment_code?: string
equipment_name?: string
original_value?: number
residual_rate?: number
service_years?: number
estimated_uses?: number
purchase_date?: string | null
is_active?: boolean
}
export interface EquipmentQuery {
page?: number
page_size?: number
keyword?: string
is_active?: boolean
}
// API 方法
export const equipmentApi = {
/**
* 获取设备列表
*/
getList(params?: EquipmentQuery) {
return request.get<PaginatedData<Equipment>>('/equipments', { params })
},
/**
* 获取单个设备
*/
getById(id: number) {
return request.get<Equipment>(`/equipments/${id}`)
},
/**
* 创建设备
*/
create(data: EquipmentCreate) {
return request.post<Equipment>('/equipments', data)
},
/**
* 更新设备
*/
update(id: number, data: EquipmentUpdate) {
return request.put<Equipment>(`/equipments/${id}`, data)
},
/**
* 删除设备
*/
delete(id: number) {
return request.delete(`/equipments/${id}`)
},
}

View File

@@ -0,0 +1,117 @@
/**
* 固定成本 API
*/
import { request, PaginatedData } from './request'
// 成本类型枚举
export type CostType = 'rent' | 'utilities' | 'property' | 'other'
// 分摊方式枚举
export type AllocationMethod = 'count' | 'revenue' | 'duration'
// 固定成本接口类型
export interface FixedCost {
id: number
cost_name: string
cost_type: CostType
monthly_amount: number
year_month: string
allocation_method: AllocationMethod
is_active: boolean
created_at: string
updated_at: string
}
export interface FixedCostCreate {
cost_name: string
cost_type: CostType
monthly_amount: number
year_month: string
allocation_method?: AllocationMethod
is_active?: boolean
}
export interface FixedCostUpdate {
cost_name?: string
cost_type?: CostType
monthly_amount?: number
year_month?: string
allocation_method?: AllocationMethod
is_active?: boolean
}
export interface FixedCostQuery {
page?: number
page_size?: number
year_month?: string
cost_type?: CostType
is_active?: boolean
}
export interface FixedCostSummary {
year_month: string
total_amount: number
by_type: Record<string, number>
count: number
}
// API 方法
export const fixedCostApi = {
/**
* 获取固定成本列表
*/
getList(params?: FixedCostQuery) {
return request.get<PaginatedData<FixedCost>>('/fixed-costs', { params })
},
/**
* 获取月度汇总
*/
getSummary(yearMonth: string) {
return request.get<FixedCostSummary>('/fixed-costs/summary', { params: { year_month: yearMonth } })
},
/**
* 获取单个固定成本
*/
getById(id: number) {
return request.get<FixedCost>(`/fixed-costs/${id}`)
},
/**
* 创建固定成本
*/
create(data: FixedCostCreate) {
return request.post<FixedCost>('/fixed-costs', data)
},
/**
* 更新固定成本
*/
update(id: number, data: FixedCostUpdate) {
return request.put<FixedCost>(`/fixed-costs/${id}`, data)
},
/**
* 删除固定成本
*/
delete(id: number) {
return request.delete(`/fixed-costs/${id}`)
},
}
// 成本类型选项
export const costTypeOptions = [
{ value: 'rent', label: '房租' },
{ value: 'utilities', label: '水电' },
{ value: 'property', label: '物业' },
{ value: 'other', label: '其他' },
]
// 分摊方式选项
export const allocationMethodOptions = [
{ value: 'count', label: '按项目数量' },
{ value: 'revenue', label: '按营收占比' },
{ value: 'duration', label: '按时长占比' },
]

View File

@@ -0,0 +1,17 @@
/**
* API 统一导出
*/
export * from './request'
export * from './categories'
export * from './materials'
export * from './equipments'
export * from './staff-levels'
export * from './fixed-costs'
export * from './projects'
export * from './competitors'
export * from './benchmark-prices'
export * from './market-analysis'
export * from './pricing'
export * from './dashboard'
export * from './profit'

View File

@@ -0,0 +1,103 @@
/**
* 市场分析 API
*/
import { request } from './request'
// 价格统计
export interface PriceStatistics {
min_price: number
max_price: number
avg_price: number
median_price: number
std_deviation: number | null
}
// 价格分布项
export interface PriceDistributionItem {
range: string
count: number
percentage: number
}
// 价格分布
export interface PriceDistribution {
low: PriceDistributionItem
medium: PriceDistributionItem
high: PriceDistributionItem
}
// 竞品价格摘要
export interface CompetitorPriceSummary {
competitor_name: string
positioning: string
original_price: number
promo_price: number | null
collected_at: string
}
// 标杆参考
export interface BenchmarkReference {
tier: string
min_price: number
max_price: number
avg_price: number
}
// 建议定价区间
export interface SuggestedRange {
min: number
max: number
recommended: number
}
// 市场分析结果
export interface MarketAnalysisResult {
project_id: number
project_name: string
analysis_date: string
competitor_count: number
price_statistics: PriceStatistics
price_distribution: PriceDistribution | null
competitor_prices: CompetitorPriceSummary[]
benchmark_reference: BenchmarkReference | null
suggested_range: SuggestedRange
}
// 市场分析响应(数据库记录)
export interface MarketAnalysisResponse {
id: number
project_id: number
analysis_date: string
competitor_count: number
market_min_price: number
market_max_price: number
market_avg_price: number
market_median_price: number
suggested_range_min: number
suggested_range_max: number
created_at: string
}
// 市场分析请求
export interface MarketAnalysisRequest {
competitor_ids?: number[]
include_benchmark?: boolean
}
// API 方法
export const marketAnalysisApi = {
/**
* 执行市场分析
*/
analyze(projectId: number, data?: MarketAnalysisRequest) {
return request.post<MarketAnalysisResult>(`/projects/${projectId}/market-analysis`, data || {})
},
/**
* 获取最新市场分析结果
*/
getLatest(projectId: number) {
return request.get<MarketAnalysisResponse>(`/projects/${projectId}/market-analysis`)
},
}

View File

@@ -0,0 +1,112 @@
/**
* 耗材管理 API
*/
import { request, PaginatedData } from './request'
// 耗材类型枚举
export type MaterialType = 'consumable' | 'injectable' | 'product'
// 耗材接口类型
export interface Material {
id: number
material_code: string
material_name: string
unit: string
unit_price: number
supplier: string | null
material_type: MaterialType
is_active: boolean
created_at: string
updated_at: string
}
export interface MaterialCreate {
material_code: string
material_name: string
unit: string
unit_price: number
supplier?: string | null
material_type: MaterialType
is_active?: boolean
}
export interface MaterialUpdate {
material_code?: string
material_name?: string
unit?: string
unit_price?: number
supplier?: string | null
material_type?: MaterialType
is_active?: boolean
}
export interface MaterialQuery {
page?: number
page_size?: number
keyword?: string
material_type?: MaterialType
is_active?: boolean
}
export interface MaterialImportResult {
total: number
success: number
failed: number
errors: { row: number; error: string }[]
}
// API 方法
export const materialApi = {
/**
* 获取耗材列表
*/
getList(params?: MaterialQuery) {
return request.get<PaginatedData<Material>>('/materials', { params })
},
/**
* 获取单个耗材
*/
getById(id: number) {
return request.get<Material>(`/materials/${id}`)
},
/**
* 创建耗材
*/
create(data: MaterialCreate) {
return request.post<Material>('/materials', data)
},
/**
* 更新耗材
*/
update(id: number, data: MaterialUpdate) {
return request.put<Material>(`/materials/${id}`, data)
},
/**
* 删除耗材
*/
delete(id: number) {
return request.delete(`/materials/${id}`)
},
/**
* 批量导入耗材
*/
import(file: File, updateExisting = false) {
return request.upload<MaterialImportResult>(
`/materials/import?update_existing=${updateExisting}`,
file
)
},
}
// 耗材类型选项
export const materialTypeOptions = [
{ value: 'consumable', label: '一般耗材' },
{ value: 'injectable', label: '针剂' },
{ value: 'product', label: '产品' },
]

View File

@@ -0,0 +1,205 @@
/**
* 智能定价 API
*/
import { request, PaginatedData } from './request'
// 策略类型
export type StrategyType = 'traffic' | 'profit' | 'premium'
// 策略类型选项
export const strategyTypeOptions = [
{ value: 'traffic', label: '引流款' },
{ value: 'profit', label: '利润款' },
{ value: 'premium', label: '高端款' },
]
// 定价方案
export interface PricingPlan {
id: number
project_id: number
project_name: string | null
plan_name: string
strategy_type: StrategyType
base_cost: number
target_margin: number
suggested_price: number
final_price: number | null
ai_advice: string | null
is_active: boolean
created_at: string
updated_at: string
created_by_name: string | null
}
export interface PricingPlanCreate {
project_id: number
plan_name: string
strategy_type: StrategyType
target_margin: number
}
export interface PricingPlanUpdate {
plan_name?: string
strategy_type?: StrategyType
target_margin?: number
final_price?: number
is_active?: boolean
}
export interface PricingPlanQuery {
page?: number
page_size?: number
project_id?: number
strategy_type?: StrategyType
is_active?: boolean
sort_by?: string
sort_order?: 'asc' | 'desc'
}
// AI 定价建议
export interface GeneratePricingRequest {
target_margin?: number
strategies?: StrategyType[]
stream?: boolean
}
export interface StrategySuggestion {
strategy: string
suggested_price: number
margin: number
description: string
}
export interface MarketReference {
min: number
max: number
avg: number
}
export interface PricingSuggestions {
traffic?: StrategySuggestion
profit?: StrategySuggestion
premium?: StrategySuggestion
}
export interface AIAdvice {
summary: string
cost_analysis: string
market_analysis: string
risk_notes: string
recommendations: string[]
}
export interface AIUsage {
provider: string
model: string
tokens: number
latency_ms: number
}
export interface GeneratePricingResponse {
project_id: number
project_name: string
cost_base: number
market_reference: MarketReference | null
pricing_suggestions: PricingSuggestions
ai_advice: AIAdvice | null
ai_usage: AIUsage | null
}
// 策略模拟
export interface SimulateStrategyRequest {
strategies: StrategyType[]
target_margin?: number
}
export interface StrategySimulationResult {
strategy_type: string
strategy_name: string
suggested_price: number
margin: number
profit_per_unit: number
market_position: string
}
export interface SimulateStrategyResponse {
project_id: number
project_name: string
base_cost: number
results: StrategySimulationResult[]
}
// API 方法
export const pricingApi = {
/**
* 获取定价方案列表
*/
getList(params?: PricingPlanQuery) {
return request.get<PaginatedData<PricingPlan>>('/pricing-plans', { params })
},
/**
* 获取定价方案详情
*/
getById(id: number) {
return request.get<PricingPlan>(`/pricing-plans/${id}`)
},
/**
* 创建定价方案
*/
create(data: PricingPlanCreate) {
return request.post<PricingPlan>('/pricing-plans', data)
},
/**
* 更新定价方案
*/
update(id: number, data: PricingPlanUpdate) {
return request.put<PricingPlan>(`/pricing-plans/${id}`, data)
},
/**
* 删除定价方案
*/
delete(id: number) {
return request.delete(`/pricing-plans/${id}`)
},
/**
* AI 生成定价建议(非流式)
*/
generatePricing(projectId: number, data?: GeneratePricingRequest) {
return request.post<GeneratePricingResponse>(
`/projects/${projectId}/generate-pricing`,
{ ...data, stream: false }
)
},
/**
* AI 生成定价建议(流式)
* 返回 EventSource URL由组件处理 SSE
*/
generatePricingStreamUrl(projectId: number, targetMargin: number = 50): string {
return `/api/v1/projects/${projectId}/generate-pricing`
},
/**
* 模拟定价策略
*/
simulateStrategy(projectId: number, data: SimulateStrategyRequest) {
return request.post<SimulateStrategyResponse>(
`/projects/${projectId}/simulate-strategy`,
data
)
},
/**
* 导出定价报告
*/
exportReport(planId: number, format: 'pdf' | 'excel' = 'pdf') {
// 返回下载 URL
return `/api/v1/pricing-plans/${planId}/export?format=${format}`
},
}

View File

@@ -0,0 +1,190 @@
/**
* 利润模拟 API
*/
import { request, PaginatedData } from './request'
// 周期类型
export type PeriodType = 'daily' | 'weekly' | 'monthly'
// 周期类型选项
export const periodTypeOptions = [
{ value: 'daily', label: '日' },
{ value: 'weekly', label: '周' },
{ value: 'monthly', label: '月' },
]
// 利润模拟
export interface ProfitSimulation {
id: number
pricing_plan_id: number
plan_name: string | null
project_name: string | null
simulation_name: string
price: number
estimated_volume: number
period_type: PeriodType
estimated_revenue: number
estimated_cost: number
estimated_profit: number
profit_margin: number
breakeven_volume: number
created_at: string
created_by_name: string | null
}
export interface ProfitSimulationQuery {
page?: number
page_size?: number
pricing_plan_id?: number
period_type?: PeriodType
sort_by?: string
sort_order?: 'asc' | 'desc'
}
// 执行模拟
export interface SimulateProfitRequest {
price: number
estimated_volume: number
period_type?: PeriodType
}
export interface SimulationInput {
price: number
cost_per_unit: number
estimated_volume: number
period_type: string
}
export interface SimulationResult {
estimated_revenue: number
estimated_cost: number
estimated_profit: number
profit_margin: number
profit_per_unit: number
}
export interface BreakevenAnalysis {
breakeven_volume: number
current_volume: number
safety_margin: number
safety_margin_percentage: number
}
export interface SimulateProfitResponse {
simulation_id: number
pricing_plan_id: number
project_name: string
input: SimulationInput
result: SimulationResult
breakeven_analysis: BreakevenAnalysis
created_at: string
}
// 敏感性分析
export interface SensitivityAnalysisRequest {
price_change_rates?: number[]
}
export interface SensitivityResultItem {
price_change_rate: number
adjusted_price: number
adjusted_profit: number
profit_change_rate: number
}
export interface SensitivityInsights {
price_elasticity: string
risk_level: string
recommendation: string
}
export interface SensitivityAnalysisResponse {
simulation_id: number
base_price: number
base_profit: number
sensitivity_results: SensitivityResultItem[]
insights?: SensitivityInsights
}
// 盈亏平衡分析
export interface BreakevenResponse {
pricing_plan_id: number
project_name: string
price: number
unit_cost: number
fixed_cost_monthly: number
breakeven_volume: number
current_margin: number
target_profit_volume: number | null
}
// API 方法
export const profitApi = {
/**
* 获取模拟列表
*/
getList(params?: ProfitSimulationQuery) {
return request.get<PaginatedData<ProfitSimulation>>('/profit-simulations', { params })
},
/**
* 获取模拟详情
*/
getById(id: number) {
return request.get<ProfitSimulation>(`/profit-simulations/${id}`)
},
/**
* 删除模拟记录
*/
delete(id: number) {
return request.delete(`/profit-simulations/${id}`)
},
/**
* 执行利润模拟
*/
simulate(planId: number, data: SimulateProfitRequest) {
return request.post<SimulateProfitResponse>(
`/pricing-plans/${planId}/simulate-profit`,
data
)
},
/**
* 执行敏感性分析
*/
sensitivityAnalysis(simulationId: number, data?: SensitivityAnalysisRequest) {
return request.post<SensitivityAnalysisResponse>(
`/profit-simulations/${simulationId}/sensitivity`,
data || {}
)
},
/**
* 获取敏感性分析结果
*/
getSensitivityAnalysis(simulationId: number) {
return request.get<SensitivityAnalysisResponse>(
`/profit-simulations/${simulationId}/sensitivity`
)
},
/**
* 获取盈亏平衡分析
*/
getBreakevenAnalysis(planId: number, targetProfit?: number) {
const params = targetProfit ? { target_profit: targetProfit } : undefined
return request.get<BreakevenResponse>(`/pricing-plans/${planId}/breakeven`, { params })
},
/**
* AI 生成利润预测分析
*/
generateForecast(simulationId: number) {
return request.post<{ content: string }>(
`/profit-simulations/${simulationId}/forecast`
)
},
}

View File

@@ -0,0 +1,278 @@
/**
* 服务项目管理 API
*/
import { request, PaginatedData } from './request'
// 成本汇总简要
export interface CostSummaryBrief {
total_cost: number
material_cost: number
equipment_cost: number
labor_cost: number
fixed_cost_allocation: number
}
// 项目接口类型
export interface Project {
id: number
project_code: string
project_name: string
category_id: number | null
category_name: string | null
description: string | null
duration_minutes: number
is_active: boolean
cost_summary: CostSummaryBrief | null
created_at: string
updated_at: string
}
export interface ProjectCreate {
project_code: string
project_name: string
category_id?: number | null
description?: string | null
duration_minutes?: number
is_active?: boolean
}
export interface ProjectUpdate {
project_code?: string
project_name?: string
category_id?: number | null
description?: string | null
duration_minutes?: number
is_active?: boolean
}
export interface ProjectQuery {
page?: number
page_size?: number
category_id?: number
keyword?: string
is_active?: boolean
sort_by?: string
sort_order?: 'asc' | 'desc'
}
// 成本明细类型
export type CostItemType = 'material' | 'equipment'
export interface CostItem {
id: number
item_type: CostItemType
item_id: number
item_name: string | null
quantity: number
unit: string | null
unit_cost: number
total_cost: number
remark: string | null
created_at: string
updated_at: string
}
export interface CostItemCreate {
item_type: CostItemType
item_id: number
quantity: number
remark?: string | null
}
export interface CostItemUpdate {
quantity?: number
remark?: string | null
}
// 人工成本
export interface LaborCost {
id: number
staff_level_id: number
level_name: string | null
duration_minutes: number
hourly_rate: number
labor_cost: number
remark: string | null
created_at: string
updated_at: string
}
export interface LaborCostCreate {
staff_level_id: number
duration_minutes: number
remark?: string | null
}
export interface LaborCostUpdate {
staff_level_id?: number
duration_minutes?: number
remark?: string | null
}
// 成本计算请求
export type AllocationMethod = 'count' | 'revenue' | 'duration'
export interface CalculateCostRequest {
fixed_cost_allocation_method?: AllocationMethod
}
// 成本计算结果
export interface CostCalculationResult {
project_id: number
project_name: string
cost_breakdown: {
material_cost: { items: any[]; subtotal: number }
equipment_cost: { items: any[]; subtotal: number }
labor_cost: { items: any[]; subtotal: number }
fixed_cost_allocation: {
method: string
total_fixed_cost: number
project_count?: number
allocation: number
}
}
total_cost: number
min_price_suggestion: number
calculated_at: string
}
// 成本汇总响应
export interface CostSummary {
project_id: number
material_cost: number
equipment_cost: number
labor_cost: number
fixed_cost_allocation: number
total_cost: number
calculated_at: string
}
// 项目详情(含成本)
export interface ProjectDetail extends Omit<Project, 'cost_summary'> {
cost_items: CostItem[]
labor_costs: LaborCost[]
cost_summary: CostSummary | null
}
// API 方法
export const projectApi = {
/**
* 获取项目列表
*/
getList(params?: ProjectQuery) {
return request.get<PaginatedData<Project>>('/projects', { params })
},
/**
* 获取项目详情
*/
getById(id: number) {
return request.get<ProjectDetail>(`/projects/${id}`)
},
/**
* 创建项目
*/
create(data: ProjectCreate) {
return request.post<Project>('/projects', data)
},
/**
* 更新项目
*/
update(id: number, data: ProjectUpdate) {
return request.put<Project>(`/projects/${id}`, data)
},
/**
* 删除项目
*/
delete(id: number) {
return request.delete(`/projects/${id}`)
},
// ============ 成本明细 ============
/**
* 获取成本明细列表
*/
getCostItems(projectId: number) {
return request.get<CostItem[]>(`/projects/${projectId}/cost-items`)
},
/**
* 添加成本明细
*/
addCostItem(projectId: number, data: CostItemCreate) {
return request.post<CostItem>(`/projects/${projectId}/cost-items`, data)
},
/**
* 更新成本明细
*/
updateCostItem(projectId: number, itemId: number, data: CostItemUpdate) {
return request.put<CostItem>(`/projects/${projectId}/cost-items/${itemId}`, data)
},
/**
* 删除成本明细
*/
deleteCostItem(projectId: number, itemId: number) {
return request.delete(`/projects/${projectId}/cost-items/${itemId}`)
},
// ============ 人工成本 ============
/**
* 获取人工成本列表
*/
getLaborCosts(projectId: number) {
return request.get<LaborCost[]>(`/projects/${projectId}/labor-costs`)
},
/**
* 添加人工成本
*/
addLaborCost(projectId: number, data: LaborCostCreate) {
return request.post<LaborCost>(`/projects/${projectId}/labor-costs`, data)
},
/**
* 更新人工成本
*/
updateLaborCost(projectId: number, itemId: number, data: LaborCostUpdate) {
return request.put<LaborCost>(`/projects/${projectId}/labor-costs/${itemId}`, data)
},
/**
* 删除人工成本
*/
deleteLaborCost(projectId: number, itemId: number) {
return request.delete(`/projects/${projectId}/labor-costs/${itemId}`)
},
// ============ 成本计算 ============
/**
* 计算项目总成本
*/
calculateCost(projectId: number, data?: CalculateCostRequest) {
return request.post<CostCalculationResult>(`/projects/${projectId}/calculate-cost`, data || {})
},
/**
* 获取成本汇总
*/
getCostSummary(projectId: number) {
return request.get<CostSummary>(`/projects/${projectId}/cost-summary`)
},
}
// 成本明细类型选项
export const costItemTypeOptions = [
{ value: 'material', label: '耗材' },
{ value: 'equipment', label: '设备' },
]
// 固定成本分摊方式选项 - 使用 fixed-costs.ts 中的 allocationMethodOptions

View File

@@ -0,0 +1,147 @@
/**
* Axios 请求封装
* 遵循瑞小美技术栈标准:统一使用 Axios
*/
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'
// API 响应格式
export interface ApiResponse<T = any> {
code: number
message: string
data: T
}
// 分页数据格式
export interface PaginatedData<T = any> {
items: T[]
total: number
page: number
page_size: number
total_pages: number
}
// 错误码
export const ErrorCode = {
SUCCESS: 0,
PARAM_ERROR: 10001,
NOT_FOUND: 10002,
ALREADY_EXISTS: 10003,
NOT_ALLOWED: 10004,
AUTH_FAILED: 20001,
PERMISSION_DENIED: 20002,
TOKEN_EXPIRED: 20003,
INTERNAL_ERROR: 30001,
SERVICE_UNAVAILABLE: 30002,
AI_SERVICE_ERROR: 40001,
AI_SERVICE_TIMEOUT: 40002,
}
// 创建 Axios 实例
const instance: AxiosInstance = axios.create({
baseURL: '/api/v1',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器
instance.interceptors.request.use(
(config) => {
// 添加 Token如果有
const token = localStorage.getItem('token')
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
instance.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const { data } = response
// 业务错误处理
if (data.code !== ErrorCode.SUCCESS) {
ElMessage.error(data.message || '请求失败')
return Promise.reject(new Error(data.message))
}
return response
},
(error) => {
// HTTP 错误处理
let message = '网络错误,请稍后重试'
if (error.response) {
const { status, data } = error.response
// 获取错误信息:支持 data.message 和 data.detail.message 两种格式
const errorMsg = data?.message || data?.detail?.message || (typeof data?.detail === 'string' ? data.detail : null)
switch (status) {
case 400:
message = errorMsg || '请求参数错误'
break
case 401:
message = '登录已过期,请重新登录'
// TODO: 跳转登录页
break
case 403:
message = '没有权限访问'
break
case 404:
message = errorMsg || '请求的资源不存在'
break
case 422:
// Pydantic 验证错误
message = errorMsg || '请求参数验证失败'
break
case 500:
message = '服务器内部错误'
break
default:
message = errorMsg || `请求失败 (${status})`
}
} else if (error.code === 'ECONNABORTED') {
message = '请求超时,请稍后重试'
}
ElMessage.error(message)
return Promise.reject(error)
}
)
// 封装请求方法
export const request = {
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
return instance.get(url, config).then((res) => res.data)
},
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
return instance.post(url, data, config).then((res) => res.data)
},
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
return instance.put(url, data, config).then((res) => res.data)
},
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
return instance.delete(url, config).then((res) => res.data)
},
upload<T = any>(url: string, file: File, fieldName = 'file'): Promise<ApiResponse<T>> {
const formData = new FormData()
formData.append(fieldName, file)
return instance.post(url, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}).then((res) => res.data)
},
}
export default instance

View File

@@ -0,0 +1,75 @@
/**
* 人员级别 API
*/
import { request, PaginatedData } from './request'
// 人员级别接口类型
export interface StaffLevel {
id: number
level_code: string
level_name: string
hourly_rate: number
is_active: boolean
created_at: string
updated_at: string
}
export interface StaffLevelCreate {
level_code: string
level_name: string
hourly_rate: number
is_active?: boolean
}
export interface StaffLevelUpdate {
level_code?: string
level_name?: string
hourly_rate?: number
is_active?: boolean
}
export interface StaffLevelQuery {
page?: number
page_size?: number
keyword?: string
is_active?: boolean
}
// API 方法
export const staffLevelApi = {
/**
* 获取人员级别列表
*/
getList(params?: StaffLevelQuery) {
return request.get<PaginatedData<StaffLevel>>('/staff-levels', { params })
},
/**
* 获取单个人员级别
*/
getById(id: number) {
return request.get<StaffLevel>(`/staff-levels/${id}`)
},
/**
* 创建人员级别
*/
create(data: StaffLevelCreate) {
return request.post<StaffLevel>('/staff-levels', data)
},
/**
* 更新人员级别
*/
update(id: number, data: StaffLevelUpdate) {
return request.put<StaffLevel>(`/staff-levels/${id}`, data)
},
/**
* 删除人员级别
*/
delete(id: number) {
return request.delete(`/staff-levels/${id}`)
},
}

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#409eff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#67c23a;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="32" height="32" rx="6" fill="url(#grad1)"/>
<text x="16" y="22" text-anchor="middle" fill="white" font-size="16" font-weight="bold" font-family="Arial">¥</text>
</svg>

After

Width:  |  Height:  |  Size: 495 B

33
前端应用/src/main.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* 智能项目定价模型 - 前端入口
* 遵循瑞小美技术栈标准Vue 3 + TypeScript + Vite + pnpm
*/
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
// 样式
import 'element-plus/dist/index.css'
import './styles/index.css'
const app = createApp(App)
// 注册 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
// 使用插件
app.use(createPinia())
app.use(router)
app.use(ElementPlus, {
locale: zhCn,
})
app.mount('#app')

View File

@@ -0,0 +1,153 @@
/**
* Vue Router 配置
*/
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('@/views/layout/MainLayout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '仪表盘', icon: 'Odometer' },
},
// 成本核算模块
{
path: 'cost',
name: 'Cost',
redirect: '/cost/projects',
meta: { title: '成本核算', icon: 'Coin' },
children: [
{
path: 'projects',
name: 'CostProjects',
component: () => import('@/views/cost/projects/index.vue'),
meta: { title: '服务项目', icon: 'List' },
},
],
},
// 市场行情模块
{
path: 'market',
name: 'Market',
redirect: '/market/competitors',
meta: { title: '市场行情', icon: 'TrendCharts' },
children: [
{
path: 'competitors',
name: 'Competitors',
component: () => import('@/views/market/competitors/index.vue'),
meta: { title: '竞品机构', icon: 'OfficeBuilding' },
},
{
path: 'benchmarks',
name: 'Benchmarks',
component: () => import('@/views/market/benchmarks/index.vue'),
meta: { title: '标杆价格', icon: 'PriceTag' },
},
{
path: 'analysis',
name: 'MarketAnalysis',
component: () => import('@/views/market/analysis/index.vue'),
meta: { title: '市场分析', icon: 'DataAnalysis' },
},
],
},
// 智能定价模块
{
path: 'pricing',
name: 'Pricing',
redirect: '/pricing/plans',
meta: { title: '智能定价', icon: 'Money' },
children: [
{
path: 'plans',
name: 'PricingPlans',
component: () => import('@/views/pricing/index.vue'),
meta: { title: '定价方案', icon: 'List' },
},
],
},
// 利润模拟模块
{
path: 'profit',
name: 'Profit',
redirect: '/profit/simulations',
meta: { title: '利润模拟', icon: 'DataLine' },
children: [
{
path: 'simulations',
name: 'ProfitSimulations',
component: () => import('@/views/profit/index.vue'),
meta: { title: '模拟测算', icon: 'DataAnalysis' },
},
],
},
// 基础数据管理
{
path: 'settings',
name: 'Settings',
redirect: '/settings/categories',
meta: { title: '基础数据', icon: 'Setting' },
children: [
{
path: 'categories',
name: 'Categories',
component: () => import('@/views/settings/categories/index.vue'),
meta: { title: '项目分类', icon: 'Menu' },
},
{
path: 'materials',
name: 'Materials',
component: () => import('@/views/settings/materials/index.vue'),
meta: { title: '耗材管理', icon: 'Box' },
},
{
path: 'equipments',
name: 'Equipments',
component: () => import('@/views/settings/equipments/index.vue'),
meta: { title: '设备管理', icon: 'Monitor' },
},
{
path: 'staff-levels',
name: 'StaffLevels',
component: () => import('@/views/settings/staff-levels/index.vue'),
meta: { title: '人员级别', icon: 'User' },
},
{
path: 'fixed-costs',
name: 'FixedCosts',
component: () => import('@/views/settings/fixed-costs/index.vue'),
meta: { title: '固定成本', icon: 'Wallet' },
},
],
},
],
},
// 404 页面
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/error/404.vue'),
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// 路由守卫
router.beforeEach((to, _from, next) => {
// 设置页面标题
const title = to.meta.title as string
document.title = title ? `${title} - 智能项目定价模型` : '智能项目定价模型'
next()
})
export default router

View File

@@ -0,0 +1,45 @@
/**
* 应用全局状态
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useAppStore = defineStore('app', () => {
// 侧边栏折叠状态
const sidebarCollapsed = ref(false)
// 当前激活的菜单
const activeMenu = ref('')
// 面包屑
const breadcrumbs = ref<{ title: string; path?: string }[]>([])
// 切换侧边栏
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
}
// 设置激活菜单
const setActiveMenu = (menu: string) => {
activeMenu.value = menu
}
// 设置面包屑
const setBreadcrumbs = (items: { title: string; path?: string }[]) => {
breadcrumbs.value = items
}
// 侧边栏宽度
const sidebarWidth = computed(() => sidebarCollapsed.value ? '64px' : '220px')
return {
sidebarCollapsed,
activeMenu,
breadcrumbs,
toggleSidebar,
setActiveMenu,
setBreadcrumbs,
sidebarWidth,
}
})

View File

@@ -0,0 +1,5 @@
/**
* Pinia Store 统一导出
*/
export * from './app'

View File

@@ -0,0 +1,73 @@
/**
* 全局样式
* Tailwind CSS + Element Plus
*/
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 基础样式 */
html, body {
margin: 0;
padding: 0;
height: 100%;
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* Element Plus 样式覆盖 */
.el-menu {
border-right: none !important;
}
.el-card {
border-radius: 8px;
}
.el-table {
--el-table-border-color: #ebeef5;
}
/* 页面容器 */
.page-container {
@apply p-6 bg-gray-50 min-h-full;
}
/* 卡片样式 */
.card-container {
@apply bg-white rounded-lg shadow-sm p-6;
}
/* 表格操作按钮 */
.table-actions {
@apply flex items-center gap-2;
}
/* 表单标签 */
.form-label-required::before {
content: '*';
color: #f56c6c;
margin-right: 4px;
}

View File

@@ -0,0 +1,87 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const effectScope: typeof import('vue')['effectScope']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

13
前端应用/src/types/components.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
}

View File

@@ -0,0 +1,512 @@
<script setup lang="ts">
/**
* 成本详情对话框
* 显示和编辑项目的成本明细(耗材、设备、人工)
*/
import { ref, watch, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, RefreshRight } from '@element-plus/icons-vue'
import {
projectApi,
Project,
ProjectDetail,
CostItem,
CostItemCreate,
LaborCost,
LaborCostCreate,
CostCalculationResult,
AllocationMethod,
} from '@/api/projects'
import { allocationMethodOptions } from '@/api/fixed-costs'
import { materialApi, Material } from '@/api/materials'
import { equipmentApi, Equipment } from '@/api/equipments'
import { staffLevelApi, StaffLevel } from '@/api/staff-levels'
const props = defineProps<{
visible: boolean
project: Project
}>()
const emit = defineEmits(['update:visible', 'close'])
// 数据
const loading = ref(false)
const projectDetail = ref<ProjectDetail | null>(null)
const calculationResult = ref<CostCalculationResult | null>(null)
// 物料和设备列表
const materials = ref<Material[]>([])
const equipments = ref<Equipment[]>([])
const staffLevels = ref<StaffLevel[]>([])
// 成本明细表单
const costItemDialogVisible = ref(false)
const costItemForm = ref<CostItemCreate>({
item_type: 'material',
item_id: 0,
quantity: 1,
remark: '',
})
// 人工成本表单
const laborCostDialogVisible = ref(false)
const laborCostForm = ref<LaborCostCreate>({
staff_level_id: 0,
duration_minutes: 30,
remark: '',
})
// 分摊方式
const allocationMethod = ref<AllocationMethod>('count')
// 计算成本构成数据
const costBreakdown = computed(() => {
if (!projectDetail.value?.cost_summary) return null
const { material_cost, equipment_cost, labor_cost, fixed_cost_allocation, total_cost } = projectDetail.value.cost_summary
return [
{ name: '耗材成本', value: material_cost },
{ name: '设备折旧', value: equipment_cost },
{ name: '人工成本', value: labor_cost },
{ name: '固定成本', value: fixed_cost_allocation },
]
})
// 获取基础数据
const fetchBasicData = async () => {
try {
const [matRes, equipRes, levelRes] = await Promise.all([
materialApi.getList({ page_size: 100, is_active: true }),
equipmentApi.getList({ page_size: 100, is_active: true }),
staffLevelApi.getList({ page_size: 100, is_active: true }),
])
materials.value = matRes.data?.items || []
equipments.value = equipRes.data?.items || []
staffLevels.value = levelRes.data?.items || []
} catch (error) {
console.error('获取基础数据失败:', error)
}
}
// 获取项目详情
const fetchProjectDetail = async () => {
loading.value = true
try {
const res = await projectApi.getById(props.project.id)
projectDetail.value = res.data
} catch (error) {
console.error('获取项目详情失败:', error)
} finally {
loading.value = false
}
}
// 添加成本明细
const handleAddCostItem = () => {
costItemForm.value = {
item_type: 'material',
item_id: 0,
quantity: 1,
remark: '',
}
costItemDialogVisible.value = true
}
// 提交成本明细
const handleSubmitCostItem = async () => {
if (!costItemForm.value.item_id) {
ElMessage.warning('请选择耗材或设备')
return
}
try {
await projectApi.addCostItem(props.project.id, costItemForm.value)
ElMessage.success('添加成功')
costItemDialogVisible.value = false
fetchProjectDetail()
} catch (error) {
console.error('添加失败:', error)
}
}
// 删除成本明细
const handleDeleteCostItem = async (item: CostItem) => {
try {
await ElMessageBox.confirm(`确定要删除该成本项吗?`, '提示', { type: 'warning' })
await projectApi.deleteCostItem(props.project.id, item.id)
ElMessage.success('删除成功')
fetchProjectDetail()
} catch (error: any) {
if (error !== 'cancel') {
console.error('删除失败:', error)
}
}
}
// 添加人工成本
const handleAddLaborCost = () => {
laborCostForm.value = {
staff_level_id: 0,
duration_minutes: 30,
remark: '',
}
laborCostDialogVisible.value = true
}
// 提交人工成本
const handleSubmitLaborCost = async () => {
if (!laborCostForm.value.staff_level_id) {
ElMessage.warning('请选择人员级别')
return
}
try {
await projectApi.addLaborCost(props.project.id, laborCostForm.value)
ElMessage.success('添加成功')
laborCostDialogVisible.value = false
fetchProjectDetail()
} catch (error) {
console.error('添加失败:', error)
}
}
// 删除人工成本
const handleDeleteLaborCost = async (item: LaborCost) => {
try {
await ElMessageBox.confirm(`确定要删除该人工成本项吗?`, '提示', { type: 'warning' })
await projectApi.deleteLaborCost(props.project.id, item.id)
ElMessage.success('删除成功')
fetchProjectDetail()
} catch (error: any) {
if (error !== 'cancel') {
console.error('删除失败:', error)
}
}
}
// 计算成本
const handleCalculateCost = async () => {
loading.value = true
try {
const res = await projectApi.calculateCost(props.project.id, {
fixed_cost_allocation_method: allocationMethod.value,
})
calculationResult.value = res.data
ElMessage.success('成本计算完成')
fetchProjectDetail()
} catch (error) {
console.error('计算失败:', error)
} finally {
loading.value = false
}
}
// 关闭对话框
const handleClose = () => {
emit('update:visible', false)
emit('close')
}
// 格式化金额
const formatMoney = (val: number) => `¥${val.toFixed(2)}`
// 监听显示状态
watch(
() => props.visible,
(val) => {
if (val) {
fetchBasicData()
fetchProjectDetail()
}
},
{ immediate: true }
)
// 可选物料列表(根据类型筛选)
const selectableItems = computed(() => {
if (costItemForm.value.item_type === 'material') {
return materials.value.map(m => ({ id: m.id, name: m.material_name, unit: m.unit, price: m.unit_price }))
} else {
return equipments.value.map(e => ({ id: e.id, name: e.equipment_name, unit: '次', price: e.depreciation_per_use }))
}
})
</script>
<template>
<el-dialog
:model-value="visible"
title="成本详情"
width="900px"
@update:model-value="$emit('update:visible', $event)"
@close="handleClose"
>
<div v-loading="loading" class="cost-detail">
<!-- 项目信息 -->
<div class="section">
<h4>{{ project.project_name }}</h4>
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="编码">{{ project.project_code }}</el-descriptions-item>
<el-descriptions-item label="分类">{{ project.category_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="时长">{{ project.duration_minutes }}分钟</el-descriptions-item>
</el-descriptions>
</div>
<!-- 成本明细 -->
<div class="section">
<div class="section-header">
<span>成本明细耗材/设备</span>
<el-button type="primary" size="small" @click="handleAddCostItem">
<el-icon><Plus /></el-icon>
添加
</el-button>
</div>
<el-table :data="projectDetail?.cost_items || []" border size="small">
<el-table-column prop="item_type" label="类型" width="80" align="center">
<template #default="{ row }">
{{ row.item_type === 'material' ? '耗材' : '设备' }}
</template>
</el-table-column>
<el-table-column prop="item_name" label="名称" min-width="120" />
<el-table-column prop="quantity" label="数量" width="80" align="center">
<template #default="{ row }">
{{ row.quantity }}{{ row.unit }}
</template>
</el-table-column>
<el-table-column prop="unit_cost" label="单价" width="100" align="right">
<template #default="{ row }">{{ formatMoney(row.unit_cost) }}</template>
</el-table-column>
<el-table-column prop="total_cost" label="小计" width="100" align="right">
<template #default="{ row }">{{ formatMoney(row.total_cost) }}</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ row }">
<el-button type="danger" link size="small" @click="handleDeleteCostItem(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 人工成本 -->
<div class="section">
<div class="section-header">
<span>人工成本</span>
<el-button type="primary" size="small" @click="handleAddLaborCost">
<el-icon><Plus /></el-icon>
添加
</el-button>
</div>
<el-table :data="projectDetail?.labor_costs || []" border size="small">
<el-table-column prop="level_name" label="人员级别" min-width="120" />
<el-table-column prop="duration_minutes" label="时长" width="100" align="center">
<template #default="{ row }">{{ row.duration_minutes }}分钟</template>
</el-table-column>
<el-table-column prop="hourly_rate" label="时薪" width="100" align="right">
<template #default="{ row }">{{ formatMoney(row.hourly_rate) }}/小时</template>
</el-table-column>
<el-table-column prop="labor_cost" label="人工成本" width="100" align="right">
<template #default="{ row }">{{ formatMoney(row.labor_cost) }}</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ row }">
<el-button type="danger" link size="small" @click="handleDeleteLaborCost(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 成本计算 -->
<div class="section">
<div class="section-header">
<span>成本汇总</span>
<div class="calc-options">
<span>固定成本分摊</span>
<el-select v-model="allocationMethod" size="small" style="width: 120px">
<el-option
v-for="item in allocationMethodOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-button type="primary" size="small" @click="handleCalculateCost">
<el-icon><RefreshRight /></el-icon>
计算成本
</el-button>
</div>
</div>
<div v-if="projectDetail?.cost_summary" class="cost-summary">
<el-row :gutter="16">
<el-col :span="6">
<div class="summary-item">
<span class="label">耗材成本</span>
<span class="value">{{ formatMoney(projectDetail.cost_summary.material_cost) }}</span>
</div>
</el-col>
<el-col :span="6">
<div class="summary-item">
<span class="label">设备折旧</span>
<span class="value">{{ formatMoney(projectDetail.cost_summary.equipment_cost) }}</span>
</div>
</el-col>
<el-col :span="6">
<div class="summary-item">
<span class="label">人工成本</span>
<span class="value">{{ formatMoney(projectDetail.cost_summary.labor_cost) }}</span>
</div>
</el-col>
<el-col :span="6">
<div class="summary-item">
<span class="label">固定成本分摊</span>
<span class="value">{{ formatMoney(projectDetail.cost_summary.fixed_cost_allocation) }}</span>
</div>
</el-col>
</el-row>
<div class="total-cost">
<span>最低成本线</span>
<span class="total-value">{{ formatMoney(projectDetail.cost_summary.total_cost) }}</span>
</div>
</div>
<el-empty v-else description="暂无成本数据,请先添加成本明细并点击「计算成本」" />
</div>
</div>
<template #footer>
<el-button @click="handleClose">关闭</el-button>
</template>
<!-- 添加成本明细对话框 -->
<el-dialog v-model="costItemDialogVisible" title="添加成本明细" width="400px" append-to-body>
<el-form :model="costItemForm" label-width="80px">
<el-form-item label="类型">
<el-radio-group v-model="costItemForm.item_type">
<el-radio value="material">耗材</el-radio>
<el-radio value="equipment">设备</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="选择">
<el-select v-model="costItemForm.item_id" placeholder="请选择" filterable>
<el-option
v-for="item in selectableItems"
:key="item.id"
:label="`${item.name} (${formatMoney(item.price)}/${item.unit})`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="数量">
<el-input-number v-model="costItemForm.quantity" :min="0.01" :precision="2" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="costItemForm.remark" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="costItemDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmitCostItem">确定</el-button>
</template>
</el-dialog>
<!-- 添加人工成本对话框 -->
<el-dialog v-model="laborCostDialogVisible" title="添加人工成本" width="400px" append-to-body>
<el-form :model="laborCostForm" label-width="80px">
<el-form-item label="人员级别">
<el-select v-model="laborCostForm.staff_level_id" placeholder="请选择" filterable>
<el-option
v-for="item in staffLevels"
:key="item.id"
:label="`${item.level_name} (${formatMoney(item.hourly_rate)}/小时)`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="时长">
<el-input-number v-model="laborCostForm.duration_minutes" :min="1" />
<span style="margin-left: 8px; color: #909399;">分钟</span>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="laborCostForm.remark" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="laborCostDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmitLaborCost">确定</el-button>
</template>
</el-dialog>
</el-dialog>
</template>
<style scoped>
.cost-detail {
max-height: 70vh;
overflow-y: auto;
}
.section {
margin-bottom: 24px;
}
.section h4 {
margin: 0 0 12px 0;
font-size: 16px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-weight: 500;
}
.calc-options {
display: flex;
align-items: center;
gap: 8px;
font-weight: normal;
font-size: 14px;
}
.cost-summary {
background: #f5f7fa;
padding: 16px;
border-radius: 8px;
}
.summary-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px;
background: white;
border-radius: 6px;
}
.summary-item .label {
font-size: 12px;
color: #909399;
margin-bottom: 4px;
}
.summary-item .value {
font-size: 18px;
font-weight: 500;
color: #303133;
}
.total-cost {
margin-top: 16px;
padding-top: 16px;
border-top: 1px dashed #dcdfe6;
text-align: right;
font-size: 16px;
}
.total-value {
font-size: 24px;
font-weight: 600;
color: #e6a23c;
margin-left: 8px;
}
</style>

View File

@@ -0,0 +1,375 @@
<script setup lang="ts">
/**
* 服务项目管理页面(含成本核算)
*/
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { View, Plus, Money } from '@element-plus/icons-vue'
import { projectApi, Project, ProjectCreate, ProjectUpdate } from '@/api/projects'
import { categoryApi } from '@/api/categories'
import CostDetailDialog from './CostDetailDialog.vue'
// 分类列表
const categories = ref<{ id: number; category_name: string }[]>([])
// 查询参数
const queryParams = reactive({
page: 1,
page_size: 20,
keyword: '',
category_id: undefined as number | undefined,
is_active: undefined as boolean | undefined,
})
// 数据
const loading = ref(false)
const tableData = ref<Project[]>([])
const total = ref(0)
const dialogVisible = ref(false)
const dialogTitle = ref('新增项目')
const formRef = ref()
const form = ref<ProjectCreate>({
project_code: '',
project_name: '',
category_id: null,
description: '',
duration_minutes: 30,
is_active: true,
})
const editingId = ref<number | null>(null)
// 成本详情对话框
const costDetailVisible = ref(false)
const currentProject = ref<Project | null>(null)
// 表单规则
const rules = {
project_code: [
{ required: true, message: '请输入项目编码', trigger: 'blur' },
],
project_name: [
{ required: true, message: '请输入项目名称', trigger: 'blur' },
],
duration_minutes: [
{ required: true, message: '请输入操作时长', trigger: 'blur' },
],
}
// 获取分类
const fetchCategories = async () => {
try {
const res = await categoryApi.getList({ page_size: 100 })
categories.value = res.data?.items || []
} catch (error) {
console.error('获取分类失败:', error)
}
}
// 获取数据
const fetchData = async () => {
loading.value = true
try {
const res = await projectApi.getList(queryParams)
tableData.value = res.data?.items || []
total.value = res.data?.total || 0
} catch (error) {
console.error('获取项目失败:', error)
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
queryParams.page = 1
fetchData()
}
// 重置
const handleReset = () => {
queryParams.keyword = ''
queryParams.category_id = undefined
queryParams.is_active = undefined
queryParams.page = 1
fetchData()
}
// 新增
const handleAdd = () => {
editingId.value = null
dialogTitle.value = '新增项目'
form.value = {
project_code: '',
project_name: '',
category_id: null,
description: '',
duration_minutes: 30,
is_active: true,
}
dialogVisible.value = true
}
// 编辑
const handleEdit = (row: Project) => {
editingId.value = row.id
dialogTitle.value = '编辑项目'
form.value = {
project_code: row.project_code,
project_name: row.project_name,
category_id: row.category_id,
description: row.description || '',
duration_minutes: row.duration_minutes,
is_active: row.is_active,
}
dialogVisible.value = true
}
// 删除
const handleDelete = async (row: Project) => {
try {
await ElMessageBox.confirm(`确定要删除项目「${row.project_name}」吗?`, '提示', {
type: 'warning',
})
await projectApi.delete(row.id)
ElMessage.success('删除成功')
fetchData()
} catch (error: any) {
if (error !== 'cancel') {
console.error('删除失败:', error)
}
}
}
// 查看成本详情
const handleViewCost = (row: Project) => {
currentProject.value = row
costDetailVisible.value = true
}
// 提交表单
const handleSubmit = async () => {
try {
await formRef.value?.validate()
if (editingId.value) {
await projectApi.update(editingId.value, form.value as ProjectUpdate)
ElMessage.success('更新成功')
} else {
await projectApi.create(form.value)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchData()
} catch (error) {
console.error('提交失败:', error)
}
}
// 分页
const handleCurrentChange = (page: number) => {
queryParams.page = page
fetchData()
}
const handleSizeChange = (size: number) => {
queryParams.page_size = size
queryParams.page = 1
fetchData()
}
// 格式化成本
const formatCost = (cost: number | null | undefined) => {
if (cost === null || cost === undefined) return '--'
return `¥${cost.toFixed(2)}`
}
// 成本详情关闭
const handleCostDetailClose = () => {
costDetailVisible.value = false
currentProject.value = null
fetchData() // 刷新列表
}
// 初始化
onMounted(() => {
fetchCategories()
fetchData()
})
</script>
<template>
<div class="page-container">
<el-card shadow="never">
<!-- 搜索栏 -->
<el-form :inline="true" class="search-form">
<el-form-item label="关键词">
<el-input
v-model="queryParams.keyword"
placeholder="编码/名称"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="分类">
<el-select v-model="queryParams.category_id" placeholder="全部" clearable>
<el-option
v-for="item in categories"
:key="item.id"
:label="item.category_name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryParams.is_active" placeholder="全部" clearable>
<el-option label="启用" :value="true" />
<el-option label="禁用" :value="false" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增项目
</el-button>
</div>
<!-- 表格 -->
<el-table v-loading="loading" :data="tableData" border>
<el-table-column prop="project_code" label="编码" width="120" />
<el-table-column prop="project_name" label="名称" min-width="150" />
<el-table-column prop="category_name" label="分类" width="100" />
<el-table-column prop="duration_minutes" label="时长" width="80" align="center">
<template #default="{ row }">
{{ row.duration_minutes }}分钟
</template>
</el-table-column>
<el-table-column label="总成本" width="120" align="right">
<template #default="{ row }">
<span :class="{ 'cost-value': row.cost_summary }">
{{ formatCost(row.cost_summary?.total_cost) }}
</span>
</template>
</el-table-column>
<el-table-column prop="is_active" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
{{ row.is_active ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleViewCost(row)">
<el-icon><Money /></el-icon>
成本
</el-button>
<el-button type="primary" link size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.page_size"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<!-- 表单对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="编码" prop="project_code">
<el-input v-model="form.project_code" placeholder="请输入编码" />
</el-form-item>
<el-form-item label="名称" prop="project_name">
<el-input v-model="form.project_name" placeholder="请输入名称" />
</el-form-item>
<el-form-item label="分类" prop="category_id">
<el-select v-model="form.category_id" placeholder="请选择分类" clearable>
<el-option
v-for="item in categories"
:key="item.id"
:label="item.category_name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="时长" prop="duration_minutes">
<el-input-number v-model="form.duration_minutes" :min="0" />
<span class="unit-label">分钟</span>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入描述"
/>
</el-form-item>
<el-form-item label="状态" prop="is_active">
<el-switch v-model="form.is_active" active-text="启用" inactive-text="禁用" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
<!-- 成本详情对话框 -->
<CostDetailDialog
v-if="currentProject"
v-model:visible="costDetailVisible"
:project="currentProject"
@close="handleCostDetailClose"
/>
</div>
</template>
<style scoped>
.search-form {
margin-bottom: 16px;
}
.toolbar {
margin-bottom: 16px;
display: flex;
gap: 12px;
}
.pagination {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.cost-value {
color: #e6a23c;
font-weight: 500;
}
.unit-label {
margin-left: 8px;
color: #909399;
}
</style>

View File

@@ -0,0 +1,608 @@
<script setup lang="ts">
/**
* 仪表盘页面
*/
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import * as echarts from 'echarts'
import { dashboardApi, type DashboardSummaryResponse, type CostTrendResponse, type MarketTrendResponse } from '@/api/dashboard'
const router = useRouter()
// 统计数据
const loading = ref(false)
const summary = ref<DashboardSummaryResponse | null>(null)
// 图表
const costChartRef = ref<HTMLElement | null>(null)
const marketChartRef = ref<HTMLElement | null>(null)
let costChart: echarts.ECharts | null = null
let marketChart: echarts.ECharts | null = null
// 图表加载状态和数据状态
const costChartLoading = ref(false)
const marketChartLoading = ref(false)
const costChartEmpty = ref(false)
const marketChartEmpty = ref(false)
// 加载仪表盘数据
const loadDashboard = async () => {
loading.value = true
try {
const res = await dashboardApi.getSummary()
summary.value = res.data
} catch (error) {
console.error('加载仪表盘数据失败:', error)
} finally {
loading.value = false
}
}
// 加载成本趋势图表
const loadCostTrend = async () => {
costChartLoading.value = true
costChartEmpty.value = false
try {
const res = await dashboardApi.getCostTrend('month')
if (!res.data?.data || res.data.data.length === 0) {
costChartEmpty.value = true
} else {
renderCostChart(res.data)
}
} catch (error) {
console.error('加载成本趋势失败:', error)
costChartEmpty.value = true
} finally {
costChartLoading.value = false
}
}
// 加载市场趋势图表
const loadMarketTrend = async () => {
marketChartLoading.value = true
marketChartEmpty.value = false
try {
const res = await dashboardApi.getMarketTrend('month')
if (!res.data?.data || res.data.data.length === 0) {
marketChartEmpty.value = true
} else {
renderMarketChart(res.data)
}
} catch (error) {
console.error('加载市场趋势失败:', error)
marketChartEmpty.value = true
} finally {
marketChartLoading.value = false
}
}
// 渲染成本趋势图表
const renderCostChart = (data: CostTrendResponse) => {
if (!costChartRef.value) return
if (costChart) costChart.dispose()
costChart = echarts.init(costChartRef.value)
const option: echarts.EChartsOption = {
tooltip: {
trigger: 'axis',
formatter: '{b}<br/>平均成本: ¥{c}',
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '10%',
containLabel: true,
},
xAxis: {
type: 'category',
data: data.data.map((d) => d.date),
axisLabel: {
rotate: 45,
fontSize: 10,
},
},
yAxis: {
type: 'value',
name: '成本 (元)',
},
series: [
{
type: 'line',
data: data.data.map((d) => d.value),
smooth: true,
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(64, 158, 255, 0.3)' },
{ offset: 1, color: 'rgba(64, 158, 255, 0.05)' },
]),
},
itemStyle: {
color: '#409EFF',
},
},
],
}
costChart.setOption(option)
}
// 渲染市场趋势图表
const renderMarketChart = (data: MarketTrendResponse) => {
if (!marketChartRef.value) return
if (marketChart) marketChart.dispose()
marketChart = echarts.init(marketChartRef.value)
const option: echarts.EChartsOption = {
tooltip: {
trigger: 'axis',
formatter: '{b}<br/>市场均价: ¥{c}',
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '10%',
containLabel: true,
},
xAxis: {
type: 'category',
data: data.data.map((d) => d.date),
axisLabel: {
rotate: 45,
fontSize: 10,
},
},
yAxis: {
type: 'value',
name: '价格 (元)',
},
series: [
{
type: 'line',
data: data.data.map((d) => d.value),
smooth: true,
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(103, 194, 58, 0.3)' },
{ offset: 1, color: 'rgba(103, 194, 58, 0.05)' },
]),
},
itemStyle: {
color: '#67C23A',
},
},
],
}
marketChart.setOption(option)
}
// 格式化时间
const formatTime = (dateStr: string) => {
const date = new Date(dateStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
return date.toLocaleDateString('zh-CN')
}
// 获取活动类型标签
const getActivityLabel = (type: string) => {
const labels: Record<string, string> = {
'pricing_created': '创建定价方案',
'cost_calculated': '计算成本',
'market_analysis': '市场分析',
'profit_simulated': '利润模拟',
}
return labels[type] || type
}
// 计算策略百分比
const getStrategyPercentage = (type: 'traffic' | 'profit' | 'premium') => {
if (!summary.value?.pricing_overview?.strategies_distribution) return 0
const dist = summary.value.pricing_overview.strategies_distribution
const total = dist.traffic + dist.profit + dist.premium
if (total === 0) return 0
return (dist[type] / total) * 100
}
// 初始化
onMounted(() => {
loadDashboard()
loadCostTrend()
loadMarketTrend()
})
</script>
<template>
<div class="dashboard" v-loading="loading">
<!-- 统计卡片 -->
<el-row :gutter="20" class="stat-cards">
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-info">
<div class="stat-title">项目总数</div>
<div class="stat-value">{{ summary?.project_overview.total_projects || 0 }}</div>
<div class="stat-sub">
启用: {{ summary?.project_overview.active_projects || 0 }}
</div>
</div>
<div class="stat-icon" style="background-color: #409eff">
<el-icon :size="24"><Document /></el-icon>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-info">
<div class="stat-title">平均项目成本</div>
<div class="stat-value">¥{{ summary?.cost_overview.avg_project_cost?.toFixed(0) || 0 }}</div>
<div class="stat-sub">
最高: ¥{{ summary?.cost_overview.highest_cost_project?.cost?.toFixed(0) || 0 }}
</div>
</div>
<div class="stat-icon" style="background-color: #67c23a">
<el-icon :size="24"><Coin /></el-icon>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-info">
<div class="stat-title">跟踪竞品数</div>
<div class="stat-value">{{ summary?.market_overview.competitors_tracked || 0 }}</div>
<div class="stat-sub">
本月记录: {{ summary?.market_overview.price_records_this_month || 0 }}
</div>
</div>
<div class="stat-icon" style="background-color: #e6a23c">
<el-icon :size="24"><OfficeBuilding /></el-icon>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-info">
<div class="stat-title">定价方案数</div>
<div class="stat-value">{{ summary?.pricing_overview.pricing_plans_count || 0 }}</div>
<div class="stat-sub">
平均毛利率: {{ summary?.pricing_overview.avg_target_margin?.toFixed(1) || 0 }}%
</div>
</div>
<div class="stat-icon" style="background-color: #f56c6c">
<el-icon :size="24"><Money /></el-icon>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 图表区域 -->
<el-row :gutter="20" class="chart-row">
<el-col :xs="24" :lg="12">
<el-card shadow="hover" v-loading="costChartLoading">
<template #header>
<div class="card-header">
<span>成本趋势</span>
<el-tag size="small">近30天</el-tag>
</div>
</template>
<div v-if="costChartEmpty" class="chart-empty">
<el-empty description="暂无成本数据" :image-size="80" />
</div>
<div v-else ref="costChartRef" style="height: 280px;"></div>
</el-card>
</el-col>
<el-col :xs="24" :lg="12">
<el-card shadow="hover" v-loading="marketChartLoading">
<template #header>
<div class="card-header">
<span>市场价格趋势</span>
<el-tag size="small">近30天</el-tag>
</div>
</template>
<div v-if="marketChartEmpty" class="chart-empty">
<el-empty description="暂无市场数据" :image-size="80" />
</div>
<div v-else ref="marketChartRef" style="height: 280px;"></div>
</el-card>
</el-col>
</el-row>
<!-- 策略分布和快捷操作 -->
<el-row :gutter="20" class="info-row">
<el-col :xs="24" :lg="8">
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span>定价策略分布</span>
</div>
</template>
<div class="strategy-distribution">
<div class="strategy-item">
<div class="strategy-label">
<el-tag type="warning" size="small">引流款</el-tag>
</div>
<div class="strategy-bar">
<div
class="bar-fill traffic"
:style="{ width: getStrategyPercentage('traffic') + '%' }"
></div>
</div>
<div class="strategy-count">{{ summary?.pricing_overview.strategies_distribution.traffic || 0 }}</div>
</div>
<div class="strategy-item">
<div class="strategy-label">
<el-tag size="small">利润款</el-tag>
</div>
<div class="strategy-bar">
<div
class="bar-fill profit"
:style="{ width: getStrategyPercentage('profit') + '%' }"
></div>
</div>
<div class="strategy-count">{{ summary?.pricing_overview.strategies_distribution.profit || 0 }}</div>
</div>
<div class="strategy-item">
<div class="strategy-label">
<el-tag type="success" size="small">高端款</el-tag>
</div>
<div class="strategy-bar">
<div
class="bar-fill premium"
:style="{ width: getStrategyPercentage('premium') + '%' }"
></div>
</div>
<div class="strategy-count">{{ summary?.pricing_overview.strategies_distribution.premium || 0 }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :lg="8">
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span>快捷操作</span>
</div>
</template>
<div class="quick-actions">
<el-button type="primary" @click="router.push('/pricing/plans')">
<el-icon><MagicStick /></el-icon>
AI 智能定价
</el-button>
<el-button @click="router.push('/profit/simulations')">
<el-icon><DataAnalysis /></el-icon>
利润模拟
</el-button>
<el-button @click="router.push('/market/analysis')">
<el-icon><TrendCharts /></el-icon>
市场分析
</el-button>
<el-button @click="router.push('/cost/projects')">
<el-icon><Coin /></el-icon>
成本核算
</el-button>
</div>
</el-card>
</el-col>
<el-col :xs="24" :lg="8">
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span>最近活动</span>
</div>
</template>
<div class="recent-activities">
<template v-if="summary?.recent_activities?.length">
<div
v-for="(activity, idx) in summary.recent_activities.slice(0, 5)"
:key="idx"
class="activity-item"
>
<div class="activity-content">
<span class="activity-type">{{ getActivityLabel(activity.type) }}</span>
<span class="activity-project">{{ activity.project_name }}</span>
</div>
<div class="activity-time">{{ formatTime(activity.time) }}</div>
</div>
</template>
<el-empty v-else description="暂无活动记录" :image-size="60" />
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<style scoped>
.dashboard {
padding: 0;
}
.stat-cards {
margin-bottom: 20px;
}
.stat-card {
border-radius: 8px;
margin-bottom: 20px;
}
.stat-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.stat-info {
flex: 1;
}
.stat-title {
font-size: 14px;
color: #909399;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: 600;
color: #303133;
}
.stat-sub {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.chart-row {
margin-bottom: 20px;
}
.chart-row .el-card {
border-radius: 8px;
margin-bottom: 20px;
}
.chart-empty {
height: 280px;
display: flex;
align-items: center;
justify-content: center;
}
.info-row .el-card {
border-radius: 8px;
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
}
.strategy-distribution {
display: flex;
flex-direction: column;
gap: 16px;
}
.strategy-item {
display: flex;
align-items: center;
gap: 12px;
}
.strategy-label {
width: 70px;
}
.strategy-bar {
flex: 1;
height: 8px;
background-color: #f0f0f0;
border-radius: 4px;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s;
}
.bar-fill.traffic {
background-color: #e6a23c;
}
.bar-fill.profit {
background-color: #409eff;
}
.bar-fill.premium {
background-color: #67c23a;
}
.strategy-count {
width: 30px;
text-align: right;
font-weight: 600;
color: #303133;
}
.quick-actions {
display: flex;
flex-direction: column;
gap: 12px;
}
.quick-actions .el-button {
justify-content: flex-start;
}
.recent-activities {
display: flex;
flex-direction: column;
gap: 12px;
}
.activity-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.activity-item:last-child {
border-bottom: none;
}
.activity-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.activity-type {
font-size: 13px;
color: #303133;
}
.activity-project {
font-size: 12px;
color: #909399;
}
.activity-time {
font-size: 12px;
color: #c0c4cc;
}
</style>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
/**
* 404 页面
*/
import { useRouter } from 'vue-router'
const router = useRouter()
const goHome = () => {
router.push('/')
}
</script>
<template>
<div class="error-page">
<div class="error-content">
<div class="error-code">404</div>
<div class="error-message">抱歉您访问的页面不存在</div>
<el-button type="primary" @click="goHome">
返回首页
</el-button>
</div>
</div>
</template>
<style scoped>
.error-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f7fa;
}
.error-content {
text-align: center;
}
.error-code {
font-size: 120px;
font-weight: 600;
color: #409eff;
line-height: 1;
margin-bottom: 20px;
}
.error-message {
font-size: 18px;
color: #606266;
margin-bottom: 30px;
}
</style>

View File

@@ -0,0 +1,222 @@
<script setup lang="ts">
/**
* 主布局组件
* 包含侧边栏导航、顶部栏和主内容区
*/
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import SideMenu from './components/SideMenu.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
// 当前路由信息
const currentRoute = computed(() => route.path)
// 面包屑导航
const breadcrumbs = computed(() => {
const matched = route.matched.filter((item) => item.meta?.title)
return matched.map((item) => ({
title: item.meta?.title as string,
path: item.path,
}))
})
// 跳转面包屑
const handleBreadcrumbClick = (path: string) => {
if (path && path !== route.path) {
router.push(path)
}
}
</script>
<template>
<el-container class="main-layout">
<!-- 侧边栏 -->
<el-aside :width="appStore.sidebarWidth" class="sidebar">
<div class="logo">
<img src="@/assets/logo.svg" alt="logo" class="logo-img" />
<span v-if="!appStore.sidebarCollapsed" class="logo-text">智能定价模型</span>
</div>
<SideMenu :collapsed="appStore.sidebarCollapsed" />
</el-aside>
<el-container class="main-container">
<!-- 顶部栏 -->
<el-header class="header">
<div class="header-left">
<!-- 折叠按钮 -->
<el-icon
class="collapse-btn"
@click="appStore.toggleSidebar"
>
<Fold v-if="!appStore.sidebarCollapsed" />
<Expand v-else />
</el-icon>
<!-- 面包屑 -->
<el-breadcrumb separator="/">
<el-breadcrumb-item
v-for="(item, index) in breadcrumbs"
:key="index"
>
<span
:class="{ 'breadcrumb-link': index < breadcrumbs.length - 1 }"
@click="handleBreadcrumbClick(item.path || '')"
>
{{ item.title }}
</span>
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<!-- 用户信息 -->
<el-dropdown trigger="click">
<div class="user-info">
<el-avatar :size="32" icon="User" />
<span class="username">管理员</span>
<el-icon><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>个人设置</el-dropdown-item>
<el-dropdown-item divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- 主内容区 -->
<el-main class="main-content">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</el-main>
</el-container>
</el-container>
</template>
<style scoped>
.main-layout {
height: 100vh;
overflow: hidden;
}
.sidebar {
background-color: #304156;
transition: width 0.3s;
overflow: hidden;
}
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 16px;
background-color: #263445;
}
.logo-img {
width: 32px;
height: 32px;
}
.logo-text {
color: #fff;
font-size: 16px;
font-weight: 600;
margin-left: 10px;
white-space: nowrap;
}
.main-container {
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
height: 60px;
background-color: #fff;
border-bottom: 1px solid #e6e6e6;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.collapse-btn {
font-size: 20px;
cursor: pointer;
color: #606266;
transition: color 0.3s;
}
.collapse-btn:hover {
color: #409eff;
}
.breadcrumb-link {
cursor: pointer;
color: #606266;
}
.breadcrumb-link:hover {
color: #409eff;
}
.header-right {
display: flex;
align-items: center;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.3s;
}
.user-info:hover {
background-color: #f5f5f5;
}
.username {
font-size: 14px;
color: #606266;
}
.main-content {
background-color: #f5f7fa;
padding: 20px;
overflow: auto;
}
/* 页面切换动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,146 @@
<script setup lang="ts">
/**
* 侧边栏菜单组件
*/
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
interface Props {
collapsed: boolean
}
defineProps<Props>()
const route = useRoute()
const router = useRouter()
// 当前激活的菜单
const activeMenu = computed(() => {
const { path } = route
return path
})
// 菜单数据
const menuItems = [
{
index: '/dashboard',
icon: 'Odometer',
title: '仪表盘',
},
{
index: '/cost',
icon: 'Coin',
title: '成本核算',
children: [
{ index: '/cost/projects', icon: 'List', title: '服务项目' },
],
},
{
index: '/market',
icon: 'TrendCharts',
title: '市场行情',
children: [
{ index: '/market/competitors', icon: 'OfficeBuilding', title: '竞品机构' },
{ index: '/market/benchmarks', icon: 'PriceTag', title: '标杆价格' },
{ index: '/market/analysis', icon: 'DataAnalysis', title: '市场分析' },
],
},
{
index: '/pricing',
icon: 'Money',
title: '智能定价',
children: [
{ index: '/pricing/plans', icon: 'List', title: '定价方案' },
],
},
{
index: '/profit',
icon: 'DataLine',
title: '利润模拟',
children: [
{ index: '/profit/simulations', icon: 'DataAnalysis', title: '模拟测算' },
],
},
{
index: '/settings',
icon: 'Setting',
title: '基础数据',
children: [
{ index: '/settings/categories', icon: 'Menu', title: '项目分类' },
{ index: '/settings/materials', icon: 'Box', title: '耗材管理' },
{ index: '/settings/equipments', icon: 'Monitor', title: '设备管理' },
{ index: '/settings/staff-levels', icon: 'User', title: '人员级别' },
{ index: '/settings/fixed-costs', icon: 'Wallet', title: '固定成本' },
],
},
]
// 菜单点击
const handleMenuSelect = (index: string) => {
router.push(index)
}
</script>
<template>
<el-menu
:default-active="activeMenu"
:collapse="collapsed"
:collapse-transition="false"
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409eff"
class="side-menu"
@select="handleMenuSelect"
>
<template v-for="item in menuItems" :key="item.index">
<!-- 有子菜单 -->
<el-sub-menu v-if="item.children" :index="item.index">
<template #title>
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</template>
<el-menu-item
v-for="child in item.children"
:key="child.index"
:index="child.index"
>
<el-icon><component :is="child.icon" /></el-icon>
<span>{{ child.title }}</span>
</el-menu-item>
</el-sub-menu>
<!-- 无子菜单 -->
<el-menu-item v-else :index="item.index">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
</template>
</el-menu>
</template>
<style scoped>
.side-menu {
border-right: none;
height: calc(100vh - 60px);
overflow-y: auto;
overflow-x: hidden;
}
.side-menu:not(.el-menu--collapse) {
width: 220px;
}
/* 滚动条样式 */
.side-menu::-webkit-scrollbar {
width: 4px;
}
.side-menu::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
}
.side-menu::-webkit-scrollbar-track {
background: transparent;
}
</style>

View File

@@ -0,0 +1,381 @@
<script setup lang="ts">
/**
* 市场分析页面
*/
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { DataAnalysis, Refresh } from '@element-plus/icons-vue'
import { marketAnalysisApi, MarketAnalysisResult } from '@/api/market-analysis'
import { projectApi, Project } from '@/api/projects'
// 项目列表
const projects = ref<Project[]>([])
const selectedProjectId = ref<number | null>(null)
// 分析结果
const loading = ref(false)
const analysisResult = ref<MarketAnalysisResult | null>(null)
// 获取项目列表
const fetchProjects = async () => {
try {
const res = await projectApi.getList({ page_size: 100, is_active: true })
projects.value = res.data?.items || []
} catch (error) {
console.error('获取项目失败:', error)
}
}
// 执行分析
const handleAnalyze = async () => {
if (!selectedProjectId.value) {
ElMessage.warning('请选择项目')
return
}
loading.value = true
try {
const res = await marketAnalysisApi.analyze(selectedProjectId.value)
analysisResult.value = res.data
ElMessage.success('分析完成')
} catch (error) {
console.error('分析失败:', error)
} finally {
loading.value = false
}
}
// 格式化金额
const formatMoney = (val: number) => `¥${val.toFixed(2)}`
// 获取定位标签颜色
const getPositioningType = (positioning: string) => {
const map: Record<string, string> = {
high: 'danger',
medium: '',
budget: 'success',
}
return map[positioning] || ''
}
// 初始化
onMounted(() => {
fetchProjects()
})
</script>
<template>
<div class="page-container">
<el-card shadow="never">
<!-- 选择项目 -->
<div class="select-section">
<span>选择项目</span>
<el-select
v-model="selectedProjectId"
placeholder="请选择要分析的项目"
filterable
style="width: 300px"
>
<el-option
v-for="item in projects"
:key="item.id"
:label="item.project_name"
:value="item.id"
/>
</el-select>
<el-button type="primary" :loading="loading" @click="handleAnalyze">
<el-icon><DataAnalysis /></el-icon>
执行分析
</el-button>
</div>
<!-- 分析结果 -->
<template v-if="analysisResult">
<el-divider />
<!-- 基本信息 -->
<div class="section">
<h4>{{ analysisResult.project_name }}</h4>
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="分析日期">{{ analysisResult.analysis_date }}</el-descriptions-item>
<el-descriptions-item label="样本数量">{{ analysisResult.competitor_count }}</el-descriptions-item>
<el-descriptions-item label="建议价格">
<span class="recommend-price">{{ formatMoney(analysisResult.suggested_range.recommended) }}</span>
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 价格统计 -->
<div class="section">
<h4>价格统计</h4>
<el-row :gutter="16">
<el-col :span="6">
<div class="stat-card">
<span class="label">市场最低价</span>
<span class="value">{{ formatMoney(analysisResult.price_statistics.min_price) }}</span>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<span class="label">市场最高价</span>
<span class="value">{{ formatMoney(analysisResult.price_statistics.max_price) }}</span>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<span class="label">市场均价</span>
<span class="value highlight">{{ formatMoney(analysisResult.price_statistics.avg_price) }}</span>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<span class="label">市场中位价</span>
<span class="value">{{ formatMoney(analysisResult.price_statistics.median_price) }}</span>
</div>
</el-col>
</el-row>
</div>
<!-- 价格分布 -->
<div v-if="analysisResult.price_distribution" class="section">
<h4>价格分布</h4>
<el-row :gutter="16">
<el-col :span="8">
<div class="distribution-item low">
<div class="dist-label">低价位 {{ analysisResult.price_distribution.low.range }}</div>
<div class="dist-bar">
<div
class="dist-fill"
:style="{ width: `${analysisResult.price_distribution.low.percentage}%` }"
></div>
</div>
<div class="dist-value">{{ analysisResult.price_distribution.low.count }} ({{ analysisResult.price_distribution.low.percentage }}%)</div>
</div>
</el-col>
<el-col :span="8">
<div class="distribution-item medium">
<div class="dist-label">中价位 {{ analysisResult.price_distribution.medium.range }}</div>
<div class="dist-bar">
<div
class="dist-fill"
:style="{ width: `${analysisResult.price_distribution.medium.percentage}%` }"
></div>
</div>
<div class="dist-value">{{ analysisResult.price_distribution.medium.count }} ({{ analysisResult.price_distribution.medium.percentage }}%)</div>
</div>
</el-col>
<el-col :span="8">
<div class="distribution-item high">
<div class="dist-label">高价位 {{ analysisResult.price_distribution.high.range }}</div>
<div class="dist-bar">
<div
class="dist-fill"
:style="{ width: `${analysisResult.price_distribution.high.percentage}%` }"
></div>
</div>
<div class="dist-value">{{ analysisResult.price_distribution.high.count }} ({{ analysisResult.price_distribution.high.percentage }}%)</div>
</div>
</el-col>
</el-row>
</div>
<!-- 建议定价区间 -->
<div class="section">
<h4>建议定价区间</h4>
<div class="price-range">
<div class="range-bar">
<div class="range-min">{{ formatMoney(analysisResult.suggested_range.min) }}</div>
<div class="range-fill"></div>
<div class="range-recommend">
<div class="recommend-marker"></div>
<span>推荐: {{ formatMoney(analysisResult.suggested_range.recommended) }}</span>
</div>
<div class="range-max">{{ formatMoney(analysisResult.suggested_range.max) }}</div>
</div>
</div>
</div>
<!-- 标杆参考 -->
<div v-if="analysisResult.benchmark_reference" class="section">
<h4>标杆参考</h4>
<el-descriptions :column="4" border size="small">
<el-descriptions-item label="价格带">
<el-tag size="small">{{ analysisResult.benchmark_reference.tier }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="最低价">{{ formatMoney(analysisResult.benchmark_reference.min_price) }}</el-descriptions-item>
<el-descriptions-item label="最高价">{{ formatMoney(analysisResult.benchmark_reference.max_price) }}</el-descriptions-item>
<el-descriptions-item label="均价">{{ formatMoney(analysisResult.benchmark_reference.avg_price) }}</el-descriptions-item>
</el-descriptions>
</div>
<!-- 竞品价格列表 -->
<div class="section">
<h4>竞品价格明细</h4>
<el-table :data="analysisResult.competitor_prices" border size="small" max-height="300">
<el-table-column prop="competitor_name" label="竞品机构" min-width="120" />
<el-table-column prop="positioning" label="定位" width="80" align="center">
<template #default="{ row }">
<el-tag :type="getPositioningType(row.positioning)" size="small">
{{ row.positioning === 'high' ? '高端' : row.positioning === 'medium' ? '中端' : '大众' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="original_price" label="原价" width="100" align="right">
<template #default="{ row }">{{ formatMoney(row.original_price) }}</template>
</el-table-column>
<el-table-column prop="promo_price" label="促销价" width="100" align="right">
<template #default="{ row }">{{ row.promo_price ? formatMoney(row.promo_price) : '-' }}</template>
</el-table-column>
<el-table-column prop="collected_at" label="采集日期" width="110" align="center" />
</el-table>
<el-empty v-if="analysisResult.competitor_prices.length === 0" description="暂无竞品价格数据" />
</div>
</template>
<el-empty v-else-if="!loading" description="请选择项目并执行分析" />
</el-card>
</div>
</template>
<style scoped>
.select-section {
display: flex;
align-items: center;
gap: 12px;
}
.section {
margin-bottom: 24px;
}
.section h4 {
margin: 0 0 12px 0;
font-size: 15px;
color: #303133;
}
.stat-card {
background: #f5f7fa;
padding: 16px;
border-radius: 8px;
text-align: center;
}
.stat-card .label {
display: block;
font-size: 12px;
color: #909399;
margin-bottom: 8px;
}
.stat-card .value {
font-size: 20px;
font-weight: 600;
color: #303133;
}
.stat-card .value.highlight {
color: #409eff;
}
.recommend-price {
font-size: 18px;
font-weight: 600;
color: #e6a23c;
}
.distribution-item {
padding: 12px;
border-radius: 8px;
background: #f5f7fa;
}
.distribution-item .dist-label {
font-size: 13px;
color: #606266;
margin-bottom: 8px;
}
.distribution-item .dist-bar {
height: 8px;
background: #e4e7ed;
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
}
.distribution-item .dist-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s;
}
.distribution-item.low .dist-fill {
background: #67c23a;
}
.distribution-item.medium .dist-fill {
background: #409eff;
}
.distribution-item.high .dist-fill {
background: #e6a23c;
}
.distribution-item .dist-value {
font-size: 12px;
color: #909399;
text-align: right;
}
.price-range {
background: #f5f7fa;
padding: 24px;
border-radius: 8px;
}
.range-bar {
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
}
.range-fill {
flex: 1;
height: 4px;
background: linear-gradient(to right, #67c23a, #409eff, #e6a23c);
margin: 0 12px;
border-radius: 2px;
}
.range-min,
.range-max {
font-size: 14px;
color: #606266;
}
.range-recommend {
position: absolute;
left: 50%;
transform: translateX(-50%);
top: -24px;
display: flex;
flex-direction: column;
align-items: center;
}
.recommend-marker {
width: 12px;
height: 12px;
background: #e6a23c;
border-radius: 50%;
margin-bottom: 4px;
}
.range-recommend span {
font-size: 14px;
font-weight: 500;
color: #e6a23c;
}
</style>

View File

@@ -0,0 +1,350 @@
<script setup lang="ts">
/**
* 标杆价格管理页面
*/
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { benchmarkPriceApi, BenchmarkPrice, BenchmarkPriceCreate, BenchmarkPriceUpdate, priceTierOptions } from '@/api/benchmark-prices'
import { categoryApi } from '@/api/categories'
// 分类列表
const categories = ref<{ id: number; category_name: string }[]>([])
// 查询参数
const queryParams = reactive({
page: 1,
page_size: 20,
category_id: undefined as number | undefined,
})
// 数据
const loading = ref(false)
const tableData = ref<BenchmarkPrice[]>([])
const total = ref(0)
const dialogVisible = ref(false)
const dialogTitle = ref('新增标杆价格')
const formRef = ref()
const form = ref<BenchmarkPriceCreate>({
benchmark_name: '',
category_id: null,
min_price: 0,
max_price: 0,
avg_price: 0,
price_tier: 'medium',
effective_date: new Date().toISOString().split('T')[0],
remark: '',
})
const editingId = ref<number | null>(null)
// 表单规则
const rules = {
benchmark_name: [
{ required: true, message: '请输入标杆机构名称', trigger: 'blur' },
],
min_price: [
{ required: true, message: '请输入最低价', trigger: 'blur' },
],
max_price: [
{ required: true, message: '请输入最高价', trigger: 'blur' },
],
avg_price: [
{ required: true, message: '请输入均价', trigger: 'blur' },
],
effective_date: [
{ required: true, message: '请选择生效日期', trigger: 'change' },
],
}
// 获取分类
const fetchCategories = async () => {
try {
const res = await categoryApi.getList({ page_size: 100 })
categories.value = res.data?.items || []
} catch (error) {
console.error('获取分类失败:', error)
}
}
// 获取数据
const fetchData = async () => {
loading.value = true
try {
const res = await benchmarkPriceApi.getList(queryParams)
tableData.value = res.data?.items || []
total.value = res.data?.total || 0
} catch (error) {
console.error('获取标杆价格失败:', error)
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
queryParams.page = 1
fetchData()
}
// 重置
const handleReset = () => {
queryParams.category_id = undefined
queryParams.page = 1
fetchData()
}
// 新增
const handleAdd = () => {
editingId.value = null
dialogTitle.value = '新增标杆价格'
form.value = {
benchmark_name: '',
category_id: null,
min_price: 0,
max_price: 0,
avg_price: 0,
price_tier: 'medium',
effective_date: new Date().toISOString().split('T')[0],
remark: '',
}
dialogVisible.value = true
}
// 编辑
const handleEdit = (row: BenchmarkPrice) => {
editingId.value = row.id
dialogTitle.value = '编辑标杆价格'
form.value = {
benchmark_name: row.benchmark_name,
category_id: row.category_id,
min_price: row.min_price,
max_price: row.max_price,
avg_price: row.avg_price,
price_tier: row.price_tier,
effective_date: row.effective_date,
remark: row.remark || '',
}
dialogVisible.value = true
}
// 删除
const handleDelete = async (row: BenchmarkPrice) => {
try {
await ElMessageBox.confirm(`确定要删除该标杆价格吗?`, '提示', {
type: 'warning',
})
await benchmarkPriceApi.delete(row.id)
ElMessage.success('删除成功')
fetchData()
} catch (error: any) {
if (error !== 'cancel') {
console.error('删除失败:', error)
}
}
}
// 提交表单
const handleSubmit = async () => {
try {
await formRef.value?.validate()
if (editingId.value) {
await benchmarkPriceApi.update(editingId.value, form.value as BenchmarkPriceUpdate)
ElMessage.success('更新成功')
} else {
await benchmarkPriceApi.create(form.value)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchData()
} catch (error) {
console.error('提交失败:', error)
}
}
// 分页
const handleCurrentChange = (page: number) => {
queryParams.page = page
fetchData()
}
const handleSizeChange = (size: number) => {
queryParams.page_size = size
queryParams.page = 1
fetchData()
}
// 获取价格带标签
const getTierLabel = (tier: string) => {
return priceTierOptions.find(item => item.value === tier)?.label || tier
}
const getTierType = (tier: string) => {
const map: Record<string, string> = {
low: 'success',
medium: '',
high: 'warning',
premium: 'danger',
}
return map[tier] || ''
}
// 格式化金额
const formatMoney = (val: number) => `¥${val.toFixed(2)}`
// 初始化
onMounted(() => {
fetchCategories()
fetchData()
})
</script>
<template>
<div class="page-container">
<el-card shadow="never">
<!-- 搜索栏 -->
<el-form :inline="true" class="search-form">
<el-form-item label="分类">
<el-select v-model="queryParams.category_id" placeholder="全部" clearable>
<el-option
v-for="item in categories"
:key="item.id"
:label="item.category_name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增标杆价格
</el-button>
</div>
<!-- 表格 -->
<el-table v-loading="loading" :data="tableData" border>
<el-table-column prop="benchmark_name" label="标杆机构" min-width="150" />
<el-table-column prop="category_name" label="分类" width="100" />
<el-table-column prop="price_tier" label="价格带" width="100" align="center">
<template #default="{ row }">
<el-tag :type="getTierType(row.price_tier)" size="small">
{{ getTierLabel(row.price_tier) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="min_price" label="最低价" width="100" align="right">
<template #default="{ row }">{{ formatMoney(row.min_price) }}</template>
</el-table-column>
<el-table-column prop="max_price" label="最高价" width="100" align="right">
<template #default="{ row }">{{ formatMoney(row.max_price) }}</template>
</el-table-column>
<el-table-column prop="avg_price" label="均价" width="100" align="right">
<template #default="{ row }">{{ formatMoney(row.avg_price) }}</template>
</el-table-column>
<el-table-column prop="effective_date" label="生效日期" width="110" align="center" />
<el-table-column label="操作" width="140" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.page_size"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<!-- 表单对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="标杆机构" prop="benchmark_name">
<el-input v-model="form.benchmark_name" placeholder="请输入标杆机构名称" />
</el-form-item>
<el-form-item label="分类" prop="category_id">
<el-select v-model="form.category_id" placeholder="请选择分类" clearable>
<el-option
v-for="item in categories"
:key="item.id"
:label="item.category_name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="价格带" prop="price_tier">
<el-select v-model="form.price_tier">
<el-option
v-for="item in priceTierOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="最低价" prop="min_price">
<el-input-number v-model="form.min_price" :min="0" :precision="2" />
</el-form-item>
<el-form-item label="最高价" prop="max_price">
<el-input-number v-model="form.max_price" :min="0" :precision="2" />
</el-form-item>
<el-form-item label="均价" prop="avg_price">
<el-input-number v-model="form.avg_price" :min="0" :precision="2" />
</el-form-item>
<el-form-item label="生效日期" prop="effective_date">
<el-date-picker
v-model="form.effective_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.search-form {
margin-bottom: 16px;
}
.toolbar {
margin-bottom: 16px;
display: flex;
gap: 12px;
}
.pagination {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,268 @@
<script setup lang="ts">
/**
* 竞品价格管理对话框
*/
import { ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import {
competitorApi,
Competitor,
CompetitorPrice,
CompetitorPriceCreate,
priceSourceOptions,
} from '@/api/competitors'
import { projectApi, Project } from '@/api/projects'
const props = defineProps<{
visible: boolean
competitor: Competitor
}>()
const emit = defineEmits(['update:visible', 'close'])
// 数据
const loading = ref(false)
const prices = ref<CompetitorPrice[]>([])
const projects = ref<Project[]>([])
// 表单
const addDialogVisible = ref(false)
const form = ref<CompetitorPriceCreate>({
project_id: null,
project_name: '',
original_price: 0,
promo_price: null,
member_price: null,
price_source: 'survey',
collected_at: new Date().toISOString().split('T')[0],
remark: '',
})
// 获取项目列表
const fetchProjects = async () => {
try {
const res = await projectApi.getList({ page_size: 100, is_active: true })
projects.value = res.data?.items || []
} catch (error) {
console.error('获取项目失败:', error)
}
}
// 获取价格列表
const fetchPrices = async () => {
loading.value = true
try {
const res = await competitorApi.getPrices(props.competitor.id)
prices.value = res.data || []
} catch (error) {
console.error('获取价格失败:', error)
} finally {
loading.value = false
}
}
// 添加价格
const handleAdd = () => {
form.value = {
project_id: null,
project_name: '',
original_price: 0,
promo_price: null,
member_price: null,
price_source: 'survey',
collected_at: new Date().toISOString().split('T')[0],
remark: '',
}
addDialogVisible.value = true
}
// 提交价格
const handleSubmit = async () => {
if (!form.value.project_name) {
ElMessage.warning('请输入项目名称')
return
}
if (!form.value.original_price) {
ElMessage.warning('请输入原价')
return
}
try {
await competitorApi.addPrice(props.competitor.id, form.value)
ElMessage.success('添加成功')
addDialogVisible.value = false
fetchPrices()
} catch (error) {
console.error('添加失败:', error)
}
}
// 删除价格
const handleDelete = async (row: CompetitorPrice) => {
try {
await ElMessageBox.confirm(`确定要删除该价格记录吗?`, '提示', { type: 'warning' })
await competitorApi.deletePrice(row.id)
ElMessage.success('删除成功')
fetchPrices()
} catch (error: any) {
if (error !== 'cancel') {
console.error('删除失败:', error)
}
}
}
// 关闭
const handleClose = () => {
emit('update:visible', false)
emit('close')
}
// 格式化金额
const formatMoney = (val: number | null) => {
if (val === null) return '-'
return `¥${val.toFixed(2)}`
}
// 获取来源标签
const getSourceLabel = (source: string) => {
return priceSourceOptions.find(item => item.value === source)?.label || source
}
// 选择项目后自动填充名称
const handleProjectSelect = (projectId: number | null) => {
if (projectId) {
const project = projects.value.find(p => p.id === projectId)
if (project) {
form.value.project_name = project.project_name
}
}
}
// 监听显示
watch(
() => props.visible,
(val) => {
if (val) {
fetchProjects()
fetchPrices()
}
},
{ immediate: true }
)
</script>
<template>
<el-dialog
:model-value="visible"
:title="`竞品价格 - ${competitor.competitor_name}`"
width="800px"
@update:model-value="$emit('update:visible', $event)"
@close="handleClose"
>
<div v-loading="loading">
<div class="toolbar">
<el-button type="primary" size="small" @click="handleAdd">
<el-icon><Plus /></el-icon>
添加价格
</el-button>
</div>
<el-table :data="prices" border size="small" max-height="400">
<el-table-column prop="project_name" label="项目名称" min-width="120" />
<el-table-column prop="original_price" label="原价" width="100" align="right">
<template #default="{ row }">{{ formatMoney(row.original_price) }}</template>
</el-table-column>
<el-table-column prop="promo_price" label="促销价" width="100" align="right">
<template #default="{ row }">{{ formatMoney(row.promo_price) }}</template>
</el-table-column>
<el-table-column prop="member_price" label="会员价" width="100" align="right">
<template #default="{ row }">{{ formatMoney(row.member_price) }}</template>
</el-table-column>
<el-table-column prop="price_source" label="来源" width="100" align="center">
<template #default="{ row }">
<el-tag size="small">{{ getSourceLabel(row.price_source) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="collected_at" label="采集日期" width="110" align="center" />
<el-table-column label="操作" width="80" align="center">
<template #default="{ row }">
<el-button type="danger" link size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="prices.length === 0" description="暂无价格记录" />
</div>
<template #footer>
<el-button @click="handleClose">关闭</el-button>
</template>
<!-- 添加价格对话框 -->
<el-dialog v-model="addDialogVisible" title="添加价格" width="450px" append-to-body>
<el-form :model="form" label-width="80px">
<el-form-item label="关联项目">
<el-select
v-model="form.project_id"
placeholder="选择本店项目(可选)"
clearable
filterable
@change="handleProjectSelect"
>
<el-option
v-for="item in projects"
:key="item.id"
:label="item.project_name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="项目名称" required>
<el-input v-model="form.project_name" placeholder="竞品项目名称" />
</el-form-item>
<el-form-item label="原价" required>
<el-input-number v-model="form.original_price" :min="0" :precision="2" />
</el-form-item>
<el-form-item label="促销价">
<el-input-number v-model="form.promo_price" :min="0" :precision="2" />
</el-form-item>
<el-form-item label="会员价">
<el-input-number v-model="form.member_price" :min="0" :precision="2" />
</el-form-item>
<el-form-item label="来源" required>
<el-select v-model="form.price_source">
<el-option
v-for="item in priceSourceOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="采集日期" required>
<el-date-picker
v-model="form.collected_at"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
/>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</el-dialog>
</template>
<style scoped>
.toolbar {
margin-bottom: 12px;
}
</style>

View File

@@ -0,0 +1,369 @@
<script setup lang="ts">
/**
* 竞品机构管理页面
*/
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, PriceTag } from '@element-plus/icons-vue'
import { competitorApi, Competitor, CompetitorCreate, CompetitorUpdate, positioningOptions } from '@/api/competitors'
import PricesDialog from './PricesDialog.vue'
// 查询参数
const queryParams = reactive({
page: 1,
page_size: 20,
keyword: '',
positioning: undefined as string | undefined,
is_key_competitor: undefined as boolean | undefined,
})
// 数据
const loading = ref(false)
const tableData = ref<Competitor[]>([])
const total = ref(0)
const dialogVisible = ref(false)
const dialogTitle = ref('新增竞品')
const formRef = ref()
const form = ref<CompetitorCreate>({
competitor_name: '',
address: '',
distance_km: null,
positioning: 'medium',
contact: '',
is_key_competitor: false,
is_active: true,
})
const editingId = ref<number | null>(null)
// 竞品价格对话框
const pricesDialogVisible = ref(false)
const currentCompetitor = ref<Competitor | null>(null)
// 表单规则
const rules = {
competitor_name: [
{ required: true, message: '请输入机构名称', trigger: 'blur' },
],
positioning: [
{ required: true, message: '请选择定位', trigger: 'change' },
],
}
// 获取数据
const fetchData = async () => {
loading.value = true
try {
const res = await competitorApi.getList(queryParams)
tableData.value = res.data?.items || []
total.value = res.data?.total || 0
} catch (error) {
console.error('获取竞品失败:', error)
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
queryParams.page = 1
fetchData()
}
// 重置
const handleReset = () => {
queryParams.keyword = ''
queryParams.positioning = undefined
queryParams.is_key_competitor = undefined
queryParams.page = 1
fetchData()
}
// 新增
const handleAdd = () => {
editingId.value = null
dialogTitle.value = '新增竞品'
form.value = {
competitor_name: '',
address: '',
distance_km: null,
positioning: 'medium',
contact: '',
is_key_competitor: false,
is_active: true,
}
dialogVisible.value = true
}
// 编辑
const handleEdit = (row: Competitor) => {
editingId.value = row.id
dialogTitle.value = '编辑竞品'
form.value = {
competitor_name: row.competitor_name,
address: row.address || '',
distance_km: row.distance_km,
positioning: row.positioning,
contact: row.contact || '',
is_key_competitor: row.is_key_competitor,
is_active: row.is_active,
}
dialogVisible.value = true
}
// 删除
const handleDelete = async (row: Competitor) => {
try {
await ElMessageBox.confirm(`确定要删除竞品「${row.competitor_name}」吗?`, '提示', {
type: 'warning',
})
await competitorApi.delete(row.id)
ElMessage.success('删除成功')
fetchData()
} catch (error: any) {
if (error !== 'cancel') {
console.error('删除失败:', error)
}
}
}
// 管理价格
const handleManagePrices = (row: Competitor) => {
currentCompetitor.value = row
pricesDialogVisible.value = true
}
// 提交表单
const handleSubmit = async () => {
try {
await formRef.value?.validate()
if (editingId.value) {
await competitorApi.update(editingId.value, form.value as CompetitorUpdate)
ElMessage.success('更新成功')
} else {
await competitorApi.create(form.value)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchData()
} catch (error) {
console.error('提交失败:', error)
}
}
// 分页
const handleCurrentChange = (page: number) => {
queryParams.page = page
fetchData()
}
const handleSizeChange = (size: number) => {
queryParams.page_size = size
queryParams.page = 1
fetchData()
}
// 获取定位标签
const getPositioningLabel = (positioning: string) => {
return positioningOptions.find(item => item.value === positioning)?.label || positioning
}
const getPositioningType = (positioning: string) => {
const map: Record<string, string> = {
high: 'danger',
medium: '',
budget: 'success',
}
return map[positioning] || ''
}
// 关闭价格对话框
const handlePricesClose = () => {
pricesDialogVisible.value = false
currentCompetitor.value = null
fetchData()
}
// 初始化
onMounted(() => {
fetchData()
})
</script>
<template>
<div class="page-container">
<el-card shadow="never">
<!-- 搜索栏 -->
<el-form :inline="true" class="search-form">
<el-form-item label="关键词">
<el-input
v-model="queryParams.keyword"
placeholder="名称/地址"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="定位">
<el-select v-model="queryParams.positioning" placeholder="全部" clearable>
<el-option
v-for="item in positioningOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="重点关注">
<el-select v-model="queryParams.is_key_competitor" placeholder="全部" clearable>
<el-option label="是" :value="true" />
<el-option label="否" :value="false" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增竞品
</el-button>
</div>
<!-- 表格 -->
<el-table v-loading="loading" :data="tableData" border>
<el-table-column prop="competitor_name" label="机构名称" min-width="150">
<template #default="{ row }">
<span>{{ row.competitor_name }}</span>
<el-tag v-if="row.is_key_competitor" type="warning" size="small" class="ml-2">
重点
</el-tag>
</template>
</el-table-column>
<el-table-column prop="positioning" label="定位" width="100" align="center">
<template #default="{ row }">
<el-tag :type="getPositioningType(row.positioning)" size="small">
{{ getPositioningLabel(row.positioning) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="distance_km" label="距离" width="100" align="center">
<template #default="{ row }">
{{ row.distance_km ? `${row.distance_km}km` : '-' }}
</template>
</el-table-column>
<el-table-column prop="address" label="地址" min-width="200" show-overflow-tooltip />
<el-table-column prop="price_count" label="价格记录" width="100" align="center">
<template #default="{ row }">
{{ row.price_count }}
</template>
</el-table-column>
<el-table-column prop="last_price_update" label="最近更新" width="120" align="center">
<template #default="{ row }">
{{ row.last_price_update || '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleManagePrices(row)">
<el-icon><PriceTag /></el-icon>
价格
</el-button>
<el-button type="primary" link size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.page_size"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<!-- 表单对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="名称" prop="competitor_name">
<el-input v-model="form.competitor_name" placeholder="请输入机构名称" />
</el-form-item>
<el-form-item label="定位" prop="positioning">
<el-select v-model="form.positioning">
<el-option
v-for="item in positioningOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="地址" prop="address">
<el-input v-model="form.address" placeholder="请输入地址" />
</el-form-item>
<el-form-item label="距离" prop="distance_km">
<el-input-number v-model="form.distance_km" :min="0" :precision="1" />
<span style="margin-left: 8px;">公里</span>
</el-form-item>
<el-form-item label="联系方式" prop="contact">
<el-input v-model="form.contact" placeholder="请输入联系方式" />
</el-form-item>
<el-form-item label="重点关注" prop="is_key_competitor">
<el-switch v-model="form.is_key_competitor" />
</el-form-item>
<el-form-item label="状态" prop="is_active">
<el-switch v-model="form.is_active" active-text="启用" inactive-text="禁用" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
<!-- 价格管理对话框 -->
<PricesDialog
v-if="currentCompetitor"
v-model:visible="pricesDialogVisible"
:competitor="currentCompetitor"
@close="handlePricesClose"
/>
</div>
</template>
<style scoped>
.search-form {
margin-bottom: 16px;
}
.toolbar {
margin-bottom: 16px;
display: flex;
gap: 12px;
}
.pagination {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.ml-2 {
margin-left: 8px;
}
</style>

View File

@@ -0,0 +1,289 @@
<template>
<el-dialog
:model-value="modelValue"
title="AI 智能定价建议"
width="800px"
@update:model-value="$emit('update:modelValue', $event)"
@close="handleClose"
>
<!-- 步骤一选择项目 -->
<div v-if="step === 1" class="step-content">
<el-form label-width="100px">
<el-form-item label="选择项目" required>
<el-select
v-model="selectedProjectId"
placeholder="请选择项目"
filterable
style="width: 100%"
>
<el-option
v-for="item in projectList"
:key="item.id"
:label="item.project_name"
:value="item.id"
>
<div class="flex justify-between items-center">
<span>{{ item.project_name }}</span>
<span v-if="item.cost_summary" class="text-gray-400 text-sm">
成本: ¥{{ item.cost_summary.total_cost.toFixed(2) }}
</span>
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="目标毛利率">
<el-slider
v-model="targetMargin"
:min="10"
:max="90"
:step="5"
show-input
:format-tooltip="(val: number) => `${val}%`"
/>
</el-form-item>
</el-form>
</div>
<!-- 步骤二AI 分析中 / 结果展示 -->
<div v-else class="step-content">
<!-- 加载中 -->
<div v-if="loading" class="text-center py-10">
<el-icon class="is-loading text-4xl text-primary mb-4"><Loading /></el-icon>
<p class="text-gray-500">AI 正在分析中,请稍候...</p>
<p v-if="streamContent" class="mt-4 text-left p-4 bg-gray-50 rounded max-h-60 overflow-auto whitespace-pre-wrap text-sm">
{{ streamContent }}
</p>
</div>
<!-- 结果展示 -->
<div v-else-if="result">
<!-- 基础信息 -->
<el-descriptions :column="3" border class="mb-4">
<el-descriptions-item label="项目">{{ result.project_name }}</el-descriptions-item>
<el-descriptions-item label="基础成本">
<span class="font-medium">¥{{ result.cost_base.toFixed(2) }}</span>
</el-descriptions-item>
<el-descriptions-item label="市场均价">
<span v-if="result.market_reference">
¥{{ result.market_reference.avg.toFixed(2) }}
</span>
<span v-else class="text-gray-400">暂无</span>
</el-descriptions-item>
</el-descriptions>
<!-- 策略建议对比 -->
<h4 class="font-medium mb-3">定价策略建议</h4>
<div class="grid grid-cols-3 gap-4 mb-4">
<template v-for="(suggestion, key) in result.pricing_suggestions" :key="key">
<el-card
v-if="suggestion"
:class="['strategy-card', { active: selectedStrategy === key }]"
shadow="hover"
@click="selectedStrategy = key as any"
>
<template #header>
<div class="flex justify-between items-center">
<el-tag :type="getStrategyTagType(key)">{{ suggestion.strategy }}</el-tag>
<el-radio v-model="selectedStrategy" :value="key" />
</div>
</template>
<div class="text-center">
<div class="text-2xl font-bold text-primary mb-2">
¥{{ suggestion.suggested_price.toFixed(0) }}
</div>
<div class="text-gray-500 text-sm mb-2">
毛利率 {{ suggestion.margin.toFixed(1) }}%
</div>
<div class="text-gray-400 text-xs">
{{ suggestion.description }}
</div>
</div>
</el-card>
</template>
</div>
<!-- AI 详细建议 -->
<div v-if="result.ai_advice">
<h4 class="font-medium mb-3">AI 分析建议</h4>
<el-collapse>
<el-collapse-item title="综合建议" name="summary">
<p class="text-gray-600">{{ result.ai_advice.summary }}</p>
</el-collapse-item>
<el-collapse-item title="成本分析" name="cost">
<p class="text-gray-600">{{ result.ai_advice.cost_analysis }}</p>
</el-collapse-item>
<el-collapse-item title="市场分析" name="market">
<p class="text-gray-600">{{ result.ai_advice.market_analysis }}</p>
</el-collapse-item>
<el-collapse-item title="风险提示" name="risk">
<p class="text-gray-600">{{ result.ai_advice.risk_notes }}</p>
</el-collapse-item>
<el-collapse-item v-if="result.ai_advice.recommendations?.length" title="具体建议" name="recommendations">
<ul class="list-disc pl-5">
<li v-for="(item, idx) in result.ai_advice.recommendations" :key="idx" class="text-gray-600">
{{ item }}
</li>
</ul>
</el-collapse-item>
</el-collapse>
</div>
<!-- AI 使用统计 -->
<div v-if="result.ai_usage" class="mt-4 text-right text-gray-400 text-xs">
服务商: {{ result.ai_usage.provider }} |
Token: {{ result.ai_usage.tokens }} |
耗时: {{ result.ai_usage.latency_ms }}ms
</div>
</div>
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button v-if="step === 1" type="primary" :disabled="!selectedProjectId" @click="generateAdvice">
生成建议
</el-button>
<el-button v-if="step === 2 && !loading" @click="step = 1">
重新选择
</el-button>
<el-button
v-if="step === 2 && result && selectedStrategy"
type="primary"
@click="createPlan"
>
创建定价方案
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Loading } from '@element-plus/icons-vue'
import { pricingApi, type GeneratePricingResponse, type StrategyType } from '@/api/pricing'
import type { Project } from '@/api/projects'
const props = defineProps<{
modelValue: boolean
projectList: Project[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'createPlan', data: any): void
}>()
// 状态
const step = ref(1)
const loading = ref(false)
const selectedProjectId = ref<number | null>(null)
const targetMargin = ref(50)
const result = ref<GeneratePricingResponse | null>(null)
const selectedStrategy = ref<StrategyType | null>(null)
const streamContent = ref('')
// 生成建议
const generateAdvice = async () => {
if (!selectedProjectId.value) return
step.value = 2
loading.value = true
result.value = null
streamContent.value = ''
try {
const res = await pricingApi.generatePricing(selectedProjectId.value, {
target_margin: targetMargin.value,
stream: false,
})
result.value = res.data
// 默认选中利润款
if (result.value.pricing_suggestions.profit) {
selectedStrategy.value = 'profit'
} else if (result.value.pricing_suggestions.traffic) {
selectedStrategy.value = 'traffic'
} else if (result.value.pricing_suggestions.premium) {
selectedStrategy.value = 'premium'
}
} catch (error) {
console.error('生成建议失败:', error)
step.value = 1
} finally {
loading.value = false
}
}
// 创建定价方案
const createPlan = () => {
if (!result.value || !selectedStrategy.value) return
const suggestion = result.value.pricing_suggestions[selectedStrategy.value]
if (!suggestion) return
emit('createPlan', {
project_id: result.value.project_id,
plan_name: `${result.value.project_name}-${suggestion.strategy}`,
strategy_type: selectedStrategy.value,
target_margin: suggestion.margin,
suggested_price: suggestion.suggested_price,
ai_advice: result.value.ai_advice?.summary,
})
}
// 关闭对话框
const handleClose = () => {
emit('update:modelValue', false)
resetState()
}
// 重置状态
const resetState = () => {
step.value = 1
loading.value = false
selectedProjectId.value = null
targetMargin.value = 50
result.value = null
selectedStrategy.value = null
streamContent.value = ''
}
// 监听对话框关闭
watch(
() => props.modelValue,
(val) => {
if (!val) {
resetState()
}
}
)
// 工具函数
const getStrategyTagType = (key: string) => {
const types: Record<string, string> = {
traffic: 'warning',
profit: '',
premium: 'success',
}
return types[key] || ''
}
</script>
<style scoped>
.step-content {
min-height: 300px;
}
.strategy-card {
cursor: pointer;
transition: all 0.3s;
}
.strategy-card.active {
border-color: var(--el-color-primary);
box-shadow: 0 0 10px rgba(var(--el-color-primary-rgb), 0.2);
}
.text-primary {
color: var(--el-color-primary);
}
</style>

View File

@@ -0,0 +1,211 @@
<template>
<el-dialog
:model-value="modelValue"
:title="isEdit ? '编辑定价方案' : '新建定价方案'"
width="500px"
@update:model-value="$emit('update:modelValue', $event)"
@close="handleClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="100px"
>
<el-form-item label="项目" prop="project_id">
<el-select
v-model="formData.project_id"
placeholder="请选择项目"
filterable
style="width: 100%"
:disabled="isEdit"
>
<el-option
v-for="item in projectList"
:key="item.id"
:label="item.project_name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="方案名称" prop="plan_name">
<el-input v-model="formData.plan_name" placeholder="请输入方案名称" />
</el-form-item>
<el-form-item label="策略类型" prop="strategy_type">
<el-radio-group v-model="formData.strategy_type">
<el-radio-button
v-for="item in strategyTypeOptions"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="目标毛利率" prop="target_margin">
<el-input-number
v-model="formData.target_margin"
:min="0"
:max="100"
:precision="1"
controls-position="right"
style="width: 100%"
/>
<span class="ml-2">%</span>
</el-form-item>
<el-form-item v-if="isEdit" label="最终定价" prop="final_price">
<el-input-number
v-model="formData.final_price"
:min="0"
:precision="2"
controls-position="right"
style="width: 100%"
/>
<span class="ml-2"></span>
</el-form-item>
<el-form-item v-if="isEdit" label="状态" prop="is_active">
<el-switch v-model="formData.is_active" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import {
pricingApi,
strategyTypeOptions,
type PricingPlan,
type PricingPlanCreate,
type PricingPlanUpdate,
type StrategyType,
} from '@/api/pricing'
import type { Project } from '@/api/projects'
const props = defineProps<{
modelValue: boolean
editData: PricingPlan | null
projectList: Project[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}>()
const formRef = ref<FormInstance>()
const loading = ref(false)
const isEdit = computed(() => !!props.editData?.id)
// 表单数据
const formData = ref<{
project_id: number | null
plan_name: string
strategy_type: StrategyType
target_margin: number
final_price: number | null
is_active: boolean
}>({
project_id: null,
plan_name: '',
strategy_type: 'profit',
target_margin: 50,
final_price: null,
is_active: true,
})
// 表单验证规则
const rules: FormRules = {
project_id: [{ required: true, message: '请选择项目', trigger: 'change' }],
plan_name: [
{ required: true, message: '请输入方案名称', trigger: 'blur' },
{ min: 1, max: 100, message: '长度在 1 到 100 个字符', trigger: 'blur' },
],
strategy_type: [{ required: true, message: '请选择策略类型', trigger: 'change' }],
target_margin: [{ required: true, message: '请输入目标毛利率', trigger: 'blur' }],
}
// 监听编辑数据
watch(
() => props.editData,
(val) => {
if (val) {
formData.value = {
project_id: val.project_id,
plan_name: val.plan_name,
strategy_type: val.strategy_type as StrategyType,
target_margin: val.target_margin,
final_price: val.final_price,
is_active: val.is_active,
}
} else {
resetForm()
}
},
{ immediate: true }
)
// 重置表单
const resetForm = () => {
formData.value = {
project_id: null,
plan_name: '',
strategy_type: 'profit',
target_margin: 50,
final_price: null,
is_active: true,
}
formRef.value?.clearValidate()
}
// 关闭对话框
const handleClose = () => {
emit('update:modelValue', false)
resetForm()
}
// 提交
const handleSubmit = async () => {
const valid = await formRef.value?.validate()
if (!valid) return
loading.value = true
try {
if (isEdit.value) {
const updateData: PricingPlanUpdate = {
plan_name: formData.value.plan_name,
strategy_type: formData.value.strategy_type,
target_margin: formData.value.target_margin,
final_price: formData.value.final_price || undefined,
is_active: formData.value.is_active,
}
await pricingApi.update(props.editData!.id, updateData)
ElMessage.success('更新成功')
} else {
const createData: PricingPlanCreate = {
project_id: formData.value.project_id!,
plan_name: formData.value.plan_name,
strategy_type: formData.value.strategy_type,
target_margin: formData.value.target_margin,
}
await pricingApi.create(createData)
ElMessage.success('创建成功')
}
emit('success')
handleClose()
} catch (error) {
console.error('操作失败:', error)
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,376 @@
<template>
<div class="pricing-page">
<!-- 页面标题和操作 -->
<div class="page-header flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold">智能定价</h2>
<div class="flex gap-2">
<el-button type="primary" @click="openAIDialog">
<el-icon class="mr-1"><MagicStick /></el-icon>
AI 生成建议
</el-button>
<el-button @click="openCreateDialog">
<el-icon class="mr-1"><Plus /></el-icon>
新建方案
</el-button>
</div>
</div>
<!-- 筛选条件 -->
<el-card class="mb-4" shadow="never">
<el-form :inline="true" :model="queryParams" class="filter-form">
<el-form-item label="项目">
<el-select
v-model="queryParams.project_id"
placeholder="全部项目"
clearable
filterable
style="width: 200px"
>
<el-option
v-for="item in projectList"
:key="item.id"
:label="item.project_name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="策略类型">
<el-select
v-model="queryParams.strategy_type"
placeholder="全部"
clearable
style="width: 120px"
>
<el-option
v-for="item in strategyTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select
v-model="queryParams.is_active"
placeholder="全部"
clearable
style="width: 100px"
>
<el-option label="启用" :value="true" />
<el-option label="禁用" :value="false" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 数据表格 -->
<el-card shadow="never">
<el-table
v-loading="loading"
:data="tableData"
border
stripe
style="width: 100%"
>
<el-table-column prop="plan_name" label="方案名称" min-width="150" />
<el-table-column prop="project_name" label="项目" min-width="120" />
<el-table-column prop="strategy_type" label="策略" width="100">
<template #default="{ row }">
<el-tag :type="getStrategyTagType(row.strategy_type)">
{{ getStrategyLabel(row.strategy_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="base_cost" label="基础成本" width="110" align="right">
<template #default="{ row }">
¥{{ row.base_cost.toFixed(2) }}
</template>
</el-table-column>
<el-table-column prop="target_margin" label="目标毛利率" width="100" align="right">
<template #default="{ row }">
{{ row.target_margin }}%
</template>
</el-table-column>
<el-table-column prop="suggested_price" label="建议价格" width="110" align="right">
<template #default="{ row }">
<span class="text-primary font-medium">¥{{ row.suggested_price.toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column prop="final_price" label="最终定价" width="110" align="right">
<template #default="{ row }">
<span v-if="row.final_price" class="text-success font-medium">
¥{{ row.final_price.toFixed(2) }}
</span>
<span v-else class="text-gray-400">未设置</span>
</template>
</el-table-column>
<el-table-column prop="is_active" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
{{ row.is_active ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="160">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleView(row)">
查看
</el-button>
<el-button type="primary" link size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="primary" link size="small" @click="handleSimulate(row)">
模拟
</el-button>
<el-popconfirm
title="确定删除该方案?"
@confirm="handleDelete(row)"
>
<template #reference>
<el-button type="danger" link size="small">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="flex justify-end mt-4">
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.page_size"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSearch"
@current-change="handleSearch"
/>
</div>
</el-card>
<!-- 新建/编辑对话框 -->
<PricingDialog
v-model="dialogVisible"
:edit-data="editData"
:project-list="projectList"
@success="handleSearch"
/>
<!-- AI 建议对话框 -->
<AIAdviceDialog
v-model="aiDialogVisible"
:project-list="projectList"
@create-plan="handleCreateFromAI"
/>
<!-- 详情对话框 -->
<el-dialog v-model="detailVisible" title="定价方案详情" width="600px">
<div v-if="detailData">
<el-descriptions :column="2" border>
<el-descriptions-item label="方案名称">{{ detailData.plan_name }}</el-descriptions-item>
<el-descriptions-item label="项目">{{ detailData.project_name }}</el-descriptions-item>
<el-descriptions-item label="策略类型">
<el-tag :type="getStrategyTagType(detailData.strategy_type)">
{{ getStrategyLabel(detailData.strategy_type) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="detailData.is_active ? 'success' : 'info'">
{{ detailData.is_active ? '启用' : '禁用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="基础成本">¥{{ detailData.base_cost.toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="目标毛利率">{{ detailData.target_margin }}%</el-descriptions-item>
<el-descriptions-item label="建议价格">
<span class="text-primary font-medium">¥{{ detailData.suggested_price.toFixed(2) }}</span>
</el-descriptions-item>
<el-descriptions-item label="最终定价">
<span v-if="detailData.final_price" class="text-success font-medium">
¥{{ detailData.final_price.toFixed(2) }}
</span>
<span v-else class="text-gray-400">未设置</span>
</el-descriptions-item>
</el-descriptions>
<div v-if="detailData.ai_advice" class="mt-4">
<h4 class="font-medium mb-2">AI 建议</h4>
<el-card shadow="never" class="bg-gray-50">
<div class="whitespace-pre-wrap text-sm">{{ detailData.ai_advice }}</div>
</el-card>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus, MagicStick } from '@element-plus/icons-vue'
import { pricingApi, strategyTypeOptions, type PricingPlan, type PricingPlanQuery } from '@/api/pricing'
import { projectApi, type Project } from '@/api/projects'
import PricingDialog from './PricingDialog.vue'
import AIAdviceDialog from './AIAdviceDialog.vue'
import { useRouter } from 'vue-router'
const router = useRouter()
// 状态
const loading = ref(false)
const tableData = ref<PricingPlan[]>([])
const total = ref(0)
const projectList = ref<Project[]>([])
const queryParams = ref<PricingPlanQuery>({
page: 1,
page_size: 20,
})
// 对话框
const dialogVisible = ref(false)
const editData = ref<PricingPlan | null>(null)
const aiDialogVisible = ref(false)
const detailVisible = ref(false)
const detailData = ref<PricingPlan | null>(null)
// 加载数据
const loadData = async () => {
loading.value = true
try {
const res = await pricingApi.getList(queryParams.value)
tableData.value = res.data.items
total.value = res.data.total
} catch (error) {
console.error('加载数据失败:', error)
} finally {
loading.value = false
}
}
// 加载项目列表
const loadProjects = async () => {
try {
const res = await projectApi.getList({ page_size: 100 })
projectList.value = res.data.items
} catch (error) {
console.error('加载项目列表失败:', error)
}
}
// 搜索
const handleSearch = () => {
queryParams.value.page = 1
loadData()
}
// 重置
const handleReset = () => {
queryParams.value = {
page: 1,
page_size: 20,
}
loadData()
}
// 打开新建对话框
const openCreateDialog = () => {
editData.value = null
dialogVisible.value = true
}
// 打开 AI 对话框
const openAIDialog = () => {
aiDialogVisible.value = true
}
// 从 AI 建议创建方案
const handleCreateFromAI = (data: any) => {
editData.value = data
dialogVisible.value = true
aiDialogVisible.value = false
}
// 查看详情
const handleView = async (row: PricingPlan) => {
try {
const res = await pricingApi.getById(row.id)
detailData.value = res.data
detailVisible.value = true
} catch (error) {
console.error('获取详情失败:', error)
}
}
// 编辑
const handleEdit = (row: PricingPlan) => {
editData.value = row
dialogVisible.value = true
}
// 模拟
const handleSimulate = (row: PricingPlan) => {
router.push({
path: '/profit/simulations',
query: { plan_id: row.id },
})
}
// 删除
const handleDelete = async (row: PricingPlan) => {
try {
await pricingApi.delete(row.id)
ElMessage.success('删除成功')
loadData()
} catch (error) {
console.error('删除失败:', error)
}
}
// 工具函数
const getStrategyLabel = (type: string) => {
const option = strategyTypeOptions.find((o) => o.value === type)
return option?.label || type
}
const getStrategyTagType = (type: string) => {
const types: Record<string, string> = {
traffic: 'warning',
profit: '',
premium: 'success',
}
return types[type] || ''
}
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString('zh-CN')
}
// 初始化
onMounted(() => {
loadData()
loadProjects()
})
</script>
<style scoped>
.pricing-page {
padding: 20px;
}
.text-primary {
color: var(--el-color-primary);
}
.text-success {
color: var(--el-color-success);
}
</style>

View File

@@ -0,0 +1,152 @@
<template>
<el-dialog
:model-value="modelValue"
title="模拟详情"
width="700px"
@update:model-value="$emit('update:modelValue', $event)"
>
<div v-if="loading" class="text-center py-10">
<el-icon class="is-loading text-2xl"><Loading /></el-icon>
</div>
<div v-else-if="detail">
<!-- 基本信息 -->
<el-descriptions :column="2" border class="mb-4">
<el-descriptions-item label="模拟名称">{{ detail.simulation_name }}</el-descriptions-item>
<el-descriptions-item label="项目">{{ detail.project_name }}</el-descriptions-item>
<el-descriptions-item label="定价方案">{{ detail.plan_name }}</el-descriptions-item>
<el-descriptions-item label="周期">{{ getPeriodLabel(detail.period_type) }}</el-descriptions-item>
</el-descriptions>
<!-- 输入参数 -->
<h4 class="font-medium mb-2">输入参数</h4>
<el-descriptions :column="2" border class="mb-4">
<el-descriptions-item label="模拟价格">
¥{{ detail.price.toFixed(2) }}
</el-descriptions-item>
<el-descriptions-item label="预估客量">
{{ detail.estimated_volume }}
</el-descriptions-item>
</el-descriptions>
<!-- 模拟结果 -->
<h4 class="font-medium mb-2">模拟结果</h4>
<el-row :gutter="20" class="mb-4">
<el-col :span="8">
<el-card shadow="never" class="text-center">
<div class="text-gray-500 text-sm mb-1">预估收入</div>
<div class="text-xl font-bold">¥{{ detail.estimated_revenue.toFixed(2) }}</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never" class="text-center">
<div class="text-gray-500 text-sm mb-1">预估成本</div>
<div class="text-xl font-bold">¥{{ detail.estimated_cost.toFixed(2) }}</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never" class="text-center">
<div class="text-gray-500 text-sm mb-1">预估利润</div>
<div class="text-xl font-bold" :class="detail.estimated_profit >= 0 ? 'text-success' : 'text-danger'">
¥{{ detail.estimated_profit.toFixed(2) }}
</div>
</el-card>
</el-col>
</el-row>
<el-descriptions :column="2" border>
<el-descriptions-item label="利润率">
<span :class="detail.profit_margin >= 30 ? 'text-success' : 'text-warning'" class="font-medium">
{{ detail.profit_margin.toFixed(1) }}%
</span>
</el-descriptions-item>
<el-descriptions-item label="盈亏平衡客量">
{{ detail.breakeven_volume }}
</el-descriptions-item>
<el-descriptions-item label="安全边际">
{{ detail.estimated_volume - detail.breakeven_volume }} ({{ safetyMarginPercentage }}%)
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ formatDate(detail.created_at) }}
</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button @click="$emit('update:modelValue', false)">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { Loading } from '@element-plus/icons-vue'
import { profitApi, periodTypeOptions, type ProfitSimulation } from '@/api/profit'
const props = defineProps<{
modelValue: boolean
simulationId: number | null
}>()
defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const loading = ref(false)
const detail = ref<ProfitSimulation | null>(null)
const safetyMarginPercentage = computed(() => {
if (!detail.value) return 0
const margin = detail.value.estimated_volume - detail.value.breakeven_volume
return ((margin / detail.value.estimated_volume) * 100).toFixed(1)
})
// 加载详情
const loadDetail = async () => {
if (!props.simulationId) return
loading.value = true
try {
const res = await profitApi.getById(props.simulationId)
detail.value = res.data
} catch (error) {
console.error('加载详情失败:', error)
} finally {
loading.value = false
}
}
// 监听
watch(
() => [props.modelValue, props.simulationId],
([visible, id]) => {
if (visible && id) {
loadDetail()
}
}
)
// 工具函数
const getPeriodLabel = (type: string) => {
const option = periodTypeOptions.find((o) => o.value === type)
return option?.label || type
}
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString('zh-CN')
}
</script>
<style scoped>
.text-success {
color: var(--el-color-success);
}
.text-warning {
color: var(--el-color-warning);
}
.text-danger {
color: var(--el-color-danger);
}
</style>

View File

@@ -0,0 +1,279 @@
<template>
<el-dialog
:model-value="modelValue"
title="敏感性分析"
width="900px"
@update:model-value="$emit('update:modelValue', $event)"
>
<div v-if="loading" class="text-center py-10">
<el-icon class="is-loading text-2xl"><Loading /></el-icon>
<p class="text-gray-500 mt-2">正在分析中...</p>
</div>
<div v-else-if="analysisResult">
<!-- 基准数据 -->
<el-descriptions :column="3" border class="mb-4">
<el-descriptions-item label="基准价格">
¥{{ analysisResult.base_price.toFixed(2) }}
</el-descriptions-item>
<el-descriptions-item label="基准利润">
¥{{ analysisResult.base_profit.toFixed(2) }}
</el-descriptions-item>
<el-descriptions-item label="数据点数">
{{ analysisResult.sensitivity_results.length }}
</el-descriptions-item>
</el-descriptions>
<!-- 图表 -->
<div ref="chartRef" style="width: 100%; height: 350px;"></div>
<!-- 数据表格 -->
<h4 class="font-medium mb-2 mt-4">详细数据</h4>
<el-table :data="analysisResult.sensitivity_results" border size="small">
<el-table-column prop="price_change_rate" label="价格变动" width="100" align="center">
<template #default="{ row }">
<span :class="row.price_change_rate >= 0 ? 'text-success' : 'text-danger'">
{{ row.price_change_rate >= 0 ? '+' : '' }}{{ row.price_change_rate }}%
</span>
</template>
</el-table-column>
<el-table-column prop="adjusted_price" label="调整后价格" width="120" align="right">
<template #default="{ row }">
¥{{ row.adjusted_price.toFixed(2) }}
</template>
</el-table-column>
<el-table-column prop="adjusted_profit" label="调整后利润" width="130" align="right">
<template #default="{ row }">
<span :class="row.adjusted_profit >= 0 ? 'text-success' : 'text-danger'">
¥{{ row.adjusted_profit.toFixed(2) }}
</span>
</template>
</el-table-column>
<el-table-column prop="profit_change_rate" label="利润变动" width="100" align="center">
<template #default="{ row }">
<span :class="row.profit_change_rate >= 0 ? 'text-success' : 'text-danger'">
{{ row.profit_change_rate >= 0 ? '+' : '' }}{{ row.profit_change_rate.toFixed(1) }}%
</span>
</template>
</el-table-column>
</el-table>
<!-- 洞察 -->
<el-card v-if="analysisResult.insights" class="mt-4" shadow="never">
<template #header>
<span class="font-medium">分析洞察</span>
</template>
<el-descriptions :column="1" border>
<el-descriptions-item label="价格弹性">
{{ analysisResult.insights.price_elasticity }}
</el-descriptions-item>
<el-descriptions-item label="风险等级">
<el-tag :type="getRiskTagType(analysisResult.insights.risk_level)">
{{ analysisResult.insights.risk_level }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="建议">
{{ analysisResult.insights.recommendation }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
<!-- 未分析状态 -->
<div v-else class="text-center py-10">
<p class="text-gray-500 mb-4">尚未执行敏感性分析</p>
<el-button type="primary" @click="runAnalysis">开始分析</el-button>
</div>
<template #footer>
<el-button v-if="analysisResult" @click="runAnalysis">重新分析</el-button>
<el-button @click="$emit('update:modelValue', false)">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { Loading } from '@element-plus/icons-vue'
import { profitApi, type SensitivityAnalysisResponse } from '@/api/profit'
import * as echarts from 'echarts'
const props = defineProps<{
modelValue: boolean
simulationId: number | null
}>()
defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const loading = ref(false)
const analysisResult = ref<SensitivityAnalysisResponse | null>(null)
const chartRef = ref<HTMLElement | null>(null)
let chartInstance: echarts.ECharts | null = null
// 执行分析
const runAnalysis = async () => {
if (!props.simulationId) return
loading.value = true
analysisResult.value = null
try {
const res = await profitApi.sensitivityAnalysis(props.simulationId, {
price_change_rates: [-20, -15, -10, -5, 0, 5, 10, 15, 20],
})
analysisResult.value = res.data
// 渲染图表
await nextTick()
renderChart()
} catch (error) {
console.error('分析失败:', error)
// 尝试获取已有结果
try {
const res = await profitApi.getSensitivityAnalysis(props.simulationId)
analysisResult.value = res.data
await nextTick()
renderChart()
} catch {
// 无已有结果
}
} finally {
loading.value = false
}
}
// 加载已有分析
const loadExistingAnalysis = async () => {
if (!props.simulationId) return
loading.value = true
try {
const res = await profitApi.getSensitivityAnalysis(props.simulationId)
analysisResult.value = res.data
await nextTick()
renderChart()
} catch {
// 无已有结果,不显示错误
analysisResult.value = null
} finally {
loading.value = false
}
}
// 渲染图表
const renderChart = () => {
if (!chartRef.value || !analysisResult.value) return
if (chartInstance) {
chartInstance.dispose()
}
chartInstance = echarts.init(chartRef.value)
const data = analysisResult.value.sensitivity_results
const xData = data.map((d) => `${d.price_change_rate >= 0 ? '+' : ''}${d.price_change_rate}%`)
const priceData = data.map((d) => d.adjusted_price)
const profitData = data.map((d) => d.adjusted_profit)
const option: echarts.EChartsOption = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
},
},
legend: {
data: ['调整后价格', '调整后利润'],
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
data: xData,
name: '价格变动',
},
yAxis: [
{
type: 'value',
name: '价格 (元)',
position: 'left',
},
{
type: 'value',
name: '利润 (元)',
position: 'right',
},
],
series: [
{
name: '调整后价格',
type: 'bar',
data: priceData,
itemStyle: {
color: '#409EFF',
},
},
{
name: '调整后利润',
type: 'line',
yAxisIndex: 1,
data: profitData,
smooth: true,
itemStyle: {
color: '#67C23A',
},
markLine: {
data: [
{
yAxis: 0,
lineStyle: { color: '#F56C6C' },
label: { formatter: '盈亏平衡' },
},
],
},
},
],
}
chartInstance.setOption(option)
}
// 监听
watch(
() => [props.modelValue, props.simulationId],
([visible, id]) => {
if (visible && id) {
loadExistingAnalysis()
} else if (!visible && chartInstance) {
chartInstance.dispose()
chartInstance = null
}
}
)
// 工具函数
const getRiskTagType = (level: string) => {
const types: Record<string, string> = {
'低': 'success',
'中等': 'warning',
'高': 'danger',
}
return types[level] || ''
}
</script>
<style scoped>
.text-success {
color: var(--el-color-success);
}
.text-danger {
color: var(--el-color-danger);
}
</style>

View File

@@ -0,0 +1,264 @@
<template>
<el-dialog
:model-value="modelValue"
title="新建利润模拟"
width="600px"
@update:model-value="$emit('update:modelValue', $event)"
@close="handleClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="100px"
>
<el-form-item label="定价方案" prop="pricing_plan_id">
<el-select
v-model="formData.pricing_plan_id"
placeholder="请选择定价方案"
filterable
style="width: 100%"
@change="handlePlanChange"
>
<el-option
v-for="item in pricingPlanList"
:key="item.id"
:label="`${item.plan_name} (${item.project_name})`"
:value="item.id"
/>
</el-select>
</el-form-item>
<!-- 方案信息 -->
<el-card v-if="selectedPlan" class="mb-4 bg-gray-50" shadow="never">
<el-descriptions :column="2" size="small">
<el-descriptions-item label="基础成本">
¥{{ selectedPlan.base_cost.toFixed(2) }}
</el-descriptions-item>
<el-descriptions-item label="建议价格">
¥{{ selectedPlan.suggested_price.toFixed(2) }}
</el-descriptions-item>
<el-descriptions-item label="目标毛利率">
{{ selectedPlan.target_margin }}%
</el-descriptions-item>
<el-descriptions-item label="最终定价">
{{ selectedPlan.final_price ? `¥${selectedPlan.final_price.toFixed(2)}` : '未设置' }}
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-form-item label="模拟价格" prop="price">
<el-input-number
v-model="formData.price"
:min="0"
:precision="2"
controls-position="right"
style="width: 100%"
/>
<span class="ml-2"></span>
</el-form-item>
<el-form-item label="预估客量" prop="estimated_volume">
<el-input-number
v-model="formData.estimated_volume"
:min="1"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="周期" prop="period_type">
<el-radio-group v-model="formData.period_type">
<el-radio-button
v-for="item in periodTypeOptions"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</el-radio-button>
</el-radio-group>
</el-form-item>
</el-form>
<!-- 预估结果 -->
<el-card v-if="previewResult" class="mt-4" shadow="never">
<template #header>
<span class="font-medium">预估结果</span>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="预估收入">
<span class="font-medium">¥{{ previewResult.revenue.toFixed(2) }}</span>
</el-descriptions-item>
<el-descriptions-item label="预估成本">
¥{{ previewResult.cost.toFixed(2) }}
</el-descriptions-item>
<el-descriptions-item label="预估利润">
<span :class="previewResult.profit >= 0 ? 'text-success' : 'text-danger'" class="font-medium">
¥{{ previewResult.profit.toFixed(2) }}
</span>
</el-descriptions-item>
<el-descriptions-item label="利润率">
<span :class="previewResult.margin >= 30 ? 'text-success' : 'text-warning'" class="font-medium">
{{ previewResult.margin.toFixed(1) }}%
</span>
</el-descriptions-item>
<el-descriptions-item label="单位利润">
¥{{ previewResult.profitPerUnit.toFixed(2) }}
</el-descriptions-item>
<el-descriptions-item label="盈亏平衡客量">
{{ previewResult.breakevenVolume }}
</el-descriptions-item>
</el-descriptions>
</el-card>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
保存模拟
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { profitApi, periodTypeOptions, type PeriodType } from '@/api/profit'
import type { PricingPlan } from '@/api/pricing'
const props = defineProps<{
modelValue: boolean
pricingPlanList: PricingPlan[]
initialPlanId: number | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}>()
const formRef = ref<FormInstance>()
const loading = ref(false)
// 表单数据
const formData = ref({
pricing_plan_id: null as number | null,
price: 0,
estimated_volume: 100,
period_type: 'monthly' as PeriodType,
})
// 选中的定价方案
const selectedPlan = computed(() => {
if (!formData.value.pricing_plan_id) return null
return props.pricingPlanList.find((p) => p.id === formData.value.pricing_plan_id)
})
// 预估结果
const previewResult = computed(() => {
if (!selectedPlan.value || !formData.value.price || !formData.value.estimated_volume) {
return null
}
const price = formData.value.price
const volume = formData.value.estimated_volume
const cost = selectedPlan.value.base_cost
const revenue = price * volume
const totalCost = cost * volume
const profit = revenue - totalCost
const margin = revenue > 0 ? (profit / revenue) * 100 : 0
const profitPerUnit = price - cost
const breakevenVolume = profitPerUnit > 0 ? Math.ceil(1) : 999999
return {
revenue,
cost: totalCost,
profit,
margin,
profitPerUnit,
breakevenVolume,
}
})
// 表单验证规则
const rules: FormRules = {
pricing_plan_id: [{ required: true, message: '请选择定价方案', trigger: 'change' }],
price: [{ required: true, message: '请输入模拟价格', trigger: 'blur' }],
estimated_volume: [{ required: true, message: '请输入预估客量', trigger: 'blur' }],
period_type: [{ required: true, message: '请选择周期', trigger: 'change' }],
}
// 监听初始方案 ID
watch(
() => props.initialPlanId,
(val) => {
if (val && props.modelValue) {
formData.value.pricing_plan_id = val
handlePlanChange(val)
}
},
{ immediate: true }
)
// 方案变更
const handlePlanChange = (planId: number) => {
const plan = props.pricingPlanList.find((p) => p.id === planId)
if (plan) {
formData.value.price = plan.final_price || plan.suggested_price
}
}
// 重置表单
const resetForm = () => {
formData.value = {
pricing_plan_id: null,
price: 0,
estimated_volume: 100,
period_type: 'monthly',
}
formRef.value?.clearValidate()
}
// 关闭对话框
const handleClose = () => {
emit('update:modelValue', false)
resetForm()
}
// 提交
const handleSubmit = async () => {
const valid = await formRef.value?.validate()
if (!valid) return
loading.value = true
try {
await profitApi.simulate(formData.value.pricing_plan_id!, {
price: formData.value.price,
estimated_volume: formData.value.estimated_volume,
period_type: formData.value.period_type,
})
ElMessage.success('模拟创建成功')
emit('success')
handleClose()
} catch (error) {
console.error('操作失败:', error)
} finally {
loading.value = false
}
}
</script>
<style scoped>
.text-success {
color: var(--el-color-success);
}
.text-warning {
color: var(--el-color-warning);
}
.text-danger {
color: var(--el-color-danger);
}
</style>

View File

@@ -0,0 +1,294 @@
<template>
<div class="profit-page">
<!-- 页面标题和操作 -->
<div class="page-header flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold">利润模拟</h2>
<el-button type="primary" @click="openSimulateDialog">
<el-icon class="mr-1"><DataAnalysis /></el-icon>
新建模拟
</el-button>
</div>
<!-- 筛选条件 -->
<el-card class="mb-4" shadow="never">
<el-form :inline="true" :model="queryParams" class="filter-form">
<el-form-item label="定价方案">
<el-select
v-model="queryParams.pricing_plan_id"
placeholder="全部方案"
clearable
filterable
style="width: 200px"
>
<el-option
v-for="item in pricingPlanList"
:key="item.id"
:label="item.plan_name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="周期">
<el-select
v-model="queryParams.period_type"
placeholder="全部"
clearable
style="width: 100px"
>
<el-option
v-for="item in periodTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 数据表格 -->
<el-card shadow="never">
<el-table
v-loading="loading"
:data="tableData"
border
stripe
style="width: 100%"
>
<el-table-column prop="simulation_name" label="模拟名称" min-width="150" />
<el-table-column prop="project_name" label="项目" min-width="120" />
<el-table-column prop="plan_name" label="定价方案" min-width="120" />
<el-table-column prop="price" label="模拟价格" width="110" align="right">
<template #default="{ row }">
¥{{ row.price.toFixed(2) }}
</template>
</el-table-column>
<el-table-column prop="estimated_volume" label="预估客量" width="100" align="right">
<template #default="{ row }">
{{ row.estimated_volume }}
</template>
</el-table-column>
<el-table-column prop="period_type" label="周期" width="80" align="center">
<template #default="{ row }">
{{ getPeriodLabel(row.period_type) }}
</template>
</el-table-column>
<el-table-column prop="estimated_profit" label="预估利润" width="120" align="right">
<template #default="{ row }">
<span :class="row.estimated_profit >= 0 ? 'text-success' : 'text-danger'">
¥{{ row.estimated_profit.toFixed(2) }}
</span>
</template>
</el-table-column>
<el-table-column prop="profit_margin" label="利润率" width="90" align="right">
<template #default="{ row }">
<span :class="row.profit_margin >= 30 ? 'text-success' : 'text-warning'">
{{ row.profit_margin.toFixed(1) }}%
</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="160">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleView(row)">
详情
</el-button>
<el-button type="primary" link size="small" @click="handleSensitivity(row)">
敏感性分析
</el-button>
<el-popconfirm
title="确定删除该模拟记录?"
@confirm="handleDelete(row)"
>
<template #reference>
<el-button type="danger" link size="small">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="flex justify-end mt-4">
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.page_size"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSearch"
@current-change="handleSearch"
/>
</div>
</el-card>
<!-- 新建模拟对话框 -->
<SimulateDialog
v-model="simulateDialogVisible"
:pricing-plan-list="pricingPlanList"
:initial-plan-id="initialPlanId"
@success="handleSearch"
/>
<!-- 详情对话框 -->
<DetailDialog
v-model="detailDialogVisible"
:simulation-id="selectedSimulationId"
/>
<!-- 敏感性分析对话框 -->
<SensitivityDialog
v-model="sensitivityDialogVisible"
:simulation-id="selectedSimulationId"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { DataAnalysis } from '@element-plus/icons-vue'
import { profitApi, periodTypeOptions, type ProfitSimulation, type ProfitSimulationQuery } from '@/api/profit'
import { pricingApi, type PricingPlan } from '@/api/pricing'
import SimulateDialog from './SimulateDialog.vue'
import DetailDialog from './DetailDialog.vue'
import SensitivityDialog from './SensitivityDialog.vue'
const route = useRoute()
// 状态
const loading = ref(false)
const tableData = ref<ProfitSimulation[]>([])
const total = ref(0)
const pricingPlanList = ref<PricingPlan[]>([])
const queryParams = ref<ProfitSimulationQuery>({
page: 1,
page_size: 20,
})
// 对话框状态
const simulateDialogVisible = ref(false)
const detailDialogVisible = ref(false)
const sensitivityDialogVisible = ref(false)
const selectedSimulationId = ref<number | null>(null)
const initialPlanId = ref<number | null>(null)
// 加载数据
const loadData = async () => {
loading.value = true
try {
const res = await profitApi.getList(queryParams.value)
tableData.value = res.data.items
total.value = res.data.total
} catch (error) {
console.error('加载数据失败:', error)
} finally {
loading.value = false
}
}
// 加载定价方案列表
const loadPricingPlans = async () => {
try {
const res = await pricingApi.getList({ page_size: 100, is_active: true })
pricingPlanList.value = res.data.items
} catch (error) {
console.error('加载定价方案列表失败:', error)
}
}
// 搜索
const handleSearch = () => {
queryParams.value.page = 1
loadData()
}
// 重置
const handleReset = () => {
queryParams.value = {
page: 1,
page_size: 20,
}
loadData()
}
// 打开模拟对话框
const openSimulateDialog = () => {
initialPlanId.value = null
simulateDialogVisible.value = true
}
// 查看详情
const handleView = (row: ProfitSimulation) => {
selectedSimulationId.value = row.id
detailDialogVisible.value = true
}
// 敏感性分析
const handleSensitivity = (row: ProfitSimulation) => {
selectedSimulationId.value = row.id
sensitivityDialogVisible.value = true
}
// 删除
const handleDelete = async (row: ProfitSimulation) => {
try {
await profitApi.delete(row.id)
ElMessage.success('删除成功')
loadData()
} catch (error) {
console.error('删除失败:', error)
}
}
// 工具函数
const getPeriodLabel = (type: string) => {
const option = periodTypeOptions.find((o) => o.value === type)
return option?.label || type
}
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString('zh-CN')
}
// 初始化
onMounted(() => {
// 检查是否从定价页面跳转过来
const planId = route.query.plan_id
if (planId) {
initialPlanId.value = Number(planId)
simulateDialogVisible.value = true
}
loadData()
loadPricingPlans()
})
</script>
<style scoped>
.profit-page {
padding: 20px;
}
.text-success {
color: var(--el-color-success);
}
.text-warning {
color: var(--el-color-warning);
}
.text-danger {
color: var(--el-color-danger);
}
</style>

View File

@@ -0,0 +1,188 @@
<script setup lang="ts">
/**
* 项目分类管理页面
*/
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { categoryApi, Category, CategoryCreate, CategoryUpdate } from '@/api'
// 数据
const loading = ref(false)
const tableData = ref<Category[]>([])
const dialogVisible = ref(false)
const dialogTitle = ref('新增分类')
const formRef = ref()
const form = ref<CategoryCreate>({
category_name: '',
parent_id: null,
sort_order: 0,
is_active: true,
})
const editingId = ref<number | null>(null)
// 表单规则
const rules = {
category_name: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
{ max: 50, message: '名称不能超过50个字符', trigger: 'blur' },
],
}
// 获取数据
const fetchData = async () => {
loading.value = true
try {
const res = await categoryApi.getTree()
tableData.value = res.data || []
} catch (error) {
console.error('获取分类失败:', error)
} finally {
loading.value = false
}
}
// 新增
const handleAdd = (parent?: Category) => {
editingId.value = null
dialogTitle.value = parent ? `新增子分类 - ${parent.category_name}` : '新增分类'
form.value = {
category_name: '',
parent_id: parent?.id || null,
sort_order: 0,
is_active: true,
}
dialogVisible.value = true
}
// 编辑
const handleEdit = (row: Category) => {
editingId.value = row.id
dialogTitle.value = '编辑分类'
form.value = {
category_name: row.category_name,
parent_id: row.parent_id,
sort_order: row.sort_order,
is_active: row.is_active,
}
dialogVisible.value = true
}
// 删除
const handleDelete = async (row: Category) => {
try {
await ElMessageBox.confirm(`确定要删除分类「${row.category_name}」吗?`, '提示', {
type: 'warning',
})
await categoryApi.delete(row.id)
ElMessage.success('删除成功')
fetchData()
} catch (error: any) {
if (error !== 'cancel') {
console.error('删除失败:', error)
}
}
}
// 提交表单
const handleSubmit = async () => {
try {
await formRef.value?.validate()
if (editingId.value) {
await categoryApi.update(editingId.value, form.value as CategoryUpdate)
ElMessage.success('更新成功')
} else {
await categoryApi.create(form.value)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchData()
} catch (error) {
console.error('提交失败:', error)
}
}
// 初始化
onMounted(() => {
fetchData()
})
</script>
<template>
<div class="page-container">
<el-card shadow="never">
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" @click="handleAdd()">
<el-icon><Plus /></el-icon>
新增分类
</el-button>
<el-button @click="fetchData">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
<!-- 表格 -->
<el-table
v-loading="loading"
:data="tableData"
row-key="id"
border
default-expand-all
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
>
<el-table-column prop="category_name" label="分类名称" min-width="200" />
<el-table-column prop="sort_order" label="排序" width="80" align="center" />
<el-table-column prop="is_active" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
{{ row.is_active ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleAdd(row)">
添加子分类
</el-button>
<el-button type="primary" link size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 表单对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="分类名称" prop="category_name">
<el-input v-model="form.category_name" placeholder="请输入分类名称" />
</el-form-item>
<el-form-item label="排序" prop="sort_order">
<el-input-number v-model="form.sort_order" :min="0" />
</el-form-item>
<el-form-item label="状态" prop="is_active">
<el-switch v-model="form.is_active" active-text="启用" inactive-text="禁用" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.toolbar {
margin-bottom: 16px;
display: flex;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,311 @@
<script setup lang="ts">
/**
* 设备管理页面
*/
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { equipmentApi, Equipment, EquipmentCreate, EquipmentUpdate } from '@/api'
// 查询参数
const queryParams = reactive({
page: 1,
page_size: 20,
keyword: '',
is_active: undefined as boolean | undefined,
})
// 数据
const loading = ref(false)
const tableData = ref<Equipment[]>([])
const total = ref(0)
const dialogVisible = ref(false)
const dialogTitle = ref('新增设备')
const formRef = ref()
const form = ref<EquipmentCreate>({
equipment_code: '',
equipment_name: '',
original_value: 0,
residual_rate: 5,
service_years: 5,
estimated_uses: 1000,
purchase_date: null,
is_active: true,
})
const editingId = ref<number | null>(null)
// 表单规则
const rules = {
equipment_code: [
{ required: true, message: '请输入设备编码', trigger: 'blur' },
],
equipment_name: [
{ required: true, message: '请输入设备名称', trigger: 'blur' },
],
original_value: [
{ required: true, message: '请输入设备原值', trigger: 'blur' },
],
service_years: [
{ required: true, message: '请输入使用年限', trigger: 'blur' },
],
estimated_uses: [
{ required: true, message: '请输入预计使用次数', trigger: 'blur' },
],
}
// 获取数据
const fetchData = async () => {
loading.value = true
try {
const res = await equipmentApi.getList(queryParams)
tableData.value = res.data?.items || []
total.value = res.data?.total || 0
} catch (error) {
console.error('获取设备失败:', error)
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
queryParams.page = 1
fetchData()
}
// 重置
const handleReset = () => {
queryParams.keyword = ''
queryParams.is_active = undefined
queryParams.page = 1
fetchData()
}
// 新增
const handleAdd = () => {
editingId.value = null
dialogTitle.value = '新增设备'
form.value = {
equipment_code: '',
equipment_name: '',
original_value: 0,
residual_rate: 5,
service_years: 5,
estimated_uses: 1000,
purchase_date: null,
is_active: true,
}
dialogVisible.value = true
}
// 编辑
const handleEdit = (row: Equipment) => {
editingId.value = row.id
dialogTitle.value = '编辑设备'
form.value = { ...row }
dialogVisible.value = true
}
// 删除
const handleDelete = async (row: Equipment) => {
try {
await ElMessageBox.confirm(`确定要删除设备「${row.equipment_name}」吗?`, '提示', {
type: 'warning',
})
await equipmentApi.delete(row.id)
ElMessage.success('删除成功')
fetchData()
} catch (error: any) {
if (error !== 'cancel') {
console.error('删除失败:', error)
}
}
}
// 提交表单
const handleSubmit = async () => {
try {
await formRef.value?.validate()
if (editingId.value) {
await equipmentApi.update(editingId.value, form.value as EquipmentUpdate)
ElMessage.success('更新成功')
} else {
await equipmentApi.create(form.value)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchData()
} catch (error) {
console.error('提交失败:', error)
}
}
// 分页
const handleCurrentChange = (page: number) => {
queryParams.page = page
fetchData()
}
const handleSizeChange = (size: number) => {
queryParams.page_size = size
queryParams.page = 1
fetchData()
}
// 初始化
onMounted(() => {
fetchData()
})
</script>
<template>
<div class="page-container">
<el-card shadow="never">
<!-- 搜索栏 -->
<el-form :inline="true" class="search-form">
<el-form-item label="关键词">
<el-input
v-model="queryParams.keyword"
placeholder="编码/名称"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryParams.is_active" placeholder="全部" clearable>
<el-option label="启用" :value="true" />
<el-option label="禁用" :value="false" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增设备
</el-button>
</div>
<!-- 表格 -->
<el-table v-loading="loading" :data="tableData" border>
<el-table-column prop="equipment_code" label="编码" width="120" />
<el-table-column prop="equipment_name" label="名称" min-width="150" />
<el-table-column prop="original_value" label="原值" width="120" align="right">
<template #default="{ row }">
¥{{ row.original_value.toLocaleString() }}
</template>
</el-table-column>
<el-table-column prop="residual_rate" label="残值率" width="80" align="center">
<template #default="{ row }">
{{ row.residual_rate }}%
</template>
</el-table-column>
<el-table-column prop="service_years" label="使用年限" width="90" align="center">
<template #default="{ row }">
{{ row.service_years }}
</template>
</el-table-column>
<el-table-column prop="estimated_uses" label="预计次数" width="100" align="center" />
<el-table-column prop="depreciation_per_use" label="单次折旧" width="100" align="right">
<template #default="{ row }">
¥{{ row.depreciation_per_use.toFixed(4) }}
</template>
</el-table-column>
<el-table-column prop="is_active" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
{{ row.is_active ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.page_size"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<!-- 表单对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="设备编码" prop="equipment_code">
<el-input v-model="form.equipment_code" placeholder="请输入编码" />
</el-form-item>
<el-form-item label="设备名称" prop="equipment_name">
<el-input v-model="form.equipment_name" placeholder="请输入名称" />
</el-form-item>
<el-form-item label="设备原值" prop="original_value">
<el-input-number v-model="form.original_value" :min="0" :precision="2" style="width: 100%;" />
</el-form-item>
<el-form-item label="残值率(%)" prop="residual_rate">
<el-input-number v-model="form.residual_rate" :min="0" :max="100" :precision="2" />
</el-form-item>
<el-form-item label="使用年限" prop="service_years">
<el-input-number v-model="form.service_years" :min="1" />
</el-form-item>
<el-form-item label="预计使用次数" prop="estimated_uses">
<el-input-number v-model="form.estimated_uses" :min="1" />
</el-form-item>
<el-form-item label="购入日期" prop="purchase_date">
<el-date-picker
v-model="form.purchase_date"
type="date"
placeholder="选择日期"
value-format="YYYY-MM-DD"
style="width: 100%;"
/>
</el-form-item>
<el-form-item label="状态" prop="is_active">
<el-switch v-model="form.is_active" active-text="启用" inactive-text="禁用" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.search-form {
margin-bottom: 16px;
}
.toolbar {
margin-bottom: 16px;
display: flex;
gap: 12px;
}
.pagination {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,351 @@
<script setup lang="ts">
/**
* 固定成本管理页面
*/
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fixedCostApi, FixedCost, FixedCostCreate, FixedCostUpdate, costTypeOptions, allocationMethodOptions } from '@/api'
import dayjs from 'dayjs'
// 查询参数
const queryParams = reactive({
page: 1,
page_size: 20,
year_month: dayjs().format('YYYY-MM'),
cost_type: undefined as string | undefined,
is_active: undefined as boolean | undefined,
})
// 数据
const loading = ref(false)
const tableData = ref<FixedCost[]>([])
const total = ref(0)
const dialogVisible = ref(false)
const dialogTitle = ref('新增固定成本')
const formRef = ref()
const form = ref<FixedCostCreate>({
cost_name: '',
cost_type: 'rent',
monthly_amount: 0,
year_month: dayjs().format('YYYY-MM'),
allocation_method: 'count',
is_active: true,
})
const editingId = ref<number | null>(null)
// 表单规则
const rules = {
cost_name: [
{ required: true, message: '请输入成本名称', trigger: 'blur' },
],
cost_type: [
{ required: true, message: '请选择类型', trigger: 'change' },
],
monthly_amount: [
{ required: true, message: '请输入月度金额', trigger: 'blur' },
],
year_month: [
{ required: true, message: '请选择年月', trigger: 'change' },
],
}
// 获取数据
const fetchData = async () => {
loading.value = true
try {
const res = await fixedCostApi.getList(queryParams)
tableData.value = res.data?.items || []
total.value = res.data?.total || 0
} catch (error) {
console.error('获取固定成本失败:', error)
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
queryParams.page = 1
fetchData()
}
// 重置
const handleReset = () => {
queryParams.year_month = dayjs().format('YYYY-MM')
queryParams.cost_type = undefined
queryParams.is_active = undefined
queryParams.page = 1
fetchData()
}
// 新增
const handleAdd = () => {
editingId.value = null
dialogTitle.value = '新增固定成本'
form.value = {
cost_name: '',
cost_type: 'rent',
monthly_amount: 0,
year_month: queryParams.year_month,
allocation_method: 'count',
is_active: true,
}
dialogVisible.value = true
}
// 编辑
const handleEdit = (row: FixedCost) => {
editingId.value = row.id
dialogTitle.value = '编辑固定成本'
form.value = { ...row }
dialogVisible.value = true
}
// 删除
const handleDelete = async (row: FixedCost) => {
try {
await ElMessageBox.confirm(`确定要删除固定成本「${row.cost_name}」吗?`, '提示', {
type: 'warning',
})
await fixedCostApi.delete(row.id)
ElMessage.success('删除成功')
fetchData()
} catch (error: any) {
if (error !== 'cancel') {
console.error('删除失败:', error)
}
}
}
// 提交表单
const handleSubmit = async () => {
try {
await formRef.value?.validate()
if (editingId.value) {
await fixedCostApi.update(editingId.value, form.value as FixedCostUpdate)
ElMessage.success('更新成功')
} else {
await fixedCostApi.create(form.value)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchData()
} catch (error) {
console.error('提交失败:', error)
}
}
// 分页
const handleCurrentChange = (page: number) => {
queryParams.page = page
fetchData()
}
const handleSizeChange = (size: number) => {
queryParams.page_size = size
queryParams.page = 1
fetchData()
}
// 获取类型标签
const getTypeLabel = (type: string) => {
return costTypeOptions.find(item => item.value === type)?.label || type
}
// 获取分摊方式标签
const getMethodLabel = (method: string) => {
return allocationMethodOptions.find(item => item.value === method)?.label || method
}
// 计算合计
const totalAmount = computed(() => {
return tableData.value.reduce((sum, item) => sum + item.monthly_amount, 0)
})
// 初始化
onMounted(() => {
fetchData()
})
</script>
<template>
<div class="page-container">
<el-card shadow="never">
<!-- 搜索栏 -->
<el-form :inline="true" class="search-form">
<el-form-item label="年月">
<el-date-picker
v-model="queryParams.year_month"
type="month"
placeholder="选择月份"
value-format="YYYY-MM"
@change="handleSearch"
/>
</el-form-item>
<el-form-item label="类型">
<el-select v-model="queryParams.cost_type" placeholder="全部" clearable>
<el-option
v-for="item in costTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryParams.is_active" placeholder="全部" clearable>
<el-option label="启用" :value="true" />
<el-option label="禁用" :value="false" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增固定成本
</el-button>
<div class="total-info">
当月合计<span class="amount">¥{{ totalAmount.toLocaleString() }}</span>
</div>
</div>
<!-- 表格 -->
<el-table v-loading="loading" :data="tableData" border>
<el-table-column prop="cost_name" label="名称" min-width="150" />
<el-table-column prop="cost_type" label="类型" width="100" align="center">
<template #default="{ row }">
<el-tag size="small">{{ getTypeLabel(row.cost_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="monthly_amount" label="月度金额" width="140" align="right">
<template #default="{ row }">
¥{{ row.monthly_amount.toLocaleString() }}
</template>
</el-table-column>
<el-table-column prop="year_month" label="年月" width="100" align="center" />
<el-table-column prop="allocation_method" label="分摊方式" width="120" align="center">
<template #default="{ row }">
{{ getMethodLabel(row.allocation_method) }}
</template>
</el-table-column>
<el-table-column prop="is_active" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
{{ row.is_active ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.page_size"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<!-- 表单对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="成本名称" prop="cost_name">
<el-input v-model="form.cost_name" placeholder="如:门店租金" />
</el-form-item>
<el-form-item label="类型" prop="cost_type">
<el-select v-model="form.cost_type">
<el-option
v-for="item in costTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="月度金额" prop="monthly_amount">
<el-input-number v-model="form.monthly_amount" :min="0" :precision="2" style="width: 100%;" />
</el-form-item>
<el-form-item label="年月" prop="year_month">
<el-date-picker
v-model="form.year_month"
type="month"
placeholder="选择月份"
value-format="YYYY-MM"
style="width: 100%;"
/>
</el-form-item>
<el-form-item label="分摊方式" prop="allocation_method">
<el-select v-model="form.allocation_method">
<el-option
v-for="item in allocationMethodOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="状态" prop="is_active">
<el-switch v-model="form.is_active" active-text="启用" inactive-text="禁用" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.search-form {
margin-bottom: 16px;
}
.toolbar {
margin-bottom: 16px;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.total-info {
font-size: 14px;
color: #606266;
}
.total-info .amount {
font-size: 18px;
font-weight: 600;
color: #f56c6c;
}
.pagination {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,315 @@
<script setup lang="ts">
/**
* 耗材管理页面
*/
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { materialApi, Material, MaterialCreate, MaterialUpdate, materialTypeOptions } from '@/api'
// 查询参数
const queryParams = reactive({
page: 1,
page_size: 20,
keyword: '',
material_type: undefined as string | undefined,
is_active: undefined as boolean | undefined,
})
// 数据
const loading = ref(false)
const tableData = ref<Material[]>([])
const total = ref(0)
const dialogVisible = ref(false)
const dialogTitle = ref('新增耗材')
const formRef = ref()
const form = ref<MaterialCreate>({
material_code: '',
material_name: '',
unit: '',
unit_price: 0,
supplier: null,
material_type: 'consumable',
is_active: true,
})
const editingId = ref<number | null>(null)
// 表单规则
const rules = {
material_code: [
{ required: true, message: '请输入耗材编码', trigger: 'blur' },
],
material_name: [
{ required: true, message: '请输入耗材名称', trigger: 'blur' },
],
unit: [
{ required: true, message: '请输入单位', trigger: 'blur' },
],
unit_price: [
{ required: true, message: '请输入单价', trigger: 'blur' },
],
material_type: [
{ required: true, message: '请选择类型', trigger: 'change' },
],
}
// 获取数据
const fetchData = async () => {
loading.value = true
try {
const res = await materialApi.getList(queryParams)
tableData.value = res.data?.items || []
total.value = res.data?.total || 0
} catch (error) {
console.error('获取耗材失败:', error)
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
queryParams.page = 1
fetchData()
}
// 重置
const handleReset = () => {
queryParams.keyword = ''
queryParams.material_type = undefined
queryParams.is_active = undefined
queryParams.page = 1
fetchData()
}
// 新增
const handleAdd = () => {
editingId.value = null
dialogTitle.value = '新增耗材'
form.value = {
material_code: '',
material_name: '',
unit: '',
unit_price: 0,
supplier: null,
material_type: 'consumable',
is_active: true,
}
dialogVisible.value = true
}
// 编辑
const handleEdit = (row: Material) => {
editingId.value = row.id
dialogTitle.value = '编辑耗材'
form.value = { ...row }
dialogVisible.value = true
}
// 删除
const handleDelete = async (row: Material) => {
try {
await ElMessageBox.confirm(`确定要删除耗材「${row.material_name}」吗?`, '提示', {
type: 'warning',
})
await materialApi.delete(row.id)
ElMessage.success('删除成功')
fetchData()
} catch (error: any) {
if (error !== 'cancel') {
console.error('删除失败:', error)
}
}
}
// 提交表单
const handleSubmit = async () => {
try {
await formRef.value?.validate()
if (editingId.value) {
await materialApi.update(editingId.value, form.value as MaterialUpdate)
ElMessage.success('更新成功')
} else {
await materialApi.create(form.value)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchData()
} catch (error) {
console.error('提交失败:', error)
}
}
// 分页
const handleCurrentChange = (page: number) => {
queryParams.page = page
fetchData()
}
const handleSizeChange = (size: number) => {
queryParams.page_size = size
queryParams.page = 1
fetchData()
}
// 获取类型标签
const getTypeLabel = (type: string) => {
return materialTypeOptions.find(item => item.value === type)?.label || type
}
// 初始化
onMounted(() => {
fetchData()
})
</script>
<template>
<div class="page-container">
<el-card shadow="never">
<!-- 搜索栏 -->
<el-form :inline="true" class="search-form">
<el-form-item label="关键词">
<el-input
v-model="queryParams.keyword"
placeholder="编码/名称"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="类型">
<el-select v-model="queryParams.material_type" placeholder="全部" clearable>
<el-option
v-for="item in materialTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryParams.is_active" placeholder="全部" clearable>
<el-option label="启用" :value="true" />
<el-option label="禁用" :value="false" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增耗材
</el-button>
</div>
<!-- 表格 -->
<el-table v-loading="loading" :data="tableData" border>
<el-table-column prop="material_code" label="编码" width="120" />
<el-table-column prop="material_name" label="名称" min-width="150" />
<el-table-column prop="unit" label="单位" width="80" align="center" />
<el-table-column prop="unit_price" label="单价" width="100" align="right">
<template #default="{ row }">
¥{{ row.unit_price.toFixed(2) }}
</template>
</el-table-column>
<el-table-column prop="material_type" label="类型" width="100" align="center">
<template #default="{ row }">
<el-tag size="small">{{ getTypeLabel(row.material_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="supplier" label="供应商" min-width="120" />
<el-table-column prop="is_active" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
{{ row.is_active ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.page_size"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<!-- 表单对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="编码" prop="material_code">
<el-input v-model="form.material_code" placeholder="请输入编码" />
</el-form-item>
<el-form-item label="名称" prop="material_name">
<el-input v-model="form.material_name" placeholder="请输入名称" />
</el-form-item>
<el-form-item label="单位" prop="unit">
<el-input v-model="form.unit" placeholder="如支、ml、个" />
</el-form-item>
<el-form-item label="单价" prop="unit_price">
<el-input-number v-model="form.unit_price" :min="0" :precision="2" />
</el-form-item>
<el-form-item label="类型" prop="material_type">
<el-select v-model="form.material_type">
<el-option
v-for="item in materialTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="供应商" prop="supplier">
<el-input v-model="form.supplier" placeholder="请输入供应商" />
</el-form-item>
<el-form-item label="状态" prop="is_active">
<el-switch v-model="form.is_active" active-text="启用" inactive-text="禁用" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.search-form {
margin-bottom: 16px;
}
.toolbar {
margin-bottom: 16px;
display: flex;
gap: 12px;
}
.pagination {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,269 @@
<script setup lang="ts">
/**
* 人员级别管理页面
*/
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { staffLevelApi, StaffLevel, StaffLevelCreate, StaffLevelUpdate } from '@/api'
// 查询参数
const queryParams = reactive({
page: 1,
page_size: 20,
keyword: '',
is_active: undefined as boolean | undefined,
})
// 数据
const loading = ref(false)
const tableData = ref<StaffLevel[]>([])
const total = ref(0)
const dialogVisible = ref(false)
const dialogTitle = ref('新增人员级别')
const formRef = ref()
const form = ref<StaffLevelCreate>({
level_code: '',
level_name: '',
hourly_rate: 0,
is_active: true,
})
const editingId = ref<number | null>(null)
// 表单规则
const rules = {
level_code: [
{ required: true, message: '请输入级别编码', trigger: 'blur' },
],
level_name: [
{ required: true, message: '请输入级别名称', trigger: 'blur' },
],
hourly_rate: [
{ required: true, message: '请输入时薪', trigger: 'blur' },
],
}
// 获取数据
const fetchData = async () => {
loading.value = true
try {
const res = await staffLevelApi.getList(queryParams)
tableData.value = res.data?.items || []
total.value = res.data?.total || 0
} catch (error) {
console.error('获取人员级别失败:', error)
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
queryParams.page = 1
fetchData()
}
// 重置
const handleReset = () => {
queryParams.keyword = ''
queryParams.is_active = undefined
queryParams.page = 1
fetchData()
}
// 新增
const handleAdd = () => {
editingId.value = null
dialogTitle.value = '新增人员级别'
form.value = {
level_code: '',
level_name: '',
hourly_rate: 0,
is_active: true,
}
dialogVisible.value = true
}
// 编辑
const handleEdit = (row: StaffLevel) => {
editingId.value = row.id
dialogTitle.value = '编辑人员级别'
form.value = { ...row }
dialogVisible.value = true
}
// 删除
const handleDelete = async (row: StaffLevel) => {
try {
await ElMessageBox.confirm(`确定要删除人员级别「${row.level_name}」吗?`, '提示', {
type: 'warning',
})
await staffLevelApi.delete(row.id)
ElMessage.success('删除成功')
fetchData()
} catch (error: any) {
if (error !== 'cancel') {
console.error('删除失败:', error)
}
}
}
// 提交表单
const handleSubmit = async () => {
try {
await formRef.value?.validate()
if (editingId.value) {
await staffLevelApi.update(editingId.value, form.value as StaffLevelUpdate)
ElMessage.success('更新成功')
} else {
await staffLevelApi.create(form.value)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchData()
} catch (error) {
console.error('提交失败:', error)
}
}
// 分页
const handleCurrentChange = (page: number) => {
queryParams.page = page
fetchData()
}
const handleSizeChange = (size: number) => {
queryParams.page_size = size
queryParams.page = 1
fetchData()
}
// 初始化
onMounted(() => {
fetchData()
})
</script>
<template>
<div class="page-container">
<el-card shadow="never">
<!-- 搜索栏 -->
<el-form :inline="true" class="search-form">
<el-form-item label="关键词">
<el-input
v-model="queryParams.keyword"
placeholder="编码/名称"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryParams.is_active" placeholder="全部" clearable>
<el-option label="启用" :value="true" />
<el-option label="禁用" :value="false" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增人员级别
</el-button>
</div>
<!-- 表格 -->
<el-table v-loading="loading" :data="tableData" border>
<el-table-column prop="level_code" label="编码" width="120" />
<el-table-column prop="level_name" label="名称" min-width="150" />
<el-table-column prop="hourly_rate" label="时薪(元/小时)" width="140" align="right">
<template #default="{ row }">
¥{{ row.hourly_rate.toFixed(2) }}
</template>
</el-table-column>
<el-table-column prop="is_active" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
{{ row.is_active ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.page_size"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<!-- 表单对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="级别编码" prop="level_code">
<el-input v-model="form.level_code" placeholder="如L1, D1" />
</el-form-item>
<el-form-item label="级别名称" prop="level_name">
<el-input v-model="form.level_name" placeholder="如:初级美容师" />
</el-form-item>
<el-form-item label="时薪" prop="hourly_rate">
<el-input-number v-model="form.hourly_rate" :min="0" :precision="2" />
<span class="unit">/小时</span>
</el-form-item>
<el-form-item label="状态" prop="is_active">
<el-switch v-model="form.is_active" active-text="启用" inactive-text="禁用" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.search-form {
margin-bottom: 16px;
}
.toolbar {
margin-bottom: 16px;
display: flex;
gap: 12px;
}
.pagination {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.unit {
margin-left: 8px;
color: #909399;
}
</style>

7
前端应用/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@@ -0,0 +1,41 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./index.html',
'./src/**/*.{vue,js,ts,jsx,tsx}',
],
theme: {
extend: {
colors: {
// 主色调(与 Element Plus 协调)
primary: {
50: '#ecf5ff',
100: '#d9ecff',
200: '#b3d8ff',
300: '#8cc5ff',
400: '#66b1ff',
500: '#409eff', // Element Plus 主色
600: '#3a8ee6',
700: '#337ecc',
800: '#2d6eb3',
900: '#265e99',
},
},
fontFamily: {
sans: [
'PingFang SC',
'Microsoft YaHei',
'Helvetica Neue',
'Helvetica',
'Arial',
'sans-serif',
],
},
},
},
// 与 Element Plus 共存,禁用冲突的样式
corePlugins: {
preflight: false,
},
plugins: [],
}

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path alias */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Types */
"types": ["vite/client", "element-plus/global"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,98 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import compression from 'vite-plugin-compression'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
// Element Plus 自动导入
AutoImport({
resolvers: [ElementPlusResolver()],
imports: ['vue', 'vue-router', 'pinia'],
dts: 'src/types/auto-imports.d.ts',
}),
Components({
resolvers: [ElementPlusResolver()],
dts: 'src/types/components.d.ts',
}),
// Gzip 压缩
compression({
algorithm: 'gzip',
ext: '.gz',
threshold: 10240, // 大于 10KB 才压缩
}),
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
minify: 'esbuild',
// 块大小警告阈值
chunkSizeWarningLimit: 500,
rollupOptions: {
output: {
chunkFileNames: 'assets/js/[name]-[hash].js',
entryFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
// 优化的代码分割策略
manualChunks(id) {
// Vue 核心库
if (id.includes('node_modules/vue') ||
id.includes('node_modules/vue-router') ||
id.includes('node_modules/pinia')) {
return 'vue-vendor'
}
// Element Plus
if (id.includes('node_modules/element-plus')) {
return 'element-plus'
}
// ECharts
if (id.includes('node_modules/echarts') ||
id.includes('node_modules/vue-echarts') ||
id.includes('node_modules/zrender')) {
return 'echarts'
}
// 其他第三方库
if (id.includes('node_modules')) {
return 'vendor'
}
},
},
},
// 资源内联阈值
assetsInlineLimit: 4096,
// CSS 代码分割
cssCodeSplit: true,
},
// 预构建优化
optimizeDeps: {
include: [
'vue',
'vue-router',
'pinia',
'axios',
'element-plus',
'echarts',
'vue-echarts',
],
},
})

68
后端服务/Dockerfile Normal file
View File

@@ -0,0 +1,68 @@
# 智能项目定价模型 - 后端 Dockerfile
# 遵循瑞小美部署规范:使用具体版本号,配置阿里云镜像源
# 构建阶段
FROM python:3.11.9-slim AS builder
# 配置阿里云 APT 源
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources
# 安装构建依赖
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libffi-dev \
&& rm -rf /var/lib/apt/lists/*
# 配置阿里云 pip 源
RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ \
&& pip config set global.trusted-host mirrors.aliyun.com
# 创建虚拟环境
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# 复制依赖文件并安装
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 运行阶段
FROM python:3.11.9-slim AS runner
# 配置阿里云 APT 源
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources
# 安装运行时依赖
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# 创建非 root 用户
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
# 设置工作目录
WORKDIR /app
# 从构建阶段复制虚拟环境
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# 复制应用代码
COPY --chown=appuser:appgroup . .
# 设置环境变量
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
TZ=Asia/Shanghai
# 切换到非 root 用户
USER appuser
# 暴露端口
EXPOSE 8000
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# 启动命令
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,29 @@
# 开发环境 Dockerfile
FROM python:3.11.9-slim
# 配置阿里云源
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources
# 安装依赖
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# 配置 pip
RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/
WORKDIR /app
# 安装依赖
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 设置环境变量
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
TZ=Asia/Shanghai
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

View File

@@ -0,0 +1,90 @@
# 安全检查清单
> 智能项目定价模型 - M4 测试优化阶段
> 遵循瑞小美系统技术栈标准与安全规范
---
## 1. API Key 管理
- [x] 未在代码中硬编码 API Key
- [x] 通过 `shared_backend.AIService` 调用 AI 服务
- [x] API Key 从门户系统统一获取
- [x] `.env` 文件在 `.gitignore` 中排除
## 2. 输入验证
- [x] 使用 Pydantic 进行请求参数验证
- [x] 添加 SQL 注入检测
- [x] 添加 XSS 检测
- [x] 限制请求数据嵌套深度
## 3. 身份认证
- [ ] 集成 OAuth 认证(待 M5 部署阶段完成)
- [x] 预留认证中间件接口
- [x] API 路由支持权限控制
## 4. 速率限制
- [x] 实现请求速率限制中间件
- [x] AI 接口有特殊限制10 次/分钟)
- [x] 默认限制 100 次/分钟
## 5. 安全响应头
- [x] X-XSS-Protection
- [x] X-Content-Type-Options
- [x] X-Frame-Options
- [x] Content-Security-Policy
## 6. 数据保护
- [x] 敏感数据使用 DECIMAL 类型存储
- [x] 数据库连接使用连接池
- [x] 支持敏感字段脱敏(待实现具体业务)
## 7. 日志审计
- [x] 实现审计日志记录器
- [x] 记录敏感操作
- [x] 日志格式为 JSON便于分析
## 8. 错误处理
- [x] 统一错误响应格式
- [x] 生产环境不暴露内部错误详情
- [x] 错误码规范化
## 9. 依赖安全
- [x] 使用固定版本的依赖
- [ ] 定期检查依赖漏洞(建议使用 `pip-audit`
## 10. 部署安全
- [x] Docker 容器使用非 root 用户(待验证)
- [x] 只暴露必要端口Nginx 80/443
- [x] 配置健康检查
- [x] 配置资源限制
---
## 安全测试命令
```bash
# 运行安全相关测试
pytest tests/ -m "security" -v
# 检查依赖漏洞
pip install pip-audit
pip-audit
# 检查代码安全问题
pip install bandit
bandit -r app/
```
---
*瑞小美技术团队 · 2026-01-20*

View File

@@ -0,0 +1,3 @@
"""智能项目定价模型 - 后端服务"""
__version__ = "1.0.0"

View File

@@ -0,0 +1,85 @@
"""配置管理模块
使用 Pydantic Settings 管理环境变量配置
遵循瑞小美系统技术栈标准
"""
import os
import secrets
import warnings
from functools import lru_cache
from typing import Optional
from pydantic_settings import BaseSettings
from pydantic import model_validator
class Settings(BaseSettings):
"""应用配置"""
# 应用配置
APP_NAME: str = "智能项目定价模型"
APP_VERSION: str = "1.0.0"
APP_ENV: str = "development"
DEBUG: bool = True
SECRET_KEY: str = "" # 必须通过环境变量设置
# 数据库配置 - MySQL 8.0, utf8mb4
# 开发环境使用默认值,生产环境必须通过环境变量设置
DATABASE_URL: str = "sqlite+aiosqlite:///:memory:" # 安全的开发默认值
# 数据库连接池配置
DB_POOL_SIZE: int = 5
DB_MAX_OVERFLOW: int = 10
DB_POOL_RECYCLE: int = 3600
# 门户系统配置 - AI Key 从门户获取
PORTAL_CONFIG_API: str = "http://portal-backend:8000/api/ai/internal/config"
# AI 服务配置
AI_MODULE_CODE: str = "pricing_model"
# 时区配置 - Asia/Shanghai
TIMEZONE: str = "Asia/Shanghai"
# CORS 配置 - 生产环境应限制为具体域名
CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://127.0.0.1:5173"]
# API 配置
API_V1_PREFIX: str = "/api/v1"
@model_validator(mode='after')
def validate_production_config(self) -> 'Settings':
"""验证生产环境配置安全性"""
if self.APP_ENV == "production":
# 生产环境必须设置安全的 SECRET_KEY
if not self.SECRET_KEY or self.SECRET_KEY == "your-secret-key-change-in-production":
raise ValueError("生产环境必须设置 SECRET_KEY 环境变量")
# 生产环境 CORS 不能使用 "*"
if "*" in self.CORS_ORIGINS:
warnings.warn("生产环境 CORS_ORIGINS 不应使用 '*',请设置具体的允许域名")
# 生产环境不应开启 DEBUG
if self.DEBUG:
warnings.warn("生产环境建议关闭 DEBUG 模式")
# 如果没有设置 SECRET_KEY开发环境自动生成一个
if not self.SECRET_KEY:
self.SECRET_KEY = secrets.token_urlsafe(32)
return self
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
case_sensitive = True
@lru_cache()
def get_settings() -> Settings:
"""获取配置单例"""
return Settings()
settings = get_settings()

View File

@@ -0,0 +1,84 @@
"""数据库连接模块
使用 SQLAlchemy 异步引擎
遵循瑞小美系统技术栈标准MySQL 8.0, utf8mb4, utf8mb4_unicode_ci
"""
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import MetaData
from app.config import settings
# 命名约定,便于数据库迁移
convention = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s"
}
metadata = MetaData(naming_convention=convention)
class Base(DeclarativeBase):
"""SQLAlchemy 模型基类"""
metadata = metadata
# 创建异步引擎
# SQLite 不支持连接池参数,需要区分处理
_engine_kwargs = {
"echo": settings.DEBUG,
}
# 仅在非 SQLite 环境下添加连接池参数
if not settings.DATABASE_URL.startswith("sqlite"):
_engine_kwargs.update({
"pool_size": settings.DB_POOL_SIZE,
"max_overflow": settings.DB_MAX_OVERFLOW,
"pool_recycle": settings.DB_POOL_RECYCLE,
"pool_pre_ping": True,
})
engine = create_async_engine(
settings.DATABASE_URL,
**_engine_kwargs,
)
# 创建异步会话工厂
async_session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""获取数据库会话依赖"""
async with async_session_maker() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db():
"""初始化数据库表"""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def close_db():
"""关闭数据库连接"""
await engine.dispose()

127
后端服务/app/main.py Normal file
View File

@@ -0,0 +1,127 @@
"""FastAPI 应用入口
智能项目定价模型后端服务
遵循瑞小美系统技术栈标准
"""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from app.config import settings
from app.database import init_db, close_db
from app.routers import health, categories, materials, equipments, staff_levels, fixed_costs, projects, market, pricing, profit, dashboard
from app.middleware import (
PerformanceMiddleware,
ResponseCacheMiddleware,
RateLimitMiddleware,
SecurityHeadersMiddleware,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
# 启动时初始化数据库
await init_db()
yield
# 关闭时清理资源
await close_db()
# 创建 FastAPI 应用
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
description="智能项目定价模型 - 帮助机构精准核算成本、分析市场、智能定价",
docs_url="/docs" if settings.DEBUG else None,
redoc_url="/redoc" if settings.DEBUG else None,
lifespan=lifespan,
)
# 配置 CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 性能优化中间件
app.add_middleware(GZipMiddleware, minimum_size=1000) # 压缩大于 1KB 的响应
app.add_middleware(PerformanceMiddleware) # 性能监控
app.add_middleware(ResponseCacheMiddleware) # 响应缓存
# 安全中间件
app.add_middleware(RateLimitMiddleware, enabled=not settings.DEBUG) # 速率限制(生产环境)
app.add_middleware(SecurityHeadersMiddleware) # 安全响应头
# 注册路由
app.include_router(health.router, tags=["健康检查"])
app.include_router(
categories.router,
prefix=f"{settings.API_V1_PREFIX}/categories",
tags=["项目分类"]
)
app.include_router(
materials.router,
prefix=f"{settings.API_V1_PREFIX}/materials",
tags=["耗材管理"]
)
app.include_router(
equipments.router,
prefix=f"{settings.API_V1_PREFIX}/equipments",
tags=["设备管理"]
)
app.include_router(
staff_levels.router,
prefix=f"{settings.API_V1_PREFIX}/staff-levels",
tags=["人员级别"]
)
app.include_router(
fixed_costs.router,
prefix=f"{settings.API_V1_PREFIX}/fixed-costs",
tags=["固定成本"]
)
app.include_router(
projects.router,
prefix=f"{settings.API_V1_PREFIX}/projects",
tags=["服务项目"]
)
app.include_router(
market.router,
prefix=f"{settings.API_V1_PREFIX}",
tags=["市场行情"]
)
app.include_router(
pricing.router,
prefix=f"{settings.API_V1_PREFIX}",
tags=["智能定价"]
)
app.include_router(
profit.router,
prefix=f"{settings.API_V1_PREFIX}",
tags=["利润模拟"]
)
app.include_router(
dashboard.router,
prefix=f"{settings.API_V1_PREFIX}",
tags=["仪表盘"]
)
@app.get("/")
async def root():
"""根路径"""
return {
"code": 0,
"message": "success",
"data": {
"name": settings.APP_NAME,
"version": settings.APP_VERSION,
"docs": "/docs" if settings.DEBUG else None
}
}

View File

@@ -0,0 +1,20 @@
"""中间件模块"""
from .performance import PerformanceMiddleware
from .cache import ResponseCacheMiddleware
from .security import (
RateLimitMiddleware,
SecurityHeadersMiddleware,
InputSanitizer,
validate_request_body,
AuditLogger,
)
__all__ = [
"PerformanceMiddleware",
"ResponseCacheMiddleware",
"RateLimitMiddleware",
"SecurityHeadersMiddleware",
"InputSanitizer",
"validate_request_body",
"AuditLogger",
]

View File

@@ -0,0 +1,121 @@
"""响应缓存中间件
对 GET 请求的响应进行缓存,减少数据库查询
"""
import hashlib
import json
import logging
from typing import Callable, Optional, Set
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from app.services.cache_service import get_cache, CacheNamespace
logger = logging.getLogger(__name__)
class ResponseCacheMiddleware(BaseHTTPMiddleware):
"""响应缓存中间件
对符合条件的 GET 请求进行响应缓存
"""
# 需要缓存的路径前缀和 TTL 配置
CACHE_CONFIG = {
"/api/v1/categories": {"ttl": 300, "namespace": CacheNamespace.CATEGORIES},
"/api/v1/materials": {"ttl": 300, "namespace": CacheNamespace.MATERIALS},
"/api/v1/equipments": {"ttl": 300, "namespace": CacheNamespace.EQUIPMENTS},
"/api/v1/staff-levels": {"ttl": 300, "namespace": CacheNamespace.STAFF_LEVELS},
}
# 不缓存的路径(精确匹配)
NO_CACHE_PATHS: Set[str] = {
"/health",
"/docs",
"/redoc",
"/openapi.json",
}
def _should_cache(self, request: Request) -> Optional[dict]:
"""判断是否应该缓存"""
# 只缓存 GET 请求
if request.method != "GET":
return None
path = request.url.path
# 排除不缓存的路径
if path in self.NO_CACHE_PATHS:
return None
# 检查是否在缓存配置中
for prefix, config in self.CACHE_CONFIG.items():
if path.startswith(prefix):
return config
return None
def _generate_cache_key(self, request: Request) -> str:
"""生成缓存键"""
# 包含路径和查询参数
key_parts = [
request.method,
request.url.path,
str(sorted(request.query_params.items())),
]
key_str = "|".join(key_parts)
return hashlib.md5(key_str.encode()).hexdigest()
async def dispatch(self, request: Request, call_next: Callable) -> Response:
cache_config = self._should_cache(request)
if not cache_config:
return await call_next(request)
# 生成缓存键
cache_key = self._generate_cache_key(request)
cache = get_cache(cache_config["namespace"])
# 尝试从缓存获取
cached_data = cache.get(cache_key)
if cached_data is not None:
logger.debug(f"Cache hit: {request.url.path}")
response = Response(
content=cached_data["content"],
status_code=cached_data["status_code"],
headers=dict(cached_data["headers"]),
media_type="application/json",
)
response.headers["X-Cache"] = "HIT"
return response
# 执行请求
response = await call_next(request)
# 只缓存成功的响应
if response.status_code == 200:
# 读取响应体
body = b""
async for chunk in response.body_iterator:
body += chunk
# 保存到缓存
cache_data = {
"content": body,
"status_code": response.status_code,
"headers": dict(response.headers),
}
cache.set(cache_key, cache_data, cache_config["ttl"])
# 重新构建响应
response = Response(
content=body,
status_code=response.status_code,
headers=dict(response.headers),
media_type="application/json",
)
response.headers["X-Cache"] = "MISS"
return response

View File

@@ -0,0 +1,50 @@
"""性能监控中间件
记录请求响应时间,用于性能分析和优化
"""
import time
import logging
from typing import Callable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
logger = logging.getLogger(__name__)
class PerformanceMiddleware(BaseHTTPMiddleware):
"""性能监控中间件
记录每个请求的响应时间,并在响应头中添加 X-Response-Time
"""
# 慢请求阈值(毫秒)
SLOW_REQUEST_THRESHOLD = 1000
async def dispatch(self, request: Request, call_next: Callable) -> Response:
start_time = time.time()
# 执行请求
response = await call_next(request)
# 计算响应时间
process_time = (time.time() - start_time) * 1000
# 添加响应头
response.headers["X-Response-Time"] = f"{process_time:.2f}ms"
# 记录慢请求
if process_time > self.SLOW_REQUEST_THRESHOLD:
logger.warning(
f"Slow request: {request.method} {request.url.path} "
f"took {process_time:.2f}ms"
)
# 记录请求日志(开发环境)
logger.debug(
f"{request.method} {request.url.path} - "
f"{response.status_code} - {process_time:.2f}ms"
)
return response

View File

@@ -0,0 +1,267 @@
"""安全中间件
实现安全相关功能:
- 请求验证
- 速率限制
- 安全头设置
- 敏感数据保护
遵循瑞小美系统技术栈标准
"""
import logging
import time
from collections import defaultdict
from typing import Callable, Dict, Optional
import re
from fastapi import Request, Response, HTTPException
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
logger = logging.getLogger(__name__)
class RateLimitMiddleware(BaseHTTPMiddleware):
"""速率限制中间件
防止 API 滥用,保护服务稳定性
"""
# 速率限制配置
RATE_LIMITS = {
"default": {"requests": 100, "window": 60}, # 默认100 次/分钟
"/api/v1/projects/*/generate-pricing": {"requests": 10, "window": 60}, # AI 接口10 次/分钟
"/api/v1/projects/*/market-analysis": {"requests": 20, "window": 60}, # 分析接口20 次/分钟
}
def __init__(self, app, enabled: bool = True):
super().__init__(app)
self.enabled = enabled
self._requests: Dict[str, list] = defaultdict(list)
def _get_client_id(self, request: Request) -> str:
"""获取客户端标识"""
# 优先使用 X-Forwarded-For反向代理场景
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
return forwarded_for.split(",")[0].strip()
# 使用客户端 IP
return request.client.host if request.client else "unknown"
def _get_rate_limit(self, path: str) -> Dict:
"""获取路径的速率限制配置"""
for pattern, limit in self.RATE_LIMITS.items():
if pattern == "default":
continue
# 简单的路径匹配(* 匹配任意字符)
regex = pattern.replace("*", "[^/]+")
if re.match(regex, path):
return limit
return self.RATE_LIMITS["default"]
def _is_rate_limited(self, client_id: str, path: str) -> bool:
"""检查是否超过速率限制"""
limit_config = self._get_rate_limit(path)
requests = limit_config["requests"]
window = limit_config["window"]
now = time.time()
key = f"{client_id}:{path}"
# 清理过期记录
self._requests[key] = [t for t in self._requests[key] if now - t < window]
# 检查是否超限
if len(self._requests[key]) >= requests:
return True
# 记录请求
self._requests[key].append(now)
return False
async def dispatch(self, request: Request, call_next: Callable) -> Response:
if not self.enabled:
return await call_next(request)
client_id = self._get_client_id(request)
path = request.url.path
if self._is_rate_limited(client_id, path):
logger.warning(f"Rate limit exceeded: {client_id} -> {path}")
return JSONResponse(
status_code=429,
content={
"code": 40001,
"message": "请求过于频繁,请稍后再试",
"data": None
}
)
return await call_next(request)
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""安全响应头中间件
添加安全相关的 HTTP 响应头
"""
async def dispatch(self, request: Request, call_next: Callable) -> Response:
response = await call_next(request)
# 防止 XSS 攻击
response.headers["X-XSS-Protection"] = "1; mode=block"
# 防止 MIME 类型嗅探
response.headers["X-Content-Type-Options"] = "nosniff"
# 点击劫持保护
response.headers["X-Frame-Options"] = "DENY"
# 内容安全策略(基础版)
response.headers["Content-Security-Policy"] = "default-src 'self'"
# 严格传输安全(仅在 HTTPS 环境)
# response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
class InputSanitizer:
"""输入清理工具
防止 SQL 注入、XSS 等攻击
"""
# SQL 注入关键字
SQL_KEYWORDS = [
"SELECT", "INSERT", "UPDATE", "DELETE", "DROP", "UNION",
"OR", "AND", "--", "/*", "*/", "EXEC", "EXECUTE"
]
# XSS 危险模式
XSS_PATTERNS = [
r"<script.*?>",
r"javascript:",
r"on\w+\s*=",
r"eval\s*\(",
]
@classmethod
def check_sql_injection(cls, value: str) -> bool:
"""检查是否包含 SQL 注入特征"""
upper_value = value.upper()
for keyword in cls.SQL_KEYWORDS:
if keyword in upper_value:
return True
return False
@classmethod
def check_xss(cls, value: str) -> bool:
"""检查是否包含 XSS 特征"""
for pattern in cls.XSS_PATTERNS:
if re.search(pattern, value, re.IGNORECASE):
return True
return False
@classmethod
def sanitize(cls, value: str) -> str:
"""清理输入值
移除潜在危险字符
"""
# 移除 HTML 标签
value = re.sub(r'<[^>]+>', '', value)
# 转义特殊字符
value = value.replace("&", "&amp;")
value = value.replace("<", "&lt;")
value = value.replace(">", "&gt;")
value = value.replace('"', "&quot;")
value = value.replace("'", "&#x27;")
return value
def validate_request_body(data: dict, max_depth: int = 10) -> None:
"""验证请求体
检查数据深度和内容安全
Args:
data: 请求数据
max_depth: 最大嵌套深度
Raises:
HTTPException: 验证失败
"""
def check_depth(obj, depth=0):
if depth > max_depth:
raise HTTPException(
status_code=400,
detail={"code": 10001, "message": "请求数据嵌套层级过深"}
)
if isinstance(obj, dict):
for value in obj.values():
check_depth(value, depth + 1)
elif isinstance(obj, list):
for item in obj:
check_depth(item, depth + 1)
elif isinstance(obj, str):
if InputSanitizer.check_sql_injection(obj):
logger.warning(f"Potential SQL injection detected: {obj[:100]}")
if InputSanitizer.check_xss(obj):
logger.warning(f"Potential XSS detected: {obj[:100]}")
check_depth(data)
class AuditLogger:
"""审计日志记录器
记录敏感操作的审计日志
"""
# 需要审计的操作
AUDIT_OPERATIONS = {
("POST", "/api/v1/pricing-plans"): "创建定价方案",
("PUT", "/api/v1/pricing-plans/*"): "更新定价方案",
("DELETE", "/api/v1/pricing-plans/*"): "删除定价方案",
("POST", "/api/v1/projects/*/generate-pricing"): "生成 AI 定价建议",
}
@classmethod
def should_audit(cls, method: str, path: str) -> Optional[str]:
"""检查是否需要审计"""
for (m, p), desc in cls.AUDIT_OPERATIONS.items():
if m != method:
continue
regex = p.replace("*", "[^/]+")
if re.match(regex, path):
return desc
return None
@classmethod
def log(
cls,
operation: str,
user_id: Optional[int],
request: Request,
response_code: int,
details: Optional[Dict] = None
):
"""记录审计日志"""
log_data = {
"operation": operation,
"user_id": user_id,
"method": request.method,
"path": str(request.url.path),
"client_ip": request.client.host if request.client else "unknown",
"response_code": response_code,
"details": details or {},
}
logger.info(f"AUDIT: {log_data}")

View File

@@ -0,0 +1,44 @@
"""SQLAlchemy 数据模型"""
from app.models.base import BaseModel, TimestampMixin
from app.models.category import Category
from app.models.material import Material
from app.models.equipment import Equipment
from app.models.staff_level import StaffLevel
from app.models.fixed_cost import FixedCost
from app.models.project import Project
from app.models.project_cost_item import ProjectCostItem
from app.models.project_labor_cost import ProjectLaborCost
from app.models.project_cost_summary import ProjectCostSummary
from app.models.competitor import Competitor
from app.models.competitor_price import CompetitorPrice
from app.models.benchmark_price import BenchmarkPrice
from app.models.market_analysis_result import MarketAnalysisResult
from app.models.pricing_plan import PricingPlan
from app.models.profit_simulation import ProfitSimulation
from app.models.sensitivity_analysis import SensitivityAnalysis
from app.models.user import User
from app.models.operation_log import OperationLog
__all__ = [
"BaseModel",
"TimestampMixin",
"Category",
"Material",
"Equipment",
"StaffLevel",
"FixedCost",
"Project",
"ProjectCostItem",
"ProjectLaborCost",
"ProjectCostSummary",
"Competitor",
"CompetitorPrice",
"BenchmarkPrice",
"MarketAnalysisResult",
"PricingPlan",
"ProfitSimulation",
"SensitivityAnalysis",
"User",
"OperationLog",
]

View File

@@ -0,0 +1,51 @@
"""模型基类
包含时间戳 Mixin 和通用基类
遵循瑞小美数据库设计规范
"""
from datetime import datetime
from sqlalchemy import BigInteger, Integer, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
from app.config import settings
# 主键类型SQLite 使用 Integer支持自增MySQL 使用 BigInteger
# SQLite 的 AUTOINCREMENT 只在 INTEGER PRIMARY KEY 时生效
_PrimaryKeyType = Integer if settings.DATABASE_URL.startswith("sqlite") else BigInteger
class TimestampMixin:
"""时间戳 Mixin"""
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=func.now(),
server_default=func.now(),
comment="创建时间"
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=func.now(),
server_default=func.now(),
onupdate=func.now(),
comment="更新时间"
)
class BaseModel(Base, TimestampMixin):
"""模型基类"""
__abstract__ = True
id: Mapped[int] = mapped_column(
_PrimaryKeyType,
primary_key=True,
autoincrement=True,
comment="主键ID"
)

View File

@@ -0,0 +1,72 @@
"""标杆价格模型
维护行业标杆机构的价格参考
"""
from typing import Optional, TYPE_CHECKING
from decimal import Decimal
from datetime import date
from sqlalchemy import BigInteger, String, Date, DECIMAL, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import BaseModel
if TYPE_CHECKING:
from app.models.category import Category
class BenchmarkPrice(BaseModel):
"""标杆价格表"""
__tablename__ = "benchmark_prices"
benchmark_name: Mapped[str] = mapped_column(
String(100),
nullable=False,
comment="标杆机构名称"
)
category_id: Mapped[Optional[int]] = mapped_column(
BigInteger,
ForeignKey("categories.id"),
nullable=True,
index=True,
comment="项目分类ID"
)
min_price: Mapped[Decimal] = mapped_column(
DECIMAL(12, 2),
nullable=False,
comment="最低价"
)
max_price: Mapped[Decimal] = mapped_column(
DECIMAL(12, 2),
nullable=False,
comment="最高价"
)
avg_price: Mapped[Decimal] = mapped_column(
DECIMAL(12, 2),
nullable=False,
comment="均价"
)
price_tier: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="medium",
comment="价格带low-低端, medium-中端, high-高端, premium-奢华"
)
effective_date: Mapped[date] = mapped_column(
Date,
nullable=False,
index=True,
comment="生效日期"
)
remark: Mapped[Optional[str]] = mapped_column(
String(200),
nullable=True,
comment="备注"
)
# 关系
category: Mapped[Optional["Category"]] = relationship(
"Category"
)

View File

@@ -0,0 +1,60 @@
"""项目分类模型
支持树形分类结构
"""
from typing import Optional, List
from sqlalchemy import BigInteger, String, Integer, Boolean, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import BaseModel
class Category(BaseModel):
"""项目分类表"""
__tablename__ = "categories"
category_name: Mapped[str] = mapped_column(
String(50),
nullable=False,
comment="分类名称"
)
parent_id: Mapped[Optional[int]] = mapped_column(
BigInteger,
ForeignKey("categories.id"),
nullable=True,
comment="父分类ID"
)
sort_order: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="排序"
)
is_active: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
comment="是否启用"
)
# 关系
parent: Mapped[Optional["Category"]] = relationship(
"Category",
remote_side="Category.id",
back_populates="children"
)
children: Mapped[List["Category"]] = relationship(
"Category",
back_populates="parent"
)
projects: Mapped[List["Project"]] = relationship(
"Project",
back_populates="category"
)
# 避免循环导入
from app.models.project import Project

View File

@@ -0,0 +1,69 @@
"""竞品机构模型
管理周边竞品医美机构信息
"""
from typing import Optional, List, TYPE_CHECKING
from decimal import Decimal
from sqlalchemy import String, Boolean, DECIMAL
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import BaseModel
if TYPE_CHECKING:
from app.models.competitor_price import CompetitorPrice
class Competitor(BaseModel):
"""竞品机构表"""
__tablename__ = "competitors"
competitor_name: Mapped[str] = mapped_column(
String(100),
nullable=False,
comment="机构名称"
)
address: Mapped[Optional[str]] = mapped_column(
String(200),
nullable=True,
comment="地址"
)
distance_km: Mapped[Optional[Decimal]] = mapped_column(
DECIMAL(5, 2),
nullable=True,
comment="距离(公里)"
)
positioning: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="medium",
index=True,
comment="定位high-高端, medium-中端, budget-大众"
)
contact: Mapped[Optional[str]] = mapped_column(
String(50),
nullable=True,
comment="联系方式"
)
is_key_competitor: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
index=True,
comment="是否重点关注"
)
is_active: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
comment="是否启用"
)
# 关系
prices: Mapped[List["CompetitorPrice"]] = relationship(
"CompetitorPrice",
back_populates="competitor",
cascade="all, delete-orphan"
)

View File

@@ -0,0 +1,83 @@
"""竞品价格模型
记录竞品机构的项目价格信息
"""
from typing import Optional, TYPE_CHECKING
from decimal import Decimal
from datetime import date
from sqlalchemy import BigInteger, String, Date, DECIMAL, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import BaseModel
if TYPE_CHECKING:
from app.models.competitor import Competitor
from app.models.project import Project
class CompetitorPrice(BaseModel):
"""竞品价格表"""
__tablename__ = "competitor_prices"
competitor_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("competitors.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="竞品机构ID"
)
project_id: Mapped[Optional[int]] = mapped_column(
BigInteger,
ForeignKey("projects.id", ondelete="SET NULL"),
nullable=True,
index=True,
comment="关联本店项目ID"
)
project_name: Mapped[str] = mapped_column(
String(100),
nullable=False,
comment="竞品项目名称"
)
original_price: Mapped[Decimal] = mapped_column(
DECIMAL(12, 2),
nullable=False,
comment="原价"
)
promo_price: Mapped[Optional[Decimal]] = mapped_column(
DECIMAL(12, 2),
nullable=True,
comment="促销价"
)
member_price: Mapped[Optional[Decimal]] = mapped_column(
DECIMAL(12, 2),
nullable=True,
comment="会员价"
)
price_source: Mapped[str] = mapped_column(
String(20),
nullable=False,
comment="来源official-官网, meituan-美团, dianping-大众点评, survey-实地调研"
)
collected_at: Mapped[date] = mapped_column(
Date,
nullable=False,
index=True,
comment="采集日期"
)
remark: Mapped[Optional[str]] = mapped_column(
String(200),
nullable=True,
comment="备注"
)
# 关系
competitor: Mapped["Competitor"] = relationship(
"Competitor",
back_populates="prices"
)
project: Mapped[Optional["Project"]] = relationship(
"Project"
)

View File

@@ -0,0 +1,75 @@
"""设备模型
管理设备基础信息和折旧计算
"""
from typing import Optional
from datetime import date
from sqlalchemy import String, Boolean, Integer, Date, DECIMAL
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BaseModel
class Equipment(BaseModel):
"""设备表"""
__tablename__ = "equipments"
equipment_code: Mapped[str] = mapped_column(
String(50),
unique=True,
nullable=False,
index=True,
comment="设备编码"
)
equipment_name: Mapped[str] = mapped_column(
String(100),
nullable=False,
comment="设备名称"
)
original_value: Mapped[float] = mapped_column(
DECIMAL(12, 2),
nullable=False,
comment="设备原值"
)
residual_rate: Mapped[float] = mapped_column(
DECIMAL(5, 2),
nullable=False,
default=5.00,
comment="残值率(%)"
)
service_years: Mapped[int] = mapped_column(
Integer,
nullable=False,
comment="预计使用年限"
)
estimated_uses: Mapped[int] = mapped_column(
Integer,
nullable=False,
comment="预计使用次数"
)
depreciation_per_use: Mapped[float] = mapped_column(
DECIMAL(12, 4),
nullable=False,
comment="单次折旧成本 = (原值 - 残值) / 总次数"
)
purchase_date: Mapped[Optional[date]] = mapped_column(
Date,
nullable=True,
comment="购入日期"
)
is_active: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
comment="是否启用"
)
def calculate_depreciation(self) -> float:
"""计算单次折旧成本"""
if self.estimated_uses <= 0:
return 0
residual_value = float(self.original_value) * float(self.residual_rate) / 100
return (float(self.original_value) - residual_value) / self.estimated_uses

View File

@@ -0,0 +1,49 @@
"""固定成本模型
管理月度固定成本(房租、水电等)及分摊方式
"""
from sqlalchemy import String, Boolean, DECIMAL
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BaseModel
class FixedCost(BaseModel):
"""固定成本表"""
__tablename__ = "fixed_costs"
cost_name: Mapped[str] = mapped_column(
String(100),
nullable=False,
comment="成本名称"
)
cost_type: Mapped[str] = mapped_column(
String(20),
nullable=False,
comment="类型rent-房租, utilities-水电, property-物业, other-其他"
)
monthly_amount: Mapped[float] = mapped_column(
DECIMAL(12, 2),
nullable=False,
comment="月度金额"
)
year_month: Mapped[str] = mapped_column(
String(7),
nullable=False,
index=True,
comment="年月2026-01"
)
allocation_method: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="count",
comment="分摊方式count-按项目数, revenue-按营收, duration-按时长"
)
is_active: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
comment="是否启用"
)

View File

@@ -0,0 +1,76 @@
"""市场分析结果模型
存储项目的市场价格分析结果
"""
from typing import TYPE_CHECKING
from decimal import Decimal
from datetime import date
from sqlalchemy import BigInteger, Integer, Date, DECIMAL, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import BaseModel
if TYPE_CHECKING:
from app.models.project import Project
class MarketAnalysisResult(BaseModel):
"""市场分析结果表"""
__tablename__ = "market_analysis_results"
project_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("projects.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="项目ID"
)
analysis_date: Mapped[date] = mapped_column(
Date,
nullable=False,
index=True,
comment="分析日期"
)
competitor_count: Mapped[int] = mapped_column(
Integer,
nullable=False,
comment="样本竞品数量"
)
market_min_price: Mapped[Decimal] = mapped_column(
DECIMAL(12, 2),
nullable=False,
comment="市场最低价"
)
market_max_price: Mapped[Decimal] = mapped_column(
DECIMAL(12, 2),
nullable=False,
comment="市场最高价"
)
market_avg_price: Mapped[Decimal] = mapped_column(
DECIMAL(12, 2),
nullable=False,
comment="市场均价"
)
market_median_price: Mapped[Decimal] = mapped_column(
DECIMAL(12, 2),
nullable=False,
comment="市场中位价"
)
suggested_range_min: Mapped[Decimal] = mapped_column(
DECIMAL(12, 2),
nullable=False,
comment="建议区间下限"
)
suggested_range_max: Mapped[Decimal] = mapped_column(
DECIMAL(12, 2),
nullable=False,
comment="建议区间上限"
)
# 关系
project: Mapped["Project"] = relationship(
"Project"
)

View File

@@ -0,0 +1,57 @@
"""耗材模型
管理耗材基础信息:名称、单位、单价、供应商等
"""
from typing import Optional
from sqlalchemy import String, Boolean, DECIMAL
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BaseModel
class Material(BaseModel):
"""耗材表"""
__tablename__ = "materials"
material_code: Mapped[str] = mapped_column(
String(50),
unique=True,
nullable=False,
index=True,
comment="耗材编码"
)
material_name: Mapped[str] = mapped_column(
String(100),
nullable=False,
comment="耗材名称"
)
unit: Mapped[str] = mapped_column(
String(20),
nullable=False,
comment="单位(支/ml/个)"
)
unit_price: Mapped[float] = mapped_column(
DECIMAL(12, 2),
nullable=False,
comment="单价"
)
supplier: Mapped[Optional[str]] = mapped_column(
String(100),
nullable=True,
comment="供应商"
)
material_type: Mapped[str] = mapped_column(
String(20),
nullable=False,
index=True,
comment="类型consumable-耗材, injectable-针剂, product-产品"
)
is_active: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
comment="是否启用"
)

View File

@@ -0,0 +1,81 @@
"""操作日志模型
记录用户操作审计日志
"""
from typing import Optional, Any
from datetime import datetime
from sqlalchemy import BigInteger, String, DateTime, JSON, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class OperationLog(Base):
"""操作日志表"""
__tablename__ = "operation_logs"
id: Mapped[int] = mapped_column(
BigInteger,
primary_key=True,
autoincrement=True,
comment="主键ID"
)
user_id: Mapped[Optional[int]] = mapped_column(
BigInteger,
ForeignKey("users.id"),
nullable=True,
index=True,
comment="用户ID"
)
module: Mapped[str] = mapped_column(
String(50),
nullable=False,
index=True,
comment="模块cost/market/pricing/profit"
)
action: Mapped[str] = mapped_column(
String(50),
nullable=False,
comment="操作create/update/delete/export"
)
target_type: Mapped[str] = mapped_column(
String(50),
nullable=False,
comment="对象类型"
)
target_id: Mapped[Optional[int]] = mapped_column(
BigInteger,
nullable=True,
comment="对象ID"
)
detail: Mapped[Optional[dict]] = mapped_column(
JSON,
nullable=True,
comment="详情"
)
ip_address: Mapped[Optional[str]] = mapped_column(
String(45),
nullable=True,
comment="IP地址"
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=func.now(),
server_default=func.now(),
index=True,
comment="操作时间"
)
# 关系
user: Mapped[Optional["User"]] = relationship(
"User",
back_populates="operation_logs"
)
# 避免循环导入
from app.models.user import User

View File

@@ -0,0 +1,94 @@
"""定价方案模型
管理项目定价方案,支持多种定价策略
"""
from typing import Optional, List, TYPE_CHECKING
from decimal import Decimal
from sqlalchemy import BigInteger, String, Boolean, Text, ForeignKey, DECIMAL
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import BaseModel
if TYPE_CHECKING:
from app.models.project import Project
from app.models.user import User
from app.models.profit_simulation import ProfitSimulation
class PricingPlan(BaseModel):
"""定价方案表"""
__tablename__ = "pricing_plans"
project_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("projects.id"),
nullable=False,
index=True,
comment="项目ID"
)
plan_name: Mapped[str] = mapped_column(
String(100),
nullable=False,
comment="方案名称"
)
strategy_type: Mapped[str] = mapped_column(
String(20),
nullable=False,
index=True,
comment="策略类型traffic-引流款, profit-利润款, premium-高端款"
)
base_cost: Mapped[Decimal] = mapped_column(
DECIMAL(12, 2),
nullable=False,
comment="基础成本"
)
target_margin: Mapped[Decimal] = mapped_column(
DECIMAL(5, 2),
nullable=False,
comment="目标毛利率(%)"
)
suggested_price: Mapped[Decimal] = mapped_column(
DECIMAL(12, 2),
nullable=False,
comment="建议价格"
)
final_price: Mapped[Optional[Decimal]] = mapped_column(
DECIMAL(12, 2),
nullable=True,
comment="最终定价"
)
ai_advice: Mapped[Optional[str]] = mapped_column(
Text,
nullable=True,
comment="AI建议内容"
)
is_active: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
comment="是否启用"
)
created_by: Mapped[Optional[int]] = mapped_column(
BigInteger,
ForeignKey("users.id"),
nullable=True,
comment="创建人ID"
)
# 关系
project: Mapped["Project"] = relationship(
"Project",
back_populates="pricing_plans"
)
creator: Mapped[Optional["User"]] = relationship(
"User",
back_populates="created_pricing_plans"
)
profit_simulations: Mapped[List["ProfitSimulation"]] = relationship(
"ProfitSimulation",
back_populates="pricing_plan",
cascade="all, delete-orphan"
)

View File

@@ -0,0 +1,97 @@
"""利润模拟模型
管理利润模拟测算记录
"""
from typing import Optional, List, TYPE_CHECKING
from decimal import Decimal
from sqlalchemy import BigInteger, String, Integer, ForeignKey, DECIMAL
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import BaseModel
if TYPE_CHECKING:
from app.models.pricing_plan import PricingPlan
from app.models.user import User
from app.models.sensitivity_analysis import SensitivityAnalysis
class ProfitSimulation(BaseModel):
"""利润模拟表"""
__tablename__ = "profit_simulations"
pricing_plan_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("pricing_plans.id"),
nullable=False,
index=True,
comment="定价方案ID"
)
simulation_name: Mapped[str] = mapped_column(
String(100),
nullable=False,
comment="模拟名称"
)
price: Mapped[Decimal] = mapped_column(
DECIMAL(12, 2),
nullable=False,
comment="模拟价格"
)
estimated_volume: Mapped[int] = mapped_column(
Integer,
nullable=False,
comment="预估客量"
)
period_type: Mapped[str] = mapped_column(
String(20),
nullable=False,
comment="周期类型daily-日, weekly-周, monthly-月"
)
estimated_revenue: Mapped[Decimal] = mapped_column(
DECIMAL(14, 2),
nullable=False,
comment="预估收入"
)
estimated_cost: Mapped[Decimal] = mapped_column(
DECIMAL(14, 2),
nullable=False,
comment="预估成本"
)
estimated_profit: Mapped[Decimal] = mapped_column(
DECIMAL(14, 2),
nullable=False,
comment="预估利润"
)
profit_margin: Mapped[Decimal] = mapped_column(
DECIMAL(5, 2),
nullable=False,
comment="利润率(%)"
)
breakeven_volume: Mapped[int] = mapped_column(
Integer,
nullable=False,
comment="盈亏平衡客量"
)
created_by: Mapped[Optional[int]] = mapped_column(
BigInteger,
ForeignKey("users.id"),
nullable=True,
comment="创建人ID"
)
# 关系
pricing_plan: Mapped["PricingPlan"] = relationship(
"PricingPlan",
back_populates="profit_simulations"
)
creator: Mapped[Optional["User"]] = relationship(
"User",
back_populates="created_profit_simulations"
)
sensitivity_analyses: Mapped[List["SensitivityAnalysis"]] = relationship(
"SensitivityAnalysis",
back_populates="simulation",
cascade="all, delete-orphan"
)

View File

@@ -0,0 +1,102 @@
"""服务项目模型
管理医美服务项目基础信息
"""
from typing import Optional, List, TYPE_CHECKING
from sqlalchemy import BigInteger, String, Boolean, Integer, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import BaseModel
if TYPE_CHECKING:
from app.models.category import Category
from app.models.user import User
from app.models.project_cost_item import ProjectCostItem
from app.models.project_labor_cost import ProjectLaborCost
from app.models.project_cost_summary import ProjectCostSummary
from app.models.pricing_plan import PricingPlan
class Project(BaseModel):
"""服务项目表"""
__tablename__ = "projects"
project_code: Mapped[str] = mapped_column(
String(50),
unique=True,
nullable=False,
index=True,
comment="项目编码"
)
project_name: Mapped[str] = mapped_column(
String(100),
nullable=False,
comment="项目名称"
)
category_id: Mapped[Optional[int]] = mapped_column(
BigInteger,
ForeignKey("categories.id"),
nullable=True,
index=True,
comment="项目分类ID"
)
description: Mapped[Optional[str]] = mapped_column(
Text,
nullable=True,
comment="项目描述"
)
duration_minutes: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="操作时长(分钟)"
)
is_active: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
index=True,
comment="是否启用"
)
created_by: Mapped[Optional[int]] = mapped_column(
BigInteger,
ForeignKey("users.id"),
nullable=True,
comment="创建人ID"
)
# 关系
category: Mapped[Optional["Category"]] = relationship(
"Category",
back_populates="projects"
)
creator: Mapped[Optional["User"]] = relationship(
"User",
back_populates="created_projects"
)
# 成本相关关系
cost_items: Mapped[List["ProjectCostItem"]] = relationship(
"ProjectCostItem",
back_populates="project",
cascade="all, delete-orphan"
)
labor_costs: Mapped[List["ProjectLaborCost"]] = relationship(
"ProjectLaborCost",
back_populates="project",
cascade="all, delete-orphan"
)
cost_summary: Mapped[Optional["ProjectCostSummary"]] = relationship(
"ProjectCostSummary",
back_populates="project",
uselist=False,
cascade="all, delete-orphan"
)
pricing_plans: Mapped[List["PricingPlan"]] = relationship(
"PricingPlan",
back_populates="project",
cascade="all, delete-orphan"
)

View File

@@ -0,0 +1,66 @@
"""项目成本明细模型
管理项目的耗材成本和设备折旧成本
"""
from typing import Optional, TYPE_CHECKING
from decimal import Decimal
from sqlalchemy import BigInteger, String, DECIMAL, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import BaseModel
if TYPE_CHECKING:
from app.models.project import Project
class ProjectCostItem(BaseModel):
"""项目成本明细表(耗材/设备)"""
__tablename__ = "project_cost_items"
project_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("projects.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="项目ID"
)
item_type: Mapped[str] = mapped_column(
String(20),
nullable=False,
index=True,
comment="类型material-耗材, equipment-设备"
)
item_id: Mapped[int] = mapped_column(
BigInteger,
nullable=False,
comment="耗材/设备ID"
)
quantity: Mapped[Decimal] = mapped_column(
DECIMAL(10, 4),
nullable=False,
comment="用量"
)
unit_cost: Mapped[Decimal] = mapped_column(
DECIMAL(12, 4),
nullable=False,
comment="单位成本"
)
total_cost: Mapped[Decimal] = mapped_column(
DECIMAL(12, 2),
nullable=False,
comment="总成本 = quantity * unit_cost"
)
remark: Mapped[Optional[str]] = mapped_column(
String(200),
nullable=True,
comment="备注"
)
# 关系
project: Mapped["Project"] = relationship(
"Project",
back_populates="cost_items"
)

View File

@@ -0,0 +1,72 @@
"""项目成本汇总模型
存储项目的成本计算结果
"""
from datetime import datetime
from decimal import Decimal
from typing import TYPE_CHECKING
from sqlalchemy import BigInteger, DateTime, DECIMAL, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import BaseModel
if TYPE_CHECKING:
from app.models.project import Project
class ProjectCostSummary(BaseModel):
"""项目成本汇总表"""
__tablename__ = "project_cost_summaries"
project_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("projects.id", ondelete="CASCADE"),
nullable=False,
unique=True,
index=True,
comment="项目ID"
)
material_cost: Mapped[Decimal] = mapped_column(
DECIMAL(12, 2),
nullable=False,
default=0,
comment="耗材成本"
)
equipment_cost: Mapped[Decimal] = mapped_column(
DECIMAL(12, 2),
nullable=False,
default=0,
comment="设备折旧成本"
)
labor_cost: Mapped[Decimal] = mapped_column(
DECIMAL(12, 2),
nullable=False,
default=0,
comment="人工成本"
)
fixed_cost_allocation: Mapped[Decimal] = mapped_column(
DECIMAL(12, 2),
nullable=False,
default=0,
comment="固定成本分摊"
)
total_cost: Mapped[Decimal] = mapped_column(
DECIMAL(12, 2),
nullable=False,
default=0,
comment="总成本(最低成本线)"
)
calculated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
comment="计算时间"
)
# 关系
project: Mapped["Project"] = relationship(
"Project",
back_populates="cost_summary"
)

View File

@@ -0,0 +1,67 @@
"""项目人工成本模型
管理项目的人工成本配置
"""
from typing import Optional, TYPE_CHECKING
from decimal import Decimal
from sqlalchemy import BigInteger, Integer, String, DECIMAL, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import BaseModel
if TYPE_CHECKING:
from app.models.project import Project
from app.models.staff_level import StaffLevel
class ProjectLaborCost(BaseModel):
"""项目人工成本表"""
__tablename__ = "project_labor_costs"
project_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("projects.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="项目ID"
)
staff_level_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("staff_levels.id"),
nullable=False,
index=True,
comment="人员级别ID"
)
duration_minutes: Mapped[int] = mapped_column(
Integer,
nullable=False,
comment="操作时长(分钟)"
)
hourly_rate: Mapped[Decimal] = mapped_column(
DECIMAL(10, 2),
nullable=False,
comment="时薪(记录时的快照)"
)
labor_cost: Mapped[Decimal] = mapped_column(
DECIMAL(12, 2),
nullable=False,
comment="人工成本 = duration/60 * hourly_rate"
)
remark: Mapped[Optional[str]] = mapped_column(
String(200),
nullable=True,
comment="备注"
)
# 关系
project: Mapped["Project"] = relationship(
"Project",
back_populates="labor_costs"
)
staff_level: Mapped["StaffLevel"] = relationship(
"StaffLevel",
back_populates="project_labor_costs"
)

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