Compare commits
151 Commits
b79a5296c4
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 4afa796ddc | |||
| ab20bc5a1f | |||
| 1ec20ee53e | |||
| c9df6066f5 | |||
| 804812dd7e | |||
| 4139b54a57 | |||
| 2187be7aeb | |||
| 9b5805447e | |||
| 80d2716b7c | |||
| c7bb49776c | |||
| 687d7cc131 | |||
| 4bfe2900fb | |||
| 6b43b77c3b | |||
| 29e4e4a804 | |||
| 7f87aa7353 | |||
| ccb436664b | |||
| af3d3bfc89 | |||
| 5b55029576 | |||
| e14308b757 | |||
| ce24937ba0 | |||
| 6fd9cfbff9 | |||
| ee3f76d960 | |||
| a9344eadae | |||
| 392380ab51 | |||
| 7699e88578 | |||
| f89638b3f0 | |||
| 04f701b27c | |||
| 07c6f65378 | |||
| cdbe59e97f | |||
| e7125ff930 | |||
| 72d869bafb | |||
| 3059f0e7e4 | |||
| d6dec7f9af | |||
| ff07e9c470 | |||
| c55ea18aaf | |||
| 32ca029580 | |||
| ae29a17825 | |||
| 1fddc35119 | |||
| 9a39b96316 | |||
| 83c87cad3d | |||
| 9f3067b412 | |||
| 7c75164323 | |||
| 6504a393e3 | |||
| 835e5d38fd | |||
| 03198557f7 | |||
| 98a0d4f7c8 | |||
| 05d33d518f | |||
| 9354752b89 | |||
| 82755e14a1 | |||
| 20f5b01987 | |||
| 78962e45d7 | |||
| fade9e62e2 | |||
| 7b91cb4230 | |||
| 6c76b34d1b | |||
| 1e0f77d4e6 | |||
| ff0a38a369 | |||
| 6d1ccbb246 | |||
| 216406326a | |||
| 33fb6a63c0 | |||
| 637a0949a8 | |||
| aed9182dbe | |||
| 24fb5f8f71 | |||
| bc6c7076fe | |||
| e06574df38 | |||
| 10835ef15c | |||
| 3ce77ed606 | |||
| 9eae965858 | |||
| 87876f11fd | |||
| 19aa9153ff | |||
| 17a0218b32 | |||
| 13f8b9c512 | |||
| 03f14df244 | |||
| 3879cdc7ef | |||
| 4453ee2ca0 | |||
| c489a8d6fd | |||
| 5682c12085 | |||
| be26ea5588 | |||
| 750ce8d8eb | |||
| efc200b6de | |||
| c38a9e74ad | |||
| e7c3081070 | |||
| 7dcb22f573 | |||
| a63bd6874c | |||
| dcb0b826de | |||
| e6237590c0 | |||
| f9eed82afb | |||
| f4847bc748 | |||
| faeaf7606b | |||
| 4debecd523 | |||
| 82bc0d9e41 | |||
| 7fd0f9b706 | |||
| 709685057b | |||
| eb05a0420e | |||
| 56adc625c0 | |||
| b6105295bb | |||
| 2633513ce3 | |||
| d74af74534 | |||
| 3786d82a27 | |||
| e17536b3e4 | |||
| 531df448c1 | |||
| e8b92a6f99 | |||
| deeafe239f | |||
| 336b60b3af | |||
| cd47d1aed5 | |||
| 3285e22142 | |||
| b343970ecc | |||
| 0e25201ede | |||
| 2bb85bf45c | |||
| 015af18d3c | |||
| 7e31606b45 | |||
| 04aaff8851 | |||
| 447932a8e6 | |||
| e99d0aec8a | |||
| a4904973bf | |||
| 576715f990 | |||
| df5b661d5f | |||
| a580eefd14 | |||
| 989c41da8c | |||
| e69d962521 | |||
| b88b29b745 | |||
| c71a2ac88e | |||
| 861a4cecff | |||
| c4e8a3b790 | |||
| 327637f94c | |||
| ab3384d6a5 | |||
| 313b25c631 | |||
| 9d3a8ae57e | |||
| 19ebb85209 | |||
| 7264184315 | |||
| 1d2dacc3ab | |||
| da1f24c710 | |||
| 5006642354 | |||
| ad0c894f93 | |||
| eae7fdafb8 | |||
| 36ba9338f1 | |||
| 72fb7524f3 | |||
| e71fa6e3ed | |||
| c2a62c29cd | |||
| b3d355179f | |||
| 8867e2f5fe | |||
| 4ae8c69193 | |||
| ce94c6b2ba | |||
| 5b38246801 | |||
| e681beba24 | |||
| 5b50e7ec27 | |||
| 24c7aff093 | |||
| d07311d5a5 | |||
| dd69e902ec | |||
| 1d0f412e4a | |||
| a8ed71fbff | |||
| 305ee5df09 |
@@ -195,8 +195,15 @@ Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Deployment docs (contain credentials, not for git)
|
||||
*部署交接文档.md
|
||||
*部署文档.md
|
||||
|
||||
# Claude
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
|
||||
.cc-connect/
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
stages:
|
||||
- verify
|
||||
- build
|
||||
|
||||
backend_verify:
|
||||
stage: verify
|
||||
image: python:3.12-slim
|
||||
script:
|
||||
- pip install --no-cache-dir -r backend/requirements.txt pytest ruff bandit pip-audit
|
||||
- PYTHONPATH=backend python -m compileall -q backend/app
|
||||
- ruff check backend/app backend/tests
|
||||
- PYTHONPATH=backend pytest -q backend/tests --junitxml=backend-test-report.xml
|
||||
- bandit -q -r backend/app -lll
|
||||
- pip-audit -r backend/requirements.txt
|
||||
artifacts:
|
||||
reports:
|
||||
junit: backend-test-report.xml
|
||||
paths:
|
||||
- backend-test-report.xml
|
||||
expire_in: 180 days
|
||||
|
||||
frontend_build:
|
||||
stage: build
|
||||
image: node:20-alpine
|
||||
script:
|
||||
- cd frontend
|
||||
- npm ci
|
||||
- npm run build
|
||||
artifacts:
|
||||
name: "qiji-frontend-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- frontend/dist/
|
||||
expire_in: 30 days
|
||||
|
||||
backend_sbom:
|
||||
stage: verify
|
||||
image: python:3.12-slim
|
||||
script:
|
||||
- pip install --no-cache-dir cyclonedx-bom
|
||||
- python -m cyclonedx_py requirements backend/requirements.txt --output-reproducible --of JSON -o sbom-backend.cdx.json
|
||||
artifacts:
|
||||
name: "qiji-sbom-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- sbom-backend.cdx.json
|
||||
expire_in: 180 days
|
||||
|
||||
filesystem_security:
|
||||
stage: verify
|
||||
image:
|
||||
name: aquasec/trivy:0.67.2
|
||||
entrypoint: [""]
|
||||
script:
|
||||
- trivy fs --exit-code 1 --ignore-unfixed --severity HIGH,CRITICAL --scanners vuln,misconfig,secret .
|
||||
@@ -0,0 +1,275 @@
|
||||
# CLAUDE.md — 企迹 (qiji) 政企周报管理系统
|
||||
|
||||
## 项目概述
|
||||
|
||||
面向中国电信政企客户经理团队,替代 Excel 的周报管理系统。覆盖每日拜访记录、下周工作计划、小微业务商机跟单、要客拜访计划四大模块。支持 PC 端(支局长/分管领导汇总查看)和移动端(客户经理外勤填报 + 企微入口)。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层 | 技术 |
|
||||
|---|------|
|
||||
| 后端 | FastAPI + SQLAlchemy 2.0 (async) + Alembic |
|
||||
| 数据库 | PostgreSQL (已有,复用) |
|
||||
| 文件存储 | MinIO (已有,复用) |
|
||||
| 认证 | Casdoor OIDC (已有) + 企业微信 OAuth |
|
||||
| 前端 | Vue 3 + Vite + TypeScript + Element Plus |
|
||||
| 状态管理 | Pinia |
|
||||
| 定时任务 | APScheduler |
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
qiji/
|
||||
├── docker-compose.yml # PostgreSQL + MinIO + backend
|
||||
├── 政企周报管理系统-设计文档.md # 完整设计文档
|
||||
├── backend/
|
||||
│ ├── app/
|
||||
│ │ ├── main.py # FastAPI 入口 + lifespan + 路由挂载
|
||||
│ │ ├── config.py # pydantic-settings 配置
|
||||
│ │ ├── database.py # async engine + session
|
||||
│ │ ├── models/ # 8 张表 (见下方数据模型)
|
||||
│ │ ├── schemas/ # Pydantic 请求/响应 schema
|
||||
│ │ ├── api/ # REST 路由 (12 个模块)
|
||||
│ │ ├── services/ # 业务逻辑层
|
||||
│ │ ├── middleware/auth.py # JWT 鉴权 + 角色权限
|
||||
│ │ └── utils/security.py # JWT 签发/验证
|
||||
│ ├── alembic/ # 数据库迁移
|
||||
│ └── requirements.txt
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── router/index.ts # 路由 (mobile + desktop 两套布局)
|
||||
│ │ ├── stores/auth.ts # Pinia 认证 store
|
||||
│ │ ├── api/ # 9 个 Axios API 模块
|
||||
│ │ ├── views/
|
||||
│ │ │ ├── mobile/ # 移动端页面 (Home, VisitForm, ...)
|
||||
│ │ │ └── desktop/ # PC 端页面 (Dashboard, WeeklyReport, ...)
|
||||
│ │ └── components/ # MobileLayout + DesktopLayout
|
||||
│ ├── nginx.conf # 生产 nginx 配置
|
||||
│ └── Dockerfile
|
||||
```
|
||||
|
||||
## 数据模型 (10 张 PostgreSQL 表)
|
||||
|
||||
| 表 | 核心字段 |
|
||||
|----|---------|
|
||||
| `users` | id, casdoor_id, name, role(manager/director/leader), wecom_userid |
|
||||
| `customers` | id, name, customer_type(unit/individual), industry, address, in_use_services, monthly_fee, remarks, created_by |
|
||||
| `customer_contacts` | id, customer_id, name, phone, role_desc |
|
||||
| `customer_assignments` | id, customer_id, manager_id, role(primary/assistant), assigned_by |
|
||||
| `visits` | id, customer_id, visit_date, visit_method, time_range, visitor_name, visitor_phone, communication_content, customer_demand, companions(UUID[]), photos(TEXT[]), manager_id, edit_log(JSONB) |
|
||||
| `daily_notes` | id, manager_id, note_date, category, content, time_range, edit_log(JSONB) |
|
||||
| `work_plans` | id, customer_id, plan_content, plan_date, manager_id, status, edit_log(JSONB) |
|
||||
| `mini_business` | id, customer_id, product_type, amount, follow_up_detail, status, manager_id, expected_revenue_date, edit_log(JSONB) |
|
||||
| `key_visits` | id, customer_id, urgency_level, description, progress_status, planned_date, planned_visitor, visit_target, manager_id, edit_log(JSONB) |
|
||||
| `leaves` | id, manager_id, leave_type(年假/事假/病假/调休/其他), start_date, end_date, reason, submitted_by, created_at, updated_at |
|
||||
|
||||
## 当前进度
|
||||
|
||||
### 已完成 ✅
|
||||
|
||||
- [x] **Phase 1: 项目骨架** — 完整目录结构,FastAPI + Vue 3 均可启动/构建
|
||||
- [x] **Phase 2: 数据模型** — 8 个 SQLAlchemy 模型 + Alembic 迁移配置
|
||||
- [x] **Phase 3: 认证系统** — JWT + Casdoor OIDC + 企微静默登录 + 角色中间件
|
||||
- [x] **Phase 4: 客户档案** — CRUD 全链路 API + PC 端管理页 + 归属分配/批量转移
|
||||
- [x] **Phase 5: 拜访记录** — CRUD API + MinIO 预签名直传 + 移动端填报表单 + 移动端首页
|
||||
- [x] **Phase 6: 计划/商机/要客** — 三个模块的 CRUD API + 移动端表单
|
||||
- [x] **Phase 7: 仪表盘与汇总** — 四卡统计 + 填报进度 + 四 Tab 周报 + 按人/客户筛选 + 时间轴
|
||||
- [x] **Phase 8: Excel 导入导出** — 导出四 sheet xlsx + 导入预览→确认→去重
|
||||
- [x] **Phase 9: 企业微信** — 静默登录 + 催办/公告推送 + 每日 18:00 定时检查
|
||||
- [x] **Phase 10: 部署** — Dockerfile × 2 + nginx.conf + docker-compose.yml
|
||||
- [x] **Casdoor 对接** — 已配置 Casdoor OIDC,前后端 Client ID 统一,登录/回调正常
|
||||
- [x] **MinIO 对接** — 已配置预签名上传/下载,bucket 就绪
|
||||
- [x] **PostgreSQL 对接** — 8 张表自动创建,数据持久化正常
|
||||
- [x] **用户管理** — 支局长可在 PC 端「用户管理」页面修改任意用户角色
|
||||
- [x] **客户档案增强** — 详情含客户经理、点击名称查看、备注字段、收支费用(金额+单位)、联系人管理(新增/删除)、多选批量转移
|
||||
- [x] **客户导入导出** — 导出含全部字段 + 模板下载 + Excel 批量导入(自动去重匹配经理)
|
||||
- [x] **路由修复** — Hash 改 HTML5 History 模式,Casdoor 回调正常
|
||||
- [x] **网络部署** — 后端监听 0.0.0.0:8002,前端监听 0.0.0.0:5173,防火墙已开放
|
||||
- [x] **今日纪要** — 9 张表之 daily_notes,6 种分类 + 彩色标签 + 时间选择器
|
||||
- [x] **客户经理 PC 端工作台** — 「我的数据」五模块 CRUD + 状态快速切换
|
||||
- [x] **拜访人字段** — visits 表新增 visitor_name/visitor_phone
|
||||
- [x] **时间选择器** — 全部时间范围改为 el-time-picker (is-range)
|
||||
- [x] **时区统一** — 后端 today_cst() 统一用 Asia/Shanghai,前端本地格式
|
||||
- [x] **UI 设计系统** — CSS 变量 + 编辑风 (ink/gold/paper) + 侧边栏折叠 + SVG 图标
|
||||
- [x] **分页筛选** — 客户列表分页(25/50/100) + 行业/业务/经理/联系人/地址搜索
|
||||
- [x] **权限细化** — 分管领导只读客户档案,经理可编辑自己客户但不可改经理/删除
|
||||
- [x] **拜访方式/紧急度** — 统一 chip 按钮风格,各色区分
|
||||
- [x] **计划拜访人多选** — 支持从系统人员多选 + 手动输入
|
||||
- [x] **旧周报导入模板** — 四 Sheet Excel 模板下载 + 示例数据
|
||||
- [x] **数据库迁移** — lifespan 自动 ALTER TABLE 加列 (remarks/visitor_name/visitor_phone)
|
||||
- [x] **历史周报** — 周选择器翻看往周数据,历史归档只读,导出支持历史周
|
||||
- [x] **图片预览** — 全屏大图查看(移动端+PC端统一),点击遮罩关闭
|
||||
- [x] **PC端照片管理** — 编辑时可上传新照片、删除已有照片
|
||||
- [x] **客户导入更新** — 重名客户自动更新信息而非跳过,显示逐条操作明细
|
||||
- [x] **导入跳过原因** — 旧周报导入+客户导入均显示跳过/更新明细
|
||||
- [x] **用户去重合并** — 4组重复用户合并,数据完整迁移
|
||||
- [x] **填报进度权限** — 经理只看到自己,支局长/领导看全员
|
||||
- [x] **客户经理列** — 暖灰色hash标签,每人唯一颜色
|
||||
- [x] **列表序号** — 客户管理+用户管理添加序号列
|
||||
- [x] **客户导入修复** — 修复 import_customers 500 错误(errors 变量未初始化)
|
||||
- [x] **图片预览增强** — 适应页面/缩放/拖拽平移 + 底部工具栏(PC端+移动端统一 ImagePreview 组件)
|
||||
- [x] **信息架构重组** — 周报精简为拜访+纪要两个Tab;工作计划/商机/要客独立为侧边栏「工作」分组下的独立页面;仪表盘四卡可点击跳转
|
||||
- [x] **侧边栏分组** — 侧边栏分为「汇总」「工作」「管理」三个分组,JetBrains Mono 标签
|
||||
- [x] **变更追踪 (edit_log)** — 5 张表新增 JSONB edit_log 列;POST 初始化创建记录;PUT 自动对比新旧值追加 diff;编辑弹窗底部展示变更时间轴(EditLogPanel.vue);表格被修改过的记录显示 🕐 时钟图标
|
||||
- [x] **导入模板增强** — 客户导入失败原因明细显示
|
||||
- [x] **操作精简** — 工作计划/商机/要客页面移除冗余"编辑"按钮(点击客户名已可编辑)
|
||||
- [x] **客户亮灯表** — 四色覆盖矩阵(●实心圆已拜访/◐半圆临期/○空心圆未拜访/◌虚线未分配),按客户经理折叠卡片流,覆盖率进度条,红灯客户连续未拜访月份追踪,未分配客户专区,支持历史月份翻看。权限:经理只看自己,支局长/领导看全员
|
||||
- [x] **AI 周报摘要** — 接入 DeepSeek V4 (OpenAI 兼容 `/v1/chat/completions`),一键生成拜访概况/客户需求/覆盖分析/下周建议四段式周报摘要,注入亮灯表覆盖数据增强分析,支局长/分管领导专用,Markdown 渲染展示,一键复制(HTTP 环境自动降级 textarea),支持配置任意 OpenAI 兼容模型
|
||||
- [x] **亮灯表→周报联动** — 点击亮灯表客户卡片自动跳转周报并按客户筛选,周报筛选栏新增客户下拉
|
||||
- [x] **复制功能修复** — HTTP 内网环境下 Clipboard API 不可用,降级为 textarea + execCommand
|
||||
- [x] **工作页客户经理列+汇总栏** — 工作计划/商机跟单/要客拜访三个页面新增"客户经理"列(哈希色标签)+ 页面顶部汇总栏(按经理分组统计条数)
|
||||
- [x] **云端部署** — FRP 隧道 `qiji-backend` (本机 8002→远程 18061),前端 Docker 容器 `qiji-frontend` (远程 18063),OpenResty 反代 `qj.dhdx.fun`,生产模式运行(去掉 --reload),SECRET_KEY 已加固,CORS 已更新
|
||||
- [x] **请假管理** — `leaves` 表 + 完整 CRUD API + 填报进度免考核(on_leave 第四态) + 催办豁免 + AI 摘要注入 + PC 端独立管理页 + 移动端列表/表单
|
||||
- [x] **商机跟单优化** — 新增跟进记录子表 `mini_business_logs`,时间线展示+跟进方式chip。拜访创建时自动联动关联商机追加跟进。状态流转优化(默认筛选跟进中、已流失置底自动清空列收)
|
||||
- [x] **个人用户** — `customers.customer_type` 列区分 unit/individual。单位客户和个人用户双Tab分离管理。个人用户Tab内嵌4Tab聚合视图(拜访/计划/商机/要客),显示所有个人用户的关联数据。个人用户通过业务模块快速新建产生,客户搜索下拉中置顶
|
||||
- [x] **移动端导航重构** — 底部TabBar从3项扩展为4项(首页/工作/拜访/纪要)。新增工作中心页面(6个模块卡片入口)。新增5个历史列表页(拜访/纪要/计划/商机/要客),支持搜索+分页。3个表单支持新增+编辑双模式
|
||||
- [x] **我的数据页面优化** — 日期筛选改为灵活daterange选择器,支持搜索+分页(25/50/100条每页)。每个Tab独立维护分页状态。日期选择器+搜索框移至标题行
|
||||
|
||||
### 待完善
|
||||
|
||||
- [ ] 实际对接企业微信(需填写 WECOM_CORP_ID/AGENT_ID/SECRET 等)
|
||||
- [ ] 前端移动端/桌面端自动适配(目前路由分两套,user-agent 判断待完善)
|
||||
- [ ] 客户查重前端集成(API 已有 `/customers/check-duplicate/{name}`)
|
||||
- [ ] 缩略图生成策略(MinIO 端配置)
|
||||
- [ ] 企微聊天侧边栏(设计文档标注为"后续扩展,首期不做")
|
||||
|
||||
## 验证状态
|
||||
|
||||
| 检查项 | 结果 |
|
||||
|--------|------|
|
||||
| `import app.main` | ✅ 成功 |
|
||||
| `vite build` | ✅ 构建成功,输出到 `dist/` |
|
||||
| 后端运行 | ✅ `http://10.10.10.14:8002`,生产模式 `http://localhost:8002/health` |
|
||||
| 前端运行 | ✅ `http://10.10.10.14:5173` (dev),`https://qj.dhdx.fun` (生产) |
|
||||
| PostgreSQL | ✅ `10.10.10.14:5432/qiji` |
|
||||
| MinIO | ✅ `10.10.10.13:17051`,bucket `qiji-photos` |
|
||||
| Casdoor | ✅ `10.10.10.14:18000`,登录/回调正常 |
|
||||
| FRP 隧道 | ✅ `qiji-backend` 运行中 (8002→18061) |
|
||||
| 前端容器 | ✅ `qiji-frontend` 运行中 (127.0.0.1:18063→80,仅本地) |
|
||||
| 企微推送 | ⚠️ 待填写有效 Token/AESKey 后测试 |
|
||||
| `docker-compose up` | ⚠️ 需要本地 PostgreSQL + MinIO 实例 |
|
||||
|
||||
## 部署架构
|
||||
|
||||
```
|
||||
用户 → https://qj.dhdx.fun → 远程服务器(175.178.19.237)
|
||||
│
|
||||
OpenResty (80/443)
|
||||
│
|
||||
┌──────────────┴──────────────┐
|
||||
▼ ▼
|
||||
前端容器 (127.0.0.1) FRP 隧道
|
||||
qiji-frontend:18063→80 18061
|
||||
│ │
|
||||
│ frps→frpc
|
||||
│ │
|
||||
│ 本机后端 :8002
|
||||
```
|
||||
|
||||
| 资源 | 位置 | 端口 |
|
||||
|------|------|------|
|
||||
| 后端 (uvicorn) | 本机 | 8002 |
|
||||
| FRP 隧道 `qiji-backend` | 本机→远程 | 8002→18061 |
|
||||
| 前端 Docker 容器 | 远程 | 127.0.0.1:18063→80(仅本地,不暴露公网) |
|
||||
| OpenResty 反代 | 远程 | `/`→18063, `/api/`→18061 |
|
||||
| frpc 配置 | `/opt/1panel/apps/frpc/frpc/data/frpc.toml` | Docker 容器管理 |
|
||||
| 远程 SSH | `ssh -p 7072 root@175.178.19.237` | |
|
||||
| 前端构建部署 | `docker build -t qiji-frontend:latest .` → `docker save` → `scp -P 7072` → `ssh docker load` | 本机构建,推送远程 |
|
||||
|
||||
## 启动命令
|
||||
|
||||
### 第一步:配置环境变量
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
cd backend
|
||||
cp .env.example .env
|
||||
# 编辑 .env,填入 Casdoor/PostgreSQL/MinIO/企微 的真实连接信息
|
||||
|
||||
# 前端(Casdoor 登录用)
|
||||
cd frontend
|
||||
cat > .env << 'EOF'
|
||||
VITE_CASDOOR_ENDPOINT=http://your-casdoor:18000
|
||||
VITE_CASDOOR_CLIENT_ID=your-client-id
|
||||
EOF
|
||||
```
|
||||
|
||||
配置项说明:
|
||||
|
||||
| 配置块 | 说明 | 必须? |
|
||||
|--------|------|--------|
|
||||
| `DATABASE_URL` | PostgreSQL 连接串,已有基础设施填真实地址 | ✅ 必须 |
|
||||
| `CASDOOR_*` | Casdoor OIDC 认证服务的地址、Client ID/Secret | ✅ 登录必须 |
|
||||
| `MINIO_*` | MinIO 对象存储地址和密钥 | ✅ 照片上传必须 |
|
||||
| `WECOM_*` | 企微自建应用的 Corp ID / Agent ID / Secret | ⚠️ 企微功能需要 |
|
||||
| `AI_API_URL` | OpenAI 兼容 LLM API 地址 (如 `https://api.openai.com/v1/chat/completions`) | ⚠️ AI 摘要需要 |
|
||||
| `AI_API_KEY` | LLM API 密钥 | ⚠️ AI 摘要需要 |
|
||||
| `AI_MODEL` | 模型名称 (默认 `gpt-4o`,也支持 `deepseek-chat` 等) | ⚠️ AI 摘要需要 |
|
||||
| `SECRET_KEY` | JWT 签发密钥,生产环境务必修改 | ✅ 必须 |
|
||||
| `CORS_ORIGINS` | 前端地址白名单 | ✅ 必须 |
|
||||
| `VITE_CASDOOR_*` | 前端登录跳转 Casdoor 所需,必须与后端一致 | ✅ 必须 |
|
||||
|
||||
### 第二步:启动 PostgreSQL + MinIO
|
||||
|
||||
如果已有可用的 PostgreSQL 和 MinIO 实例,跳过此步。否则用 docker-compose 启动:
|
||||
|
||||
```bash
|
||||
# 在项目根目录执行
|
||||
docker-compose up -d postgres minio
|
||||
```
|
||||
|
||||
### 第三步:启动后端
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uvicorn app.main:app --reload --port 8002 --host 0.0.0.0
|
||||
# 首次启动会自动创建数据库表(lifespan 中执行 Base.metadata.create_all)
|
||||
# API 文档 → http://localhost:8002/docs
|
||||
```
|
||||
|
||||
### 第四步:启动前端
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev -- --host 0.0.0.0
|
||||
# 页面 → http://localhost:5173
|
||||
# vite proxy 自动将 /api/* 请求转发到 localhost:8002
|
||||
```
|
||||
|
||||
### 一键启动(生产模式)
|
||||
|
||||
```bash
|
||||
# 项目根目录
|
||||
docker-compose up -d
|
||||
# 后端 :8000 + PostgreSQL :5432 + MinIO :9000 + :9001(console)
|
||||
# 前端需单独部署到 nginx,见 frontend/Dockerfile + nginx.conf
|
||||
```
|
||||
|
||||
## 关键设计决策
|
||||
|
||||
1. **双布局**: `/m/*` 移动端(底部 TabBar),`/*` PC 端(侧边栏菜单)
|
||||
2. **照片上传**: 前端从 `/api/upload/presigned-url` 拿 PUT URL → 直传 MinIO → 表单只传 object key
|
||||
3. **同访人员**: 选同访人后,后端自动创建一条内容为空的状态副本
|
||||
4. **权限**: JWT payload 含 `role`,`RoleChecker` 做接口级鉴权,前端 Pinia store 做 UI 级控制
|
||||
5. **企微静默登录**: wecom code → userid → 查 users 表 → 找到签 JWT / 未找到引导 Casdoor 绑定
|
||||
6. **前后端分离开发**: `vite.config.ts` proxy 转发 `/api` → `localhost:8002`
|
||||
7. **填报进度判断**: 今日有拜访记录 OR 今日有纪要 → 已填报(双维度考核)
|
||||
8. **拜访方式颜色**: 上门(墨绿)/电话(灰蓝)/微信(翠绿)/出差(金色),卡片+表单统一
|
||||
9. **纪要分类**: 行政事务/合同整理/发票处理/内部会议/培训学习/其他,单字徽章+彩色标签
|
||||
10. **时间范围**: 所有表单统一用 el-time-picker is-range,存储为 HH:mm-HH:mm 格式
|
||||
11. **列表操作**: 点击客户名→详情弹窗含编辑/删除按钮,移除表格操作栏
|
||||
12. **侧边栏**: ink 深色渐变 + SVG 内联图标 + 可折叠(64px) + 金色装饰线
|
||||
13. **客户经理列**: hash 配色标签,每人唯一颜色(8 色暖灰调)
|
||||
14. **侧边栏分组**: 汇总(仪表盘/周报)、工作(工作计划/商机跟单/要客拜访)、管理(客户/用户/系统设置)三层分组,JetBrains Mono 标签
|
||||
15. **周报精简**: 只保留每日拜访记录+今日纪要两个 Tab,工作计划/商机/要客独立为侧边栏项目
|
||||
16. **独立工作页**: 工作计划/商机/要客各自拥有独立页面 + CRUD 弹窗 + 状态快速切换
|
||||
17. **变更追踪 (edit_log)**: 5 张业务表均含 JSONB edit_log 列;POST 自动插入创建记录;PUT 自动 diff 新旧值追加变更条目(编辑人+时间+字段级 diff+原因);编辑弹窗底部折叠时间轴面板;表格 🕐 图标标记被修改过的记录
|
||||
18. **图片预览**: ImagePreview.vue 统一组件,支持适应页面/缩放(0.25x~5x)/拖拽平移/滚轮缩放/键盘快捷键,PC+移动端一致体验
|
||||
19. **仪表盘跳转**: 四张统计卡片可点击跳转到对应的周报/工作计划/商机/要客页面
|
||||
20. **客户亮灯表**: 几何圆形图标(●实心圆已拜访/◐半圆临期/○空心圆未拜访/◌虚线未分配),按客户经理折叠卡片流(220px),覆盖率进度条,红灯卡片呼吸动画,未分配客户专区(虚线边框),默认折叠→点击展开明细,支持历史月份翻看。权限:`get_light_board(user_id, role)` — director/leader 看全员+未分配客户,manager 只看自己
|
||||
21. **AI 周报摘要**: 接入 DeepSeek V4 (模型 `deepseek-v4-flash`,端点 `/v1/chat/completions`,OpenAI 兼容协议)。`build_summary_prompt()` 注入拜访总览+各经理明细+客户需求汇总+纪要分类+亮灯表覆盖数据,生成四段式 Markdown。WeeklyReport.vue 集成:按钮(闪电图标)→loading 脉冲→渲染→一键复制(安全上下文检测降级)。权限:director/leader 可见按钮
|
||||
22. **云端部署**: FRP 隧道 (8002→18061) + Docker 前端容器 (18063) + OpenResty 反代。frpc 由 1Panel Docker 容器管理,配置在 `/opt/1panel/apps/frpc/frpc/data/frpc.toml`。前端生产构建用 `.env.production` 指向 Casdoor 公网地址。nginx.conf 只保留 SPA fallback,API 路由由 OpenResty 边缘处理。SSH 端口 7072,前端容器仅绑定 127.0.0.1:18063(不直接暴露公网)
|
||||
23. **请假管理**: `leaves` 表存储请假记录,按天粒度(start_date~end_date),五种类型(年假/事假/病假/调休/其他),提交即生效无审批。权限:经理看自己、支局长/领导看全员。填报进度新增 `on_leave` 第四态(蓝「假」印章),催办自动过滤请假人员,AI 摘要注入本周请假数据。PC 端侧边栏「工作」分组下独立页面,移动端首页快捷入口 + 列表页 + 表单页
|
||||
24. **个人用户**: `customers` 表 `customer_type` 列区分 unit/individual。单位客户管理页支持双Tab(单位客户/个人用户)切换。个人用户Tab 内嵌4Tab聚合视图(拜访/计划/商机/要客),显示所有个人用户的关联数据。个人用户通过业务模块快速新建产生,客户搜索下拉中置顶显示。单位客户列表默认只显示单位类型
|
||||
25. **移动端信息架构**: 底部TabBar 4项(首页/工作/拜访/纪要)。工作中心页面6个模块卡片(历史拜访/历史纪要/工作计划/商机跟单/要客拜访/请假管理)。各模块列表页支持搜索+25条/页分页。表单复用新增/编辑双模式(通过 route.params.id 判断)
|
||||
26. **我的数据优化**: 日期范围用 el-date-picker daterange 灵活选择。搜索框+日期选择器移至标题行同行。每个Tab独立维护分页状态(25/50/100)。5个后端列表API均支持 search 参数模糊匹配
|
||||
@@ -21,7 +21,7 @@
|
||||
|---|------|
|
||||
| 后端 | FastAPI + SQLAlchemy 2.0 (async) + asyncpg |
|
||||
| 数据库 | PostgreSQL |
|
||||
| 文件存储 | MinIO (预签名直传) |
|
||||
| 文件存储 | MinIO(后端受控图片上传) |
|
||||
| 认证 | Casdoor OIDC + JWT |
|
||||
| 前端 | Vue 3 + Vite + TypeScript + Element Plus |
|
||||
| 状态管理 | Pinia |
|
||||
@@ -98,14 +98,17 @@ npm run dev -- --host 0.0.0.0
|
||||
# 页面 → http://localhost:5173
|
||||
```
|
||||
|
||||
### 一键部署
|
||||
### 本地集成环境
|
||||
|
||||
```bash
|
||||
# 先在 .env 中设置 POSTGRES_USER、POSTGRES_PASSWORD、MINIO_ROOT_USER、
|
||||
# MINIO_ROOT_PASSWORD、SECRET_KEY 和固定版本的 MINIO_IMAGE
|
||||
docker-compose up -d
|
||||
# 后端 :8000 + PostgreSQL :5432 + MinIO :9000/:9001
|
||||
# 前端需单独部署到 nginx,见 frontend/Dockerfile
|
||||
# 服务仅绑定到 127.0.0.1;前端需单独部署到 nginx,见 frontend/Dockerfile
|
||||
```
|
||||
|
||||
生产环境必须使用批准的部署流水线、密钥管理、HTTPS 反向代理和上线审批流程;不得直接将本 Compose 文件作为生产部署方案。
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 配置项 | 说明 | 必须 |
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info
|
||||
.eggs
|
||||
dist
|
||||
build
|
||||
.venv
|
||||
venv
|
||||
env
|
||||
.pytest_cache
|
||||
tests
|
||||
htmlcov
|
||||
.coverage
|
||||
node_modules
|
||||
.vite
|
||||
.git
|
||||
.gitignore
|
||||
.vscode
|
||||
.idea
|
||||
.env
|
||||
.env.*
|
||||
.ruff_cache
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
+19
-6
@@ -5,11 +5,13 @@
|
||||
|
||||
# ── 应用 ──
|
||||
APP_NAME=企迹-政企周报管理系统
|
||||
DEBUG=true
|
||||
SECRET_KEY=change-me-to-a-random-string-in-production
|
||||
ENVIRONMENT=development
|
||||
DEBUG=false
|
||||
# Use a random value of at least 32 characters. Never use a default in production.
|
||||
SECRET_KEY=replace-with-a-random-secret-of-at-least-32-characters
|
||||
|
||||
# ── PostgreSQL (已有基础设施,填写实际连接信息) ──
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/qiji
|
||||
DATABASE_URL=postgresql+asyncpg://qiji_app:replace-with-a-strong-password@localhost:5432/qiji
|
||||
|
||||
# ── JWT ──
|
||||
JWT_ALGORITHM=HS256
|
||||
@@ -25,10 +27,11 @@ CASDOOR_APPLICATION=qiji-weekly-report
|
||||
|
||||
# ── MinIO (已有,填写实际部署地址和密钥) ──
|
||||
MINIO_ENDPOINT=localhost:9000
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
MINIO_ACCESS_KEY=qiji-app
|
||||
MINIO_SECRET_KEY=replace-with-a-strong-minio-secret
|
||||
MINIO_BUCKET=qiji-photos
|
||||
MINIO_SECURE=false
|
||||
# Set false only when connecting to a local development MinIO instance over the internal Docker network.
|
||||
MINIO_SECURE=true
|
||||
|
||||
# ── 企业微信 (需在企微管理后台创建自建应用后获取) ──
|
||||
WECOM_CORP_ID=your-corp-id
|
||||
@@ -36,6 +39,16 @@ WECOM_AGENT_ID=your-agent-id
|
||||
WECOM_SECRET=your-app-secret
|
||||
WECOM_TOKEN=your-token
|
||||
WECOM_ENCODING_AES_KEY=your-encoding-aes-key
|
||||
# 企微 API 代理(解决 IP 白名单问题),默认直连 https://qyapi.weixin.qq.com
|
||||
WECOM_API_BASE=https://qyapi.weixin.qq.com
|
||||
|
||||
# ── AI / LLM (OpenAI 兼容接口,用于周报摘要) ──
|
||||
# 支持 OpenAI / DeepSeek / 通义千问 / 本地 Ollama 等
|
||||
# DeepSeek: AI_API_URL=https://api.deepseek.com/v1/chat/completions AI_MODEL=deepseek-v4-flash
|
||||
AI_API_URL=https://api.openai.com/v1/chat/completions
|
||||
AI_API_KEY=sk-your-api-key
|
||||
AI_MODEL=gpt-4o
|
||||
AI_MAX_TOKENS=2000
|
||||
|
||||
# ── CORS (前端地址) ──
|
||||
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
||||
|
||||
+4
-1
@@ -1,5 +1,8 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
@@ -9,4 +12,4 @@ COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["sh", "-c", "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8000"]
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import asyncio
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
from pathlib import Path
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
from alembic import context
|
||||
|
||||
# Ensure Alembic works both in the container and when invoked from a virtualenv
|
||||
# on a server where the console-script directory would otherwise be sys.path[0].
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from app.database import Base
|
||||
from app.models import * # noqa: import all models
|
||||
from app.config import settings
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Establish the tracked baseline schema and replace application-startup DDL.
|
||||
|
||||
Revision ID: 20260728_01
|
||||
Revises:
|
||||
Create Date: 2026-07-28
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.database import Base
|
||||
import app.models # noqa: F401 # Register every model with Base.metadata.
|
||||
|
||||
|
||||
revision: str = "20260728_01"
|
||||
down_revision: str | None = None
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create the current schema and upgrade tables created by older app versions."""
|
||||
bind = op.get_bind()
|
||||
Base.metadata.create_all(bind=bind, checkfirst=True)
|
||||
|
||||
# These columns were historically added in application startup code. Keep this
|
||||
# compatibility path so existing deployments can adopt Alembic safely.
|
||||
statements = (
|
||||
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS remarks TEXT DEFAULT ''",
|
||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_name VARCHAR(50) DEFAULT ''",
|
||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_phone VARCHAR(20) DEFAULT ''",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS require_report BOOLEAN DEFAULT TRUE",
|
||||
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_date DATE",
|
||||
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_manager_id UUID",
|
||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS companion_names TEXT[] DEFAULT '{}'",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS color VARCHAR(7)",
|
||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'",
|
||||
"ALTER TABLE daily_notes ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'",
|
||||
"ALTER TABLE work_plans ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'",
|
||||
"ALTER TABLE mini_business ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'",
|
||||
"ALTER TABLE key_visits ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_users_wecom_userid "
|
||||
"ON users (wecom_userid) WHERE wecom_userid IS NOT NULL",
|
||||
)
|
||||
for statement in statements:
|
||||
op.execute(statement)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove only objects introduced by the tracked security hardening revision.
|
||||
|
||||
The historical base tables pre-date Alembic and may contain production data, so
|
||||
they are deliberately not dropped by a downgrade operation.
|
||||
"""
|
||||
op.execute("DROP INDEX IF EXISTS uq_users_wecom_userid")
|
||||
op.execute("DROP TABLE IF EXISTS wecom_bind_tokens")
|
||||
@@ -0,0 +1 @@
|
||||
"""Alembic migration revisions for the Qiji backend."""
|
||||
@@ -0,0 +1,74 @@
|
||||
"""AI summary endpoints — LLM-powered weekly report narrative with caching."""
|
||||
|
||||
import uuid
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.services.ai_summary import generate_summary, get_cached_summary, delete_cached_summary
|
||||
|
||||
router = APIRouter(prefix="/ai", tags=["AI Summary"])
|
||||
|
||||
|
||||
def _check_role(role: str):
|
||||
if role not in ("director", "leader"):
|
||||
raise HTTPException(status_code=403, detail="仅限支局长和分管领导使用")
|
||||
|
||||
|
||||
def _parse_ref(reference_date: Optional[str]) -> date | None:
|
||||
return date.fromisoformat(reference_date) if reference_date else None
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
async def get_summary(
|
||||
reference_date: Optional[str] = Query(None),
|
||||
period: str = Query("week", regex="^(week|month)$"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get cached AI summary for this week. Returns null if none exists."""
|
||||
_check_role(current_user["role"])
|
||||
cached = await get_cached_summary(
|
||||
db, uuid.UUID(current_user["user_id"]), _parse_ref(reference_date), period,
|
||||
)
|
||||
return cached or {"summary": None}
|
||||
|
||||
|
||||
@router.post("/summary")
|
||||
async def create_summary(
|
||||
reference_date: Optional[str] = Query(None),
|
||||
period: str = Query("week", regex="^(week|month)$"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Generate (or regenerate) AI summary. Saves to DB automatically."""
|
||||
_check_role(current_user["role"])
|
||||
try:
|
||||
return await generate_summary(
|
||||
db=db,
|
||||
user_id=uuid.UUID(current_user["user_id"]),
|
||||
role=current_user["role"],
|
||||
reference_date=_parse_ref(reference_date),
|
||||
period=period,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"AI 摘要生成失败:{str(e)}")
|
||||
|
||||
|
||||
@router.delete("/summary")
|
||||
async def delete_summary(
|
||||
reference_date: Optional[str] = Query(None),
|
||||
period: str = Query("week", regex="^(week|month)$"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete cached summary so it can be regenerated fresh."""
|
||||
_check_role(current_user["role"])
|
||||
deleted = await delete_cached_summary(
|
||||
db, uuid.UUID(current_user["user_id"]), _parse_ref(reference_date), period,
|
||||
)
|
||||
return {"deleted": deleted}
|
||||
@@ -0,0 +1,80 @@
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from app.database import get_db
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
router = APIRouter(prefix="/audit-logs", tags=["AuditLogs"])
|
||||
|
||||
ENTITY_LABELS: dict[str, str] = {
|
||||
"customer": "客户",
|
||||
"visit": "拜访记录",
|
||||
"daily_note": "今日纪要",
|
||||
"work_plan": "工作计划",
|
||||
"mini_business": "小微商机",
|
||||
"key_visit": "要客拜访",
|
||||
"user": "用户",
|
||||
"leave": "请假",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_audit_logs(
|
||||
entity_type: Optional[str] = Query(None),
|
||||
action: Optional[str] = Query(None),
|
||||
operator_id: Optional[str] = Query(None),
|
||||
date_from: Optional[str] = Query(None),
|
||||
date_to: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=10, le=200),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List audit logs. Only director/leader can access."""
|
||||
if current_user["role"] not in ("director", "leader"):
|
||||
return {"items": [], "total": 0}
|
||||
|
||||
base = select(AuditLog)
|
||||
count_q = select(func.count(AuditLog.id))
|
||||
|
||||
if entity_type:
|
||||
base = base.where(AuditLog.entity_type == entity_type)
|
||||
count_q = count_q.where(AuditLog.entity_type == entity_type)
|
||||
if action:
|
||||
base = base.where(AuditLog.action == action)
|
||||
count_q = count_q.where(AuditLog.action == action)
|
||||
if operator_id:
|
||||
base = base.where(AuditLog.operator_id == uuid.UUID(operator_id))
|
||||
count_q = count_q.where(AuditLog.operator_id == uuid.UUID(operator_id))
|
||||
if date_from:
|
||||
base = base.where(AuditLog.created_at >= date_from)
|
||||
count_q = count_q.where(AuditLog.created_at >= date_from)
|
||||
if date_to:
|
||||
base = base.where(AuditLog.created_at < date_to + "T23:59:59")
|
||||
count_q = count_q.where(AuditLog.created_at < date_to + "T23:59:59")
|
||||
|
||||
total = (await db.execute(count_q)).scalar() or 0
|
||||
|
||||
query = base.order_by(AuditLog.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
rows = (await db.execute(query)).scalars().all()
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
items.append({
|
||||
"id": str(r.id),
|
||||
"entity_type": r.entity_type,
|
||||
"entity_type_label": ENTITY_LABELS.get(r.entity_type, r.entity_type),
|
||||
"entity_id": str(r.entity_id),
|
||||
"entity_name": r.entity_name,
|
||||
"action": r.action,
|
||||
"operator_id": str(r.operator_id),
|
||||
"operator_name": r.operator_name,
|
||||
"summary": r.summary or "",
|
||||
"details": r.details,
|
||||
"created_at": str(r.created_at),
|
||||
})
|
||||
|
||||
return {"items": items, "total": total}
|
||||
+9
-27
@@ -34,6 +34,7 @@ async def casdoor_login(req: CasdoorLoginRequest, db: AsyncSession = Depends(get
|
||||
user_id=str(user.id),
|
||||
name=user.name,
|
||||
role=user.role,
|
||||
theme=user.theme or "editorial",
|
||||
)
|
||||
|
||||
|
||||
@@ -56,38 +57,19 @@ async def wecom_login(req: WecomLoginRequest, db: AsyncSession = Depends(get_db)
|
||||
user_id=str(user.id),
|
||||
name=user.name,
|
||||
role=user.role,
|
||||
theme=user.theme or "editorial",
|
||||
)
|
||||
|
||||
# Not bound yet — return a redirect URL to Casdoor for binding
|
||||
casdoor_auth_url = (
|
||||
f"{settings.CASDOOR_ENDPOINT}/login/oauth/authorize"
|
||||
f"?client_id={settings.CASDOOR_CLIENT_ID}"
|
||||
f"&response_type=code"
|
||||
f"&redirect_uri={settings.CORS_ORIGINS[0]}/bind-wecom"
|
||||
f"&scope=openid+profile"
|
||||
f"&state={wecom_userid}"
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="企业微信账号尚未绑定。请在企业微信内使用一次性绑定链接完成绑定。",
|
||||
)
|
||||
return {"need_bind": True, "casdoor_url": casdoor_auth_url, "wecom_userid": wecom_userid}
|
||||
|
||||
|
||||
@router.post("/bind-wecom")
|
||||
async def bind_wecom(req: WecomBindRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Bind Casdoor account with WeChat Work userid after OIDC redirect."""
|
||||
userinfo = await exchange_casdoor_code(req.casdoor_code)
|
||||
if not userinfo:
|
||||
raise HTTPException(status_code=400, detail="Failed to exchange casdoor code")
|
||||
|
||||
casdoor_id = userinfo.get("sub") or userinfo.get("id")
|
||||
wecom_userid = req.wecom_userid or userinfo.get("state", "")
|
||||
|
||||
user = await bind_wecom_user(db, casdoor_id, wecom_userid)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
token = build_token_for_user(user)
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
user_id=str(user.id),
|
||||
name=user.name,
|
||||
role=user.role,
|
||||
"""Deprecated unsafe binding flow. Use /wecom/bind-confirm with a one-time token."""
|
||||
raise HTTPException(
|
||||
status_code=410,
|
||||
detail="该绑定接口已停用,请使用企业微信一次性绑定链接。",
|
||||
)
|
||||
|
||||
+256
-21
@@ -1,18 +1,26 @@
|
||||
import io
|
||||
import uuid as uuid_mod
|
||||
from uuid import uuid4
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy import select, or_, update
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel
|
||||
import openpyxl
|
||||
from app.database import get_db
|
||||
from app.middleware.auth import get_current_user, require_director, require_any_role
|
||||
from app.models.customer import Customer
|
||||
from app.models.customer_contact import CustomerContact
|
||||
from app.models.customer_assignment import CustomerAssignment
|
||||
from app.models.visit import Visit
|
||||
from app.models.work_plan import WorkPlan
|
||||
from app.models.mini_business import MiniBusiness
|
||||
from app.models.key_visit import KeyVisit
|
||||
from app.models.daily_note import DailyNote
|
||||
from app.models.user import User
|
||||
from app.utils.audit import log_audit
|
||||
from app.schemas.customer import (
|
||||
CustomerCreate, CustomerUpdate, CustomerOut, CustomerListOut, CustomerListResponse,
|
||||
ContactCreate, ContactOut, AssignmentCreate, AssignmentOut, BatchAssignRequest,
|
||||
@@ -43,25 +51,19 @@ async def list_customers(
|
||||
industry: Optional[str] = Query(None),
|
||||
service: Optional[str] = Query(None),
|
||||
manager_id: Optional[str] = Query(None),
|
||||
customer_type: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(25, ge=1, le=100),
|
||||
page_size: int = Query(100, ge=1, le=1000),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List customers with filters and pagination. Managers only see their assigned."""
|
||||
"""List customers with filters and pagination. All roles see all customers."""
|
||||
from sqlalchemy import func
|
||||
|
||||
base_query = select(Customer)
|
||||
if current_user["role"] == "manager":
|
||||
base_query = base_query.where(or_(
|
||||
Customer.id.in_(
|
||||
select(CustomerAssignment.customer_id).where(
|
||||
CustomerAssignment.manager_id == current_user["user_id"]
|
||||
)
|
||||
),
|
||||
Customer.created_by == current_user["user_id"],
|
||||
))
|
||||
|
||||
if customer_type:
|
||||
base_query = base_query.where(Customer.customer_type == customer_type)
|
||||
if industry:
|
||||
base_query = base_query.where(Customer.industry.ilike(f"%{industry}%"))
|
||||
if service:
|
||||
@@ -91,7 +93,7 @@ async def list_customers(
|
||||
|
||||
# Paginate
|
||||
offset = (page - 1) * page_size
|
||||
query = base_query.order_by(Customer.name).offset(offset).limit(page_size)
|
||||
query = base_query.order_by(Customer.customer_type, Customer.name).offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
items = result.scalars().all()
|
||||
|
||||
@@ -101,10 +103,14 @@ async def list_customers(
|
||||
select(CustomerAssignment.customer_id, User.name)
|
||||
.join(User, CustomerAssignment.manager_id == User.id)
|
||||
.where(CustomerAssignment.customer_id.in_([c.id for c in items]), CustomerAssignment.role == "primary")
|
||||
.order_by(CustomerAssignment.customer_id)
|
||||
)
|
||||
mgr_map = {str(cid): name for cid, name in mgr_result.all()}
|
||||
mgr_map: dict[str, list] = {}
|
||||
for cid, name in mgr_result.all():
|
||||
mgr_map.setdefault(str(cid), []).append(name)
|
||||
for item in items:
|
||||
item.primary_manager_name = mgr_map.get(str(item.id), None)
|
||||
names = mgr_map.get(str(item.id), [])
|
||||
item.primary_manager_name = "、".join(names) if names else None
|
||||
|
||||
return CustomerListResponse(
|
||||
items=items,
|
||||
@@ -401,6 +407,10 @@ async def create_customer(
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
# Audit log
|
||||
await log_audit(db, "customer", customer.id, customer.name, "create", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(
|
||||
select(Customer).where(Customer.id == customer.id).options(selectinload(Customer.contacts))
|
||||
)
|
||||
@@ -425,6 +435,28 @@ async def update_customer(
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
assignee_id = update_data.pop("assignee_id", None) # Handle separately
|
||||
|
||||
# ── Name collision detection (for merge) ──
|
||||
new_name = update_data.get("name")
|
||||
if new_name and new_name.strip() != customer.name:
|
||||
existing = await db.execute(
|
||||
select(Customer).where(
|
||||
Customer.name == new_name.strip(),
|
||||
Customer.id != customer.id,
|
||||
)
|
||||
)
|
||||
target = existing.scalar_one_or_none()
|
||||
if target:
|
||||
# Return 409 with merge preview — frontend should show merge dialog
|
||||
preview = await _build_merge_preview(db, str(customer.id), str(target.id))
|
||||
raise HTTPException(status_code=409, detail={
|
||||
"message": "名称冲突 — 同名客户已存在",
|
||||
"source_id": str(customer.id),
|
||||
"source_name": customer.name,
|
||||
"target_id": str(target.id),
|
||||
"target_name": target.name,
|
||||
"preview": preview,
|
||||
})
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(customer, key, value)
|
||||
|
||||
@@ -432,17 +464,16 @@ async def update_customer(
|
||||
if assignee_id:
|
||||
if current_user["role"] != "director":
|
||||
raise HTTPException(status_code=403, detail="Only director can change manager assignment")
|
||||
import uuid as uuid_mod
|
||||
assign_result = await db.execute(
|
||||
select(CustomerAssignment).where(
|
||||
CustomerAssignment.customer_id == customer.id,
|
||||
CustomerAssignment.role == "primary",
|
||||
)
|
||||
)
|
||||
existing = assign_result.scalar_one_or_none()
|
||||
if existing:
|
||||
existing.manager_id = assignee_id
|
||||
existing.assigned_by = uuid_mod.UUID(current_user["user_id"])
|
||||
existing_a = assign_result.scalar_one_or_none()
|
||||
if existing_a:
|
||||
existing_a.manager_id = assignee_id
|
||||
existing_a.assigned_by = uuid_mod.UUID(current_user["user_id"])
|
||||
else:
|
||||
db.add(CustomerAssignment(
|
||||
customer_id=customer.id, manager_id=assignee_id,
|
||||
@@ -451,6 +482,10 @@ async def update_customer(
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Audit log
|
||||
await log_audit(db, "customer", uuid_mod.UUID(customer_id), customer.name, "update", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(
|
||||
select(Customer).where(Customer.id == customer.id).options(selectinload(Customer.contacts))
|
||||
)
|
||||
@@ -467,12 +502,182 @@ async def delete_customer(
|
||||
customer = result.scalar_one_or_none()
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
cust_name = customer.name
|
||||
await db.delete(customer)
|
||||
await db.commit()
|
||||
# Audit log
|
||||
await log_audit(db, "customer", uuid_mod.UUID(customer_id), cust_name, "delete", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return {"detail": "deleted"}
|
||||
|
||||
|
||||
# ── Contacts ──
|
||||
# ── Customer Merge ──
|
||||
|
||||
|
||||
class MergePreviewOut(BaseModel):
|
||||
source_id: str
|
||||
source_name: str
|
||||
target_id: str
|
||||
target_name: str
|
||||
visits: int = 0
|
||||
work_plans: int = 0
|
||||
mini_business: int = 0
|
||||
key_visits: int = 0
|
||||
contacts: int = 0
|
||||
assignments: int = 0
|
||||
note: str = ""
|
||||
|
||||
|
||||
class MergeRequest(BaseModel):
|
||||
target_id: str
|
||||
|
||||
|
||||
async def _build_merge_preview(db: AsyncSession, source_id: str, target_id: str) -> dict:
|
||||
"""Count all records that would be migrated from source to target."""
|
||||
sid = uuid_mod.UUID(source_id)
|
||||
tid = uuid_mod.UUID(target_id)
|
||||
|
||||
visits = (await db.execute(
|
||||
select(select(Visit).where(Visit.customer_id == sid).subquery()).with_only_columns(
|
||||
__import__('sqlalchemy').func.count()
|
||||
)
|
||||
)).scalar() or 0
|
||||
# Simpler: count directly
|
||||
from sqlalchemy import func as sa_func
|
||||
visits = (await db.execute(select(sa_func.count()).select_from(Visit).where(Visit.customer_id == sid))).scalar() or 0
|
||||
plans = (await db.execute(select(sa_func.count()).select_from(WorkPlan).where(WorkPlan.customer_id == sid))).scalar() or 0
|
||||
mini = (await db.execute(select(sa_func.count()).select_from(MiniBusiness).where(MiniBusiness.customer_id == sid))).scalar() or 0
|
||||
kv = (await db.execute(select(sa_func.count()).select_from(KeyVisit).where(KeyVisit.customer_id == sid))).scalar() or 0
|
||||
contacts = (await db.execute(select(sa_func.count()).select_from(CustomerContact).where(CustomerContact.customer_id == sid))).scalar() or 0
|
||||
assignments = (await db.execute(select(sa_func.count()).select_from(CustomerAssignment).where(CustomerAssignment.customer_id == sid))).scalar() or 0
|
||||
|
||||
# Check for assignment conflicts
|
||||
target_assigns = await db.execute(
|
||||
select(CustomerAssignment.manager_id).where(CustomerAssignment.customer_id == tid)
|
||||
)
|
||||
target_managers = {str(row[0]) for row in target_assigns.all()}
|
||||
source_assigns = await db.execute(
|
||||
select(CustomerAssignment).where(CustomerAssignment.customer_id == sid)
|
||||
)
|
||||
conflict_note = ""
|
||||
for sa in source_assigns.scalars().all():
|
||||
if str(sa.manager_id) in target_managers:
|
||||
conflict_note = f"目标客户已有相同经理的分配,将去重"
|
||||
|
||||
return {
|
||||
"source_id": source_id,
|
||||
"source_name": "",
|
||||
"target_id": target_id,
|
||||
"target_name": "",
|
||||
"visits": visits,
|
||||
"work_plans": plans,
|
||||
"mini_business": mini,
|
||||
"key_visits": kv,
|
||||
"contacts": contacts,
|
||||
"assignments": assignments,
|
||||
"note": conflict_note,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{customer_id}/merge-preview")
|
||||
async def get_merge_preview(
|
||||
customer_id: str,
|
||||
target_id: str = Query(...),
|
||||
current_user: dict = Depends(require_director),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Preview merge: show what data would be moved from source to target."""
|
||||
source = (await db.execute(select(Customer).where(Customer.id == customer_id))).scalar_one_or_none()
|
||||
target = (await db.execute(select(Customer).where(Customer.id == target_id))).scalar_one_or_none()
|
||||
if not source or not target:
|
||||
raise HTTPException(status_code=404, detail="客户不存在")
|
||||
if customer_id == target_id:
|
||||
raise HTTPException(status_code=400, detail="不能合并到自身")
|
||||
|
||||
preview = await _build_merge_preview(db, customer_id, target_id)
|
||||
preview["source_name"] = source.name
|
||||
preview["target_name"] = target.name
|
||||
return preview
|
||||
|
||||
|
||||
@router.post("/{customer_id}/merge")
|
||||
async def execute_merge(
|
||||
customer_id: str,
|
||||
body: MergeRequest,
|
||||
current_user: dict = Depends(require_director),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Merge source customer into target. All related data is migrated, source is deleted."""
|
||||
source_id = uuid_mod.UUID(customer_id)
|
||||
target_id = uuid_mod.UUID(body.target_id)
|
||||
|
||||
if customer_id == body.target_id:
|
||||
raise HTTPException(status_code=400, detail="不能合并到自身")
|
||||
|
||||
source = (await db.execute(select(Customer).where(Customer.id == source_id))).scalar_one_or_none()
|
||||
target = (await db.execute(select(Customer).where(Customer.id == target_id))).scalar_one_or_none()
|
||||
if not source or not target:
|
||||
raise HTTPException(status_code=404, detail="客户不存在")
|
||||
|
||||
# Build preview for response
|
||||
preview = await _build_merge_preview(db, customer_id, body.target_id)
|
||||
|
||||
# ── Transaction: migrate all FK references ──
|
||||
for model, fk_col in [
|
||||
(Visit, Visit.customer_id),
|
||||
(WorkPlan, WorkPlan.customer_id),
|
||||
(MiniBusiness, MiniBusiness.customer_id),
|
||||
(KeyVisit, KeyVisit.customer_id),
|
||||
]:
|
||||
await db.execute(
|
||||
update(model).where(fk_col == source_id).values(customer_id=target_id)
|
||||
)
|
||||
|
||||
# Contacts: migrate, skip duplicates
|
||||
source_contacts = (await db.execute(
|
||||
select(CustomerContact).where(CustomerContact.customer_id == source_id)
|
||||
)).scalars().all()
|
||||
target_contacts = (await db.execute(
|
||||
select(CustomerContact).where(CustomerContact.customer_id == target_id)
|
||||
)).scalars().all()
|
||||
existing_contact_keys = {(c.name, c.phone) for c in target_contacts}
|
||||
for c in source_contacts:
|
||||
if (c.name, c.phone) in existing_contact_keys:
|
||||
await db.delete(c) # Skip duplicate
|
||||
else:
|
||||
c.customer_id = target_id
|
||||
db.add(c)
|
||||
|
||||
# Assignments: migrate, skip same (manager_id, role) pairs
|
||||
target_assigns = (await db.execute(
|
||||
select(CustomerAssignment).where(CustomerAssignment.customer_id == target_id)
|
||||
)).scalars().all()
|
||||
existing_assign_keys = {(str(a.manager_id), a.role) for a in target_assigns}
|
||||
source_assigns = (await db.execute(
|
||||
select(CustomerAssignment).where(CustomerAssignment.customer_id == source_id)
|
||||
)).scalars().all()
|
||||
for a in source_assigns:
|
||||
if (str(a.manager_id), a.role) in existing_assign_keys:
|
||||
await db.delete(a) # Skip duplicate
|
||||
else:
|
||||
a.customer_id = target_id
|
||||
db.add(a)
|
||||
|
||||
# Update target's last_visit_date to the max of both
|
||||
if source.last_visit_date:
|
||||
if not target.last_visit_date or source.last_visit_date > target.last_visit_date:
|
||||
target.last_visit_date = source.last_visit_date
|
||||
target.last_visit_manager_id = source.last_visit_manager_id
|
||||
|
||||
# Delete source customer
|
||||
source_name = source.name
|
||||
target_name = target.name
|
||||
await db.delete(source)
|
||||
await db.commit()
|
||||
|
||||
preview["result"] = f"已将「{source_name}」合并到「{target_name}」"
|
||||
return preview
|
||||
|
||||
|
||||
@router.post("/{customer_id}/contacts", response_model=ContactOut)
|
||||
async def add_contact(
|
||||
@@ -530,3 +735,33 @@ async def assign_manager(
|
||||
await db.commit()
|
||||
await db.refresh(assignment)
|
||||
return assignment
|
||||
|
||||
|
||||
@router.get("/{customer_id}/overdue-plans")
|
||||
async def get_overdue_plans(
|
||||
customer_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return the latest overdue (plan_date < today) and still '计划中' plan for a customer.
|
||||
Used by the frontend to pre-fill visit forms with pending plan content."""
|
||||
from app.utils.timezone import today_cst
|
||||
today = today_cst()
|
||||
result = await db.execute(
|
||||
select(WorkPlan).where(
|
||||
WorkPlan.customer_id == uuid_mod.UUID(customer_id),
|
||||
WorkPlan.status == "计划中",
|
||||
WorkPlan.plan_date < today,
|
||||
).order_by(WorkPlan.plan_date.desc()).limit(1)
|
||||
)
|
||||
plan = result.scalar_one_or_none()
|
||||
if not plan:
|
||||
return {"plan": None}
|
||||
return {
|
||||
"plan": {
|
||||
"id": str(plan.id),
|
||||
"plan_content": plan.plan_content,
|
||||
"plan_date": str(plan.plan_date),
|
||||
"manager_id": str(plan.manager_id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.models.user import User
|
||||
from app.schemas.daily_note import DailyNoteCreate, DailyNoteUpdate, DailyNoteOut
|
||||
from app.utils.timezone import today_cst, parse_date
|
||||
from app.utils.edit_log import compute_diff, append_entry, init_entry
|
||||
from app.utils.audit import log_audit
|
||||
|
||||
router = APIRouter(prefix="/daily-notes", tags=["DailyNotes"])
|
||||
|
||||
@@ -30,6 +31,7 @@ async def _enrich(note: DailyNote, db: AsyncSession) -> dict:
|
||||
async def list_notes(
|
||||
date_from: Optional[str] = Query(None),
|
||||
date_to: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -40,6 +42,8 @@ async def list_notes(
|
||||
query = query.where(DailyNote.note_date >= parse_date(date_from))
|
||||
if date_to:
|
||||
query = query.where(DailyNote.note_date <= parse_date(date_to))
|
||||
if search:
|
||||
query = query.where(DailyNote.content.ilike(f"%{search}%"))
|
||||
query = query.order_by(DailyNote.note_date.desc(), DailyNote.created_at.desc()).limit(100)
|
||||
result = await db.execute(query)
|
||||
return [await _enrich(n, db) for n in result.scalars().all()]
|
||||
@@ -80,7 +84,7 @@ async def create_note(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
note = DailyNote(
|
||||
manager_id=uuid.UUID(current_user["user_id"]),
|
||||
manager_id=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else uuid.UUID(current_user["user_id"]),
|
||||
note_date=parse_date(data.note_date),
|
||||
category=data.category,
|
||||
content=data.content,
|
||||
@@ -90,6 +94,9 @@ async def create_note(
|
||||
db.add(note)
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
# Audit log
|
||||
await log_audit(db, "daily_note", note.id, f"{note.note_date} 纪要", "create", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return await _enrich(note, db)
|
||||
|
||||
|
||||
@@ -108,6 +115,8 @@ async def update_note(
|
||||
|
||||
old_snapshot = {"note_date": str(note.note_date), "category": note.category, "content": note.content, "time_range": note.time_range}
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if current_user["role"] not in ("director", "leader"):
|
||||
update_data.pop("manager_id", None)
|
||||
if "note_date" in update_data and update_data["note_date"]:
|
||||
update_data["note_date"] = parse_date(update_data["note_date"])
|
||||
for k, v in update_data.items():
|
||||
@@ -118,6 +127,9 @@ async def update_note(
|
||||
append_entry(note, current_user["name"], changes, getattr(data, "edit_reason", None))
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
# Audit log
|
||||
await log_audit(db, "daily_note", note.id, f"{note.note_date} 纪要", "update", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return await _enrich(note, db)
|
||||
|
||||
|
||||
@@ -133,6 +145,10 @@ async def delete_note(
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if current_user["role"] == "manager" and str(note.manager_id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
note_date = str(note.note_date)
|
||||
await db.delete(note)
|
||||
await db.commit()
|
||||
# Audit log
|
||||
await log_audit(db, "daily_note", uuid.UUID(note_id), f"{note_date} 纪要", "delete", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return {"detail": "deleted"}
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.services.dashboard import get_dashboard_stats, get_reporting_progress, get_weekly_report
|
||||
from app.services.light_board import get_light_board
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
|
||||
|
||||
@@ -18,7 +19,7 @@ async def dashboard_stats(
|
||||
):
|
||||
"""Get dashboard card statistics. Pass reference_date (YYYY-MM-DD) for historical weeks."""
|
||||
ref = date.fromisoformat(reference_date) if reference_date else None
|
||||
stats = await get_dashboard_stats(db, ref)
|
||||
stats = await get_dashboard_stats(db, ref, current_user["user_id"], current_user["role"])
|
||||
return stats
|
||||
|
||||
|
||||
@@ -51,3 +52,14 @@ async def weekly_report(
|
||||
filter_customer_id=uuid.UUID(customer_id) if customer_id else None,
|
||||
reference_date=ref,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/light-board")
|
||||
async def light_board(
|
||||
reference_date: Optional[str] = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get customer visit coverage matrix. Managers see only themselves."""
|
||||
ref = date.fromisoformat(reference_date) if reference_date else None
|
||||
return await get_light_board(db, ref, uuid.UUID(current_user["user_id"]), current_user["role"])
|
||||
|
||||
@@ -12,7 +12,7 @@ router = APIRouter(prefix="/import", tags=["Import"])
|
||||
|
||||
@router.get("/template")
|
||||
async def download_weekly_report_template():
|
||||
"""Download a 4-sheet weekly report import template."""
|
||||
"""Download a 5-sheet weekly report import template."""
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font
|
||||
|
||||
@@ -22,9 +22,9 @@ async def download_weekly_report_template():
|
||||
# Sheet 1: 每日拜访记录
|
||||
ws1 = wb.active
|
||||
ws1.title = "每日拜访记录"
|
||||
ws1.append(["客户单位", "拜访日期", "拜访方式", "时间范围", "拜访人姓名", "拜访人电话", "沟通内容", "客户需求", "同访人员", "客户经理"])
|
||||
ws1.append(["客户单位", "拜访日期", "拜访方式", "时间范围", "拜访人姓名", "拜访人电话", "沟通内容", "客户需求", "相关人员", "客户经理"])
|
||||
for c in ws1[1]: c.font = header_font
|
||||
ws1.append(["XX科技有限公司", "2026-06-23", "上门", "9:00-10:00", "韦柳柏", "13800000000", "沟通了解云桌面需求", "希望扩容", "", "韦矍森"])
|
||||
ws1.append(["XX科技有限公司", "2026-06-23", "上门", "9:00-10:00", "韦柳柏", "13800000000", "沟通了解云桌面需求", "希望扩容", "韦柳柏, 张科长", "韦矍森"])
|
||||
ws1.column_dimensions['A'].width = 20; ws1.column_dimensions['E'].width = 30; ws1.column_dimensions['F'].width = 20
|
||||
|
||||
# Sheet 2: 下周工作计划
|
||||
@@ -48,6 +48,13 @@ async def download_weekly_report_template():
|
||||
ws4.append(["XX科技有限公司", "重要", "拜访技术负责人确认方案", "未开始", "2026-07-01", "韦柳柏", "王局长", "韦矍森"])
|
||||
ws4.column_dimensions['A'].width = 20; ws4.column_dimensions['C'].width = 30
|
||||
|
||||
# Sheet 5: 今日纪要
|
||||
ws5 = wb.create_sheet("今日纪要")
|
||||
ws5.append(["日期", "分类", "内容", "时间范围", "填报人"])
|
||||
for c in ws5[1]: c.font = header_font
|
||||
ws5.append(["2026-06-23", "内部会议", "参加云桌面项目方案讨论会", "14:00-16:00", "韦矍森"])
|
||||
ws5.column_dimensions['A'].width = 15; ws5.column_dimensions['B'].width = 12; ws5.column_dimensions['C'].width = 40
|
||||
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
|
||||
@@ -2,7 +2,7 @@ import uuid
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, or_
|
||||
from app.database import get_db
|
||||
from app.middleware.auth import get_current_user, require_any_role
|
||||
from app.models.key_visit import KeyVisit
|
||||
@@ -10,6 +10,7 @@ from app.models.customer import Customer
|
||||
from app.models.user import User
|
||||
from app.schemas.key_visit import KeyVisitCreate, KeyVisitUpdate, KeyVisitOut
|
||||
from app.utils.edit_log import compute_diff, append_entry, init_entry
|
||||
from app.utils.audit import log_audit
|
||||
|
||||
router = APIRouter(prefix="/key-visits", tags=["KeyVisits"])
|
||||
|
||||
@@ -36,6 +37,8 @@ async def _enrich(k: KeyVisit, db: AsyncSession) -> dict:
|
||||
@router.get("/")
|
||||
async def list_key_visits(
|
||||
customer_id: Optional[str] = Query(None),
|
||||
customer_type: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -44,11 +47,32 @@ async def list_key_visits(
|
||||
query = query.where(KeyVisit.manager_id == uuid.UUID(current_user["user_id"]))
|
||||
if customer_id:
|
||||
query = query.where(KeyVisit.customer_id == uuid.UUID(customer_id))
|
||||
if customer_type:
|
||||
query = query.join(Customer, KeyVisit.customer_id == Customer.id).where(Customer.customer_type == customer_type)
|
||||
if search:
|
||||
query = query.outerjoin(Customer, KeyVisit.customer_id == Customer.id).where(
|
||||
or_(KeyVisit.description.ilike(f"%{search}%"), KeyVisit.planned_visitor.ilike(f"%{search}%"), Customer.name.ilike(f"%{search}%"))
|
||||
)
|
||||
query = query.order_by(KeyVisit.planned_date.desc()).limit(200)
|
||||
result = await db.execute(query)
|
||||
return [await _enrich(k, db) for k in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/{item_id}")
|
||||
async def get_key_visit(
|
||||
item_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(KeyVisit).where(KeyVisit.id == item_id))
|
||||
k = result.scalar_one_or_none()
|
||||
if not k:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if current_user["role"] == "manager" and str(k.manager_id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
return await _enrich(k, db)
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_key_visit(
|
||||
data: KeyVisitCreate,
|
||||
@@ -63,12 +87,27 @@ async def create_key_visit(
|
||||
planned_date=data.planned_date,
|
||||
planned_visitor=data.planned_visitor,
|
||||
visit_target=data.visit_target,
|
||||
manager_id=uuid.UUID(current_user["user_id"]),
|
||||
manager_id=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else uuid.UUID(current_user["user_id"]),
|
||||
)
|
||||
init_entry(k, current_user["name"])
|
||||
db.add(k)
|
||||
await db.commit()
|
||||
await db.refresh(k)
|
||||
# Audit log
|
||||
cust = await db.execute(select(Customer.name).where(Customer.id == k.customer_id))
|
||||
await log_audit(db, "key_visit", k.id, f"{cust.scalar_one_or_none() or ''} ({k.urgency_level})", "create", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return await _enrich(k, db)
|
||||
|
||||
|
||||
@router.get("/{item_id}")
|
||||
async def get_key_visit(item_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user)):
|
||||
result = await db.execute(select(KeyVisit).where(KeyVisit.id == item_id))
|
||||
k = result.scalar_one_or_none()
|
||||
if not k:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if current_user["role"] == "manager" and str(k.manager_id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
return await _enrich(k, db)
|
||||
|
||||
|
||||
@@ -87,6 +126,10 @@ async def update_key_visit(
|
||||
|
||||
old_snapshot = {"customer_id": str(k.customer_id), "urgency_level": k.urgency_level, "description": k.description, "progress_status": k.progress_status, "planned_date": k.planned_date, "planned_visitor": k.planned_visitor, "visit_target": k.visit_target}
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if current_user["role"] not in ("director", "leader"):
|
||||
update_data.pop("manager_id", None)
|
||||
if "manager_id" in update_data and update_data["manager_id"]:
|
||||
update_data["manager_id"] = uuid.UUID(update_data["manager_id"])
|
||||
for key, v in update_data.items():
|
||||
setattr(k, key, v)
|
||||
new_snapshot = {"customer_id": str(k.customer_id), "urgency_level": k.urgency_level, "description": k.description, "progress_status": k.progress_status, "planned_date": k.planned_date, "planned_visitor": k.planned_visitor, "visit_target": k.visit_target}
|
||||
@@ -95,6 +138,10 @@ async def update_key_visit(
|
||||
append_entry(k, current_user["name"], changes, getattr(data, "edit_reason", None))
|
||||
await db.commit()
|
||||
await db.refresh(k)
|
||||
# Audit log
|
||||
cust = await db.execute(select(Customer.name).where(Customer.id == k.customer_id))
|
||||
await log_audit(db, "key_visit", k.id, f"{cust.scalar_one_or_none() or ''} ({k.urgency_level})", "update", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return await _enrich(k, db)
|
||||
|
||||
|
||||
@@ -110,6 +157,11 @@ async def delete_key_visit(
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if current_user["role"] == "manager" and str(k.manager_id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
cust = await db.execute(select(Customer.name).where(Customer.id == k.customer_id))
|
||||
entity_name = f"{cust.scalar_one_or_none() or ''} ({k.urgency_level})"
|
||||
await db.delete(k)
|
||||
await db.commit()
|
||||
# Audit log
|
||||
await log_audit(db, "key_visit", uuid.UUID(item_id), entity_name, "delete", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return {"detail": "deleted"}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
from app.middleware.auth import get_current_user
|
||||
from app.schemas.leave import LeaveCreate, LeaveUpdate
|
||||
from app.services import leaves as leave_service
|
||||
|
||||
router = APIRouter(prefix="/leaves", tags=["Leaves"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_leaves(
|
||||
manager_id: str | None = Query(None),
|
||||
status: str | None = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(25, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List leave records. Managers see only their own."""
|
||||
return await leave_service.get_leaves(
|
||||
db=db,
|
||||
user_id=current_user["user_id"],
|
||||
role=current_user["role"],
|
||||
manager_id=manager_id,
|
||||
status=status,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_leave(
|
||||
data: LeaveCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Create a leave record. Director can create for anyone; manager only for self."""
|
||||
try:
|
||||
leave = await leave_service.create_leave(
|
||||
db=db,
|
||||
data=data.model_dump(),
|
||||
submitted_by=UUID(current_user["user_id"]),
|
||||
role=current_user["role"],
|
||||
operator_name=current_user["name"],
|
||||
)
|
||||
return {
|
||||
"id": str(leave.id),
|
||||
"manager_id": str(leave.manager_id),
|
||||
"leave_type": leave.leave_type,
|
||||
"start_date": str(leave.start_date),
|
||||
"end_date": str(leave.end_date),
|
||||
"reason": leave.reason,
|
||||
"submitted_by": str(leave.submitted_by),
|
||||
}
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{leave_id}")
|
||||
async def update_leave(
|
||||
leave_id: UUID,
|
||||
data: LeaveUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Update a leave record."""
|
||||
try:
|
||||
leave = await leave_service.update_leave(
|
||||
db=db,
|
||||
leave_id=leave_id,
|
||||
data=data.model_dump(exclude_none=True),
|
||||
user_id=UUID(current_user["user_id"]),
|
||||
role=current_user["role"],
|
||||
operator_name=current_user["name"],
|
||||
)
|
||||
return {
|
||||
"id": str(leave.id),
|
||||
"manager_id": str(leave.manager_id),
|
||||
"leave_type": leave.leave_type,
|
||||
"start_date": str(leave.start_date),
|
||||
"end_date": str(leave.end_date),
|
||||
"reason": leave.reason,
|
||||
}
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404 if "不存在" in str(e) else 400, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{leave_id}")
|
||||
async def delete_leave(
|
||||
leave_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a leave record."""
|
||||
try:
|
||||
ok = await leave_service.delete_leave(
|
||||
db=db,
|
||||
leave_id=leave_id,
|
||||
user_id=UUID(current_user["user_id"]),
|
||||
role=current_user["role"],
|
||||
operator_name=current_user["name"],
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="请假记录不存在")
|
||||
return {"status": "deleted"}
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/overview")
|
||||
async def leave_overview(
|
||||
reference_date: str | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get weekly leave overview for dashboard card."""
|
||||
from datetime import date
|
||||
ref = date.fromisoformat(reference_date) if reference_date else None
|
||||
return await leave_service.get_leave_overview(
|
||||
db=db,
|
||||
user_id=current_user["user_id"],
|
||||
role=current_user["role"],
|
||||
reference_date=ref,
|
||||
)
|
||||
@@ -2,7 +2,7 @@ import uuid
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, or_
|
||||
from app.database import get_db
|
||||
from app.middleware.auth import get_current_user, require_any_role
|
||||
from app.models.mini_business import MiniBusiness
|
||||
@@ -10,6 +10,7 @@ from app.models.customer import Customer
|
||||
from app.models.user import User
|
||||
from app.schemas.mini_business import MiniBusinessCreate, MiniBusinessUpdate, MiniBusinessOut
|
||||
from app.utils.edit_log import compute_diff, append_entry, init_entry
|
||||
from app.utils.audit import log_audit
|
||||
|
||||
router = APIRouter(prefix="/mini-business", tags=["MiniBusiness"])
|
||||
|
||||
@@ -35,6 +36,8 @@ async def _enrich(m: MiniBusiness, db: AsyncSession) -> dict:
|
||||
@router.get("/")
|
||||
async def list_mini_business(
|
||||
customer_id: Optional[str] = Query(None),
|
||||
customer_type: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -43,11 +46,32 @@ async def list_mini_business(
|
||||
query = query.where(MiniBusiness.manager_id == uuid.UUID(current_user["user_id"]))
|
||||
if customer_id:
|
||||
query = query.where(MiniBusiness.customer_id == uuid.UUID(customer_id))
|
||||
if customer_type:
|
||||
query = query.join(Customer, MiniBusiness.customer_id == Customer.id).where(Customer.customer_type == customer_type)
|
||||
if search:
|
||||
query = query.outerjoin(Customer, MiniBusiness.customer_id == Customer.id).where(
|
||||
or_(MiniBusiness.product_type.ilike(f"%{search}%"), MiniBusiness.follow_up_detail.ilike(f"%{search}%"), Customer.name.ilike(f"%{search}%"))
|
||||
)
|
||||
query = query.order_by(MiniBusiness.expected_revenue_date.desc()).limit(200)
|
||||
result = await db.execute(query)
|
||||
return [await _enrich(m, db) for m in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/{item_id}")
|
||||
async def get_mini_business(
|
||||
item_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(MiniBusiness).where(MiniBusiness.id == item_id))
|
||||
m = result.scalar_one_or_none()
|
||||
if not m:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if current_user["role"] == "manager" and str(m.manager_id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
return await _enrich(m, db)
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_mini_business(
|
||||
data: MiniBusinessCreate,
|
||||
@@ -60,13 +84,28 @@ async def create_mini_business(
|
||||
amount=data.amount,
|
||||
follow_up_detail=data.follow_up_detail,
|
||||
status=data.status,
|
||||
manager_id=uuid.UUID(current_user["user_id"]),
|
||||
manager_id=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else uuid.UUID(current_user["user_id"]),
|
||||
expected_revenue_date=data.expected_revenue_date,
|
||||
)
|
||||
init_entry(m, current_user["name"])
|
||||
db.add(m)
|
||||
await db.commit()
|
||||
await db.refresh(m)
|
||||
# Audit log
|
||||
cust = await db.execute(select(Customer.name).where(Customer.id == m.customer_id))
|
||||
await log_audit(db, "mini_business", m.id, f"{cust.scalar_one_or_none() or ''} ({m.product_type})", "create", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return await _enrich(m, db)
|
||||
|
||||
|
||||
@router.get("/{item_id}")
|
||||
async def get_mini_business(item_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user)):
|
||||
result = await db.execute(select(MiniBusiness).where(MiniBusiness.id == item_id))
|
||||
m = result.scalar_one_or_none()
|
||||
if not m:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if current_user["role"] == "manager" and str(m.manager_id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
return await _enrich(m, db)
|
||||
|
||||
|
||||
@@ -85,14 +124,25 @@ async def update_mini_business(
|
||||
|
||||
old_snapshot = {"customer_id": str(m.customer_id), "product_type": m.product_type, "amount": m.amount, "follow_up_detail": m.follow_up_detail, "status": m.status, "expected_revenue_date": m.expected_revenue_date}
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if current_user["role"] not in ("director", "leader"):
|
||||
update_data.pop("manager_id", None)
|
||||
if "manager_id" in update_data and update_data["manager_id"]:
|
||||
update_data["manager_id"] = uuid.UUID(update_data["manager_id"])
|
||||
for k, v in update_data.items():
|
||||
setattr(m, k, v)
|
||||
# Auto-clear expected revenue date when status becomes 已流失
|
||||
if m.status == "已流失" and m.expected_revenue_date:
|
||||
m.expected_revenue_date = ""
|
||||
new_snapshot = {"customer_id": str(m.customer_id), "product_type": m.product_type, "amount": m.amount, "follow_up_detail": m.follow_up_detail, "status": m.status, "expected_revenue_date": m.expected_revenue_date}
|
||||
changes = compute_diff(old_snapshot, new_snapshot)
|
||||
if changes:
|
||||
append_entry(m, current_user["name"], changes, getattr(data, "edit_reason", None))
|
||||
await db.commit()
|
||||
await db.refresh(m)
|
||||
# Audit log
|
||||
cust = await db.execute(select(Customer.name).where(Customer.id == m.customer_id))
|
||||
await log_audit(db, "mini_business", m.id, f"{cust.scalar_one_or_none() or ''} ({m.product_type})", "update", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return await _enrich(m, db)
|
||||
|
||||
|
||||
@@ -108,6 +158,11 @@ async def delete_mini_business(
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if current_user["role"] == "manager" and str(m.manager_id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
cust = await db.execute(select(Customer.name).where(Customer.id == m.customer_id))
|
||||
entity_name = f"{cust.scalar_one_or_none() or ''} ({m.product_type})"
|
||||
await db.delete(m)
|
||||
await db.commit()
|
||||
# Audit log
|
||||
await log_audit(db, "mini_business", uuid.UUID(item_id), entity_name, "delete", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return {"detail": "deleted"}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""MiniBusinessLog CRUD API — follow-up log entries for mini business opportunities."""
|
||||
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
from app.middleware.auth import get_current_user, require_any_role
|
||||
from app.models.mini_business import MiniBusiness
|
||||
from app.models.mini_business_log import MiniBusinessLog
|
||||
from app.models.user import User
|
||||
from app.schemas.mini_business_log import (
|
||||
MiniBusinessLogCreate, MiniBusinessLogUpdate, MiniBusinessLogOut,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/mini-business", tags=["MiniBusiness Logs"])
|
||||
|
||||
|
||||
async def _get_log(db: AsyncSession, log_id: str) -> MiniBusinessLog:
|
||||
result = await db.execute(select(MiniBusinessLog).where(MiniBusinessLog.id == log_id))
|
||||
log = result.scalar_one_or_none()
|
||||
if not log:
|
||||
raise HTTPException(status_code=404, detail="跟进记录不存在")
|
||||
return log
|
||||
|
||||
|
||||
@router.get("/{business_id}/logs")
|
||||
async def list_logs(
|
||||
business_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List follow-up logs for a mini business, newest first."""
|
||||
# Verify business exists and user has access
|
||||
mb = await db.get(MiniBusiness, business_id)
|
||||
if not mb:
|
||||
raise HTTPException(status_code=404, detail="商机不存在")
|
||||
if current_user["role"] == "manager" and str(mb.manager_id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
result = await db.execute(
|
||||
select(MiniBusinessLog)
|
||||
.where(MiniBusinessLog.business_id == business_id)
|
||||
.order_by(MiniBusinessLog.log_date.desc(), MiniBusinessLog.created_at.desc())
|
||||
)
|
||||
logs = result.scalars().all()
|
||||
|
||||
# Resolve creator names
|
||||
user_ids = {log.created_by for log in logs}
|
||||
users = (await db.execute(select(User).where(User.id.in_(user_ids)))).scalars().all()
|
||||
user_map = {u.id: u.name for u in users}
|
||||
|
||||
return [
|
||||
{
|
||||
"id": log.id,
|
||||
"business_id": log.business_id,
|
||||
"log_date": str(log.log_date),
|
||||
"method": log.method,
|
||||
"content": log.content,
|
||||
"created_by": log.created_by,
|
||||
"created_by_name": user_map.get(log.created_by, ""),
|
||||
"created_at": str(log.created_at) if log.created_at else None,
|
||||
}
|
||||
for log in logs
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{business_id}/logs")
|
||||
async def create_log(
|
||||
business_id: str,
|
||||
data: MiniBusinessLogCreate,
|
||||
current_user: dict = Depends(require_any_role),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add a follow-up log entry."""
|
||||
mb = await db.get(MiniBusiness, business_id)
|
||||
if not mb:
|
||||
raise HTTPException(status_code=404, detail="商机不存在")
|
||||
if current_user["role"] == "manager" and str(mb.manager_id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
log = MiniBusinessLog(
|
||||
business_id=uuid.UUID(business_id),
|
||||
log_date=data.log_date,
|
||||
method=data.method,
|
||||
content=data.content,
|
||||
created_by=uuid.UUID(current_user["user_id"]),
|
||||
)
|
||||
db.add(log)
|
||||
await db.commit()
|
||||
await db.refresh(log)
|
||||
|
||||
creator = await db.get(User, log.created_by)
|
||||
return {
|
||||
"id": log.id,
|
||||
"business_id": log.business_id,
|
||||
"log_date": str(log.log_date),
|
||||
"method": log.method,
|
||||
"content": log.content,
|
||||
"created_by": log.created_by,
|
||||
"created_by_name": creator.name if creator else "",
|
||||
"created_at": str(log.created_at) if log.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/logs/{log_id}")
|
||||
async def update_log(
|
||||
log_id: str,
|
||||
data: MiniBusinessLogUpdate,
|
||||
current_user: dict = Depends(require_any_role),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a follow-up log entry."""
|
||||
log = await _get_log(db, log_id)
|
||||
|
||||
# Managers can only edit their own logs; directors/leaders can edit any
|
||||
if current_user["role"] == "manager" and str(log.created_by) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for k, v in update_data.items():
|
||||
setattr(log, k, v)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(log)
|
||||
return {"id": log.id, "log_date": str(log.log_date), "method": log.method, "content": log.content}
|
||||
|
||||
|
||||
@router.delete("/logs/{log_id}")
|
||||
async def delete_log(
|
||||
log_id: str,
|
||||
current_user: dict = Depends(require_any_role),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a follow-up log entry."""
|
||||
log = await _get_log(db, log_id)
|
||||
|
||||
if current_user["role"] == "manager" and str(log.created_by) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
await db.delete(log)
|
||||
await db.commit()
|
||||
return {"status": "deleted"}
|
||||
@@ -0,0 +1,114 @@
|
||||
"""System configuration API — get/set runtime settings like notification time."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.database import get_db
|
||||
from app.middleware.auth import get_current_user, require_director
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.services.holidays import refresh_holidays
|
||||
from app.services.scheduler_manager import reschedule_daily_check
|
||||
|
||||
router = APIRouter(prefix="/system-config", tags=["SystemConfig"])
|
||||
|
||||
DEFAULT_NOTIFICATION_TIME = "17:30"
|
||||
|
||||
|
||||
def _parse_time(time_str: str) -> tuple[int, int]:
|
||||
"""Parse 'HH:MM' string to (hour, minute). Raises ValueError on bad input."""
|
||||
parts = time_str.strip().split(":")
|
||||
if len(parts) != 2:
|
||||
raise ValueError("时间格式必须为 HH:MM")
|
||||
h, m = int(parts[0]), int(parts[1])
|
||||
if not (0 <= h <= 23 and 0 <= m <= 59):
|
||||
raise ValueError("小时 0-23,分钟 0-59")
|
||||
return h, m
|
||||
|
||||
|
||||
async def _get_config(db: AsyncSession, key: str) -> str | None:
|
||||
row = await db.get(SystemConfig, key)
|
||||
return row.value if row else None
|
||||
|
||||
|
||||
async def _set_config(db: AsyncSession, key: str, value: str):
|
||||
row = await db.get(SystemConfig, key)
|
||||
if row:
|
||||
row.value = value
|
||||
else:
|
||||
db.add(SystemConfig(key=key, value=value))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_notification_time(db: AsyncSession) -> str:
|
||||
"""Read notification_time from DB, falling back to default."""
|
||||
val = await _get_config(db, "notification_time")
|
||||
return val if val else DEFAULT_NOTIFICATION_TIME
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_config(
|
||||
current_user: dict = Depends(require_director),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return all system config as {key: value} dict."""
|
||||
result = await db.execute(select(SystemConfig))
|
||||
rows = result.scalars().all()
|
||||
config = {r.key: r.value for r in rows}
|
||||
# Ensure notification_time always has a value
|
||||
if "notification_time" not in config:
|
||||
config["notification_time"] = DEFAULT_NOTIFICATION_TIME
|
||||
return config
|
||||
|
||||
|
||||
@router.put("/{key}")
|
||||
async def update_config(
|
||||
key: str,
|
||||
body: dict,
|
||||
current_user: dict = Depends(require_director),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a single config key. Only directors can change settings."""
|
||||
value = body.get("value", "")
|
||||
valid_keys = {"notification_time", "holidays", "ai_summary_prompt"}
|
||||
|
||||
if key not in valid_keys:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的配置项: {key}")
|
||||
|
||||
if key == "notification_time":
|
||||
try:
|
||||
h, m = _parse_time(value)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
await _set_config(db, key, value)
|
||||
await reschedule_daily_check(h, m)
|
||||
return {"key": key, "value": value, "scheduled": f"{h:02d}:{m:02d}"}
|
||||
|
||||
if key == "holidays":
|
||||
# Validate: comma-separated YYYY-MM-DD dates
|
||||
if value.strip():
|
||||
for s in value.split(","):
|
||||
s = s.strip()
|
||||
if s:
|
||||
try:
|
||||
from datetime import date
|
||||
date.fromisoformat(s)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"日期格式错误: {s},应为 YYYY-MM-DD")
|
||||
await _set_config(db, key, value)
|
||||
return {"key": key, "value": value}
|
||||
|
||||
await _set_config(db, key, value)
|
||||
return {"key": key, "value": value}
|
||||
|
||||
|
||||
@router.post("/refresh-holidays")
|
||||
async def refresh_holidays_endpoint(
|
||||
current_user: dict = Depends(require_director),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Fetch the latest Chinese holiday data from apisbo.com and cache it."""
|
||||
try:
|
||||
result = await refresh_holidays(db)
|
||||
return {"status": "ok", **result}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"刷新节假日数据失败: {str(e)}")
|
||||
+82
-18
@@ -1,36 +1,100 @@
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends
|
||||
import io
|
||||
import warnings
|
||||
from datetime import date
|
||||
from pathlib import PurePosixPath
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
from app.middleware.auth import get_current_user, require_any_role
|
||||
from app.services.minio_client import generate_presigned_upload_url, generate_presigned_download_url
|
||||
from app.services.minio_client import generate_presigned_download_url, put_validated_image
|
||||
from app.models.visit import Visit
|
||||
|
||||
router = APIRouter(prefix="/upload", tags=["Upload"])
|
||||
|
||||
_IMAGE_TYPES = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
}
|
||||
MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
MAX_IMAGE_PIXELS = 25_000_000
|
||||
|
||||
@router.post("/presigned-url")
|
||||
async def get_presigned_upload_url(
|
||||
filename: str,
|
||||
content_type: str = "image/jpeg",
|
||||
|
||||
def validate_image_upload(filename: str, content_type: str) -> str:
|
||||
"""Validate image metadata before accepting bytes for server-side verification."""
|
||||
if content_type.lower() not in _IMAGE_TYPES:
|
||||
raise HTTPException(status_code=400, detail="Only JPEG, PNG, and WebP images are supported")
|
||||
if PurePosixPath(filename).suffix.lower() not in {".jpg", ".jpeg", ".png", ".webp"}:
|
||||
raise HTTPException(status_code=400, detail="Invalid image filename extension")
|
||||
return _IMAGE_TYPES[content_type.lower()]
|
||||
|
||||
|
||||
def build_object_key(user_id: str, extension: str) -> str:
|
||||
return f"uploads/{user_id}/{date.today().isoformat()}/{uuid.uuid4().hex}.{extension}"
|
||||
|
||||
|
||||
def is_owned_upload_key(object_key: str, user_id: str) -> bool:
|
||||
path = PurePosixPath(object_key)
|
||||
parts = path.parts
|
||||
return (
|
||||
len(parts) == 4
|
||||
and parts[0] == "uploads"
|
||||
and parts[1] == user_id
|
||||
and ".." not in parts
|
||||
and path.suffix.lower() in {".jpg", ".png", ".webp"}
|
||||
)
|
||||
|
||||
|
||||
def normalize_image(image_bytes: bytes) -> bytes:
|
||||
"""Verify image bytes, enforce a pixel limit, and strip active/metadata content by re-encoding."""
|
||||
if not image_bytes or len(image_bytes) > MAX_IMAGE_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Image must be between 1 byte and 10 MB")
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", Image.DecompressionBombWarning)
|
||||
with Image.open(io.BytesIO(image_bytes)) as image:
|
||||
image.verify()
|
||||
with Image.open(io.BytesIO(image_bytes)) as image:
|
||||
if image.width * image.height > MAX_IMAGE_PIXELS:
|
||||
raise HTTPException(status_code=413, detail="Image dimensions are too large")
|
||||
normalized = image.convert("RGB")
|
||||
output = io.BytesIO()
|
||||
normalized.save(output, format="JPEG", quality=85, optimize=True)
|
||||
return output.getvalue()
|
||||
except HTTPException:
|
||||
raise
|
||||
except (Image.DecompressionBombError, Image.DecompressionBombWarning, OSError, UnidentifiedImageError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid image content") from exc
|
||||
|
||||
|
||||
@router.post("/image")
|
||||
async def upload_image(
|
||||
file: UploadFile = File(...),
|
||||
current_user: dict = Depends(require_any_role),
|
||||
):
|
||||
"""Get a presigned PUT URL for direct MinIO upload."""
|
||||
import datetime
|
||||
today = datetime.date.today().isoformat()
|
||||
user_id = current_user["user_id"][:8]
|
||||
object_key = f"{today}/{user_id}/{uuid.uuid4()}.jpg"
|
||||
|
||||
url = generate_presigned_upload_url(object_key)
|
||||
|
||||
return {
|
||||
"upload_url": url,
|
||||
"object_key": object_key,
|
||||
}
|
||||
"""Validate and normalize an image before persisting it to private object storage."""
|
||||
validate_image_upload(file.filename or "upload.jpg", file.content_type or "")
|
||||
normalized = normalize_image(await file.read(MAX_IMAGE_BYTES + 1))
|
||||
object_key = build_object_key(current_user["user_id"], "jpg")
|
||||
put_validated_image(object_key, normalized)
|
||||
return {"object_key": object_key}
|
||||
|
||||
|
||||
@router.get("/download-url")
|
||||
async def get_presigned_download_url(
|
||||
object_key: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a presigned GET URL for viewing a photo (1 hour validity)."""
|
||||
result = await db.execute(select(Visit).where(Visit.photos.any(object_key)).limit(1))
|
||||
visit = result.scalar_one_or_none()
|
||||
if not visit:
|
||||
raise HTTPException(status_code=404, detail="Photo not found")
|
||||
if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
url = generate_presigned_download_url(object_key)
|
||||
return {"download_url": url}
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy import select
|
||||
from app.database import get_db
|
||||
from app.middleware.auth import get_current_user, require_director
|
||||
from app.models.user import User
|
||||
from app.utils.audit import log_audit
|
||||
from app.schemas.customer import UserOut
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["Users"])
|
||||
@@ -15,6 +16,8 @@ router = APIRouter(prefix="/users", tags=["Users"])
|
||||
class UpdateUserRoleRequest(BaseModel):
|
||||
role: str # manager / director / leader
|
||||
department: str = ""
|
||||
require_report: bool = True
|
||||
color: Optional[str] = None # hex color for manager tags
|
||||
|
||||
|
||||
@router.get("/", response_model=list[UserOut])
|
||||
@@ -71,12 +74,105 @@ async def update_user_role(
|
||||
user.role = data.role
|
||||
if data.department:
|
||||
user.department = data.department
|
||||
user.require_report = data.require_report
|
||||
if data.color is not None:
|
||||
user.color = data.color
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
# Audit log
|
||||
await log_audit(db, "user", uuid.UUID(user_id), user.name, "update", current_user["user_id"], current_user["name"], f"角色更新为 {data.role}")
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"department": user.department,
|
||||
"require_report": user.require_report,
|
||||
"color": user.color,
|
||||
}
|
||||
|
||||
|
||||
class UpdateWecomRequest(BaseModel):
|
||||
wecom_userid: Optional[str] = None # None or "" to unbind
|
||||
|
||||
|
||||
@router.put("/{user_id}/wecom")
|
||||
async def update_user_wecom(
|
||||
user_id: str,
|
||||
data: UpdateWecomRequest,
|
||||
current_user: dict = Depends(require_director),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Director binds or unbinds a user's WeChat Work account."""
|
||||
result = await db.execute(select(User).where(User.id == uuid.UUID(user_id)))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
new_id = (data.wecom_userid or "").strip()
|
||||
|
||||
# Check duplicate
|
||||
if new_id:
|
||||
dup = await db.execute(
|
||||
select(User).where(User.wecom_userid == new_id, User.id != uuid.UUID(user_id))
|
||||
)
|
||||
existing = dup.scalar_one_or_none()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail=f"该企微ID已被「{existing.name}」绑定")
|
||||
|
||||
user.wecom_userid = new_id if new_id else None
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"name": user.name,
|
||||
"wecom_userid": user.wecom_userid,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
current_user: dict = Depends(require_director),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Director deletes a user. Cannot delete self."""
|
||||
if user_id == current_user["user_id"]:
|
||||
raise HTTPException(status_code=400, detail="不能删除自己")
|
||||
|
||||
result = await db.execute(select(User).where(User.id == uuid.UUID(user_id)))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
user_name = user.name
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
# Audit log
|
||||
await log_audit(db, "user", uuid.UUID(user_id), user_name, "delete", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return {"detail": "deleted", "name": user_name}
|
||||
|
||||
|
||||
class UpdateThemeRequest(BaseModel):
|
||||
theme: str # editorial / light
|
||||
|
||||
|
||||
@router.put("/me/theme")
|
||||
async def update_my_theme(
|
||||
data: UpdateThemeRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update the current user's theme preference."""
|
||||
if data.theme not in ("editorial", "light"):
|
||||
raise HTTPException(status_code=400, detail="Invalid theme")
|
||||
result = await db.execute(select(User).where(User.id == uuid.UUID(current_user["user_id"])))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
user.theme = data.theme
|
||||
await db.commit()
|
||||
return {"theme": user.theme}
|
||||
|
||||
+165
-8
@@ -3,7 +3,7 @@ from datetime import date, datetime
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy import select, func, or_
|
||||
from app.database import get_db
|
||||
from app.middleware.auth import get_current_user, require_any_role
|
||||
from app.models.visit import Visit
|
||||
@@ -12,11 +12,22 @@ from app.models.user import User
|
||||
from app.schemas.visit import VisitCreate, VisitUpdate, VisitOut, VisitListOut
|
||||
from app.utils.timezone import today_cst, parse_date
|
||||
from app.services.minio_client import delete_objects
|
||||
from app.api.upload import is_owned_upload_key
|
||||
from app.utils.edit_log import compute_diff, append_entry, init_entry
|
||||
from app.utils.audit import log_audit
|
||||
|
||||
router = APIRouter(prefix="/visits", tags=["Visits"])
|
||||
|
||||
|
||||
def _validate_photo_keys(photo_keys: list[str], user_id: str, existing_keys: list[str] | None = None) -> None:
|
||||
if len(photo_keys) > 9:
|
||||
raise HTTPException(status_code=400, detail="A visit may contain at most 9 photos")
|
||||
existing = set(existing_keys or [])
|
||||
for key in photo_keys:
|
||||
if key not in existing and not is_owned_upload_key(key, user_id):
|
||||
raise HTTPException(status_code=400, detail="Invalid or unauthorized photo reference")
|
||||
|
||||
|
||||
async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict:
|
||||
"""Enrich a visit record with customer/manager names."""
|
||||
customer_name = None
|
||||
@@ -28,6 +39,12 @@ async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict:
|
||||
mgr_result = await db.execute(select(User.name).where(User.id == visit.manager_id))
|
||||
manager_name = mgr_result.scalar_one_or_none()
|
||||
|
||||
# Resolve companion UUIDs to names
|
||||
companion_names_resolved = list(visit.companion_names or [])
|
||||
if visit.companions:
|
||||
comp_result = await db.execute(select(User.name).where(User.id.in_(visit.companions)))
|
||||
companion_names_resolved = [n for n, in comp_result.all()] + companion_names_resolved
|
||||
|
||||
return {
|
||||
"id": str(visit.id),
|
||||
"customer_id": str(visit.customer_id),
|
||||
@@ -40,9 +57,12 @@ async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict:
|
||||
"communication_content": visit.communication_content,
|
||||
"customer_demand": visit.customer_demand,
|
||||
"companions": visit.companions,
|
||||
"companion_names": visit.companion_names or [],
|
||||
"companion_names_resolved": companion_names_resolved,
|
||||
"photos": visit.photos,
|
||||
"manager_id": str(visit.manager_id),
|
||||
"manager_name": manager_name,
|
||||
"visit_group_id": str(visit.visit_group_id) if visit.visit_group_id else None,
|
||||
"edit_log": visit.edit_log or [],
|
||||
"created_at": str(visit.created_at),
|
||||
"updated_at": str(visit.updated_at),
|
||||
@@ -54,6 +74,8 @@ async def list_visits(
|
||||
date_from: Optional[str] = Query(None),
|
||||
date_to: Optional[str] = Query(None),
|
||||
customer_id: Optional[str] = Query(None),
|
||||
customer_type: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -69,6 +91,12 @@ async def list_visits(
|
||||
query = query.where(Visit.visit_date <= parse_date(date_to))
|
||||
if customer_id:
|
||||
query = query.where(Visit.customer_id == uuid.UUID(customer_id))
|
||||
if customer_type:
|
||||
query = query.join(Customer, Visit.customer_id == Customer.id).where(Customer.customer_type == customer_type)
|
||||
if search:
|
||||
query = query.outerjoin(Customer, Visit.customer_id == Customer.id).where(
|
||||
or_(Visit.communication_content.ilike(f"%{search}%"), Visit.customer_demand.ilike(f"%{search}%"), Visit.visitor_name.ilike(f"%{search}%"), Customer.name.ilike(f"%{search}%"))
|
||||
)
|
||||
|
||||
query = query.order_by(Visit.visit_date.desc(), Visit.created_at.desc()).limit(200)
|
||||
result = await db.execute(query)
|
||||
@@ -128,7 +156,9 @@ async def create_visit(
|
||||
current_user: dict = Depends(require_any_role),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a visit record. If companions are selected, creates draft copies for them."""
|
||||
"""Create a visit record. If companions are selected, creates full copies for them."""
|
||||
_validate_photo_keys(data.photos, current_user["user_id"])
|
||||
group_id = uuid.uuid4()
|
||||
visit = Visit(
|
||||
customer_id=data.customer_id,
|
||||
visit_date=parse_date(data.visit_date),
|
||||
@@ -137,28 +167,87 @@ async def create_visit(
|
||||
communication_content=data.communication_content,
|
||||
customer_demand=data.customer_demand,
|
||||
companions=data.companions,
|
||||
companion_names=data.companion_names,
|
||||
photos=data.photos,
|
||||
manager_id=uuid.UUID(current_user["user_id"]),
|
||||
manager_id=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else uuid.UUID(current_user["user_id"]),
|
||||
visit_group_id=group_id,
|
||||
)
|
||||
init_entry(visit, current_user["name"])
|
||||
db.add(visit)
|
||||
|
||||
# Create draft copies for companions
|
||||
# Update customer's last visit tracking
|
||||
cust_result = await db.execute(select(Customer).where(Customer.id == data.customer_id))
|
||||
customer = cust_result.scalar_one_or_none()
|
||||
if customer:
|
||||
customer.last_visit_date = parse_date(data.visit_date)
|
||||
customer.last_visit_manager_id = uuid.UUID(current_user["user_id"])
|
||||
db.add(customer)
|
||||
|
||||
# Auto-complete matching work plans for this customer
|
||||
from app.services.visits import auto_complete_work_plans
|
||||
await auto_complete_work_plans(
|
||||
db, data.customer_id, parse_date(data.visit_date),
|
||||
current_user["name"], "拜访自动完成",
|
||||
)
|
||||
|
||||
# Auto-create follow-up logs for active mini business opportunities
|
||||
from app.models.mini_business import MiniBusiness
|
||||
from app.models.mini_business_log import MiniBusinessLog
|
||||
method_map = {"上门": "上门", "电话": "电话", "微信": "微信", "出差": "其他"}
|
||||
active_biz = await db.execute(
|
||||
select(MiniBusiness).where(
|
||||
MiniBusiness.customer_id == data.customer_id,
|
||||
MiniBusiness.status == "跟进中",
|
||||
)
|
||||
)
|
||||
# Create full copies for companions (not blank drafts)
|
||||
creator_name = current_user["name"]
|
||||
for companion_id in data.companions:
|
||||
if companion_id != uuid.UUID(current_user["user_id"]):
|
||||
# Build companion's companions list: the creator + other companions excluding self
|
||||
comp_companions = [uuid.UUID(current_user["user_id"])]
|
||||
for cid in data.companions:
|
||||
if cid != companion_id and cid not in comp_companions:
|
||||
comp_companions.append(cid)
|
||||
draft = Visit(
|
||||
customer_id=data.customer_id,
|
||||
visit_date=parse_date(data.visit_date),
|
||||
visit_method=data.visit_method,
|
||||
time_range=data.time_range,
|
||||
communication_content="", # Leave blank for companion to fill
|
||||
customer_demand="",
|
||||
companions=[],
|
||||
photos=[],
|
||||
visitor_name=data.visitor_name,
|
||||
visitor_phone=data.visitor_phone,
|
||||
communication_content=(data.communication_content or "") + f"(协同{creator_name})",
|
||||
customer_demand=data.customer_demand,
|
||||
companions=comp_companions,
|
||||
companion_names=[],
|
||||
photos=data.photos,
|
||||
manager_id=companion_id,
|
||||
visit_group_id=group_id,
|
||||
)
|
||||
init_entry(draft, current_user["name"])
|
||||
db.add(draft)
|
||||
|
||||
# Auto-create follow-up logs for active mini business opportunities
|
||||
for biz in active_biz.scalars().all():
|
||||
parts = []
|
||||
if data.communication_content and data.communication_content.strip():
|
||||
parts.append(f"沟通内容:{data.communication_content.strip()}")
|
||||
if data.customer_demand and data.customer_demand.strip():
|
||||
parts.append(f"客户需求:{data.customer_demand.strip()}")
|
||||
log_content = "\n".join(parts) if parts else "拜访跟进"
|
||||
follow_method = method_map.get(data.visit_method, "其他")
|
||||
log = MiniBusinessLog(
|
||||
business_id=biz.id,
|
||||
log_date=parse_date(data.visit_date),
|
||||
method=follow_method,
|
||||
content=log_content,
|
||||
created_by=uuid.UUID(current_user["user_id"]),
|
||||
)
|
||||
db.add(log)
|
||||
|
||||
# Audit log (before commit — part of same transaction)
|
||||
cust = await db.execute(select(Customer.name).where(Customer.id == visit.customer_id))
|
||||
await log_audit(db, "visit", visit.id, f"{cust.scalar_one_or_none() or ''} ({data.visit_date})", "create", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
await db.refresh(visit)
|
||||
return await _enrich_visit(visit, db)
|
||||
@@ -179,17 +268,25 @@ async def update_visit(
|
||||
if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
if data.photos is not None:
|
||||
_validate_photo_keys(data.photos, current_user["user_id"], visit.photos or [])
|
||||
|
||||
# Snapshot old values for diff
|
||||
old_snapshot = {
|
||||
"customer_id": str(visit.customer_id), "visit_date": str(visit.visit_date),
|
||||
"visit_method": visit.visit_method, "time_range": visit.time_range,
|
||||
"visitor_name": visit.visitor_name or "", "visitor_phone": visit.visitor_phone or "",
|
||||
"communication_content": visit.communication_content, "customer_demand": visit.customer_demand,
|
||||
"manager_id": str(visit.manager_id) if visit.manager_id else "",
|
||||
}
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if current_user["role"] not in ("director", "leader"):
|
||||
update_data.pop("manager_id", None)
|
||||
if "visit_date" in update_data and update_data["visit_date"]:
|
||||
update_data["visit_date"] = parse_date(update_data["visit_date"])
|
||||
if "manager_id" in update_data and update_data["manager_id"]:
|
||||
update_data["manager_id"] = uuid.UUID(update_data["manager_id"])
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(visit, key, value)
|
||||
@@ -200,13 +297,39 @@ async def update_visit(
|
||||
"visit_method": visit.visit_method, "time_range": visit.time_range,
|
||||
"visitor_name": visit.visitor_name or "", "visitor_phone": visit.visitor_phone or "",
|
||||
"communication_content": visit.communication_content, "customer_demand": visit.customer_demand,
|
||||
"manager_id": str(visit.manager_id) if visit.manager_id else "",
|
||||
}
|
||||
changes = compute_diff(old_snapshot, new_snapshot)
|
||||
if changes:
|
||||
append_entry(visit, current_user["name"], changes, getattr(data, "edit_reason", None))
|
||||
|
||||
# Auto-complete matching work plans when visit date or customer changes
|
||||
from app.models.work_plan import WorkPlan
|
||||
plans_result = await db.execute(
|
||||
select(WorkPlan).where(
|
||||
WorkPlan.customer_id == visit.customer_id,
|
||||
WorkPlan.status == "计划中",
|
||||
WorkPlan.plan_date <= visit.visit_date,
|
||||
)
|
||||
)
|
||||
for plan in plans_result.scalars().all():
|
||||
plan.status = "已完成"
|
||||
append_entry(plan, current_user["name"], [{"field": "status", "from": "计划中", "to": "已完成", "reason": "拜访更新自动完成"}])
|
||||
|
||||
# Audit log (before commit — part of same transaction)
|
||||
cust = await db.execute(select(Customer.name).where(Customer.id == visit.customer_id))
|
||||
await log_audit(db, "visit", visit.id, f"{cust.scalar_one_or_none() or ''} ({visit.visit_date})", "update", current_user["user_id"], current_user["name"], ", ".join(changes.keys()) if changes else "")
|
||||
await db.commit()
|
||||
await db.refresh(visit)
|
||||
|
||||
# Auto-complete matching work plans after visit update
|
||||
from app.services.visits import auto_complete_work_plans
|
||||
await auto_complete_work_plans(
|
||||
db, visit.customer_id, visit.visit_date,
|
||||
current_user["name"], "拜访更新自动完成",
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return await _enrich_visit(visit, db)
|
||||
|
||||
|
||||
@@ -224,10 +347,44 @@ async def delete_visit(
|
||||
if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
customer_id = visit.customer_id
|
||||
manager_id = visit.manager_id
|
||||
|
||||
# Clean up photos in MinIO
|
||||
if visit.photos:
|
||||
delete_objects(visit.photos)
|
||||
|
||||
# Snapshot entity name before deletion
|
||||
cust_name_result = await db.execute(select(Customer.name).where(Customer.id == customer_id))
|
||||
entity_name = f"{cust_name_result.scalar_one_or_none() or ''} ({visit.visit_date})"
|
||||
|
||||
await db.delete(visit)
|
||||
|
||||
# Recalculate customer's last_visit_date from remaining visits
|
||||
latest = await db.execute(
|
||||
select(func.max(Visit.visit_date)).where(
|
||||
Visit.customer_id == customer_id,
|
||||
)
|
||||
)
|
||||
new_latest = latest.scalar()
|
||||
cust = await db.get(Customer, customer_id)
|
||||
if cust:
|
||||
if new_latest:
|
||||
cust.last_visit_date = new_latest
|
||||
latest_visit = await db.execute(
|
||||
select(Visit).where(
|
||||
Visit.customer_id == customer_id,
|
||||
Visit.visit_date == new_latest,
|
||||
).order_by(Visit.created_at.desc()).limit(1)
|
||||
)
|
||||
lv = latest_visit.scalar_one_or_none()
|
||||
if lv:
|
||||
cust.last_visit_manager_id = lv.manager_id
|
||||
else:
|
||||
cust.last_visit_date = None
|
||||
cust.last_visit_manager_id = None
|
||||
|
||||
# Audit log (before final commit — part of same transaction)
|
||||
await log_audit(db, "visit", uuid.UUID(visit_id), entity_name, "delete", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return {"detail": "deleted"}
|
||||
|
||||
+305
-11
@@ -1,13 +1,22 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import struct
|
||||
import base64
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from defusedxml import ElementTree as ET
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import PlainTextResponse, HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.database import get_db
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from app.config import settings
|
||||
from app.database import async_session, get_db
|
||||
from app.middleware.auth import get_current_user, require_director
|
||||
from app.models.user import User
|
||||
from app.services.wecom import wecom_client
|
||||
from app.services.wecom import wecom_client, store_bind_token, consume_bind_token
|
||||
from app.services.scheduler import check_daily_reporting
|
||||
|
||||
router = APIRouter(prefix="/wecom", tags=["WeChatWork"])
|
||||
@@ -22,6 +31,163 @@ class AnnouncementRequest(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
class BindConfirmRequest(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
# ── Crypto helpers ──
|
||||
|
||||
def _verify_signature(token: str, timestamp: str, nonce: str, encrypted: str, signature: str) -> bool:
|
||||
items = sorted([token, timestamp, nonce, encrypted])
|
||||
raw = "".join(items)
|
||||
# The WeCom callback protocol mandates SHA-1; compare_digest avoids timing leaks.
|
||||
expected = hashlib.sha1(raw.encode(), usedforsecurity=False).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
def _decrypt_msg(encrypted: str) -> str:
|
||||
"""Decrypt WeChat Work callback message. Returns plaintext XML."""
|
||||
key = base64.b64decode(settings.WECOM_ENCODING_AES_KEY + "=")
|
||||
ciphertext = base64.b64decode(encrypted)
|
||||
decryptor = Cipher(algorithms.AES(key), modes.CBC(key[:16])).decryptor()
|
||||
plaintext = decryptor.update(ciphertext) + decryptor.finalize()
|
||||
pad_len = plaintext[-1]
|
||||
if pad_len < 1 or pad_len > 32 or plaintext[-pad_len:] != bytes([pad_len]) * pad_len:
|
||||
raise ValueError("Invalid callback padding")
|
||||
plaintext = plaintext[:-pad_len]
|
||||
msg_len = struct.unpack(">I", plaintext[16:20])[0]
|
||||
return plaintext[20:20 + msg_len].decode("utf-8")
|
||||
|
||||
|
||||
# ── Callback ──
|
||||
|
||||
@router.get("/callback")
|
||||
async def wecom_callback_verify(
|
||||
msg_signature: str = Query(...),
|
||||
timestamp: str = Query(...),
|
||||
nonce: str = Query(...),
|
||||
echostr: str = Query(...),
|
||||
):
|
||||
"""WeChat Work callback URL verification (GET)."""
|
||||
token = settings.WECOM_TOKEN
|
||||
if not token or not settings.WECOM_ENCODING_AES_KEY:
|
||||
raise HTTPException(status_code=500, detail="WeCom callback not configured")
|
||||
if not _verify_signature(token, timestamp, nonce, echostr, msg_signature):
|
||||
raise HTTPException(status_code=403, detail="Signature mismatch")
|
||||
try:
|
||||
plaintext = _decrypt_msg(echostr)
|
||||
return PlainTextResponse(content=plaintext, status_code=200)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Decrypt failed: {e}")
|
||||
|
||||
|
||||
@router.post("/callback")
|
||||
async def wecom_callback_event(request: Request):
|
||||
"""WeChat Work callback event receiver (POST). Handles menu clicks and text messages."""
|
||||
token = settings.WECOM_TOKEN
|
||||
if not token or not settings.WECOM_ENCODING_AES_KEY:
|
||||
return PlainTextResponse(content="success")
|
||||
|
||||
# Parse XML envelope
|
||||
try:
|
||||
root = ET.fromstring(await request.body())
|
||||
encrypt = root.findtext("Encrypt", "")
|
||||
except ET.ParseError:
|
||||
return PlainTextResponse(content="success")
|
||||
|
||||
# Verify signature
|
||||
msg_signature = request.query_params.get("msg_signature", "")
|
||||
timestamp = request.query_params.get("timestamp", "")
|
||||
nonce = request.query_params.get("nonce", "")
|
||||
if not _verify_signature(token, timestamp, nonce, encrypt, msg_signature):
|
||||
return PlainTextResponse(content="success")
|
||||
|
||||
# Decrypt inner XML
|
||||
try:
|
||||
xml_str = _decrypt_msg(encrypt)
|
||||
msg = ET.fromstring(xml_str)
|
||||
except Exception:
|
||||
return PlainTextResponse(content="success")
|
||||
|
||||
msg_type = msg.findtext("MsgType", "")
|
||||
from_user = msg.findtext("FromUserName", "")
|
||||
content = (msg.findtext("Content", "") or "").strip()
|
||||
event = (msg.findtext("Event", "") or "").strip()
|
||||
event_key = (msg.findtext("EventKey", "") or "").strip()
|
||||
|
||||
import logging
|
||||
_log = logging.getLogger("wecom_callback")
|
||||
_log.info("wecom callback received: type=%s event=%s key=%s", msg_type, event, event_key)
|
||||
|
||||
# Triggers for binding
|
||||
if (msg_type == "text" and content == "绑定") or (msg_type == "event" and event == "click" and event_key == "BIND_ACCOUNT"):
|
||||
_log.info("wecom bind triggered")
|
||||
await _handle_bind_request(from_user)
|
||||
return PlainTextResponse(content="success")
|
||||
|
||||
_log.info("wecom message ignored (no matching action)")
|
||||
return PlainTextResponse(content="success")
|
||||
|
||||
|
||||
async def _handle_bind_request(wecom_userid: str):
|
||||
"""Generate bind token, store it, and push a bind link to the user."""
|
||||
if not wecom_userid:
|
||||
return
|
||||
|
||||
async with async_session() as db:
|
||||
bind_token = await store_bind_token(db, wecom_userid)
|
||||
|
||||
content = (
|
||||
f"【账号绑定】\n\n"
|
||||
f"请在10分钟内点击以下链接完成账号绑定:\n"
|
||||
f"https://qj.dhdx.fun/wecom-bind?token={bind_token}\n\n"
|
||||
f"绑定后可使用企微一键登录,并接收填报提醒通知。"
|
||||
)
|
||||
|
||||
await wecom_client.send_text_message([wecom_userid], content)
|
||||
|
||||
|
||||
# ── Bind confirmation (JWT-protected) ──
|
||||
|
||||
@router.post("/bind-confirm")
|
||||
async def bind_confirm(
|
||||
data: BindConfirmRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""User confirms binding after clicking the link in WeChat Work message."""
|
||||
if not data.token:
|
||||
raise HTTPException(status_code=400, detail="token 不能为空")
|
||||
|
||||
wecom_userid = await consume_bind_token(db, data.token)
|
||||
if not wecom_userid:
|
||||
raise HTTPException(status_code=400, detail="绑定链接已过期或无效,请重新在企微发送「绑定」")
|
||||
|
||||
# Check if this wecom_userid is already bound to another user
|
||||
result = await db.execute(
|
||||
select(User).where(User.wecom_userid == wecom_userid)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing and str(existing.id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=400, detail=f"该企业微信已绑定账号「{existing.name}」")
|
||||
|
||||
# Bind
|
||||
result = await db.execute(
|
||||
select(User).where(User.id == uuid.UUID(current_user["user_id"]))
|
||||
)
|
||||
user = result.scalar_one()
|
||||
user.wecom_userid = wecom_userid
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=409, detail="该企业微信已绑定其他账号") from exc
|
||||
|
||||
return {"code": 200, "msg": "绑定成功", "wecom_userid": wecom_userid}
|
||||
|
||||
|
||||
# ── Manual actions (director only) ──
|
||||
|
||||
@router.post("/remind")
|
||||
async def send_reminder(
|
||||
data: RemindRequest,
|
||||
@@ -29,16 +195,23 @@ async def send_reminder(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Director manually sends reminder to specific managers."""
|
||||
# Get wecom_userids for the selected users
|
||||
result = await db.execute(
|
||||
select(User.wecom_userid).where(User.id.in_([uuid.UUID(uid) for uid in data.user_ids]))
|
||||
)
|
||||
wecom_ids = [r[0] for r in result.all() if r[0]]
|
||||
|
||||
content = data.message or "📋 请及时完成今日拜访记录填报。"
|
||||
success = await wecom_client.send_text_message(wecom_ids, content)
|
||||
success = await wecom_client.send_template_card(
|
||||
user_ids=wecom_ids,
|
||||
title="📋 填报提醒",
|
||||
desc=content,
|
||||
url="https://qj.dhdx.fun/m",
|
||||
btn_text="去填报",
|
||||
)
|
||||
if not success:
|
||||
success = await wecom_client.send_text_message(wecom_ids, content)
|
||||
|
||||
return {"success": success, "sent_to": len(wecom_ids)}
|
||||
return {"success": success, "sent_to": len(wecom_ids), "rich": True}
|
||||
|
||||
|
||||
@router.post("/announcement")
|
||||
@@ -47,15 +220,48 @@ async def send_announcement(
|
||||
current_user: dict = Depends(require_director),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Director sends an announcement to all team members."""
|
||||
# Get all wecom_userids in the department
|
||||
"""Director sends an announcement to all team members (markdown)."""
|
||||
result = await db.execute(select(User.wecom_userid).where(User.wecom_userid.isnot(None)))
|
||||
wecom_ids = [r[0] for r in result.all()]
|
||||
|
||||
content = f"📢 支局长公告\n\n{data.content}"
|
||||
success = await wecom_client.send_text_message(wecom_ids, content)
|
||||
markdown = f"## 📢 支局长公告\n\n{data.content}"
|
||||
success = await wecom_client.send_markdown_message(markdown)
|
||||
if not success:
|
||||
success = await wecom_client.send_text_message(wecom_ids, f"📢 支局长公告\n\n{data.content}")
|
||||
|
||||
return {"success": success, "sent_to": len(wecom_ids)}
|
||||
return {"success": success, "sent_to": len(wecom_ids), "rich": True}
|
||||
|
||||
|
||||
@router.post("/test-message")
|
||||
async def test_message(
|
||||
wecom_userid: str,
|
||||
current_user: dict = Depends(require_director),
|
||||
):
|
||||
"""Send a test message to a specific wecom_userid for debugging."""
|
||||
success = await wecom_client.send_text_message(
|
||||
[wecom_userid],
|
||||
"🧪 企迹周报系统 — 测试消息\n\n如果您收到此消息,说明企微消息推送配置成功!"
|
||||
)
|
||||
return {"success": success, "sent_to": wecom_userid}
|
||||
|
||||
|
||||
@router.post("/setup-menu")
|
||||
async def setup_menu(current_user: dict = Depends(require_director)):
|
||||
"""Create the standard app menu (开始填报 view + 绑定账号 click)."""
|
||||
oauth_url = _build_oauth_url()
|
||||
buttons = [
|
||||
{"type": "view", "name": "开始填报", "url": oauth_url},
|
||||
{"type": "click", "name": "绑定账号", "key": "BIND_ACCOUNT"},
|
||||
]
|
||||
success = await wecom_client.create_menu(buttons)
|
||||
return {"success": success, "message": "菜单已部署 (含静默登录)" if success else "菜单创建失败"}
|
||||
|
||||
|
||||
@router.get("/menu")
|
||||
async def get_menu(current_user: dict = Depends(require_director)):
|
||||
"""Get current app menu configuration."""
|
||||
menu = await wecom_client.get_menu()
|
||||
return menu or {"message": "No menu configured"}
|
||||
|
||||
|
||||
@router.post("/trigger-daily-check")
|
||||
@@ -66,3 +272,91 @@ async def trigger_daily_check(
|
||||
"""Manually trigger the daily reporting check (for testing or manual use)."""
|
||||
result = await check_daily_reporting(db)
|
||||
return result
|
||||
|
||||
|
||||
# ── OAuth silent login ──
|
||||
|
||||
import urllib.parse
|
||||
from app.services.auth import build_token_for_user
|
||||
|
||||
|
||||
def _build_oauth_url(redirect_path: str = "/m") -> str:
|
||||
"""Build the WeChat Work OAuth2 authorize URL (snsapi_base = silent).
|
||||
The redirect target is encoded in the 'state' param to avoid query-string issues
|
||||
with the OAuth redirect_uri validation."""
|
||||
if not settings.WECOM_CORP_ID:
|
||||
return f"https://qj.dhdx.fun{redirect_path}"
|
||||
params = urllib.parse.urlencode({
|
||||
"appid": settings.WECOM_CORP_ID,
|
||||
"redirect_uri": "https://qj.dhdx.fun/api/wecom/oauth-callback",
|
||||
"response_type": "code",
|
||||
"scope": "snsapi_base",
|
||||
"agentid": settings.WECOM_AGENT_ID,
|
||||
"state": f"r={urllib.parse.quote(redirect_path)}",
|
||||
})
|
||||
return f"https://open.weixin.qq.com/connect/oauth2/authorize?{params}#wechat_redirect"
|
||||
|
||||
|
||||
@router.get("/oauth-url")
|
||||
async def get_oauth_url(redirect: str = Query("/m")):
|
||||
"""Redirect to the WeChat Work OAuth authorize URL (silent login)."""
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url=_build_oauth_url(redirect))
|
||||
|
||||
|
||||
@router.get("/oauth-callback")
|
||||
async def oauth_callback(
|
||||
code: str = Query(...),
|
||||
state: str = Query(""),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""WeChat Work OAuth2 callback — exchange code for userid, find bound user, auto-login."""
|
||||
# Parse redirect target from state param: "r=%2Fm"
|
||||
redirect = "/m"
|
||||
if state.startswith("r="):
|
||||
redirect = urllib.parse.unquote(state[2:])
|
||||
|
||||
# Exchange code for wecom userinfo
|
||||
userinfo = await wecom_client.get_userinfo_by_code(code)
|
||||
if not userinfo:
|
||||
raise HTTPException(status_code=400, detail="Failed to exchange code with WeChat Work")
|
||||
|
||||
wecom_userid = userinfo.get("UserId") or userinfo.get("userid")
|
||||
if not wecom_userid:
|
||||
raise HTTPException(status_code=400, detail="Could not get userid from WeChat Work")
|
||||
|
||||
# Find bound user (case-insensitive — WeChat Work userids may vary in case)
|
||||
from sqlalchemy import func as sa_func
|
||||
result = await db.execute(
|
||||
select(User).where(sa_func.lower(User.wecom_userid) == wecom_userid.lower())
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
# Not bound — generate a one-time bind token and redirect to bind page
|
||||
bind_token = await store_bind_token(db, wecom_userid)
|
||||
bind_url = f"https://qj.dhdx.fun/wecom-bind?token={bind_token}&wecom_userid={wecom_userid}"
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url=bind_url)
|
||||
|
||||
# Issue JWT and auto-login via HTML page — saves credentials to localStorage, then redirects
|
||||
token = build_token_for_user(user)
|
||||
import json as _json
|
||||
auth_data = _json.dumps({
|
||||
"token": token,
|
||||
"userId": str(user.id),
|
||||
"userName": user.name,
|
||||
"userRole": user.role,
|
||||
"theme": user.theme or "editorial",
|
||||
}, ensure_ascii=False)
|
||||
frontend_url = f"https://qj.dhdx.fun{redirect}"
|
||||
html = f"""<!DOCTYPE html><html><head><meta charset="utf-8"><title>登录中...</title></head>
|
||||
<body><script>
|
||||
var d = {auth_data};
|
||||
localStorage.setItem('token', d.token);
|
||||
localStorage.setItem('userId', d.userId);
|
||||
localStorage.setItem('userName', d.userName);
|
||||
localStorage.setItem('userRole', d.userRole);
|
||||
localStorage.setItem('theme', d.theme);
|
||||
window.location.replace({_json.dumps(frontend_url)});
|
||||
</script></body></html>"""
|
||||
return HTMLResponse(content=html, status_code=200)
|
||||
|
||||
@@ -3,7 +3,7 @@ from datetime import date
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, or_
|
||||
from app.database import get_db
|
||||
from app.middleware.auth import get_current_user, require_any_role
|
||||
from app.utils.timezone import parse_date
|
||||
@@ -35,6 +35,8 @@ async def _enrich(wp: WorkPlan, db: AsyncSession) -> dict:
|
||||
@router.get("/")
|
||||
async def list_work_plans(
|
||||
customer_id: Optional[str] = Query(None),
|
||||
customer_type: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -43,11 +45,32 @@ async def list_work_plans(
|
||||
query = query.where(WorkPlan.manager_id == uuid.UUID(current_user["user_id"]))
|
||||
if customer_id:
|
||||
query = query.where(WorkPlan.customer_id == uuid.UUID(customer_id))
|
||||
if customer_type:
|
||||
query = query.join(Customer, WorkPlan.customer_id == Customer.id).where(Customer.customer_type == customer_type)
|
||||
if search:
|
||||
query = query.outerjoin(Customer, WorkPlan.customer_id == Customer.id).where(
|
||||
or_(WorkPlan.plan_content.ilike(f"%{search}%"), Customer.name.ilike(f"%{search}%"))
|
||||
)
|
||||
query = query.order_by(WorkPlan.plan_date.desc()).limit(200)
|
||||
result = await db.execute(query)
|
||||
return [await _enrich(w, db) for w in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/{item_id}")
|
||||
async def get_work_plan(
|
||||
item_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(WorkPlan).where(WorkPlan.id == item_id))
|
||||
wp = result.scalar_one_or_none()
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
if current_user["role"] == "manager" and str(wp.manager_id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
return await _enrich(wp, db)
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_work_plan(
|
||||
data: WorkPlanCreate,
|
||||
@@ -58,7 +81,7 @@ async def create_work_plan(
|
||||
customer_id=data.customer_id,
|
||||
plan_content=data.plan_content,
|
||||
plan_date=parse_date(data.plan_date),
|
||||
manager_id=uuid.UUID(current_user["user_id"]),
|
||||
manager_id=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else uuid.UUID(current_user["user_id"]),
|
||||
status=data.status,
|
||||
)
|
||||
init_entry(wp, current_user["name"])
|
||||
@@ -83,6 +106,8 @@ async def update_work_plan(
|
||||
|
||||
old_snapshot = {"customer_id": str(wp.customer_id), "plan_content": wp.plan_content, "plan_date": str(wp.plan_date), "status": wp.status}
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if current_user["role"] not in ("director", "leader"):
|
||||
update_data.pop("manager_id", None)
|
||||
if "plan_date" in update_data and update_data["plan_date"]:
|
||||
update_data["plan_date"] = parse_date(update_data["plan_date"])
|
||||
for k, v in update_data.items():
|
||||
|
||||
+39
-13
@@ -1,15 +1,18 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
# App
|
||||
APP_NAME: str = "企迹-政企周报管理系统"
|
||||
DEBUG: bool = True
|
||||
SECRET_KEY: str = "change-me-in-production"
|
||||
ENVIRONMENT: str = "production"
|
||||
DEBUG: bool = False
|
||||
SECRET_KEY: str = ""
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/qiji"
|
||||
# A syntactically valid non-working placeholder keeps tooling imports side-effect free.
|
||||
DATABASE_URL: str = "postgresql+asyncpg://qiji_app:invalid@localhost:5432/qiji"
|
||||
|
||||
# JWT
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
@@ -24,11 +27,11 @@ class Settings(BaseSettings):
|
||||
CASDOOR_APPLICATION: str = "qiji-weekly-report"
|
||||
|
||||
# MinIO
|
||||
MINIO_ENDPOINT: str = "localhost:9000"
|
||||
MINIO_ACCESS_KEY: str = "minioadmin"
|
||||
MINIO_SECRET_KEY: str = "minioadmin"
|
||||
MINIO_ENDPOINT: str = ""
|
||||
MINIO_ACCESS_KEY: str = ""
|
||||
MINIO_SECRET_KEY: str = ""
|
||||
MINIO_BUCKET: str = "qiji-photos"
|
||||
MINIO_SECURE: bool = False
|
||||
MINIO_SECURE: bool = True
|
||||
|
||||
# WeChat Work
|
||||
WECOM_CORP_ID: str = ""
|
||||
@@ -36,13 +39,36 @@ class Settings(BaseSettings):
|
||||
WECOM_SECRET: str = ""
|
||||
WECOM_TOKEN: str = ""
|
||||
WECOM_ENCODING_AES_KEY: str = ""
|
||||
WECOM_API_BASE: str = "https://qyapi.weixin.qq.com"
|
||||
|
||||
# AI / LLM (OpenAI-compatible)
|
||||
AI_API_URL: str = ""
|
||||
AI_API_KEY: str = ""
|
||||
AI_MODEL: str = "gpt-4o"
|
||||
AI_MAX_TOKENS: int = 2000
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
def validate_security_settings() -> None:
|
||||
"""Reject unsafe or incomplete configuration before the application starts."""
|
||||
if settings.ENVIRONMENT.lower() == "development":
|
||||
return
|
||||
|
||||
invalid = []
|
||||
if len(settings.SECRET_KEY) < 32 or settings.SECRET_KEY in {"change-me-in-production", ""}:
|
||||
invalid.append("SECRET_KEY")
|
||||
if not settings.DATABASE_URL or "postgres:postgres@" in settings.DATABASE_URL or ":invalid@" in settings.DATABASE_URL:
|
||||
invalid.append("DATABASE_URL")
|
||||
if not settings.MINIO_ENDPOINT or not settings.MINIO_ACCESS_KEY or not settings.MINIO_SECRET_KEY:
|
||||
invalid.append("MINIO configuration")
|
||||
if settings.MINIO_ACCESS_KEY == "minioadmin" or settings.MINIO_SECRET_KEY == "minioadmin":
|
||||
invalid.append("MINIO default credentials")
|
||||
if not settings.MINIO_SECURE:
|
||||
invalid.append("MINIO_SECURE")
|
||||
|
||||
if invalid:
|
||||
raise RuntimeError(f"Unsafe production configuration: {', '.join(invalid)}")
|
||||
|
||||
+35
-22
@@ -1,35 +1,43 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.config import settings
|
||||
from app.database import engine, Base
|
||||
from sqlalchemy import select
|
||||
from app.config import settings, validate_security_settings
|
||||
from app.database import engine, async_session
|
||||
from app.api import router as api_router
|
||||
from app.api import auth, users, customers, visits, work_plans, mini_business, key_visits
|
||||
from app.api import dashboard, upload, export, import_data, wecom, daily_notes
|
||||
from app.api import auth, users, customers, visits, work_plans, mini_business, mini_business_logs, key_visits
|
||||
from app.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary, system_config, leaves, audit
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.services.holidays import refresh_holidays
|
||||
from app.services.scheduler_manager import start_scheduler, shutdown_scheduler
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup: create tables if not exists (for dev convenience)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# Add columns that may be missing from older tables
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS remarks TEXT DEFAULT ''"
|
||||
))
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_name VARCHAR(50) DEFAULT ''"
|
||||
))
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_phone VARCHAR(20) DEFAULT ''"
|
||||
))
|
||||
# edit_log columns for change tracking
|
||||
for tbl in ["visits", "daily_notes", "work_plans", "mini_business", "key_visits"]:
|
||||
await conn.run_sync(lambda c, t=tbl: c.exec_driver_sql(
|
||||
f"ALTER TABLE {t} ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'"
|
||||
))
|
||||
validate_security_settings()
|
||||
# Read notification_time from DB (or use default 17:30)
|
||||
notification_hour, notification_minute = 17, 30
|
||||
async with async_session() as db:
|
||||
row = await db.get(SystemConfig, "notification_time")
|
||||
if row and row.value:
|
||||
try:
|
||||
parts = row.value.strip().split(":")
|
||||
notification_hour, notification_minute = int(parts[0]), int(parts[1])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
# Auto-refresh Chinese holiday data from API on startup
|
||||
try:
|
||||
await refresh_holidays(db)
|
||||
except Exception:
|
||||
pass # Use cached data if API unavailable
|
||||
|
||||
# Start daily reporting scheduler
|
||||
start_scheduler(notification_hour, notification_minute)
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
shutdown_scheduler()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@@ -55,13 +63,18 @@ app.include_router(customers.router, prefix="/api")
|
||||
app.include_router(visits.router, prefix="/api")
|
||||
app.include_router(work_plans.router, prefix="/api")
|
||||
app.include_router(mini_business.router, prefix="/api")
|
||||
app.include_router(mini_business_logs.router, prefix="/api")
|
||||
app.include_router(key_visits.router, prefix="/api")
|
||||
app.include_router(dashboard.router, prefix="/api")
|
||||
app.include_router(leaves.router, prefix="/api")
|
||||
app.include_router(upload.router, prefix="/api")
|
||||
app.include_router(export.router, prefix="/api")
|
||||
app.include_router(import_data.router, prefix="/api")
|
||||
app.include_router(wecom.router, prefix="/api")
|
||||
app.include_router(daily_notes.router, prefix="/api")
|
||||
app.include_router(ai_summary.router, prefix="/api")
|
||||
app.include_router(system_config.router, prefix="/api")
|
||||
app.include_router(audit.router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -7,6 +7,11 @@ from app.models.work_plan import WorkPlan
|
||||
from app.models.mini_business import MiniBusiness
|
||||
from app.models.key_visit import KeyVisit
|
||||
from app.models.daily_note import DailyNote
|
||||
from app.models.ai_summary import AISummary
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.models.leave import Leave
|
||||
from app.models.wecom_bind_token import WecomBindToken
|
||||
from app.models.mini_business_log import MiniBusinessLog
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -16,6 +21,11 @@ __all__ = [
|
||||
"Visit",
|
||||
"WorkPlan",
|
||||
"MiniBusiness",
|
||||
"MiniBusinessLog",
|
||||
"KeyVisit",
|
||||
"DailyNote",
|
||||
"AISummary",
|
||||
"SystemConfig",
|
||||
"Leave",
|
||||
"WecomBindToken",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""AI-generated weekly report summary — cached per week per user."""
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from sqlalchemy import String, Text, DateTime, Date, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AISummary(Base):
|
||||
__tablename__ = "ai_summaries"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
week_start: Mapped[date] = mapped_column(Date, index=True)
|
||||
week_end: Mapped[date] = mapped_column(Date)
|
||||
period: Mapped[str] = mapped_column(String(10), default="week") # 'week' / 'month'
|
||||
generated_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True)
|
||||
role: Mapped[str] = mapped_column(String(20))
|
||||
summary: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,21 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
entity_type: Mapped[str] = mapped_column(String(30))
|
||||
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
entity_name: Mapped[str] = mapped_column(String(200), default="")
|
||||
action: Mapped[str] = mapped_column(String(10)) # create / update / delete
|
||||
operator_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
operator_name: Mapped[str] = mapped_column(String(50), default="")
|
||||
summary: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
details: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -1,6 +1,6 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Text, DateTime, func, ForeignKey
|
||||
from datetime import date, datetime
|
||||
from sqlalchemy import String, Text, Date, DateTime, Boolean, func, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.database import Base
|
||||
@@ -11,6 +11,7 @@ class Customer(Base):
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(200), index=True)
|
||||
customer_type: Mapped[str] = mapped_column(String(20), default="unit")
|
||||
industry: Mapped[str] = mapped_column(String(100), default="")
|
||||
address: Mapped[str] = mapped_column(String(500), default="")
|
||||
in_use_services: Mapped[str] = mapped_column(Text, default="")
|
||||
@@ -19,6 +20,8 @@ class Customer(Base):
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
last_visit_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
last_visit_manager_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
|
||||
contacts: Mapped[list["CustomerContact"]] = relationship("CustomerContact", back_populates="customer", cascade="all, delete-orphan")
|
||||
assignments: Mapped[list["CustomerAssignment"]] = relationship("CustomerAssignment", back_populates="customer", cascade="all, delete-orphan")
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from sqlalchemy import String, Date, DateTime, ForeignKey, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Leave(Base):
|
||||
__tablename__ = "leaves"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
manager_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id"), index=True
|
||||
)
|
||||
leave_type: Mapped[str] = mapped_column(String(20), default="事假")
|
||||
start_date: Mapped[date] = mapped_column(Date)
|
||||
end_date: Mapped[date] = mapped_column(Date)
|
||||
reason: Mapped[str] = mapped_column(String(500), default="")
|
||||
submitted_by: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id")
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -1,9 +1,13 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
from sqlalchemy import String, Text, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from app.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.mini_business_log import MiniBusinessLog
|
||||
|
||||
|
||||
class MiniBusiness(Base):
|
||||
__tablename__ = "mini_business"
|
||||
@@ -17,3 +21,5 @@ class MiniBusiness(Base):
|
||||
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
|
||||
expected_revenue_date: Mapped[str] = mapped_column(String(50), default="")
|
||||
edit_log: Mapped[list] = mapped_column(JSONB, default=list)
|
||||
|
||||
logs: Mapped[list["MiniBusinessLog"]] = relationship(back_populates="business", cascade="all, delete-orphan")
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""MiniBusinessLog — follow-up log entries for mini business opportunities."""
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from sqlalchemy import String, Date, DateTime, ForeignKey, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class MiniBusinessLog(Base):
|
||||
__tablename__ = "mini_business_logs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
business_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("mini_business.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
log_date: Mapped[date] = mapped_column(Date)
|
||||
method: Mapped[str] = mapped_column(String(20), default="电话")
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id")
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
business: Mapped["MiniBusiness"] = relationship(back_populates="logs")
|
||||
@@ -0,0 +1,12 @@
|
||||
"""System-wide configuration key-value store."""
|
||||
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class SystemConfig(Base):
|
||||
__tablename__ = "system_config"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
value: Mapped[str] = mapped_column(Text, default="")
|
||||
@@ -15,4 +15,7 @@ class User(Base):
|
||||
role: Mapped[str] = mapped_column(String(30)) # manager / director / leader
|
||||
department: Mapped[str] = mapped_column(String(100), default="")
|
||||
wecom_userid: Mapped[str | None] = mapped_column(String(100), unique=True, nullable=True)
|
||||
require_report: Mapped[bool] = mapped_column(default=True, server_default="true")
|
||||
color: Mapped[str | None] = mapped_column(String(7), nullable=True) # e.g. "#C62828"
|
||||
theme: Mapped[str] = mapped_column(String(20), default="editorial", server_default="editorial") # editorial / light
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
@@ -19,8 +19,10 @@ class Visit(Base):
|
||||
visitor_name: Mapped[str] = mapped_column(String(50), default="")
|
||||
visitor_phone: Mapped[str] = mapped_column(String(20), default="")
|
||||
companions: Mapped[list | None] = mapped_column(ARRAY(UUID(as_uuid=True)), nullable=True)
|
||||
companion_names: Mapped[list] = mapped_column(ARRAY(Text), default=list)
|
||||
photos: Mapped[list | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
|
||||
visit_group_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True)
|
||||
edit_log: Mapped[list] = mapped_column(JSONB, default=list)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class WecomBindToken(Base):
|
||||
"""A single-use, expiring binding token issued after a verified WeCom callback."""
|
||||
|
||||
__tablename__ = "wecom_bind_tokens"
|
||||
|
||||
token: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
wecom_userid: Mapped[str] = mapped_column(String(100), index=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -19,6 +19,8 @@ class UserOut(BaseModel):
|
||||
role: str
|
||||
department: str
|
||||
wecom_userid: Optional[str] = None
|
||||
require_report: bool = True
|
||||
color: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -43,6 +45,7 @@ class ContactOut(BaseModel):
|
||||
# ── Customer ──
|
||||
class CustomerCreate(BaseModel):
|
||||
name: str
|
||||
customer_type: str = "unit"
|
||||
industry: str = ""
|
||||
address: str = ""
|
||||
in_use_services: str = ""
|
||||
@@ -54,6 +57,7 @@ class CustomerCreate(BaseModel):
|
||||
|
||||
class CustomerUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
customer_type: Optional[str] = None
|
||||
industry: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
in_use_services: Optional[str] = None
|
||||
@@ -65,6 +69,7 @@ class CustomerUpdate(BaseModel):
|
||||
class CustomerOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
customer_type: str = "unit"
|
||||
industry: str
|
||||
address: str
|
||||
in_use_services: str
|
||||
@@ -82,6 +87,7 @@ class CustomerOut(BaseModel):
|
||||
class CustomerListOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
customer_type: str = "unit"
|
||||
industry: str
|
||||
in_use_services: str
|
||||
primary_manager_name: Optional[str] = None
|
||||
|
||||
@@ -9,6 +9,7 @@ class DailyNoteCreate(BaseModel):
|
||||
category: str = "其他"
|
||||
content: str = ""
|
||||
time_range: str = ""
|
||||
manager_id: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class DailyNoteUpdate(BaseModel):
|
||||
@@ -16,6 +17,7 @@ class DailyNoteUpdate(BaseModel):
|
||||
category: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
time_range: Optional[str] = None
|
||||
manager_id: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class DailyNoteOut(BaseModel):
|
||||
|
||||
@@ -11,6 +11,7 @@ class KeyVisitCreate(BaseModel):
|
||||
planned_date: str = ""
|
||||
planned_visitor: str = ""
|
||||
visit_target: str = ""
|
||||
manager_id: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class KeyVisitUpdate(BaseModel):
|
||||
@@ -21,6 +22,7 @@ class KeyVisitUpdate(BaseModel):
|
||||
planned_date: Optional[str] = None
|
||||
planned_visitor: Optional[str] = None
|
||||
visit_target: Optional[str] = None
|
||||
manager_id: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class KeyVisitOut(BaseModel):
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
from datetime import date, datetime
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
LEAVE_TYPES = ["年假", "事假", "病假", "调休", "其他"]
|
||||
|
||||
|
||||
class LeaveCreate(BaseModel):
|
||||
manager_id: UUID
|
||||
leave_type: str = Field(default="事假", pattern="^(年假|事假|病假|调休|其他)$")
|
||||
start_date: date
|
||||
end_date: date
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class LeaveUpdate(BaseModel):
|
||||
manager_id: UUID | None = None
|
||||
leave_type: str | None = Field(default=None, pattern="^(年假|事假|病假|调休|其他)$")
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class LeaveOut(BaseModel):
|
||||
id: UUID
|
||||
manager_id: UUID
|
||||
manager_name: str = ""
|
||||
leave_type: str
|
||||
start_date: date
|
||||
end_date: date
|
||||
days: int = 0
|
||||
reason: str = ""
|
||||
submitted_by: UUID
|
||||
submitted_by_name: str = ""
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class LeaveOverviewItem(BaseModel):
|
||||
manager_id: UUID
|
||||
manager_name: str
|
||||
leave_type: str
|
||||
start_date: date
|
||||
end_date: date
|
||||
days: int
|
||||
|
||||
|
||||
class LeaveOverview(BaseModel):
|
||||
week_start: str
|
||||
week_end: str
|
||||
total_on_leave: int
|
||||
leave_list: list[LeaveOverviewItem]
|
||||
@@ -10,6 +10,7 @@ class MiniBusinessCreate(BaseModel):
|
||||
follow_up_detail: str = ""
|
||||
status: str = "跟进中"
|
||||
expected_revenue_date: str = ""
|
||||
manager_id: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class MiniBusinessUpdate(BaseModel):
|
||||
@@ -19,6 +20,7 @@ class MiniBusinessUpdate(BaseModel):
|
||||
follow_up_detail: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
expected_revenue_date: Optional[str] = None
|
||||
manager_id: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class MiniBusinessOut(BaseModel):
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""MiniBusinessLog Pydantic schemas."""
|
||||
|
||||
from datetime import date, datetime
|
||||
from uuid import UUID
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
FOLLOW_UP_METHODS = ["电话", "微信", "上门", "邮件", "其他"]
|
||||
|
||||
|
||||
class MiniBusinessLogCreate(BaseModel):
|
||||
log_date: date
|
||||
method: str = Field(default="电话", pattern="^(电话|微信|上门|邮件|其他)$")
|
||||
content: str = ""
|
||||
|
||||
|
||||
class MiniBusinessLogUpdate(BaseModel):
|
||||
log_date: Optional[date] = None
|
||||
method: Optional[str] = Field(default=None, pattern="^(电话|微信|上门|邮件|其他)$")
|
||||
content: Optional[str] = None
|
||||
|
||||
|
||||
class MiniBusinessLogOut(BaseModel):
|
||||
id: UUID
|
||||
business_id: UUID
|
||||
log_date: date
|
||||
method: str
|
||||
content: str
|
||||
created_by: UUID
|
||||
created_by_name: str = ""
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -9,6 +9,7 @@ class TokenResponse(BaseModel):
|
||||
user_id: str
|
||||
name: str
|
||||
role: str
|
||||
theme: str = "editorial"
|
||||
|
||||
|
||||
class WecomLoginRequest(BaseModel):
|
||||
|
||||
@@ -14,7 +14,9 @@ class VisitCreate(BaseModel):
|
||||
communication_content: str = ""
|
||||
customer_demand: str = ""
|
||||
companions: list[uuid.UUID] = []
|
||||
companion_names: list[str] = []
|
||||
photos: list[str] = []
|
||||
manager_id: Optional[uuid.UUID] = None # director/leader can specify
|
||||
|
||||
|
||||
class VisitUpdate(BaseModel):
|
||||
@@ -27,7 +29,10 @@ class VisitUpdate(BaseModel):
|
||||
communication_content: Optional[str] = None
|
||||
customer_demand: Optional[str] = None
|
||||
companions: Optional[list[uuid.UUID]] = None
|
||||
manager_id: Optional[uuid.UUID] = None
|
||||
companion_names: Optional[list[str]] = None
|
||||
photos: Optional[list[str]] = None
|
||||
manager_id: Optional[str] = None
|
||||
|
||||
|
||||
class VisitOut(BaseModel):
|
||||
@@ -41,8 +46,10 @@ class VisitOut(BaseModel):
|
||||
communication_content: str
|
||||
customer_demand: str
|
||||
companions: Optional[list[uuid.UUID]] = None
|
||||
companion_names: list[str] = []
|
||||
photos: Optional[list[str]] = None
|
||||
manager_id: uuid.UUID
|
||||
visit_group_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
# Joined fields
|
||||
|
||||
@@ -9,6 +9,7 @@ class WorkPlanCreate(BaseModel):
|
||||
plan_content: str = ""
|
||||
plan_date: str # "YYYY-MM-DD"
|
||||
status: str = "计划中"
|
||||
manager_id: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class WorkPlanUpdate(BaseModel):
|
||||
@@ -16,6 +17,7 @@ class WorkPlanUpdate(BaseModel):
|
||||
plan_content: Optional[str] = None
|
||||
plan_date: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
manager_id: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class WorkPlanOut(BaseModel):
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
"""AI-powered weekly report summary using an OpenAI-compatible LLM."""
|
||||
|
||||
import json
|
||||
import httpx
|
||||
from app.config import settings
|
||||
from app.services.dashboard import get_weekly_report, get_week_range
|
||||
from app.services.light_board import get_light_board
|
||||
from app.models.ai_summary import AISummary
|
||||
from app.models.system_config import SystemConfig
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from uuid import UUID
|
||||
from datetime import date, datetime
|
||||
|
||||
|
||||
SUMMARY_SYSTEM_PROMPT = """你是一位经验丰富的政企客户经理团队的周报分析助手。你的分析将被直接提交给支局长作为工作周报的文字摘要。
|
||||
|
||||
## 约束
|
||||
- 严格基于提供的数据进行分析,绝不编造数据中不存在的信息
|
||||
- 使用正式但不生硬的中文,适合放入政企工作周报
|
||||
- 每条分析简洁有力,1-2句话即可,避免空泛套话
|
||||
- 如果某个结论是基于数据推断的,请使用"数据显示""从本周情况看"等表述
|
||||
- 对于覆盖不足的情况,请明确指出具体客户名称和负责人,方便支局长跟进
|
||||
|
||||
## 输出格式(使用 Markdown)
|
||||
|
||||
### 一、本周概况
|
||||
[2-3句话,涵盖:拜访总量、覆盖客户数、团队参与情况、拜访方式分布]
|
||||
|
||||
### 二、拜访重点与客户需求
|
||||
[2-3个值得关注的客户需求或沟通内容要点,有具体客户名称]
|
||||
|
||||
### 三、客户覆盖分析
|
||||
[引用覆盖数据,明确指出:覆盖率、低于60%的经理、红灯客户名单、需要关注的客户]
|
||||
|
||||
### 四、下周建议
|
||||
[2-3条针对性的工作建议,基于数据中暴露的问题和客户需求]"""
|
||||
|
||||
|
||||
def build_summary_prompt(
|
||||
weekly_report: dict,
|
||||
light_board: dict,
|
||||
period: str,
|
||||
reference_date: str,
|
||||
leaves: list | None = None,
|
||||
) -> str:
|
||||
"""Build the user prompt with structured visit data for the LLM."""
|
||||
|
||||
# ── Summary stats ──
|
||||
visits = weekly_report.get("visits", [])
|
||||
daily_notes = weekly_report.get("daily_notes", [])
|
||||
managers_involved: set[str] = set()
|
||||
customers_visited: set[str] = set()
|
||||
methods: dict[str, int] = {}
|
||||
demands: list[str] = []
|
||||
|
||||
for v in visits:
|
||||
managers_involved.add(v.get("manager_name", ""))
|
||||
customers_visited.add(v.get("customer_name", ""))
|
||||
method = v.get("visit_method", "")
|
||||
methods[method] = methods.get(method, 0) + 1
|
||||
demand = v.get("customer_demand", "")
|
||||
if demand and demand.strip():
|
||||
demands.append(f"{v.get('customer_name', '未知')}: {demand.strip()}")
|
||||
|
||||
# Manager breakdown
|
||||
manager_visits: dict[str, list] = {}
|
||||
for v in visits:
|
||||
mn = v.get("manager_name", "未知")
|
||||
if mn not in manager_visits:
|
||||
manager_visits[mn] = []
|
||||
manager_visits[mn].append({
|
||||
"client": v.get("customer_name", ""),
|
||||
"method": v.get("visit_method", ""),
|
||||
"content": (v.get("communication_content", "") or "")[:120],
|
||||
"demand": v.get("customer_demand", "") or "",
|
||||
})
|
||||
|
||||
# Build the data block
|
||||
data_block = f"""## 基本信息
|
||||
- 分析周期:{period}
|
||||
- 参考日期:{reference_date}
|
||||
- 周范围:{weekly_report.get('week_start', '')} — {weekly_report.get('week_end', '')}
|
||||
|
||||
## 拜访总览
|
||||
- 拜访记录总数:{len(visits)}
|
||||
- 覆盖客户数:{len(customers_visited)}
|
||||
- 参与经理数:{len(managers_involved)}
|
||||
- 拜访方式分布:{json.dumps(methods, ensure_ascii=False)}
|
||||
|
||||
## 各客户经理拜访明细
|
||||
"""
|
||||
for mn, items in manager_visits.items():
|
||||
data_block += f"\n### {mn}({len(items)}条)\n"
|
||||
for item in items[:10]: # cap per manager
|
||||
data_block += f"- {item['method']}拜访 {item['client']}"
|
||||
if item['content']:
|
||||
data_block += f" — {item['content'][:100]}"
|
||||
if item['demand']:
|
||||
data_block += f" [需求: {item['demand'][:80]}]"
|
||||
data_block += "\n"
|
||||
|
||||
# Customer demands
|
||||
if demands:
|
||||
data_block += "\n## 客户需求汇总\n"
|
||||
for d in demands[:15]:
|
||||
data_block += f"- {d[:200]}\n"
|
||||
|
||||
# Daily notes summary
|
||||
notes_by_cat: dict[str, int] = {}
|
||||
for n in daily_notes:
|
||||
cat = n.get("category", "其他")
|
||||
notes_by_cat[cat] = notes_by_cat.get(cat, 0) + 1
|
||||
if notes_by_cat:
|
||||
data_block += "\n## 纪要分类统计\n"
|
||||
data_block += json.dumps(notes_by_cat, ensure_ascii=False) + "\n"
|
||||
|
||||
# Light board data
|
||||
team = light_board.get("team_summary", {})
|
||||
data_block += f"""
|
||||
## 客户覆盖数据(亮灯表)
|
||||
- 团队总客户数:{team.get('total_customers', 0)}
|
||||
- 本月已拜访(绿灯):{team.get('visited_this_month', 0)}
|
||||
- 仅上月拜访(黄灯):{team.get('visited_last_month_only', 0)}
|
||||
- 连续未拜访(红灯):{team.get('not_visited_2months', 0)}
|
||||
- 未分配客户:{team.get('unassigned', 0)}
|
||||
- 整体覆盖率:{team.get('coverage_rate', 0) * 100:.1f}%
|
||||
|
||||
### 各经理覆盖率
|
||||
"""
|
||||
for m in light_board.get("managers", []):
|
||||
data_block += (
|
||||
f"- {m['manager_name']}: {m['coverage_rate'] * 100:.0f}% "
|
||||
f"({m['visited_this_month']}/{m['total_customers']}) "
|
||||
f"🟢{m['visited_this_month']} 🟡{m['visited_last_month_only']} 🔴{m['not_visited_2months']}\n"
|
||||
)
|
||||
# List red customers
|
||||
red_customers = [c for c in m.get("customers", []) if c["status"] == "red"]
|
||||
if red_customers:
|
||||
data_block += " 红灯客户:\n"
|
||||
for rc in red_customers[:5]:
|
||||
lvd = rc.get("last_visit_date") or "从未"
|
||||
data_block += f" - {rc['customer_name']}(上次拜访: {lvd})\n"
|
||||
|
||||
# ── Leave data ──
|
||||
if leaves:
|
||||
data_block += "\n## 本周请假情况\n"
|
||||
for lv in leaves:
|
||||
data_block += (
|
||||
f"- {lv.get('manager_name', '未知')} ({lv.get('leave_type', '请假')}): "
|
||||
f"{lv.get('start_date', '')} ~ {lv.get('end_date', '')} "
|
||||
f"({lv.get('days', 0)}天)\n"
|
||||
)
|
||||
else:
|
||||
data_block += "\n## 本周请假情况\n无请假记录\n"
|
||||
|
||||
return data_block
|
||||
|
||||
|
||||
async def get_cached_summary(
|
||||
db: AsyncSession,
|
||||
user_id: UUID,
|
||||
reference_date: date | None = None,
|
||||
period: str = "week",
|
||||
) -> dict | None:
|
||||
"""Load a previously generated summary for this week/user."""
|
||||
ref = reference_date or date.today()
|
||||
monday, sunday = get_week_range(ref)
|
||||
result = await db.execute(
|
||||
select(AISummary)
|
||||
.where(
|
||||
AISummary.week_start == monday,
|
||||
AISummary.period == period,
|
||||
AISummary.generated_by == user_id,
|
||||
)
|
||||
.order_by(AISummary.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
row = result.scalar()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"summary": row.summary,
|
||||
"week_start": str(row.week_start),
|
||||
"week_end": str(row.week_end),
|
||||
"period": row.period,
|
||||
"created_at": str(row.created_at),
|
||||
"cached": True,
|
||||
}
|
||||
|
||||
|
||||
async def delete_cached_summary(
|
||||
db: AsyncSession,
|
||||
user_id: UUID,
|
||||
reference_date: date | None = None,
|
||||
period: str = "week",
|
||||
) -> bool:
|
||||
"""Delete a cached summary so it can be regenerated."""
|
||||
ref = reference_date or date.today()
|
||||
monday, sunday = get_week_range(ref)
|
||||
result = await db.execute(
|
||||
select(AISummary).where(
|
||||
AISummary.week_start == monday,
|
||||
AISummary.period == period,
|
||||
AISummary.generated_by == user_id,
|
||||
)
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
for row in rows:
|
||||
await db.delete(row)
|
||||
if rows:
|
||||
await db.commit()
|
||||
return len(rows) > 0
|
||||
|
||||
|
||||
async def generate_summary(
|
||||
db: AsyncSession,
|
||||
user_id: UUID,
|
||||
role: str,
|
||||
reference_date: date | None = None,
|
||||
period: str = "week",
|
||||
) -> dict:
|
||||
"""Generate an AI-powered weekly summary, save to DB, return it.
|
||||
|
||||
Raises ValueError if AI config is missing, httpx.HTTPError on API failure.
|
||||
"""
|
||||
if not settings.AI_API_URL:
|
||||
raise ValueError("AI_API_URL not configured")
|
||||
|
||||
# Gather data
|
||||
ref = reference_date or date.today()
|
||||
monday, sunday = get_week_range(ref)
|
||||
weekly_report = await get_weekly_report(
|
||||
db=db, user_id=user_id, role=role, reference_date=ref,
|
||||
)
|
||||
light_board = await get_light_board(db, ref, user_id, role)
|
||||
from app.services.leaves import get_leave_overview
|
||||
leave_overview = await get_leave_overview(db, str(user_id), role, ref)
|
||||
user_prompt = build_summary_prompt(
|
||||
weekly_report, light_board, period, str(ref),
|
||||
leaves=leave_overview.get("leave_list", []),
|
||||
)
|
||||
|
||||
# Load custom system prompt (director/leader can override via system settings)
|
||||
custom_row = await db.get(SystemConfig, "ai_summary_prompt")
|
||||
system_prompt = SUMMARY_SYSTEM_PROMPT
|
||||
if custom_row and custom_row.value and custom_row.value.strip():
|
||||
system_prompt = custom_row.value.strip()
|
||||
|
||||
# Call LLM
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if settings.AI_API_KEY:
|
||||
headers["Authorization"] = f"Bearer {settings.AI_API_KEY}"
|
||||
|
||||
payload = {
|
||||
"model": settings.AI_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
"max_tokens": settings.AI_MAX_TOKENS,
|
||||
"temperature": 0.3,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=90.0) as client:
|
||||
resp = await client.post(settings.AI_API_URL, json=payload, headers=headers)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
|
||||
content = result.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
if not content:
|
||||
raise ValueError("AI returned empty response")
|
||||
|
||||
# Delete old cached entry for this week/user, then save new
|
||||
await delete_cached_summary(db, user_id, reference_date=ref, period=period)
|
||||
row = AISummary(
|
||||
week_start=monday,
|
||||
week_end=sunday,
|
||||
period=period,
|
||||
generated_by=user_id,
|
||||
role=role,
|
||||
summary=content,
|
||||
)
|
||||
db.add(row)
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"summary": content,
|
||||
"week_start": str(monday),
|
||||
"week_end": str(sunday),
|
||||
"period": period,
|
||||
"created_at": str(row.created_at),
|
||||
"cached": False,
|
||||
}
|
||||
@@ -49,12 +49,12 @@ async def exchange_casdoor_code(code: str) -> dict | None:
|
||||
"code": code,
|
||||
}, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"[Casdoor] token exchange failed: status={resp.status_code}, body={resp.text[:500]}")
|
||||
logger.error("[Casdoor] token exchange failed: status=%s", resp.status_code)
|
||||
return None
|
||||
token_data = resp.json()
|
||||
access_token = token_data.get("access_token", "")
|
||||
if not access_token:
|
||||
logger.error(f"[Casdoor] no access_token in response: {token_data}")
|
||||
logger.error("[Casdoor] token exchange response did not contain an access token")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"[Casdoor] token exchange exception: {e}")
|
||||
@@ -67,7 +67,7 @@ async def exchange_casdoor_code(code: str) -> dict | None:
|
||||
"Authorization": f"Bearer {access_token}"
|
||||
}, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"[Casdoor] userinfo failed: status={resp.status_code}, body={resp.text[:500]}")
|
||||
logger.error("[Casdoor] userinfo failed: status=%s", resp.status_code)
|
||||
return None
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
@@ -103,4 +103,5 @@ def build_token_for_user(user: User) -> str:
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"department": user.department,
|
||||
"theme": user.theme or "editorial",
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import date, timedelta
|
||||
from uuid import UUID
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import aliased
|
||||
from app.models.customer import Customer
|
||||
@@ -10,7 +10,9 @@ from app.models.work_plan import WorkPlan
|
||||
from app.models.mini_business import MiniBusiness
|
||||
from app.models.key_visit import KeyVisit
|
||||
from app.models.daily_note import DailyNote
|
||||
from app.utils.timezone import today_cst
|
||||
from app.models.leave import Leave
|
||||
from app.services.holidays import load_holiday_sets
|
||||
from app.utils.timezone import today_cst, is_working_day, parse_date
|
||||
|
||||
|
||||
def get_week_range(reference_date: date | None = None):
|
||||
@@ -20,13 +22,14 @@ def get_week_range(reference_date: date | None = None):
|
||||
return monday, sunday
|
||||
|
||||
|
||||
async def get_dashboard_stats(db: AsyncSession, reference_date: date | None = None) -> dict:
|
||||
async def get_dashboard_stats(db: AsyncSession, reference_date: date | None = None, user_id: str = "", role: str = "") -> dict:
|
||||
"""Get dashboard statistics for a given week (defaults to current)."""
|
||||
monday, sunday = get_week_range(reference_date)
|
||||
today = date.today()
|
||||
|
||||
visits_count = (await db.execute(
|
||||
select(func.count(Visit.id)).where(Visit.visit_date >= monday, Visit.visit_date <= sunday)
|
||||
select(func.count(func.distinct(func.coalesce(Visit.visit_group_id, Visit.id))))
|
||||
.where(Visit.visit_date >= monday, Visit.visit_date <= sunday)
|
||||
)).scalar() or 0
|
||||
|
||||
plans_count = (await db.execute(
|
||||
@@ -41,11 +44,29 @@ async def get_dashboard_stats(db: AsyncSession, reference_date: date | None = No
|
||||
select(func.count(KeyVisit.id))
|
||||
)).scalar() or 0
|
||||
|
||||
# Week leave count — distinct managers on leave this week
|
||||
leaves_count = (await db.execute(
|
||||
select(func.count(func.distinct(Leave.manager_id))).where(
|
||||
and_(Leave.start_date <= sunday, Leave.end_date >= monday)
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
# Overdue plans (status=计划中, plan_date < today). Managers see only own.
|
||||
overdue_q = select(func.count(WorkPlan.id)).where(
|
||||
WorkPlan.status == "计划中",
|
||||
WorkPlan.plan_date < today,
|
||||
)
|
||||
if role == "manager":
|
||||
overdue_q = overdue_q.where(WorkPlan.manager_id == UUID(user_id))
|
||||
overdue_plans = (await db.execute(overdue_q)).scalar() or 0
|
||||
|
||||
return {
|
||||
"week_visits": visits_count,
|
||||
"work_plans": plans_count,
|
||||
"mini_business": mini_biz_count,
|
||||
"key_visits": key_visit_count,
|
||||
"week_leaves": leaves_count,
|
||||
"overdue_plans": overdue_plans,
|
||||
"week_start": str(monday),
|
||||
"week_end": str(sunday),
|
||||
}
|
||||
@@ -55,10 +76,11 @@ async def get_reporting_progress(db: AsyncSession, reference_date: date | None =
|
||||
"""Get per-manager reporting progress. Managers only see themselves."""
|
||||
monday, sunday = get_week_range(reference_date)
|
||||
|
||||
managers_result = await db.execute(select(User).where(User.role == "manager"))
|
||||
all_managers = managers_result.scalars().all()
|
||||
# Get all users who need to report (any role, filtered by require_report)
|
||||
managers_result = await db.execute(select(User).where(User.require_report == True))
|
||||
all_reporters = managers_result.scalars().all()
|
||||
# Filter: managers only see themselves
|
||||
managers = all_managers if role in ("director", "leader") else [m for m in all_managers if str(m.id) == user_id]
|
||||
reporters = all_reporters if role in ("director", "leader") else [m for m in all_reporters if str(m.id) == user_id]
|
||||
|
||||
# Get visit counts per manager this week
|
||||
visits_result = await db.execute(
|
||||
@@ -68,27 +90,56 @@ async def get_reporting_progress(db: AsyncSession, reference_date: date | None =
|
||||
)
|
||||
visit_map = {str(uid): cnt for uid, cnt in visits_result.all()}
|
||||
|
||||
# Load holiday data (auto-refreshes from API if not cached)
|
||||
holidays, workdays = await load_holiday_sets(db)
|
||||
|
||||
# Query leave records for today — build a set of managers on leave
|
||||
today = today_cst()
|
||||
is_rest = not is_working_day(today, holidays, workdays)
|
||||
|
||||
leaves_result = await db.execute(
|
||||
select(Leave).where(and_(
|
||||
Leave.start_date <= today,
|
||||
Leave.end_date >= today,
|
||||
))
|
||||
)
|
||||
on_leave_today = {str(r.manager_id): r for r in leaves_result.scalars().all()}
|
||||
|
||||
progress = []
|
||||
for m in managers:
|
||||
for m in reporters:
|
||||
count = visit_map.get(str(m.id), 0)
|
||||
# Calculate expected working days (Mon-Fri)
|
||||
days_passed = min((date.today() - monday).days + 1, 5)
|
||||
expected = days_passed # At least 1 visit per working day
|
||||
|
||||
mid = str(m.id)
|
||||
leave = on_leave_today.get(mid)
|
||||
|
||||
progress.append({
|
||||
"manager_id": str(m.id),
|
||||
"manager_id": mid,
|
||||
"manager_name": m.name,
|
||||
"department": m.department,
|
||||
"visit_count": count,
|
||||
"expected": expected,
|
||||
"completed": count >= expected,
|
||||
"has_reported_today": False, # Will be set below
|
||||
"on_leave": leave is not None,
|
||||
"is_rest_day": is_rest,
|
||||
"leave_info": {
|
||||
"leave_type": leave.leave_type,
|
||||
"start_date": str(leave.start_date),
|
||||
"end_date": str(leave.end_date),
|
||||
} if leave else None,
|
||||
})
|
||||
|
||||
# Check today's reporting — visits OR daily notes
|
||||
today = today_cst()
|
||||
today_visits = await db.execute(
|
||||
select(Visit.manager_id).where(Visit.visit_date == today)
|
||||
# Check today's reporting — visits (direct + companions) OR daily notes
|
||||
from sqlalchemy import union_all
|
||||
today_direct = select(Visit.manager_id).where(Visit.visit_date == today)
|
||||
today_companion = select(func.unnest(Visit.companions).label("manager_id")).where(
|
||||
Visit.visit_date == today, Visit.companions.isnot(None)
|
||||
)
|
||||
today_combined = union_all(today_direct, today_companion).subquery()
|
||||
today_visits = await db.execute(select(today_combined.c.manager_id))
|
||||
today_notes = await db.execute(
|
||||
select(DailyNote.manager_id).where(DailyNote.note_date == today)
|
||||
)
|
||||
@@ -126,32 +177,75 @@ async def get_weekly_report(
|
||||
# ── Visits ──
|
||||
visit_query = select(Visit).where(Visit.visit_date >= monday, Visit.visit_date <= sunday)
|
||||
if role == "manager":
|
||||
visit_query = visit_query.where(Visit.manager_id == user_id)
|
||||
visit_query = visit_query.where(
|
||||
(Visit.manager_id == user_id) | (Visit.companions.any(user_id))
|
||||
)
|
||||
elif filter_manager_id:
|
||||
visit_query = visit_query.where(Visit.manager_id == filter_manager_id)
|
||||
visit_query = visit_query.where(
|
||||
(Visit.manager_id == filter_manager_id) | (Visit.companions.any(filter_manager_id))
|
||||
)
|
||||
if filter_customer_id:
|
||||
visit_query = visit_query.where(Visit.customer_id == filter_customer_id)
|
||||
visit_query = visit_query.order_by(Visit.visit_date.desc())
|
||||
visits_result = await db.execute(visit_query)
|
||||
visits = visits_result.scalars().all()
|
||||
|
||||
visits_data = []
|
||||
# Group visits by visit_group_id (or id for solo visits)
|
||||
groups: dict[str, list] = {}
|
||||
for v in visits:
|
||||
visits_data.append({
|
||||
"id": str(v.id),
|
||||
"customer_id": str(v.customer_id),
|
||||
"customer_name": customer_map.get(v.customer_id, ""),
|
||||
"visit_date": str(v.visit_date),
|
||||
"visit_method": v.visit_method,
|
||||
"time_range": v.time_range,
|
||||
"communication_content": v.communication_content,
|
||||
"customer_demand": v.customer_demand,
|
||||
"companions": [str(c) for c in (v.companions or [])],
|
||||
"photos": v.photos or [],
|
||||
"manager_id": str(v.manager_id),
|
||||
"manager_name": user_map.get(v.manager_id, ""),
|
||||
"created_at": str(v.created_at),
|
||||
})
|
||||
gid = str(v.visit_group_id) if v.visit_group_id else str(v.id)
|
||||
groups.setdefault(gid, []).append(v)
|
||||
|
||||
visits_data = []
|
||||
for gid, gvisits in groups.items():
|
||||
# Determine the "primary" — the creator's record (not a companion copy)
|
||||
def _is_companion_copy(v) -> bool:
|
||||
content = (v.communication_content or "").strip()
|
||||
# New-style: has "(协同XXX)" suffix
|
||||
if content.endswith(")") and "(协同" in content:
|
||||
return True
|
||||
# Old-style: empty draft created for companion
|
||||
if not content:
|
||||
return True
|
||||
return False
|
||||
creator_records = [v for v in gvisits if not _is_companion_copy(v)]
|
||||
primary = creator_records[0] if creator_records else gvisits[0]
|
||||
companion_names_resolved = [user_map.get(c, str(c)) for c in (primary.companions or [])]
|
||||
companion_names_resolved.extend(primary.companion_names or [])
|
||||
|
||||
merged = {
|
||||
"id": str(primary.id),
|
||||
"customer_id": str(primary.customer_id),
|
||||
"customer_name": customer_map.get(primary.customer_id, ""),
|
||||
"visit_date": str(primary.visit_date),
|
||||
"visit_method": primary.visit_method,
|
||||
"time_range": primary.time_range,
|
||||
"visitor_name": primary.visitor_name or "",
|
||||
"visitor_phone": primary.visitor_phone or "",
|
||||
"visit_group_id": gid if len(gvisits) > 1 else None,
|
||||
"companions": [str(c) for c in (primary.companions or [])],
|
||||
"companion_names": primary.companion_names or [],
|
||||
"companion_names_resolved": companion_names_resolved,
|
||||
"communication_content": primary.communication_content,
|
||||
"customer_demand": primary.customer_demand,
|
||||
"photos": primary.photos or [],
|
||||
"manager_id": str(primary.manager_id),
|
||||
"manager_name": user_map.get(primary.manager_id, ""),
|
||||
"edit_log": primary.edit_log or [],
|
||||
"created_at": str(primary.created_at),
|
||||
"participants": [
|
||||
{
|
||||
"manager_id": str(v.manager_id),
|
||||
"manager_name": user_map.get(v.manager_id, ""),
|
||||
"role": "primary" if v is primary else "companion",
|
||||
"communication_content": v.communication_content,
|
||||
"customer_demand": v.customer_demand,
|
||||
"photos": v.photos or [],
|
||||
}
|
||||
for v in gvisits
|
||||
],
|
||||
}
|
||||
visits_data.append(merged)
|
||||
|
||||
# ── Work Plans ──
|
||||
wp_query = select(WorkPlan)
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.models.visit import Visit
|
||||
from app.models.work_plan import WorkPlan
|
||||
from app.models.mini_business import MiniBusiness
|
||||
from app.models.key_visit import KeyVisit
|
||||
from app.models.daily_note import DailyNote
|
||||
from app.models.customer import Customer
|
||||
from app.models.user import User
|
||||
|
||||
@@ -21,7 +22,7 @@ def get_week_range(reference_date: date | None = None):
|
||||
|
||||
|
||||
async def export_weekly_report(db: AsyncSession, reference_date: date | None = None) -> io.BytesIO:
|
||||
"""Generate a 4-sheet xlsx matching the existing weekly report template."""
|
||||
"""Generate a 5-sheet xlsx matching the existing weekly report template."""
|
||||
monday, sunday = get_week_range(reference_date)
|
||||
wb = Workbook()
|
||||
|
||||
@@ -40,7 +41,7 @@ async def export_weekly_report(db: AsyncSession, reference_date: date | None = N
|
||||
# ── Sheet 1: 每日拜访记录 ──
|
||||
ws1 = wb.active
|
||||
ws1.title = "每日拜访记录"
|
||||
headers1 = ["客户单位", "拜访日期", "拜访方式", "时间范围", "拜访人姓名", "拜访人电话", "沟通内容", "客户需求", "同访人员", "客户经理"]
|
||||
headers1 = ["客户单位", "拜访日期", "拜访方式", "时间范围", "拜访人姓名", "拜访人电话", "沟通内容", "客户需求", "相关人员", "客户经理"]
|
||||
ws1.append(headers1)
|
||||
for col in range(1, len(headers1) + 1):
|
||||
cell = ws1.cell(row=1, column=col)
|
||||
@@ -52,6 +53,7 @@ async def export_weekly_report(db: AsyncSession, reference_date: date | None = N
|
||||
)
|
||||
for v in visits_result.scalars():
|
||||
companions_names = [user_map.get(str(cid), str(cid)) for cid in (v.companions or [])]
|
||||
companions_names.extend(v.companion_names or [])
|
||||
ws1.append([
|
||||
customer_map.get(str(v.customer_id), ""),
|
||||
str(v.visit_date),
|
||||
@@ -127,8 +129,29 @@ async def export_weekly_report(db: AsyncSession, reference_date: date | None = N
|
||||
user_map.get(str(k.manager_id), ""),
|
||||
])
|
||||
|
||||
# ── Sheet 5: 今日纪要 ──
|
||||
ws5 = wb.create_sheet("今日纪要")
|
||||
headers5 = ["日期", "分类", "内容", "时间范围", "填报人"]
|
||||
ws5.append(headers5)
|
||||
for col in range(1, len(headers5) + 1):
|
||||
cell = ws5.cell(row=1, column=col)
|
||||
cell.font = header_font
|
||||
cell.border = thin_border
|
||||
|
||||
notes_result = await db.execute(
|
||||
select(DailyNote).where(DailyNote.note_date >= monday, DailyNote.note_date <= sunday)
|
||||
)
|
||||
for n in notes_result.scalars():
|
||||
ws5.append([
|
||||
str(n.note_date),
|
||||
n.category,
|
||||
n.content,
|
||||
n.time_range,
|
||||
user_map.get(str(n.manager_id), ""),
|
||||
])
|
||||
|
||||
# Adjust column widths
|
||||
for ws in [ws1, ws2, ws3, ws4]:
|
||||
for ws in [ws1, ws2, ws3, ws4, ws5]:
|
||||
for col_cells in ws.columns:
|
||||
max_length = max((len(str(cell.value or "")) for cell in col_cells), default=10)
|
||||
ws.column_dimensions[col_cells[0].column_letter].width = min(max_length + 4, 50)
|
||||
|
||||
@@ -1,26 +1,142 @@
|
||||
import io
|
||||
import uuid
|
||||
import re
|
||||
from datetime import datetime, date
|
||||
from typing import Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
import openpyxl
|
||||
from sqlalchemy import select, update
|
||||
from app.models.customer import Customer
|
||||
from app.models.customer_assignment import CustomerAssignment
|
||||
from app.models.visit import Visit
|
||||
from app.models.work_plan import WorkPlan
|
||||
from app.models.mini_business import MiniBusiness
|
||||
from app.models.key_visit import KeyVisit
|
||||
from app.models.daily_note import DailyNote
|
||||
from app.models.user import User
|
||||
|
||||
import openpyxl
|
||||
|
||||
# Companion name separators: Chinese comma, English comma, Chinese semicolon, dun-hao
|
||||
_COMPANION_SEP = re.compile(r'[,,;、;]')
|
||||
|
||||
|
||||
def _parse_companions(raw: str, user_map: dict[str, uuid.UUID]) -> tuple[list[uuid.UUID], list[str]]:
|
||||
"""Split a companion string into system user IDs and external names.
|
||||
|
||||
Returns (system_ids, external_names).
|
||||
"""
|
||||
if not raw or not raw.strip():
|
||||
return [], []
|
||||
|
||||
parts = [p.strip() for p in _COMPANION_SEP.split(raw) if p.strip()]
|
||||
system_ids: list[uuid.UUID] = []
|
||||
external_names: list[str] = []
|
||||
|
||||
for name in parts:
|
||||
uid = user_map.get(name)
|
||||
if uid:
|
||||
system_ids.append(uid)
|
||||
else:
|
||||
external_names.append(name)
|
||||
|
||||
return system_ids, external_names
|
||||
|
||||
|
||||
def _fuzzy_match_customer(
|
||||
cust_name: str,
|
||||
customer_map: dict[str, uuid.UUID],
|
||||
) -> uuid.UUID | None:
|
||||
"""Try to match a customer name with fuzzy rules.
|
||||
|
||||
Rules (in order):
|
||||
1. Exact match (already handled before calling this)
|
||||
2. Normalize whitespace → exact match
|
||||
3. One name fully contains the other
|
||||
"""
|
||||
norm = cust_name.replace(' ', '').replace(' ', '')
|
||||
# Rule 2: whitespace-normalized match
|
||||
for existing_name, cid in customer_map.items():
|
||||
existing_norm = existing_name.replace(' ', '').replace(' ', '')
|
||||
if norm == existing_norm:
|
||||
return cid
|
||||
|
||||
# Rule 3: containment (longer name contains shorter)
|
||||
for existing_name, cid in customer_map.items():
|
||||
if len(norm) >= 4 and len(existing_name.replace(' ', '').replace(' ', '')) >= 4:
|
||||
if norm in existing_name.replace(' ', '').replace(' ', '') or \
|
||||
existing_name.replace(' ', '').replace(' ', '') in norm:
|
||||
return cid
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uuid.UUID) -> dict:
|
||||
"""Parse old weekly report Excel and import data. Returns summary stats."""
|
||||
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True)
|
||||
stats = {"visits": 0, "work_plans": 0, "mini_business": 0, "key_visits": 0, "skipped": 0, "skip_reasons": []}
|
||||
"""Parse old weekly report Excel and import data. Returns summary stats.
|
||||
|
||||
# Resolve customer name -> id cache
|
||||
Features:
|
||||
- Companion parsing: split by comma/dun-hao → system users + external names
|
||||
- Auto-create customers: if customer not found, create from Excel data
|
||||
- Fuzzy name matching: whitespace normalization + containment
|
||||
- Daily notes import (Sheet 5)
|
||||
"""
|
||||
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True)
|
||||
stats = {
|
||||
"visits": 0, "work_plans": 0, "mini_business": 0, "key_visits": 0,
|
||||
"daily_notes": 0, "skipped": 0, "skip_reasons": [],
|
||||
"customers_created": 0, "customers_created_names": [],
|
||||
"external_companions": 0, "name_corrections": [],
|
||||
}
|
||||
|
||||
# ── Pre-fetch lookups ──
|
||||
customers_result = await db.execute(select(Customer.id, Customer.name))
|
||||
customer_map = {c.name: c.id for c in customers_result.all()}
|
||||
|
||||
users_result = await db.execute(select(User.id, User.name))
|
||||
user_map = {u.name: u.id for u in users_result.all()}
|
||||
|
||||
# Customer → assigned primary manager lookup (not from Excel)
|
||||
assign_result = await db.execute(
|
||||
select(CustomerAssignment.customer_id, CustomerAssignment.manager_id)
|
||||
.where(CustomerAssignment.role == "primary")
|
||||
)
|
||||
customer_manager_map: dict[uuid.UUID, uuid.UUID] = {}
|
||||
for cid, mid in assign_result.all():
|
||||
if cid not in customer_manager_map: # first primary wins
|
||||
customer_manager_map[cid] = mid
|
||||
|
||||
def get_assigned_manager(customer_id: uuid.UUID) -> uuid.UUID:
|
||||
"""Return the customer's assigned primary manager, or the importer as fallback."""
|
||||
return customer_manager_map.get(customer_id, manager_id)
|
||||
|
||||
# Helper: resolve or create customer
|
||||
async def resolve_customer(cust_name: str, mgr_name: str = "") -> tuple[uuid.UUID | None, str]:
|
||||
"""Resolve customer by name. Auto-creates if not found. Returns (id, note)."""
|
||||
if not cust_name or not cust_name.strip():
|
||||
return None, ""
|
||||
|
||||
cust_name = cust_name.strip()
|
||||
cid = customer_map.get(cust_name)
|
||||
if cid:
|
||||
return cid, ""
|
||||
|
||||
# Fuzzy match
|
||||
fuzzy_id = _fuzzy_match_customer(cust_name, customer_map)
|
||||
if fuzzy_id:
|
||||
real_name = next((n for n, i in customer_map.items() if i == fuzzy_id), cust_name)
|
||||
stats["name_corrections"].append(f"「{cust_name}」→「{real_name}」")
|
||||
customer_map[cust_name] = fuzzy_id # cache for future rows
|
||||
return fuzzy_id, ""
|
||||
|
||||
# Auto-create customer (no manager assignment — leave unassigned)
|
||||
customer = Customer(name=cust_name, created_by=manager_id)
|
||||
db.add(customer)
|
||||
await db.flush()
|
||||
customer_map[cust_name] = customer.id
|
||||
stats["customers_created"] += 1
|
||||
stats["customers_created_names"].append(cust_name)
|
||||
|
||||
return customer.id, ""
|
||||
|
||||
# ── Parse Sheet 1: 每日拜访记录 ──
|
||||
if "每日拜访记录" in wb.sheetnames:
|
||||
ws = wb["每日拜访记录"]
|
||||
@@ -28,28 +144,36 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui
|
||||
if not row[0]:
|
||||
continue
|
||||
try:
|
||||
cust_name, visit_date_str, visit_method, time_range, visitor_name, visitor_phone, content, demand, companions_str, mgr_str = \
|
||||
row[0], str(row[1]) if row[1] else str(date.today()), \
|
||||
str(row[2]) if row[2] else "上门", str(row[3]) if row[3] else "", \
|
||||
str(row[4]) if row[4] else "", str(row[5]) if row[5] else "", \
|
||||
str(row[6]) if row[6] else "", str(row[7]) if row[7] else "", \
|
||||
str(row[8]) if row[8] else "", str(row[9]) if row[9] else ""
|
||||
cust_name, visit_date_str, visit_method, time_range, visitor_name, visitor_phone, \
|
||||
content, demand, companions_str, mgr_str = (
|
||||
row[0], str(row[1]) if row[1] else str(date.today()),
|
||||
str(row[2]) if row[2] else "上门", str(row[3]) if row[3] else "",
|
||||
str(row[4]) if row[4] else "", str(row[5]) if row[5] else "",
|
||||
str(row[6]) if row[6] else "", str(row[7]) if row[7] else "",
|
||||
str(row[8]) if row[8] else "", str(row[9]) if row[9] else "",
|
||||
)
|
||||
|
||||
customer_id = customer_map.get(cust_name)
|
||||
# Resolve customer (auto-create if needed)
|
||||
customer_id, _ = await resolve_customer(cust_name, str(row[9]) if row[9] else "")
|
||||
if not customer_id:
|
||||
stats["skipped"] += 1
|
||||
stats["skip_reasons"].append(f"客户「{cust_name}」不存在,跳过")
|
||||
stats["skip_reasons"].append(f"客户名称为空,跳过")
|
||||
continue
|
||||
|
||||
# Parse visit date
|
||||
try:
|
||||
visit_date = datetime.strptime(visit_date_str[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
visit_date = date.today()
|
||||
|
||||
# Use customer's assigned primary manager (not Excel column)
|
||||
visit_manager_id = get_assigned_manager(customer_id)
|
||||
|
||||
# Check duplicate: same date + same manager + same customer
|
||||
existing = await db.execute(
|
||||
select(Visit).where(
|
||||
Visit.visit_date == visit_date,
|
||||
Visit.manager_id == manager_id,
|
||||
Visit.manager_id == visit_manager_id,
|
||||
Visit.customer_id == customer_id,
|
||||
)
|
||||
)
|
||||
@@ -58,6 +182,11 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui
|
||||
stats["skip_reasons"].append(f"重复:{cust_name} {visit_date} 已存在")
|
||||
continue
|
||||
|
||||
# Parse companions into system users + external names
|
||||
sys_companions, ext_names = _parse_companions(str(companions_str), user_map)
|
||||
if ext_names:
|
||||
stats["external_companions"] += len(ext_names)
|
||||
|
||||
visit = Visit(
|
||||
customer_id=customer_id,
|
||||
visit_date=visit_date,
|
||||
@@ -67,15 +196,201 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui
|
||||
visitor_phone=visitor_phone,
|
||||
communication_content=content,
|
||||
customer_demand=demand,
|
||||
manager_id=manager_id,
|
||||
companions=sys_companions,
|
||||
companion_names=ext_names,
|
||||
manager_id=visit_manager_id,
|
||||
)
|
||||
db.add(visit)
|
||||
stats["visits"] += 1
|
||||
|
||||
# Auto-complete matching work plans
|
||||
from app.services.visits import auto_complete_work_plans
|
||||
await auto_complete_work_plans(
|
||||
db, customer_id, visit_date,
|
||||
mgr_name, "旧周报导入自动完成",
|
||||
)
|
||||
|
||||
# Update customer's last_visit_date for light board
|
||||
cust = await db.get(Customer, customer_id)
|
||||
if cust and (not cust.last_visit_date or visit_date > cust.last_visit_date):
|
||||
cust.last_visit_date = visit_date
|
||||
cust.last_visit_manager_id = visit_manager_id
|
||||
except Exception as e:
|
||||
stats["skipped"] += 1
|
||||
stats["skip_reasons"].append(f"拜访解析异常:{repr(e)[:120]}")
|
||||
|
||||
# ── Parse Sheet 2: 下周工作计划 ──
|
||||
if "下周工作计划" in wb.sheetnames:
|
||||
ws2 = wb["下周工作计划"]
|
||||
for row in ws2.iter_rows(min_row=2, values_only=True):
|
||||
if not row[0]:
|
||||
continue
|
||||
try:
|
||||
cust_name = str(row[0]).strip() if row[0] else ""
|
||||
plan_content = str(row[1]) if row[1] else ""
|
||||
plan_date_str = str(row[2]) if row[2] else str(date.today())
|
||||
mgr_name = str(row[3]).strip() if row[3] else ""
|
||||
status = str(row[4]) if row[4] else "计划中"
|
||||
|
||||
customer_id, _ = await resolve_customer(cust_name, mgr_name)
|
||||
if not customer_id:
|
||||
stats["skipped"] += 1
|
||||
stats["skip_reasons"].append(f"工作计划:客户「{cust_name}」无法解析")
|
||||
continue
|
||||
|
||||
try:
|
||||
plan_date = datetime.strptime(plan_date_str[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
plan_date = date.today()
|
||||
|
||||
plan_manager_id = get_assigned_manager(customer_id)
|
||||
|
||||
work_plan = WorkPlan(
|
||||
customer_id=customer_id,
|
||||
plan_content=plan_content,
|
||||
plan_date=plan_date,
|
||||
manager_id=plan_manager_id,
|
||||
status=status if status in ["计划中", "已完成", "已取消"] else "计划中",
|
||||
)
|
||||
db.add(work_plan)
|
||||
stats["work_plans"] += 1
|
||||
except Exception:
|
||||
stats["skipped"] += 1
|
||||
|
||||
# ── Parse Sheet 3: 小微业务商机 ──
|
||||
if "小微业务商机" in wb.sheetnames:
|
||||
ws3 = wb["小微业务商机"]
|
||||
for row in ws3.iter_rows(min_row=2, values_only=True):
|
||||
if not row[0]:
|
||||
continue
|
||||
try:
|
||||
cust_name = str(row[0]).strip() if row[0] else ""
|
||||
product_type = str(row[1]) if row[1] else ""
|
||||
amount = str(row[2]) if row[2] else ""
|
||||
follow_up = str(row[3]) if row[3] else ""
|
||||
status = str(row[4]) if row[4] else "跟进中"
|
||||
mgr_name = str(row[5]).strip() if row[5] else ""
|
||||
expected_revenue = str(row[6]) if row[6] else ""
|
||||
|
||||
customer_id, _ = await resolve_customer(cust_name, mgr_name)
|
||||
if not customer_id:
|
||||
stats["skipped"] += 1
|
||||
stats["skip_reasons"].append(f"商机:客户「{cust_name}」无法解析")
|
||||
continue
|
||||
|
||||
biz_manager_id = get_assigned_manager(customer_id)
|
||||
|
||||
mini = MiniBusiness(
|
||||
customer_id=customer_id,
|
||||
product_type=product_type,
|
||||
amount=amount,
|
||||
follow_up_detail=follow_up,
|
||||
status=status if status in ["跟进中", "已成交", "已流失"] else "跟进中",
|
||||
manager_id=biz_manager_id,
|
||||
expected_revenue_date=expected_revenue,
|
||||
)
|
||||
db.add(mini)
|
||||
stats["mini_business"] += 1
|
||||
except Exception:
|
||||
stats["skipped"] += 1
|
||||
|
||||
# ── Parse Sheet 4: 要客拜访计划 ──
|
||||
if "要客拜访计划" in wb.sheetnames:
|
||||
ws4 = wb["要客拜访计划"]
|
||||
for row in ws4.iter_rows(min_row=2, values_only=True):
|
||||
if not row[0]:
|
||||
continue
|
||||
try:
|
||||
cust_name = str(row[0]).strip() if row[0] else ""
|
||||
urgency = str(row[1]) if row[1] else "一般"
|
||||
description = str(row[2]) if row[2] else ""
|
||||
progress = str(row[3]) if row[3] else "未开始"
|
||||
planned_date = str(row[4]) if row[4] else ""
|
||||
planned_visitor = str(row[5]) if row[5] else ""
|
||||
visit_target = str(row[6]) if row[6] else ""
|
||||
mgr_name = str(row[7]).strip() if row[7] else ""
|
||||
|
||||
customer_id, _ = await resolve_customer(cust_name, mgr_name)
|
||||
if not customer_id:
|
||||
stats["skipped"] += 1
|
||||
stats["skip_reasons"].append(f"要客:客户「{cust_name}」无法解析")
|
||||
continue
|
||||
|
||||
kv_manager_id = get_assigned_manager(customer_id)
|
||||
valid_urgency = urgency if urgency in ["一般", "重要", "紧急"] else "一般"
|
||||
valid_progress = progress if progress in ["未开始", "进行中", "已完成"] else "未开始"
|
||||
|
||||
key_visit = KeyVisit(
|
||||
customer_id=customer_id,
|
||||
urgency_level=valid_urgency,
|
||||
description=description,
|
||||
progress_status=valid_progress,
|
||||
planned_date=planned_date,
|
||||
planned_visitor=planned_visitor,
|
||||
visit_target=visit_target,
|
||||
manager_id=kv_manager_id,
|
||||
)
|
||||
db.add(key_visit)
|
||||
stats["key_visits"] += 1
|
||||
except Exception:
|
||||
stats["skipped"] += 1
|
||||
|
||||
# ── Parse Sheet 5: 今日纪要 ──
|
||||
if "今日纪要" in wb.sheetnames:
|
||||
ws5 = wb["今日纪要"]
|
||||
valid_categories = ["行政事务", "合同整理", "发票处理", "内部会议", "培训学习", "其他"]
|
||||
for row in ws5.iter_rows(min_row=2, values_only=True):
|
||||
if not row[0]:
|
||||
continue
|
||||
try:
|
||||
note_date_str = str(row[0]) if row[0] else str(date.today())
|
||||
category = str(row[1]) if row[1] else "其他"
|
||||
content = str(row[2]) if row[2] else ""
|
||||
time_range = str(row[3]) if row[3] else ""
|
||||
mgr_name = str(row[4]).strip() if row[4] else ""
|
||||
|
||||
# Validate category
|
||||
if category not in valid_categories:
|
||||
category = "其他"
|
||||
|
||||
# Resolve manager by name
|
||||
note_manager_id = manager_id # default to importer
|
||||
if mgr_name:
|
||||
mgr_id = user_map.get(mgr_name)
|
||||
if mgr_id:
|
||||
note_manager_id = mgr_id
|
||||
else:
|
||||
stats["skip_reasons"].append(f"纪要:客户经理「{mgr_name}」不存在,使用导入人")
|
||||
|
||||
try:
|
||||
note_date = datetime.strptime(note_date_str[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
note_date = date.today()
|
||||
|
||||
# Skip duplicates: same manager, same date, same category
|
||||
existing = await db.execute(
|
||||
select(DailyNote).where(
|
||||
DailyNote.note_date == note_date,
|
||||
DailyNote.manager_id == note_manager_id,
|
||||
DailyNote.category == category,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
stats["skipped"] += 1
|
||||
stats["skip_reasons"].append(f"重复:{note_date} {category} 纪要已存在")
|
||||
continue
|
||||
|
||||
daily_note = DailyNote(
|
||||
manager_id=note_manager_id,
|
||||
note_date=note_date,
|
||||
category=category,
|
||||
content=content,
|
||||
time_range=time_range,
|
||||
)
|
||||
db.add(daily_note)
|
||||
stats["daily_notes"] += 1
|
||||
except Exception:
|
||||
stats["skipped"] += 1
|
||||
|
||||
await db.commit()
|
||||
return stats
|
||||
|
||||
|
||||
import io
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Chinese holiday data service — fetches from apisbo.com and caches to system_config."""
|
||||
|
||||
import json
|
||||
import httpx
|
||||
from datetime import date
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.system_config import SystemConfig
|
||||
|
||||
|
||||
HOLIDAY_API = "https://api.apisbo.com/holidays/year"
|
||||
|
||||
|
||||
async def _get_config(db: AsyncSession, key: str) -> str | None:
|
||||
row = await db.get(SystemConfig, key)
|
||||
return row.value if row else None
|
||||
|
||||
|
||||
async def _set_config(db: AsyncSession, key: str, value: str):
|
||||
row = await db.get(SystemConfig, key)
|
||||
if row:
|
||||
row.value = value
|
||||
else:
|
||||
db.add(SystemConfig(key=key, value=value))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def refresh_holidays(db: AsyncSession, year: int | None = None) -> dict:
|
||||
"""Fetch Chinese holidays for a year from apisbo.com and cache to DB.
|
||||
|
||||
Caches two config keys:
|
||||
- holidays: comma-separated rest-day dates (holidays + weekends already handled by weekday check)
|
||||
We only store holiday dates here, since weekends are auto-detected.
|
||||
- workdays: comma-separated makeup workday dates (调休, when Sat/Sun becomes a workday)
|
||||
|
||||
Returns dict with counts.
|
||||
"""
|
||||
year = year or date.today().year
|
||||
|
||||
# Fetch current year + next year on first load
|
||||
years_to_fetch = {year}
|
||||
existing = await _get_config(db, f"holidays_{year}")
|
||||
if not existing:
|
||||
years_to_fetch.add(year + 1)
|
||||
|
||||
all_holidays: list[str] = []
|
||||
all_workdays: list[str] = []
|
||||
|
||||
for y in years_to_fetch:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(f"{HOLIDAY_API}/{y}")
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
|
||||
if result.get("code") != 0:
|
||||
continue
|
||||
|
||||
holidays = []
|
||||
workdays = []
|
||||
for item in result.get("data", []):
|
||||
d = item["date"]
|
||||
if item["type"] == "holiday" and date.fromisoformat(d).weekday() < 5:
|
||||
# Only cache weekday-holidays (weekend holidays are already skipped)
|
||||
holidays.append(d)
|
||||
elif item["type"] == "workday":
|
||||
# Makeup workday — Saturday/Sunday that becomes a workday
|
||||
workdays.append(d)
|
||||
|
||||
# Store per-year for reference, and accumulate for runtime use
|
||||
await _set_config(db, f"holidays_{y}", ",".join(holidays))
|
||||
await _set_config(db, f"workdays_{y}", ",".join(workdays))
|
||||
all_holidays.extend(holidays)
|
||||
all_workdays.extend(workdays)
|
||||
|
||||
except Exception:
|
||||
# API unavailable — fall back to existing cached data
|
||||
cached_h = await _get_config(db, f"holidays_{y}")
|
||||
if cached_h:
|
||||
all_holidays.extend([d for d in cached_h.split(",") if d.strip()])
|
||||
cached_w = await _get_config(db, f"workdays_{y}")
|
||||
if cached_w:
|
||||
all_workdays.extend([d for d in cached_w.split(",") if d.strip()])
|
||||
|
||||
# Write runtime config keys (used by is_working_day)
|
||||
if all_holidays or all_workdays:
|
||||
await _set_config(db, "holidays", ",".join(sorted(all_holidays)))
|
||||
await _set_config(db, "workdays", ",".join(sorted(all_workdays)))
|
||||
|
||||
return {
|
||||
"holidays": len(all_holidays),
|
||||
"workdays": len(all_workdays),
|
||||
"years": sorted(years_to_fetch),
|
||||
}
|
||||
|
||||
|
||||
async def load_holiday_sets(db: AsyncSession) -> tuple[set[date], set[date]]:
|
||||
"""Load holiday and workday date sets from cached config.
|
||||
|
||||
Returns (holidays_set, workdays_set).
|
||||
- holidays_set: dates that are rest days (weekday holidays)
|
||||
- workdays_set: dates that are workdays despite being weekends (调休)
|
||||
"""
|
||||
holidays: set[date] = set()
|
||||
workdays: set[date] = set()
|
||||
|
||||
# Try runtime config first
|
||||
h_val = await _get_config(db, "holidays")
|
||||
if h_val:
|
||||
for s in h_val.split(","):
|
||||
s = s.strip()
|
||||
if s:
|
||||
try:
|
||||
holidays.add(date.fromisoformat(s))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
w_val = await _get_config(db, "workdays")
|
||||
if w_val:
|
||||
for s in w_val.split(","):
|
||||
s = s.strip()
|
||||
if s:
|
||||
try:
|
||||
workdays.add(date.fromisoformat(s))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# If no data cached yet, try to refresh
|
||||
if not holidays and not workdays:
|
||||
try:
|
||||
await refresh_holidays(db)
|
||||
# Reload after refresh
|
||||
return await load_holiday_sets(db)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return holidays, workdays
|
||||
@@ -0,0 +1,218 @@
|
||||
from datetime import date
|
||||
from uuid import UUID
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.leave import Leave
|
||||
from app.models.user import User
|
||||
from app.utils.timezone import today_cst
|
||||
from app.utils.audit import log_audit
|
||||
from app.services.dashboard import get_week_range
|
||||
|
||||
|
||||
async def get_leaves(
|
||||
db: AsyncSession,
|
||||
user_id: str,
|
||||
role: str,
|
||||
manager_id: str | None = None,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 25,
|
||||
) -> dict:
|
||||
"""List leaves. Managers see only their own."""
|
||||
today = today_cst()
|
||||
|
||||
query = select(Leave)
|
||||
count_q = select(func.count(Leave.id))
|
||||
|
||||
# Role filter
|
||||
if role == "manager":
|
||||
query = query.where(Leave.manager_id == user_id)
|
||||
count_q = count_q.where(Leave.manager_id == user_id)
|
||||
elif manager_id:
|
||||
query = query.where(Leave.manager_id == manager_id)
|
||||
count_q = count_q.where(Leave.manager_id == manager_id)
|
||||
|
||||
# Status filter
|
||||
if status == "active":
|
||||
query = query.where(and_(Leave.start_date <= today, Leave.end_date >= today))
|
||||
count_q = count_q.where(and_(Leave.start_date <= today, Leave.end_date >= today))
|
||||
elif status == "upcoming":
|
||||
query = query.where(Leave.start_date > today)
|
||||
count_q = count_q.where(Leave.start_date > today)
|
||||
elif status == "past":
|
||||
query = query.where(Leave.end_date < today)
|
||||
count_q = count_q.where(Leave.end_date < today)
|
||||
|
||||
total = (await db.execute(count_q)).scalar() or 0
|
||||
|
||||
query = query.order_by(Leave.start_date.desc())
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
rows = result.scalars().all()
|
||||
|
||||
# Resolve names
|
||||
user_ids = set()
|
||||
for r in rows:
|
||||
user_ids.add(r.manager_id)
|
||||
user_ids.add(r.submitted_by)
|
||||
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
|
||||
user_map = {u.id: u.name for u in users_result.scalars().all()}
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
days = ((r.end_date - r.start_date).days + 1) if r.start_date and r.end_date else 0
|
||||
items.append({
|
||||
"id": r.id,
|
||||
"manager_id": r.manager_id,
|
||||
"manager_name": user_map.get(r.manager_id, ""),
|
||||
"leave_type": r.leave_type,
|
||||
"start_date": str(r.start_date),
|
||||
"end_date": str(r.end_date),
|
||||
"days": days,
|
||||
"reason": r.reason or "",
|
||||
"submitted_by": r.submitted_by,
|
||||
"submitted_by_name": user_map.get(r.submitted_by, ""),
|
||||
"created_at": str(r.created_at) if r.created_at else None,
|
||||
"updated_at": str(r.updated_at) if r.updated_at else None,
|
||||
})
|
||||
|
||||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||
|
||||
|
||||
async def get_leave_by_id(db: AsyncSession, leave_id: UUID) -> Leave | None:
|
||||
result = await db.execute(select(Leave).where(Leave.id == leave_id))
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def create_leave(
|
||||
db: AsyncSession,
|
||||
data: dict,
|
||||
submitted_by: UUID,
|
||||
role: str,
|
||||
operator_name: str = "",
|
||||
) -> Leave:
|
||||
"""Create leave. Manager can only create for self; director can create for anyone."""
|
||||
if role == "manager":
|
||||
if str(data.get("manager_id")) != str(submitted_by):
|
||||
raise PermissionError("客户经理只能为自己提交请假")
|
||||
|
||||
if data["start_date"] > data["end_date"]:
|
||||
raise ValueError("结束日期不能早于开始日期")
|
||||
|
||||
leave = Leave(
|
||||
manager_id=data["manager_id"],
|
||||
leave_type=data.get("leave_type", "事假"),
|
||||
start_date=data["start_date"],
|
||||
end_date=data["end_date"],
|
||||
reason=data.get("reason", ""),
|
||||
submitted_by=submitted_by,
|
||||
)
|
||||
db.add(leave)
|
||||
await db.commit()
|
||||
await db.refresh(leave)
|
||||
# Audit log
|
||||
await log_audit(db, "leave", leave.id, f"{leave.leave_type} ({leave.start_date}~{leave.end_date})", "create", str(submitted_by), operator_name)
|
||||
await db.commit()
|
||||
return leave
|
||||
|
||||
|
||||
async def update_leave(
|
||||
db: AsyncSession,
|
||||
leave_id: UUID,
|
||||
data: dict,
|
||||
user_id: UUID,
|
||||
role: str,
|
||||
operator_name: str = "",
|
||||
) -> Leave:
|
||||
"""Update leave. Director can edit any; manager can edit own submitted leaves."""
|
||||
leave = await get_leave_by_id(db, leave_id)
|
||||
if not leave:
|
||||
raise ValueError("请假记录不存在")
|
||||
|
||||
if role == "manager":
|
||||
if str(leave.submitted_by) != str(user_id):
|
||||
raise PermissionError("客户经理只能编辑自己提交的请假")
|
||||
|
||||
for field in ("manager_id", "leave_type", "start_date", "end_date", "reason"):
|
||||
if field in data and data[field] is not None:
|
||||
setattr(leave, field, data[field])
|
||||
|
||||
if leave.start_date > leave.end_date:
|
||||
raise ValueError("结束日期不能早于开始日期")
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(leave)
|
||||
# Audit log
|
||||
await log_audit(db, "leave", leave.id, f"{leave.leave_type} ({leave.start_date}~{leave.end_date})", "update", str(user_id), operator_name)
|
||||
await db.commit()
|
||||
return leave
|
||||
|
||||
|
||||
async def delete_leave(
|
||||
db: AsyncSession,
|
||||
leave_id: UUID,
|
||||
user_id: UUID,
|
||||
role: str,
|
||||
operator_name: str = "",
|
||||
) -> bool:
|
||||
"""Delete leave. Director can delete any; manager can delete own submitted leaves."""
|
||||
leave = await get_leave_by_id(db, leave_id)
|
||||
if not leave:
|
||||
return False
|
||||
|
||||
if role == "manager":
|
||||
if str(leave.submitted_by) != str(user_id):
|
||||
raise PermissionError("客户经理只能删除自己提交的请假")
|
||||
|
||||
entity_name = f"{leave.leave_type} ({leave.start_date}~{leave.end_date})"
|
||||
await db.delete(leave)
|
||||
await db.commit()
|
||||
# Audit log
|
||||
await log_audit(db, "leave", leave_id, entity_name, "delete", str(user_id), operator_name)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def get_leave_overview(
|
||||
db: AsyncSession,
|
||||
user_id: str,
|
||||
role: str,
|
||||
reference_date: date | None = None,
|
||||
) -> dict:
|
||||
"""Get leave overview for the dashboard card. Returns leaves overlapping with current week."""
|
||||
monday, sunday = get_week_range(reference_date)
|
||||
|
||||
query = select(Leave).where(
|
||||
and_(Leave.start_date <= sunday, Leave.end_date >= monday)
|
||||
)
|
||||
if role == "manager":
|
||||
query = query.where(Leave.manager_id == user_id)
|
||||
|
||||
result = await db.execute(query.order_by(Leave.start_date))
|
||||
rows = result.scalars().all()
|
||||
|
||||
# Deduplicate by manager_id (one manager may have multiple leave records)
|
||||
user_ids = set(r.manager_id for r in rows)
|
||||
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
|
||||
user_map = {u.id: u.name for u in users_result.scalars().all()}
|
||||
|
||||
manager_set = set()
|
||||
leave_list = []
|
||||
for r in rows:
|
||||
days = ((r.end_date - r.start_date).days + 1) if r.start_date and r.end_date else 0
|
||||
leave_list.append({
|
||||
"manager_id": str(r.manager_id),
|
||||
"manager_name": user_map.get(r.manager_id, ""),
|
||||
"leave_type": r.leave_type,
|
||||
"start_date": str(r.start_date),
|
||||
"end_date": str(r.end_date),
|
||||
"days": days,
|
||||
})
|
||||
manager_set.add(str(r.manager_id))
|
||||
|
||||
return {
|
||||
"week_start": str(monday),
|
||||
"week_end": str(sunday),
|
||||
"total_on_leave": len(manager_set),
|
||||
"leave_list": leave_list,
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Customer light board — visit coverage matrix per manager.
|
||||
|
||||
Status (rolling 30-day window):
|
||||
green — visited within last 30 days (亮灯)
|
||||
yellow — visited 31-60 days ago (临期)
|
||||
red — visited 61+ days ago, or never visited (灭灯+警示)
|
||||
gray — customer has no assigned primary manager (未分配)
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from uuid import UUID
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.user import User
|
||||
from app.models.customer import Customer
|
||||
from app.models.customer_assignment import CustomerAssignment
|
||||
from app.models.visit import Visit
|
||||
from app.models.work_plan import WorkPlan
|
||||
from app.utils.timezone import today_cst
|
||||
|
||||
|
||||
def month_start(ref: date) -> date:
|
||||
return ref.replace(day=1)
|
||||
|
||||
|
||||
def month_end(ref: date) -> date:
|
||||
nxt = ref.replace(day=28) + timedelta(days=4)
|
||||
return nxt - timedelta(days=nxt.day)
|
||||
|
||||
|
||||
def classify(last_visit_date: date | None, ref: date) -> tuple[str, int]:
|
||||
"""Return (status, consecutive_missed_periods).
|
||||
|
||||
30-day rolling window:
|
||||
green — visited within 30 days
|
||||
yellow — visited 31-60 days ago (临期)
|
||||
red — 61+ days ago, or never visited
|
||||
"""
|
||||
if last_visit_date is None:
|
||||
return ("red", 99) # never visited
|
||||
|
||||
days_since = (ref - last_visit_date).days
|
||||
|
||||
if days_since <= 30:
|
||||
return ("green", 0)
|
||||
elif days_since <= 60:
|
||||
return ("yellow", 0)
|
||||
|
||||
# Each 30-day block beyond 60 days counts as one missed period
|
||||
periods = days_since // 30
|
||||
return ("red", min(periods, 99))
|
||||
|
||||
|
||||
async def get_light_board(
|
||||
db: AsyncSession,
|
||||
reference_date: date | None = None,
|
||||
user_id: UUID | None = None,
|
||||
role: str = "manager",
|
||||
) -> dict:
|
||||
"""Get per-manager customer visit coverage for the light board.
|
||||
|
||||
Directors/leaders see all managers; managers see only themselves.
|
||||
"""
|
||||
ref = reference_date or today_cst()
|
||||
|
||||
# ── Managers (filtered by role) ──
|
||||
managers_result = await db.execute(select(User).where(User.role == "manager"))
|
||||
all_managers = managers_result.scalars().all()
|
||||
# Filter: managers only see themselves
|
||||
visible_managers = all_managers if role in ("director", "leader") else [
|
||||
m for m in all_managers if m.id == user_id
|
||||
]
|
||||
|
||||
manager_map: dict[UUID, dict] = {
|
||||
m.id: {
|
||||
"manager_id": str(m.id),
|
||||
"manager_name": m.name,
|
||||
"total_customers": 0,
|
||||
"visited_this_month": 0,
|
||||
"visited_last_month_only": 0,
|
||||
"not_visited_2months": 0,
|
||||
"coverage_rate": 0.0,
|
||||
"customers": [],
|
||||
}
|
||||
for m in visible_managers
|
||||
}
|
||||
|
||||
# ── All customers with their last visit per manager (including companions) ──
|
||||
# UNION direct + companions (unnested), then DISTINCT ON to get the latest per (manager, customer)
|
||||
from sqlalchemy import union_all, distinct
|
||||
direct = select(
|
||||
Visit.manager_id, Visit.customer_id, Visit.visit_date, Visit.visit_method,
|
||||
)
|
||||
companion = select(
|
||||
func.unnest(Visit.companions).label("manager_id"),
|
||||
Visit.customer_id,
|
||||
Visit.visit_date,
|
||||
Visit.visit_method,
|
||||
).where(Visit.companions.isnot(None))
|
||||
combined = union_all(direct, companion).subquery()
|
||||
|
||||
# Get latest visit (date + method) per (manager_id, customer_id) using DISTINCT ON
|
||||
latest_visit = (
|
||||
select(
|
||||
combined.c.manager_id,
|
||||
combined.c.customer_id,
|
||||
combined.c.visit_date.label("last_visit_date"),
|
||||
combined.c.visit_method,
|
||||
)
|
||||
.distinct(combined.c.manager_id, combined.c.customer_id)
|
||||
.order_by(combined.c.manager_id, combined.c.customer_id, combined.c.visit_date.desc())
|
||||
.subquery()
|
||||
)
|
||||
|
||||
# Join: assignments → customers → latest_visit
|
||||
rows = await db.execute(
|
||||
select(
|
||||
CustomerAssignment.manager_id,
|
||||
CustomerAssignment.customer_id,
|
||||
Customer.id.label("c_id"),
|
||||
Customer.name.label("c_name"),
|
||||
Customer.industry,
|
||||
Customer.in_use_services,
|
||||
Customer.monthly_fee,
|
||||
latest_visit.c.last_visit_date,
|
||||
latest_visit.c.visit_method,
|
||||
)
|
||||
.join(Customer, Customer.id == CustomerAssignment.customer_id)
|
||||
.outerjoin(latest_visit, and_(
|
||||
latest_visit.c.manager_id == CustomerAssignment.manager_id,
|
||||
latest_visit.c.customer_id == CustomerAssignment.customer_id,
|
||||
))
|
||||
.where(CustomerAssignment.role == "primary")
|
||||
.order_by(Customer.name)
|
||||
)
|
||||
|
||||
assigned_customer_ids: set[UUID] = set()
|
||||
|
||||
for row in rows.all():
|
||||
mgr_id, cust_id, c_id, c_name, industry, services, fee, lvd, method = row
|
||||
assigned_customer_ids.add(c_id)
|
||||
|
||||
status, missed = classify(lvd, ref)
|
||||
cust_entry = {
|
||||
"customer_id": str(c_id),
|
||||
"customer_name": c_name,
|
||||
"industry": industry or "",
|
||||
"in_use_services": services or "",
|
||||
"monthly_fee": str(fee) if fee else "",
|
||||
"last_visit_date": str(lvd) if lvd else None,
|
||||
"last_visit_method": method or "",
|
||||
"status": status,
|
||||
"consecutive_missed_months": missed,
|
||||
}
|
||||
|
||||
mgr_entry = manager_map.get(mgr_id)
|
||||
if mgr_entry:
|
||||
mgr_entry["customers"].append(cust_entry)
|
||||
mgr_entry["total_customers"] += 1
|
||||
if status == "green":
|
||||
mgr_entry["visited_this_month"] += 1
|
||||
elif status == "yellow":
|
||||
mgr_entry["visited_last_month_only"] += 1
|
||||
else:
|
||||
mgr_entry["not_visited_2months"] += 1
|
||||
|
||||
# ── Active work plans per customer ──
|
||||
active_plans_result = await db.execute(
|
||||
select(
|
||||
WorkPlan.customer_id,
|
||||
WorkPlan.plan_content,
|
||||
WorkPlan.plan_date,
|
||||
WorkPlan.status,
|
||||
WorkPlan.manager_id,
|
||||
).where(
|
||||
WorkPlan.customer_id.in_(assigned_customer_ids),
|
||||
WorkPlan.status == "计划中",
|
||||
).order_by(WorkPlan.plan_date)
|
||||
)
|
||||
plans_map: dict[UUID, list] = {}
|
||||
for row in active_plans_result.all():
|
||||
cid, content, pdate, pstatus, pmanager = row
|
||||
plans_map.setdefault(cid, []).append({
|
||||
"plan_content": content,
|
||||
"plan_date": str(pdate),
|
||||
"status": pstatus,
|
||||
"plan_overdue": pdate < ref,
|
||||
"manager_id": str(pmanager) if pmanager else None,
|
||||
})
|
||||
|
||||
# ── Recent visits for green/yellow customers ──
|
||||
recent_visits_result = await db.execute(
|
||||
select(
|
||||
Visit.customer_id,
|
||||
Visit.visit_date,
|
||||
Visit.visit_method,
|
||||
Visit.communication_content,
|
||||
Visit.manager_id,
|
||||
).where(
|
||||
Visit.customer_id.in_(assigned_customer_ids),
|
||||
).order_by(Visit.visit_date.desc()).limit(500)
|
||||
)
|
||||
visits_map: dict[UUID, list] = {}
|
||||
for row in recent_visits_result.all():
|
||||
cid, vdate, vmethod, vcontent, vmanager = row
|
||||
visits_map.setdefault(cid, []).append({
|
||||
"visit_date": str(vdate),
|
||||
"visit_method": vmethod,
|
||||
"content": (vcontent or "")[:120],
|
||||
"manager_id": str(vmanager) if vmanager else None,
|
||||
})
|
||||
|
||||
# ── Merge plans & visits into customer entries ──
|
||||
for mgr_entry in manager_map.values():
|
||||
for c in mgr_entry["customers"]:
|
||||
cid = UUID(c["customer_id"])
|
||||
c["plans"] = plans_map.get(cid, [])
|
||||
c["recent_visits"] = (visits_map.get(cid, []) or [])[:2]
|
||||
|
||||
# ── Calculate coverage rates ──
|
||||
for mgr_entry in manager_map.values():
|
||||
total = mgr_entry["total_customers"]
|
||||
if total > 0:
|
||||
mgr_entry["coverage_rate"] = round(mgr_entry["visited_this_month"] / total, 3)
|
||||
|
||||
# Sort managers: lowest coverage first (most problematic first)
|
||||
manager_list = sorted(manager_map.values(), key=lambda m: m["coverage_rate"])
|
||||
|
||||
# ── Unassigned customers (no primary manager) ──
|
||||
# Only visible to directors/leaders — not actionable for individual managers
|
||||
unassigned = []
|
||||
if role in ("director", "leader"):
|
||||
unassigned_rows = await db.execute(
|
||||
select(Customer)
|
||||
.outerjoin(CustomerAssignment, and_(
|
||||
CustomerAssignment.customer_id == Customer.id,
|
||||
CustomerAssignment.role == "primary",
|
||||
))
|
||||
.where(CustomerAssignment.id == None)
|
||||
.order_by(Customer.name)
|
||||
)
|
||||
for c in unassigned_rows.scalars():
|
||||
unassigned.append({
|
||||
"customer_id": str(c.id),
|
||||
"customer_name": c.name,
|
||||
"industry": c.industry or "",
|
||||
"in_use_services": c.in_use_services or "",
|
||||
"monthly_fee": str(c.monthly_fee) if c.monthly_fee else "",
|
||||
"last_visit_date": None,
|
||||
"last_visit_method": "",
|
||||
"status": "gray",
|
||||
"consecutive_missed_months": 0,
|
||||
})
|
||||
|
||||
# ── Team summary ──
|
||||
all_total = sum(m["total_customers"] for m in manager_list)
|
||||
all_green = sum(m["visited_this_month"] for m in manager_list)
|
||||
all_yellow = sum(m["visited_last_month_only"] for m in manager_list)
|
||||
all_red = sum(m["not_visited_2months"] for m in manager_list)
|
||||
team_summary = {
|
||||
"total_customers": all_total,
|
||||
"visited_this_month": all_green,
|
||||
"visited_last_month_only": all_yellow,
|
||||
"not_visited_2months": all_red,
|
||||
"unassigned": len(unassigned),
|
||||
"coverage_rate": round(all_green / all_total, 3) if all_total > 0 else 0.0,
|
||||
}
|
||||
|
||||
return {
|
||||
"reference_month": ref.strftime("%Y-%m"),
|
||||
"managers": manager_list,
|
||||
"unassigned_customers": unassigned,
|
||||
"team_summary": team_summary,
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
from datetime import timedelta
|
||||
from io import BytesIO
|
||||
from minio import Minio
|
||||
from app.config import settings
|
||||
|
||||
@@ -20,10 +21,16 @@ def get_minio_client() -> Minio:
|
||||
return _client
|
||||
|
||||
|
||||
def generate_presigned_upload_url(object_key: str, expires: int = 600) -> str:
|
||||
"""Generate a presigned PUT URL for direct upload to MinIO."""
|
||||
def put_validated_image(object_key: str, image_bytes: bytes) -> None:
|
||||
"""Store a normalized image only after the API has validated its content."""
|
||||
client = get_minio_client()
|
||||
return client.presigned_put_object(settings.MINIO_BUCKET, object_key, expires=timedelta(seconds=expires))
|
||||
client.put_object(
|
||||
settings.MINIO_BUCKET,
|
||||
object_key,
|
||||
BytesIO(image_bytes),
|
||||
length=len(image_bytes),
|
||||
content_type="image/jpeg",
|
||||
)
|
||||
|
||||
|
||||
def generate_presigned_download_url(object_key: str, expires: int = 3600) -> str:
|
||||
|
||||
@@ -1,24 +1,42 @@
|
||||
from datetime import date, datetime
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.visit import Visit
|
||||
from app.models.daily_note import DailyNote
|
||||
from app.models.user import User
|
||||
from app.models.leave import Leave
|
||||
from app.models.work_plan import WorkPlan
|
||||
from app.models.customer import Customer
|
||||
from app.services.holidays import load_holiday_sets
|
||||
from app.services.wecom import wecom_client
|
||||
from app.utils.timezone import today_cst
|
||||
from app.utils.timezone import today_cst, is_working_day
|
||||
|
||||
|
||||
async def check_daily_reporting(db: AsyncSession) -> dict:
|
||||
"""Check today's reporting progress and send wecom reminders to managers who haven't reported."""
|
||||
today = today_cst()
|
||||
weekday = today.weekday()
|
||||
if weekday >= 5: # Skip weekends
|
||||
return {"status": "weekend", "date": str(today)}
|
||||
|
||||
# Get all managers
|
||||
result = await db.execute(select(User).where(User.role == "manager"))
|
||||
# Load holiday data (auto-refreshes from API if not cached)
|
||||
holidays, workdays = await load_holiday_sets(db)
|
||||
|
||||
if not is_working_day(today, holidays, workdays):
|
||||
return {"status": "rest_day", "date": str(today)}
|
||||
|
||||
# Get all users who need to report
|
||||
result = await db.execute(
|
||||
select(User).where(User.require_report == True)
|
||||
)
|
||||
managers = result.scalars().all()
|
||||
|
||||
# ── Exclude managers on leave today ──
|
||||
leaves_today = await db.execute(
|
||||
select(Leave.manager_id).where(and_(
|
||||
Leave.start_date <= today,
|
||||
Leave.end_date >= today,
|
||||
))
|
||||
)
|
||||
on_leave_ids = {str(uid) for uid, in leaves_today.all()}
|
||||
|
||||
# Get managers who have reported today
|
||||
reported_visits = await db.execute(
|
||||
select(Visit.manager_id).where(Visit.visit_date == today)
|
||||
@@ -31,19 +49,64 @@ async def check_daily_reporting(db: AsyncSession) -> dict:
|
||||
reported_map[str(uid)] = True
|
||||
|
||||
not_reported = []
|
||||
reported_names = []
|
||||
on_leave_names = []
|
||||
for m in managers:
|
||||
if str(m.id) not in reported_map:
|
||||
if str(m.id) in on_leave_ids:
|
||||
on_leave_names.append(m.name)
|
||||
continue # skip — exempt from reporting
|
||||
if str(m.id) in reported_map:
|
||||
reported_names.append(m.name)
|
||||
else:
|
||||
not_reported.append(m)
|
||||
|
||||
if not_reported and managers:
|
||||
content = f"📋 今日填报提醒({today})\n\n以下同事尚未提交今日拜访记录:\n"
|
||||
for m in not_reported:
|
||||
content += f"• {m.name}\n"
|
||||
content += "\n请尽快完成今日拜访填报 🙏"
|
||||
|
||||
# 1. Send template card to not-reported managers (tap to open app)
|
||||
if not_reported:
|
||||
user_ids = [m.wecom_userid for m in not_reported if m.wecom_userid]
|
||||
if user_ids:
|
||||
await wecom_client.send_text_message(user_ids, content)
|
||||
desc = f"{today} | 已填报 {len(reported_names)}/{len(managers)} 人"
|
||||
success = await wecom_client.send_template_card(
|
||||
user_ids=user_ids,
|
||||
title="📋 今日填报提醒",
|
||||
desc=desc,
|
||||
url="https://qj.dhdx.fun/m",
|
||||
btn_text="去填报",
|
||||
)
|
||||
# Fallback to text
|
||||
if not success:
|
||||
names_text = "、".join(m.name for m in not_reported)
|
||||
await wecom_client.send_text_message(
|
||||
user_ids,
|
||||
f"📋 今日填报提醒\n\n{desc}\n未填报:{names_text}\n\n请尽快完成填报 🙏\nhttps://qj.dhdx.fun/m",
|
||||
)
|
||||
|
||||
# 2. Send summary to directors/leaders only (not broadcast to @all)
|
||||
directors = await db.execute(
|
||||
select(User).where(User.role.in_(["director", "leader"]), User.wecom_userid.isnot(None))
|
||||
)
|
||||
director_ids = [d.wecom_userid for d in directors.scalars().all()]
|
||||
if managers and director_ids:
|
||||
effective_total = len(managers) - len(on_leave_names)
|
||||
if effective_total > 0:
|
||||
pct = len(reported_map) / effective_total * 100
|
||||
else:
|
||||
pct = 100.0
|
||||
leave_note = ""
|
||||
if on_leave_names:
|
||||
leave_note = f"\n> 请假中(已豁免):{len(on_leave_names)}人\n"
|
||||
leave_note += "".join(f"- {n} (请假)\n" for n in on_leave_names)
|
||||
summary = (
|
||||
f"## 📊 今日填报汇总\n\n"
|
||||
f"> 日期:{today}\n"
|
||||
f"> 填报率:**{pct:.0f}%** ({len(reported_map)}/{effective_total})\n"
|
||||
)
|
||||
if leave_note:
|
||||
summary += leave_note + "\n"
|
||||
if not_reported:
|
||||
summary += "**未填报:**\n" + "".join(f"- {m.name}\n" for m in not_reported)
|
||||
else:
|
||||
summary += "✅ 全体已完成今日填报"
|
||||
await wecom_client.send_markdown_message(summary, user_ids=director_ids)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
@@ -52,3 +115,86 @@ async def check_daily_reporting(db: AsyncSession) -> dict:
|
||||
"reported": len(reported_map),
|
||||
"not_reported": len(not_reported),
|
||||
}
|
||||
|
||||
|
||||
async def check_overdue_plans(db: AsyncSession) -> dict:
|
||||
"""Check for overdue work plans and remind managers (runs at 9:00 AM)."""
|
||||
today = today_cst()
|
||||
if today.weekday() >= 5:
|
||||
return {"status": "weekend", "date": str(today)}
|
||||
|
||||
result = await db.execute(
|
||||
select(WorkPlan).where(
|
||||
WorkPlan.status == "计划中",
|
||||
WorkPlan.plan_date < today,
|
||||
).order_by(WorkPlan.manager_id, WorkPlan.plan_date)
|
||||
)
|
||||
overdue = result.scalars().all()
|
||||
|
||||
if not overdue:
|
||||
return {"status": "ok", "date": str(today), "overdue": 0}
|
||||
|
||||
# Group by manager
|
||||
by_manager: dict[str, list] = {}
|
||||
for p in overdue:
|
||||
mid = str(p.manager_id)
|
||||
by_manager.setdefault(mid, []).append(p)
|
||||
|
||||
users_result = await db.execute(
|
||||
select(User).where(User.id.in_([uid for uid in by_manager.keys()]))
|
||||
)
|
||||
user_map = {str(u.id): u for u in users_result.scalars().all()}
|
||||
|
||||
for mid, plans in by_manager.items():
|
||||
user = user_map.get(mid)
|
||||
if not user or not user.wecom_userid:
|
||||
continue
|
||||
names = "、".join(f"{p.customer_id}" for p in plans[:5])
|
||||
# Get customer names
|
||||
cust_result = await db.execute(
|
||||
select(Customer.name).where(Customer.id.in_([p.customer_id for p in plans[:5]]))
|
||||
)
|
||||
cust_names = [r[0] for r in cust_result.all()]
|
||||
|
||||
plan_lines = "".join(f"- {n} (计划 {p.plan_date})\n" for p, n in zip(plans[:5], cust_names))
|
||||
content = (
|
||||
f"📅 拜访计划过期提醒\n\n"
|
||||
f"以下 {len(plans)} 个拜访计划已过期,请尽快安排拜访:\n"
|
||||
f"{plan_lines}"
|
||||
)
|
||||
if len(plans) > 5:
|
||||
content += f"... 还有 {len(plans) - 5} 个过期计划\n"
|
||||
content += f"\n👉 查看详情:https://qj.dhdx.fun/light-board"
|
||||
|
||||
await wecom_client.send_text_message([user.wecom_userid], content)
|
||||
|
||||
# ── Auto-cancel overdue plans that have no matching visit ──
|
||||
from app.utils.edit_log import append_entry as append_edit_log
|
||||
auto_cancelled = 0
|
||||
for plan in overdue:
|
||||
has_visit = await db.execute(
|
||||
select(Visit).where(
|
||||
Visit.customer_id == plan.customer_id,
|
||||
Visit.visit_date >= plan.plan_date,
|
||||
)
|
||||
)
|
||||
if not has_visit.scalar():
|
||||
plan.status = "已取消"
|
||||
append_edit_log(plan, "系统", [{
|
||||
"field": "status",
|
||||
"from": "计划中",
|
||||
"to": "已取消",
|
||||
"reason": "逾期自动取消",
|
||||
}])
|
||||
auto_cancelled += 1
|
||||
|
||||
if auto_cancelled:
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"date": str(today),
|
||||
"overdue": len(overdue),
|
||||
"auto_cancelled": auto_cancelled,
|
||||
"managers_affected": len(by_manager),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""APScheduler singleton — allows runtime reschedule of notification jobs."""
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
||||
_scheduler: AsyncIOScheduler | None = None
|
||||
|
||||
|
||||
def get_scheduler() -> AsyncIOScheduler:
|
||||
"""Lazy-init and return the global scheduler."""
|
||||
global _scheduler
|
||||
if _scheduler is None:
|
||||
_scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
|
||||
return _scheduler
|
||||
|
||||
|
||||
async def reschedule_daily_check(hour: int, minute: int):
|
||||
"""Update the daily_check cron job to a new time."""
|
||||
sched = get_scheduler()
|
||||
# Remove and re-add — APScheduler reschedule_job is inconsistent with cron triggers
|
||||
try:
|
||||
sched.remove_job("daily_check")
|
||||
except Exception:
|
||||
pass
|
||||
from app.services.scheduler import check_daily_reporting
|
||||
from app.database import async_session
|
||||
|
||||
async def _wrapper():
|
||||
async with async_session() as db:
|
||||
await check_daily_reporting(db)
|
||||
|
||||
sched.add_job(_wrapper, "cron", hour=hour, minute=minute, id="daily_check", timezone="Asia/Shanghai")
|
||||
|
||||
|
||||
def start_scheduler(notification_hour: int = 17, notification_minute: int = 30):
|
||||
"""Start the scheduler with configured notification times."""
|
||||
sched = get_scheduler()
|
||||
|
||||
from app.services.scheduler import check_daily_reporting, check_overdue_plans
|
||||
from app.database import async_session
|
||||
|
||||
async def _daily():
|
||||
async with async_session() as db:
|
||||
await check_daily_reporting(db)
|
||||
|
||||
async def _overdue():
|
||||
async with async_session() as db:
|
||||
await check_overdue_plans(db)
|
||||
|
||||
sched.add_job(_daily, "cron", hour=notification_hour, minute=notification_minute, id="daily_check", timezone="Asia/Shanghai")
|
||||
sched.add_job(_overdue, "cron", hour=9, minute=0, id="overdue_check", timezone="Asia/Shanghai")
|
||||
sched.start()
|
||||
|
||||
|
||||
def shutdown_scheduler():
|
||||
"""Shutdown the scheduler gracefully."""
|
||||
global _scheduler
|
||||
if _scheduler is not None:
|
||||
_scheduler.shutdown(wait=False)
|
||||
_scheduler = None
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Visit service — shared logic for visit creation, update, and import."""
|
||||
|
||||
from uuid import UUID
|
||||
from datetime import date
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.work_plan import WorkPlan
|
||||
from app.utils.edit_log import append_entry
|
||||
|
||||
|
||||
async def auto_complete_work_plans(
|
||||
db: AsyncSession,
|
||||
customer_id: UUID,
|
||||
visit_date: date,
|
||||
editor_name: str,
|
||||
reason: str = "拜访自动完成",
|
||||
) -> int:
|
||||
"""Auto-complete matching work plans when a visit is created/updated/imported.
|
||||
|
||||
Matches by: same customer_id + status=="计划中" + plan_date <= visit_date.
|
||||
Returns the number of plans completed.
|
||||
"""
|
||||
plans_result = await db.execute(
|
||||
select(WorkPlan).where(
|
||||
WorkPlan.customer_id == customer_id,
|
||||
WorkPlan.status == "计划中",
|
||||
WorkPlan.plan_date <= visit_date,
|
||||
)
|
||||
)
|
||||
count = 0
|
||||
for plan in plans_result.scalars().all():
|
||||
plan.status = "已完成"
|
||||
append_entry(plan, editor_name, [{
|
||||
"field": "status",
|
||||
"from": "计划中",
|
||||
"to": "已完成",
|
||||
"reason": reason,
|
||||
}])
|
||||
count += 1
|
||||
return count
|
||||
+222
-23
@@ -1,55 +1,254 @@
|
||||
import asyncio
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import httpx
|
||||
from sqlalchemy import delete, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.models.wecom_bind_token import WecomBindToken
|
||||
|
||||
async def store_bind_token(db: AsyncSession, wecom_userid: str, ttl: int = 600) -> str:
|
||||
"""Persist a one-time bind token so it works across application workers."""
|
||||
token = uuid.uuid4().hex
|
||||
now = datetime.now(timezone.utc)
|
||||
await db.execute(delete(WecomBindToken).where(WecomBindToken.expires_at < now))
|
||||
db.add(WecomBindToken(
|
||||
token=token,
|
||||
wecom_userid=wecom_userid,
|
||||
expires_at=now + timedelta(seconds=ttl),
|
||||
))
|
||||
await db.commit()
|
||||
return token
|
||||
|
||||
|
||||
async def consume_bind_token(db: AsyncSession, token: str) -> str | None:
|
||||
"""Atomically consume an unexpired binding token and return its WeCom user ID."""
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await db.execute(
|
||||
update(WecomBindToken)
|
||||
.where(
|
||||
WecomBindToken.token == token,
|
||||
WecomBindToken.consumed_at.is_(None),
|
||||
WecomBindToken.expires_at > now,
|
||||
)
|
||||
.values(consumed_at=now)
|
||||
.returning(WecomBindToken.wecom_userid)
|
||||
)
|
||||
await db.commit()
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
class WecomClient:
|
||||
"""Minimal WeChat Work API client for sending app messages."""
|
||||
"""WeChat Work API client — token management, message sending, OAuth."""
|
||||
|
||||
def __init__(self):
|
||||
self.corp_id = settings.WECOM_CORP_ID
|
||||
self.agent_id = settings.WECOM_AGENT_ID
|
||||
self.secret = settings.WECOM_SECRET
|
||||
self._access_token: str | None = None
|
||||
self._token_expires_at: float = 0 # epoch seconds
|
||||
|
||||
@property
|
||||
def access_token(self) -> str | None:
|
||||
return self._access_token
|
||||
|
||||
async def _get_token(self) -> str:
|
||||
if self._access_token:
|
||||
"""Get a valid access token, refreshing if expired."""
|
||||
if self._access_token and time.time() < self._token_expires_at - 60:
|
||||
return self._access_token
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corp_id}&corpsecret={self.secret}"
|
||||
|
||||
url = f"{settings.WECOM_API_BASE}/cgi-bin/gettoken?corpid={self.corp_id}&corpsecret={self.secret}"
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url, timeout=10)
|
||||
data = resp.json()
|
||||
if data.get("errcode") == 0:
|
||||
self._access_token = data["access_token"]
|
||||
self._token_expires_at = time.time() + data.get("expires_in", 7200)
|
||||
return self._access_token
|
||||
raise Exception(f"Failed to get wecom token: {data}")
|
||||
|
||||
async def _post_with_retry(self, url: str, body: dict, max_retries: int = 3) -> dict:
|
||||
"""POST with retry on network errors and automatic token refresh on 42001."""
|
||||
last_error = None
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
token = await self._get_token()
|
||||
sep = "&" if "?" in url else "?"
|
||||
full_url = f"{url}{sep}access_token={token}"
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(full_url, json=body, timeout=10)
|
||||
data = resp.json()
|
||||
errcode = data.get("errcode", 0)
|
||||
|
||||
if errcode == 0:
|
||||
return data
|
||||
|
||||
if errcode == 42001:
|
||||
self._access_token = None
|
||||
self._token_expires_at = 0
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
|
||||
if errcode == 45009:
|
||||
if attempt < max_retries - 1:
|
||||
await asyncio.sleep(1 * (attempt + 1))
|
||||
continue
|
||||
|
||||
last_error = data
|
||||
except (httpx.TimeoutException, httpx.ConnectError) as e:
|
||||
last_error = {"errcode": -1, "errmsg": str(e)}
|
||||
if attempt < max_retries - 1:
|
||||
await asyncio.sleep(0.5 * (attempt + 1))
|
||||
|
||||
raise Exception(f"WeCom API error after {max_retries} retries: {last_error}")
|
||||
|
||||
# ── OAuth ──
|
||||
|
||||
async def get_userinfo_by_code(self, code: str) -> dict | None:
|
||||
"""Exchange OAuth2 code for userid (used in silent login)."""
|
||||
token = await self._get_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_token={token}&code={code}"
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url, timeout=10)
|
||||
data = resp.json()
|
||||
if data.get("errcode") == 0:
|
||||
return data
|
||||
try:
|
||||
token = await self._get_token()
|
||||
url = f"{settings.WECOM_API_BASE}/cgi-bin/auth/getuserinfo?access_token={token}&code={code}"
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url, timeout=10)
|
||||
data = resp.json()
|
||||
if data.get("errcode") == 0:
|
||||
return data
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# ── Messaging ──
|
||||
|
||||
async def send_text_message(self, user_ids: list[str], content: str) -> bool:
|
||||
"""Send a text app message to specified users."""
|
||||
if not settings.WECOM_AGENT_ID:
|
||||
return False # Not configured, skip silently in dev
|
||||
token = await self._get_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}"
|
||||
body = {
|
||||
"touser": "|".join(user_ids),
|
||||
"msgtype": "text",
|
||||
"agentid": int(settings.WECOM_AGENT_ID),
|
||||
"text": {"content": content},
|
||||
}
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(url, json=body, timeout=10)
|
||||
data = resp.json()
|
||||
return data.get("errcode") == 0
|
||||
return False
|
||||
try:
|
||||
body = {
|
||||
"touser": "|".join(user_ids),
|
||||
"msgtype": "text",
|
||||
"agentid": int(settings.WECOM_AGENT_ID),
|
||||
"text": {"content": content},
|
||||
}
|
||||
await self._post_with_retry(
|
||||
f"{settings.WECOM_API_BASE}/cgi-bin/message/send", body
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def send_markdown_message(self, content: str, user_ids: list[str] | None = None) -> bool:
|
||||
"""Send a markdown message. If user_ids is provided, send to those users; otherwise broadcast to @all."""
|
||||
if not settings.WECOM_AGENT_ID:
|
||||
return False
|
||||
try:
|
||||
touser = "|".join(user_ids) if user_ids else "@all"
|
||||
body = {
|
||||
"touser": touser,
|
||||
"msgtype": "markdown",
|
||||
"agentid": int(settings.WECOM_AGENT_ID),
|
||||
"markdown": {"content": content},
|
||||
}
|
||||
await self._post_with_retry(
|
||||
f"{settings.WECOM_API_BASE}/cgi-bin/message/send", body
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def send_text_card(self, user_id: str, title: str, description: str, url: str) -> bool:
|
||||
"""Send a textcard message (clickable card) to a single user."""
|
||||
if not settings.WECOM_AGENT_ID:
|
||||
return False
|
||||
try:
|
||||
body = {
|
||||
"touser": user_id,
|
||||
"msgtype": "textcard",
|
||||
"agentid": int(settings.WECOM_AGENT_ID),
|
||||
"textcard": {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"url": url,
|
||||
},
|
||||
}
|
||||
await self._post_with_retry(
|
||||
f"{settings.WECOM_API_BASE}/cgi-bin/message/send", body
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def send_template_card(
|
||||
self, user_ids: list[str], title: str, desc: str, url: str, btn_text: str = "查看详情"
|
||||
) -> bool:
|
||||
"""Send a text_notice template card with a deep-link button."""
|
||||
if not settings.WECOM_AGENT_ID:
|
||||
return False
|
||||
try:
|
||||
body = {
|
||||
"touser": "|".join(user_ids),
|
||||
"msgtype": "template_card",
|
||||
"agentid": int(settings.WECOM_AGENT_ID),
|
||||
"template_card": {
|
||||
"card_type": "text_notice",
|
||||
"main_title": {"title": title, "desc": desc},
|
||||
"card_action": {"type": 1, "url": url},
|
||||
"button_list": [{"text": btn_text, "style": 1, "key": "open_url"}],
|
||||
},
|
||||
}
|
||||
await self._post_with_retry(
|
||||
f"{settings.WECOM_API_BASE}/cgi-bin/message/send", body
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ── Menu management ──
|
||||
|
||||
async def create_menu(self, buttons: list[dict]) -> bool:
|
||||
"""Create/replace the app's custom menu."""
|
||||
if not settings.WECOM_AGENT_ID:
|
||||
return False
|
||||
try:
|
||||
body = {"button": buttons}
|
||||
await self._post_with_retry(
|
||||
f"{settings.WECOM_API_BASE}/cgi-bin/menu/create?agentid={settings.WECOM_AGENT_ID}", body
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def get_menu(self) -> dict | None:
|
||||
"""Get current app menu configuration."""
|
||||
if not settings.WECOM_AGENT_ID:
|
||||
return None
|
||||
try:
|
||||
token = await self._get_token()
|
||||
url = f"{settings.WECOM_API_BASE}/cgi-bin/menu/get?access_token={token}&agentid={settings.WECOM_AGENT_ID}"
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url, timeout=10)
|
||||
data = resp.json()
|
||||
if data.get("errcode") == 0:
|
||||
return data
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def delete_menu(self) -> bool:
|
||||
"""Delete the app's custom menu."""
|
||||
if not settings.WECOM_AGENT_ID:
|
||||
return False
|
||||
try:
|
||||
token = await self._get_token()
|
||||
url = f"{settings.WECOM_API_BASE}/cgi-bin/menu/delete?access_token={token}&agentid={settings.WECOM_AGENT_ID}"
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url, timeout=10)
|
||||
data = resp.json()
|
||||
return data.get("errcode") == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
wecom_client = WecomClient()
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Audit log utility — write once, query forever."""
|
||||
import uuid
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
|
||||
async def log_audit(
|
||||
db: AsyncSession,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
entity_name: str,
|
||||
action: str, # create / update / delete
|
||||
operator_id: str,
|
||||
operator_name: str,
|
||||
summary: str = "",
|
||||
details: dict | None = None,
|
||||
):
|
||||
"""Write an audit log entry."""
|
||||
entry = AuditLog(
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
entity_name=entity_name,
|
||||
action=action,
|
||||
operator_id=uuid.UUID(operator_id),
|
||||
operator_name=operator_name,
|
||||
summary=summary or f"{'创建了' if action == 'create' else '更新了' if action == 'update' else '删除了'} {entity_name}",
|
||||
details=details or {},
|
||||
)
|
||||
db.add(entry)
|
||||
# Don't commit here — let the caller handle it as part of their transaction
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from jose import jwt, JWTError
|
||||
import jwt
|
||||
from jwt import InvalidTokenError
|
||||
from app.config import settings
|
||||
|
||||
|
||||
@@ -15,5 +16,5 @@ def decode_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
except InvalidTokenError:
|
||||
return None
|
||||
|
||||
@@ -11,3 +11,32 @@ def today_cst() -> date:
|
||||
def parse_date(s: str) -> date:
|
||||
"""Parse a date string that may be YYYY-MM-DD or a full ISO datetime."""
|
||||
return date.fromisoformat(s[:10])
|
||||
|
||||
|
||||
def is_working_day(d: date | None = None, holidays: set[date] | None = None, workdays: set[date] | None = None) -> bool:
|
||||
"""Check if a date is a Chinese working day.
|
||||
|
||||
Returns False for:
|
||||
- Saturdays and Sundays (weekday >= 5), UNLESS in the workdays set (调休)
|
||||
- Dates in the holidays set (weekday holidays like 春节/国庆)
|
||||
|
||||
Returns True for:
|
||||
- Monday-Friday, unless in holidays set
|
||||
- Saturday/Sunday that are in the workdays set (调休 makeup days)
|
||||
"""
|
||||
d = d or today_cst()
|
||||
is_weekend = d.weekday() >= 5
|
||||
|
||||
# 调休: weekend that becomes a workday
|
||||
if is_weekend and workdays and d in workdays:
|
||||
return True
|
||||
|
||||
# Normal weekend
|
||||
if is_weekend:
|
||||
return False
|
||||
|
||||
# Weekday holiday
|
||||
if holidays and d in holidays:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
# Fail CI for parse errors and undefined names. Broader style cleanup is tracked
|
||||
# separately because the existing codebase has a substantial legacy backlog.
|
||||
select = ["E9", "F63", "F7", "F82"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# SQLAlchemy resolves these relationship targets from strings after model import.
|
||||
"app/models/*.py" = ["F821"]
|
||||
@@ -1,14 +1,17 @@
|
||||
fastapi==0.115.6
|
||||
fastapi==0.140.7
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy[asyncio]==2.0.36
|
||||
asyncpg==0.30.0
|
||||
alembic==1.14.0
|
||||
pydantic==2.10.3
|
||||
pydantic-settings==2.7.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
PyJWT==2.13.0
|
||||
httpx==0.28.1
|
||||
python-multipart==0.0.18
|
||||
python-multipart==0.0.32
|
||||
minio==7.2.10
|
||||
openpyxl==3.1.5
|
||||
apscheduler==3.11.0
|
||||
python-dotenv==1.0.1
|
||||
python-dotenv==1.2.2
|
||||
cryptography==49.0.0
|
||||
defusedxml==0.7.1
|
||||
Pillow==12.3.0
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from io import BytesIO
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
from app.api import system_config, upload
|
||||
|
||||
|
||||
def _png_bytes() -> bytes:
|
||||
buffer = BytesIO()
|
||||
Image.new("RGB", (8, 8), color=(10, 20, 30)).save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _upload_client(authenticated: bool) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(upload.router)
|
||||
if authenticated:
|
||||
app.dependency_overrides[upload.require_any_role] = lambda: {
|
||||
"user_id": "a2d4447f-7b67-4304-ad9a-953007e75ef2",
|
||||
"role": "manager",
|
||||
}
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_upload_endpoint_requires_bearer_authentication():
|
||||
response = _upload_client(authenticated=False).post(
|
||||
"/upload/image",
|
||||
files={"file": ("photo.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_upload_endpoint_validates_and_normalizes_before_storage():
|
||||
with patch("app.api.upload.put_validated_image") as put_image:
|
||||
response = _upload_client(authenticated=True).post(
|
||||
"/upload/image",
|
||||
files={"file": ("photo.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
object_key = response.json()["object_key"]
|
||||
assert object_key.startswith("uploads/a2d4447f-7b67-4304-ad9a-953007e75ef2/")
|
||||
assert object_key.endswith(".jpg")
|
||||
stored_bytes = put_image.call_args.args[1]
|
||||
with Image.open(BytesIO(stored_bytes)) as image:
|
||||
assert image.format == "JPEG"
|
||||
|
||||
|
||||
def test_system_configuration_cannot_be_read_without_director_authentication():
|
||||
app = FastAPI()
|
||||
app.include_router(system_config.router)
|
||||
|
||||
response = TestClient(app).get("/system-config")
|
||||
|
||||
assert response.status_code == 401
|
||||
@@ -0,0 +1,10 @@
|
||||
from app.config import settings
|
||||
from app.utils.security import create_access_token, decode_token
|
||||
|
||||
|
||||
def test_jwt_round_trip_and_tamper_rejection(monkeypatch):
|
||||
monkeypatch.setattr(settings, "SECRET_KEY", "x" * 32)
|
||||
token = create_access_token({"user_id": "user-1", "role": "manager"})
|
||||
|
||||
assert decode_token(token)["user_id"] == "user-1"
|
||||
assert decode_token(f"{token}tampered") is None
|
||||
@@ -0,0 +1,95 @@
|
||||
import asyncio
|
||||
from io import BytesIO
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from fastapi import HTTPException
|
||||
from PIL import Image
|
||||
|
||||
from app.api.upload import build_object_key, is_owned_upload_key, normalize_image, validate_image_upload
|
||||
from app.config import settings, validate_security_settings
|
||||
from app.services.wecom import consume_bind_token, store_bind_token
|
||||
|
||||
|
||||
USER_ID = "a2d4447f-7b67-4304-ad9a-953007e75ef2"
|
||||
|
||||
|
||||
def test_build_object_key_is_scoped_to_the_authenticated_user():
|
||||
object_key = build_object_key(USER_ID, "jpg")
|
||||
|
||||
assert is_owned_upload_key(object_key, USER_ID)
|
||||
assert not is_owned_upload_key(object_key, "cf610b28-51a7-423d-a55a-730e193c9971")
|
||||
|
||||
|
||||
def test_upload_rejects_non_image_content_type():
|
||||
try:
|
||||
validate_image_upload("payload.exe", "application/octet-stream")
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 400
|
||||
else:
|
||||
raise AssertionError("non-image uploads must be rejected")
|
||||
|
||||
|
||||
def test_upload_rejects_an_invalid_filename_extension():
|
||||
try:
|
||||
validate_image_upload("payload.txt", "image/jpeg")
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 400
|
||||
else:
|
||||
raise AssertionError("invalid extensions must be rejected")
|
||||
|
||||
|
||||
def test_image_content_is_normalized_to_jpeg():
|
||||
source = BytesIO()
|
||||
Image.new("RGBA", (8, 8), color=(255, 0, 0, 128)).save(source, format="PNG")
|
||||
|
||||
normalized = normalize_image(source.getvalue())
|
||||
|
||||
with Image.open(BytesIO(normalized)) as image:
|
||||
assert image.format == "JPEG"
|
||||
assert image.mode == "RGB"
|
||||
|
||||
|
||||
def test_image_content_rejects_non_images():
|
||||
try:
|
||||
normalize_image(b"not an image")
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 400
|
||||
else:
|
||||
raise AssertionError("non-image bytes must be rejected")
|
||||
|
||||
|
||||
def test_wecom_binding_tokens_are_persisted_and_consumed_atomically():
|
||||
session = MagicMock()
|
||||
session.execute = AsyncMock()
|
||||
session.commit = AsyncMock()
|
||||
|
||||
token = asyncio.run(store_bind_token(session, "wecom-user"))
|
||||
|
||||
assert len(token) == 32
|
||||
assert session.add.call_args.args[0].wecom_userid == "wecom-user"
|
||||
session.commit.assert_awaited_once()
|
||||
|
||||
result = MagicMock()
|
||||
result.scalar_one_or_none.return_value = "wecom-user"
|
||||
session.execute = AsyncMock(return_value=result)
|
||||
session.commit = AsyncMock()
|
||||
|
||||
assert asyncio.run(consume_bind_token(session, token)) == "wecom-user"
|
||||
session.commit.assert_awaited_once()
|
||||
|
||||
|
||||
def test_production_rejects_placeholder_database_configuration(monkeypatch):
|
||||
monkeypatch.setattr(settings, "ENVIRONMENT", "production")
|
||||
monkeypatch.setattr(settings, "SECRET_KEY", "a" * 32)
|
||||
monkeypatch.setattr(settings, "DATABASE_URL", "postgresql+asyncpg://qiji_app:invalid@localhost:5432/qiji")
|
||||
monkeypatch.setattr(settings, "MINIO_ENDPOINT", "minio.example.test:9000")
|
||||
monkeypatch.setattr(settings, "MINIO_ACCESS_KEY", "qiji-app")
|
||||
monkeypatch.setattr(settings, "MINIO_SECRET_KEY", "a-secure-minio-secret")
|
||||
monkeypatch.setattr(settings, "MINIO_SECURE", True)
|
||||
|
||||
try:
|
||||
validate_security_settings()
|
||||
except RuntimeError as exc:
|
||||
assert "DATABASE_URL" in str(exc)
|
||||
else:
|
||||
raise AssertionError("production configuration must reject placeholders")
|
||||
@@ -0,0 +1,24 @@
|
||||
[Unit]
|
||||
Description=Qiji backend API
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=v6ole
|
||||
Group=v6ole
|
||||
WorkingDirectory=/home/v6ole/pyproject/qiji/backend
|
||||
EnvironmentFile=/home/v6ole/pyproject/qiji/backend/.env
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
ExecStart=/home/v6ole/pyproject/qiji/backend/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8002
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectKernelTunables=true
|
||||
ProtectControlGroups=true
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,22 @@
|
||||
# 企迹 (qiji) — 后端生产部署
|
||||
# PostgreSQL / MinIO / Casdoor / 前端 均为外部服务,仅容器化运行 FastAPI 后端
|
||||
# 用法: docker compose -f docker-compose.backend.yml up -d
|
||||
#
|
||||
# 使用 host 网络模式:
|
||||
# - 可直接访问 10.10.10.x 外部服务 (DB/MinIO/Casdoor)
|
||||
# - FRP frpc 同为 host 网络,127.0.0.1:8002 直达
|
||||
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
container_name: qiji-backend
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
command: sh -c "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8002"
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
+29
-18
@@ -1,45 +1,56 @@
|
||||
# This compose file is for a single-host development/integration environment.
|
||||
# Production deployment must use the approved platform pipeline and secret manager.
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: qiji
|
||||
POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER must be set}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-qiji}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "127.0.0.1:5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
image: ${MINIO_IMAGE:?MINIO_IMAGE must be a pinned MinIO version or digest}
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
- "127.0.0.1:9000:9000"
|
||||
- "127.0.0.1:9001:9001"
|
||||
volumes:
|
||||
- miniodata:/data
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "127.0.0.1:8000:8000"
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/qiji
|
||||
ENVIRONMENT: development
|
||||
DEBUG: "false"
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set}
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:?POSTGRES_USER must be set}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}@postgres:5432/${POSTGRES_DB:-qiji}
|
||||
MINIO_ENDPOINT: minio:9000
|
||||
MINIO_ACCESS_KEY: minioadmin
|
||||
MINIO_SECRET_KEY: minioadmin
|
||||
MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
|
||||
MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
|
||||
MINIO_BUCKET: qiji-photos
|
||||
MINIO_SECURE: "false"
|
||||
depends_on:
|
||||
- postgres
|
||||
- minio
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_started
|
||||
command: sh -c "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8000"
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,454 @@
|
||||
# 暗黑模式 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 为企迹系统新增双选暗黑模式:用户在设置页分别选择亮色/暗色偏好主题,顶部栏一键切换亮↔暗。
|
||||
|
||||
**Architecture:** 扩展 theme store 支持 mode(light/dark) + lightTheme + darkTheme 三字段,新增 minimal-dark/tech-dark 两套 CSS 变量,Element Plus 通过 `html.dark` class 适配暗色。
|
||||
|
||||
**Tech Stack:** Vue 3 + Pinia + TypeScript + Element Plus + CSS Custom Properties
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 四大主题: editorial(已有), light(已有), minimal-dark(新增), tech-dark(新增)
|
||||
- localStorage 持久化: mode, lightTheme, darkTheme 三项
|
||||
- 暗色模式时 `<html>` 同时设置 `data-theme` 和 `class="dark"`
|
||||
- 移动端暂不加主题切换(仅 PC 端顶部栏按钮)
|
||||
- 保持现有 editorial/light 的 CSS 变量不变
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 改造 Theme Store
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/stores/theme.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `useThemeStore()` with `mode`, `lightTheme`, `darkTheme`, `effectiveTheme`, `toggleMode()`, `setLightTheme(t)`, `setDarkTheme(t)`, `applyTheme(t)`
|
||||
- Theme type: `'editorial' | 'light' | 'minimal-dark' | 'tech-dark'`
|
||||
|
||||
- [ ] **Step 1: 重写 theme store**
|
||||
|
||||
```typescript
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export type Theme = 'editorial' | 'light' | 'minimal-dark' | 'tech-dark'
|
||||
type Mode = 'light' | 'dark'
|
||||
|
||||
export const themeLabels: Record<Theme, string> = {
|
||||
editorial: '编辑风',
|
||||
light: '极简亮色',
|
||||
'minimal-dark': '极简深色',
|
||||
'tech-dark': '科技深色',
|
||||
}
|
||||
|
||||
export const lightThemes: Theme[] = ['editorial', 'light']
|
||||
export const darkThemes: Theme[] = ['minimal-dark', 'tech-dark']
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const mode = ref<Mode>((localStorage.getItem('theme-mode') as Mode) || 'light')
|
||||
const lightTheme = ref<Theme>((localStorage.getItem('theme-light') as Theme) || 'editorial')
|
||||
const darkTheme = ref<Theme>((localStorage.getItem('theme-dark') as Theme) || 'tech-dark')
|
||||
|
||||
const effectiveTheme = computed<Theme>(() => {
|
||||
return mode.value === 'light' ? lightTheme.value : darkTheme.value
|
||||
})
|
||||
|
||||
function applyTheme(theme: Theme, isDark: boolean) {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
function apply() {
|
||||
applyTheme(effectiveTheme.value, mode.value === 'dark')
|
||||
}
|
||||
|
||||
function setLightTheme(theme: Theme) {
|
||||
lightTheme.value = theme
|
||||
localStorage.setItem('theme-light', theme)
|
||||
if (mode.value === 'light') apply()
|
||||
}
|
||||
|
||||
function setDarkTheme(theme: Theme) {
|
||||
darkTheme.value = theme
|
||||
localStorage.setItem('theme-dark', theme)
|
||||
if (mode.value === 'dark') apply()
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
mode.value = mode.value === 'light' ? 'dark' : 'light'
|
||||
localStorage.setItem('theme-mode', mode.value)
|
||||
apply()
|
||||
}
|
||||
|
||||
// Apply on init
|
||||
apply()
|
||||
|
||||
return { mode, lightTheme, darkTheme, effectiveTheme, setLightTheme, setDarkTheme, toggleMode }
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/stores/theme.ts
|
||||
git commit -m "feat: 改造 theme store 支持暗黑模式双选架构"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 新增两套暗色 CSS 变量
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/App.vue` (在 `:root[data-theme="light"]` 块之后添加两个新块)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Theme type `'minimal-dark'`, `'tech-dark'` from store
|
||||
- Produces: CSS variables for both dark themes + Element Plus dark overrides
|
||||
|
||||
- [ ] **Step 1: 添加 minimal-dark 主题 CSS**
|
||||
|
||||
在 `:root[data-theme="light"]` 块结束后(第178行 `}` 之后),插入:
|
||||
|
||||
```css
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
THEME: Minimal Dark — 极简深色
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
:root[data-theme="minimal-dark"] {
|
||||
/* ── Core palette ── */
|
||||
--ink: #E5E5E5;
|
||||
--ink-light: #CCCCCC;
|
||||
--ink-dark: #FFFFFF;
|
||||
--vermilion: #F87171;
|
||||
--vermilion-light: #FCA5A5;
|
||||
--vermilion-dark: #EF4444;
|
||||
--gold: #A78BFA;
|
||||
--gold-light: #C4B5FD;
|
||||
--gold-dark: #8B5CF6;
|
||||
--paper: #1A1A1A;
|
||||
--paper-dark: #141414;
|
||||
--surface: #2D2D2D;
|
||||
--sage: #4ADE80;
|
||||
--amber: #FBBF24;
|
||||
--warm-gray: #999999;
|
||||
--warm-border: #3A3A3A;
|
||||
|
||||
/* ── Semantic tokens ── */
|
||||
--c-primary: var(--ink);
|
||||
--c-primary-light: var(--ink-light);
|
||||
--c-primary-bg: #262626;
|
||||
--c-accent: var(--vermilion);
|
||||
--c-accent-light: var(--vermilion-light);
|
||||
--c-gold: var(--gold);
|
||||
--c-success: var(--sage);
|
||||
--c-warning: var(--amber);
|
||||
--c-danger: var(--vermilion);
|
||||
--c-info: #60A5FA;
|
||||
--c-text: #E5E5E5;
|
||||
--c-text-secondary: #999999;
|
||||
--c-text-muted: #666666;
|
||||
--c-bg: var(--paper);
|
||||
--c-bg-card: var(--surface);
|
||||
--c-border: var(--warm-border);
|
||||
|
||||
/* ── Effects ── */
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.20);
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.30);
|
||||
--shadow-md: 0 2px 6px rgba(0,0,0,0.40);
|
||||
--shadow-lg: 0 4px 12px rgba(0,0,0,0.50);
|
||||
--radius: 0px;
|
||||
--radius-sm: 0px;
|
||||
--radius-xs: 0px;
|
||||
--transition: 0.18s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ── Sidebar — dark minimal ── */
|
||||
--sidebar-bg: #141414;
|
||||
--sidebar-color: #E5E5E5;
|
||||
--sidebar-accent: var(--gold);
|
||||
--sidebar-nav-text: #777777;
|
||||
--sidebar-nav-text-hover: #CCCCCC;
|
||||
--sidebar-nav-text-active: #FFFFFF;
|
||||
--sidebar-nav-bg-hover: rgba(255,255,255,0.04);
|
||||
--sidebar-nav-bg-active: rgba(255,255,255,0.06);
|
||||
--sidebar-nav-border-active: var(--gold);
|
||||
--sidebar-divider: #2A2A2A;
|
||||
--sidebar-brand-color: #FFFFFF;
|
||||
--sidebar-brand-sub-color: #666666;
|
||||
--sidebar-group-label-color: #555555;
|
||||
--sidebar-footer-role-color: var(--gold);
|
||||
--sidebar-footer-name-color: #999999;
|
||||
|
||||
/* ── Mobile page accent ── */
|
||||
--page-accent-color: rgba(167,139,250,0.06);
|
||||
|
||||
/* ── Manager chip tags ── */
|
||||
--mgr-chip-text: #E5E5E5;
|
||||
|
||||
/* ── Fonts — sans-serif minimal ── */
|
||||
--font-body: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--font-heading: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加 tech-dark 主题 CSS**
|
||||
|
||||
在 minimal-dark 块之后插入:
|
||||
|
||||
```css
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
THEME: Tech Dark — 科技深色 (编辑风暗色版)
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
:root[data-theme="tech-dark"] {
|
||||
/* ── Core palette ── */
|
||||
--ink: #C8D6E5;
|
||||
--ink-light: #A0B4CC;
|
||||
--ink-dark: #E8EEF4;
|
||||
--vermilion: #F26B6B;
|
||||
--vermilion-light: #F59898;
|
||||
--vermilion-dark: #E55555;
|
||||
--gold: #5B8DEF;
|
||||
--gold-light: #7BA5F5;
|
||||
--gold-dark: #4070D0;
|
||||
--paper: #0B1120;
|
||||
--paper-dark: #080C18;
|
||||
--surface: #111B2E;
|
||||
--sage: #4EC9B0;
|
||||
--amber: #E5C07B;
|
||||
--warm-gray: #6B7A8D;
|
||||
--warm-border: #1E2D45;
|
||||
|
||||
/* ── Semantic tokens ── */
|
||||
--c-primary: var(--ink);
|
||||
--c-primary-light: var(--ink-light);
|
||||
--c-primary-bg: #131D30;
|
||||
--c-accent: var(--vermilion);
|
||||
--c-accent-light: var(--vermilion-light);
|
||||
--c-gold: var(--gold);
|
||||
--c-success: var(--sage);
|
||||
--c-warning: var(--amber);
|
||||
--c-danger: var(--vermilion);
|
||||
--c-info: #5B8DEF;
|
||||
--c-text: #C8D6E5;
|
||||
--c-text-secondary: #6B7A8D;
|
||||
--c-text-muted: #455570;
|
||||
--c-bg: var(--paper);
|
||||
--c-bg-card: var(--surface);
|
||||
--c-border: var(--warm-border);
|
||||
|
||||
/* ── Effects ── */
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.25);
|
||||
--shadow: 0 2px 6px rgba(0,0,0,0.35);
|
||||
--shadow-md: 0 6px 18px rgba(0,0,0,0.45);
|
||||
--shadow-lg: 0 12px 36px rgba(0,0,0,0.55);
|
||||
--radius: 2px;
|
||||
--radius-sm: 2px;
|
||||
--radius-xs: 2px;
|
||||
--transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ── Sidebar — dark tech ── */
|
||||
--sidebar-bg: #070D1A;
|
||||
--sidebar-color: #C8D6E5;
|
||||
--sidebar-accent: var(--gold);
|
||||
--sidebar-nav-text: rgba(200,214,229,0.45);
|
||||
--sidebar-nav-text-hover: rgba(200,214,229,0.80);
|
||||
--sidebar-nav-text-active: #FFFFFF;
|
||||
--sidebar-nav-bg-hover: rgba(91,141,239,0.06);
|
||||
--sidebar-nav-bg-active: rgba(91,141,239,0.10);
|
||||
--sidebar-nav-border-active: var(--gold);
|
||||
--sidebar-divider: rgba(200,214,229,0.06);
|
||||
--sidebar-brand-color: #FFFFFF;
|
||||
--sidebar-brand-sub-color: rgba(200,214,229,0.35);
|
||||
--sidebar-group-label-color: rgba(200,214,229,0.20);
|
||||
--sidebar-footer-role-color: var(--gold);
|
||||
--sidebar-footer-name-color: rgba(200,214,229,0.65);
|
||||
|
||||
/* ── Mobile page accent ── */
|
||||
--page-accent-color: rgba(91,141,239,0.06);
|
||||
|
||||
/* ── Manager chip tags ── */
|
||||
--mgr-chip-text: #C8D6E5;
|
||||
|
||||
/* ── Fonts — editorial Chinese ── */
|
||||
--font-body: 'Noto Serif SC', STSong, Songti SC, '宋体', serif;
|
||||
--font-heading: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
|
||||
--font-mono: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/App.vue
|
||||
git commit -m "feat: 新增 minimal-dark 和 tech-dark 两套暗色 CSS 变量"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 改造 DesktopLayout 顶部栏按钮
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/DesktopLayout.vue` (script 和 template 中的主题切换按钮)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `useThemeStore()` — `mode`, `toggleMode()`
|
||||
|
||||
- [ ] **Step 1: 替换主题按钮为亮/暗切换**
|
||||
|
||||
将 `cycleTheme` 函数替换为 `toggleMode`,按钮改为 sun/moon 图标切换:
|
||||
|
||||
```typescript
|
||||
// 替换 cycleTheme 函数:
|
||||
function toggleDarkMode() {
|
||||
themeStore.toggleMode()
|
||||
}
|
||||
```
|
||||
|
||||
Template 中替换按钮:
|
||||
|
||||
```html
|
||||
<button
|
||||
class="topbar-btn"
|
||||
@click="toggleDarkMode"
|
||||
:title="themeStore.mode === 'light' ? '切换至暗色模式' : '切换至亮色模式'"
|
||||
>
|
||||
<!-- Sun icon (light mode) -->
|
||||
<svg v-if="themeStore.mode === 'light'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
<!-- Moon icon (dark mode) -->
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 触发构建验证**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build -- --emptyOutDir
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/DesktopLayout.vue
|
||||
git commit -m "feat: 桌面端顶部栏改为亮/暗模式切换按钮"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 改造设置页面主题面板
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/desktop/Settings.vue` (替换 Theme 卡片为双选下拉)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `useThemeStore()` — `lightTheme`, `darkTheme`, `mode`, `setLightTheme()`, `setDarkTheme()`
|
||||
|
||||
- [ ] **Step 1: 替换主题选择区域**
|
||||
|
||||
Replace the el-card template section (lines 158-173) and the `themeLabels` import:
|
||||
|
||||
**Script 部分修改 import:**
|
||||
```typescript
|
||||
import { useThemeStore, themeLabels, lightThemes, darkThemes, type Theme } from '@/stores/theme'
|
||||
```
|
||||
|
||||
**Template 部分替换主题卡片:**
|
||||
```html
|
||||
<!-- Theme -->
|
||||
<el-card class="setting-card">
|
||||
<template #header>
|
||||
<div class="card-header-title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px; color: var(--gold)">
|
||||
<circle cx="12" cy="12" r="5"></circle><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"></path>
|
||||
</svg>
|
||||
界面主题
|
||||
</div>
|
||||
</template>
|
||||
<div class="theme-config">
|
||||
<div class="theme-row">
|
||||
<span class="theme-label">☀️ 亮色模式主题</span>
|
||||
<el-select :model-value="themeStore.lightTheme" @update:model-value="themeStore.setLightTheme($event as Theme)" style="width:200px">
|
||||
<el-option v-for="t in lightThemes" :key="t" :label="themeLabels[t]" :value="t" />
|
||||
</el-select>
|
||||
<span v-if="themeStore.mode === 'light'" class="theme-badge theme-badge--active">当前</span>
|
||||
</div>
|
||||
<div class="theme-row">
|
||||
<span class="theme-label">🌙 暗色模式主题</span>
|
||||
<el-select :model-value="themeStore.darkTheme" @update:model-value="themeStore.setDarkTheme($event as Theme)" style="width:200px">
|
||||
<el-option v-for="t in darkThemes" :key="t" :label="themeLabels[t]" :value="t" />
|
||||
</el-select>
|
||||
<span v-if="themeStore.mode === 'dark'" class="theme-badge theme-badge--active">当前</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
```
|
||||
|
||||
**Style 部分替换 `.theme-options` 样式:**
|
||||
```css
|
||||
/* ── Theme Config ── */
|
||||
.theme-config { display: flex; flex-direction: column; gap: 12px; }
|
||||
.theme-row { display: flex; align-items: center; gap: 12px; }
|
||||
.theme-label { font-family: var(--font-body); font-size: 14px; color: var(--c-text); min-width: 140px; }
|
||||
.theme-badge { font-size: 11px; padding: 2px 8px; border-radius: 2px; font-family: var(--font-mono); }
|
||||
.theme-badge--active { background: var(--c-primary); color: #fff; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 触发构建验证**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build -- --emptyOutDir
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/desktop/Settings.vue
|
||||
git commit -m "feat: 设置页改为亮/暗双选主题配置面板"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 验证与收尾
|
||||
|
||||
- [ ] **Step 1: 完整构建**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 检查 localStorage 兼容性**
|
||||
|
||||
验证旧 `localStorage.setItem('theme', ...)` 数据不会导致新系统崩溃。在 theme store 的 `apply()` 中已有兜底逻辑。
|
||||
|
||||
- [ ] **Step 3: 快速手动测试检查清单**
|
||||
- [ ] 默认加载为 editorial 亮色
|
||||
- [ ] 点击顶部栏 ☀️→🌙 切换到 tech-dark
|
||||
- [ ] 设置页切换亮色主题为 light,暗色主题为 minimal-dark
|
||||
- [ ] 点击顶部栏按钮在两主题间切换
|
||||
- [ ] 刷新页面偏好保持
|
||||
- [ ] Element Plus 组件(表格/对话框/按钮)在暗色下正常
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: 暗黑模式最终验证通过"
|
||||
```
|
||||
@@ -0,0 +1,349 @@
|
||||
# 客户经理请假功能 — 设计文档
|
||||
|
||||
> 日期:2026-07-12 | 状态:已确认
|
||||
|
||||
## 一、需求概述
|
||||
|
||||
为政企周报管理系统添加客户经理请假功能。请假可由客户经理自己提交或支局长代为提交,提交即生效(无需审批)。请假期间免填报考核、免催办提醒,AI 周报摘要标注请假信息。
|
||||
|
||||
## 二、核心决策
|
||||
|
||||
| 决策项 | 结论 |
|
||||
|--------|------|
|
||||
| 粒度 | 按天(start_date ~ end_date) |
|
||||
| 审批 | 无需审批,提交即生效 |
|
||||
| 类型 | 年假 / 事假 / 病假 / 调休 / 其他 |
|
||||
| 权限 | 经理看自己,支局长/领导看全员 |
|
||||
| 入口 | PC 端仪表盘卡片 + 独立页面 + 移动端首页 + 表单页 |
|
||||
|
||||
## 三、数据模型
|
||||
|
||||
### 新增表 `leaves`
|
||||
|
||||
```sql
|
||||
CREATE TABLE leaves (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
manager_id UUID NOT NULL REFERENCES users(id),
|
||||
leave_type VARCHAR(20) NOT NULL DEFAULT '事假', -- 年假/事假/病假/调休/其他
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
reason VARCHAR(500) DEFAULT '',
|
||||
submitted_by UUID NOT NULL REFERENCES users(id), -- 提交人(本人或支局长)
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_leaves_manager_id ON leaves(manager_id);
|
||||
CREATE INDEX idx_leaves_dates ON leaves(start_date, end_date);
|
||||
```
|
||||
|
||||
### 设计要点
|
||||
|
||||
- `start_date <= end_date`,应用层校验
|
||||
- 判断"今天是否请假中":`start_date <= today <= end_date`
|
||||
- 判断"本周是否有请假":`start_date <= sunday AND end_date >= monday`
|
||||
- 无 edit_log 列(请假记录简单,不追变更历史)
|
||||
|
||||
### SQLAlchemy 模型
|
||||
|
||||
```python
|
||||
class Leave(Base):
|
||||
__tablename__ = "leaves"
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
|
||||
leave_type: Mapped[str] = mapped_column(String(20), default="事假")
|
||||
start_date: Mapped[date] = mapped_column(Date)
|
||||
end_date: Mapped[date] = mapped_column(Date)
|
||||
reason: Mapped[str] = mapped_column(String(500), default="")
|
||||
submitted_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
```
|
||||
|
||||
## 四、业务逻辑变更
|
||||
|
||||
### 4.1 填报进度判断 (`get_reporting_progress`)
|
||||
|
||||
**文件:** `backend/app/services/dashboard.py`
|
||||
|
||||
变更点:
|
||||
1. 查询本周所有请假记录(`start_date <= sunday AND end_date >= monday`)
|
||||
2. 对每个经理判断今日是否在请假区间内
|
||||
3. 新增第四种状态 `on_leave`
|
||||
|
||||
```python
|
||||
# 新增返回字段
|
||||
{
|
||||
"manager_id": "uuid",
|
||||
"has_reported_today": bool,
|
||||
"completed": bool,
|
||||
"on_leave": bool, # ← 新增
|
||||
"leave_info": { # ← 新增(仅 on_leave=True 时有值)
|
||||
"leave_type": "事假",
|
||||
"start_date": "...",
|
||||
"end_date": "..."
|
||||
} | null
|
||||
}
|
||||
```
|
||||
|
||||
前端三色状态扩展为四色:
|
||||
|
||||
| 状态 | 条件 | 颜色 | 印章 |
|
||||
|------|------|------|------|
|
||||
| `full` | 已填 + 完成 | 绿 (sage) | 满 |
|
||||
| `catching` | 已填 + 追赶中 | 黄 (gold) | 追 |
|
||||
| `missing` | 未填 | 红 (vermilion) | 未 |
|
||||
| `on_leave` | 请假中 | 蓝灰 | 假 |
|
||||
|
||||
### 4.2 催办提醒 (`check_daily_reporting`)
|
||||
|
||||
**文件:** `backend/app/services/scheduler.py`
|
||||
|
||||
变更点:
|
||||
1. 查询当天请假中的经理(`start_date <= today <= end_date`)
|
||||
2. 从催办列表中排除这些经理
|
||||
3. 汇总消息标注"X人请假,已豁免"
|
||||
|
||||
### 4.3 仪表盘统计卡
|
||||
|
||||
**文件:** `backend/app/services/dashboard.py` + 前端 `Dashboard.vue`
|
||||
|
||||
新增第五张统计卡「本周请假」:
|
||||
- 数字:本周请假总人数
|
||||
- 数据来源:统计 `start_date <= sunday AND end_date >= monday` 的去重 manager 数
|
||||
- 点击跳转 `/leaves`
|
||||
- 颜色:蓝灰调
|
||||
|
||||
### 4.4 AI 摘要提示词
|
||||
|
||||
**文件:** `backend/app/services/ai_summary.py` → `build_summary_prompt()`
|
||||
|
||||
新增一节注入请假数据:
|
||||
|
||||
```markdown
|
||||
## 本周请假情况
|
||||
- 张三 (事假): 2026-07-13 ~ 2026-07-15 (3天)
|
||||
- 李四 (年假): 2026-07-14 ~ 2026-07-17 (4天)
|
||||
```
|
||||
|
||||
效果:LLM 生成的摘要会自然提及"本周张三请事假3天未参与拜访"等信息。
|
||||
|
||||
## 五、API 设计
|
||||
|
||||
所有接口挂载在 `/api/leaves`,需登录认证。
|
||||
|
||||
### 5.1 列表
|
||||
|
||||
```
|
||||
GET /api/leaves?manager_id=xxx&status=active&page=1&page_size=25
|
||||
```
|
||||
|
||||
权限:`director`/`leader` 看全员,`manager` 只返回自己的记录。
|
||||
`status` 筛选项:`active`(进行中)/ `upcoming`(未来)/ `past`(已结束)/ 不传全部。
|
||||
默认按 `start_date DESC` 排序。
|
||||
|
||||
### 5.2 新建
|
||||
|
||||
```
|
||||
POST /api/leaves
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"manager_id": "uuid",
|
||||
"leave_type": "年假",
|
||||
"start_date": "2026-07-13",
|
||||
"end_date": "2026-07-15",
|
||||
"reason": "回老家"
|
||||
}
|
||||
```
|
||||
|
||||
权限:`manager` 只能给自己建(manager_id 必须等于自己),`director` 可以给任何人建。
|
||||
校验:`end_date >= start_date`。
|
||||
|
||||
### 5.3 编辑
|
||||
|
||||
```
|
||||
PUT /api/leaves/{id}
|
||||
```
|
||||
|
||||
请求体同新建。权限:`director` 可编辑任意记录,`manager` 仅可编辑自己提交且尚未开始的记录。
|
||||
|
||||
### 5.4 删除
|
||||
|
||||
```
|
||||
DELETE /api/leaves/{id}
|
||||
```
|
||||
|
||||
权限:同编辑。
|
||||
|
||||
### 5.5 概览(仪表盘用)
|
||||
|
||||
```
|
||||
GET /api/leaves/overview?reference_date=2026-07-12
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"week_start": "2026-07-13",
|
||||
"week_end": "2026-07-19",
|
||||
"total_on_leave": 2,
|
||||
"leave_list": [
|
||||
{
|
||||
"manager_id": "uuid",
|
||||
"manager_name": "张三",
|
||||
"leave_type": "事假",
|
||||
"start_date": "2026-07-13",
|
||||
"end_date": "2026-07-15",
|
||||
"days": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
权限:`director`/`leader` 返回全员请假数据,`manager` 只返回自己。
|
||||
|
||||
## 六、前端设计
|
||||
|
||||
### 6.1 PC 端 — 仪表盘卡片
|
||||
|
||||
**文件:** `frontend/src/views/desktop/Dashboard.vue`
|
||||
|
||||
- 新增第五张统计卡「本周请假」
|
||||
- 样式与现有四卡一致(stat-card + stat-glyph + 数字 + 标签)
|
||||
- 颜色:蓝灰色调(`--slate` 系列 CSS 变量)
|
||||
- 点击跳转 `/leaves`
|
||||
- 数据来源:`GET /api/leaves/overview`
|
||||
|
||||
### 6.2 PC 端 — 请假管理页
|
||||
|
||||
**文件:** 新增 `frontend/src/views/desktop/Leaves.vue`
|
||||
|
||||
**路由:** `/leaves`,DesktopLayout 侧边栏「工作」分组下新增「请假管理」
|
||||
|
||||
**页面结构:**
|
||||
|
||||
- 顶部汇总栏:按请假类型分组统计条数(年假X / 事假X / 病假X / 调休X / 其他X)
|
||||
- 筛选栏:按经理筛选(支局长/领导可见)、按状态筛选(进行中 / 即将开始 / 已结束)
|
||||
- 表格列:姓名 + 类型(彩色标签)+ 日期范围 + 天数 + 原因摘要 + 提交人 + 操作(编辑/删除)
|
||||
- 进行中的行蓝色左边框高亮
|
||||
- 新增/编辑弹窗(el-dialog):选择经理(支局长代填时)、请假类型 chip 按钮组、日期范围选择器、原因 textarea
|
||||
|
||||
**参考文件:**
|
||||
- 汇总栏样式:参考 `WorkPlans.vue` / `KeyVisits.vue` 的顶部汇总栏
|
||||
- 表格模式:参考 `CustomerManage.vue` 的 el-table + 分页
|
||||
- 表单弹窗:参考各独立工作页的 CRUD 弹窗
|
||||
|
||||
### 6.3 PC 端 — 填报进度卡片
|
||||
|
||||
**文件:** `frontend/src/views/desktop/Dashboard.vue`
|
||||
|
||||
- 请假中的经理卡片:蓝色「假」印章水印 + 灰色进度条 + "请假中"文案
|
||||
- 不计入未填报人数统计
|
||||
|
||||
### 6.4 PC 端 — 侧边栏
|
||||
|
||||
**文件:** `frontend/src/components/DesktopLayout.vue`
|
||||
|
||||
侧边栏「工作」分组新增:
|
||||
|
||||
```
|
||||
工作
|
||||
├─ 工作计划
|
||||
├─ 商机跟单
|
||||
├─ 要客拜访
|
||||
└─ 请假管理 ← 新增,📋 icon
|
||||
```
|
||||
|
||||
### 6.5 移动端 — 首页入口
|
||||
|
||||
**文件:** `frontend/src/views/mobile/Home.vue`
|
||||
|
||||
- 在快捷按钮区域(现有「工作计划」「商机跟单」「要客拜访」旁)新增「请假」chip
|
||||
- 如果当前有进行中的请假,chip 右上角显示蓝色小圆点
|
||||
- 点击跳转 `/m/leaves`
|
||||
|
||||
### 6.6 移动端 — 请假列表页
|
||||
|
||||
**文件:** 新增 `frontend/src/views/mobile/LeavesList.vue`
|
||||
|
||||
**路由:** `/m/leaves`
|
||||
|
||||
- 卡片流式列表(参考 Home.vue 的记录卡片风格)
|
||||
- 每条记录:类型彩色标签 + 日期范围 + 天数 + 原因
|
||||
- 进行中的卡片蓝色左边框
|
||||
- 顶部有「新建请假」按钮(ink 风格全宽按钮)
|
||||
|
||||
### 6.7 移动端 — 请假表单页
|
||||
|
||||
**文件:** 新增 `frontend/src/views/mobile/LeaveForm.vue`
|
||||
|
||||
**路由:** `/m/leave/new` + `/m/leave/:id/edit`(复用同一组件)
|
||||
|
||||
- 沿用现有移动端表单模式(editorial header + 返回按钮 + 中文标题 + 英文副标题 + 金色装饰线)
|
||||
- 请假类型:chip 按钮组(年假/事假/病假/调休/其他),参考 VisitForm 的拜访方式 chip
|
||||
- 日期范围:开始日期 → 结束日期
|
||||
- 原因:textarea,选填
|
||||
- 提交按钮:全宽暗色按钮「提交请假」
|
||||
- 编辑模式下额外显示删除按钮(vermilion 描边)
|
||||
|
||||
### 6.8 移动端 — 路由配置
|
||||
|
||||
**文件:** `frontend/src/router/index.ts`
|
||||
|
||||
新增 3 条移动端路由:
|
||||
|
||||
```typescript
|
||||
{ path: 'leaves', component: LeavesList }, // /m/leaves
|
||||
{ path: 'leave/new', component: LeaveForm }, // /m/leave/new
|
||||
{ path: 'leave/:id/edit', component: LeaveForm }, // /m/leave/:id/edit
|
||||
```
|
||||
|
||||
## 七、文件变更清单
|
||||
|
||||
### 后端新增
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `backend/app/models/leave.py` | Leave SQLAlchemy 模型 |
|
||||
| `backend/app/schemas/leave.py` | Pydantic 请求/响应 schema |
|
||||
| `backend/app/api/leaves.py` | REST API 路由 |
|
||||
| `backend/app/services/leaves.py` | 业务逻辑层 |
|
||||
| `backend/alembic/versions/xxxx_add_leaves_table.py` | 数据库迁移 |
|
||||
|
||||
### 后端修改
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `backend/app/services/dashboard.py` | 填报进度 +`on_leave` 状态;统计卡 +请假数 |
|
||||
| `backend/app/services/scheduler.py` | 催办过滤请假人员 |
|
||||
| `backend/app/services/ai_summary.py` | `build_summary_prompt` 注入请假数据 |
|
||||
| `backend/app/models/__init__.py` | 导入 Leave 模型 |
|
||||
| `backend/app/main.py` | 注册 leaves 路由 + lifespan 自动建表 |
|
||||
|
||||
### 前端新增
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `frontend/src/views/desktop/Leaves.vue` | PC 端请假管理页 |
|
||||
| `frontend/src/views/mobile/LeavesList.vue` | 移动端请假列表页 |
|
||||
| `frontend/src/views/mobile/LeaveForm.vue` | 移动端请假表单页 |
|
||||
| `frontend/src/api/leaves.ts` | Axios API 模块 |
|
||||
|
||||
### 前端修改
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `frontend/src/views/desktop/Dashboard.vue` | 新增请假统计卡 + 填报进度 on_leave 状态 |
|
||||
| `frontend/src/components/DesktopLayout.vue` | 侧边栏新增请假管理入口 |
|
||||
| `frontend/src/router/index.ts` | 新增 /leaves + /m/leaves 等路由 |
|
||||
| `frontend/src/views/mobile/Home.vue` | 新增请假快捷入口 chip |
|
||||
|
||||
## 八、验证要点
|
||||
|
||||
1. 经理提交请假后,当天仪表盘填报进度显示蓝「假」状态
|
||||
2. 请假中的经理不收到催办提醒
|
||||
3. 支局长可代任意经理提交请假
|
||||
4. AI 摘要中体现请假信息
|
||||
5. 移动端可完成请假提交→列表查看→编辑→删除全流程
|
||||
6. 历史周翻看时,请假数据对应当时的日期区间
|
||||
@@ -0,0 +1,130 @@
|
||||
# 商机跟单优化 — 跟进记录子表 — 设计文档
|
||||
|
||||
> 日期:2026-07-12 | 状态:已确认
|
||||
|
||||
## 一、需求概述
|
||||
|
||||
当前商机跟单只有一条静态记录(产品+金额+跟进内容文本+状态),缺少"持续跟进"的时间线感。新增跟进记录子表,将每个商机从"快照"变为"持续跟单"。
|
||||
|
||||
## 二、核心决策
|
||||
|
||||
| 决策项 | 结论 |
|
||||
|--------|------|
|
||||
| 方案 | 新增 `mini_business_logs` 子表 |
|
||||
| 跟进方式 | 电话/微信/上门/邮件/其他(chip 按钮选择) |
|
||||
| 原 follow_up_detail | 保留,含义改为"商机概述/背景" |
|
||||
| 状态 | 不变(跟进中/已签约/已流失) |
|
||||
|
||||
## 三、新增表 `mini_business_logs`
|
||||
|
||||
```sql
|
||||
CREATE TABLE mini_business_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
business_id UUID NOT NULL REFERENCES mini_business(id) ON DELETE CASCADE,
|
||||
log_date DATE NOT NULL,
|
||||
method VARCHAR(20) NOT NULL DEFAULT '电话',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_mbl_business_id ON mini_business_logs(business_id);
|
||||
CREATE INDEX idx_mbl_log_date ON mini_business_logs(log_date);
|
||||
```
|
||||
|
||||
### SQLAlchemy 模型
|
||||
|
||||
```python
|
||||
class MiniBusinessLog(Base):
|
||||
__tablename__ = "mini_business_logs"
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
business_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("mini_business.id", ondelete="CASCADE"), index=True)
|
||||
log_date: Mapped[date] = mapped_column(Date)
|
||||
method: Mapped[str] = mapped_column(String(20), default="电话")
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
```
|
||||
|
||||
### MiniBusiness 模型添加 relationship
|
||||
|
||||
```python
|
||||
logs: Mapped[list["MiniBusinessLog"]] = relationship(back_populates="business", cascade="all, delete-orphan")
|
||||
```
|
||||
|
||||
## 四、商机主体字段调整
|
||||
|
||||
| 字段 | 变更 | 前端 label |
|
||||
|------|------|-----------|
|
||||
| `follow_up_detail` | 保留,含义改为商机概述 | "商机概述" |
|
||||
| 其他字段 | 不变 | |
|
||||
|
||||
## 五、API
|
||||
|
||||
### 子路由
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| `GET` | `/mini-business/{id}/logs` | 获取跟进记录列表(date DESC) | 经理仅看自己 |
|
||||
| `POST` | `/mini-business/{id}/logs` | 新增跟进记录 | 经理本人或支局长/领导 |
|
||||
| `PUT` | `/mini-business/logs/{log_id}` | 编辑跟进记录 | 创建人或支局长/领导 |
|
||||
| `DELETE` | `/mini-business/logs/{log_id}` | 删除跟进记录 | 创建人或支局长/领导 |
|
||||
|
||||
### 请求体 (POST/PUT)
|
||||
|
||||
```json
|
||||
{
|
||||
"log_date": "2026-07-12",
|
||||
"method": "电话",
|
||||
"content": "客户反馈价格偏高,需进一步沟通方案"
|
||||
}
|
||||
```
|
||||
|
||||
### 跟进方式枚举
|
||||
|
||||
`["电话", "微信", "上门", "邮件", "其他"]`
|
||||
|
||||
## 六、前端
|
||||
|
||||
### PC 端 — 商机列表页增强
|
||||
|
||||
- 表格新增「最近跟进」列(最近一次跟进日期 + 跟进次数 badge)
|
||||
- 点击客户名打开详情弹窗(替代直接编辑)
|
||||
- 详情弹窗:上部=商机基本信息(客户/产品/金额/状态/概述)+ 编辑按钮,下部=跟进时间线 + 新增跟进入口
|
||||
|
||||
### PC 端 — 跟进时间线组件
|
||||
|
||||
```
|
||||
📞 07-10 电话 — 客户反馈价格偏高,需进一步沟通 [编辑] [删除]
|
||||
🏢 07-05 上门 — 现场演示产品功能,客户初步认可 [编辑] [删除]
|
||||
📧 07-01 邮件 — 发送报价方案 [编辑] [删除]
|
||||
```
|
||||
|
||||
- 按日期倒序,最新在上
|
||||
- 每条显示:方式图标 + 日期 + 内容 + 操作按钮
|
||||
- 底部「新增跟进」按钮展开内联表单(日期+方式 chip+内容 textarea)
|
||||
|
||||
### 移动端 — 商机表单页改为详情页
|
||||
|
||||
- 现有新建表单保留
|
||||
- 新增商机详情页(路由 `/m/mini-biz/:id`)
|
||||
- 上部:商机信息卡片(可点击编辑)
|
||||
- 下部:跟进时间线 + 底部固定「新增跟进」按钮
|
||||
|
||||
### 移动端 — 首页入口
|
||||
|
||||
- Home.vue 的「商机跟单」chip 改为跳转商机列表(需新增 `/m/mini-biz` 路由)
|
||||
|
||||
## 七、文件变更
|
||||
|
||||
| 类型 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| 新增 | `models/mini_business_log.py` | Log 模型 |
|
||||
| 新增 | `schemas/mini_business_log.py` | Log Schema |
|
||||
| 新增 | `api/mini_business_logs.py` | Log CRUD API |
|
||||
| 修改 | `models/mini_business.py` | 添加 logs relationship |
|
||||
| 修改 | `models/__init__.py` | 注册新模型 |
|
||||
| 修改 | `main.py` | lifespan 自动建表 |
|
||||
| 修改 | `MiniBusiness.vue` (PC) | 详情弹窗 + 时间线 |
|
||||
| 修改 | `MiniBusinessForm.vue` (mobile) | 改为详情页模式 |
|
||||
| 新增 | `frontend/src/api/miniBusiness.ts` | 追加 logs API |
|
||||
| 新增 | 移动端商机列表页 | `/m/mini-biz` |
|
||||
@@ -0,0 +1,122 @@
|
||||
# 工作计划状态自动流转 — 设计文档
|
||||
|
||||
> 日期:2026-07-12 | 状态:已确认
|
||||
|
||||
## 一、需求概述
|
||||
|
||||
工作计划目前有三种状态(计划中/已完成/已取消),完全依赖手动切换。利用已有的拜访记录数据,实现状态的自动流转,减少客户经理手动操作。
|
||||
|
||||
## 二、核心决策
|
||||
|
||||
| 决策项 | 结论 |
|
||||
|--------|------|
|
||||
| 匹配精度 | 仅匹配 `customer_id`(任何人拜访该客户即触发,与现有逻辑一致) |
|
||||
| 增强范围 | 创建/更新触发完成 + 逾期自动取消 + Excel 导入联动 |
|
||||
| 删除回退 | 不做(边缘场景少,复杂度高) |
|
||||
|
||||
## 三、现状
|
||||
|
||||
- `POST /visits/` 已有自动完成逻辑:同一 `customer_id` + `plan_date <= visit_date` → 标记"已完成"
|
||||
- 该逻辑嵌在 API 层,不可复用
|
||||
- 更新拜访、Excel 导入均不触发自动完成
|
||||
- 逾期计划(`plan_date < today`)只发企微提醒,不自动取消
|
||||
|
||||
## 四、设计
|
||||
|
||||
### 4.1 抽取可复用函数
|
||||
|
||||
**文件:** `backend/app/services/visits.py`(如不存在则新建,如已有则追加)
|
||||
|
||||
```python
|
||||
async def auto_complete_work_plans(
|
||||
db: AsyncSession,
|
||||
customer_id: UUID,
|
||||
visit_date: date,
|
||||
editor_name: str,
|
||||
reason: str = "拜访自动完成",
|
||||
) -> int:
|
||||
"""将匹配的工作计划自动标记为已完成。返回完成数量。"""
|
||||
from app.models.work_plan import WorkPlan
|
||||
from app.utils.edit_log import append_entry as append_edit_log
|
||||
|
||||
plans_result = await db.execute(
|
||||
select(WorkPlan).where(
|
||||
WorkPlan.customer_id == customer_id,
|
||||
WorkPlan.status == "计划中",
|
||||
WorkPlan.plan_date <= visit_date,
|
||||
)
|
||||
)
|
||||
count = 0
|
||||
for plan in plans_result.scalars().all():
|
||||
plan.status = "已完成"
|
||||
append_edit_log(plan, editor_name, [{
|
||||
"field": "status", "from": "计划中", "to": "已完成",
|
||||
"reason": reason,
|
||||
}])
|
||||
count += 1
|
||||
return count
|
||||
```
|
||||
|
||||
### 4.2 三处调用点
|
||||
|
||||
| 调用点 | 文件 | 触发时机 | reason 参数 |
|
||||
|--------|------|---------|------------|
|
||||
| POST /visits/ | `api/visits.py` | 创建拜访后 | `"拜访自动完成"` |
|
||||
| PUT /visits/{id} | `api/visits.py` | 更新拜访后 | `"拜访更新自动完成"` |
|
||||
| excel_import.py | `services/excel_import.py` | 导入每条拜访后 | `"旧周报导入自动完成"` |
|
||||
|
||||
### 4.3 逾期计划自动取消
|
||||
|
||||
**文件:** `backend/app/services/scheduler.py`,`check_overdue_plans` 函数
|
||||
|
||||
在现有「发送企微提醒」逻辑后新增:
|
||||
|
||||
```python
|
||||
# 自动取消:逾期且无匹配拜访记录的计划
|
||||
for plan in overdue:
|
||||
has_visit = await db.execute(
|
||||
select(Visit.id).where(
|
||||
Visit.customer_id == plan.customer_id,
|
||||
Visit.visit_date >= plan.plan_date,
|
||||
)
|
||||
)
|
||||
if not has_visit.scalar():
|
||||
plan.status = "已取消"
|
||||
append_edit_log(plan, "系统", [{
|
||||
"field": "status", "from": "计划中", "to": "已取消",
|
||||
"reason": "逾期自动取消",
|
||||
}])
|
||||
```
|
||||
|
||||
每日 09:00 执行,与现有逾期检查共用一个调度任务。
|
||||
|
||||
### 4.4 状态流转总图
|
||||
|
||||
```
|
||||
计划中 ──┬── 拜访创建/更新/导入(customer_id + plan_date <= visit_date)──→ 已完成
|
||||
│
|
||||
└── 每日 09:00 调度(plan_date < today 且无匹配拜访)───────────→ 已取消
|
||||
```
|
||||
|
||||
## 五、文件变更
|
||||
|
||||
### 后端新增/修改
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `backend/app/services/visits.py` | 新增 `auto_complete_work_plans()` 函数 |
|
||||
| `backend/app/api/visits.py` | POST 重构为调用 service 函数;PUT 新增调用 |
|
||||
| `backend/app/services/excel_import.py` | 导入拜访后调用自动完成 |
|
||||
| `backend/app/services/scheduler.py` | `check_overdue_plans` 新增自动取消逻辑 |
|
||||
|
||||
### 前端
|
||||
|
||||
无需改动。
|
||||
|
||||
## 六、验证要点
|
||||
|
||||
1. 创建拜访 → 匹配的计划自动变为"已完成",edit_log 有记录
|
||||
2. 更新拜访日期 → 匹配的计划自动完成
|
||||
3. 导入旧周报 → 导入的拜访触发计划自动完成
|
||||
4. 逾期且无拜访的计划 → 每日 09:00 自动变为"已取消"
|
||||
5. 逾期但有拜访的计划 → 保持"计划中"(已有拜访覆盖,不算僵尸)
|
||||
@@ -0,0 +1,156 @@
|
||||
# 协同拜访记录去重 — 设计方案
|
||||
|
||||
## 背景
|
||||
|
||||
当前机制:客户经理 A 创建拜访记录并选择协同人员 B 时,后端为 B 生成一条空内容的草稿副本。B 填写后,同一客户、同一天出现两条独立拜访记录,导致周报重复展示、亮灯表重复计数。
|
||||
|
||||
## 设计目标
|
||||
|
||||
1. 周报中同一客户同一次拜访合并展示为一张卡片
|
||||
2. 亮灯表不重复计数
|
||||
3. 每个参与人的填报进度不受影响(各自计入)
|
||||
4. 每个参与人能独立编辑自己的副本
|
||||
5. 历史数据兼容,不做强制迁移
|
||||
|
||||
## 方案概要
|
||||
|
||||
### 1. 数据库:新增 `visit_group_id`
|
||||
|
||||
```sql
|
||||
ALTER TABLE visits ADD COLUMN visit_group_id UUID;
|
||||
CREATE INDEX ix_visits_visit_group_id ON visits(visit_group_id);
|
||||
```
|
||||
|
||||
- 单人拜访:`visit_group_id = NULL`
|
||||
- 多人协同拜访:同一次拜访的所有记录共享同一个 `visit_group_id`
|
||||
|
||||
### 2. 后端:POST /visits/ 改造
|
||||
|
||||
**原逻辑:** 为每个协同人生成一条空内容草稿(`communication_content=""`, `photos=[]`, `companions=[]`)
|
||||
|
||||
**新逻辑:** 为每个协同人生成一条完整副本:
|
||||
|
||||
| 副本字段 | 值 | 说明 |
|
||||
|----------|-----|------|
|
||||
| `customer_id` | 同主记录 | |
|
||||
| `visit_date` | 同主记录 | |
|
||||
| `visit_method` | 同主记录 | |
|
||||
| `time_range` | 同主记录 | |
|
||||
| `visitor_name` | 同主记录 | 实际拜访人不变 |
|
||||
| `visitor_phone` | 同主记录 | |
|
||||
| `communication_content` | 原内容 + `(协同XXX)` | 如:`了解项目进度...(协同韦佶秀)` |
|
||||
| `customer_demand` | 同主记录 | |
|
||||
| `companions` | `[主记录创建人]` | 互换:协同人看到主访人 |
|
||||
| `companion_names` | 同主记录 | |
|
||||
| `photos` | 同主记录 | |
|
||||
| `manager_id` | 协同人的 UUID | 归属到协同人名下 |
|
||||
| `visit_group_id` | 同主记录 | **同一 UUID,关联所有副本** |
|
||||
|
||||
**前端表现(创建人韦佶秀视角):**
|
||||
|
||||
```
|
||||
相关人员选择了 [韦伦]
|
||||
→ 保存后韦伦收到一条完整拜访记录
|
||||
→ 韦伦记录内容末尾自动追加"(协同韦佶秀)"
|
||||
```
|
||||
|
||||
### 3. 后端:PUT /visits/{id} 不变
|
||||
|
||||
协同人编辑自己的副本时不影响主记录,各自独立。
|
||||
|
||||
### 4. 仪表盘统计:按 visit_group_id 去重
|
||||
|
||||
`get_dashboard_stats` 中的 `week_visits` 改为:
|
||||
|
||||
```python
|
||||
# 原:count 所有 visit 行
|
||||
select count(distinct coalesce(visit_group_id, id))
|
||||
from visits
|
||||
where visit_date between monday and sunday
|
||||
```
|
||||
|
||||
每个 visit_group_id 只算一次,跳过 NULL(单人拜访用 id 做 fallback key)。
|
||||
|
||||
### 5. 填报进度:不变
|
||||
|
||||
`get_reporting_progress` 按 `manager_id` 分组计数,每个参与人各算一次,不受去重影响。
|
||||
|
||||
### 6. 亮灯表:按 (customer_id, visit_date, visit_group_id) 去重
|
||||
|
||||
同一客户同一天同一次拜访只亮一盏灯。
|
||||
|
||||
### 7. 周报展示:按 visit_group_id 合并
|
||||
|
||||
`get_weekly_report` 查询时,同一个 `visit_group_id` 的记录合并成一条输出:
|
||||
|
||||
```json
|
||||
{
|
||||
"customer_name": "污水处理厂",
|
||||
"visit_date": "2026-07-30",
|
||||
"visit_method": "上门",
|
||||
"time_range": "09:00-10:30",
|
||||
"visit_group_id": "abc-123",
|
||||
"participants": [
|
||||
{
|
||||
"manager_name": "韦佶秀",
|
||||
"manager_id": "uuid-1",
|
||||
"role": "primary",
|
||||
"communication_content": "了解污水处理新项目...",
|
||||
"customer_demand": "专线扩容至100M...",
|
||||
"photos": ["photo_001", "photo_002"]
|
||||
},
|
||||
{
|
||||
"manager_name": "韦伦",
|
||||
"manager_id": "uuid-2",
|
||||
"role": "companion",
|
||||
"communication_content": "了解污水处理新项目...(协同韦佶秀)",
|
||||
"customer_demand": "专线扩容至100M...",
|
||||
"photos": ["photo_001", "photo_002"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**前端周报卡片展示:**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 污水处理厂 2026-07-30 上门 │
|
||||
│ 09:00-10:30 │
|
||||
│ │
|
||||
│ ▸ 韦佶秀(主访) │
|
||||
│ 了解污水处理新项目建设进度,客户计划新增... │
|
||||
│ 客户需求:专线扩容至100M,新增视频监控... │
|
||||
│ 📷 2张 │
|
||||
│ │
|
||||
│ ▸ 韦伦(协同) │
|
||||
│ 了解污水处理新项目建设进度...(协同韦佶秀) │
|
||||
│ 客户需求:专线扩容至100M... │
|
||||
│ 📷 2张 │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 8. 前端:协同记录编辑提示
|
||||
|
||||
协同人打开自己的副本编辑时,沟通内容区域上方显示一行提示:
|
||||
|
||||
> 💡 此记录为协同韦佶秀拜访「污水处理厂」的副本。你可以补充或修改内容,不会影响主记录。
|
||||
|
||||
## 改动清单
|
||||
|
||||
| 层 | 文件 | 改动 |
|
||||
|----|------|------|
|
||||
| DB | migration | `ALTER TABLE visits ADD visit_group_id UUID` |
|
||||
| 后端 | `models/visit.py` | 新增 `visit_group_id` 字段 |
|
||||
| 后端 | `schemas/visit.py` | `VisitOut` 新增 `visit_group_id` |
|
||||
| 后端 | `api/visits.py` POST | 副本改为完整复制 + `(协同XXX)` 后缀 + 互换 companions + 设置 visit_group_id |
|
||||
| 后端 | `services/dashboard.py` | `get_dashboard_stats` 的 week_visits 改为 distinct 去重 |
|
||||
| 后端 | `services/dashboard.py` | `get_weekly_report` 合并同 visit_group_id 记录 |
|
||||
| 后端 | `services/light_board.py` | 亮灯计数去重 |
|
||||
| 前端 | 周报 `WeeklyReport.vue` | 拜访卡片支持 participants 展开/折叠 |
|
||||
| 前端 | 移动端 `VisitForm.vue` | 协同记录编辑时显示提示条 |
|
||||
|
||||
## 兼容性
|
||||
|
||||
- 历史数据 `visit_group_id = NULL`,行为与现在完全一致
|
||||
- 前端 `participants` 字段回退:单 participant 时展示与现在无差异
|
||||
@@ -0,0 +1,153 @@
|
||||
# 企业微信 OAuth 静默登录 — 踩坑记录
|
||||
|
||||
## 目标
|
||||
|
||||
用户在企微内点击菜单 → 自动登录 → 直达应用首页,零手动操作。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
企微菜单(view) → OAuth authorize URL(snsapi_base)
|
||||
→ 企微静默获取 code
|
||||
→ 302 到 callback URL
|
||||
→ 后端换 wecom_userid → 查 DB → 签 JWT
|
||||
→ 返回 HTML 页面(JS 写 localStorage + 跳转)
|
||||
→ 前端启动 → auth store 读到 localStorage → 已登录
|
||||
```
|
||||
|
||||
## 踩坑清单
|
||||
|
||||
### 坑 1:`redirect_uri` 不能夹带自定义 query 参数
|
||||
|
||||
**错误做法:**
|
||||
|
||||
```
|
||||
redirect_uri = https://xxx.com/api/callback?redirect=/m
|
||||
```
|
||||
|
||||
企微 OAuth 完成后会在 `redirect_uri` 后追加 `?code=xxx&state=xxx`,但可能覆盖或丢弃原有的 query 参数。
|
||||
|
||||
**正确做法:** 把自定义参数编码到 `state` 里:
|
||||
|
||||
```python
|
||||
state = f"r={urllib.parse.quote('/m')}"
|
||||
redirect_uri = "https://xxx.com/api/callback" # 干净,无额外 query
|
||||
```
|
||||
|
||||
回调时从 `state` 中解析:
|
||||
|
||||
```python
|
||||
redirect = "/m"
|
||||
if state.startswith("r="):
|
||||
redirect = urllib.parse.unquote(state[2:])
|
||||
```
|
||||
|
||||
### 坑 2:必须配置可信域名
|
||||
|
||||
企微后台 → 应用管理 → 网页授权及 JS-SDK → 设置可信域名。
|
||||
|
||||
不加的话 OAuth 直接报错:`redirect_uri 需使用应用可信域名`。
|
||||
|
||||
验证文件(`WW_verify_xxx.txt`)需要放在前端可访问的静态目录下。
|
||||
|
||||
### 坑 3:`agentid` 参数
|
||||
|
||||
内部应用使用 `snsapi_base` 静默授权时,OAuth URL 必须带 `agentid`:
|
||||
|
||||
```
|
||||
?appid=CORPID&agentid=AGENTID&redirect_uri=...
|
||||
```
|
||||
|
||||
不加的话企微可能静默授权失败(表现:跳到登录页而不是自动登录)。
|
||||
|
||||
### 坑 4:服务端 302/307 重定向在 webview 中不可靠
|
||||
|
||||
**这是最大的坑。** 后端回调返回 HTTP 重定向(307/302),企微内置浏览器可能不跟进,或跟进时丢掉 query 参数。
|
||||
|
||||
**解决方案:** 回调返回 HTML 页面,用 JS 做客户端跳转。
|
||||
|
||||
```python
|
||||
html = """<!DOCTYPE html><html><head><meta charset="utf-8"></head>
|
||||
<body><script>
|
||||
var d = {auth_data_json};
|
||||
localStorage.setItem('token', d.token);
|
||||
localStorage.setItem('userId', d.userId);
|
||||
localStorage.setItem('userName', d.userName);
|
||||
localStorage.setItem('userRole', d.userRole);
|
||||
window.location.replace({frontend_url_json});
|
||||
</script></body></html>"""
|
||||
return HTMLResponse(content=html, status_code=200)
|
||||
```
|
||||
|
||||
**为什么直接写 localStorage 而不是 URL 传 token?**
|
||||
|
||||
- URL `?token=xxx` 需要前端路由守卫解析 JWT(`atob` 解码)
|
||||
- 企微 webview 中 `atob` 可能不可靠
|
||||
- `localStorage` 是浏览器原生 API,前端 auth store 启动时直接读取
|
||||
|
||||
### 坑 5:用户名字符串中的特殊字符
|
||||
|
||||
如果直接把用户名拼进 JS 字符串,包含 `'` `"` `\` 会破坏 JS 语法。
|
||||
|
||||
**安全做法:** 整个 auth data 用 `json.dumps` 序列化后直接作为 JS 对象字面量:
|
||||
|
||||
```python
|
||||
import json
|
||||
auth_data = json.dumps({
|
||||
"token": token,
|
||||
"userId": str(user.id),
|
||||
"userName": user.name, # json.dumps 会自动转义特殊字符
|
||||
"userRole": user.role,
|
||||
"theme": user.theme or "editorial",
|
||||
}, ensure_ascii=False)
|
||||
# 在 HTML 模板中:
|
||||
# var d = {auth_data}; ← 直接是 JS 对象,不需要 JSON.parse
|
||||
```
|
||||
|
||||
### 坑 6:`wecom_userid` 大小写
|
||||
|
||||
数据库中存的 `wecom_userid` 可能与 OAuth 返回的大小写不一致(如 `WeiJueSen` vs `weijuesen`)。
|
||||
|
||||
**修复:** 查询时做大小写不敏感匹配:
|
||||
|
||||
```python
|
||||
from sqlalchemy import func
|
||||
result = await db.execute(
|
||||
select(User).where(func.lower(User.wecom_userid) == wecom_userid.lower())
|
||||
)
|
||||
```
|
||||
|
||||
### 坑 7:Login 页面死循环
|
||||
|
||||
Login.vue 检测到企微环境后自动跳 OAuth,但如果 OAuth 回调又回到 Login 页,就形成死循环。
|
||||
|
||||
**原因分析:**
|
||||
- OAuth 成功 → 回调 → 写 localStorage → 跳到首页
|
||||
- 如果 localStorage 写入失败或 token 无效 → 路由守卫 → /login
|
||||
- Login 检测 isWecom() → 又跳 OAuth → 死循环
|
||||
|
||||
**防护:** 在 Login 页跳 OAuth 前检查是否刚从 OAuth 回来(如 URL 带 `?from_wecom=1`),避免重复跳转。本项目通过 localStorage 写入 + 前端 auth store 从 localStorage 初始化避开了这个问题。
|
||||
|
||||
## 调试技巧
|
||||
|
||||
1. **后端日志看回调是否被触发:** `grep oauth-callback /var/log/app.log`
|
||||
2. **HTTP 状态码:** 回调返回 307 说明走到了 RedirectResponse,返回 200 说明走了 HTML 方案
|
||||
3. **检查绑定状态:** 查数据库确认 `users.wecom_userid` 有值
|
||||
4. **OAuth URL 自测:** 浏览器直接访问 `/api/wecom/oauth-url`,看 302 跳转的目标 URL 是否正确
|
||||
|
||||
## 完整代码量
|
||||
|
||||
| 文件 | 新增行数 | 说明 |
|
||||
|------|---------|------|
|
||||
| 后端 OAuth 回调 | ~30 行 | `_build_oauth_url` + `oauth-callback` 端点 |
|
||||
| 前端路由守卫 | ~15 行 | 处理 `?token=` 参数自动登录(本方案中作为 fallback) |
|
||||
| 前端 Login 页 | ~8 行 | 企微环境检测 + 跳 OAuth |
|
||||
|
||||
## 最终效果
|
||||
|
||||
用户视角:企微点击「开始填报」→ 白屏 0.5 秒 → 首页。
|
||||
|
||||
技术视角:
|
||||
```
|
||||
点菜单 → OAuth(snsapi_base静默) → 回调HTML → JS写localStorage → 跳首页 → 已登录
|
||||
```
|
||||
@@ -0,0 +1,53 @@
|
||||
# 企迹系统发布与回滚检查表
|
||||
|
||||
> 每次生产发布由发布负责人逐项勾选,并将结果随变更单、流水线和制品一并归档。
|
||||
|
||||
## 1. 发布前
|
||||
|
||||
- [ ] 变更已合并至受保护分支,MR 审核记录完整。
|
||||
- [ ] 已创建发布 Tag,记录提交 SHA、镜像摘要和发布日期。
|
||||
- [ ] CI 全部通过:后端测试、前端构建、Ruff、Bandit、依赖漏洞审计、SBOM、Trivy 文件系统扫描。
|
||||
- [ ] 已归档 `sbom-backend.cdx.json`、测试报告和扫描报告。
|
||||
- [ ] 生产密钥由受控环境或密钥管理系统注入,未写入 Git、镜像层或发布记录。
|
||||
- [ ] 数据库已完成备份,并验证备份可读取;回滚负责人和窗口已确认。
|
||||
- [ ] 对象存储桶为私有,应用服务账号采用最小读写权限。
|
||||
|
||||
## 2. 数据库迁移
|
||||
|
||||
在维护窗口、使用生产应用运行身份执行:
|
||||
|
||||
```bash
|
||||
cd /path/to/qiji/backend
|
||||
alembic current
|
||||
alembic upgrade head
|
||||
alembic current
|
||||
```
|
||||
|
||||
- [ ] `alembic current` 显示预期修订版本。
|
||||
- [ ] 核验 `users.wecom_userid` 的唯一索引已创建。
|
||||
- [ ] 如迁移失败,停止发布;不得通过手工修改表结构绕过迁移记录。
|
||||
|
||||
## 3. 发布与验证
|
||||
|
||||
- [ ] 使用指定 Tag/镜像摘要部署;禁止直接从未审核工作区运行。
|
||||
- [ ] 服务以受控 systemd 服务或编排平台运行,未使用开发热重载。
|
||||
- [ ] 仅反向代理对公网开放 HTTPS;数据库、缓存、MinIO 管理端口不对公网监听。
|
||||
- [ ] 健康检查通过:`GET /health`。
|
||||
- [ ] 回归登录、角色授权、企微绑定、受控图片上传和图片下载授权。
|
||||
- [ ] 抽查应用日志,确认未输出口令、密钥、令牌或用户输入正文。
|
||||
|
||||
## 4. 回滚
|
||||
|
||||
触发条件:迁移失败、健康检查失败、关键业务回归失败、出现高危安全告警。
|
||||
|
||||
1. 停止本次发布流量切换,恢复上一稳定版本的镜像/制品。
|
||||
2. 仅在已验证可逆且有审批的情况下执行 `alembic downgrade`;基线迁移不允许通过盲目降级删除历史表。
|
||||
3. 如数据不兼容,按已验证的备份恢复方案处理,并保留操作审计。
|
||||
4. 复测健康接口和关键业务,记录故障、影响范围、回滚时间和后续整改项。
|
||||
|
||||
## 5. 发布归档
|
||||
|
||||
- [ ] 变更单与审批记录
|
||||
- [ ] Git Tag、提交 SHA、镜像摘要和 SBOM
|
||||
- [ ] 测试、扫描、迁移和部署日志
|
||||
- [ ] 上线验证与回滚演练记录
|
||||
+14
-2
@@ -2,9 +2,21 @@
|
||||
<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" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>企迹 - 政企周报管理系统</title>
|
||||
|
||||
<!-- Apply saved theme before page render to prevent flash -->
|
||||
<script>
|
||||
(function() {
|
||||
var theme = localStorage.getItem('theme') || 'editorial';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Preconnect to Google Fonts origins for faster font loading -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
+9
-12
@@ -5,22 +5,19 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Vue app
|
||||
# Immutable hashed static assets (Vite content-hashed filenames)
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
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;
|
||||
}
|
||||
|
||||
# Health check
|
||||
location /health {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
return 200 "ok";
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+662
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"dom-to-image-more": "^3.10.0",
|
||||
"element-plus": "^2.9.1",
|
||||
"pinia": "^2.3.0",
|
||||
"vue": "^3.5.13",
|
||||
@@ -21,6 +22,8 @@
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.6.0",
|
||||
"unplugin-element-plus": "^0.11.2",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^6.0.5",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
@@ -580,6 +583,17 @@
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/remapping": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
@@ -645,6 +659,48 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/kit": {
|
||||
"version": "4.4.8",
|
||||
"resolved": "https://registry.npmmirror.com/@nuxt/kit/-/kit-4.4.8.tgz",
|
||||
"integrity": "sha512-ZUlZ5iYfyfJFDPluhn6ZxFWcsuxWbLnZBc8w3MAROcQ4lYfZ+qFpALBLSNlpc0zhOa++33EE+5PEbOAdVIY+dw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"c12": "^3.3.4",
|
||||
"consola": "^3.4.2",
|
||||
"defu": "^6.1.7",
|
||||
"destr": "^2.0.5",
|
||||
"errx": "^0.1.0",
|
||||
"exsolve": "^1.0.8",
|
||||
"ignore": "^7.0.5",
|
||||
"jiti": "^2.7.0",
|
||||
"klona": "^2.0.6",
|
||||
"mlly": "^1.8.2",
|
||||
"ohash": "^2.0.11",
|
||||
"pathe": "^2.0.3",
|
||||
"pkg-types": "^2.3.1",
|
||||
"rc9": "^3.0.1",
|
||||
"scule": "^1.3.0",
|
||||
"semver": "^7.8.1",
|
||||
"tinyglobby": "^0.2.17",
|
||||
"ufo": "^1.6.4",
|
||||
"unctx": "^2.5.0",
|
||||
"untyped": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/kit/node_modules/jiti": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz",
|
||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"name": "@sxzz/popperjs-es",
|
||||
"version": "2.11.8",
|
||||
@@ -1270,6 +1326,19 @@
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.17.0",
|
||||
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.17.0.tgz",
|
||||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
|
||||
@@ -1481,6 +1550,75 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/c12": {
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmmirror.com/c12/-/c12-3.3.4.tgz",
|
||||
"integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^5.0.0",
|
||||
"confbox": "^0.2.4",
|
||||
"defu": "^6.1.6",
|
||||
"dotenv": "^17.3.1",
|
||||
"exsolve": "^1.0.8",
|
||||
"giget": "^3.2.0",
|
||||
"jiti": "^2.6.1",
|
||||
"ohash": "^2.0.11",
|
||||
"pathe": "^2.0.3",
|
||||
"perfect-debounce": "^2.1.0",
|
||||
"pkg-types": "^2.3.0",
|
||||
"rc9": "^3.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"magicast": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"magicast": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/c12/node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/c12/node_modules/jiti": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz",
|
||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/c12/node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz",
|
||||
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@@ -1563,6 +1701,16 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/citty": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/citty/-/citty-0.1.6.tgz",
|
||||
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"consola": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -1585,6 +1733,23 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/confbox": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.2.4.tgz",
|
||||
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/consola": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmmirror.com/consola/-/consola-3.4.2.tgz",
|
||||
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -1634,6 +1799,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/defu": {
|
||||
"version": "6.1.7",
|
||||
"resolved": "https://registry.npmmirror.com/defu/-/defu-6.1.7.tgz",
|
||||
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -1643,6 +1815,13 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/destr": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/destr/-/destr-2.0.5.tgz",
|
||||
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/didyoumean": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
@@ -1657,6 +1836,25 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-to-image-more": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmmirror.com/dom-to-image-more/-/dom-to-image-more-3.10.0.tgz",
|
||||
"integrity": "sha512-APrFEimSmH4phJKs8DlURuSr3BFwDqNi62l14bx73Wrhx0OhE4dd0qzZR+4E3sH8PnHxvax1LOrosC9oXRFA5A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-17.4.2.tgz",
|
||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -1716,6 +1914,13 @@
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/errx": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/errx/-/errx-0.1.0.tgz",
|
||||
"integrity": "sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
@@ -1734,6 +1939,13 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.2.0.tgz",
|
||||
"integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
@@ -1813,12 +2025,32 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
||||
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/exsolve": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.1.0.tgz",
|
||||
"integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
@@ -2001,6 +2233,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/giget": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/giget/-/giget-3.3.0.tgz",
|
||||
"integrity": "sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"giget": "dist/cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@@ -2088,6 +2330,16 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -2160,6 +2412,23 @@
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/klona": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/klona/-/klona-2.0.6.tgz",
|
||||
"integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/knitwork": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/knitwork/-/knitwork-1.3.0.tgz",
|
||||
"integrity": "sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -2180,6 +2449,24 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/local-pkg": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.2.1.tgz",
|
||||
"integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mlly": "^1.7.4",
|
||||
"pkg-types": "^2.3.0",
|
||||
"quansync": "^0.2.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
|
||||
@@ -2301,6 +2588,38 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/mlly": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.2.tgz",
|
||||
"integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.16.0",
|
||||
"pathe": "^2.0.3",
|
||||
"pkg-types": "^1.3.1",
|
||||
"ufo": "^1.6.3"
|
||||
}
|
||||
},
|
||||
"node_modules/mlly/node_modules/confbox": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz",
|
||||
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mlly/node_modules/pkg-types": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz",
|
||||
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.1.8",
|
||||
"mlly": "^1.7.4",
|
||||
"pathe": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
|
||||
@@ -2390,6 +2709,27 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/obug/-/obug-2.1.3.tgz",
|
||||
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ohash": {
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmmirror.com/ohash/-/ohash-2.0.11.tgz",
|
||||
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-browserify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||
@@ -2404,6 +2744,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/perfect-debounce": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
|
||||
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -2465,6 +2819,18 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/pkg-types": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.3.1.tgz",
|
||||
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.2.4",
|
||||
"exsolve": "^1.0.8",
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",
|
||||
@@ -2650,6 +3016,23 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz",
|
||||
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -2671,6 +3054,17 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rc9": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/rc9/-/rc9-3.0.1.tgz",
|
||||
"integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"defu": "^6.1.6",
|
||||
"destr": "^2.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -2740,6 +3134,22 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown-string": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/rolldown-string/-/rolldown-string-0.2.1.tgz",
|
||||
"integrity": "sha512-7H8oH5A8+L96pbBTPCt/rZrwayEhZY5/ejhdk9nRODH32H1v7+bfkaCr+kS15DcGQ7VC1HcWdQVNABFYgrMOzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz",
|
||||
@@ -2809,6 +3219,26 @@
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/scule": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz",
|
||||
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -2980,6 +3410,231 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/ufo": {
|
||||
"version": "1.6.4",
|
||||
"resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.4.tgz",
|
||||
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unctx": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/unctx/-/unctx-2.5.0.tgz",
|
||||
"integrity": "sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.15.0",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21",
|
||||
"unplugin": "^2.3.11"
|
||||
}
|
||||
},
|
||||
"node_modules/unctx/node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin": {
|
||||
"version": "2.3.11",
|
||||
"resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-2.3.11.tgz",
|
||||
"integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"acorn": "^8.15.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"webpack-virtual-modules": "^0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-element-plus": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmmirror.com/unplugin-element-plus/-/unplugin-element-plus-0.11.2.tgz",
|
||||
"integrity": "sha512-jr88ePpv43h8cCmVW0SqM73sTD+g1n9Rmy4uMbTh+pSmceH9ZdKteWX9f+twC4aDlP3svdZuKMqLoUNBT2V6Tg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nuxt/kit": "^4.2.2",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"rolldown-string": "^0.2.1",
|
||||
"unplugin": "^2.3.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-utils": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/unplugin-utils/-/unplugin-utils-0.3.1.tgz",
|
||||
"integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-vue-components": {
|
||||
"version": "32.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/unplugin-vue-components/-/unplugin-vue-components-32.1.0.tgz",
|
||||
"integrity": "sha512-YiUkSxuRjab18XFOrX5VsIxXzccrfmHVGsGeJgSgklb829DQmCy9E4vvDUE4tuvZZdxyFJZX0Oc4TPnnxiiMyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^5.0.0",
|
||||
"local-pkg": "^1.2.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"mlly": "^1.8.2",
|
||||
"obug": "^2.1.1",
|
||||
"picomatch": "^4.0.4",
|
||||
"tinyglobby": "^0.2.16",
|
||||
"unplugin": "^3.0.0",
|
||||
"unplugin-utils": "^0.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nuxt/kit": "^3.2.2 || ^4.0.0",
|
||||
"vue": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@nuxt/kit": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-vue-components/node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-vue-components/node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz",
|
||||
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-vue-components/node_modules/unplugin": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-3.3.0.tgz",
|
||||
"integrity": "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"picomatch": "^4.0.4",
|
||||
"webpack-virtual-modules": "^0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@farmfe/core": "*",
|
||||
"@rspack/core": "*",
|
||||
"bun-types-no-globals": "*",
|
||||
"esbuild": "*",
|
||||
"rolldown": "*",
|
||||
"rollup": "*",
|
||||
"unloader": "*",
|
||||
"vite": "*",
|
||||
"webpack": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@farmfe/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@rspack/core": {
|
||||
"optional": true
|
||||
},
|
||||
"bun-types-no-globals": {
|
||||
"optional": true
|
||||
},
|
||||
"esbuild": {
|
||||
"optional": true
|
||||
},
|
||||
"rolldown": {
|
||||
"optional": true
|
||||
},
|
||||
"rollup": {
|
||||
"optional": true
|
||||
},
|
||||
"unloader": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
},
|
||||
"webpack": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/untyped": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/untyped/-/untyped-2.0.0.tgz",
|
||||
"integrity": "sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"citty": "^0.1.6",
|
||||
"defu": "^6.1.4",
|
||||
"jiti": "^2.4.2",
|
||||
"knitwork": "^1.2.0",
|
||||
"scule": "^1.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"untyped": "dist/cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/untyped/node_modules/jiti": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz",
|
||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
@@ -3184,6 +3839,13 @@
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-virtual-modules": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"dom-to-image-more": "^3.10.0",
|
||||
"element-plus": "^2.9.1",
|
||||
"pinia": "^2.3.0",
|
||||
"vue": "^3.5.13",
|
||||
@@ -22,6 +23,8 @@
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.6.0",
|
||||
"unplugin-element-plus": "^0.11.2",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^6.0.5",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 989 B |
+616
-11
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { computed } from 'vue'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const isMobile = computed(() => {
|
||||
@@ -9,7 +10,9 @@ const isMobile = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
<el-config-provider :locale="zhCn">
|
||||
<router-view />
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@@ -64,6 +67,323 @@ const isMobile = computed(() => {
|
||||
--radius-sm: 2px;
|
||||
--radius-xs: 2px;
|
||||
--transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ── Sidebar — dark editorial ── */
|
||||
--sidebar-bg: var(--ink);
|
||||
--sidebar-color: #fff;
|
||||
--sidebar-accent: var(--gold);
|
||||
--sidebar-nav-text: rgba(255,255,255,0.55);
|
||||
--sidebar-nav-text-hover: rgba(255,255,255,0.85);
|
||||
--sidebar-nav-text-active: #fff;
|
||||
--sidebar-nav-bg-hover: rgba(255,255,255,0.04);
|
||||
--sidebar-nav-bg-active: rgba(255,255,255,0.06);
|
||||
--sidebar-nav-border-active: var(--gold);
|
||||
--sidebar-divider: rgba(255,255,255,0.06);
|
||||
--sidebar-brand-color: #fff;
|
||||
--sidebar-brand-sub-color: rgba(255,255,255,0.4);
|
||||
--sidebar-group-label-color: rgba(255,255,255,0.25);
|
||||
--sidebar-footer-role-color: var(--gold);
|
||||
--sidebar-footer-name-color: rgba(255,255,255,0.7);
|
||||
|
||||
/* ── Mobile page accent ── */
|
||||
--page-accent-color: rgba(196,147,74,0.06);
|
||||
|
||||
/* ── Manager chip tags ── */
|
||||
--mgr-chip-text: #fff;
|
||||
|
||||
/* ── Fonts — editorial Chinese ── */
|
||||
--font-body: 'Noto Serif SC', STSong, Songti SC, '宋体', serif;
|
||||
--font-heading: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
|
||||
--font-mono: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
THEME: Light — 极简功能主义 / Brutally Minimal
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
:root[data-theme="light"] {
|
||||
/* ── Core palette ── */
|
||||
--ink: #1A1A1A;
|
||||
--ink-light: #3D3D3D;
|
||||
--ink-dark: #0A0A0A;
|
||||
--vermilion: #DC2626;
|
||||
--vermilion-light: #EF4444;
|
||||
--vermilion-dark: #B91C1C;
|
||||
--gold: #D4AF37;
|
||||
--gold-light: #E5C955;
|
||||
--gold-dark: #B8941F;
|
||||
--paper: #F5F5F5;
|
||||
--paper-dark: #EBEBEB;
|
||||
--surface: #FFFFFF;
|
||||
--sage: #16A34A;
|
||||
--amber: #D97706;
|
||||
--warm-gray: #666666;
|
||||
--warm-border: #DDDDDD;
|
||||
|
||||
/* ── Semantic tokens ── */
|
||||
--c-primary: var(--ink);
|
||||
--c-primary-light: var(--ink-light);
|
||||
--c-primary-bg: #F5F5F5;
|
||||
--c-accent: var(--vermilion);
|
||||
--c-accent-light: var(--vermilion-light);
|
||||
--c-gold: var(--gold);
|
||||
--c-success: var(--sage);
|
||||
--c-warning: var(--amber);
|
||||
--c-danger: var(--vermilion);
|
||||
--c-info: #2563EB;
|
||||
--c-text: #1A1A1A;
|
||||
--c-text-secondary: #666666;
|
||||
--c-text-muted: #999999;
|
||||
--c-bg: var(--paper);
|
||||
--c-bg-card: var(--surface);
|
||||
--c-border: var(--warm-border);
|
||||
|
||||
/* ── Effects ── */
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
--shadow-md: 0 2px 6px rgba(0,0,0,0.10);
|
||||
--shadow-lg: 0 4px 12px rgba(0,0,0,0.12);
|
||||
--radius: 0px;
|
||||
--radius-sm: 0px;
|
||||
--radius-xs: 0px;
|
||||
--transition: 0.18s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ── Sidebar — light minimal ── */
|
||||
--sidebar-bg: #FFFFFF;
|
||||
--sidebar-color: #1A1A1A;
|
||||
--sidebar-accent: var(--gold);
|
||||
--sidebar-nav-text: #777777;
|
||||
--sidebar-nav-text-hover: #1A1A1A;
|
||||
--sidebar-nav-text-active: #1A1A1A;
|
||||
--sidebar-nav-bg-hover: #F5F5F5;
|
||||
--sidebar-nav-bg-active: #F0F0F0;
|
||||
--sidebar-nav-border-active: var(--gold);
|
||||
--sidebar-divider: #EEEEEE;
|
||||
--sidebar-brand-color: #1A1A1A;
|
||||
--sidebar-brand-sub-color: #999999;
|
||||
--sidebar-group-label-color: #AAAAAA;
|
||||
--sidebar-footer-role-color: var(--gold);
|
||||
--sidebar-footer-name-color: #777777;
|
||||
|
||||
/* ── Mobile page accent ── */
|
||||
--page-accent-color: rgba(212,175,55,0.04);
|
||||
|
||||
/* ── Manager chip tags ── */
|
||||
--mgr-chip-text: #1A1A1A;
|
||||
|
||||
/* ── Fonts — sans-serif minimal ── */
|
||||
--font-body: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--font-heading: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
THEME: Minimal Dark — 极简深色
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
:root[data-theme="minimal-dark"] {
|
||||
/* ── Core palette ── */
|
||||
--ink: #E5E5E5;
|
||||
--ink-light: #CCCCCC;
|
||||
--ink-dark: #FFFFFF;
|
||||
--vermilion: #F87171;
|
||||
--vermilion-light: #FCA5A5;
|
||||
--vermilion-dark: #EF4444;
|
||||
--gold: #A78BFA;
|
||||
--gold-light: #C4B5FD;
|
||||
--gold-dark: #8B5CF6;
|
||||
--paper: #141414;
|
||||
--paper-dark: #0D0D0D;
|
||||
--surface: #2D2D2D;
|
||||
--sage: #4ADE80;
|
||||
--amber: #FBBF24;
|
||||
--warm-gray: #999999;
|
||||
--warm-border: #3A3A3A;
|
||||
|
||||
/* ── Semantic tokens ── */
|
||||
--c-primary: var(--ink);
|
||||
--c-primary-light: var(--ink-light);
|
||||
--c-primary-bg: #262626;
|
||||
--c-accent: var(--vermilion);
|
||||
--c-accent-light: var(--vermilion-light);
|
||||
--c-gold: var(--gold);
|
||||
--c-success: var(--sage);
|
||||
--c-warning: var(--amber);
|
||||
--c-danger: var(--vermilion);
|
||||
--c-info: #60A5FA;
|
||||
--c-text: #E5E5E5;
|
||||
--c-text-secondary: #999999;
|
||||
--c-text-muted: #666666;
|
||||
--c-bg: var(--paper);
|
||||
--c-bg-card: var(--surface);
|
||||
--c-border: var(--warm-border);
|
||||
|
||||
/* ── Effects ── */
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.20);
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.30);
|
||||
--shadow-md: 0 2px 6px rgba(0,0,0,0.40);
|
||||
--shadow-lg: 0 4px 12px rgba(0,0,0,0.50);
|
||||
--radius: 0px;
|
||||
--radius-sm: 0px;
|
||||
--radius-xs: 0px;
|
||||
--transition: 0.18s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ── Misc dark tokens ── */
|
||||
--c-bg-light: #1E1E1E;
|
||||
|
||||
/* ── Sidebar — dark minimal ── */
|
||||
--sidebar-bg: #1A1A1A;
|
||||
--sidebar-color: #E5E5E5;
|
||||
--sidebar-accent: var(--gold);
|
||||
--sidebar-nav-text: #777777;
|
||||
--sidebar-nav-text-hover: #CCCCCC;
|
||||
--sidebar-nav-text-active: #FFFFFF;
|
||||
--sidebar-nav-bg-hover: rgba(255,255,255,0.04);
|
||||
--sidebar-nav-bg-active: rgba(255,255,255,0.06);
|
||||
--sidebar-nav-border-active: var(--gold);
|
||||
--sidebar-divider: #2A2A2A;
|
||||
--sidebar-brand-color: #FFFFFF;
|
||||
--sidebar-brand-sub-color: #666666;
|
||||
--sidebar-group-label-color: #555555;
|
||||
--sidebar-footer-role-color: var(--gold);
|
||||
--sidebar-footer-name-color: #999999;
|
||||
|
||||
/* ── Mobile page accent ── */
|
||||
--page-accent-color: rgba(167,139,250,0.06);
|
||||
|
||||
/* ── Manager chip tags ── */
|
||||
--mgr-chip-text: #E5E5E5;
|
||||
|
||||
/* ── Fonts — sans-serif minimal ── */
|
||||
--font-body: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--font-heading: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
THEME: Tech Dark — 科技深色 (编辑风暗色版)
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
:root[data-theme="tech-dark"] {
|
||||
/* ── Core palette ── */
|
||||
--ink: #C8D6E5;
|
||||
--ink-light: #A0B4CC;
|
||||
--ink-dark: #E8EEF4;
|
||||
--vermilion: #F26B6B;
|
||||
--vermilion-light: #F59898;
|
||||
--vermilion-dark: #E55555;
|
||||
--gold: #5B8DEF;
|
||||
--gold-light: #7BA5F5;
|
||||
--gold-dark: #4070D0;
|
||||
--paper: #070D1A;
|
||||
--paper-dark: #050910;
|
||||
--surface: #111B2E;
|
||||
--sage: #4EC9B0;
|
||||
--amber: #E5C07B;
|
||||
--warm-gray: #6B7A8D;
|
||||
--warm-border: #1E2D45;
|
||||
|
||||
/* ── Semantic tokens ── */
|
||||
--c-primary: var(--ink);
|
||||
--c-primary-light: var(--ink-light);
|
||||
--c-primary-bg: #131D30;
|
||||
--c-accent: var(--vermilion);
|
||||
--c-accent-light: var(--vermilion-light);
|
||||
--c-gold: var(--gold);
|
||||
--c-success: var(--sage);
|
||||
--c-warning: var(--amber);
|
||||
--c-danger: var(--vermilion);
|
||||
--c-info: #5B8DEF;
|
||||
--c-text: #C8D6E5;
|
||||
--c-text-secondary: #6B7A8D;
|
||||
--c-text-muted: #455570;
|
||||
--c-bg: var(--paper);
|
||||
--c-bg-card: var(--surface);
|
||||
--c-border: var(--warm-border);
|
||||
|
||||
/* ── Effects ── */
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.25);
|
||||
--shadow: 0 2px 6px rgba(0,0,0,0.35);
|
||||
--shadow-md: 0 6px 18px rgba(0,0,0,0.45);
|
||||
--shadow-lg: 0 12px 36px rgba(0,0,0,0.55);
|
||||
--radius: 2px;
|
||||
--radius-sm: 2px;
|
||||
--radius-xs: 2px;
|
||||
--transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ── Misc dark tokens ── */
|
||||
--c-bg-light: #101A28;
|
||||
|
||||
/* ── Sidebar — dark tech ── */
|
||||
--sidebar-bg: #0B1120;
|
||||
--sidebar-color: #C8D6E5;
|
||||
--sidebar-accent: var(--gold);
|
||||
--sidebar-nav-text: rgba(200,214,229,0.45);
|
||||
--sidebar-nav-text-hover: rgba(200,214,229,0.80);
|
||||
--sidebar-nav-text-active: #FFFFFF;
|
||||
--sidebar-nav-bg-hover: rgba(91,141,239,0.06);
|
||||
--sidebar-nav-bg-active: rgba(91,141,239,0.10);
|
||||
--sidebar-nav-border-active: var(--gold);
|
||||
--sidebar-divider: rgba(200,214,229,0.06);
|
||||
--sidebar-brand-color: #FFFFFF;
|
||||
--sidebar-brand-sub-color: rgba(200,214,229,0.35);
|
||||
--sidebar-group-label-color: rgba(200,214,229,0.20);
|
||||
--sidebar-footer-role-color: var(--gold);
|
||||
--sidebar-footer-name-color: rgba(200,214,229,0.65);
|
||||
|
||||
/* ── Mobile page accent ── */
|
||||
--page-accent-color: rgba(91,141,239,0.06);
|
||||
|
||||
/* ── Manager chip tags ── */
|
||||
--mgr-chip-text: #C8D6E5;
|
||||
|
||||
/* ── Fonts — editorial Chinese ── */
|
||||
--font-body: 'Noto Serif SC', STSong, Songti SC, '宋体', serif;
|
||||
--font-heading: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
|
||||
--font-mono: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
Dark Mode — Element Plus CSS Variable Overrides
|
||||
Override --el-* variables so ALL components render dark
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
html.dark {
|
||||
--el-color-white: var(--surface);
|
||||
--el-color-black: var(--c-text);
|
||||
--el-color-primary: var(--c-gold);
|
||||
--el-color-primary-light-3: var(--gold-light);
|
||||
--el-color-primary-light-5: var(--gold-light);
|
||||
--el-color-primary-light-7: var(--c-primary-bg);
|
||||
--el-color-primary-light-8: var(--c-primary-bg);
|
||||
--el-color-primary-light-9: var(--paper-dark);
|
||||
--el-color-primary-dark-2: var(--gold-dark);
|
||||
--el-color-success: var(--c-success);
|
||||
--el-color-warning: var(--c-warning);
|
||||
--el-color-danger: var(--c-danger);
|
||||
--el-color-info: var(--c-info);
|
||||
--el-bg-color: var(--paper);
|
||||
--el-bg-color-page: var(--paper);
|
||||
--el-bg-color-overlay: var(--surface);
|
||||
--el-text-color-primary: var(--c-text);
|
||||
--el-text-color-regular: var(--c-text);
|
||||
--el-text-color-secondary: var(--c-text-secondary);
|
||||
--el-text-color-placeholder: var(--c-text-muted);
|
||||
--el-text-color-disabled: var(--c-text-muted);
|
||||
--el-border-color: var(--warm-border);
|
||||
--el-border-color-light: var(--warm-border);
|
||||
--el-border-color-lighter: var(--warm-border);
|
||||
--el-border-color-extra-light: var(--warm-border);
|
||||
--el-border-color-dark: var(--c-text-muted);
|
||||
--el-fill-color: var(--c-primary-bg);
|
||||
--el-fill-color-light: var(--c-primary-bg);
|
||||
--el-fill-color-lighter: var(--paper-dark);
|
||||
--el-fill-color-blank: var(--surface);
|
||||
--el-disabled-bg-color: var(--paper-dark);
|
||||
--el-disabled-text-color: var(--c-text-muted);
|
||||
--el-disabled-border-color: var(--warm-border);
|
||||
--el-mask-color: rgba(0,0,0,0.6);
|
||||
--el-mask-color-extra-light: rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* ── Reset & Base ── */
|
||||
@@ -73,7 +393,7 @@ html, body, #app {
|
||||
margin: 0; padding: 0; height: 100%;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
font-family: 'Noto Serif SC', STSong, Songti SC, '宋体', serif;
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
color: var(--c-text);
|
||||
@@ -84,7 +404,7 @@ html, body, #app {
|
||||
|
||||
/* ── Editorial heading style ── */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
|
||||
font-family: var(--font-heading);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
@@ -104,7 +424,7 @@ h1, h2, h3, h4, h5, h6 {
|
||||
.el-card__header {
|
||||
border-bottom: 2px solid var(--gold) !important;
|
||||
padding: 16px 20px !important;
|
||||
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: var(--ink);
|
||||
@@ -118,7 +438,7 @@ h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
transition: all var(--transition);
|
||||
font-family: 'Noto Serif SC', STSong, Songti SC, serif;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
.el-button--primary {
|
||||
background: var(--ink) !important;
|
||||
@@ -146,14 +466,14 @@ h1, h2, h3, h4, h5, h6 {
|
||||
.el-tag {
|
||||
border-radius: 2px !important;
|
||||
font-weight: 500;
|
||||
font-family: 'Noto Serif SC', STSong, serif;
|
||||
font-family: var(--font-body);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ── Tabs ── */
|
||||
.el-tabs__item {
|
||||
font-weight: 500;
|
||||
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
.el-tabs__active-bar { background: var(--gold) !important; }
|
||||
.el-tabs__item.is-active { color: var(--ink) !important; }
|
||||
@@ -164,7 +484,7 @@ h1, h2, h3, h4, h5, h6 {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
||||
font-family: var(--font-heading);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
|
||||
@@ -181,7 +501,7 @@ h1, h2, h3, h4, h5, h6 {
|
||||
.el-dialog__header {
|
||||
padding: 20px 24px 0 !important;
|
||||
font-weight: 600;
|
||||
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
.el-dialog__body { padding: 20px 24px !important; }
|
||||
|
||||
@@ -222,7 +542,7 @@ h1, h2, h3, h4, h5, h6 {
|
||||
/* ── Textarea ── */
|
||||
.el-textarea__inner {
|
||||
border-radius: 2px !important;
|
||||
font-family: 'Noto Serif SC', STSong, serif;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
/* ── Radio ── */
|
||||
@@ -255,7 +575,7 @@ h1, h2, h3, h4, h5, h6 {
|
||||
|
||||
/* ── Divider ── */
|
||||
.el-divider__text {
|
||||
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
||||
font-family: var(--font-heading);
|
||||
color: var(--warm-gray);
|
||||
}
|
||||
|
||||
@@ -277,4 +597,289 @@ h1, h2, h3, h4, h5, h6 {
|
||||
cursor: pointer; border-radius: 50%; transition: background 0.2s;
|
||||
}
|
||||
.image-preview-close:hover { background: rgba(255,255,255,0.35); }
|
||||
|
||||
/* ── Screenshot capture mode ── */
|
||||
.taking-screenshot { background: #fff !important; }
|
||||
.taking-screenshot * { background-color: transparent; }
|
||||
.taking-screenshot .el-card,
|
||||
.taking-screenshot .el-table,
|
||||
.taking-screenshot .el-table__header,
|
||||
.taking-screenshot .el-table__body,
|
||||
.taking-screenshot .filter-bar,
|
||||
.taking-screenshot .summary-bar,
|
||||
.taking-screenshot .page-head { background: #fff !important; }
|
||||
.taking-screenshot .screenshot-hide { display: none !important; }
|
||||
.taking-screenshot .el-table__fixed-right { display: none !important; }
|
||||
|
||||
/* ── Screenshot capture animation ── */
|
||||
.body-capturing .work-plans-page,
|
||||
.body-capturing .mini-biz-page,
|
||||
.body-capturing .key-visits-page {
|
||||
position: relative;
|
||||
}
|
||||
.body-capturing .work-plans-page::after,
|
||||
.body-capturing .mini-biz-page::after,
|
||||
.body-capturing .key-visits-page::after {
|
||||
content: '';
|
||||
position: fixed; inset: 0; z-index: 9998;
|
||||
background: rgba(255,255,255,0.6);
|
||||
pointer-events: none;
|
||||
animation: capture-flash 0.35s ease-out;
|
||||
}
|
||||
@keyframes capture-flash {
|
||||
0% { opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Column resize handle ── */
|
||||
.el-table__header th {
|
||||
position: relative !important;
|
||||
}
|
||||
/* Exclude gutter (scrollbar spacer) and selection column from resize */
|
||||
.el-table__header th.el-table__cell--selection .col-resize-handle,
|
||||
.el-table__header th.gutter .col-resize-handle {
|
||||
display: none !important;
|
||||
}
|
||||
.col-resize-handle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
cursor: col-resize;
|
||||
z-index: 1;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.col-resize-handle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 1px;
|
||||
top: 20%;
|
||||
bottom: 20%;
|
||||
width: 2px;
|
||||
border-radius: 1px;
|
||||
background: transparent;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.el-table__header th:hover .col-resize-handle::after,
|
||||
.col-resize-handle.is-resizing::after {
|
||||
background: var(--gold);
|
||||
}
|
||||
.col-resize-handle:hover::after,
|
||||
.col-resize-handle.is-resizing::after {
|
||||
background: var(--gold) !important;
|
||||
}
|
||||
/* Prevent text selection while resizing columns */
|
||||
body.col-resizing {
|
||||
cursor: col-resize !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
Dark Mode — Element Plus Global Overrides
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
html.dark .el-input__wrapper {
|
||||
background: var(--surface) !important;
|
||||
box-shadow: 0 0 0 1px var(--warm-border) inset !important;
|
||||
}
|
||||
html.dark .el-input__inner {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-input__wrapper:hover {
|
||||
box-shadow: 0 0 0 1px var(--c-text-muted) inset !important;
|
||||
}
|
||||
html.dark .el-input__wrapper.is-focus {
|
||||
box-shadow: 0 0 0 2px var(--gold) inset !important;
|
||||
}
|
||||
|
||||
/* Select dropdown */
|
||||
html.dark .el-select-dropdown {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--warm-border) !important;
|
||||
}
|
||||
html.dark .el-select-dropdown__item {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-select-dropdown__item:hover {
|
||||
background: var(--c-primary-bg) !important;
|
||||
}
|
||||
html.dark .el-select-dropdown__item.selected {
|
||||
color: var(--gold) !important;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
html.dark .el-table {
|
||||
--el-table-bg-color: var(--surface) !important;
|
||||
--el-table-tr-bg-color: var(--surface) !important;
|
||||
--el-table-header-bg-color: var(--c-primary-bg) !important;
|
||||
--el-table-row-hover-bg-color: var(--c-primary-bg) !important;
|
||||
--el-table-border-color: var(--warm-border) !important;
|
||||
--el-table-text-color: var(--c-text) !important;
|
||||
--el-table-header-text-color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-table th.el-table__cell {
|
||||
background: var(--c-primary-bg) !important;
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-table td.el-table__cell {
|
||||
background: var(--surface) !important;
|
||||
}
|
||||
html.dark .el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
|
||||
background: var(--c-primary-bg) !important;
|
||||
}
|
||||
html.dark .el-table__body tr:hover > td.el-table__cell {
|
||||
background: var(--c-primary-bg) !important;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
html.dark .el-pagination button,
|
||||
html.dark .el-pager li {
|
||||
background: var(--surface) !important;
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-pager li.is-active {
|
||||
background: var(--ink) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
html.dark .el-pagination button:hover,
|
||||
html.dark .el-pager li:hover {
|
||||
color: var(--gold) !important;
|
||||
}
|
||||
|
||||
/* Button — default / text variant */
|
||||
html.dark .el-button--default {
|
||||
--el-button-bg-color: var(--surface);
|
||||
--el-button-border-color: var(--warm-border);
|
||||
--el-button-text-color: var(--c-text);
|
||||
--el-button-hover-bg-color: var(--c-primary-bg);
|
||||
--el-button-hover-border-color: var(--c-text-muted);
|
||||
--el-button-hover-text-color: var(--c-text);
|
||||
}
|
||||
|
||||
/* Dialog */
|
||||
html.dark .el-dialog {
|
||||
--el-dialog-bg-color: var(--surface);
|
||||
}
|
||||
html.dark .el-dialog__title {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-dialog__header {
|
||||
border-bottom: 1px solid var(--warm-border);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
html.dark .el-tabs__item {
|
||||
color: var(--c-text-secondary) !important;
|
||||
}
|
||||
html.dark .el-tabs__item.is-active {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-tabs__header {
|
||||
border-bottom-color: var(--warm-border);
|
||||
}
|
||||
|
||||
/* Tag */
|
||||
html.dark .el-tag {
|
||||
--el-tag-bg-color: var(--c-primary-bg);
|
||||
--el-tag-border-color: var(--warm-border);
|
||||
--el-tag-text-color: var(--c-text);
|
||||
}
|
||||
|
||||
/* Form label */
|
||||
html.dark .el-form-item__label {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
|
||||
/* Progress */
|
||||
html.dark .el-progress-bar__outer {
|
||||
background: var(--paper-dark) !important;
|
||||
}
|
||||
|
||||
/* Date picker panel */
|
||||
html.dark .el-picker-panel {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--warm-border) !important;
|
||||
}
|
||||
html.dark .el-date-picker__header-label {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-date-table td {
|
||||
color: var(--c-text);
|
||||
}
|
||||
html.dark .el-date-table td.current:not(.disabled) .el-date-table-cell__text {
|
||||
background: var(--gold) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
html.dark .el-date-table td.today .el-date-table-cell__text {
|
||||
color: var(--gold) !important;
|
||||
}
|
||||
|
||||
/* Time picker panel */
|
||||
html.dark .el-time-panel {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--warm-border) !important;
|
||||
}
|
||||
html.dark .el-time-spinner__item {
|
||||
color: var(--c-text-muted) !important;
|
||||
}
|
||||
html.dark .el-time-spinner__item.is-active {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
|
||||
/* Popover / tooltip */
|
||||
html.dark .el-popover,
|
||||
html.dark .el-tooltip__popper {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--warm-border) !important;
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
|
||||
/* Message box confirm */
|
||||
html.dark .el-message-box {
|
||||
--el-messagebox-bg-color: var(--surface);
|
||||
--el-messagebox-title-color: var(--c-text);
|
||||
--el-messagebox-content-color: var(--c-text-secondary);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
html.dark .el-empty__description p {
|
||||
color: var(--c-text-muted) !important;
|
||||
}
|
||||
|
||||
/* Radio / Checkbox label */
|
||||
html.dark .el-radio__label,
|
||||
html.dark .el-checkbox__label {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
|
||||
/* Cascader / Tree select */
|
||||
html.dark .el-cascader-node__label {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-cascader-menu {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--warm-border) !important;
|
||||
}
|
||||
|
||||
/* Alert */
|
||||
html.dark .el-alert {
|
||||
--el-alert-bg-color: var(--c-primary-bg);
|
||||
}
|
||||
|
||||
/* Drawer */
|
||||
html.dark .el-drawer {
|
||||
background: var(--surface) !important;
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
html.dark .el-divider__text {
|
||||
background: var(--surface) !important;
|
||||
}
|
||||
|
||||
/* Loading mask */
|
||||
html.dark .el-loading-mask {
|
||||
background-color: rgba(0,0,0,0.5) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import api from './index'
|
||||
|
||||
export const aiApi = {
|
||||
getSummary(params?: { reference_date?: string; period?: string }) {
|
||||
return api.get('/ai/summary', { params })
|
||||
},
|
||||
generateSummary(params?: { reference_date?: string; period?: string }) {
|
||||
return api.post('/ai/summary', null, { params })
|
||||
},
|
||||
}
|
||||
@@ -10,4 +10,7 @@ export const dashboardApi = {
|
||||
getWeeklyReport(params?: any) {
|
||||
return api.get('/dashboard/weekly-report', { params })
|
||||
},
|
||||
getLightBoard(params?: any) {
|
||||
return api.get('/dashboard/light-board', { params })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import api from './index'
|
||||
|
||||
export const leavesApi = {
|
||||
list(params?: Record<string, any>) {
|
||||
return api.get('/leaves/', { params })
|
||||
},
|
||||
create(data: Record<string, any>) {
|
||||
return api.post('/leaves/', data)
|
||||
},
|
||||
update(id: string, data: Record<string, any>) {
|
||||
return api.put(`/leaves/${id}`, data)
|
||||
},
|
||||
delete(id: string) {
|
||||
return api.delete(`/leaves/${id}`)
|
||||
},
|
||||
overview(params?: Record<string, any>) {
|
||||
return api.get('/leaves/overview', { params })
|
||||
},
|
||||
}
|
||||
@@ -13,4 +13,17 @@ export const miniBusinessApi = {
|
||||
delete(id: string) {
|
||||
return api.delete(`/mini-business/${id}`)
|
||||
},
|
||||
// Follow-up logs
|
||||
getLogs(businessId: string) {
|
||||
return api.get(`/mini-business/${businessId}/logs`)
|
||||
},
|
||||
createLog(businessId: string, data: any) {
|
||||
return api.post(`/mini-business/${businessId}/logs`, data)
|
||||
},
|
||||
updateLog(logId: string, data: any) {
|
||||
return api.put(`/mini-business/logs/${logId}`, data)
|
||||
},
|
||||
deleteLog(logId: string) {
|
||||
return api.delete(`/mini-business/logs/${logId}`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import api from './index'
|
||||
import axios from 'axios'
|
||||
|
||||
export const uploadApi = {
|
||||
async getPresignedUrl(filename: string, contentType: string = 'image/jpeg') {
|
||||
return api.post('/upload/presigned-url', null, {
|
||||
params: { filename, content_type: contentType },
|
||||
async uploadImage(file: File) {
|
||||
const data = new FormData()
|
||||
data.append('file', file)
|
||||
return api.post('/upload/image', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 60000,
|
||||
})
|
||||
},
|
||||
getDownloadUrl(objectKey: string) {
|
||||
return api.get('/upload/download-url', { params: { object_key: objectKey } })
|
||||
},
|
||||
// Direct upload to MinIO
|
||||
async uploadFile(uploadUrl: string, file: File) {
|
||||
return axios.put(uploadUrl, file, {
|
||||
headers: { 'Content-Type': file.type },
|
||||
timeout: 60000,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
Vendored
+53
@@ -0,0 +1,53 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
DesktopLayout: typeof import('./components/DesktopLayout.vue')['default']
|
||||
EditLogPanel: typeof import('./components/EditLogPanel.vue')['default']
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElLink: typeof import('element-plus/es')['ElLink']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
|
||||
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
ImagePreview: typeof import('./components/ImagePreview.vue')['default']
|
||||
MobileLayout: typeof import('./components/MobileLayout.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,12 @@
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const themeStore = useThemeStore()
|
||||
const collapsed = ref(false)
|
||||
|
||||
interface MenuGroup { label?: string; items: { path: string; label: string; icon: string }[] }
|
||||
@@ -17,6 +19,7 @@ const menuGroups = computed<MenuGroup[]>(() => {
|
||||
items: [
|
||||
{ path: '/', label: '仪表盘', icon: '<polyline points="4 7 12 3 20 7"></polyline><polyline points="20 7 20 21 4 21 4 7"></polyline><line x1="8" y1="21" x2="8" y2="12"></line><line x1="16" y1="21" x2="16" y2="12"></line>' },
|
||||
{ path: '/weekly-report', label: '周报', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line>' },
|
||||
{ path: '/light-board', label: '亮灯表', icon: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -25,6 +28,7 @@ const menuGroups = computed<MenuGroup[]>(() => {
|
||||
{ path: '/work-plans', label: '工作计划', icon: '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line><polyline points="8 14 12 18 16 14"></polyline>' },
|
||||
{ path: '/mini-business', label: '商机跟单', icon: '<line x1="12" y1="1" x2="12" y2="23"></line><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>' },
|
||||
{ path: '/key-visits', label: '要客拜访', icon: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>' },
|
||||
{ path: '/leaves', label: '请假管理', icon: '<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>' },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -40,12 +44,15 @@ const menuGroups = computed<MenuGroup[]>(() => {
|
||||
label: '管理',
|
||||
items: [
|
||||
{ path: '/customers', label: '客户管理', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle>' },
|
||||
{ path: '/settings', label: '系统设置', icon: '<circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>' },
|
||||
],
|
||||
})
|
||||
if (auth.isDirector) {
|
||||
groups[groups.length - 1].items.push(
|
||||
groups[groups.length - 1].items.splice(1, 0,
|
||||
{ path: '/users', label: '用户管理', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path>' },
|
||||
{ path: '/settings', label: '系统设置', icon: '<circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>' },
|
||||
)
|
||||
groups[groups.length - 1].items.push(
|
||||
{ path: '/audit-log', label: '操作日志', icon: '<polyline points="9 11 12 14 22 4"></polyline><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>' },
|
||||
)
|
||||
}
|
||||
return groups
|
||||
@@ -56,6 +63,10 @@ const roleLabel = computed(() => {
|
||||
if (auth.isLeader) return '分管领导'
|
||||
return '客户经理'
|
||||
})
|
||||
|
||||
function cycleTheme() {
|
||||
themeStore.toggleMode()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -99,6 +110,13 @@ const roleLabel = computed(() => {
|
||||
</svg>
|
||||
</button>
|
||||
<div class="topbar-actions">
|
||||
<button class="topbar-btn" @click="cycleTheme" :title="'切换至' + (themeStore.mode === 'light' ? '深色模式' : '亮色模式')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path v-if="themeStore.mode === 'light'" d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
<circle v-if="themeStore.mode === 'dark'" cx="12" cy="12" r="5"></circle>
|
||||
<path v-if="themeStore.mode === 'dark'" d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="topbar-btn" @click="router.push('/m')" title="移动端">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="5" y="2" width="14" height="20" rx="2" ry="2"></rect>
|
||||
@@ -134,8 +152,8 @@ const roleLabel = computed(() => {
|
||||
/* ═══ Sidebar ═══ */
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
background: var(--sidebar-bg);
|
||||
color: var(--sidebar-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
@@ -157,31 +175,31 @@ const roleLabel = computed(() => {
|
||||
|
||||
.sidebar-brand {
|
||||
padding: 26px 22px 18px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
border-bottom: 1px solid var(--sidebar-divider);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
margin: 0;
|
||||
font-family: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.12em;
|
||||
color: #fff;
|
||||
color: var(--sidebar-brand-color);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
margin: 2px 0 0;
|
||||
font-family: 'Noto Serif SC', STSong, serif;
|
||||
font-family: var(--font-body);
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.4);
|
||||
color: var(--sidebar-brand-sub-color);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.brand-rule {
|
||||
width: 28px;
|
||||
height: 3px;
|
||||
background: var(--gold);
|
||||
background: var(--sidebar-accent);
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
@@ -196,10 +214,10 @@ const roleLabel = computed(() => {
|
||||
}
|
||||
|
||||
.nav-group-label {
|
||||
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.15em;
|
||||
color: rgba(255,255,255,0.25);
|
||||
color: var(--sidebar-group-label-color);
|
||||
padding: 12px 14px 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -211,9 +229,9 @@ const roleLabel = computed(() => {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 11px 14px;
|
||||
color: rgba(255,255,255,0.55);
|
||||
color: var(--sidebar-nav-text);
|
||||
text-decoration: none;
|
||||
font-family: 'Noto Serif SC', STSong, serif;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.04em;
|
||||
transition: all 0.22s;
|
||||
@@ -221,14 +239,14 @@ const roleLabel = computed(() => {
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
color: rgba(255,255,255,0.85);
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: var(--sidebar-nav-text-hover);
|
||||
background: var(--sidebar-nav-bg-hover);
|
||||
}
|
||||
|
||||
.nav-item--active {
|
||||
color: #fff;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-left-color: var(--gold);
|
||||
color: var(--sidebar-nav-text-active);
|
||||
background: var(--sidebar-nav-bg-active);
|
||||
border-left-color: var(--sidebar-nav-border-active);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
@@ -243,23 +261,23 @@ const roleLabel = computed(() => {
|
||||
/* ═══ Sidebar Footer ═══ */
|
||||
.sidebar-footer {
|
||||
padding: 14px 22px;
|
||||
border-top: 1px solid rgba(255,255,255,0.06);
|
||||
border-top: 1px solid var(--sidebar-divider);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.footer-role {
|
||||
font-family: 'Noto Serif SC', STSong, serif;
|
||||
font-family: var(--font-body);
|
||||
font-size: 10px;
|
||||
color: var(--gold);
|
||||
color: var(--sidebar-footer-role-color);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.footer-name {
|
||||
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 13px;
|
||||
color: rgba(255,255,255,0.7);
|
||||
color: var(--sidebar-footer-name-color);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
@@ -284,7 +302,7 @@ const roleLabel = computed(() => {
|
||||
}
|
||||
|
||||
.topbar-greeting {
|
||||
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 14px;
|
||||
color: var(--ink);
|
||||
letter-spacing: 0.04em;
|
||||
@@ -314,7 +332,7 @@ const roleLabel = computed(() => {
|
||||
background: none;
|
||||
border: 1px solid var(--warm-border);
|
||||
color: var(--warm-gray);
|
||||
font-family: 'Noto Serif SC', STSong, serif;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.03em;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -125,14 +125,14 @@ function labelFor(field: string) {
|
||||
}
|
||||
|
||||
.log-editor {
|
||||
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 13px;
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
@@ -266,7 +266,7 @@ const imgStyle = computed(() => {
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
cursor: pointer;
|
||||
font-family: 'Noto Serif SC', STSong, serif;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
@@ -287,7 +287,7 @@ const imgStyle = computed(() => {
|
||||
}
|
||||
|
||||
.fit-label {
|
||||
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
@@ -2,31 +2,28 @@
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useThemeStore, themeLabels } from '@/stores/theme'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
function cycleTheme() {
|
||||
const next = themeStore.currentTheme === 'editorial' ? 'light' : 'editorial'
|
||||
themeStore.setTheme(next)
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
path: '/m',
|
||||
label: '首页',
|
||||
icon: 'home',
|
||||
},
|
||||
{
|
||||
path: '/m/visit/new',
|
||||
label: '拜访',
|
||||
icon: 'visit',
|
||||
},
|
||||
{
|
||||
path: '/m/note/new',
|
||||
label: '纪要',
|
||||
icon: 'note',
|
||||
},
|
||||
{ path: '/m', label: '首页', icon: 'home' },
|
||||
{ path: '/m/work', label: '工作', icon: 'work' },
|
||||
{ path: '/m/visit/new', label: '拜访', icon: 'visit' },
|
||||
{ path: '/m/note/new', label: '纪要', icon: 'note' },
|
||||
]
|
||||
|
||||
const activeTab = computed(() => {
|
||||
if (route.path === '/m') return '/m'
|
||||
if (route.path === '/m/work' || route.path.startsWith('/m/plans') || route.path.startsWith('/m/mini-biz') || route.path.startsWith('/m/key-visits') || route.path.startsWith('/m/leaves') || route.path.startsWith('/m/leave')) return '/m/work'
|
||||
if (route.path.includes('/visit')) return '/m/visit/new'
|
||||
if (route.path.includes('/note')) return '/m/note/new'
|
||||
return route.path
|
||||
@@ -43,6 +40,22 @@ const activeTab = computed(() => {
|
||||
<div class="header-rule"></div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="header-pc-btn" @click="cycleTheme" :title="'切换至' + themeLabels[themeStore.currentTheme === 'editorial' ? 'light' : 'editorial']">
|
||||
<svg v-if="themeStore.currentTheme === 'editorial'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="header-pc-btn" @click="router.push('/')" title="PC版">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
|
||||
@@ -76,6 +89,11 @@ const activeTab = computed(() => {
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
|
||||
<polyline points="9 22 9 12 15 12 15 22"></polyline>
|
||||
</svg>
|
||||
<!-- Work icon -->
|
||||
<svg v-else-if="tab.icon === 'work'" class="tab-icon" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect>
|
||||
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path>
|
||||
</svg>
|
||||
<!-- Visit icon -->
|
||||
<svg v-else-if="tab.icon === 'visit'" class="tab-icon" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
|
||||
@@ -129,7 +147,7 @@ const activeTab = computed(() => {
|
||||
|
||||
.header-title {
|
||||
margin: 0;
|
||||
font-family: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 26px;
|
||||
font-weight: 400;
|
||||
color: var(--ink);
|
||||
@@ -138,7 +156,7 @@ const activeTab = computed(() => {
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-family: 'Noto Serif SC', STSong, Songti SC, serif;
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
color: var(--warm-gray);
|
||||
letter-spacing: 0.12em;
|
||||
@@ -178,7 +196,7 @@ const activeTab = computed(() => {
|
||||
}
|
||||
|
||||
.header-user {
|
||||
font-family: 'Noto Serif SC', STSong, serif;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
color: var(--warm-gray);
|
||||
letter-spacing: 0.03em;
|
||||
@@ -201,7 +219,7 @@ const activeTab = computed(() => {
|
||||
right: 0;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: linear-gradient(135deg, transparent 60%, rgba(196,147,74,0.06) 60%);
|
||||
background: linear-gradient(135deg, transparent 60%, var(--page-accent-color) 60%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
@@ -238,7 +256,7 @@ const activeTab = computed(() => {
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
white-space: nowrap;
|
||||
font-family: 'Noto Serif SC', STSong, serif;
|
||||
font-family: var(--font-body);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* v-column-resize directive — adds drag-to-resize handles to el-table column headers.
|
||||
*
|
||||
* Usage: <el-table v-column-resize ...>
|
||||
*
|
||||
* Holders drag the right edge of any column header (except the last) to resize.
|
||||
* Works with Element Plus's fixed table-layout by updating <col> widths in
|
||||
* every colgroup (header + body tables stay in sync).
|
||||
*/
|
||||
|
||||
import type { Directive, DirectiveBinding } from 'vue'
|
||||
|
||||
interface DragState {
|
||||
startX: number
|
||||
startWidth: number
|
||||
th: HTMLElement
|
||||
tableWrapper: HTMLElement
|
||||
colIndex: number
|
||||
}
|
||||
|
||||
const drag: DragState = {
|
||||
startX: 0,
|
||||
startWidth: 0,
|
||||
th: null!,
|
||||
tableWrapper: null!,
|
||||
colIndex: -1,
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────
|
||||
|
||||
function getColIndex(th: HTMLElement): number {
|
||||
const cells = Array.from(th.parentElement?.children || [])
|
||||
return cells.indexOf(th)
|
||||
}
|
||||
|
||||
/** All <col> elements across every colgroup inside the wrapper, by col index */
|
||||
function allCols(wrapper: HTMLElement, idx: number): HTMLElement[] {
|
||||
const groups = wrapper.querySelectorAll('colgroup')
|
||||
const result: HTMLElement[] = []
|
||||
groups.forEach((g) => {
|
||||
const col = g.children[idx] as HTMLElement | undefined
|
||||
if (col) result.push(col)
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
function setColumnWidth(wrapper: HTMLElement, colIndex: number, width: number) {
|
||||
const w = Math.max(40, width) + 'px'
|
||||
for (const col of allCols(wrapper, colIndex)) {
|
||||
col.style.width = w
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event handlers ───────────────────────────────────────
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
const diff = e.clientX - drag.startX
|
||||
setColumnWidth(drag.tableWrapper, drag.colIndex, drag.startWidth + diff)
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
const handle = drag.th.querySelector('.col-resize-handle') as HTMLElement | null
|
||||
if (handle) handle.classList.remove('is-resizing')
|
||||
}
|
||||
|
||||
function onHandleDown(e: MouseEvent, th: HTMLElement, wrapper: HTMLElement) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const idx = getColIndex(th)
|
||||
const cols = allCols(wrapper, idx)
|
||||
const currentWidth = cols[0] ? cols[0].getBoundingClientRect().width : th.offsetWidth
|
||||
|
||||
drag.startX = e.clientX
|
||||
drag.startWidth = currentWidth
|
||||
drag.th = th
|
||||
drag.tableWrapper = wrapper
|
||||
drag.colIndex = idx
|
||||
|
||||
const handle = th.querySelector('.col-resize-handle') as HTMLElement | null
|
||||
if (handle) handle.classList.add('is-resizing')
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}
|
||||
|
||||
// ── Setup / Teardown ─────────────────────────────────────
|
||||
|
||||
function injectHandles(wrapper: HTMLElement) {
|
||||
const headerThs = wrapper.querySelectorAll<HTMLElement>(
|
||||
'.el-table__header-wrapper th:not(.el-table__cell--selection)',
|
||||
)
|
||||
if (headerThs.length === 0) return
|
||||
|
||||
headerThs.forEach((th) => {
|
||||
// Don't add handle to the last column (or to columns that already have one)
|
||||
// Actually, allow all columns except the last to be resizable
|
||||
if (th.querySelector('.col-resize-handle')) return
|
||||
|
||||
const handle = document.createElement('div')
|
||||
handle.className = 'col-resize-handle'
|
||||
handle.addEventListener('mousedown', (e: MouseEvent) => onHandleDown(e, th, wrapper))
|
||||
// Prevent sort trigger on header click when grabbing handle
|
||||
handle.addEventListener('click', (e: Event) => e.stopPropagation())
|
||||
th.appendChild(handle)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Directive definition ─────────────────────────────────
|
||||
|
||||
function findWrapper(el: HTMLElement): HTMLElement | null {
|
||||
return el.querySelector<HTMLElement>('.el-table__inner-wrapper')
|
||||
|| el.querySelector<HTMLElement>('.el-table__header-wrapper')?.closest('.el-table') as HTMLElement
|
||||
|| null
|
||||
}
|
||||
|
||||
export const vColumnResize: Directive<HTMLElement> = {
|
||||
mounted(el: HTMLElement, _binding: DirectiveBinding) {
|
||||
const trySetup = () => {
|
||||
const wrapper = findWrapper(el)
|
||||
if (wrapper) {
|
||||
injectHandles(wrapper)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (!trySetup()) {
|
||||
const timer = setTimeout(() => trySetup(), 200)
|
||||
;(el as any).__colResizeTimer = timer
|
||||
}
|
||||
// Re-inject handles when DOM changes (sort, filter, data reload)
|
||||
const observer = new MutationObserver(() => {
|
||||
const wrapper = findWrapper(el)
|
||||
if (wrapper) injectHandles(wrapper)
|
||||
})
|
||||
observer.observe(el, { childList: true, subtree: true })
|
||||
;(el as any).__colResizeObserver = observer
|
||||
},
|
||||
|
||||
unmounted(el: HTMLElement) {
|
||||
clearTimeout((el as any).__colResizeTimer)
|
||||
const observer = (el as any).__colResizeObserver as MutationObserver | undefined
|
||||
observer?.disconnect()
|
||||
},
|
||||
}
|
||||
|
||||
export default vColumnResize
|
||||
@@ -1,14 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
import './tailwind.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { vColumnResize } from '@/directives/columnResize'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn as any })
|
||||
app.directive('column-resize', vColumnResize)
|
||||
app.mount('#app')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { isMobile } from '@/utils'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -16,19 +17,43 @@ const router = createRouter({
|
||||
component: () => import('@/views/BindWecom.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/bind-wechat',
|
||||
name: 'BindWechat',
|
||||
component: () => import('@/views/BindWechat.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/wecom-bind',
|
||||
name: 'WecomBind',
|
||||
component: () => import('@/views/WecomBind.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
// Mobile routes (manager-facing)
|
||||
{
|
||||
path: '/m',
|
||||
component: () => import('@/components/MobileLayout.vue'),
|
||||
children: [
|
||||
{ path: '', name: 'MobileHome', component: () => import('@/views/mobile/Home.vue') },
|
||||
{ path: 'work', name: 'WorkCenter', component: () => import('@/views/mobile/WorkCenter.vue') },
|
||||
{ path: 'visit/new', name: 'VisitForm', component: () => import('@/views/mobile/VisitForm.vue') },
|
||||
{ path: 'visit/:id/edit', name: 'VisitEdit', component: () => import('@/views/mobile/VisitForm.vue') },
|
||||
{ path: 'work-plan/new', name: 'WorkPlanForm', component: () => import('@/views/mobile/WorkPlanForm.vue') },
|
||||
{ path: 'work-plan/:id/edit', name: 'WorkPlanEdit', component: () => import('@/views/mobile/WorkPlanForm.vue') },
|
||||
{ path: 'mini-biz/new', name: 'MiniBusinessForm', component: () => import('@/views/mobile/MiniBusinessForm.vue') },
|
||||
{ path: 'mini-biz/:id/edit', name: 'MiniBusinessEdit', component: () => import('@/views/mobile/MiniBusinessForm.vue') },
|
||||
{ path: 'key-visit/new', name: 'KeyVisitForm', component: () => import('@/views/mobile/KeyVisitForm.vue') },
|
||||
{ path: 'key-visit/:id/edit', name: 'KeyVisitEdit', component: () => import('@/views/mobile/KeyVisitForm.vue') },
|
||||
{ path: 'note/new', name: 'DailyNoteForm', component: () => import('@/views/mobile/DailyNoteForm.vue') },
|
||||
{ path: 'note/:id/edit', name: 'DailyNoteEdit', component: () => import('@/views/mobile/DailyNoteForm.vue') },
|
||||
{ path: 'visits', name: 'VisitsList', component: () => import('@/views/mobile/VisitsList.vue') },
|
||||
{ path: 'notes', name: 'NotesList', component: () => import('@/views/mobile/NotesList.vue') },
|
||||
{ path: 'plans', name: 'PlansList', component: () => import('@/views/mobile/PlansList.vue') },
|
||||
{ path: 'mini-biz', name: 'MiniBizList', component: () => import('@/views/mobile/MiniBizList.vue') },
|
||||
{ path: 'key-visits', name: 'KeyVisitsList', component: () => import('@/views/mobile/KeyVisitsList.vue') },
|
||||
{ path: 'leaves', name: 'LeavesList', component: () => import('@/views/mobile/LeavesList.vue') },
|
||||
{ path: 'leave/new', name: 'LeaveForm', component: () => import('@/views/mobile/LeaveForm.vue') },
|
||||
{ path: 'leave/:id/edit', name: 'LeaveEdit', component: () => import('@/views/mobile/LeaveForm.vue') },
|
||||
],
|
||||
},
|
||||
// Desktop routes (director/leader-facing)
|
||||
@@ -38,12 +63,15 @@ const router = createRouter({
|
||||
children: [
|
||||
{ path: '', name: 'Dashboard', component: () => import('@/views/desktop/Dashboard.vue') },
|
||||
{ path: 'weekly-report', name: 'WeeklyReport', component: () => import('@/views/desktop/WeeklyReport.vue') },
|
||||
{ path: 'light-board', name: 'LightBoard', component: () => import('@/views/desktop/LightBoard.vue') },
|
||||
{ path: 'work-plans', name: 'WorkPlans', component: () => import('@/views/desktop/WorkPlans.vue') },
|
||||
{ path: 'mini-business', name: 'MiniBusiness', component: () => import('@/views/desktop/MiniBusiness.vue') },
|
||||
{ path: 'key-visits', name: 'KeyVisits', component: () => import('@/views/desktop/KeyVisits.vue') },
|
||||
{ path: 'leaves', name: 'Leaves', component: () => import('@/views/desktop/Leaves.vue') },
|
||||
{ path: 'workspace', name: 'ManagerWorkspace', component: () => import('@/views/desktop/ManagerWorkspace.vue') },
|
||||
{ path: 'customers', name: 'CustomerManage', component: () => import('@/views/desktop/CustomerManage.vue') },
|
||||
{ path: 'users', name: 'UserManage', component: () => import('@/views/desktop/UserManage.vue') },
|
||||
{ path: 'audit-log', name: 'AuditLog', component: () => import('@/views/desktop/AuditLog.vue') },
|
||||
{ path: 'settings', name: 'Settings', component: () => import('@/views/desktop/Settings.vue') },
|
||||
],
|
||||
},
|
||||
@@ -52,6 +80,30 @@ const router = createRouter({
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
// Handle WeChat Work OAuth silent login callback: ?token=JWT
|
||||
// Only strip the token if it's a valid JWT — bind tokens (UUID hex) must
|
||||
// survive so pages like WecomBind can read them.
|
||||
const tokenParam = to.query.token as string
|
||||
if (tokenParam && tokenParam.split('.').length === 3) {
|
||||
// Save token to auth store (the JWT contains user_id, name, role, theme)
|
||||
try {
|
||||
const payload = JSON.parse(atob(tokenParam.split('.')[1]))
|
||||
auth.saveLogin({
|
||||
access_token: tokenParam,
|
||||
user_id: payload.user_id,
|
||||
name: payload.name,
|
||||
role: payload.role,
|
||||
theme: payload.theme,
|
||||
})
|
||||
// Remove token from URL only on successful JWT decode
|
||||
const cleanQuery = { ...to.query }
|
||||
delete cleanQuery.token
|
||||
next({ path: to.path, query: cleanQuery, replace: true })
|
||||
return
|
||||
} catch (_) { /* invalid JWT — leave token for page to handle */ }
|
||||
}
|
||||
|
||||
if (to.meta.public) {
|
||||
next()
|
||||
return
|
||||
@@ -60,6 +112,22 @@ router.beforeEach((to, _from, next) => {
|
||||
next('/login')
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-redirect on FIRST entry only (no previous route = fresh page load)
|
||||
// Don't interfere with manual navigation (user clicked switch button)
|
||||
const viewOverride = to.query.view as string
|
||||
if (!viewOverride && !_from.name) {
|
||||
const onMobile = isMobile()
|
||||
if (onMobile && to.path === '/') {
|
||||
next('/m')
|
||||
return
|
||||
}
|
||||
if (!onMobile && to.path.startsWith('/m')) {
|
||||
next('/')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user