Compare commits
151 Commits
| 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
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Deployment docs (contain credentials, not for git)
|
||||||
|
*部署交接文档.md
|
||||||
|
*部署文档.md
|
||||||
|
|
||||||
# Claude
|
# Claude
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
.claude/
|
.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 |
|
| 后端 | FastAPI + SQLAlchemy 2.0 (async) + asyncpg |
|
||||||
| 数据库 | PostgreSQL |
|
| 数据库 | PostgreSQL |
|
||||||
| 文件存储 | MinIO (预签名直传) |
|
| 文件存储 | MinIO(后端受控图片上传) |
|
||||||
| 认证 | Casdoor OIDC + JWT |
|
| 认证 | Casdoor OIDC + JWT |
|
||||||
| 前端 | Vue 3 + Vite + TypeScript + Element Plus |
|
| 前端 | Vue 3 + Vite + TypeScript + Element Plus |
|
||||||
| 状态管理 | Pinia |
|
| 状态管理 | Pinia |
|
||||||
@@ -98,14 +98,17 @@ npm run dev -- --host 0.0.0.0
|
|||||||
# 页面 → http://localhost:5173
|
# 页面 → http://localhost:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
### 一键部署
|
### 本地集成环境
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 先在 .env 中设置 POSTGRES_USER、POSTGRES_PASSWORD、MINIO_ROOT_USER、
|
||||||
|
# MINIO_ROOT_PASSWORD、SECRET_KEY 和固定版本的 MINIO_IMAGE
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
# 后端 :8000 + PostgreSQL :5432 + MinIO :9000/:9001
|
# 服务仅绑定到 127.0.0.1;前端需单独部署到 nginx,见 frontend/Dockerfile
|
||||||
# 前端需单独部署到 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=企迹-政企周报管理系统
|
APP_NAME=企迹-政企周报管理系统
|
||||||
DEBUG=true
|
ENVIRONMENT=development
|
||||||
SECRET_KEY=change-me-to-a-random-string-in-production
|
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 (已有基础设施,填写实际连接信息) ──
|
# ── 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 ──
|
||||||
JWT_ALGORITHM=HS256
|
JWT_ALGORITHM=HS256
|
||||||
@@ -25,10 +27,11 @@ CASDOOR_APPLICATION=qiji-weekly-report
|
|||||||
|
|
||||||
# ── MinIO (已有,填写实际部署地址和密钥) ──
|
# ── MinIO (已有,填写实际部署地址和密钥) ──
|
||||||
MINIO_ENDPOINT=localhost:9000
|
MINIO_ENDPOINT=localhost:9000
|
||||||
MINIO_ACCESS_KEY=minioadmin
|
MINIO_ACCESS_KEY=qiji-app
|
||||||
MINIO_SECRET_KEY=minioadmin
|
MINIO_SECRET_KEY=replace-with-a-strong-minio-secret
|
||||||
MINIO_BUCKET=qiji-photos
|
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
|
WECOM_CORP_ID=your-corp-id
|
||||||
@@ -36,6 +39,16 @@ WECOM_AGENT_ID=your-agent-id
|
|||||||
WECOM_SECRET=your-app-secret
|
WECOM_SECRET=your-app-secret
|
||||||
WECOM_TOKEN=your-token
|
WECOM_TOKEN=your-token
|
||||||
WECOM_ENCODING_AES_KEY=your-encoding-aes-key
|
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 (前端地址) ──
|
||||||
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
||||||
|
|||||||
+4
-1
@@ -1,5 +1,8 @@
|
|||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
@@ -9,4 +12,4 @@ COPY . .
|
|||||||
|
|
||||||
EXPOSE 8000
|
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 asyncio
|
||||||
|
import sys
|
||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
|
from pathlib import Path
|
||||||
from sqlalchemy import pool
|
from sqlalchemy import pool
|
||||||
from sqlalchemy.engine import Connection
|
from sqlalchemy.engine import Connection
|
||||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
from alembic import context
|
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.database import Base
|
||||||
from app.models import * # noqa: import all models
|
from app.models import * # noqa: import all models
|
||||||
from app.config import settings
|
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),
|
user_id=str(user.id),
|
||||||
name=user.name,
|
name=user.name,
|
||||||
role=user.role,
|
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),
|
user_id=str(user.id),
|
||||||
name=user.name,
|
name=user.name,
|
||||||
role=user.role,
|
role=user.role,
|
||||||
|
theme=user.theme or "editorial",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Not bound yet — return a redirect URL to Casdoor for binding
|
raise HTTPException(
|
||||||
casdoor_auth_url = (
|
status_code=403,
|
||||||
f"{settings.CASDOOR_ENDPOINT}/login/oauth/authorize"
|
detail="企业微信账号尚未绑定。请在企业微信内使用一次性绑定链接完成绑定。",
|
||||||
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}"
|
|
||||||
)
|
)
|
||||||
return {"need_bind": True, "casdoor_url": casdoor_auth_url, "wecom_userid": wecom_userid}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/bind-wecom")
|
@router.post("/bind-wecom")
|
||||||
async def bind_wecom(req: WecomBindRequest, db: AsyncSession = Depends(get_db)):
|
async def bind_wecom(req: WecomBindRequest, db: AsyncSession = Depends(get_db)):
|
||||||
"""Bind Casdoor account with WeChat Work userid after OIDC redirect."""
|
"""Deprecated unsafe binding flow. Use /wecom/bind-confirm with a one-time token."""
|
||||||
userinfo = await exchange_casdoor_code(req.casdoor_code)
|
raise HTTPException(
|
||||||
if not userinfo:
|
status_code=410,
|
||||||
raise HTTPException(status_code=400, detail="Failed to exchange casdoor code")
|
detail="该绑定接口已停用,请使用企业微信一次性绑定链接。",
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
|||||||
+256
-21
@@ -1,18 +1,26 @@
|
|||||||
import io
|
import io
|
||||||
|
import uuid as uuid_mod
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, or_
|
from sqlalchemy import select, or_, update
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
from pydantic import BaseModel
|
||||||
import openpyxl
|
import openpyxl
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.middleware.auth import get_current_user, require_director, require_any_role
|
from app.middleware.auth import get_current_user, require_director, require_any_role
|
||||||
from app.models.customer import Customer
|
from app.models.customer import Customer
|
||||||
from app.models.customer_contact import CustomerContact
|
from app.models.customer_contact import CustomerContact
|
||||||
from app.models.customer_assignment import CustomerAssignment
|
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.models.user import User
|
||||||
|
from app.utils.audit import log_audit
|
||||||
from app.schemas.customer import (
|
from app.schemas.customer import (
|
||||||
CustomerCreate, CustomerUpdate, CustomerOut, CustomerListOut, CustomerListResponse,
|
CustomerCreate, CustomerUpdate, CustomerOut, CustomerListOut, CustomerListResponse,
|
||||||
ContactCreate, ContactOut, AssignmentCreate, AssignmentOut, BatchAssignRequest,
|
ContactCreate, ContactOut, AssignmentCreate, AssignmentOut, BatchAssignRequest,
|
||||||
@@ -43,25 +51,19 @@ async def list_customers(
|
|||||||
industry: Optional[str] = Query(None),
|
industry: Optional[str] = Query(None),
|
||||||
service: Optional[str] = Query(None),
|
service: Optional[str] = Query(None),
|
||||||
manager_id: Optional[str] = Query(None),
|
manager_id: Optional[str] = Query(None),
|
||||||
|
customer_type: Optional[str] = Query(None),
|
||||||
page: int = Query(1, ge=1),
|
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),
|
current_user: dict = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
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
|
from sqlalchemy import func
|
||||||
|
|
||||||
base_query = select(Customer)
|
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:
|
if industry:
|
||||||
base_query = base_query.where(Customer.industry.ilike(f"%{industry}%"))
|
base_query = base_query.where(Customer.industry.ilike(f"%{industry}%"))
|
||||||
if service:
|
if service:
|
||||||
@@ -91,7 +93,7 @@ async def list_customers(
|
|||||||
|
|
||||||
# Paginate
|
# Paginate
|
||||||
offset = (page - 1) * page_size
|
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)
|
result = await db.execute(query)
|
||||||
items = result.scalars().all()
|
items = result.scalars().all()
|
||||||
|
|
||||||
@@ -101,10 +103,14 @@ async def list_customers(
|
|||||||
select(CustomerAssignment.customer_id, User.name)
|
select(CustomerAssignment.customer_id, User.name)
|
||||||
.join(User, CustomerAssignment.manager_id == User.id)
|
.join(User, CustomerAssignment.manager_id == User.id)
|
||||||
.where(CustomerAssignment.customer_id.in_([c.id for c in items]), CustomerAssignment.role == "primary")
|
.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:
|
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(
|
return CustomerListResponse(
|
||||||
items=items,
|
items=items,
|
||||||
@@ -401,6 +407,10 @@ async def create_customer(
|
|||||||
))
|
))
|
||||||
await db.commit()
|
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(
|
result = await db.execute(
|
||||||
select(Customer).where(Customer.id == customer.id).options(selectinload(Customer.contacts))
|
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)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
assignee_id = update_data.pop("assignee_id", None) # Handle separately
|
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():
|
for key, value in update_data.items():
|
||||||
setattr(customer, key, value)
|
setattr(customer, key, value)
|
||||||
|
|
||||||
@@ -432,17 +464,16 @@ async def update_customer(
|
|||||||
if assignee_id:
|
if assignee_id:
|
||||||
if current_user["role"] != "director":
|
if current_user["role"] != "director":
|
||||||
raise HTTPException(status_code=403, detail="Only director can change manager assignment")
|
raise HTTPException(status_code=403, detail="Only director can change manager assignment")
|
||||||
import uuid as uuid_mod
|
|
||||||
assign_result = await db.execute(
|
assign_result = await db.execute(
|
||||||
select(CustomerAssignment).where(
|
select(CustomerAssignment).where(
|
||||||
CustomerAssignment.customer_id == customer.id,
|
CustomerAssignment.customer_id == customer.id,
|
||||||
CustomerAssignment.role == "primary",
|
CustomerAssignment.role == "primary",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
existing = assign_result.scalar_one_or_none()
|
existing_a = assign_result.scalar_one_or_none()
|
||||||
if existing:
|
if existing_a:
|
||||||
existing.manager_id = assignee_id
|
existing_a.manager_id = assignee_id
|
||||||
existing.assigned_by = uuid_mod.UUID(current_user["user_id"])
|
existing_a.assigned_by = uuid_mod.UUID(current_user["user_id"])
|
||||||
else:
|
else:
|
||||||
db.add(CustomerAssignment(
|
db.add(CustomerAssignment(
|
||||||
customer_id=customer.id, manager_id=assignee_id,
|
customer_id=customer.id, manager_id=assignee_id,
|
||||||
@@ -451,6 +482,10 @@ async def update_customer(
|
|||||||
|
|
||||||
await db.commit()
|
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(
|
result = await db.execute(
|
||||||
select(Customer).where(Customer.id == customer.id).options(selectinload(Customer.contacts))
|
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()
|
customer = result.scalar_one_or_none()
|
||||||
if not customer:
|
if not customer:
|
||||||
raise HTTPException(status_code=404, detail="Customer not found")
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
cust_name = customer.name
|
||||||
await db.delete(customer)
|
await db.delete(customer)
|
||||||
await db.commit()
|
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"}
|
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)
|
@router.post("/{customer_id}/contacts", response_model=ContactOut)
|
||||||
async def add_contact(
|
async def add_contact(
|
||||||
@@ -530,3 +735,33 @@ async def assign_manager(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(assignment)
|
await db.refresh(assignment)
|
||||||
return 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.schemas.daily_note import DailyNoteCreate, DailyNoteUpdate, DailyNoteOut
|
||||||
from app.utils.timezone import today_cst, parse_date
|
from app.utils.timezone import today_cst, parse_date
|
||||||
from app.utils.edit_log import compute_diff, append_entry, init_entry
|
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"])
|
router = APIRouter(prefix="/daily-notes", tags=["DailyNotes"])
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ async def _enrich(note: DailyNote, db: AsyncSession) -> dict:
|
|||||||
async def list_notes(
|
async def list_notes(
|
||||||
date_from: Optional[str] = Query(None),
|
date_from: Optional[str] = Query(None),
|
||||||
date_to: Optional[str] = Query(None),
|
date_to: Optional[str] = Query(None),
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -40,6 +42,8 @@ async def list_notes(
|
|||||||
query = query.where(DailyNote.note_date >= parse_date(date_from))
|
query = query.where(DailyNote.note_date >= parse_date(date_from))
|
||||||
if date_to:
|
if date_to:
|
||||||
query = query.where(DailyNote.note_date <= parse_date(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)
|
query = query.order_by(DailyNote.note_date.desc(), DailyNote.created_at.desc()).limit(100)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
return [await _enrich(n, db) for n in result.scalars().all()]
|
return [await _enrich(n, db) for n in result.scalars().all()]
|
||||||
@@ -80,7 +84,7 @@ async def create_note(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
note = DailyNote(
|
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),
|
note_date=parse_date(data.note_date),
|
||||||
category=data.category,
|
category=data.category,
|
||||||
content=data.content,
|
content=data.content,
|
||||||
@@ -90,6 +94,9 @@ async def create_note(
|
|||||||
db.add(note)
|
db.add(note)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(note)
|
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)
|
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}
|
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)
|
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"]:
|
if "note_date" in update_data and update_data["note_date"]:
|
||||||
update_data["note_date"] = parse_date(update_data["note_date"])
|
update_data["note_date"] = parse_date(update_data["note_date"])
|
||||||
for k, v in update_data.items():
|
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))
|
append_entry(note, current_user["name"], changes, getattr(data, "edit_reason", None))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(note)
|
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)
|
return await _enrich(note, db)
|
||||||
|
|
||||||
|
|
||||||
@@ -133,6 +145,10 @@ async def delete_note(
|
|||||||
raise HTTPException(status_code=404, detail="Not found")
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
if current_user["role"] == "manager" and str(note.manager_id) != current_user["user_id"]:
|
if current_user["role"] == "manager" and str(note.manager_id) != current_user["user_id"]:
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
note_date = str(note.note_date)
|
||||||
await db.delete(note)
|
await db.delete(note)
|
||||||
await db.commit()
|
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"}
|
return {"detail": "deleted"}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.middleware.auth import get_current_user
|
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.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"])
|
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."""
|
"""Get dashboard card statistics. Pass reference_date (YYYY-MM-DD) for historical weeks."""
|
||||||
ref = date.fromisoformat(reference_date) if reference_date else None
|
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
|
return stats
|
||||||
|
|
||||||
|
|
||||||
@@ -51,3 +52,14 @@ async def weekly_report(
|
|||||||
filter_customer_id=uuid.UUID(customer_id) if customer_id else None,
|
filter_customer_id=uuid.UUID(customer_id) if customer_id else None,
|
||||||
reference_date=ref,
|
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")
|
@router.get("/template")
|
||||||
async def download_weekly_report_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 import Workbook
|
||||||
from openpyxl.styles import Font
|
from openpyxl.styles import Font
|
||||||
|
|
||||||
@@ -22,9 +22,9 @@ async def download_weekly_report_template():
|
|||||||
# Sheet 1: 每日拜访记录
|
# Sheet 1: 每日拜访记录
|
||||||
ws1 = wb.active
|
ws1 = wb.active
|
||||||
ws1.title = "每日拜访记录"
|
ws1.title = "每日拜访记录"
|
||||||
ws1.append(["客户单位", "拜访日期", "拜访方式", "时间范围", "拜访人姓名", "拜访人电话", "沟通内容", "客户需求", "同访人员", "客户经理"])
|
ws1.append(["客户单位", "拜访日期", "拜访方式", "时间范围", "拜访人姓名", "拜访人电话", "沟通内容", "客户需求", "相关人员", "客户经理"])
|
||||||
for c in ws1[1]: c.font = header_font
|
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
|
ws1.column_dimensions['A'].width = 20; ws1.column_dimensions['E'].width = 30; ws1.column_dimensions['F'].width = 20
|
||||||
|
|
||||||
# Sheet 2: 下周工作计划
|
# Sheet 2: 下周工作计划
|
||||||
@@ -48,6 +48,13 @@ async def download_weekly_report_template():
|
|||||||
ws4.append(["XX科技有限公司", "重要", "拜访技术负责人确认方案", "未开始", "2026-07-01", "韦柳柏", "王局长", "韦矍森"])
|
ws4.append(["XX科技有限公司", "重要", "拜访技术负责人确认方案", "未开始", "2026-07-01", "韦柳柏", "王局长", "韦矍森"])
|
||||||
ws4.column_dimensions['A'].width = 20; ws4.column_dimensions['C'].width = 30
|
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()
|
output = io.BytesIO()
|
||||||
wb.save(output)
|
wb.save(output)
|
||||||
output.seek(0)
|
output.seek(0)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import uuid
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, or_
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.middleware.auth import get_current_user, require_any_role
|
from app.middleware.auth import get_current_user, require_any_role
|
||||||
from app.models.key_visit import KeyVisit
|
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.models.user import User
|
||||||
from app.schemas.key_visit import KeyVisitCreate, KeyVisitUpdate, KeyVisitOut
|
from app.schemas.key_visit import KeyVisitCreate, KeyVisitUpdate, KeyVisitOut
|
||||||
from app.utils.edit_log import compute_diff, append_entry, init_entry
|
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"])
|
router = APIRouter(prefix="/key-visits", tags=["KeyVisits"])
|
||||||
|
|
||||||
@@ -36,6 +37,8 @@ async def _enrich(k: KeyVisit, db: AsyncSession) -> dict:
|
|||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def list_key_visits(
|
async def list_key_visits(
|
||||||
customer_id: 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),
|
current_user: dict = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
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"]))
|
query = query.where(KeyVisit.manager_id == uuid.UUID(current_user["user_id"]))
|
||||||
if customer_id:
|
if customer_id:
|
||||||
query = query.where(KeyVisit.customer_id == uuid.UUID(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)
|
query = query.order_by(KeyVisit.planned_date.desc()).limit(200)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
return [await _enrich(k, db) for k in result.scalars().all()]
|
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("/")
|
@router.post("/")
|
||||||
async def create_key_visit(
|
async def create_key_visit(
|
||||||
data: KeyVisitCreate,
|
data: KeyVisitCreate,
|
||||||
@@ -63,12 +87,27 @@ async def create_key_visit(
|
|||||||
planned_date=data.planned_date,
|
planned_date=data.planned_date,
|
||||||
planned_visitor=data.planned_visitor,
|
planned_visitor=data.planned_visitor,
|
||||||
visit_target=data.visit_target,
|
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"])
|
init_entry(k, current_user["name"])
|
||||||
db.add(k)
|
db.add(k)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(k)
|
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)
|
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}
|
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)
|
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():
|
for key, v in update_data.items():
|
||||||
setattr(k, key, v)
|
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}
|
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))
|
append_entry(k, current_user["name"], changes, getattr(data, "edit_reason", None))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(k)
|
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)
|
return await _enrich(k, db)
|
||||||
|
|
||||||
|
|
||||||
@@ -110,6 +157,11 @@ async def delete_key_visit(
|
|||||||
raise HTTPException(status_code=404, detail="Not found")
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
if current_user["role"] == "manager" and str(k.manager_id) != current_user["user_id"]:
|
if current_user["role"] == "manager" and str(k.manager_id) != current_user["user_id"]:
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
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.delete(k)
|
||||||
await db.commit()
|
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"}
|
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 typing import Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, or_
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.middleware.auth import get_current_user, require_any_role
|
from app.middleware.auth import get_current_user, require_any_role
|
||||||
from app.models.mini_business import MiniBusiness
|
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.models.user import User
|
||||||
from app.schemas.mini_business import MiniBusinessCreate, MiniBusinessUpdate, MiniBusinessOut
|
from app.schemas.mini_business import MiniBusinessCreate, MiniBusinessUpdate, MiniBusinessOut
|
||||||
from app.utils.edit_log import compute_diff, append_entry, init_entry
|
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"])
|
router = APIRouter(prefix="/mini-business", tags=["MiniBusiness"])
|
||||||
|
|
||||||
@@ -35,6 +36,8 @@ async def _enrich(m: MiniBusiness, db: AsyncSession) -> dict:
|
|||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def list_mini_business(
|
async def list_mini_business(
|
||||||
customer_id: 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),
|
current_user: dict = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
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"]))
|
query = query.where(MiniBusiness.manager_id == uuid.UUID(current_user["user_id"]))
|
||||||
if customer_id:
|
if customer_id:
|
||||||
query = query.where(MiniBusiness.customer_id == uuid.UUID(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)
|
query = query.order_by(MiniBusiness.expected_revenue_date.desc()).limit(200)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
return [await _enrich(m, db) for m in result.scalars().all()]
|
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("/")
|
@router.post("/")
|
||||||
async def create_mini_business(
|
async def create_mini_business(
|
||||||
data: MiniBusinessCreate,
|
data: MiniBusinessCreate,
|
||||||
@@ -60,13 +84,28 @@ async def create_mini_business(
|
|||||||
amount=data.amount,
|
amount=data.amount,
|
||||||
follow_up_detail=data.follow_up_detail,
|
follow_up_detail=data.follow_up_detail,
|
||||||
status=data.status,
|
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,
|
expected_revenue_date=data.expected_revenue_date,
|
||||||
)
|
)
|
||||||
init_entry(m, current_user["name"])
|
init_entry(m, current_user["name"])
|
||||||
db.add(m)
|
db.add(m)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(m)
|
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)
|
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}
|
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)
|
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():
|
for k, v in update_data.items():
|
||||||
setattr(m, k, v)
|
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}
|
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)
|
changes = compute_diff(old_snapshot, new_snapshot)
|
||||||
if changes:
|
if changes:
|
||||||
append_entry(m, current_user["name"], changes, getattr(data, "edit_reason", None))
|
append_entry(m, current_user["name"], changes, getattr(data, "edit_reason", None))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(m)
|
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)
|
return await _enrich(m, db)
|
||||||
|
|
||||||
|
|
||||||
@@ -108,6 +158,11 @@ async def delete_mini_business(
|
|||||||
raise HTTPException(status_code=404, detail="Not found")
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
if current_user["role"] == "manager" and str(m.manager_id) != current_user["user_id"]:
|
if current_user["role"] == "manager" and str(m.manager_id) != current_user["user_id"]:
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
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.delete(m)
|
||||||
await db.commit()
|
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"}
|
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
|
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.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"])
|
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(
|
def validate_image_upload(filename: str, content_type: str) -> str:
|
||||||
filename: str,
|
"""Validate image metadata before accepting bytes for server-side verification."""
|
||||||
content_type: str = "image/jpeg",
|
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),
|
current_user: dict = Depends(require_any_role),
|
||||||
):
|
):
|
||||||
"""Get a presigned PUT URL for direct MinIO upload."""
|
"""Validate and normalize an image before persisting it to private object storage."""
|
||||||
import datetime
|
validate_image_upload(file.filename or "upload.jpg", file.content_type or "")
|
||||||
today = datetime.date.today().isoformat()
|
normalized = normalize_image(await file.read(MAX_IMAGE_BYTES + 1))
|
||||||
user_id = current_user["user_id"][:8]
|
object_key = build_object_key(current_user["user_id"], "jpg")
|
||||||
object_key = f"{today}/{user_id}/{uuid.uuid4()}.jpg"
|
put_validated_image(object_key, normalized)
|
||||||
|
return {"object_key": object_key}
|
||||||
url = generate_presigned_upload_url(object_key)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"upload_url": url,
|
|
||||||
"object_key": object_key,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/download-url")
|
@router.get("/download-url")
|
||||||
async def get_presigned_download_url(
|
async def get_presigned_download_url(
|
||||||
object_key: str,
|
object_key: str,
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Get a presigned GET URL for viewing a photo (1 hour validity)."""
|
"""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)
|
url = generate_presigned_download_url(object_key)
|
||||||
return {"download_url": url}
|
return {"download_url": url}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from sqlalchemy import select
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.middleware.auth import get_current_user, require_director
|
from app.middleware.auth import get_current_user, require_director
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.utils.audit import log_audit
|
||||||
from app.schemas.customer import UserOut
|
from app.schemas.customer import UserOut
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["Users"])
|
router = APIRouter(prefix="/users", tags=["Users"])
|
||||||
@@ -15,6 +16,8 @@ router = APIRouter(prefix="/users", tags=["Users"])
|
|||||||
class UpdateUserRoleRequest(BaseModel):
|
class UpdateUserRoleRequest(BaseModel):
|
||||||
role: str # manager / director / leader
|
role: str # manager / director / leader
|
||||||
department: str = ""
|
department: str = ""
|
||||||
|
require_report: bool = True
|
||||||
|
color: Optional[str] = None # hex color for manager tags
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[UserOut])
|
@router.get("/", response_model=list[UserOut])
|
||||||
@@ -71,12 +74,105 @@ async def update_user_role(
|
|||||||
user.role = data.role
|
user.role = data.role
|
||||||
if data.department:
|
if data.department:
|
||||||
user.department = 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.commit()
|
||||||
await db.refresh(user)
|
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 {
|
return {
|
||||||
"id": str(user.id),
|
"id": str(user.id),
|
||||||
"name": user.name,
|
"name": user.name,
|
||||||
"role": user.role,
|
"role": user.role,
|
||||||
"department": user.department,
|
"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 typing import Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.database import get_db
|
||||||
from app.middleware.auth import get_current_user, require_any_role
|
from app.middleware.auth import get_current_user, require_any_role
|
||||||
from app.models.visit import Visit
|
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.schemas.visit import VisitCreate, VisitUpdate, VisitOut, VisitListOut
|
||||||
from app.utils.timezone import today_cst, parse_date
|
from app.utils.timezone import today_cst, parse_date
|
||||||
from app.services.minio_client import delete_objects
|
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.edit_log import compute_diff, append_entry, init_entry
|
||||||
|
from app.utils.audit import log_audit
|
||||||
|
|
||||||
router = APIRouter(prefix="/visits", tags=["Visits"])
|
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:
|
async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict:
|
||||||
"""Enrich a visit record with customer/manager names."""
|
"""Enrich a visit record with customer/manager names."""
|
||||||
customer_name = None
|
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))
|
mgr_result = await db.execute(select(User.name).where(User.id == visit.manager_id))
|
||||||
manager_name = mgr_result.scalar_one_or_none()
|
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 {
|
return {
|
||||||
"id": str(visit.id),
|
"id": str(visit.id),
|
||||||
"customer_id": str(visit.customer_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,
|
"communication_content": visit.communication_content,
|
||||||
"customer_demand": visit.customer_demand,
|
"customer_demand": visit.customer_demand,
|
||||||
"companions": visit.companions,
|
"companions": visit.companions,
|
||||||
|
"companion_names": visit.companion_names or [],
|
||||||
|
"companion_names_resolved": companion_names_resolved,
|
||||||
"photos": visit.photos,
|
"photos": visit.photos,
|
||||||
"manager_id": str(visit.manager_id),
|
"manager_id": str(visit.manager_id),
|
||||||
"manager_name": manager_name,
|
"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 [],
|
"edit_log": visit.edit_log or [],
|
||||||
"created_at": str(visit.created_at),
|
"created_at": str(visit.created_at),
|
||||||
"updated_at": str(visit.updated_at),
|
"updated_at": str(visit.updated_at),
|
||||||
@@ -54,6 +74,8 @@ async def list_visits(
|
|||||||
date_from: Optional[str] = Query(None),
|
date_from: Optional[str] = Query(None),
|
||||||
date_to: Optional[str] = Query(None),
|
date_to: Optional[str] = Query(None),
|
||||||
customer_id: 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),
|
current_user: dict = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -69,6 +91,12 @@ async def list_visits(
|
|||||||
query = query.where(Visit.visit_date <= parse_date(date_to))
|
query = query.where(Visit.visit_date <= parse_date(date_to))
|
||||||
if customer_id:
|
if customer_id:
|
||||||
query = query.where(Visit.customer_id == uuid.UUID(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)
|
query = query.order_by(Visit.visit_date.desc(), Visit.created_at.desc()).limit(200)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
@@ -128,7 +156,9 @@ async def create_visit(
|
|||||||
current_user: dict = Depends(require_any_role),
|
current_user: dict = Depends(require_any_role),
|
||||||
db: AsyncSession = Depends(get_db),
|
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(
|
visit = Visit(
|
||||||
customer_id=data.customer_id,
|
customer_id=data.customer_id,
|
||||||
visit_date=parse_date(data.visit_date),
|
visit_date=parse_date(data.visit_date),
|
||||||
@@ -137,28 +167,87 @@ async def create_visit(
|
|||||||
communication_content=data.communication_content,
|
communication_content=data.communication_content,
|
||||||
customer_demand=data.customer_demand,
|
customer_demand=data.customer_demand,
|
||||||
companions=data.companions,
|
companions=data.companions,
|
||||||
|
companion_names=data.companion_names,
|
||||||
photos=data.photos,
|
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"])
|
init_entry(visit, current_user["name"])
|
||||||
db.add(visit)
|
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:
|
for companion_id in data.companions:
|
||||||
if companion_id != uuid.UUID(current_user["user_id"]):
|
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(
|
draft = Visit(
|
||||||
customer_id=data.customer_id,
|
customer_id=data.customer_id,
|
||||||
visit_date=parse_date(data.visit_date),
|
visit_date=parse_date(data.visit_date),
|
||||||
visit_method=data.visit_method,
|
visit_method=data.visit_method,
|
||||||
time_range=data.time_range,
|
time_range=data.time_range,
|
||||||
communication_content="", # Leave blank for companion to fill
|
visitor_name=data.visitor_name,
|
||||||
customer_demand="",
|
visitor_phone=data.visitor_phone,
|
||||||
companions=[],
|
communication_content=(data.communication_content or "") + f"(协同{creator_name})",
|
||||||
photos=[],
|
customer_demand=data.customer_demand,
|
||||||
|
companions=comp_companions,
|
||||||
|
companion_names=[],
|
||||||
|
photos=data.photos,
|
||||||
manager_id=companion_id,
|
manager_id=companion_id,
|
||||||
|
visit_group_id=group_id,
|
||||||
)
|
)
|
||||||
|
init_entry(draft, current_user["name"])
|
||||||
db.add(draft)
|
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.commit()
|
||||||
await db.refresh(visit)
|
await db.refresh(visit)
|
||||||
return await _enrich_visit(visit, db)
|
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"]:
|
if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]:
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
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
|
# Snapshot old values for diff
|
||||||
old_snapshot = {
|
old_snapshot = {
|
||||||
"customer_id": str(visit.customer_id), "visit_date": str(visit.visit_date),
|
"customer_id": str(visit.customer_id), "visit_date": str(visit.visit_date),
|
||||||
"visit_method": visit.visit_method, "time_range": visit.time_range,
|
"visit_method": visit.visit_method, "time_range": visit.time_range,
|
||||||
"visitor_name": visit.visitor_name or "", "visitor_phone": visit.visitor_phone or "",
|
"visitor_name": visit.visitor_name or "", "visitor_phone": visit.visitor_phone or "",
|
||||||
"communication_content": visit.communication_content, "customer_demand": visit.customer_demand,
|
"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)
|
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"]:
|
if "visit_date" in update_data and update_data["visit_date"]:
|
||||||
update_data["visit_date"] = parse_date(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():
|
for key, value in update_data.items():
|
||||||
setattr(visit, key, value)
|
setattr(visit, key, value)
|
||||||
@@ -200,13 +297,39 @@ async def update_visit(
|
|||||||
"visit_method": visit.visit_method, "time_range": visit.time_range,
|
"visit_method": visit.visit_method, "time_range": visit.time_range,
|
||||||
"visitor_name": visit.visitor_name or "", "visitor_phone": visit.visitor_phone or "",
|
"visitor_name": visit.visitor_name or "", "visitor_phone": visit.visitor_phone or "",
|
||||||
"communication_content": visit.communication_content, "customer_demand": visit.customer_demand,
|
"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)
|
changes = compute_diff(old_snapshot, new_snapshot)
|
||||||
if changes:
|
if changes:
|
||||||
append_entry(visit, current_user["name"], changes, getattr(data, "edit_reason", None))
|
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.commit()
|
||||||
await db.refresh(visit)
|
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)
|
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"]:
|
if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]:
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
customer_id = visit.customer_id
|
||||||
|
manager_id = visit.manager_id
|
||||||
|
|
||||||
# Clean up photos in MinIO
|
# Clean up photos in MinIO
|
||||||
if visit.photos:
|
if visit.photos:
|
||||||
delete_objects(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)
|
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()
|
await db.commit()
|
||||||
return {"detail": "deleted"}
|
return {"detail": "deleted"}
|
||||||
|
|||||||
+304
-10
@@ -1,13 +1,22 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import struct
|
||||||
|
import base64
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
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 pydantic import BaseModel
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
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.middleware.auth import get_current_user, require_director
|
||||||
from app.models.user import User
|
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
|
from app.services.scheduler import check_daily_reporting
|
||||||
|
|
||||||
router = APIRouter(prefix="/wecom", tags=["WeChatWork"])
|
router = APIRouter(prefix="/wecom", tags=["WeChatWork"])
|
||||||
@@ -22,6 +31,163 @@ class AnnouncementRequest(BaseModel):
|
|||||||
content: str
|
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")
|
@router.post("/remind")
|
||||||
async def send_reminder(
|
async def send_reminder(
|
||||||
data: RemindRequest,
|
data: RemindRequest,
|
||||||
@@ -29,16 +195,23 @@ async def send_reminder(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Director manually sends reminder to specific managers."""
|
"""Director manually sends reminder to specific managers."""
|
||||||
# Get wecom_userids for the selected users
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(User.wecom_userid).where(User.id.in_([uuid.UUID(uid) for uid in data.user_ids]))
|
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]]
|
wecom_ids = [r[0] for r in result.all() if r[0]]
|
||||||
|
|
||||||
content = data.message or "📋 请及时完成今日拜访记录填报。"
|
content = data.message or "📋 请及时完成今日拜访记录填报。"
|
||||||
|
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)
|
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")
|
@router.post("/announcement")
|
||||||
@@ -47,15 +220,48 @@ async def send_announcement(
|
|||||||
current_user: dict = Depends(require_director),
|
current_user: dict = Depends(require_director),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Director sends an announcement to all team members."""
|
"""Director sends an announcement to all team members (markdown)."""
|
||||||
# Get all wecom_userids in the department
|
|
||||||
result = await db.execute(select(User.wecom_userid).where(User.wecom_userid.isnot(None)))
|
result = await db.execute(select(User.wecom_userid).where(User.wecom_userid.isnot(None)))
|
||||||
wecom_ids = [r[0] for r in result.all()]
|
wecom_ids = [r[0] for r in result.all()]
|
||||||
|
|
||||||
content = f"📢 支局长公告\n\n{data.content}"
|
markdown = f"## 📢 支局长公告\n\n{data.content}"
|
||||||
success = await wecom_client.send_text_message(wecom_ids, 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")
|
@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)."""
|
"""Manually trigger the daily reporting check (for testing or manual use)."""
|
||||||
result = await check_daily_reporting(db)
|
result = await check_daily_reporting(db)
|
||||||
return result
|
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 typing import Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, or_
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.middleware.auth import get_current_user, require_any_role
|
from app.middleware.auth import get_current_user, require_any_role
|
||||||
from app.utils.timezone import parse_date
|
from app.utils.timezone import parse_date
|
||||||
@@ -35,6 +35,8 @@ async def _enrich(wp: WorkPlan, db: AsyncSession) -> dict:
|
|||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def list_work_plans(
|
async def list_work_plans(
|
||||||
customer_id: 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),
|
current_user: dict = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
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"]))
|
query = query.where(WorkPlan.manager_id == uuid.UUID(current_user["user_id"]))
|
||||||
if customer_id:
|
if customer_id:
|
||||||
query = query.where(WorkPlan.customer_id == uuid.UUID(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)
|
query = query.order_by(WorkPlan.plan_date.desc()).limit(200)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
return [await _enrich(w, db) for w in result.scalars().all()]
|
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("/")
|
@router.post("/")
|
||||||
async def create_work_plan(
|
async def create_work_plan(
|
||||||
data: WorkPlanCreate,
|
data: WorkPlanCreate,
|
||||||
@@ -58,7 +81,7 @@ async def create_work_plan(
|
|||||||
customer_id=data.customer_id,
|
customer_id=data.customer_id,
|
||||||
plan_content=data.plan_content,
|
plan_content=data.plan_content,
|
||||||
plan_date=parse_date(data.plan_date),
|
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,
|
status=data.status,
|
||||||
)
|
)
|
||||||
init_entry(wp, current_user["name"])
|
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}
|
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)
|
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"]:
|
if "plan_date" in update_data and update_data["plan_date"]:
|
||||||
update_data["plan_date"] = parse_date(update_data["plan_date"])
|
update_data["plan_date"] = parse_date(update_data["plan_date"])
|
||||||
for k, v in update_data.items():
|
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
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||||
# App
|
# App
|
||||||
APP_NAME: str = "企迹-政企周报管理系统"
|
APP_NAME: str = "企迹-政企周报管理系统"
|
||||||
DEBUG: bool = True
|
ENVIRONMENT: str = "production"
|
||||||
SECRET_KEY: str = "change-me-in-production"
|
DEBUG: bool = False
|
||||||
|
SECRET_KEY: str = ""
|
||||||
|
|
||||||
# Database
|
# 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
|
||||||
JWT_ALGORITHM: str = "HS256"
|
JWT_ALGORITHM: str = "HS256"
|
||||||
@@ -24,11 +27,11 @@ class Settings(BaseSettings):
|
|||||||
CASDOOR_APPLICATION: str = "qiji-weekly-report"
|
CASDOOR_APPLICATION: str = "qiji-weekly-report"
|
||||||
|
|
||||||
# MinIO
|
# MinIO
|
||||||
MINIO_ENDPOINT: str = "localhost:9000"
|
MINIO_ENDPOINT: str = ""
|
||||||
MINIO_ACCESS_KEY: str = "minioadmin"
|
MINIO_ACCESS_KEY: str = ""
|
||||||
MINIO_SECRET_KEY: str = "minioadmin"
|
MINIO_SECRET_KEY: str = ""
|
||||||
MINIO_BUCKET: str = "qiji-photos"
|
MINIO_BUCKET: str = "qiji-photos"
|
||||||
MINIO_SECURE: bool = False
|
MINIO_SECURE: bool = True
|
||||||
|
|
||||||
# WeChat Work
|
# WeChat Work
|
||||||
WECOM_CORP_ID: str = ""
|
WECOM_CORP_ID: str = ""
|
||||||
@@ -36,13 +39,36 @@ class Settings(BaseSettings):
|
|||||||
WECOM_SECRET: str = ""
|
WECOM_SECRET: str = ""
|
||||||
WECOM_TOKEN: str = ""
|
WECOM_TOKEN: str = ""
|
||||||
WECOM_ENCODING_AES_KEY: 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
|
||||||
CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
||||||
|
|
||||||
class Config:
|
|
||||||
env_file = ".env"
|
|
||||||
env_file_encoding = "utf-8"
|
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
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 contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from app.config import settings
|
from sqlalchemy import select
|
||||||
from app.database import engine, Base
|
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 router as api_router
|
||||||
from app.api import auth, users, customers, visits, work_plans, mini_business, key_visits
|
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
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Startup: create tables if not exists (for dev convenience)
|
validate_security_settings()
|
||||||
async with engine.begin() as conn:
|
# Read notification_time from DB (or use default 17:30)
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
notification_hour, notification_minute = 17, 30
|
||||||
# Add columns that may be missing from older tables
|
async with async_session() as db:
|
||||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
row = await db.get(SystemConfig, "notification_time")
|
||||||
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS remarks TEXT DEFAULT ''"
|
if row and row.value:
|
||||||
))
|
try:
|
||||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
parts = row.value.strip().split(":")
|
||||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_name VARCHAR(50) DEFAULT ''"
|
notification_hour, notification_minute = int(parts[0]), int(parts[1])
|
||||||
))
|
except (ValueError, IndexError):
|
||||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
pass
|
||||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_phone VARCHAR(20) DEFAULT ''"
|
# Auto-refresh Chinese holiday data from API on startup
|
||||||
))
|
try:
|
||||||
# edit_log columns for change tracking
|
await refresh_holidays(db)
|
||||||
for tbl in ["visits", "daily_notes", "work_plans", "mini_business", "key_visits"]:
|
except Exception:
|
||||||
await conn.run_sync(lambda c, t=tbl: c.exec_driver_sql(
|
pass # Use cached data if API unavailable
|
||||||
f"ALTER TABLE {t} ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'"
|
|
||||||
))
|
# Start daily reporting scheduler
|
||||||
|
start_scheduler(notification_hour, notification_minute)
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown
|
# Shutdown
|
||||||
|
shutdown_scheduler()
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
@@ -55,13 +63,18 @@ app.include_router(customers.router, prefix="/api")
|
|||||||
app.include_router(visits.router, prefix="/api")
|
app.include_router(visits.router, prefix="/api")
|
||||||
app.include_router(work_plans.router, prefix="/api")
|
app.include_router(work_plans.router, prefix="/api")
|
||||||
app.include_router(mini_business.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(key_visits.router, prefix="/api")
|
||||||
app.include_router(dashboard.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(upload.router, prefix="/api")
|
||||||
app.include_router(export.router, prefix="/api")
|
app.include_router(export.router, prefix="/api")
|
||||||
app.include_router(import_data.router, prefix="/api")
|
app.include_router(import_data.router, prefix="/api")
|
||||||
app.include_router(wecom.router, prefix="/api")
|
app.include_router(wecom.router, prefix="/api")
|
||||||
app.include_router(daily_notes.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")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ from app.models.work_plan import WorkPlan
|
|||||||
from app.models.mini_business import MiniBusiness
|
from app.models.mini_business import MiniBusiness
|
||||||
from app.models.key_visit import KeyVisit
|
from app.models.key_visit import KeyVisit
|
||||||
from app.models.daily_note import DailyNote
|
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__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -16,6 +21,11 @@ __all__ = [
|
|||||||
"Visit",
|
"Visit",
|
||||||
"WorkPlan",
|
"WorkPlan",
|
||||||
"MiniBusiness",
|
"MiniBusiness",
|
||||||
|
"MiniBusinessLog",
|
||||||
"KeyVisit",
|
"KeyVisit",
|
||||||
"DailyNote",
|
"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
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
from sqlalchemy import String, Text, DateTime, func, ForeignKey
|
from sqlalchemy import String, Text, Date, DateTime, Boolean, func, ForeignKey
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from app.database import Base
|
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)
|
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)
|
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="")
|
industry: Mapped[str] = mapped_column(String(100), default="")
|
||||||
address: Mapped[str] = mapped_column(String(500), default="")
|
address: Mapped[str] = mapped_column(String(500), default="")
|
||||||
in_use_services: Mapped[str] = mapped_column(Text, 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_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())
|
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())
|
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")
|
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")
|
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
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
from sqlalchemy import String, Text, ForeignKey
|
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 sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.mini_business_log import MiniBusinessLog
|
||||||
|
|
||||||
|
|
||||||
class MiniBusiness(Base):
|
class MiniBusiness(Base):
|
||||||
__tablename__ = "mini_business"
|
__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)
|
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="")
|
expected_revenue_date: Mapped[str] = mapped_column(String(50), default="")
|
||||||
edit_log: Mapped[list] = mapped_column(JSONB, default=list)
|
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
|
role: Mapped[str] = mapped_column(String(30)) # manager / director / leader
|
||||||
department: Mapped[str] = mapped_column(String(100), default="")
|
department: Mapped[str] = mapped_column(String(100), default="")
|
||||||
wecom_userid: Mapped[str | None] = mapped_column(String(100), unique=True, nullable=True)
|
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())
|
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_name: Mapped[str] = mapped_column(String(50), default="")
|
||||||
visitor_phone: Mapped[str] = mapped_column(String(20), default="")
|
visitor_phone: Mapped[str] = mapped_column(String(20), default="")
|
||||||
companions: Mapped[list | None] = mapped_column(ARRAY(UUID(as_uuid=True)), nullable=True)
|
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)
|
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)
|
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)
|
edit_log: Mapped[list] = mapped_column(JSONB, default=list)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
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())
|
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
|
role: str
|
||||||
department: str
|
department: str
|
||||||
wecom_userid: Optional[str] = None
|
wecom_userid: Optional[str] = None
|
||||||
|
require_report: bool = True
|
||||||
|
color: Optional[str] = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -43,6 +45,7 @@ class ContactOut(BaseModel):
|
|||||||
# ── Customer ──
|
# ── Customer ──
|
||||||
class CustomerCreate(BaseModel):
|
class CustomerCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
customer_type: str = "unit"
|
||||||
industry: str = ""
|
industry: str = ""
|
||||||
address: str = ""
|
address: str = ""
|
||||||
in_use_services: str = ""
|
in_use_services: str = ""
|
||||||
@@ -54,6 +57,7 @@ class CustomerCreate(BaseModel):
|
|||||||
|
|
||||||
class CustomerUpdate(BaseModel):
|
class CustomerUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
|
customer_type: Optional[str] = None
|
||||||
industry: Optional[str] = None
|
industry: Optional[str] = None
|
||||||
address: Optional[str] = None
|
address: Optional[str] = None
|
||||||
in_use_services: Optional[str] = None
|
in_use_services: Optional[str] = None
|
||||||
@@ -65,6 +69,7 @@ class CustomerUpdate(BaseModel):
|
|||||||
class CustomerOut(BaseModel):
|
class CustomerOut(BaseModel):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
name: str
|
name: str
|
||||||
|
customer_type: str = "unit"
|
||||||
industry: str
|
industry: str
|
||||||
address: str
|
address: str
|
||||||
in_use_services: str
|
in_use_services: str
|
||||||
@@ -82,6 +87,7 @@ class CustomerOut(BaseModel):
|
|||||||
class CustomerListOut(BaseModel):
|
class CustomerListOut(BaseModel):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
name: str
|
name: str
|
||||||
|
customer_type: str = "unit"
|
||||||
industry: str
|
industry: str
|
||||||
in_use_services: str
|
in_use_services: str
|
||||||
primary_manager_name: Optional[str] = None
|
primary_manager_name: Optional[str] = None
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class DailyNoteCreate(BaseModel):
|
|||||||
category: str = "其他"
|
category: str = "其他"
|
||||||
content: str = ""
|
content: str = ""
|
||||||
time_range: str = ""
|
time_range: str = ""
|
||||||
|
manager_id: Optional[uuid.UUID] = None
|
||||||
|
|
||||||
|
|
||||||
class DailyNoteUpdate(BaseModel):
|
class DailyNoteUpdate(BaseModel):
|
||||||
@@ -16,6 +17,7 @@ class DailyNoteUpdate(BaseModel):
|
|||||||
category: Optional[str] = None
|
category: Optional[str] = None
|
||||||
content: Optional[str] = None
|
content: Optional[str] = None
|
||||||
time_range: Optional[str] = None
|
time_range: Optional[str] = None
|
||||||
|
manager_id: Optional[uuid.UUID] = None
|
||||||
|
|
||||||
|
|
||||||
class DailyNoteOut(BaseModel):
|
class DailyNoteOut(BaseModel):
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class KeyVisitCreate(BaseModel):
|
|||||||
planned_date: str = ""
|
planned_date: str = ""
|
||||||
planned_visitor: str = ""
|
planned_visitor: str = ""
|
||||||
visit_target: str = ""
|
visit_target: str = ""
|
||||||
|
manager_id: Optional[uuid.UUID] = None
|
||||||
|
|
||||||
|
|
||||||
class KeyVisitUpdate(BaseModel):
|
class KeyVisitUpdate(BaseModel):
|
||||||
@@ -21,6 +22,7 @@ class KeyVisitUpdate(BaseModel):
|
|||||||
planned_date: Optional[str] = None
|
planned_date: Optional[str] = None
|
||||||
planned_visitor: Optional[str] = None
|
planned_visitor: Optional[str] = None
|
||||||
visit_target: Optional[str] = None
|
visit_target: Optional[str] = None
|
||||||
|
manager_id: Optional[uuid.UUID] = None
|
||||||
|
|
||||||
|
|
||||||
class KeyVisitOut(BaseModel):
|
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 = ""
|
follow_up_detail: str = ""
|
||||||
status: str = "跟进中"
|
status: str = "跟进中"
|
||||||
expected_revenue_date: str = ""
|
expected_revenue_date: str = ""
|
||||||
|
manager_id: Optional[uuid.UUID] = None
|
||||||
|
|
||||||
|
|
||||||
class MiniBusinessUpdate(BaseModel):
|
class MiniBusinessUpdate(BaseModel):
|
||||||
@@ -19,6 +20,7 @@ class MiniBusinessUpdate(BaseModel):
|
|||||||
follow_up_detail: Optional[str] = None
|
follow_up_detail: Optional[str] = None
|
||||||
status: Optional[str] = None
|
status: Optional[str] = None
|
||||||
expected_revenue_date: Optional[str] = None
|
expected_revenue_date: Optional[str] = None
|
||||||
|
manager_id: Optional[uuid.UUID] = None
|
||||||
|
|
||||||
|
|
||||||
class MiniBusinessOut(BaseModel):
|
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
|
user_id: str
|
||||||
name: str
|
name: str
|
||||||
role: str
|
role: str
|
||||||
|
theme: str = "editorial"
|
||||||
|
|
||||||
|
|
||||||
class WecomLoginRequest(BaseModel):
|
class WecomLoginRequest(BaseModel):
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ class VisitCreate(BaseModel):
|
|||||||
communication_content: str = ""
|
communication_content: str = ""
|
||||||
customer_demand: str = ""
|
customer_demand: str = ""
|
||||||
companions: list[uuid.UUID] = []
|
companions: list[uuid.UUID] = []
|
||||||
|
companion_names: list[str] = []
|
||||||
photos: list[str] = []
|
photos: list[str] = []
|
||||||
|
manager_id: Optional[uuid.UUID] = None # director/leader can specify
|
||||||
|
|
||||||
|
|
||||||
class VisitUpdate(BaseModel):
|
class VisitUpdate(BaseModel):
|
||||||
@@ -27,7 +29,10 @@ class VisitUpdate(BaseModel):
|
|||||||
communication_content: Optional[str] = None
|
communication_content: Optional[str] = None
|
||||||
customer_demand: Optional[str] = None
|
customer_demand: Optional[str] = None
|
||||||
companions: Optional[list[uuid.UUID]] = None
|
companions: Optional[list[uuid.UUID]] = None
|
||||||
|
manager_id: Optional[uuid.UUID] = None
|
||||||
|
companion_names: Optional[list[str]] = None
|
||||||
photos: Optional[list[str]] = None
|
photos: Optional[list[str]] = None
|
||||||
|
manager_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class VisitOut(BaseModel):
|
class VisitOut(BaseModel):
|
||||||
@@ -41,8 +46,10 @@ class VisitOut(BaseModel):
|
|||||||
communication_content: str
|
communication_content: str
|
||||||
customer_demand: str
|
customer_demand: str
|
||||||
companions: Optional[list[uuid.UUID]] = None
|
companions: Optional[list[uuid.UUID]] = None
|
||||||
|
companion_names: list[str] = []
|
||||||
photos: Optional[list[str]] = None
|
photos: Optional[list[str]] = None
|
||||||
manager_id: uuid.UUID
|
manager_id: uuid.UUID
|
||||||
|
visit_group_id: Optional[str] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
# Joined fields
|
# Joined fields
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class WorkPlanCreate(BaseModel):
|
|||||||
plan_content: str = ""
|
plan_content: str = ""
|
||||||
plan_date: str # "YYYY-MM-DD"
|
plan_date: str # "YYYY-MM-DD"
|
||||||
status: str = "计划中"
|
status: str = "计划中"
|
||||||
|
manager_id: Optional[uuid.UUID] = None
|
||||||
|
|
||||||
|
|
||||||
class WorkPlanUpdate(BaseModel):
|
class WorkPlanUpdate(BaseModel):
|
||||||
@@ -16,6 +17,7 @@ class WorkPlanUpdate(BaseModel):
|
|||||||
plan_content: Optional[str] = None
|
plan_content: Optional[str] = None
|
||||||
plan_date: Optional[str] = None
|
plan_date: Optional[str] = None
|
||||||
status: Optional[str] = None
|
status: Optional[str] = None
|
||||||
|
manager_id: Optional[uuid.UUID] = None
|
||||||
|
|
||||||
|
|
||||||
class WorkPlanOut(BaseModel):
|
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,
|
"code": code,
|
||||||
}, timeout=10)
|
}, timeout=10)
|
||||||
if resp.status_code != 200:
|
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
|
return None
|
||||||
token_data = resp.json()
|
token_data = resp.json()
|
||||||
access_token = token_data.get("access_token", "")
|
access_token = token_data.get("access_token", "")
|
||||||
if not 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
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Casdoor] token exchange exception: {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}"
|
"Authorization": f"Bearer {access_token}"
|
||||||
}, timeout=10)
|
}, timeout=10)
|
||||||
if resp.status_code != 200:
|
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 None
|
||||||
return resp.json()
|
return resp.json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -103,4 +103,5 @@ def build_token_for_user(user: User) -> str:
|
|||||||
"name": user.name,
|
"name": user.name,
|
||||||
"role": user.role,
|
"role": user.role,
|
||||||
"department": user.department,
|
"department": user.department,
|
||||||
|
"theme": user.theme or "editorial",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func, and_
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
from app.models.customer import Customer
|
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.mini_business import MiniBusiness
|
||||||
from app.models.key_visit import KeyVisit
|
from app.models.key_visit import KeyVisit
|
||||||
from app.models.daily_note import DailyNote
|
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):
|
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
|
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)."""
|
"""Get dashboard statistics for a given week (defaults to current)."""
|
||||||
monday, sunday = get_week_range(reference_date)
|
monday, sunday = get_week_range(reference_date)
|
||||||
today = date.today()
|
today = date.today()
|
||||||
|
|
||||||
visits_count = (await db.execute(
|
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
|
)).scalar() or 0
|
||||||
|
|
||||||
plans_count = (await db.execute(
|
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))
|
select(func.count(KeyVisit.id))
|
||||||
)).scalar() or 0
|
)).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 {
|
return {
|
||||||
"week_visits": visits_count,
|
"week_visits": visits_count,
|
||||||
"work_plans": plans_count,
|
"work_plans": plans_count,
|
||||||
"mini_business": mini_biz_count,
|
"mini_business": mini_biz_count,
|
||||||
"key_visits": key_visit_count,
|
"key_visits": key_visit_count,
|
||||||
|
"week_leaves": leaves_count,
|
||||||
|
"overdue_plans": overdue_plans,
|
||||||
"week_start": str(monday),
|
"week_start": str(monday),
|
||||||
"week_end": str(sunday),
|
"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."""
|
"""Get per-manager reporting progress. Managers only see themselves."""
|
||||||
monday, sunday = get_week_range(reference_date)
|
monday, sunday = get_week_range(reference_date)
|
||||||
|
|
||||||
managers_result = await db.execute(select(User).where(User.role == "manager"))
|
# Get all users who need to report (any role, filtered by require_report)
|
||||||
all_managers = managers_result.scalars().all()
|
managers_result = await db.execute(select(User).where(User.require_report == True))
|
||||||
|
all_reporters = managers_result.scalars().all()
|
||||||
# Filter: managers only see themselves
|
# 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
|
# Get visit counts per manager this week
|
||||||
visits_result = await db.execute(
|
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()}
|
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 = []
|
progress = []
|
||||||
for m in managers:
|
for m in reporters:
|
||||||
count = visit_map.get(str(m.id), 0)
|
count = visit_map.get(str(m.id), 0)
|
||||||
# Calculate expected working days (Mon-Fri)
|
# Calculate expected working days (Mon-Fri)
|
||||||
days_passed = min((date.today() - monday).days + 1, 5)
|
days_passed = min((date.today() - monday).days + 1, 5)
|
||||||
expected = days_passed # At least 1 visit per working day
|
expected = days_passed # At least 1 visit per working day
|
||||||
|
|
||||||
|
mid = str(m.id)
|
||||||
|
leave = on_leave_today.get(mid)
|
||||||
|
|
||||||
progress.append({
|
progress.append({
|
||||||
"manager_id": str(m.id),
|
"manager_id": mid,
|
||||||
"manager_name": m.name,
|
"manager_name": m.name,
|
||||||
"department": m.department,
|
"department": m.department,
|
||||||
"visit_count": count,
|
"visit_count": count,
|
||||||
"expected": expected,
|
"expected": expected,
|
||||||
"completed": count >= expected,
|
"completed": count >= expected,
|
||||||
"has_reported_today": False, # Will be set below
|
"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
|
# Check today's reporting — visits (direct + companions) OR daily notes
|
||||||
today = today_cst()
|
from sqlalchemy import union_all
|
||||||
today_visits = await db.execute(
|
today_direct = select(Visit.manager_id).where(Visit.visit_date == today)
|
||||||
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(
|
today_notes = await db.execute(
|
||||||
select(DailyNote.manager_id).where(DailyNote.note_date == today)
|
select(DailyNote.manager_id).where(DailyNote.note_date == today)
|
||||||
)
|
)
|
||||||
@@ -126,32 +177,75 @@ async def get_weekly_report(
|
|||||||
# ── Visits ──
|
# ── Visits ──
|
||||||
visit_query = select(Visit).where(Visit.visit_date >= monday, Visit.visit_date <= sunday)
|
visit_query = select(Visit).where(Visit.visit_date >= monday, Visit.visit_date <= sunday)
|
||||||
if role == "manager":
|
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:
|
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:
|
if filter_customer_id:
|
||||||
visit_query = visit_query.where(Visit.customer_id == filter_customer_id)
|
visit_query = visit_query.where(Visit.customer_id == filter_customer_id)
|
||||||
visit_query = visit_query.order_by(Visit.visit_date.desc())
|
visit_query = visit_query.order_by(Visit.visit_date.desc())
|
||||||
visits_result = await db.execute(visit_query)
|
visits_result = await db.execute(visit_query)
|
||||||
visits = visits_result.scalars().all()
|
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:
|
for v in visits:
|
||||||
visits_data.append({
|
gid = str(v.visit_group_id) if v.visit_group_id else str(v.id)
|
||||||
"id": str(v.id),
|
groups.setdefault(gid, []).append(v)
|
||||||
"customer_id": str(v.customer_id),
|
|
||||||
"customer_name": customer_map.get(v.customer_id, ""),
|
visits_data = []
|
||||||
"visit_date": str(v.visit_date),
|
for gid, gvisits in groups.items():
|
||||||
"visit_method": v.visit_method,
|
# Determine the "primary" — the creator's record (not a companion copy)
|
||||||
"time_range": v.time_range,
|
def _is_companion_copy(v) -> bool:
|
||||||
"communication_content": v.communication_content,
|
content = (v.communication_content or "").strip()
|
||||||
"customer_demand": v.customer_demand,
|
# New-style: has "(协同XXX)" suffix
|
||||||
"companions": [str(c) for c in (v.companions or [])],
|
if content.endswith(")") and "(协同" in content:
|
||||||
"photos": v.photos or [],
|
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_id": str(v.manager_id),
|
||||||
"manager_name": user_map.get(v.manager_id, ""),
|
"manager_name": user_map.get(v.manager_id, ""),
|
||||||
"created_at": str(v.created_at),
|
"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 ──
|
# ── Work Plans ──
|
||||||
wp_query = select(WorkPlan)
|
wp_query = select(WorkPlan)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from app.models.visit import Visit
|
|||||||
from app.models.work_plan import WorkPlan
|
from app.models.work_plan import WorkPlan
|
||||||
from app.models.mini_business import MiniBusiness
|
from app.models.mini_business import MiniBusiness
|
||||||
from app.models.key_visit import KeyVisit
|
from app.models.key_visit import KeyVisit
|
||||||
|
from app.models.daily_note import DailyNote
|
||||||
from app.models.customer import Customer
|
from app.models.customer import Customer
|
||||||
from app.models.user import User
|
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:
|
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)
|
monday, sunday = get_week_range(reference_date)
|
||||||
wb = Workbook()
|
wb = Workbook()
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ async def export_weekly_report(db: AsyncSession, reference_date: date | None = N
|
|||||||
# ── Sheet 1: 每日拜访记录 ──
|
# ── Sheet 1: 每日拜访记录 ──
|
||||||
ws1 = wb.active
|
ws1 = wb.active
|
||||||
ws1.title = "每日拜访记录"
|
ws1.title = "每日拜访记录"
|
||||||
headers1 = ["客户单位", "拜访日期", "拜访方式", "时间范围", "拜访人姓名", "拜访人电话", "沟通内容", "客户需求", "同访人员", "客户经理"]
|
headers1 = ["客户单位", "拜访日期", "拜访方式", "时间范围", "拜访人姓名", "拜访人电话", "沟通内容", "客户需求", "相关人员", "客户经理"]
|
||||||
ws1.append(headers1)
|
ws1.append(headers1)
|
||||||
for col in range(1, len(headers1) + 1):
|
for col in range(1, len(headers1) + 1):
|
||||||
cell = ws1.cell(row=1, column=col)
|
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():
|
for v in visits_result.scalars():
|
||||||
companions_names = [user_map.get(str(cid), str(cid)) for cid in (v.companions or [])]
|
companions_names = [user_map.get(str(cid), str(cid)) for cid in (v.companions or [])]
|
||||||
|
companions_names.extend(v.companion_names or [])
|
||||||
ws1.append([
|
ws1.append([
|
||||||
customer_map.get(str(v.customer_id), ""),
|
customer_map.get(str(v.customer_id), ""),
|
||||||
str(v.visit_date),
|
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), ""),
|
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
|
# Adjust column widths
|
||||||
for ws in [ws1, ws2, ws3, ws4]:
|
for ws in [ws1, ws2, ws3, ws4, ws5]:
|
||||||
for col_cells in ws.columns:
|
for col_cells in ws.columns:
|
||||||
max_length = max((len(str(cell.value or "")) for cell in col_cells), default=10)
|
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)
|
ws.column_dimensions[col_cells[0].column_letter].width = min(max_length + 4, 50)
|
||||||
|
|||||||
@@ -1,26 +1,142 @@
|
|||||||
|
import io
|
||||||
import uuid
|
import uuid
|
||||||
|
import re
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, update
|
||||||
import openpyxl
|
|
||||||
from app.models.customer import Customer
|
from app.models.customer import Customer
|
||||||
|
from app.models.customer_assignment import CustomerAssignment
|
||||||
from app.models.visit import Visit
|
from app.models.visit import Visit
|
||||||
from app.models.work_plan import WorkPlan
|
from app.models.work_plan import WorkPlan
|
||||||
from app.models.mini_business import MiniBusiness
|
from app.models.mini_business import MiniBusiness
|
||||||
from app.models.key_visit import KeyVisit
|
from app.models.key_visit import KeyVisit
|
||||||
|
from app.models.daily_note import DailyNote
|
||||||
from app.models.user import User
|
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:
|
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."""
|
"""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": []}
|
|
||||||
|
|
||||||
# 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))
|
customers_result = await db.execute(select(Customer.id, Customer.name))
|
||||||
customer_map = {c.name: c.id for c in customers_result.all()}
|
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: 每日拜访记录 ──
|
# ── Parse Sheet 1: 每日拜访记录 ──
|
||||||
if "每日拜访记录" in wb.sheetnames:
|
if "每日拜访记录" in wb.sheetnames:
|
||||||
ws = wb["每日拜访记录"]
|
ws = wb["每日拜访记录"]
|
||||||
@@ -28,28 +144,36 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui
|
|||||||
if not row[0]:
|
if not row[0]:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
cust_name, visit_date_str, visit_method, time_range, visitor_name, visitor_phone, content, demand, companions_str, mgr_str = \
|
cust_name, visit_date_str, visit_method, time_range, visitor_name, visitor_phone, \
|
||||||
row[0], str(row[1]) if row[1] else str(date.today()), \
|
content, demand, companions_str, mgr_str = (
|
||||||
str(row[2]) if row[2] else "上门", str(row[3]) if row[3] else "", \
|
row[0], str(row[1]) if row[1] else str(date.today()),
|
||||||
str(row[4]) if row[4] else "", str(row[5]) if row[5] else "", \
|
str(row[2]) if row[2] else "上门", str(row[3]) if row[3] else "",
|
||||||
str(row[6]) if row[6] else "", str(row[7]) if row[7] else "", \
|
str(row[4]) if row[4] else "", str(row[5]) if row[5] else "",
|
||||||
str(row[8]) if row[8] else "", str(row[9]) if row[9] 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:
|
if not customer_id:
|
||||||
stats["skipped"] += 1
|
stats["skipped"] += 1
|
||||||
stats["skip_reasons"].append(f"客户「{cust_name}」不存在,跳过")
|
stats["skip_reasons"].append(f"客户名称为空,跳过")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Parse visit date
|
||||||
try:
|
try:
|
||||||
visit_date = datetime.strptime(visit_date_str[:10], "%Y-%m-%d").date()
|
visit_date = datetime.strptime(visit_date_str[:10], "%Y-%m-%d").date()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
visit_date = date.today()
|
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(
|
existing = await db.execute(
|
||||||
select(Visit).where(
|
select(Visit).where(
|
||||||
Visit.visit_date == visit_date,
|
Visit.visit_date == visit_date,
|
||||||
Visit.manager_id == manager_id,
|
Visit.manager_id == visit_manager_id,
|
||||||
Visit.customer_id == customer_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} 已存在")
|
stats["skip_reasons"].append(f"重复:{cust_name} {visit_date} 已存在")
|
||||||
continue
|
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(
|
visit = Visit(
|
||||||
customer_id=customer_id,
|
customer_id=customer_id,
|
||||||
visit_date=visit_date,
|
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,
|
visitor_phone=visitor_phone,
|
||||||
communication_content=content,
|
communication_content=content,
|
||||||
customer_demand=demand,
|
customer_demand=demand,
|
||||||
manager_id=manager_id,
|
companions=sys_companions,
|
||||||
|
companion_names=ext_names,
|
||||||
|
manager_id=visit_manager_id,
|
||||||
)
|
)
|
||||||
db.add(visit)
|
db.add(visit)
|
||||||
stats["visits"] += 1
|
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:
|
except Exception:
|
||||||
stats["skipped"] += 1
|
stats["skipped"] += 1
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return stats
|
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 datetime import timedelta
|
||||||
|
from io import BytesIO
|
||||||
from minio import Minio
|
from minio import Minio
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
@@ -20,10 +21,16 @@ def get_minio_client() -> Minio:
|
|||||||
return _client
|
return _client
|
||||||
|
|
||||||
|
|
||||||
def generate_presigned_upload_url(object_key: str, expires: int = 600) -> str:
|
def put_validated_image(object_key: str, image_bytes: bytes) -> None:
|
||||||
"""Generate a presigned PUT URL for direct upload to MinIO."""
|
"""Store a normalized image only after the API has validated its content."""
|
||||||
client = get_minio_client()
|
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:
|
def generate_presigned_download_url(object_key: str, expires: int = 3600) -> str:
|
||||||
|
|||||||
@@ -1,24 +1,42 @@
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func, and_
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.models.visit import Visit
|
from app.models.visit import Visit
|
||||||
from app.models.daily_note import DailyNote
|
from app.models.daily_note import DailyNote
|
||||||
from app.models.user import User
|
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.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:
|
async def check_daily_reporting(db: AsyncSession) -> dict:
|
||||||
"""Check today's reporting progress and send wecom reminders to managers who haven't reported."""
|
"""Check today's reporting progress and send wecom reminders to managers who haven't reported."""
|
||||||
today = today_cst()
|
today = today_cst()
|
||||||
weekday = today.weekday()
|
|
||||||
if weekday >= 5: # Skip weekends
|
|
||||||
return {"status": "weekend", "date": str(today)}
|
|
||||||
|
|
||||||
# Get all managers
|
# Load holiday data (auto-refreshes from API if not cached)
|
||||||
result = await db.execute(select(User).where(User.role == "manager"))
|
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()
|
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
|
# Get managers who have reported today
|
||||||
reported_visits = await db.execute(
|
reported_visits = await db.execute(
|
||||||
select(Visit.manager_id).where(Visit.visit_date == today)
|
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
|
reported_map[str(uid)] = True
|
||||||
|
|
||||||
not_reported = []
|
not_reported = []
|
||||||
|
reported_names = []
|
||||||
|
on_leave_names = []
|
||||||
for m in managers:
|
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)
|
not_reported.append(m)
|
||||||
|
|
||||||
if not_reported and managers:
|
# 1. Send template card to not-reported managers (tap to open app)
|
||||||
content = f"📋 今日填报提醒({today})\n\n以下同事尚未提交今日拜访记录:\n"
|
if not_reported:
|
||||||
for m in not_reported:
|
|
||||||
content += f"• {m.name}\n"
|
|
||||||
content += "\n请尽快完成今日拜访填报 🙏"
|
|
||||||
|
|
||||||
user_ids = [m.wecom_userid for m in not_reported if m.wecom_userid]
|
user_ids = [m.wecom_userid for m in not_reported if m.wecom_userid]
|
||||||
if user_ids:
|
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 {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
@@ -52,3 +115,86 @@ async def check_daily_reporting(db: AsyncSession) -> dict:
|
|||||||
"reported": len(reported_map),
|
"reported": len(reported_map),
|
||||||
"not_reported": len(not_reported),
|
"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
|
||||||
@@ -1,55 +1,254 @@
|
|||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
import httpx
|
import httpx
|
||||||
|
from sqlalchemy import delete, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.config import settings
|
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:
|
class WecomClient:
|
||||||
"""Minimal WeChat Work API client for sending app messages."""
|
"""WeChat Work API client — token management, message sending, OAuth."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.corp_id = settings.WECOM_CORP_ID
|
self.corp_id = settings.WECOM_CORP_ID
|
||||||
self.agent_id = settings.WECOM_AGENT_ID
|
self.agent_id = settings.WECOM_AGENT_ID
|
||||||
self.secret = settings.WECOM_SECRET
|
self.secret = settings.WECOM_SECRET
|
||||||
self._access_token: str | None = None
|
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:
|
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
|
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:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.get(url, timeout=10)
|
resp = await client.get(url, timeout=10)
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
if data.get("errcode") == 0:
|
if data.get("errcode") == 0:
|
||||||
self._access_token = data["access_token"]
|
self._access_token = data["access_token"]
|
||||||
|
self._token_expires_at = time.time() + data.get("expires_in", 7200)
|
||||||
return self._access_token
|
return self._access_token
|
||||||
raise Exception(f"Failed to get wecom token: {data}")
|
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:
|
async def get_userinfo_by_code(self, code: str) -> dict | None:
|
||||||
"""Exchange OAuth2 code for userid (used in silent login)."""
|
"""Exchange OAuth2 code for userid (used in silent login)."""
|
||||||
|
try:
|
||||||
token = await self._get_token()
|
token = await self._get_token()
|
||||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_token={token}&code={code}"
|
url = f"{settings.WECOM_API_BASE}/cgi-bin/auth/getuserinfo?access_token={token}&code={code}"
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.get(url, timeout=10)
|
resp = await client.get(url, timeout=10)
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
if data.get("errcode") == 0:
|
if data.get("errcode") == 0:
|
||||||
return data
|
return data
|
||||||
return None
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── Messaging ──
|
||||||
|
|
||||||
async def send_text_message(self, user_ids: list[str], content: str) -> bool:
|
async def send_text_message(self, user_ids: list[str], content: str) -> bool:
|
||||||
"""Send a text app message to specified users."""
|
"""Send a text app message to specified users."""
|
||||||
if not settings.WECOM_AGENT_ID:
|
if not settings.WECOM_AGENT_ID:
|
||||||
return False # Not configured, skip silently in dev
|
return False
|
||||||
token = await self._get_token()
|
try:
|
||||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}"
|
|
||||||
body = {
|
body = {
|
||||||
"touser": "|".join(user_ids),
|
"touser": "|".join(user_ids),
|
||||||
"msgtype": "text",
|
"msgtype": "text",
|
||||||
"agentid": int(settings.WECOM_AGENT_ID),
|
"agentid": int(settings.WECOM_AGENT_ID),
|
||||||
"text": {"content": content},
|
"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:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.post(url, json=body, timeout=10)
|
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()
|
data = resp.json()
|
||||||
return data.get("errcode") == 0
|
return data.get("errcode") == 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
wecom_client = WecomClient()
|
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 datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from jose import jwt, JWTError
|
import jwt
|
||||||
|
from jwt import InvalidTokenError
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
@@ -15,5 +16,5 @@ def decode_token(token: str) -> Optional[dict]:
|
|||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
||||||
return payload
|
return payload
|
||||||
except JWTError:
|
except InvalidTokenError:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -11,3 +11,32 @@ def today_cst() -> date:
|
|||||||
def parse_date(s: str) -> date:
|
def parse_date(s: str) -> date:
|
||||||
"""Parse a date string that may be YYYY-MM-DD or a full ISO datetime."""
|
"""Parse a date string that may be YYYY-MM-DD or a full ISO datetime."""
|
||||||
return date.fromisoformat(s[:10])
|
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
|
uvicorn[standard]==0.34.0
|
||||||
sqlalchemy[asyncio]==2.0.36
|
sqlalchemy[asyncio]==2.0.36
|
||||||
asyncpg==0.30.0
|
asyncpg==0.30.0
|
||||||
alembic==1.14.0
|
alembic==1.14.0
|
||||||
pydantic==2.10.3
|
pydantic==2.10.3
|
||||||
pydantic-settings==2.7.0
|
pydantic-settings==2.7.0
|
||||||
python-jose[cryptography]==3.3.0
|
PyJWT==2.13.0
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
python-multipart==0.0.18
|
python-multipart==0.0.32
|
||||||
minio==7.2.10
|
minio==7.2.10
|
||||||
openpyxl==3.1.5
|
openpyxl==3.1.5
|
||||||
apscheduler==3.11.0
|
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"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER must be set}
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
|
||||||
POSTGRES_DB: qiji
|
POSTGRES_DB: ${POSTGRES_DB:-qiji}
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "127.0.0.1:5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
image: minio/minio:latest
|
image: ${MINIO_IMAGE:?MINIO_IMAGE must be a pinned MinIO version or digest}
|
||||||
command: server /data --console-address ":9001"
|
command: server /data --console-address ":9001"
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: minioadmin
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
|
||||||
MINIO_ROOT_PASSWORD: minioadmin
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
|
||||||
ports:
|
ports:
|
||||||
- "9000:9000"
|
- "127.0.0.1:9000:9000"
|
||||||
- "9001:9001"
|
- "127.0.0.1:9001:9001"
|
||||||
volumes:
|
volumes:
|
||||||
- miniodata:/data
|
- miniodata:/data
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "127.0.0.1:8000:8000"
|
||||||
environment:
|
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_ENDPOINT: minio:9000
|
||||||
MINIO_ACCESS_KEY: minioadmin
|
MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
|
||||||
MINIO_SECRET_KEY: minioadmin
|
MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
|
||||||
MINIO_BUCKET: qiji-photos
|
MINIO_BUCKET: qiji-photos
|
||||||
|
MINIO_SECURE: "false"
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
postgres:
|
||||||
- minio
|
condition: service_healthy
|
||||||
volumes:
|
minio:
|
||||||
- ./backend:/app
|
condition: service_started
|
||||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
command: sh -c "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8000"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
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">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<title>企迹 - 政企周报管理系统</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
+9
-12
@@ -5,22 +5,19 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.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 / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# API proxy
|
# Health check
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /health {
|
location /health {
|
||||||
proxy_pass http://backend:8000;
|
return 200 "ok";
|
||||||
proxy_set_header Host $host;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+662
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
"dom-to-image-more": "^3.10.0",
|
||||||
"element-plus": "^2.9.1",
|
"element-plus": "^2.9.1",
|
||||||
"pinia": "^2.3.0",
|
"pinia": "^2.3.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
@@ -21,6 +22,8 @@
|
|||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.15",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "~5.6.0",
|
"typescript": "~5.6.0",
|
||||||
|
"unplugin-element-plus": "^0.11.2",
|
||||||
|
"unplugin-vue-components": "^32.1.0",
|
||||||
"vite": "^6.0.5",
|
"vite": "^6.0.5",
|
||||||
"vue-tsc": "^2.2.0"
|
"vue-tsc": "^2.2.0"
|
||||||
}
|
}
|
||||||
@@ -580,6 +583,17 @@
|
|||||||
"@jridgewell/trace-mapping": "^0.3.24"
|
"@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": {
|
"node_modules/@jridgewell/resolve-uri": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
@@ -645,6 +659,48 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/@popperjs/core": {
|
||||||
"name": "@sxzz/popperjs-es",
|
"name": "@sxzz/popperjs-es",
|
||||||
"version": "2.11.8",
|
"version": "2.11.8",
|
||||||
@@ -1270,6 +1326,19 @@
|
|||||||
"vue": "^3.5.0"
|
"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": {
|
"node_modules/agent-base": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
|
"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": "^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": {
|
"node_modules/call-bind-apply-helpers": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
@@ -1563,6 +1701,16 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@@ -1585,6 +1733,23 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
|
"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": {
|
"node_modules/delayed-stream": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
@@ -1643,6 +1815,13 @@
|
|||||||
"node": ">=0.4.0"
|
"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": {
|
"node_modules/didyoumean": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz",
|
"resolved": "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||||
@@ -1657,6 +1836,25 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"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"
|
"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": {
|
"node_modules/es-define-property": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
@@ -1734,6 +1939,13 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/es-object-atoms": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
@@ -1813,12 +2025,32 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/estree-walker": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz",
|
"resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
@@ -2001,6 +2233,16 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
@@ -2088,6 +2330,16 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/is-binary-path": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
@@ -2160,6 +2412,23 @@
|
|||||||
"jiti": "bin/jiti.js"
|
"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": {
|
"node_modules/lilconfig": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz",
|
"resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||||
@@ -2180,6 +2449,24 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lodash": {
|
||||||
"version": "4.18.1",
|
"version": "4.18.1",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
|
||||||
@@ -2301,6 +2588,38 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -2390,6 +2709,27 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/path-browserify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||||
@@ -2404,6 +2744,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -2465,6 +2819,18 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",
|
||||||
@@ -2650,6 +3016,23 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -2671,6 +3054,17 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
@@ -2740,6 +3134,22 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.62.2",
|
"version": "4.62.2",
|
||||||
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz",
|
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz",
|
||||||
@@ -2809,6 +3219,26 @@
|
|||||||
"queue-microtask": "^1.2.2"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -2980,6 +3410,231 @@
|
|||||||
"node": ">=14.17"
|
"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": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
@@ -3184,6 +3839,13 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": ">=5.0.0"
|
"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": {
|
"dependencies": {
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
"dom-to-image-more": "^3.10.0",
|
||||||
"element-plus": "^2.9.1",
|
"element-plus": "^2.9.1",
|
||||||
"pinia": "^2.3.0",
|
"pinia": "^2.3.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
@@ -22,6 +23,8 @@
|
|||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.15",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "~5.6.0",
|
"typescript": "~5.6.0",
|
||||||
|
"unplugin-element-plus": "^0.11.2",
|
||||||
|
"unplugin-vue-components": "^32.1.0",
|
||||||
"vite": "^6.0.5",
|
"vite": "^6.0.5",
|
||||||
"vue-tsc": "^2.2.0"
|
"vue-tsc": "^2.2.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 989 B |
+615
-10
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const isMobile = computed(() => {
|
const isMobile = computed(() => {
|
||||||
@@ -9,7 +10,9 @@ const isMobile = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<el-config-provider :locale="zhCn">
|
||||||
<router-view />
|
<router-view />
|
||||||
|
</el-config-provider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -64,6 +67,323 @@ const isMobile = computed(() => {
|
|||||||
--radius-sm: 2px;
|
--radius-sm: 2px;
|
||||||
--radius-xs: 2px;
|
--radius-xs: 2px;
|
||||||
--transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
--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 ── */
|
/* ── Reset & Base ── */
|
||||||
@@ -73,7 +393,7 @@ html, body, #app {
|
|||||||
margin: 0; padding: 0; height: 100%;
|
margin: 0; padding: 0; height: 100%;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
font-family: 'Noto Serif SC', STSong, Songti SC, '宋体', serif;
|
font-family: var(--font-body);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
color: var(--c-text);
|
color: var(--c-text);
|
||||||
@@ -84,7 +404,7 @@ html, body, #app {
|
|||||||
|
|
||||||
/* ── Editorial heading style ── */
|
/* ── Editorial heading style ── */
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
font-family: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
|
font-family: var(--font-heading);
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +424,7 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
.el-card__header {
|
.el-card__header {
|
||||||
border-bottom: 2px solid var(--gold) !important;
|
border-bottom: 2px solid var(--gold) !important;
|
||||||
padding: 16px 20px !important;
|
padding: 16px 20px !important;
|
||||||
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
font-family: var(--font-heading);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
@@ -118,7 +438,7 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
transition: all var(--transition);
|
transition: all var(--transition);
|
||||||
font-family: 'Noto Serif SC', STSong, Songti SC, serif;
|
font-family: var(--font-body);
|
||||||
}
|
}
|
||||||
.el-button--primary {
|
.el-button--primary {
|
||||||
background: var(--ink) !important;
|
background: var(--ink) !important;
|
||||||
@@ -146,14 +466,14 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
.el-tag {
|
.el-tag {
|
||||||
border-radius: 2px !important;
|
border-radius: 2px !important;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-family: 'Noto Serif SC', STSong, serif;
|
font-family: var(--font-body);
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Tabs ── */
|
/* ── Tabs ── */
|
||||||
.el-tabs__item {
|
.el-tabs__item {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
font-family: var(--font-heading);
|
||||||
}
|
}
|
||||||
.el-tabs__active-bar { background: var(--gold) !important; }
|
.el-tabs__active-bar { background: var(--gold) !important; }
|
||||||
.el-tabs__item.is-active { color: var(--ink) !important; }
|
.el-tabs__item.is-active { color: var(--ink) !important; }
|
||||||
@@ -164,7 +484,7 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
font-family: var(--font-heading);
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
|
.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 {
|
.el-dialog__header {
|
||||||
padding: 20px 24px 0 !important;
|
padding: 20px 24px 0 !important;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
font-family: var(--font-heading);
|
||||||
}
|
}
|
||||||
.el-dialog__body { padding: 20px 24px !important; }
|
.el-dialog__body { padding: 20px 24px !important; }
|
||||||
|
|
||||||
@@ -222,7 +542,7 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
/* ── Textarea ── */
|
/* ── Textarea ── */
|
||||||
.el-textarea__inner {
|
.el-textarea__inner {
|
||||||
border-radius: 2px !important;
|
border-radius: 2px !important;
|
||||||
font-family: 'Noto Serif SC', STSong, serif;
|
font-family: var(--font-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Radio ── */
|
/* ── Radio ── */
|
||||||
@@ -255,7 +575,7 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
|
|
||||||
/* ── Divider ── */
|
/* ── Divider ── */
|
||||||
.el-divider__text {
|
.el-divider__text {
|
||||||
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
font-family: var(--font-heading);
|
||||||
color: var(--warm-gray);
|
color: var(--warm-gray);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,4 +597,289 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
cursor: pointer; border-radius: 50%; transition: background 0.2s;
|
cursor: pointer; border-radius: 50%; transition: background 0.2s;
|
||||||
}
|
}
|
||||||
.image-preview-close:hover { background: rgba(255,255,255,0.35); }
|
.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>
|
</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) {
|
getWeeklyReport(params?: any) {
|
||||||
return api.get('/dashboard/weekly-report', { params })
|
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) {
|
delete(id: string) {
|
||||||
return api.delete(`/mini-business/${id}`)
|
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 api from './index'
|
||||||
import axios from 'axios'
|
|
||||||
|
|
||||||
export const uploadApi = {
|
export const uploadApi = {
|
||||||
async getPresignedUrl(filename: string, contentType: string = 'image/jpeg') {
|
async uploadImage(file: File) {
|
||||||
return api.post('/upload/presigned-url', null, {
|
const data = new FormData()
|
||||||
params: { filename, content_type: contentType },
|
data.append('file', file)
|
||||||
|
return api.post('/upload/image', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
timeout: 60000,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
getDownloadUrl(objectKey: string) {
|
getDownloadUrl(objectKey: string) {
|
||||||
return api.get('/upload/download-url', { params: { object_key: objectKey } })
|
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 { useRoute, useRouter } from 'vue-router'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const themeStore = useThemeStore()
|
||||||
const collapsed = ref(false)
|
const collapsed = ref(false)
|
||||||
|
|
||||||
interface MenuGroup { label?: string; items: { path: string; label: string; icon: string }[] }
|
interface MenuGroup { label?: string; items: { path: string; label: string; icon: string }[] }
|
||||||
@@ -17,6 +19,7 @@ const menuGroups = computed<MenuGroup[]>(() => {
|
|||||||
items: [
|
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: '/', 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: '/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: '/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: '/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: '/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: '管理',
|
label: '管理',
|
||||||
items: [
|
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: '/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) {
|
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: '/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
|
return groups
|
||||||
@@ -56,6 +63,10 @@ const roleLabel = computed(() => {
|
|||||||
if (auth.isLeader) return '分管领导'
|
if (auth.isLeader) return '分管领导'
|
||||||
return '客户经理'
|
return '客户经理'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function cycleTheme() {
|
||||||
|
themeStore.toggleMode()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -99,6 +110,13 @@ const roleLabel = computed(() => {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="topbar-actions">
|
<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="移动端">
|
<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">
|
<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>
|
<rect x="5" y="2" width="14" height="20" rx="2" ry="2"></rect>
|
||||||
@@ -134,8 +152,8 @@ const roleLabel = computed(() => {
|
|||||||
/* ═══ Sidebar ═══ */
|
/* ═══ Sidebar ═══ */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 240px;
|
width: 240px;
|
||||||
background: var(--ink);
|
background: var(--sidebar-bg);
|
||||||
color: #fff;
|
color: var(--sidebar-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -157,31 +175,31 @@ const roleLabel = computed(() => {
|
|||||||
|
|
||||||
.sidebar-brand {
|
.sidebar-brand {
|
||||||
padding: 26px 22px 18px;
|
padding: 26px 22px 18px;
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
border-bottom: 1px solid var(--sidebar-divider);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-title {
|
.brand-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
|
font-family: var(--font-heading);
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
color: #fff;
|
color: var(--sidebar-brand-color);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-sub {
|
.brand-sub {
|
||||||
margin: 2px 0 0;
|
margin: 2px 0 0;
|
||||||
font-family: 'Noto Serif SC', STSong, serif;
|
font-family: var(--font-body);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: rgba(255,255,255,0.4);
|
color: var(--sidebar-brand-sub-color);
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-rule {
|
.brand-rule {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 3px;
|
height: 3px;
|
||||||
background: var(--gold);
|
background: var(--sidebar-accent);
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,10 +214,10 @@ const roleLabel = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-group-label {
|
.nav-group-label {
|
||||||
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
letter-spacing: 0.15em;
|
letter-spacing: 0.15em;
|
||||||
color: rgba(255,255,255,0.25);
|
color: var(--sidebar-group-label-color);
|
||||||
padding: 12px 14px 4px;
|
padding: 12px 14px 4px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
@@ -211,9 +229,9 @@ const roleLabel = computed(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 11px 14px;
|
padding: 11px 14px;
|
||||||
color: rgba(255,255,255,0.55);
|
color: var(--sidebar-nav-text);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-family: 'Noto Serif SC', STSong, serif;
|
font-family: var(--font-body);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
transition: all 0.22s;
|
transition: all 0.22s;
|
||||||
@@ -221,14 +239,14 @@ const roleLabel = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:hover {
|
.nav-item:hover {
|
||||||
color: rgba(255,255,255,0.85);
|
color: var(--sidebar-nav-text-hover);
|
||||||
background: rgba(255,255,255,0.04);
|
background: var(--sidebar-nav-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item--active {
|
.nav-item--active {
|
||||||
color: #fff;
|
color: var(--sidebar-nav-text-active);
|
||||||
background: rgba(255,255,255,0.06);
|
background: var(--sidebar-nav-bg-active);
|
||||||
border-left-color: var(--gold);
|
border-left-color: var(--sidebar-nav-border-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-icon {
|
.nav-icon {
|
||||||
@@ -243,23 +261,23 @@ const roleLabel = computed(() => {
|
|||||||
/* ═══ Sidebar Footer ═══ */
|
/* ═══ Sidebar Footer ═══ */
|
||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
padding: 14px 22px;
|
padding: 14px 22px;
|
||||||
border-top: 1px solid rgba(255,255,255,0.06);
|
border-top: 1px solid var(--sidebar-divider);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-role {
|
.footer-role {
|
||||||
font-family: 'Noto Serif SC', STSong, serif;
|
font-family: var(--font-body);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--gold);
|
color: var(--sidebar-footer-role-color);
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-name {
|
.footer-name {
|
||||||
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
font-family: var(--font-heading);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: rgba(255,255,255,0.7);
|
color: var(--sidebar-footer-name-color);
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +302,7 @@ const roleLabel = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.topbar-greeting {
|
.topbar-greeting {
|
||||||
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
font-family: var(--font-heading);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
@@ -314,7 +332,7 @@ const roleLabel = computed(() => {
|
|||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--warm-border);
|
border: 1px solid var(--warm-border);
|
||||||
color: var(--warm-gray);
|
color: var(--warm-gray);
|
||||||
font-family: 'Noto Serif SC', STSong, serif;
|
font-family: var(--font-body);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -125,14 +125,14 @@ function labelFor(field: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.log-editor {
|
.log-editor {
|
||||||
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
font-family: var(--font-heading);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-time {
|
.log-time {
|
||||||
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--c-text-muted);
|
color: var(--c-text-muted);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ const imgStyle = computed(() => {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: rgba(255, 255, 255, 0.75);
|
color: rgba(255, 255, 255, 0.75);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: 'Noto Serif SC', STSong, serif;
|
font-family: var(--font-body);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -287,7 +287,7 @@ const imgStyle = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fit-label {
|
.fit-label {
|
||||||
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,31 +2,28 @@
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useThemeStore, themeLabels } from '@/stores/theme'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
|
||||||
|
function cycleTheme() {
|
||||||
|
const next = themeStore.currentTheme === 'editorial' ? 'light' : 'editorial'
|
||||||
|
themeStore.setTheme(next)
|
||||||
|
}
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{ path: '/m', label: '首页', icon: 'home' },
|
||||||
path: '/m',
|
{ path: '/m/work', label: '工作', icon: 'work' },
|
||||||
label: '首页',
|
{ path: '/m/visit/new', label: '拜访', icon: 'visit' },
|
||||||
icon: 'home',
|
{ path: '/m/note/new', label: '纪要', icon: 'note' },
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/m/visit/new',
|
|
||||||
label: '拜访',
|
|
||||||
icon: 'visit',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/m/note/new',
|
|
||||||
label: '纪要',
|
|
||||||
icon: 'note',
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const activeTab = computed(() => {
|
const activeTab = computed(() => {
|
||||||
if (route.path === '/m') return '/m'
|
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('/visit')) return '/m/visit/new'
|
||||||
if (route.path.includes('/note')) return '/m/note/new'
|
if (route.path.includes('/note')) return '/m/note/new'
|
||||||
return route.path
|
return route.path
|
||||||
@@ -43,6 +40,22 @@ const activeTab = computed(() => {
|
|||||||
<div class="header-rule"></div>
|
<div class="header-rule"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<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版">
|
<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">
|
<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>
|
<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>
|
<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>
|
<polyline points="9 22 9 12 15 12 15 22"></polyline>
|
||||||
</svg>
|
</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 -->
|
<!-- 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">
|
<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>
|
<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 {
|
.header-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
|
font-family: var(--font-heading);
|
||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
@@ -138,7 +156,7 @@ const activeTab = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-subtitle {
|
.header-subtitle {
|
||||||
font-family: 'Noto Serif SC', STSong, Songti SC, serif;
|
font-family: var(--font-body);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--warm-gray);
|
color: var(--warm-gray);
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
@@ -178,7 +196,7 @@ const activeTab = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-user {
|
.header-user {
|
||||||
font-family: 'Noto Serif SC', STSong, serif;
|
font-family: var(--font-body);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--warm-gray);
|
color: var(--warm-gray);
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
@@ -201,7 +219,7 @@ const activeTab = computed(() => {
|
|||||||
right: 0;
|
right: 0;
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 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;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
@@ -238,7 +256,7 @@ const activeTab = computed(() => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-family: 'Noto Serif SC', STSong, serif;
|
font-family: var(--font-body);
|
||||||
letter-spacing: 0.03em;
|
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 { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
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 './tailwind.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import { vColumnResize } from '@/directives/columnResize'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(ElementPlus, { locale: zhCn as any })
|
app.directive('column-resize', vColumnResize)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { isMobile } from '@/utils'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -16,19 +17,43 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/BindWecom.vue'),
|
component: () => import('@/views/BindWecom.vue'),
|
||||||
meta: { public: true },
|
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)
|
// Mobile routes (manager-facing)
|
||||||
{
|
{
|
||||||
path: '/m',
|
path: '/m',
|
||||||
component: () => import('@/components/MobileLayout.vue'),
|
component: () => import('@/components/MobileLayout.vue'),
|
||||||
children: [
|
children: [
|
||||||
{ path: '', name: 'MobileHome', component: () => import('@/views/mobile/Home.vue') },
|
{ 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/new', name: 'VisitForm', component: () => import('@/views/mobile/VisitForm.vue') },
|
||||||
{ path: 'visit/:id/edit', name: 'VisitEdit', 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/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/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/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/new', name: 'DailyNoteForm', component: () => import('@/views/mobile/DailyNoteForm.vue') },
|
||||||
{ path: 'note/:id/edit', name: 'DailyNoteEdit', 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)
|
// Desktop routes (director/leader-facing)
|
||||||
@@ -38,12 +63,15 @@ const router = createRouter({
|
|||||||
children: [
|
children: [
|
||||||
{ path: '', name: 'Dashboard', component: () => import('@/views/desktop/Dashboard.vue') },
|
{ path: '', name: 'Dashboard', component: () => import('@/views/desktop/Dashboard.vue') },
|
||||||
{ path: 'weekly-report', name: 'WeeklyReport', component: () => import('@/views/desktop/WeeklyReport.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: 'work-plans', name: 'WorkPlans', component: () => import('@/views/desktop/WorkPlans.vue') },
|
||||||
{ path: 'mini-business', name: 'MiniBusiness', component: () => import('@/views/desktop/MiniBusiness.vue') },
|
{ path: 'mini-business', name: 'MiniBusiness', component: () => import('@/views/desktop/MiniBusiness.vue') },
|
||||||
{ path: 'key-visits', name: 'KeyVisits', component: () => import('@/views/desktop/KeyVisits.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: 'workspace', name: 'ManagerWorkspace', component: () => import('@/views/desktop/ManagerWorkspace.vue') },
|
||||||
{ path: 'customers', name: 'CustomerManage', component: () => import('@/views/desktop/CustomerManage.vue') },
|
{ path: 'customers', name: 'CustomerManage', component: () => import('@/views/desktop/CustomerManage.vue') },
|
||||||
{ path: 'users', name: 'UserManage', component: () => import('@/views/desktop/UserManage.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') },
|
{ path: 'settings', name: 'Settings', component: () => import('@/views/desktop/Settings.vue') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -52,6 +80,30 @@ const router = createRouter({
|
|||||||
|
|
||||||
router.beforeEach((to, _from, next) => {
|
router.beforeEach((to, _from, next) => {
|
||||||
const auth = useAuthStore()
|
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) {
|
if (to.meta.public) {
|
||||||
next()
|
next()
|
||||||
return
|
return
|
||||||
@@ -60,6 +112,22 @@ router.beforeEach((to, _from, next) => {
|
|||||||
next('/login')
|
next('/login')
|
||||||
return
|
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()
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user