Compare commits

...

151 Commits

Author SHA1 Message Date
v6ole 4afa796ddc fix: 客户列表多 primary 经理时顿号连接显示(修复字典覆盖)
- mgr_map 由字典推导式改为按 customer_id 聚合 list,再用顿号连接
- 修复同一客户多个 primary 经理时,后写入覆盖先写入导致只显示一个名字

数据修复: 大化瑶族自治县公安局交通警察大队 移除韦柳柏的 primary 分配,唐晓静为唯一 primary

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 09:43:58 +08:00
v6ole ab20bc5a1f Merge branch 'main' into develop
# Conflicts:
#	backend/app/api/key_visits.py
#	backend/app/api/mini_business.py
#	backend/app/api/visits.py
#	backend/app/api/work_plans.py
#	backend/app/main.py
#	backend/app/models/__init__.py
#	backend/app/schemas/key_visit.py
#	backend/app/schemas/mini_business.py
#	backend/app/schemas/work_plan.py
#	backend/app/services/light_board.py
#	frontend/src/components/DesktopLayout.vue
#	frontend/src/stores/theme.ts
#	frontend/src/views/desktop/ManagerWorkspace.vue
#	frontend/src/views/desktop/WorkPlans.vue
#	frontend/src/views/mobile/KeyVisitForm.vue
#	frontend/src/views/mobile/LeaveForm.vue
#	frontend/src/views/mobile/PlansList.vue
#	frontend/src/views/mobile/VisitForm.vue
#	frontend/src/views/mobile/WorkPlanForm.vue
2026-08-17 09:31:12 +08:00
v6ole 1ec20ee53e chore: 保存进行中的未提交改动(企微定向推送+搜索筛选UI等)
保存合并前工作树中遗留的进行中改动,避免分支合并时丢失:
- scheduler: 填报汇总改为定向推送给支局长/领导(而非广播@all)
- router: JWT token 校验修复,bind token(UUID)不被误剥离
- 工作计划/计划列表: 搜索+筛选+分页 UI
- 各列表 API 增加 search 参数支持
- docker-compose.backend.yml + backend/.dockerignore 纳入版本管理

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 09:17:03 +08:00
v6ole c9df6066f5 fix: 亮灯表重复卡片 — 修复同客户经理同日多条拜访导致的 fan-out
- 原查询用 outerjoin(Visit, visit_date == MAX(visit_date)) 获取拜访方式,
  当同一天有多条拜访(协同拜访)时会扩展成多行,导致亮灯表同一客户显示多张卡片
- 改用 DISTINCT ON (manager_id, customer_id) 子查询,保证每对(经理,客户)
  只取最新一条拜访,消除重复
- 移除不再使用的 func 导入

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 09:06:36 +08:00
v6ole 804812dd7e feat: 企业微信OAuth静默登录
- 新增 GET /api/wecom/oauth-url 和 /api/wecom/oauth-callback
- OAuth回调返回HTML页面直接写localStorage(避坑企微webview 307重定向不可靠)
- 菜单URL改为OAuth静默授权链接(snsapi_base + agentid)
- redirect目标编码到state参数中(避坑redirect_uri不允许自定义query)
- wecom_userid大小写不敏感匹配
- 前端路由守卫处理 ?token= 自动登录(fallback)
- Login.vue企微环境检测自动跳OAuth
- setup-menu同步更新为OAuth链接

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-30 12:33:43 +08:00
v6ole 4139b54a57 docs: 企业微信OAuth静默登录踩坑记录
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-30 12:31:00 +08:00
v6ole 2187be7aeb feat: 操作日志系统 + 日期修复 + 协同去重 + 主题持久化 + 计划自动完成
本次累积提交包含以下功能:

