Compare commits

...

79 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
106 changed files with 6106 additions and 708 deletions
+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 .
+8 -1
View File
@@ -53,7 +53,7 @@ qiji/
| 表 | 核心字段 |
|----|---------|
| `users` | id, casdoor_id, name, role(manager/director/leader), wecom_userid |
| `customers` | id, name, industry, address, in_use_services, monthly_fee, remarks, created_by |
| `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) |
@@ -120,6 +120,10 @@ qiji/
- [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独立维护分页状态。日期选择器+搜索框移至标题行
### 待完善
@@ -266,3 +270,6 @@ docker-compose up -d
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
+9 -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
+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."""
+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="该绑定接口已停用,请使用企业微信一次性绑定链接。",
)
+53 -3
View File
@@ -20,6 +20,7 @@ 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,
@@ -50,6 +51,7 @@ 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(100, ge=1, le=1000),
current_user: dict = Depends(get_current_user),
@@ -60,6 +62,8 @@ async def list_customers(
base_query = select(Customer)
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:
@@ -89,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()
@@ -99,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,
@@ -399,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))
)
@@ -470,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))
)
@@ -486,8 +502,12 @@ 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"}
@@ -715,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"}
+1 -1
View File
@@ -19,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
+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"}
+3
View File
@@ -43,6 +43,7 @@ async def create_leave(
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),
@@ -74,6 +75,7 @@ async def update_leave(
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),
@@ -102,6 +104,7 @@ async def delete_leave(
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="请假记录不存在")
+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"}
+4 -1
View File
@@ -46,7 +46,10 @@ async def get_notification_time(db: AsyncSession) -> str:
@router.get("")
async def list_config(db: AsyncSession = Depends(get_db)):
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()
+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}
+31 -1
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"])
@@ -78,6 +79,9 @@ async def update_user_role(
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),
@@ -143,6 +147,32 @@ async def delete_user(
if not user:
raise HTTPException(status_code=404, detail="User not found")
user_name = user.name
await db.delete(user)
await db.commit()
return {"detail": "deleted", "name": user.name}
# 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}
+117 -19
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
@@ -51,6 +62,7 @@ async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict:
"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),
@@ -62,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),
):
@@ -77,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)
@@ -136,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),
@@ -147,7 +169,8 @@ async def create_visit(
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)
@@ -161,34 +184,70 @@ async def create_visit(
db.add(customer)
# Auto-complete matching work plans for this customer
from app.models.work_plan import WorkPlan
plans_result = await db.execute(
select(WorkPlan).where(
WorkPlan.customer_id == data.customer_id,
WorkPlan.status == "计划中",
WorkPlan.plan_date <= parse_date(data.visit_date),
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"], "拜访自动完成",
)
)
for plan in plans_result.scalars().all():
plan.status = "已完成"
append_entry(plan, current_user["name"], [{"field": "status", "from": "计划中", "to": "已完成", "reason": "拜访自动完成"}])
# Create draft copies for companions
# 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)
@@ -209,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)
@@ -230,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)
@@ -261,6 +354,10 @@ async def delete_visit(
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
@@ -274,7 +371,6 @@ async def delete_visit(
if cust:
if new_latest:
cust.last_visit_date = new_latest
# Keep the existing manager if date unchanged, or find who made the latest visit
latest_visit = await db.execute(
select(Visit).where(
Visit.customer_id == customer_id,
@@ -288,5 +384,7 @@ async def delete_visit(
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"}
+117 -28
View File
@@ -1,16 +1,19 @@
import hashlib
import hmac
import struct
import base64
import uuid
import xml.etree.ElementTree as ET
from typing import Optional
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
from fastapi.responses import PlainTextResponse, HTMLResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.config import settings
from app.database import get_db
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, store_bind_token, consume_bind_token
@@ -37,17 +40,20 @@ class BindConfirmRequest(BaseModel):
def _verify_signature(token: str, timestamp: str, nonce: str, encrypted: str, signature: str) -> bool:
items = sorted([token, timestamp, nonce, encrypted])
raw = "".join(items)
return hashlib.sha1(raw.encode()).hexdigest() == signature
# 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)
from Crypto.Cipher import AES
cipher = AES.new(key, AES.MODE_CBC, iv=key[:16])
plaintext = cipher.decrypt(ciphertext)
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")
@@ -111,26 +117,25 @@ async def wecom_callback_event(request: Request):
import logging
_log = logging.getLogger("wecom_callback")
_log.warning(f"wecom msg: type={msg_type} from={from_user} content={repr(content)} event={event} key={event_key}")
_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.warning(f"wecom bind triggered for {from_user}")
_handle_bind_request(from_user)
_log.info("wecom bind triggered")
await _handle_bind_request(from_user)
return PlainTextResponse(content="success")
_log.warning(f"wecom msg ignored (no match)")
_log.info("wecom message ignored (no matching action)")
return PlainTextResponse(content="success")
def _handle_bind_request(wecom_userid: str):
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
import asyncio
bind_token = store_bind_token(wecom_userid)
async with async_session() as db:
bind_token = await store_bind_token(db, wecom_userid)
content = (
f"【账号绑定】\n\n"
@@ -139,16 +144,7 @@ def _handle_bind_request(wecom_userid: str):
f"绑定后可使用企微一键登录,并接收填报提醒通知。"
)
try:
# Must run async in sync context
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(
wecom_client.send_text_message([wecom_userid], content)
)
await wecom_client.send_text_message([wecom_userid], content)
# ── Bind confirmation (JWT-protected) ──
@@ -163,7 +159,7 @@ async def bind_confirm(
if not data.token:
raise HTTPException(status_code=400, detail="token 不能为空")
wecom_userid = consume_bind_token(data.token)
wecom_userid = await consume_bind_token(db, data.token)
if not wecom_userid:
raise HTTPException(status_code=400, detail="绑定链接已过期或无效,请重新在企微发送「绑定」")
@@ -181,7 +177,11 @@ async def bind_confirm(
)
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}
@@ -248,12 +248,13 @@ async def test_message(
@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": "https://qj.dhdx.fun/m"},
{"type": "view", "name": "开始填报", "url": oauth_url},
{"type": "click", "name": "绑定账号", "key": "BIND_ACCOUNT"},
]
success = await wecom_client.create_menu(buttons)
return {"success": success, "message": "菜单已部署 (view + click)" if success else "菜单创建失败"}
return {"success": success, "message": "菜单已部署 (含静默登录)" if success else "菜单创建失败"}
@router.get("/menu")
@@ -271,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():
+32 -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 = ""
@@ -47,9 +50,25 @@ class Settings(BaseSettings):
# 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)}")
+7 -65
View File
@@ -2,11 +2,11 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select
from app.config import settings
from app.database import engine, Base, async_session
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, ai_summary, system_config, leaves
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
@@ -14,67 +14,7 @@ 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 '[]'"
))
# New columns for v0.3
await conn.run_sync(lambda c: c.exec_driver_sql(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS require_report BOOLEAN DEFAULT TRUE"
))
await conn.run_sync(lambda c: c.exec_driver_sql(
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_date DATE"
))
await conn.run_sync(lambda c: c.exec_driver_sql(
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_manager_id UUID"
))
# New columns for v0.4
await conn.run_sync(lambda c: c.exec_driver_sql(
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS companion_names TEXT[] DEFAULT '{}'"
))
# v0.6 — manager color tag
await conn.run_sync(lambda c: c.exec_driver_sql(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS color VARCHAR(7)"
))
# system_config table for v0.5
await conn.run_sync(lambda c: c.exec_driver_sql(
"CREATE TABLE IF NOT EXISTS system_config (key VARCHAR(64) PRIMARY KEY, value TEXT DEFAULT '')"
))
# leaves table for v0.7
await conn.run_sync(lambda c: c.exec_driver_sql(
"CREATE TABLE IF NOT EXISTS 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()"
")"
))
await conn.run_sync(lambda c: c.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS idx_leaves_manager_id ON leaves(manager_id)"
))
await conn.run_sync(lambda c: c.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS idx_leaves_dates ON leaves(start_date, end_date)"
))
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:
@@ -123,6 +63,7 @@ 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")
@@ -133,6 +74,7 @@ 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")
+4
View File
@@ -10,6 +10,8 @@ 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",
@@ -19,9 +21,11 @@ __all__ = [
"Visit",
"WorkPlan",
"MiniBusiness",
"MiniBusinessLog",
"KeyVisit",
"DailyNote",
"AISummary",
"SystemConfig",
"Leave",
"WecomBindToken",
]
+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())
+1
View File
@@ -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="")
+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")
+1
View File
@@ -17,4 +17,5 @@ class User(Base):
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())
+1
View File
@@ -22,6 +22,7 @@ class Visit(Base):
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())
+4
View File
@@ -45,6 +45,7 @@ class ContactOut(BaseModel):
# ── Customer ──
class CustomerCreate(BaseModel):
name: str
customer_type: str = "unit"
industry: str = ""
address: str = ""
in_use_services: str = ""
@@ -56,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
@@ -67,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
@@ -84,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):
+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):
+4
View File
@@ -16,6 +16,7 @@ class VisitCreate(BaseModel):
companions: list[uuid.UUID] = []
companion_names: list[str] = []
photos: list[str] = []
manager_id: Optional[uuid.UUID] = None # director/leader can specify
class VisitUpdate(BaseModel):
@@ -28,8 +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):
@@ -46,6 +49,7 @@ class VisitOut(BaseModel):
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):
+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",
})
+79 -29
View File
@@ -12,7 +12,7 @@ from app.models.key_visit import KeyVisit
from app.models.daily_note import DailyNote
from app.models.leave import Leave
from app.services.holidays import load_holiday_sets
from app.utils.timezone import today_cst, is_working_day
from app.utils.timezone import today_cst, is_working_day, parse_date
def get_week_range(reference_date: date | None = None):
@@ -22,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(
@@ -50,12 +51,22 @@ async def get_dashboard_stats(db: AsyncSession, reference_date: date | None = No
)
)).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),
}
@@ -121,10 +132,14 @@ async def get_reporting_progress(db: AsyncSession, reference_date: date | None =
} if leave else None,
})
# Check today's reporting — visits OR daily notes
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)
)
@@ -162,40 +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:
# Resolve companion names: system users → names, external → direct
companion_names_resolved = [user_map.get(c, str(c)) for c in (v.companions or [])]
companion_names_resolved.extend(v.companion_names or [])
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,
"visitor_name": v.visitor_name or "",
"visitor_phone": v.visitor_phone or "",
"communication_content": v.communication_content,
"customer_demand": v.customer_demand,
"companions": [str(c) for c in (v.companions or [])],
"companion_names": v.companion_names or [],
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,
"photos": v.photos or [],
"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, ""),
"edit_log": v.edit_log or [],
"created_at": str(v.created_at),
})
"role": "primary" if v is primary else "companion",
"communication_content": v.communication_content,
"customer_demand": v.customer_demand,
"photos": v.photos or [],
}
for v in gvisits
],
}
visits_data.append(merged)
# ── Work Plans ──
wp_query = select(WorkPlan)
+7
View File
@@ -203,6 +203,13 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui
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):
+14
View File
@@ -5,6 +5,7 @@ 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
@@ -88,6 +89,7 @@ async def create_leave(
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":
@@ -108,6 +110,9 @@ async def create_leave(
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
@@ -117,6 +122,7 @@ async def update_leave(
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)
@@ -136,6 +142,9 @@ async def update_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})", "update", str(user_id), operator_name)
await db.commit()
return leave
@@ -144,6 +153,7 @@ async def delete_leave(
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)
@@ -154,8 +164,12 @@ async def delete_leave(
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
+23 -13
View File
@@ -85,19 +85,34 @@ async def get_light_board(
for m in visible_managers
}
# ── All customers with their last visit per manager ──
# Subquery: latest visit date + method per (manager_id, customer_id)
# ── 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(
Visit.manager_id,
Visit.customer_id,
func.max(Visit.visit_date).label("last_visit_date"),
combined.c.manager_id,
combined.c.customer_id,
combined.c.visit_date.label("last_visit_date"),
combined.c.visit_method,
)
.group_by(Visit.manager_id, Visit.customer_id)
.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 → visits (for method)
# Join: assignments → customers → latest_visit
rows = await db.execute(
select(
CustomerAssignment.manager_id,
@@ -108,18 +123,13 @@ async def get_light_board(
Customer.in_use_services,
Customer.monthly_fee,
latest_visit.c.last_visit_date,
Visit.visit_method,
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,
))
.outerjoin(Visit, and_(
Visit.manager_id == CustomerAssignment.manager_id,
Visit.customer_id == CustomerAssignment.customer_id,
Visit.visit_date == latest_visit.c.last_visit_date,
))
.where(CustomerAssignment.role == "primary")
.order_by(Customer.name)
)
+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:
+28 -4
View File
@@ -80,12 +80,12 @@ async def check_daily_reporting(db: AsyncSession) -> dict:
f"📋 今日填报提醒\n\n{desc}\n未填报:{names_text}\n\n请尽快完成填报 🙏\nhttps://qj.dhdx.fun/m",
)
# 2. Send summary to director
# 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))
)
for d in directors.scalars().all():
if managers:
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
@@ -106,7 +106,7 @@ async def check_daily_reporting(db: AsyncSession) -> dict:
summary += "**未填报:**\n" + "".join(f"- {m.name}\n" for m in not_reported)
else:
summary += "✅ 全体已完成今日填报"
await wecom_client.send_markdown_message(summary) # broadcast for director visibility
await wecom_client.send_markdown_message(summary, user_ids=director_ids)
return {
"status": "ok",
@@ -168,9 +168,33 @@ async def check_overdue_plans(db: AsyncSession) -> dict:
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),
}
+4 -4
View File
@@ -9,7 +9,7 @@ def get_scheduler() -> AsyncIOScheduler:
"""Lazy-init and return the global scheduler."""
global _scheduler
if _scheduler is None:
_scheduler = AsyncIOScheduler()
_scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
return _scheduler
@@ -28,7 +28,7 @@ async def reschedule_daily_check(hour: int, minute: int):
async with async_session() as db:
await check_daily_reporting(db)
sched.add_job(_wrapper, "cron", hour=hour, minute=minute, id="daily_check")
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):
@@ -46,8 +46,8 @@ def start_scheduler(notification_hour: int = 17, notification_minute: int = 30):
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")
sched.add_job(_overdue, "cron", hour=9, minute=0, id="overdue_check")
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()
+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
+36 -23
View File
@@ -1,30 +1,42 @@
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
# In-memory bind token store (TTL 600s). Replace with Redis if scaling to multiple workers.
_bind_tokens: dict[str, tuple[str, float]] = {} # token → (wecom_userid, expires_at)
def store_bind_token(wecom_userid: str, ttl: int = 600) -> str:
"""Store a bind token → wecom_userid mapping. Returns the token."""
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
_bind_tokens[token] = (wecom_userid, time.time() + ttl)
# Cleanup expired tokens
now = time.time()
for k in list(_bind_tokens):
if _bind_tokens[k][1] < now:
del _bind_tokens[k]
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
def consume_bind_token(token: str) -> str | None:
"""Lookup and consume a bind token. Returns wecom_userid or None."""
entry = _bind_tokens.pop(token, None)
if entry and entry[1] > time.time():
return entry[0]
return None
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:
@@ -80,14 +92,14 @@ class WecomClient:
if errcode == 45009:
if attempt < max_retries - 1:
time.sleep(1 * (attempt + 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:
time.sleep(0.5 * (attempt + 1))
await asyncio.sleep(0.5 * (attempt + 1))
raise Exception(f"WeCom API error after {max_retries} retries: {last_error}")
@@ -127,13 +139,14 @@ class WecomClient:
except Exception:
return False
async def send_markdown_message(self, content: str) -> bool:
"""Send a markdown message to all users in the app (broadcast)."""
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": "@all",
"touser": touser,
"msgtype": "markdown",
"agentid": int(settings.WECOM_AGENT_ID),
"markdown": {"content": content},
+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
+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 -5
View File
@@ -1,15 +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
pycryptodome
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:
@@ -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,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
- [ ] 测试、扫描、迁移和部署日志
- [ ] 上线验证与回滚演练记录
+8
View File
@@ -6,6 +6,14 @@
<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>
+416
View File
@@ -177,6 +177,215 @@ const isMobile = computed(() => {
--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 ── */
*, *::before, *::after { box-sizing: border-box; }
@@ -466,4 +675,211 @@ 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>
+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,
})
},
}
+11 -9
View File
@@ -2,7 +2,7 @@
import { useRoute, useRouter } from 'vue-router'
import { computed, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useThemeStore, themeLabels } from '@/stores/theme'
import { useThemeStore } from '@/stores/theme'
const route = useRoute()
const router = useRouter()
@@ -44,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
@@ -62,8 +65,7 @@ const roleLabel = computed(() => {
})
function cycleTheme() {
const next = themeStore.currentTheme === 'editorial' ? 'light' : 'editorial'
themeStore.setTheme(next)
themeStore.toggleMode()
}
</script>
@@ -108,11 +110,11 @@ function cycleTheme() {
</svg>
</button>
<div class="topbar-actions">
<button class="topbar-btn" @click="cycleTheme" :title="'切换至' + themeLabels[themeStore.currentTheme === 'editorial' ? 'light' : 'editorial']">
<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">
<circle v-if="themeStore.currentTheme === 'editorial'" cx="12" cy="12" r="5"></circle>
<path v-if="themeStore.currentTheme === 'editorial'" 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>
<path v-if="themeStore.currentTheme === 'light'" d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
<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="移动端">
+33 -15
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>
+34
View File
@@ -35,13 +35,22 @@ const router = createRouter({
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') },
@@ -62,6 +71,7 @@ const router = createRouter({
{ 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') },
],
},
@@ -70,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
+12 -5
View File
@@ -7,21 +7,24 @@ export const useAuthStore = defineStore('auth', () => {
const userId = ref(localStorage.getItem('userId') || '')
const name = ref(localStorage.getItem('userName') || '')
const role = ref(localStorage.getItem('userRole') || '')
const theme = ref(localStorage.getItem('theme') || 'editorial')
const isLoggedIn = computed(() => !!token.value)
const isManager = computed(() => role.value === 'manager')
const isDirector = computed(() => role.value === 'director')
const isLeader = computed(() => role.value === 'leader')
function saveLogin(data: { access_token: string; user_id: string; name: string; role: string }) {
function saveLogin(data: { access_token: string; user_id: string; name: string; role: string; theme?: string }) {
token.value = data.access_token
userId.value = data.user_id
name.value = data.name
role.value = data.role
if (data.theme) theme.value = data.theme
localStorage.setItem('token', data.access_token)
localStorage.setItem('userId', data.user_id)
localStorage.setItem('userName', data.name)
localStorage.setItem('userRole', data.role)
if (data.theme) localStorage.setItem('theme', data.theme)
}
function logout() {
@@ -29,10 +32,14 @@ export const useAuthStore = defineStore('auth', () => {
userId.value = ''
name.value = ''
role.value = ''
// Preserve theme preference across logout
const savedTheme = localStorage.getItem('theme')
// Preserve theme preferences across logout
const savedMode = localStorage.getItem('theme-mode')
const savedLight = localStorage.getItem('theme-light')
const savedDark = localStorage.getItem('theme-dark')
localStorage.clear()
if (savedTheme) localStorage.setItem('theme', savedTheme)
if (savedMode) localStorage.setItem('theme-mode', savedMode)
if (savedLight) localStorage.setItem('theme-light', savedLight)
if (savedDark) localStorage.setItem('theme-dark', savedDark)
}
async function casdoorLogin(code: string, state: string) {
@@ -52,7 +59,7 @@ export const useAuthStore = defineStore('auth', () => {
}
return {
token, userId, name, role,
token, userId, name, role, theme,
isLoggedIn, isManager, isDirector, isLeader,
saveLogin, logout, casdoorLogin, wecomLogin, bindWecom,
}
+44 -13
View File
@@ -1,30 +1,61 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, computed } from 'vue'
export type Theme = 'editorial' | 'light'
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 useThemeStore = defineStore('theme', () => {
const currentTheme = ref<Theme>(
(localStorage.getItem('theme') as Theme) || 'editorial'
)
export const lightThemes: Theme[] = ['editorial', 'light']
export const darkThemes: Theme[] = ['minimal-dark', 'tech-dark']
function applyTheme(theme: Theme) {
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 setTheme(theme: Theme) {
currentTheme.value = theme
localStorage.setItem('theme', theme)
applyTheme(theme)
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
applyTheme(currentTheme.value)
apply()
return { currentTheme, setTheme }
return { mode, lightTheme, darkTheme, effectiveTheme, setLightTheme, setDarkTheme, toggleMode }
})
+30 -2
View File
@@ -9,6 +9,22 @@ const route = useRoute()
const auth = useAuthStore()
const loading = ref(false)
function isWecom(): boolean {
const ua = navigator.userAgent || ''
return /wxwork/i.test(ua)
}
function isMobileDevice(): boolean {
const ua = navigator.userAgent || ''
return /Android|iPhone|iPad|iPod|webOS/i.test(ua) || window.innerWidth < 768
}
function getHomePath(): string {
// 客户经理永远走移动端;支局长/分管领导根据设备自动适配
if (auth.isManager) return '/m'
return isMobileDevice() ? '/m' : '/'
}
function goCasdoorLogin() {
const returnUrl = (route.query.redirect as string) || ''
if (returnUrl) {
@@ -32,7 +48,7 @@ onMounted(async () => {
const returnUrl = sessionStorage.getItem('login_return_url')
if (returnUrl) { sessionStorage.removeItem('login_return_url'); router.push(returnUrl); return }
ElMessage.success('登录成功')
router.push(auth.isManager ? '/m' : '/')
router.push(getHomePath())
} catch (e: any) {
ElMessage.error('登录失败: ' + (e.response?.data?.detail || e.message))
} finally {
@@ -60,7 +76,7 @@ onMounted(async () => {
const returnUrl = sessionStorage.getItem('login_return_url')
if (returnUrl) { sessionStorage.removeItem('login_return_url'); router.push(returnUrl); return }
ElMessage.success('登录成功')
router.push(auth.isManager ? '/m' : '/')
router.push(getHomePath())
} catch (e: any) {
ElMessage.error('企微登录失败')
} finally {
@@ -69,6 +85,18 @@ onMounted(async () => {
return
}
// ── WeChat Work silent login: redirect to OAuth (no user interaction needed) ──
// Skip OAuth when user is on the bind flow — OAuth for unbound users redirects
// right back to /wecom-bind without logging them in, creating an infinite loop.
if (isWecom()) {
const targetPath = (route.query.redirect as string) || '/m'
if (!targetPath.startsWith('/wecom-bind')) {
const apiBase = window.location.origin
window.location.href = `${apiBase}/api/wecom/oauth-url?redirect=${encodeURIComponent(targetPath)}`
return
}
}
// ── Auto-redirect to Casdoor (no button needed) ──
loading.value = true
goCasdoorLogin()
+161
View File
@@ -0,0 +1,161 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import api from '@/api/index'
const loading = ref(false)
const items = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(50)
const filterEntityType = ref('')
const filterAction = ref('')
const filterOperatorId = ref('')
const filterDateFrom = ref('')
const filterDateTo = ref('')
const managers = ref<any[]>([])
const entityTypes = [
{ value: '', label: '全部类型' },
{ value: 'customer', label: '客户' },
{ value: 'visit', label: '拜访记录' },
{ value: 'daily_note', label: '今日纪要' },
{ value: 'work_plan', label: '工作计划' },
{ value: 'mini_business', label: '小微商机' },
{ value: 'key_visit', label: '要客拜访' },
{ value: 'user', label: '用户' },
{ value: 'leave', label: '请假' },
]
const actions = [
{ value: '', label: '全部操作' },
{ value: 'create', label: '创建' },
{ value: 'update', label: '更新' },
{ value: 'delete', label: '删除' },
]
const actionColors: Record<string, string> = {
create: '#4A6741',
update: '#5B7FA5',
delete: '#B8472E',
}
const actionLabels: Record<string, string> = {
create: '创建',
update: '更新',
delete: '删除',
}
onMounted(async () => {
try {
const res = await api.get('/users/', { params: { role: 'manager' } })
managers.value = res.data || []
} catch (_) {}
await loadLogs()
})
async function loadLogs() {
loading.value = true
try {
const params: any = { page: page.value, page_size: pageSize.value }
if (filterEntityType.value) params.entity_type = filterEntityType.value
if (filterAction.value) params.action = filterAction.value
if (filterOperatorId.value) params.operator_id = filterOperatorId.value
if (filterDateFrom.value) params.date_from = filterDateFrom.value
if (filterDateTo.value) params.date_to = filterDateTo.value
const res = await api.get('/audit-logs/', { params })
items.value = res.data.items || []
total.value = res.data.total || 0
} catch (e: any) {
ElMessage.error('加载失败: ' + (e.response?.data?.detail || e.message))
} finally {
loading.value = false
}
}
function onSearch() { page.value = 1; loadLogs() }
function onPageChange() { loadLogs() }
function formatTime(s: string): string {
if (!s) return ''
const d = new Date(s)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
</script>
<template>
<div class="audit-log-page" v-loading="loading">
<div class="page-head">
<h2 class="page-title">操作日志</h2>
<span class="page-subtitle">AUDIT LOG</span>
<div class="page-rule"></div>
</div>
<!-- Filters -->
<el-card style="margin-bottom:16px">
<div class="filter-row">
<el-select v-model="filterEntityType" placeholder="实体类型" clearable style="width:120px">
<el-option v-for="e in entityTypes" :key="e.value" :label="e.label" :value="e.value" />
</el-select>
<el-select v-model="filterAction" placeholder="操作" clearable style="width:100px">
<el-option v-for="a in actions" :key="a.value" :label="a.label" :value="a.value" />
</el-select>
<el-select v-model="filterOperatorId" placeholder="操作人" clearable filterable style="width:130px">
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
<el-date-picker v-model="filterDateFrom" type="date" placeholder="开始日期" value-format="YYYY-MM-DD" style="width:140px" />
<el-date-picker v-model="filterDateTo" type="date" placeholder="结束日期" value-format="YYYY-MM-DD" style="width:140px" />
<el-button type="primary" @click="onSearch">查询</el-button>
</div>
</el-card>
<!-- Table -->
<el-card>
<el-table :data="items" stripe size="small" v-column-resize>
<el-table-column label="时间" width="165">
<template #default="{ row }">
<span class="log-time">{{ formatTime(row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column label="操作人" width="90" prop="operator_name" />
<el-table-column label="操作" width="65">
<template #default="{ row }">
<el-tag :color="actionColors[row.action]" effect="dark" size="small" disable-transitions style="border:none;color:#fff">
{{ actionLabels[row.action] }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="类型" width="85">
<template #default="{ row }">
<span class="entity-type">{{ row.entity_type_label }}</span>
</template>
</el-table-column>
<el-table-column label="对象" min-width="250">
<template #default="{ row }">
<span class="log-entity">{{ row.entity_name }}</span>
<span v-if="row.summary" class="log-summary"> {{ row.summary }}</span>
</template>
</el-table-column>
</el-table>
<div v-if="total > pageSize" style="display:flex;justify-content:center;margin-top:16px">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" @current-change="onPageChange" />
</div>
</el-card>
</div>
</template>
<style scoped>
.page-head { margin-bottom: 20px; display: flex; flex-direction: column; gap: 2px; }
.page-title { margin: 0; font-family: var(--font-heading); font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
.page-subtitle { font-family: var(--font-mono); font-size: 9px; color: var(--gold); letter-spacing: 0.2em; }
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
.filter-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.log-time { font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray); }
.entity-type { font-family: var(--font-body); font-size: 12px; color: var(--warm-gray); }
.log-entity { font-family: var(--font-heading); font-size: 13px; color: var(--ink); letter-spacing: 0.03em; }
.log-summary { font-family: var(--font-body); font-size: 12px; color: var(--warm-gray); }
</style>
+393 -21
View File
@@ -5,17 +5,124 @@ import { useThemeStore } from '@/stores/theme'
import { customersApi } from '@/api/customers'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getManagerColor as mgrColorFn, getMgrTextColor, loadManagerColors } from '@/utils/managerColor'
import { compressImage } from '@/utils/image'
import api from '@/api/index'
import axios from 'axios'
const auth = useAuthStore()
const themeStore = useThemeStore()
const isLight = computed(() => themeStore.currentTheme === 'light')
const isLight = computed(() => themeStore.mode === 'light')
const loading = ref(false)
const search = ref('')
const filterIndustry = ref('')
const filterService = ref('')
const filterManagerId = ref('')
const customerType = ref('unit')
const customers = ref<any[]>([])
// Individual view tab data
const indivTab = ref('visits')
const indivVisits = ref<any[]>([])
const indivPlans = ref<any[]>([])
const indivMini = ref<any[]>([])
const indivKeyVisits = ref<any[]>([])
const indivLoading = ref(false)
// Individual record edit
const indivEditVisible = ref(false)
const indivEditType = ref('')
const indivEditForm = ref<any>({})
const indivEditSaving = ref(false)
const indivPhotoUrls = ref<Record<string, string>>({})
async function openIndivEdit(type: string, row: any) {
indivEditType.value = type
indivEditForm.value = { ...row }
indivEditVisible.value = true
// Load photo URLs for visit editing
if (type === 'visit' && row.photos?.length) {
for (const key of row.photos) {
if (!indivPhotoUrls.value[key]) {
try {
const urlRes = await api.get('/upload/download-url', { params: { object_key: key } })
indivPhotoUrls.value[key] = urlRes.data.download_url
} catch (_) { indivPhotoUrls.value[key] = '' }
}
}
}
}
async function handleIndivPhotoUpload(event: Event) {
const target = event.target as HTMLInputElement
if (!target.files?.length) return
const form = indivEditForm.value
if (!form.photos) form.photos = []
if (form.photos.length >= 9) { ElMessage.warning('最多9张照片'); return }
for (const file of Array.from(target.files)) {
if (form.photos.length >= 9) break
try {
const compressed = await compressImage(file, { maxPixels: 1920, quality: 0.8 })
const presignRes = await api.post('/upload/presigned-url', null, { params: { filename: compressed.name, content_type: compressed.type || 'image/jpeg' } })
await axios.put(presignRes.data.upload_url, compressed, { headers: { 'Content-Type': compressed.type || 'image/jpeg' } })
form.photos.push(presignRes.data.object_key)
indivPhotoUrls.value[presignRes.data.object_key] = URL.createObjectURL(compressed)
} catch (_) { ElMessage.error('上传失败') }
}
target.value = ''
}
function removeIndivPhoto(idx: number) {
const form = indivEditForm.value
const key = form.photos[idx]
delete indivPhotoUrls.value[key]
form.photos.splice(idx, 1)
}
async function handleIndivEditSave() {
indivEditSaving.value = true
try {
const type = indivEditType.value
const f = indivEditForm.value
if (type === 'visit') {
await api.put(`/visits/${f.id}`, {
communication_content: f.communication_content,
customer_demand: f.customer_demand || '',
visitor_name: f.visitor_name || '',
visitor_phone: f.visitor_phone || '',
visit_method: f.visit_method,
time_range: f.time_range || '',
visit_date: f.visit_date,
manager_id: f.manager_id,
photos: f.photos || [],
})
} else if (type === 'plan') {
await api.put(`/work-plans/${f.id}`, { plan_content: f.plan_content, status: f.status, plan_date: f.plan_date, manager_id: f.manager_id })
} else if (type === 'mini') {
await api.put(`/mini-business/${f.id}`, { product_type: f.product_type, amount: f.amount, follow_up_detail: f.follow_up_detail, status: f.status, expected_revenue_date: f.expected_revenue_date, manager_id: f.manager_id })
} else if (type === 'key') {
await api.put(`/key-visits/${f.id}`, { description: f.description, urgency_level: f.urgency_level, progress_status: f.progress_status, planned_date: f.planned_date, planned_visitor: f.planned_visitor, visit_target: f.visit_target, manager_id: f.manager_id })
}
ElMessage.success('已更新')
indivEditVisible.value = false
loadIndividualData()
} catch (e: any) { ElMessage.error('保存失败') }
finally { indivEditSaving.value = false }
}
async function handleIndivEditDelete() {
try {
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
const type = indivEditType.value
const id = indivEditForm.value.id
if (type === 'visit') await api.delete(`/visits/${id}`)
else if (type === 'plan') await api.delete(`/work-plans/${id}`)
else if (type === 'mini') await api.delete(`/mini-business/${id}`)
else if (type === 'key') await api.delete(`/key-visits/${id}`)
ElMessage.success('已删除')
indivEditVisible.value = false
loadIndividualData()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
const currentPage = ref(1)
const pageSize = ref(25)
const total = ref(0)
@@ -36,6 +143,12 @@ const feeCustomUnit = ref('')
const detailVisible = ref(false)
const detailCustomer = ref<any>(null)
const detailActiveTab = ref('visits')
const detailVisits = ref<any[]>([])
const detailPlans = ref<any[]>([])
const detailMini = ref<any[]>([])
const detailKeyVisits = ref<any[]>([])
const detailLoading = ref(false)
const managers = ref<any[]>([])
const selectedIds = ref<string[]>([])
@@ -62,6 +175,7 @@ async function loadCustomers() {
if (filterIndustry.value) params.industry = filterIndustry.value
if (filterService.value) params.service = filterService.value
if (filterManagerId.value) params.manager_id = filterManagerId.value
if (customerType.value) params.customer_type = customerType.value
const res = await customersApi.list(params)
customers.value = res.data.items
total.value = res.data.total
@@ -73,6 +187,32 @@ function onPageChange(page: number) { currentPage.value = page; loadCustomers()
function onPageSizeChange(size: number) { pageSize.value = size; currentPage.value = 1; loadCustomers() }
function onFilterChange() { currentPage.value = 1; loadCustomers() }
function switchCustomerType(type: string) {
customerType.value = type
if (type === 'unit') {
onFilterChange()
} else {
loadIndividualData()
}
}
async function loadIndividualData() {
indivLoading.value = true
try {
const [visits, plans, mini, keyVisits] = await Promise.all([
api.get('/visits/', { params: { customer_type: 'individual' } }),
api.get('/work-plans/', { params: { customer_type: 'individual' } }),
api.get('/mini-business/', { params: { customer_type: 'individual' } }),
api.get('/key-visits/', { params: { customer_type: 'individual' } }),
])
indivVisits.value = Array.isArray(visits.data) ? visits.data : (visits.data.items || [])
indivPlans.value = Array.isArray(plans.data) ? plans.data : (plans.data.items || [])
indivMini.value = Array.isArray(mini.data) ? mini.data : (mini.data.items || [])
indivKeyVisits.value = Array.isArray(keyVisits.data) ? keyVisits.data : (keyVisits.data.items || [])
} catch (_) {}
finally { indivLoading.value = false }
}
function buildMonthlyFee(): string {
const amt = form.value.fee_amount.trim()
if (!amt) return ''
@@ -205,7 +345,30 @@ function mgrTextColor(name: string) {
}
async function openDetail(customer: any) {
try { const res = await customersApi.get(customer.id); detailCustomer.value = res.data; detailVisible.value = true } catch (_) {}
try {
const res = await customersApi.get(customer.id)
detailCustomer.value = res.data
detailActiveTab.value = 'visits'
detailVisible.value = true
loadDetailData(customer.id)
} catch (_) {}
}
async function loadDetailData(customerId: string) {
detailLoading.value = true
try {
const [visits, plans, mini, keyVisits] = await Promise.all([
api.get('/visits/', { params: { customer_id: customerId } }),
api.get('/work-plans/', { params: { customer_id: customerId } }),
api.get('/mini-business/', { params: { customer_id: customerId } }),
api.get('/key-visits/', { params: { customer_id: customerId } }),
])
detailVisits.value = Array.isArray(visits.data) ? visits.data : (visits.data.items || [])
detailPlans.value = Array.isArray(plans.data) ? plans.data : (plans.data.items || [])
detailMini.value = Array.isArray(mini.data) ? mini.data : (mini.data.items || [])
detailKeyVisits.value = Array.isArray(keyVisits.data) ? keyVisits.data : (keyVisits.data.items || [])
} catch (_) {}
finally { detailLoading.value = false }
}
async function handleDelete(customer: any) {
@@ -294,12 +457,72 @@ async function handleImport() {
</svg>
导出
</el-button>
<el-button v-if="auth.isDirector" type="primary" @click="openCreate">+ 新建客户</el-button>
<el-button v-if="auth.isDirector" type="primary" @click="openCreate" :disabled="customerType === 'individual'">+ 新建客户</el-button>
</div>
</div>
<div class="page-rule"></div>
</div>
<!-- Type Toggle -->
<div style="margin-bottom:14px">
<div class="type-tabs">
<button :class="['type-tab', { 'type-tab--active': customerType === 'unit' }]" @click="switchCustomerType('unit')">单位客户</button>
<button :class="['type-tab', { 'type-tab--active': customerType === 'individual' }]" @click="switchCustomerType('individual')">个人用户</button>
</div>
</div>
<!-- Individual View: 4 Tabs -->
<div v-if="customerType === 'individual'" v-loading="indivLoading">
<el-tabs v-model="indivTab">
<el-tab-pane label="拜访记录" name="visits">
<el-table :data="indivVisits" size="small" max-height="400" v-if="indivVisits.length">
<el-table-column prop="visit_date" label="日期" width="110" />
<el-table-column prop="visit_method" label="方式" width="80" />
<el-table-column prop="communication_content" label="沟通内容" min-width="200" show-overflow-tooltip />
<el-table-column prop="visitor_name" label="拜访人" width="80" />
<el-table-column prop="manager_name" label="客户经理" width="80" />
<el-table-column prop="customer_name" label="客户" width="100" />
<el-table-column label="操作" width="60" fixed="right"><template #default="{ row }"><el-button size="small" @click="openIndivEdit('visit', row)">编辑</el-button></template></el-table-column>
</el-table>
<div v-else class="empty-tab">暂无拜访记录</div>
</el-tab-pane>
<el-tab-pane label="工作计划" name="plans">
<el-table :data="indivPlans" size="small" max-height="400" v-if="indivPlans.length">
<el-table-column prop="plan_date" label="计划时间" width="110" />
<el-table-column prop="plan_content" label="工作计划" min-width="250" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="80" />
<el-table-column prop="customer_name" label="客户" width="120" />
<el-table-column label="操作" width="60" fixed="right"><template #default="{ row }"><el-button size="small" @click="openIndivEdit('plan', row)">编辑</el-button></template></el-table-column>
</el-table>
<div v-else class="empty-tab">暂无工作计划</div>
</el-tab-pane>
<el-tab-pane label="商机跟单" name="mini">
<el-table :data="indivMini" size="small" max-height="400" v-if="indivMini.length">
<el-table-column prop="product_type" label="产品类型" width="110" />
<el-table-column prop="amount" label="金额" width="100" />
<el-table-column prop="status" label="状态" width="80" />
<el-table-column prop="follow_up_detail" label="跟进内容" min-width="200" show-overflow-tooltip />
<el-table-column prop="customer_name" label="客户" width="120" />
<el-table-column label="操作" width="60" fixed="right"><template #default="{ row }"><el-button size="small" @click="openIndivEdit('mini', row)">编辑</el-button></template></el-table-column>
</el-table>
<div v-else class="empty-tab">暂无商机记录</div>
</el-tab-pane>
<el-tab-pane label="要客拜访" name="keyVisits">
<el-table :data="indivKeyVisits" size="small" max-height="400" v-if="indivKeyVisits.length">
<el-table-column prop="planned_date" label="计划时间" width="110" />
<el-table-column prop="description" label="内容" min-width="250" show-overflow-tooltip />
<el-table-column prop="urgency_level" label="紧急度" width="80" />
<el-table-column prop="customer_name" label="客户" width="120" />
<el-table-column label="操作" width="60" fixed="right"><template #default="{ row }"><el-button size="small" @click="openIndivEdit('key', row)">编辑</el-button></template></el-table-column>
</el-table>
<div v-else class="empty-tab">暂不要客记录</div>
</el-tab-pane>
</el-tabs>
</div>
<!-- Unit View: Search + Table -->
<template v-if="customerType === 'unit'">
<!-- Search & Filter -->
<el-card style="margin-bottom: 16px;">
<el-row :gutter="12" align="middle">
@@ -403,25 +626,76 @@ async function handleImport() {
</el-dialog>
<!-- Detail Dialog -->
<el-dialog v-model="detailVisible" title="客户详情" width="500px">
<template v-if="detailCustomer">
<el-descriptions :column="1" border>
<el-descriptions-item label="单位名称">{{ detailCustomer.name }}</el-descriptions-item>
<el-descriptions-item label="行业">{{ detailCustomer.industry || '-' }}</el-descriptions-item>
<el-descriptions-item label="地址">{{ detailCustomer.address || '-' }}</el-descriptions-item>
<el-descriptions-item label="在用业务">{{ detailCustomer.in_use_services || '-' }}</el-descriptions-item>
<el-descriptions-item label="收支费用">{{ detailCustomer.monthly_fee || '-' }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ detailCustomer.remarks || '-' }}</el-descriptions-item>
<el-descriptions-item label="客户经理">{{ detailCustomer.primary_manager_name || '-' }}</el-descriptions-item>
</el-descriptions>
<h4 style="margin-top:16px; font-family: var(--font-heading); color: var(--ink)">联系人</h4>
<div v-if="detailCustomer.contacts?.length">
<el-tag v-for="c in detailCustomer.contacts" :key="c.id" style="margin:4px">
{{ c.name }}{{ c.phone ? ' · '+c.phone : '' }}{{ c.role_desc ? ' ('+c.role_desc+')' : '' }}
</el-tag>
<el-dialog v-model="detailVisible" :title="detailCustomer?.name || '客户详情'" width="800px" v-if="detailCustomer">
<!-- Basic Info Card -->
<div class="cust-info-card">
<div class="cust-info-name">{{ detailCustomer.name }}</div>
<div class="cust-info-bar">
<span class="cust-info-item">行业{{ detailCustomer.industry || '-' }}</span>
<span class="cust-info-sep">|</span>
<span class="cust-info-item">地址{{ detailCustomer.address || '-' }}</span>
<span class="cust-info-sep">|</span>
<span class="cust-info-item">费用{{ detailCustomer.monthly_fee || '-' }}</span>
<span class="cust-info-sep">|</span>
<span class="cust-info-item">客户经理{{ detailCustomer.primary_manager_name || '-' }}</span>
</div>
<div v-else style="color: var(--c-text-muted); text-align:center; padding:12px">暂无联系人</div>
</template>
<div v-if="detailCustomer.in_use_services" class="cust-info-bar">
<span class="cust-info-item">在用业务{{ detailCustomer.in_use_services }}</span>
</div>
<div v-if="detailCustomer.contacts?.length" class="cust-info-bar">
<span class="cust-info-item">联系人{{ detailCustomer.contacts.map((c:any) => c.name + (c.phone ? ' '+c.phone : '')).join('、') }}</span>
</div>
</div>
<!-- Tabs -->
<el-tabs v-model="detailActiveTab" style="margin-top:16px">
<el-tab-pane label="拜访记录" name="visits">
<div v-loading="detailLoading">
<el-table :data="detailVisits" size="small" max-height="300" v-if="detailVisits.length">
<el-table-column prop="visit_date" label="日期" width="110" />
<el-table-column prop="visit_method" label="方式" width="80" />
<el-table-column prop="communication_content" label="沟通内容" min-width="200" show-overflow-tooltip />
<el-table-column prop="manager_name" label="客户经理" width="90" />
</el-table>
<div v-else class="empty-tab">暂无拜访记录</div>
</div>
</el-tab-pane>
<el-tab-pane label="工作计划" name="plans">
<div v-loading="detailLoading">
<el-table :data="detailPlans" size="small" max-height="300" v-if="detailPlans.length">
<el-table-column prop="plan_date" label="计划时间" width="110" />
<el-table-column prop="plan_content" label="工作计划" min-width="220" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90" />
<el-table-column prop="manager_name" label="客户经理" width="90" />
</el-table>
<div v-else class="empty-tab">暂无工作计划</div>
</div>
</el-tab-pane>
<el-tab-pane label="商机跟单" name="mini">
<div v-loading="detailLoading">
<el-table :data="detailMini" size="small" max-height="300" v-if="detailMini.length">
<el-table-column prop="product_type" label="产品类型" width="110" />
<el-table-column prop="amount" label="金额" width="100" />
<el-table-column prop="status" label="状态" width="80" />
<el-table-column prop="follow_up_detail" label="跟进内容" min-width="180" show-overflow-tooltip />
<el-table-column prop="manager_name" label="客户经理" width="90" />
</el-table>
<div v-else class="empty-tab">暂无商机记录</div>
</div>
</el-tab-pane>
<el-tab-pane label="要客拜访" name="keyVisits">
<div v-loading="detailLoading">
<el-table :data="detailKeyVisits" size="small" max-height="300" v-if="detailKeyVisits.length">
<el-table-column prop="planned_date" label="计划时间" width="110" />
<el-table-column prop="description" label="内容" min-width="200" show-overflow-tooltip />
<el-table-column prop="urgency_level" label="紧急度" width="80" />
<el-table-column prop="manager_name" label="客户经理" width="90" />
</el-table>
<div v-else class="empty-tab">暂不要客记录</div>
</div>
</el-tab-pane>
</el-tabs>
<template #footer>
<el-button v-if="!auth.isLeader" type="primary" @click="detailVisible=false; openEdit(detailCustomer)">编辑</el-button>
<el-button v-if="auth.isDirector" type="danger" @click="detailVisible=false; handleDelete(detailCustomer)">删除</el-button>
@@ -454,6 +728,92 @@ async function handleImport() {
<el-button type="primary" :loading="importLoading" @click="handleImport" :disabled="!importFile">确认导入</el-button>
</template>
</el-dialog>
</template>
<!-- Individual Record Edit Dialog -->
<el-dialog v-model="indivEditVisible" :title="'编辑' + ({visit:'拜访',plan:'计划',mini:'商机',key:'要客'}[indivEditType]||'')" width="550px">
<el-form label-position="top" v-if="indivEditForm">
<template v-if="indivEditType === 'visit'">
<el-form-item label="客户经理">
<el-select v-model="indivEditForm.manager_id" filterable style="width:100%">
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item label="日期"><el-date-picker v-model="indivEditForm.visit_date" type="date" style="width:100%" /></el-form-item>
<el-form-item label="拜访方式">
<div style="display:flex;gap:8px">
<el-button v-for="m in ['上门','电话','微信','出差']" :key="m" size="small" :type="indivEditForm.visit_method === m ? 'primary' : ''" @click="indivEditForm.visit_method = m">{{ m }}</el-button>
</div>
</el-form-item>
<el-form-item label="时间范围"><el-input v-model="indivEditForm.time_range" placeholder="如: 10:00-11:30" /></el-form-item>
<el-form-item label="拜访人姓名"><el-input v-model="indivEditForm.visitor_name" /></el-form-item>
<el-form-item label="拜访人电话"><el-input v-model="indivEditForm.visitor_phone" /></el-form-item>
<el-form-item label="相关人员"><el-input v-model="indivEditForm.companion_names_str" placeholder="多个姓名用、分隔" /></el-form-item>
<el-form-item label="沟通内容"><el-input v-model="indivEditForm.communication_content" type="textarea" :rows="4" /></el-form-item>
<el-form-item label="客户需求"><el-input v-model="indivEditForm.customer_demand" type="textarea" :rows="2" /></el-form-item>
<el-form-item v-if="indivEditForm.photos?.length" label="照片">
<div style="display:flex;gap:8px;flex-wrap:wrap">
<div v-for="(key, idx) in indivEditForm.photos" :key="key" style="position:relative">
<img :src="indivPhotoUrls[key]" style="width:80px;height:80px;object-fit:cover;border:1px solid var(--warm-border)" v-if="indivPhotoUrls[key]" />
<span v-else style="width:80px;height:80px;display:flex;align-items:center;justify-content:center;background:var(--paper);border:1px solid var(--warm-border);font-size:12px;color:var(--warm-gray)">加载中</span>
<button type="button" style="position:absolute;top:-6px;right:-6px;background:var(--vermilion);color:#fff;border:none;width:18px;height:18px;cursor:pointer;font-size:12px;line-height:1" @click="removeIndivPhoto(idx)">×</button>
</div>
</div>
</el-form-item>
<el-form-item label="添加照片">
<label style="cursor:pointer;display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border:1px dashed var(--warm-border);color:var(--warm-gray);font-size:13px">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>
上传照片最多9张
<input type="file" accept="image/*" multiple style="display:none" @change="handleIndivPhotoUpload" />
</label>
</el-form-item>
</template>
<template v-else-if="indivEditType === 'plan'">
<el-form-item label="客户经理">
<el-select v-model="indivEditForm.manager_id" filterable style="width:100%">
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item label="计划日期"><el-date-picker v-model="indivEditForm.plan_date" type="date" style="width:100%" /></el-form-item>
<el-form-item label="工作计划"><el-input v-model="indivEditForm.plan_content" type="textarea" :rows="4" /></el-form-item>
<el-form-item label="状态"><el-select v-model="indivEditForm.status" style="width:100%"><el-option v-for="s in ['计划中','已完成','已取消']" :key="s" :label="s" :value="s" /></el-select></el-form-item>
</template>
<template v-else-if="indivEditType === 'mini'">
<el-form-item label="客户经理">
<el-select v-model="indivEditForm.manager_id" filterable style="width:100%">
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item label="产品类型"><el-input v-model="indivEditForm.product_type" /></el-form-item>
<el-form-item label="金额"><el-input v-model="indivEditForm.amount" /></el-form-item>
<el-form-item label="跟进内容"><el-input v-model="indivEditForm.follow_up_detail" type="textarea" :rows="4" /></el-form-item>
<el-form-item label="状态"><el-select v-model="indivEditForm.status" style="width:100%"><el-option v-for="s in ['跟进中','已签约','已流失']" :key="s" :label="s" :value="s" /></el-select></el-form-item>
<el-form-item label="预计列收时间"><el-date-picker v-model="indivEditForm.expected_revenue_date" type="month" value-format="YYYY-MM" placeholder="选择月份" style="width:100%" /></el-form-item>
</template>
<template v-else-if="indivEditType === 'key'">
<el-form-item label="客户经理">
<el-select v-model="indivEditForm.manager_id" filterable style="width:100%">
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item label="紧急重要度">
<div style="display:flex;gap:8px">
<el-button v-for="u in ['一般','重要','紧急']" :key="u" size="small" :type="indivEditForm.urgency_level === u ? (u==='紧急'?'danger':u==='重要'?'warning':'') : ''" @click="indivEditForm.urgency_level = u">{{ u }}</el-button>
</div>
</el-form-item>
<el-form-item label="内容描述"><el-input v-model="indivEditForm.description" type="textarea" :rows="4" /></el-form-item>
<el-form-item label="进展状态"><el-select v-model="indivEditForm.progress_status" style="width:100%"><el-option v-for="s in ['未开始','进行中','已完成']" :key="s" :label="s" :value="s" /></el-select></el-form-item>
<el-form-item label="计划拜访时间"><el-date-picker v-model="indivEditForm.planned_date" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
<el-form-item label="计划拜访人"><el-input v-model="indivEditForm.planned_visitor" placeholder="多个用、分隔" /></el-form-item>
<el-form-item label="拜访对象"><el-input v-model="indivEditForm.visit_target" /></el-form-item>
</template>
</el-form>
<template #footer>
<el-button type="danger" @click="handleIndivEditDelete" style="float:left">删除</el-button>
<el-button @click="indivEditVisible = false">取消</el-button>
<el-button type="primary" :loading="indivEditSaving" @click="handleIndivEditSave">保存</el-button>
</template>
</el-dialog>
<!-- Merge confirmation dialog -->
<el-dialog v-model="mergeDialogVisible" title="合并客户" width="520px" :close-on-click-modal="false">
@@ -499,4 +859,16 @@ async function handleImport() {
}
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 8px; }
.header-btns { display: flex; gap: 8px; }
.cust-info-bar { display: flex; align-items: center; gap: 4px; font-size: 13px; color: var(--ink); padding: 2px 0; }
.cust-info-item { white-space: nowrap; }
.cust-info-sep { color: var(--warm-border); margin: 0 6px; }
.empty-tab { text-align: center; color: var(--c-text-muted); padding: 24px 0 12px; font-size: 13px; }
.type-tabs { display: inline-flex; border: 1px solid var(--warm-border); }
.type-tab { padding: 8px 22px; border: none; background: var(--surface); font-family: var(--font-heading); font-size: 14px; color: var(--warm-gray); cursor: pointer; transition: all 0.2s; letter-spacing: 0.04em; }
.type-tab:not(:last-child) { border-right: 1px solid var(--warm-border); }
.type-tab:hover { color: var(--ink); }
.type-tab--active { background: var(--ink); color: #fff; }
.type-tab--active:hover { color: #fff; }
.cust-info-card { background: var(--c-bg-light, #faf9f6); border: 1px solid var(--warm-border); border-radius: 6px; padding: 14px 18px; margin-bottom: 4px; }
.cust-info-name { font-family: var(--font-heading); font-size: 16px; color: var(--ink); letter-spacing: 0.04em; margin-bottom: 6px; }
</style>
+17 -3
View File
@@ -9,7 +9,7 @@ import api from '@/api/index'
const router = useRouter()
const auth = useAuthStore()
const loading = ref(false)
const stats = ref({ week_visits: 0, work_plans: 0, mini_business: 0, key_visits: 0, week_leaves: 0, week_start: '', week_end: '' })
const stats = ref({ week_visits: 0, work_plans: 0, mini_business: 0, key_visits: 0, week_leaves: 0, overdue_plans: 0, week_start: '', week_end: '' })
const progress = ref<any[]>([])
const weekOffset = ref(0) // 0 = current week, -1 = last week, etc.
@@ -145,8 +145,8 @@ function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' | 'rest_
</svg>
</div>
<div class="stat-body">
<span class="stat-num">{{ stats.work_plans }}</span>
<span class="stat-label">工作计划</span>
<span class="stat-num">{{ stats.work_plans }}<span v-if="stats.overdue_plans" class="stat-overdue">/{{ stats.overdue_plans }}</span></span>
<span class="stat-label">工作计划<template v-if="stats.overdue_plans"> · <span style="color:var(--vermilion)">{{ stats.overdue_plans }}条过期</span></template></span>
</div>
</div>
<div class="stat-card stat-card--clickable" @click="router.push('/mini-business')">
@@ -296,6 +296,7 @@ function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' | 'rest_
font-family: var(--font-body);
font-size: 12px; color: var(--warm-gray); letter-spacing: 0.04em;
}
.stat-overdue { font-size: 16px; color: var(--vermilion); font-family: var(--font-mono); }
/* ═══ Progress Grid — responsive 2-column ═══ */
.progress-grid {
@@ -426,4 +427,17 @@ function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' | 'rest_
.leave-text { font-size: 13px; color: #5B7FA5; font-family: var(--font-body); }
.rest-text { font-size: 13px; color: #9CA3AF; font-family: var(--font-body); }
.empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: var(--font-body); }
/* ═══ Dark Mode — Dashboard ═══ */
html.dark .stat-glyph--ink,
html.dark .stat-glyph--sage,
html.dark .stat-glyph--gold,
html.dark .stat-glyph--vermilion {
background: transparent;
}
html.dark .progress-row--missing { background: rgba(248,113,113,0.06); }
html.dark .progress-row--catching { background: rgba(251,191,36,0.05); }
html.dark .progress-row--full { background: rgba(74,222,128,0.05); }
html.dark .progress-row--on_leave { background: rgba(96,165,250,0.05); }
html.dark .progress-row--rest_day { background: rgba(156,163,175,0.03); }
</style>
+56 -5
View File
@@ -12,9 +12,10 @@ import EditLogPanel from '@/components/EditLogPanel.vue'
const auth = useAuthStore()
const themeStore = useThemeStore()
const loading = ref(false)
const isLight = computed(() => themeStore.currentTheme === 'light')
const isLight = computed(() => themeStore.mode === 'light')
const keyVisits = ref<any[]>([])
const customers = ref<any[]>([])
const allManagers = ref<any[]>([])
const shotRef = ref<HTMLElement | null>(null)
const { capturing, captureEl } = useScreenshot()
const allUsers = ref<any[]>([])
@@ -29,6 +30,8 @@ const keyStatuses = ['未开始', '进行中', '已完成']
// ── Filters ──
const filterManager = ref('')
const filterStatus = ref('')
const searchText = ref('')
function onSearch() { loadItems() }
const managerOptions = computed(() => {
const seen = new Set<string>()
@@ -79,6 +82,9 @@ const managerSummary = computed(() => {
onMounted(async () => {
await Promise.all([loadItems(), loadCustomers(), loadUsers(), loadManagerColors()])
if (auth.isDirector || auth.isLeader) {
api.get('/users/', { params: { role: 'manager' } }).then(r => { allManagers.value = r.data }).catch(() => {})
}
})
async function loadUsers() {
@@ -88,6 +94,8 @@ async function loadUsers() {
} catch (_) {}
}
const customerSearch = ref('')
async function loadCustomers(q?: string) {
try {
const params: any = { page_size: 500 }
@@ -97,10 +105,34 @@ async function loadCustomers(q?: string) {
} catch (_) {}
}
async function handleCustomerSearch(query: string) {
customerSearch.value = query
if (query) { await loadCustomers(query) }
else { await loadCustomers() }
}
async function handleQuickCreate() {
const name = customerSearch.value.trim()
if (!name) { ElMessage.warning('请输入单位名称'); return }
try {
const res = await api.post('/customers/quick-create', null, { params: { name } })
const newCust = res.data
customers.value.unshift(newCust)
form.value.customer_id = newCust.id
customerSearch.value = ''
ElMessage.success(`已创建客户:${name}`)
await loadCustomers()
} catch (e: any) {
ElMessage.error('创建失败: ' + (e.response?.data?.detail || e.message))
}
}
async function loadItems() {
loading.value = true
try {
const res = await api.get('/key-visits/')
const params: any = {}
if (searchText.value) params.search = searchText.value
const res = await api.get('/key-visits/', { params })
keyVisits.value = res.data
} catch (e: any) { ElMessage.error('加载失败') }
finally { loading.value = false }
@@ -108,7 +140,7 @@ async function loadItems() {
function openCreate() {
dialogMode.value = 'create'
form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '' }
form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
plannedVisitors.value = []
dialogVisible.value = true
}
@@ -128,6 +160,7 @@ function onVisitorsChange(val: string[]) {
}
async function handleSave() {
if (!form.value.customer_id) { ElMessage.warning('请选择客户单位'); return }
try {
if (dialogMode.value === 'create') {
await api.post('/key-visits/', form.value)
@@ -198,6 +231,9 @@ async function quickStatusChange(row: any, newStatus: string) {
<!-- Filter Bar -->
<div class="filter-bar">
<div class="filter-row">
<div class="filter-item">
<el-input v-model="searchText" placeholder="搜索..." clearable size="small" style="width:180px" @keyup.enter="onSearch" @clear="onSearch" />
</div>
<div class="filter-item">
<label class="filter-label">客户经理</label>
<el-select v-model="filterManager" clearable placeholder="全部" size="small" style="width:150px">
@@ -269,9 +305,20 @@ async function quickStatusChange(row: any, newStatus: string) {
<!-- Dialog -->
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 要客拜访'" width="520px">
<el-form label-position="top">
<el-form-item label="客户单位">
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
<el-form-item label="客户单位 *">
<el-select v-model="form.customer_id" filterable remote :remote-method="handleCustomerSearch" placeholder="搜索选择客户" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
<template #empty>
<div v-if="customerSearch" class="select-empty-create">
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到「{{ customerSearch }}」</p>
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">+ 新建客户「{{ customerSearch }}」</button>
</div>
</template>
</el-select>
</el-form-item>
<el-form-item v-if="auth.isDirector || auth.isLeader" label="客户经理">
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item label="紧急重要度">
@@ -329,4 +376,8 @@ async function quickStatusChange(row: any, newStatus: string) {
.filter-item { display: flex; align-items: center; gap: 8px; }
.filter-label { font-size: 13px; color: var(--warm-gray); font-family: var(--font-body); white-space: nowrap; }
.filter-count { font-size: 12px; color: var(--c-text-muted); font-family: var(--font-mono); margin-left: auto; }
.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 6px 12px; color: var(--gold-dark); font-family: var(--font-body); font-size: 12px; cursor: pointer; transition: all 0.2s; }
.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); }
.select-empty-create { padding: 8px 12px; text-align: center; }
.required-star { color: var(--vermilion); font-weight: 700; }
</style>
+7 -2
View File
@@ -27,7 +27,7 @@ const leaveTypeColors: Record<string, string> = {
const filterManager = ref('')
const filterStatus = ref('')
const isLight = computed(() => themeStore.currentTheme === 'light')
const isLight = computed(() => themeStore.mode === 'light')
const today = new Date().toISOString().slice(0, 10)
@@ -138,7 +138,9 @@ async function handleSave() {
dialogVisible.value = false
await loadLeaves()
} catch (e: any) {
ElMessage.error('保存失败: ' + (e.response?.data?.detail || e.message))
const detail = e.response?.data?.detail
const msg = Array.isArray(detail) ? detail.map((d: any) => d.msg).join('; ') : (detail || e.message)
ElMessage.error('保存失败: ' + msg)
}
}
@@ -276,12 +278,14 @@ function onStatusChange(val: string) {
type="date"
placeholder="开始日期"
style="width:100%; margin-bottom: 8px"
value-format="YYYY-MM-DD"
/>
<el-date-picker
v-model="form.end_date"
type="date"
placeholder="结束日期"
style="width:100%"
value-format="YYYY-MM-DD"
/>
</el-form-item>
<el-form-item label="原因(选填)">
@@ -329,4 +333,5 @@ function onStatusChange(val: string) {
border-left: 3px solid #5B7FA5 !important;
background: rgba(91, 127, 165, 0.04) !important;
}
.required-star { color: var(--vermilion); font-weight: 700; }
</style>
+20
View File
@@ -141,6 +141,11 @@ const statusLabel: Record<string, string> = { green: '近30天已拜访', yellow
<span class="manager-expand">{{ expandedManagers.has(m.manager_id) ? '▼' : '▶' }}</span>
<span class="manager-name" @click.stop="goManagerReport(m.manager_id)">{{ m.manager_name }}</span>
<span class="manager-count">{{ m.total_customers }} 个客户</span>
<span class="manager-metrics">
已拜访<strong>{{ m.visited_this_month }}</strong> ·
未拜访<strong>{{ m.not_visited_2months }}</strong> ·
完成率<strong :style="{ color: coverageColor(m.coverage_rate) }">{{ (m.coverage_rate * 100).toFixed(0) }}%</strong>
</span>
</div>
<div class="manager-lights">
<span class="light-dot light-dot--green">{{ m.visited_this_month }}</span>
@@ -304,6 +309,8 @@ const statusLabel: Record<string, string> = { green: '近30天已拜访', yellow
.manager-name { font-family: var(--font-heading); font-size: 15px; color: var(--ink); letter-spacing: 0.04em; cursor: pointer; }
.manager-name:hover { color: var(--gold); }
.manager-count { font-family: var(--font-body); font-size: 12px; color: var(--warm-gray); }
.manager-metrics { font-family: var(--font-body); font-size: 12px; color: var(--warm-gray); white-space: nowrap; }
.manager-metrics strong { font-weight: 600; color: var(--ink); margin: 0 1px; }
.manager-lights { display: flex; gap: 6px; }
.light-dot { display: inline-flex; align-items: center; justify-content: center; min-width: 26px; height: 26px; border-radius: 4px; font-family: var(--font-mono); font-size: 12px; font-weight: 600; cursor: help; }
.light-dot--green { background: #EDF2EC; color: var(--sage); }
@@ -382,4 +389,17 @@ const statusLabel: Record<string, string> = { green: '近30天已拜访', yellow
.dlg-plan-item.dlg-plan-overdue { background: #FBF1EE; margin: 2px -4px; padding: 4px; border-radius: 4px; }
.dlg-plan-form { background: var(--c-bg-light, #faf9f6); padding: 10px 12px; border-radius: 6px; }
.plan-form-row { display: flex; gap: 8px; align-items: center; }
/* ═══ Dark Mode — light card text stays dark ═══ */
html.dark .customer-card .card-name { color: #1A1A1A; }
html.dark .customer-card .card-industry,
html.dark .customer-card .card-services,
html.dark .customer-card .card-last-visit { color: #555; }
html.dark .customer-card .card-method { color: #555; border-color: #999; }
html.dark .customer-card .card-last-visit--never { color: #B8472E; }
html.dark .plan-badge { background: #2D2D2D; color: var(--sage); }
html.dark .plan-badge.plan-overdue { background: #3D2020; }
html.dark .light-dot--green { background: #1E2F1E; }
html.dark .light-dot--yellow { background: #2F2818; }
html.dark .light-dot--red { background: #2F1E1E; }
</style>
+175 -32
View File
@@ -2,19 +2,40 @@
import { ref, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { todayStr } from '@/utils'
import { useAuthStore } from '@/stores/auth'
import { getMgrStyle } from '@/utils/managerColor'
import api from '@/api/index'
import { compressImage } from '@/utils/image'
import ImagePreview from '@/components/ImagePreview.vue'
import EditLogPanel from '@/components/EditLogPanel.vue'
const auth = useAuthStore()
const activeTab = ref('visits')
const loading = ref(false)
const allManagers = ref<any[]>([])
// Date range filter
const dateRange = ref<any>(null)
const searchText = ref('')
const pageSize = ref(25)
const visits = ref<any[]>([])
const workPlans = ref<any[]>([])
const miniBusiness = ref<any[]>([])
const dailyNotes = ref<any[]>([])
const keyVisits = ref<any[]>([])
// Pagination per tab
const page = ref<Record<string, number>>({ visits: 1, notes: 1, plans: 1, mini: 1, key: 1 })
const total = ref<Record<string, number>>({ visits: 0, notes: 0, plans: 0, mini: 0, key: 0 })
function getDateRange() {
if (dateRange.value && Array.isArray(dateRange.value) && dateRange.value.length === 2) {
return { from: dateRange.value[0], to: dateRange.value[1] }
}
return { from: '', to: '' }
}
const customers = ref<any[]>([])
const allUsers = ref<any[]>([])
const plannedVisitors = ref<string[]>([])
@@ -39,6 +60,9 @@ const keyStatuses = ['未开始', '进行中', '已完成']
onMounted(async () => {
await Promise.all([loadAll(), loadCustomers(), loadUsers()])
if (auth.isDirector || auth.isLeader) {
api.get('/users/', { params: { role: 'manager' } }).then(r => { allManagers.value = r.data }).catch(() => {})
}
})
async function loadUsers() {
@@ -53,6 +77,8 @@ function typeLabel(t: string) {
return labels[t] || ''
}
const customerSearch = ref('')
async function loadCustomers(q?: string) {
try {
const params: any = { page_size: 100 }
@@ -62,18 +88,61 @@ async function loadCustomers(q?: string) {
} catch (_) {}
}
async function handleCustomerSearch(query: string) {
customerSearch.value = query
if (query) { await loadCustomers(query) }
else { await loadCustomers() }
}
async function handleQuickCreate() {
const name = customerSearch.value.trim()
if (!name) { ElMessage.warning('请输入单位名称'); return }
try {
const res = await api.post('/customers/quick-create', null, { params: { name } })
const newCust = res.data
customers.value.unshift(newCust)
form.value.customer_id = newCust.id
customerSearch.value = ''
ElMessage.success(`已创建客户:${name}`)
await loadCustomers()
} catch (e: any) {
ElMessage.error('创建失败: ' + (e.response?.data?.detail || e.message))
}
}
function buildParams(tabKey: string) {
const { from, to } = getDateRange()
const params: any = { page: page.value[tabKey] || 1, page_size: pageSize.value }
if (from) { params.date_from = from; params.date_to = to }
if (searchText.value) params.search = searchText.value
return params
}
async function loadAll() {
loading.value = true
try {
const [v, w, m, d, k] = await Promise.all([
api.get('/visits/'), api.get('/work-plans/'),
api.get('/mini-business/'), api.get('/daily-notes/'),
api.get('/key-visits/'),
const [v, d] = await Promise.all([
api.get('/visits/', { params: buildParams('visits') }),
api.get('/daily-notes/', { params: buildParams('notes') }),
])
visits.value = v.data; workPlans.value = w.data
miniBusiness.value = m.data; dailyNotes.value = d.data
keyVisits.value = k.data
// Load photo previews
visits.value = Array.isArray(v.data) ? v.data : (v.data.items || [])
dailyNotes.value = Array.isArray(d.data) ? d.data : (d.data.items || [])
total.value.visits = v.data.total || visits.value.length
total.value.notes = d.data.total || dailyNotes.value.length
const [w, m, k] = await Promise.all([
api.get('/work-plans/', { params: buildParams('plans') }),
api.get('/mini-business/', { params: buildParams('mini') }),
api.get('/key-visits/', { params: buildParams('key') }),
])
workPlans.value = Array.isArray(w.data) ? w.data : (w.data.items || [])
miniBusiness.value = Array.isArray(m.data) ? m.data : (m.data.items || [])
keyVisits.value = Array.isArray(k.data) ? k.data : (k.data.items || [])
total.value.plans = w.data.total || workPlans.value.length
total.value.mini = m.data.total || miniBusiness.value.length
total.value.key = k.data.total || keyVisits.value.length
// Load photo previews for visits
for (const visit of visits.value) {
if (visit.photos?.length) {
for (const key of visit.photos) {
@@ -90,14 +159,19 @@ async function loadAll() {
finally { loading.value = false }
}
function onTabChange(tab: string) { activeTab.value = tab; loadAll() }
function onFilterChange() { Object.keys(page.value).forEach(k => page.value[k] = 1); loadAll() }
function onPageChange(tabKey: string, p: number) { page.value[tabKey] = p; loadAll() }
function onSizeChange() { Object.keys(page.value).forEach(k => page.value[k] = 1); loadAll() }
function openCreate(type: string) {
dialogMode.value = 'create'; dialogType.value = type
dialogTimeRange.value = null
if (type === 'visit') form.value = { customer_id: '', visit_date: todayStr(), visit_method: '上门', time_range: '', visitor_name: '', visitor_phone: '', communication_content: '', customer_demand: '', companions: [], companion_names: [] }
else if (type === 'note') form.value = { note_date: todayStr(), category: '其他', content: '', time_range: '' }
else if (type === 'plan') form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中' }
else if (type === 'mini') form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '' }
else if (type === 'key') { form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '' }; plannedVisitors.value = [] }
if (type === 'visit') form.value = { customer_id: '', visit_date: todayStr(), visit_method: '上门', time_range: '', visitor_name: '', visitor_phone: '', communication_content: '', customer_demand: '', companions: [], companion_names: [], manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
else if (type === 'note') form.value = { note_date: todayStr(), category: '其他', content: '', time_range: '', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
else if (type === 'plan') form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
else if (type === 'mini') form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
else if (type === 'key') { form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }; plannedVisitors.value = [] }
dialogVisible.value = true
}
@@ -107,6 +181,10 @@ function openEdit(type: string, item: any) {
if (item.customer_id && item.customer_name && !customers.value.find((c: any) => c.id === item.customer_id)) {
customers.value.unshift({ id: item.customer_id, name: item.customer_name })
}
// Ensure assigned manager is in the select options (avoid showing raw UUID)
if (item.manager_id && item.manager_name && !allUsers.value.find((u: any) => u.id === item.manager_id)) {
allUsers.value.unshift({ id: item.manager_id, name: item.manager_name })
}
dialogTimeRange.value = null
if ((type === 'visit' || type === 'note') && item.time_range && item.time_range.includes('-')) {
const parts = item.time_range.split('-')
@@ -140,12 +218,13 @@ async function handleDialogPhotoUpload(event: Event) {
try {
// Compress before upload to reduce storage & transfer
const compressed = await compressImage(file, { maxPixels: 1920, quality: 0.8 })
// Get presigned URL
const presignRes = await api.post('/upload/presigned-url', null, { params: { filename: compressed.name, content_type: compressed.type || 'image/jpeg' } })
// Upload directly to MinIO (not through our API)
const axios = (await import('axios')).default
await axios.put(presignRes.data.upload_url, compressed, { headers: { 'Content-Type': compressed.type || 'image/jpeg' } })
const key = presignRes.data.object_key
const data = new FormData()
data.append('file', compressed)
const uploadRes = await api.post('/upload/image', data, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000,
})
const key = uploadRes.data.object_key
if (!form.value.photos) form.value.photos = []
form.value.photos.push(key)
form.value.photos = [...form.value.photos]
@@ -237,6 +316,17 @@ function onDialogTimeChange(val: [string, string] | null) {
form.value.time_range = val ? val.join('-') : ''
}
async function onDialogCustomerChange(customerId: string) {
// Only pre-fill for new visit creation (not edit)
if (dialogMode.value !== 'create' || dialogType.value !== 'visit' || !customerId) return
try {
const planRes = await api.get(`/customers/${customerId}/overdue-plans`)
if (planRes.data?.plan?.plan_content) {
form.value.communication_content = planRes.data.plan.plan_content
}
} catch (_) {}
}
// Quick status change
async function quickStatusChange(type: string, row: any, newStatus: string) {
try {
@@ -274,16 +364,24 @@ const notesByDate = computed(() => {
<template>
<div class="workspace" v-loading="loading">
<div class="page-head">
<div class="page-head-row">
<h2 class="page-title">我的工作数据</h2>
<div class="page-head-controls">
<el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" size="small" style="width:240px" value-format="YYYY-MM-DD" @change="onFilterChange" />
<el-button v-if="dateRange" size="small" @click="dateRange=null;onFilterChange()">清除</el-button>
<el-input v-model="searchText" placeholder="搜索..." clearable size="small" style="width:240px" @keyup.enter="onFilterChange" @clear="onFilterChange">
<template #append><el-button size="small" @click="onFilterChange">搜索</el-button></template>
</el-input>
</div>
</div>
<div class="page-rule"></div>
</div>
<el-card>
<el-tabs v-model="activeTab">
<el-tabs v-model="activeTab" @tab-change="onTabChange">
<!-- 拜访记录 -->
<el-tab-pane label="拜访记录" name="visits">
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('visit')">+ 新建拜访</el-button></div>
<div v-for="[date, items] in visitsByDate" :key="date" class="date-group">
<div v-for="[date, items] in visitsByDate" :key="date" class="date-group">
<h4 class="date-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px; color: var(--gold)">
<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>
@@ -294,6 +392,11 @@ const notesByDate = computed(() => {
<el-table-column prop="customer_name" label="客户" width="130">
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('visit', row)">{{ row.customer_name }}</el-link></template>
</el-table-column>
<el-table-column v-if="auth.isDirector || auth.isLeader" label="客户经理" width="90">
<template #default="{ row }">
<el-tag :style="getMgrStyle(row.manager_name || '', false)" effect="dark" size="small" disable-transitions>{{ row.manager_name || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="visit_method" label="方式" width="60" />
<el-table-column prop="time_range" label="时间" width="100" />
<el-table-column label="照片" width="60">
@@ -315,7 +418,6 @@ const notesByDate = computed(() => {
<!-- 今日纪要 -->
<el-tab-pane label="今日纪要" name="daily_notes">
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('note')">+ 新建纪要</el-button></div>
<div v-for="[date, items] in notesByDate" :key="date" class="date-group">
<h4 class="date-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px; color: var(--gold)">
@@ -325,6 +427,11 @@ const notesByDate = computed(() => {
</h4>
<el-table :data="items" stripe size="small" v-column-resize>
<el-table-column prop="category" label="分类" width="100"><template #default="{ row }"><el-tag size="small">{{ row.category }}</el-tag></template></el-table-column>
<el-table-column v-if="auth.isDirector || auth.isLeader" label="客户经理" width="90">
<template #default="{ row }">
<el-tag :style="getMgrStyle(row.manager_name || '', false)" effect="dark" size="small" disable-transitions>{{ row.manager_name || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="content" label="工作内容" min-width="250" show-overflow-tooltip>
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('note', row)">{{ row.content }}</el-link></template>
</el-table-column>
@@ -341,7 +448,6 @@ const notesByDate = computed(() => {
<!-- 工作计划 -->
<el-tab-pane label="工作计划" name="work_plans">
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('plan')">+ 新建计划</el-button></div>
<el-table :data="workPlans" stripe size="small" v-column-resize>
<el-table-column prop="customer_name" label="客户" width="130">
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('plan', row)">{{ row.customer_name }}</el-link></template>
@@ -373,7 +479,6 @@ const notesByDate = computed(() => {
<!-- ═══ 小微商机 ═══ -->
<el-tab-pane label="小微商机" name="mini_biz">
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('mini')">+ 新建商机</el-button></div>
<el-table :data="miniBusiness" stripe size="small" v-column-resize>
<el-table-column prop="customer_name" label="客户" width="130">
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('mini', row)">{{ row.customer_name }}</el-link></template>
@@ -407,7 +512,6 @@ const notesByDate = computed(() => {
<!-- ═══ 要客拜访 ═══ -->
<el-tab-pane label="要客拜访" name="key_visits">
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('key')">+ 新建要客拜访</el-button></div>
<el-table :data="keyVisits" stripe size="small" v-column-resize>
<el-table-column prop="customer_name" label="客户" width="130">
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('key', row)">{{ row.customer_name }}</el-link></template>
@@ -439,20 +543,53 @@ const notesByDate = computed(() => {
</el-table>
<div v-if="!keyVisits.length" class="empty">暂无数据</div>
</el-tab-pane>
<div style="margin-top:16px;display:flex;align-items:center;gap:12px">
<el-pagination
v-model:current-page="page[activeTab === 'notes' ? 'notes' : activeTab === 'plans' ? 'plans' : activeTab === 'mini' ? 'mini' : activeTab === 'key' ? 'key' : 'visits']"
:page-size="pageSize"
:total="total[activeTab === 'notes' ? 'notes' : activeTab === 'plans' ? 'plans' : activeTab === 'mini' ? 'mini' : activeTab === 'key' ? 'key' : 'visits']"
:page-sizes="[25, 50, 100]"
layout="total, sizes, prev, pager, next"
small
@current-change="(p: number) => onPageChange(activeTab === 'notes' ? 'notes' : activeTab === 'plans' ? 'plans' : activeTab === 'mini' ? 'mini' : activeTab === 'key' ? 'key' : 'visits', p)"
@size-change="onSizeChange"
/>
</div>
</el-tabs>
</el-card>
<!-- Create/Edit Dialog -->
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' ' + typeLabel(dialogType)" width="520px">
<el-form label-position="top" v-if="form">
<el-form-item v-if="['visit','plan','mini','key'].includes(dialogType)" label="客户单位">
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
<el-form-item v-if="['visit','plan','mini','key'].includes(dialogType)" label="客户单位 *">
<el-select v-model="form.customer_id" filterable remote :remote-method="handleCustomerSearch" placeholder="搜索选择客户" style="width:100%" @change="onDialogCustomerChange">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
<template #empty>
<div v-if="customerSearch" class="select-empty-create">
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到「{{ customerSearch }}」</p>
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">+ 新建客户「{{ customerSearch }}」</button>
</div>
</template>
</el-select>
</el-form-item>
<el-form-item v-if="(auth.isDirector || auth.isLeader) && ['visit','plan','mini','key','note'].includes(dialogType)" label="客户经理">
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item v-if="dialogType === 'visit'" label="日期"><el-date-picker v-model="form.visit_date" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
<el-form-item v-if="dialogType === 'note'" label="日期"><el-date-picker v-model="form.note_date" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
<el-form-item v-if="dialogType === 'plan'" label="计划拜访时间"><el-date-picker v-model="form.plan_date" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
<el-form-item v-if="dialogType === 'visit' && (auth.isDirector || auth.isLeader)" label="客户经理">
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
<el-option v-for="u in allUsers.filter((u: any) => u.role === 'manager')" :key="u.id" :label="u.name" :value="u.id" />
</el-select>
</el-form-item>
<el-form-item v-if="(dialogType === 'plan' || dialogType === 'mini' || dialogType === 'key') && (auth.isDirector || auth.isLeader)" label="客户经理">
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
<el-option v-for="u in allUsers.filter((u: any) => u.role === 'manager')" :key="u.id" :label="u.name" :value="u.id" />
</el-select>
</el-form-item>
<el-form-item v-if="dialogType === 'visit'" label="日期"><el-date-picker v-model="form.visit_date" type="date" style="width:100%" /></el-form-item>
<el-form-item v-if="dialogType === 'note'" label="日期"><el-date-picker v-model="form.note_date" type="date" style="width:100%" /></el-form-item>
<el-form-item v-if="dialogType === 'plan'" label="计划拜访时间"><el-date-picker v-model="form.plan_date" type="date" style="width:100%" /></el-form-item>
<template v-if="dialogType === 'visit'">
<el-form-item label="拜访方式">
<div class="method-grid">
@@ -537,9 +674,15 @@ const notesByDate = computed(() => {
<style scoped>
.page-head { margin-bottom: 20px; }
.page-title { margin: 0; font-family: var(--font-heading); font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
.page-head-row { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
.page-head-controls { display: flex; align-items: center; gap: 8px; }
.page-title { margin: 0; font-family: var(--font-heading); font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; white-space: nowrap; }
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
.date-group { margin-bottom: 20px; }
.date-title { display: flex; align-items: center; margin: 12px 0 8px; font-family: var(--font-heading); font-size: 14px; color: var(--ink); letter-spacing: 0.04em; }
.empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: var(--font-body); }
.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 6px 12px; color: var(--gold-dark); font-family: var(--font-body); font-size: 12px; cursor: pointer; transition: all 0.2s; }
.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); }
.select-empty-create { padding: 8px 12px; text-align: center; }
.required-star { color: var(--vermilion); font-weight: 700; }
</style>
+196 -8
View File
@@ -7,14 +7,16 @@ import { todayStr } from '@/utils'
import { getMgrStyle, loadManagerColors } from '@/utils/managerColor'
import { useScreenshot } from '@/utils/screenshot'
import api from '@/api/index'
import { miniBusinessApi } from '@/api/miniBusiness'
import EditLogPanel from '@/components/EditLogPanel.vue'
const auth = useAuthStore()
const themeStore = useThemeStore()
const loading = ref(false)
const isLight = computed(() => themeStore.currentTheme === 'light')
const isLight = computed(() => themeStore.mode === 'light')
const miniBusiness = ref<any[]>([])
const customers = ref<any[]>([])
const allManagers = ref<any[]>([])
const shotRef = ref<HTMLElement | null>(null)
const { capturing, captureEl } = useScreenshot()
@@ -24,9 +26,22 @@ const form = ref<any>({})
const statusPick = ref<Record<string, string>>({})
const miniStatuses = ['跟进中', '已签约', '已流失']
// ── Detail + Follow-up logs ──
const detailVisible = ref(false)
const selectedBusiness = ref<any>(null)
const logs = ref<any[]>([])
const logForm = ref({ log_date: '', method: '电话', content: '' })
const logMethods = ['电话', '微信', '上门', '邮件', '其他']
const logMethodIcons: Record<string, string> = { '电话': '📞', '微信': '💬', '上门': '🏢', '邮件': '📧', '其他': '📋' }
const logMethodColors: Record<string, string> = { '电话': '#5B7FA5', '微信': '#22c55e', '上门': '#4A6741', '邮件': '#C4934A', '其他': '#7B7568' }
const logLoading = ref(false)
const logSaving = ref(false)
// ── Filters ──
const filterManager = ref('')
const filterStatus = ref('')
const filterStatus = ref('跟进中')
const searchText = ref('')
function onSearch() { loadItems() }
const managerOptions = computed(() => {
const seen = new Set<string>()
@@ -36,10 +51,12 @@ const managerOptions = computed(() => {
.sort()
})
const statusOrder: Record<string, number> = { '跟进中': 0, '已签约': 1, '已流失': 2 }
const filteredItems = computed(() => {
let list = miniBusiness.value
if (filterManager.value) list = list.filter((m: any) => (m.manager_name || '未知') === filterManager.value)
if (filterStatus.value) list = list.filter((m: any) => m.status === filterStatus.value)
list.sort((a: any, b: any) => (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9))
return list
})
@@ -64,8 +81,13 @@ const managerSummary = computed(() => {
onMounted(async () => {
await Promise.all([loadItems(), loadCustomers(), loadManagerColors()])
if (auth.isDirector || auth.isLeader) {
api.get('/users/', { params: { role: 'manager' } }).then(r => { allManagers.value = r.data }).catch(() => {})
}
})
const customerSearch = ref('')
async function loadCustomers(q?: string) {
try {
const params: any = { page_size: 500 }
@@ -75,10 +97,34 @@ async function loadCustomers(q?: string) {
} catch (_) {}
}
async function handleCustomerSearch(query: string) {
customerSearch.value = query
if (query) { await loadCustomers(query) }
else { await loadCustomers() }
}
async function handleQuickCreate() {
const name = customerSearch.value.trim()
if (!name) { ElMessage.warning('请输入单位名称'); return }
try {
const res = await api.post('/customers/quick-create', null, { params: { name } })
const newCust = res.data
customers.value.unshift(newCust)
form.value.customer_id = newCust.id
customerSearch.value = ''
ElMessage.success(`已创建客户:${name}`)
await loadCustomers()
} catch (e: any) {
ElMessage.error('创建失败: ' + (e.response?.data?.detail || e.message))
}
}
async function loadItems() {
loading.value = true
try {
const res = await api.get('/mini-business/')
const params: any = {}
if (searchText.value) params.search = searchText.value
const res = await api.get('/mini-business/', { params })
miniBusiness.value = res.data
} catch (e: any) { ElMessage.error('加载失败') }
finally { loading.value = false }
@@ -86,7 +132,7 @@ async function loadItems() {
function openCreate() {
dialogMode.value = 'create'
form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '' }
form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
dialogVisible.value = true
}
@@ -100,7 +146,9 @@ function openEdit(item: any) {
}
async function handleSave() {
if (!form.value.customer_id) { ElMessage.warning('请选择客户单位'); return }
try {
if (form.value.status === '已流失') form.value.expected_revenue_date = ''
if (dialogMode.value === 'create') {
await api.post('/mini-business/', form.value)
} else {
@@ -121,10 +169,50 @@ async function handleDelete(id: string) {
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
// ── Detail + Follow-up logs ──
async function openDetail(item: any) {
selectedBusiness.value = item
detailVisible.value = true
logForm.value = { log_date: new Date().toISOString().slice(0, 10), method: '电话', content: '' }
await loadLogs()
}
async function loadLogs() {
logLoading.value = true
try {
const res = await miniBusinessApi.getLogs(selectedBusiness.value.id)
logs.value = res.data || []
} catch (_) { logs.value = [] }
finally { logLoading.value = false }
}
async function handleCreateLog() {
if (!logForm.value.content.trim()) { ElMessage.warning('请输入跟进内容'); return }
logSaving.value = true
try {
await miniBusinessApi.createLog(selectedBusiness.value.id, logForm.value)
ElMessage.success('跟进记录已添加')
logForm.value = { log_date: new Date().toISOString().slice(0, 10), method: '电话', content: '' }
await loadLogs()
} catch (e: any) { ElMessage.error('添加失败: ' + (e.response?.data?.detail || e.message)) }
finally { logSaving.value = false }
}
async function handleDeleteLog(logId: string) {
try {
await ElMessageBox.confirm('确定删除该跟进记录?', '确认', { type: 'warning' })
await miniBusinessApi.deleteLog(logId)
ElMessage.success('已删除')
await loadLogs()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
async function quickStatusChange(row: any, newStatus: string) {
try {
await ElMessageBox.confirm(`确定将状态改为「${newStatus}」?`, '确认', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' })
await api.put(`/mini-business/${row.id}`, { status: newStatus })
const payload: any = { status: newStatus }
if (newStatus === '已流失') payload.expected_revenue_date = ''
await api.put(`/mini-business/${row.id}`, payload)
ElMessage.success('状态已更新')
await loadItems()
} catch (e: any) {
@@ -170,6 +258,9 @@ async function quickStatusChange(row: any, newStatus: string) {
<!-- Filter Bar -->
<div class="filter-bar">
<div class="filter-row">
<div class="filter-item">
<el-input v-model="searchText" placeholder="搜索..." clearable size="small" style="width:180px" @keyup.enter="onSearch" @clear="onSearch" />
</div>
<div class="filter-item">
<label class="filter-label">客户经理</label>
<el-select v-model="filterManager" clearable placeholder="全部" size="small" style="width:150px">
@@ -192,7 +283,7 @@ async function quickStatusChange(row: any, newStatus: string) {
<el-table-column type="index" label="序号" width="50" />
<el-table-column prop="customer_name" label="客户" width="160">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="openEdit(row)">{{ row.customer_name }}</el-link>
<el-link type="primary" :underline="false" @click="openDetail(row)">{{ row.customer_name }}</el-link>
<el-tooltip v-if="row.edit_log?.length > 1" placement="top">
<template #content>最后编辑{{ row.edit_log[row.edit_log.length-1].editor }} · {{ row.edit_log.length-1 }}次修改</template>
<span class="edit-indicator" title="有过修改">🕐</span>
@@ -236,9 +327,20 @@ async function quickStatusChange(row: any, newStatus: string) {
<!-- Dialog -->
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 小微商机'" width="500px">
<el-form label-position="top">
<el-form-item label="客户单位">
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
<el-form-item label="客户单位 *">
<el-select v-model="form.customer_id" filterable remote :remote-method="handleCustomerSearch" placeholder="搜索选择客户" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
<template #empty>
<div v-if="customerSearch" class="select-empty-create">
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到「{{ customerSearch }}」</p>
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">+ 新建客户「{{ customerSearch }}」</button>
</div>
</template>
</el-select>
</el-form-item>
<el-form-item v-if="auth.isDirector || auth.isLeader" label="客户经理">
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item label="产品类型"><el-input v-model="form.product_type" placeholder="云专线SD-WAN" /></el-form-item>
@@ -261,6 +363,59 @@ async function quickStatusChange(row: any, newStatus: string) {
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
<!-- Detail Dialog with Follow-up Timeline -->
<el-dialog v-model="detailVisible" :title="selectedBusiness?.customer_name + ' — 商机跟单'" width="650px" v-if="selectedBusiness">
<!-- Business Info -->
<div class="detail-info">
<div class="detail-row"><span class="detail-label">产品类型</span><span>{{ selectedBusiness.product_type }}</span></div>
<div class="detail-row"><span class="detail-label">金额</span><span>{{ selectedBusiness.amount }}</span></div>
<div class="detail-row"><span class="detail-label">状态</span><span :style="{ color: selectedBusiness.status === '已签约' ? '#4A6741' : selectedBusiness.status === '已流失' ? '#B8472E' : '#C4934A' }">{{ selectedBusiness.status }}</span></div>
<div class="detail-row"><span class="detail-label">预计列收</span><span>{{ selectedBusiness.expected_revenue_date }}</span></div>
<div class="detail-row"><span class="detail-label">客户经理</span><span class="mgr-tag" :style="getMgrStyle(selectedBusiness.manager_name, isLight)">{{ selectedBusiness.manager_name }}</span></div>
<div class="detail-row" v-if="selectedBusiness.follow_up_detail"><span class="detail-label">商机概述</span><span>{{ selectedBusiness.follow_up_detail }}</span></div>
</div>
<div style="margin-top:12px">
<el-button size="small" @click="detailVisible = false; openEdit(selectedBusiness)">编辑商机</el-button>
</div>
<!-- Follow-up Timeline -->
<div class="log-section">
<h4 class="log-title">跟进记录</h4>
<div v-loading="logLoading" class="log-list">
<div v-if="logs.length === 0 && !logLoading" class="log-empty">暂无跟进记录</div>
<div v-for="l in logs" :key="l.id" class="log-item">
<span class="log-icon">{{ logMethodIcons[l.method] || '📋' }}</span>
<div class="log-body">
<div class="log-header">
<span class="log-date">{{ l.log_date }}</span>
<span class="log-method-chip" :style="{ background: logMethodColors[l.method] || '#7B7568', color: '#fff', padding: '1px 8px', fontSize: '11px' }">{{ l.method }}</span>
<span class="log-author">{{ l.created_by_name }}</span>
</div>
<div class="log-content">{{ l.content }}</div>
</div>
<el-button size="small" type="danger" text @click="handleDeleteLog(l.id)" class="log-del">删除</el-button>
</div>
</div>
<!-- Add Log Form -->
<div class="log-add">
<div class="log-add-row">
<el-date-picker v-model="logForm.log_date" type="date" value-format="YYYY-MM-DD" size="small" style="width:140px" />
<div class="log-method-chips">
<button v-for="m in logMethods" :key="m" type="button" class="log-chip" :class="{ 'log-chip--active': logForm.method === m }" :style="logForm.method === m ? { background: logMethodColors[m], borderColor: logMethodColors[m], color: '#fff' } : {}" @click="logForm.method = m">{{ m }}</button>
</div>
</div>
<el-input v-model="logForm.content" type="textarea" :rows="2" placeholder="输入跟进内容..." size="small" style="margin-top:6px" />
<el-button type="primary" size="small" :loading="logSaving" @click="handleCreateLog" style="margin-top:6px">添加跟进</el-button>
</div>
</div>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -284,4 +439,37 @@ async function quickStatusChange(row: any, newStatus: string) {
.filter-item { display: flex; align-items: center; gap: 8px; }
.filter-label { font-size: 13px; color: var(--warm-gray); font-family: var(--font-body); white-space: nowrap; }
.filter-count { font-size: 12px; color: var(--c-text-muted); font-family: var(--font-mono); margin-left: auto; }
.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 6px 12px; color: var(--gold-dark); font-family: var(--font-body); font-size: 12px; cursor: pointer; transition: all 0.2s; }
.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); }
.select-empty-create { padding: 8px 12px; text-align: center; }
/* ── Detail Dialog ── */
.detail-info { background: var(--c-bg-light, #faf9f6); border-radius: 6px; padding: 14px 18px; }
.detail-row { display: flex; gap: 12px; padding: 4px 0; font-size: 13px; }
.detail-label { color: var(--warm-gray); min-width: 70px; flex-shrink: 0; }
/* ── Log Timeline ── */
.log-section { margin-top: 18px; border-top: 1px solid var(--warm-border); padding-top: 14px; }
.log-title { margin: 0 0 12px; font-family: var(--font-heading); font-size: 16px; color: var(--ink); }
.log-list { display: flex; flex-direction: column; gap: 10px; max-height: 300px; overflow-y: auto; margin-bottom: 14px; }
.log-empty { text-align: center; color: var(--warm-gray); font-size: 13px; padding: 20px 0; }
.log-item { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; background: var(--surface); border: 1px solid var(--warm-border); }
.log-icon { font-size: 18px; flex-shrink: 0; margin-top: 2px; }
.log-body { flex: 1; min-width: 0; }
.log-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.log-date { font-family: var(--font-mono); font-size: 12px; color: var(--ink); }
.log-author { font-size: 11px; color: var(--warm-gray); margin-left: auto; }
.log-content { font-size: 13px; color: var(--c-text); line-height: 1.6; }
.log-del { flex-shrink: 0; opacity: 0.5; }
.log-del:hover { opacity: 1; }
.log-method-chip { border-radius: 3px; white-space: nowrap; }
/* ── Add Log Form ── */
.log-add { border-top: 1px solid var(--warm-border); padding-top: 12px; }
.log-add-row { display: flex; gap: 8px; align-items: center; }
.log-method-chips { display: flex; gap: 4px; }
.log-chip { padding: 4px 10px; border: 1px solid var(--warm-border); background: var(--surface); font-size: 12px; cursor: pointer; transition: all 0.2s; }
.log-chip:hover { border-color: var(--ink); }
.log-chip--active { font-weight: 600; }
.required-star { color: var(--vermilion); font-weight: 700; }
</style>
+27 -4
View File
@@ -2,9 +2,11 @@
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import api from '@/api/index'
import { useThemeStore, themeLabels, type Theme } from '@/stores/theme'
import { useThemeStore, themeLabels, type Theme, lightThemes, darkThemes } from '@/stores/theme'
import { useAuthStore } from '@/stores/auth'
const themeStore = useThemeStore()
const auth = useAuthStore()
const managers = ref<any[]>([])
const selectedUserIds = ref<string[]>([])
@@ -165,13 +167,28 @@ async function handleImportPreview() {
</div>
</template>
<div class="theme-options">
<label v-for="t in (['editorial', 'light'] as Theme[])" :key="t" class="theme-opt" :class="{ 'theme-opt--active': themeStore.currentTheme === t }" @click="themeStore.setTheme(t)">
<span class="theme-opt-radio">{{ themeStore.currentTheme === t ? '●' : '○' }}</span>
<div class="theme-mode-row">
<span class="theme-mode-label">模式</span>
<button class="theme-mode-btn" @click="themeStore.toggleMode()">
{{ themeStore.mode === 'light' ? ' 亮色模式' : '🌙 深色模式' }}
</button>
</div>
<div class="theme-sub-options">
<label
v-for="t in (themeStore.mode === 'light' ? lightThemes : darkThemes)"
:key="t"
class="theme-opt"
:class="{ 'theme-opt--active': (themeStore.mode === 'light' ? themeStore.lightTheme : themeStore.darkTheme) === t }"
@click="themeStore.mode === 'light' ? themeStore.setLightTheme(t) : themeStore.setDarkTheme(t)"
>
<span class="theme-opt-radio">{{ (themeStore.mode === 'light' ? themeStore.lightTheme : themeStore.darkTheme) === t ? '●' : '○' }}</span>
<span class="theme-opt-label">{{ themeLabels[t] }}</span>
</label>
</div>
</div>
</el-card>
<template v-if="auth.isDirector">
<!-- Manual Remind -->
<el-card class="setting-card">
<template #header>
@@ -371,6 +388,7 @@ async function handleImportPreview() {
</el-alert>
</div>
</el-card>
</template>
</div>
</template>
@@ -395,7 +413,12 @@ async function handleImportPreview() {
.setting-label { font-family: var(--font-body); font-size: 13px; color: var(--ink); }
/* ── Theme Selector ── */
.theme-options { display: flex; gap: 16px; }
.theme-options { display: flex; flex-direction: column; gap: 12px; }
.theme-mode-row { display: flex; align-items: center; gap: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--warm-border); margin-bottom: 4px; }
.theme-mode-label { font-family: var(--font-heading); font-size: 13px; color: var(--warm-gray); }
.theme-mode-btn { font-family: var(--font-body); font-size: 14px; cursor: pointer; padding: 6px 16px; border: 1px solid var(--warm-border); border-radius: 6px; background: var(--c-primary-bg); color: var(--c-text); transition: all var(--transition); }
.theme-mode-btn:hover { border-color: var(--gold); }
.theme-sub-options { display: flex; gap: 12px; padding-left: 4px; }
.theme-opt { display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 8px 16px; border: 1px solid var(--warm-border); transition: all var(--transition); }
.theme-opt:hover { border-color: var(--ink); }
.theme-opt--active { border-color: var(--ink); background: var(--c-primary-bg); }
+108 -25
View File
@@ -323,35 +323,50 @@ const notesByDate = computed(() => {
</svg>
{{ date }}
</h4>
<el-table :data="items" stripe style="width:100%" v-column-resize>
<el-table-column prop="customer_name" label="客户" width="120" />
<el-table-column prop="visit_method" label="方式" width="60" />
<el-table-column prop="time_range" label="时间" width="100" />
<el-table-column prop="communication_content" label="沟通内容" min-width="200" show-overflow-tooltip />
<el-table-column prop="customer_demand" label="客户需求" min-width="150" show-overflow-tooltip />
<el-table-column label="相关人员" width="120">
<template #default="{ row }">
<div style="display:flex;flex-wrap:wrap;gap:2px">
<span>{{ row.manager_name }}</span>
<span v-for="n in (row.companion_names_resolved || [])" :key="n" style="color:var(--warm-gray);font-size:12px">, {{ n }}</span>
<!-- Visit cards: merged group visits get participant sections -->
<div v-for="item in items" :key="item.id" class="visit-card">
<div class="visit-card-main">
<div class="visit-card-left">
<span class="visit-card-customer">{{ item.customer_name }}</span>
<span class="visit-card-method">{{ item.visit_method }}</span>
<span v-if="item.time_range" class="visit-card-time">{{ item.time_range }}</span>
</div>
<el-tooltip v-if="row.edit_log?.length > 1" placement="top">
<template #content>最后编辑{{ row.edit_log[row.edit_log.length-1].editor }} · {{ row.edit_log.length-1 }}次修改</template>
<span class="edit-indicator" title="有过修改">🕐</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="照片" width="100">
<template #default="{ row }">
<div class="photo-cell" v-if="row.photos?.length">
<img v-for="key in row.photos.slice(0,2)" :key="key"
<div class="visit-card-right">
<span class="visit-card-mgr">{{ item.manager_name }}</span>
<span v-for="n in (item.companion_names_resolved || [])" :key="n" class="visit-card-companion">, {{ n }}</span>
</div>
</div>
<div class="visit-card-content">
<div class="visit-card-section">
<div class="visit-card-label">沟通内容</div>
<div class="visit-card-text">{{ item.communication_content || '-' }}</div>
</div>
<div v-if="item.customer_demand" class="visit-card-section">
<div class="visit-card-label">客户需求</div>
<div class="visit-card-text">{{ item.customer_demand }}</div>
</div>
<!-- Companion participants -->
<div v-if="item.participants && item.participants.length > 1" class="companion-section">
<div v-for="p in item.participants.filter((p: any) => p.role === 'companion')" :key="p.manager_id" class="companion-row">
<div class="companion-header">
<span class="companion-badge">协同</span>
<span class="companion-name">{{ p.manager_name }}</span>
</div>
<div class="companion-body">
<div class="visit-card-text">{{ p.communication_content || '-' }}</div>
<div v-if="p.customer_demand && p.customer_demand !== item.customer_demand" class="visit-card-text" style="margin-top:4px;color:var(--warm-gray)">需求补充{{ p.customer_demand }}</div>
</div>
</div>
</div>
<!-- Photos -->
<div v-if="item.photos?.length" class="visit-card-photos">
<img v-for="key in item.photos.slice(0,3)" :key="key"
:src="photoUrls[key] || ''" class="mini-thumb"
@click="viewPhoto(photoUrls[key])" />
<span v-if="item.photos.length > 3" class="photo-more">+{{ item.photos.length - 3 }}</span>
</div>
</div>
</div>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
@@ -414,6 +429,74 @@ const notesByDate = computed(() => {
font-family: var(--font-heading);
font-size: 14px; color: var(--ink); letter-spacing: 0.04em;
}
/* ═══ Visit Cards ═══ */
.visit-card {
background: var(--surface);
border: 1px solid var(--warm-border);
margin-bottom: 10px;
transition: border-color 0.2s;
}
.visit-card:hover { border-color: var(--gold); }
.visit-card-main {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--warm-border);
}
.visit-card-left { display: flex; align-items: center; gap: 10px; }
.visit-card-customer {
font-family: var(--font-heading); font-size: 15px; color: var(--ink);
letter-spacing: 0.04em; font-weight: 500;
}
.visit-card-method {
font-family: var(--font-mono); font-size: 11px;
background: var(--paper); padding: 2px 8px; color: var(--warm-gray);
}
.visit-card-time {
font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray);
}
.visit-card-right { display: flex; align-items: center; gap: 2px; }
.visit-card-mgr {
font-family: var(--font-body); font-size: 13px; color: var(--ink);
}
.visit-card-companion {
font-size: 12px; color: var(--warm-gray);
}
.visit-card-content { padding: 14px 16px; }
.visit-card-section { margin-bottom: 10px; }
.visit-card-label {
font-family: var(--font-mono); font-size: 10px; color: var(--gold);
letter-spacing: 0.12em; margin-bottom: 4px; text-transform: uppercase;
}
.visit-card-text {
font-family: var(--font-body); font-size: 14px; color: var(--c-text);
line-height: 1.7; white-space: pre-wrap;
}
/* ═══ Companion Section ═══ */
.companion-section {
margin-top: 12px;
border-top: 1px dashed var(--warm-border);
padding-top: 10px;
}
.companion-row { margin-bottom: 8px; }
.companion-row:last-child { margin-bottom: 0; }
.companion-header {
display: flex; align-items: center; gap: 6px; margin-bottom: 4px;
}
.companion-badge {
font-family: var(--font-mono); font-size: 9px;
background: var(--gold); color: #fff; padding: 1px 6px;
letter-spacing: 0.1em;
}
.companion-name {
font-family: var(--font-body); font-size: 13px; color: var(--ink);
}
.companion-body {
padding-left: 28px;
border-left: 2px solid var(--gold);
margin-left: 6px;
}
.visit-card-photos { display: flex; gap: 4px; margin-top: 8px; align-items: center; }
.photo-more { font-family: var(--font-mono); font-size: 11px; color: var(--warm-gray); }
.photo-cell { display: flex; gap: 4px; }
.mini-thumb { width: 36px; height: 36px; object-fit: cover; cursor: pointer; }
.edit-indicator { font-size: 12px; margin-left: 3px; opacity: 0.5; cursor: help; }
+221 -101
View File
@@ -14,72 +14,165 @@ const themeStore = useThemeStore()
const loading = ref(false)
const workPlans = ref<any[]>([])
const customers = ref<any[]>([])
const allManagers = ref<any[]>([])
const shotRef = ref<HTMLElement | null>(null)
const { capturing, captureEl } = useScreenshot()
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const form = ref<any>({})
const statusPick = ref<Record<string, string>>({})
const planStatuses = ['计划中', '已完成', '已取消']
const statusPick = ref<Record<string, string>>({})
// Quick reschedule dialog
const rescheduleVisible = ref(false)
const rescheduleRow = ref<any>(null)
const rescheduleDate = ref('')
function openReschedule(row: any) {
rescheduleRow.value = row
rescheduleDate.value = row.plan_date
rescheduleVisible.value = true
}
async function confirmReschedule() {
if (!rescheduleDate.value || !rescheduleRow.value) return
try {
await api.put(`/work-plans/${rescheduleRow.value.id}`, { plan_date: rescheduleDate.value })
ElMessage.success('计划日期已更新')
rescheduleVisible.value = false
await loadPlans()
} catch (e: any) { ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message)) }
}
// ── Filters ──
const filterManager = ref('')
const filterStatus = ref('')
const isLight = computed(() => themeStore.currentTheme === 'light')
const managerOptions = computed(() => {
const seen = new Set<string>()
return workPlans.value
.map((w: any) => w.manager_name || '未知')
.filter((n: string) => { if (seen.has(n)) return false; seen.add(n); return true })
.sort()
const expandedSections = ref<Record<string, boolean>>({
thisWeek: true, nextWeek: true, thisMonth: false, later: false, archived: false,
})
const searchText = ref('')
const filteredPlans = computed(() => {
function onSearch() { loadPlans() }
const isLight = computed(() => themeStore.mode === 'light')
const statusColors: Record<string, string> = {
'计划中': '#4A6741',
'已完成': '#5B7FA5',
'已取消': '#909399',
}
// ── Week boundaries ──
const today = new Date()
const weekStart = (d: Date) => { const s = new Date(d); s.setDate(s.getDate() - s.getDay() + 1); s.setHours(0,0,0,0); return s }
const weekEnd = (d: Date) => { const e = weekStart(d); e.setDate(e.getDate() + 6); return e }
const thisWeekStart = weekStart(today)
const nextWeekStart = new Date(thisWeekStart); nextWeekStart.setDate(nextWeekStart.getDate() + 7)
const thisMonthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0)
function classifyPlan(planDate: string, status: string): string {
if (status !== '计划中') return 'archived'
const d = new Date(planDate)
if (d < thisWeekStart) return 'overdue' // falls into thisWeek section with overdue flag
if (d <= weekEnd(today)) return 'thisWeek'
if (d <= weekEnd(nextWeekStart)) return 'nextWeek'
if (d <= thisMonthEnd) return 'thisMonth'
return 'later'
}
function sectionLabel(key: string): string {
const labels: Record<string, string> = { overdue: '已过期', thisWeek: '本周', nextWeek: '下周', thisMonth: '本月', later: '更远', archived: '已归档' }
return labels[key] || key
}
function isOverdue(row: any): boolean {
return row.status === '计划中' && row.plan_date < todayStr()
}
function overdueDays(row: any): number {
return Math.floor((today.getTime() - new Date(row.plan_date).getTime()) / 86400000)
}
// ── Group plans ──
const groupedPlans = computed(() => {
const groups: Record<string, any[]> = { thisWeek: [], nextWeek: [], thisMonth: [], later: [], archived: [] }
let list = workPlans.value
if (filterManager.value) list = list.filter((w: any) => (w.manager_name || '未知') === filterManager.value)
if (filterStatus.value) list = list.filter((w: any) => w.status === filterStatus.value)
return list
for (const p of list) {
const key = classifyPlan(p.plan_date, p.status)
const g = key === 'overdue' ? 'thisWeek' : key
if (!groups[g]) groups[g] = []
groups[g].push(p)
}
// Sort each group by plan_date asc
for (const g of Object.values(groups)) {
g.sort((a: any, b: any) => a.plan_date.localeCompare(b.plan_date))
}
return Object.entries(groups)
.filter(([_, items]) => items.length > 0)
.map(([key, items]) => ({ key, label: sectionLabel(key), items }))
})
function resetFilters() {
filterManager.value = ''
filterStatus.value = ''
}
const allOverdue = computed(() => workPlans.value.filter((w: any) => isOverdue(w)))
async function handleScreenshot() {
const d = new Date().toISOString().slice(0, 10)
await captureEl(shotRef.value, `工作计划_${d}.png`)
}
const managerOptions = computed(() => {
const seen = new Set<string>()
return workPlans.value.map((w: any) => w.manager_name || '未知').filter((n: string) => { if (seen.has(n)) return false; seen.add(n); return true }).sort()
})
const managerSummary = computed(() => {
const map: Record<string, number> = {}
workPlans.value.forEach((w: any) => {
const n = w.manager_name || '未知'
map[n] = (map[n] || 0) + 1
})
workPlans.value.forEach((w: any) => { const n = w.manager_name || '未知'; map[n] = (map[n] || 0) + 1 })
return Object.entries(map).sort((a, b) => b[1] - a[1])
})
function resetFilters() { filterManager.value = ''; filterStatus.value = '' }
onMounted(async () => {
await Promise.all([loadPlans(), loadCustomers(), loadManagerColors()])
if (auth.isDirector || auth.isLeader) {
api.get('/users/', { params: { role: 'manager' } }).then(r => { allManagers.value = r.data }).catch(() => {})
}
})
const customerSearch = ref('')
async function loadCustomers(q?: string) {
try { const params: any = { page_size: 500 }; if (q) params.search = q; const res = await api.get('/customers/', { params }); customers.value = res.data.items || [] } catch (_) {}
}
async function handleCustomerSearch(query: string) {
customerSearch.value = query
if (query) { await loadCustomers(query) }
else { await loadCustomers() }
}
async function handleQuickCreate() {
const name = customerSearch.value.trim()
if (!name) { ElMessage.warning('请输入单位名称'); return }
try {
const params: any = { page_size: 500 }
if (q) params.search = q
const res = await api.get('/customers/', { params })
customers.value = res.data.items || []
} catch (_) {}
const res = await api.post('/customers/quick-create', null, { params: { name } })
const newCust = res.data
customers.value.unshift(newCust)
form.value.customer_id = newCust.id
customerSearch.value = ''
ElMessage.success(`已创建客户:${name}`)
await loadCustomers()
} catch (e: any) {
ElMessage.error('创建失败: ' + (e.response?.data?.detail || e.message))
}
}
async function loadPlans() {
loading.value = true
try {
const res = await api.get('/work-plans/')
const params: any = {}
if (searchText.value) params.search = searchText.value
const res = await api.get('/work-plans/', { params })
workPlans.value = res.data
} catch (e: any) { ElMessage.error('加载失败') }
finally { loading.value = false }
@@ -87,7 +180,7 @@ async function loadPlans() {
function openCreate() {
dialogMode.value = 'create'
form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中' }
form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
dialogVisible.value = true
}
@@ -101,38 +194,24 @@ function openEdit(item: any) {
}
async function handleSave() {
if (!form.value.customer_id) { ElMessage.warning('请选择客户单位'); return }
try {
if (dialogMode.value === 'create') {
await api.post('/work-plans/', form.value)
} else {
await api.put(`/work-plans/${form.value.id}`, form.value)
}
ElMessage.success(dialogMode.value === 'create' ? '已创建' : '已更新')
dialogVisible.value = false
await loadPlans()
if (dialogMode.value === 'create') await api.post('/work-plans/', form.value)
else await api.put(`/work-plans/${form.value.id}`, form.value)
ElMessage.success(dialogMode.value === 'create' ? '已创建' : '已更新'); dialogVisible.value = false; await loadPlans()
} catch (e: any) { ElMessage.error('保存失败: ' + (e.response?.data?.detail || e.message)) }
}
async function handleDelete(id: string) {
try {
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
await api.delete(`/work-plans/${id}`)
ElMessage.success('已删除')
await loadPlans()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
async function handleDelete(id: string) { try { await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' }); await api.delete(`/work-plans/${id}`); ElMessage.success('已删除'); await loadPlans() } catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') } }
async function quickStatusChange(row: any, newStatus: string) {
try {
await ElMessageBox.confirm(`确定将状态改为「${newStatus}」?`, '确认', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' })
await api.put(`/work-plans/${row.id}`, { status: newStatus })
ElMessage.success('状态已更新')
await loadPlans()
} catch (e: any) {
if (e !== 'cancel') ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message))
delete statusPick.value[row.id]
}
try { await ElMessageBox.confirm(`确定将状态改为「${newStatus}」?`, '确认', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }); await api.put(`/work-plans/${row.id}`, { status: newStatus }); ElMessage.success('状态已更新'); await loadPlans() } catch (e: any) { if (e !== 'cancel') ElMessage.error('更新失败') }
}
async function quickReschedule(row: any) { openReschedule(row) }
async function quickCancel(row: any) { await quickStatusChange(row, '已取消') }
async function handleScreenshot() { const d = new Date().toISOString().slice(0, 10); await captureEl(shotRef.value, `工作计划_${d}.png`) }
</script>
<template>
@@ -142,26 +221,22 @@ async function quickStatusChange(row: any, newStatus: string) {
<div class="page-head-row">
<div>
<h2 class="page-title">工作计划</h2>
<p class="page-desc">面向未来的工作计划安排支持状态流转跟踪</p>
<p class="page-desc">按周分组展示过期计划高亮提醒💡 拜访客户后计划自动完成无需手动改状态</p>
</div>
<div class="page-head-actions">
<el-button type="primary" @click="openCreate" class="screenshot-hide">
<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:4px">
<line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<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:4px"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
新建计划
</el-button>
<el-button type="warning" :loading="capturing" @click="handleScreenshot" class="screenshot-hide">
<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:4px">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
截图导出
</el-button>
<el-button type="warning" :loading="capturing" @click="handleScreenshot" class="screenshot-hide">截图导出</el-button>
</div>
</div>
<div class="page-rule"></div>
<!-- Overdue alert banner -->
<div v-if="allOverdue.length" class="overdue-banner">
<svg 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="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<span> <strong>{{ allOverdue.length }}</strong> 条计划已过期请及时改期或取消</span>
</div>
<div v-if="managerSummary.length" class="summary-bar">
<span class="summary-label">客户经理汇总</span>
<span v-for="[name, count] in managerSummary" :key="name" class="summary-chip" :class="{ 'summary-chip--active': filterManager === name }" :style="getMgrStyle(name, isLight)" @click="filterManager = filterManager === name ? '' : name">{{ name }} · {{ count }}</span>
@@ -171,6 +246,9 @@ async function quickStatusChange(row: any, newStatus: string) {
<!-- Filter Bar -->
<div class="filter-bar">
<div class="filter-row">
<div class="filter-item">
<el-input v-model="searchText" placeholder="搜索..." clearable size="small" style="width:180px" @keyup.enter="onSearch" @clear="onSearch" />
</div>
<div class="filter-item">
<label class="filter-label">客户经理</label>
<el-select v-model="filterManager" clearable placeholder="全部" size="small" style="width:150px">
@@ -184,64 +262,77 @@ async function quickStatusChange(row: any, newStatus: string) {
</el-select>
</div>
<el-button v-if="filterManager || filterStatus" size="small" plain @click="resetFilters">重置</el-button>
<span class="filter-count">{{ filteredPlans.length }} / {{ workPlans.length }} </span>
<span class="filter-count">{{ workPlans.length }} </span>
</div>
</div>
<el-card>
<el-table :data="filteredPlans" stripe size="small" v-if="filteredPlans.length" v-column-resize>
<el-table-column type="index" label="序号" width="50" />
<!-- Grouped sections -->
<div v-if="!workPlans.length" class="empty">暂无工作计划</div>
<div v-for="group in groupedPlans" :key="group.key" class="plan-section">
<div class="section-header" @click="expandedSections[group.key] = !expandedSections[group.key]">
<span class="section-arrow">{{ expandedSections[group.key] ? '▼' : '▶' }}</span>
<span class="section-title">{{ group.label }}</span>
<span class="section-count">{{ group.items.length }} </span>
</div>
<div v-show="expandedSections[group.key]">
<el-table :data="group.items" stripe size="small" v-column-resize>
<el-table-column type="index" label="#" width="45" />
<el-table-column prop="customer_name" label="客户" width="160">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="openEdit(row)">{{ row.customer_name }}</el-link>
<el-tooltip v-if="row.edit_log?.length > 1" placement="top">
<template #content>最后编辑{{ row.edit_log[row.edit_log.length-1].editor }} · {{ row.edit_log.length-1 }}次修改</template>
<span class="edit-indicator" title="有过修改">🕐</span>
</el-tooltip>
<el-tooltip v-if="row.edit_log?.length > 1" placement="top"><template #content>最后编辑:{{ row.edit_log[row.edit_log.length-1].editor }} · {{ row.edit_log.length-1 }}次修改</template><span class="edit-indicator">🕐</span></el-tooltip>
</template>
</el-table-column>
<el-table-column label="客户经理" width="100">
<template #default="{ row }">
<span class="mgr-tag" :style="getMgrStyle(row.manager_name, isLight)">{{ row.manager_name || '-' }}</span>
</template>
<template #default="{ row }"><span class="mgr-tag" :style="getMgrStyle(row.manager_name, isLight)">{{ row.manager_name || '-' }}</span></template>
</el-table-column>
<el-table-column prop="plan_content" label="工作计划" min-width="280" show-overflow-tooltip />
<el-table-column prop="plan_content" label="工作计划" min-width="220" show-overflow-tooltip />
<el-table-column prop="plan_date" label="计划时间" width="110" />
<el-table-column label="状态" width="130">
<el-table-column label="状态" width="170">
<template #default="{ row }">
<el-select
v-model="statusPick[row.id]"
size="small"
style="width:100%"
:placeholder="row.status"
@change="(v: string) => quickStatusChange(row, v)"
@visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.status }"
>
<div style="display:flex;align-items:center;gap:6px">
<el-select v-model="statusPick[row.id]" size="small" style="width:110px" :placeholder="row.status" @change="(v: string) => quickStatusChange(row, v)" @visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.status }">
<el-option v-for="s in planStatuses" :key="s" :label="s" :value="s" />
</el-select>
<span v-if="isOverdue(row)" class="overdue-badge">过期{{ overdueDays(row) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right" class-name="screenshot-hide">
<el-table-column label="操作" width="140" fixed="right" class-name="screenshot-hide">
<template #default="{ row }">
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
<template v-if="isOverdue(row)">
<el-button size="small" type="warning" @click="quickReschedule(row)">改期</el-button>
<el-button size="small" @click="quickCancel(row)">取消</el-button>
</template>
<el-button v-else size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="!workPlans.length" class="empty">暂无工作计划</div>
<div v-else-if="workPlans.length && !filteredPlans.length" class="empty">无匹配结果</div>
</el-card>
</div>
</div>
</div><!-- /shotRef -->
<!-- Dialog -->
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 工作计划'" width="500px">
<el-form label-position="top">
<el-form-item label="客户单位">
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
<el-form-item label="客户单位 *">
<el-select v-model="form.customer_id" filterable remote :remote-method="handleCustomerSearch" placeholder="搜索选择客户" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
<template #empty>
<div v-if="customerSearch" class="select-empty-create">
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到{{ customerSearch }}</p>
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">+ 新建客户{{ customerSearch }}</button>
</div>
</template>
</el-select>
</el-form-item>
<el-form-item v-if="auth.isDirector || auth.isLeader" label="客户经理">
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item label="计划拜访时间">
<el-date-picker v-model="form.plan_date" type="date" style="width:100%" />
<el-date-picker v-model="form.plan_date" type="date" value-format="YYYY-MM-DD" style="width:100%" />
</el-form-item>
<el-form-item label="工作计划">
<el-input v-model="form.plan_content" type="textarea" :rows="4" placeholder="请输入计划内容" />
@@ -255,10 +346,17 @@ async function quickStatusChange(row: any, newStatus: string) {
</el-form-item>
</el-form>
<EditLogPanel v-if="dialogMode === 'edit'" :edit-log="form.edit_log || []" />
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" @click="handleSave">保存</el-button></template>
</el-dialog>
<!-- Quick reschedule dialog -->
<el-dialog v-model="rescheduleVisible" title="修改计划日期" width="360px">
<el-form label-position="top">
<el-form-item label="新日期">
<el-date-picker v-model="rescheduleDate" type="date" value-format="YYYY-MM-DD" style="width:100%" />
</el-form-item>
</el-form>
<template #footer><el-button @click="rescheduleVisible = false">取消</el-button><el-button type="primary" @click="confirmReschedule">确认改期</el-button></template>
</el-dialog>
</div>
</template>
@@ -271,6 +369,12 @@ async function quickStatusChange(row: any, newStatus: string) {
.page-title { margin: 0; font-family: var(--font-heading); font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
.page-desc { margin: 4px 0 0; font-size: 13px; color: var(--c-text-muted); font-family: var(--font-body); }
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
/* ── Overdue Banner ── */
.overdue-banner { display: flex; align-items: center; gap: 8px; margin-top: 12px; padding: 10px 16px; background: rgba(184,71,46,0.06); border-left: 3px solid var(--vermilion); font-family: var(--font-body); font-size: 13px; color: var(--ink); }
.overdue-banner strong { color: var(--vermilion); }
/* ── Summary Bar ── */
.summary-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 14px; padding: 10px 14px; background: var(--c-bg-light, #faf9f6); border-radius: 6px; border: 1px solid var(--c-border, #e8e5df); }
.summary-label { font-size: 12px; color: var(--c-text-muted); font-family: var(--font-body); margin-right: 4px; }
.summary-chip { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; color: var(--mgr-chip-text); white-space: nowrap; cursor: pointer; transition: opacity 0.2s, transform 0.15s; }
@@ -285,4 +389,20 @@ async function quickStatusChange(row: any, newStatus: string) {
.filter-item { display: flex; align-items: center; gap: 8px; }
.filter-label { font-size: 13px; color: var(--warm-gray); font-family: var(--font-body); white-space: nowrap; }
.filter-count { font-size: 12px; color: var(--c-text-muted); font-family: var(--font-mono); margin-left: auto; }
/* ── Sections ── */
.plan-section { margin-bottom: 16px; border: 1px solid var(--warm-border); background: var(--surface); }
.section-header { display: flex; align-items: center; gap: 10px; padding: 14px 18px; cursor: pointer; user-select: none; border-bottom: 1px solid var(--warm-border); transition: background 0.2s; }
.section-header:hover { background: var(--c-bg-light, #faf9f6); }
.section-arrow { font-size: 10px; color: var(--gold); width: 14px; transition: transform 0.2s; }
.section-title { font-family: var(--font-heading); font-size: 15px; color: var(--ink); letter-spacing: 0.04em; }
.section-count { font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray); }
/* ── Overdue Badge ── */
.overdue-badge { display: inline-block; padding: 1px 7px; border-radius: 8px; background: var(--vermilion); color: #fff; font-family: var(--font-mono); font-size: 10px; white-space: nowrap; }
.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 6px 12px; color: var(--gold-dark); font-family: var(--font-body); font-size: 12px; cursor: pointer; transition: all 0.2s; }
.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); }
.select-empty-create { padding: 8px 12px; text-align: center; }
.required-star { color: var(--vermilion); font-weight: 700; }
</style>
+27 -3
View File
@@ -3,13 +3,16 @@ import { ref, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { todayStr } from '@/utils'
import { useAuthStore } from '@/stores/auth'
import api from '@/api/index'
import EditLogPanel from '@/components/EditLogPanel.vue'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const isEdit = computed(() => !!route.params.id)
const submitLoading = ref(false)
const allManagers = ref<any[]>([])
const categories = ['行政事务', '合同整理', '发票处理', '内部会议', '培训学习', '其他']
@@ -24,6 +27,7 @@ const form = ref({
category: '其他',
content: '',
time_range: '',
manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || ''),
})
function onTimeRangeChange(val: [string, string] | null) {
@@ -36,6 +40,9 @@ function parseTimeRange(str: string): [string, string] | null {
}
onMounted(async () => {
if (auth.isDirector || auth.isLeader) {
try { const res = await api.get('/users/', { params: { role: 'manager' } }); allManagers.value = res.data } catch (_) {}
}
if (isEdit.value) {
try {
const res = await api.get(`/daily-notes/${route.params.id}`)
@@ -45,6 +52,7 @@ onMounted(async () => {
category: n.category,
content: n.content || '',
time_range: n.time_range || '',
manager_id: n.manager_id || auth.userId || '',
}
timeRangeValue.value = parseTimeRange(n.time_range)
} catch (_) {}
@@ -52,6 +60,7 @@ onMounted(async () => {
})
async function handleSubmit() {
if (!form.value.note_date) { ElMessage.warning('请选择日期'); return }
if (!form.value.content.trim()) { ElMessage.warning('请输入纪要内容'); return }
submitLoading.value = true
try {
@@ -100,9 +109,23 @@ async function handleDelete() {
<el-form label-position="top" class="editorial-form">
<el-form-item>
<template #label>
<span class="form-label">日期</span>
<span class="form-label">日期 <span class="required-star">*</span></span>
</template>
<el-date-picker v-model="form.note_date" type="date" style="width:100%" />
<el-date-picker v-model="form.note_date" type="date" value-format="YYYY-MM-DD" style="width:100%" />
</el-form-item>
<el-form-item v-if="auth.isDirector || auth.isLeader">
<template #label><span class="form-label">客户经理</span></template>
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item v-if="auth.isDirector || auth.isLeader">
<template #label><span class="form-label">客户经理</span></template>
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item>
@@ -143,7 +166,7 @@ async function handleDelete() {
<el-form-item>
<template #label>
<span class="form-label">工作内容 <span class="form-label-required">*</span></span>
<span class="form-label">工作内容 <span class="required-star">*</span></span>
</template>
<el-input v-model="form.content" type="textarea" :rows="5" placeholder="今天做了什么..." />
</el-form-item>
@@ -273,4 +296,5 @@ async function handleDelete() {
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.required-star { color: var(--vermilion); font-weight: 700; }
</style>
+15 -33
View File
@@ -16,6 +16,7 @@ const previewImageUrl = ref('')
const previewDialogVisible = ref(false)
const photoUrls = ref<Record<string, string>>({})
const loading = ref(false)
const overduePlans = ref(0)
const todayTotal = computed(() => todayVisitCount.value + todayNoteCount.value)
@@ -57,7 +58,10 @@ async function loadToday() {
function previewPhoto(url: string) { previewImageUrl.value = url; previewDialogVisible.value = true }
onMounted(loadToday)
async function loadOverdue() {
try { const res = await api.get('/dashboard/stats'); overduePlans.value = res.data.overdue_plans || 0 } catch (_) {}
}
onMounted(() => { loadToday(); loadOverdue() })
</script>
<template>
@@ -81,6 +85,13 @@ onMounted(loadToday)
<div class="stat-ornament"></div>
</div>
<!-- Overdue Plans Alert -->
<div v-if="overduePlans" class="overdue-alert" @click="router.push('/m/plans')">
<svg 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="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<span>{{ overduePlans }} 条工作计划已过期点击处理</span>
<span class="overdue-arrow"></span>
</div>
<!-- Primary Quick Actions -->
<div class="quick-actions">
<button class="action-btn action-btn--primary" @click="router.push('/m/visit/new')">
@@ -99,38 +110,6 @@ onMounted(loadToday)
</button>
</div>
<!-- Secondary Quick Links -->
<div class="quick-links">
<button class="link-chip" @click="router.push('/m/work-plan/new')">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<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>
</svg>
工作计划
</button>
<button class="link-chip" @click="router.push('/m/mini-biz/new')">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<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>
</svg>
商机跟单
</button>
<button class="link-chip" @click="router.push('/m/key-visit/new')">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<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>
</svg>
要客拜访
</button>
<button class="link-chip" @click="router.push('/m/leaves')">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<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>
</svg>
请假
</button>
</div>
<!-- Section Divider -->
<div class="section-head" v-if="dailyNotes.length || visits.length">
<span class="section-title">今日记录</span>
@@ -303,6 +282,9 @@ onMounted(loadToday)
background: linear-gradient(135deg, transparent 50%, rgba(196,147,74,0.08) 50%);
pointer-events: none;
}
.overdue-arrow { margin-left: auto; color: var(--vermilion); }
.overdue-alert:hover { background: rgba(184,71,46,0.1); }
.overdue-alert { display: flex; align-items: center; gap: 8px; padding: 12px 16px; margin-bottom: 16px; background: rgba(184,71,46,0.06); border-left: 3px solid var(--vermilion); font-family: var(--font-body); font-size: 13px; color: var(--ink); cursor: pointer; transition: background 0.2s; }
/* ═══ Quick Actions ═══ */
.quick-actions {
+92 -10
View File
@@ -1,15 +1,20 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ref, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { keyVisitsApi } from '@/api/keyVisits'
import { customersApi } from '@/api/customers'
import { useAuthStore } from '@/stores/auth'
import api from '@/api/index'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const isEdit = computed(() => !!route.params.id)
const submitLoading = ref(false)
const customers = ref<any[]>([])
const allUsers = ref<any[]>([])
const allManagers = ref<any[]>([])
const urgencyLevels = ['一般', '重要', '紧急']
const urgencyColors: Record<string, string> = {
@@ -25,6 +30,7 @@ const form = ref({
planned_date: '',
planned_visitor: '',
visit_target: '',
manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || ''),
})
function onVisitorsChange(val: string[]) {
@@ -33,8 +39,23 @@ function onVisitorsChange(val: string[]) {
onMounted(async () => {
await Promise.all([loadCustomers(), loadUsers()])
if (auth.isDirector || auth.isLeader) {
api.get('/users/', { params: { role: 'manager' } }).then(r => { allManagers.value = r.data }).catch(() => {})
}
if (isEdit.value) {
try {
const res = await api.get(`/key-visits/${route.params.id}`)
const d = res.data
if (d.customer_id && d.customer_name && !customers.value.find((c: any) => c.id === d.customer_id)) {
customers.value.unshift({ id: d.customer_id, name: d.customer_name })
}
form.value = { customer_id: d.customer_id, urgency_level: d.urgency_level, description: d.description || '', progress_status: d.progress_status, planned_date: d.planned_date, planned_visitor: d.planned_visitor, visit_target: d.visit_target, manager_id: d.manager_id }
} catch (_) {}
}
})
const customerSearch = ref('')
async function loadCustomers(q?: string) {
try {
const params: any = { page_size: 100 }
@@ -44,6 +65,28 @@ async function loadCustomers(q?: string) {
} catch (_) {}
}
async function handleCustomerSearch(query: string) {
customerSearch.value = query
if (query) { await loadCustomers(query) }
else { await loadCustomers() }
}
async function handleQuickCreate() {
const name = customerSearch.value.trim()
if (!name) { ElMessage.warning('请输入单位名称'); return }
try {
const res = await api.post('/customers/quick-create', null, { params: { name } })
const newCust = res.data
customers.value.unshift(newCust)
form.value.customer_id = newCust.id
customerSearch.value = ''
ElMessage.success(`已创建客户:${name}`)
await loadCustomers()
} catch (e: any) {
ElMessage.error('创建失败: ' + (e.response?.data?.detail || e.message))
}
}
async function loadUsers() {
try {
const res = await api.get('/users/')
@@ -55,13 +98,27 @@ async function handleSubmit() {
if (!form.value.customer_id) { ElMessage.warning('请选择客户'); return }
submitLoading.value = true
try {
if (isEdit.value) {
await keyVisitsApi.update(route.params.id as string, form.value)
ElMessage.success('已更新')
} else {
await keyVisitsApi.create(form.value)
ElMessage.success('要客拜访计划已提交')
router.push('/m')
}
router.push('/m/key-visits')
} catch (e: any) {
ElMessage.error('提交失败')
ElMessage.error((isEdit.value ? '更新' : '提交') + '失败')
} finally { submitLoading.value = false }
}
async function handleDelete() {
try {
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
await keyVisitsApi.delete(route.params.id as string)
ElMessage.success('已删除')
router.push('/m/key-visits')
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
</script>
<template>
@@ -74,17 +131,31 @@ async function handleSubmit() {
</svg>
</button>
<div class="form-title-group">
<h2 class="form-title">添加要客拜访</h2>
<h2 class="form-title">{{ isEdit ? '编辑要客拜访' : '添加要客拜访' }}</h2>
<span class="form-subtitle">KEY VISIT PLAN</span>
</div>
<button v-if="isEdit" class="header-delete-btn" @click="handleDelete">删除</button>
<div class="form-rule"></div>
</header>
<el-form label-position="top" class="editorial-form">
<el-form-item>
<template #label><span class="form-label">客户单位</span></template>
<el-select v-model="form.customer_id" filterable remote :remote-method="loadCustomers" placeholder="搜索客户" style="width:100%">
<template #label><span class="form-label">客户单位 <span class="required-star">*</span></span></template>
<el-select v-model="form.customer_id" filterable remote :remote-method="handleCustomerSearch" placeholder="搜索客户" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
<template #empty>
<div v-if="customerSearch" class="select-empty-create">
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到{{ customerSearch }}</p>
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">新建客户{{ customerSearch }}</button>
</div>
</template>
</el-select>
</el-form-item>
<el-form-item v-if="auth.isDirector || auth.isLeader">
<template #label><span class="form-label">客户经理</span></template>
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
@@ -118,7 +189,7 @@ async function handleSubmit() {
<el-form-item>
<template #label><span class="form-label">计划拜访时间</span></template>
<el-input v-model="form.planned_date" placeholder="如: 2026-06-25" />
<el-date-picker v-model="form.planned_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width:100%" />
</el-form-item>
<el-form-item>
@@ -142,10 +213,12 @@ async function handleSubmit() {
<el-input v-model="form.visit_target" placeholder="如: 王局长、张处长" />
</el-form-item>
<div class="form-actions">
<button class="submit-btn" :disabled="submitLoading" @click="handleSubmit">
<span v-if="submitLoading" class="btn-loading"></span>
提交计划
{{ isEdit ? '保存修改' : '提交计划' }}
</button>
</div>
</el-form>
</div>
</template>
@@ -231,4 +304,13 @@ async function handleSubmit() {
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 8px 14px; color: var(--gold-dark); font-family: var(--font-body); font-size: 13px; cursor: pointer; transition: all 0.2s; letter-spacing: 0.03em; }
.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); }
.select-empty-create { padding: 8px 12px; text-align: center; }
.form-actions { display: flex; gap: 10px; margin-top: 24px; }
.delete-btn { padding: 16px 24px; background: var(--surface); color: var(--vermilion); border: 1px solid var(--vermilion); font-family: var(--font-heading); font-size: 16px; letter-spacing: 0.08em; cursor: pointer; transition: all 0.25s; }
.delete-btn:hover { background: var(--vermilion); color: #fff; }
.header-delete-btn { margin-left: auto; margin-top: 4px; padding: 6px 14px; background: none; color: var(--vermilion); border: 1px solid var(--vermilion); font-family: var(--font-body); font-size: 13px; cursor: pointer; transition: all 0.2s; white-space: nowrap; }
.header-delete-btn:hover { background: var(--vermilion); color: #fff; }
.required-star { color: var(--vermilion); font-weight: 700; }
</style>
+110
View File
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { keyVisitsApi } from '@/api/keyVisits'
const router = useRouter()
const loading = ref(false)
const searchText = ref('')
const page = ref(1)
const pageSize = ref(25)
const total = ref(0)
const items = ref<any[]>([])
onMounted(loadItems)
async function loadItems() {
loading.value = true
try {
const params: any = { page: page.value, page_size: pageSize.value }
if (searchText.value) params.search = searchText.value
const res = await keyVisitsApi.list(params)
const raw = Array.isArray(res.data) ? res.data : (res.data.items || [])
items.value = raw; total.value = res.data.total || raw.length
} catch (_) {}
finally { loading.value = false }
}
function onSearch() { page.value = 1; loadItems() }
const urgencyColors: Record<string, string> = { '一般': '#5B7FA5', '重要': '#C4934A', '紧急': '#B8472E' }
async function handleDelete(id: string) {
try {
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
await keyVisitsApi.delete(id)
ElMessage.success('已删除')
await loadItems()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
</script>
<template>
<div class="list-page">
<header class="form-editorial-header">
<button type="button" class="form-back-btn" @click="router.back()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
</button>
<div class="form-title-group"><h2 class="form-title">要客拜访</h2><span class="form-subtitle">KEY VISITS</span></div>
<div class="form-rule"></div>
</header>
<button class="new-btn" @click="router.push('/m/key-visit/new')">+ 新建要客</button>
<div style="margin-bottom:12px;display:flex;gap:8px">
<el-input v-model="searchText" placeholder="搜索..." clearable @keyup.enter="onSearch" @clear="onSearch" />
<el-button @click="onSearch">搜索</el-button>
</div>
<div class="cards">
<div v-if="items.length === 0 && !loading" class="empty-state"><div class="empty-glyph">—</div><p class="empty-text">暂不要客记录</p></div>
<article v-for="item in items" :key="item.id" class="card" @click="router.push(`/m/key-visit/${item.id}/edit`)">
<div class="card-accent" :style="{ background: urgencyColors[item.urgency_level] || '#5B7FA5' }"></div>
<div class="card-body">
<div class="card-header">
<span class="card-urgency" :style="{ color: urgencyColors[item.urgency_level] || '#5B7FA5' }">{{ item.urgency_level }}</span>
<span class="card-status">{{ item.progress_status }}</span>
</div>
<div class="card-content">{{ item.description }}</div>
<div class="card-footer">
<span class="card-customer">{{ item.customer_name }}</span>
</div>
</div>
</article>
<div v-if="total > pageSize" style="display:flex;justify-content:center;margin-top:14px">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" small @current-change="loadItems" />
</div>
</div>
</div>
</template>
<style scoped>
.list-page { max-width: 100%; padding-bottom: 20px; }
.form-editorial-header { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; flex-wrap: wrap; }
.form-back-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 8px 10px; cursor: pointer; color: var(--warm-gray); display: flex; align-items: center; transition: all var(--transition); flex-shrink: 0; margin-top: 2px; }
.form-back-btn:hover { color: var(--ink); border-color: var(--ink); }
.form-title-group { display: flex; flex-direction: column; gap: 0; flex: 1; }
.form-title { margin: 0; font-family: var(--font-heading); font-size: 24px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; line-height: 1.3; }
.form-subtitle { font-family: var(--font-mono); font-size: 9px; color: var(--gold); letter-spacing: 0.2em; }
.form-rule { width: 100%; height: 2px; background: var(--warm-border); margin-top: 6px; position: relative; }
.form-rule::after { content: ''; position: absolute; left: 0; top: 0; width: 32px; height: 2px; background: var(--gold); }
.new-btn { width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 14px; background: var(--ink); color: #fff; border: none; font-family: var(--font-heading); font-size: 15px; letter-spacing: 0.06em; cursor: pointer; transition: all 0.25s; margin-bottom: 18px; }
.new-btn:hover { background: var(--ink-light); }
.new-btn:active { transform: scale(0.98); }
.cards { display: flex; flex-direction: column; gap: 10px; }
.card { background: var(--surface); border: 1px solid var(--warm-border); display: flex; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; }
.card:active { transform: scale(0.99); }
.card:hover { box-shadow: 0 4px 12px rgba(28,55,56,0.06); }
.card-accent { width: 4px; flex-shrink: 0; }
.card-body { flex: 1; padding: 14px 16px; min-width: 0; }
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.card-urgency { font-family: var(--font-heading); font-size: 13px; }
.card-status { font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray); }
.card-content { font-family: var(--font-body); font-size: 14px; color: var(--ink); margin-bottom: 6px; line-height: 1.5; }
.card-footer { display: flex; align-items: center; justify-content: space-between; }
.card-customer { font-size: 12px; color: var(--warm-gray); }
.empty-state { text-align: center; padding: 48px 0; }
.empty-glyph { font-family: var(--font-heading); font-size: 40px; color: var(--gold); opacity: 0.4; margin-bottom: 8px; }
.empty-text { font-family: var(--font-body); font-size: 15px; color: var(--warm-gray); margin: 0; }
</style>
+8 -5
View File
@@ -70,7 +70,9 @@ async function handleSubmit() {
}
router.push('/m/leaves')
} catch (e: any) {
ElMessage.error((isEdit ? '更新' : '提交') + '失败: ' + (e.response?.data?.detail || e.message))
const detail = e.response?.data?.detail
const msg = Array.isArray(detail) ? detail.map((d: any) => d.msg).join('; ') : (detail || e.message)
ElMessage.error((isEdit ? '更新' : '提交') + '失败: ' + msg)
} finally { submitLoading.value = false }
}
@@ -123,13 +125,13 @@ async function handleDelete() {
</el-form-item>
<el-form-item>
<template #label><span class="form-label">开始日期</span></template>
<el-date-picker v-model="form.start_date" type="date" placeholder="选择开始日期" style="width:100%" />
<template #label><span class="form-label">开始日期 <span class="required-star">*</span></span></template>
<el-date-picker v-model="form.start_date" type="date" placeholder="选择开始日期" style="width:100%" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item>
<template #label><span class="form-label">结束日期</span></template>
<el-date-picker v-model="form.end_date" type="date" placeholder="选择结束日期" style="width:100%" />
<template #label><span class="form-label">结束日期 <span class="required-star">*</span></span></template>
<el-date-picker v-model="form.end_date" type="date" placeholder="选择结束日期" style="width:100%" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item>
@@ -198,4 +200,5 @@ async function handleDelete() {
.delete-btn:hover { background: var(--vermilion); color: #fff; }
.btn-loading { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.required-star { color: var(--vermilion); font-weight: 700; }
</style>
+111
View File
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { miniBusinessApi } from '@/api/miniBusiness'
const router = useRouter()
const loading = ref(false)
const searchText = ref('')
const page = ref(1)
const pageSize = ref(25)
const total = ref(0)
const items = ref<any[]>([])
const statusOrder: Record<string, number> = { '跟进中': 0, '已签约': 1, '已流失': 2 }
onMounted(loadItems)
async function loadItems() {
loading.value = true
try {
const params: any = { page: page.value, page_size: pageSize.value }
if (searchText.value) params.search = searchText.value
const res = await miniBusinessApi.list(params)
const raw = Array.isArray(res.data) ? res.data : (res.data.items || [])
items.value = raw; total.value = res.data.total || raw.length
} catch (_) {}
finally { loading.value = false }
}
function onSearch() { page.value = 1; loadItems() }
const statusColors: Record<string, string> = { '跟进中': '#C4934A', '已签约': '#4A6741', '已流失': '#B8472E' }
async function handleDelete(id: string) {
try {
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
await miniBusinessApi.delete(id)
ElMessage.success('已删除')
await loadItems()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
</script>
<template>
<div class="list-page">
<header class="form-editorial-header">
<button type="button" class="form-back-btn" @click="router.back()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
</button>
<div class="form-title-group"><h2 class="form-title">商机跟单</h2><span class="form-subtitle">MINI BUSINESS</span></div>
<div class="form-rule"></div>
</header>
<button class="new-btn" @click="router.push('/m/mini-biz/new')">+ 新建商机</button>
<div style="margin-bottom:12px;display:flex;gap:8px">
<el-input v-model="searchText" placeholder="搜索..." clearable @keyup.enter="onSearch" @clear="onSearch" />
<el-button @click="onSearch">搜索</el-button>
</div>
<div class="cards">
<div v-if="items.length === 0 && !loading" class="empty-state"><div class="empty-glyph">—</div><p class="empty-text">暂无商机记录</p></div>
<article v-for="item in items" :key="item.id" class="card" @click="router.push(`/m/mini-biz/${item.id}/edit`)">
<div class="card-accent" :style="{ background: statusColors[item.status] || '#C4934A' }"></div>
<div class="card-body">
<div class="card-header">
<span class="card-status" :style="{ color: statusColors[item.status] || '#C4934A' }">{{ item.status }}</span>
<span class="card-amount">{{ item.amount }}</span>
</div>
<div class="card-content">{{ item.product_type }}</div>
<div class="card-footer">
<span class="card-customer">{{ item.customer_name }}</span>
</div>
</div>
</article>
<div v-if="total > pageSize" style="display:flex;justify-content:center;margin-top:14px">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" small @current-change="loadItems" />
</div>
</div>
</div>
</template>
<style scoped>
.list-page { max-width: 100%; padding-bottom: 20px; }
.form-editorial-header { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; flex-wrap: wrap; }
.form-back-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 8px 10px; cursor: pointer; color: var(--warm-gray); display: flex; align-items: center; transition: all var(--transition); flex-shrink: 0; margin-top: 2px; }
.form-back-btn:hover { color: var(--ink); border-color: var(--ink); }
.form-title-group { display: flex; flex-direction: column; gap: 0; flex: 1; }
.form-title { margin: 0; font-family: var(--font-heading); font-size: 24px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; line-height: 1.3; }
.form-subtitle { font-family: var(--font-mono); font-size: 9px; color: var(--gold); letter-spacing: 0.2em; }
.form-rule { width: 100%; height: 2px; background: var(--warm-border); margin-top: 6px; position: relative; }
.form-rule::after { content: ''; position: absolute; left: 0; top: 0; width: 32px; height: 2px; background: var(--gold); }
.new-btn { width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 14px; background: var(--ink); color: #fff; border: none; font-family: var(--font-heading); font-size: 15px; letter-spacing: 0.06em; cursor: pointer; transition: all 0.25s; margin-bottom: 18px; }
.new-btn:hover { background: var(--ink-light); }
.new-btn:active { transform: scale(0.98); }
.cards { display: flex; flex-direction: column; gap: 10px; }
.card { background: var(--surface); border: 1px solid var(--warm-border); display: flex; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; }
.card:active { transform: scale(0.99); }
.card:hover { box-shadow: 0 4px 12px rgba(28,55,56,0.06); }
.card-accent { width: 4px; flex-shrink: 0; }
.card-body { flex: 1; padding: 14px 16px; min-width: 0; }
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.card-status { font-family: var(--font-heading); font-size: 13px; }
.card-amount { font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray); }
.card-content { font-family: var(--font-body); font-size: 14px; color: var(--ink); margin-bottom: 6px; line-height: 1.5; }
.card-footer { display: flex; align-items: center; justify-content: space-between; }
.card-customer { font-size: 12px; color: var(--warm-gray); }
.empty-state { text-align: center; padding: 48px 0; }
.empty-glyph { font-family: var(--font-heading); font-size: 40px; color: var(--gold); opacity: 0.4; margin-bottom: 8px; }
.empty-text { font-family: var(--font-body); font-size: 15px; color: var(--warm-gray); margin: 0; }
</style>
+200 -10
View File
@@ -1,13 +1,19 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ref, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { miniBusinessApi } from '@/api/miniBusiness'
import { customersApi } from '@/api/customers'
import { useAuthStore } from '@/stores/auth'
import api from '@/api/index'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const isEdit = computed(() => !!route.params.id)
const submitLoading = ref(false)
const customers = ref<any[]>([])
const allManagers = ref<any[]>([])
const form = ref({
customer_id: '',
@@ -16,9 +22,28 @@ const form = ref({
follow_up_detail: '',
status: '跟进中',
expected_revenue_date: '',
manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || ''),
})
onMounted(() => { loadCustomers() })
onMounted(async () => {
loadCustomers()
if (auth.isDirector || auth.isLeader) {
api.get('/users/', { params: { role: 'manager' } }).then(r => { allManagers.value = r.data }).catch(() => {})
}
if (isEdit.value) {
try {
const res = await api.get(`/mini-business/${route.params.id}`)
const d = res.data
if (d.customer_id && d.customer_name && !customers.value.find((c: any) => c.id === d.customer_id)) {
customers.value.unshift({ id: d.customer_id, name: d.customer_name })
}
form.value = { customer_id: d.customer_id, product_type: d.product_type, amount: d.amount, follow_up_detail: d.follow_up_detail || '', status: d.status, expected_revenue_date: d.expected_revenue_date, manager_id: d.manager_id }
} catch (_) {}
loadLogs()
}
})
const customerSearch = ref('')
async function loadCustomers(q?: string) {
try {
@@ -29,17 +54,93 @@ async function loadCustomers(q?: string) {
} catch (_) {}
}
async function handleCustomerSearch(query: string) {
customerSearch.value = query
if (query) { await loadCustomers(query) }
else { await loadCustomers() }
}
async function handleQuickCreate() {
const name = customerSearch.value.trim()
if (!name) { ElMessage.warning('请输入单位名称'); return }
try {
const res = await api.post('/customers/quick-create', null, { params: { name } })
const newCust = res.data
customers.value.unshift(newCust)
form.value.customer_id = newCust.id
customerSearch.value = ''
ElMessage.success(`已创建客户:${name}`)
await loadCustomers()
} catch (e: any) {
ElMessage.error('创建失败: ' + (e.response?.data?.detail || e.message))
}
}
async function handleSubmit() {
if (!form.value.customer_id) { ElMessage.warning('请选择客户'); return }
if (form.value.status === '已流失') form.value.expected_revenue_date = ''
submitLoading.value = true
try {
if (isEdit.value) {
await miniBusinessApi.update(route.params.id as string, form.value)
ElMessage.success('已更新')
} else {
await miniBusinessApi.create(form.value)
ElMessage.success('商机已提交')
router.push('/m')
}
router.push('/m/mini-biz')
} catch (e: any) {
ElMessage.error('提交失败')
ElMessage.error((isEdit.value ? '更新' : '提交') + '失败')
} finally { submitLoading.value = false }
}
async function handleDelete() {
try {
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
await miniBusinessApi.delete(route.params.id as string)
ElMessage.success('已删除')
router.push('/m/mini-biz')
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
// ── Follow-up logs ──
const logs = ref<any[]>([])
const logForm = ref({ log_date: '', method: '电话', content: '' })
const logMethods = ['电话', '微信', '上门', '邮件', '其他']
const logMethodIcons: Record<string, string> = { '电话': '📞', '微信': '💬', '上门': '🏢', '邮件': '📧', '其他': '📋' }
const logMethodColors: Record<string, string> = { '电话': '#5B7FA5', '微信': '#22c55e', '上门': '#4A6741', '邮件': '#C4934A', '其他': '#7B7568' }
const logLoading = ref(false)
const logSaving = ref(false)
async function loadLogs() {
logLoading.value = true
try {
const res = await miniBusinessApi.getLogs(route.params.id as string)
logs.value = res.data || []
} catch (_) { logs.value = [] }
finally { logLoading.value = false }
}
async function handleCreateLog() {
if (!logForm.value.content.trim()) { ElMessage.warning('请输入跟进内容'); return }
logSaving.value = true
try {
await miniBusinessApi.createLog(route.params.id as string, logForm.value)
ElMessage.success('跟进记录已添加')
logForm.value = { log_date: new Date().toISOString().slice(0, 10), method: '电话', content: '' }
await loadLogs()
} catch (e: any) { ElMessage.error('添加失败: ' + (e.response?.data?.detail || e.message)) }
finally { logSaving.value = false }
}
async function handleDeleteLog(logId: string) {
try {
await ElMessageBox.confirm('确定删除该跟进记录?', '确认', { type: 'warning' })
await miniBusinessApi.deleteLog(logId)
ElMessage.success('已删除')
await loadLogs()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
</script>
<template>
@@ -52,17 +153,31 @@ async function handleSubmit() {
</svg>
</button>
<div class="form-title-group">
<h2 class="form-title">添加商机跟单</h2>
<h2 class="form-title">{{ isEdit ? '编辑商机跟单' : '添加商机跟单' }}</h2>
<span class="form-subtitle">BUSINESS OPPORTUNITY</span>
</div>
<button v-if="isEdit" class="header-delete-btn" @click="handleDelete">删除</button>
<div class="form-rule"></div>
</header>
<el-form label-position="top" class="editorial-form">
<el-form-item>
<template #label><span class="form-label">客户单位</span></template>
<el-select v-model="form.customer_id" filterable remote :remote-method="loadCustomers" placeholder="搜索客户" style="width:100%">
<template #label><span class="form-label">客户单位 <span class="required-star">*</span></span></template>
<el-select v-model="form.customer_id" filterable remote :remote-method="handleCustomerSearch" placeholder="搜索客户" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
<template #empty>
<div v-if="customerSearch" class="select-empty-create">
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到{{ customerSearch }}</p>
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">新建客户{{ customerSearch }}</button>
</div>
</template>
</el-select>
</el-form-item>
<el-form-item v-if="auth.isDirector || auth.isLeader">
<template #label><span class="form-label">客户经理</span></template>
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
@@ -95,10 +210,43 @@ async function handleSubmit() {
<el-input v-model="form.expected_revenue_date" placeholder="如: 2026Q3" />
</el-form-item>
<div class="form-actions">
<button class="submit-btn" :disabled="submitLoading" @click="handleSubmit">
<span v-if="submitLoading" class="btn-loading"></span>
提交商机
{{ isEdit ? '保存修改' : '提交商机' }}
</button>
</div>
<!-- Follow-up Timeline (edit mode only) -->
<div v-if="isEdit" class="log-section">
<div class="section-head"><span class="section-title">跟进记录</span><span class="section-line"></span></div>
<div v-loading="logLoading" class="log-list">
<div v-if="logs.length === 0 && !logLoading" class="log-empty">暂无跟进记录</div>
<div v-for="l in logs" :key="l.id" class="log-item">
<span class="log-icon">{{ logMethodIcons[l.method] || '📋' }}</span>
<div class="log-body">
<div class="log-header">
<span class="log-date">{{ l.log_date }}</span>
<span class="log-chip" :style="{ background: logMethodColors[l.method] || '#7B7568', color: '#fff', padding: '1px 6px', fontSize: '11px' }">{{ l.method }}</span>
<span class="log-author">{{ l.created_by_name }}</span>
</div>
<div class="log-content">{{ l.content }}</div>
</div>
<button class="log-del" @click="handleDeleteLog(l.id)"></button>
</div>
</div>
<!-- Add Log -->
<div class="log-add">
<div class="log-add-row">
<el-date-picker v-model="logForm.log_date" type="date" value-format="YYYY-MM-DD" size="small" style="width:130px" />
<div class="log-chips">
<button v-for="m in logMethods" :key="m" type="button" class="log-chip-btn" :class="{ 'log-chip-btn--active': logForm.method === m }" :style="logForm.method === m ? { background: logMethodColors[m], borderColor: logMethodColors[m], color: '#fff' } : {}" @click="logForm.method = m">{{ m }}</button>
</div>
</div>
<el-input v-model="logForm.content" type="textarea" :rows="2" placeholder="输入跟进内容..." size="small" style="margin-top:6px" />
<button class="log-submit" :disabled="logSaving" @click="handleCreateLog">{{ logSaving ? '添加中...' : '添加跟进' }}</button>
</div>
</div>
</el-form>
</div>
</template>
@@ -167,4 +315,46 @@ async function handleSubmit() {
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 8px 14px; color: var(--gold-dark); font-family: var(--font-body); font-size: 13px; cursor: pointer; transition: all 0.2s; letter-spacing: 0.03em; }
.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); }
.select-empty-create { padding: 8px 12px; text-align: center; }
.form-actions { display: flex; gap: 10px; margin-top: 24px; }
.submit-btn { flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 16px; background: var(--ink); color: #fff; border: none; font-family: var(--font-heading); font-size: 16px; letter-spacing: 0.08em; cursor: pointer; transition: all 0.25s; }
.submit-btn:hover { background: var(--ink-light); }
.submit-btn:active { transform: scale(0.98); }
.submit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.delete-btn { padding: 16px 24px; background: var(--surface); color: var(--vermilion); border: 1px solid var(--vermilion); font-family: var(--font-heading); font-size: 16px; letter-spacing: 0.08em; cursor: pointer; transition: all 0.25s; }
.delete-btn:hover { background: var(--vermilion); color: #fff; }
.header-delete-btn { margin-left: auto; margin-top: 4px; padding: 6px 14px; background: none; color: var(--vermilion); border: 1px solid var(--vermilion); font-family: var(--font-body); font-size: 13px; cursor: pointer; transition: all 0.2s; white-space: nowrap; }
.header-delete-btn:hover { background: var(--vermilion); color: #fff; }
.btn-loading { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.required-star { color: var(--vermilion); font-weight: 700; }
/* ── Follow-up Log Section ── */
.log-section { margin-top: 20px; border-top: 1px solid var(--warm-border); padding-top: 16px; }
.section-head { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
.section-title { font-family: var(--font-heading); font-size: 18px; color: var(--ink); letter-spacing: 0.06em; white-space: nowrap; }
.section-line { flex: 1; height: 2px; background: var(--gold); }
.log-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px; max-height: 260px; overflow-y: auto; }
.log-empty { text-align: center; color: var(--warm-gray); font-size: 13px; padding: 16px 0; }
.log-item { display: flex; gap: 8px; align-items: flex-start; padding: 10px; background: var(--surface); border: 1px solid var(--warm-border); }
.log-icon { font-size: 16px; flex-shrink: 0; margin-top: 2px; }
.log-body { flex: 1; min-width: 0; }
.log-header { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
.log-date { font-family: var(--font-mono); font-size: 12px; color: var(--ink); }
.log-author { font-size: 11px; color: var(--warm-gray); margin-left: auto; }
.log-content { font-size: 13px; color: var(--c-text); line-height: 1.5; }
.log-del { flex-shrink: 0; background: none; border: none; color: var(--warm-gray); cursor: pointer; font-size: 14px; padding: 2px; }
.log-del:hover { color: var(--vermilion); }
.log-chip { border-radius: 2px; white-space: nowrap; }
.log-add { border-top: 1px solid var(--warm-border); padding-top: 12px; }
.log-add-row { display: flex; gap: 6px; align-items: center; }
.log-chips { display: flex; gap: 3px; }
.log-chip-btn { padding: 4px 8px; border: 1px solid var(--warm-border); background: var(--surface); font-size: 11px; cursor: pointer; transition: all 0.2s; }
.log-chip-btn:hover { border-color: var(--ink); }
.log-chip-btn--active { font-weight: 600; }
.log-submit { width: 100%; margin-top: 8px; padding: 10px; background: var(--ink); color: #fff; border: none; font-family: var(--font-heading); font-size: 14px; letter-spacing: 0.06em; cursor: pointer; }
.log-submit:hover { background: var(--ink-light); }
.log-submit:disabled { opacity: 0.6; cursor: not-allowed; }
</style>
+98
View File
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import api from '@/api/index'
const router = useRouter()
const loading = ref(false)
const searchText = ref('')
const page = ref(1)
const pageSize = ref(25)
const total = ref(0)
const items = ref<any[]>([])
onMounted(loadItems)
const catColors: Record<string, string> = {
'行政事务': '#1C3738', '合同整理': '#4A6741', '发票处理': '#C4934A',
'内部会议': '#B8472E', '培训学习': '#5B7FA5', '其他': '#7B7568',
}
async function loadItems() {
loading.value = true
try {
const params: any = { page: page.value, page_size: pageSize.value }
if (searchText.value) params.search = searchText.value
const res = await api.get('/daily-notes/', { params })
const raw = Array.isArray(res.data) ? res.data : (res.data.items || [])
items.value = raw; total.value = res.data.total || raw.length
} catch (_) {}
finally { loading.value = false }
}
function onSearch() { page.value = 1; loadItems() }
</script>
<template>
<div class="list-page">
<header class="form-editorial-header">
<button type="button" class="form-back-btn" @click="router.back()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
</button>
<div class="form-title-group"><h2 class="form-title">历史纪要</h2><span class="form-subtitle">NOTE HISTORY</span></div>
<div class="form-rule"></div>
</header>
<div style="margin-bottom:12px;display:flex;gap:8px">
<el-input v-model="searchText" placeholder="搜索..." clearable @keyup.enter="onSearch" @clear="onSearch" />
<el-button @click="onSearch">搜索</el-button>
</div>
<div v-loading="loading" class="cards">
<div v-if="items.length === 0 && !loading" class="empty-state"><div class="empty-glyph">—</div><p class="empty-text">暂无纪要记录</p></div>
<article v-for="item in items" :key="item.id" class="card" @click="router.push(`/m/note/${item.id}/edit`)">
<div class="card-accent" :style="{ background: catColors[item.category] || '#7B7568' }"></div>
<div class="card-body">
<div class="card-header">
<span class="card-cat" :style="{ color: catColors[item.category] || '#7B7568' }">{{ item.category }}</span>
<span class="card-date">{{ item.note_date }}</span>
</div>
<div class="card-content">{{ item.content || '暂无内容' }}</div>
<div class="card-footer">
<span v-if="item.time_range" class="card-time">{{ item.time_range }}</span>
</div>
</div>
</article>
<div v-if="total > pageSize" style="display:flex;justify-content:center;margin-top:14px">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" small @current-change="loadItems" />
</div>
</div>
</div>
</template>
<style scoped>
.list-page { max-width: 100%; padding-bottom: 20px; }
.form-editorial-header { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; flex-wrap: wrap; }
.form-back-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 8px 10px; cursor: pointer; color: var(--warm-gray); display: flex; align-items: center; transition: all var(--transition); flex-shrink: 0; margin-top: 2px; }
.form-back-btn:hover { color: var(--ink); border-color: var(--ink); }
.form-title-group { display: flex; flex-direction: column; gap: 0; flex: 1; }
.form-title { margin: 0; font-family: var(--font-heading); font-size: 24px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; line-height: 1.3; }
.form-subtitle { font-family: var(--font-mono); font-size: 9px; color: var(--gold); letter-spacing: 0.2em; }
.form-rule { width: 100%; height: 2px; background: var(--warm-border); margin-top: 6px; position: relative; }
.form-rule::after { content: ''; position: absolute; left: 0; top: 0; width: 32px; height: 2px; background: var(--gold); }
.cards { display: flex; flex-direction: column; gap: 10px; }
.card { background: var(--surface); border: 1px solid var(--warm-border); display: flex; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; }
.card:active { transform: scale(0.99); }
.card:hover { box-shadow: 0 4px 12px rgba(28,55,56,0.06); }
.card-accent { width: 4px; flex-shrink: 0; }
.card-body { flex: 1; padding: 14px 16px; min-width: 0; }
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.card-cat { font-family: var(--font-heading); font-size: 13px; }
.card-date { font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray); }
.card-content { font-family: var(--font-body); font-size: 14px; color: var(--ink); margin-bottom: 6px; line-height: 1.5; }
.card-footer { display: flex; align-items: center; }
.card-time { font-size: 12px; color: var(--warm-gray); }
.empty-state { text-align: center; padding: 48px 0; }
.empty-glyph { font-family: var(--font-heading); font-size: 40px; color: var(--gold); opacity: 0.4; margin-bottom: 8px; }
.empty-text { font-family: var(--font-body); font-size: 15px; color: var(--warm-gray); margin: 0; }
</style>
+207
View File
@@ -0,0 +1,207 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api/index'
const router = useRouter()
const loading = ref(false)
const searchText = ref('')
const page = ref(1)
const pageSize = ref(25)
const total = ref(0)
const items = ref<any[]>([])
const today = new Date()
const weekStart = (d: Date) => { const s = new Date(d); s.setDate(s.getDate() - s.getDay() + 1); s.setHours(0,0,0,0); return s }
const weekEnd = (d: Date) => { const e = weekStart(d); e.setDate(e.getDate() + 6); return e }
const thisWeekStart = weekStart(today)
const nextWeekStart = new Date(thisWeekStart); nextWeekStart.setDate(nextWeekStart.getDate() + 7)
const thisMonthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0)
const todayStr = computed(() => today.toISOString().slice(0, 10))
function classifyPlan(planDate: string, status: string): string {
if (status !== '计划中') return 'archived'
const d = new Date(planDate)
if (d < thisWeekStart) return 'thisWeek'
if (d <= weekEnd(today)) return 'thisWeek'
if (d <= weekEnd(nextWeekStart)) return 'nextWeek'
if (d <= thisMonthEnd) return 'thisMonth'
return 'later'
}
function isOverdue(row: any): boolean {
return row.status === '计划中' && row.plan_date < todayStr.value
}
function overdueDays(row: any): number {
return Math.floor((today.getTime() - new Date(row.plan_date).getTime()) / 86400000)
}
const sectionLabels: Record<string, string> = {
thisWeek: '本周', nextWeek: '下周', thisMonth: '本月', later: '更远', archived: '已归档',
}
const groupedPlans = computed(() => {
const groups: Record<string, any[]> = { thisWeek: [], nextWeek: [], thisMonth: [], later: [], archived: [] }
for (const p of items.value) {
const key = classifyPlan(p.plan_date, p.status)
if (!groups[key]) groups[key] = []
groups[key].push(p)
}
for (const g of Object.values(groups)) g.sort((a: any, b: any) => a.plan_date.localeCompare(b.plan_date))
return Object.entries(groups).filter(([_, list]) => list.length > 0).map(([key, list]) => ({ key, label: sectionLabels[key] || key, list }))
})
const statusColors: Record<string, string> = {
'计划中': '#4A6741', '已完成': '#5B7FA5', '已取消': '#909399',
}
onMounted(loadItems)
async function loadItems() {
loading.value = true
try {
const params: any = { page: page.value, page_size: pageSize.value }
if (searchText.value) params.search = searchText.value
const res = await api.get('/work-plans/', { params })
const raw = Array.isArray(res.data) ? res.data : (res.data.items || [])
items.value = raw; total.value = res.data.total || raw.length
} catch (_) {}
finally { loading.value = false }
}
function onSearch() { page.value = 1; loadItems() }
async function handleDelete(id: string) {
try { await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' }); await api.delete(`/work-plans/${id}`); ElMessage.success('已删除'); await loadItems() }
catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
async function quickCancel(item: any) {
try {
await ElMessageBox.confirm('确定将此计划标记为「已取消」?', '确认取消', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '返回' })
await api.put(`/work-plans/${item.id}`, { status: '已取消' }); ElMessage.success('已取消'); await loadItems()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('操作失败') }
}
const rescheduleVisible = ref(false)
const rescheduleItem = ref<any>(null)
const rescheduleDate = ref('')
function openReschedule(item: any) { rescheduleItem.value = item; rescheduleDate.value = item.plan_date; rescheduleVisible.value = true }
async function confirmReschedule() {
if (!rescheduleDate.value || !rescheduleItem.value) return
try { await api.put(`/work-plans/${rescheduleItem.value.id}`, { plan_date: rescheduleDate.value }); ElMessage.success('日期已更新'); rescheduleVisible.value = false; await loadItems() }
catch (e: any) { ElMessage.error('更新失败') }
}
</script>
<template>
<div class="list-page">
<header class="form-editorial-header">
<button type="button" class="form-back-btn" @click="router.back()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
</button>
<div class="form-title-group"><h2 class="form-title">工作计划</h2><span class="form-subtitle">WORK PLANS</span></div>
<div class="form-rule"></div>
</header>
<button class="new-btn" @click="router.push('/m/work-plan/new')">+ 新建计划</button>
<div class="auto-hint">💡 拜访客户后计划会自动完成无需手动改状态</div>
<div style="margin-bottom:12px;display:flex;gap:8px">
<el-input v-model="searchText" placeholder="搜索..." clearable @keyup.enter="onSearch" @clear="onSearch" />
<el-button @click="onSearch">搜索</el-button>
</div>
<div v-if="items.length === 0 && !loading" class="empty-state"><div class="empty-glyph">—</div><p class="empty-text">暂无工作计划</p></div>
<div v-for="group in groupedPlans" :key="group.key" class="section">
<div class="section-header">
<span class="section-title">{{ group.label }}</span>
<span class="section-count">{{ group.list.length }} </span>
</div>
<div class="cards">
<article v-for="item in group.list" :key="item.id" class="card" :class="{ 'card--overdue': isOverdue(item) }" @click="router.push(`/m/work-plan/${item.id}/edit`)">
<div class="card-accent" :style="{ background: isOverdue(item) ? '#B8472E' : statusColors[item.status] || '#909399' }"></div>
<div class="card-body">
<div class="card-header">
<span class="card-status" :style="{ color: statusColors[item.status] || '#909399' }">{{ item.status }}</span>
<span v-if="isOverdue(item)" class="overdue-badge">过期{{ overdueDays(item) }}</span>
<span class="card-date">{{ item.plan_date }}</span>
</div>
<div class="card-content">{{ item.plan_content }}</div>
<div class="card-footer">
<span class="card-customer">{{ item.customer_name }}</span>
<div v-if="isOverdue(item)" style="display:flex;gap:6px">
<button class="quick-reschedule-btn" @click.stop="openReschedule(item)">改期</button>
<button class="quick-cancel-btn" @click.stop="quickCancel(item)">取消</button>
</div>
</div>
</div>
</article>
</div>
</div>
<div v-if="total > pageSize" style="display:flex;justify-content:center;margin-top:14px">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" small @current-change="loadItems" />
</div>
<!-- Quick reschedule dialog -->
<el-dialog v-model="rescheduleVisible" title="修改计划日期" width="90%">
<el-form label-position="top">
<el-form-item label="新日期">
<el-date-picker v-model="rescheduleDate" type="date" value-format="YYYY-MM-DD" style="width:100%" />
</el-form-item>
</el-form>
<template #footer><el-button @click="rescheduleVisible = false">取消</el-button><el-button type="primary" @click="confirmReschedule">确认改期</el-button></template>
</el-dialog>
</div>
</template>
<style scoped>
.list-page { max-width: 100%; padding-bottom: 20px; }
.form-editorial-header { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; flex-wrap: wrap; }
.form-back-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 8px 10px; cursor: pointer; color: var(--warm-gray); display: flex; align-items: center; transition: all var(--transition); flex-shrink: 0; margin-top: 2px; }
.form-back-btn:hover { color: var(--ink); border-color: var(--ink); }
.form-title-group { display: flex; flex-direction: column; gap: 0; flex: 1; }
.form-title { margin: 0; font-family: var(--font-heading); font-size: 24px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; line-height: 1.3; }
.form-subtitle { font-family: var(--font-mono); font-size: 9px; color: var(--gold); letter-spacing: 0.2em; }
.form-rule { width: 100%; height: 2px; background: var(--warm-border); margin-top: 6px; position: relative; }
.form-rule::after { content: ''; position: absolute; left: 0; top: 0; width: 32px; height: 2px; background: var(--gold); }
.new-btn { width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 14px; background: var(--ink); color: #fff; border: none; font-family: var(--font-heading); font-size: 15px; letter-spacing: 0.06em; cursor: pointer; transition: all 0.25s; margin-bottom: 18px; }
.new-btn:hover { background: var(--ink-light); }
.new-btn:active { transform: scale(0.98); }
.auto-hint { padding: 10px 14px; margin-bottom: 14px; background: rgba(74,103,65,0.06); border-left: 3px solid #4A6741; font-family: var(--font-body); font-size: 12px; color: var(--ink); line-height: 1.5; }
/* ── Sections ── */
.section { margin-bottom: 20px; }
.section-header { display: flex; align-items: baseline; gap: 8px; padding: 8px 0 10px; border-bottom: 1px solid var(--warm-border); margin-bottom: 10px; }
.section-title { font-family: var(--font-heading); font-size: 14px; color: var(--ink); letter-spacing: 0.04em; }
.section-count { font-family: var(--font-mono); font-size: 11px; color: var(--warm-gray); }
.cards { display: flex; flex-direction: column; gap: 10px; }
.card { background: var(--surface); border: 1px solid var(--warm-border); display: flex; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; }
.card--overdue { border-left: 3px solid var(--vermilion); background: rgba(184,71,46,0.03); }
.card:active { transform: scale(0.99); }
.card:hover { box-shadow: 0 4px 12px rgba(28,55,56,0.06); }
.card-accent { width: 4px; flex-shrink: 0; }
.card-body { flex: 1; padding: 14px 16px; min-width: 0; }
.card-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.card-status { font-family: var(--font-heading); font-size: 13px; }
.card-date { font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray); margin-left: auto; }
.card-content { font-family: var(--font-body); font-size: 14px; color: var(--ink); margin-bottom: 6px; line-height: 1.5; }
.card-footer { display: flex; align-items: center; justify-content: space-between; }
.card-customer { font-size: 12px; color: var(--warm-gray); }
/* ── Overdue ── */
.overdue-badge { padding: 1px 6px; border-radius: 6px; background: var(--vermilion); color: #fff; font-family: var(--font-mono); font-size: 10px; white-space: nowrap; }
.quick-reschedule-btn { padding: 4px 10px; background: none; border: 1px solid var(--gold); color: var(--gold-dark); font-family: var(--font-body); font-size: 11px; cursor: pointer; transition: all 0.2s; }
.quick-reschedule-btn:hover { background: var(--gold); color: #fff; }
.quick-cancel-btn { padding: 4px 10px; background: none; border: 1px solid var(--vermilion); color: var(--vermilion); font-family: var(--font-body); font-size: 11px; cursor: pointer; transition: all 0.2s; }
.quick-cancel-btn:hover { background: var(--vermilion); color: #fff; }
.empty-state { text-align: center; padding: 48px 0; }
.empty-glyph { font-family: var(--font-heading); font-size: 40px; color: var(--gold); opacity: 0.4; margin-bottom: 8px; }
.empty-text { font-family: var(--font-body); font-size: 15px; color: var(--warm-gray); margin: 0; }
</style>
+71 -9
View File
@@ -30,6 +30,7 @@ const form = ref({
customer_demand: '',
companions: [] as string[],
photos: [] as string[],
manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || ''),
})
const timeRangeValue = ref<any>(null)
@@ -41,6 +42,8 @@ const previewDialogVisible = ref(false)
const previewImageUrl = ref('')
const uploading = ref(false)
const customerSearch = ref('')
const isCompanionRecord = ref(false)
const companionOf = ref('')
onMounted(async () => {
await loadCustomers()
@@ -52,10 +55,13 @@ onMounted(async () => {
try {
const res = await visitsApi.get(route.params.id as string)
const v = res.data
// select UUID
// / select UUID
if (v.customer_id && v.customer_name && !customers.value.find(c => c.id === v.customer_id)) {
customers.value.unshift({ id: v.customer_id, name: v.customer_name })
}
if (v.manager_id && v.manager_name && !managers.value.find((m: any) => m.id === v.manager_id)) {
managers.value.unshift({ id: v.manager_id, name: v.manager_name })
}
form.value = {
customer_id: v.customer_id,
visit_date: v.visit_date,
@@ -67,6 +73,7 @@ onMounted(async () => {
customer_demand: v.customer_demand || '',
companions: (v.companions || []).map(String),
photos: v.photos || [],
manager_id: v.manager_id || auth.userId || '',
}
uploadedPhotos.value = v.photos || []
// Parse time range back into picker
@@ -74,6 +81,16 @@ onMounted(async () => {
const parts = v.time_range.split('-')
timeRangeValue.value = [parts[0], parts[1]]
}
// Check if this is a companion record
if (v.visit_group_id) {
isCompanionRecord.value = true
// The companions field on a companion record contains the primary creator
const creatorId = (v.companions || [])[0]
if (creatorId) {
const creator = managers.value.find((m: any) => m.id === String(creatorId))
companionOf.value = creator?.name || ''
}
}
for (const key of (v.photos || [])) {
try {
const urlRes = await uploadApi.getDownloadUrl(key)
@@ -115,6 +132,17 @@ async function handleQuickCreate() {
}
}
async function onCustomerChange(customerId: string) {
if (!customerId) return
try {
const res = await api.get(`/customers/${customerId}/overdue-plans`)
if (res.data?.plan?.plan_content) {
form.value.communication_content = res.data.plan.plan_content
form.value.customer_demand = ''
}
} catch (_) {}
}
async function handlePhotoUpload(event: Event) {
const target = event.target as HTMLInputElement
if (!target.files?.length) return
@@ -128,8 +156,7 @@ async function handlePhotoUpload(event: Event) {
try {
// Compress before upload to reduce storage & transfer
const compressed = await compressImage(file, { maxPixels: 1920, quality: 0.8 })
const res = await uploadApi.getPresignedUrl(compressed.name, compressed.type || 'image/jpeg')
await uploadApi.uploadFile(res.data.upload_url, compressed)
const res = await uploadApi.uploadImage(compressed)
uploadedPhotos.value.push(res.data.object_key)
form.value.photos = [...uploadedPhotos.value]
} catch (e: any) {
@@ -221,10 +248,20 @@ async function handleDelete() {
<div class="form-rule"></div>
</header>
<!-- Companion record notification -->
<div v-if="isCompanionRecord" class="companion-notice">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;color:var(--gold)">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
<span>此记录为协同 <strong>{{ companionOf }}</strong> 拜访的副本你可以补充或修改内容不会影响主记录</span>
</div>
<el-form label-position="top" class="editorial-form">
<el-form-item>
<template #label>
<span class="form-label">客户单位</span>
<span class="form-label">客户单位 <span class="required-star">*</span></span>
</template>
<el-select
v-model="form.customer_id"
@@ -233,11 +270,13 @@ async function handleDelete() {
:remote-method="handleCustomerSearch"
placeholder="搜索/选择客户"
style="width:100%"
@change="onCustomerChange"
>
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
<div v-if="customerSearch && !customers.some(c => c.name === customerSearch)" style="margin-top:8px">
<button type="button" class="quick-create-btn" @click="handleQuickCreate">
<template #empty>
<div v-if="customerSearch" class="select-empty-create">
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到{{ customerSearch }}</p>
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
@@ -245,13 +284,22 @@ async function handleDelete() {
新建客户{{ customerSearch }}
</button>
</div>
</template>
</el-select>
</el-form-item>
<el-form-item v-if="auth.isDirector || auth.isLeader">
<template #label><span class="form-label">客户经理</span></template>
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item>
<template #label>
<span class="form-label">拜访日期</span>
</template>
<el-date-picker v-model="form.visit_date" type="date" style="width:100%" />
<el-date-picker v-model="form.visit_date" type="date" value-format="YYYY-MM-DD" style="width:100%" />
</el-form-item>
<el-form-item>
@@ -289,7 +337,7 @@ async function handleDelete() {
<el-form-item>
<template #label>
<span class="form-label">相关人员 <span class="form-label-hint">可输入外部人员</span></span>
<span class="form-label">相关人员 <span class="form-label-hint">同访人周报中也会显示此记录</span></span>
</template>
<el-select v-model="form.companions" multiple filterable allow-create default-first-option placeholder="选择或输入姓名(可多选)" style="width:100%">
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" :disabled="m.id === auth.userId" />
@@ -494,6 +542,7 @@ async function handleDelete() {
color: var(--vermilion);
background: rgba(184,71,46,0.03);
}
.select-empty-create { padding: 8px 12px; text-align: center; }
/* ═══ Photo Area ═══ */
.photo-area {
@@ -646,4 +695,17 @@ async function handleDelete() {
.method--电话.method-chip--active { background: #5B7FA5; border-color: #5B7FA5; color: #fff; }
.method--微信.method-chip--active { background: #22c55e; border-color: #22c55e; color: #fff; }
.method--出差.method-chip--active { background: #C4934A; border-color: #C4934A; color: #fff; }
.required-star { color: var(--vermilion); font-weight: 700; }
/* ═══ Companion Notice ═══ */
.companion-notice {
display: flex; align-items: flex-start; gap: 10px;
padding: 14px 16px; margin-bottom: 20px;
background: rgba(196,147,74,0.06);
border-left: 3px solid var(--gold);
font-family: var(--font-body);
font-size: 13px; color: var(--ink);
line-height: 1.6; letter-spacing: 0.03em;
}
.companion-notice strong { color: var(--gold-dark); }
</style>
+96
View File
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import api from '@/api/index'
const router = useRouter()
const loading = ref(false)
const searchText = ref('')
const page = ref(1)
const pageSize = ref(25)
const total = ref(0)
const items = ref<any[]>([])
onMounted(loadItems)
async function loadItems() {
loading.value = true
try {
const params: any = { page: page.value, page_size: pageSize.value }
if (searchText.value) params.search = searchText.value
const res = await api.get('/visits/', { params })
const raw = Array.isArray(res.data) ? res.data : (res.data.items || [])
items.value = raw; total.value = res.data.total || raw.length
} catch (_) {}
finally { loading.value = false }
}
function onSearch() { page.value = 1; loadItems() }
const methodColors: Record<string, string> = { '上门': '#4A6741', '电话': '#5B7FA5', '微信': '#22c55e', '出差': '#C4934A' }
</script>
<template>
<div class="list-page">
<header class="form-editorial-header">
<button type="button" class="form-back-btn" @click="router.back()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
</button>
<div class="form-title-group"><h2 class="form-title">历史拜访</h2><span class="form-subtitle">VISIT HISTORY</span></div>
<div class="form-rule"></div>
</header>
<div style="margin-bottom:12px;display:flex;gap:8px">
<el-input v-model="searchText" placeholder="搜索..." clearable @keyup.enter="onSearch" @clear="onSearch" />
<el-button @click="onSearch">搜索</el-button>
</div>
<div v-loading="loading" class="cards">
<div v-if="items.length === 0 && !loading" class="empty-state"><div class="empty-glyph">—</div><p class="empty-text">暂无拜访记录</p></div>
<article v-for="item in items" :key="item.id" class="card" @click="router.push(`/m/visit/${item.id}/edit`)">
<div class="card-accent" :style="{ background: methodColors[item.visit_method] || '#7B7568' }"></div>
<div class="card-body">
<div class="card-header">
<span class="card-method" :style="{ color: methodColors[item.visit_method] || '#7B7568' }">{{ item.visit_method }}</span>
<span class="card-date">{{ item.visit_date }}</span>
</div>
<div class="card-content">{{ item.communication_content || '暂无沟通内容' }}</div>
<div class="card-footer">
<span class="card-customer">{{ item.customer_name }}</span>
</div>
</div>
</article>
<div v-if="total > pageSize" style="display:flex;justify-content:center;margin-top:14px">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" small @current-change="loadItems" />
</div>
</div>
</div>
</template>
<style scoped>
.list-page { max-width: 100%; padding-bottom: 20px; }
.form-editorial-header { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; flex-wrap: wrap; }
.form-back-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 8px 10px; cursor: pointer; color: var(--warm-gray); display: flex; align-items: center; transition: all var(--transition); flex-shrink: 0; margin-top: 2px; }
.form-back-btn:hover { color: var(--ink); border-color: var(--ink); }
.form-title-group { display: flex; flex-direction: column; gap: 0; flex: 1; }
.form-title { margin: 0; font-family: var(--font-heading); font-size: 24px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; line-height: 1.3; }
.form-subtitle { font-family: var(--font-mono); font-size: 9px; color: var(--gold); letter-spacing: 0.2em; }
.form-rule { width: 100%; height: 2px; background: var(--warm-border); margin-top: 6px; position: relative; }
.form-rule::after { content: ''; position: absolute; left: 0; top: 0; width: 32px; height: 2px; background: var(--gold); }
.cards { display: flex; flex-direction: column; gap: 10px; }
.card { background: var(--surface); border: 1px solid var(--warm-border); display: flex; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; }
.card:active { transform: scale(0.99); }
.card:hover { box-shadow: 0 4px 12px rgba(28,55,56,0.06); }
.card-accent { width: 4px; flex-shrink: 0; }
.card-body { flex: 1; padding: 14px 16px; min-width: 0; }
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.card-method { font-family: var(--font-heading); font-size: 13px; }
.card-date { font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray); }
.card-content { font-family: var(--font-body); font-size: 14px; color: var(--ink); margin-bottom: 6px; line-height: 1.5; }
.card-footer { display: flex; align-items: center; justify-content: space-between; }
.card-customer { font-size: 12px; color: var(--warm-gray); }
.empty-state { text-align: center; padding: 48px 0; }
.empty-glyph { font-family: var(--font-heading); font-size: 40px; color: var(--gold); opacity: 0.4; margin-bottom: 8px; }
.empty-text { font-family: var(--font-body); font-size: 15px; color: var(--warm-gray); margin: 0; }
</style>

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