Initial commit: 智能项目定价模型
This commit is contained in:
86
.gitignore
vendored
Normal file
86
.gitignore
vendored
Normal 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
262
DEPLOYMENT_CHECKLIST.md
Normal 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
267
README.md
Normal 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(支持热重载)
|
||||
- 后端 API:http://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 UI:http://localhost:8000/docs
|
||||
- ReDoc:http://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
84
docker-compose.dev.yml
Normal 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
133
docker-compose.yml
Normal 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
488
docs/用户操作手册.md
Normal 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
620
docs/系统管理手册.md
Normal 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)
|
||||
- 后端服务仅内网访问
|
||||
- 数据库端口禁止外部访问
|
||||
- 启用 HTTPS,HTTP 自动重定向
|
||||
|
||||
### 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
46
env.dev.example
Normal 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
57
env.example
Normal 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
224
init.sql
Normal 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
94
nginx.conf
Normal 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
178
scripts/backup.sh
Executable 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
246
scripts/deploy.sh
Executable 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
318
scripts/monitor.sh
Executable 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
235
scripts/setup-ssl.sh
Executable 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
49
前端应用/Dockerfile
Normal 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;"]
|
||||
23
前端应用/Dockerfile.dev
Normal file
23
前端应用/Dockerfile.dev
Normal 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"]
|
||||
73
前端应用/eslint.config.js
Normal file
73
前端应用/eslint.config.js
Normal 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
13
前端应用/index.html
Normal 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
80
前端应用/nginx.conf
Normal 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
43
前端应用/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
前端应用/postcss.config.js
Normal file
6
前端应用/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
16
前端应用/src/App.vue
Normal file
16
前端应用/src/App.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 应用根组件
|
||||
*/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
91
前端应用/src/api/benchmark-prices.ts
Normal file
91
前端应用/src/api/benchmark-prices.ts
Normal 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: '奢华' },
|
||||
]
|
||||
83
前端应用/src/api/categories.ts
Normal file
83
前端应用/src/api/categories.ts
Normal 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}`)
|
||||
},
|
||||
}
|
||||
178
前端应用/src/api/competitors.ts
Normal file
178
前端应用/src/api/competitors.ts
Normal 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: '实地调研' },
|
||||
]
|
||||
121
前端应用/src/api/dashboard.ts
Normal file
121
前端应用/src/api/dashboard.ts
Normal 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 },
|
||||
})
|
||||
},
|
||||
}
|
||||
88
前端应用/src/api/equipments.ts
Normal file
88
前端应用/src/api/equipments.ts
Normal 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}`)
|
||||
},
|
||||
}
|
||||
117
前端应用/src/api/fixed-costs.ts
Normal file
117
前端应用/src/api/fixed-costs.ts
Normal 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: '按时长占比' },
|
||||
]
|
||||
17
前端应用/src/api/index.ts
Normal file
17
前端应用/src/api/index.ts
Normal 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'
|
||||
103
前端应用/src/api/market-analysis.ts
Normal file
103
前端应用/src/api/market-analysis.ts
Normal 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`)
|
||||
},
|
||||
}
|
||||
112
前端应用/src/api/materials.ts
Normal file
112
前端应用/src/api/materials.ts
Normal 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: '产品' },
|
||||
]
|
||||
205
前端应用/src/api/pricing.ts
Normal file
205
前端应用/src/api/pricing.ts
Normal 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}`
|
||||
},
|
||||
}
|
||||
190
前端应用/src/api/profit.ts
Normal file
190
前端应用/src/api/profit.ts
Normal 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`
|
||||
)
|
||||
},
|
||||
}
|
||||
278
前端应用/src/api/projects.ts
Normal file
278
前端应用/src/api/projects.ts
Normal 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
|
||||
147
前端应用/src/api/request.ts
Normal file
147
前端应用/src/api/request.ts
Normal 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
|
||||
75
前端应用/src/api/staff-levels.ts
Normal file
75
前端应用/src/api/staff-levels.ts
Normal 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}`)
|
||||
},
|
||||
}
|
||||
10
前端应用/src/assets/logo.svg
Normal file
10
前端应用/src/assets/logo.svg
Normal 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
33
前端应用/src/main.ts
Normal 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')
|
||||
153
前端应用/src/router/index.ts
Normal file
153
前端应用/src/router/index.ts
Normal 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
|
||||
45
前端应用/src/stores/app.ts
Normal file
45
前端应用/src/stores/app.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
5
前端应用/src/stores/index.ts
Normal file
5
前端应用/src/stores/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Pinia Store 统一导出
|
||||
*/
|
||||
|
||||
export * from './app'
|
||||
73
前端应用/src/styles/index.css
Normal file
73
前端应用/src/styles/index.css
Normal 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;
|
||||
}
|
||||
87
前端应用/src/types/auto-imports.d.ts
vendored
Normal file
87
前端应用/src/types/auto-imports.d.ts
vendored
Normal 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
13
前端应用/src/types/components.d.ts
vendored
Normal 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']
|
||||
}
|
||||
}
|
||||
512
前端应用/src/views/cost/projects/CostDetailDialog.vue
Normal file
512
前端应用/src/views/cost/projects/CostDetailDialog.vue
Normal 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>
|
||||
375
前端应用/src/views/cost/projects/index.vue
Normal file
375
前端应用/src/views/cost/projects/index.vue
Normal 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>
|
||||
608
前端应用/src/views/dashboard/index.vue
Normal file
608
前端应用/src/views/dashboard/index.vue
Normal 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>
|
||||
52
前端应用/src/views/error/404.vue
Normal file
52
前端应用/src/views/error/404.vue
Normal 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>
|
||||
222
前端应用/src/views/layout/MainLayout.vue
Normal file
222
前端应用/src/views/layout/MainLayout.vue
Normal 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>
|
||||
146
前端应用/src/views/layout/components/SideMenu.vue
Normal file
146
前端应用/src/views/layout/components/SideMenu.vue
Normal 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>
|
||||
381
前端应用/src/views/market/analysis/index.vue
Normal file
381
前端应用/src/views/market/analysis/index.vue
Normal 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>
|
||||
350
前端应用/src/views/market/benchmarks/index.vue
Normal file
350
前端应用/src/views/market/benchmarks/index.vue
Normal 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>
|
||||
268
前端应用/src/views/market/competitors/PricesDialog.vue
Normal file
268
前端应用/src/views/market/competitors/PricesDialog.vue
Normal 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>
|
||||
369
前端应用/src/views/market/competitors/index.vue
Normal file
369
前端应用/src/views/market/competitors/index.vue
Normal 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>
|
||||
289
前端应用/src/views/pricing/AIAdviceDialog.vue
Normal file
289
前端应用/src/views/pricing/AIAdviceDialog.vue
Normal 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>
|
||||
211
前端应用/src/views/pricing/PricingDialog.vue
Normal file
211
前端应用/src/views/pricing/PricingDialog.vue
Normal 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>
|
||||
376
前端应用/src/views/pricing/index.vue
Normal file
376
前端应用/src/views/pricing/index.vue
Normal 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>
|
||||
152
前端应用/src/views/profit/DetailDialog.vue
Normal file
152
前端应用/src/views/profit/DetailDialog.vue
Normal 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>
|
||||
279
前端应用/src/views/profit/SensitivityDialog.vue
Normal file
279
前端应用/src/views/profit/SensitivityDialog.vue
Normal 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>
|
||||
264
前端应用/src/views/profit/SimulateDialog.vue
Normal file
264
前端应用/src/views/profit/SimulateDialog.vue
Normal 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>
|
||||
294
前端应用/src/views/profit/index.vue
Normal file
294
前端应用/src/views/profit/index.vue
Normal 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>
|
||||
188
前端应用/src/views/settings/categories/index.vue
Normal file
188
前端应用/src/views/settings/categories/index.vue
Normal 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>
|
||||
311
前端应用/src/views/settings/equipments/index.vue
Normal file
311
前端应用/src/views/settings/equipments/index.vue
Normal 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>
|
||||
351
前端应用/src/views/settings/fixed-costs/index.vue
Normal file
351
前端应用/src/views/settings/fixed-costs/index.vue
Normal 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>
|
||||
315
前端应用/src/views/settings/materials/index.vue
Normal file
315
前端应用/src/views/settings/materials/index.vue
Normal 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>
|
||||
269
前端应用/src/views/settings/staff-levels/index.vue
Normal file
269
前端应用/src/views/settings/staff-levels/index.vue
Normal 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
7
前端应用/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
41
前端应用/tailwind.config.js
Normal file
41
前端应用/tailwind.config.js
Normal 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: [],
|
||||
}
|
||||
34
前端应用/tsconfig.json
Normal file
34
前端应用/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
11
前端应用/tsconfig.node.json
Normal file
11
前端应用/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
98
前端应用/vite.config.ts
Normal file
98
前端应用/vite.config.ts
Normal 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
68
后端服务/Dockerfile
Normal 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"]
|
||||
29
后端服务/Dockerfile.dev
Normal file
29
后端服务/Dockerfile.dev
Normal 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"]
|
||||
90
后端服务/SECURITY_CHECKLIST.md
Normal file
90
后端服务/SECURITY_CHECKLIST.md
Normal 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*
|
||||
3
后端服务/app/__init__.py
Normal file
3
后端服务/app/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""智能项目定价模型 - 后端服务"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
85
后端服务/app/config.py
Normal file
85
后端服务/app/config.py
Normal 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()
|
||||
84
后端服务/app/database.py
Normal file
84
后端服务/app/database.py
Normal 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
127
后端服务/app/main.py
Normal 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
|
||||
}
|
||||
}
|
||||
20
后端服务/app/middleware/__init__.py
Normal file
20
后端服务/app/middleware/__init__.py
Normal 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",
|
||||
]
|
||||
121
后端服务/app/middleware/cache.py
Normal file
121
后端服务/app/middleware/cache.py
Normal 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
|
||||
50
后端服务/app/middleware/performance.py
Normal file
50
后端服务/app/middleware/performance.py
Normal 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
|
||||
267
后端服务/app/middleware/security.py
Normal file
267
后端服务/app/middleware/security.py
Normal 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("&", "&")
|
||||
value = value.replace("<", "<")
|
||||
value = value.replace(">", ">")
|
||||
value = value.replace('"', """)
|
||||
value = value.replace("'", "'")
|
||||
|
||||
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}")
|
||||
44
后端服务/app/models/__init__.py
Normal file
44
后端服务/app/models/__init__.py
Normal 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",
|
||||
]
|
||||
51
后端服务/app/models/base.py
Normal file
51
后端服务/app/models/base.py
Normal 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"
|
||||
)
|
||||
72
后端服务/app/models/benchmark_price.py
Normal file
72
后端服务/app/models/benchmark_price.py
Normal 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"
|
||||
)
|
||||
60
后端服务/app/models/category.py
Normal file
60
后端服务/app/models/category.py
Normal 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
|
||||
69
后端服务/app/models/competitor.py
Normal file
69
后端服务/app/models/competitor.py
Normal 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"
|
||||
)
|
||||
83
后端服务/app/models/competitor_price.py
Normal file
83
后端服务/app/models/competitor_price.py
Normal 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"
|
||||
)
|
||||
75
后端服务/app/models/equipment.py
Normal file
75
后端服务/app/models/equipment.py
Normal 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
|
||||
49
后端服务/app/models/fixed_cost.py
Normal file
49
后端服务/app/models/fixed_cost.py
Normal 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="是否启用"
|
||||
)
|
||||
76
后端服务/app/models/market_analysis_result.py
Normal file
76
后端服务/app/models/market_analysis_result.py
Normal 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"
|
||||
)
|
||||
57
后端服务/app/models/material.py
Normal file
57
后端服务/app/models/material.py
Normal 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="是否启用"
|
||||
)
|
||||
81
后端服务/app/models/operation_log.py
Normal file
81
后端服务/app/models/operation_log.py
Normal 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
|
||||
94
后端服务/app/models/pricing_plan.py
Normal file
94
后端服务/app/models/pricing_plan.py
Normal 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"
|
||||
)
|
||||
97
后端服务/app/models/profit_simulation.py
Normal file
97
后端服务/app/models/profit_simulation.py
Normal 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"
|
||||
)
|
||||
102
后端服务/app/models/project.py
Normal file
102
后端服务/app/models/project.py
Normal 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"
|
||||
)
|
||||
66
后端服务/app/models/project_cost_item.py
Normal file
66
后端服务/app/models/project_cost_item.py
Normal 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"
|
||||
)
|
||||
72
后端服务/app/models/project_cost_summary.py
Normal file
72
后端服务/app/models/project_cost_summary.py
Normal 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"
|
||||
)
|
||||
67
后端服务/app/models/project_labor_cost.py
Normal file
67
后端服务/app/models/project_labor_cost.py
Normal 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
Reference in New Issue
Block a user