1. 修复:el-date-picker 缺失 value-format 导致日期保存偏移(8处)
2. 新增:协同拜访 visit_group_id 去重机制(完整副本 + 周报合并 + 亮灯去重)
3. 新增:计划自动完成(创建计划检测已有拜访 / 编辑拜访触发)
4. 新增:拜访表单选择客户后自动预填过期计划内容
5. 新增:移动端主题切换按钮 + 后端持久化
6. 新增:操作日志系统 (audit_logs),全覆盖 8 个模块 CRUD
7. 新增:PC端「我的数据」支局长/领导视角客户经理列

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-30 12:01:14 +08:00
v6ole 9b5805447e docs: 协同拜访记录去重设计方案
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-30 09:55:08 +08:00
v6ole 80d2716b7c deploy: manage backend with systemd 2026-07-28 17:38:45 +08:00
v6ole c7bb49776c fix(migrations): support virtualenv Alembic execution 2026-07-28 17:38:17 +08:00
v6ole 687d7cc131 ci(security): add SBOM scan and release evidence 2026-07-28 17:03:13 +08:00
v6ole 4bfe2900fb build(security): add migration and CI security gates 2026-07-28 16:41:36 +08:00
v6ole 6b43b77c3b fix(security): harden uploads and wecom binding 2026-07-28 12:18:45 +08:00
v6ole 29e4e4a804 fix(security): remediate telecom compliance findings 2026-07-28 11:33:05 +08:00
v6ole 7f87aa7353 fix: 请假表单日期序列化与错误消息格式化修复
- el-date-picker 添加 value-format="YYYY-MM-DD",确保日期以字符串格式发送而非 ISO datetime,修复后端 Pydantic date 校验 422 错误
- 错误处理中正确解析 FastAPI 422 返回的 detail 数组,提取 msg 字段拼接显示,修复 [object Object] 显示问题

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 11:02:26 +08:00
v6ole ccb436664b fix: Element Plus暗色CSS变量覆盖 + 设置页对经理开放(仅主题)
- App.vue: 新增 html.dark --el-* 变量全覆盖(30+变量),修复所有select/input/dropdown/table/pagination等组件暗色背景
- DesktopLayout: 设置页对所有角色开放,用户管理仍限支局长
- Settings: 经理只显示主题面板,其他6个管理模块仅支局长可见

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 08:42:29 +08:00
v6ole af3d3bfc89 fix: 暗黑模式UI优化 — 导航栏/页面背景互换, Element Plus组件适配, 亮灯表卡片+仪表盘图标暗色适配
- 极简/科技深色: 导航栏和页面背景颜色互换(sidebar更亮,page更暗)
- 新增20+条 Element Plus 暗色全局覆盖(input/select/table/pagination/button/dialog/tabs/date-picker等)
- Dashboard: stat-glyph在暗色下移除背景色(参考本周请假), progress-row背景降低亮度
- LightBoard: 卡片文字固定深色(card背景是浅色), light-dot适配暗色背景
- 新增 --c-bg-light token 防止暗色下summary-bar回退到浅色

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 08:29:58 +08:00
v6ole 5b55029576 fix: 修复logout清除新主题偏好; 移除DesktopLayout未使用的themeLabels导入
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 01:51:17 +08:00
v6ole e14308b757 feat: 新增 minimal-dark 和 tech-dark 两套暗色 CSS 变量
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 01:48:14 +08:00
v6ole ce24937ba0 feat: 改造 theme store 支持暗黑模式双选架构
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 01:45:56 +08:00
v6ole 6fd9cfbff9 chore: 更新 components.d.ts 自动生成文件
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 01:34:03 +08:00
v6ole ee3f76d960 docs: 更新 CLAUDE.md — 个人用户+移动端导航+我的数据优化决策
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 01:29:27 +08:00
v6ole a9344eadae fix: 日期选择框和搜索框统一宽度240px; 搜索按钮改为文字
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 01:27:55 +08:00
v6ole 392380ab51 fix: 日期选择合并为daterange; 搜索框+日期选择移至标题行
- 两个独立month picker合并为一个daterange选择器
- 日期选择+搜索框移至我的工作数据标题同行
- 搜索框增加搜索按钮
- 移除新建按钮

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 01:25:52 +08:00
v6ole 7699e88578 feat: 我的数据页面日期筛选改为月度选择器+新建按钮移至筛选栏
- 替换固定周期(本周/本月/本年)为 el-date-picker type=month
- 支持任意月份筛选,选完自动刷新
- 新建按钮移至筛选栏最右侧,与搜索框同行
- 各Tab内移除独立的创建按钮

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 01:12:05 +08:00
v6ole f89638b3f0 feat: 桌面端工作计划/商机/要客列表新增搜索框
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 01:06:56 +08:00
v6ole 04f701b27c fix: 移动端列表页搜索框和按钮去掉size=small,使用默认大小
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 01:02:35 +08:00
v6ole 07c6f65378 fix: 商机跟单/要客拜访搜索框漏传params参数
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 00:59:53 +08:00
v6ole cdbe59e97f feat: 5个列表API新增搜索参数支持
- visits: search沟通内容/客户需求/拜访人/客户名
- daily_notes: search内容
- work_plans: search计划内容/客户名
- mini_business: search产品/跟进/客户名
- key_visits: search描述/拜访人/客户名

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 00:55:43 +08:00
v6ole e7125ff930 docs: 更新 CLAUDE.md — 个人用户决策
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 00:50:22 +08:00
v6ole 72d869bafb feat: 移动端列表页支持搜索+分页(25条/页);历史移至请假后
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 00:49:43 +08:00
v6ole 3059f0e7e4 feat: 移动端工作中心新增历史拜访+历史纪要
- 工作中心新增两个卡片入口:历史拜访 / 历史纪要
- VisitsList.vue: 拜访历史记录列表,点击进入编辑
- NotesList.vue: 纪要历史记录列表,点击进入编辑
- 复用已有编辑表单(VisitForm/DailyNoteForm edit mode)
- 网格改为3列布局容纳6个卡片

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 00:32:16 +08:00
v6ole d6dec7f9af feat: 我的数据页面新增日期筛选+搜索+分页
- 本周/本月/本年/全部 四个时间范围筛选(默认本月)
- 关键词搜索所有内容
- el-pagination 分页(25/50/100条每页)
- 每个Tab独立分页状态

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 00:25:21 +08:00
v6ole ff07e9c470 fix: 图片上传改用 POST + axios.put,与ManagerWorkspace一致
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 00:17:33 +08:00
v6ole c55ea18aaf feat: 个人用户拜访编辑新增照片查看/删除/上传功能
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 00:14:40 +08:00
v6ole 32ca029580 fix: 个人用户编辑弹窗与我的数据保持一致—补全四模块全部字段
- 拜访: 客户经理/日期/方式/时间/拜访人/电话/相关人员/内容/需求
- 计划: 客户经理/计划日期/内容/状态
- 商机: 客户经理/产品/金额/跟进/状态/预计列收(月选择器)
- 要客: 客户经理/紧急度/描述/进展/时间/拜访人/对象

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 00:12:09 +08:00
v6ole ae29a17825 fix: 个人用户拜访编辑弹窗补全所有字段
客户经理/日期/方式/时间/拜访人/电话/沟通内容/客户需求

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 00:08:13 +08:00
v6ole 1fddc35119 feat: 个人用户视图表格新增编辑按钮+编辑弹窗
4个Tab的表格均添编辑按钮,点击弹窗可编辑关键字段并保存/删除

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 00:04:32 +08:00
v6ole 9a39b96316 fix: 客户类型Tab改为自定义按钮样式,匹配整体设计风格
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 00:01:02 +08:00
v6ole 83c87cad3d fix: 个人用户Tab禁用新建客户按钮
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 23:58:38 +08:00
v6ole 9f3067b412 fix: 单位客户列表API请求加上 customer_type 筛选参数
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 23:52:26 +08:00
v6ole 7c75164323 feat: 个人用户拜访记录新增拜访人+客户经理列
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 22:19:14 +08:00
v6ole 6504a393e3 feat: 个人用户聚合视图 — 客户管理双Tab+四模块数据聚合
- customers 表新增 customer_type 列 (unit/individual)
- 客户管理页:单位客户 | 个人用户 Tab 切换
- 个人用户 Tab: 直接内嵌4 Tab 显示所有个人用户的聚合数据
- 个人用户通过业务模块快速新建产生 (customer_type=individual)
- 4个业务API新增 customer_type 筛选参数(JOIN customers)
- 客户列表排序: 个人用户置顶

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 22:14:44 +08:00
v6ole 835e5d38fd Revert "feat: 客户管理新增个人用户类型,双Tab分离管理"
This reverts commit 03198557f7.
2026-07-12 21:45:23 +08:00
v6ole 03198557f7 feat: 客户管理新增个人用户类型,双Tab分离管理
- customers 表新增 customer_type 列 (unit/individual)
- 列表按个人优先排序,支持 customer_type 筛选
- PC端 el-radio-group 切换:单位客户 | 个人用户
- 个人用户表单: 仅姓名+备注,隐藏行业/地址/业务/月费/客户经理/联系人
- 所有业务模块客户搜索下拉中个人用户置顶

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 21:43:08 +08:00
v6ole 98a0d4f7c8 fix: 客户详情顶部信息区用卡片样式区分Tab区域
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 21:09:58 +08:00
v6ole 05d33d518f feat: 客户详情弹窗新增拜访/计划/商机/要客四个关联数据Tab
点击客户名 → 800px 弹窗,顶部显示基本信息,下方 el-tabs 切换:
- 拜访记录 / 工作计划 / 商机跟单 / 要客拜访
- 各 Tab 并行加载对应 customer_id 筛选数据

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 21:03:10 +08:00
v6ole 9354752b89 fix: 商机改为已流失时前端自动清空列收时间再提交
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 20:56:03 +08:00
v6ole 82755e14a1 fix: 桌面端支局长/领导新建记录时客户经理栏显示UUID
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 20:50:07 +08:00
v6ole 20f5b01987 feat: 商机跟单默认筛选跟进中 + 已流失置底 + 清空列收
- 后端: 状态改为已流失时自动清空 expected_revenue_date
- PC端: 默认筛选跟进中,排序 跟进中→已签约→已流失
- 移动端: 列表自动排序,已流失在最底部

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 20:49:01 +08:00
v6ole 78962e45d7 feat: 移动端商机跟单新增跟进记录时间线+添加功能
编辑模式下表单底部显示跟进时间线 + 新增跟进入口
与PC端一致的体验:方式chip选择 + 日期 + 内容

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 20:42:22 +08:00
v6ole fade9e62e2 fix: 支局长/领导新建记录时客户经理栏显示UUID
manager_id 初始值: director/leader 设为空让用户选择,manager 仍用自身ID
编辑模式保留原记录的 manager_id

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 20:38:51 +08:00
v6ole 7b91cb4230 fix: 移动端编辑表单自动补充客户到选项列表,避免显示UUID
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 20:34:34 +08:00
v6ole 6c76b34d1b fix: 移动端刷新页面后主题回退的问题
在 index.html 添加内联 script,Vue 启动前从 localStorage 读取主题
并设置 data-theme 属性,消除 Pinia store 初始化时序导致的闪烁

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 20:32:31 +08:00
v6ole 1e0f77d4e6 feat: 桌面端表单必填项标记 * + 提交校验
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 20:30:43 +08:00
v6ole ff0a38a369 feat: 移动端表单必填项标记红色 * 号 + 提交校验
- 客户单位/日期/纪要内容/计划拜访时间 标记 *
- 缺失必填项时 ElMessage.warning 提示
- CSS: .required-star { color: var(--vermilion); }

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 20:28:54 +08:00
v6ole 6d1ccbb246 fix: 删除按钮移至标题栏右侧,与标题水平对齐
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 20:23:31 +08:00
v6ole 216406326a fix: 添加单条记录GET端点,修复移动端编辑表单数据加载
- work-plans/mini-business/key-visits 均新增 GET /{id} 端点
- 三个移动端编辑表单改用单条端点加载数据
- 修复点击卡片进入编辑时空白的 bug

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 20:20:07 +08:00
v6ole 33fb6a63c0 fix: 移动端列表卡片去掉编辑按钮,点击卡片直接进入编辑
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 20:17:03 +08:00
v6ole 637a0949a8 fix: 移动端列表页删除按钮改为编辑按钮
卡片底部按钮从 🗑 删除改为 ✏️ 编辑,编辑表单内保留删除功能

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 20:13:39 +08:00
v6ole aed9182dbe fix: 移动端列表页移除 v-loading 遮罩,修复卡片点击无反应
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 20:12:14 +08:00
v6ole 24fb5f8f71 feat: 移动端工作计划/商机跟单/要客拜访支持编辑和删除
- 三个表单均支持 create+edit 双模式(通过 route.params.id 判断)
- 编辑模式: 加载已有数据 → 修改 → PUT 更新
- 编辑模式增加删除按钮
- 列表页卡片点击跳转编辑页
- 新增 6 条编辑路由

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 20:06:47 +08:00
v6ole bc6c7076fe feat: 移动端导航重构 — 4 Tab + 工作中心 + 模块列表
- TabBar 新增「工作」标签(首页/工作/拜访/纪要)
- 新增工作中心页(/m/work),四卡片入口显示各模块记录数
- 新增三个列表页:工作计划/商机跟单/要客拜访(卡片流+删除)
- 首页精简:移除快捷 chip,入口统一到工作中心
- 路由新增: /m/work, /m/plans, /m/mini-biz, /m/key-visits

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 19:58:01 +08:00
v6ole e06574df38 fix: 移动端相关人员提示更新为同访人周报显示说明
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 19:53:00 +08:00
v6ole 10835ef15c feat: 消除同访草案机制,改为查询时通过 companions 数组覆盖
- 删除 POST /visits 中的草案创建逻辑(不再创建空拜访记录)
- 周报查询:manager 角色也显示同访的拜访记录
- 亮灯表覆盖:同访人也算客户拜访覆盖(UNION companions)
- 填报进度:同访人也算今日已填报
- 清理已有 2 条空草案记录

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 19:40:49 +08:00
v6ole 3ce77ed606 feat: 拜访记录创建时自动联动商机跟进
- POST /visits 创建拜访后,自动查询同客户下「跟进中」的商机
- 为每个匹配商机追加一条跟进记录(mini_business_log)
- 内容: 沟通内容 + 客户需求拼接
- 方式: 拜访方式自动映射(上门→上门/电话→电话/微信→微信/出差→其他)
- 仅创建时触发,更新/导入不联动

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 19:22:40 +08:00
v6ole 9eae965858 fix: 跟进记录日期选择器添加 value-format 避免 422 错误
el-date-picker 默认返回 Date 对象,JSON 序列化为 ISO 格式,
Pydantic date 字段不接受,需显式指定 value-format="YYYY-MM-DD"

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 19:04:21 +08:00
v6ole 87876f11fd fix: MiniBusiness.vue 缺少 miniBusinessApi 导入
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 18:57:48 +08:00
v6ole 19aa9153ff feat: PC端商机跟单新增详情弹窗+跟进时间线
- 点击客户名打开详情弹窗(替代直接编辑)
- 详情弹窗: 商机信息 + 跟进时间线 + 添加入口
- 跟进方式 chip: 电话/微信/上门/邮件/其他(色标区分)
- 每条跟进记录显示: 图标+日期+方式+内容+作者
- 支持添加和删除跟进记录

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 18:53:46 +08:00
v6ole 17a0218b32 feat: 商机跟单新增跟进记录子表(后端+API模块)
- 新增 mini_business_logs 表(business_id/log_date/method/content/created_by)
- MiniBusiness 模型添加 logs relationship(cascade delete)
- 5个端点: GET列表/POST新增/PUT编辑/DELETE删除
- 跟进方式: 电话/微信/上门/邮件/其他
- lifespan 自动建表+索引

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 18:51:04 +08:00
v6ole 13f8b9c512 docs: 商机跟单优化设计文档 — 跟进记录子表
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 18:48:23 +08:00
v6ole 03f14df244 fix: 移动端要客拜访计划时间改用日期选择器
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 18:34:25 +08:00
v6ole 3879cdc7ef fix: 移动端工作计划/商机跟单/要客拜访新增快速新建客户
三个移动端表单现均支持搜索时直接新建客户(el-select #empty 插槽)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 18:29:41 +08:00
v6ole 4453ee2ca0 feat: 支局长/分管领导可为任意客户经理代填记录
后端:
- 5个模块的 Create/Update schema 新增可选 manager_id 字段
- POST 端点: director/leader 可指定 manager_id,manager 角色自动用自身
- PUT 端点: director/leader 可修改 manager_id,manager 角色禁止修改

前端(移动端+桌面端共10个页面):
- 支局长/领导可见「客户经理」下拉选择框
- 创建和编辑时均可指定记录归属的客户经理
- 涵盖今日拜访、今日纪要、工作计划、商机跟单、要客拜访五个模块

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 18:19:05 +08:00
v6ole c489a8d6fd fix: 搜索无结果时新建按钮改为el-select的empty插槽,避免被遮挡
- 将「新建客户」按钮从 el-select 下方的独立 div 移入 #empty 插槽
- 搜索无匹配时,下拉面板直接显示「未找到xxx」+ 新建按钮
- 不再被原生「无数据」空状态遮挡
- 移动端+桌面端五个页面统一修复

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 18:04:45 +08:00
v6ole 5682c12085 feat: 桌面端工作页面支持搜索时直接新建客户
- WorkPlans/MiniBusiness/KeyVisits/ManagerWorkspace 四个页面新增快速新建客户功能
- 客户搜索框输入时,若无匹配结果,直接显示「+ 新建客户」按钮
- 点击即调用 POST /customers/quick-create 创建并自动选中
- 与移动端 VisitForm 保持一致体验

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 18:00:35 +08:00
v6ole be26ea5588 fix: 亮灯表指标移除临期计数,精简显示
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 16:35:00 +08:00
v6ole 750ce8d8eb feat: 亮灯表新增客户经理指标文字说明
每个客户经理折叠栏新增一行指标摘要:
「已拜访X · 临期X · 未拜访X · 完成率X%」
数字加粗,完成率按阈值着色(>=80%绿 >=50%金 <50%红)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 16:25:35 +08:00
v6ole efc200b6de feat: 工作计划状态自动流转
- 抽取 auto_complete_work_plans() 可复用函数到 visits service
- POST/PUT 拜访均触发自动完成(更新场景解决补录问题)
- Excel 导入旧周报时也触发自动完成
- 逾期计划自动取消:plan_date < today 且无匹配拜访→已取消
- edit_log 记录区分三种来源:拜访自动完成/拜访更新自动完成/旧周报导入自动完成/逾期自动取消

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 16:21:38 +08:00
v6ole c38a9e74ad chore: ignore .cc-connect bridge metadata 2026-07-12 14:39:28 +08:00
v6ole e7c3081070 feat: AI 周报摘要提示词自定义设置
- system_config 新增 ai_summary_prompt 配置键
- generate_summary 从数据库加载自定义提示词,为空则使用默认 SUMMARY_SYSTEM_PROMPT
- 系统设置新增「AI 周报提示词」卡片,支持编辑和清空恢复默认

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 14:20:20 +08:00
v6ole 7dcb22f573 feat: 集成 apisbo.com 中国节假日 API 自动获取+缓存
- 新增 holidays.py 服务:按年获取节假日数据,缓存到 system_config
- 区分 holiday(工作日假日)和 workday(调休上班日)两种类型
- is_working_day() 支持调休判断:周末在 workdays 集合中视为工作日
- 启动时自动从 API 刷新当年+明年数据,失败则使用缓存
- 系统设置新增「从 API 刷新」按钮,支局长可手动触发
- 支持 2010-2026 年数据,API 免费免鉴权

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 14:17:31 +08:00
v6ole a63bd6874c feat: 仪表盘+催办在休息日跳过填报检查
- 新增 is_working_day() 工具函数,自动跳过周末 + 可配置法定假日
- 仪表盘填报进度新增 is_rest_day 字段,休息日显示灰「休」印章
- 催办调度改用 is_working_day() 替代原始 weekday 检查
- 系统设置新增「法定节假日」配置卡片,支局长可管理假日日期

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 14:13:32 +08:00
v6ole dcb0b826de fix: 桌面端编辑时客户名称显示UUID的问题
WorkPlans/MiniBusiness/KeyVisits/ManagerWorkspace 四个页面的
openEdit 均直接 spread item 到 form,未确保客户在 el-select
选项列表中。修复:编辑前检查并补充客户到 customers 列表。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 13:45:06 +08:00
v6ole e6237590c0 fix: 移动端编辑拜访记录时客户名称显示UUID的问题
编辑模式下,el-select 的 customers 列表可能不包含当前记录对应的客户,
导致无法匹配选项而直接显示原始 UUID 值。
修复:从 GET 响应中获取 customer_name,补充到 customers 列表。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 13:43:21 +08:00
v6ole f9eed82afb fix: disable mobile zoom to prevent input auto-zoom on iOS
Added maximum-scale=1.0, user-scalable=no to viewport meta tag.
Prevents iOS Safari from auto-zooming when focusing inputs with font-size < 16px.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 13:36:37 +08:00
v6ole f4847bc748 docs: update CLAUDE.md — SSH port 7072, 127.0.0.1 binding, leave feature
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 13:33:55 +08:00
v6ole faeaf7606b fix: remove dead code in ai_summary + unused import in leaves API
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 13:21:58 +08:00
v6ole 4debecd523 feat: add mobile leave form + list + home entry + routes
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 13:18:07 +08:00
v6ole 82bc0d9e41 feat: add mobile leave list page
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 13:16:34 +08:00
v6ole 7fd0f9b706 feat: add sidebar entry + route + dashboard leave card + on_leave status
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 13:15:50 +08:00
v6ole 709685057b feat: add Leaves management page (PC)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 13:12:56 +08:00
v6ole eb05a0420e feat: scheduler skips on-leave managers; AI summary includes leave data
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 13:10:36 +08:00
v6ole 56adc625c0 feat: add on_leave status to reporting progress + week_leaves stat
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 13:09:05 +08:00
v6ole b6105295bb feat: add Leave API endpoints + frontend API module
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 13:05:49 +08:00
v6ole 2633513ce3 feat: add Leave service layer
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 13:01:55 +08:00
v6ole d74af74534 feat: add Leave Pydantic schemas
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 13:00:19 +08:00
v6ole 3786d82a27 feat: add Leave model with auto-migration
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 12:58:53 +08:00
v6ole e17536b3e4 docs: 请假功能实现计划 (11 tasks)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 12:52:44 +08:00
v6ole 531df448c1 docs: 请假功能设计文档
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 12:48:19 +08:00
v6ole e8b92a6f99 feat: 列宽拖拽 + 筛选栏 + 截图导出 + UI优化
- 新增 v-column-resize 指令,11 张表格均支持拖拽调整列宽
- 工作计划/商机跟单/要客拜访新增筛选栏(客户经理 + 状态)
- 汇总栏 chip 可点击筛选,支持重置与计数
- 新增截图导出功能(dom-to-image-more,像素级文字渲染)
- 截图范围从标题开始,自动隐藏操作按钮,5px白边
- 截图时页面闪烁动画反馈
- 全局 CSS 完善(列宽手柄、截图模式、捕获动画)
- 仪表盘 UI 细节优化 + 亮灯表标签调整
- 登录页 UI 改进

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-11 22:17:45 +08:00
v6ole deeafe239f feat: 双主题系统 — 编辑风 + 极简亮色
前端:
- 新增 Pinia theme store,localStorage 持久化,data-theme 切换
- App.vue: :root 新增 --font-*/--sidebar-* CSS 变量;新增 :root[data-theme=light] 完整配色(极简黑白金 + Noto Sans SC)
- DesktopLayout: 侧边栏 15 个变量化 + 顶栏主题切换按钮
- MobileLayout: 装饰色变量化
- Settings: 新增「界面主题」卡片
- 21 个组件 font-family 统一替换为 var(--font-*)
- 新增 utils/managerColor.ts — 12 色调色板 + 自适应文字色 + 后端自定义色优先
- WorkPlans/MiniBusiness/KeyVisits/CustomerManage: 客户经理标签色统一走共享工具
- UserManage: 编辑弹窗新增颜色选择器(仅 manager)
- LightBoard: 去标题星标图标
- ManagerWorkspace/WorkPlans/MiniBusiness/KeyVisits: 删除按钮改为实心红底白字

后端:
- User 模型新增 color 列 + lifespan 自动迁移
- users API 支持 color 字段读写
- UserOut schema 新增 color

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 16:03:13 +08:00
v6ole 336b60b3af feat: 系统设置新增通报时间配置 + 亮灯表30天滚动窗口 + 相关人员文案统一 + 周报数据补全
- 亮灯表从自然月改为30天滚动窗口(30/60天阈值),解决月底覆盖断崖
- 系统设置新增「定时通报时间」选择器,支局长可配置每日企微通报时刻
- 新增 SystemConfig 键值表 + scheduler_manager APScheduler 运行时重调度
- 拜访记录/导入模板/弹窗表单统一「同访人员」→「相关人员」
- 仪表盘周报数据补全:companion_names_resolved、visitor_name/phone 等字段
- 新增 weekly_report_template.xlsx 模板文件

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 11:51:07 +08:00
v6ole cd47d1aed5 feat: 亮灯表30天滚动窗口 + 相关人员文案统一 + 周报数据补全
- light_board.classify: 自然月 → 30/60天滚动窗口 (≤30天🟢, 31-60天🟡, 61+天🔴)
- LightBoard.vue: 4处文案同步 (近30天已拜访 / 上次 / 个周期)
- dashboard.get_weekly_report: 补全 companion_names_resolved + visitor_name + visitor_phone + edit_log
- 导入/导出模板 + VisitForm + ManagerWorkspace: 同访人员 → 相关人员
2026-07-02 16:58:29 +08:00
v6ole 3285e22142 feat: 导入增强 + 客户合并 + 同伴自定义 + 性能优化
=== 导入系统全面增强 ===
- 5 Sheet 完整导入:拜访/计划/商机/要客/纪要
- 同访人智能解析:中英文逗号/顿号/分号 → 系统用户UUID + 外部人员TEXT
- 客户自动创建:Excel中不存在的客户自动入库
- 模糊名称匹配:去空格 + 包含关系纠错
- manager_id 修正:导入时用客户分配的经理(非Excel列/非导入人)
- 模板更新:Sheet5「客户经理」→「填报人」,同访人示例含逗号分隔

=== 客户合并功能 ===
- PUT 改名碰撞检测 → 409 + 合并预览
- GET merge-preview / POST merge 端点
- 事务级迁移:Visit/WorkPlan/MiniBusiness/KeyVisit/联系人/分配
- 去重逻辑:联系人(name+phone)、分配(manager+role)
- last_visit_date 取最大值

=== 拜访记录完善 ===
- visits 新增 companion_names TEXT[] 列
- 同访人支持自定义输入(外部人员),el-select allow-create
- 移动端 VisitForm + PC端 ManagerWorkspace 统一
- 周报「客户经理」→「相关人员」(创建人+同访人)
- 纪要「客户经理」→「填报人」
- 删除拜访后重新计算 customer.last_visit_date

=== 客户选择放开 ===
- 客户经理可看到全部客户(不再限自己分配的)
- 拜访时客户下拉返回全量

=== 前端性能优化 ===
- Element Plus 按需加载 (unplugin-vue-components + unplugin-element-plus)
- 周报照片URL并行请求 (Promise.all)
- onMounted 三路并行 (loadReport + dropdowns + AI)
- nginx Cache-Control: immutable for /assets/
- Google Fonts preconnect hints
- 图片上传前 Canvas 压缩 (max 1920px, quality 0.8)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-29 22:25:04 +08:00
v6ole b343970ecc feat: 三项联动 — 计划过期提醒 + 拜访自动关闭 + 批量制定
1. 计划过期自动提醒:
   - scheduler.py 新增 check_overdue_plans()
   - 每日 9:00 检查过期计划(plan_date < today, status=计划中)
   - 按经理汇总 → 企微推送提醒消息

2. 拜访后自动关闭计划:
   - create_visit 后自动将匹配的计划标记为「已完成」
   - 条件: customer_id 匹配 + status=计划中 + plan_date <= visit_date

3. 亮灯表批量制定:
   - 红灯/黄灯卡片右上角 ☐ 复选框
   - 勾选后出现「批量制定计划」按钮
   - 弹窗统一设置日期+内容 → 批量 POST

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-27 12:28:36 +08:00
v6ole 0e25201ede feat: 亮灯表重构 — 点击卡片弹窗替代跳转周报
后端 light_board.py:
- 每张客户卡片返回 plans[] (活跃工作计划) + recent_visits[] (最近2条)
- 计划过期检测 (plan_overdue)

前端 LightBoard.vue:
- 点击卡片 → el-dialog 弹窗 (不再跳转周报)
- 绿灯: 显示最近拜访记录
- 黄/红灯: 客户信息 + 快速制定拜访计划表单
- 卡片显示计划标签 (📅正常 / ⚠过期闪烁)
- 弹窗底部保留「查看周报记录」链接

计划过期逻辑: plan_date < 当月首日 且 status=计划中

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-27 12:23:58 +08:00
v6ole 2bb85bf45c fix: UserManage.vue 补充丢失的 useAuthStore 导入
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-27 12:09:04 +08:00
v6ole 015af18d3c fix: 用户编辑弹窗修复 — footer 简化 + 删除后关闭弹窗
- footer 用 span 替代 flex div (避免 element-plus 兼容问题)
- 删除成功后自动关闭弹窗

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-27 12:02:04 +08:00
v6ole 7e31606b45 refine: 删除按钮移到编辑弹窗 — 统一操作入口
- 表格行内只保留「编辑」按钮
- 编辑弹窗左下角红色「删除用户」按钮(仅支局长可见)
- 避免表格行内误删

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-27 11:56:03 +08:00
v6ole 04aaff8851 feat: 支局长可删除用户 + 清理重复蓝浩博
- 新增 DELETE /api/users/{id} (require_director, 不能删自己)
- 用户管理页新增「删除」按钮(仅支局长可见)
- 删除重复的分管领导蓝浩博

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-27 11:53:11 +08:00
v6ole 447932a8e6 fix: 新建表单客户选择器 — 提高 page_size 上限
- 前端三个页面: page_size 100 → 500(覆盖所有客户)
- 后端 customers API: page_size 上限 100 → 1000,默认 25 → 100

支局长/分管领导新建时下拉框可加载全量客户。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-27 11:47:00 +08:00
v6ole e99d0aec8a Revert "feat: 支局长/领导可指定客户经理创建记录"
This reverts commit a4904973bf.
2026-06-27 11:41:17 +08:00
v6ole a4904973bf feat: 支局长/领导可指定客户经理创建记录
后端 schema: WorkPlanCreate/MiniBusinessCreate/KeyVisitCreate 新增可选 manager_id
后端 API: create 端点支持 manager_id 参数(留空则默认本人)
前端: 三个页面新建弹窗新增「客户经理」下拉(仅支局长/领导可见)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-27 11:17:54 +08:00
v6ole 576715f990 feat: 网站 favicon — 公文包图标
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 17:21:18 +08:00
v6ole df5b661d5f fix: 填报统计不再限定 role=manager,只看 require_report=True
之前: role='manager' AND require_report=True → 只统计客户经理
现在: require_report=True → 统计所有需填报的用户

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 16:44:59 +08:00
v6ole a580eefd14 feat: 三项改进 — require_report + 拜访联动 + 解绑移到弹窗
1. 用户管理页 UX 优化:
   - 表格内「解绑」按钮移入编辑弹窗(clearable 输入框)
   - 新增「需要填写周报」el-switch 开关
   - 新增「填报」列显示是否计入统计

2. require_report 字段 (DB + API + 联动):
   - users 表新增 require_report BOOLEAN DEFAULT TRUE
   - 调度器/仪表盘进度/立即检查 均排除 require_report=false
   - UserOut schema + UpdateUserRoleRequest 同步更新

3. 拜访联动客户 last_visit:
   - customers 表新增 last_visit_date / last_visit_manager_id
   - 创建拜访记录时自动更新对应客户的最后拜访信息
   - 亮灯表可据此精确判断客户拜访状态

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 16:36:27 +08:00
v6ole 989c41da8c fix: 填报检查统计所有经理 (不限于已绑定企微)
之前只统计 wecom_userid IS NOT NULL 的经理导致始终 0/0,
现在统计全员,仅推送消息时跳过未绑定用户。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 16:10:17 +08:00
v6ole e69d962521 fix: 手动切换布局 — 仅首页首次加载自动适配
检测 _from.name 区分「首次加载」和「应用内导航」:
- 首次: 手机→/m, 桌面→/
- 手动点击切换按钮: 完全尊重用户选择, 不拦截

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 16:03:54 +08:00
v6ole b88b29b745 fix: 手动切换布局失效 — 仅首页自动适配
- 只对根路径 / 做手机→/m 跳转,不再拦截桌面访问 /m
- 支持 ?view=desktop 或 ?view=mobile 强制覆盖自动检测
- 手动输入的具体路径完全不受影响

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 15:56:44 +08:00
v6ole c71a2ac88e perf: Vite 分包优化 — Element Plus / Vue 独立 chunk
build.rollupOptions.manualChunks:
- element-plus → ~923 kB (独立缓存,首次加载后 HIT)
- vue-vendor → ~110 kB (Vue + Router + Pinia)
- index (app code) → ~59 kB (从 ~1 MB 缩减)

首屏加载显著减少,二次访问几乎零加载。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 15:40:07 +08:00
v6ole 861a4cecff feat: 客户查重前端集成
- 新建客户时输入名称失焦后自动调用 /customers/check-duplicate
- 若有同名客户显示黄色警告条(含客户名+经理)
- 编辑模式不触发查重

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 15:37:44 +08:00
v6ole c4e8a3b790 feat: 富消息升级 — 催办用模板卡片 + 公告用 Markdown
scheduler.py:
- 未填报经理 → 模板卡片消息(标题+描述+「去填报」按钮)
- 支局长/领导 → Markdown 每日填报汇总(markdown)
- 文本消息作为降级方案

wecom.py API:
- remind: 模板卡片优先 → 降级文本
- announcement: Markdown 优先 → 降级文本

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 15:34:21 +08:00
v6ole 327637f94c feat: 移动端/桌面端自动适配
- utils/index.ts: 新增 isMobile() 检测 user-agent
- router: beforeEach 守卫中根据设备自动跳转
  - 手机访问 / 桌面路由 → 自动跳 /m
  - 桌面访问 /m 移动路由 → 自动跳 /

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 15:33:09 +08:00
v6ole ab3384d6a5 chore: 企微绑定流程收尾 — 回调日志 + 调试完成
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 15:24:33 +08:00
v6ole 313b25c631 fix: WecomBind.vue — 修复 auth.user 不存在的问题
auth store 暴露的是独立字段 (name/userId/role) 而非 user 对象,
导致 auth.user?.name 始终为 undefined。

修复:
- auth.name 替代 auth.user?.name
- 调用 /users/me 获取 wecom_userid (store 不存储该字段)
- window.location.href 替代 router.push (企微浏览器兼容)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 15:18:36 +08:00
v6ole 9d3a8ae57e fix: 绑定页用 window.location 跳转登录 (企微浏览器兼容)
router.push 在企微内置浏览器可能不触发跳转,
改用 window.location.href 强制整页跳转

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 15:09:31 +08:00
v6ole 19ebb85209 fix: 绑定页面登录态检查 + 登录后返回原页面
Login.vue:
- goCasdoorLogin() 前将 redirect 参数存入 sessionStorage
- 登录成功后检查 sessionStorage 跳回原页面
- wecomLogin 流程同样支持 returnUrl

WecomBind.vue:
- onMounted 检测未登录 → 跳转 /login?redirect=/wecom-bind?token=xxx
- 登录后自动回到绑定页面,auth.user 不再为空

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 13:14:26 +08:00
v6ole 7264184315 feat: 企微绑定重构 — 回调事件 + 支局长手动绑定/解绑
绑定流程改为 rural-optical-rectify 方案:
- 企微菜单「绑定账号」发送 click 事件(而非 OAuth 跳转)
- 回调 POST 接收事件 → 生成绑定 token → 推送绑定链接
- 前端 WecomBind.vue (/wecom-bind) 确认绑定
- 新增 POST /api/wecom/bind-confirm (JWT 保护)

支局长手动管理:
- 新增 PUT /api/users/{id}/wecom 绑定/解绑端点
- UserManage.vue 编辑对话框新增企微 UserID 字段
- 表格操作列新增「解绑」按钮
- 重复绑定检测

wecom.py 新增:
- in-memory bind token store (TTL 600s)
- send_text_card() 卡片消息方法
- access_token 属性暴露

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 12:10:38 +08:00
v6ole 1d2dacc3ab feat: 企微 API 代理 + 菜单部署修复
config.py:
- 新增 WECOM_API_BASE 环境变量 (默认 https://qyapi.weixin.qq.com)

wecom.py:
- 所有 API 调用改用 settings.WECOM_API_BASE (支持代理)
- 修复 _post_with_retry URL 拼接 bug (? vs &)
- 菜单名去掉 emoji (企微限制 16 字符)

api/wecom.py:
- setup-menu 端点菜单名修正

.env.example:
- 新增 WECOM_API_BASE 说明

当前 .env 配置: WECOM_API_BASE=https://api.v6ole.top
已验证: 通过代理获取 token , 菜单创建 

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 11:58:12 +08:00
v6ole da1f24c710 feat: 企微菜单管理 — create_menu / get_menu / delete_menu
wecom.py 新增:
- create_menu(buttons) — 创建/替换应用自定义菜单
- get_menu() — 获取当前菜单配置
- delete_menu() — 删除菜单

API 新增:
- POST /api/wecom/setup-menu — 一键部署标准菜单(开始填报 + 绑定账号)
- GET /api/wecom/menu — 查看当前菜单

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 11:36:16 +08:00
v6ole 5006642354 feat: 企微绑定入口 — BindWechat.vue + 路由
新增 /bind-wechat 公开路由:
- 页面加载后自动重定向到企微 OAuth 授权页
- scope=snsapi_base (静默授权,无需用户确认)
- 回调到 /login?wecom_code=xxx → 自动绑定

用法: 企微工作台应用主页设为 https://qj.dhdx.fun/bind-wechat

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 11:30:06 +08:00
v6ole ad0c894f93 feat: 企微测试消息端点 + 调度器 msgfmt 优化
- 新增 POST /api/wecom/test-message 测试端点(需 director)
- 调度器仅推已绑定 wecom_userid 的经理
- wecom.py 已支持 markdown/模板卡片消息

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 09:49:33 +08:00
v6ole eae7fdafb8 fix: 企微回调验证 — 返回纯文本而非 JSON
企微后台保存时要求响应为原始解密明文(Content-Type: text/plain),
FastAPI 默认 JSON 序列化会加引号导致验证失败。
改用 PlainTextResponse 返回纯文本。

验证: 本地 + 生产域名均返回 text/plain, 解密内容正确

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 09:46:19 +08:00
v6ole 36ba9338f1 feat: 企业微信 Phase 1 — token 续期 + 回调验证 + 调度器注册
wecom.py:
- Token 自动续期 (7200s 过期 + 60s 提前量 + 42001 重试)
- POST 请求自动重试 (最多 3 次,含速率限制退避)
- 新增 send_markdown_message() / send_template_card() 富消息方法

wecom.py API:
- GET /api/wecom/callback: 企微回调 URL 验证 (SHA1 签名 + AES 解密)
- POST /api/wecom/callback: 事件接收占位

scheduler.py:
- 仅推送给已绑定 wecom_userid 的经理
- 消息内容优化 (已填报/未填报人数统计)

main.py:
- APScheduler 注册每日 17:30 自动检查填报

已验证: WECOM_TOKEN 获取成功

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 09:37:13 +08:00
v6ole 72fb7524f3 docs: CLAUDE.md — 更新部署架构 + 工作页客户经理列
- 新增完成项: 工作页客户经理列+汇总栏, 云端部署
- 新增部署架构章节 (FRP 隧道/前端容器/OpenResty/端口表)
- 新增设计决策 #22 (云端部署)
- 更新验证状态表 (生产 URL/FRP/容器)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 09:21:41 +08:00
v6ole e71fa6e3ed chore: 生产部署适配 — nginx.conf 精简 + .gitignore 更新
- nginx.conf: 移除 /api/ 代理(改由 OpenResty 边缘路由),仅保留 SPA fallback
- .gitignore: 排除所有 .env.* (保留 .env.example),防止 .env.production 误提交

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 09:02:46 +08:00
v6ole c2a62c29cd feat: 工作计划/商机/要客页面新增客户经理列与汇总栏
- 表格新增「客户经理」列,暖灰色哈希标签,每人唯一颜色
- 页面顶部新增汇总栏,按经理分组显示各有多少条记录
- 汇总栏仅在有数据时显示,与亮灯表配色体系一致

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 08:30:19 +08:00
v6ole b3d355179f chore: 部署文档不纳入版本管理
- 从 git 移除 qiji部署交接文档.md 和 通用部署文档.md
- 更新 .gitignore 忽略所有 *部署文档.md/*部署交接文档.md
- 部署文档含敏感信息(服务器IP/端口/密码),应本地保存

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 08:30:06 +08:00
v6ole 8867e2f5fe docs: 通用部署文档 — FRP + OpenResty + Docker 平台化部署模板
包含:
- 完整端口规划表 (基础设施/项目后端/项目前端 三区间)
- 当前端口占用清单 (所有已用端口一目了然)
- 本机端口分配方案 (8000/8002/8004...)
- 新项目 7 步部署流程 (端口分配→FRP→后端→前端→OpenResty→DNS→验证)
- 前端 Dockerfile + nginx.conf 模板
- 日常运维速查 (FRP/容器/OpenResty/后端)
- 故障排查表
- 已有项目索引

特点:所有项目特定值均用 {PLACEHOLDER} 标记,复制即用

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 08:29:05 +08:00
v6ole 4ae8c69193 docs: qiji 部署交接文档 — FRP + OpenResty + Docker 部署方案
基于 H3ConuMS2 部署模式,适配 qiji 项目:
- 新增 qiji-backend FRP 隧道 (8002→18061)
- 前端容器化部署 (qiji-frontend:18063)
- OpenResty 站点配置 (qj.dhdx.fun)
- 复用已有 Casdoor / PostgreSQL / MinIO
- 含部署检查清单和故障排查指南
- 所有敏感凭据已替换为占位符

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 08:27:31 +08:00
v6ole ce94c6b2ba refine: 缓存的 AI 摘要默认折叠,点击展开
- aiExpanded ref: 缓存加载=false, 新生成=true
- 标题栏可点击切换折叠/展开
- 折叠态显示 ▶ + '点击展开' 提示
- 展开态显示 ▼ + 复制按钮
- 卡片边框折叠时降为 warm-border
2026-06-25 12:05:33 +08:00
v6ole 5b38246801 feat: AI 摘要持久化 — DB 缓存 + 自动加载
后端:
- 新增 ai_summaries 表 (week_start/week_end/generated_by/summary)
- get_cached_summary(): 按周+用户查缓存
- delete_cached_summary(): 生成前清理旧缓存
- generate_summary(): 自动保存到 DB
- API: GET /ai/summary(加载) POST(生成) DELETE(清除)

前端:
- onMounted 自动 GET 缓存摘要, 有则直接展示
- 按钮: 无缓存→'AI 生成摘要', 有缓存→'重新生成'
- 标题栏显示生成时间戳
2026-06-25 11:45:04 +08:00
v6ole e681beba24 chore: 更新 CLAUDE.md — 亮灯表+AI摘要 v0.2
- 亮灯表: 几何圆形图标、权限控制、周报联动
- AI 摘要: DeepSeek V4 配置、亮灯表数据注入、复制兼容
- 新增关键设计决策 #20 #21
2026-06-24 19:45:04 +08:00
v6ole 5b50e7ec27 fix: AI 摘要复制失败 — HTTP 环境 Clipboard API 不可用
navigator.clipboard 需要安全上下文(HTTPS/localhost),
内网 HTTP 部署时自动降级为 textarea + execCommand('copy')
2026-06-24 19:39:28 +08:00
v6ole 24c7aff093 fix: 恢复 AI_MODEL 为 deepseek-v4-flash
DeepSeek API 当前模型:
- deepseek-v4-flash / deepseek-v4-pro (2026-04-24 发布)
- deepseek-chat / deepseek-reasoner 是旧别名, 2026-07-24 废弃

.env.example 同步添加 DeepSeek 配置注释
2026-06-24 19:33:06 +08:00
v6ole d07311d5a5 fix: AI 摘要 DeepSeek 配置修复
1. generate_summary 调用 get_light_board 补传 user_id/role
   (否则亮灯表数据为空, 摘要缺少覆盖分析)
2. AI_API_URL: /anthropic → /v1/chat/completions
   (原先 URL 不是 DeepSeek 标准 OpenAI 兼容端点)
3. AI_MODEL: deepseek-v4-flash → deepseek-chat
   (原模型名不是已知的 DeepSeek 模型)
2026-06-24 19:19:57 +08:00
v6ole dd69e902ec feat: 亮灯表点击客户 → 周报自动按客户筛选
- WeeklyReport onMounted 新增 customer_id 路由参数处理
- 筛选栏新增「按客户筛选」下拉框(filterable搜索)
- 加载客户列表供下拉选项
2026-06-24 19:07:28 +08:00
v6ole 1d0f412e4a fix: 亮灯表权限控制 — 客户经理只看自己
get_light_board 新增 user_id/role 参数:
- director/leader: 看全部经理 + 未分配客户
- manager: 只看自己, 不显示未分配客户
- API 层从 current_user 提取身份信息透传
2026-06-24 17:22:05 +08:00
v6ole a8ed71fbff refine: 亮灯表视觉优化
- 用几何圆形图标代替文字'亮/临/灭':
  ● 实心圆(已拜访) / ◐ 半圆(临期) / ○ 空心圆(未拜访) / ◌ 虚线圆(未分配)
- 客户名称全量显示, 允许换行, card 拓宽至 220px
- 仪表盘标签从 '🟢 亮灯' 改为 '已拜访/临期/未拜访/未分配'
2026-06-24 16:07:15 +08:00
v6ole 305ee5df09 feat: 客户亮灯表 + AI 周报摘要 — v0.2
亮灯表:
- 四色覆盖矩阵: 绿亮灯/黄临期/红灭灯/灰未分配
- 按客户经理折叠卡片流, 覆盖率进度条, 团队总览统计
- 红灯客户显示连续未拜访月份, 未分配客户专区
- 后端: light_board.py service + GET /api/dashboard/light-board
- 前端: LightBoard.vue + 路由 /light-board + 汇总侧边栏

AI 周报摘要:
- 接入 OpenAI 兼容大模型, 注入拜访数据+亮灯表覆盖数据
- 四段式结构化输出: 概况/需求/覆盖分析/建议
- 一键生成+Markdown渲染+复制纯文本
- 支局长/分管领导专用, 支持配置内部模型
- 后端: ai_summary.py service + POST /api/ai/summary
- 前端: WeeklyReport 集成按钮+结果面板
- 新增配置: AI_API_URL / AI_API_KEY / AI_MODEL

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 12:03:00 +08:00
146 changed files with 17076 additions and 906 deletions
+7
View File
@@ -195,8 +195,15 @@ Thumbs.db
# Env
.env
.env.*
!.env.example
# Deployment docs (contain credentials, not for git)
*部署交接文档.md
*部署文档.md
# Claude
CLAUDE.md
.claude/
.cc-connect/
+53
View File
@@ -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 .
+275
View File
@@ -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 fallbackAPI 路由由 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 参数模糊匹配
+7 -4
View File
@@ -21,7 +21,7 @@
|---|------|
| 后端 | FastAPI + SQLAlchemy 2.0 (async) + asyncpg |
| 数据库 | PostgreSQL |
| 文件存储 | MinIO (预签名直传) |
| 文件存储 | MinIO(后端受控图片上传) |
| 认证 | Casdoor OIDC + JWT |
| 前端 | Vue 3 + Vite + TypeScript + Element Plus |
| 状态管理 | Pinia |
@@ -98,14 +98,17 @@ npm run dev -- --host 0.0.0.0
# 页面 → http://localhost:5173
```
### 一键部署
### 本地集成环境
```bash
# 先在 .env 中设置 POSTGRES_USER、POSTGRES_PASSWORD、MINIO_ROOT_USER、
# MINIO_ROOT_PASSWORD、SECRET_KEY 和固定版本的 MINIO_IMAGE
docker-compose up -d
# 后端 :8000 + PostgreSQL :5432 + MinIO :9000/:9001
# 前端需单独部署到 nginx,见 frontend/Dockerfile
# 服务仅绑定到 127.0.0.1;前端需单独部署到 nginx,见 frontend/Dockerfile
```
生产环境必须使用批准的部署流水线、密钥管理、HTTPS 反向代理和上线审批流程;不得直接将本 Compose 文件作为生产部署方案。
## 环境变量
| 配置项 | 说明 | 必须 |
+25
View File
@@ -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
View File
@@ -5,11 +5,13 @@
# ── 应用 ──
APP_NAME=企迹-政企周报管理系统
DEBUG=true
SECRET_KEY=change-me-to-a-random-string-in-production
ENVIRONMENT=development
DEBUG=false
# Use a random value of at least 32 characters. Never use a default in production.
SECRET_KEY=replace-with-a-random-secret-of-at-least-32-characters
# ── PostgreSQL (已有基础设施,填写实际连接信息) ──
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/qiji
DATABASE_URL=postgresql+asyncpg://qiji_app:replace-with-a-strong-password@localhost:5432/qiji
# ── JWT ──
JWT_ALGORITHM=HS256
@@ -25,10 +27,11 @@ CASDOOR_APPLICATION=qiji-weekly-report
# ── MinIO (已有,填写实际部署地址和密钥) ──
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_ACCESS_KEY=qiji-app
MINIO_SECRET_KEY=replace-with-a-strong-minio-secret
MINIO_BUCKET=qiji-photos
MINIO_SECURE=false
# Set false only when connecting to a local development MinIO instance over the internal Docker network.
MINIO_SECURE=true
# ── 企业微信 (需在企微管理后台创建自建应用后获取) ──
WECOM_CORP_ID=your-corp-id
@@ -36,6 +39,16 @@ WECOM_AGENT_ID=your-agent-id
WECOM_SECRET=your-app-secret
WECOM_TOKEN=your-token
WECOM_ENCODING_AES_KEY=your-encoding-aes-key
# 企微 API 代理(解决 IP 白名单问题),默认直连 https://qyapi.weixin.qq.com
WECOM_API_BASE=https://qyapi.weixin.qq.com
# ── AI / LLM (OpenAI 兼容接口,用于周报摘要) ──
# 支持 OpenAI / DeepSeek / 通义千问 / 本地 Ollama 等
# DeepSeek: AI_API_URL=https://api.deepseek.com/v1/chat/completions AI_MODEL=deepseek-v4-flash
AI_API_URL=https://api.openai.com/v1/chat/completions
AI_API_KEY=sk-your-api-key
AI_MODEL=gpt-4o
AI_MAX_TOKENS=2000
# ── CORS (前端地址) ──
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
+4 -1
View File
@@ -1,5 +1,8 @@
FROM python:3.12-slim
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /app
COPY requirements.txt .
@@ -9,4 +12,4 @@ COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["sh", "-c", "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8000"]
+8
View File
@@ -1,10 +1,18 @@
import asyncio
import sys
from logging.config import fileConfig
from pathlib import Path
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# Ensure Alembic works both in the container and when invoked from a virtualenv
# on a server where the console-script directory would otherwise be sys.path[0].
BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(BACKEND_ROOT))
from app.database import Base
from app.models import * # noqa: import all models
from app.config import settings
@@ -0,0 +1,57 @@
"""Establish the tracked baseline schema and replace application-startup DDL.
Revision ID: 20260728_01
Revises:
Create Date: 2026-07-28
"""
from collections.abc import Sequence
from alembic import op
from app.database import Base
import app.models # noqa: F401 # Register every model with Base.metadata.
revision: str = "20260728_01"
down_revision: str | None = None
branch_labels: Sequence[str] | None = None
depends_on: Sequence[str] | None = None
def upgrade() -> None:
"""Create the current schema and upgrade tables created by older app versions."""
bind = op.get_bind()
Base.metadata.create_all(bind=bind, checkfirst=True)
# These columns were historically added in application startup code. Keep this
# compatibility path so existing deployments can adopt Alembic safely.
statements = (
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS remarks TEXT DEFAULT ''",
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_name VARCHAR(50) DEFAULT ''",
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_phone VARCHAR(20) DEFAULT ''",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS require_report BOOLEAN DEFAULT TRUE",
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_date DATE",
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_manager_id UUID",
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS companion_names TEXT[] DEFAULT '{}'",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS color VARCHAR(7)",
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'",
"ALTER TABLE daily_notes ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'",
"ALTER TABLE work_plans ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'",
"ALTER TABLE mini_business ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'",
"ALTER TABLE key_visits ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'",
"CREATE UNIQUE INDEX IF NOT EXISTS uq_users_wecom_userid "
"ON users (wecom_userid) WHERE wecom_userid IS NOT NULL",
)
for statement in statements:
op.execute(statement)
def downgrade() -> None:
"""Remove only objects introduced by the tracked security hardening revision.
The historical base tables pre-date Alembic and may contain production data, so
they are deliberately not dropped by a downgrade operation.
"""
op.execute("DROP INDEX IF EXISTS uq_users_wecom_userid")
op.execute("DROP TABLE IF EXISTS wecom_bind_tokens")
+1
View File
@@ -0,0 +1 @@
"""Alembic migration revisions for the Qiji backend."""
+74
View File
@@ -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}
+80
View File
@@ -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
View File
@@ -34,6 +34,7 @@ async def casdoor_login(req: CasdoorLoginRequest, db: AsyncSession = Depends(get
user_id=str(user.id),
name=user.name,
role=user.role,
theme=user.theme or "editorial",
)
@@ -56,38 +57,19 @@ async def wecom_login(req: WecomLoginRequest, db: AsyncSession = Depends(get_db)
user_id=str(user.id),
name=user.name,
role=user.role,
theme=user.theme or "editorial",
)
# Not bound yet — return a redirect URL to Casdoor for binding
casdoor_auth_url = (
f"{settings.CASDOOR_ENDPOINT}/login/oauth/authorize"
f"?client_id={settings.CASDOOR_CLIENT_ID}"
f"&response_type=code"
f"&redirect_uri={settings.CORS_ORIGINS[0]}/bind-wecom"
f"&scope=openid+profile"
f"&state={wecom_userid}"
raise HTTPException(
status_code=403,
detail="企业微信账号尚未绑定。请在企业微信内使用一次性绑定链接完成绑定。",
)
return {"need_bind": True, "casdoor_url": casdoor_auth_url, "wecom_userid": wecom_userid}
@router.post("/bind-wecom")
async def bind_wecom(req: WecomBindRequest, db: AsyncSession = Depends(get_db)):
"""Bind Casdoor account with WeChat Work userid after OIDC redirect."""
userinfo = await exchange_casdoor_code(req.casdoor_code)
if not userinfo:
raise HTTPException(status_code=400, detail="Failed to exchange casdoor code")
casdoor_id = userinfo.get("sub") or userinfo.get("id")
wecom_userid = req.wecom_userid or userinfo.get("state", "")
user = await bind_wecom_user(db, casdoor_id, wecom_userid)
if not user:
raise HTTPException(status_code=404, detail="User not found")
token = build_token_for_user(user)
return TokenResponse(
access_token=token,
user_id=str(user.id),
name=user.name,
role=user.role,
"""Deprecated unsafe binding flow. Use /wecom/bind-confirm with a one-time token."""
raise HTTPException(
status_code=410,
detail="该绑定接口已停用,请使用企业微信一次性绑定链接。",
)
+256 -21
View File
@@ -1,18 +1,26 @@
import io
import uuid as uuid_mod
from uuid import uuid4
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_
from sqlalchemy import select, or_, update
from sqlalchemy.orm import selectinload
from pydantic import BaseModel
import openpyxl
from app.database import get_db
from app.middleware.auth import get_current_user, require_director, require_any_role
from app.models.customer import Customer
from app.models.customer_contact import CustomerContact
from app.models.customer_assignment import CustomerAssignment
from app.models.visit import Visit
from app.models.work_plan import WorkPlan
from app.models.mini_business import MiniBusiness
from app.models.key_visit import KeyVisit
from app.models.daily_note import DailyNote
from app.models.user import User
from app.utils.audit import log_audit
from app.schemas.customer import (
CustomerCreate, CustomerUpdate, CustomerOut, CustomerListOut, CustomerListResponse,
ContactCreate, ContactOut, AssignmentCreate, AssignmentOut, BatchAssignRequest,
@@ -43,25 +51,19 @@ async def list_customers(
industry: Optional[str] = Query(None),
service: Optional[str] = Query(None),
manager_id: Optional[str] = Query(None),
customer_type: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(25, ge=1, le=100),
page_size: int = Query(100, ge=1, le=1000),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""List customers with filters and pagination. Managers only see their assigned."""
"""List customers with filters and pagination. All roles see all customers."""
from sqlalchemy import func
base_query = select(Customer)
if current_user["role"] == "manager":
base_query = base_query.where(or_(
Customer.id.in_(
select(CustomerAssignment.customer_id).where(
CustomerAssignment.manager_id == current_user["user_id"]
)
),
Customer.created_by == current_user["user_id"],
))
if customer_type:
base_query = base_query.where(Customer.customer_type == customer_type)
if industry:
base_query = base_query.where(Customer.industry.ilike(f"%{industry}%"))
if service:
@@ -91,7 +93,7 @@ async def list_customers(
# Paginate
offset = (page - 1) * page_size
query = base_query.order_by(Customer.name).offset(offset).limit(page_size)
query = base_query.order_by(Customer.customer_type, Customer.name).offset(offset).limit(page_size)
result = await db.execute(query)
items = result.scalars().all()
@@ -101,10 +103,14 @@ async def list_customers(
select(CustomerAssignment.customer_id, User.name)
.join(User, CustomerAssignment.manager_id == User.id)
.where(CustomerAssignment.customer_id.in_([c.id for c in items]), CustomerAssignment.role == "primary")
.order_by(CustomerAssignment.customer_id)
)
mgr_map = {str(cid): name for cid, name in mgr_result.all()}
mgr_map: dict[str, list] = {}
for cid, name in mgr_result.all():
mgr_map.setdefault(str(cid), []).append(name)
for item in items:
item.primary_manager_name = mgr_map.get(str(item.id), None)
names = mgr_map.get(str(item.id), [])
item.primary_manager_name = "".join(names) if names else None
return CustomerListResponse(
items=items,
@@ -401,6 +407,10 @@ async def create_customer(
))
await db.commit()
# Audit log
await log_audit(db, "customer", customer.id, customer.name, "create", current_user["user_id"], current_user["name"])
await db.commit()
result = await db.execute(
select(Customer).where(Customer.id == customer.id).options(selectinload(Customer.contacts))
)
@@ -425,6 +435,28 @@ async def update_customer(
update_data = data.model_dump(exclude_unset=True)
assignee_id = update_data.pop("assignee_id", None) # Handle separately
# ── Name collision detection (for merge) ──
new_name = update_data.get("name")
if new_name and new_name.strip() != customer.name:
existing = await db.execute(
select(Customer).where(
Customer.name == new_name.strip(),
Customer.id != customer.id,
)
)
target = existing.scalar_one_or_none()
if target:
# Return 409 with merge preview — frontend should show merge dialog
preview = await _build_merge_preview(db, str(customer.id), str(target.id))
raise HTTPException(status_code=409, detail={
"message": "名称冲突 — 同名客户已存在",
"source_id": str(customer.id),
"source_name": customer.name,
"target_id": str(target.id),
"target_name": target.name,
"preview": preview,
})
for key, value in update_data.items():
setattr(customer, key, value)
@@ -432,17 +464,16 @@ async def update_customer(
if assignee_id:
if current_user["role"] != "director":
raise HTTPException(status_code=403, detail="Only director can change manager assignment")
import uuid as uuid_mod
assign_result = await db.execute(
select(CustomerAssignment).where(
CustomerAssignment.customer_id == customer.id,
CustomerAssignment.role == "primary",
)
)
existing = assign_result.scalar_one_or_none()
if existing:
existing.manager_id = assignee_id
existing.assigned_by = uuid_mod.UUID(current_user["user_id"])
existing_a = assign_result.scalar_one_or_none()
if existing_a:
existing_a.manager_id = assignee_id
existing_a.assigned_by = uuid_mod.UUID(current_user["user_id"])
else:
db.add(CustomerAssignment(
customer_id=customer.id, manager_id=assignee_id,
@@ -451,6 +482,10 @@ async def update_customer(
await db.commit()
# Audit log
await log_audit(db, "customer", uuid_mod.UUID(customer_id), customer.name, "update", current_user["user_id"], current_user["name"])
await db.commit()
result = await db.execute(
select(Customer).where(Customer.id == customer.id).options(selectinload(Customer.contacts))
)
@@ -467,12 +502,182 @@ async def delete_customer(
customer = result.scalar_one_or_none()
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
cust_name = customer.name
await db.delete(customer)
await db.commit()
# Audit log
await log_audit(db, "customer", uuid_mod.UUID(customer_id), cust_name, "delete", current_user["user_id"], current_user["name"])
await db.commit()
return {"detail": "deleted"}
# ── Contacts ──
# ── Customer Merge ──
class MergePreviewOut(BaseModel):
source_id: str
source_name: str
target_id: str
target_name: str
visits: int = 0
work_plans: int = 0
mini_business: int = 0
key_visits: int = 0
contacts: int = 0
assignments: int = 0
note: str = ""
class MergeRequest(BaseModel):
target_id: str
async def _build_merge_preview(db: AsyncSession, source_id: str, target_id: str) -> dict:
"""Count all records that would be migrated from source to target."""
sid = uuid_mod.UUID(source_id)
tid = uuid_mod.UUID(target_id)
visits = (await db.execute(
select(select(Visit).where(Visit.customer_id == sid).subquery()).with_only_columns(
__import__('sqlalchemy').func.count()
)
)).scalar() or 0
# Simpler: count directly
from sqlalchemy import func as sa_func
visits = (await db.execute(select(sa_func.count()).select_from(Visit).where(Visit.customer_id == sid))).scalar() or 0
plans = (await db.execute(select(sa_func.count()).select_from(WorkPlan).where(WorkPlan.customer_id == sid))).scalar() or 0
mini = (await db.execute(select(sa_func.count()).select_from(MiniBusiness).where(MiniBusiness.customer_id == sid))).scalar() or 0
kv = (await db.execute(select(sa_func.count()).select_from(KeyVisit).where(KeyVisit.customer_id == sid))).scalar() or 0
contacts = (await db.execute(select(sa_func.count()).select_from(CustomerContact).where(CustomerContact.customer_id == sid))).scalar() or 0
assignments = (await db.execute(select(sa_func.count()).select_from(CustomerAssignment).where(CustomerAssignment.customer_id == sid))).scalar() or 0
# Check for assignment conflicts
target_assigns = await db.execute(
select(CustomerAssignment.manager_id).where(CustomerAssignment.customer_id == tid)
)
target_managers = {str(row[0]) for row in target_assigns.all()}
source_assigns = await db.execute(
select(CustomerAssignment).where(CustomerAssignment.customer_id == sid)
)
conflict_note = ""
for sa in source_assigns.scalars().all():
if str(sa.manager_id) in target_managers:
conflict_note = f"目标客户已有相同经理的分配,将去重"
return {
"source_id": source_id,
"source_name": "",
"target_id": target_id,
"target_name": "",
"visits": visits,
"work_plans": plans,
"mini_business": mini,
"key_visits": kv,
"contacts": contacts,
"assignments": assignments,
"note": conflict_note,
}
@router.get("/{customer_id}/merge-preview")
async def get_merge_preview(
customer_id: str,
target_id: str = Query(...),
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Preview merge: show what data would be moved from source to target."""
source = (await db.execute(select(Customer).where(Customer.id == customer_id))).scalar_one_or_none()
target = (await db.execute(select(Customer).where(Customer.id == target_id))).scalar_one_or_none()
if not source or not target:
raise HTTPException(status_code=404, detail="客户不存在")
if customer_id == target_id:
raise HTTPException(status_code=400, detail="不能合并到自身")
preview = await _build_merge_preview(db, customer_id, target_id)
preview["source_name"] = source.name
preview["target_name"] = target.name
return preview
@router.post("/{customer_id}/merge")
async def execute_merge(
customer_id: str,
body: MergeRequest,
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Merge source customer into target. All related data is migrated, source is deleted."""
source_id = uuid_mod.UUID(customer_id)
target_id = uuid_mod.UUID(body.target_id)
if customer_id == body.target_id:
raise HTTPException(status_code=400, detail="不能合并到自身")
source = (await db.execute(select(Customer).where(Customer.id == source_id))).scalar_one_or_none()
target = (await db.execute(select(Customer).where(Customer.id == target_id))).scalar_one_or_none()
if not source or not target:
raise HTTPException(status_code=404, detail="客户不存在")
# Build preview for response
preview = await _build_merge_preview(db, customer_id, body.target_id)
# ── Transaction: migrate all FK references ──
for model, fk_col in [
(Visit, Visit.customer_id),
(WorkPlan, WorkPlan.customer_id),
(MiniBusiness, MiniBusiness.customer_id),
(KeyVisit, KeyVisit.customer_id),
]:
await db.execute(
update(model).where(fk_col == source_id).values(customer_id=target_id)
)
# Contacts: migrate, skip duplicates
source_contacts = (await db.execute(
select(CustomerContact).where(CustomerContact.customer_id == source_id)
)).scalars().all()
target_contacts = (await db.execute(
select(CustomerContact).where(CustomerContact.customer_id == target_id)
)).scalars().all()
existing_contact_keys = {(c.name, c.phone) for c in target_contacts}
for c in source_contacts:
if (c.name, c.phone) in existing_contact_keys:
await db.delete(c) # Skip duplicate
else:
c.customer_id = target_id
db.add(c)
# Assignments: migrate, skip same (manager_id, role) pairs
target_assigns = (await db.execute(
select(CustomerAssignment).where(CustomerAssignment.customer_id == target_id)
)).scalars().all()
existing_assign_keys = {(str(a.manager_id), a.role) for a in target_assigns}
source_assigns = (await db.execute(
select(CustomerAssignment).where(CustomerAssignment.customer_id == source_id)
)).scalars().all()
for a in source_assigns:
if (str(a.manager_id), a.role) in existing_assign_keys:
await db.delete(a) # Skip duplicate
else:
a.customer_id = target_id
db.add(a)
# Update target's last_visit_date to the max of both
if source.last_visit_date:
if not target.last_visit_date or source.last_visit_date > target.last_visit_date:
target.last_visit_date = source.last_visit_date
target.last_visit_manager_id = source.last_visit_manager_id
# Delete source customer
source_name = source.name
target_name = target.name
await db.delete(source)
await db.commit()
preview["result"] = f"已将「{source_name}」合并到「{target_name}"
return preview
@router.post("/{customer_id}/contacts", response_model=ContactOut)
async def add_contact(
@@ -530,3 +735,33 @@ async def assign_manager(
await db.commit()
await db.refresh(assignment)
return assignment
@router.get("/{customer_id}/overdue-plans")
async def get_overdue_plans(
customer_id: str,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Return the latest overdue (plan_date < today) and still '计划中' plan for a customer.
Used by the frontend to pre-fill visit forms with pending plan content."""
from app.utils.timezone import today_cst
today = today_cst()
result = await db.execute(
select(WorkPlan).where(
WorkPlan.customer_id == uuid_mod.UUID(customer_id),
WorkPlan.status == "计划中",
WorkPlan.plan_date < today,
).order_by(WorkPlan.plan_date.desc()).limit(1)
)
plan = result.scalar_one_or_none()
if not plan:
return {"plan": None}
return {
"plan": {
"id": str(plan.id),
"plan_content": plan.plan_content,
"plan_date": str(plan.plan_date),
"manager_id": str(plan.manager_id),
}
}
+17 -1
View File
@@ -11,6 +11,7 @@ from app.models.user import User
from app.schemas.daily_note import DailyNoteCreate, DailyNoteUpdate, DailyNoteOut
from app.utils.timezone import today_cst, parse_date
from app.utils.edit_log import compute_diff, append_entry, init_entry
from app.utils.audit import log_audit
router = APIRouter(prefix="/daily-notes", tags=["DailyNotes"])
@@ -30,6 +31,7 @@ async def _enrich(note: DailyNote, db: AsyncSession) -> dict:
async def list_notes(
date_from: Optional[str] = Query(None),
date_to: Optional[str] = Query(None),
search: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
@@ -40,6 +42,8 @@ async def list_notes(
query = query.where(DailyNote.note_date >= parse_date(date_from))
if date_to:
query = query.where(DailyNote.note_date <= parse_date(date_to))
if search:
query = query.where(DailyNote.content.ilike(f"%{search}%"))
query = query.order_by(DailyNote.note_date.desc(), DailyNote.created_at.desc()).limit(100)
result = await db.execute(query)
return [await _enrich(n, db) for n in result.scalars().all()]
@@ -80,7 +84,7 @@ async def create_note(
db: AsyncSession = Depends(get_db),
):
note = DailyNote(
manager_id=uuid.UUID(current_user["user_id"]),
manager_id=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else uuid.UUID(current_user["user_id"]),
note_date=parse_date(data.note_date),
category=data.category,
content=data.content,
@@ -90,6 +94,9 @@ async def create_note(
db.add(note)
await db.commit()
await db.refresh(note)
# Audit log
await log_audit(db, "daily_note", note.id, f"{note.note_date} 纪要", "create", current_user["user_id"], current_user["name"])
await db.commit()
return await _enrich(note, db)
@@ -108,6 +115,8 @@ async def update_note(
old_snapshot = {"note_date": str(note.note_date), "category": note.category, "content": note.content, "time_range": note.time_range}
update_data = data.model_dump(exclude_unset=True)
if current_user["role"] not in ("director", "leader"):
update_data.pop("manager_id", None)
if "note_date" in update_data and update_data["note_date"]:
update_data["note_date"] = parse_date(update_data["note_date"])
for k, v in update_data.items():
@@ -118,6 +127,9 @@ async def update_note(
append_entry(note, current_user["name"], changes, getattr(data, "edit_reason", None))
await db.commit()
await db.refresh(note)
# Audit log
await log_audit(db, "daily_note", note.id, f"{note.note_date} 纪要", "update", current_user["user_id"], current_user["name"])
await db.commit()
return await _enrich(note, db)
@@ -133,6 +145,10 @@ async def delete_note(
raise HTTPException(status_code=404, detail="Not found")
if current_user["role"] == "manager" and str(note.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
note_date = str(note.note_date)
await db.delete(note)
await db.commit()
# Audit log
await log_audit(db, "daily_note", uuid.UUID(note_id), f"{note_date} 纪要", "delete", current_user["user_id"], current_user["name"])
await db.commit()
return {"detail": "deleted"}
+13 -1
View File
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.auth import get_current_user
from app.services.dashboard import get_dashboard_stats, get_reporting_progress, get_weekly_report
from app.services.light_board import get_light_board
router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
@@ -18,7 +19,7 @@ async def dashboard_stats(
):
"""Get dashboard card statistics. Pass reference_date (YYYY-MM-DD) for historical weeks."""
ref = date.fromisoformat(reference_date) if reference_date else None
stats = await get_dashboard_stats(db, ref)
stats = await get_dashboard_stats(db, ref, current_user["user_id"], current_user["role"])
return stats
@@ -51,3 +52,14 @@ async def weekly_report(
filter_customer_id=uuid.UUID(customer_id) if customer_id else None,
reference_date=ref,
)
@router.get("/light-board")
async def light_board(
reference_date: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get customer visit coverage matrix. Managers see only themselves."""
ref = date.fromisoformat(reference_date) if reference_date else None
return await get_light_board(db, ref, uuid.UUID(current_user["user_id"]), current_user["role"])
+10 -3
View File
@@ -12,7 +12,7 @@ router = APIRouter(prefix="/import", tags=["Import"])
@router.get("/template")
async def download_weekly_report_template():
"""Download a 4-sheet weekly report import template."""
"""Download a 5-sheet weekly report import template."""
from openpyxl import Workbook
from openpyxl.styles import Font
@@ -22,9 +22,9 @@ async def download_weekly_report_template():
# Sheet 1: 每日拜访记录
ws1 = wb.active
ws1.title = "每日拜访记录"
ws1.append(["客户单位", "拜访日期", "拜访方式", "时间范围", "拜访人姓名", "拜访人电话", "沟通内容", "客户需求", "同访人员", "客户经理"])
ws1.append(["客户单位", "拜访日期", "拜访方式", "时间范围", "拜访人姓名", "拜访人电话", "沟通内容", "客户需求", "相关人员", "客户经理"])
for c in ws1[1]: c.font = header_font
ws1.append(["XX科技有限公司", "2026-06-23", "上门", "9:00-10:00", "韦柳柏", "13800000000", "沟通了解云桌面需求", "希望扩容", "", "韦矍森"])
ws1.append(["XX科技有限公司", "2026-06-23", "上门", "9:00-10:00", "韦柳柏", "13800000000", "沟通了解云桌面需求", "希望扩容", "韦柳柏, 张科长", "韦矍森"])
ws1.column_dimensions['A'].width = 20; ws1.column_dimensions['E'].width = 30; ws1.column_dimensions['F'].width = 20
# Sheet 2: 下周工作计划
@@ -48,6 +48,13 @@ async def download_weekly_report_template():
ws4.append(["XX科技有限公司", "重要", "拜访技术负责人确认方案", "未开始", "2026-07-01", "韦柳柏", "王局长", "韦矍森"])
ws4.column_dimensions['A'].width = 20; ws4.column_dimensions['C'].width = 30
# Sheet 5: 今日纪要
ws5 = wb.create_sheet("今日纪要")
ws5.append(["日期", "分类", "内容", "时间范围", "填报人"])
for c in ws5[1]: c.font = header_font
ws5.append(["2026-06-23", "内部会议", "参加云桌面项目方案讨论会", "14:00-16:00", "韦矍森"])
ws5.column_dimensions['A'].width = 15; ws5.column_dimensions['B'].width = 12; ws5.column_dimensions['C'].width = 40
output = io.BytesIO()
wb.save(output)
output.seek(0)
+54 -2
View File
@@ -2,7 +2,7 @@ import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy import select, or_
from app.database import get_db
from app.middleware.auth import get_current_user, require_any_role
from app.models.key_visit import KeyVisit
@@ -10,6 +10,7 @@ from app.models.customer import Customer
from app.models.user import User
from app.schemas.key_visit import KeyVisitCreate, KeyVisitUpdate, KeyVisitOut
from app.utils.edit_log import compute_diff, append_entry, init_entry
from app.utils.audit import log_audit
router = APIRouter(prefix="/key-visits", tags=["KeyVisits"])
@@ -36,6 +37,8 @@ async def _enrich(k: KeyVisit, db: AsyncSession) -> dict:
@router.get("/")
async def list_key_visits(
customer_id: Optional[str] = Query(None),
customer_type: Optional[str] = Query(None),
search: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
@@ -44,11 +47,32 @@ async def list_key_visits(
query = query.where(KeyVisit.manager_id == uuid.UUID(current_user["user_id"]))
if customer_id:
query = query.where(KeyVisit.customer_id == uuid.UUID(customer_id))
if customer_type:
query = query.join(Customer, KeyVisit.customer_id == Customer.id).where(Customer.customer_type == customer_type)
if search:
query = query.outerjoin(Customer, KeyVisit.customer_id == Customer.id).where(
or_(KeyVisit.description.ilike(f"%{search}%"), KeyVisit.planned_visitor.ilike(f"%{search}%"), Customer.name.ilike(f"%{search}%"))
)
query = query.order_by(KeyVisit.planned_date.desc()).limit(200)
result = await db.execute(query)
return [await _enrich(k, db) for k in result.scalars().all()]
@router.get("/{item_id}")
async def get_key_visit(
item_id: str,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(KeyVisit).where(KeyVisit.id == item_id))
k = result.scalar_one_or_none()
if not k:
raise HTTPException(status_code=404, detail="Not found")
if current_user["role"] == "manager" and str(k.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
return await _enrich(k, db)
@router.post("/")
async def create_key_visit(
data: KeyVisitCreate,
@@ -63,12 +87,27 @@ async def create_key_visit(
planned_date=data.planned_date,
planned_visitor=data.planned_visitor,
visit_target=data.visit_target,
manager_id=uuid.UUID(current_user["user_id"]),
manager_id=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else uuid.UUID(current_user["user_id"]),
)
init_entry(k, current_user["name"])
db.add(k)
await db.commit()
await db.refresh(k)
# Audit log
cust = await db.execute(select(Customer.name).where(Customer.id == k.customer_id))
await log_audit(db, "key_visit", k.id, f"{cust.scalar_one_or_none() or ''} ({k.urgency_level})", "create", current_user["user_id"], current_user["name"])
await db.commit()
return await _enrich(k, db)
@router.get("/{item_id}")
async def get_key_visit(item_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user)):
result = await db.execute(select(KeyVisit).where(KeyVisit.id == item_id))
k = result.scalar_one_or_none()
if not k:
raise HTTPException(status_code=404, detail="Not found")
if current_user["role"] == "manager" and str(k.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
return await _enrich(k, db)
@@ -87,6 +126,10 @@ async def update_key_visit(
old_snapshot = {"customer_id": str(k.customer_id), "urgency_level": k.urgency_level, "description": k.description, "progress_status": k.progress_status, "planned_date": k.planned_date, "planned_visitor": k.planned_visitor, "visit_target": k.visit_target}
update_data = data.model_dump(exclude_unset=True)
if current_user["role"] not in ("director", "leader"):
update_data.pop("manager_id", None)
if "manager_id" in update_data and update_data["manager_id"]:
update_data["manager_id"] = uuid.UUID(update_data["manager_id"])
for key, v in update_data.items():
setattr(k, key, v)
new_snapshot = {"customer_id": str(k.customer_id), "urgency_level": k.urgency_level, "description": k.description, "progress_status": k.progress_status, "planned_date": k.planned_date, "planned_visitor": k.planned_visitor, "visit_target": k.visit_target}
@@ -95,6 +138,10 @@ async def update_key_visit(
append_entry(k, current_user["name"], changes, getattr(data, "edit_reason", None))
await db.commit()
await db.refresh(k)
# Audit log
cust = await db.execute(select(Customer.name).where(Customer.id == k.customer_id))
await log_audit(db, "key_visit", k.id, f"{cust.scalar_one_or_none() or ''} ({k.urgency_level})", "update", current_user["user_id"], current_user["name"])
await db.commit()
return await _enrich(k, db)
@@ -110,6 +157,11 @@ async def delete_key_visit(
raise HTTPException(status_code=404, detail="Not found")
if current_user["role"] == "manager" and str(k.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
cust = await db.execute(select(Customer.name).where(Customer.id == k.customer_id))
entity_name = f"{cust.scalar_one_or_none() or ''} ({k.urgency_level})"
await db.delete(k)
await db.commit()
# Audit log
await log_audit(db, "key_visit", uuid.UUID(item_id), entity_name, "delete", current_user["user_id"], current_user["name"])
await db.commit()
return {"detail": "deleted"}
+130
View File
@@ -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,
)
+57 -2
View File
@@ -2,7 +2,7 @@ import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy import select, or_
from app.database import get_db
from app.middleware.auth import get_current_user, require_any_role
from app.models.mini_business import MiniBusiness
@@ -10,6 +10,7 @@ from app.models.customer import Customer
from app.models.user import User
from app.schemas.mini_business import MiniBusinessCreate, MiniBusinessUpdate, MiniBusinessOut
from app.utils.edit_log import compute_diff, append_entry, init_entry
from app.utils.audit import log_audit
router = APIRouter(prefix="/mini-business", tags=["MiniBusiness"])
@@ -35,6 +36,8 @@ async def _enrich(m: MiniBusiness, db: AsyncSession) -> dict:
@router.get("/")
async def list_mini_business(
customer_id: Optional[str] = Query(None),
customer_type: Optional[str] = Query(None),
search: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
@@ -43,11 +46,32 @@ async def list_mini_business(
query = query.where(MiniBusiness.manager_id == uuid.UUID(current_user["user_id"]))
if customer_id:
query = query.where(MiniBusiness.customer_id == uuid.UUID(customer_id))
if customer_type:
query = query.join(Customer, MiniBusiness.customer_id == Customer.id).where(Customer.customer_type == customer_type)
if search:
query = query.outerjoin(Customer, MiniBusiness.customer_id == Customer.id).where(
or_(MiniBusiness.product_type.ilike(f"%{search}%"), MiniBusiness.follow_up_detail.ilike(f"%{search}%"), Customer.name.ilike(f"%{search}%"))
)
query = query.order_by(MiniBusiness.expected_revenue_date.desc()).limit(200)
result = await db.execute(query)
return [await _enrich(m, db) for m in result.scalars().all()]
@router.get("/{item_id}")
async def get_mini_business(
item_id: str,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(MiniBusiness).where(MiniBusiness.id == item_id))
m = result.scalar_one_or_none()
if not m:
raise HTTPException(status_code=404, detail="Not found")
if current_user["role"] == "manager" and str(m.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
return await _enrich(m, db)
@router.post("/")
async def create_mini_business(
data: MiniBusinessCreate,
@@ -60,13 +84,28 @@ async def create_mini_business(
amount=data.amount,
follow_up_detail=data.follow_up_detail,
status=data.status,
manager_id=uuid.UUID(current_user["user_id"]),
manager_id=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else uuid.UUID(current_user["user_id"]),
expected_revenue_date=data.expected_revenue_date,
)
init_entry(m, current_user["name"])
db.add(m)
await db.commit()
await db.refresh(m)
# Audit log
cust = await db.execute(select(Customer.name).where(Customer.id == m.customer_id))
await log_audit(db, "mini_business", m.id, f"{cust.scalar_one_or_none() or ''} ({m.product_type})", "create", current_user["user_id"], current_user["name"])
await db.commit()
return await _enrich(m, db)
@router.get("/{item_id}")
async def get_mini_business(item_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user)):
result = await db.execute(select(MiniBusiness).where(MiniBusiness.id == item_id))
m = result.scalar_one_or_none()
if not m:
raise HTTPException(status_code=404, detail="Not found")
if current_user["role"] == "manager" and str(m.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
return await _enrich(m, db)
@@ -85,14 +124,25 @@ async def update_mini_business(
old_snapshot = {"customer_id": str(m.customer_id), "product_type": m.product_type, "amount": m.amount, "follow_up_detail": m.follow_up_detail, "status": m.status, "expected_revenue_date": m.expected_revenue_date}
update_data = data.model_dump(exclude_unset=True)
if current_user["role"] not in ("director", "leader"):
update_data.pop("manager_id", None)
if "manager_id" in update_data and update_data["manager_id"]:
update_data["manager_id"] = uuid.UUID(update_data["manager_id"])
for k, v in update_data.items():
setattr(m, k, v)
# Auto-clear expected revenue date when status becomes 已流失
if m.status == "已流失" and m.expected_revenue_date:
m.expected_revenue_date = ""
new_snapshot = {"customer_id": str(m.customer_id), "product_type": m.product_type, "amount": m.amount, "follow_up_detail": m.follow_up_detail, "status": m.status, "expected_revenue_date": m.expected_revenue_date}
changes = compute_diff(old_snapshot, new_snapshot)
if changes:
append_entry(m, current_user["name"], changes, getattr(data, "edit_reason", None))
await db.commit()
await db.refresh(m)
# Audit log
cust = await db.execute(select(Customer.name).where(Customer.id == m.customer_id))
await log_audit(db, "mini_business", m.id, f"{cust.scalar_one_or_none() or ''} ({m.product_type})", "update", current_user["user_id"], current_user["name"])
await db.commit()
return await _enrich(m, db)
@@ -108,6 +158,11 @@ async def delete_mini_business(
raise HTTPException(status_code=404, detail="Not found")
if current_user["role"] == "manager" and str(m.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
cust = await db.execute(select(Customer.name).where(Customer.id == m.customer_id))
entity_name = f"{cust.scalar_one_or_none() or ''} ({m.product_type})"
await db.delete(m)
await db.commit()
# Audit log
await log_audit(db, "mini_business", uuid.UUID(item_id), entity_name, "delete", current_user["user_id"], current_user["name"])
await db.commit()
return {"detail": "deleted"}
+143
View File
@@ -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"}
+114
View File
@@ -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
View File
@@ -1,36 +1,100 @@
import uuid
from fastapi import APIRouter, Depends
import io
import warnings
from datetime import date
from pathlib import PurePosixPath
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from PIL import Image, UnidentifiedImageError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.auth import get_current_user, require_any_role
from app.services.minio_client import generate_presigned_upload_url, generate_presigned_download_url
from app.services.minio_client import generate_presigned_download_url, put_validated_image
from app.models.visit import Visit
router = APIRouter(prefix="/upload", tags=["Upload"])
_IMAGE_TYPES = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
}
MAX_IMAGE_BYTES = 10 * 1024 * 1024
MAX_IMAGE_PIXELS = 25_000_000
@router.post("/presigned-url")
async def get_presigned_upload_url(
filename: str,
content_type: str = "image/jpeg",
def validate_image_upload(filename: str, content_type: str) -> str:
"""Validate image metadata before accepting bytes for server-side verification."""
if content_type.lower() not in _IMAGE_TYPES:
raise HTTPException(status_code=400, detail="Only JPEG, PNG, and WebP images are supported")
if PurePosixPath(filename).suffix.lower() not in {".jpg", ".jpeg", ".png", ".webp"}:
raise HTTPException(status_code=400, detail="Invalid image filename extension")
return _IMAGE_TYPES[content_type.lower()]
def build_object_key(user_id: str, extension: str) -> str:
return f"uploads/{user_id}/{date.today().isoformat()}/{uuid.uuid4().hex}.{extension}"
def is_owned_upload_key(object_key: str, user_id: str) -> bool:
path = PurePosixPath(object_key)
parts = path.parts
return (
len(parts) == 4
and parts[0] == "uploads"
and parts[1] == user_id
and ".." not in parts
and path.suffix.lower() in {".jpg", ".png", ".webp"}
)
def normalize_image(image_bytes: bytes) -> bytes:
"""Verify image bytes, enforce a pixel limit, and strip active/metadata content by re-encoding."""
if not image_bytes or len(image_bytes) > MAX_IMAGE_BYTES:
raise HTTPException(status_code=413, detail="Image must be between 1 byte and 10 MB")
try:
with warnings.catch_warnings():
warnings.simplefilter("error", Image.DecompressionBombWarning)
with Image.open(io.BytesIO(image_bytes)) as image:
image.verify()
with Image.open(io.BytesIO(image_bytes)) as image:
if image.width * image.height > MAX_IMAGE_PIXELS:
raise HTTPException(status_code=413, detail="Image dimensions are too large")
normalized = image.convert("RGB")
output = io.BytesIO()
normalized.save(output, format="JPEG", quality=85, optimize=True)
return output.getvalue()
except HTTPException:
raise
except (Image.DecompressionBombError, Image.DecompressionBombWarning, OSError, UnidentifiedImageError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Invalid image content") from exc
@router.post("/image")
async def upload_image(
file: UploadFile = File(...),
current_user: dict = Depends(require_any_role),
):
"""Get a presigned PUT URL for direct MinIO upload."""
import datetime
today = datetime.date.today().isoformat()
user_id = current_user["user_id"][:8]
object_key = f"{today}/{user_id}/{uuid.uuid4()}.jpg"
url = generate_presigned_upload_url(object_key)
return {
"upload_url": url,
"object_key": object_key,
}
"""Validate and normalize an image before persisting it to private object storage."""
validate_image_upload(file.filename or "upload.jpg", file.content_type or "")
normalized = normalize_image(await file.read(MAX_IMAGE_BYTES + 1))
object_key = build_object_key(current_user["user_id"], "jpg")
put_validated_image(object_key, normalized)
return {"object_key": object_key}
@router.get("/download-url")
async def get_presigned_download_url(
object_key: str,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get a presigned GET URL for viewing a photo (1 hour validity)."""
result = await db.execute(select(Visit).where(Visit.photos.any(object_key)).limit(1))
visit = result.scalar_one_or_none()
if not visit:
raise HTTPException(status_code=404, detail="Photo not found")
if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
url = generate_presigned_download_url(object_key)
return {"download_url": url}
+96
View File
@@ -7,6 +7,7 @@ from sqlalchemy import select
from app.database import get_db
from app.middleware.auth import get_current_user, require_director
from app.models.user import User
from app.utils.audit import log_audit
from app.schemas.customer import UserOut
router = APIRouter(prefix="/users", tags=["Users"])
@@ -15,6 +16,8 @@ router = APIRouter(prefix="/users", tags=["Users"])
class UpdateUserRoleRequest(BaseModel):
role: str # manager / director / leader
department: str = ""
require_report: bool = True
color: Optional[str] = None # hex color for manager tags
@router.get("/", response_model=list[UserOut])
@@ -71,12 +74,105 @@ async def update_user_role(
user.role = data.role
if data.department:
user.department = data.department
user.require_report = data.require_report
if data.color is not None:
user.color = data.color
await db.commit()
await db.refresh(user)
# Audit log
await log_audit(db, "user", uuid.UUID(user_id), user.name, "update", current_user["user_id"], current_user["name"], f"角色更新为 {data.role}")
await db.commit()
return {
"id": str(user.id),
"name": user.name,
"role": user.role,
"department": user.department,
"require_report": user.require_report,
"color": user.color,
}
class UpdateWecomRequest(BaseModel):
wecom_userid: Optional[str] = None # None or "" to unbind
@router.put("/{user_id}/wecom")
async def update_user_wecom(
user_id: str,
data: UpdateWecomRequest,
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Director binds or unbinds a user's WeChat Work account."""
result = await db.execute(select(User).where(User.id == uuid.UUID(user_id)))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
new_id = (data.wecom_userid or "").strip()
# Check duplicate
if new_id:
dup = await db.execute(
select(User).where(User.wecom_userid == new_id, User.id != uuid.UUID(user_id))
)
existing = dup.scalar_one_or_none()
if existing:
raise HTTPException(status_code=400, detail=f"该企微ID已被「{existing.name}」绑定")
user.wecom_userid = new_id if new_id else None
await db.commit()
await db.refresh(user)
return {
"id": str(user.id),
"name": user.name,
"wecom_userid": user.wecom_userid,
}
@router.delete("/{user_id}")
async def delete_user(
user_id: str,
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Director deletes a user. Cannot delete self."""
if user_id == current_user["user_id"]:
raise HTTPException(status_code=400, detail="不能删除自己")
result = await db.execute(select(User).where(User.id == uuid.UUID(user_id)))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user_name = user.name
await db.delete(user)
await db.commit()
# Audit log
await log_audit(db, "user", uuid.UUID(user_id), user_name, "delete", current_user["user_id"], current_user["name"])
await db.commit()
return {"detail": "deleted", "name": user_name}
class UpdateThemeRequest(BaseModel):
theme: str # editorial / light
@router.put("/me/theme")
async def update_my_theme(
data: UpdateThemeRequest,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Update the current user's theme preference."""
if data.theme not in ("editorial", "light"):
raise HTTPException(status_code=400, detail="Invalid theme")
result = await db.execute(select(User).where(User.id == uuid.UUID(current_user["user_id"])))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.theme = data.theme
await db.commit()
return {"theme": user.theme}
+165 -8
View File
@@ -3,7 +3,7 @@ from datetime import date, datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy import select, func, or_
from app.database import get_db
from app.middleware.auth import get_current_user, require_any_role
from app.models.visit import Visit
@@ -12,11 +12,22 @@ from app.models.user import User
from app.schemas.visit import VisitCreate, VisitUpdate, VisitOut, VisitListOut
from app.utils.timezone import today_cst, parse_date
from app.services.minio_client import delete_objects
from app.api.upload import is_owned_upload_key
from app.utils.edit_log import compute_diff, append_entry, init_entry
from app.utils.audit import log_audit
router = APIRouter(prefix="/visits", tags=["Visits"])
def _validate_photo_keys(photo_keys: list[str], user_id: str, existing_keys: list[str] | None = None) -> None:
if len(photo_keys) > 9:
raise HTTPException(status_code=400, detail="A visit may contain at most 9 photos")
existing = set(existing_keys or [])
for key in photo_keys:
if key not in existing and not is_owned_upload_key(key, user_id):
raise HTTPException(status_code=400, detail="Invalid or unauthorized photo reference")
async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict:
"""Enrich a visit record with customer/manager names."""
customer_name = None
@@ -28,6 +39,12 @@ async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict:
mgr_result = await db.execute(select(User.name).where(User.id == visit.manager_id))
manager_name = mgr_result.scalar_one_or_none()
# Resolve companion UUIDs to names
companion_names_resolved = list(visit.companion_names or [])
if visit.companions:
comp_result = await db.execute(select(User.name).where(User.id.in_(visit.companions)))
companion_names_resolved = [n for n, in comp_result.all()] + companion_names_resolved
return {
"id": str(visit.id),
"customer_id": str(visit.customer_id),
@@ -40,9 +57,12 @@ async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict:
"communication_content": visit.communication_content,
"customer_demand": visit.customer_demand,
"companions": visit.companions,
"companion_names": visit.companion_names or [],
"companion_names_resolved": companion_names_resolved,
"photos": visit.photos,
"manager_id": str(visit.manager_id),
"manager_name": manager_name,
"visit_group_id": str(visit.visit_group_id) if visit.visit_group_id else None,
"edit_log": visit.edit_log or [],
"created_at": str(visit.created_at),
"updated_at": str(visit.updated_at),
@@ -54,6 +74,8 @@ async def list_visits(
date_from: Optional[str] = Query(None),
date_to: Optional[str] = Query(None),
customer_id: Optional[str] = Query(None),
customer_type: Optional[str] = Query(None),
search: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
@@ -69,6 +91,12 @@ async def list_visits(
query = query.where(Visit.visit_date <= parse_date(date_to))
if customer_id:
query = query.where(Visit.customer_id == uuid.UUID(customer_id))
if customer_type:
query = query.join(Customer, Visit.customer_id == Customer.id).where(Customer.customer_type == customer_type)
if search:
query = query.outerjoin(Customer, Visit.customer_id == Customer.id).where(
or_(Visit.communication_content.ilike(f"%{search}%"), Visit.customer_demand.ilike(f"%{search}%"), Visit.visitor_name.ilike(f"%{search}%"), Customer.name.ilike(f"%{search}%"))
)
query = query.order_by(Visit.visit_date.desc(), Visit.created_at.desc()).limit(200)
result = await db.execute(query)
@@ -128,7 +156,9 @@ async def create_visit(
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
"""Create a visit record. If companions are selected, creates draft copies for them."""
"""Create a visit record. If companions are selected, creates full copies for them."""
_validate_photo_keys(data.photos, current_user["user_id"])
group_id = uuid.uuid4()
visit = Visit(
customer_id=data.customer_id,
visit_date=parse_date(data.visit_date),
@@ -137,28 +167,87 @@ async def create_visit(
communication_content=data.communication_content,
customer_demand=data.customer_demand,
companions=data.companions,
companion_names=data.companion_names,
photos=data.photos,
manager_id=uuid.UUID(current_user["user_id"]),
manager_id=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else uuid.UUID(current_user["user_id"]),
visit_group_id=group_id,
)
init_entry(visit, current_user["name"])
db.add(visit)
# Create draft copies for companions
# Update customer's last visit tracking
cust_result = await db.execute(select(Customer).where(Customer.id == data.customer_id))
customer = cust_result.scalar_one_or_none()
if customer:
customer.last_visit_date = parse_date(data.visit_date)
customer.last_visit_manager_id = uuid.UUID(current_user["user_id"])
db.add(customer)
# Auto-complete matching work plans for this customer
from app.services.visits import auto_complete_work_plans
await auto_complete_work_plans(
db, data.customer_id, parse_date(data.visit_date),
current_user["name"], "拜访自动完成",
)
# Auto-create follow-up logs for active mini business opportunities
from app.models.mini_business import MiniBusiness
from app.models.mini_business_log import MiniBusinessLog
method_map = {"上门": "上门", "电话": "电话", "微信": "微信", "出差": "其他"}
active_biz = await db.execute(
select(MiniBusiness).where(
MiniBusiness.customer_id == data.customer_id,
MiniBusiness.status == "跟进中",
)
)
# Create full copies for companions (not blank drafts)
creator_name = current_user["name"]
for companion_id in data.companions:
if companion_id != uuid.UUID(current_user["user_id"]):
# Build companion's companions list: the creator + other companions excluding self
comp_companions = [uuid.UUID(current_user["user_id"])]
for cid in data.companions:
if cid != companion_id and cid not in comp_companions:
comp_companions.append(cid)
draft = Visit(
customer_id=data.customer_id,
visit_date=parse_date(data.visit_date),
visit_method=data.visit_method,
time_range=data.time_range,
communication_content="", # Leave blank for companion to fill
customer_demand="",
companions=[],
photos=[],
visitor_name=data.visitor_name,
visitor_phone=data.visitor_phone,
communication_content=(data.communication_content or "") + f"(协同{creator_name}",
customer_demand=data.customer_demand,
companions=comp_companions,
companion_names=[],
photos=data.photos,
manager_id=companion_id,
visit_group_id=group_id,
)
init_entry(draft, current_user["name"])
db.add(draft)
# Auto-create follow-up logs for active mini business opportunities
for biz in active_biz.scalars().all():
parts = []
if data.communication_content and data.communication_content.strip():
parts.append(f"沟通内容:{data.communication_content.strip()}")
if data.customer_demand and data.customer_demand.strip():
parts.append(f"客户需求:{data.customer_demand.strip()}")
log_content = "\n".join(parts) if parts else "拜访跟进"
follow_method = method_map.get(data.visit_method, "其他")
log = MiniBusinessLog(
business_id=biz.id,
log_date=parse_date(data.visit_date),
method=follow_method,
content=log_content,
created_by=uuid.UUID(current_user["user_id"]),
)
db.add(log)
# Audit log (before commit — part of same transaction)
cust = await db.execute(select(Customer.name).where(Customer.id == visit.customer_id))
await log_audit(db, "visit", visit.id, f"{cust.scalar_one_or_none() or ''} ({data.visit_date})", "create", current_user["user_id"], current_user["name"])
await db.commit()
await db.refresh(visit)
return await _enrich_visit(visit, db)
@@ -179,17 +268,25 @@ async def update_visit(
if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
if data.photos is not None:
_validate_photo_keys(data.photos, current_user["user_id"], visit.photos or [])
# Snapshot old values for diff
old_snapshot = {
"customer_id": str(visit.customer_id), "visit_date": str(visit.visit_date),
"visit_method": visit.visit_method, "time_range": visit.time_range,
"visitor_name": visit.visitor_name or "", "visitor_phone": visit.visitor_phone or "",
"communication_content": visit.communication_content, "customer_demand": visit.customer_demand,
"manager_id": str(visit.manager_id) if visit.manager_id else "",
}
update_data = data.model_dump(exclude_unset=True)
if current_user["role"] not in ("director", "leader"):
update_data.pop("manager_id", None)
if "visit_date" in update_data and update_data["visit_date"]:
update_data["visit_date"] = parse_date(update_data["visit_date"])
if "manager_id" in update_data and update_data["manager_id"]:
update_data["manager_id"] = uuid.UUID(update_data["manager_id"])
for key, value in update_data.items():
setattr(visit, key, value)
@@ -200,13 +297,39 @@ async def update_visit(
"visit_method": visit.visit_method, "time_range": visit.time_range,
"visitor_name": visit.visitor_name or "", "visitor_phone": visit.visitor_phone or "",
"communication_content": visit.communication_content, "customer_demand": visit.customer_demand,
"manager_id": str(visit.manager_id) if visit.manager_id else "",
}
changes = compute_diff(old_snapshot, new_snapshot)
if changes:
append_entry(visit, current_user["name"], changes, getattr(data, "edit_reason", None))
# Auto-complete matching work plans when visit date or customer changes
from app.models.work_plan import WorkPlan
plans_result = await db.execute(
select(WorkPlan).where(
WorkPlan.customer_id == visit.customer_id,
WorkPlan.status == "计划中",
WorkPlan.plan_date <= visit.visit_date,
)
)
for plan in plans_result.scalars().all():
plan.status = "已完成"
append_entry(plan, current_user["name"], [{"field": "status", "from": "计划中", "to": "已完成", "reason": "拜访更新自动完成"}])
# Audit log (before commit — part of same transaction)
cust = await db.execute(select(Customer.name).where(Customer.id == visit.customer_id))
await log_audit(db, "visit", visit.id, f"{cust.scalar_one_or_none() or ''} ({visit.visit_date})", "update", current_user["user_id"], current_user["name"], ", ".join(changes.keys()) if changes else "")
await db.commit()
await db.refresh(visit)
# Auto-complete matching work plans after visit update
from app.services.visits import auto_complete_work_plans
await auto_complete_work_plans(
db, visit.customer_id, visit.visit_date,
current_user["name"], "拜访更新自动完成",
)
await db.commit()
return await _enrich_visit(visit, db)
@@ -224,10 +347,44 @@ async def delete_visit(
if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
customer_id = visit.customer_id
manager_id = visit.manager_id
# Clean up photos in MinIO
if visit.photos:
delete_objects(visit.photos)
# Snapshot entity name before deletion
cust_name_result = await db.execute(select(Customer.name).where(Customer.id == customer_id))
entity_name = f"{cust_name_result.scalar_one_or_none() or ''} ({visit.visit_date})"
await db.delete(visit)
# Recalculate customer's last_visit_date from remaining visits
latest = await db.execute(
select(func.max(Visit.visit_date)).where(
Visit.customer_id == customer_id,
)
)
new_latest = latest.scalar()
cust = await db.get(Customer, customer_id)
if cust:
if new_latest:
cust.last_visit_date = new_latest
latest_visit = await db.execute(
select(Visit).where(
Visit.customer_id == customer_id,
Visit.visit_date == new_latest,
).order_by(Visit.created_at.desc()).limit(1)
)
lv = latest_visit.scalar_one_or_none()
if lv:
cust.last_visit_manager_id = lv.manager_id
else:
cust.last_visit_date = None
cust.last_visit_manager_id = None
# Audit log (before final commit — part of same transaction)
await log_audit(db, "visit", uuid.UUID(visit_id), entity_name, "delete", current_user["user_id"], current_user["name"])
await db.commit()
return {"detail": "deleted"}
+305 -11
View File
@@ -1,13 +1,22 @@
import hashlib
import hmac
import struct
import base64
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from defusedxml import ElementTree as ET
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import PlainTextResponse, HTMLResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from sqlalchemy.exc import IntegrityError
from app.config import settings
from app.database import async_session, get_db
from app.middleware.auth import get_current_user, require_director
from app.models.user import User
from app.services.wecom import wecom_client
from app.services.wecom import wecom_client, store_bind_token, consume_bind_token
from app.services.scheduler import check_daily_reporting
router = APIRouter(prefix="/wecom", tags=["WeChatWork"])
@@ -22,6 +31,163 @@ class AnnouncementRequest(BaseModel):
content: str
class BindConfirmRequest(BaseModel):
token: str
# ── Crypto helpers ──
def _verify_signature(token: str, timestamp: str, nonce: str, encrypted: str, signature: str) -> bool:
items = sorted([token, timestamp, nonce, encrypted])
raw = "".join(items)
# The WeCom callback protocol mandates SHA-1; compare_digest avoids timing leaks.
expected = hashlib.sha1(raw.encode(), usedforsecurity=False).hexdigest()
return hmac.compare_digest(expected, signature)
def _decrypt_msg(encrypted: str) -> str:
"""Decrypt WeChat Work callback message. Returns plaintext XML."""
key = base64.b64decode(settings.WECOM_ENCODING_AES_KEY + "=")
ciphertext = base64.b64decode(encrypted)
decryptor = Cipher(algorithms.AES(key), modes.CBC(key[:16])).decryptor()
plaintext = decryptor.update(ciphertext) + decryptor.finalize()
pad_len = plaintext[-1]
if pad_len < 1 or pad_len > 32 or plaintext[-pad_len:] != bytes([pad_len]) * pad_len:
raise ValueError("Invalid callback padding")
plaintext = plaintext[:-pad_len]
msg_len = struct.unpack(">I", plaintext[16:20])[0]
return plaintext[20:20 + msg_len].decode("utf-8")
# ── Callback ──
@router.get("/callback")
async def wecom_callback_verify(
msg_signature: str = Query(...),
timestamp: str = Query(...),
nonce: str = Query(...),
echostr: str = Query(...),
):
"""WeChat Work callback URL verification (GET)."""
token = settings.WECOM_TOKEN
if not token or not settings.WECOM_ENCODING_AES_KEY:
raise HTTPException(status_code=500, detail="WeCom callback not configured")
if not _verify_signature(token, timestamp, nonce, echostr, msg_signature):
raise HTTPException(status_code=403, detail="Signature mismatch")
try:
plaintext = _decrypt_msg(echostr)
return PlainTextResponse(content=plaintext, status_code=200)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Decrypt failed: {e}")
@router.post("/callback")
async def wecom_callback_event(request: Request):
"""WeChat Work callback event receiver (POST). Handles menu clicks and text messages."""
token = settings.WECOM_TOKEN
if not token or not settings.WECOM_ENCODING_AES_KEY:
return PlainTextResponse(content="success")
# Parse XML envelope
try:
root = ET.fromstring(await request.body())
encrypt = root.findtext("Encrypt", "")
except ET.ParseError:
return PlainTextResponse(content="success")
# Verify signature
msg_signature = request.query_params.get("msg_signature", "")
timestamp = request.query_params.get("timestamp", "")
nonce = request.query_params.get("nonce", "")
if not _verify_signature(token, timestamp, nonce, encrypt, msg_signature):
return PlainTextResponse(content="success")
# Decrypt inner XML
try:
xml_str = _decrypt_msg(encrypt)
msg = ET.fromstring(xml_str)
except Exception:
return PlainTextResponse(content="success")
msg_type = msg.findtext("MsgType", "")
from_user = msg.findtext("FromUserName", "")
content = (msg.findtext("Content", "") or "").strip()
event = (msg.findtext("Event", "") or "").strip()
event_key = (msg.findtext("EventKey", "") or "").strip()
import logging
_log = logging.getLogger("wecom_callback")
_log.info("wecom callback received: type=%s event=%s key=%s", msg_type, event, event_key)
# Triggers for binding
if (msg_type == "text" and content == "绑定") or (msg_type == "event" and event == "click" and event_key == "BIND_ACCOUNT"):
_log.info("wecom bind triggered")
await _handle_bind_request(from_user)
return PlainTextResponse(content="success")
_log.info("wecom message ignored (no matching action)")
return PlainTextResponse(content="success")
async def _handle_bind_request(wecom_userid: str):
"""Generate bind token, store it, and push a bind link to the user."""
if not wecom_userid:
return
async with async_session() as db:
bind_token = await store_bind_token(db, wecom_userid)
content = (
f"【账号绑定】\n\n"
f"请在10分钟内点击以下链接完成账号绑定:\n"
f"https://qj.dhdx.fun/wecom-bind?token={bind_token}\n\n"
f"绑定后可使用企微一键登录,并接收填报提醒通知。"
)
await wecom_client.send_text_message([wecom_userid], content)
# ── Bind confirmation (JWT-protected) ──
@router.post("/bind-confirm")
async def bind_confirm(
data: BindConfirmRequest,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""User confirms binding after clicking the link in WeChat Work message."""
if not data.token:
raise HTTPException(status_code=400, detail="token 不能为空")
wecom_userid = await consume_bind_token(db, data.token)
if not wecom_userid:
raise HTTPException(status_code=400, detail="绑定链接已过期或无效,请重新在企微发送「绑定」")
# Check if this wecom_userid is already bound to another user
result = await db.execute(
select(User).where(User.wecom_userid == wecom_userid)
)
existing = result.scalar_one_or_none()
if existing and str(existing.id) != current_user["user_id"]:
raise HTTPException(status_code=400, detail=f"该企业微信已绑定账号「{existing.name}")
# Bind
result = await db.execute(
select(User).where(User.id == uuid.UUID(current_user["user_id"]))
)
user = result.scalar_one()
user.wecom_userid = wecom_userid
try:
await db.commit()
except IntegrityError as exc:
await db.rollback()
raise HTTPException(status_code=409, detail="该企业微信已绑定其他账号") from exc
return {"code": 200, "msg": "绑定成功", "wecom_userid": wecom_userid}
# ── Manual actions (director only) ──
@router.post("/remind")
async def send_reminder(
data: RemindRequest,
@@ -29,16 +195,23 @@ async def send_reminder(
db: AsyncSession = Depends(get_db),
):
"""Director manually sends reminder to specific managers."""
# Get wecom_userids for the selected users
result = await db.execute(
select(User.wecom_userid).where(User.id.in_([uuid.UUID(uid) for uid in data.user_ids]))
)
wecom_ids = [r[0] for r in result.all() if r[0]]
content = data.message or "📋 请及时完成今日拜访记录填报。"
success = await wecom_client.send_text_message(wecom_ids, content)
success = await wecom_client.send_template_card(
user_ids=wecom_ids,
title="📋 填报提醒",
desc=content,
url="https://qj.dhdx.fun/m",
btn_text="去填报",
)
if not success:
success = await wecom_client.send_text_message(wecom_ids, content)
return {"success": success, "sent_to": len(wecom_ids)}
return {"success": success, "sent_to": len(wecom_ids), "rich": True}
@router.post("/announcement")
@@ -47,15 +220,48 @@ async def send_announcement(
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Director sends an announcement to all team members."""
# Get all wecom_userids in the department
"""Director sends an announcement to all team members (markdown)."""
result = await db.execute(select(User.wecom_userid).where(User.wecom_userid.isnot(None)))
wecom_ids = [r[0] for r in result.all()]
content = f"📢 支局长公告\n\n{data.content}"
success = await wecom_client.send_text_message(wecom_ids, content)
markdown = f"## 📢 支局长公告\n\n{data.content}"
success = await wecom_client.send_markdown_message(markdown)
if not success:
success = await wecom_client.send_text_message(wecom_ids, f"📢 支局长公告\n\n{data.content}")
return {"success": success, "sent_to": len(wecom_ids)}
return {"success": success, "sent_to": len(wecom_ids), "rich": True}
@router.post("/test-message")
async def test_message(
wecom_userid: str,
current_user: dict = Depends(require_director),
):
"""Send a test message to a specific wecom_userid for debugging."""
success = await wecom_client.send_text_message(
[wecom_userid],
"🧪 企迹周报系统 — 测试消息\n\n如果您收到此消息,说明企微消息推送配置成功!"
)
return {"success": success, "sent_to": wecom_userid}
@router.post("/setup-menu")
async def setup_menu(current_user: dict = Depends(require_director)):
"""Create the standard app menu (开始填报 view + 绑定账号 click)."""
oauth_url = _build_oauth_url()
buttons = [
{"type": "view", "name": "开始填报", "url": oauth_url},
{"type": "click", "name": "绑定账号", "key": "BIND_ACCOUNT"},
]
success = await wecom_client.create_menu(buttons)
return {"success": success, "message": "菜单已部署 (含静默登录)" if success else "菜单创建失败"}
@router.get("/menu")
async def get_menu(current_user: dict = Depends(require_director)):
"""Get current app menu configuration."""
menu = await wecom_client.get_menu()
return menu or {"message": "No menu configured"}
@router.post("/trigger-daily-check")
@@ -66,3 +272,91 @@ async def trigger_daily_check(
"""Manually trigger the daily reporting check (for testing or manual use)."""
result = await check_daily_reporting(db)
return result
# ── OAuth silent login ──
import urllib.parse
from app.services.auth import build_token_for_user
def _build_oauth_url(redirect_path: str = "/m") -> str:
"""Build the WeChat Work OAuth2 authorize URL (snsapi_base = silent).
The redirect target is encoded in the 'state' param to avoid query-string issues
with the OAuth redirect_uri validation."""
if not settings.WECOM_CORP_ID:
return f"https://qj.dhdx.fun{redirect_path}"
params = urllib.parse.urlencode({
"appid": settings.WECOM_CORP_ID,
"redirect_uri": "https://qj.dhdx.fun/api/wecom/oauth-callback",
"response_type": "code",
"scope": "snsapi_base",
"agentid": settings.WECOM_AGENT_ID,
"state": f"r={urllib.parse.quote(redirect_path)}",
})
return f"https://open.weixin.qq.com/connect/oauth2/authorize?{params}#wechat_redirect"
@router.get("/oauth-url")
async def get_oauth_url(redirect: str = Query("/m")):
"""Redirect to the WeChat Work OAuth authorize URL (silent login)."""
from fastapi.responses import RedirectResponse
return RedirectResponse(url=_build_oauth_url(redirect))
@router.get("/oauth-callback")
async def oauth_callback(
code: str = Query(...),
state: str = Query(""),
db: AsyncSession = Depends(get_db),
):
"""WeChat Work OAuth2 callback — exchange code for userid, find bound user, auto-login."""
# Parse redirect target from state param: "r=%2Fm"
redirect = "/m"
if state.startswith("r="):
redirect = urllib.parse.unquote(state[2:])
# Exchange code for wecom userinfo
userinfo = await wecom_client.get_userinfo_by_code(code)
if not userinfo:
raise HTTPException(status_code=400, detail="Failed to exchange code with WeChat Work")
wecom_userid = userinfo.get("UserId") or userinfo.get("userid")
if not wecom_userid:
raise HTTPException(status_code=400, detail="Could not get userid from WeChat Work")
# Find bound user (case-insensitive — WeChat Work userids may vary in case)
from sqlalchemy import func as sa_func
result = await db.execute(
select(User).where(sa_func.lower(User.wecom_userid) == wecom_userid.lower())
)
user = result.scalar_one_or_none()
if not user:
# Not bound — generate a one-time bind token and redirect to bind page
bind_token = await store_bind_token(db, wecom_userid)
bind_url = f"https://qj.dhdx.fun/wecom-bind?token={bind_token}&wecom_userid={wecom_userid}"
from fastapi.responses import RedirectResponse
return RedirectResponse(url=bind_url)
# Issue JWT and auto-login via HTML page — saves credentials to localStorage, then redirects
token = build_token_for_user(user)
import json as _json
auth_data = _json.dumps({
"token": token,
"userId": str(user.id),
"userName": user.name,
"userRole": user.role,
"theme": user.theme or "editorial",
}, ensure_ascii=False)
frontend_url = f"https://qj.dhdx.fun{redirect}"
html = f"""<!DOCTYPE html><html><head><meta charset="utf-8"><title>登录中...</title></head>
<body><script>
var d = {auth_data};
localStorage.setItem('token', d.token);
localStorage.setItem('userId', d.userId);
localStorage.setItem('userName', d.userName);
localStorage.setItem('userRole', d.userRole);
localStorage.setItem('theme', d.theme);
window.location.replace({_json.dumps(frontend_url)});
</script></body></html>"""
return HTMLResponse(content=html, status_code=200)
+27 -2
View File
@@ -3,7 +3,7 @@ from datetime import date
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy import select, or_
from app.database import get_db
from app.middleware.auth import get_current_user, require_any_role
from app.utils.timezone import parse_date
@@ -35,6 +35,8 @@ async def _enrich(wp: WorkPlan, db: AsyncSession) -> dict:
@router.get("/")
async def list_work_plans(
customer_id: Optional[str] = Query(None),
customer_type: Optional[str] = Query(None),
search: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
@@ -43,11 +45,32 @@ async def list_work_plans(
query = query.where(WorkPlan.manager_id == uuid.UUID(current_user["user_id"]))
if customer_id:
query = query.where(WorkPlan.customer_id == uuid.UUID(customer_id))
if customer_type:
query = query.join(Customer, WorkPlan.customer_id == Customer.id).where(Customer.customer_type == customer_type)
if search:
query = query.outerjoin(Customer, WorkPlan.customer_id == Customer.id).where(
or_(WorkPlan.plan_content.ilike(f"%{search}%"), Customer.name.ilike(f"%{search}%"))
)
query = query.order_by(WorkPlan.plan_date.desc()).limit(200)
result = await db.execute(query)
return [await _enrich(w, db) for w in result.scalars().all()]
@router.get("/{item_id}")
async def get_work_plan(
item_id: str,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(WorkPlan).where(WorkPlan.id == item_id))
wp = result.scalar_one_or_none()
if not wp:
raise HTTPException(status_code=404, detail="Not found")
if current_user["role"] == "manager" and str(wp.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
return await _enrich(wp, db)
@router.post("/")
async def create_work_plan(
data: WorkPlanCreate,
@@ -58,7 +81,7 @@ async def create_work_plan(
customer_id=data.customer_id,
plan_content=data.plan_content,
plan_date=parse_date(data.plan_date),
manager_id=uuid.UUID(current_user["user_id"]),
manager_id=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else uuid.UUID(current_user["user_id"]),
status=data.status,
)
init_entry(wp, current_user["name"])
@@ -83,6 +106,8 @@ async def update_work_plan(
old_snapshot = {"customer_id": str(wp.customer_id), "plan_content": wp.plan_content, "plan_date": str(wp.plan_date), "status": wp.status}
update_data = data.model_dump(exclude_unset=True)
if current_user["role"] not in ("director", "leader"):
update_data.pop("manager_id", None)
if "plan_date" in update_data and update_data["plan_date"]:
update_data["plan_date"] = parse_date(update_data["plan_date"])
for k, v in update_data.items():
+39 -13
View File
@@ -1,15 +1,18 @@
from pydantic_settings import BaseSettings
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Optional
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
# App
APP_NAME: str = "企迹-政企周报管理系统"
DEBUG: bool = True
SECRET_KEY: str = "change-me-in-production"
ENVIRONMENT: str = "production"
DEBUG: bool = False
SECRET_KEY: str = ""
# Database
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/qiji"
# A syntactically valid non-working placeholder keeps tooling imports side-effect free.
DATABASE_URL: str = "postgresql+asyncpg://qiji_app:invalid@localhost:5432/qiji"
# JWT
JWT_ALGORITHM: str = "HS256"
@@ -24,11 +27,11 @@ class Settings(BaseSettings):
CASDOOR_APPLICATION: str = "qiji-weekly-report"
# MinIO
MINIO_ENDPOINT: str = "localhost:9000"
MINIO_ACCESS_KEY: str = "minioadmin"
MINIO_SECRET_KEY: str = "minioadmin"
MINIO_ENDPOINT: str = ""
MINIO_ACCESS_KEY: str = ""
MINIO_SECRET_KEY: str = ""
MINIO_BUCKET: str = "qiji-photos"
MINIO_SECURE: bool = False
MINIO_SECURE: bool = True
# WeChat Work
WECOM_CORP_ID: str = ""
@@ -36,13 +39,36 @@ class Settings(BaseSettings):
WECOM_SECRET: str = ""
WECOM_TOKEN: str = ""
WECOM_ENCODING_AES_KEY: str = ""
WECOM_API_BASE: str = "https://qyapi.weixin.qq.com"
# AI / LLM (OpenAI-compatible)
AI_API_URL: str = ""
AI_API_KEY: str = ""
AI_MODEL: str = "gpt-4o"
AI_MAX_TOKENS: int = 2000
# CORS
CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:3000"]
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()
def validate_security_settings() -> None:
"""Reject unsafe or incomplete configuration before the application starts."""
if settings.ENVIRONMENT.lower() == "development":
return
invalid = []
if len(settings.SECRET_KEY) < 32 or settings.SECRET_KEY in {"change-me-in-production", ""}:
invalid.append("SECRET_KEY")
if not settings.DATABASE_URL or "postgres:postgres@" in settings.DATABASE_URL or ":invalid@" in settings.DATABASE_URL:
invalid.append("DATABASE_URL")
if not settings.MINIO_ENDPOINT or not settings.MINIO_ACCESS_KEY or not settings.MINIO_SECRET_KEY:
invalid.append("MINIO configuration")
if settings.MINIO_ACCESS_KEY == "minioadmin" or settings.MINIO_SECRET_KEY == "minioadmin":
invalid.append("MINIO default credentials")
if not settings.MINIO_SECURE:
invalid.append("MINIO_SECURE")
if invalid:
raise RuntimeError(f"Unsafe production configuration: {', '.join(invalid)}")
+35 -22
View File
@@ -1,35 +1,43 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.database import engine, Base
from sqlalchemy import select
from app.config import settings, validate_security_settings
from app.database import engine, async_session
from app.api import router as api_router
from app.api import auth, users, customers, visits, work_plans, mini_business, key_visits
from app.api import dashboard, upload, export, import_data, wecom, daily_notes
from app.api import auth, users, customers, visits, work_plans, mini_business, mini_business_logs, key_visits
from app.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary, system_config, leaves, audit
from app.models.system_config import SystemConfig
from app.services.holidays import refresh_holidays
from app.services.scheduler_manager import start_scheduler, shutdown_scheduler
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: create tables if not exists (for dev convenience)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Add columns that may be missing from older tables
await conn.run_sync(lambda c: c.exec_driver_sql(
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS remarks TEXT DEFAULT ''"
))
await conn.run_sync(lambda c: c.exec_driver_sql(
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_name VARCHAR(50) DEFAULT ''"
))
await conn.run_sync(lambda c: c.exec_driver_sql(
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_phone VARCHAR(20) DEFAULT ''"
))
# edit_log columns for change tracking
for tbl in ["visits", "daily_notes", "work_plans", "mini_business", "key_visits"]:
await conn.run_sync(lambda c, t=tbl: c.exec_driver_sql(
f"ALTER TABLE {t} ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'"
))
validate_security_settings()
# Read notification_time from DB (or use default 17:30)
notification_hour, notification_minute = 17, 30
async with async_session() as db:
row = await db.get(SystemConfig, "notification_time")
if row and row.value:
try:
parts = row.value.strip().split(":")
notification_hour, notification_minute = int(parts[0]), int(parts[1])
except (ValueError, IndexError):
pass
# Auto-refresh Chinese holiday data from API on startup
try:
await refresh_holidays(db)
except Exception:
pass # Use cached data if API unavailable
# Start daily reporting scheduler
start_scheduler(notification_hour, notification_minute)
yield
# Shutdown
shutdown_scheduler()
await engine.dispose()
@@ -55,13 +63,18 @@ app.include_router(customers.router, prefix="/api")
app.include_router(visits.router, prefix="/api")
app.include_router(work_plans.router, prefix="/api")
app.include_router(mini_business.router, prefix="/api")
app.include_router(mini_business_logs.router, prefix="/api")
app.include_router(key_visits.router, prefix="/api")
app.include_router(dashboard.router, prefix="/api")
app.include_router(leaves.router, prefix="/api")
app.include_router(upload.router, prefix="/api")
app.include_router(export.router, prefix="/api")
app.include_router(import_data.router, prefix="/api")
app.include_router(wecom.router, prefix="/api")
app.include_router(daily_notes.router, prefix="/api")
app.include_router(ai_summary.router, prefix="/api")
app.include_router(system_config.router, prefix="/api")
app.include_router(audit.router, prefix="/api")
@app.get("/health")
+10
View File
@@ -7,6 +7,11 @@ from app.models.work_plan import WorkPlan
from app.models.mini_business import MiniBusiness
from app.models.key_visit import KeyVisit
from app.models.daily_note import DailyNote
from app.models.ai_summary import AISummary
from app.models.system_config import SystemConfig
from app.models.leave import Leave
from app.models.wecom_bind_token import WecomBindToken
from app.models.mini_business_log import MiniBusinessLog
__all__ = [
"User",
@@ -16,6 +21,11 @@ __all__ = [
"Visit",
"WorkPlan",
"MiniBusiness",
"MiniBusinessLog",
"KeyVisit",
"DailyNote",
"AISummary",
"SystemConfig",
"Leave",
"WecomBindToken",
]
+21
View File
@@ -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())
+21
View File
@@ -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())
+5 -2
View File
@@ -1,6 +1,6 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Text, DateTime, func, ForeignKey
from datetime import date, datetime
from sqlalchemy import String, Text, Date, DateTime, Boolean, func, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
@@ -11,6 +11,7 @@ class Customer(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200), index=True)
customer_type: Mapped[str] = mapped_column(String(20), default="unit")
industry: Mapped[str] = mapped_column(String(100), default="")
address: Mapped[str] = mapped_column(String(500), default="")
in_use_services: Mapped[str] = mapped_column(Text, default="")
@@ -19,6 +20,8 @@ class Customer(Base):
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
last_visit_date: Mapped[date | None] = mapped_column(Date, nullable=True)
last_visit_manager_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
contacts: Mapped[list["CustomerContact"]] = relationship("CustomerContact", back_populates="customer", cascade="all, delete-orphan")
assignments: Mapped[list["CustomerAssignment"]] = relationship("CustomerAssignment", back_populates="customer", cascade="all, delete-orphan")
+30
View File
@@ -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()
)
+7 -1
View File
@@ -1,9 +1,13 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import String, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
if TYPE_CHECKING:
from app.models.mini_business_log import MiniBusinessLog
class MiniBusiness(Base):
__tablename__ = "mini_business"
@@ -17,3 +21,5 @@ class MiniBusiness(Base):
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
expected_revenue_date: Mapped[str] = mapped_column(String(50), default="")
edit_log: Mapped[list] = mapped_column(JSONB, default=list)
logs: Mapped[list["MiniBusinessLog"]] = relationship(back_populates="business", cascade="all, delete-orphan")
+30
View File
@@ -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")
+12
View File
@@ -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="")
+3
View File
@@ -15,4 +15,7 @@ class User(Base):
role: Mapped[str] = mapped_column(String(30)) # manager / director / leader
department: Mapped[str] = mapped_column(String(100), default="")
wecom_userid: Mapped[str | None] = mapped_column(String(100), unique=True, nullable=True)
require_report: Mapped[bool] = mapped_column(default=True, server_default="true")
color: Mapped[str | None] = mapped_column(String(7), nullable=True) # e.g. "#C62828"
theme: Mapped[str] = mapped_column(String(20), default="editorial", server_default="editorial") # editorial / light
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+2
View File
@@ -19,8 +19,10 @@ class Visit(Base):
visitor_name: Mapped[str] = mapped_column(String(50), default="")
visitor_phone: Mapped[str] = mapped_column(String(20), default="")
companions: Mapped[list | None] = mapped_column(ARRAY(UUID(as_uuid=True)), nullable=True)
companion_names: Mapped[list] = mapped_column(ARRAY(Text), default=list)
photos: Mapped[list | None] = mapped_column(ARRAY(Text), nullable=True)
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
visit_group_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True)
edit_log: Mapped[list] = mapped_column(JSONB, default=list)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+18
View File
@@ -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())
+6
View File
@@ -19,6 +19,8 @@ class UserOut(BaseModel):
role: str
department: str
wecom_userid: Optional[str] = None
require_report: bool = True
color: Optional[str] = None
model_config = {"from_attributes": True}
@@ -43,6 +45,7 @@ class ContactOut(BaseModel):
# ── Customer ──
class CustomerCreate(BaseModel):
name: str
customer_type: str = "unit"
industry: str = ""
address: str = ""
in_use_services: str = ""
@@ -54,6 +57,7 @@ class CustomerCreate(BaseModel):
class CustomerUpdate(BaseModel):
name: Optional[str] = None
customer_type: Optional[str] = None
industry: Optional[str] = None
address: Optional[str] = None
in_use_services: Optional[str] = None
@@ -65,6 +69,7 @@ class CustomerUpdate(BaseModel):
class CustomerOut(BaseModel):
id: uuid.UUID
name: str
customer_type: str = "unit"
industry: str
address: str
in_use_services: str
@@ -82,6 +87,7 @@ class CustomerOut(BaseModel):
class CustomerListOut(BaseModel):
id: uuid.UUID
name: str
customer_type: str = "unit"
industry: str
in_use_services: str
primary_manager_name: Optional[str] = None
+2
View File
@@ -9,6 +9,7 @@ class DailyNoteCreate(BaseModel):
category: str = "其他"
content: str = ""
time_range: str = ""
manager_id: Optional[uuid.UUID] = None
class DailyNoteUpdate(BaseModel):
@@ -16,6 +17,7 @@ class DailyNoteUpdate(BaseModel):
category: Optional[str] = None
content: Optional[str] = None
time_range: Optional[str] = None
manager_id: Optional[uuid.UUID] = None
class DailyNoteOut(BaseModel):
+2
View File
@@ -11,6 +11,7 @@ class KeyVisitCreate(BaseModel):
planned_date: str = ""
planned_visitor: str = ""
visit_target: str = ""
manager_id: Optional[uuid.UUID] = None
class KeyVisitUpdate(BaseModel):
@@ -21,6 +22,7 @@ class KeyVisitUpdate(BaseModel):
planned_date: Optional[str] = None
planned_visitor: Optional[str] = None
visit_target: Optional[str] = None
manager_id: Optional[uuid.UUID] = None
class KeyVisitOut(BaseModel):
+55
View File
@@ -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]
+2
View File
@@ -10,6 +10,7 @@ class MiniBusinessCreate(BaseModel):
follow_up_detail: str = ""
status: str = "跟进中"
expected_revenue_date: str = ""
manager_id: Optional[uuid.UUID] = None
class MiniBusinessUpdate(BaseModel):
@@ -19,6 +20,7 @@ class MiniBusinessUpdate(BaseModel):
follow_up_detail: Optional[str] = None
status: Optional[str] = None
expected_revenue_date: Optional[str] = None
manager_id: Optional[uuid.UUID] = None
class MiniBusinessOut(BaseModel):
+33
View File
@@ -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}
+1
View File
@@ -9,6 +9,7 @@ class TokenResponse(BaseModel):
user_id: str
name: str
role: str
theme: str = "editorial"
class WecomLoginRequest(BaseModel):
+7
View File
@@ -14,7 +14,9 @@ class VisitCreate(BaseModel):
communication_content: str = ""
customer_demand: str = ""
companions: list[uuid.UUID] = []
companion_names: list[str] = []
photos: list[str] = []
manager_id: Optional[uuid.UUID] = None # director/leader can specify
class VisitUpdate(BaseModel):
@@ -27,7 +29,10 @@ class VisitUpdate(BaseModel):
communication_content: Optional[str] = None
customer_demand: Optional[str] = None
companions: Optional[list[uuid.UUID]] = None
manager_id: Optional[uuid.UUID] = None
companion_names: Optional[list[str]] = None
photos: Optional[list[str]] = None
manager_id: Optional[str] = None
class VisitOut(BaseModel):
@@ -41,8 +46,10 @@ class VisitOut(BaseModel):
communication_content: str
customer_demand: str
companions: Optional[list[uuid.UUID]] = None
companion_names: list[str] = []
photos: Optional[list[str]] = None
manager_id: uuid.UUID
visit_group_id: Optional[str] = None
created_at: datetime
updated_at: datetime
# Joined fields
+2
View File
@@ -9,6 +9,7 @@ class WorkPlanCreate(BaseModel):
plan_content: str = ""
plan_date: str # "YYYY-MM-DD"
status: str = "计划中"
manager_id: Optional[uuid.UUID] = None
class WorkPlanUpdate(BaseModel):
@@ -16,6 +17,7 @@ class WorkPlanUpdate(BaseModel):
plan_content: Optional[str] = None
plan_date: Optional[str] = None
status: Optional[str] = None
manager_id: Optional[uuid.UUID] = None
class WorkPlanOut(BaseModel):
+294
View File
@@ -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,
}
+4 -3
View File
@@ -49,12 +49,12 @@ async def exchange_casdoor_code(code: str) -> dict | None:
"code": code,
}, timeout=10)
if resp.status_code != 200:
logger.error(f"[Casdoor] token exchange failed: status={resp.status_code}, body={resp.text[:500]}")
logger.error("[Casdoor] token exchange failed: status=%s", resp.status_code)
return None
token_data = resp.json()
access_token = token_data.get("access_token", "")
if not access_token:
logger.error(f"[Casdoor] no access_token in response: {token_data}")
logger.error("[Casdoor] token exchange response did not contain an access token")
return None
except Exception as e:
logger.error(f"[Casdoor] token exchange exception: {e}")
@@ -67,7 +67,7 @@ async def exchange_casdoor_code(code: str) -> dict | None:
"Authorization": f"Bearer {access_token}"
}, timeout=10)
if resp.status_code != 200:
logger.error(f"[Casdoor] userinfo failed: status={resp.status_code}, body={resp.text[:500]}")
logger.error("[Casdoor] userinfo failed: status=%s", resp.status_code)
return None
return resp.json()
except Exception as e:
@@ -103,4 +103,5 @@ def build_token_for_user(user: User) -> str:
"name": user.name,
"role": user.role,
"department": user.department,
"theme": user.theme or "editorial",
})
+125 -31
View File
@@ -1,6 +1,6 @@
from datetime import date, timedelta
from uuid import UUID
from sqlalchemy import select, func
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from app.models.customer import Customer
@@ -10,7 +10,9 @@ from app.models.work_plan import WorkPlan
from app.models.mini_business import MiniBusiness
from app.models.key_visit import KeyVisit
from app.models.daily_note import DailyNote
from app.utils.timezone import today_cst
from app.models.leave import Leave
from app.services.holidays import load_holiday_sets
from app.utils.timezone import today_cst, is_working_day, parse_date
def get_week_range(reference_date: date | None = None):
@@ -20,13 +22,14 @@ def get_week_range(reference_date: date | None = None):
return monday, sunday
async def get_dashboard_stats(db: AsyncSession, reference_date: date | None = None) -> dict:
async def get_dashboard_stats(db: AsyncSession, reference_date: date | None = None, user_id: str = "", role: str = "") -> dict:
"""Get dashboard statistics for a given week (defaults to current)."""
monday, sunday = get_week_range(reference_date)
today = date.today()
visits_count = (await db.execute(
select(func.count(Visit.id)).where(Visit.visit_date >= monday, Visit.visit_date <= sunday)
select(func.count(func.distinct(func.coalesce(Visit.visit_group_id, Visit.id))))
.where(Visit.visit_date >= monday, Visit.visit_date <= sunday)
)).scalar() or 0
plans_count = (await db.execute(
@@ -41,11 +44,29 @@ async def get_dashboard_stats(db: AsyncSession, reference_date: date | None = No
select(func.count(KeyVisit.id))
)).scalar() or 0
# Week leave count — distinct managers on leave this week
leaves_count = (await db.execute(
select(func.count(func.distinct(Leave.manager_id))).where(
and_(Leave.start_date <= sunday, Leave.end_date >= monday)
)
)).scalar() or 0
# Overdue plans (status=计划中, plan_date < today). Managers see only own.
overdue_q = select(func.count(WorkPlan.id)).where(
WorkPlan.status == "计划中",
WorkPlan.plan_date < today,
)
if role == "manager":
overdue_q = overdue_q.where(WorkPlan.manager_id == UUID(user_id))
overdue_plans = (await db.execute(overdue_q)).scalar() or 0
return {
"week_visits": visits_count,
"work_plans": plans_count,
"mini_business": mini_biz_count,
"key_visits": key_visit_count,
"week_leaves": leaves_count,
"overdue_plans": overdue_plans,
"week_start": str(monday),
"week_end": str(sunday),
}
@@ -55,10 +76,11 @@ async def get_reporting_progress(db: AsyncSession, reference_date: date | None =
"""Get per-manager reporting progress. Managers only see themselves."""
monday, sunday = get_week_range(reference_date)
managers_result = await db.execute(select(User).where(User.role == "manager"))
all_managers = managers_result.scalars().all()
# Get all users who need to report (any role, filtered by require_report)
managers_result = await db.execute(select(User).where(User.require_report == True))
all_reporters = managers_result.scalars().all()
# Filter: managers only see themselves
managers = all_managers if role in ("director", "leader") else [m for m in all_managers if str(m.id) == user_id]
reporters = all_reporters if role in ("director", "leader") else [m for m in all_reporters if str(m.id) == user_id]
# Get visit counts per manager this week
visits_result = await db.execute(
@@ -68,27 +90,56 @@ async def get_reporting_progress(db: AsyncSession, reference_date: date | None =
)
visit_map = {str(uid): cnt for uid, cnt in visits_result.all()}
# Load holiday data (auto-refreshes from API if not cached)
holidays, workdays = await load_holiday_sets(db)
# Query leave records for today — build a set of managers on leave
today = today_cst()
is_rest = not is_working_day(today, holidays, workdays)
leaves_result = await db.execute(
select(Leave).where(and_(
Leave.start_date <= today,
Leave.end_date >= today,
))
)
on_leave_today = {str(r.manager_id): r for r in leaves_result.scalars().all()}
progress = []
for m in managers:
for m in reporters:
count = visit_map.get(str(m.id), 0)
# Calculate expected working days (Mon-Fri)
days_passed = min((date.today() - monday).days + 1, 5)
expected = days_passed # At least 1 visit per working day
mid = str(m.id)
leave = on_leave_today.get(mid)
progress.append({
"manager_id": str(m.id),
"manager_id": mid,
"manager_name": m.name,
"department": m.department,
"visit_count": count,
"expected": expected,
"completed": count >= expected,
"has_reported_today": False, # Will be set below
"on_leave": leave is not None,
"is_rest_day": is_rest,
"leave_info": {
"leave_type": leave.leave_type,
"start_date": str(leave.start_date),
"end_date": str(leave.end_date),
} if leave else None,
})
# Check today's reporting — visits OR daily notes
today = today_cst()
today_visits = await db.execute(
select(Visit.manager_id).where(Visit.visit_date == today)
# Check today's reporting — visits (direct + companions) OR daily notes
from sqlalchemy import union_all
today_direct = select(Visit.manager_id).where(Visit.visit_date == today)
today_companion = select(func.unnest(Visit.companions).label("manager_id")).where(
Visit.visit_date == today, Visit.companions.isnot(None)
)
today_combined = union_all(today_direct, today_companion).subquery()
today_visits = await db.execute(select(today_combined.c.manager_id))
today_notes = await db.execute(
select(DailyNote.manager_id).where(DailyNote.note_date == today)
)
@@ -126,32 +177,75 @@ async def get_weekly_report(
# ── Visits ──
visit_query = select(Visit).where(Visit.visit_date >= monday, Visit.visit_date <= sunday)
if role == "manager":
visit_query = visit_query.where(Visit.manager_id == user_id)
visit_query = visit_query.where(
(Visit.manager_id == user_id) | (Visit.companions.any(user_id))
)
elif filter_manager_id:
visit_query = visit_query.where(Visit.manager_id == filter_manager_id)
visit_query = visit_query.where(
(Visit.manager_id == filter_manager_id) | (Visit.companions.any(filter_manager_id))
)
if filter_customer_id:
visit_query = visit_query.where(Visit.customer_id == filter_customer_id)
visit_query = visit_query.order_by(Visit.visit_date.desc())
visits_result = await db.execute(visit_query)
visits = visits_result.scalars().all()
visits_data = []
# Group visits by visit_group_id (or id for solo visits)
groups: dict[str, list] = {}
for v in visits:
visits_data.append({
"id": str(v.id),
"customer_id": str(v.customer_id),
"customer_name": customer_map.get(v.customer_id, ""),
"visit_date": str(v.visit_date),
"visit_method": v.visit_method,
"time_range": v.time_range,
"communication_content": v.communication_content,
"customer_demand": v.customer_demand,
"companions": [str(c) for c in (v.companions or [])],
"photos": v.photos or [],
"manager_id": str(v.manager_id),
"manager_name": user_map.get(v.manager_id, ""),
"created_at": str(v.created_at),
})
gid = str(v.visit_group_id) if v.visit_group_id else str(v.id)
groups.setdefault(gid, []).append(v)
visits_data = []
for gid, gvisits in groups.items():
# Determine the "primary" — the creator's record (not a companion copy)
def _is_companion_copy(v) -> bool:
content = (v.communication_content or "").strip()
# New-style: has "(协同XXX" suffix
if content.endswith("") and "(协同" in content:
return True
# Old-style: empty draft created for companion
if not content:
return True
return False
creator_records = [v for v in gvisits if not _is_companion_copy(v)]
primary = creator_records[0] if creator_records else gvisits[0]
companion_names_resolved = [user_map.get(c, str(c)) for c in (primary.companions or [])]
companion_names_resolved.extend(primary.companion_names or [])
merged = {
"id": str(primary.id),
"customer_id": str(primary.customer_id),
"customer_name": customer_map.get(primary.customer_id, ""),
"visit_date": str(primary.visit_date),
"visit_method": primary.visit_method,
"time_range": primary.time_range,
"visitor_name": primary.visitor_name or "",
"visitor_phone": primary.visitor_phone or "",
"visit_group_id": gid if len(gvisits) > 1 else None,
"companions": [str(c) for c in (primary.companions or [])],
"companion_names": primary.companion_names or [],
"companion_names_resolved": companion_names_resolved,
"communication_content": primary.communication_content,
"customer_demand": primary.customer_demand,
"photos": primary.photos or [],
"manager_id": str(primary.manager_id),
"manager_name": user_map.get(primary.manager_id, ""),
"edit_log": primary.edit_log or [],
"created_at": str(primary.created_at),
"participants": [
{
"manager_id": str(v.manager_id),
"manager_name": user_map.get(v.manager_id, ""),
"role": "primary" if v is primary else "companion",
"communication_content": v.communication_content,
"customer_demand": v.customer_demand,
"photos": v.photos or [],
}
for v in gvisits
],
}
visits_data.append(merged)
# ── Work Plans ──
wp_query = select(WorkPlan)
+26 -3
View File
@@ -8,6 +8,7 @@ from app.models.visit import Visit
from app.models.work_plan import WorkPlan
from app.models.mini_business import MiniBusiness
from app.models.key_visit import KeyVisit
from app.models.daily_note import DailyNote
from app.models.customer import Customer
from app.models.user import User
@@ -21,7 +22,7 @@ def get_week_range(reference_date: date | None = None):
async def export_weekly_report(db: AsyncSession, reference_date: date | None = None) -> io.BytesIO:
"""Generate a 4-sheet xlsx matching the existing weekly report template."""
"""Generate a 5-sheet xlsx matching the existing weekly report template."""
monday, sunday = get_week_range(reference_date)
wb = Workbook()
@@ -40,7 +41,7 @@ async def export_weekly_report(db: AsyncSession, reference_date: date | None = N
# ── Sheet 1: 每日拜访记录 ──
ws1 = wb.active
ws1.title = "每日拜访记录"
headers1 = ["客户单位", "拜访日期", "拜访方式", "时间范围", "拜访人姓名", "拜访人电话", "沟通内容", "客户需求", "同访人员", "客户经理"]
headers1 = ["客户单位", "拜访日期", "拜访方式", "时间范围", "拜访人姓名", "拜访人电话", "沟通内容", "客户需求", "相关人员", "客户经理"]
ws1.append(headers1)
for col in range(1, len(headers1) + 1):
cell = ws1.cell(row=1, column=col)
@@ -52,6 +53,7 @@ async def export_weekly_report(db: AsyncSession, reference_date: date | None = N
)
for v in visits_result.scalars():
companions_names = [user_map.get(str(cid), str(cid)) for cid in (v.companions or [])]
companions_names.extend(v.companion_names or [])
ws1.append([
customer_map.get(str(v.customer_id), ""),
str(v.visit_date),
@@ -127,8 +129,29 @@ async def export_weekly_report(db: AsyncSession, reference_date: date | None = N
user_map.get(str(k.manager_id), ""),
])
# ── Sheet 5: 今日纪要 ──
ws5 = wb.create_sheet("今日纪要")
headers5 = ["日期", "分类", "内容", "时间范围", "填报人"]
ws5.append(headers5)
for col in range(1, len(headers5) + 1):
cell = ws5.cell(row=1, column=col)
cell.font = header_font
cell.border = thin_border
notes_result = await db.execute(
select(DailyNote).where(DailyNote.note_date >= monday, DailyNote.note_date <= sunday)
)
for n in notes_result.scalars():
ws5.append([
str(n.note_date),
n.category,
n.content,
n.time_range,
user_map.get(str(n.manager_id), ""),
])
# Adjust column widths
for ws in [ws1, ws2, ws3, ws4]:
for ws in [ws1, ws2, ws3, ws4, ws5]:
for col_cells in ws.columns:
max_length = max((len(str(cell.value or "")) for cell in col_cells), default=10)
ws.column_dimensions[col_cells[0].column_letter].width = min(max_length + 4, 50)
+334 -19
View File
@@ -1,26 +1,142 @@
import io
import uuid
import re
from datetime import datetime, date
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import openpyxl
from sqlalchemy import select, update
from app.models.customer import Customer
from app.models.customer_assignment import CustomerAssignment
from app.models.visit import Visit
from app.models.work_plan import WorkPlan
from app.models.mini_business import MiniBusiness
from app.models.key_visit import KeyVisit
from app.models.daily_note import DailyNote
from app.models.user import User
import openpyxl
# Companion name separators: Chinese comma, English comma, Chinese semicolon, dun-hao
_COMPANION_SEP = re.compile(r'[,;、;]')
def _parse_companions(raw: str, user_map: dict[str, uuid.UUID]) -> tuple[list[uuid.UUID], list[str]]:
"""Split a companion string into system user IDs and external names.
Returns (system_ids, external_names).
"""
if not raw or not raw.strip():
return [], []
parts = [p.strip() for p in _COMPANION_SEP.split(raw) if p.strip()]
system_ids: list[uuid.UUID] = []
external_names: list[str] = []
for name in parts:
uid = user_map.get(name)
if uid:
system_ids.append(uid)
else:
external_names.append(name)
return system_ids, external_names
def _fuzzy_match_customer(
cust_name: str,
customer_map: dict[str, uuid.UUID],
) -> uuid.UUID | None:
"""Try to match a customer name with fuzzy rules.
Rules (in order):
1. Exact match (already handled before calling this)
2. Normalize whitespace → exact match
3. One name fully contains the other
"""
norm = cust_name.replace(' ', '').replace(' ', '')
# Rule 2: whitespace-normalized match
for existing_name, cid in customer_map.items():
existing_norm = existing_name.replace(' ', '').replace(' ', '')
if norm == existing_norm:
return cid
# Rule 3: containment (longer name contains shorter)
for existing_name, cid in customer_map.items():
if len(norm) >= 4 and len(existing_name.replace(' ', '').replace(' ', '')) >= 4:
if norm in existing_name.replace(' ', '').replace(' ', '') or \
existing_name.replace(' ', '').replace(' ', '') in norm:
return cid
return None
async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uuid.UUID) -> dict:
"""Parse old weekly report Excel and import data. Returns summary stats."""
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True)
stats = {"visits": 0, "work_plans": 0, "mini_business": 0, "key_visits": 0, "skipped": 0, "skip_reasons": []}
"""Parse old weekly report Excel and import data. Returns summary stats.
# Resolve customer name -> id cache
Features:
- Companion parsing: split by comma/dun-hao → system users + external names
- Auto-create customers: if customer not found, create from Excel data
- Fuzzy name matching: whitespace normalization + containment
- Daily notes import (Sheet 5)
"""
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True)
stats = {
"visits": 0, "work_plans": 0, "mini_business": 0, "key_visits": 0,
"daily_notes": 0, "skipped": 0, "skip_reasons": [],
"customers_created": 0, "customers_created_names": [],
"external_companions": 0, "name_corrections": [],
}
# ── Pre-fetch lookups ──
customers_result = await db.execute(select(Customer.id, Customer.name))
customer_map = {c.name: c.id for c in customers_result.all()}
users_result = await db.execute(select(User.id, User.name))
user_map = {u.name: u.id for u in users_result.all()}
# Customer → assigned primary manager lookup (not from Excel)
assign_result = await db.execute(
select(CustomerAssignment.customer_id, CustomerAssignment.manager_id)
.where(CustomerAssignment.role == "primary")
)
customer_manager_map: dict[uuid.UUID, uuid.UUID] = {}
for cid, mid in assign_result.all():
if cid not in customer_manager_map: # first primary wins
customer_manager_map[cid] = mid
def get_assigned_manager(customer_id: uuid.UUID) -> uuid.UUID:
"""Return the customer's assigned primary manager, or the importer as fallback."""
return customer_manager_map.get(customer_id, manager_id)
# Helper: resolve or create customer
async def resolve_customer(cust_name: str, mgr_name: str = "") -> tuple[uuid.UUID | None, str]:
"""Resolve customer by name. Auto-creates if not found. Returns (id, note)."""
if not cust_name or not cust_name.strip():
return None, ""
cust_name = cust_name.strip()
cid = customer_map.get(cust_name)
if cid:
return cid, ""
# Fuzzy match
fuzzy_id = _fuzzy_match_customer(cust_name, customer_map)
if fuzzy_id:
real_name = next((n for n, i in customer_map.items() if i == fuzzy_id), cust_name)
stats["name_corrections"].append(f"{cust_name}」→「{real_name}")
customer_map[cust_name] = fuzzy_id # cache for future rows
return fuzzy_id, ""
# Auto-create customer (no manager assignment — leave unassigned)
customer = Customer(name=cust_name, created_by=manager_id)
db.add(customer)
await db.flush()
customer_map[cust_name] = customer.id
stats["customers_created"] += 1
stats["customers_created_names"].append(cust_name)
return customer.id, ""
# ── Parse Sheet 1: 每日拜访记录 ──
if "每日拜访记录" in wb.sheetnames:
ws = wb["每日拜访记录"]
@@ -28,28 +144,36 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui
if not row[0]:
continue
try:
cust_name, visit_date_str, visit_method, time_range, visitor_name, visitor_phone, content, demand, companions_str, mgr_str = \
row[0], str(row[1]) if row[1] else str(date.today()), \
str(row[2]) if row[2] else "上门", str(row[3]) if row[3] else "", \
str(row[4]) if row[4] else "", str(row[5]) if row[5] else "", \
str(row[6]) if row[6] else "", str(row[7]) if row[7] else "", \
str(row[8]) if row[8] else "", str(row[9]) if row[9] else ""
cust_name, visit_date_str, visit_method, time_range, visitor_name, visitor_phone, \
content, demand, companions_str, mgr_str = (
row[0], str(row[1]) if row[1] else str(date.today()),
str(row[2]) if row[2] else "上门", str(row[3]) if row[3] else "",
str(row[4]) if row[4] else "", str(row[5]) if row[5] else "",
str(row[6]) if row[6] else "", str(row[7]) if row[7] else "",
str(row[8]) if row[8] else "", str(row[9]) if row[9] else "",
)
customer_id = customer_map.get(cust_name)
# Resolve customer (auto-create if needed)
customer_id, _ = await resolve_customer(cust_name, str(row[9]) if row[9] else "")
if not customer_id:
stats["skipped"] += 1
stats["skip_reasons"].append(f"客户{cust_name}」不存在,跳过")
stats["skip_reasons"].append(f"客户名称为空,跳过")
continue
# Parse visit date
try:
visit_date = datetime.strptime(visit_date_str[:10], "%Y-%m-%d").date()
except ValueError:
visit_date = date.today()
# Use customer's assigned primary manager (not Excel column)
visit_manager_id = get_assigned_manager(customer_id)
# Check duplicate: same date + same manager + same customer
existing = await db.execute(
select(Visit).where(
Visit.visit_date == visit_date,
Visit.manager_id == manager_id,
Visit.manager_id == visit_manager_id,
Visit.customer_id == customer_id,
)
)
@@ -58,6 +182,11 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui
stats["skip_reasons"].append(f"重复:{cust_name} {visit_date} 已存在")
continue
# Parse companions into system users + external names
sys_companions, ext_names = _parse_companions(str(companions_str), user_map)
if ext_names:
stats["external_companions"] += len(ext_names)
visit = Visit(
customer_id=customer_id,
visit_date=visit_date,
@@ -67,15 +196,201 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui
visitor_phone=visitor_phone,
communication_content=content,
customer_demand=demand,
manager_id=manager_id,
companions=sys_companions,
companion_names=ext_names,
manager_id=visit_manager_id,
)
db.add(visit)
stats["visits"] += 1
# Auto-complete matching work plans
from app.services.visits import auto_complete_work_plans
await auto_complete_work_plans(
db, customer_id, visit_date,
mgr_name, "旧周报导入自动完成",
)
# Update customer's last_visit_date for light board
cust = await db.get(Customer, customer_id)
if cust and (not cust.last_visit_date or visit_date > cust.last_visit_date):
cust.last_visit_date = visit_date
cust.last_visit_manager_id = visit_manager_id
except Exception as e:
stats["skipped"] += 1
stats["skip_reasons"].append(f"拜访解析异常:{repr(e)[:120]}")
# ── Parse Sheet 2: 下周工作计划 ──
if "下周工作计划" in wb.sheetnames:
ws2 = wb["下周工作计划"]
for row in ws2.iter_rows(min_row=2, values_only=True):
if not row[0]:
continue
try:
cust_name = str(row[0]).strip() if row[0] else ""
plan_content = str(row[1]) if row[1] else ""
plan_date_str = str(row[2]) if row[2] else str(date.today())
mgr_name = str(row[3]).strip() if row[3] else ""
status = str(row[4]) if row[4] else "计划中"
customer_id, _ = await resolve_customer(cust_name, mgr_name)
if not customer_id:
stats["skipped"] += 1
stats["skip_reasons"].append(f"工作计划:客户「{cust_name}」无法解析")
continue
try:
plan_date = datetime.strptime(plan_date_str[:10], "%Y-%m-%d").date()
except ValueError:
plan_date = date.today()
plan_manager_id = get_assigned_manager(customer_id)
work_plan = WorkPlan(
customer_id=customer_id,
plan_content=plan_content,
plan_date=plan_date,
manager_id=plan_manager_id,
status=status if status in ["计划中", "已完成", "已取消"] else "计划中",
)
db.add(work_plan)
stats["work_plans"] += 1
except Exception:
stats["skipped"] += 1
# ── Parse Sheet 3: 小微业务商机 ──
if "小微业务商机" in wb.sheetnames:
ws3 = wb["小微业务商机"]
for row in ws3.iter_rows(min_row=2, values_only=True):
if not row[0]:
continue
try:
cust_name = str(row[0]).strip() if row[0] else ""
product_type = str(row[1]) if row[1] else ""
amount = str(row[2]) if row[2] else ""
follow_up = str(row[3]) if row[3] else ""
status = str(row[4]) if row[4] else "跟进中"
mgr_name = str(row[5]).strip() if row[5] else ""
expected_revenue = str(row[6]) if row[6] else ""
customer_id, _ = await resolve_customer(cust_name, mgr_name)
if not customer_id:
stats["skipped"] += 1
stats["skip_reasons"].append(f"商机:客户「{cust_name}」无法解析")
continue
biz_manager_id = get_assigned_manager(customer_id)
mini = MiniBusiness(
customer_id=customer_id,
product_type=product_type,
amount=amount,
follow_up_detail=follow_up,
status=status if status in ["跟进中", "已成交", "已流失"] else "跟进中",
manager_id=biz_manager_id,
expected_revenue_date=expected_revenue,
)
db.add(mini)
stats["mini_business"] += 1
except Exception:
stats["skipped"] += 1
# ── Parse Sheet 4: 要客拜访计划 ──
if "要客拜访计划" in wb.sheetnames:
ws4 = wb["要客拜访计划"]
for row in ws4.iter_rows(min_row=2, values_only=True):
if not row[0]:
continue
try:
cust_name = str(row[0]).strip() if row[0] else ""
urgency = str(row[1]) if row[1] else "一般"
description = str(row[2]) if row[2] else ""
progress = str(row[3]) if row[3] else "未开始"
planned_date = str(row[4]) if row[4] else ""
planned_visitor = str(row[5]) if row[5] else ""
visit_target = str(row[6]) if row[6] else ""
mgr_name = str(row[7]).strip() if row[7] else ""
customer_id, _ = await resolve_customer(cust_name, mgr_name)
if not customer_id:
stats["skipped"] += 1
stats["skip_reasons"].append(f"要客:客户「{cust_name}」无法解析")
continue
kv_manager_id = get_assigned_manager(customer_id)
valid_urgency = urgency if urgency in ["一般", "重要", "紧急"] else "一般"
valid_progress = progress if progress in ["未开始", "进行中", "已完成"] else "未开始"
key_visit = KeyVisit(
customer_id=customer_id,
urgency_level=valid_urgency,
description=description,
progress_status=valid_progress,
planned_date=planned_date,
planned_visitor=planned_visitor,
visit_target=visit_target,
manager_id=kv_manager_id,
)
db.add(key_visit)
stats["key_visits"] += 1
except Exception:
stats["skipped"] += 1
# ── Parse Sheet 5: 今日纪要 ──
if "今日纪要" in wb.sheetnames:
ws5 = wb["今日纪要"]
valid_categories = ["行政事务", "合同整理", "发票处理", "内部会议", "培训学习", "其他"]
for row in ws5.iter_rows(min_row=2, values_only=True):
if not row[0]:
continue
try:
note_date_str = str(row[0]) if row[0] else str(date.today())
category = str(row[1]) if row[1] else "其他"
content = str(row[2]) if row[2] else ""
time_range = str(row[3]) if row[3] else ""
mgr_name = str(row[4]).strip() if row[4] else ""
# Validate category
if category not in valid_categories:
category = "其他"
# Resolve manager by name
note_manager_id = manager_id # default to importer
if mgr_name:
mgr_id = user_map.get(mgr_name)
if mgr_id:
note_manager_id = mgr_id
else:
stats["skip_reasons"].append(f"纪要:客户经理「{mgr_name}」不存在,使用导入人")
try:
note_date = datetime.strptime(note_date_str[:10], "%Y-%m-%d").date()
except ValueError:
note_date = date.today()
# Skip duplicates: same manager, same date, same category
existing = await db.execute(
select(DailyNote).where(
DailyNote.note_date == note_date,
DailyNote.manager_id == note_manager_id,
DailyNote.category == category,
)
)
if existing.scalar_one_or_none():
stats["skipped"] += 1
stats["skip_reasons"].append(f"重复:{note_date} {category} 纪要已存在")
continue
daily_note = DailyNote(
manager_id=note_manager_id,
note_date=note_date,
category=category,
content=content,
time_range=time_range,
)
db.add(daily_note)
stats["daily_notes"] += 1
except Exception:
stats["skipped"] += 1
await db.commit()
return stats
import io
+136
View File
@@ -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
+218
View File
@@ -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,
}
+274
View File
@@ -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,
}
+10 -3
View File
@@ -1,4 +1,5 @@
from datetime import timedelta
from io import BytesIO
from minio import Minio
from app.config import settings
@@ -20,10 +21,16 @@ def get_minio_client() -> Minio:
return _client
def generate_presigned_upload_url(object_key: str, expires: int = 600) -> str:
"""Generate a presigned PUT URL for direct upload to MinIO."""
def put_validated_image(object_key: str, image_bytes: bytes) -> None:
"""Store a normalized image only after the API has validated its content."""
client = get_minio_client()
return client.presigned_put_object(settings.MINIO_BUCKET, object_key, expires=timedelta(seconds=expires))
client.put_object(
settings.MINIO_BUCKET,
object_key,
BytesIO(image_bytes),
length=len(image_bytes),
content_type="image/jpeg",
)
def generate_presigned_download_url(object_key: str, expires: int = 3600) -> str:
+161 -15
View File
@@ -1,24 +1,42 @@
from datetime import date, datetime
from sqlalchemy import select, func
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.visit import Visit
from app.models.daily_note import DailyNote
from app.models.user import User
from app.models.leave import Leave
from app.models.work_plan import WorkPlan
from app.models.customer import Customer
from app.services.holidays import load_holiday_sets
from app.services.wecom import wecom_client
from app.utils.timezone import today_cst
from app.utils.timezone import today_cst, is_working_day
async def check_daily_reporting(db: AsyncSession) -> dict:
"""Check today's reporting progress and send wecom reminders to managers who haven't reported."""
today = today_cst()
weekday = today.weekday()
if weekday >= 5: # Skip weekends
return {"status": "weekend", "date": str(today)}
# Get all managers
result = await db.execute(select(User).where(User.role == "manager"))
# Load holiday data (auto-refreshes from API if not cached)
holidays, workdays = await load_holiday_sets(db)
if not is_working_day(today, holidays, workdays):
return {"status": "rest_day", "date": str(today)}
# Get all users who need to report
result = await db.execute(
select(User).where(User.require_report == True)
)
managers = result.scalars().all()
# ── Exclude managers on leave today ──
leaves_today = await db.execute(
select(Leave.manager_id).where(and_(
Leave.start_date <= today,
Leave.end_date >= today,
))
)
on_leave_ids = {str(uid) for uid, in leaves_today.all()}
# Get managers who have reported today
reported_visits = await db.execute(
select(Visit.manager_id).where(Visit.visit_date == today)
@@ -31,19 +49,64 @@ async def check_daily_reporting(db: AsyncSession) -> dict:
reported_map[str(uid)] = True
not_reported = []
reported_names = []
on_leave_names = []
for m in managers:
if str(m.id) not in reported_map:
if str(m.id) in on_leave_ids:
on_leave_names.append(m.name)
continue # skip — exempt from reporting
if str(m.id) in reported_map:
reported_names.append(m.name)
else:
not_reported.append(m)
if not_reported and managers:
content = f"📋 今日填报提醒({today}\n\n以下同事尚未提交今日拜访记录:\n"
for m in not_reported:
content += f"{m.name}\n"
content += "\n请尽快完成今日拜访填报 🙏"
# 1. Send template card to not-reported managers (tap to open app)
if not_reported:
user_ids = [m.wecom_userid for m in not_reported if m.wecom_userid]
if user_ids:
await wecom_client.send_text_message(user_ids, content)
desc = f"{today} | 已填报 {len(reported_names)}/{len(managers)}"
success = await wecom_client.send_template_card(
user_ids=user_ids,
title="📋 今日填报提醒",
desc=desc,
url="https://qj.dhdx.fun/m",
btn_text="去填报",
)
# Fallback to text
if not success:
names_text = "".join(m.name for m in not_reported)
await wecom_client.send_text_message(
user_ids,
f"📋 今日填报提醒\n\n{desc}\n未填报:{names_text}\n\n请尽快完成填报 🙏\nhttps://qj.dhdx.fun/m",
)
# 2. Send summary to directors/leaders only (not broadcast to @all)
directors = await db.execute(
select(User).where(User.role.in_(["director", "leader"]), User.wecom_userid.isnot(None))
)
director_ids = [d.wecom_userid for d in directors.scalars().all()]
if managers and director_ids:
effective_total = len(managers) - len(on_leave_names)
if effective_total > 0:
pct = len(reported_map) / effective_total * 100
else:
pct = 100.0
leave_note = ""
if on_leave_names:
leave_note = f"\n> 请假中(已豁免):{len(on_leave_names)}\n"
leave_note += "".join(f"- {n} (请假)\n" for n in on_leave_names)
summary = (
f"## 📊 今日填报汇总\n\n"
f"> 日期:{today}\n"
f"> 填报率:**{pct:.0f}%** ({len(reported_map)}/{effective_total})\n"
)
if leave_note:
summary += leave_note + "\n"
if not_reported:
summary += "**未填报:**\n" + "".join(f"- {m.name}\n" for m in not_reported)
else:
summary += "✅ 全体已完成今日填报"
await wecom_client.send_markdown_message(summary, user_ids=director_ids)
return {
"status": "ok",
@@ -52,3 +115,86 @@ async def check_daily_reporting(db: AsyncSession) -> dict:
"reported": len(reported_map),
"not_reported": len(not_reported),
}
async def check_overdue_plans(db: AsyncSession) -> dict:
"""Check for overdue work plans and remind managers (runs at 9:00 AM)."""
today = today_cst()
if today.weekday() >= 5:
return {"status": "weekend", "date": str(today)}
result = await db.execute(
select(WorkPlan).where(
WorkPlan.status == "计划中",
WorkPlan.plan_date < today,
).order_by(WorkPlan.manager_id, WorkPlan.plan_date)
)
overdue = result.scalars().all()
if not overdue:
return {"status": "ok", "date": str(today), "overdue": 0}
# Group by manager
by_manager: dict[str, list] = {}
for p in overdue:
mid = str(p.manager_id)
by_manager.setdefault(mid, []).append(p)
users_result = await db.execute(
select(User).where(User.id.in_([uid for uid in by_manager.keys()]))
)
user_map = {str(u.id): u for u in users_result.scalars().all()}
for mid, plans in by_manager.items():
user = user_map.get(mid)
if not user or not user.wecom_userid:
continue
names = "".join(f"{p.customer_id}" for p in plans[:5])
# Get customer names
cust_result = await db.execute(
select(Customer.name).where(Customer.id.in_([p.customer_id for p in plans[:5]]))
)
cust_names = [r[0] for r in cust_result.all()]
plan_lines = "".join(f"- {n} (计划 {p.plan_date})\n" for p, n in zip(plans[:5], cust_names))
content = (
f"📅 拜访计划过期提醒\n\n"
f"以下 {len(plans)} 个拜访计划已过期,请尽快安排拜访:\n"
f"{plan_lines}"
)
if len(plans) > 5:
content += f"... 还有 {len(plans) - 5} 个过期计划\n"
content += f"\n👉 查看详情:https://qj.dhdx.fun/light-board"
await wecom_client.send_text_message([user.wecom_userid], content)
# ── Auto-cancel overdue plans that have no matching visit ──
from app.utils.edit_log import append_entry as append_edit_log
auto_cancelled = 0
for plan in overdue:
has_visit = await db.execute(
select(Visit).where(
Visit.customer_id == plan.customer_id,
Visit.visit_date >= plan.plan_date,
)
)
if not has_visit.scalar():
plan.status = "已取消"
append_edit_log(plan, "系统", [{
"field": "status",
"from": "计划中",
"to": "已取消",
"reason": "逾期自动取消",
}])
auto_cancelled += 1
if auto_cancelled:
await db.commit()
return {
"status": "ok",
"date": str(today),
"overdue": len(overdue),
"auto_cancelled": auto_cancelled,
"managers_affected": len(by_manager),
}
+59
View File
@@ -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
+40
View File
@@ -0,0 +1,40 @@
"""Visit service — shared logic for visit creation, update, and import."""
from uuid import UUID
from datetime import date
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.work_plan import WorkPlan
from app.utils.edit_log import append_entry
async def auto_complete_work_plans(
db: AsyncSession,
customer_id: UUID,
visit_date: date,
editor_name: str,
reason: str = "拜访自动完成",
) -> int:
"""Auto-complete matching work plans when a visit is created/updated/imported.
Matches by: same customer_id + status=="计划中" + plan_date <= visit_date.
Returns the number of plans completed.
"""
plans_result = await db.execute(
select(WorkPlan).where(
WorkPlan.customer_id == customer_id,
WorkPlan.status == "计划中",
WorkPlan.plan_date <= visit_date,
)
)
count = 0
for plan in plans_result.scalars().all():
plan.status = "已完成"
append_entry(plan, editor_name, [{
"field": "status",
"from": "计划中",
"to": "已完成",
"reason": reason,
}])
count += 1
return count
+222 -23
View File
@@ -1,55 +1,254 @@
import asyncio
import time
import uuid
from datetime import datetime, timedelta, timezone
import httpx
from sqlalchemy import delete, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.wecom_bind_token import WecomBindToken
async def store_bind_token(db: AsyncSession, wecom_userid: str, ttl: int = 600) -> str:
"""Persist a one-time bind token so it works across application workers."""
token = uuid.uuid4().hex
now = datetime.now(timezone.utc)
await db.execute(delete(WecomBindToken).where(WecomBindToken.expires_at < now))
db.add(WecomBindToken(
token=token,
wecom_userid=wecom_userid,
expires_at=now + timedelta(seconds=ttl),
))
await db.commit()
return token
async def consume_bind_token(db: AsyncSession, token: str) -> str | None:
"""Atomically consume an unexpired binding token and return its WeCom user ID."""
now = datetime.now(timezone.utc)
result = await db.execute(
update(WecomBindToken)
.where(
WecomBindToken.token == token,
WecomBindToken.consumed_at.is_(None),
WecomBindToken.expires_at > now,
)
.values(consumed_at=now)
.returning(WecomBindToken.wecom_userid)
)
await db.commit()
return result.scalar_one_or_none()
class WecomClient:
"""Minimal WeChat Work API client for sending app messages."""
"""WeChat Work API client — token management, message sending, OAuth."""
def __init__(self):
self.corp_id = settings.WECOM_CORP_ID
self.agent_id = settings.WECOM_AGENT_ID
self.secret = settings.WECOM_SECRET
self._access_token: str | None = None
self._token_expires_at: float = 0 # epoch seconds
@property
def access_token(self) -> str | None:
return self._access_token
async def _get_token(self) -> str:
if self._access_token:
"""Get a valid access token, refreshing if expired."""
if self._access_token and time.time() < self._token_expires_at - 60:
return self._access_token
url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corp_id}&corpsecret={self.secret}"
url = f"{settings.WECOM_API_BASE}/cgi-bin/gettoken?corpid={self.corp_id}&corpsecret={self.secret}"
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=10)
data = resp.json()
if data.get("errcode") == 0:
self._access_token = data["access_token"]
self._token_expires_at = time.time() + data.get("expires_in", 7200)
return self._access_token
raise Exception(f"Failed to get wecom token: {data}")
async def _post_with_retry(self, url: str, body: dict, max_retries: int = 3) -> dict:
"""POST with retry on network errors and automatic token refresh on 42001."""
last_error = None
for attempt in range(max_retries):
try:
token = await self._get_token()
sep = "&" if "?" in url else "?"
full_url = f"{url}{sep}access_token={token}"
async with httpx.AsyncClient() as client:
resp = await client.post(full_url, json=body, timeout=10)
data = resp.json()
errcode = data.get("errcode", 0)
if errcode == 0:
return data
if errcode == 42001:
self._access_token = None
self._token_expires_at = 0
if attempt < max_retries - 1:
continue
if errcode == 45009:
if attempt < max_retries - 1:
await asyncio.sleep(1 * (attempt + 1))
continue
last_error = data
except (httpx.TimeoutException, httpx.ConnectError) as e:
last_error = {"errcode": -1, "errmsg": str(e)}
if attempt < max_retries - 1:
await asyncio.sleep(0.5 * (attempt + 1))
raise Exception(f"WeCom API error after {max_retries} retries: {last_error}")
# ── OAuth ──
async def get_userinfo_by_code(self, code: str) -> dict | None:
"""Exchange OAuth2 code for userid (used in silent login)."""
token = await self._get_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_token={token}&code={code}"
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=10)
data = resp.json()
if data.get("errcode") == 0:
return data
try:
token = await self._get_token()
url = f"{settings.WECOM_API_BASE}/cgi-bin/auth/getuserinfo?access_token={token}&code={code}"
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=10)
data = resp.json()
if data.get("errcode") == 0:
return data
return None
except Exception:
return None
# ── Messaging ──
async def send_text_message(self, user_ids: list[str], content: str) -> bool:
"""Send a text app message to specified users."""
if not settings.WECOM_AGENT_ID:
return False # Not configured, skip silently in dev
token = await self._get_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}"
body = {
"touser": "|".join(user_ids),
"msgtype": "text",
"agentid": int(settings.WECOM_AGENT_ID),
"text": {"content": content},
}
async with httpx.AsyncClient() as client:
resp = await client.post(url, json=body, timeout=10)
data = resp.json()
return data.get("errcode") == 0
return False
try:
body = {
"touser": "|".join(user_ids),
"msgtype": "text",
"agentid": int(settings.WECOM_AGENT_ID),
"text": {"content": content},
}
await self._post_with_retry(
f"{settings.WECOM_API_BASE}/cgi-bin/message/send", body
)
return True
except Exception:
return False
async def send_markdown_message(self, content: str, user_ids: list[str] | None = None) -> bool:
"""Send a markdown message. If user_ids is provided, send to those users; otherwise broadcast to @all."""
if not settings.WECOM_AGENT_ID:
return False
try:
touser = "|".join(user_ids) if user_ids else "@all"
body = {
"touser": touser,
"msgtype": "markdown",
"agentid": int(settings.WECOM_AGENT_ID),
"markdown": {"content": content},
}
await self._post_with_retry(
f"{settings.WECOM_API_BASE}/cgi-bin/message/send", body
)
return True
except Exception:
return False
async def send_text_card(self, user_id: str, title: str, description: str, url: str) -> bool:
"""Send a textcard message (clickable card) to a single user."""
if not settings.WECOM_AGENT_ID:
return False
try:
body = {
"touser": user_id,
"msgtype": "textcard",
"agentid": int(settings.WECOM_AGENT_ID),
"textcard": {
"title": title,
"description": description,
"url": url,
},
}
await self._post_with_retry(
f"{settings.WECOM_API_BASE}/cgi-bin/message/send", body
)
return True
except Exception:
return False
async def send_template_card(
self, user_ids: list[str], title: str, desc: str, url: str, btn_text: str = "查看详情"
) -> bool:
"""Send a text_notice template card with a deep-link button."""
if not settings.WECOM_AGENT_ID:
return False
try:
body = {
"touser": "|".join(user_ids),
"msgtype": "template_card",
"agentid": int(settings.WECOM_AGENT_ID),
"template_card": {
"card_type": "text_notice",
"main_title": {"title": title, "desc": desc},
"card_action": {"type": 1, "url": url},
"button_list": [{"text": btn_text, "style": 1, "key": "open_url"}],
},
}
await self._post_with_retry(
f"{settings.WECOM_API_BASE}/cgi-bin/message/send", body
)
return True
except Exception:
return False
# ── Menu management ──
async def create_menu(self, buttons: list[dict]) -> bool:
"""Create/replace the app's custom menu."""
if not settings.WECOM_AGENT_ID:
return False
try:
body = {"button": buttons}
await self._post_with_retry(
f"{settings.WECOM_API_BASE}/cgi-bin/menu/create?agentid={settings.WECOM_AGENT_ID}", body
)
return True
except Exception:
return False
async def get_menu(self) -> dict | None:
"""Get current app menu configuration."""
if not settings.WECOM_AGENT_ID:
return None
try:
token = await self._get_token()
url = f"{settings.WECOM_API_BASE}/cgi-bin/menu/get?access_token={token}&agentid={settings.WECOM_AGENT_ID}"
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=10)
data = resp.json()
if data.get("errcode") == 0:
return data
return None
except Exception:
return None
async def delete_menu(self) -> bool:
"""Delete the app's custom menu."""
if not settings.WECOM_AGENT_ID:
return False
try:
token = await self._get_token()
url = f"{settings.WECOM_API_BASE}/cgi-bin/menu/delete?access_token={token}&agentid={settings.WECOM_AGENT_ID}"
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=10)
data = resp.json()
return data.get("errcode") == 0
except Exception:
return False
wecom_client = WecomClient()
+30
View File
@@ -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
+3 -2
View File
@@ -1,6 +1,7 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import jwt, JWTError
import jwt
from jwt import InvalidTokenError
from app.config import settings
@@ -15,5 +16,5 @@ def decode_token(token: str) -> Optional[dict]:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
return payload
except JWTError:
except InvalidTokenError:
return None
+29
View File
@@ -11,3 +11,32 @@ def today_cst() -> date:
def parse_date(s: str) -> date:
"""Parse a date string that may be YYYY-MM-DD or a full ISO datetime."""
return date.fromisoformat(s[:10])
def is_working_day(d: date | None = None, holidays: set[date] | None = None, workdays: set[date] | None = None) -> bool:
"""Check if a date is a Chinese working day.
Returns False for:
- Saturdays and Sundays (weekday >= 5), UNLESS in the workdays set (调休)
- Dates in the holidays set (weekday holidays like 春节/国庆)
Returns True for:
- Monday-Friday, unless in holidays set
- Saturday/Sunday that are in the workdays set (调休 makeup days)
"""
d = d or today_cst()
is_weekend = d.weekday() >= 5
# 调休: weekend that becomes a workday
if is_weekend and workdays and d in workdays:
return True
# Normal weekend
if is_weekend:
return False
# Weekday holiday
if holidays and d in holidays:
return False
return True
+11
View File
@@ -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"]
+7 -4
View File
@@ -1,14 +1,17 @@
fastapi==0.115.6
fastapi==0.140.7
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
alembic==1.14.0
pydantic==2.10.3
pydantic-settings==2.7.0
python-jose[cryptography]==3.3.0
PyJWT==2.13.0
httpx==0.28.1
python-multipart==0.0.18
python-multipart==0.0.32
minio==7.2.10
openpyxl==3.1.5
apscheduler==3.11.0
python-dotenv==1.0.1
python-dotenv==1.2.2
cryptography==49.0.0
defusedxml==0.7.1
Pillow==12.3.0
+59
View File
@@ -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
+10
View File
@@ -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
+95
View File
@@ -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")
+24
View File
@@ -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
+22
View File
@@ -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
View File
@@ -1,45 +1,56 @@
# This compose file is for a single-host development/integration environment.
# Production deployment must use the approved platform pipeline and secret manager.
version: "3.8"
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: qiji
POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER must be set}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
POSTGRES_DB: ${POSTGRES_DB:-qiji}
ports:
- "5432:5432"
- "127.0.0.1:5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 12
minio:
image: minio/minio:latest
image: ${MINIO_IMAGE:?MINIO_IMAGE must be a pinned MinIO version or digest}
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
ports:
- "9000:9000"
- "9001:9001"
- "127.0.0.1:9000:9000"
- "127.0.0.1:9001:9001"
volumes:
- miniodata:/data
backend:
build: ./backend
ports:
- "8000:8000"
- "127.0.0.1:8000:8000"
environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/qiji
ENVIRONMENT: development
DEBUG: "false"
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set}
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:?POSTGRES_USER must be set}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}@postgres:5432/${POSTGRES_DB:-qiji}
MINIO_ENDPOINT: minio:9000
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
MINIO_BUCKET: qiji-photos
MINIO_SECURE: "false"
depends_on:
- postgres
- minio
volumes:
- ./backend:/app
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
postgres:
condition: service_healthy
minio:
condition: service_started
command: sh -c "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8000"
volumes:
pgdata:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,454 @@
# 暗黑模式 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 为企迹系统新增双选暗黑模式:用户在设置页分别选择亮色/暗色偏好主题,顶部栏一键切换亮↔暗。
**Architecture:** 扩展 theme store 支持 mode(light/dark) + lightTheme + darkTheme 三字段,新增 minimal-dark/tech-dark 两套 CSS 变量,Element Plus 通过 `html.dark` class 适配暗色。
**Tech Stack:** Vue 3 + Pinia + TypeScript + Element Plus + CSS Custom Properties
## Global Constraints
- 四大主题: editorial(已有), light(已有), minimal-dark(新增), tech-dark(新增)
- localStorage 持久化: mode, lightTheme, darkTheme 三项
- 暗色模式时 `<html>` 同时设置 `data-theme``class="dark"`
- 移动端暂不加主题切换(仅 PC 端顶部栏按钮)
- 保持现有 editorial/light 的 CSS 变量不变
---
### Task 1: 改造 Theme Store
**Files:**
- Modify: `frontend/src/stores/theme.ts`
**Interfaces:**
- Produces: `useThemeStore()` with `mode`, `lightTheme`, `darkTheme`, `effectiveTheme`, `toggleMode()`, `setLightTheme(t)`, `setDarkTheme(t)`, `applyTheme(t)`
- Theme type: `'editorial' | 'light' | 'minimal-dark' | 'tech-dark'`
- [ ] **Step 1: 重写 theme store**
```typescript
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export type Theme = 'editorial' | 'light' | 'minimal-dark' | 'tech-dark'
type Mode = 'light' | 'dark'
export const themeLabels: Record<Theme, string> = {
editorial: '编辑风',
light: '极简亮色',
'minimal-dark': '极简深色',
'tech-dark': '科技深色',
}
export const lightThemes: Theme[] = ['editorial', 'light']
export const darkThemes: Theme[] = ['minimal-dark', 'tech-dark']
export const useThemeStore = defineStore('theme', () => {
const mode = ref<Mode>((localStorage.getItem('theme-mode') as Mode) || 'light')
const lightTheme = ref<Theme>((localStorage.getItem('theme-light') as Theme) || 'editorial')
const darkTheme = ref<Theme>((localStorage.getItem('theme-dark') as Theme) || 'tech-dark')
const effectiveTheme = computed<Theme>(() => {
return mode.value === 'light' ? lightTheme.value : darkTheme.value
})
function applyTheme(theme: Theme, isDark: boolean) {
document.documentElement.setAttribute('data-theme', theme)
if (isDark) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
function apply() {
applyTheme(effectiveTheme.value, mode.value === 'dark')
}
function setLightTheme(theme: Theme) {
lightTheme.value = theme
localStorage.setItem('theme-light', theme)
if (mode.value === 'light') apply()
}
function setDarkTheme(theme: Theme) {
darkTheme.value = theme
localStorage.setItem('theme-dark', theme)
if (mode.value === 'dark') apply()
}
function toggleMode() {
mode.value = mode.value === 'light' ? 'dark' : 'light'
localStorage.setItem('theme-mode', mode.value)
apply()
}
// Apply on init
apply()
return { mode, lightTheme, darkTheme, effectiveTheme, setLightTheme, setDarkTheme, toggleMode }
})
```
- [ ] **Step 2: Commit**
```bash
git add frontend/src/stores/theme.ts
git commit -m "feat: 改造 theme store 支持暗黑模式双选架构"
```
---
### Task 2: 新增两套暗色 CSS 变量
**Files:**
- Modify: `frontend/src/App.vue` (在 `:root[data-theme="light"]` 块之后添加两个新块)
**Interfaces:**
- Consumes: Theme type `'minimal-dark'`, `'tech-dark'` from store
- Produces: CSS variables for both dark themes + Element Plus dark overrides
- [ ] **Step 1: 添加 minimal-dark 主题 CSS**
`:root[data-theme="light"]` 块结束后(第178行 `}` 之后),插入:
```css
/* ═══════════════════════════════════════════════════════════
THEME: Minimal Dark — 极简深色
═══════════════════════════════════════════════════════════ */
:root[data-theme="minimal-dark"] {
/* ── Core palette ── */
--ink: #E5E5E5;
--ink-light: #CCCCCC;
--ink-dark: #FFFFFF;
--vermilion: #F87171;
--vermilion-light: #FCA5A5;
--vermilion-dark: #EF4444;
--gold: #A78BFA;
--gold-light: #C4B5FD;
--gold-dark: #8B5CF6;
--paper: #1A1A1A;
--paper-dark: #141414;
--surface: #2D2D2D;
--sage: #4ADE80;
--amber: #FBBF24;
--warm-gray: #999999;
--warm-border: #3A3A3A;
/* ── Semantic tokens ── */
--c-primary: var(--ink);
--c-primary-light: var(--ink-light);
--c-primary-bg: #262626;
--c-accent: var(--vermilion);
--c-accent-light: var(--vermilion-light);
--c-gold: var(--gold);
--c-success: var(--sage);
--c-warning: var(--amber);
--c-danger: var(--vermilion);
--c-info: #60A5FA;
--c-text: #E5E5E5;
--c-text-secondary: #999999;
--c-text-muted: #666666;
--c-bg: var(--paper);
--c-bg-card: var(--surface);
--c-border: var(--warm-border);
/* ── Effects ── */
--shadow-sm: 0 1px 2px rgba(0,0,0,0.20);
--shadow: 0 1px 3px rgba(0,0,0,0.30);
--shadow-md: 0 2px 6px rgba(0,0,0,0.40);
--shadow-lg: 0 4px 12px rgba(0,0,0,0.50);
--radius: 0px;
--radius-sm: 0px;
--radius-xs: 0px;
--transition: 0.18s cubic-bezier(0.4, 0, 0.2, 1);
/* ── Sidebar — dark minimal ── */
--sidebar-bg: #141414;
--sidebar-color: #E5E5E5;
--sidebar-accent: var(--gold);
--sidebar-nav-text: #777777;
--sidebar-nav-text-hover: #CCCCCC;
--sidebar-nav-text-active: #FFFFFF;
--sidebar-nav-bg-hover: rgba(255,255,255,0.04);
--sidebar-nav-bg-active: rgba(255,255,255,0.06);
--sidebar-nav-border-active: var(--gold);
--sidebar-divider: #2A2A2A;
--sidebar-brand-color: #FFFFFF;
--sidebar-brand-sub-color: #666666;
--sidebar-group-label-color: #555555;
--sidebar-footer-role-color: var(--gold);
--sidebar-footer-name-color: #999999;
/* ── Mobile page accent ── */
--page-accent-color: rgba(167,139,250,0.06);
/* ── Manager chip tags ── */
--mgr-chip-text: #E5E5E5;
/* ── Fonts — sans-serif minimal ── */
--font-body: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-heading: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
```
- [ ] **Step 2: 添加 tech-dark 主题 CSS**
在 minimal-dark 块之后插入:
```css
/* ═══════════════════════════════════════════════════════════
THEME: Tech Dark — 科技深色 (编辑风暗色版)
═══════════════════════════════════════════════════════════ */
:root[data-theme="tech-dark"] {
/* ── Core palette ── */
--ink: #C8D6E5;
--ink-light: #A0B4CC;
--ink-dark: #E8EEF4;
--vermilion: #F26B6B;
--vermilion-light: #F59898;
--vermilion-dark: #E55555;
--gold: #5B8DEF;
--gold-light: #7BA5F5;
--gold-dark: #4070D0;
--paper: #0B1120;
--paper-dark: #080C18;
--surface: #111B2E;
--sage: #4EC9B0;
--amber: #E5C07B;
--warm-gray: #6B7A8D;
--warm-border: #1E2D45;
/* ── Semantic tokens ── */
--c-primary: var(--ink);
--c-primary-light: var(--ink-light);
--c-primary-bg: #131D30;
--c-accent: var(--vermilion);
--c-accent-light: var(--vermilion-light);
--c-gold: var(--gold);
--c-success: var(--sage);
--c-warning: var(--amber);
--c-danger: var(--vermilion);
--c-info: #5B8DEF;
--c-text: #C8D6E5;
--c-text-secondary: #6B7A8D;
--c-text-muted: #455570;
--c-bg: var(--paper);
--c-bg-card: var(--surface);
--c-border: var(--warm-border);
/* ── Effects ── */
--shadow-sm: 0 1px 2px rgba(0,0,0,0.25);
--shadow: 0 2px 6px rgba(0,0,0,0.35);
--shadow-md: 0 6px 18px rgba(0,0,0,0.45);
--shadow-lg: 0 12px 36px rgba(0,0,0,0.55);
--radius: 2px;
--radius-sm: 2px;
--radius-xs: 2px;
--transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
/* ── Sidebar — dark tech ── */
--sidebar-bg: #070D1A;
--sidebar-color: #C8D6E5;
--sidebar-accent: var(--gold);
--sidebar-nav-text: rgba(200,214,229,0.45);
--sidebar-nav-text-hover: rgba(200,214,229,0.80);
--sidebar-nav-text-active: #FFFFFF;
--sidebar-nav-bg-hover: rgba(91,141,239,0.06);
--sidebar-nav-bg-active: rgba(91,141,239,0.10);
--sidebar-nav-border-active: var(--gold);
--sidebar-divider: rgba(200,214,229,0.06);
--sidebar-brand-color: #FFFFFF;
--sidebar-brand-sub-color: rgba(200,214,229,0.35);
--sidebar-group-label-color: rgba(200,214,229,0.20);
--sidebar-footer-role-color: var(--gold);
--sidebar-footer-name-color: rgba(200,214,229,0.65);
/* ── Mobile page accent ── */
--page-accent-color: rgba(91,141,239,0.06);
/* ── Manager chip tags ── */
--mgr-chip-text: #C8D6E5;
/* ── Fonts — editorial Chinese ── */
--font-body: 'Noto Serif SC', STSong, Songti SC, '宋体', serif;
--font-heading: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
--font-mono: 'JetBrains Mono', 'SF Mono', monospace;
}
```
- [ ] **Step 3: Commit**
```bash
git add frontend/src/App.vue
git commit -m "feat: 新增 minimal-dark 和 tech-dark 两套暗色 CSS 变量"
```
---
### Task 3: 改造 DesktopLayout 顶部栏按钮
**Files:**
- Modify: `frontend/src/components/DesktopLayout.vue` (script 和 template 中的主题切换按钮)
**Interfaces:**
- Consumes: `useThemeStore()``mode`, `toggleMode()`
- [ ] **Step 1: 替换主题按钮为亮/暗切换**
`cycleTheme` 函数替换为 `toggleMode`,按钮改为 sun/moon 图标切换:
```typescript
// 替换 cycleTheme 函数:
function toggleDarkMode() {
themeStore.toggleMode()
}
```
Template 中替换按钮:
```html
<button
class="topbar-btn"
@click="toggleDarkMode"
:title="themeStore.mode === 'light' ? '切换至暗色模式' : '切换至亮色模式'"
>
<!-- Sun icon (light mode) -->
<svg v-if="themeStore.mode === 'light'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
<!-- Moon icon (dark mode) -->
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
```
- [ ] **Step 2: 触发构建验证**
```bash
cd frontend && npm run build -- --emptyOutDir
```
- [ ] **Step 3: Commit**
```bash
git add frontend/src/components/DesktopLayout.vue
git commit -m "feat: 桌面端顶部栏改为亮/暗模式切换按钮"
```
---
### Task 4: 改造设置页面主题面板
**Files:**
- Modify: `frontend/src/views/desktop/Settings.vue` (替换 Theme 卡片为双选下拉)
**Interfaces:**
- Consumes: `useThemeStore()``lightTheme`, `darkTheme`, `mode`, `setLightTheme()`, `setDarkTheme()`
- [ ] **Step 1: 替换主题选择区域**
Replace the el-card template section (lines 158-173) and the `themeLabels` import:
**Script 部分修改 import:**
```typescript
import { useThemeStore, themeLabels, lightThemes, darkThemes, type Theme } from '@/stores/theme'
```
**Template 部分替换主题卡片:**
```html
<!-- Theme -->
<el-card class="setting-card">
<template #header>
<div class="card-header-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px; color: var(--gold)">
<circle cx="12" cy="12" r="5"></circle><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"></path>
</svg>
界面主题
</div>
</template>
<div class="theme-config">
<div class="theme-row">
<span class="theme-label">☀️ 亮色模式主题</span>
<el-select :model-value="themeStore.lightTheme" @update:model-value="themeStore.setLightTheme($event as Theme)" style="width:200px">
<el-option v-for="t in lightThemes" :key="t" :label="themeLabels[t]" :value="t" />
</el-select>
<span v-if="themeStore.mode === 'light'" class="theme-badge theme-badge--active">当前</span>
</div>
<div class="theme-row">
<span class="theme-label">🌙 暗色模式主题</span>
<el-select :model-value="themeStore.darkTheme" @update:model-value="themeStore.setDarkTheme($event as Theme)" style="width:200px">
<el-option v-for="t in darkThemes" :key="t" :label="themeLabels[t]" :value="t" />
</el-select>
<span v-if="themeStore.mode === 'dark'" class="theme-badge theme-badge--active">当前</span>
</div>
</div>
</el-card>
```
**Style 部分替换 `.theme-options` 样式:**
```css
/* ── Theme Config ── */
.theme-config { display: flex; flex-direction: column; gap: 12px; }
.theme-row { display: flex; align-items: center; gap: 12px; }
.theme-label { font-family: var(--font-body); font-size: 14px; color: var(--c-text); min-width: 140px; }
.theme-badge { font-size: 11px; padding: 2px 8px; border-radius: 2px; font-family: var(--font-mono); }
.theme-badge--active { background: var(--c-primary); color: #fff; }
```
- [ ] **Step 2: 触发构建验证**
```bash
cd frontend && npm run build -- --emptyOutDir
```
- [ ] **Step 3: Commit**
```bash
git add frontend/src/views/desktop/Settings.vue
git commit -m "feat: 设置页改为亮/暗双选主题配置面板"
```
---
### Task 5: 验证与收尾
- [ ] **Step 1: 完整构建**
```bash
cd frontend && npm run build
```
- [ ] **Step 2: 检查 localStorage 兼容性**
验证旧 `localStorage.setItem('theme', ...)` 数据不会导致新系统崩溃。在 theme store 的 `apply()` 中已有兜底逻辑。
- [ ] **Step 3: 快速手动测试检查清单**
- [ ] 默认加载为 editorial 亮色
- [ ] 点击顶部栏 ☀️→🌙 切换到 tech-dark
- [ ] 设置页切换亮色主题为 light,暗色主题为 minimal-dark
- [ ] 点击顶部栏按钮在两主题间切换
- [ ] 刷新页面偏好保持
- [ ] Element Plus 组件(表格/对话框/按钮)在暗色下正常
- [ ] **Step 4: Commit**
```bash
git add -A
git commit -m "chore: 暗黑模式最终验证通过"
```
@@ -0,0 +1,349 @@
# 客户经理请假功能 — 设计文档
> 日期:2026-07-12 | 状态:已确认
## 一、需求概述
为政企周报管理系统添加客户经理请假功能。请假可由客户经理自己提交或支局长代为提交,提交即生效(无需审批)。请假期间免填报考核、免催办提醒,AI 周报摘要标注请假信息。
## 二、核心决策
| 决策项 | 结论 |
|--------|------|
| 粒度 | 按天(start_date ~ end_date |
| 审批 | 无需审批,提交即生效 |
| 类型 | 年假 / 事假 / 病假 / 调休 / 其他 |
| 权限 | 经理看自己,支局长/领导看全员 |
| 入口 | PC 端仪表盘卡片 + 独立页面 + 移动端首页 + 表单页 |
## 三、数据模型
### 新增表 `leaves`
```sql
CREATE TABLE leaves (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
manager_id UUID NOT NULL REFERENCES users(id),
leave_type VARCHAR(20) NOT NULL DEFAULT '事假', -- 年假/事假/病假/调休/其他
start_date DATE NOT NULL,
end_date DATE NOT NULL,
reason VARCHAR(500) DEFAULT '',
submitted_by UUID NOT NULL REFERENCES users(id), -- 提交人(本人或支局长)
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_leaves_manager_id ON leaves(manager_id);
CREATE INDEX idx_leaves_dates ON leaves(start_date, end_date);
```
### 设计要点
- `start_date <= end_date`,应用层校验
- 判断"今天是否请假中":`start_date <= today <= end_date`
- 判断"本周是否有请假":`start_date <= sunday AND end_date >= monday`
- 无 edit_log 列(请假记录简单,不追变更历史)
### SQLAlchemy 模型
```python
class Leave(Base):
__tablename__ = "leaves"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
leave_type: Mapped[str] = mapped_column(String(20), default="事假")
start_date: Mapped[date] = mapped_column(Date)
end_date: Mapped[date] = mapped_column(Date)
reason: Mapped[str] = mapped_column(String(500), default="")
submitted_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
```
## 四、业务逻辑变更
### 4.1 填报进度判断 (`get_reporting_progress`)
**文件:** `backend/app/services/dashboard.py`
变更点:
1. 查询本周所有请假记录(`start_date <= sunday AND end_date >= monday`
2. 对每个经理判断今日是否在请假区间内
3. 新增第四种状态 `on_leave`
```python
# 新增返回字段
{
"manager_id": "uuid",
"has_reported_today": bool,
"completed": bool,
"on_leave": bool, # ← 新增
"leave_info": { # ← 新增(仅 on_leave=True 时有值)
"leave_type": "事假",
"start_date": "...",
"end_date": "..."
} | null
}
```
前端三色状态扩展为四色:
| 状态 | 条件 | 颜色 | 印章 |
|------|------|------|------|
| `full` | 已填 + 完成 | 绿 (sage) | 满 |
| `catching` | 已填 + 追赶中 | 黄 (gold) | 追 |
| `missing` | 未填 | 红 (vermilion) | 未 |
| `on_leave` | 请假中 | 蓝灰 | 假 |
### 4.2 催办提醒 (`check_daily_reporting`)
**文件:** `backend/app/services/scheduler.py`
变更点:
1. 查询当天请假中的经理(`start_date <= today <= end_date`
2. 从催办列表中排除这些经理
3. 汇总消息标注"X人请假,已豁免"
### 4.3 仪表盘统计卡
**文件:** `backend/app/services/dashboard.py` + 前端 `Dashboard.vue`
新增第五张统计卡「本周请假」:
- 数字:本周请假总人数
- 数据来源:统计 `start_date <= sunday AND end_date >= monday` 的去重 manager 数
- 点击跳转 `/leaves`
- 颜色:蓝灰调
### 4.4 AI 摘要提示词
**文件:** `backend/app/services/ai_summary.py``build_summary_prompt()`
新增一节注入请假数据:
```markdown
## 本周请假情况
- 张三 (事假): 2026-07-13 ~ 2026-07-15 (3天)
- 李四 (年假): 2026-07-14 ~ 2026-07-17 (4天)
```
效果:LLM 生成的摘要会自然提及"本周张三请事假3天未参与拜访"等信息。
## 五、API 设计
所有接口挂载在 `/api/leaves`,需登录认证。
### 5.1 列表
```
GET /api/leaves?manager_id=xxx&status=active&page=1&page_size=25
```
权限:`director`/`leader` 看全员,`manager` 只返回自己的记录。
`status` 筛选项:`active`(进行中)/ `upcoming`(未来)/ `past`(已结束)/ 不传全部。
默认按 `start_date DESC` 排序。
### 5.2 新建
```
POST /api/leaves
```
```json
{
"manager_id": "uuid",
"leave_type": "年假",
"start_date": "2026-07-13",
"end_date": "2026-07-15",
"reason": "回老家"
}
```
权限:`manager` 只能给自己建(manager_id 必须等于自己),`director` 可以给任何人建。
校验:`end_date >= start_date`
### 5.3 编辑
```
PUT /api/leaves/{id}
```
请求体同新建。权限:`director` 可编辑任意记录,`manager` 仅可编辑自己提交且尚未开始的记录。
### 5.4 删除
```
DELETE /api/leaves/{id}
```
权限:同编辑。
### 5.5 概览(仪表盘用)
```
GET /api/leaves/overview?reference_date=2026-07-12
```
```json
{
"week_start": "2026-07-13",
"week_end": "2026-07-19",
"total_on_leave": 2,
"leave_list": [
{
"manager_id": "uuid",
"manager_name": "张三",
"leave_type": "事假",
"start_date": "2026-07-13",
"end_date": "2026-07-15",
"days": 3
}
]
}
```
权限:`director`/`leader` 返回全员请假数据,`manager` 只返回自己。
## 六、前端设计
### 6.1 PC 端 — 仪表盘卡片
**文件:** `frontend/src/views/desktop/Dashboard.vue`
- 新增第五张统计卡「本周请假」
- 样式与现有四卡一致(stat-card + stat-glyph + 数字 + 标签)
- 颜色:蓝灰色调(`--slate` 系列 CSS 变量)
- 点击跳转 `/leaves`
- 数据来源:`GET /api/leaves/overview`
### 6.2 PC 端 — 请假管理页
**文件:** 新增 `frontend/src/views/desktop/Leaves.vue`
**路由:** `/leaves`DesktopLayout 侧边栏「工作」分组下新增「请假管理」
**页面结构:**
- 顶部汇总栏:按请假类型分组统计条数(年假X / 事假X / 病假X / 调休X / 其他X)
- 筛选栏:按经理筛选(支局长/领导可见)、按状态筛选(进行中 / 即将开始 / 已结束)
- 表格列:姓名 + 类型(彩色标签)+ 日期范围 + 天数 + 原因摘要 + 提交人 + 操作(编辑/删除)
- 进行中的行蓝色左边框高亮
- 新增/编辑弹窗(el-dialog):选择经理(支局长代填时)、请假类型 chip 按钮组、日期范围选择器、原因 textarea
**参考文件:**
- 汇总栏样式:参考 `WorkPlans.vue` / `KeyVisits.vue` 的顶部汇总栏
- 表格模式:参考 `CustomerManage.vue` 的 el-table + 分页
- 表单弹窗:参考各独立工作页的 CRUD 弹窗
### 6.3 PC 端 — 填报进度卡片
**文件:** `frontend/src/views/desktop/Dashboard.vue`
- 请假中的经理卡片:蓝色「假」印章水印 + 灰色进度条 + "请假中"文案
- 不计入未填报人数统计
### 6.4 PC 端 — 侧边栏
**文件:** `frontend/src/components/DesktopLayout.vue`
侧边栏「工作」分组新增:
```
工作
├─ 工作计划
├─ 商机跟单
├─ 要客拜访
└─ 请假管理 ← 新增,📋 icon
```
### 6.5 移动端 — 首页入口
**文件:** `frontend/src/views/mobile/Home.vue`
- 在快捷按钮区域(现有「工作计划」「商机跟单」「要客拜访」旁)新增「请假」chip
- 如果当前有进行中的请假,chip 右上角显示蓝色小圆点
- 点击跳转 `/m/leaves`
### 6.6 移动端 — 请假列表页
**文件:** 新增 `frontend/src/views/mobile/LeavesList.vue`
**路由:** `/m/leaves`
- 卡片流式列表(参考 Home.vue 的记录卡片风格)
- 每条记录:类型彩色标签 + 日期范围 + 天数 + 原因
- 进行中的卡片蓝色左边框
- 顶部有「新建请假」按钮(ink 风格全宽按钮)
### 6.7 移动端 — 请假表单页
**文件:** 新增 `frontend/src/views/mobile/LeaveForm.vue`
**路由:** `/m/leave/new` + `/m/leave/:id/edit`(复用同一组件)
- 沿用现有移动端表单模式(editorial header + 返回按钮 + 中文标题 + 英文副标题 + 金色装饰线)
- 请假类型:chip 按钮组(年假/事假/病假/调休/其他),参考 VisitForm 的拜访方式 chip
- 日期范围:开始日期 → 结束日期
- 原因:textarea,选填
- 提交按钮:全宽暗色按钮「提交请假」
- 编辑模式下额外显示删除按钮(vermilion 描边)
### 6.8 移动端 — 路由配置
**文件:** `frontend/src/router/index.ts`
新增 3 条移动端路由:
```typescript
{ path: 'leaves', component: LeavesList }, // /m/leaves
{ path: 'leave/new', component: LeaveForm }, // /m/leave/new
{ path: 'leave/:id/edit', component: LeaveForm }, // /m/leave/:id/edit
```
## 七、文件变更清单
### 后端新增
| 文件 | 说明 |
|------|------|
| `backend/app/models/leave.py` | Leave SQLAlchemy 模型 |
| `backend/app/schemas/leave.py` | Pydantic 请求/响应 schema |
| `backend/app/api/leaves.py` | REST API 路由 |
| `backend/app/services/leaves.py` | 业务逻辑层 |
| `backend/alembic/versions/xxxx_add_leaves_table.py` | 数据库迁移 |
### 后端修改
| 文件 | 改动 |
|------|------|
| `backend/app/services/dashboard.py` | 填报进度 +`on_leave` 状态;统计卡 +请假数 |
| `backend/app/services/scheduler.py` | 催办过滤请假人员 |
| `backend/app/services/ai_summary.py` | `build_summary_prompt` 注入请假数据 |
| `backend/app/models/__init__.py` | 导入 Leave 模型 |
| `backend/app/main.py` | 注册 leaves 路由 + lifespan 自动建表 |
### 前端新增
| 文件 | 说明 |
|------|------|
| `frontend/src/views/desktop/Leaves.vue` | PC 端请假管理页 |
| `frontend/src/views/mobile/LeavesList.vue` | 移动端请假列表页 |
| `frontend/src/views/mobile/LeaveForm.vue` | 移动端请假表单页 |
| `frontend/src/api/leaves.ts` | Axios API 模块 |
### 前端修改
| 文件 | 改动 |
|------|------|
| `frontend/src/views/desktop/Dashboard.vue` | 新增请假统计卡 + 填报进度 on_leave 状态 |
| `frontend/src/components/DesktopLayout.vue` | 侧边栏新增请假管理入口 |
| `frontend/src/router/index.ts` | 新增 /leaves + /m/leaves 等路由 |
| `frontend/src/views/mobile/Home.vue` | 新增请假快捷入口 chip |
## 八、验证要点
1. 经理提交请假后,当天仪表盘填报进度显示蓝「假」状态
2. 请假中的经理不收到催办提醒
3. 支局长可代任意经理提交请假
4. AI 摘要中体现请假信息
5. 移动端可完成请假提交→列表查看→编辑→删除全流程
6. 历史周翻看时,请假数据对应当时的日期区间
@@ -0,0 +1,130 @@
# 商机跟单优化 — 跟进记录子表 — 设计文档
> 日期:2026-07-12 | 状态:已确认
## 一、需求概述
当前商机跟单只有一条静态记录(产品+金额+跟进内容文本+状态),缺少"持续跟进"的时间线感。新增跟进记录子表,将每个商机从"快照"变为"持续跟单"。
## 二、核心决策
| 决策项 | 结论 |
|--------|------|
| 方案 | 新增 `mini_business_logs` 子表 |
| 跟进方式 | 电话/微信/上门/邮件/其他(chip 按钮选择) |
| 原 follow_up_detail | 保留,含义改为"商机概述/背景" |
| 状态 | 不变(跟进中/已签约/已流失) |
## 三、新增表 `mini_business_logs`
```sql
CREATE TABLE mini_business_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
business_id UUID NOT NULL REFERENCES mini_business(id) ON DELETE CASCADE,
log_date DATE NOT NULL,
method VARCHAR(20) NOT NULL DEFAULT '电话',
content TEXT NOT NULL DEFAULT '',
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_mbl_business_id ON mini_business_logs(business_id);
CREATE INDEX idx_mbl_log_date ON mini_business_logs(log_date);
```
### SQLAlchemy 模型
```python
class MiniBusinessLog(Base):
__tablename__ = "mini_business_logs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
business_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("mini_business.id", ondelete="CASCADE"), index=True)
log_date: Mapped[date] = mapped_column(Date)
method: Mapped[str] = mapped_column(String(20), default="电话")
content: Mapped[str] = mapped_column(Text, default="")
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
```
### MiniBusiness 模型添加 relationship
```python
logs: Mapped[list["MiniBusinessLog"]] = relationship(back_populates="business", cascade="all, delete-orphan")
```
## 四、商机主体字段调整
| 字段 | 变更 | 前端 label |
|------|------|-----------|
| `follow_up_detail` | 保留,含义改为商机概述 | "商机概述" |
| 其他字段 | 不变 | |
## 五、API
### 子路由
| 方法 | 路径 | 说明 | 权限 |
|------|------|------|------|
| `GET` | `/mini-business/{id}/logs` | 获取跟进记录列表(date DESC) | 经理仅看自己 |
| `POST` | `/mini-business/{id}/logs` | 新增跟进记录 | 经理本人或支局长/领导 |
| `PUT` | `/mini-business/logs/{log_id}` | 编辑跟进记录 | 创建人或支局长/领导 |
| `DELETE` | `/mini-business/logs/{log_id}` | 删除跟进记录 | 创建人或支局长/领导 |
### 请求体 (POST/PUT)
```json
{
"log_date": "2026-07-12",
"method": "电话",
"content": "客户反馈价格偏高,需进一步沟通方案"
}
```
### 跟进方式枚举
`["电话", "微信", "上门", "邮件", "其他"]`
## 六、前端
### PC 端 — 商机列表页增强
- 表格新增「最近跟进」列(最近一次跟进日期 + 跟进次数 badge)
- 点击客户名打开详情弹窗(替代直接编辑)
- 详情弹窗:上部=商机基本信息(客户/产品/金额/状态/概述)+ 编辑按钮,下部=跟进时间线 + 新增跟进入口
### PC 端 — 跟进时间线组件
```
📞 07-10 电话 — 客户反馈价格偏高,需进一步沟通 [编辑] [删除]
🏢 07-05 上门 — 现场演示产品功能,客户初步认可 [编辑] [删除]
📧 07-01 邮件 — 发送报价方案 [编辑] [删除]
```
- 按日期倒序,最新在上
- 每条显示:方式图标 + 日期 + 内容 + 操作按钮
- 底部「新增跟进」按钮展开内联表单(日期+方式 chip+内容 textarea
### 移动端 — 商机表单页改为详情页
- 现有新建表单保留
- 新增商机详情页(路由 `/m/mini-biz/:id`
- 上部:商机信息卡片(可点击编辑)
- 下部:跟进时间线 + 底部固定「新增跟进」按钮
### 移动端 — 首页入口
- Home.vue 的「商机跟单」chip 改为跳转商机列表(需新增 `/m/mini-biz` 路由)
## 七、文件变更
| 类型 | 文件 | 说明 |
|------|------|------|
| 新增 | `models/mini_business_log.py` | Log 模型 |
| 新增 | `schemas/mini_business_log.py` | Log Schema |
| 新增 | `api/mini_business_logs.py` | Log CRUD API |
| 修改 | `models/mini_business.py` | 添加 logs relationship |
| 修改 | `models/__init__.py` | 注册新模型 |
| 修改 | `main.py` | lifespan 自动建表 |
| 修改 | `MiniBusiness.vue` (PC) | 详情弹窗 + 时间线 |
| 修改 | `MiniBusinessForm.vue` (mobile) | 改为详情页模式 |
| 新增 | `frontend/src/api/miniBusiness.ts` | 追加 logs API |
| 新增 | 移动端商机列表页 | `/m/mini-biz` |
@@ -0,0 +1,122 @@
# 工作计划状态自动流转 — 设计文档
> 日期:2026-07-12 | 状态:已确认
## 一、需求概述
工作计划目前有三种状态(计划中/已完成/已取消),完全依赖手动切换。利用已有的拜访记录数据,实现状态的自动流转,减少客户经理手动操作。
## 二、核心决策
| 决策项 | 结论 |
|--------|------|
| 匹配精度 | 仅匹配 `customer_id`(任何人拜访该客户即触发,与现有逻辑一致) |
| 增强范围 | 创建/更新触发完成 + 逾期自动取消 + Excel 导入联动 |
| 删除回退 | 不做(边缘场景少,复杂度高) |
## 三、现状
- `POST /visits/` 已有自动完成逻辑:同一 `customer_id` + `plan_date <= visit_date` → 标记"已完成"
- 该逻辑嵌在 API 层,不可复用
- 更新拜访、Excel 导入均不触发自动完成
- 逾期计划(`plan_date < today`)只发企微提醒,不自动取消
## 四、设计
### 4.1 抽取可复用函数
**文件:** `backend/app/services/visits.py`(如不存在则新建,如已有则追加)
```python
async def auto_complete_work_plans(
db: AsyncSession,
customer_id: UUID,
visit_date: date,
editor_name: str,
reason: str = "拜访自动完成",
) -> int:
"""将匹配的工作计划自动标记为已完成。返回完成数量。"""
from app.models.work_plan import WorkPlan
from app.utils.edit_log import append_entry as append_edit_log
plans_result = await db.execute(
select(WorkPlan).where(
WorkPlan.customer_id == customer_id,
WorkPlan.status == "计划中",
WorkPlan.plan_date <= visit_date,
)
)
count = 0
for plan in plans_result.scalars().all():
plan.status = "已完成"
append_edit_log(plan, editor_name, [{
"field": "status", "from": "计划中", "to": "已完成",
"reason": reason,
}])
count += 1
return count
```
### 4.2 三处调用点
| 调用点 | 文件 | 触发时机 | reason 参数 |
|--------|------|---------|------------|
| POST /visits/ | `api/visits.py` | 创建拜访后 | `"拜访自动完成"` |
| PUT /visits/{id} | `api/visits.py` | 更新拜访后 | `"拜访更新自动完成"` |
| excel_import.py | `services/excel_import.py` | 导入每条拜访后 | `"旧周报导入自动完成"` |
### 4.3 逾期计划自动取消
**文件:** `backend/app/services/scheduler.py``check_overdue_plans` 函数
在现有「发送企微提醒」逻辑后新增:
```python
# 自动取消:逾期且无匹配拜访记录的计划
for plan in overdue:
has_visit = await db.execute(
select(Visit.id).where(
Visit.customer_id == plan.customer_id,
Visit.visit_date >= plan.plan_date,
)
)
if not has_visit.scalar():
plan.status = "已取消"
append_edit_log(plan, "系统", [{
"field": "status", "from": "计划中", "to": "已取消",
"reason": "逾期自动取消",
}])
```
每日 09:00 执行,与现有逾期检查共用一个调度任务。
### 4.4 状态流转总图
```
计划中 ──┬── 拜访创建/更新/导入(customer_id + plan_date <= visit_date)──→ 已完成
└── 每日 09:00 调度(plan_date < today 且无匹配拜访)───────────→ 已取消
```
## 五、文件变更
### 后端新增/修改
| 文件 | 变更 |
|------|------|
| `backend/app/services/visits.py` | 新增 `auto_complete_work_plans()` 函数 |
| `backend/app/api/visits.py` | POST 重构为调用 service 函数;PUT 新增调用 |
| `backend/app/services/excel_import.py` | 导入拜访后调用自动完成 |
| `backend/app/services/scheduler.py` | `check_overdue_plans` 新增自动取消逻辑 |
### 前端
无需改动。
## 六、验证要点
1. 创建拜访 → 匹配的计划自动变为"已完成"edit_log 有记录
2. 更新拜访日期 → 匹配的计划自动完成
3. 导入旧周报 → 导入的拜访触发计划自动完成
4. 逾期且无拜访的计划 → 每日 09:00 自动变为"已取消"
5. 逾期但有拜访的计划 → 保持"计划中"(已有拜访覆盖,不算僵尸)
@@ -0,0 +1,156 @@
# 协同拜访记录去重 — 设计方案
## 背景
当前机制:客户经理 A 创建拜访记录并选择协同人员 B 时,后端为 B 生成一条空内容的草稿副本。B 填写后,同一客户、同一天出现两条独立拜访记录,导致周报重复展示、亮灯表重复计数。
## 设计目标
1. 周报中同一客户同一次拜访合并展示为一张卡片
2. 亮灯表不重复计数
3. 每个参与人的填报进度不受影响(各自计入)
4. 每个参与人能独立编辑自己的副本
5. 历史数据兼容,不做强制迁移
## 方案概要
### 1. 数据库:新增 `visit_group_id`
```sql
ALTER TABLE visits ADD COLUMN visit_group_id UUID;
CREATE INDEX ix_visits_visit_group_id ON visits(visit_group_id);
```
- 单人拜访:`visit_group_id = NULL`
- 多人协同拜访:同一次拜访的所有记录共享同一个 `visit_group_id`
### 2. 后端:POST /visits/ 改造
**原逻辑:** 为每个协同人生成一条空内容草稿(`communication_content=""`, `photos=[]`, `companions=[]`
**新逻辑:** 为每个协同人生成一条完整副本:
| 副本字段 | 值 | 说明 |
|----------|-----|------|
| `customer_id` | 同主记录 | |
| `visit_date` | 同主记录 | |
| `visit_method` | 同主记录 | |
| `time_range` | 同主记录 | |
| `visitor_name` | 同主记录 | 实际拜访人不变 |
| `visitor_phone` | 同主记录 | |
| `communication_content` | 原内容 + `(协同XXX` | 如:`了解项目进度...(协同韦佶秀)` |
| `customer_demand` | 同主记录 | |
| `companions` | `[主记录创建人]` | 互换:协同人看到主访人 |
| `companion_names` | 同主记录 | |
| `photos` | 同主记录 | |
| `manager_id` | 协同人的 UUID | 归属到协同人名下 |
| `visit_group_id` | 同主记录 | **同一 UUID,关联所有副本** |
**前端表现(创建人韦佶秀视角):**
```
相关人员选择了 [韦伦]
→ 保存后韦伦收到一条完整拜访记录
→ 韦伦记录内容末尾自动追加"(协同韦佶秀)"
```
### 3. 后端:PUT /visits/{id} 不变
协同人编辑自己的副本时不影响主记录,各自独立。
### 4. 仪表盘统计:按 visit_group_id 去重
`get_dashboard_stats` 中的 `week_visits` 改为:
```python
# 原:count 所有 visit 行
select count(distinct coalesce(visit_group_id, id))
from visits
where visit_date between monday and sunday
```
每个 visit_group_id 只算一次,跳过 NULL(单人拜访用 id 做 fallback key)。
### 5. 填报进度:不变
`get_reporting_progress``manager_id` 分组计数,每个参与人各算一次,不受去重影响。
### 6. 亮灯表:按 (customer_id, visit_date, visit_group_id) 去重
同一客户同一天同一次拜访只亮一盏灯。
### 7. 周报展示:按 visit_group_id 合并
`get_weekly_report` 查询时,同一个 `visit_group_id` 的记录合并成一条输出:
```json
{
"customer_name": "污水处理厂",
"visit_date": "2026-07-30",
"visit_method": "上门",
"time_range": "09:00-10:30",
"visit_group_id": "abc-123",
"participants": [
{
"manager_name": "韦佶秀",
"manager_id": "uuid-1",
"role": "primary",
"communication_content": "了解污水处理新项目...",
"customer_demand": "专线扩容至100M...",
"photos": ["photo_001", "photo_002"]
},
{
"manager_name": "韦伦",
"manager_id": "uuid-2",
"role": "companion",
"communication_content": "了解污水处理新项目...(协同韦佶秀)",
"customer_demand": "专线扩容至100M...",
"photos": ["photo_001", "photo_002"]
}
]
}
```
**前端周报卡片展示:**
```
┌─────────────────────────────────────────────┐
│ 污水处理厂 2026-07-30 上门 │
│ 09:00-10:30 │
│ │
│ ▸ 韦佶秀(主访) │
│ 了解污水处理新项目建设进度,客户计划新增... │
│ 客户需求:专线扩容至100M,新增视频监控... │
│ 📷 2张 │
│ │
│ ▸ 韦伦(协同) │
│ 了解污水处理新项目建设进度...(协同韦佶秀) │
│ 客户需求:专线扩容至100M... │
│ 📷 2张 │
└─────────────────────────────────────────────┘
```
### 8. 前端:协同记录编辑提示
协同人打开自己的副本编辑时,沟通内容区域上方显示一行提示:
> 💡 此记录为协同韦佶秀拜访「污水处理厂」的副本。你可以补充或修改内容,不会影响主记录。
## 改动清单
| 层 | 文件 | 改动 |
|----|------|------|
| DB | migration | `ALTER TABLE visits ADD visit_group_id UUID` |
| 后端 | `models/visit.py` | 新增 `visit_group_id` 字段 |
| 后端 | `schemas/visit.py` | `VisitOut` 新增 `visit_group_id` |
| 后端 | `api/visits.py` POST | 副本改为完整复制 + `(协同XXX)` 后缀 + 互换 companions + 设置 visit_group_id |
| 后端 | `services/dashboard.py` | `get_dashboard_stats` 的 week_visits 改为 distinct 去重 |
| 后端 | `services/dashboard.py` | `get_weekly_report` 合并同 visit_group_id 记录 |
| 后端 | `services/light_board.py` | 亮灯计数去重 |
| 前端 | 周报 `WeeklyReport.vue` | 拜访卡片支持 participants 展开/折叠 |
| 前端 | 移动端 `VisitForm.vue` | 协同记录编辑时显示提示条 |
## 兼容性
- 历史数据 `visit_group_id = NULL`,行为与现在完全一致
- 前端 `participants` 字段回退:单 participant 时展示与现在无差异
+153
View File
@@ -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())
)
```
### 坑 7Login 页面死循环
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 → 跳首页 → 已登录
```
+53
View File
@@ -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
View File
@@ -2,9 +2,21 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>企迹 - 政企周报管理系统</title>
<!-- Apply saved theme before page render to prevent flash -->
<script>
(function() {
var theme = localStorage.getItem('theme') || 'editorial';
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
<!-- Preconnect to Google Fonts origins for faster font loading -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
</head>
<body>
<div id="app"></div>
+9 -12
View File
@@ -5,22 +5,19 @@ server {
root /usr/share/nginx/html;
index index.html;
# Vue app
# Immutable hashed static assets (Vite content-hashed filenames)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# API proxy
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Health check
location /health {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
return 200 "ok";
}
}
+662
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"axios": "^1.7.9",
"dom-to-image-more": "^3.10.0",
"element-plus": "^2.9.1",
"pinia": "^2.3.0",
"vue": "^3.5.13",
@@ -21,6 +22,8 @@
"postcss": "^8.5.15",
"tailwindcss": "^3.4.19",
"typescript": "~5.6.0",
"unplugin-element-plus": "^0.11.2",
"unplugin-vue-components": "^32.1.0",
"vite": "^6.0.5",
"vue-tsc": "^2.2.0"
}
@@ -580,6 +583,17 @@
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -645,6 +659,48 @@
"node": ">= 8"
}
},
"node_modules/@nuxt/kit": {
"version": "4.4.8",
"resolved": "https://registry.npmmirror.com/@nuxt/kit/-/kit-4.4.8.tgz",
"integrity": "sha512-ZUlZ5iYfyfJFDPluhn6ZxFWcsuxWbLnZBc8w3MAROcQ4lYfZ+qFpALBLSNlpc0zhOa++33EE+5PEbOAdVIY+dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"c12": "^3.3.4",
"consola": "^3.4.2",
"defu": "^6.1.7",
"destr": "^2.0.5",
"errx": "^0.1.0",
"exsolve": "^1.0.8",
"ignore": "^7.0.5",
"jiti": "^2.7.0",
"klona": "^2.0.6",
"mlly": "^1.8.2",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"pkg-types": "^2.3.1",
"rc9": "^3.0.1",
"scule": "^1.3.0",
"semver": "^7.8.1",
"tinyglobby": "^0.2.17",
"ufo": "^1.6.4",
"unctx": "^2.5.0",
"untyped": "^2.0.0"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/@nuxt/kit/node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/@popperjs/core": {
"name": "@sxzz/popperjs-es",
"version": "2.11.8",
@@ -1270,6 +1326,19 @@
"vue": "^3.5.0"
}
},
"node_modules/acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
@@ -1481,6 +1550,75 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/c12": {
"version": "3.3.4",
"resolved": "https://registry.npmmirror.com/c12/-/c12-3.3.4.tgz",
"integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^5.0.0",
"confbox": "^0.2.4",
"defu": "^6.1.6",
"dotenv": "^17.3.1",
"exsolve": "^1.0.8",
"giget": "^3.2.0",
"jiti": "^2.6.1",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"perfect-debounce": "^2.1.0",
"pkg-types": "^2.3.0",
"rc9": "^3.0.1"
},
"peerDependencies": {
"magicast": "*"
},
"peerDependenciesMeta": {
"magicast": {
"optional": true
}
}
},
"node_modules/c12/node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/c12/node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/c12/node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -1563,6 +1701,16 @@
"node": ">= 6"
}
},
"node_modules/citty": {
"version": "0.1.6",
"resolved": "https://registry.npmmirror.com/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"consola": "^3.2.3"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -1585,6 +1733,23 @@
"node": ">= 6"
}
},
"node_modules/confbox": {
"version": "0.2.4",
"resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.2.4.tgz",
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
"dev": true,
"license": "MIT"
},
"node_modules/consola": {
"version": "3.4.2",
"resolved": "https://registry.npmmirror.com/consola/-/consola-3.4.2.tgz",
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
@@ -1634,6 +1799,13 @@
}
}
},
"node_modules/defu": {
"version": "6.1.7",
"resolved": "https://registry.npmmirror.com/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"dev": true,
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1643,6 +1815,13 @@
"node": ">=0.4.0"
}
},
"node_modules/destr": {
"version": "2.0.5",
"resolved": "https://registry.npmmirror.com/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"dev": true,
"license": "MIT"
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -1657,6 +1836,25 @@
"dev": true,
"license": "MIT"
},
"node_modules/dom-to-image-more": {
"version": "3.10.0",
"resolved": "https://registry.npmmirror.com/dom-to-image-more/-/dom-to-image-more-3.10.0.tgz",
"integrity": "sha512-APrFEimSmH4phJKs8DlURuSr3BFwDqNi62l14bx73Wrhx0OhE4dd0qzZR+4E3sH8PnHxvax1LOrosC9oXRFA5A==",
"license": "MIT"
},
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1716,6 +1914,13 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/errx": {
"version": "0.1.0",
"resolved": "https://registry.npmmirror.com/errx/-/errx-0.1.0.tgz",
"integrity": "sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q==",
"dev": true,
"license": "MIT"
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -1734,6 +1939,13 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.2.0.tgz",
"integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
@@ -1813,12 +2025,32 @@
"node": ">=6"
}
},
"node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/exsolve": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.1.0.tgz",
"integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -2001,6 +2233,16 @@
"node": ">= 0.4"
}
},
"node_modules/giget": {
"version": "3.3.0",
"resolved": "https://registry.npmmirror.com/giget/-/giget-3.3.0.tgz",
"integrity": "sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==",
"dev": true,
"license": "MIT",
"bin": {
"giget": "dist/cli.mjs"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2088,6 +2330,16 @@
"node": ">= 6"
}
},
"node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -2160,6 +2412,23 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/klona": {
"version": "2.0.6",
"resolved": "https://registry.npmmirror.com/klona/-/klona-2.0.6.tgz",
"integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/knitwork": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/knitwork/-/knitwork-1.3.0.tgz",
"integrity": "sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==",
"dev": true,
"license": "MIT"
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -2180,6 +2449,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/local-pkg": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.2.1.tgz",
"integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"mlly": "^1.7.4",
"pkg-types": "^2.3.0",
"quansync": "^0.2.11"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
@@ -2301,6 +2588,38 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/mlly": {
"version": "1.8.2",
"resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.2.tgz",
"integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.16.0",
"pathe": "^2.0.3",
"pkg-types": "^1.3.1",
"ufo": "^1.6.3"
}
},
"node_modules/mlly/node_modules/confbox": {
"version": "0.1.8",
"resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz",
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
"dev": true,
"license": "MIT"
},
"node_modules/mlly/node_modules/pkg-types": {
"version": "1.3.1",
"resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz",
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.1.8",
"mlly": "^1.7.4",
"pathe": "^2.0.1"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
@@ -2390,6 +2709,27 @@
"node": ">= 6"
}
},
"node_modules/obug": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/obug/-/obug-2.1.3.tgz",
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/ohash": {
"version": "2.0.11",
"resolved": "https://registry.npmmirror.com/ohash/-/ohash-2.0.11.tgz",
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
"dev": true,
"license": "MIT"
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -2404,6 +2744,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/perfect-debounce": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@@ -2465,6 +2819,18 @@
"node": ">= 6"
}
},
"node_modules/pkg-types": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.3.1.tgz",
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.2.4",
"exsolve": "^1.0.8",
"pathe": "^2.0.3"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",
@@ -2650,6 +3016,23 @@
"node": ">=10"
}
},
"node_modules/quansync": {
"version": "0.2.11",
"resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz",
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/antfu"
},
{
"type": "individual",
"url": "https://github.com/sponsors/sxzz"
}
],
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -2671,6 +3054,17 @@
],
"license": "MIT"
},
"node_modules/rc9": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/rc9/-/rc9-3.0.1.tgz",
"integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"defu": "^6.1.6",
"destr": "^2.0.5"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz",
@@ -2740,6 +3134,22 @@
"node": ">=0.10.0"
}
},
"node_modules/rolldown-string": {
"version": "0.2.1",
"resolved": "https://registry.npmmirror.com/rolldown-string/-/rolldown-string-0.2.1.tgz",
"integrity": "sha512-7H8oH5A8+L96pbBTPCt/rZrwayEhZY5/ejhdk9nRODH32H1v7+bfkaCr+kS15DcGQ7VC1HcWdQVNABFYgrMOzg==",
"dev": true,
"license": "MIT",
"dependencies": {
"magic-string": "^0.30.21"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/rollup": {
"version": "4.62.2",
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz",
@@ -2809,6 +3219,26 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/scule": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz",
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"dev": true,
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2980,6 +3410,231 @@
"node": ">=14.17"
}
},
"node_modules/ufo": {
"version": "1.6.4",
"resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.4.tgz",
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
"dev": true,
"license": "MIT"
},
"node_modules/unctx": {
"version": "2.5.0",
"resolved": "https://registry.npmmirror.com/unctx/-/unctx-2.5.0.tgz",
"integrity": "sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.15.0",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21",
"unplugin": "^2.3.11"
}
},
"node_modules/unctx/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/unplugin": {
"version": "2.3.11",
"resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-2.3.11.tgz",
"integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"acorn": "^8.15.0",
"picomatch": "^4.0.3",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/unplugin-element-plus": {
"version": "0.11.2",
"resolved": "https://registry.npmmirror.com/unplugin-element-plus/-/unplugin-element-plus-0.11.2.tgz",
"integrity": "sha512-jr88ePpv43h8cCmVW0SqM73sTD+g1n9Rmy4uMbTh+pSmceH9ZdKteWX9f+twC4aDlP3svdZuKMqLoUNBT2V6Tg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nuxt/kit": "^4.2.2",
"es-module-lexer": "^2.0.0",
"escape-string-regexp": "^5.0.0",
"rolldown-string": "^0.2.1",
"unplugin": "^2.3.11"
},
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/unplugin-utils": {
"version": "0.3.1",
"resolved": "https://registry.npmmirror.com/unplugin-utils/-/unplugin-utils-0.3.1.tgz",
"integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==",
"dev": true,
"license": "MIT",
"dependencies": {
"pathe": "^2.0.3",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/unplugin-vue-components": {
"version": "32.1.0",
"resolved": "https://registry.npmmirror.com/unplugin-vue-components/-/unplugin-vue-components-32.1.0.tgz",
"integrity": "sha512-YiUkSxuRjab18XFOrX5VsIxXzccrfmHVGsGeJgSgklb829DQmCy9E4vvDUE4tuvZZdxyFJZX0Oc4TPnnxiiMyg==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^5.0.0",
"local-pkg": "^1.2.0",
"magic-string": "^0.30.21",
"mlly": "^1.8.2",
"obug": "^2.1.1",
"picomatch": "^4.0.4",
"tinyglobby": "^0.2.16",
"unplugin": "^3.0.0",
"unplugin-utils": "^0.3.1"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@nuxt/kit": "^3.2.2 || ^4.0.0",
"vue": "^3.0.0"
},
"peerDependenciesMeta": {
"@nuxt/kit": {
"optional": true
}
}
},
"node_modules/unplugin-vue-components/node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/unplugin-vue-components/node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/unplugin-vue-components/node_modules/unplugin": {
"version": "3.3.0",
"resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-3.3.0.tgz",
"integrity": "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"picomatch": "^4.0.4",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"@farmfe/core": "*",
"@rspack/core": "*",
"bun-types-no-globals": "*",
"esbuild": "*",
"rolldown": "*",
"rollup": "*",
"unloader": "*",
"vite": "*",
"webpack": "*"
},
"peerDependenciesMeta": {
"@farmfe/core": {
"optional": true
},
"@rspack/core": {
"optional": true
},
"bun-types-no-globals": {
"optional": true
},
"esbuild": {
"optional": true
},
"rolldown": {
"optional": true
},
"rollup": {
"optional": true
},
"unloader": {
"optional": true
},
"vite": {
"optional": true
},
"webpack": {
"optional": true
}
}
},
"node_modules/untyped": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/untyped/-/untyped-2.0.0.tgz",
"integrity": "sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"citty": "^0.1.6",
"defu": "^6.1.4",
"jiti": "^2.4.2",
"knitwork": "^1.2.0",
"scule": "^1.3.0"
},
"bin": {
"untyped": "dist/cli.mjs"
}
},
"node_modules/untyped/node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -3184,6 +3839,13 @@
"peerDependencies": {
"typescript": ">=5.0.0"
}
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"dev": true,
"license": "MIT"
}
}
}
+3
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"axios": "^1.7.9",
"dom-to-image-more": "^3.10.0",
"element-plus": "^2.9.1",
"pinia": "^2.3.0",
"vue": "^3.5.13",
@@ -22,6 +23,8 @@
"postcss": "^8.5.15",
"tailwindcss": "^3.4.19",
"typescript": "~5.6.0",
"unplugin-element-plus": "^0.11.2",
"unplugin-vue-components": "^32.1.0",
"vite": "^6.0.5",
"vue-tsc": "^2.2.0"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 989 B

+616 -11
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
import { computed } from 'vue'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
const auth = useAuthStore()
const isMobile = computed(() => {
@@ -9,7 +10,9 @@ const isMobile = computed(() => {
</script>
<template>
<router-view />
<el-config-provider :locale="zhCn">
<router-view />
</el-config-provider>
</template>
<style>
@@ -64,6 +67,323 @@ const isMobile = computed(() => {
--radius-sm: 2px;
--radius-xs: 2px;
--transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
/* ── Sidebar — dark editorial ── */
--sidebar-bg: var(--ink);
--sidebar-color: #fff;
--sidebar-accent: var(--gold);
--sidebar-nav-text: rgba(255,255,255,0.55);
--sidebar-nav-text-hover: rgba(255,255,255,0.85);
--sidebar-nav-text-active: #fff;
--sidebar-nav-bg-hover: rgba(255,255,255,0.04);
--sidebar-nav-bg-active: rgba(255,255,255,0.06);
--sidebar-nav-border-active: var(--gold);
--sidebar-divider: rgba(255,255,255,0.06);
--sidebar-brand-color: #fff;
--sidebar-brand-sub-color: rgba(255,255,255,0.4);
--sidebar-group-label-color: rgba(255,255,255,0.25);
--sidebar-footer-role-color: var(--gold);
--sidebar-footer-name-color: rgba(255,255,255,0.7);
/* ── Mobile page accent ── */
--page-accent-color: rgba(196,147,74,0.06);
/* ── Manager chip tags ── */
--mgr-chip-text: #fff;
/* ── Fonts — editorial Chinese ── */
--font-body: 'Noto Serif SC', STSong, Songti SC, '宋体', serif;
--font-heading: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
--font-mono: 'JetBrains Mono', 'SF Mono', monospace;
}
/* ═══════════════════════════════════════════════════════════
THEME: Light — 极简功能主义 / Brutally Minimal
═══════════════════════════════════════════════════════════ */
:root[data-theme="light"] {
/* ── Core palette ── */
--ink: #1A1A1A;
--ink-light: #3D3D3D;
--ink-dark: #0A0A0A;
--vermilion: #DC2626;
--vermilion-light: #EF4444;
--vermilion-dark: #B91C1C;
--gold: #D4AF37;
--gold-light: #E5C955;
--gold-dark: #B8941F;
--paper: #F5F5F5;
--paper-dark: #EBEBEB;
--surface: #FFFFFF;
--sage: #16A34A;
--amber: #D97706;
--warm-gray: #666666;
--warm-border: #DDDDDD;
/* ── Semantic tokens ── */
--c-primary: var(--ink);
--c-primary-light: var(--ink-light);
--c-primary-bg: #F5F5F5;
--c-accent: var(--vermilion);
--c-accent-light: var(--vermilion-light);
--c-gold: var(--gold);
--c-success: var(--sage);
--c-warning: var(--amber);
--c-danger: var(--vermilion);
--c-info: #2563EB;
--c-text: #1A1A1A;
--c-text-secondary: #666666;
--c-text-muted: #999999;
--c-bg: var(--paper);
--c-bg-card: var(--surface);
--c-border: var(--warm-border);
/* ── Effects ── */
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
--shadow: 0 1px 3px rgba(0,0,0,0.08);
--shadow-md: 0 2px 6px rgba(0,0,0,0.10);
--shadow-lg: 0 4px 12px rgba(0,0,0,0.12);
--radius: 0px;
--radius-sm: 0px;
--radius-xs: 0px;
--transition: 0.18s cubic-bezier(0.4, 0, 0.2, 1);
/* ── Sidebar — light minimal ── */
--sidebar-bg: #FFFFFF;
--sidebar-color: #1A1A1A;
--sidebar-accent: var(--gold);
--sidebar-nav-text: #777777;
--sidebar-nav-text-hover: #1A1A1A;
--sidebar-nav-text-active: #1A1A1A;
--sidebar-nav-bg-hover: #F5F5F5;
--sidebar-nav-bg-active: #F0F0F0;
--sidebar-nav-border-active: var(--gold);
--sidebar-divider: #EEEEEE;
--sidebar-brand-color: #1A1A1A;
--sidebar-brand-sub-color: #999999;
--sidebar-group-label-color: #AAAAAA;
--sidebar-footer-role-color: var(--gold);
--sidebar-footer-name-color: #777777;
/* ── Mobile page accent ── */
--page-accent-color: rgba(212,175,55,0.04);
/* ── Manager chip tags ── */
--mgr-chip-text: #1A1A1A;
/* ── Fonts — sans-serif minimal ── */
--font-body: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-heading: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
/* ═══════════════════════════════════════════════════════════
THEME: Minimal Dark — 极简深色
═══════════════════════════════════════════════════════════ */
:root[data-theme="minimal-dark"] {
/* ── Core palette ── */
--ink: #E5E5E5;
--ink-light: #CCCCCC;
--ink-dark: #FFFFFF;
--vermilion: #F87171;
--vermilion-light: #FCA5A5;
--vermilion-dark: #EF4444;
--gold: #A78BFA;
--gold-light: #C4B5FD;
--gold-dark: #8B5CF6;
--paper: #141414;
--paper-dark: #0D0D0D;
--surface: #2D2D2D;
--sage: #4ADE80;
--amber: #FBBF24;
--warm-gray: #999999;
--warm-border: #3A3A3A;
/* ── Semantic tokens ── */
--c-primary: var(--ink);
--c-primary-light: var(--ink-light);
--c-primary-bg: #262626;
--c-accent: var(--vermilion);
--c-accent-light: var(--vermilion-light);
--c-gold: var(--gold);
--c-success: var(--sage);
--c-warning: var(--amber);
--c-danger: var(--vermilion);
--c-info: #60A5FA;
--c-text: #E5E5E5;
--c-text-secondary: #999999;
--c-text-muted: #666666;
--c-bg: var(--paper);
--c-bg-card: var(--surface);
--c-border: var(--warm-border);
/* ── Effects ── */
--shadow-sm: 0 1px 2px rgba(0,0,0,0.20);
--shadow: 0 1px 3px rgba(0,0,0,0.30);
--shadow-md: 0 2px 6px rgba(0,0,0,0.40);
--shadow-lg: 0 4px 12px rgba(0,0,0,0.50);
--radius: 0px;
--radius-sm: 0px;
--radius-xs: 0px;
--transition: 0.18s cubic-bezier(0.4, 0, 0.2, 1);
/* ── Misc dark tokens ── */
--c-bg-light: #1E1E1E;
/* ── Sidebar — dark minimal ── */
--sidebar-bg: #1A1A1A;
--sidebar-color: #E5E5E5;
--sidebar-accent: var(--gold);
--sidebar-nav-text: #777777;
--sidebar-nav-text-hover: #CCCCCC;
--sidebar-nav-text-active: #FFFFFF;
--sidebar-nav-bg-hover: rgba(255,255,255,0.04);
--sidebar-nav-bg-active: rgba(255,255,255,0.06);
--sidebar-nav-border-active: var(--gold);
--sidebar-divider: #2A2A2A;
--sidebar-brand-color: #FFFFFF;
--sidebar-brand-sub-color: #666666;
--sidebar-group-label-color: #555555;
--sidebar-footer-role-color: var(--gold);
--sidebar-footer-name-color: #999999;
/* ── Mobile page accent ── */
--page-accent-color: rgba(167,139,250,0.06);
/* ── Manager chip tags ── */
--mgr-chip-text: #E5E5E5;
/* ── Fonts — sans-serif minimal ── */
--font-body: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-heading: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
/* ═══════════════════════════════════════════════════════════
THEME: Tech Dark — 科技深色 (编辑风暗色版)
═══════════════════════════════════════════════════════════ */
:root[data-theme="tech-dark"] {
/* ── Core palette ── */
--ink: #C8D6E5;
--ink-light: #A0B4CC;
--ink-dark: #E8EEF4;
--vermilion: #F26B6B;
--vermilion-light: #F59898;
--vermilion-dark: #E55555;
--gold: #5B8DEF;
--gold-light: #7BA5F5;
--gold-dark: #4070D0;
--paper: #070D1A;
--paper-dark: #050910;
--surface: #111B2E;
--sage: #4EC9B0;
--amber: #E5C07B;
--warm-gray: #6B7A8D;
--warm-border: #1E2D45;
/* ── Semantic tokens ── */
--c-primary: var(--ink);
--c-primary-light: var(--ink-light);
--c-primary-bg: #131D30;
--c-accent: var(--vermilion);
--c-accent-light: var(--vermilion-light);
--c-gold: var(--gold);
--c-success: var(--sage);
--c-warning: var(--amber);
--c-danger: var(--vermilion);
--c-info: #5B8DEF;
--c-text: #C8D6E5;
--c-text-secondary: #6B7A8D;
--c-text-muted: #455570;
--c-bg: var(--paper);
--c-bg-card: var(--surface);
--c-border: var(--warm-border);
/* ── Effects ── */
--shadow-sm: 0 1px 2px rgba(0,0,0,0.25);
--shadow: 0 2px 6px rgba(0,0,0,0.35);
--shadow-md: 0 6px 18px rgba(0,0,0,0.45);
--shadow-lg: 0 12px 36px rgba(0,0,0,0.55);
--radius: 2px;
--radius-sm: 2px;
--radius-xs: 2px;
--transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
/* ── Misc dark tokens ── */
--c-bg-light: #101A28;
/* ── Sidebar — dark tech ── */
--sidebar-bg: #0B1120;
--sidebar-color: #C8D6E5;
--sidebar-accent: var(--gold);
--sidebar-nav-text: rgba(200,214,229,0.45);
--sidebar-nav-text-hover: rgba(200,214,229,0.80);
--sidebar-nav-text-active: #FFFFFF;
--sidebar-nav-bg-hover: rgba(91,141,239,0.06);
--sidebar-nav-bg-active: rgba(91,141,239,0.10);
--sidebar-nav-border-active: var(--gold);
--sidebar-divider: rgba(200,214,229,0.06);
--sidebar-brand-color: #FFFFFF;
--sidebar-brand-sub-color: rgba(200,214,229,0.35);
--sidebar-group-label-color: rgba(200,214,229,0.20);
--sidebar-footer-role-color: var(--gold);
--sidebar-footer-name-color: rgba(200,214,229,0.65);
/* ── Mobile page accent ── */
--page-accent-color: rgba(91,141,239,0.06);
/* ── Manager chip tags ── */
--mgr-chip-text: #C8D6E5;
/* ── Fonts — editorial Chinese ── */
--font-body: 'Noto Serif SC', STSong, Songti SC, '宋体', serif;
--font-heading: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
--font-mono: 'JetBrains Mono', 'SF Mono', monospace;
}
/* ═══════════════════════════════════════════════════════════
Dark Mode — Element Plus CSS Variable Overrides
Override --el-* variables so ALL components render dark
═══════════════════════════════════════════════════════════ */
html.dark {
--el-color-white: var(--surface);
--el-color-black: var(--c-text);
--el-color-primary: var(--c-gold);
--el-color-primary-light-3: var(--gold-light);
--el-color-primary-light-5: var(--gold-light);
--el-color-primary-light-7: var(--c-primary-bg);
--el-color-primary-light-8: var(--c-primary-bg);
--el-color-primary-light-9: var(--paper-dark);
--el-color-primary-dark-2: var(--gold-dark);
--el-color-success: var(--c-success);
--el-color-warning: var(--c-warning);
--el-color-danger: var(--c-danger);
--el-color-info: var(--c-info);
--el-bg-color: var(--paper);
--el-bg-color-page: var(--paper);
--el-bg-color-overlay: var(--surface);
--el-text-color-primary: var(--c-text);
--el-text-color-regular: var(--c-text);
--el-text-color-secondary: var(--c-text-secondary);
--el-text-color-placeholder: var(--c-text-muted);
--el-text-color-disabled: var(--c-text-muted);
--el-border-color: var(--warm-border);
--el-border-color-light: var(--warm-border);
--el-border-color-lighter: var(--warm-border);
--el-border-color-extra-light: var(--warm-border);
--el-border-color-dark: var(--c-text-muted);
--el-fill-color: var(--c-primary-bg);
--el-fill-color-light: var(--c-primary-bg);
--el-fill-color-lighter: var(--paper-dark);
--el-fill-color-blank: var(--surface);
--el-disabled-bg-color: var(--paper-dark);
--el-disabled-text-color: var(--c-text-muted);
--el-disabled-border-color: var(--warm-border);
--el-mask-color: rgba(0,0,0,0.6);
--el-mask-color-extra-light: rgba(0,0,0,0.3);
}
/* ── Reset & Base ── */
@@ -73,7 +393,7 @@ html, body, #app {
margin: 0; padding: 0; height: 100%;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
font-family: 'Noto Serif SC', STSong, Songti SC, '宋体', serif;
font-family: var(--font-body);
font-size: 15px;
line-height: 1.7;
color: var(--c-text);
@@ -84,7 +404,7 @@ html, body, #app {
/* ── Editorial heading style ── */
h1, h2, h3, h4, h5, h6 {
font-family: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
font-family: var(--font-heading);
letter-spacing: 0.02em;
}
@@ -104,7 +424,7 @@ h1, h2, h3, h4, h5, h6 {
.el-card__header {
border-bottom: 2px solid var(--gold) !important;
padding: 16px 20px !important;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-weight: 600;
font-size: 16px;
color: var(--ink);
@@ -118,7 +438,7 @@ h1, h2, h3, h4, h5, h6 {
font-weight: 500;
letter-spacing: 0.02em;
transition: all var(--transition);
font-family: 'Noto Serif SC', STSong, Songti SC, serif;
font-family: var(--font-body);
}
.el-button--primary {
background: var(--ink) !important;
@@ -146,14 +466,14 @@ h1, h2, h3, h4, h5, h6 {
.el-tag {
border-radius: 2px !important;
font-weight: 500;
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
letter-spacing: 0.02em;
}
/* ── Tabs ── */
.el-tabs__item {
font-weight: 500;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
}
.el-tabs__active-bar { background: var(--gold) !important; }
.el-tabs__item.is-active { color: var(--ink) !important; }
@@ -164,7 +484,7 @@ h1, h2, h3, h4, h5, h6 {
color: var(--ink);
font-weight: 600;
font-size: 13px;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
letter-spacing: 0.03em;
}
.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
@@ -181,7 +501,7 @@ h1, h2, h3, h4, h5, h6 {
.el-dialog__header {
padding: 20px 24px 0 !important;
font-weight: 600;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
}
.el-dialog__body { padding: 20px 24px !important; }
@@ -222,7 +542,7 @@ h1, h2, h3, h4, h5, h6 {
/* ── Textarea ── */
.el-textarea__inner {
border-radius: 2px !important;
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
}
/* ── Radio ── */
@@ -255,7 +575,7 @@ h1, h2, h3, h4, h5, h6 {
/* ── Divider ── */
.el-divider__text {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
color: var(--warm-gray);
}
@@ -277,4 +597,289 @@ h1, h2, h3, h4, h5, h6 {
cursor: pointer; border-radius: 50%; transition: background 0.2s;
}
.image-preview-close:hover { background: rgba(255,255,255,0.35); }
/* ── Screenshot capture mode ── */
.taking-screenshot { background: #fff !important; }
.taking-screenshot * { background-color: transparent; }
.taking-screenshot .el-card,
.taking-screenshot .el-table,
.taking-screenshot .el-table__header,
.taking-screenshot .el-table__body,
.taking-screenshot .filter-bar,
.taking-screenshot .summary-bar,
.taking-screenshot .page-head { background: #fff !important; }
.taking-screenshot .screenshot-hide { display: none !important; }
.taking-screenshot .el-table__fixed-right { display: none !important; }
/* ── Screenshot capture animation ── */
.body-capturing .work-plans-page,
.body-capturing .mini-biz-page,
.body-capturing .key-visits-page {
position: relative;
}
.body-capturing .work-plans-page::after,
.body-capturing .mini-biz-page::after,
.body-capturing .key-visits-page::after {
content: '';
position: fixed; inset: 0; z-index: 9998;
background: rgba(255,255,255,0.6);
pointer-events: none;
animation: capture-flash 0.35s ease-out;
}
@keyframes capture-flash {
0% { opacity: 0; }
50% { opacity: 1; }
100% { opacity: 0; }
}
/* ── Column resize handle ── */
.el-table__header th {
position: relative !important;
}
/* Exclude gutter (scrollbar spacer) and selection column from resize */
.el-table__header th.el-table__cell--selection .col-resize-handle,
.el-table__header th.gutter .col-resize-handle {
display: none !important;
}
.col-resize-handle {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 6px;
cursor: col-resize;
z-index: 1;
transition: background 0.15s;
}
.col-resize-handle::after {
content: '';
position: absolute;
right: 1px;
top: 20%;
bottom: 20%;
width: 2px;
border-radius: 1px;
background: transparent;
transition: background 0.15s;
}
.el-table__header th:hover .col-resize-handle::after,
.col-resize-handle.is-resizing::after {
background: var(--gold);
}
.col-resize-handle:hover::after,
.col-resize-handle.is-resizing::after {
background: var(--gold) !important;
}
/* Prevent text selection while resizing columns */
body.col-resizing {
cursor: col-resize !important;
user-select: none !important;
}
/* ═══════════════════════════════════════════════════════════
Dark Mode — Element Plus Global Overrides
═══════════════════════════════════════════════════════════ */
html.dark .el-input__wrapper {
background: var(--surface) !important;
box-shadow: 0 0 0 1px var(--warm-border) inset !important;
}
html.dark .el-input__inner {
color: var(--c-text) !important;
}
html.dark .el-input__wrapper:hover {
box-shadow: 0 0 0 1px var(--c-text-muted) inset !important;
}
html.dark .el-input__wrapper.is-focus {
box-shadow: 0 0 0 2px var(--gold) inset !important;
}
/* Select dropdown */
html.dark .el-select-dropdown {
background: var(--surface) !important;
border-color: var(--warm-border) !important;
}
html.dark .el-select-dropdown__item {
color: var(--c-text) !important;
}
html.dark .el-select-dropdown__item:hover {
background: var(--c-primary-bg) !important;
}
html.dark .el-select-dropdown__item.selected {
color: var(--gold) !important;
}
/* Table */
html.dark .el-table {
--el-table-bg-color: var(--surface) !important;
--el-table-tr-bg-color: var(--surface) !important;
--el-table-header-bg-color: var(--c-primary-bg) !important;
--el-table-row-hover-bg-color: var(--c-primary-bg) !important;
--el-table-border-color: var(--warm-border) !important;
--el-table-text-color: var(--c-text) !important;
--el-table-header-text-color: var(--c-text) !important;
}
html.dark .el-table th.el-table__cell {
background: var(--c-primary-bg) !important;
color: var(--c-text) !important;
}
html.dark .el-table td.el-table__cell {
background: var(--surface) !important;
}
html.dark .el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
background: var(--c-primary-bg) !important;
}
html.dark .el-table__body tr:hover > td.el-table__cell {
background: var(--c-primary-bg) !important;
}
/* Pagination */
html.dark .el-pagination button,
html.dark .el-pager li {
background: var(--surface) !important;
color: var(--c-text) !important;
}
html.dark .el-pager li.is-active {
background: var(--ink) !important;
color: #fff !important;
}
html.dark .el-pagination button:hover,
html.dark .el-pager li:hover {
color: var(--gold) !important;
}
/* Button — default / text variant */
html.dark .el-button--default {
--el-button-bg-color: var(--surface);
--el-button-border-color: var(--warm-border);
--el-button-text-color: var(--c-text);
--el-button-hover-bg-color: var(--c-primary-bg);
--el-button-hover-border-color: var(--c-text-muted);
--el-button-hover-text-color: var(--c-text);
}
/* Dialog */
html.dark .el-dialog {
--el-dialog-bg-color: var(--surface);
}
html.dark .el-dialog__title {
color: var(--c-text) !important;
}
html.dark .el-dialog__header {
border-bottom: 1px solid var(--warm-border);
}
/* Tabs */
html.dark .el-tabs__item {
color: var(--c-text-secondary) !important;
}
html.dark .el-tabs__item.is-active {
color: var(--c-text) !important;
}
html.dark .el-tabs__header {
border-bottom-color: var(--warm-border);
}
/* Tag */
html.dark .el-tag {
--el-tag-bg-color: var(--c-primary-bg);
--el-tag-border-color: var(--warm-border);
--el-tag-text-color: var(--c-text);
}
/* Form label */
html.dark .el-form-item__label {
color: var(--c-text) !important;
}
/* Progress */
html.dark .el-progress-bar__outer {
background: var(--paper-dark) !important;
}
/* Date picker panel */
html.dark .el-picker-panel {
background: var(--surface) !important;
border-color: var(--warm-border) !important;
}
html.dark .el-date-picker__header-label {
color: var(--c-text) !important;
}
html.dark .el-date-table td {
color: var(--c-text);
}
html.dark .el-date-table td.current:not(.disabled) .el-date-table-cell__text {
background: var(--gold) !important;
color: #fff !important;
}
html.dark .el-date-table td.today .el-date-table-cell__text {
color: var(--gold) !important;
}
/* Time picker panel */
html.dark .el-time-panel {
background: var(--surface) !important;
border-color: var(--warm-border) !important;
}
html.dark .el-time-spinner__item {
color: var(--c-text-muted) !important;
}
html.dark .el-time-spinner__item.is-active {
color: var(--c-text) !important;
}
/* Popover / tooltip */
html.dark .el-popover,
html.dark .el-tooltip__popper {
background: var(--surface) !important;
border-color: var(--warm-border) !important;
color: var(--c-text) !important;
}
/* Message box confirm */
html.dark .el-message-box {
--el-messagebox-bg-color: var(--surface);
--el-messagebox-title-color: var(--c-text);
--el-messagebox-content-color: var(--c-text-secondary);
}
/* Empty state */
html.dark .el-empty__description p {
color: var(--c-text-muted) !important;
}
/* Radio / Checkbox label */
html.dark .el-radio__label,
html.dark .el-checkbox__label {
color: var(--c-text) !important;
}
/* Cascader / Tree select */
html.dark .el-cascader-node__label {
color: var(--c-text) !important;
}
html.dark .el-cascader-menu {
background: var(--surface) !important;
border-color: var(--warm-border) !important;
}
/* Alert */
html.dark .el-alert {
--el-alert-bg-color: var(--c-primary-bg);
}
/* Drawer */
html.dark .el-drawer {
background: var(--surface) !important;
}
/* Divider */
html.dark .el-divider__text {
background: var(--surface) !important;
}
/* Loading mask */
html.dark .el-loading-mask {
background-color: rgba(0,0,0,0.5) !important;
}
</style>
+10
View File
@@ -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 })
},
}
+3
View File
@@ -10,4 +10,7 @@ export const dashboardApi = {
getWeeklyReport(params?: any) {
return api.get('/dashboard/weekly-report', { params })
},
getLightBoard(params?: any) {
return api.get('/dashboard/light-board', { params })
},
}
+19
View File
@@ -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
View File
@@ -13,4 +13,17 @@ export const miniBusinessApi = {
delete(id: string) {
return api.delete(`/mini-business/${id}`)
},
// Follow-up logs
getLogs(businessId: string) {
return api.get(`/mini-business/${businessId}/logs`)
},
createLog(businessId: string, data: any) {
return api.post(`/mini-business/${businessId}/logs`, data)
},
updateLog(logId: string, data: any) {
return api.put(`/mini-business/logs/${logId}`, data)
},
deleteLog(logId: string) {
return api.delete(`/mini-business/logs/${logId}`)
},
}
+6 -11
View File
@@ -1,20 +1,15 @@
import api from './index'
import axios from 'axios'
export const uploadApi = {
async getPresignedUrl(filename: string, contentType: string = 'image/jpeg') {
return api.post('/upload/presigned-url', null, {
params: { filename, content_type: contentType },
async uploadImage(file: File) {
const data = new FormData()
data.append('file', file)
return api.post('/upload/image', data, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000,
})
},
getDownloadUrl(objectKey: string) {
return api.get('/upload/download-url', { params: { object_key: objectKey } })
},
// Direct upload to MinIO
async uploadFile(uploadUrl: string, file: File) {
return axios.put(uploadUrl, file, {
headers: { 'Content-Type': file.type },
timeout: 60000,
})
},
}
+53
View File
@@ -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']
}
}
+44 -26
View File
@@ -2,10 +2,12 @@
import { useRoute, useRouter } from 'vue-router'
import { computed, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const themeStore = useThemeStore()
const collapsed = ref(false)
interface MenuGroup { label?: string; items: { path: string; label: string; icon: string }[] }
@@ -17,6 +19,7 @@ const menuGroups = computed<MenuGroup[]>(() => {
items: [
{ path: '/', label: '仪表盘', icon: '<polyline points="4 7 12 3 20 7"></polyline><polyline points="20 7 20 21 4 21 4 7"></polyline><line x1="8" y1="21" x2="8" y2="12"></line><line x1="16" y1="21" x2="16" y2="12"></line>' },
{ path: '/weekly-report', label: '周报', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line>' },
{ path: '/light-board', label: '亮灯表', icon: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>' },
],
},
{
@@ -25,6 +28,7 @@ const menuGroups = computed<MenuGroup[]>(() => {
{ path: '/work-plans', label: '工作计划', icon: '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line><polyline points="8 14 12 18 16 14"></polyline>' },
{ path: '/mini-business', label: '商机跟单', icon: '<line x1="12" y1="1" x2="12" y2="23"></line><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>' },
{ path: '/key-visits', label: '要客拜访', icon: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>' },
{ path: '/leaves', label: '请假管理', icon: '<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>' },
],
},
]
@@ -40,12 +44,15 @@ const menuGroups = computed<MenuGroup[]>(() => {
label: '管理',
items: [
{ path: '/customers', label: '客户管理', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle>' },
{ path: '/settings', label: '系统设置', icon: '<circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>' },
],
})
if (auth.isDirector) {
groups[groups.length - 1].items.push(
groups[groups.length - 1].items.splice(1, 0,
{ path: '/users', label: '用户管理', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path>' },
{ path: '/settings', label: '系统设置', icon: '<circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>' },
)
groups[groups.length - 1].items.push(
{ path: '/audit-log', label: '操作日志', icon: '<polyline points="9 11 12 14 22 4"></polyline><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>' },
)
}
return groups
@@ -56,6 +63,10 @@ const roleLabel = computed(() => {
if (auth.isLeader) return '分管领导'
return '客户经理'
})
function cycleTheme() {
themeStore.toggleMode()
}
</script>
<template>
@@ -99,6 +110,13 @@ const roleLabel = computed(() => {
</svg>
</button>
<div class="topbar-actions">
<button class="topbar-btn" @click="cycleTheme" :title="'切换至' + (themeStore.mode === 'light' ? '深色模式' : '亮色模式')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path v-if="themeStore.mode === 'light'" d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
<circle v-if="themeStore.mode === 'dark'" cx="12" cy="12" r="5"></circle>
<path v-if="themeStore.mode === 'dark'" d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"></path>
</svg>
</button>
<button class="topbar-btn" @click="router.push('/m')" title="移动端">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2"></rect>
@@ -134,8 +152,8 @@ const roleLabel = computed(() => {
/* ═══ Sidebar ═══ */
.sidebar {
width: 240px;
background: var(--ink);
color: #fff;
background: var(--sidebar-bg);
color: var(--sidebar-color);
display: flex;
flex-direction: column;
flex-shrink: 0;
@@ -157,31 +175,31 @@ const roleLabel = computed(() => {
.sidebar-brand {
padding: 26px 22px 18px;
border-bottom: 1px solid rgba(255,255,255,0.06);
border-bottom: 1px solid var(--sidebar-divider);
}
.brand-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
font-family: var(--font-heading);
font-size: 24px;
font-weight: 400;
letter-spacing: 0.12em;
color: #fff;
color: var(--sidebar-brand-color);
line-height: 1.2;
}
.brand-sub {
margin: 2px 0 0;
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 10px;
color: rgba(255,255,255,0.4);
color: var(--sidebar-brand-sub-color);
letter-spacing: 0.1em;
}
.brand-rule {
width: 28px;
height: 3px;
background: var(--gold);
background: var(--sidebar-accent);
margin-top: 14px;
}
@@ -196,10 +214,10 @@ const roleLabel = computed(() => {
}
.nav-group-label {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.15em;
color: rgba(255,255,255,0.25);
color: var(--sidebar-group-label-color);
padding: 12px 14px 4px;
text-transform: uppercase;
}
@@ -211,9 +229,9 @@ const roleLabel = computed(() => {
align-items: center;
gap: 10px;
padding: 11px 14px;
color: rgba(255,255,255,0.55);
color: var(--sidebar-nav-text);
text-decoration: none;
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 14px;
letter-spacing: 0.04em;
transition: all 0.22s;
@@ -221,14 +239,14 @@ const roleLabel = computed(() => {
}
.nav-item:hover {
color: rgba(255,255,255,0.85);
background: rgba(255,255,255,0.04);
color: var(--sidebar-nav-text-hover);
background: var(--sidebar-nav-bg-hover);
}
.nav-item--active {
color: #fff;
background: rgba(255,255,255,0.06);
border-left-color: var(--gold);
color: var(--sidebar-nav-text-active);
background: var(--sidebar-nav-bg-active);
border-left-color: var(--sidebar-nav-border-active);
}
.nav-icon {
@@ -243,23 +261,23 @@ const roleLabel = computed(() => {
/* ═══ Sidebar Footer ═══ */
.sidebar-footer {
padding: 14px 22px;
border-top: 1px solid rgba(255,255,255,0.06);
border-top: 1px solid var(--sidebar-divider);
display: flex;
flex-direction: column;
gap: 2px;
}
.footer-role {
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 10px;
color: var(--gold);
color: var(--sidebar-footer-role-color);
letter-spacing: 0.1em;
}
.footer-name {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 13px;
color: rgba(255,255,255,0.7);
color: var(--sidebar-footer-name-color);
letter-spacing: 0.04em;
}
@@ -284,7 +302,7 @@ const roleLabel = computed(() => {
}
.topbar-greeting {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 14px;
color: var(--ink);
letter-spacing: 0.04em;
@@ -314,7 +332,7 @@ const roleLabel = computed(() => {
background: none;
border: 1px solid var(--warm-border);
color: var(--warm-gray);
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 12px;
letter-spacing: 0.03em;
cursor: pointer;
+2 -2
View File
@@ -125,14 +125,14 @@ function labelFor(field: string) {
}
.log-editor {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 13px;
color: var(--ink);
font-weight: 600;
}
.log-time {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
color: var(--c-text-muted);
}
+2 -2
View File
@@ -266,7 +266,7 @@ const imgStyle = computed(() => {
background: transparent;
color: rgba(255, 255, 255, 0.75);
cursor: pointer;
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 12px;
transition: all 0.15s;
white-space: nowrap;
@@ -287,7 +287,7 @@ const imgStyle = computed(() => {
}
.fit-label {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.03em;
}
+38 -20
View File
@@ -2,31 +2,28 @@
import { useRoute, useRouter } from 'vue-router'
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useThemeStore, themeLabels } from '@/stores/theme'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const themeStore = useThemeStore()
function cycleTheme() {
const next = themeStore.currentTheme === 'editorial' ? 'light' : 'editorial'
themeStore.setTheme(next)
}
const tabs = [
{
path: '/m',
label: '首页',
icon: 'home',
},
{
path: '/m/visit/new',
label: '拜访',
icon: 'visit',
},
{
path: '/m/note/new',
label: '纪要',
icon: 'note',
},
{ path: '/m', label: '首页', icon: 'home' },
{ path: '/m/work', label: '工作', icon: 'work' },
{ path: '/m/visit/new', label: '拜访', icon: 'visit' },
{ path: '/m/note/new', label: '纪要', icon: 'note' },
]
const activeTab = computed(() => {
if (route.path === '/m') return '/m'
if (route.path === '/m/work' || route.path.startsWith('/m/plans') || route.path.startsWith('/m/mini-biz') || route.path.startsWith('/m/key-visits') || route.path.startsWith('/m/leaves') || route.path.startsWith('/m/leave')) return '/m/work'
if (route.path.includes('/visit')) return '/m/visit/new'
if (route.path.includes('/note')) return '/m/note/new'
return route.path
@@ -43,6 +40,22 @@ const activeTab = computed(() => {
<div class="header-rule"></div>
</div>
<div class="header-actions">
<button class="header-pc-btn" @click="cycleTheme" :title="'切换至' + themeLabels[themeStore.currentTheme === 'editorial' ? 'light' : 'editorial']">
<svg v-if="themeStore.currentTheme === 'editorial'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
<button class="header-pc-btn" @click="router.push('/')" title="PC版">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
@@ -76,6 +89,11 @@ const activeTab = computed(() => {
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
<!-- Work icon -->
<svg v-else-if="tab.icon === 'work'" class="tab-icon" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect>
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path>
</svg>
<!-- Visit icon -->
<svg v-else-if="tab.icon === 'visit'" class="tab-icon" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
@@ -129,7 +147,7 @@ const activeTab = computed(() => {
.header-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
font-family: var(--font-heading);
font-size: 26px;
font-weight: 400;
color: var(--ink);
@@ -138,7 +156,7 @@ const activeTab = computed(() => {
}
.header-subtitle {
font-family: 'Noto Serif SC', STSong, Songti SC, serif;
font-family: var(--font-body);
font-size: 11px;
color: var(--warm-gray);
letter-spacing: 0.12em;
@@ -178,7 +196,7 @@ const activeTab = computed(() => {
}
.header-user {
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 13px;
color: var(--warm-gray);
letter-spacing: 0.03em;
@@ -201,7 +219,7 @@ const activeTab = computed(() => {
right: 0;
width: 60px;
height: 60px;
background: linear-gradient(135deg, transparent 60%, rgba(196,147,74,0.06) 60%);
background: linear-gradient(135deg, transparent 60%, var(--page-accent-color) 60%);
pointer-events: none;
z-index: 0;
}
@@ -238,7 +256,7 @@ const activeTab = computed(() => {
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
letter-spacing: 0.03em;
}
+152
View File
@@ -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
+2 -4
View File
@@ -1,14 +1,12 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import './tailwind.css'
import App from './App.vue'
import router from './router'
import { vColumnResize } from '@/directives/columnResize'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn as any })
app.directive('column-resize', vColumnResize)
app.mount('#app')
+68
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { isMobile } from '@/utils'
const router = createRouter({
history: createWebHistory(),
@@ -16,19 +17,43 @@ const router = createRouter({
component: () => import('@/views/BindWecom.vue'),
meta: { public: true },
},
{
path: '/bind-wechat',
name: 'BindWechat',
component: () => import('@/views/BindWechat.vue'),
meta: { public: true },
},
{
path: '/wecom-bind',
name: 'WecomBind',
component: () => import('@/views/WecomBind.vue'),
meta: { public: true },
},
// Mobile routes (manager-facing)
{
path: '/m',
component: () => import('@/components/MobileLayout.vue'),
children: [
{ path: '', name: 'MobileHome', component: () => import('@/views/mobile/Home.vue') },
{ path: 'work', name: 'WorkCenter', component: () => import('@/views/mobile/WorkCenter.vue') },
{ path: 'visit/new', name: 'VisitForm', component: () => import('@/views/mobile/VisitForm.vue') },
{ path: 'visit/:id/edit', name: 'VisitEdit', component: () => import('@/views/mobile/VisitForm.vue') },
{ path: 'work-plan/new', name: 'WorkPlanForm', component: () => import('@/views/mobile/WorkPlanForm.vue') },
{ path: 'work-plan/:id/edit', name: 'WorkPlanEdit', component: () => import('@/views/mobile/WorkPlanForm.vue') },
{ path: 'mini-biz/new', name: 'MiniBusinessForm', component: () => import('@/views/mobile/MiniBusinessForm.vue') },
{ path: 'mini-biz/:id/edit', name: 'MiniBusinessEdit', component: () => import('@/views/mobile/MiniBusinessForm.vue') },
{ path: 'key-visit/new', name: 'KeyVisitForm', component: () => import('@/views/mobile/KeyVisitForm.vue') },
{ path: 'key-visit/:id/edit', name: 'KeyVisitEdit', component: () => import('@/views/mobile/KeyVisitForm.vue') },
{ path: 'note/new', name: 'DailyNoteForm', component: () => import('@/views/mobile/DailyNoteForm.vue') },
{ path: 'note/:id/edit', name: 'DailyNoteEdit', component: () => import('@/views/mobile/DailyNoteForm.vue') },
{ path: 'visits', name: 'VisitsList', component: () => import('@/views/mobile/VisitsList.vue') },
{ path: 'notes', name: 'NotesList', component: () => import('@/views/mobile/NotesList.vue') },
{ path: 'plans', name: 'PlansList', component: () => import('@/views/mobile/PlansList.vue') },
{ path: 'mini-biz', name: 'MiniBizList', component: () => import('@/views/mobile/MiniBizList.vue') },
{ path: 'key-visits', name: 'KeyVisitsList', component: () => import('@/views/mobile/KeyVisitsList.vue') },
{ path: 'leaves', name: 'LeavesList', component: () => import('@/views/mobile/LeavesList.vue') },
{ path: 'leave/new', name: 'LeaveForm', component: () => import('@/views/mobile/LeaveForm.vue') },
{ path: 'leave/:id/edit', name: 'LeaveEdit', component: () => import('@/views/mobile/LeaveForm.vue') },
],
},
// Desktop routes (director/leader-facing)
@@ -38,12 +63,15 @@ const router = createRouter({
children: [
{ path: '', name: 'Dashboard', component: () => import('@/views/desktop/Dashboard.vue') },
{ path: 'weekly-report', name: 'WeeklyReport', component: () => import('@/views/desktop/WeeklyReport.vue') },
{ path: 'light-board', name: 'LightBoard', component: () => import('@/views/desktop/LightBoard.vue') },
{ path: 'work-plans', name: 'WorkPlans', component: () => import('@/views/desktop/WorkPlans.vue') },
{ path: 'mini-business', name: 'MiniBusiness', component: () => import('@/views/desktop/MiniBusiness.vue') },
{ path: 'key-visits', name: 'KeyVisits', component: () => import('@/views/desktop/KeyVisits.vue') },
{ path: 'leaves', name: 'Leaves', component: () => import('@/views/desktop/Leaves.vue') },
{ path: 'workspace', name: 'ManagerWorkspace', component: () => import('@/views/desktop/ManagerWorkspace.vue') },
{ path: 'customers', name: 'CustomerManage', component: () => import('@/views/desktop/CustomerManage.vue') },
{ path: 'users', name: 'UserManage', component: () => import('@/views/desktop/UserManage.vue') },
{ path: 'audit-log', name: 'AuditLog', component: () => import('@/views/desktop/AuditLog.vue') },
{ path: 'settings', name: 'Settings', component: () => import('@/views/desktop/Settings.vue') },
],
},
@@ -52,6 +80,30 @@ const router = createRouter({
router.beforeEach((to, _from, next) => {
const auth = useAuthStore()
// Handle WeChat Work OAuth silent login callback: ?token=JWT
// Only strip the token if it's a valid JWT — bind tokens (UUID hex) must
// survive so pages like WecomBind can read them.
const tokenParam = to.query.token as string
if (tokenParam && tokenParam.split('.').length === 3) {
// Save token to auth store (the JWT contains user_id, name, role, theme)
try {
const payload = JSON.parse(atob(tokenParam.split('.')[1]))
auth.saveLogin({
access_token: tokenParam,
user_id: payload.user_id,
name: payload.name,
role: payload.role,
theme: payload.theme,
})
// Remove token from URL only on successful JWT decode
const cleanQuery = { ...to.query }
delete cleanQuery.token
next({ path: to.path, query: cleanQuery, replace: true })
return
} catch (_) { /* invalid JWT — leave token for page to handle */ }
}
if (to.meta.public) {
next()
return
@@ -60,6 +112,22 @@ router.beforeEach((to, _from, next) => {
next('/login')
return
}
// Auto-redirect on FIRST entry only (no previous route = fresh page load)
// Don't interfere with manual navigation (user clicked switch button)
const viewOverride = to.query.view as string
if (!viewOverride && !_from.name) {
const onMobile = isMobile()
if (onMobile && to.path === '/') {
next('/m')
return
}
if (!onMobile && to.path.startsWith('/m')) {
next('/')
return
}
}
next()
})

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