Compare commits

..

72 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

.env.example:
- 新增 WECOM_API_BASE 说明

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

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

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

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

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

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

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

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

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

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

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

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

已验证: WECOM_TOKEN 获取成功

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 12:03:00 +08:00
94 changed files with 11237 additions and 465 deletions
+7
View File
@@ -195,8 +195,15 @@ Thumbs.db
# Env
.env
.env.*
!.env.example
# Deployment docs (contain credentials, not for git)
*部署交接文档.md
*部署文档.md
# Claude
CLAUDE.md
.claude/
.cc-connect/
+268
View File
@@ -0,0 +1,268 @@
# CLAUDE.md — 企迹 (qiji) 政企周报管理系统
## 项目概述
面向中国电信政企客户经理团队,替代 Excel 的周报管理系统。覆盖每日拜访记录、下周工作计划、小微业务商机跟单、要客拜访计划四大模块。支持 PC 端(支局长/分管领导汇总查看)和移动端(客户经理外勤填报 + 企微入口)。
## 技术栈
| 层 | 技术 |
|---|------|
| 后端 | FastAPI + SQLAlchemy 2.0 (async) + Alembic |
| 数据库 | PostgreSQL (已有,复用) |
| 文件存储 | MinIO (已有,复用) |
| 认证 | Casdoor OIDC (已有) + 企业微信 OAuth |
| 前端 | Vue 3 + Vite + TypeScript + Element Plus |
| 状态管理 | Pinia |
| 定时任务 | APScheduler |
## 项目结构
```
qiji/
├── docker-compose.yml # PostgreSQL + MinIO + backend
├── 政企周报管理系统-设计文档.md # 完整设计文档
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI 入口 + lifespan + 路由挂载
│ │ ├── config.py # pydantic-settings 配置
│ │ ├── database.py # async engine + session
│ │ ├── models/ # 8 张表 (见下方数据模型)
│ │ ├── schemas/ # Pydantic 请求/响应 schema
│ │ ├── api/ # REST 路由 (12 个模块)
│ │ ├── services/ # 业务逻辑层
│ │ ├── middleware/auth.py # JWT 鉴权 + 角色权限
│ │ └── utils/security.py # JWT 签发/验证
│ ├── alembic/ # 数据库迁移
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── router/index.ts # 路由 (mobile + desktop 两套布局)
│ │ ├── stores/auth.ts # Pinia 认证 store
│ │ ├── api/ # 9 个 Axios API 模块
│ │ ├── views/
│ │ │ ├── mobile/ # 移动端页面 (Home, VisitForm, ...)
│ │ │ └── desktop/ # PC 端页面 (Dashboard, WeeklyReport, ...)
│ │ └── components/ # MobileLayout + DesktopLayout
│ ├── nginx.conf # 生产 nginx 配置
│ └── Dockerfile
```
## 数据模型 (10 张 PostgreSQL 表)
| 表 | 核心字段 |
|----|---------|
| `users` | id, casdoor_id, name, role(manager/director/leader), wecom_userid |
| `customers` | id, name, industry, address, in_use_services, monthly_fee, remarks, created_by |
| `customer_contacts` | id, customer_id, name, phone, role_desc |
| `customer_assignments` | id, customer_id, manager_id, role(primary/assistant), assigned_by |
| `visits` | id, customer_id, visit_date, visit_method, time_range, visitor_name, visitor_phone, communication_content, customer_demand, companions(UUID[]), photos(TEXT[]), manager_id, edit_log(JSONB) |
| `daily_notes` | id, manager_id, note_date, category, content, time_range, edit_log(JSONB) |
| `work_plans` | id, customer_id, plan_content, plan_date, manager_id, status, edit_log(JSONB) |
| `mini_business` | id, customer_id, product_type, amount, follow_up_detail, status, manager_id, expected_revenue_date, edit_log(JSONB) |
| `key_visits` | id, customer_id, urgency_level, description, progress_status, planned_date, planned_visitor, visit_target, manager_id, edit_log(JSONB) |
| `leaves` | id, manager_id, leave_type(年假/事假/病假/调休/其他), start_date, end_date, reason, submitted_by, created_at, updated_at |
## 当前进度
### 已完成 ✅
- [x] **Phase 1: 项目骨架** — 完整目录结构,FastAPI + Vue 3 均可启动/构建
- [x] **Phase 2: 数据模型** — 8 个 SQLAlchemy 模型 + Alembic 迁移配置
- [x] **Phase 3: 认证系统** — JWT + Casdoor OIDC + 企微静默登录 + 角色中间件
- [x] **Phase 4: 客户档案** — CRUD 全链路 API + PC 端管理页 + 归属分配/批量转移
- [x] **Phase 5: 拜访记录** — CRUD API + MinIO 预签名直传 + 移动端填报表单 + 移动端首页
- [x] **Phase 6: 计划/商机/要客** — 三个模块的 CRUD API + 移动端表单
- [x] **Phase 7: 仪表盘与汇总** — 四卡统计 + 填报进度 + 四 Tab 周报 + 按人/客户筛选 + 时间轴
- [x] **Phase 8: Excel 导入导出** — 导出四 sheet xlsx + 导入预览→确认→去重
- [x] **Phase 9: 企业微信** — 静默登录 + 催办/公告推送 + 每日 18:00 定时检查
- [x] **Phase 10: 部署** — Dockerfile × 2 + nginx.conf + docker-compose.yml
- [x] **Casdoor 对接** — 已配置 Casdoor OIDC,前后端 Client ID 统一,登录/回调正常
- [x] **MinIO 对接** — 已配置预签名上传/下载,bucket 就绪
- [x] **PostgreSQL 对接** — 8 张表自动创建,数据持久化正常
- [x] **用户管理** — 支局长可在 PC 端「用户管理」页面修改任意用户角色
- [x] **客户档案增强** — 详情含客户经理、点击名称查看、备注字段、收支费用(金额+单位)、联系人管理(新增/删除)、多选批量转移
- [x] **客户导入导出** — 导出含全部字段 + 模板下载 + Excel 批量导入(自动去重匹配经理)
- [x] **路由修复** — Hash 改 HTML5 History 模式,Casdoor 回调正常
- [x] **网络部署** — 后端监听 0.0.0.0:8002,前端监听 0.0.0.0:5173,防火墙已开放
- [x] **今日纪要** — 9 张表之 daily_notes,6 种分类 + 彩色标签 + 时间选择器
- [x] **客户经理 PC 端工作台** — 「我的数据」五模块 CRUD + 状态快速切换
- [x] **拜访人字段** — visits 表新增 visitor_name/visitor_phone
- [x] **时间选择器** — 全部时间范围改为 el-time-picker (is-range)
- [x] **时区统一** — 后端 today_cst() 统一用 Asia/Shanghai,前端本地格式
- [x] **UI 设计系统** — CSS 变量 + 编辑风 (ink/gold/paper) + 侧边栏折叠 + SVG 图标
- [x] **分页筛选** — 客户列表分页(25/50/100) + 行业/业务/经理/联系人/地址搜索
- [x] **权限细化** — 分管领导只读客户档案,经理可编辑自己客户但不可改经理/删除
- [x] **拜访方式/紧急度** — 统一 chip 按钮风格,各色区分
- [x] **计划拜访人多选** — 支持从系统人员多选 + 手动输入
- [x] **旧周报导入模板** — 四 Sheet Excel 模板下载 + 示例数据
- [x] **数据库迁移** — lifespan 自动 ALTER TABLE 加列 (remarks/visitor_name/visitor_phone)
- [x] **历史周报** — 周选择器翻看往周数据,历史归档只读,导出支持历史周
- [x] **图片预览** — 全屏大图查看(移动端+PC端统一),点击遮罩关闭
- [x] **PC端照片管理** — 编辑时可上传新照片、删除已有照片
- [x] **客户导入更新** — 重名客户自动更新信息而非跳过,显示逐条操作明细
- [x] **导入跳过原因** — 旧周报导入+客户导入均显示跳过/更新明细
- [x] **用户去重合并** — 4组重复用户合并,数据完整迁移
- [x] **填报进度权限** — 经理只看到自己,支局长/领导看全员
- [x] **客户经理列** — 暖灰色hash标签,每人唯一颜色
- [x] **列表序号** — 客户管理+用户管理添加序号列
- [x] **客户导入修复** — 修复 import_customers 500 错误(errors 变量未初始化)
- [x] **图片预览增强** — 适应页面/缩放/拖拽平移 + 底部工具栏(PC端+移动端统一 ImagePreview 组件)
- [x] **信息架构重组** — 周报精简为拜访+纪要两个Tab;工作计划/商机/要客独立为侧边栏「工作」分组下的独立页面;仪表盘四卡可点击跳转
- [x] **侧边栏分组** — 侧边栏分为「汇总」「工作」「管理」三个分组,JetBrains Mono 标签
- [x] **变更追踪 (edit_log)** — 5 张表新增 JSONB edit_log 列;POST 初始化创建记录;PUT 自动对比新旧值追加 diff;编辑弹窗底部展示变更时间轴(EditLogPanel.vue);表格被修改过的记录显示 🕐 时钟图标
- [x] **导入模板增强** — 客户导入失败原因明细显示
- [x] **操作精简** — 工作计划/商机/要客页面移除冗余"编辑"按钮(点击客户名已可编辑)
- [x] **客户亮灯表** — 四色覆盖矩阵(●实心圆已拜访/◐半圆临期/○空心圆未拜访/◌虚线未分配),按客户经理折叠卡片流,覆盖率进度条,红灯客户连续未拜访月份追踪,未分配客户专区,支持历史月份翻看。权限:经理只看自己,支局长/领导看全员
- [x] **AI 周报摘要** — 接入 DeepSeek V4 (OpenAI 兼容 `/v1/chat/completions`),一键生成拜访概况/客户需求/覆盖分析/下周建议四段式周报摘要,注入亮灯表覆盖数据增强分析,支局长/分管领导专用,Markdown 渲染展示,一键复制(HTTP 环境自动降级 textarea),支持配置任意 OpenAI 兼容模型
- [x] **亮灯表→周报联动** — 点击亮灯表客户卡片自动跳转周报并按客户筛选,周报筛选栏新增客户下拉
- [x] **复制功能修复** — HTTP 内网环境下 Clipboard API 不可用,降级为 textarea + execCommand
- [x] **工作页客户经理列+汇总栏** — 工作计划/商机跟单/要客拜访三个页面新增"客户经理"列(哈希色标签)+ 页面顶部汇总栏(按经理分组统计条数)
- [x] **云端部署** — FRP 隧道 `qiji-backend` (本机 8002→远程 18061),前端 Docker 容器 `qiji-frontend` (远程 18063)OpenResty 反代 `qj.dhdx.fun`,生产模式运行(去掉 --reload),SECRET_KEY 已加固,CORS 已更新
- [x] **请假管理**`leaves` 表 + 完整 CRUD API + 填报进度免考核(on_leave 第四态) + 催办豁免 + AI 摘要注入 + PC 端独立管理页 + 移动端列表/表单
### 待完善
- [ ] 实际对接企业微信(需填写 WECOM_CORP_ID/AGENT_ID/SECRET 等)
- [ ] 前端移动端/桌面端自动适配(目前路由分两套,user-agent 判断待完善)
- [ ] 客户查重前端集成(API 已有 `/customers/check-duplicate/{name}`
- [ ] 缩略图生成策略(MinIO 端配置)
- [ ] 企微聊天侧边栏(设计文档标注为"后续扩展,首期不做")
## 验证状态
| 检查项 | 结果 |
|--------|------|
| `import app.main` | ✅ 成功 |
| `vite build` | ✅ 构建成功,输出到 `dist/` |
| 后端运行 | ✅ `http://10.10.10.14:8002`,生产模式 `http://localhost:8002/health` |
| 前端运行 | ✅ `http://10.10.10.14:5173` (dev)`https://qj.dhdx.fun` (生产) |
| PostgreSQL | ✅ `10.10.10.14:5432/qiji` |
| MinIO | ✅ `10.10.10.13:17051`bucket `qiji-photos` |
| Casdoor | ✅ `10.10.10.14:18000`,登录/回调正常 |
| FRP 隧道 | ✅ `qiji-backend` 运行中 (8002→18061) |
| 前端容器 | ✅ `qiji-frontend` 运行中 (127.0.0.1:18063→80,仅本地) |
| 企微推送 | ⚠️ 待填写有效 Token/AESKey 后测试 |
| `docker-compose up` | ⚠️ 需要本地 PostgreSQL + MinIO 实例 |
## 部署架构
```
用户 → https://qj.dhdx.fun → 远程服务器(175.178.19.237)
OpenResty (80/443)
┌──────────────┴──────────────┐
▼ ▼
前端容器 (127.0.0.1) FRP 隧道
qiji-frontend:18063→80 18061
│ │
│ frps→frpc
│ │
│ 本机后端 :8002
```
| 资源 | 位置 | 端口 |
|------|------|------|
| 后端 (uvicorn) | 本机 | 8002 |
| FRP 隧道 `qiji-backend` | 本机→远程 | 8002→18061 |
| 前端 Docker 容器 | 远程 | 127.0.0.1:18063→80(仅本地,不暴露公网) |
| OpenResty 反代 | 远程 | `/`→18063, `/api/`→18061 |
| frpc 配置 | `/opt/1panel/apps/frpc/frpc/data/frpc.toml` | Docker 容器管理 |
| 远程 SSH | `ssh -p 7072 root@175.178.19.237` | |
| 前端构建部署 | `docker build -t qiji-frontend:latest .``docker save``scp -P 7072``ssh docker load` | 本机构建,推送远程 |
## 启动命令
### 第一步:配置环境变量
```bash
# 后端
cd backend
cp .env.example .env
# 编辑 .env,填入 Casdoor/PostgreSQL/MinIO/企微 的真实连接信息
# 前端(Casdoor 登录用)
cd frontend
cat > .env << 'EOF'
VITE_CASDOOR_ENDPOINT=http://your-casdoor:18000
VITE_CASDOOR_CLIENT_ID=your-client-id
EOF
```
配置项说明:
| 配置块 | 说明 | 必须? |
|--------|------|--------|
| `DATABASE_URL` | PostgreSQL 连接串,已有基础设施填真实地址 | ✅ 必须 |
| `CASDOOR_*` | Casdoor OIDC 认证服务的地址、Client ID/Secret | ✅ 登录必须 |
| `MINIO_*` | MinIO 对象存储地址和密钥 | ✅ 照片上传必须 |
| `WECOM_*` | 企微自建应用的 Corp ID / Agent ID / Secret | ⚠️ 企微功能需要 |
| `AI_API_URL` | OpenAI 兼容 LLM API 地址 (如 `https://api.openai.com/v1/chat/completions`) | ⚠️ AI 摘要需要 |
| `AI_API_KEY` | LLM API 密钥 | ⚠️ AI 摘要需要 |
| `AI_MODEL` | 模型名称 (默认 `gpt-4o`,也支持 `deepseek-chat` 等) | ⚠️ AI 摘要需要 |
| `SECRET_KEY` | JWT 签发密钥,生产环境务必修改 | ✅ 必须 |
| `CORS_ORIGINS` | 前端地址白名单 | ✅ 必须 |
| `VITE_CASDOOR_*` | 前端登录跳转 Casdoor 所需,必须与后端一致 | ✅ 必须 |
### 第二步:启动 PostgreSQL + MinIO
如果已有可用的 PostgreSQL 和 MinIO 实例,跳过此步。否则用 docker-compose 启动:
```bash
# 在项目根目录执行
docker-compose up -d postgres minio
```
### 第三步:启动后端
```bash
cd backend
uvicorn app.main:app --reload --port 8002 --host 0.0.0.0
# 首次启动会自动创建数据库表(lifespan 中执行 Base.metadata.create_all
# API 文档 → http://localhost:8002/docs
```
### 第四步:启动前端
```bash
cd frontend
npm run dev -- --host 0.0.0.0
# 页面 → http://localhost:5173
# vite proxy 自动将 /api/* 请求转发到 localhost:8002
```
### 一键启动(生产模式)
```bash
# 项目根目录
docker-compose up -d
# 后端 :8000 + PostgreSQL :5432 + MinIO :9000 + :9001(console)
# 前端需单独部署到 nginx,见 frontend/Dockerfile + nginx.conf
```
## 关键设计决策
1. **双布局**: `/m/*` 移动端(底部 TabBar),`/*` PC 端(侧边栏菜单)
2. **照片上传**: 前端从 `/api/upload/presigned-url` 拿 PUT URL → 直传 MinIO → 表单只传 object key
3. **同访人员**: 选同访人后,后端自动创建一条内容为空的状态副本
4. **权限**: JWT payload 含 `role``RoleChecker` 做接口级鉴权,前端 Pinia store 做 UI 级控制
5. **企微静默登录**: wecom code → userid → 查 users 表 → 找到签 JWT / 未找到引导 Casdoor 绑定
6. **前后端分离开发**: `vite.config.ts` proxy 转发 `/api``localhost:8002`
7. **填报进度判断**: 今日有拜访记录 OR 今日有纪要 → 已填报(双维度考核)
8. **拜访方式颜色**: 上门(墨绿)/电话(灰蓝)/微信(翠绿)/出差(金色),卡片+表单统一
9. **纪要分类**: 行政事务/合同整理/发票处理/内部会议/培训学习/其他,单字徽章+彩色标签
10. **时间范围**: 所有表单统一用 el-time-picker is-range,存储为 HH:mm-HH:mm 格式
11. **列表操作**: 点击客户名→详情弹窗含编辑/删除按钮,移除表格操作栏
12. **侧边栏**: ink 深色渐变 + SVG 内联图标 + 可折叠(64px) + 金色装饰线
13. **客户经理列**: hash 配色标签,每人唯一颜色(8 色暖灰调)
14. **侧边栏分组**: 汇总(仪表盘/周报)、工作(工作计划/商机跟单/要客拜访)、管理(客户/用户/系统设置)三层分组,JetBrains Mono 标签
15. **周报精简**: 只保留每日拜访记录+今日纪要两个 Tab,工作计划/商机/要客独立为侧边栏项目
16. **独立工作页**: 工作计划/商机/要客各自拥有独立页面 + CRUD 弹窗 + 状态快速切换
17. **变更追踪 (edit_log)**: 5 张业务表均含 JSONB edit_log 列;POST 自动插入创建记录;PUT 自动 diff 新旧值追加变更条目(编辑人+时间+字段级 diff+原因);编辑弹窗底部折叠时间轴面板;表格 🕐 图标标记被修改过的记录
18. **图片预览**: ImagePreview.vue 统一组件,支持适应页面/缩放(0.25x~5x)/拖拽平移/滚轮缩放/键盘快捷键,PC+移动端一致体验
19. **仪表盘跳转**: 四张统计卡片可点击跳转到对应的周报/工作计划/商机/要客页面
20. **客户亮灯表**: 几何圆形图标(●实心圆已拜访/◐半圆临期/○空心圆未拜访/◌虚线未分配),按客户经理折叠卡片流(220px),覆盖率进度条,红灯卡片呼吸动画,未分配客户专区(虚线边框),默认折叠→点击展开明细,支持历史月份翻看。权限:`get_light_board(user_id, role)` — director/leader 看全员+未分配客户,manager 只看自己
21. **AI 周报摘要**: 接入 DeepSeek V4 (模型 `deepseek-v4-flash`,端点 `/v1/chat/completions`OpenAI 兼容协议)。`build_summary_prompt()` 注入拜访总览+各经理明细+客户需求汇总+纪要分类+亮灯表覆盖数据,生成四段式 Markdown。WeeklyReport.vue 集成:按钮(闪电图标)→loading 脉冲→渲染→一键复制(安全上下文检测降级)。权限:director/leader 可见按钮
22. **云端部署**: FRP 隧道 (8002→18061) + Docker 前端容器 (18063) + OpenResty 反代。frpc 由 1Panel Docker 容器管理,配置在 `/opt/1panel/apps/frpc/frpc/data/frpc.toml`。前端生产构建用 `.env.production` 指向 Casdoor 公网地址。nginx.conf 只保留 SPA fallbackAPI 路由由 OpenResty 边缘处理。SSH 端口 7072,前端容器仅绑定 127.0.0.1:18063(不直接暴露公网)
23. **请假管理**: `leaves` 表存储请假记录,按天粒度(start_date~end_date),五种类型(年假/事假/病假/调休/其他),提交即生效无审批。权限:经理看自己、支局长/领导看全员。填报进度新增 `on_leave` 第四态(蓝「假」印章),催办自动过滤请假人员,AI 摘要注入本周请假数据。PC 端侧边栏「工作」分组下独立页面,移动端首页快捷入口 + 列表页 + 表单页
+10
View File
@@ -36,6 +36,16 @@ WECOM_AGENT_ID=your-agent-id
WECOM_SECRET=your-app-secret
WECOM_TOKEN=your-token
WECOM_ENCODING_AES_KEY=your-encoding-aes-key
# 企微 API 代理(解决 IP 白名单问题),默认直连 https://qyapi.weixin.qq.com
WECOM_API_BASE=https://qyapi.weixin.qq.com
# ── AI / LLM (OpenAI 兼容接口,用于周报摘要) ──
# 支持 OpenAI / DeepSeek / 通义千问 / 本地 Ollama 等
# DeepSeek: AI_API_URL=https://api.deepseek.com/v1/chat/completions AI_MODEL=deepseek-v4-flash
AI_API_URL=https://api.openai.com/v1/chat/completions
AI_API_KEY=sk-your-api-key
AI_MODEL=gpt-4o
AI_MAX_TOKENS=2000
# ── CORS (前端地址) ──
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
+74
View File
@@ -0,0 +1,74 @@
"""AI summary endpoints — LLM-powered weekly report narrative with caching."""
import uuid
from datetime import date
from typing import Optional
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.auth import get_current_user
from app.services.ai_summary import generate_summary, get_cached_summary, delete_cached_summary
router = APIRouter(prefix="/ai", tags=["AI Summary"])
def _check_role(role: str):
if role not in ("director", "leader"):
raise HTTPException(status_code=403, detail="仅限支局长和分管领导使用")
def _parse_ref(reference_date: Optional[str]) -> date | None:
return date.fromisoformat(reference_date) if reference_date else None
@router.get("/summary")
async def get_summary(
reference_date: Optional[str] = Query(None),
period: str = Query("week", regex="^(week|month)$"),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get cached AI summary for this week. Returns null if none exists."""
_check_role(current_user["role"])
cached = await get_cached_summary(
db, uuid.UUID(current_user["user_id"]), _parse_ref(reference_date), period,
)
return cached or {"summary": None}
@router.post("/summary")
async def create_summary(
reference_date: Optional[str] = Query(None),
period: str = Query("week", regex="^(week|month)$"),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Generate (or regenerate) AI summary. Saves to DB automatically."""
_check_role(current_user["role"])
try:
return await generate_summary(
db=db,
user_id=uuid.UUID(current_user["user_id"]),
role=current_user["role"],
reference_date=_parse_ref(reference_date),
period=period,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"AI 摘要生成失败:{str(e)}")
@router.delete("/summary")
async def delete_summary(
reference_date: Optional[str] = Query(None),
period: str = Query("week", regex="^(week|month)$"),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Delete cached summary so it can be regenerated fresh."""
_check_role(current_user["role"])
deleted = await delete_cached_summary(
db, uuid.UUID(current_user["user_id"]), _parse_ref(reference_date), period,
)
return {"deleted": deleted}
+203 -18
View File
@@ -1,17 +1,24 @@
import io
import uuid as uuid_mod
from uuid import uuid4
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_
from sqlalchemy import select, or_, update
from sqlalchemy.orm import selectinload
from pydantic import BaseModel
import openpyxl
from app.database import get_db
from app.middleware.auth import get_current_user, require_director, require_any_role
from app.models.customer import Customer
from app.models.customer_contact import CustomerContact
from app.models.customer_assignment import CustomerAssignment
from app.models.visit import Visit
from app.models.work_plan import WorkPlan
from app.models.mini_business import MiniBusiness
from app.models.key_visit import KeyVisit
from app.models.daily_note import DailyNote
from app.models.user import User
from app.schemas.customer import (
CustomerCreate, CustomerUpdate, CustomerOut, CustomerListOut, CustomerListResponse,
@@ -44,23 +51,14 @@ async def list_customers(
service: Optional[str] = Query(None),
manager_id: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(25, ge=1, le=100),
page_size: int = Query(100, ge=1, le=1000),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""List customers with filters and pagination. Managers only see their assigned."""
"""List customers with filters and pagination. All roles see all customers."""
from sqlalchemy import func
base_query = select(Customer)
if current_user["role"] == "manager":
base_query = base_query.where(or_(
Customer.id.in_(
select(CustomerAssignment.customer_id).where(
CustomerAssignment.manager_id == current_user["user_id"]
)
),
Customer.created_by == current_user["user_id"],
))
if industry:
base_query = base_query.where(Customer.industry.ilike(f"%{industry}%"))
@@ -425,6 +423,28 @@ async def update_customer(
update_data = data.model_dump(exclude_unset=True)
assignee_id = update_data.pop("assignee_id", None) # Handle separately
# ── Name collision detection (for merge) ──
new_name = update_data.get("name")
if new_name and new_name.strip() != customer.name:
existing = await db.execute(
select(Customer).where(
Customer.name == new_name.strip(),
Customer.id != customer.id,
)
)
target = existing.scalar_one_or_none()
if target:
# Return 409 with merge preview — frontend should show merge dialog
preview = await _build_merge_preview(db, str(customer.id), str(target.id))
raise HTTPException(status_code=409, detail={
"message": "名称冲突 — 同名客户已存在",
"source_id": str(customer.id),
"source_name": customer.name,
"target_id": str(target.id),
"target_name": target.name,
"preview": preview,
})
for key, value in update_data.items():
setattr(customer, key, value)
@@ -432,17 +452,16 @@ async def update_customer(
if assignee_id:
if current_user["role"] != "director":
raise HTTPException(status_code=403, detail="Only director can change manager assignment")
import uuid as uuid_mod
assign_result = await db.execute(
select(CustomerAssignment).where(
CustomerAssignment.customer_id == customer.id,
CustomerAssignment.role == "primary",
)
)
existing = assign_result.scalar_one_or_none()
if existing:
existing.manager_id = assignee_id
existing.assigned_by = uuid_mod.UUID(current_user["user_id"])
existing_a = assign_result.scalar_one_or_none()
if existing_a:
existing_a.manager_id = assignee_id
existing_a.assigned_by = uuid_mod.UUID(current_user["user_id"])
else:
db.add(CustomerAssignment(
customer_id=customer.id, manager_id=assignee_id,
@@ -472,7 +491,173 @@ async def delete_customer(
return {"detail": "deleted"}
# ── Contacts ──
# ── Customer Merge ──
class MergePreviewOut(BaseModel):
source_id: str
source_name: str
target_id: str
target_name: str
visits: int = 0
work_plans: int = 0
mini_business: int = 0
key_visits: int = 0
contacts: int = 0
assignments: int = 0
note: str = ""
class MergeRequest(BaseModel):
target_id: str
async def _build_merge_preview(db: AsyncSession, source_id: str, target_id: str) -> dict:
"""Count all records that would be migrated from source to target."""
sid = uuid_mod.UUID(source_id)
tid = uuid_mod.UUID(target_id)
visits = (await db.execute(
select(select(Visit).where(Visit.customer_id == sid).subquery()).with_only_columns(
__import__('sqlalchemy').func.count()
)
)).scalar() or 0
# Simpler: count directly
from sqlalchemy import func as sa_func
visits = (await db.execute(select(sa_func.count()).select_from(Visit).where(Visit.customer_id == sid))).scalar() or 0
plans = (await db.execute(select(sa_func.count()).select_from(WorkPlan).where(WorkPlan.customer_id == sid))).scalar() or 0
mini = (await db.execute(select(sa_func.count()).select_from(MiniBusiness).where(MiniBusiness.customer_id == sid))).scalar() or 0
kv = (await db.execute(select(sa_func.count()).select_from(KeyVisit).where(KeyVisit.customer_id == sid))).scalar() or 0
contacts = (await db.execute(select(sa_func.count()).select_from(CustomerContact).where(CustomerContact.customer_id == sid))).scalar() or 0
assignments = (await db.execute(select(sa_func.count()).select_from(CustomerAssignment).where(CustomerAssignment.customer_id == sid))).scalar() or 0
# Check for assignment conflicts
target_assigns = await db.execute(
select(CustomerAssignment.manager_id).where(CustomerAssignment.customer_id == tid)
)
target_managers = {str(row[0]) for row in target_assigns.all()}
source_assigns = await db.execute(
select(CustomerAssignment).where(CustomerAssignment.customer_id == sid)
)
conflict_note = ""
for sa in source_assigns.scalars().all():
if str(sa.manager_id) in target_managers:
conflict_note = f"目标客户已有相同经理的分配,将去重"
return {
"source_id": source_id,
"source_name": "",
"target_id": target_id,
"target_name": "",
"visits": visits,
"work_plans": plans,
"mini_business": mini,
"key_visits": kv,
"contacts": contacts,
"assignments": assignments,
"note": conflict_note,
}
@router.get("/{customer_id}/merge-preview")
async def get_merge_preview(
customer_id: str,
target_id: str = Query(...),
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Preview merge: show what data would be moved from source to target."""
source = (await db.execute(select(Customer).where(Customer.id == customer_id))).scalar_one_or_none()
target = (await db.execute(select(Customer).where(Customer.id == target_id))).scalar_one_or_none()
if not source or not target:
raise HTTPException(status_code=404, detail="客户不存在")
if customer_id == target_id:
raise HTTPException(status_code=400, detail="不能合并到自身")
preview = await _build_merge_preview(db, customer_id, target_id)
preview["source_name"] = source.name
preview["target_name"] = target.name
return preview
@router.post("/{customer_id}/merge")
async def execute_merge(
customer_id: str,
body: MergeRequest,
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Merge source customer into target. All related data is migrated, source is deleted."""
source_id = uuid_mod.UUID(customer_id)
target_id = uuid_mod.UUID(body.target_id)
if customer_id == body.target_id:
raise HTTPException(status_code=400, detail="不能合并到自身")
source = (await db.execute(select(Customer).where(Customer.id == source_id))).scalar_one_or_none()
target = (await db.execute(select(Customer).where(Customer.id == target_id))).scalar_one_or_none()
if not source or not target:
raise HTTPException(status_code=404, detail="客户不存在")
# Build preview for response
preview = await _build_merge_preview(db, customer_id, body.target_id)
# ── Transaction: migrate all FK references ──
for model, fk_col in [
(Visit, Visit.customer_id),
(WorkPlan, WorkPlan.customer_id),
(MiniBusiness, MiniBusiness.customer_id),
(KeyVisit, KeyVisit.customer_id),
]:
await db.execute(
update(model).where(fk_col == source_id).values(customer_id=target_id)
)
# Contacts: migrate, skip duplicates
source_contacts = (await db.execute(
select(CustomerContact).where(CustomerContact.customer_id == source_id)
)).scalars().all()
target_contacts = (await db.execute(
select(CustomerContact).where(CustomerContact.customer_id == target_id)
)).scalars().all()
existing_contact_keys = {(c.name, c.phone) for c in target_contacts}
for c in source_contacts:
if (c.name, c.phone) in existing_contact_keys:
await db.delete(c) # Skip duplicate
else:
c.customer_id = target_id
db.add(c)
# Assignments: migrate, skip same (manager_id, role) pairs
target_assigns = (await db.execute(
select(CustomerAssignment).where(CustomerAssignment.customer_id == target_id)
)).scalars().all()
existing_assign_keys = {(str(a.manager_id), a.role) for a in target_assigns}
source_assigns = (await db.execute(
select(CustomerAssignment).where(CustomerAssignment.customer_id == source_id)
)).scalars().all()
for a in source_assigns:
if (str(a.manager_id), a.role) in existing_assign_keys:
await db.delete(a) # Skip duplicate
else:
a.customer_id = target_id
db.add(a)
# Update target's last_visit_date to the max of both
if source.last_visit_date:
if not target.last_visit_date or source.last_visit_date > target.last_visit_date:
target.last_visit_date = source.last_visit_date
target.last_visit_manager_id = source.last_visit_manager_id
# Delete source customer
source_name = source.name
target_name = target.name
await db.delete(source)
await db.commit()
preview["result"] = f"已将「{source_name}」合并到「{target_name}"
return preview
@router.post("/{customer_id}/contacts", response_model=ContactOut)
async def add_contact(
+12
View File
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.auth import get_current_user
from app.services.dashboard import get_dashboard_stats, get_reporting_progress, get_weekly_report
from app.services.light_board import get_light_board
router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
@@ -51,3 +52,14 @@ async def weekly_report(
filter_customer_id=uuid.UUID(customer_id) if customer_id else None,
reference_date=ref,
)
@router.get("/light-board")
async def light_board(
reference_date: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get customer visit coverage matrix. Managers see only themselves."""
ref = date.fromisoformat(reference_date) if reference_date else None
return await get_light_board(db, ref, uuid.UUID(current_user["user_id"]), current_user["role"])
+10 -3
View File
@@ -12,7 +12,7 @@ router = APIRouter(prefix="/import", tags=["Import"])
@router.get("/template")
async def download_weekly_report_template():
"""Download a 4-sheet weekly report import template."""
"""Download a 5-sheet weekly report import template."""
from openpyxl import Workbook
from openpyxl.styles import Font
@@ -22,9 +22,9 @@ async def download_weekly_report_template():
# Sheet 1: 每日拜访记录
ws1 = wb.active
ws1.title = "每日拜访记录"
ws1.append(["客户单位", "拜访日期", "拜访方式", "时间范围", "拜访人姓名", "拜访人电话", "沟通内容", "客户需求", "同访人员", "客户经理"])
ws1.append(["客户单位", "拜访日期", "拜访方式", "时间范围", "拜访人姓名", "拜访人电话", "沟通内容", "客户需求", "相关人员", "客户经理"])
for c in ws1[1]: c.font = header_font
ws1.append(["XX科技有限公司", "2026-06-23", "上门", "9:00-10:00", "韦柳柏", "13800000000", "沟通了解云桌面需求", "希望扩容", "", "韦矍森"])
ws1.append(["XX科技有限公司", "2026-06-23", "上门", "9:00-10:00", "韦柳柏", "13800000000", "沟通了解云桌面需求", "希望扩容", "韦柳柏, 张科长", "韦矍森"])
ws1.column_dimensions['A'].width = 20; ws1.column_dimensions['E'].width = 30; ws1.column_dimensions['F'].width = 20
# Sheet 2: 下周工作计划
@@ -48,6 +48,13 @@ async def download_weekly_report_template():
ws4.append(["XX科技有限公司", "重要", "拜访技术负责人确认方案", "未开始", "2026-07-01", "韦柳柏", "王局长", "韦矍森"])
ws4.column_dimensions['A'].width = 20; ws4.column_dimensions['C'].width = 30
# Sheet 5: 今日纪要
ws5 = wb.create_sheet("今日纪要")
ws5.append(["日期", "分类", "内容", "时间范围", "填报人"])
for c in ws5[1]: c.font = header_font
ws5.append(["2026-06-23", "内部会议", "参加云桌面项目方案讨论会", "14:00-16:00", "韦矍森"])
ws5.column_dimensions['A'].width = 15; ws5.column_dimensions['B'].width = 12; ws5.column_dimensions['C'].width = 40
output = io.BytesIO()
wb.save(output)
output.seek(0)
+127
View File
@@ -0,0 +1,127 @@
from uuid import UUID
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.auth import get_current_user
from app.schemas.leave import LeaveCreate, LeaveUpdate
from app.services import leaves as leave_service
router = APIRouter(prefix="/leaves", tags=["Leaves"])
@router.get("")
async def list_leaves(
manager_id: str | None = Query(None),
status: str | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(25, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List leave records. Managers see only their own."""
return await leave_service.get_leaves(
db=db,
user_id=current_user["user_id"],
role=current_user["role"],
manager_id=manager_id,
status=status,
page=page,
page_size=page_size,
)
@router.post("")
async def create_leave(
data: LeaveCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a leave record. Director can create for anyone; manager only for self."""
try:
leave = await leave_service.create_leave(
db=db,
data=data.model_dump(),
submitted_by=UUID(current_user["user_id"]),
role=current_user["role"],
)
return {
"id": str(leave.id),
"manager_id": str(leave.manager_id),
"leave_type": leave.leave_type,
"start_date": str(leave.start_date),
"end_date": str(leave.end_date),
"reason": leave.reason,
"submitted_by": str(leave.submitted_by),
}
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/{leave_id}")
async def update_leave(
leave_id: UUID,
data: LeaveUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update a leave record."""
try:
leave = await leave_service.update_leave(
db=db,
leave_id=leave_id,
data=data.model_dump(exclude_none=True),
user_id=UUID(current_user["user_id"]),
role=current_user["role"],
)
return {
"id": str(leave.id),
"manager_id": str(leave.manager_id),
"leave_type": leave.leave_type,
"start_date": str(leave.start_date),
"end_date": str(leave.end_date),
"reason": leave.reason,
}
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=404 if "不存在" in str(e) else 400, detail=str(e))
@router.delete("/{leave_id}")
async def delete_leave(
leave_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Delete a leave record."""
try:
ok = await leave_service.delete_leave(
db=db,
leave_id=leave_id,
user_id=UUID(current_user["user_id"]),
role=current_user["role"],
)
if not ok:
raise HTTPException(status_code=404, detail="请假记录不存在")
return {"status": "deleted"}
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
@router.get("/overview")
async def leave_overview(
reference_date: str | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get weekly leave overview for dashboard card."""
from datetime import date
ref = date.fromisoformat(reference_date) if reference_date else None
return await leave_service.get_leave_overview(
db=db,
user_id=current_user["user_id"],
role=current_user["role"],
reference_date=ref,
)
+111
View File
@@ -0,0 +1,111 @@
"""System configuration API — get/set runtime settings like notification time."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.middleware.auth import get_current_user, require_director
from app.models.system_config import SystemConfig
from app.services.holidays import refresh_holidays
from app.services.scheduler_manager import reschedule_daily_check
router = APIRouter(prefix="/system-config", tags=["SystemConfig"])
DEFAULT_NOTIFICATION_TIME = "17:30"
def _parse_time(time_str: str) -> tuple[int, int]:
"""Parse 'HH:MM' string to (hour, minute). Raises ValueError on bad input."""
parts = time_str.strip().split(":")
if len(parts) != 2:
raise ValueError("时间格式必须为 HH:MM")
h, m = int(parts[0]), int(parts[1])
if not (0 <= h <= 23 and 0 <= m <= 59):
raise ValueError("小时 0-23,分钟 0-59")
return h, m
async def _get_config(db: AsyncSession, key: str) -> str | None:
row = await db.get(SystemConfig, key)
return row.value if row else None
async def _set_config(db: AsyncSession, key: str, value: str):
row = await db.get(SystemConfig, key)
if row:
row.value = value
else:
db.add(SystemConfig(key=key, value=value))
await db.commit()
async def get_notification_time(db: AsyncSession) -> str:
"""Read notification_time from DB, falling back to default."""
val = await _get_config(db, "notification_time")
return val if val else DEFAULT_NOTIFICATION_TIME
@router.get("")
async def list_config(db: AsyncSession = Depends(get_db)):
"""Return all system config as {key: value} dict."""
result = await db.execute(select(SystemConfig))
rows = result.scalars().all()
config = {r.key: r.value for r in rows}
# Ensure notification_time always has a value
if "notification_time" not in config:
config["notification_time"] = DEFAULT_NOTIFICATION_TIME
return config
@router.put("/{key}")
async def update_config(
key: str,
body: dict,
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Update a single config key. Only directors can change settings."""
value = body.get("value", "")
valid_keys = {"notification_time", "holidays", "ai_summary_prompt"}
if key not in valid_keys:
raise HTTPException(status_code=400, detail=f"不支持的配置项: {key}")
if key == "notification_time":
try:
h, m = _parse_time(value)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
await _set_config(db, key, value)
await reschedule_daily_check(h, m)
return {"key": key, "value": value, "scheduled": f"{h:02d}:{m:02d}"}
if key == "holidays":
# Validate: comma-separated YYYY-MM-DD dates
if value.strip():
for s in value.split(","):
s = s.strip()
if s:
try:
from datetime import date
date.fromisoformat(s)
except ValueError:
raise HTTPException(status_code=400, detail=f"日期格式错误: {s},应为 YYYY-MM-DD")
await _set_config(db, key, value)
return {"key": key, "value": value}
await _set_config(db, key, value)
return {"key": key, "value": value}
@router.post("/refresh-holidays")
async def refresh_holidays_endpoint(
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Fetch the latest Chinese holiday data from apisbo.com and cache it."""
try:
result = await refresh_holidays(db)
return {"status": "ok", **result}
except Exception as e:
raise HTTPException(status_code=500, detail=f"刷新节假日数据失败: {str(e)}")
+66
View File
@@ -15,6 +15,8 @@ router = APIRouter(prefix="/users", tags=["Users"])
class UpdateUserRoleRequest(BaseModel):
role: str # manager / director / leader
department: str = ""
require_report: bool = True
color: Optional[str] = None # hex color for manager tags
@router.get("/", response_model=list[UserOut])
@@ -71,6 +73,9 @@ async def update_user_role(
user.role = data.role
if data.department:
user.department = data.department
user.require_report = data.require_report
if data.color is not None:
user.color = data.color
await db.commit()
await db.refresh(user)
@@ -79,4 +84,65 @@ async def update_user_role(
"name": user.name,
"role": user.role,
"department": user.department,
"require_report": user.require_report,
"color": user.color,
}
class UpdateWecomRequest(BaseModel):
wecom_userid: Optional[str] = None # None or "" to unbind
@router.put("/{user_id}/wecom")
async def update_user_wecom(
user_id: str,
data: UpdateWecomRequest,
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Director binds or unbinds a user's WeChat Work account."""
result = await db.execute(select(User).where(User.id == uuid.UUID(user_id)))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
new_id = (data.wecom_userid or "").strip()
# Check duplicate
if new_id:
dup = await db.execute(
select(User).where(User.wecom_userid == new_id, User.id != uuid.UUID(user_id))
)
existing = dup.scalar_one_or_none()
if existing:
raise HTTPException(status_code=400, detail=f"该企微ID已被「{existing.name}」绑定")
user.wecom_userid = new_id if new_id else None
await db.commit()
await db.refresh(user)
return {
"id": str(user.id),
"name": user.name,
"wecom_userid": user.wecom_userid,
}
@router.delete("/{user_id}")
async def delete_user(
user_id: str,
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Director deletes a user. Cannot delete self."""
if user_id == current_user["user_id"]:
raise HTTPException(status_code=400, detail="不能删除自己")
result = await db.execute(select(User).where(User.id == uuid.UUID(user_id)))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
await db.delete(user)
await db.commit()
return {"detail": "deleted", "name": user.name}
+59
View File
@@ -28,6 +28,12 @@ async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict:
mgr_result = await db.execute(select(User.name).where(User.id == visit.manager_id))
manager_name = mgr_result.scalar_one_or_none()
# Resolve companion UUIDs to names
companion_names_resolved = list(visit.companion_names or [])
if visit.companions:
comp_result = await db.execute(select(User.name).where(User.id.in_(visit.companions)))
companion_names_resolved = [n for n, in comp_result.all()] + companion_names_resolved
return {
"id": str(visit.id),
"customer_id": str(visit.customer_id),
@@ -40,6 +46,8 @@ async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict:
"communication_content": visit.communication_content,
"customer_demand": visit.customer_demand,
"companions": visit.companions,
"companion_names": visit.companion_names or [],
"companion_names_resolved": companion_names_resolved,
"photos": visit.photos,
"manager_id": str(visit.manager_id),
"manager_name": manager_name,
@@ -137,12 +145,34 @@ async def create_visit(
communication_content=data.communication_content,
customer_demand=data.customer_demand,
companions=data.companions,
companion_names=data.companion_names,
photos=data.photos,
manager_id=uuid.UUID(current_user["user_id"]),
)
init_entry(visit, current_user["name"])
db.add(visit)
# Update customer's last visit tracking
cust_result = await db.execute(select(Customer).where(Customer.id == data.customer_id))
customer = cust_result.scalar_one_or_none()
if customer:
customer.last_visit_date = parse_date(data.visit_date)
customer.last_visit_manager_id = uuid.UUID(current_user["user_id"])
db.add(customer)
# Auto-complete matching work plans for this customer
from app.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),
)
)
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
for companion_id in data.companions:
if companion_id != uuid.UUID(current_user["user_id"]):
@@ -224,10 +254,39 @@ async def delete_visit(
if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
customer_id = visit.customer_id
manager_id = visit.manager_id
# Clean up photos in MinIO
if visit.photos:
delete_objects(visit.photos)
await db.delete(visit)
# Recalculate customer's last_visit_date from remaining visits
latest = await db.execute(
select(func.max(Visit.visit_date)).where(
Visit.customer_id == customer_id,
)
)
new_latest = latest.scalar()
cust = await db.get(Customer, customer_id)
if cust:
if new_latest:
cust.last_visit_date = new_latest
# 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,
Visit.visit_date == new_latest,
).order_by(Visit.created_at.desc()).limit(1)
)
lv = latest_visit.scalar_one_or_none()
if lv:
cust.last_visit_manager_id = lv.manager_id
else:
cust.last_visit_date = None
cust.last_visit_manager_id = None
await db.commit()
return {"detail": "deleted"}
+215 -10
View File
@@ -1,13 +1,19 @@
import hashlib
import struct
import base64
import uuid
import xml.etree.ElementTree as ET
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.config import settings
from app.database import get_db
from app.middleware.auth import get_current_user, require_director
from app.models.user import User
from app.services.wecom import wecom_client
from app.services.wecom import wecom_client, store_bind_token, consume_bind_token
from app.services.scheduler import check_daily_reporting
router = APIRouter(prefix="/wecom", tags=["WeChatWork"])
@@ -22,6 +28,166 @@ class AnnouncementRequest(BaseModel):
content: str
class BindConfirmRequest(BaseModel):
token: str
# ── Crypto helpers ──
def _verify_signature(token: str, timestamp: str, nonce: str, encrypted: str, signature: str) -> bool:
items = sorted([token, timestamp, nonce, encrypted])
raw = "".join(items)
return hashlib.sha1(raw.encode()).hexdigest() == 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)
pad_len = plaintext[-1]
plaintext = plaintext[:-pad_len]
msg_len = struct.unpack(">I", plaintext[16:20])[0]
return plaintext[20:20 + msg_len].decode("utf-8")
# ── Callback ──
@router.get("/callback")
async def wecom_callback_verify(
msg_signature: str = Query(...),
timestamp: str = Query(...),
nonce: str = Query(...),
echostr: str = Query(...),
):
"""WeChat Work callback URL verification (GET)."""
token = settings.WECOM_TOKEN
if not token or not settings.WECOM_ENCODING_AES_KEY:
raise HTTPException(status_code=500, detail="WeCom callback not configured")
if not _verify_signature(token, timestamp, nonce, echostr, msg_signature):
raise HTTPException(status_code=403, detail="Signature mismatch")
try:
plaintext = _decrypt_msg(echostr)
return PlainTextResponse(content=plaintext, status_code=200)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Decrypt failed: {e}")
@router.post("/callback")
async def wecom_callback_event(request: Request):
"""WeChat Work callback event receiver (POST). Handles menu clicks and text messages."""
token = settings.WECOM_TOKEN
if not token or not settings.WECOM_ENCODING_AES_KEY:
return PlainTextResponse(content="success")
# Parse XML envelope
try:
root = ET.fromstring(await request.body())
encrypt = root.findtext("Encrypt", "")
except ET.ParseError:
return PlainTextResponse(content="success")
# Verify signature
msg_signature = request.query_params.get("msg_signature", "")
timestamp = request.query_params.get("timestamp", "")
nonce = request.query_params.get("nonce", "")
if not _verify_signature(token, timestamp, nonce, encrypt, msg_signature):
return PlainTextResponse(content="success")
# Decrypt inner XML
try:
xml_str = _decrypt_msg(encrypt)
msg = ET.fromstring(xml_str)
except Exception:
return PlainTextResponse(content="success")
msg_type = msg.findtext("MsgType", "")
from_user = msg.findtext("FromUserName", "")
content = (msg.findtext("Content", "") or "").strip()
event = (msg.findtext("Event", "") or "").strip()
event_key = (msg.findtext("EventKey", "") or "").strip()
import logging
_log = logging.getLogger("wecom_callback")
_log.warning(f"wecom msg: type={msg_type} from={from_user} content={repr(content)} event={event} key={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)
return PlainTextResponse(content="success")
_log.warning(f"wecom msg ignored (no match)")
return PlainTextResponse(content="success")
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)
content = (
f"【账号绑定】\n\n"
f"请在10分钟内点击以下链接完成账号绑定:\n"
f"https://qj.dhdx.fun/wecom-bind?token={bind_token}\n\n"
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)
)
# ── Bind confirmation (JWT-protected) ──
@router.post("/bind-confirm")
async def bind_confirm(
data: BindConfirmRequest,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""User confirms binding after clicking the link in WeChat Work message."""
if not data.token:
raise HTTPException(status_code=400, detail="token 不能为空")
wecom_userid = consume_bind_token(data.token)
if not wecom_userid:
raise HTTPException(status_code=400, detail="绑定链接已过期或无效,请重新在企微发送「绑定」")
# Check if this wecom_userid is already bound to another user
result = await db.execute(
select(User).where(User.wecom_userid == wecom_userid)
)
existing = result.scalar_one_or_none()
if existing and str(existing.id) != current_user["user_id"]:
raise HTTPException(status_code=400, detail=f"该企业微信已绑定账号「{existing.name}")
# Bind
result = await db.execute(
select(User).where(User.id == uuid.UUID(current_user["user_id"]))
)
user = result.scalar_one()
user.wecom_userid = wecom_userid
await db.commit()
return {"code": 200, "msg": "绑定成功", "wecom_userid": wecom_userid}
# ── Manual actions (director only) ──
@router.post("/remind")
async def send_reminder(
data: RemindRequest,
@@ -29,16 +195,23 @@ async def send_reminder(
db: AsyncSession = Depends(get_db),
):
"""Director manually sends reminder to specific managers."""
# Get wecom_userids for the selected users
result = await db.execute(
select(User.wecom_userid).where(User.id.in_([uuid.UUID(uid) for uid in data.user_ids]))
)
wecom_ids = [r[0] for r in result.all() if r[0]]
content = data.message or "📋 请及时完成今日拜访记录填报。"
success = await wecom_client.send_text_message(wecom_ids, content)
success = await wecom_client.send_template_card(
user_ids=wecom_ids,
title="📋 填报提醒",
desc=content,
url="https://qj.dhdx.fun/m",
btn_text="去填报",
)
if not success:
success = await wecom_client.send_text_message(wecom_ids, content)
return {"success": success, "sent_to": len(wecom_ids)}
return {"success": success, "sent_to": len(wecom_ids), "rich": True}
@router.post("/announcement")
@@ -47,15 +220,47 @@ async def send_announcement(
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Director sends an announcement to all team members."""
# Get all wecom_userids in the department
"""Director sends an announcement to all team members (markdown)."""
result = await db.execute(select(User.wecom_userid).where(User.wecom_userid.isnot(None)))
wecom_ids = [r[0] for r in result.all()]
content = f"📢 支局长公告\n\n{data.content}"
success = await wecom_client.send_text_message(wecom_ids, content)
markdown = f"## 📢 支局长公告\n\n{data.content}"
success = await wecom_client.send_markdown_message(markdown)
if not success:
success = await wecom_client.send_text_message(wecom_ids, f"📢 支局长公告\n\n{data.content}")
return {"success": success, "sent_to": len(wecom_ids)}
return {"success": success, "sent_to": len(wecom_ids), "rich": True}
@router.post("/test-message")
async def test_message(
wecom_userid: str,
current_user: dict = Depends(require_director),
):
"""Send a test message to a specific wecom_userid for debugging."""
success = await wecom_client.send_text_message(
[wecom_userid],
"🧪 企迹周报系统 — 测试消息\n\n如果您收到此消息,说明企微消息推送配置成功!"
)
return {"success": success, "sent_to": wecom_userid}
@router.post("/setup-menu")
async def setup_menu(current_user: dict = Depends(require_director)):
"""Create the standard app menu (开始填报 view + 绑定账号 click)."""
buttons = [
{"type": "view", "name": "开始填报", "url": "https://qj.dhdx.fun/m"},
{"type": "click", "name": "绑定账号", "key": "BIND_ACCOUNT"},
]
success = await wecom_client.create_menu(buttons)
return {"success": success, "message": "菜单已部署 (view + click)" if success else "菜单创建失败"}
@router.get("/menu")
async def get_menu(current_user: dict = Depends(require_director)):
"""Get current app menu configuration."""
menu = await wecom_client.get_menu()
return menu or {"message": "No menu configured"}
@router.post("/trigger-daily-check")
+7
View File
@@ -36,6 +36,13 @@ class Settings(BaseSettings):
WECOM_SECRET: str = ""
WECOM_TOKEN: str = ""
WECOM_ENCODING_AES_KEY: str = ""
WECOM_API_BASE: str = "https://qyapi.weixin.qq.com"
# AI / LLM (OpenAI-compatible)
AI_API_URL: str = ""
AI_API_KEY: str = ""
AI_MODEL: str = "gpt-4o"
AI_MAX_TOKENS: int = 2000
# CORS
CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:3000"]
+73 -2
View File
@@ -1,11 +1,15 @@
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
from app.database import engine, Base, async_session
from app.api import router as api_router
from app.api import auth, users, customers, visits, work_plans, mini_business, key_visits
from app.api import dashboard, upload, export, import_data, wecom, daily_notes
from app.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary, system_config, leaves
from app.models.system_config import SystemConfig
from app.services.holidays import refresh_holidays
from app.services.scheduler_manager import start_scheduler, shutdown_scheduler
@asynccontextmanager
@@ -28,8 +32,72 @@ async def lifespan(app: FastAPI):
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)"
))
# Read notification_time from DB (or use default 17:30)
notification_hour, notification_minute = 17, 30
async with async_session() as db:
row = await db.get(SystemConfig, "notification_time")
if row and row.value:
try:
parts = row.value.strip().split(":")
notification_hour, notification_minute = int(parts[0]), int(parts[1])
except (ValueError, IndexError):
pass
# Auto-refresh Chinese holiday data from API on startup
try:
await refresh_holidays(db)
except Exception:
pass # Use cached data if API unavailable
# Start daily reporting scheduler
start_scheduler(notification_hour, notification_minute)
yield
# Shutdown
shutdown_scheduler()
await engine.dispose()
@@ -57,11 +125,14 @@ app.include_router(work_plans.router, prefix="/api")
app.include_router(mini_business.router, prefix="/api")
app.include_router(key_visits.router, prefix="/api")
app.include_router(dashboard.router, prefix="/api")
app.include_router(leaves.router, prefix="/api")
app.include_router(upload.router, prefix="/api")
app.include_router(export.router, prefix="/api")
app.include_router(import_data.router, prefix="/api")
app.include_router(wecom.router, prefix="/api")
app.include_router(daily_notes.router, prefix="/api")
app.include_router(ai_summary.router, prefix="/api")
app.include_router(system_config.router, prefix="/api")
@app.get("/health")
+6
View File
@@ -7,6 +7,9 @@ from app.models.work_plan import WorkPlan
from app.models.mini_business import MiniBusiness
from app.models.key_visit import KeyVisit
from app.models.daily_note import DailyNote
from app.models.ai_summary import AISummary
from app.models.system_config import SystemConfig
from app.models.leave import Leave
__all__ = [
"User",
@@ -18,4 +21,7 @@ __all__ = [
"MiniBusiness",
"KeyVisit",
"DailyNote",
"AISummary",
"SystemConfig",
"Leave",
]
+21
View File
@@ -0,0 +1,21 @@
"""AI-generated weekly report summary — cached per week per user."""
import uuid
from datetime import date, datetime
from sqlalchemy import String, Text, DateTime, Date, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class AISummary(Base):
__tablename__ = "ai_summaries"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
week_start: Mapped[date] = mapped_column(Date, index=True)
week_end: Mapped[date] = mapped_column(Date)
period: Mapped[str] = mapped_column(String(10), default="week") # 'week' / 'month'
generated_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True)
role: Mapped[str] = mapped_column(String(20))
summary: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+4 -2
View File
@@ -1,6 +1,6 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Text, DateTime, func, ForeignKey
from datetime import date, datetime
from sqlalchemy import String, Text, Date, DateTime, Boolean, func, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
@@ -19,6 +19,8 @@ class Customer(Base):
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
last_visit_date: Mapped[date | None] = mapped_column(Date, nullable=True)
last_visit_manager_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
contacts: Mapped[list["CustomerContact"]] = relationship("CustomerContact", back_populates="customer", cascade="all, delete-orphan")
assignments: Mapped[list["CustomerAssignment"]] = relationship("CustomerAssignment", back_populates="customer", cascade="all, delete-orphan")
+30
View File
@@ -0,0 +1,30 @@
import uuid
from datetime import date, datetime
from sqlalchemy import String, Date, DateTime, ForeignKey, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Leave(Base):
__tablename__ = "leaves"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
manager_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), index=True
)
leave_type: Mapped[str] = mapped_column(String(20), default="事假")
start_date: Mapped[date] = mapped_column(Date)
end_date: Mapped[date] = mapped_column(Date)
reason: Mapped[str] = mapped_column(String(500), default="")
submitted_by: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id")
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
+12
View File
@@ -0,0 +1,12 @@
"""System-wide configuration key-value store."""
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class SystemConfig(Base):
__tablename__ = "system_config"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[str] = mapped_column(Text, default="")
+2
View File
@@ -15,4 +15,6 @@ class User(Base):
role: Mapped[str] = mapped_column(String(30)) # manager / director / leader
department: Mapped[str] = mapped_column(String(100), default="")
wecom_userid: Mapped[str | None] = mapped_column(String(100), unique=True, nullable=True)
require_report: Mapped[bool] = mapped_column(default=True, server_default="true")
color: Mapped[str | None] = mapped_column(String(7), nullable=True) # e.g. "#C62828"
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+1
View File
@@ -19,6 +19,7 @@ class Visit(Base):
visitor_name: Mapped[str] = mapped_column(String(50), default="")
visitor_phone: Mapped[str] = mapped_column(String(20), default="")
companions: Mapped[list | None] = mapped_column(ARRAY(UUID(as_uuid=True)), nullable=True)
companion_names: Mapped[list] = mapped_column(ARRAY(Text), default=list)
photos: Mapped[list | None] = mapped_column(ARRAY(Text), nullable=True)
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
edit_log: Mapped[list] = mapped_column(JSONB, default=list)
+2
View File
@@ -19,6 +19,8 @@ class UserOut(BaseModel):
role: str
department: str
wecom_userid: Optional[str] = None
require_report: bool = True
color: Optional[str] = None
model_config = {"from_attributes": True}
+55
View File
@@ -0,0 +1,55 @@
from datetime import date, datetime
from uuid import UUID
from pydantic import BaseModel, Field
LEAVE_TYPES = ["年假", "事假", "病假", "调休", "其他"]
class LeaveCreate(BaseModel):
manager_id: UUID
leave_type: str = Field(default="事假", pattern="^(年假|事假|病假|调休|其他)$")
start_date: date
end_date: date
reason: str = ""
class LeaveUpdate(BaseModel):
manager_id: UUID | None = None
leave_type: str | None = Field(default=None, pattern="^(年假|事假|病假|调休|其他)$")
start_date: date | None = None
end_date: date | None = None
reason: str | None = None
class LeaveOut(BaseModel):
id: UUID
manager_id: UUID
manager_name: str = ""
leave_type: str
start_date: date
end_date: date
days: int = 0
reason: str = ""
submitted_by: UUID
submitted_by_name: str = ""
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = {"from_attributes": True}
class LeaveOverviewItem(BaseModel):
manager_id: UUID
manager_name: str
leave_type: str
start_date: date
end_date: date
days: int
class LeaveOverview(BaseModel):
week_start: str
week_end: str
total_on_leave: int
leave_list: list[LeaveOverviewItem]
+3
View File
@@ -14,6 +14,7 @@ class VisitCreate(BaseModel):
communication_content: str = ""
customer_demand: str = ""
companions: list[uuid.UUID] = []
companion_names: list[str] = []
photos: list[str] = []
@@ -27,6 +28,7 @@ class VisitUpdate(BaseModel):
communication_content: Optional[str] = None
customer_demand: Optional[str] = None
companions: Optional[list[uuid.UUID]] = None
companion_names: Optional[list[str]] = None
photos: Optional[list[str]] = None
@@ -41,6 +43,7 @@ class VisitOut(BaseModel):
communication_content: str
customer_demand: str
companions: Optional[list[uuid.UUID]] = None
companion_names: list[str] = []
photos: Optional[list[str]] = None
manager_id: uuid.UUID
created_at: datetime
+294
View File
@@ -0,0 +1,294 @@
"""AI-powered weekly report summary using an OpenAI-compatible LLM."""
import json
import httpx
from app.config import settings
from app.services.dashboard import get_weekly_report, get_week_range
from app.services.light_board import get_light_board
from app.models.ai_summary import AISummary
from app.models.system_config import SystemConfig
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from uuid import UUID
from datetime import date, datetime
SUMMARY_SYSTEM_PROMPT = """你是一位经验丰富的政企客户经理团队的周报分析助手。你的分析将被直接提交给支局长作为工作周报的文字摘要。
## 约束
- 严格基于提供的数据进行分析,绝不编造数据中不存在的信息
- 使用正式但不生硬的中文,适合放入政企工作周报
- 每条分析简洁有力,1-2句话即可,避免空泛套话
- 如果某个结论是基于数据推断的,请使用"数据显示""从本周情况看"等表述
- 对于覆盖不足的情况,请明确指出具体客户名称和负责人,方便支局长跟进
## 输出格式(使用 Markdown
### 一、本周概况
[2-3句话,涵盖:拜访总量、覆盖客户数、团队参与情况、拜访方式分布]
### 二、拜访重点与客户需求
[2-3个值得关注的客户需求或沟通内容要点,有具体客户名称]
### 三、客户覆盖分析
[引用覆盖数据,明确指出:覆盖率、低于60%的经理、红灯客户名单、需要关注的客户]
### 四、下周建议
[2-3条针对性的工作建议,基于数据中暴露的问题和客户需求]"""
def build_summary_prompt(
weekly_report: dict,
light_board: dict,
period: str,
reference_date: str,
leaves: list | None = None,
) -> str:
"""Build the user prompt with structured visit data for the LLM."""
# ── Summary stats ──
visits = weekly_report.get("visits", [])
daily_notes = weekly_report.get("daily_notes", [])
managers_involved: set[str] = set()
customers_visited: set[str] = set()
methods: dict[str, int] = {}
demands: list[str] = []
for v in visits:
managers_involved.add(v.get("manager_name", ""))
customers_visited.add(v.get("customer_name", ""))
method = v.get("visit_method", "")
methods[method] = methods.get(method, 0) + 1
demand = v.get("customer_demand", "")
if demand and demand.strip():
demands.append(f"{v.get('customer_name', '未知')}: {demand.strip()}")
# Manager breakdown
manager_visits: dict[str, list] = {}
for v in visits:
mn = v.get("manager_name", "未知")
if mn not in manager_visits:
manager_visits[mn] = []
manager_visits[mn].append({
"client": v.get("customer_name", ""),
"method": v.get("visit_method", ""),
"content": (v.get("communication_content", "") or "")[:120],
"demand": v.get("customer_demand", "") or "",
})
# Build the data block
data_block = f"""## 基本信息
- 分析周期:{period}
- 参考日期:{reference_date}
- 周范围:{weekly_report.get('week_start', '')}{weekly_report.get('week_end', '')}
## 拜访总览
- 拜访记录总数:{len(visits)}
- 覆盖客户数:{len(customers_visited)}
- 参与经理数:{len(managers_involved)}
- 拜访方式分布:{json.dumps(methods, ensure_ascii=False)}
## 各客户经理拜访明细
"""
for mn, items in manager_visits.items():
data_block += f"\n### {mn}{len(items)}条)\n"
for item in items[:10]: # cap per manager
data_block += f"- {item['method']}拜访 {item['client']}"
if item['content']:
data_block += f"{item['content'][:100]}"
if item['demand']:
data_block += f" [需求: {item['demand'][:80]}]"
data_block += "\n"
# Customer demands
if demands:
data_block += "\n## 客户需求汇总\n"
for d in demands[:15]:
data_block += f"- {d[:200]}\n"
# Daily notes summary
notes_by_cat: dict[str, int] = {}
for n in daily_notes:
cat = n.get("category", "其他")
notes_by_cat[cat] = notes_by_cat.get(cat, 0) + 1
if notes_by_cat:
data_block += "\n## 纪要分类统计\n"
data_block += json.dumps(notes_by_cat, ensure_ascii=False) + "\n"
# Light board data
team = light_board.get("team_summary", {})
data_block += f"""
## 客户覆盖数据(亮灯表)
- 团队总客户数:{team.get('total_customers', 0)}
- 本月已拜访(绿灯):{team.get('visited_this_month', 0)}
- 仅上月拜访(黄灯):{team.get('visited_last_month_only', 0)}
- 连续未拜访(红灯):{team.get('not_visited_2months', 0)}
- 未分配客户:{team.get('unassigned', 0)}
- 整体覆盖率:{team.get('coverage_rate', 0) * 100:.1f}%
### 各经理覆盖率
"""
for m in light_board.get("managers", []):
data_block += (
f"- {m['manager_name']}: {m['coverage_rate'] * 100:.0f}% "
f"({m['visited_this_month']}/{m['total_customers']}) "
f"🟢{m['visited_this_month']} 🟡{m['visited_last_month_only']} 🔴{m['not_visited_2months']}\n"
)
# List red customers
red_customers = [c for c in m.get("customers", []) if c["status"] == "red"]
if red_customers:
data_block += " 红灯客户:\n"
for rc in red_customers[:5]:
lvd = rc.get("last_visit_date") or "从未"
data_block += f" - {rc['customer_name']}(上次拜访: {lvd}\n"
# ── Leave data ──
if leaves:
data_block += "\n## 本周请假情况\n"
for lv in leaves:
data_block += (
f"- {lv.get('manager_name', '未知')} ({lv.get('leave_type', '请假')}): "
f"{lv.get('start_date', '')} ~ {lv.get('end_date', '')} "
f"({lv.get('days', 0)}天)\n"
)
else:
data_block += "\n## 本周请假情况\n无请假记录\n"
return data_block
async def get_cached_summary(
db: AsyncSession,
user_id: UUID,
reference_date: date | None = None,
period: str = "week",
) -> dict | None:
"""Load a previously generated summary for this week/user."""
ref = reference_date or date.today()
monday, sunday = get_week_range(ref)
result = await db.execute(
select(AISummary)
.where(
AISummary.week_start == monday,
AISummary.period == period,
AISummary.generated_by == user_id,
)
.order_by(AISummary.created_at.desc())
.limit(1)
)
row = result.scalar()
if not row:
return None
return {
"summary": row.summary,
"week_start": str(row.week_start),
"week_end": str(row.week_end),
"period": row.period,
"created_at": str(row.created_at),
"cached": True,
}
async def delete_cached_summary(
db: AsyncSession,
user_id: UUID,
reference_date: date | None = None,
period: str = "week",
) -> bool:
"""Delete a cached summary so it can be regenerated."""
ref = reference_date or date.today()
monday, sunday = get_week_range(ref)
result = await db.execute(
select(AISummary).where(
AISummary.week_start == monday,
AISummary.period == period,
AISummary.generated_by == user_id,
)
)
rows = result.scalars().all()
for row in rows:
await db.delete(row)
if rows:
await db.commit()
return len(rows) > 0
async def generate_summary(
db: AsyncSession,
user_id: UUID,
role: str,
reference_date: date | None = None,
period: str = "week",
) -> dict:
"""Generate an AI-powered weekly summary, save to DB, return it.
Raises ValueError if AI config is missing, httpx.HTTPError on API failure.
"""
if not settings.AI_API_URL:
raise ValueError("AI_API_URL not configured")
# Gather data
ref = reference_date or date.today()
monday, sunday = get_week_range(ref)
weekly_report = await get_weekly_report(
db=db, user_id=user_id, role=role, reference_date=ref,
)
light_board = await get_light_board(db, ref, user_id, role)
from app.services.leaves import get_leave_overview
leave_overview = await get_leave_overview(db, str(user_id), role, ref)
user_prompt = build_summary_prompt(
weekly_report, light_board, period, str(ref),
leaves=leave_overview.get("leave_list", []),
)
# Load custom system prompt (director/leader can override via system settings)
custom_row = await db.get(SystemConfig, "ai_summary_prompt")
system_prompt = SUMMARY_SYSTEM_PROMPT
if custom_row and custom_row.value and custom_row.value.strip():
system_prompt = custom_row.value.strip()
# Call LLM
headers = {"Content-Type": "application/json"}
if settings.AI_API_KEY:
headers["Authorization"] = f"Bearer {settings.AI_API_KEY}"
payload = {
"model": settings.AI_MODEL,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"max_tokens": settings.AI_MAX_TOKENS,
"temperature": 0.3,
}
async with httpx.AsyncClient(timeout=90.0) as client:
resp = await client.post(settings.AI_API_URL, json=payload, headers=headers)
resp.raise_for_status()
result = resp.json()
content = result.get("choices", [{}])[0].get("message", {}).get("content", "")
if not content:
raise ValueError("AI returned empty response")
# Delete old cached entry for this week/user, then save new
await delete_cached_summary(db, user_id, reference_date=ref, period=period)
row = AISummary(
week_start=monday,
week_end=sunday,
period=period,
generated_by=user_id,
role=role,
summary=content,
)
db.add(row)
await db.commit()
return {
"summary": content,
"week_start": str(monday),
"week_end": str(sunday),
"period": period,
"created_at": str(row.created_at),
"cached": False,
}
+52 -8
View File
@@ -1,6 +1,6 @@
from datetime import date, timedelta
from uuid import UUID
from sqlalchemy import select, func
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from app.models.customer import Customer
@@ -10,7 +10,9 @@ from app.models.work_plan import WorkPlan
from app.models.mini_business import MiniBusiness
from app.models.key_visit import KeyVisit
from app.models.daily_note import DailyNote
from app.utils.timezone import today_cst
from app.models.leave import Leave
from app.services.holidays import load_holiday_sets
from app.utils.timezone import today_cst, is_working_day
def get_week_range(reference_date: date | None = None):
@@ -41,11 +43,19 @@ async def get_dashboard_stats(db: AsyncSession, reference_date: date | None = No
select(func.count(KeyVisit.id))
)).scalar() or 0
# Week leave count — distinct managers on leave this week
leaves_count = (await db.execute(
select(func.count(func.distinct(Leave.manager_id))).where(
and_(Leave.start_date <= sunday, Leave.end_date >= monday)
)
)).scalar() or 0
return {
"week_visits": visits_count,
"work_plans": plans_count,
"mini_business": mini_biz_count,
"key_visits": key_visit_count,
"week_leaves": leaves_count,
"week_start": str(monday),
"week_end": str(sunday),
}
@@ -55,10 +65,11 @@ async def get_reporting_progress(db: AsyncSession, reference_date: date | None =
"""Get per-manager reporting progress. Managers only see themselves."""
monday, sunday = get_week_range(reference_date)
managers_result = await db.execute(select(User).where(User.role == "manager"))
all_managers = managers_result.scalars().all()
# Get all users who need to report (any role, filtered by require_report)
managers_result = await db.execute(select(User).where(User.require_report == True))
all_reporters = managers_result.scalars().all()
# Filter: managers only see themselves
managers = all_managers if role in ("director", "leader") else [m for m in all_managers if str(m.id) == user_id]
reporters = all_reporters if role in ("director", "leader") else [m for m in all_reporters if str(m.id) == user_id]
# Get visit counts per manager this week
visits_result = await db.execute(
@@ -68,24 +79,49 @@ async def get_reporting_progress(db: AsyncSession, reference_date: date | None =
)
visit_map = {str(uid): cnt for uid, cnt in visits_result.all()}
# Load holiday data (auto-refreshes from API if not cached)
holidays, workdays = await load_holiday_sets(db)
# Query leave records for today — build a set of managers on leave
today = today_cst()
is_rest = not is_working_day(today, holidays, workdays)
leaves_result = await db.execute(
select(Leave).where(and_(
Leave.start_date <= today,
Leave.end_date >= today,
))
)
on_leave_today = {str(r.manager_id): r for r in leaves_result.scalars().all()}
progress = []
for m in managers:
for m in reporters:
count = visit_map.get(str(m.id), 0)
# Calculate expected working days (Mon-Fri)
days_passed = min((date.today() - monday).days + 1, 5)
expected = days_passed # At least 1 visit per working day
mid = str(m.id)
leave = on_leave_today.get(mid)
progress.append({
"manager_id": str(m.id),
"manager_id": mid,
"manager_name": m.name,
"department": m.department,
"visit_count": count,
"expected": expected,
"completed": count >= expected,
"has_reported_today": False, # Will be set below
"on_leave": leave is not None,
"is_rest_day": is_rest,
"leave_info": {
"leave_type": leave.leave_type,
"start_date": str(leave.start_date),
"end_date": str(leave.end_date),
} if leave else None,
})
# Check today's reporting — visits OR daily notes
today = today_cst()
today_visits = await db.execute(
select(Visit.manager_id).where(Visit.visit_date == today)
)
@@ -137,6 +173,9 @@ async def get_weekly_report(
visits_data = []
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),
@@ -144,12 +183,17 @@ async def get_weekly_report(
"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 [],
"companion_names_resolved": companion_names_resolved,
"photos": v.photos or [],
"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),
})
+26 -3
View File
@@ -8,6 +8,7 @@ from app.models.visit import Visit
from app.models.work_plan import WorkPlan
from app.models.mini_business import MiniBusiness
from app.models.key_visit import KeyVisit
from app.models.daily_note import DailyNote
from app.models.customer import Customer
from app.models.user import User
@@ -21,7 +22,7 @@ def get_week_range(reference_date: date | None = None):
async def export_weekly_report(db: AsyncSession, reference_date: date | None = None) -> io.BytesIO:
"""Generate a 4-sheet xlsx matching the existing weekly report template."""
"""Generate a 5-sheet xlsx matching the existing weekly report template."""
monday, sunday = get_week_range(reference_date)
wb = Workbook()
@@ -40,7 +41,7 @@ async def export_weekly_report(db: AsyncSession, reference_date: date | None = N
# ── Sheet 1: 每日拜访记录 ──
ws1 = wb.active
ws1.title = "每日拜访记录"
headers1 = ["客户单位", "拜访日期", "拜访方式", "时间范围", "拜访人姓名", "拜访人电话", "沟通内容", "客户需求", "同访人员", "客户经理"]
headers1 = ["客户单位", "拜访日期", "拜访方式", "时间范围", "拜访人姓名", "拜访人电话", "沟通内容", "客户需求", "相关人员", "客户经理"]
ws1.append(headers1)
for col in range(1, len(headers1) + 1):
cell = ws1.cell(row=1, column=col)
@@ -52,6 +53,7 @@ async def export_weekly_report(db: AsyncSession, reference_date: date | None = N
)
for v in visits_result.scalars():
companions_names = [user_map.get(str(cid), str(cid)) for cid in (v.companions or [])]
companions_names.extend(v.companion_names or [])
ws1.append([
customer_map.get(str(v.customer_id), ""),
str(v.visit_date),
@@ -127,8 +129,29 @@ async def export_weekly_report(db: AsyncSession, reference_date: date | None = N
user_map.get(str(k.manager_id), ""),
])
# ── Sheet 5: 今日纪要 ──
ws5 = wb.create_sheet("今日纪要")
headers5 = ["日期", "分类", "内容", "时间范围", "填报人"]
ws5.append(headers5)
for col in range(1, len(headers5) + 1):
cell = ws5.cell(row=1, column=col)
cell.font = header_font
cell.border = thin_border
notes_result = await db.execute(
select(DailyNote).where(DailyNote.note_date >= monday, DailyNote.note_date <= sunday)
)
for n in notes_result.scalars():
ws5.append([
str(n.note_date),
n.category,
n.content,
n.time_range,
user_map.get(str(n.manager_id), ""),
])
# Adjust column widths
for ws in [ws1, ws2, ws3, ws4]:
for ws in [ws1, ws2, ws3, ws4, ws5]:
for col_cells in ws.columns:
max_length = max((len(str(cell.value or "")) for cell in col_cells), default=10)
ws.column_dimensions[col_cells[0].column_letter].width = min(max_length + 4, 50)
+327 -19
View File
@@ -1,26 +1,142 @@
import io
import uuid
import re
from datetime import datetime, date
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import openpyxl
from sqlalchemy import select, update
from app.models.customer import Customer
from app.models.customer_assignment import CustomerAssignment
from app.models.visit import Visit
from app.models.work_plan import WorkPlan
from app.models.mini_business import MiniBusiness
from app.models.key_visit import KeyVisit
from app.models.daily_note import DailyNote
from app.models.user import User
import openpyxl
# Companion name separators: Chinese comma, English comma, Chinese semicolon, dun-hao
_COMPANION_SEP = re.compile(r'[,;、;]')
def _parse_companions(raw: str, user_map: dict[str, uuid.UUID]) -> tuple[list[uuid.UUID], list[str]]:
"""Split a companion string into system user IDs and external names.
Returns (system_ids, external_names).
"""
if not raw or not raw.strip():
return [], []
parts = [p.strip() for p in _COMPANION_SEP.split(raw) if p.strip()]
system_ids: list[uuid.UUID] = []
external_names: list[str] = []
for name in parts:
uid = user_map.get(name)
if uid:
system_ids.append(uid)
else:
external_names.append(name)
return system_ids, external_names
def _fuzzy_match_customer(
cust_name: str,
customer_map: dict[str, uuid.UUID],
) -> uuid.UUID | None:
"""Try to match a customer name with fuzzy rules.
Rules (in order):
1. Exact match (already handled before calling this)
2. Normalize whitespace → exact match
3. One name fully contains the other
"""
norm = cust_name.replace(' ', '').replace(' ', '')
# Rule 2: whitespace-normalized match
for existing_name, cid in customer_map.items():
existing_norm = existing_name.replace(' ', '').replace(' ', '')
if norm == existing_norm:
return cid
# Rule 3: containment (longer name contains shorter)
for existing_name, cid in customer_map.items():
if len(norm) >= 4 and len(existing_name.replace(' ', '').replace(' ', '')) >= 4:
if norm in existing_name.replace(' ', '').replace(' ', '') or \
existing_name.replace(' ', '').replace(' ', '') in norm:
return cid
return None
async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uuid.UUID) -> dict:
"""Parse old weekly report Excel and import data. Returns summary stats."""
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True)
stats = {"visits": 0, "work_plans": 0, "mini_business": 0, "key_visits": 0, "skipped": 0, "skip_reasons": []}
"""Parse old weekly report Excel and import data. Returns summary stats.
# Resolve customer name -> id cache
Features:
- Companion parsing: split by comma/dun-hao → system users + external names
- Auto-create customers: if customer not found, create from Excel data
- Fuzzy name matching: whitespace normalization + containment
- Daily notes import (Sheet 5)
"""
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True)
stats = {
"visits": 0, "work_plans": 0, "mini_business": 0, "key_visits": 0,
"daily_notes": 0, "skipped": 0, "skip_reasons": [],
"customers_created": 0, "customers_created_names": [],
"external_companions": 0, "name_corrections": [],
}
# ── Pre-fetch lookups ──
customers_result = await db.execute(select(Customer.id, Customer.name))
customer_map = {c.name: c.id for c in customers_result.all()}
users_result = await db.execute(select(User.id, User.name))
user_map = {u.name: u.id for u in users_result.all()}
# Customer → assigned primary manager lookup (not from Excel)
assign_result = await db.execute(
select(CustomerAssignment.customer_id, CustomerAssignment.manager_id)
.where(CustomerAssignment.role == "primary")
)
customer_manager_map: dict[uuid.UUID, uuid.UUID] = {}
for cid, mid in assign_result.all():
if cid not in customer_manager_map: # first primary wins
customer_manager_map[cid] = mid
def get_assigned_manager(customer_id: uuid.UUID) -> uuid.UUID:
"""Return the customer's assigned primary manager, or the importer as fallback."""
return customer_manager_map.get(customer_id, manager_id)
# Helper: resolve or create customer
async def resolve_customer(cust_name: str, mgr_name: str = "") -> tuple[uuid.UUID | None, str]:
"""Resolve customer by name. Auto-creates if not found. Returns (id, note)."""
if not cust_name or not cust_name.strip():
return None, ""
cust_name = cust_name.strip()
cid = customer_map.get(cust_name)
if cid:
return cid, ""
# Fuzzy match
fuzzy_id = _fuzzy_match_customer(cust_name, customer_map)
if fuzzy_id:
real_name = next((n for n, i in customer_map.items() if i == fuzzy_id), cust_name)
stats["name_corrections"].append(f"{cust_name}」→「{real_name}")
customer_map[cust_name] = fuzzy_id # cache for future rows
return fuzzy_id, ""
# Auto-create customer (no manager assignment — leave unassigned)
customer = Customer(name=cust_name, created_by=manager_id)
db.add(customer)
await db.flush()
customer_map[cust_name] = customer.id
stats["customers_created"] += 1
stats["customers_created_names"].append(cust_name)
return customer.id, ""
# ── Parse Sheet 1: 每日拜访记录 ──
if "每日拜访记录" in wb.sheetnames:
ws = wb["每日拜访记录"]
@@ -28,28 +144,36 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui
if not row[0]:
continue
try:
cust_name, visit_date_str, visit_method, time_range, visitor_name, visitor_phone, content, demand, companions_str, mgr_str = \
row[0], str(row[1]) if row[1] else str(date.today()), \
str(row[2]) if row[2] else "上门", str(row[3]) if row[3] else "", \
str(row[4]) if row[4] else "", str(row[5]) if row[5] else "", \
str(row[6]) if row[6] else "", str(row[7]) if row[7] else "", \
str(row[8]) if row[8] else "", str(row[9]) if row[9] else ""
cust_name, visit_date_str, visit_method, time_range, visitor_name, visitor_phone, \
content, demand, companions_str, mgr_str = (
row[0], str(row[1]) if row[1] else str(date.today()),
str(row[2]) if row[2] else "上门", str(row[3]) if row[3] else "",
str(row[4]) if row[4] else "", str(row[5]) if row[5] else "",
str(row[6]) if row[6] else "", str(row[7]) if row[7] else "",
str(row[8]) if row[8] else "", str(row[9]) if row[9] else "",
)
customer_id = customer_map.get(cust_name)
# Resolve customer (auto-create if needed)
customer_id, _ = await resolve_customer(cust_name, str(row[9]) if row[9] else "")
if not customer_id:
stats["skipped"] += 1
stats["skip_reasons"].append(f"客户{cust_name}」不存在,跳过")
stats["skip_reasons"].append(f"客户名称为空,跳过")
continue
# Parse visit date
try:
visit_date = datetime.strptime(visit_date_str[:10], "%Y-%m-%d").date()
except ValueError:
visit_date = date.today()
# Use customer's assigned primary manager (not Excel column)
visit_manager_id = get_assigned_manager(customer_id)
# Check duplicate: same date + same manager + same customer
existing = await db.execute(
select(Visit).where(
Visit.visit_date == visit_date,
Visit.manager_id == manager_id,
Visit.manager_id == visit_manager_id,
Visit.customer_id == customer_id,
)
)
@@ -58,6 +182,11 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui
stats["skip_reasons"].append(f"重复:{cust_name} {visit_date} 已存在")
continue
# Parse companions into system users + external names
sys_companions, ext_names = _parse_companions(str(companions_str), user_map)
if ext_names:
stats["external_companions"] += len(ext_names)
visit = Visit(
customer_id=customer_id,
visit_date=visit_date,
@@ -67,15 +196,194 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui
visitor_phone=visitor_phone,
communication_content=content,
customer_demand=demand,
manager_id=manager_id,
companions=sys_companions,
companion_names=ext_names,
manager_id=visit_manager_id,
)
db.add(visit)
stats["visits"] += 1
# Update customer's last_visit_date for light board
cust = await db.get(Customer, customer_id)
if cust and (not cust.last_visit_date or visit_date > cust.last_visit_date):
cust.last_visit_date = visit_date
cust.last_visit_manager_id = visit_manager_id
except Exception as e:
stats["skipped"] += 1
stats["skip_reasons"].append(f"拜访解析异常:{repr(e)[:120]}")
# ── Parse Sheet 2: 下周工作计划 ──
if "下周工作计划" in wb.sheetnames:
ws2 = wb["下周工作计划"]
for row in ws2.iter_rows(min_row=2, values_only=True):
if not row[0]:
continue
try:
cust_name = str(row[0]).strip() if row[0] else ""
plan_content = str(row[1]) if row[1] else ""
plan_date_str = str(row[2]) if row[2] else str(date.today())
mgr_name = str(row[3]).strip() if row[3] else ""
status = str(row[4]) if row[4] else "计划中"
customer_id, _ = await resolve_customer(cust_name, mgr_name)
if not customer_id:
stats["skipped"] += 1
stats["skip_reasons"].append(f"工作计划:客户「{cust_name}」无法解析")
continue
try:
plan_date = datetime.strptime(plan_date_str[:10], "%Y-%m-%d").date()
except ValueError:
plan_date = date.today()
plan_manager_id = get_assigned_manager(customer_id)
work_plan = WorkPlan(
customer_id=customer_id,
plan_content=plan_content,
plan_date=plan_date,
manager_id=plan_manager_id,
status=status if status in ["计划中", "已完成", "已取消"] else "计划中",
)
db.add(work_plan)
stats["work_plans"] += 1
except Exception:
stats["skipped"] += 1
# ── Parse Sheet 3: 小微业务商机 ──
if "小微业务商机" in wb.sheetnames:
ws3 = wb["小微业务商机"]
for row in ws3.iter_rows(min_row=2, values_only=True):
if not row[0]:
continue
try:
cust_name = str(row[0]).strip() if row[0] else ""
product_type = str(row[1]) if row[1] else ""
amount = str(row[2]) if row[2] else ""
follow_up = str(row[3]) if row[3] else ""
status = str(row[4]) if row[4] else "跟进中"
mgr_name = str(row[5]).strip() if row[5] else ""
expected_revenue = str(row[6]) if row[6] else ""
customer_id, _ = await resolve_customer(cust_name, mgr_name)
if not customer_id:
stats["skipped"] += 1
stats["skip_reasons"].append(f"商机:客户「{cust_name}」无法解析")
continue
biz_manager_id = get_assigned_manager(customer_id)
mini = MiniBusiness(
customer_id=customer_id,
product_type=product_type,
amount=amount,
follow_up_detail=follow_up,
status=status if status in ["跟进中", "已成交", "已流失"] else "跟进中",
manager_id=biz_manager_id,
expected_revenue_date=expected_revenue,
)
db.add(mini)
stats["mini_business"] += 1
except Exception:
stats["skipped"] += 1
# ── Parse Sheet 4: 要客拜访计划 ──
if "要客拜访计划" in wb.sheetnames:
ws4 = wb["要客拜访计划"]
for row in ws4.iter_rows(min_row=2, values_only=True):
if not row[0]:
continue
try:
cust_name = str(row[0]).strip() if row[0] else ""
urgency = str(row[1]) if row[1] else "一般"
description = str(row[2]) if row[2] else ""
progress = str(row[3]) if row[3] else "未开始"
planned_date = str(row[4]) if row[4] else ""
planned_visitor = str(row[5]) if row[5] else ""
visit_target = str(row[6]) if row[6] else ""
mgr_name = str(row[7]).strip() if row[7] else ""
customer_id, _ = await resolve_customer(cust_name, mgr_name)
if not customer_id:
stats["skipped"] += 1
stats["skip_reasons"].append(f"要客:客户「{cust_name}」无法解析")
continue
kv_manager_id = get_assigned_manager(customer_id)
valid_urgency = urgency if urgency in ["一般", "重要", "紧急"] else "一般"
valid_progress = progress if progress in ["未开始", "进行中", "已完成"] else "未开始"
key_visit = KeyVisit(
customer_id=customer_id,
urgency_level=valid_urgency,
description=description,
progress_status=valid_progress,
planned_date=planned_date,
planned_visitor=planned_visitor,
visit_target=visit_target,
manager_id=kv_manager_id,
)
db.add(key_visit)
stats["key_visits"] += 1
except Exception:
stats["skipped"] += 1
# ── Parse Sheet 5: 今日纪要 ──
if "今日纪要" in wb.sheetnames:
ws5 = wb["今日纪要"]
valid_categories = ["行政事务", "合同整理", "发票处理", "内部会议", "培训学习", "其他"]
for row in ws5.iter_rows(min_row=2, values_only=True):
if not row[0]:
continue
try:
note_date_str = str(row[0]) if row[0] else str(date.today())
category = str(row[1]) if row[1] else "其他"
content = str(row[2]) if row[2] else ""
time_range = str(row[3]) if row[3] else ""
mgr_name = str(row[4]).strip() if row[4] else ""
# Validate category
if category not in valid_categories:
category = "其他"
# Resolve manager by name
note_manager_id = manager_id # default to importer
if mgr_name:
mgr_id = user_map.get(mgr_name)
if mgr_id:
note_manager_id = mgr_id
else:
stats["skip_reasons"].append(f"纪要:客户经理「{mgr_name}」不存在,使用导入人")
try:
note_date = datetime.strptime(note_date_str[:10], "%Y-%m-%d").date()
except ValueError:
note_date = date.today()
# Skip duplicates: same manager, same date, same category
existing = await db.execute(
select(DailyNote).where(
DailyNote.note_date == note_date,
DailyNote.manager_id == note_manager_id,
DailyNote.category == category,
)
)
if existing.scalar_one_or_none():
stats["skipped"] += 1
stats["skip_reasons"].append(f"重复:{note_date} {category} 纪要已存在")
continue
daily_note = DailyNote(
manager_id=note_manager_id,
note_date=note_date,
category=category,
content=content,
time_range=time_range,
)
db.add(daily_note)
stats["daily_notes"] += 1
except Exception:
stats["skipped"] += 1
await db.commit()
return stats
import io
+136
View File
@@ -0,0 +1,136 @@
"""Chinese holiday data service — fetches from apisbo.com and caches to system_config."""
import json
import httpx
from datetime import date
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.system_config import SystemConfig
HOLIDAY_API = "https://api.apisbo.com/holidays/year"
async def _get_config(db: AsyncSession, key: str) -> str | None:
row = await db.get(SystemConfig, key)
return row.value if row else None
async def _set_config(db: AsyncSession, key: str, value: str):
row = await db.get(SystemConfig, key)
if row:
row.value = value
else:
db.add(SystemConfig(key=key, value=value))
await db.commit()
async def refresh_holidays(db: AsyncSession, year: int | None = None) -> dict:
"""Fetch Chinese holidays for a year from apisbo.com and cache to DB.
Caches two config keys:
- holidays: comma-separated rest-day dates (holidays + weekends already handled by weekday check)
We only store holiday dates here, since weekends are auto-detected.
- workdays: comma-separated makeup workday dates (调休, when Sat/Sun becomes a workday)
Returns dict with counts.
"""
year = year or date.today().year
# Fetch current year + next year on first load
years_to_fetch = {year}
existing = await _get_config(db, f"holidays_{year}")
if not existing:
years_to_fetch.add(year + 1)
all_holidays: list[str] = []
all_workdays: list[str] = []
for y in years_to_fetch:
try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(f"{HOLIDAY_API}/{y}")
resp.raise_for_status()
result = resp.json()
if result.get("code") != 0:
continue
holidays = []
workdays = []
for item in result.get("data", []):
d = item["date"]
if item["type"] == "holiday" and date.fromisoformat(d).weekday() < 5:
# Only cache weekday-holidays (weekend holidays are already skipped)
holidays.append(d)
elif item["type"] == "workday":
# Makeup workday — Saturday/Sunday that becomes a workday
workdays.append(d)
# Store per-year for reference, and accumulate for runtime use
await _set_config(db, f"holidays_{y}", ",".join(holidays))
await _set_config(db, f"workdays_{y}", ",".join(workdays))
all_holidays.extend(holidays)
all_workdays.extend(workdays)
except Exception:
# API unavailable — fall back to existing cached data
cached_h = await _get_config(db, f"holidays_{y}")
if cached_h:
all_holidays.extend([d for d in cached_h.split(",") if d.strip()])
cached_w = await _get_config(db, f"workdays_{y}")
if cached_w:
all_workdays.extend([d for d in cached_w.split(",") if d.strip()])
# Write runtime config keys (used by is_working_day)
if all_holidays or all_workdays:
await _set_config(db, "holidays", ",".join(sorted(all_holidays)))
await _set_config(db, "workdays", ",".join(sorted(all_workdays)))
return {
"holidays": len(all_holidays),
"workdays": len(all_workdays),
"years": sorted(years_to_fetch),
}
async def load_holiday_sets(db: AsyncSession) -> tuple[set[date], set[date]]:
"""Load holiday and workday date sets from cached config.
Returns (holidays_set, workdays_set).
- holidays_set: dates that are rest days (weekday holidays)
- workdays_set: dates that are workdays despite being weekends (调休)
"""
holidays: set[date] = set()
workdays: set[date] = set()
# Try runtime config first
h_val = await _get_config(db, "holidays")
if h_val:
for s in h_val.split(","):
s = s.strip()
if s:
try:
holidays.add(date.fromisoformat(s))
except ValueError:
pass
w_val = await _get_config(db, "workdays")
if w_val:
for s in w_val.split(","):
s = s.strip()
if s:
try:
workdays.add(date.fromisoformat(s))
except ValueError:
pass
# If no data cached yet, try to refresh
if not holidays and not workdays:
try:
await refresh_holidays(db)
# Reload after refresh
return await load_holiday_sets(db)
except Exception:
pass
return holidays, workdays
+204
View File
@@ -0,0 +1,204 @@
from datetime import date
from uuid import UUID
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.leave import Leave
from app.models.user import User
from app.utils.timezone import today_cst
from app.services.dashboard import get_week_range
async def get_leaves(
db: AsyncSession,
user_id: str,
role: str,
manager_id: str | None = None,
status: str | None = None,
page: int = 1,
page_size: int = 25,
) -> dict:
"""List leaves. Managers see only their own."""
today = today_cst()
query = select(Leave)
count_q = select(func.count(Leave.id))
# Role filter
if role == "manager":
query = query.where(Leave.manager_id == user_id)
count_q = count_q.where(Leave.manager_id == user_id)
elif manager_id:
query = query.where(Leave.manager_id == manager_id)
count_q = count_q.where(Leave.manager_id == manager_id)
# Status filter
if status == "active":
query = query.where(and_(Leave.start_date <= today, Leave.end_date >= today))
count_q = count_q.where(and_(Leave.start_date <= today, Leave.end_date >= today))
elif status == "upcoming":
query = query.where(Leave.start_date > today)
count_q = count_q.where(Leave.start_date > today)
elif status == "past":
query = query.where(Leave.end_date < today)
count_q = count_q.where(Leave.end_date < today)
total = (await db.execute(count_q)).scalar() or 0
query = query.order_by(Leave.start_date.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
rows = result.scalars().all()
# Resolve names
user_ids = set()
for r in rows:
user_ids.add(r.manager_id)
user_ids.add(r.submitted_by)
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
user_map = {u.id: u.name for u in users_result.scalars().all()}
items = []
for r in rows:
days = ((r.end_date - r.start_date).days + 1) if r.start_date and r.end_date else 0
items.append({
"id": r.id,
"manager_id": r.manager_id,
"manager_name": user_map.get(r.manager_id, ""),
"leave_type": r.leave_type,
"start_date": str(r.start_date),
"end_date": str(r.end_date),
"days": days,
"reason": r.reason or "",
"submitted_by": r.submitted_by,
"submitted_by_name": user_map.get(r.submitted_by, ""),
"created_at": str(r.created_at) if r.created_at else None,
"updated_at": str(r.updated_at) if r.updated_at else None,
})
return {"items": items, "total": total, "page": page, "page_size": page_size}
async def get_leave_by_id(db: AsyncSession, leave_id: UUID) -> Leave | None:
result = await db.execute(select(Leave).where(Leave.id == leave_id))
return result.scalar()
async def create_leave(
db: AsyncSession,
data: dict,
submitted_by: UUID,
role: str,
) -> Leave:
"""Create leave. Manager can only create for self; director can create for anyone."""
if role == "manager":
if str(data.get("manager_id")) != str(submitted_by):
raise PermissionError("客户经理只能为自己提交请假")
if data["start_date"] > data["end_date"]:
raise ValueError("结束日期不能早于开始日期")
leave = Leave(
manager_id=data["manager_id"],
leave_type=data.get("leave_type", "事假"),
start_date=data["start_date"],
end_date=data["end_date"],
reason=data.get("reason", ""),
submitted_by=submitted_by,
)
db.add(leave)
await db.commit()
await db.refresh(leave)
return leave
async def update_leave(
db: AsyncSession,
leave_id: UUID,
data: dict,
user_id: UUID,
role: str,
) -> Leave:
"""Update leave. Director can edit any; manager can edit own submitted leaves."""
leave = await get_leave_by_id(db, leave_id)
if not leave:
raise ValueError("请假记录不存在")
if role == "manager":
if str(leave.submitted_by) != str(user_id):
raise PermissionError("客户经理只能编辑自己提交的请假")
for field in ("manager_id", "leave_type", "start_date", "end_date", "reason"):
if field in data and data[field] is not None:
setattr(leave, field, data[field])
if leave.start_date > leave.end_date:
raise ValueError("结束日期不能早于开始日期")
await db.commit()
await db.refresh(leave)
return leave
async def delete_leave(
db: AsyncSession,
leave_id: UUID,
user_id: UUID,
role: str,
) -> bool:
"""Delete leave. Director can delete any; manager can delete own submitted leaves."""
leave = await get_leave_by_id(db, leave_id)
if not leave:
return False
if role == "manager":
if str(leave.submitted_by) != str(user_id):
raise PermissionError("客户经理只能删除自己提交的请假")
await db.delete(leave)
await db.commit()
return True
async def get_leave_overview(
db: AsyncSession,
user_id: str,
role: str,
reference_date: date | None = None,
) -> dict:
"""Get leave overview for the dashboard card. Returns leaves overlapping with current week."""
monday, sunday = get_week_range(reference_date)
query = select(Leave).where(
and_(Leave.start_date <= sunday, Leave.end_date >= monday)
)
if role == "manager":
query = query.where(Leave.manager_id == user_id)
result = await db.execute(query.order_by(Leave.start_date))
rows = result.scalars().all()
# Deduplicate by manager_id (one manager may have multiple leave records)
user_ids = set(r.manager_id for r in rows)
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
user_map = {u.id: u.name for u in users_result.scalars().all()}
manager_set = set()
leave_list = []
for r in rows:
days = ((r.end_date - r.start_date).days + 1) if r.start_date and r.end_date else 0
leave_list.append({
"manager_id": str(r.manager_id),
"manager_name": user_map.get(r.manager_id, ""),
"leave_type": r.leave_type,
"start_date": str(r.start_date),
"end_date": str(r.end_date),
"days": days,
})
manager_set.add(str(r.manager_id))
return {
"week_start": str(monday),
"week_end": str(sunday),
"total_on_leave": len(manager_set),
"leave_list": leave_list,
}
+264
View File
@@ -0,0 +1,264 @@
"""Customer light board — visit coverage matrix per manager.
Status (rolling 30-day window):
green — visited within last 30 days (亮灯)
yellow — visited 31-60 days ago (临期)
red — visited 61+ days ago, or never visited (灭灯+警示)
gray — customer has no assigned primary manager (未分配)
"""
from datetime import date, timedelta
from uuid import UUID
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.models.customer import Customer
from app.models.customer_assignment import CustomerAssignment
from app.models.visit import Visit
from app.models.work_plan import WorkPlan
from app.utils.timezone import today_cst
def month_start(ref: date) -> date:
return ref.replace(day=1)
def month_end(ref: date) -> date:
nxt = ref.replace(day=28) + timedelta(days=4)
return nxt - timedelta(days=nxt.day)
def classify(last_visit_date: date | None, ref: date) -> tuple[str, int]:
"""Return (status, consecutive_missed_periods).
30-day rolling window:
green — visited within 30 days
yellow — visited 31-60 days ago (临期)
red — 61+ days ago, or never visited
"""
if last_visit_date is None:
return ("red", 99) # never visited
days_since = (ref - last_visit_date).days
if days_since <= 30:
return ("green", 0)
elif days_since <= 60:
return ("yellow", 0)
# Each 30-day block beyond 60 days counts as one missed period
periods = days_since // 30
return ("red", min(periods, 99))
async def get_light_board(
db: AsyncSession,
reference_date: date | None = None,
user_id: UUID | None = None,
role: str = "manager",
) -> dict:
"""Get per-manager customer visit coverage for the light board.
Directors/leaders see all managers; managers see only themselves.
"""
ref = reference_date or today_cst()
# ── Managers (filtered by role) ──
managers_result = await db.execute(select(User).where(User.role == "manager"))
all_managers = managers_result.scalars().all()
# Filter: managers only see themselves
visible_managers = all_managers if role in ("director", "leader") else [
m for m in all_managers if m.id == user_id
]
manager_map: dict[UUID, dict] = {
m.id: {
"manager_id": str(m.id),
"manager_name": m.name,
"total_customers": 0,
"visited_this_month": 0,
"visited_last_month_only": 0,
"not_visited_2months": 0,
"coverage_rate": 0.0,
"customers": [],
}
for m in visible_managers
}
# ── All customers with their last visit per manager ──
# Subquery: latest visit date + method per (manager_id, customer_id)
latest_visit = (
select(
Visit.manager_id,
Visit.customer_id,
func.max(Visit.visit_date).label("last_visit_date"),
)
.group_by(Visit.manager_id, Visit.customer_id)
.subquery()
)
# Join: assignments → customers → latest_visit → visits (for method)
rows = await db.execute(
select(
CustomerAssignment.manager_id,
CustomerAssignment.customer_id,
Customer.id.label("c_id"),
Customer.name.label("c_name"),
Customer.industry,
Customer.in_use_services,
Customer.monthly_fee,
latest_visit.c.last_visit_date,
Visit.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)
)
assigned_customer_ids: set[UUID] = set()
for row in rows.all():
mgr_id, cust_id, c_id, c_name, industry, services, fee, lvd, method = row
assigned_customer_ids.add(c_id)
status, missed = classify(lvd, ref)
cust_entry = {
"customer_id": str(c_id),
"customer_name": c_name,
"industry": industry or "",
"in_use_services": services or "",
"monthly_fee": str(fee) if fee else "",
"last_visit_date": str(lvd) if lvd else None,
"last_visit_method": method or "",
"status": status,
"consecutive_missed_months": missed,
}
mgr_entry = manager_map.get(mgr_id)
if mgr_entry:
mgr_entry["customers"].append(cust_entry)
mgr_entry["total_customers"] += 1
if status == "green":
mgr_entry["visited_this_month"] += 1
elif status == "yellow":
mgr_entry["visited_last_month_only"] += 1
else:
mgr_entry["not_visited_2months"] += 1
# ── Active work plans per customer ──
active_plans_result = await db.execute(
select(
WorkPlan.customer_id,
WorkPlan.plan_content,
WorkPlan.plan_date,
WorkPlan.status,
WorkPlan.manager_id,
).where(
WorkPlan.customer_id.in_(assigned_customer_ids),
WorkPlan.status == "计划中",
).order_by(WorkPlan.plan_date)
)
plans_map: dict[UUID, list] = {}
for row in active_plans_result.all():
cid, content, pdate, pstatus, pmanager = row
plans_map.setdefault(cid, []).append({
"plan_content": content,
"plan_date": str(pdate),
"status": pstatus,
"plan_overdue": pdate < ref,
"manager_id": str(pmanager) if pmanager else None,
})
# ── Recent visits for green/yellow customers ──
recent_visits_result = await db.execute(
select(
Visit.customer_id,
Visit.visit_date,
Visit.visit_method,
Visit.communication_content,
Visit.manager_id,
).where(
Visit.customer_id.in_(assigned_customer_ids),
).order_by(Visit.visit_date.desc()).limit(500)
)
visits_map: dict[UUID, list] = {}
for row in recent_visits_result.all():
cid, vdate, vmethod, vcontent, vmanager = row
visits_map.setdefault(cid, []).append({
"visit_date": str(vdate),
"visit_method": vmethod,
"content": (vcontent or "")[:120],
"manager_id": str(vmanager) if vmanager else None,
})
# ── Merge plans & visits into customer entries ──
for mgr_entry in manager_map.values():
for c in mgr_entry["customers"]:
cid = UUID(c["customer_id"])
c["plans"] = plans_map.get(cid, [])
c["recent_visits"] = (visits_map.get(cid, []) or [])[:2]
# ── Calculate coverage rates ──
for mgr_entry in manager_map.values():
total = mgr_entry["total_customers"]
if total > 0:
mgr_entry["coverage_rate"] = round(mgr_entry["visited_this_month"] / total, 3)
# Sort managers: lowest coverage first (most problematic first)
manager_list = sorted(manager_map.values(), key=lambda m: m["coverage_rate"])
# ── Unassigned customers (no primary manager) ──
# Only visible to directors/leaders — not actionable for individual managers
unassigned = []
if role in ("director", "leader"):
unassigned_rows = await db.execute(
select(Customer)
.outerjoin(CustomerAssignment, and_(
CustomerAssignment.customer_id == Customer.id,
CustomerAssignment.role == "primary",
))
.where(CustomerAssignment.id == None)
.order_by(Customer.name)
)
for c in unassigned_rows.scalars():
unassigned.append({
"customer_id": str(c.id),
"customer_name": c.name,
"industry": c.industry or "",
"in_use_services": c.in_use_services or "",
"monthly_fee": str(c.monthly_fee) if c.monthly_fee else "",
"last_visit_date": None,
"last_visit_method": "",
"status": "gray",
"consecutive_missed_months": 0,
})
# ── Team summary ──
all_total = sum(m["total_customers"] for m in manager_list)
all_green = sum(m["visited_this_month"] for m in manager_list)
all_yellow = sum(m["visited_last_month_only"] for m in manager_list)
all_red = sum(m["not_visited_2months"] for m in manager_list)
team_summary = {
"total_customers": all_total,
"visited_this_month": all_green,
"visited_last_month_only": all_yellow,
"not_visited_2months": all_red,
"unassigned": len(unassigned),
"coverage_rate": round(all_green / all_total, 3) if all_total > 0 else 0.0,
}
return {
"reference_month": ref.strftime("%Y-%m"),
"managers": manager_list,
"unassigned_customers": unassigned,
"team_summary": team_summary,
}
+137 -15
View File
@@ -1,24 +1,42 @@
from datetime import date, datetime
from sqlalchemy import select, func
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.visit import Visit
from app.models.daily_note import DailyNote
from app.models.user import User
from app.models.leave import Leave
from app.models.work_plan import WorkPlan
from app.models.customer import Customer
from app.services.holidays import load_holiday_sets
from app.services.wecom import wecom_client
from app.utils.timezone import today_cst
from app.utils.timezone import today_cst, is_working_day
async def check_daily_reporting(db: AsyncSession) -> dict:
"""Check today's reporting progress and send wecom reminders to managers who haven't reported."""
today = today_cst()
weekday = today.weekday()
if weekday >= 5: # Skip weekends
return {"status": "weekend", "date": str(today)}
# Get all managers
result = await db.execute(select(User).where(User.role == "manager"))
# Load holiday data (auto-refreshes from API if not cached)
holidays, workdays = await load_holiday_sets(db)
if not is_working_day(today, holidays, workdays):
return {"status": "rest_day", "date": str(today)}
# Get all users who need to report
result = await db.execute(
select(User).where(User.require_report == True)
)
managers = result.scalars().all()
# ── Exclude managers on leave today ──
leaves_today = await db.execute(
select(Leave.manager_id).where(and_(
Leave.start_date <= today,
Leave.end_date >= today,
))
)
on_leave_ids = {str(uid) for uid, in leaves_today.all()}
# Get managers who have reported today
reported_visits = await db.execute(
select(Visit.manager_id).where(Visit.visit_date == today)
@@ -31,19 +49,64 @@ async def check_daily_reporting(db: AsyncSession) -> dict:
reported_map[str(uid)] = True
not_reported = []
reported_names = []
on_leave_names = []
for m in managers:
if str(m.id) not in reported_map:
if str(m.id) in on_leave_ids:
on_leave_names.append(m.name)
continue # skip — exempt from reporting
if str(m.id) in reported_map:
reported_names.append(m.name)
else:
not_reported.append(m)
if not_reported and managers:
content = f"📋 今日填报提醒({today}\n\n以下同事尚未提交今日拜访记录:\n"
for m in not_reported:
content += f"{m.name}\n"
content += "\n请尽快完成今日拜访填报 🙏"
# 1. Send template card to not-reported managers (tap to open app)
if not_reported:
user_ids = [m.wecom_userid for m in not_reported if m.wecom_userid]
if user_ids:
await wecom_client.send_text_message(user_ids, content)
desc = f"{today} | 已填报 {len(reported_names)}/{len(managers)}"
success = await wecom_client.send_template_card(
user_ids=user_ids,
title="📋 今日填报提醒",
desc=desc,
url="https://qj.dhdx.fun/m",
btn_text="去填报",
)
# Fallback to text
if not success:
names_text = "".join(m.name for m in not_reported)
await wecom_client.send_text_message(
user_ids,
f"📋 今日填报提醒\n\n{desc}\n未填报:{names_text}\n\n请尽快完成填报 🙏\nhttps://qj.dhdx.fun/m",
)
# 2. Send summary to director
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:
effective_total = len(managers) - len(on_leave_names)
if effective_total > 0:
pct = len(reported_map) / effective_total * 100
else:
pct = 100.0
leave_note = ""
if on_leave_names:
leave_note = f"\n> 请假中(已豁免):{len(on_leave_names)}\n"
leave_note += "".join(f"- {n} (请假)\n" for n in on_leave_names)
summary = (
f"## 📊 今日填报汇总\n\n"
f"> 日期:{today}\n"
f"> 填报率:**{pct:.0f}%** ({len(reported_map)}/{effective_total})\n"
)
if leave_note:
summary += leave_note + "\n"
if not_reported:
summary += "**未填报:**\n" + "".join(f"- {m.name}\n" for m in not_reported)
else:
summary += "✅ 全体已完成今日填报"
await wecom_client.send_markdown_message(summary) # broadcast for director visibility
return {
"status": "ok",
@@ -52,3 +115,62 @@ async def check_daily_reporting(db: AsyncSession) -> dict:
"reported": len(reported_map),
"not_reported": len(not_reported),
}
async def check_overdue_plans(db: AsyncSession) -> dict:
"""Check for overdue work plans and remind managers (runs at 9:00 AM)."""
today = today_cst()
if today.weekday() >= 5:
return {"status": "weekend", "date": str(today)}
result = await db.execute(
select(WorkPlan).where(
WorkPlan.status == "计划中",
WorkPlan.plan_date < today,
).order_by(WorkPlan.manager_id, WorkPlan.plan_date)
)
overdue = result.scalars().all()
if not overdue:
return {"status": "ok", "date": str(today), "overdue": 0}
# Group by manager
by_manager: dict[str, list] = {}
for p in overdue:
mid = str(p.manager_id)
by_manager.setdefault(mid, []).append(p)
users_result = await db.execute(
select(User).where(User.id.in_([uid for uid in by_manager.keys()]))
)
user_map = {str(u.id): u for u in users_result.scalars().all()}
for mid, plans in by_manager.items():
user = user_map.get(mid)
if not user or not user.wecom_userid:
continue
names = "".join(f"{p.customer_id}" for p in plans[:5])
# Get customer names
cust_result = await db.execute(
select(Customer.name).where(Customer.id.in_([p.customer_id for p in plans[:5]]))
)
cust_names = [r[0] for r in cust_result.all()]
plan_lines = "".join(f"- {n} (计划 {p.plan_date})\n" for p, n in zip(plans[:5], cust_names))
content = (
f"📅 拜访计划过期提醒\n\n"
f"以下 {len(plans)} 个拜访计划已过期,请尽快安排拜访:\n"
f"{plan_lines}"
)
if len(plans) > 5:
content += f"... 还有 {len(plans) - 5} 个过期计划\n"
content += f"\n👉 查看详情:https://qj.dhdx.fun/light-board"
await wecom_client.send_text_message([user.wecom_userid], content)
return {
"status": "ok",
"date": str(today),
"overdue": len(overdue),
"managers_affected": len(by_manager),
}
+59
View File
@@ -0,0 +1,59 @@
"""APScheduler singleton — allows runtime reschedule of notification jobs."""
from apscheduler.schedulers.asyncio import AsyncIOScheduler
_scheduler: AsyncIOScheduler | None = None
def get_scheduler() -> AsyncIOScheduler:
"""Lazy-init and return the global scheduler."""
global _scheduler
if _scheduler is None:
_scheduler = AsyncIOScheduler()
return _scheduler
async def reschedule_daily_check(hour: int, minute: int):
"""Update the daily_check cron job to a new time."""
sched = get_scheduler()
# Remove and re-add — APScheduler reschedule_job is inconsistent with cron triggers
try:
sched.remove_job("daily_check")
except Exception:
pass
from app.services.scheduler import check_daily_reporting
from app.database import async_session
async def _wrapper():
async with async_session() as db:
await check_daily_reporting(db)
sched.add_job(_wrapper, "cron", hour=hour, minute=minute, id="daily_check")
def start_scheduler(notification_hour: int = 17, notification_minute: int = 30):
"""Start the scheduler with configured notification times."""
sched = get_scheduler()
from app.services.scheduler import check_daily_reporting, check_overdue_plans
from app.database import async_session
async def _daily():
async with async_session() as db:
await check_daily_reporting(db)
async def _overdue():
async with async_session() as db:
await check_overdue_plans(db)
sched.add_job(_daily, "cron", hour=notification_hour, minute=notification_minute, id="daily_check")
sched.add_job(_overdue, "cron", hour=9, minute=0, id="overdue_check")
sched.start()
def shutdown_scheduler():
"""Shutdown the scheduler gracefully."""
global _scheduler
if _scheduler is not None:
_scheduler.shutdown(wait=False)
_scheduler = None
+209 -23
View File
@@ -1,55 +1,241 @@
import time
import uuid
import httpx
from app.config import settings
# 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."""
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]
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
class WecomClient:
"""Minimal WeChat Work API client for sending app messages."""
"""WeChat Work API client — token management, message sending, OAuth."""
def __init__(self):
self.corp_id = settings.WECOM_CORP_ID
self.agent_id = settings.WECOM_AGENT_ID
self.secret = settings.WECOM_SECRET
self._access_token: str | None = None
self._token_expires_at: float = 0 # epoch seconds
@property
def access_token(self) -> str | None:
return self._access_token
async def _get_token(self) -> str:
if self._access_token:
"""Get a valid access token, refreshing if expired."""
if self._access_token and time.time() < self._token_expires_at - 60:
return self._access_token
url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corp_id}&corpsecret={self.secret}"
url = f"{settings.WECOM_API_BASE}/cgi-bin/gettoken?corpid={self.corp_id}&corpsecret={self.secret}"
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=10)
data = resp.json()
if data.get("errcode") == 0:
self._access_token = data["access_token"]
self._token_expires_at = time.time() + data.get("expires_in", 7200)
return self._access_token
raise Exception(f"Failed to get wecom token: {data}")
async def _post_with_retry(self, url: str, body: dict, max_retries: int = 3) -> dict:
"""POST with retry on network errors and automatic token refresh on 42001."""
last_error = None
for attempt in range(max_retries):
try:
token = await self._get_token()
sep = "&" if "?" in url else "?"
full_url = f"{url}{sep}access_token={token}"
async with httpx.AsyncClient() as client:
resp = await client.post(full_url, json=body, timeout=10)
data = resp.json()
errcode = data.get("errcode", 0)
if errcode == 0:
return data
if errcode == 42001:
self._access_token = None
self._token_expires_at = 0
if attempt < max_retries - 1:
continue
if errcode == 45009:
if attempt < max_retries - 1:
time.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))
raise Exception(f"WeCom API error after {max_retries} retries: {last_error}")
# ── OAuth ──
async def get_userinfo_by_code(self, code: str) -> dict | None:
"""Exchange OAuth2 code for userid (used in silent login)."""
token = await self._get_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_token={token}&code={code}"
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=10)
data = resp.json()
if data.get("errcode") == 0:
return data
try:
token = await self._get_token()
url = f"{settings.WECOM_API_BASE}/cgi-bin/auth/getuserinfo?access_token={token}&code={code}"
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=10)
data = resp.json()
if data.get("errcode") == 0:
return data
return None
except Exception:
return None
# ── Messaging ──
async def send_text_message(self, user_ids: list[str], content: str) -> bool:
"""Send a text app message to specified users."""
if not settings.WECOM_AGENT_ID:
return False # Not configured, skip silently in dev
token = await self._get_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}"
body = {
"touser": "|".join(user_ids),
"msgtype": "text",
"agentid": int(settings.WECOM_AGENT_ID),
"text": {"content": content},
}
async with httpx.AsyncClient() as client:
resp = await client.post(url, json=body, timeout=10)
data = resp.json()
return data.get("errcode") == 0
return False
try:
body = {
"touser": "|".join(user_ids),
"msgtype": "text",
"agentid": int(settings.WECOM_AGENT_ID),
"text": {"content": content},
}
await self._post_with_retry(
f"{settings.WECOM_API_BASE}/cgi-bin/message/send", body
)
return True
except Exception:
return False
async def send_markdown_message(self, content: str) -> bool:
"""Send a markdown message to all users in the app (broadcast)."""
if not settings.WECOM_AGENT_ID:
return False
try:
body = {
"touser": "@all",
"msgtype": "markdown",
"agentid": int(settings.WECOM_AGENT_ID),
"markdown": {"content": content},
}
await self._post_with_retry(
f"{settings.WECOM_API_BASE}/cgi-bin/message/send", body
)
return True
except Exception:
return False
async def send_text_card(self, user_id: str, title: str, description: str, url: str) -> bool:
"""Send a textcard message (clickable card) to a single user."""
if not settings.WECOM_AGENT_ID:
return False
try:
body = {
"touser": user_id,
"msgtype": "textcard",
"agentid": int(settings.WECOM_AGENT_ID),
"textcard": {
"title": title,
"description": description,
"url": url,
},
}
await self._post_with_retry(
f"{settings.WECOM_API_BASE}/cgi-bin/message/send", body
)
return True
except Exception:
return False
async def send_template_card(
self, user_ids: list[str], title: str, desc: str, url: str, btn_text: str = "查看详情"
) -> bool:
"""Send a text_notice template card with a deep-link button."""
if not settings.WECOM_AGENT_ID:
return False
try:
body = {
"touser": "|".join(user_ids),
"msgtype": "template_card",
"agentid": int(settings.WECOM_AGENT_ID),
"template_card": {
"card_type": "text_notice",
"main_title": {"title": title, "desc": desc},
"card_action": {"type": 1, "url": url},
"button_list": [{"text": btn_text, "style": 1, "key": "open_url"}],
},
}
await self._post_with_retry(
f"{settings.WECOM_API_BASE}/cgi-bin/message/send", body
)
return True
except Exception:
return False
# ── Menu management ──
async def create_menu(self, buttons: list[dict]) -> bool:
"""Create/replace the app's custom menu."""
if not settings.WECOM_AGENT_ID:
return False
try:
body = {"button": buttons}
await self._post_with_retry(
f"{settings.WECOM_API_BASE}/cgi-bin/menu/create?agentid={settings.WECOM_AGENT_ID}", body
)
return True
except Exception:
return False
async def get_menu(self) -> dict | None:
"""Get current app menu configuration."""
if not settings.WECOM_AGENT_ID:
return None
try:
token = await self._get_token()
url = f"{settings.WECOM_API_BASE}/cgi-bin/menu/get?access_token={token}&agentid={settings.WECOM_AGENT_ID}"
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=10)
data = resp.json()
if data.get("errcode") == 0:
return data
return None
except Exception:
return None
async def delete_menu(self) -> bool:
"""Delete the app's custom menu."""
if not settings.WECOM_AGENT_ID:
return False
try:
token = await self._get_token()
url = f"{settings.WECOM_API_BASE}/cgi-bin/menu/delete?access_token={token}&agentid={settings.WECOM_AGENT_ID}"
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=10)
data = resp.json()
return data.get("errcode") == 0
except Exception:
return False
wecom_client = WecomClient()
+29
View File
@@ -11,3 +11,32 @@ def today_cst() -> date:
def parse_date(s: str) -> date:
"""Parse a date string that may be YYYY-MM-DD or a full ISO datetime."""
return date.fromisoformat(s[:10])
def is_working_day(d: date | None = None, holidays: set[date] | None = None, workdays: set[date] | None = None) -> bool:
"""Check if a date is a Chinese working day.
Returns False for:
- Saturdays and Sundays (weekday >= 5), UNLESS in the workdays set (调休)
- Dates in the holidays set (weekday holidays like 春节/国庆)
Returns True for:
- Monday-Friday, unless in holidays set
- Saturday/Sunday that are in the workdays set (调休 makeup days)
"""
d = d or today_cst()
is_weekend = d.weekday() >= 5
# 调休: weekend that becomes a workday
if is_weekend and workdays and d in workdays:
return True
# Normal weekend
if is_weekend:
return False
# Weekday holiday
if holidays and d in holidays:
return False
return True
+1
View File
@@ -12,3 +12,4 @@ minio==7.2.10
openpyxl==3.1.5
apscheduler==3.11.0
python-dotenv==1.0.1
pycryptodome
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,349 @@
# 客户经理请假功能 — 设计文档
> 日期:2026-07-12 | 状态:已确认
## 一、需求概述
为政企周报管理系统添加客户经理请假功能。请假可由客户经理自己提交或支局长代为提交,提交即生效(无需审批)。请假期间免填报考核、免催办提醒,AI 周报摘要标注请假信息。
## 二、核心决策
| 决策项 | 结论 |
|--------|------|
| 粒度 | 按天(start_date ~ end_date |
| 审批 | 无需审批,提交即生效 |
| 类型 | 年假 / 事假 / 病假 / 调休 / 其他 |
| 权限 | 经理看自己,支局长/领导看全员 |
| 入口 | PC 端仪表盘卡片 + 独立页面 + 移动端首页 + 表单页 |
## 三、数据模型
### 新增表 `leaves`
```sql
CREATE TABLE leaves (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
manager_id UUID NOT NULL REFERENCES users(id),
leave_type VARCHAR(20) NOT NULL DEFAULT '事假', -- 年假/事假/病假/调休/其他
start_date DATE NOT NULL,
end_date DATE NOT NULL,
reason VARCHAR(500) DEFAULT '',
submitted_by UUID NOT NULL REFERENCES users(id), -- 提交人(本人或支局长)
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_leaves_manager_id ON leaves(manager_id);
CREATE INDEX idx_leaves_dates ON leaves(start_date, end_date);
```
### 设计要点
- `start_date <= end_date`,应用层校验
- 判断"今天是否请假中":`start_date <= today <= end_date`
- 判断"本周是否有请假":`start_date <= sunday AND end_date >= monday`
- 无 edit_log 列(请假记录简单,不追变更历史)
### SQLAlchemy 模型
```python
class Leave(Base):
__tablename__ = "leaves"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
leave_type: Mapped[str] = mapped_column(String(20), default="事假")
start_date: Mapped[date] = mapped_column(Date)
end_date: Mapped[date] = mapped_column(Date)
reason: Mapped[str] = mapped_column(String(500), default="")
submitted_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
```
## 四、业务逻辑变更
### 4.1 填报进度判断 (`get_reporting_progress`)
**文件:** `backend/app/services/dashboard.py`
变更点:
1. 查询本周所有请假记录(`start_date <= sunday AND end_date >= monday`
2. 对每个经理判断今日是否在请假区间内
3. 新增第四种状态 `on_leave`
```python
# 新增返回字段
{
"manager_id": "uuid",
"has_reported_today": bool,
"completed": bool,
"on_leave": bool, # ← 新增
"leave_info": { # ← 新增(仅 on_leave=True 时有值)
"leave_type": "事假",
"start_date": "...",
"end_date": "..."
} | null
}
```
前端三色状态扩展为四色:
| 状态 | 条件 | 颜色 | 印章 |
|------|------|------|------|
| `full` | 已填 + 完成 | 绿 (sage) | 满 |
| `catching` | 已填 + 追赶中 | 黄 (gold) | 追 |
| `missing` | 未填 | 红 (vermilion) | 未 |
| `on_leave` | 请假中 | 蓝灰 | 假 |
### 4.2 催办提醒 (`check_daily_reporting`)
**文件:** `backend/app/services/scheduler.py`
变更点:
1. 查询当天请假中的经理(`start_date <= today <= end_date`
2. 从催办列表中排除这些经理
3. 汇总消息标注"X人请假,已豁免"
### 4.3 仪表盘统计卡
**文件:** `backend/app/services/dashboard.py` + 前端 `Dashboard.vue`
新增第五张统计卡「本周请假」:
- 数字:本周请假总人数
- 数据来源:统计 `start_date <= sunday AND end_date >= monday` 的去重 manager 数
- 点击跳转 `/leaves`
- 颜色:蓝灰调
### 4.4 AI 摘要提示词
**文件:** `backend/app/services/ai_summary.py``build_summary_prompt()`
新增一节注入请假数据:
```markdown
## 本周请假情况
- 张三 (事假): 2026-07-13 ~ 2026-07-15 (3天)
- 李四 (年假): 2026-07-14 ~ 2026-07-17 (4天)
```
效果:LLM 生成的摘要会自然提及"本周张三请事假3天未参与拜访"等信息。
## 五、API 设计
所有接口挂载在 `/api/leaves`,需登录认证。
### 5.1 列表
```
GET /api/leaves?manager_id=xxx&status=active&page=1&page_size=25
```
权限:`director`/`leader` 看全员,`manager` 只返回自己的记录。
`status` 筛选项:`active`(进行中)/ `upcoming`(未来)/ `past`(已结束)/ 不传全部。
默认按 `start_date DESC` 排序。
### 5.2 新建
```
POST /api/leaves
```
```json
{
"manager_id": "uuid",
"leave_type": "年假",
"start_date": "2026-07-13",
"end_date": "2026-07-15",
"reason": "回老家"
}
```
权限:`manager` 只能给自己建(manager_id 必须等于自己),`director` 可以给任何人建。
校验:`end_date >= start_date`
### 5.3 编辑
```
PUT /api/leaves/{id}
```
请求体同新建。权限:`director` 可编辑任意记录,`manager` 仅可编辑自己提交且尚未开始的记录。
### 5.4 删除
```
DELETE /api/leaves/{id}
```
权限:同编辑。
### 5.5 概览(仪表盘用)
```
GET /api/leaves/overview?reference_date=2026-07-12
```
```json
{
"week_start": "2026-07-13",
"week_end": "2026-07-19",
"total_on_leave": 2,
"leave_list": [
{
"manager_id": "uuid",
"manager_name": "张三",
"leave_type": "事假",
"start_date": "2026-07-13",
"end_date": "2026-07-15",
"days": 3
}
]
}
```
权限:`director`/`leader` 返回全员请假数据,`manager` 只返回自己。
## 六、前端设计
### 6.1 PC 端 — 仪表盘卡片
**文件:** `frontend/src/views/desktop/Dashboard.vue`
- 新增第五张统计卡「本周请假」
- 样式与现有四卡一致(stat-card + stat-glyph + 数字 + 标签)
- 颜色:蓝灰色调(`--slate` 系列 CSS 变量)
- 点击跳转 `/leaves`
- 数据来源:`GET /api/leaves/overview`
### 6.2 PC 端 — 请假管理页
**文件:** 新增 `frontend/src/views/desktop/Leaves.vue`
**路由:** `/leaves`DesktopLayout 侧边栏「工作」分组下新增「请假管理」
**页面结构:**
- 顶部汇总栏:按请假类型分组统计条数(年假X / 事假X / 病假X / 调休X / 其他X)
- 筛选栏:按经理筛选(支局长/领导可见)、按状态筛选(进行中 / 即将开始 / 已结束)
- 表格列:姓名 + 类型(彩色标签)+ 日期范围 + 天数 + 原因摘要 + 提交人 + 操作(编辑/删除)
- 进行中的行蓝色左边框高亮
- 新增/编辑弹窗(el-dialog):选择经理(支局长代填时)、请假类型 chip 按钮组、日期范围选择器、原因 textarea
**参考文件:**
- 汇总栏样式:参考 `WorkPlans.vue` / `KeyVisits.vue` 的顶部汇总栏
- 表格模式:参考 `CustomerManage.vue` 的 el-table + 分页
- 表单弹窗:参考各独立工作页的 CRUD 弹窗
### 6.3 PC 端 — 填报进度卡片
**文件:** `frontend/src/views/desktop/Dashboard.vue`
- 请假中的经理卡片:蓝色「假」印章水印 + 灰色进度条 + "请假中"文案
- 不计入未填报人数统计
### 6.4 PC 端 — 侧边栏
**文件:** `frontend/src/components/DesktopLayout.vue`
侧边栏「工作」分组新增:
```
工作
├─ 工作计划
├─ 商机跟单
├─ 要客拜访
└─ 请假管理 ← 新增,📋 icon
```
### 6.5 移动端 — 首页入口
**文件:** `frontend/src/views/mobile/Home.vue`
- 在快捷按钮区域(现有「工作计划」「商机跟单」「要客拜访」旁)新增「请假」chip
- 如果当前有进行中的请假,chip 右上角显示蓝色小圆点
- 点击跳转 `/m/leaves`
### 6.6 移动端 — 请假列表页
**文件:** 新增 `frontend/src/views/mobile/LeavesList.vue`
**路由:** `/m/leaves`
- 卡片流式列表(参考 Home.vue 的记录卡片风格)
- 每条记录:类型彩色标签 + 日期范围 + 天数 + 原因
- 进行中的卡片蓝色左边框
- 顶部有「新建请假」按钮(ink 风格全宽按钮)
### 6.7 移动端 — 请假表单页
**文件:** 新增 `frontend/src/views/mobile/LeaveForm.vue`
**路由:** `/m/leave/new` + `/m/leave/:id/edit`(复用同一组件)
- 沿用现有移动端表单模式(editorial header + 返回按钮 + 中文标题 + 英文副标题 + 金色装饰线)
- 请假类型:chip 按钮组(年假/事假/病假/调休/其他),参考 VisitForm 的拜访方式 chip
- 日期范围:开始日期 → 结束日期
- 原因:textarea,选填
- 提交按钮:全宽暗色按钮「提交请假」
- 编辑模式下额外显示删除按钮(vermilion 描边)
### 6.8 移动端 — 路由配置
**文件:** `frontend/src/router/index.ts`
新增 3 条移动端路由:
```typescript
{ path: 'leaves', component: LeavesList }, // /m/leaves
{ path: 'leave/new', component: LeaveForm }, // /m/leave/new
{ path: 'leave/:id/edit', component: LeaveForm }, // /m/leave/:id/edit
```
## 七、文件变更清单
### 后端新增
| 文件 | 说明 |
|------|------|
| `backend/app/models/leave.py` | Leave SQLAlchemy 模型 |
| `backend/app/schemas/leave.py` | Pydantic 请求/响应 schema |
| `backend/app/api/leaves.py` | REST API 路由 |
| `backend/app/services/leaves.py` | 业务逻辑层 |
| `backend/alembic/versions/xxxx_add_leaves_table.py` | 数据库迁移 |
### 后端修改
| 文件 | 改动 |
|------|------|
| `backend/app/services/dashboard.py` | 填报进度 +`on_leave` 状态;统计卡 +请假数 |
| `backend/app/services/scheduler.py` | 催办过滤请假人员 |
| `backend/app/services/ai_summary.py` | `build_summary_prompt` 注入请假数据 |
| `backend/app/models/__init__.py` | 导入 Leave 模型 |
| `backend/app/main.py` | 注册 leaves 路由 + lifespan 自动建表 |
### 前端新增
| 文件 | 说明 |
|------|------|
| `frontend/src/views/desktop/Leaves.vue` | PC 端请假管理页 |
| `frontend/src/views/mobile/LeavesList.vue` | 移动端请假列表页 |
| `frontend/src/views/mobile/LeaveForm.vue` | 移动端请假表单页 |
| `frontend/src/api/leaves.ts` | Axios API 模块 |
### 前端修改
| 文件 | 改动 |
|------|------|
| `frontend/src/views/desktop/Dashboard.vue` | 新增请假统计卡 + 填报进度 on_leave 状态 |
| `frontend/src/components/DesktopLayout.vue` | 侧边栏新增请假管理入口 |
| `frontend/src/router/index.ts` | 新增 /leaves + /m/leaves 等路由 |
| `frontend/src/views/mobile/Home.vue` | 新增请假快捷入口 chip |
## 八、验证要点
1. 经理提交请假后,当天仪表盘填报进度显示蓝「假」状态
2. 请假中的经理不收到催办提醒
3. 支局长可代任意经理提交请假
4. AI 摘要中体现请假信息
5. 移动端可完成请假提交→列表查看→编辑→删除全流程
6. 历史周翻看时,请假数据对应当时的日期区间
+6 -2
View File
@@ -2,9 +2,13 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>企迹 - 政企周报管理系统</title>
<!-- Preconnect to Google Fonts origins for faster font loading -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
</head>
<body>
<div id="app"></div>
+9 -12
View File
@@ -5,22 +5,19 @@ server {
root /usr/share/nginx/html;
index index.html;
# Vue app
# Immutable hashed static assets (Vite content-hashed filenames)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# API proxy
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Health check
location /health {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
return 200 "ok";
}
}
+662
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"axios": "^1.7.9",
"dom-to-image-more": "^3.10.0",
"element-plus": "^2.9.1",
"pinia": "^2.3.0",
"vue": "^3.5.13",
@@ -21,6 +22,8 @@
"postcss": "^8.5.15",
"tailwindcss": "^3.4.19",
"typescript": "~5.6.0",
"unplugin-element-plus": "^0.11.2",
"unplugin-vue-components": "^32.1.0",
"vite": "^6.0.5",
"vue-tsc": "^2.2.0"
}
@@ -580,6 +583,17 @@
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -645,6 +659,48 @@
"node": ">= 8"
}
},
"node_modules/@nuxt/kit": {
"version": "4.4.8",
"resolved": "https://registry.npmmirror.com/@nuxt/kit/-/kit-4.4.8.tgz",
"integrity": "sha512-ZUlZ5iYfyfJFDPluhn6ZxFWcsuxWbLnZBc8w3MAROcQ4lYfZ+qFpALBLSNlpc0zhOa++33EE+5PEbOAdVIY+dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"c12": "^3.3.4",
"consola": "^3.4.2",
"defu": "^6.1.7",
"destr": "^2.0.5",
"errx": "^0.1.0",
"exsolve": "^1.0.8",
"ignore": "^7.0.5",
"jiti": "^2.7.0",
"klona": "^2.0.6",
"mlly": "^1.8.2",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"pkg-types": "^2.3.1",
"rc9": "^3.0.1",
"scule": "^1.3.0",
"semver": "^7.8.1",
"tinyglobby": "^0.2.17",
"ufo": "^1.6.4",
"unctx": "^2.5.0",
"untyped": "^2.0.0"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/@nuxt/kit/node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/@popperjs/core": {
"name": "@sxzz/popperjs-es",
"version": "2.11.8",
@@ -1270,6 +1326,19 @@
"vue": "^3.5.0"
}
},
"node_modules/acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
@@ -1481,6 +1550,75 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/c12": {
"version": "3.3.4",
"resolved": "https://registry.npmmirror.com/c12/-/c12-3.3.4.tgz",
"integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^5.0.0",
"confbox": "^0.2.4",
"defu": "^6.1.6",
"dotenv": "^17.3.1",
"exsolve": "^1.0.8",
"giget": "^3.2.0",
"jiti": "^2.6.1",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"perfect-debounce": "^2.1.0",
"pkg-types": "^2.3.0",
"rc9": "^3.0.1"
},
"peerDependencies": {
"magicast": "*"
},
"peerDependenciesMeta": {
"magicast": {
"optional": true
}
}
},
"node_modules/c12/node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/c12/node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/c12/node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -1563,6 +1701,16 @@
"node": ">= 6"
}
},
"node_modules/citty": {
"version": "0.1.6",
"resolved": "https://registry.npmmirror.com/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"consola": "^3.2.3"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -1585,6 +1733,23 @@
"node": ">= 6"
}
},
"node_modules/confbox": {
"version": "0.2.4",
"resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.2.4.tgz",
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
"dev": true,
"license": "MIT"
},
"node_modules/consola": {
"version": "3.4.2",
"resolved": "https://registry.npmmirror.com/consola/-/consola-3.4.2.tgz",
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
@@ -1634,6 +1799,13 @@
}
}
},
"node_modules/defu": {
"version": "6.1.7",
"resolved": "https://registry.npmmirror.com/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"dev": true,
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1643,6 +1815,13 @@
"node": ">=0.4.0"
}
},
"node_modules/destr": {
"version": "2.0.5",
"resolved": "https://registry.npmmirror.com/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"dev": true,
"license": "MIT"
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -1657,6 +1836,25 @@
"dev": true,
"license": "MIT"
},
"node_modules/dom-to-image-more": {
"version": "3.10.0",
"resolved": "https://registry.npmmirror.com/dom-to-image-more/-/dom-to-image-more-3.10.0.tgz",
"integrity": "sha512-APrFEimSmH4phJKs8DlURuSr3BFwDqNi62l14bx73Wrhx0OhE4dd0qzZR+4E3sH8PnHxvax1LOrosC9oXRFA5A==",
"license": "MIT"
},
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1716,6 +1914,13 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/errx": {
"version": "0.1.0",
"resolved": "https://registry.npmmirror.com/errx/-/errx-0.1.0.tgz",
"integrity": "sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q==",
"dev": true,
"license": "MIT"
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -1734,6 +1939,13 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.2.0.tgz",
"integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
@@ -1813,12 +2025,32 @@
"node": ">=6"
}
},
"node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/exsolve": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.1.0.tgz",
"integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -2001,6 +2233,16 @@
"node": ">= 0.4"
}
},
"node_modules/giget": {
"version": "3.3.0",
"resolved": "https://registry.npmmirror.com/giget/-/giget-3.3.0.tgz",
"integrity": "sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==",
"dev": true,
"license": "MIT",
"bin": {
"giget": "dist/cli.mjs"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2088,6 +2330,16 @@
"node": ">= 6"
}
},
"node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -2160,6 +2412,23 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/klona": {
"version": "2.0.6",
"resolved": "https://registry.npmmirror.com/klona/-/klona-2.0.6.tgz",
"integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/knitwork": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/knitwork/-/knitwork-1.3.0.tgz",
"integrity": "sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==",
"dev": true,
"license": "MIT"
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -2180,6 +2449,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/local-pkg": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.2.1.tgz",
"integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"mlly": "^1.7.4",
"pkg-types": "^2.3.0",
"quansync": "^0.2.11"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
@@ -2301,6 +2588,38 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/mlly": {
"version": "1.8.2",
"resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.2.tgz",
"integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.16.0",
"pathe": "^2.0.3",
"pkg-types": "^1.3.1",
"ufo": "^1.6.3"
}
},
"node_modules/mlly/node_modules/confbox": {
"version": "0.1.8",
"resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz",
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
"dev": true,
"license": "MIT"
},
"node_modules/mlly/node_modules/pkg-types": {
"version": "1.3.1",
"resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz",
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.1.8",
"mlly": "^1.7.4",
"pathe": "^2.0.1"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
@@ -2390,6 +2709,27 @@
"node": ">= 6"
}
},
"node_modules/obug": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/obug/-/obug-2.1.3.tgz",
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/ohash": {
"version": "2.0.11",
"resolved": "https://registry.npmmirror.com/ohash/-/ohash-2.0.11.tgz",
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
"dev": true,
"license": "MIT"
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -2404,6 +2744,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/perfect-debounce": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@@ -2465,6 +2819,18 @@
"node": ">= 6"
}
},
"node_modules/pkg-types": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.3.1.tgz",
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.2.4",
"exsolve": "^1.0.8",
"pathe": "^2.0.3"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",
@@ -2650,6 +3016,23 @@
"node": ">=10"
}
},
"node_modules/quansync": {
"version": "0.2.11",
"resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz",
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/antfu"
},
{
"type": "individual",
"url": "https://github.com/sponsors/sxzz"
}
],
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -2671,6 +3054,17 @@
],
"license": "MIT"
},
"node_modules/rc9": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/rc9/-/rc9-3.0.1.tgz",
"integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"defu": "^6.1.6",
"destr": "^2.0.5"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz",
@@ -2740,6 +3134,22 @@
"node": ">=0.10.0"
}
},
"node_modules/rolldown-string": {
"version": "0.2.1",
"resolved": "https://registry.npmmirror.com/rolldown-string/-/rolldown-string-0.2.1.tgz",
"integrity": "sha512-7H8oH5A8+L96pbBTPCt/rZrwayEhZY5/ejhdk9nRODH32H1v7+bfkaCr+kS15DcGQ7VC1HcWdQVNABFYgrMOzg==",
"dev": true,
"license": "MIT",
"dependencies": {
"magic-string": "^0.30.21"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/rollup": {
"version": "4.62.2",
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz",
@@ -2809,6 +3219,26 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/scule": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz",
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"dev": true,
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2980,6 +3410,231 @@
"node": ">=14.17"
}
},
"node_modules/ufo": {
"version": "1.6.4",
"resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.4.tgz",
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
"dev": true,
"license": "MIT"
},
"node_modules/unctx": {
"version": "2.5.0",
"resolved": "https://registry.npmmirror.com/unctx/-/unctx-2.5.0.tgz",
"integrity": "sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.15.0",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21",
"unplugin": "^2.3.11"
}
},
"node_modules/unctx/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/unplugin": {
"version": "2.3.11",
"resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-2.3.11.tgz",
"integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"acorn": "^8.15.0",
"picomatch": "^4.0.3",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/unplugin-element-plus": {
"version": "0.11.2",
"resolved": "https://registry.npmmirror.com/unplugin-element-plus/-/unplugin-element-plus-0.11.2.tgz",
"integrity": "sha512-jr88ePpv43h8cCmVW0SqM73sTD+g1n9Rmy4uMbTh+pSmceH9ZdKteWX9f+twC4aDlP3svdZuKMqLoUNBT2V6Tg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nuxt/kit": "^4.2.2",
"es-module-lexer": "^2.0.0",
"escape-string-regexp": "^5.0.0",
"rolldown-string": "^0.2.1",
"unplugin": "^2.3.11"
},
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/unplugin-utils": {
"version": "0.3.1",
"resolved": "https://registry.npmmirror.com/unplugin-utils/-/unplugin-utils-0.3.1.tgz",
"integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==",
"dev": true,
"license": "MIT",
"dependencies": {
"pathe": "^2.0.3",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/unplugin-vue-components": {
"version": "32.1.0",
"resolved": "https://registry.npmmirror.com/unplugin-vue-components/-/unplugin-vue-components-32.1.0.tgz",
"integrity": "sha512-YiUkSxuRjab18XFOrX5VsIxXzccrfmHVGsGeJgSgklb829DQmCy9E4vvDUE4tuvZZdxyFJZX0Oc4TPnnxiiMyg==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^5.0.0",
"local-pkg": "^1.2.0",
"magic-string": "^0.30.21",
"mlly": "^1.8.2",
"obug": "^2.1.1",
"picomatch": "^4.0.4",
"tinyglobby": "^0.2.16",
"unplugin": "^3.0.0",
"unplugin-utils": "^0.3.1"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@nuxt/kit": "^3.2.2 || ^4.0.0",
"vue": "^3.0.0"
},
"peerDependenciesMeta": {
"@nuxt/kit": {
"optional": true
}
}
},
"node_modules/unplugin-vue-components/node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/unplugin-vue-components/node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/unplugin-vue-components/node_modules/unplugin": {
"version": "3.3.0",
"resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-3.3.0.tgz",
"integrity": "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"picomatch": "^4.0.4",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"@farmfe/core": "*",
"@rspack/core": "*",
"bun-types-no-globals": "*",
"esbuild": "*",
"rolldown": "*",
"rollup": "*",
"unloader": "*",
"vite": "*",
"webpack": "*"
},
"peerDependenciesMeta": {
"@farmfe/core": {
"optional": true
},
"@rspack/core": {
"optional": true
},
"bun-types-no-globals": {
"optional": true
},
"esbuild": {
"optional": true
},
"rolldown": {
"optional": true
},
"rollup": {
"optional": true
},
"unloader": {
"optional": true
},
"vite": {
"optional": true
},
"webpack": {
"optional": true
}
}
},
"node_modules/untyped": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/untyped/-/untyped-2.0.0.tgz",
"integrity": "sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"citty": "^0.1.6",
"defu": "^6.1.4",
"jiti": "^2.4.2",
"knitwork": "^1.2.0",
"scule": "^1.3.0"
},
"bin": {
"untyped": "dist/cli.mjs"
}
},
"node_modules/untyped/node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -3184,6 +3839,13 @@
"peerDependencies": {
"typescript": ">=5.0.0"
}
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"dev": true,
"license": "MIT"
}
}
}
+3
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"axios": "^1.7.9",
"dom-to-image-more": "^3.10.0",
"element-plus": "^2.9.1",
"pinia": "^2.3.0",
"vue": "^3.5.13",
@@ -22,6 +23,8 @@
"postcss": "^8.5.15",
"tailwindcss": "^3.4.19",
"typescript": "~5.6.0",
"unplugin-element-plus": "^0.11.2",
"unplugin-vue-components": "^32.1.0",
"vite": "^6.0.5",
"vue-tsc": "^2.2.0"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 989 B

+200 -11
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
import { computed } from 'vue'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
const auth = useAuthStore()
const isMobile = computed(() => {
@@ -9,7 +10,9 @@ const isMobile = computed(() => {
</script>
<template>
<router-view />
<el-config-provider :locale="zhCn">
<router-view />
</el-config-provider>
</template>
<style>
@@ -64,6 +67,114 @@ const isMobile = computed(() => {
--radius-sm: 2px;
--radius-xs: 2px;
--transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
/* ── Sidebar — dark editorial ── */
--sidebar-bg: var(--ink);
--sidebar-color: #fff;
--sidebar-accent: var(--gold);
--sidebar-nav-text: rgba(255,255,255,0.55);
--sidebar-nav-text-hover: rgba(255,255,255,0.85);
--sidebar-nav-text-active: #fff;
--sidebar-nav-bg-hover: rgba(255,255,255,0.04);
--sidebar-nav-bg-active: rgba(255,255,255,0.06);
--sidebar-nav-border-active: var(--gold);
--sidebar-divider: rgba(255,255,255,0.06);
--sidebar-brand-color: #fff;
--sidebar-brand-sub-color: rgba(255,255,255,0.4);
--sidebar-group-label-color: rgba(255,255,255,0.25);
--sidebar-footer-role-color: var(--gold);
--sidebar-footer-name-color: rgba(255,255,255,0.7);
/* ── Mobile page accent ── */
--page-accent-color: rgba(196,147,74,0.06);
/* ── Manager chip tags ── */
--mgr-chip-text: #fff;
/* ── Fonts — editorial Chinese ── */
--font-body: 'Noto Serif SC', STSong, Songti SC, '宋体', serif;
--font-heading: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
--font-mono: 'JetBrains Mono', 'SF Mono', monospace;
}
/* ═══════════════════════════════════════════════════════════
THEME: Light — 极简功能主义 / Brutally Minimal
═══════════════════════════════════════════════════════════ */
:root[data-theme="light"] {
/* ── Core palette ── */
--ink: #1A1A1A;
--ink-light: #3D3D3D;
--ink-dark: #0A0A0A;
--vermilion: #DC2626;
--vermilion-light: #EF4444;
--vermilion-dark: #B91C1C;
--gold: #D4AF37;
--gold-light: #E5C955;
--gold-dark: #B8941F;
--paper: #F5F5F5;
--paper-dark: #EBEBEB;
--surface: #FFFFFF;
--sage: #16A34A;
--amber: #D97706;
--warm-gray: #666666;
--warm-border: #DDDDDD;
/* ── Semantic tokens ── */
--c-primary: var(--ink);
--c-primary-light: var(--ink-light);
--c-primary-bg: #F5F5F5;
--c-accent: var(--vermilion);
--c-accent-light: var(--vermilion-light);
--c-gold: var(--gold);
--c-success: var(--sage);
--c-warning: var(--amber);
--c-danger: var(--vermilion);
--c-info: #2563EB;
--c-text: #1A1A1A;
--c-text-secondary: #666666;
--c-text-muted: #999999;
--c-bg: var(--paper);
--c-bg-card: var(--surface);
--c-border: var(--warm-border);
/* ── Effects ── */
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
--shadow: 0 1px 3px rgba(0,0,0,0.08);
--shadow-md: 0 2px 6px rgba(0,0,0,0.10);
--shadow-lg: 0 4px 12px rgba(0,0,0,0.12);
--radius: 0px;
--radius-sm: 0px;
--radius-xs: 0px;
--transition: 0.18s cubic-bezier(0.4, 0, 0.2, 1);
/* ── Sidebar — light minimal ── */
--sidebar-bg: #FFFFFF;
--sidebar-color: #1A1A1A;
--sidebar-accent: var(--gold);
--sidebar-nav-text: #777777;
--sidebar-nav-text-hover: #1A1A1A;
--sidebar-nav-text-active: #1A1A1A;
--sidebar-nav-bg-hover: #F5F5F5;
--sidebar-nav-bg-active: #F0F0F0;
--sidebar-nav-border-active: var(--gold);
--sidebar-divider: #EEEEEE;
--sidebar-brand-color: #1A1A1A;
--sidebar-brand-sub-color: #999999;
--sidebar-group-label-color: #AAAAAA;
--sidebar-footer-role-color: var(--gold);
--sidebar-footer-name-color: #777777;
/* ── Mobile page accent ── */
--page-accent-color: rgba(212,175,55,0.04);
/* ── Manager chip tags ── */
--mgr-chip-text: #1A1A1A;
/* ── Fonts — sans-serif minimal ── */
--font-body: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-heading: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
/* ── Reset & Base ── */
@@ -73,7 +184,7 @@ html, body, #app {
margin: 0; padding: 0; height: 100%;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
font-family: 'Noto Serif SC', STSong, Songti SC, '宋体', serif;
font-family: var(--font-body);
font-size: 15px;
line-height: 1.7;
color: var(--c-text);
@@ -84,7 +195,7 @@ html, body, #app {
/* ── Editorial heading style ── */
h1, h2, h3, h4, h5, h6 {
font-family: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
font-family: var(--font-heading);
letter-spacing: 0.02em;
}
@@ -104,7 +215,7 @@ h1, h2, h3, h4, h5, h6 {
.el-card__header {
border-bottom: 2px solid var(--gold) !important;
padding: 16px 20px !important;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-weight: 600;
font-size: 16px;
color: var(--ink);
@@ -118,7 +229,7 @@ h1, h2, h3, h4, h5, h6 {
font-weight: 500;
letter-spacing: 0.02em;
transition: all var(--transition);
font-family: 'Noto Serif SC', STSong, Songti SC, serif;
font-family: var(--font-body);
}
.el-button--primary {
background: var(--ink) !important;
@@ -146,14 +257,14 @@ h1, h2, h3, h4, h5, h6 {
.el-tag {
border-radius: 2px !important;
font-weight: 500;
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
letter-spacing: 0.02em;
}
/* ── Tabs ── */
.el-tabs__item {
font-weight: 500;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
}
.el-tabs__active-bar { background: var(--gold) !important; }
.el-tabs__item.is-active { color: var(--ink) !important; }
@@ -164,7 +275,7 @@ h1, h2, h3, h4, h5, h6 {
color: var(--ink);
font-weight: 600;
font-size: 13px;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
letter-spacing: 0.03em;
}
.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
@@ -181,7 +292,7 @@ h1, h2, h3, h4, h5, h6 {
.el-dialog__header {
padding: 20px 24px 0 !important;
font-weight: 600;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
}
.el-dialog__body { padding: 20px 24px !important; }
@@ -222,7 +333,7 @@ h1, h2, h3, h4, h5, h6 {
/* ── Textarea ── */
.el-textarea__inner {
border-radius: 2px !important;
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
}
/* ── Radio ── */
@@ -255,7 +366,7 @@ h1, h2, h3, h4, h5, h6 {
/* ── Divider ── */
.el-divider__text {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
color: var(--warm-gray);
}
@@ -277,4 +388,82 @@ h1, h2, h3, h4, h5, h6 {
cursor: pointer; border-radius: 50%; transition: background 0.2s;
}
.image-preview-close:hover { background: rgba(255,255,255,0.35); }
/* ── Screenshot capture mode ── */
.taking-screenshot { background: #fff !important; }
.taking-screenshot * { background-color: transparent; }
.taking-screenshot .el-card,
.taking-screenshot .el-table,
.taking-screenshot .el-table__header,
.taking-screenshot .el-table__body,
.taking-screenshot .filter-bar,
.taking-screenshot .summary-bar,
.taking-screenshot .page-head { background: #fff !important; }
.taking-screenshot .screenshot-hide { display: none !important; }
.taking-screenshot .el-table__fixed-right { display: none !important; }
/* ── Screenshot capture animation ── */
.body-capturing .work-plans-page,
.body-capturing .mini-biz-page,
.body-capturing .key-visits-page {
position: relative;
}
.body-capturing .work-plans-page::after,
.body-capturing .mini-biz-page::after,
.body-capturing .key-visits-page::after {
content: '';
position: fixed; inset: 0; z-index: 9998;
background: rgba(255,255,255,0.6);
pointer-events: none;
animation: capture-flash 0.35s ease-out;
}
@keyframes capture-flash {
0% { opacity: 0; }
50% { opacity: 1; }
100% { opacity: 0; }
}
/* ── Column resize handle ── */
.el-table__header th {
position: relative !important;
}
/* Exclude gutter (scrollbar spacer) and selection column from resize */
.el-table__header th.el-table__cell--selection .col-resize-handle,
.el-table__header th.gutter .col-resize-handle {
display: none !important;
}
.col-resize-handle {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 6px;
cursor: col-resize;
z-index: 1;
transition: background 0.15s;
}
.col-resize-handle::after {
content: '';
position: absolute;
right: 1px;
top: 20%;
bottom: 20%;
width: 2px;
border-radius: 1px;
background: transparent;
transition: background 0.15s;
}
.el-table__header th:hover .col-resize-handle::after,
.col-resize-handle.is-resizing::after {
background: var(--gold);
}
.col-resize-handle:hover::after,
.col-resize-handle.is-resizing::after {
background: var(--gold) !important;
}
/* Prevent text selection while resizing columns */
body.col-resizing {
cursor: col-resize !important;
user-select: none !important;
}
</style>
+10
View File
@@ -0,0 +1,10 @@
import api from './index'
export const aiApi = {
getSummary(params?: { reference_date?: string; period?: string }) {
return api.get('/ai/summary', { params })
},
generateSummary(params?: { reference_date?: string; period?: string }) {
return api.post('/ai/summary', null, { params })
},
}
+3
View File
@@ -10,4 +10,7 @@ export const dashboardApi = {
getWeeklyReport(params?: any) {
return api.get('/dashboard/weekly-report', { params })
},
getLightBoard(params?: any) {
return api.get('/dashboard/light-board', { params })
},
}
+19
View File
@@ -0,0 +1,19 @@
import api from './index'
export const leavesApi = {
list(params?: Record<string, any>) {
return api.get('/leaves/', { params })
},
create(data: Record<string, any>) {
return api.post('/leaves/', data)
},
update(id: string, data: Record<string, any>) {
return api.put(`/leaves/${id}`, data)
},
delete(id: string) {
return api.delete(`/leaves/${id}`)
},
overview(params?: Record<string, any>) {
return api.get('/leaves/overview', { params })
},
}
+53
View File
@@ -0,0 +1,53 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
DesktopLayout: typeof import('./components/DesktopLayout.vue')['default']
EditLogPanel: typeof import('./components/EditLogPanel.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElInput: typeof import('element-plus/es')['ElInput']
ElLink: typeof import('element-plus/es')['ElLink']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ImagePreview: typeof import('./components/ImagePreview.vue')['default']
MobileLayout: typeof import('./components/MobileLayout.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}
+40 -24
View File
@@ -2,10 +2,12 @@
import { useRoute, useRouter } from 'vue-router'
import { computed, ref } 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()
const collapsed = ref(false)
interface MenuGroup { label?: string; items: { path: string; label: string; icon: string }[] }
@@ -17,6 +19,7 @@ const menuGroups = computed<MenuGroup[]>(() => {
items: [
{ path: '/', label: '仪表盘', icon: '<polyline points="4 7 12 3 20 7"></polyline><polyline points="20 7 20 21 4 21 4 7"></polyline><line x1="8" y1="21" x2="8" y2="12"></line><line x1="16" y1="21" x2="16" y2="12"></line>' },
{ path: '/weekly-report', label: '周报', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line>' },
{ path: '/light-board', label: '亮灯表', icon: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>' },
],
},
{
@@ -25,6 +28,7 @@ const menuGroups = computed<MenuGroup[]>(() => {
{ path: '/work-plans', label: '工作计划', icon: '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line><polyline points="8 14 12 18 16 14"></polyline>' },
{ path: '/mini-business', label: '商机跟单', icon: '<line x1="12" y1="1" x2="12" y2="23"></line><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>' },
{ path: '/key-visits', label: '要客拜访', icon: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>' },
{ path: '/leaves', label: '请假管理', icon: '<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>' },
],
},
]
@@ -56,6 +60,11 @@ const roleLabel = computed(() => {
if (auth.isLeader) return '分管领导'
return '客户经理'
})
function cycleTheme() {
const next = themeStore.currentTheme === 'editorial' ? 'light' : 'editorial'
themeStore.setTheme(next)
}
</script>
<template>
@@ -99,6 +108,13 @@ const roleLabel = computed(() => {
</svg>
</button>
<div class="topbar-actions">
<button class="topbar-btn" @click="cycleTheme" :title="'切换至' + themeLabels[themeStore.currentTheme === 'editorial' ? 'light' : 'editorial']">
<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>
</svg>
</button>
<button class="topbar-btn" @click="router.push('/m')" title="移动端">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2"></rect>
@@ -134,8 +150,8 @@ const roleLabel = computed(() => {
/* ═══ Sidebar ═══ */
.sidebar {
width: 240px;
background: var(--ink);
color: #fff;
background: var(--sidebar-bg);
color: var(--sidebar-color);
display: flex;
flex-direction: column;
flex-shrink: 0;
@@ -157,31 +173,31 @@ const roleLabel = computed(() => {
.sidebar-brand {
padding: 26px 22px 18px;
border-bottom: 1px solid rgba(255,255,255,0.06);
border-bottom: 1px solid var(--sidebar-divider);
}
.brand-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
font-family: var(--font-heading);
font-size: 24px;
font-weight: 400;
letter-spacing: 0.12em;
color: #fff;
color: var(--sidebar-brand-color);
line-height: 1.2;
}
.brand-sub {
margin: 2px 0 0;
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 10px;
color: rgba(255,255,255,0.4);
color: var(--sidebar-brand-sub-color);
letter-spacing: 0.1em;
}
.brand-rule {
width: 28px;
height: 3px;
background: var(--gold);
background: var(--sidebar-accent);
margin-top: 14px;
}
@@ -196,10 +212,10 @@ const roleLabel = computed(() => {
}
.nav-group-label {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.15em;
color: rgba(255,255,255,0.25);
color: var(--sidebar-group-label-color);
padding: 12px 14px 4px;
text-transform: uppercase;
}
@@ -211,9 +227,9 @@ const roleLabel = computed(() => {
align-items: center;
gap: 10px;
padding: 11px 14px;
color: rgba(255,255,255,0.55);
color: var(--sidebar-nav-text);
text-decoration: none;
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 14px;
letter-spacing: 0.04em;
transition: all 0.22s;
@@ -221,14 +237,14 @@ const roleLabel = computed(() => {
}
.nav-item:hover {
color: rgba(255,255,255,0.85);
background: rgba(255,255,255,0.04);
color: var(--sidebar-nav-text-hover);
background: var(--sidebar-nav-bg-hover);
}
.nav-item--active {
color: #fff;
background: rgba(255,255,255,0.06);
border-left-color: var(--gold);
color: var(--sidebar-nav-text-active);
background: var(--sidebar-nav-bg-active);
border-left-color: var(--sidebar-nav-border-active);
}
.nav-icon {
@@ -243,23 +259,23 @@ const roleLabel = computed(() => {
/* ═══ Sidebar Footer ═══ */
.sidebar-footer {
padding: 14px 22px;
border-top: 1px solid rgba(255,255,255,0.06);
border-top: 1px solid var(--sidebar-divider);
display: flex;
flex-direction: column;
gap: 2px;
}
.footer-role {
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 10px;
color: var(--gold);
color: var(--sidebar-footer-role-color);
letter-spacing: 0.1em;
}
.footer-name {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 13px;
color: rgba(255,255,255,0.7);
color: var(--sidebar-footer-name-color);
letter-spacing: 0.04em;
}
@@ -284,7 +300,7 @@ const roleLabel = computed(() => {
}
.topbar-greeting {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 14px;
color: var(--ink);
letter-spacing: 0.04em;
@@ -314,7 +330,7 @@ const roleLabel = computed(() => {
background: none;
border: 1px solid var(--warm-border);
color: var(--warm-gray);
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 12px;
letter-spacing: 0.03em;
cursor: pointer;
+2 -2
View File
@@ -125,14 +125,14 @@ function labelFor(field: string) {
}
.log-editor {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 13px;
color: var(--ink);
font-weight: 600;
}
.log-time {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
color: var(--c-text-muted);
}
+2 -2
View File
@@ -266,7 +266,7 @@ const imgStyle = computed(() => {
background: transparent;
color: rgba(255, 255, 255, 0.75);
cursor: pointer;
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 12px;
transition: all 0.15s;
white-space: nowrap;
@@ -287,7 +287,7 @@ const imgStyle = computed(() => {
}
.fit-label {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.03em;
}
+5 -5
View File
@@ -129,7 +129,7 @@ const activeTab = computed(() => {
.header-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
font-family: var(--font-heading);
font-size: 26px;
font-weight: 400;
color: var(--ink);
@@ -138,7 +138,7 @@ const activeTab = computed(() => {
}
.header-subtitle {
font-family: 'Noto Serif SC', STSong, Songti SC, serif;
font-family: var(--font-body);
font-size: 11px;
color: var(--warm-gray);
letter-spacing: 0.12em;
@@ -178,7 +178,7 @@ const activeTab = computed(() => {
}
.header-user {
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 13px;
color: var(--warm-gray);
letter-spacing: 0.03em;
@@ -201,7 +201,7 @@ const activeTab = computed(() => {
right: 0;
width: 60px;
height: 60px;
background: linear-gradient(135deg, transparent 60%, rgba(196,147,74,0.06) 60%);
background: linear-gradient(135deg, transparent 60%, var(--page-accent-color) 60%);
pointer-events: none;
z-index: 0;
}
@@ -238,7 +238,7 @@ const activeTab = computed(() => {
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
letter-spacing: 0.03em;
}
+152
View File
@@ -0,0 +1,152 @@
/**
* v-column-resize directive — adds drag-to-resize handles to el-table column headers.
*
* Usage: <el-table v-column-resize ...>
*
* Holders drag the right edge of any column header (except the last) to resize.
* Works with Element Plus's fixed table-layout by updating <col> widths in
* every colgroup (header + body tables stay in sync).
*/
import type { Directive, DirectiveBinding } from 'vue'
interface DragState {
startX: number
startWidth: number
th: HTMLElement
tableWrapper: HTMLElement
colIndex: number
}
const drag: DragState = {
startX: 0,
startWidth: 0,
th: null!,
tableWrapper: null!,
colIndex: -1,
}
// ── Helpers ──────────────────────────────────────────────
function getColIndex(th: HTMLElement): number {
const cells = Array.from(th.parentElement?.children || [])
return cells.indexOf(th)
}
/** All <col> elements across every colgroup inside the wrapper, by col index */
function allCols(wrapper: HTMLElement, idx: number): HTMLElement[] {
const groups = wrapper.querySelectorAll('colgroup')
const result: HTMLElement[] = []
groups.forEach((g) => {
const col = g.children[idx] as HTMLElement | undefined
if (col) result.push(col)
})
return result
}
function setColumnWidth(wrapper: HTMLElement, colIndex: number, width: number) {
const w = Math.max(40, width) + 'px'
for (const col of allCols(wrapper, colIndex)) {
col.style.width = w
}
}
// ── Event handlers ───────────────────────────────────────
function onMouseMove(e: MouseEvent) {
const diff = e.clientX - drag.startX
setColumnWidth(drag.tableWrapper, drag.colIndex, drag.startWidth + diff)
}
function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
const handle = drag.th.querySelector('.col-resize-handle') as HTMLElement | null
if (handle) handle.classList.remove('is-resizing')
}
function onHandleDown(e: MouseEvent, th: HTMLElement, wrapper: HTMLElement) {
e.preventDefault()
e.stopPropagation()
const idx = getColIndex(th)
const cols = allCols(wrapper, idx)
const currentWidth = cols[0] ? cols[0].getBoundingClientRect().width : th.offsetWidth
drag.startX = e.clientX
drag.startWidth = currentWidth
drag.th = th
drag.tableWrapper = wrapper
drag.colIndex = idx
const handle = th.querySelector('.col-resize-handle') as HTMLElement | null
if (handle) handle.classList.add('is-resizing')
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}
// ── Setup / Teardown ─────────────────────────────────────
function injectHandles(wrapper: HTMLElement) {
const headerThs = wrapper.querySelectorAll<HTMLElement>(
'.el-table__header-wrapper th:not(.el-table__cell--selection)',
)
if (headerThs.length === 0) return
headerThs.forEach((th) => {
// Don't add handle to the last column (or to columns that already have one)
// Actually, allow all columns except the last to be resizable
if (th.querySelector('.col-resize-handle')) return
const handle = document.createElement('div')
handle.className = 'col-resize-handle'
handle.addEventListener('mousedown', (e: MouseEvent) => onHandleDown(e, th, wrapper))
// Prevent sort trigger on header click when grabbing handle
handle.addEventListener('click', (e: Event) => e.stopPropagation())
th.appendChild(handle)
})
}
// ── Directive definition ─────────────────────────────────
function findWrapper(el: HTMLElement): HTMLElement | null {
return el.querySelector<HTMLElement>('.el-table__inner-wrapper')
|| el.querySelector<HTMLElement>('.el-table__header-wrapper')?.closest('.el-table') as HTMLElement
|| null
}
export const vColumnResize: Directive<HTMLElement> = {
mounted(el: HTMLElement, _binding: DirectiveBinding) {
const trySetup = () => {
const wrapper = findWrapper(el)
if (wrapper) {
injectHandles(wrapper)
return true
}
return false
}
if (!trySetup()) {
const timer = setTimeout(() => trySetup(), 200)
;(el as any).__colResizeTimer = timer
}
// Re-inject handles when DOM changes (sort, filter, data reload)
const observer = new MutationObserver(() => {
const wrapper = findWrapper(el)
if (wrapper) injectHandles(wrapper)
})
observer.observe(el, { childList: true, subtree: true })
;(el as any).__colResizeObserver = observer
},
unmounted(el: HTMLElement) {
clearTimeout((el as any).__colResizeTimer)
const observer = (el as any).__colResizeObserver as MutationObserver | undefined
observer?.disconnect()
},
}
export default vColumnResize
+2 -4
View File
@@ -1,14 +1,12 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import './tailwind.css'
import App from './App.vue'
import router from './router'
import { vColumnResize } from '@/directives/columnResize'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn as any })
app.directive('column-resize', vColumnResize)
app.mount('#app')
+34
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { isMobile } from '@/utils'
const router = createRouter({
history: createWebHistory(),
@@ -16,6 +17,18 @@ const router = createRouter({
component: () => import('@/views/BindWecom.vue'),
meta: { public: true },
},
{
path: '/bind-wechat',
name: 'BindWechat',
component: () => import('@/views/BindWechat.vue'),
meta: { public: true },
},
{
path: '/wecom-bind',
name: 'WecomBind',
component: () => import('@/views/WecomBind.vue'),
meta: { public: true },
},
// Mobile routes (manager-facing)
{
path: '/m',
@@ -29,6 +42,9 @@ const router = createRouter({
{ path: 'key-visit/new', name: 'KeyVisitForm', 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: 'leaves', name: 'LeavesList', component: () => import('@/views/mobile/LeavesList.vue') },
{ path: 'leave/new', name: 'LeaveForm', component: () => import('@/views/mobile/LeaveForm.vue') },
{ path: 'leave/:id/edit', name: 'LeaveEdit', component: () => import('@/views/mobile/LeaveForm.vue') },
],
},
// Desktop routes (director/leader-facing)
@@ -38,9 +54,11 @@ const router = createRouter({
children: [
{ path: '', name: 'Dashboard', component: () => import('@/views/desktop/Dashboard.vue') },
{ path: 'weekly-report', name: 'WeeklyReport', component: () => import('@/views/desktop/WeeklyReport.vue') },
{ path: 'light-board', name: 'LightBoard', component: () => import('@/views/desktop/LightBoard.vue') },
{ path: 'work-plans', name: 'WorkPlans', component: () => import('@/views/desktop/WorkPlans.vue') },
{ path: 'mini-business', name: 'MiniBusiness', component: () => import('@/views/desktop/MiniBusiness.vue') },
{ path: 'key-visits', name: 'KeyVisits', component: () => import('@/views/desktop/KeyVisits.vue') },
{ path: 'leaves', name: 'Leaves', component: () => import('@/views/desktop/Leaves.vue') },
{ path: 'workspace', name: 'ManagerWorkspace', component: () => import('@/views/desktop/ManagerWorkspace.vue') },
{ path: 'customers', name: 'CustomerManage', component: () => import('@/views/desktop/CustomerManage.vue') },
{ path: 'users', name: 'UserManage', component: () => import('@/views/desktop/UserManage.vue') },
@@ -60,6 +78,22 @@ router.beforeEach((to, _from, next) => {
next('/login')
return
}
// Auto-redirect on FIRST entry only (no previous route = fresh page load)
// Don't interfere with manual navigation (user clicked switch button)
const viewOverride = to.query.view as string
if (!viewOverride && !_from.name) {
const onMobile = isMobile()
if (onMobile && to.path === '/') {
next('/m')
return
}
if (!onMobile && to.path.startsWith('/m')) {
next('/')
return
}
}
next()
})
+3
View File
@@ -29,7 +29,10 @@ export const useAuthStore = defineStore('auth', () => {
userId.value = ''
name.value = ''
role.value = ''
// Preserve theme preference across logout
const savedTheme = localStorage.getItem('theme')
localStorage.clear()
if (savedTheme) localStorage.setItem('theme', savedTheme)
}
async function casdoorLogin(code: string, state: string) {
+30
View File
@@ -0,0 +1,30 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export type Theme = 'editorial' | 'light'
export const themeLabels: Record<Theme, string> = {
editorial: '编辑风',
light: '极简亮色',
}
export const useThemeStore = defineStore('theme', () => {
const currentTheme = ref<Theme>(
(localStorage.getItem('theme') as Theme) || 'editorial'
)
function applyTheme(theme: Theme) {
document.documentElement.setAttribute('data-theme', theme)
}
function setTheme(theme: Theme) {
currentTheme.value = theme
localStorage.setItem('theme', theme)
applyTheme(theme)
}
// Apply on init
applyTheme(currentTheme.value)
return { currentTheme, setTheme }
})
+2 -2
View File
@@ -1,5 +1,5 @@
/* ── Google Fonts import for Editorial Chinese fonts ── */
@import url('https://fonts.googleapis.com/css2?family=ZCOOL+XiaoWei&family=Noto+Serif+SC:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
/* ── Google Fonts import ── */
@import url('https://fonts.googleapis.com/css2?family=ZCOOL+XiaoWei&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Noto+Sans+SC:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
@tailwind base;
@tailwind components;
+101
View File
@@ -0,0 +1,101 @@
/**
* Client-side image compression before MinIO upload.
* Uses Canvas API to resize and re-encode images, reducing
* storage/bandwidth costs and improving load times.
*/
export interface CompressOptions {
/** Max dimension (width or height) in pixels. Default 1920. */
maxPixels?: number
/** JPEG quality 01. Default 0.8. */
quality?: number
/** Max file size in bytes before compression is applied. Default 200KB. */
sizeThreshold?: number
}
/**
* Compress an image file if it exceeds the size/dimension thresholds.
* Returns a File-like object suitable for upload, or the original file
* if compression is not needed.
*/
export async function compressImage(
file: File,
options: CompressOptions = {},
): Promise<File> {
const { maxPixels = 1920, quality = 0.8, sizeThreshold = 200 * 1024 } = options
// Skip non-image files
if (!file.type.startsWith('image/')) return file
// Don't re-compress GIF/SVG
if (file.type === 'image/gif' || file.type === 'image/svg+xml') return file
// Skip small files
if (file.size <= sizeThreshold) return file
return new Promise((resolve, reject) => {
const img = new Image()
const url = URL.createObjectURL(file)
img.onload = () => {
URL.revokeObjectURL(url)
let { width, height } = img
// Skip if already within dimension limits and file is small enough
if (width <= maxPixels && height <= maxPixels && file.size <= sizeThreshold * 2) {
return resolve(file)
}
// Calculate new dimensions maintaining aspect ratio
if (width > maxPixels || height > maxPixels) {
if (width > height) {
height = Math.round((height * maxPixels) / width)
width = maxPixels
} else {
width = Math.round((width * maxPixels) / height)
height = maxPixels
}
}
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
return resolve(file) // fallback
}
// Use better image smoothing
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'medium'
ctx.drawImage(img, 0, 0, width, height)
// Use original MIME type if supported, otherwise JPEG
let mimeType = file.type
if (!['image/jpeg', 'image/png', 'image/webp'].includes(mimeType)) {
mimeType = 'image/jpeg'
}
canvas.toBlob(
(blob) => {
if (!blob || blob.size >= file.size) {
// Compression didn't help or failed — use original
return resolve(file)
}
const compressed = new File([blob], file.name, {
type: mimeType,
lastModified: Date.now(),
})
resolve(compressed)
},
mimeType,
quality,
)
}
img.onerror = () => {
URL.revokeObjectURL(url)
resolve(file) // fallback to original on error
}
})
}
+6
View File
@@ -3,3 +3,9 @@ export function todayStr(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** Detect if current device is mobile based on user-agent. */
export function isMobile(): boolean {
const ua = navigator.userAgent || ''
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|Windows Phone/i.test(ua)
}
+55
View File
@@ -0,0 +1,55 @@
import { reactive } from 'vue'
// 12-color palettes — wide hue spacing for max distinction
const COLORS_DARK = ['#C62828','#D84315','#EF6C00','#B8860B','#558B2F','#00897B','#0277BD','#C2185B','#4E342E','#00695C','#5D4037','#2E7D32']
const COLORS_LIGHT = ['#FB7185','#FB923C','#FBBF24','#A3E635','#34D399','#2DD4BF','#38BDF8','#F472B6','#F97316','#84CC16','#22D3EE','#FDA4AF']
// Reactive name→color overrides (populated from backend user.color)
export const managerColorOverrides: Record<string, string> = reactive({})
let loaded = false
export async function loadManagerColors() {
if (loaded) return
try {
const { default: api } = await import('@/api/index')
const res = await api.get('/users/')
for (const u of res.data) {
if (u.color) managerColorOverrides[u.name] = u.color
}
loaded = true
} catch (_) {}
}
export function setManagerColorOverride(name: string, color: string | null) {
if (color) {
managerColorOverrides[name] = color
} else {
delete managerColorOverrides[name]
}
}
export function getManagerColor(name: string, isLight: boolean): string {
if (!name) return '#909399'
// Stored override takes priority
if (managerColorOverrides[name]) return managerColorOverrides[name]
// Fallback: deterministic hash from name
let h = 0
for (let i = 0; i < name.length; i++) h = name.charCodeAt(i) + ((h << 5) - h)
const colors = isLight ? COLORS_LIGHT : COLORS_DARK
return colors[Math.abs(h) % colors.length]
}
/** Return white or dark text color based on background luminance */
export function getMgrTextColor(bgHex: string): string {
const r = parseInt(bgHex.slice(1, 3), 16)
const g = parseInt(bgHex.slice(3, 5), 16)
const b = parseInt(bgHex.slice(5, 7), 16)
const lum = 0.299 * r + 0.587 * g + 0.114 * b
return lum > 150 ? '#1A1A1A' : '#FFFFFF'
}
/** Return { background, color } style object for a manager tag */
export function getMgrStyle(name: string, isLight: boolean) {
const bg = getManagerColor(name, isLight)
return { background: bg, color: getMgrTextColor(bg) }
}
+72
View File
@@ -0,0 +1,72 @@
/**
* useScreenshot — capture a DOM element as PNG and trigger download.
*
* Uses dom-to-image-more (SVG foreignObject) for pixel-perfect text rendering —
* unlike html2canvas, the browser itself paints the text so there's zero offset.
*
* Usage:
* const { capturing, captureEl } = useScreenshot()
* <div ref="shotRef">...</div>
* <button @click="captureEl(shotRef, 'filename')">截图</button>
*/
import { ref, nextTick } from 'vue'
import domtoimage from 'dom-to-image-more'
export function useScreenshot() {
const capturing = ref(false)
async function captureEl(el: HTMLElement | null, filename: string): Promise<boolean> {
if (!el || capturing.value) return false
capturing.value = true
await nextTick()
el.classList.add('taking-screenshot')
document.body.classList.add('body-capturing')
await new Promise((r) => setTimeout(r, 80))
try {
const dataUrl = await domtoimage.toPng(el, {
bgColor: '#ffffff',
width: el.scrollWidth,
height: el.scrollHeight,
quality: 1,
cacheBust: true,
})
// Pad 5px white border around the image
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const i = new Image()
i.onload = () => resolve(i)
i.onerror = reject
i.src = dataUrl
})
const PAD = 5
const canvas = document.createElement('canvas')
canvas.width = img.width + PAD * 2
canvas.height = img.height + PAD * 2
const ctx = canvas.getContext('2d')!
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(img, PAD, PAD)
const link = document.createElement('a')
link.download = filename
link.href = canvas.toDataURL('image/png')
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
return true
} catch (e) {
console.error('Screenshot failed:', e)
return false
} finally {
el.classList.remove('taking-screenshot')
document.body.classList.remove('body-capturing')
capturing.value = false
}
}
return { capturing, captureEl }
}
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { onMounted } from 'vue'
const CORP_ID = 'ww69e8e44636f47780'
const REDIRECT_URI = encodeURIComponent('https://qj.dhdx.fun/login')
const STATE = 'bind'
onMounted(() => {
const url = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${CORP_ID}&redirect_uri=${REDIRECT_URI}&response_type=code&scope=snsapi_base&state=${STATE}#wechat_redirect`
window.location.href = url
})
</script>
<template>
<div class="bind-wechat-page">
<p>正在跳转企业微信授权...</p>
</div>
</template>
<style scoped>
.bind-wechat-page {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-size: 16px;
color: var(--ink);
font-family: var(--font-body);
}
</style>
+112 -59
View File
@@ -9,15 +9,28 @@ const route = useRoute()
const auth = useAuthStore()
const loading = ref(false)
// Check for Casdoor callback
function goCasdoorLogin() {
const returnUrl = (route.query.redirect as string) || ''
if (returnUrl) {
sessionStorage.setItem('login_return_url', returnUrl)
}
const endpoint = import.meta.env.VITE_CASDOOR_ENDPOINT
const clientId = import.meta.env.VITE_CASDOOR_CLIENT_ID
const redirectUri = encodeURIComponent(window.location.origin + '/login')
const casdoorUrl = `${endpoint}/login/oauth/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&scope=openid+profile&state=login`
window.location.href = casdoorUrl
}
onMounted(async () => {
// ── Casdoor callback ──
const code = route.query.code as string
const state = route.query.state as string
if (code) {
loading.value = true
try {
await auth.casdoorLogin(code, state)
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' : '/')
} catch (e: any) {
@@ -28,14 +41,13 @@ onMounted(async () => {
return
}
// Check for wecom code
// ── Wecom callback ──
const wecomCode = route.query.wecom_code as string
if (wecomCode) {
loading.value = true
try {
const res = await auth.wecomLogin(wecomCode)
if (res.data.need_bind) {
// Redirect to Casdoor for binding
window.location.href = res.data.casdoor_url
return
}
@@ -45,6 +57,8 @@ onMounted(async () => {
name: res.data.name,
role: res.data.role,
})
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' : '/')
} catch (e: any) {
@@ -52,94 +66,133 @@ onMounted(async () => {
} finally {
loading.value = false
}
return
}
})
function goCasdoorLogin() {
// Redirect to Casdoor authorization page
const endpoint = import.meta.env.VITE_CASDOOR_ENDPOINT
const clientId = import.meta.env.VITE_CASDOOR_CLIENT_ID
const redirectUri = encodeURIComponent(window.location.origin + '/login')
const casdoorUrl = `${endpoint}/login/oauth/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&scope=openid+profile&state=login`
window.location.href = casdoorUrl
}
// ── Auto-redirect to Casdoor (no button needed) ──
loading.value = true
goCasdoorLogin()
})
</script>
<template>
<div class="login-page">
<div class="login-card">
<!-- Decorative ink-wash circles -->
<div class="bg-orb bg-orb--1"></div>
<div class="bg-orb bg-orb--2"></div>
<div class="bg-orb bg-orb--3"></div>
<div class="splash-card">
<div class="brand">
<h1>企迹</h1>
<div class="brand-rule"></div>
<p>政企周报管理系统</p>
</div>
<el-button
type="primary"
size="large"
:loading="loading"
@click="goCasdoorLogin"
style="width: 100%"
>
登录 / 注册
</el-button>
<p class="hint">使用 Casdoor 账号登录</p>
<div class="redirect-hint">
<span class="dot-pulse"></span>
正在跳转至统一认证...
</div>
</div>
</div>
</template>
<style scoped>
/* ═══ Background — editorial ink wash ═══ */
.login-page {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: linear-gradient(135deg, #4f6ef7 0%, #7b93fa 40%, #a5b4fc 100%);
background: var(--ink);
position: relative;
overflow: hidden;
font-family: var(--font-body);
}
.login-page::before {
content: ''; position: absolute; width: 600px; height: 600px;
background: rgba(255,255,255,0.05); border-radius: 50%;
top: -200px; right: -200px;
/* Decorative orbs — subtle ink-wash circles */
.bg-orb {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.login-page::after {
content: ''; position: absolute; width: 400px; height: 400px;
background: rgba(255,255,255,0.04); border-radius: 50%;
bottom: -100px; left: -100px;
.bg-orb--1 {
width: 600px; height: 600px;
background: radial-gradient(circle, rgba(196,147,74,0.06) 0%, transparent 70%);
top: -180px; right: -180px;
}
.login-card {
background: white;
border-radius: 20px;
padding: 48px 40px;
width: 380px;
max-width: 90vw;
box-shadow: 0 25px 60px rgba(0,0,0,0.15);
.bg-orb--2 {
width: 400px; height: 400px;
background: radial-gradient(circle, rgba(255,255,255,0.03) 0%, transparent 70%);
bottom: -100px; left: -80px;
}
.bg-orb--3 {
width: 200px; height: 200px;
background: radial-gradient(circle, rgba(196,147,74,0.08) 0%, transparent 60%);
top: 50%; left: 50%;
transform: translate(-50%, -50%);
}
/* ═══ Splash Card ═══ */
.splash-card {
position: relative;
z-index: 1;
}
.brand {
text-align: center;
margin-bottom: 36px;
animation: fade-in 0.6s ease-out;
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* ═══ Brand ═══ */
.brand {
margin-bottom: 40px;
}
.brand h1 {
margin: 0;
font-size: 36px;
font-weight: 800;
letter-spacing: 4px;
background: linear-gradient(135deg, #4f6ef7, #7b93fa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-family: var(--font-heading);
font-size: 48px;
font-weight: 400;
letter-spacing: 0.15em;
color: #fff;
line-height: 1.2;
}
.brand-rule {
width: 36px; height: 3px;
background: var(--gold);
margin: 16px auto;
}
.brand p {
margin: 10px 0 0;
color: var(--c-text-secondary);
font-size: 14px;
letter-spacing: 1px;
margin: 0;
font-family: var(--font-body);
font-size: 13px;
color: rgba(255,255,255,0.35);
letter-spacing: 0.15em;
}
.hint {
text-align: center;
color: var(--c-text-muted);
font-size: 12px;
margin-top: 20px;
/* ═══ Redirect Hint ═══ */
.redirect-hint {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-family: var(--font-body);
font-size: 13px;
color: rgba(255,255,255,0.45);
letter-spacing: 0.06em;
}
/* Pulsing dot animation */
.dot-pulse {
display: inline-block;
width: 6px; height: 6px;
background: var(--gold);
border-radius: 50%;
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.3; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1.2); }
}
</style>
+115
View File
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import api from '@/api/index'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const binding = ref(false)
const bound = ref(false)
const errorMsg = ref('')
const wecomUserId = ref('')
const bindToken = (route.query.token as string) || ''
onMounted(async () => {
if (!bindToken) {
errorMsg.value = '无效的绑定链接'
setTimeout(() => router.replace('/'), 2000)
return
}
if (!auth.isLoggedIn) {
window.location.href = `/login?redirect=${encodeURIComponent(route.fullPath)}`
return
}
// Fetch current user's wecom_userid
try {
const res = await api.get('/users/me')
wecomUserId.value = res.data.wecom_userid || ''
} catch (_) {}
})
async function doBind() {
if (!bindToken) return
binding.value = true
try {
const res = await api.post('/wecom/bind-confirm', { token: bindToken })
if (res.data.code === 200) {
bound.value = true
wecomUserId.value = res.data.wecom_userid
ElMessage.success('绑定成功!')
setTimeout(() => router.replace('/'), 1500)
} else {
ElMessage.error(res.data.msg || '绑定失败')
}
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '绑定失败,请重试')
} finally {
binding.value = false
}
}
</script>
<template>
<div class="wecom-bind-page">
<div v-if="errorMsg" class="bind-error">
<p>{{ errorMsg }}</p>
</div>
<template v-else>
<div class="bind-icon">💬</div>
<h2 class="bind-title">企业微信账号绑定</h2>
<p class="bind-desc">绑定后可接收填报提醒支持企微一键登录</p>
<div class="bind-info">
<div class="info-row">
<span class="info-label">当前账号</span>
<span class="info-value">{{ auth.name || '--' }}</span>
</div>
<div class="info-row">
<span class="info-label">绑定状态</span>
<span :class="['info-tag', wecomUserId ? 'bound' : 'unbound']">
{{ wecomUserId ? '已绑定' : '未绑定' }}
</span>
</div>
<div v-if="wecomUserId" class="info-row">
<span class="info-label">企微ID</span>
<span class="info-value">{{ wecomUserId }}</span>
</div>
</div>
<div class="bind-actions">
<el-button
v-if="!bound && !wecomUserId"
type="primary"
size="large"
:loading="binding"
@click="doBind"
>确认绑定</el-button>
<el-button v-else type="success" size="large" disabled>已绑定 </el-button>
</div>
<p class="bind-hint">链接有效期 10 分钟过期请重新在企微发送绑定</p>
</template>
</div>
</template>
<style scoped>
.wecom-bind-page { max-width: 400px; margin: 80px auto; padding: 32px 24px; text-align: center; }
.bind-error { color: var(--vermilion); font-size: 15px; }
.bind-icon { font-size: 56px; margin-bottom: 16px; }
.bind-title { margin: 0 0 8px; font-family: var(--font-heading); font-size: 22px; font-weight: 400; color: var(--ink); }
.bind-desc { margin: 0 0 28px; font-size: 14px; color: var(--c-text-muted); }
.bind-info { text-align: left; background: var(--c-bg-light, #faf9f6); border-radius: 8px; padding: 16px 20px; margin-bottom: 28px; }
.info-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; }
.info-row + .info-row { border-top: 1px solid var(--c-border, #e8e5df); }
.info-label { font-size: 14px; color: var(--c-text-muted); }
.info-value { font-size: 14px; color: var(--ink); font-weight: 500; }
.info-tag { font-size: 12px; padding: 2px 10px; border-radius: 10px; }
.info-tag.bound { background: var(--sage); color: #fff; }
.info-tag.unbound { background: var(--gold); color: #fff; }
.bind-actions { margin-bottom: 16px; }
.bind-hint { font-size: 12px; color: var(--c-text-muted); }
</style>
+99 -13
View File
@@ -1,11 +1,15 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
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 api from '@/api/index'
const auth = useAuthStore()
const themeStore = useThemeStore()
const isLight = computed(() => themeStore.currentTheme === 'light')
const loading = ref(false)
const search = ref('')
const filterIndustry = ref('')
@@ -43,7 +47,7 @@ const importLoading = ref(false)
const importResult = ref<any>(null)
onMounted(async () => {
await loadCustomers()
await Promise.all([loadCustomers(), loadManagerColors()])
try {
const res = await api.get('/users/')
managers.value = (res.data || []).filter((u: any) => u.role !== 'leader')
@@ -96,7 +100,21 @@ function resetForm() {
existingContacts.value = []
}
function openCreate() { dialogTitle.value = '新建客户'; editId.value = ''; resetForm(); dialogVisible.value = true }
const duplicateHint = ref('')
async function checkName() {
if (!form.value.name || editId.value) { duplicateHint.value = ''; return }
try {
const res = await customersApi.checkDuplicate(form.value.name)
if (res.data?.exists) {
const dup = res.data.customers || []
duplicateHint.value = `⚠ 已存在同名客户:${dup.map((c: any) => c.name + (c.primary_manager_name ? ` (${c.primary_manager_name})` : '')).join('、')}`
} else {
duplicateHint.value = ''
}
} catch (_) { duplicateHint.value = '' }
}
function openCreate() { dialogTitle.value = '新建客户'; editId.value = ''; resetForm(); duplicateHint.value = ''; dialogVisible.value = true }
async function openEdit(customer: any) {
dialogTitle.value = '编辑客户'; editId.value = customer.id
@@ -130,7 +148,44 @@ async function handleSubmit() {
}
dialogVisible.value = false
await loadCustomers()
} catch (e: any) { ElMessage.error('操作失败: ' + (e.response?.data?.detail || e.message)) }
} catch (e: any) {
// Handle name collision → offer merge
if (e.response?.status === 409 && e.response?.data?.detail?.preview) {
const d = e.response.data.detail
mergeSourceId.value = d.source_id
mergeSourceName.value = d.source_name
mergeTargetId.value = d.target_id
mergeTargetName.value = d.target_name
mergePreview.value = d.preview
mergeDialogVisible.value = true
return
}
ElMessage.error('操作失败: ' + (typeof e.response?.data?.detail === 'object' ? e.response.data.detail.message : (e.response?.data?.detail || e.message)))
}
}
// ── Merge ──
const mergeDialogVisible = ref(false)
const mergeSourceId = ref('')
const mergeSourceName = ref('')
const mergeTargetId = ref('')
const mergeTargetName = ref('')
const mergePreview = ref<any>({})
const mergeLoading = ref(false)
async function handleMerge() {
mergeLoading.value = true
try {
const res = await api.post(`/customers/${mergeSourceId.value}/merge`, { target_id: mergeTargetId.value })
ElMessage.success(res.data.result || '合并完成')
mergeDialogVisible.value = false
dialogVisible.value = false
await loadCustomers()
} catch (e: any) {
ElMessage.error('合并失败: ' + (e.response?.data?.detail || e.message))
} finally {
mergeLoading.value = false
}
}
async function removeExistingContact(contactId: string) {
@@ -142,11 +197,11 @@ async function removeExistingContact(contactId: string) {
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
const mgrColors = ['#1C3738','#4A6741','#5B7FA5','#7B7568','#8B6F47','#6B5B4F','#3D5A5C','#5C4A3D']
function mgrColor(name: string) {
if (!name) return '#909399'
let h = 0; for (let i=0;i<name.length;i++) h = name.charCodeAt(i) + ((h<<5)-h)
return mgrColors[Math.abs(h) % mgrColors.length]
return mgrColorFn(name, isLight.value)
}
function mgrTextColor(name: string) {
return getMgrTextColor(mgrColor(name))
}
async function openDetail(customer: any) {
@@ -276,7 +331,7 @@ async function handleImport() {
<!-- Customer Table -->
<el-card>
<el-table :data="customers" stripe @selection-change="onSelectionChange">
<el-table :data="customers" stripe v-column-resize @selection-change="onSelectionChange">
<el-table-column v-if="auth.isDirector" type="selection" width="50" />
<el-table-column type="index" label="序号" width="60" :index="(idx: number) => (currentPage - 1) * pageSize + idx + 1" />
<el-table-column prop="name" label="单位名称" min-width="180">
@@ -288,7 +343,7 @@ async function handleImport() {
<el-table-column prop="in_use_services" label="在用业务" min-width="150" />
<el-table-column label="客户经理" width="90">
<template #default="{ row }">
<el-tag :color="mgrColor(row.primary_manager_name)" effect="dark" size="small" style="border:none;color:#fff">{{ row.primary_manager_name || '-' }}</el-tag>
<el-tag :color="mgrColor(row.primary_manager_name)" effect="dark" size="small" :style="{ border:'none', color: mgrTextColor(row.primary_manager_name) }">{{ row.primary_manager_name || '-' }}</el-tag>
</template>
</el-table-column>
</el-table>
@@ -305,7 +360,7 @@ async function handleImport() {
<!-- Create/Edit Dialog -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="520px">
<el-form label-position="top">
<el-form-item label="单位名称" required><el-input v-model="form.name" placeholder="请输入单位名称" /></el-form-item>
<el-form-item label="单位名称" required><el-input v-model="form.name" placeholder="请输入单位名称" @blur="checkName" /><el-alert v-if="duplicateHint" :title="duplicateHint" type="warning" :closable="false" style="margin-top:6px" /></el-form-item>
<el-form-item label="所属行业"><el-input v-model="form.industry" placeholder="如: 教育、医疗、政府..." /></el-form-item>
<el-form-item label="单位地址"><el-input v-model="form.address" /></el-form-item>
<el-form-item label="在用业务"><el-input v-model="form.in_use_services" placeholder="如: 云桌面、专线、视频会议" /></el-form-item>
@@ -359,7 +414,7 @@ async function handleImport() {
<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: 'ZCOOL XiaoWei', STSong, serif; color: var(--ink)">联系人</h4>
<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+')' : '' }}
@@ -399,6 +454,37 @@ async function handleImport() {
<el-button type="primary" :loading="importLoading" @click="handleImport" :disabled="!importFile">确认导入</el-button>
</template>
</el-dialog>
<!-- Merge confirmation dialog -->
<el-dialog v-model="mergeDialogVisible" title="合并客户" width="520px" :close-on-click-modal="false">
<div style="line-height:1.8">
<el-alert type="warning" :closable="false" style="margin-bottom:16px">
客户<b>{{ mergeTargetName }}</b>已存在是否将<b>{{ mergeSourceName }}</b>合并到{{ mergeTargetName }}
</el-alert>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="源客户">{{ mergeSourceName }}</el-descriptions-item>
<el-descriptions-item label="目标客户">{{ mergeTargetName }}</el-descriptions-item>
<el-descriptions-item label="拜访记录">{{ mergePreview.visits || 0 }} </el-descriptions-item>
<el-descriptions-item label="工作计划">{{ mergePreview.work_plans || 0 }} </el-descriptions-item>
<el-descriptions-item label="商机跟单">{{ mergePreview.mini_business || 0 }} </el-descriptions-item>
<el-descriptions-item label="要客拜访">{{ mergePreview.key_visits || 0 }} </el-descriptions-item>
<el-descriptions-item label="联系人">{{ mergePreview.contacts || 0 }} </el-descriptions-item>
<el-descriptions-item label="经理分配">{{ mergePreview.assignments || 0 }} </el-descriptions-item>
</el-descriptions>
<div v-if="mergePreview.note" style="margin-top:12px; color:var(--amber); font-size:13px">
{{ mergePreview.note }}
</div>
<div style="margin-top:12px; color:var(--vermilion); font-size:13px">
合并后{{ mergeSourceName }}将被删除此操作不可撤销
</div>
</div>
<template #footer>
<el-button @click="mergeDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="mergeLoading" @click="handleMerge">确认合并</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -407,7 +493,7 @@ async function handleImport() {
.page-head-row { display: flex; justify-content: space-between; align-items: center; }
.page-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 22px; font-weight: 400;
color: var(--ink); letter-spacing: 0.06em;
}
+110 -55
View File
@@ -4,11 +4,12 @@ import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { dashboardApi } from '@/api/dashboard'
import { ElMessage } from 'element-plus'
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_start: '', week_end: '' })
const stats = ref({ week_visits: 0, work_plans: 0, mini_business: 0, key_visits: 0, week_leaves: 0, week_start: '', week_end: '' })
const progress = ref<any[]>([])
const weekOffset = ref(0) // 0 = current week, -1 = last week, etc.
@@ -58,7 +59,26 @@ function goWeeklyReport(managerId?: string) {
else router.push('/weekly-report')
}
function rowState(p: any): 'full' | 'catching' | 'missing' {
function getRefDate(): string {
const d = new Date()
d.setDate(d.getDate() + weekOffset.value * 7)
return d.toISOString().slice(0, 10)
}
async function handleExport() {
try {
const res = await api.get('/export/weekly-report', { params: { reference_date: getRefDate() }, responseType: 'blob' })
const url = URL.createObjectURL(res.data)
const a = document.createElement('a')
a.href = url; a.download = 'weekly_report.xlsx'; a.click()
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch (e: any) { ElMessage.error('导出失败') }
}
function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' | 'rest_day' {
if (p.is_rest_day) return 'rest_day'
if (p.on_leave) return 'on_leave'
if (p.has_reported_today && p.completed) return 'full'
if (p.has_reported_today && !p.completed) return 'catching'
return 'missing'
@@ -67,15 +87,36 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
<template>
<div class="dashboard" v-loading="loading">
<!-- Editorial Page Header -->
<!-- Page Header -->
<div class="page-head">
<h2 class="page-title">仪表盘</h2>
<div class="week-nav">
<button class="week-nav-btn" @click="changeWeek(-1)" title="上一周"></button>
<span class="week-label">{{ stats.week_start }} {{ stats.week_end }}</span>
<button class="week-nav-btn" @click="changeWeek(1)" :disabled="weekOffset >= 0" title="一周"></button>
<button v-if="isHistoricalWeek" class="week-nav-reset" @click="goCurrentWeek">回到本周</button>
<el-tag v-if="isHistoricalWeek" type="info" size="small" style="margin-left:8px">📦 历史归档 · 只读</el-tag>
<div class="page-head-row">
<div>
<h2 class="page-title">仪表盘</h2>
<div class="week-nav">
<button class="week-nav-btn" @click="changeWeek(-1)" title="一周"></button>
<span class="week-label">{{ stats.week_start }} {{ stats.week_end }}</span>
<button class="week-nav-btn" @click="changeWeek(1)" :disabled="weekOffset >= 0" title="下一周"></button>
<button v-if="isHistoricalWeek" class="week-nav-reset" @click="goCurrentWeek">回到本周</button>
<el-tag v-if="isHistoricalWeek" type="info" size="small" style="margin-left:8px">📦 历史归档 · 只读</el-tag>
</div>
</div>
<div class="page-head-actions">
<el-button type="primary" @click="goWeeklyReport()">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
本周周报详情
</el-button>
<el-button type="success" v-if="auth.isDirector" @click="handleExport">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
导出 Excel
</el-button>
</div>
</div>
<div class="page-rule"></div>
</div>
@@ -131,34 +172,17 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
<span class="stat-label">要客拜访</span>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="actions">
<el-button type="primary" @click="goWeeklyReport">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
本周周报详情
</el-button>
<el-button type="success" v-if="auth.isDirector" @click="router.push('/weekly-report')">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
导出 Excel
</el-button>
<el-button plain @click="router.push('/customers')">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<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>
</svg>
客户管理
</el-button>
<div class="stat-card stat-card--clickable stat-card--slate" @click="router.push('/leaves')">
<div class="stat-glyph stat-glyph--slate">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" 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>
</div>
<div class="stat-body">
<span class="stat-num">{{ stats.week_leaves }}</span>
<span class="stat-label">本周请假</span>
</div>
</div>
</div>
<!-- Reporting Progress -->
@@ -174,6 +198,7 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
</div>
</template>
<div v-if="progress.length === 0" class="empty">暂无数据</div>
<div class="progress-grid">
<div
v-for="p in progress"
:key="p.manager_id"
@@ -182,24 +207,28 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
>
<!-- Chinese Seal Watermark -->
<div class="seal-stamp" :class="`seal-stamp--${rowState(p)}`" aria-hidden="true">
<span class="seal-char">{{ { full: '满', catching: '追', missing: '未' }[rowState(p)] }}</span>
<span class="seal-char">{{ { full: '', catching: '', missing: '填' }[rowState(p)] }}</span>
<span class="seal-char">{{ { full: '满', catching: '追', missing: '未', on_leave: '假', rest_day: '休' }[rowState(p)] }}</span>
<span class="seal-char">{{ { full: '', catching: '', missing: '填', on_leave: '', rest_day: '' }[rowState(p)] }}</span>
</div>
<div class="progress-info">
<span class="progress-name">
<el-link type="primary" :underline="false" @click="goWeeklyReport(p.manager_id)">{{ p.manager_name }}</el-link>
<span class="progress-status" :class="`progress-status--${rowState(p)}`">
{{ { full: '本周已满', catching: '今日已填', missing: '今日未填' }[rowState(p)] }}
{{ { full: '本周已满', catching: '今日已填', missing: '今日未填', on_leave: '请假中', rest_day: '休息日' }[rowState(p)] }}
</span>
</span>
<span class="progress-count">{{ p.visit_count }} / {{ p.expected }} </span>
<span class="progress-count" v-if="!p.on_leave">{{ p.visit_count }} / {{ p.expected }} </span>
</div>
<el-progress
v-if="!p.on_leave && !p.is_rest_day"
:percentage="Math.min(100, Math.round((p.visit_count / Math.max(p.expected, 1)) * 100))"
:color="rowState(p) === 'full' ? '#4A6741' : rowState(p) === 'catching' ? '#C4934A' : '#B8472E'"
:stroke-width="12"
/>
<span v-else-if="p.on_leave" class="leave-text">请假中</span>
<span v-else-if="p.is_rest_day" class="rest-text">休息日</span>
</div>
</div>
</el-card>
</div>
@@ -207,10 +236,12 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
<style scoped>
/* ═══ Page Header ═══ */
.page-head { margin-bottom: 24px; }
.page-head { margin-bottom: 20px; }
.page-head-row { display: flex; justify-content: space-between; align-items: center; }
.page-head-actions { display: flex; gap: 8px; flex-shrink: 0; }
.page-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 22px; font-weight: 400;
color: var(--ink); letter-spacing: 0.06em;
}
@@ -218,7 +249,7 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
.week-nav-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 4px 10px; cursor: pointer; color: var(--warm-gray); font-size: 12px; }
.week-nav-btn:hover:not(:disabled) { color: var(--ink); border-color: var(--ink); }
.week-nav-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.week-label { font-family: 'JetBrains Mono', 'SF Mono', monospace; font-size: 12px; color: var(--ink); letter-spacing: 0.04em; }
.week-label { font-family: var(--font-mono); font-size: 12px; color: var(--ink); letter-spacing: 0.04em; }
.week-nav-reset { background: none; border: 1px solid var(--gold); color: var(--gold); padding: 4px 10px; cursor: pointer; font-size: 12px; }
.week-nav-reset:hover { background: var(--gold); color: #fff; }
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
@@ -226,7 +257,7 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
/* ═══ Stat Grid ═══ */
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(5, 1fr);
gap: 16px;
margin-bottom: 24px;
}
@@ -243,6 +274,7 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
.stat-card:hover { box-shadow: var(--shadow-md); }
.stat-card--clickable { cursor: pointer; }
.stat-card--clickable:hover { border-color: var(--gold); }
.stat-card--slate:hover { border-color: #5B7FA5; }
.stat-glyph {
width: 48px; height: 48px;
@@ -253,25 +285,30 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
.stat-glyph--sage { background: #EDF2EC; color: var(--sage); }
.stat-glyph--gold { background: #FBF6EE; color: var(--gold); }
.stat-glyph--vermilion { background: #FBF1EE; color: var(--vermilion); }
.stat-glyph--slate { color: #5B7FA5; }
.stat-body { display: flex; flex-direction: column; }
.stat-num {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 28px; color: var(--ink); line-height: 1.1; letter-spacing: 0.04em;
}
.stat-label {
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 12px; color: var(--warm-gray); letter-spacing: 0.04em;
}
/* ═══ Actions ═══ */
.actions { display: flex; gap: 10px; margin-bottom: 24px; }
/* ═══ Progress Grid — responsive 2-column ═══ */
.progress-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
gap: 8px 24px;
}
/* ═══ Progress ═══ */
.progress-card { margin-top: 4px; }
.card-header-title {
display: flex; align-items: center;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 15px; color: var(--ink); letter-spacing: 0.05em;
}
@@ -289,6 +326,8 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
.progress-row--missing { border-left-color: var(--vermilion); background: rgba(184,71,46,0.02); }
.progress-row--catching { border-left-color: var(--gold); background: rgba(196,147,74,0.03); }
.progress-row--full { border-left-color: var(--sage); background: rgba(74,103,65,0.02); }
.progress-row--on_leave { border-left-color: #5B7FA5; }
.progress-row--rest_day { border-left-color: #9CA3AF; background: rgba(156,163,175,0.02); }
/* ═══ Chinese Seal Stamp Watermark ═══ */
.seal-stamp {
@@ -338,8 +377,21 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
color: var(--sage);
}
/* On-leave stamp — slate blue-gray ink */
.seal-stamp--on_leave {
border-color: #5B7FA5;
outline-color: #5B7FA5;
color: #5B7FA5;
}
.seal-stamp--rest_day {
border-color: #9CA3AF;
outline-color: #9CA3AF;
color: #9CA3AF;
}
.seal-char {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 20px;
font-weight: 400;
line-height: 1.1;
@@ -356,19 +408,22 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
/* Inline status text (replaces el-tag) */
.progress-status {
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 12px;
letter-spacing: 0.05em;
}
.progress-status--missing { color: var(--vermilion); }
.progress-status--catching { color: var(--gold); }
.progress-status--full { color: var(--sage); }
.progress-status--on_leave { color: #5B7FA5; }
.progress-count {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-family: var(--font-mono);
font-size: 13px; color: var(--warm-gray); letter-spacing: 0.03em;
position: relative; z-index: 1;
}
.empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: 'Noto Serif SC', STSong, serif; }
.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); }
</style>
+131 -16
View File
@@ -1,15 +1,22 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme'
import { ElMessage, ElMessageBox } from 'element-plus'
import { todayStr } from '@/utils'
import { getMgrStyle, loadManagerColors } from '@/utils/managerColor'
import { useScreenshot } from '@/utils/screenshot'
import api from '@/api/index'
import EditLogPanel from '@/components/EditLogPanel.vue'
const auth = useAuthStore()
const themeStore = useThemeStore()
const loading = ref(false)
const isLight = computed(() => themeStore.currentTheme === 'light')
const keyVisits = ref<any[]>([])
const customers = ref<any[]>([])
const shotRef = ref<HTMLElement | null>(null)
const { capturing, captureEl } = useScreenshot()
const allUsers = ref<any[]>([])
const plannedVisitors = ref<string[]>([])
@@ -19,8 +26,59 @@ const form = ref<any>({})
const statusPick = ref<Record<string, string>>({})
const keyStatuses = ['未开始', '进行中', '已完成']
// ── Filters ──
const filterManager = ref('')
const filterStatus = ref('')
const managerOptions = computed(() => {
const seen = new Set<string>()
return keyVisits.value
.map((k: any) => k.manager_name || '未知')
.filter((n: string) => { if (seen.has(n)) return false; seen.add(n); return true })
.sort()
})
const filteredItems = computed(() => {
let list = keyVisits.value
if (filterManager.value) list = list.filter((k: any) => (k.manager_name || '未知') === filterManager.value)
if (filterStatus.value) list = list.filter((k: any) => k.progress_status === filterStatus.value)
return list
})
// ── Three-level sort: urgency → planned_date → manager pinyin ──
const urgencyWeight: Record<string, number> = { '紧急': 0, '重要': 1, '普通': 2 }
const sortedItems = computed(() => {
return [...filteredItems.value].sort((a: any, b: any) => {
const ua = urgencyWeight[a.urgency_level] ?? 3
const ub = urgencyWeight[b.urgency_level] ?? 3
if (ua !== ub) return ua - ub
if (a.planned_date !== b.planned_date) return (a.planned_date || '').localeCompare(b.planned_date || '')
return (a.manager_name || '').localeCompare(b.manager_name || '', 'zh-CN')
})
})
function resetFilters() {
filterManager.value = ''
filterStatus.value = ''
}
async function handleScreenshot() {
const d = new Date().toISOString().slice(0, 10)
await captureEl(shotRef.value, `要客拜访_${d}.png`)
}
const managerSummary = computed(() => {
const map: Record<string, number> = {}
keyVisits.value.forEach((k: any) => {
const n = k.manager_name || '未知'
map[n] = (map[n] || 0) + 1
})
return Object.entries(map).sort((a, b) => b[1] - a[1])
})
onMounted(async () => {
await Promise.all([loadItems(), loadCustomers(), loadUsers()])
await Promise.all([loadItems(), loadCustomers(), loadUsers(), loadManagerColors()])
})
async function loadUsers() {
@@ -32,7 +90,7 @@ async function loadUsers() {
async function loadCustomers(q?: string) {
try {
const params: any = { page_size: 100 }
const params: any = { page_size: 500 }
if (q) params.search = q
const res = await api.get('/customers/', { params })
customers.value = res.data.items || []
@@ -58,6 +116,9 @@ function openCreate() {
function openEdit(item: any) {
dialogMode.value = 'edit'
form.value = { ...item }
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 })
}
plannedVisitors.value = item.planned_visitor ? item.planned_visitor.split('、') : []
dialogVisible.value = true
}
@@ -103,24 +164,59 @@ async function quickStatusChange(row: any, newStatus: string) {
<template>
<div class="key-visits-page" v-loading="loading">
<div ref="shotRef">
<div class="page-head">
<div class="page-head-row">
<div>
<h2 class="page-title">要客拜访</h2>
<p class="page-desc">重要客户拜访计划按紧急程度排序跟踪</p>
</div>
<el-button type="primary" @click="openCreate">
<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>
<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>
新建要客
</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>
</div>
</div>
<div class="page-rule"></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>
</div>
</div>
<!-- Filter Bar -->
<div class="filter-bar">
<div class="filter-row">
<div class="filter-item">
<label class="filter-label">客户经理</label>
<el-select v-model="filterManager" clearable placeholder="全部" size="small" style="width:150px">
<el-option v-for="m in managerOptions" :key="m" :label="m" :value="m" />
</el-select>
</div>
<div class="filter-item">
<label class="filter-label">进展</label>
<el-select v-model="filterStatus" clearable placeholder="全部" size="small" style="width:120px">
<el-option v-for="s in keyStatuses" :key="s" :label="s" :value="s" />
</el-select>
</div>
<el-button v-if="filterManager || filterStatus" size="small" plain @click="resetFilters">重置</el-button>
<span class="filter-count">{{ filteredItems.length }} / {{ keyVisits.length }} </span>
</div>
</div>
<el-card>
<el-table :data="keyVisits" stripe size="small" v-if="keyVisits.length">
<el-table :data="sortedItems" stripe size="small" v-if="sortedItems.length" v-column-resize>
<el-table-column type="index" label="序号" width="50" />
<el-table-column prop="customer_name" label="客户" width="160">
<template #default="{ row }">
@@ -131,6 +227,11 @@ async function quickStatusChange(row: any, newStatus: string) {
</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>
</el-table-column>
<el-table-column prop="urgency_level" label="重要度" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.urgency_level==='紧急'?'danger':row.urgency_level==='重要'?'warning':''">{{ row.urgency_level }}</el-tag>
@@ -154,14 +255,16 @@ async function quickStatusChange(row: any, newStatus: string) {
<el-table-column prop="planned_date" label="计划时间" width="110" />
<el-table-column prop="planned_visitor" label="拜访人" width="80" />
<el-table-column prop="visit_target" label="拜访对象" width="110" />
<el-table-column label="操作" width="80" fixed="right">
<el-table-column label="操作" width="80" fixed="right" class-name="screenshot-hide">
<template #default="{ row }">
<el-button text size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-else class="empty">暂无需重点关注的客户</div>
<div v-if="!keyVisits.length" class="empty">暂无需重点关注的客户</div>
<div v-else-if="keyVisits.length && !filteredItems.length" class="empty">无匹配结果</div>
</el-card>
</div><!-- /shotRef -->
<!-- Dialog -->
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 要客拜访'" width="520px">
@@ -209,9 +312,21 @@ async function quickStatusChange(row: any, newStatus: string) {
.edit-indicator { font-size: 12px; margin-left: 3px; opacity: 0.5; cursor: help; }
.page-head { margin-bottom: 20px; }
.page-head-row { display: flex; justify-content: space-between; align-items: flex-start; }
.page-title { margin: 0; font-family: 'ZCOOL XiaoWei', STSong, serif; 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: 'Noto Serif SC', STSong, serif; }
.page-head-actions { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
.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; }
.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; }
.summary-chip:hover { opacity: 0.8; transform: translateY(-1px); }
.summary-chip--active { outline: 2px solid var(--gold); outline-offset: 1px; }
.mgr-tag { display: inline-block; padding: 1px 9px; border-radius: 10px; font-size: 12px; color: var(--mgr-chip-text); white-space: nowrap; }
.method-grid { display: flex; gap: 8px; }
.empty { text-align: center; color: var(--c-text-muted); padding: 48px 0; font-family: 'Noto Serif SC', STSong, serif; }
.empty { text-align: center; color: var(--c-text-muted); padding: 48px 0; font-family: var(--font-body); }
.filter-bar { margin-bottom: 16px; padding: 12px 16px; background: var(--surface); border: 1px solid var(--warm-border); }
.filter-row { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.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; }
</style>
+332
View File
@@ -0,0 +1,332 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getMgrStyle, loadManagerColors } from '@/utils/managerColor'
import { useScreenshot } from '@/utils/screenshot'
import { leavesApi } from '@/api/leaves'
import api from '@/api/index'
const auth = useAuthStore()
const themeStore = useThemeStore()
const loading = ref(false)
const leaves = ref<any[]>([])
const allUsers = 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 leaveTypes = ['年假', '事假', '病假', '调休', '其他']
const leaveTypeColors: Record<string, string> = {
'年假': '#4A6741', '事假': '#5B7FA5', '病假': '#B8472E',
'调休': '#C4934A', '其他': '#7B7568',
}
const filterManager = ref('')
const filterStatus = ref('')
const isLight = computed(() => themeStore.currentTheme === 'light')
const today = new Date().toISOString().slice(0, 10)
const managerOptions = computed(() => {
const seen = new Set<string>()
return leaves.value
.map((l: any) => l.manager_name || '未知')
.filter((n: string) => { if (seen.has(n)) return false; seen.add(n); return true })
.sort()
})
const statusOptions = [
{ label: '进行中', value: 'active' },
{ label: '即将开始', value: 'upcoming' },
{ label: '已结束', value: 'past' },
]
const filteredLeaves = computed(() => {
let list = leaves.value
if (filterManager.value) list = list.filter((l: any) => (l.manager_name || '未知') === filterManager.value)
return list
})
function resetFilters() {
filterManager.value = ''
filterStatus.value = ''
}
const leaveTypeSummary = computed(() => {
const map: Record<string, number> = {}
leaves.value.forEach((l: any) => {
map[l.leave_type] = (map[l.leave_type] || 0) + 1
})
return Object.entries(map).sort((a, b) => b[1] - a[1])
})
function isLeaveActive(row: any): boolean {
return row.start_date <= today && row.end_date >= today
}
function tableRowClass({ row }: any) {
return isLeaveActive(row) ? 'leave-row--active' : ''
}
onMounted(async () => {
await Promise.all([loadLeaves(), loadUsers(), loadManagerColors()])
})
async function loadUsers() {
try {
const res = await api.get('/users/?role=manager')
allUsers.value = Array.isArray(res.data) ? res.data : (res.data.items || [])
} catch (_) {}
}
async function loadLeaves() {
loading.value = true
try {
const params: any = {}
if (filterStatus.value) params.status = filterStatus.value
const res = await leavesApi.list(params)
leaves.value = res.data.items || []
} catch (e: any) { ElMessage.error('加载失败') }
finally { loading.value = false }
}
function openCreate() {
dialogMode.value = 'create'
form.value = {
manager_id: auth.isDirector ? '' : auth.userId,
leave_type: '事假',
start_date: '',
end_date: '',
reason: '',
}
dialogVisible.value = true
}
function openEdit(item: any) {
dialogMode.value = 'edit'
form.value = { ...item }
dialogVisible.value = true
}
async function handleSave() {
if (!form.value.start_date || !form.value.end_date) {
ElMessage.warning('请选择日期范围')
return
}
if (form.value.start_date > form.value.end_date) {
ElMessage.warning('结束日期不能早于开始日期')
return
}
try {
const payload = {
manager_id: form.value.manager_id,
leave_type: form.value.leave_type,
start_date: form.value.start_date,
end_date: form.value.end_date,
reason: form.value.reason || '',
}
if (dialogMode.value === 'create') {
await leavesApi.create(payload)
} else {
await leavesApi.update(form.value.id, payload)
}
ElMessage.success(dialogMode.value === 'create' ? '已创建' : '已更新')
dialogVisible.value = false
await loadLeaves()
} catch (e: any) {
ElMessage.error('保存失败: ' + (e.response?.data?.detail || e.message))
}
}
async function handleDelete(id: string) {
try {
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
await leavesApi.delete(id)
ElMessage.success('已删除')
await loadLeaves()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
async function handleScreenshot() {
const d = new Date().toISOString().slice(0, 10)
await captureEl(shotRef.value, `请假管理_${d}.png`)
}
function onStatusChange(val: string) {
filterStatus.value = val
loadLeaves()
}
</script>
<template>
<div class="leaves-page" v-loading="loading">
<div ref="shotRef">
<div class="page-head">
<div class="page-head-row">
<div>
<h2 class="page-title">请假管理</h2>
<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>
新建请假
</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>
</div>
</div>
<div class="page-rule"></div>
<div v-if="leaveTypeSummary.length" class="summary-bar">
<span class="summary-label">请假类型汇总</span>
<span v-for="[type, count] in leaveTypeSummary" :key="type" class="summary-chip" :style="{ background: leaveTypeColors[type] + '20', color: leaveTypeColors[type], border: '1px solid ' + leaveTypeColors[type] + '40' }">{{ type }} · {{ count }}</span>
</div>
</div>
<!-- Filter Bar -->
<div class="filter-bar">
<div class="filter-row">
<div class="filter-item">
<label class="filter-label">客户经理</label>
<el-select v-model="filterManager" clearable placeholder="全部" size="small" style="width:150px">
<el-option v-for="m in managerOptions" :key="m" :label="m" :value="m" />
</el-select>
</div>
<div class="filter-item">
<label class="filter-label">状态</label>
<el-select v-model="filterStatus" clearable placeholder="全部" size="small" style="width:120px" @change="onStatusChange">
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
</div>
<el-button v-if="filterManager" size="small" plain @click="resetFilters">重置</el-button>
<span class="filter-count">{{ filteredLeaves.length }} / {{ leaves.length }} </span>
</div>
</div>
<el-card>
<el-table :data="filteredLeaves" stripe size="small" v-if="filteredLeaves.length" :row-class-name="tableRowClass" v-column-resize>
<el-table-column type="index" label="序号" width="50" />
<el-table-column label="姓名" width="100">
<template #default="{ row }">
<span class="mgr-tag" :style="getMgrStyle(row.manager_name, isLight)">{{ row.manager_name || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="类型" width="90">
<template #default="{ row }">
<span class="leave-type-chip" :style="{ background: leaveTypeColors[row.leave_type] || '#7B7568', color: '#fff', padding: '2px 10px', fontSize: '12px' }">{{ row.leave_type }}</span>
</template>
</el-table-column>
<el-table-column label="日期范围" width="220">
<template #default="{ row }">{{ row.start_date }} ~ {{ row.end_date }}</template>
</el-table-column>
<el-table-column label="天数" width="60" align="center">
<template #default="{ row }">{{ row.days }}</template>
</el-table-column>
<el-table-column prop="reason" label="原因" min-width="200" show-overflow-tooltip />
<el-table-column label="提交人" width="100">
<template #default="{ row }">{{ row.submitted_by_name || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right" class-name="screenshot-hide">
<template #default="{ row }">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="!leaves.length" class="empty">暂无请假记录</div>
<div v-else-if="leaves.length && !filteredLeaves.length" class="empty">无匹配结果</div>
</el-card>
</div><!-- /shotRef -->
<!-- Dialog -->
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 请假'" width="500px">
<el-form label-position="top">
<el-form-item v-if="auth.isDirector" label="客户经理">
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
<el-option v-for="u in allUsers" :key="u.id" :label="u.name" :value="u.id" />
</el-select>
</el-form-item>
<el-form-item label="请假类型">
<div class="leave-type-grid">
<button
v-for="t in leaveTypes" :key="t"
type="button"
class="leave-type-chip-btn"
:class="{ 'leave-type-chip-btn--active': form.leave_type === t }"
:style="form.leave_type === t ? { background: leaveTypeColors[t], borderColor: leaveTypeColors[t], color: '#fff' } : {}"
@click="form.leave_type = t"
>{{ t }}</button>
</div>
</el-form-item>
<el-form-item label="请假日期">
<el-date-picker
v-model="form.start_date"
type="date"
placeholder="开始日期"
style="width:100%; margin-bottom: 8px"
/>
<el-date-picker
v-model="form.end_date"
type="date"
placeholder="结束日期"
style="width:100%"
/>
</el-form-item>
<el-form-item label="原因(选填)">
<el-input v-model="form.reason" type="textarea" :rows="3" placeholder="请假原因..." />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-head { margin-bottom: 20px; }
.page-head-row { display: flex; justify-content: space-between; align-items: flex-start; }
.page-head-actions { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
.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; }
.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; white-space: nowrap; }
.mgr-tag { display: inline-block; padding: 1px 9px; border-radius: 10px; font-size: 12px; color: var(--mgr-chip-text); white-space: nowrap; }
.empty { text-align: center; color: var(--c-text-muted); padding: 48px 0; font-family: var(--font-body); }
.filter-bar { margin-bottom: 16px; padding: 12px 16px; background: var(--surface); border: 1px solid var(--warm-border); }
.filter-row { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.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; }
.leave-type-grid { display: flex; gap: 8px; flex-wrap: wrap; }
.leave-type-chip-btn {
flex: 1; min-width: 60px; padding: 10px 8px;
background: var(--surface); border: 1px solid var(--warm-border);
font-family: var(--font-body); font-size: 13px; letter-spacing: 0.04em;
cursor: pointer; transition: all 0.25s; text-align: center;
}
.leave-type-chip-btn:hover { border-color: var(--ink); }
.leave-type-chip-btn--active { font-weight: 600; }
:deep(.leave-row--active) {
border-left: 3px solid #5B7FA5 !important;
background: rgba(91, 127, 165, 0.04) !important;
}
</style>
+385
View File
@@ -0,0 +1,385 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { dashboardApi } from '@/api/dashboard'
import { ElMessage } from 'element-plus'
import api from '@/api/index'
import { todayStr } from '@/utils'
const router = useRouter()
const auth = useAuthStore()
const loading = ref(false)
const board = ref<any>(null)
const expandedManagers = ref<Set<string>>(new Set())
const monthOffset = ref(0)
// ── Single dialog state ──
const dialogVisible = ref(false)
const selectedCustomer = ref<any>(null)
const planDate = ref(todayStr())
const planContent = ref('')
const planSaving = ref(false)
const referenceMonth = computed(() => {
const d = new Date()
d.setMonth(d.getMonth() + monthOffset.value)
return d.toISOString().slice(0, 7)
})
const isCurrentMonth = computed(() => monthOffset.value >= 0)
function changeMonth(delta: number) { monthOffset.value += delta; loadData() }
function goCurrentMonth() { monthOffset.value = 0; loadData() }
function toggleManager(id: string) {
if (expandedManagers.value.has(id)) expandedManagers.value.delete(id)
else expandedManagers.value.add(id)
}
async function loadData() {
loading.value = true
try {
const refDate = new Date()
refDate.setMonth(refDate.getMonth() + monthOffset.value)
const res = await dashboardApi.getLightBoard({ reference_date: refDate.toISOString().slice(0, 10) })
board.value = res.data
} catch (e: any) {
ElMessage.error('加载亮灯表失败')
} finally { loading.value = false }
}
onMounted(loadData)
function goManagerReport(managerId: string) {
router.push({ path: '/weekly-report', query: { manager_id: managerId } })
}
const coverageColor = (rate: number): string => {
if (rate >= 0.8) return 'var(--sage)'
if (rate >= 0.5) return 'var(--gold)'
return 'var(--vermilion)'
}
// ── Customer Dialog ──
function openCustomerDialog(cust: any) {
selectedCustomer.value = cust
planContent.value = ''
// Default plan date: today for red, Friday for yellow
if (cust.status === 'red') {
planDate.value = todayStr()
} else if (cust.status === 'yellow') {
const d = new Date()
d.setDate(d.getDate() + ((5 - d.getDay()) % 7 + 7) % 7 || 7)
planDate.value = d.toISOString().slice(0, 10)
} else {
planDate.value = todayStr()
}
dialogVisible.value = true
}
async function handleCreatePlan() {
if (!planContent.value.trim()) { ElMessage.warning('请输入计划内容'); return }
planSaving.value = true
try {
await api.post('/work-plans/', {
customer_id: selectedCustomer.value.customer_id,
plan_content: planContent.value.trim(),
plan_date: planDate.value,
status: '计划中',
})
ElMessage.success('拜访计划已制定')
planContent.value = ''
dialogVisible.value = false
await loadData() // Refresh to show new plan on card
} catch (e: any) {
ElMessage.error('制定失败: ' + (e.response?.data?.detail || e.message))
} finally { planSaving.value = false }
}
function goWeeklyReport(customerId: string) {
router.push({ path: '/weekly-report', query: { customer_id: customerId } })
}
// Status label map
const statusLabel: Record<string, string> = { green: '近30天已拜访', yellow: '31-60天前 (临期)', red: '急需拜访', gray: '未分配' }
</script>
<template>
<div class="light-board" v-loading="loading">
<!-- Page Header -->
<div class="page-head">
<div class="page-head-row">
<div>
<h2 class="page-title">客户拜访亮灯表</h2>
<div class="month-nav">
<button class="month-nav-btn" @click="changeMonth(-1)"></button>
<span class="month-label">{{ referenceMonth }}</span>
<button class="month-nav-btn" @click="changeMonth(1)" :disabled="isCurrentMonth"></button>
<button v-if="!isCurrentMonth" class="month-nav-reset" @click="goCurrentMonth">回到本月</button>
</div>
</div>
</div>
<div class="page-rule"></div>
</div>
<!-- Team Summary Bar -->
<div v-if="board" class="team-bar">
<div class="team-stat"><span class="team-stat-num">{{ board.team_summary.total_customers }}</span><span class="team-stat-label">总客户</span></div>
<div class="team-stat team-stat--green"><span class="team-stat-num">{{ board.team_summary.visited_this_month }}</span><span class="team-stat-label">已拜访</span></div>
<div class="team-stat team-stat--yellow"><span class="team-stat-num">{{ board.team_summary.visited_last_month_only }}</span><span class="team-stat-label">临期</span></div>
<div class="team-stat team-stat--red"><span class="team-stat-num">{{ board.team_summary.not_visited_2months }}</span><span class="team-stat-label">未拜访</span></div>
<div class="team-stat" v-if="board.team_summary.unassigned"><span class="team-stat-num">{{ board.team_summary.unassigned }}</span><span class="team-stat-label">未分配</span></div>
<div class="team-stat team-stat--coverage"><span class="team-stat-num">{{ (board.team_summary.coverage_rate * 100).toFixed(0) }}%</span><span class="team-stat-label">覆盖率</span></div>
</div>
<!-- Manager Rows -->
<div v-if="board" class="manager-list">
<div v-for="m in board.managers" :key="m.manager_id" class="manager-row" :class="{ 'manager-row--expanded': expandedManagers.has(m.manager_id) }">
<div class="manager-summary" @click="toggleManager(m.manager_id)">
<div class="manager-info">
<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>
</div>
<div class="manager-lights">
<span class="light-dot light-dot--green">{{ m.visited_this_month }}</span>
<span class="light-dot light-dot--yellow">{{ m.visited_last_month_only }}</span>
<span class="light-dot light-dot--red">{{ m.not_visited_2months }}</span>
</div>
<div class="manager-coverage">
<div class="coverage-bar"><div class="coverage-fill" :style="{ width: (m.coverage_rate * 100) + '%', background: coverageColor(m.coverage_rate) }"></div></div>
<span class="coverage-pct" :style="{ color: coverageColor(m.coverage_rate) }">{{ (m.coverage_rate * 100).toFixed(0) }}%</span>
</div>
</div>
<div v-if="expandedManagers.has(m.manager_id)" class="customer-grid">
<div v-for="cust in m.customers" :key="cust.customer_id" class="customer-card" :class="`customer-card--${cust.status}`" @click="openCustomerDialog(cust)">
<div class="card-status-stripe"></div>
<div class="card-body">
<div class="card-name-row">
<span class="status-circle" :class="`status-circle--${cust.status}`"></span>
<strong class="card-name">{{ cust.customer_name }}</strong>
</div>
<div class="card-meta">
<span v-if="cust.industry" class="card-industry">{{ cust.industry }}</span>
<span v-if="cust.in_use_services" class="card-services">{{ cust.in_use_services }}</span>
</div>
<div class="card-visit-info">
<span class="card-last-visit" v-if="cust.last_visit_date">{{ { green: '最近', yellow: '上次', red: '上次' }[cust.status] }}{{ cust.last_visit_date }}</span>
<span class="card-last-visit card-last-visit--never" v-else>从未拜访</span>
<span v-if="cust.last_visit_method" class="card-method">{{ cust.last_visit_method }}</span>
</div>
<!-- Plan badges -->
<div v-if="cust.plans?.length" class="card-plans">
<span v-for="p in cust.plans" :key="p.plan_date" class="plan-badge" :class="{ 'plan-overdue': p.plan_overdue }">
{{ p.plan_overdue ? ' 过期' : '📅' }} {{ p.plan_date }}
</span>
</div>
<div v-if="cust.status === 'red' && cust.consecutive_missed_months > 0" class="card-warning">
连续 {{ cust.consecutive_missed_months }} 个周期未拜访
</div>
</div>
</div>
<div v-if="m.customers.length === 0" class="empty">暂无客户</div>
</div>
</div>
<!-- Unassigned Customers -->
<div v-if="board.unassigned_customers.length > 0" class="manager-row unassigned-section">
<div class="manager-summary unassigned-summary" @click="toggleManager('unassigned')">
<div class="manager-info">
<span class="manager-expand">{{ expandedManagers.has('unassigned') ? '▼' : '▶' }}</span>
<span class="manager-name" style="color: var(--warm-gray)">未分配客户</span>
<span class="manager-count">{{ board.unassigned_customers.length }} </span>
</div>
</div>
<div v-if="expandedManagers.has('unassigned')" class="customer-grid">
<div v-for="cust in board.unassigned_customers" :key="cust.customer_id" class="customer-card customer-card--gray" @click="router.push('/customers')">
<div class="card-status-stripe"></div>
<div class="card-body">
<div class="card-name-row"><span class="status-circle status-circle--gray"></span><strong class="card-name">{{ cust.customer_name }}</strong></div>
<div class="card-meta"><span v-if="cust.industry" class="card-industry">{{ cust.industry }}</span></div>
<div class="card-visit-info"><span class="card-last-visit card-last-visit--never">未分配客户经理</span></div>
</div>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-if="!loading && !board" class="empty-state"><p class="empty-text">暂无数据</p></div>
<!-- Customer Detail Dialog -->
<el-dialog v-model="dialogVisible" :title="selectedCustomer?.customer_name" width="520px" v-if="selectedCustomer">
<!-- Status header -->
<div class="dlg-status" :class="`dlg-status--${selectedCustomer.status}`">
{{ statusLabel[selectedCustomer.status] }}
<span v-if="selectedCustomer.consecutive_missed_months > 1" style="margin-left:6px">
(连续 {{ selectedCustomer.consecutive_missed_months }} 个周期)
</span>
</div>
<!-- Customer Info -->
<div class="dlg-info">
<div class="dlg-info-row" v-if="selectedCustomer.industry"><span class="dlg-label">行业</span><span>{{ selectedCustomer.industry }}</span></div>
<div class="dlg-info-row" v-if="selectedCustomer.in_use_services"><span class="dlg-label">在用业务</span><span>{{ selectedCustomer.in_use_services }}</span></div>
<div class="dlg-info-row" v-if="selectedCustomer.monthly_fee"><span class="dlg-label">月费</span><span>{{ selectedCustomer.monthly_fee }}</span></div>
<div class="dlg-info-row"><span class="dlg-label">上次拜访</span><span>{{ selectedCustomer.last_visit_date || '从未' }}<span v-if="selectedCustomer.last_visit_method"> · {{ selectedCustomer.last_visit_method }}</span></span></div>
</div>
<!-- Recent Visits (green/yellow) -->
<div v-if="selectedCustomer.recent_visits?.length" class="dlg-section">
<h4 class="dlg-section-title">最近拜访记录</h4>
<div v-for="v in selectedCustomer.recent_visits" :key="v.visit_date" class="dlg-visit-item">
<span class="dlg-vdate">{{ v.visit_date }}</span>
<span class="dlg-vmethod">{{ v.visit_method }}</span>
<span class="dlg-vcontent">{{ v.content }}</span>
</div>
</div>
<!-- Existing Plans -->
<div v-if="selectedCustomer.plans?.length" class="dlg-section">
<h4 class="dlg-section-title">已有计划</h4>
<div v-for="p in selectedCustomer.plans" :key="p.plan_date" class="dlg-plan-item" :class="{ 'dlg-plan-overdue': p.plan_overdue }">
<span>📅 {{ p.plan_date }}</span>
<span>{{ p.plan_content }}</span>
<el-tag v-if="p.plan_overdue" size="small" type="danger">已过期</el-tag>
</div>
</div>
<!-- Quick Plan Form (yellow/red) -->
<div v-if="selectedCustomer.status !== 'green' && selectedCustomer.status !== 'gray'" class="dlg-section dlg-plan-form">
<h4 class="dlg-section-title">{{ selectedCustomer.status === 'red' ? '⚡ 快速制定拜访计划' : '📋 制定拜访计划' }}</h4>
<div class="plan-form-row">
<el-date-picker v-model="planDate" type="date" style="width:150px" value-format="YYYY-MM-DD" />
<el-input v-model="planContent" placeholder="计划内容,如:上门拜访了解云业务需求" style="flex:1" :disabled="planSaving" />
<el-button type="primary" :loading="planSaving" @click="handleCreatePlan">制定</el-button>
</div>
</div>
<!-- Link to weekly report -->
<div class="dlg-section" style="text-align:center;padding-top:8px">
<el-link type="primary" :underline="false" @click="goWeeklyReport(selectedCustomer.customer_id); dialogVisible=false">
🔗 查看该客户周报记录
</el-link>
</div>
</el-dialog>
</div>
</template>
<style scoped>
/* ═══ Page Header ═══ */
.page-head { margin-bottom: 20px; }
.page-head-row { display: flex; justify-content: space-between; align-items: flex-start; }
.page-title { margin: 0; font-family: var(--font-heading); font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
.month-nav { display: flex; align-items: center; gap: 8px; margin: 4px 0 6px; }
.month-nav-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 3px 8px; cursor: pointer; color: var(--warm-gray); font-size: 12px; }
.month-nav-btn:hover:not(:disabled) { color: var(--ink); border-color: var(--ink); }
.month-nav-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.month-label { font-family: var(--font-mono); font-size: 14px; color: var(--ink); letter-spacing: 0.04em; }
.month-nav-reset { background: none; border: 1px solid var(--gold); color: var(--gold); padding: 3px 8px; cursor: pointer; font-size: 12px; }
.month-nav-reset:hover { background: var(--gold); color: #fff; }
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
/* ═══ Team Bar ═══ */
.team-bar { display: flex; gap: 0; background: var(--surface); border: 1px solid var(--warm-border); margin-bottom: 20px; }
.team-stat { flex: 1; text-align: center; padding: 16px 8px; border-right: 1px solid var(--warm-border); display: flex; flex-direction: column; gap: 4px; }
.team-stat:last-child { border-right: none; }
.team-stat-num { font-family: var(--font-heading); font-size: 26px; color: var(--ink); line-height: 1.1; }
.team-stat-label { font-family: var(--font-body); font-size: 11px; color: var(--warm-gray); letter-spacing: 0.04em; }
.team-stat--green .team-stat-num { color: var(--sage); }
.team-stat--yellow .team-stat-num { color: var(--gold); }
.team-stat--red .team-stat-num { color: var(--vermilion); }
.team-stat--coverage .team-stat-num { color: var(--ink); }
/* ═══ Manager Rows ═══ */
.manager-list { display: flex; flex-direction: column; gap: 8px; }
.manager-row { background: var(--surface); border: 1px solid var(--warm-border); transition: border-color 0.2s; }
.manager-row--expanded { border-color: var(--ink); }
.manager-summary { display: flex; align-items: center; gap: 16px; padding: 14px 18px; cursor: pointer; transition: background 0.2s; user-select: none; }
.manager-summary:hover { background: rgba(196,147,74,0.03); }
.manager-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
.manager-expand { font-size: 10px; color: var(--warm-gray); width: 14px; flex-shrink: 0; }
.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-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); }
.light-dot--yellow { background: #FBF6EE; color: var(--gold); }
.light-dot--red { background: #FBF1EE; color: var(--vermilion); }
.manager-coverage { display: flex; align-items: center; gap: 10px; width: 180px; flex-shrink: 0; }
.coverage-bar { flex: 1; height: 6px; background: var(--paper-dark); border-radius: 3px; overflow: hidden; }
.coverage-fill { height: 100%; border-radius: 3px; transition: width 0.5s ease; }
.coverage-pct { font-family: var(--font-mono); font-size: 13px; font-weight: 600; width: 40px; text-align: right; }
/* ═══ Customer Grid ═══ */
.customer-grid { display: flex; flex-wrap: wrap; gap: 10px; padding: 0 18px 16px; border-top: 1px solid var(--warm-border); padding-top: 14px; }
/* ═══ Customer Card ═══ */
.customer-card { width: 220px; background: var(--paper); border: 1px solid var(--warm-border); cursor: pointer; transition: all 0.2s; display: flex; flex-direction: column; overflow: hidden; position: relative; }
.customer-card:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(28,55,56,0.08); }
.customer-card:active { transform: scale(0.99); }
/* ── Card background by status (Option A: full-color tint) ── */
.customer-card--green { background: #ECF7EE; border-color: #B7DFC2; }
.customer-card--yellow { background: #FFF8EB; border-color: #F0D9A0; }
.customer-card--red { background: #FFF0EF; border-color: #F5C4C0; }
.customer-card--gray { background: #F5F5F5; border-color: #DDDDDD; }
/* ── Top accent stripe (subtle, secondary indicator) ── */
.card-status-stripe { height: 3px; width: 100%; flex-shrink: 0; }
.customer-card--green .card-status-stripe { background: var(--sage); }
.customer-card--yellow .card-status-stripe { background: var(--gold); }
.customer-card--red .card-status-stripe { background: var(--vermilion); }
.customer-card--gray .card-status-stripe { background: var(--warm-gray); }
/* ── Red card pulse ── */
.customer-card--red { animation: pulse-card 3s ease-in-out infinite; }
@keyframes pulse-card { 0%,100% { box-shadow: 0 0 0 0 rgba(184,71,46,0); } 50% { box-shadow: 0 0 0 4px rgba(184,71,46,0.12); } }
.card-body { padding: 12px 14px; flex: 1; min-width: 0; }
.card-name-row { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 6px; }
.status-circle { display: inline-block; width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0; margin-top: 2px; }
.status-circle--green { background: var(--sage); }
.status-circle--yellow { background: conic-gradient(var(--gold) 50%, transparent 50%); box-shadow: inset 0 0 0 1.5px var(--gold); }
.status-circle--red { background: transparent; border: 2px solid var(--vermilion); }
.status-circle--gray { background: transparent; border: 1.5px dashed var(--warm-gray); }
.card-name { font-family: var(--font-heading); font-size: 13px; color: var(--ink); letter-spacing: 0.03em; line-height: 1.4; word-break: break-all; }
.card-meta { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; }
.card-industry,.card-services { font-family: var(--font-body); font-size: 10px; color: var(--warm-gray); }
.card-visit-info { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.card-last-visit { font-family: var(--font-mono); font-size: 10px; color: var(--warm-gray); }
.card-last-visit--never { color: var(--vermilion); }
.card-method { font-family: var(--font-body); font-size: 10px; padding: 1px 6px; border: 1px solid var(--warm-border); color: var(--warm-gray); }
.card-plans { display: flex; gap: 4px; flex-wrap: wrap; }
.plan-badge { font-family: var(--font-mono); font-size: 9px; padding: 2px 6px; border-radius: 8px; background: #EDF2EC; color: var(--sage); }
.plan-badge.plan-overdue { background: #FBF1EE; color: var(--vermilion); animation: pulse-warn 2s ease-in-out infinite; }
.card-warning { margin-top: 6px; font-family: var(--font-body); font-size: 10px; color: var(--vermilion); }
.unassigned-section { border-style: dashed; border-color: var(--warm-gray); }
.unassigned-summary { cursor: pointer; }
.empty-state { text-align: center; padding: 48px 0; }
.empty-text { font-family: var(--font-body); font-size: 15px; color: var(--warm-gray); }
.empty { text-align: center; padding: 20px; color: var(--c-text-muted); font-family: var(--font-body); }
/* ═══ Dialog ═══ */
.dlg-status { padding: 8px 12px; border-radius: 6px; font-size: 13px; font-weight: 600; margin-bottom: 12px; }
.dlg-status--green { background: #EDF2EC; color: var(--sage); }
.dlg-status--yellow { background: #FBF6EE; color: var(--gold); }
.dlg-status--red { background: #FBF1EE; color: var(--vermilion); }
.dlg-status--gray { background: #f0ede5; color: var(--warm-gray); }
.dlg-info { background: var(--c-bg-light, #faf9f6); border-radius: 6px; padding: 12px 16px; margin-bottom: 12px; }
.dlg-info-row { display: flex; gap: 16px; padding: 4px 0; font-size: 13px; }
.dlg-label { color: var(--c-text-muted); min-width: 60px; }
.dlg-section { border-top: 1px solid var(--warm-border); padding-top: 10px; margin-top: 10px; }
.dlg-section-title { margin: 0 0 8px; font-size: 13px; color: var(--ink); }
.dlg-visit-item { display: flex; gap: 8px; padding: 4px 0; font-size: 12px; }
.dlg-vdate { font-family: var(--font-mono); color: var(--warm-gray); white-space: nowrap; }
.dlg-vmethod { color: var(--sage); white-space: nowrap; }
.dlg-vcontent { color: var(--c-text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dlg-plan-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 12px; }
.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; }
</style>
+43 -17
View File
@@ -3,6 +3,7 @@ import { ref, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { todayStr } from '@/utils'
import api from '@/api/index'
import { compressImage } from '@/utils/image'
import ImagePreview from '@/components/ImagePreview.vue'
import EditLogPanel from '@/components/EditLogPanel.vue'
@@ -92,7 +93,7 @@ async function 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: '' }
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: '' }
@@ -103,6 +104,9 @@ function openCreate(type: string) {
function openEdit(type: string, item: any) {
dialogMode.value = 'edit'; dialogType.value = type
form.value = { ...item }
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 })
}
dialogTimeRange.value = null
if ((type === 'visit' || type === 'note') && item.time_range && item.time_range.includes('-')) {
const parts = item.time_range.split('-')
@@ -134,11 +138,13 @@ async function handleDialogPhotoUpload(event: Event) {
for (const file of Array.from(target.files)) {
if ((form.value.photos || []).length >= 9) break
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: file.name, content_type: file.type || 'image/jpeg' } })
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, file, { headers: { 'Content-Type': file.type || 'image/jpeg' } })
await axios.put(presignRes.data.upload_url, compressed, { headers: { 'Content-Type': compressed.type || 'image/jpeg' } })
const key = presignRes.data.object_key
if (!form.value.photos) form.value.photos = []
form.value.photos.push(key)
@@ -167,8 +173,23 @@ function removePhotoFromEdit(idx: number) {
}
}
function splitCompanions(values: string[]) {
const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const sys: string[] = []; const ext: string[] = []
for (const v of (values || [])) {
if (uuidRe.test(v)) sys.push(v)
else if (v.trim()) ext.push(v.trim())
}
return { companions: sys, companion_names: ext }
}
async function handleSave() {
const t = dialogType.value; const d = form.value
const t = dialogType.value; let d = { ...form.value }
// Split companions for visit type
if (t === 'visit' && d.companions) {
const { companions, companion_names } = splitCompanions(d.companions)
d = { ...d, companions, companion_names }
}
try {
if (dialogMode.value === 'create') {
switch (t) {
@@ -269,7 +290,7 @@ const notesByDate = computed(() => {
</svg>
{{ date }}
</h4>
<el-table :data="items" stripe size="small">
<el-table :data="items" 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('visit', row)">{{ row.customer_name }}</el-link></template>
</el-table-column>
@@ -284,7 +305,7 @@ const notesByDate = computed(() => {
<el-table-column prop="communication_content" label="沟通内容" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="70">
<template #default="{ row }">
<el-button text size="small" type="danger" @click="handleDelete('visit', row.id)">删除</el-button>
<el-button size="small" type="danger" @click="handleDelete('visit', row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -302,7 +323,7 @@ const notesByDate = computed(() => {
</svg>
{{ date }}
</h4>
<el-table :data="items" stripe size="small">
<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 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>
@@ -310,7 +331,7 @@ const notesByDate = computed(() => {
<el-table-column prop="time_range" label="时间" width="100" />
<el-table-column label="操作" width="70">
<template #default="{ row }">
<el-button text size="small" type="danger" @click="handleDelete('note', row.id)">删除</el-button>
<el-button size="small" type="danger" @click="handleDelete('note', row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -321,7 +342,7 @@ 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">
<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>
</el-table-column>
@@ -343,7 +364,7 @@ const notesByDate = computed(() => {
</el-table-column>
<el-table-column label="操作" width="70">
<template #default="{ row }">
<el-button text size="small" type="danger" @click="handleDelete('plan', row.id)">删除</el-button>
<el-button size="small" type="danger" @click="handleDelete('plan', row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -353,7 +374,7 @@ 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">
<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>
</el-table-column>
@@ -377,7 +398,7 @@ const notesByDate = computed(() => {
<el-table-column prop="expected_revenue_date" label="预计列收" width="110" />
<el-table-column label="操作" width="70">
<template #default="{ row }">
<el-button text size="small" type="danger" @click="handleDelete('mini', row.id)">删除</el-button>
<el-button size="small" type="danger" @click="handleDelete('mini', row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -387,7 +408,7 @@ 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">
<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>
</el-table-column>
@@ -412,7 +433,7 @@ const notesByDate = computed(() => {
<el-table-column prop="visit_target" label="拜访对象" width="100" />
<el-table-column label="操作" width="70">
<template #default="{ row }">
<el-button text size="small" type="danger" @click="handleDelete('key', row.id)">删除</el-button>
<el-button size="small" type="danger" @click="handleDelete('key', row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -443,6 +464,11 @@ const notesByDate = computed(() => {
</el-form-item>
<el-form-item label="拜访人姓名"><el-input v-model="form.visitor_name" placeholder="实际拜访人姓名" /></el-form-item>
<el-form-item label="拜访人电话"><el-input v-model="form.visitor_phone" placeholder="联系电话可选" /></el-form-item>
<el-form-item label="相关人员">
<el-select v-model="form.companions" multiple filterable allow-create default-first-option placeholder="选择或输入姓名可多选" style="width:100%">
<el-option v-for="u in allUsers" :key="u.id" :label="u.name" :value="u.id" />
</el-select>
</el-form-item>
<el-form-item label="沟通内容"><el-input v-model="form.communication_content" type="textarea" :rows="3" /></el-form-item>
<el-form-item label="客户需求"><el-input v-model="form.customer_demand" type="textarea" :rows="2" /></el-form-item>
<el-form-item v-if="dialogMode === 'edit' && form.photos?.length" label="照片">
@@ -511,9 +537,9 @@ const notesByDate = computed(() => {
<style scoped>
.page-head { margin-bottom: 20px; }
.page-title { margin: 0; font-family: 'ZCOOL XiaoWei', STSong, serif; font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
.page-title { margin: 0; font-family: var(--font-heading); font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
.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: 'ZCOOL XiaoWei', STSong, serif; font-size: 14px; color: var(--ink); letter-spacing: 0.04em; }
.empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: 'Noto Serif SC', STSong, serif; }
.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); }
</style>
+118 -16
View File
@@ -1,15 +1,22 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme'
import { ElMessage, ElMessageBox } from 'element-plus'
import { todayStr } from '@/utils'
import { getMgrStyle, loadManagerColors } from '@/utils/managerColor'
import { useScreenshot } from '@/utils/screenshot'
import api from '@/api/index'
import EditLogPanel from '@/components/EditLogPanel.vue'
const auth = useAuthStore()
const themeStore = useThemeStore()
const loading = ref(false)
const isLight = computed(() => themeStore.currentTheme === 'light')
const miniBusiness = ref<any[]>([])
const customers = ref<any[]>([])
const shotRef = ref<HTMLElement | null>(null)
const { capturing, captureEl } = useScreenshot()
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
@@ -17,13 +24,51 @@ const form = ref<any>({})
const statusPick = ref<Record<string, string>>({})
const miniStatuses = ['跟进中', '已签约', '已流失']
// ── Filters ──
const filterManager = ref('')
const filterStatus = ref('')
const managerOptions = computed(() => {
const seen = new Set<string>()
return miniBusiness.value
.map((m: any) => m.manager_name || '未知')
.filter((n: string) => { if (seen.has(n)) return false; seen.add(n); return true })
.sort()
})
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)
return list
})
function resetFilters() {
filterManager.value = ''
filterStatus.value = ''
}
async function handleScreenshot() {
const d = new Date().toISOString().slice(0, 10)
await captureEl(shotRef.value, `商机跟单_${d}.png`)
}
const managerSummary = computed(() => {
const map: Record<string, number> = {}
miniBusiness.value.forEach((m: any) => {
const n = m.manager_name || '未知'
map[n] = (map[n] || 0) + 1
})
return Object.entries(map).sort((a, b) => b[1] - a[1])
})
onMounted(async () => {
await Promise.all([loadItems(), loadCustomers()])
await Promise.all([loadItems(), loadCustomers(), loadManagerColors()])
})
async function loadCustomers(q?: string) {
try {
const params: any = { page_size: 100 }
const params: any = { page_size: 500 }
if (q) params.search = q
const res = await api.get('/customers/', { params })
customers.value = res.data.items || []
@@ -48,6 +93,9 @@ function openCreate() {
function openEdit(item: any) {
dialogMode.value = 'edit'
form.value = { ...item }
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 })
}
dialogVisible.value = true
}
@@ -88,24 +136,59 @@ async function quickStatusChange(row: any, newStatus: string) {
<template>
<div class="mini-biz-page" v-loading="loading">
<div ref="shotRef">
<div class="page-head">
<div class="page-head-row">
<div>
<h2 class="page-title">商机跟单</h2>
<p class="page-desc">小微业务商机管道按状态跟踪签约进展</p>
</div>
<el-button type="primary" @click="openCreate">
<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>
<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>
新建商机
</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>
</div>
</div>
<div class="page-rule"></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>
</div>
</div>
<!-- Filter Bar -->
<div class="filter-bar">
<div class="filter-row">
<div class="filter-item">
<label class="filter-label">客户经理</label>
<el-select v-model="filterManager" clearable placeholder="全部" size="small" style="width:150px">
<el-option v-for="m in managerOptions" :key="m" :label="m" :value="m" />
</el-select>
</div>
<div class="filter-item">
<label class="filter-label">状态</label>
<el-select v-model="filterStatus" clearable placeholder="全部" size="small" style="width:120px">
<el-option v-for="s in miniStatuses" :key="s" :label="s" :value="s" />
</el-select>
</div>
<el-button v-if="filterManager || filterStatus" size="small" plain @click="resetFilters">重置</el-button>
<span class="filter-count">{{ filteredItems.length }} / {{ miniBusiness.length }} </span>
</div>
</div>
<el-card>
<el-table :data="miniBusiness" stripe size="small" v-if="miniBusiness.length">
<el-table :data="filteredItems" stripe size="small" v-if="filteredItems.length" v-column-resize>
<el-table-column type="index" label="序号" width="50" />
<el-table-column prop="customer_name" label="客户" width="160">
<template #default="{ row }">
@@ -116,6 +199,11 @@ async function quickStatusChange(row: any, newStatus: string) {
</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>
</el-table-column>
<el-table-column prop="product_type" label="产品类型" width="130" />
<el-table-column prop="amount" label="金额" width="100" />
<el-table-column prop="follow_up_detail" label="跟进内容" min-width="250" show-overflow-tooltip />
@@ -134,14 +222,16 @@ async function quickStatusChange(row: any, newStatus: string) {
</template>
</el-table-column>
<el-table-column prop="expected_revenue_date" label="预计列收" width="110" />
<el-table-column label="操作" width="80" fixed="right">
<el-table-column label="操作" width="80" fixed="right" class-name="screenshot-hide">
<template #default="{ row }">
<el-button text size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-else class="empty">暂无商机记录</div>
<div v-if="!miniBusiness.length" class="empty">暂无商机记录</div>
<div v-else-if="miniBusiness.length && !filteredItems.length" class="empty">无匹配结果</div>
</el-card>
</div><!-- /shotRef -->
<!-- Dialog -->
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 小微商机'" width="500px">
@@ -178,8 +268,20 @@ async function quickStatusChange(row: any, newStatus: string) {
.edit-indicator { font-size: 12px; margin-left: 3px; opacity: 0.5; cursor: help; }
.page-head { margin-bottom: 20px; }
.page-head-row { display: flex; justify-content: space-between; align-items: flex-start; }
.page-title { margin: 0; font-family: 'ZCOOL XiaoWei', STSong, serif; 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: 'Noto Serif SC', STSong, serif; }
.page-head-actions { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
.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; }
.empty { text-align: center; color: var(--c-text-muted); padding: 48px 0; font-family: 'Noto Serif SC', STSong, serif; }
.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; }
.summary-chip:hover { opacity: 0.8; transform: translateY(-1px); }
.summary-chip--active { outline: 2px solid var(--gold); outline-offset: 1px; }
.mgr-tag { display: inline-block; padding: 1px 9px; border-radius: 10px; font-size: 12px; color: var(--mgr-chip-text); white-space: nowrap; }
.empty { text-align: center; color: var(--c-text-muted); padding: 48px 0; font-family: var(--font-body); }
.filter-bar { margin-bottom: 16px; padding: 12px 16px; background: var(--surface); border: 1px solid var(--warm-border); }
.filter-row { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.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; }
</style>
+198 -6
View File
@@ -2,6 +2,9 @@
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import api from '@/api/index'
import { useThemeStore, themeLabels, type Theme } from '@/stores/theme'
const themeStore = useThemeStore()
const managers = ref<any[]>([])
const selectedUserIds = ref<string[]>([])
@@ -10,6 +13,13 @@ const announcementContent = ref('')
const remindLoading = ref(false)
const announceLoading = ref(false)
const dailyCheckLoading = ref(false)
const notificationTime = ref('17:30')
const notificationTimeLoading = ref(false)
const holidays = ref('')
const holidaysLoading = ref(false)
const holidaysRefreshing = ref(false)
const aiPrompt = ref('')
const aiPromptLoading = ref(false)
const importLoading = ref(false)
const importFile = ref<File | null>(null)
@@ -21,6 +31,19 @@ onMounted(async () => {
const res = await api.get('/users/', { params: { role: 'manager' } })
managers.value = res.data
} catch (_) {}
// Load current notification time
try {
const res = await api.get('/system-config')
if (res.data?.notification_time) {
notificationTime.value = res.data.notification_time
}
if (res.data?.holidays) {
holidays.value = res.data.holidays
}
if (res.data?.ai_summary_prompt) {
aiPrompt.value = res.data.ai_summary_prompt
}
} catch (_) {}
})
async function handleRemind() {
@@ -53,6 +76,49 @@ async function handleDailyCheck() {
finally { dailyCheckLoading.value = false }
}
async function handleSaveNotificationTime() {
notificationTimeLoading.value = true
try {
await api.put('/system-config/notification_time', { value: notificationTime.value })
ElMessage.success(`通报时间已更新为 ${notificationTime.value}`)
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '保存失败')
} finally { notificationTimeLoading.value = false }
}
async function handleSaveHolidays() {
holidaysLoading.value = true
try {
await api.put('/system-config/holidays', { value: holidays.value })
ElMessage.success('节假日已更新')
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '保存失败')
} finally { holidaysLoading.value = false }
}
async function handleSaveAiPrompt() {
aiPromptLoading.value = true
try {
await api.put('/system-config/ai_summary_prompt', { value: aiPrompt.value })
ElMessage.success(aiPrompt.value.trim() ? 'AI 提示词已保存' : '已恢复默认提示词')
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '保存失败')
} finally { aiPromptLoading.value = false }
}
async function handleRefreshHolidays() {
holidaysRefreshing.value = true
try {
const res = await api.post('/system-config/refresh-holidays')
ElMessage.success(`节假日数据已刷新:${res.data.holidays} 个假日,${res.data.workdays} 个调休`)
// Reload the config display
const cfg = await api.get('/system-config')
if (cfg.data?.holidays) holidays.value = cfg.data.holidays
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '刷新失败')
} finally { holidaysRefreshing.value = false }
}
function handleImportFile(e: Event) {
const target = e.target as HTMLInputElement
if (target.files?.[0]) importFile.value = target.files[0]
@@ -88,6 +154,24 @@ async function handleImportPreview() {
<div class="page-rule"></div>
</div>
<!-- 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-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>
<span class="theme-opt-label">{{ themeLabels[t] }}</span>
</label>
</div>
</el-card>
<!-- Manual Remind -->
<el-card class="setting-card">
<template #header>
@@ -141,8 +225,92 @@ async function handleImportPreview() {
填报检查
</div>
</template>
<p style="color: var(--warm-gray); font-family: 'Noto Serif SC', STSong, serif;">手动触发今日填报检查通常每日 18:00 自动执行</p>
<el-button :loading="dailyCheckLoading" @click="handleDailyCheck">立即检查</el-button>
<div class="setting-row">
<div class="setting-col">
<label class="setting-label">定时通报时间</label>
<div style="display:flex;gap:8px;align-items:center">
<el-time-select
v-model="notificationTime"
start="08:00"
step="00:05"
end="21:00"
placeholder="选择时间"
format="HH:mm"
style="width:140px"
/>
<el-button type="primary" :loading="notificationTimeLoading" @click="handleSaveNotificationTime" size="small">保存</el-button>
</div>
<p style="color:var(--warm-gray);font-size:12px;margin-top:6px">每日定时向未填报人员推送催办提醒并向支局长发送汇总</p>
</div>
<div class="setting-col">
<label class="setting-label">手动触发</label>
<el-button :loading="dailyCheckLoading" @click="handleDailyCheck">立即检查</el-button>
</div>
</div>
</el-card>
<!-- Holidays -->
<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)">
<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>
法定节假日
</div>
</template>
<div class="setting-row">
<div class="setting-col" style="flex:1">
<label class="setting-label">节假日日期逗号分隔格式 YYYY-MM-DD</label>
<div style="display:flex;gap:8px;align-items:flex-start">
<el-input
v-model="holidays"
type="textarea"
:rows="2"
placeholder="如: 2026-10-01, 2026-10-02, 2026-10-05"
style="flex:1"
/>
<el-button type="primary" :loading="holidaysLoading" @click="handleSaveHolidays" size="small">保存</el-button>
</div>
<p style="color:var(--warm-gray);font-size:12px;margin-top:6px">
数据来源apisbo.com 中国节假日 API · 周末自动跳过无需配置
<el-button :loading="holidaysRefreshing" @click="handleRefreshHolidays" size="small" text type="primary" style="margin-left:8px"> API 刷新</el-button>
</p>
</div>
</div>
</el-card>
<!-- AI Summary Prompt -->
<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)">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
</svg>
AI 周报提示词
</div>
</template>
<div class="setting-row">
<div class="setting-col" style="flex:1">
<label class="setting-label">自定义 System Prompt清空则使用默认提示词</label>
<div style="display:flex;gap:8px;align-items:flex-start">
<el-input
v-model="aiPrompt"
type="textarea"
:rows="6"
placeholder="留空使用默认提示词,输入自定义内容将替换默认提示词..."
style="flex:1"
/>
<el-button type="primary" :loading="aiPromptLoading" @click="handleSaveAiPrompt" size="small">保存</el-button>
</div>
<p style="color:var(--warm-gray);font-size:12px;margin-top:6px">
自定义 AI 生成周报摘要时的行为风格支持 Markdown 格式约束留空则恢复默认提示词
</p>
</div>
</div>
</el-card>
<!-- Import -->
@@ -167,7 +335,7 @@ async function handleImportPreview() {
<el-button type="success" :loading="importLoading" @click="handleImportPreview" :disabled="!importFile">预览并导入</el-button>
</el-form>
<div v-if="importPreview" style="margin-top:16px">
<h4 style="font-family: 'ZCOOL XiaoWei', STSong, serif; color: var(--ink)">文件预览</h4>
<h4 style="font-family: var(--font-heading); color: var(--ink)">文件预览</h4>
<el-table :data="Object.entries(importPreview)" stripe>
<el-table-column prop="0" label="Sheet" />
<el-table-column prop="1.row_count" label="数据行数" />
@@ -178,7 +346,20 @@ async function handleImportPreview() {
</div>
<div v-if="importResult" style="margin-top:12px">
<el-alert type="success" :closable="false">
导入完成拜访 {{ importResult.visits }} 跳过 {{ importResult.skipped }}
导入完成拜访 {{ importResult.visits || 0 }} 纪要 {{ importResult.daily_notes || 0 }} 计划 {{ importResult.work_plans || 0 }} 商机 {{ importResult.mini_business || 0 }} 要客 {{ importResult.key_visits || 0 }}
<template v-if="importResult.customers_created">自动创建客户 {{ importResult.customers_created }} </template>
<template v-if="importResult.external_companions">外部同访人 {{ importResult.external_companions }} </template>
<template v-if="importResult.skipped">跳过 {{ importResult.skipped }} </template>
<template v-if="importResult.name_corrections?.length">
<div style="margin-top:6px; font-size:12px; color:var(--amber)">
<div v-for="(r, i) in importResult.name_corrections.slice(0, 10)" :key="'nc'+i">🔧 {{ r }}</div>
</div>
</template>
<template v-if="importResult.customers_created_names?.length">
<div style="margin-top:4px; font-size:12px; color:var(--sage)">
🆕 新建客户{{ importResult.customers_created_names.join('、') }}
</div>
</template>
<template v-if="importResult.skip_reasons?.length">
<div style="margin-top:8px; font-size:12px; max-height:200px; overflow-y:auto">
<div v-for="(r, i) in importResult.skip_reasons.slice(0, 20)" :key="i"> {{ r }}</div>
@@ -197,7 +378,7 @@ async function handleImportPreview() {
.page-head { margin-bottom: 24px; }
.page-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 22px; font-weight: 400;
color: var(--ink); letter-spacing: 0.06em;
}
@@ -206,7 +387,18 @@ async function handleImportPreview() {
.setting-card { margin-bottom: 16px; }
.card-header-title {
display: flex; align-items: center;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 15px; color: var(--ink); letter-spacing: 0.05em;
}
.setting-row { display: flex; gap: 32px; align-items: flex-start; flex-wrap: wrap; }
.setting-col { display: flex; flex-direction: column; gap: 8px; }
.setting-label { font-family: var(--font-body); font-size: 13px; color: var(--ink); }
/* ── Theme Selector ── */
.theme-options { display: flex; gap: 16px; }
.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); }
.theme-opt-radio { font-size: 14px; color: var(--ink); }
.theme-opt-label { font-family: var(--font-body); font-size: 14px; color: var(--c-text); }
</style>
+69 -13
View File
@@ -1,14 +1,19 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api/index'
const auth = useAuthStore()
const loading = ref(false)
const users = ref<any[]>([])
const editDialogVisible = ref(false)
const editUser = ref<any>(null)
const editRole = ref('')
const editDepartment = ref('')
const editWecomId = ref('')
const editColor = ref('')
const editRequireReport = ref(true)
const roleOptions = [
{ label: '客户经理', value: 'manager' },
@@ -38,18 +43,36 @@ function openEdit(user: any) {
editUser.value = user
editRole.value = user.role
editDepartment.value = user.department || ''
editWecomId.value = user.wecom_userid || ''
editRequireReport.value = user.require_report !== false // default true
editColor.value = user.color || ''
editDialogVisible.value = true
}
async function handleSave() {
if (!editUser.value) return
try {
await api.put(`/users/${editUser.value.id}/role`, { role: editRole.value, department: editDepartment.value })
ElMessage.success('角色已更新')
const payload: any = { role: editRole.value, department: editDepartment.value, require_report: editRequireReport.value, color: editColor.value || null }
await api.put(`/users/${editUser.value.id}/role`, payload)
const newWecomId = editWecomId.value.trim()
if (newWecomId !== (editUser.value.wecom_userid || '')) {
await api.put(`/users/${editUser.value.id}/wecom`, { wecom_userid: newWecomId || null })
}
ElMessage.success('已更新')
editDialogVisible.value = false
await loadUsers()
} catch (e: any) { ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message)) }
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(`确定删除「${row.name}」?此操作不可恢复。`, '确认删除', { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' })
await api.delete(`/users/${row.id}`)
ElMessage.success(`已删除「${row.name}`)
editDialogVisible.value = false
await loadUsers()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败: ' + (e.response?.data?.detail || e.message)) }
}
</script>
<template>
@@ -59,7 +82,7 @@ async function handleSave() {
<div class="page-rule"></div>
</div>
<el-card>
<el-table :data="users" stripe>
<el-table :data="users" stripe v-column-resize>
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column label="角色" width="120">
@@ -67,18 +90,28 @@ async function handleSave() {
<el-tag :type="roleTagType[row.role]">{{ roleLabel[row.role] || row.role }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="wecom_userid" label="企微 UserID" width="140">
<template #default="{ row }">{{ row.wecom_userid || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<el-table-column label="填报" width="70">
<template #default="{ row }">
<el-button text size="small" type="primary" @click="openEdit(row)">修改角色</el-button>
<span :class="row.require_report !== false ? 'tag-yes' : 'tag-no'">
{{ row.require_report !== false ? '✓ 是' : '— 否' }}
</span>
</template>
</el-table-column>
<el-table-column prop="wecom_userid" label="企微 UserID" min-width="140">
<template #default="{ row }">
<span v-if="row.wecom_userid" class="wecom-id">{{ row.wecom_userid }}</span>
<span v-else style="color:var(--c-text-muted);font-size:12px">未绑定</span>
</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button text size="small" type="primary" @click="openEdit(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="editDialogVisible" title="修改用户角色" width="420px">
<el-dialog v-model="editDialogVisible" title="编辑用户" width="440px">
<template v-if="editUser">
<el-form label-position="top">
<el-form-item label="用户"><el-input :value="editUser.name" disabled /></el-form-item>
@@ -87,12 +120,31 @@ async function handleSave() {
<el-option v-for="opt in roleOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="部门(可选)"><el-input v-model="editDepartment" placeholder="如:XX支局" /></el-form-item>
<el-form-item label="部门"><el-input v-model="editDepartment" placeholder="如:XX支局" /></el-form-item>
<el-form-item label="企业微信 UserID">
<el-input v-model="editWecomId" placeholder="从企微后台通讯录获取,留空则解绑" clearable />
<div class="form-hint">企微后台 通讯录 成员详情 账号</div>
</el-form-item>
<el-form-item label="需要填写周报">
<el-switch v-model="editRequireReport" active-text="是" inactive-text="否" />
<div class="form-hint">关闭后该用户不计入填报统计不接收催办提醒</div>
</el-form-item>
<el-form-item v-if="editRole === 'manager'" label="标签颜色">
<div style="display:flex;align-items:center;gap:8px">
<input type="color" v-model="editColor" style="width:36px;height:32px;border:none;cursor:pointer;padding:0" />
<span style="font-family:var(--font-mono);font-size:13px;color:var(--c-text-secondary)">{{ editColor || '默认(自动分配)' }}</span>
<el-button v-if="editColor" size="small" text @click="editColor = ''">清除</el-button>
</div>
<div class="form-hint">仅客户经理可用留空则自动分配颜色</div>
</el-form-item>
</el-form>
</template>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
<span>
<el-button v-if="auth.isDirector" type="danger" @click="handleDelete(editUser)" style="float:left">删除用户</el-button>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</span>
</template>
</el-dialog>
</div>
@@ -102,9 +154,13 @@ async function handleSave() {
.page-head { margin-bottom: 20px; }
.page-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 22px; font-weight: 400;
color: var(--ink); letter-spacing: 0.06em;
}
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
.wecom-id { font-family: var(--font-mono); font-size: 12px; color: var(--sage); background: var(--c-bg-light, #f0ede5); padding: 2px 8px; border-radius: 4px; }
.form-hint { font-size: 12px; color: var(--c-text-muted); margin-top: 4px; }
.tag-yes { font-size: 12px; color: var(--sage); }
.tag-no { font-size: 12px; color: var(--c-text-muted); }
</style>
+257 -26
View File
@@ -4,6 +4,8 @@ import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { dashboardApi } from '@/api/dashboard'
import { uploadApi } from '@/api/upload'
import { customersApi } from '@/api/customers'
import { aiApi } from '@/api/ai'
import { ElMessage } from 'element-plus'
import ImagePreview from '@/components/ImagePreview.vue'
import api from '@/api/index'
@@ -11,10 +13,17 @@ import api from '@/api/index'
const auth = useAuthStore()
const route = useRoute()
const loading = ref(false)
const aiLoading = ref(false)
const aiSummary = ref('')
const aiError = ref('')
const aiCached = ref(false)
const aiCreatedAt = ref('')
const aiExpanded = ref(false)
const activeTab = ref('visits')
const filterManagerId = ref('')
const filterCustomerId = ref('')
const managers = ref<any[]>([])
const customers = ref<any[]>([])
const weekOffset = ref(0)
const isHistoricalWeek = computed(() => weekOffset.value < 0)
@@ -29,13 +38,40 @@ const photoUrls = ref<Record<string, string>>({})
const photoDialogVisible = ref(false)
const currentPhotoUrl = ref('')
onMounted(async () => {
onMounted(() => {
if (route.query.manager_id) filterManagerId.value = route.query.manager_id as string
await loadReport()
try {
const res = await api.get('/users/', { params: { role: 'manager' } })
managers.value = res.data
} catch (_) {}
if (route.query.customer_id) filterCustomerId.value = route.query.customer_id as string
// Kick off report load immediately (includes photo URL fetching)
const reportPromise = loadReport()
// Dropdown data loads in parallel with report
const dropdownsPromise = (async () => {
try {
const [mRes, cRes] = await Promise.all([
api.get('/users/', { params: { role: 'manager' } }),
customersApi.list({ page_size: 500 }),
])
managers.value = mRes.data
customers.value = cRes.data.items || cRes.data
} catch (_) {}
})()
// AI summary loads in parallel too
const aiPromise = (async () => {
if (auth.isDirector || auth.isLeader) {
try {
const cached = await aiApi.getSummary({ reference_date: getRefDate(), period: 'week' })
if (cached.data?.summary) {
aiSummary.value = cached.data.summary
aiCached.value = !!cached.data.cached
aiCreatedAt.value = cached.data.created_at || ''
}
} catch (_) {}
}
})()
Promise.all([reportPromise, dropdownsPromise, aiPromise])
})
function changeWeek(delta: number) { weekOffset.value += delta; loadReport() }
@@ -56,17 +92,19 @@ async function loadReport() {
params.reference_date = getRefDate()
const res = await dashboardApi.getWeeklyReport(params)
report.value = res.data
// Collect all unique photo keys first, then fetch in parallel
const photoKeys = new Set<string>()
for (const v of report.value.visits) {
if (v.photos?.length) {
for (const key of v.photos) {
if (!photoUrls.value[key]) {
try {
const urlRes = await uploadApi.getDownloadUrl(key)
photoUrls.value[key] = urlRes.data.download_url
} catch (_) {}
}
}
}
for (const key of (v.photos || [])) photoKeys.add(key)
}
const newKeys = [...photoKeys].filter(k => !photoUrls.value[k])
if (newKeys.length > 0) {
const results = await Promise.allSettled(
newKeys.map(k => uploadApi.getDownloadUrl(k))
)
results.forEach((r, i) => {
if (r.status === 'fulfilled') photoUrls.value[newKeys[i]] = r.value.data.download_url
})
}
} catch (e: any) {
ElMessage.error('加载周报失败')
@@ -91,6 +129,69 @@ function viewPhoto(url: string) {
photoDialogVisible.value = true
}
async function generateAISummary() {
aiLoading.value = true
aiSummary.value = ''
aiError.value = ''
aiCached.value = false
try {
const res = await aiApi.generateSummary({ reference_date: getRefDate(), period: 'week' })
aiSummary.value = res.data.summary
aiCached.value = !!res.data.cached
aiCreatedAt.value = res.data.created_at || ''
aiExpanded.value = true
} catch (e: any) {
const detail = e.response?.data?.detail || 'AI 摘要生成失败,请检查 AI 服务配置或稍后重试'
aiError.value = detail
ElMessage.error(detail)
} finally {
aiLoading.value = false
}
}
async function copySummary() {
if (!aiSummary.value) return
const plain = aiSummary.value.replace(/^#{1,4}\s+/gm, '').replace(/\*\*/g, '').replace(/\*/g, '')
try {
// Prefer Clipboard API (requires HTTPS or localhost)
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(plain)
} else {
// Fallback for HTTP environments
const ta = document.createElement('textarea')
ta.value = plain
ta.style.position = 'fixed'; ta.style.left = '-9999px'; ta.style.top = '-9999px'
document.body.appendChild(ta)
ta.focus(); ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
ElMessage.success('摘要已复制到剪贴板')
} catch {
ElMessage.error('复制失败')
}
}
function renderMarkdown(md: string): string {
if (!md) return ''
let html = md
// Headers
.replace(/^#### (.+)$/gm, '<h4 class="ai-h4">$1</h4>')
.replace(/^### (.+)$/gm, '<h3 class="ai-h3">$1</h3>')
.replace(/^## (.+)$/gm, '<h2 class="ai-h2">$1</h2>')
.replace(/^# (.+)$/gm, '<h1 class="ai-h1">$1</h1>')
// Bold
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// Unordered lists
.replace(/^- (.+)$/gm, '<li>$1</li>')
// Wrap consecutive <li> in <ul>
.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>')
// Line breaks
.replace(/\n\n/g, '<br/><br/>')
.replace(/\n/g, '<br/>')
return html
}
const visitsByDate = computed(() => {
const grouped: Record<string, any[]> = {}
for (const v of report.value.visits) {
@@ -128,6 +229,12 @@ const notesByDate = computed(() => {
</div>
</div>
<div>
<el-button v-if="auth.isDirector || auth.isLeader" type="warning" :loading="aiLoading" @click="generateAISummary">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
</svg>
{{ aiCached ? '重新生成' : 'AI 生成摘要' }}
</el-button>
<el-button v-if="auth.isDirector" type="success" @click="handleExport">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
@@ -144,17 +251,63 @@ const notesByDate = computed(() => {
<!-- Filters -->
<el-card style="margin-bottom: 16px;">
<el-row :gutter="16">
<el-col :span="8">
<el-col :span="6">
<el-select v-model="filterManagerId" placeholder="按客户经理筛选" clearable @change="loadReport" style="width:100%">
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-col>
<el-col :span="6">
<el-select v-model="filterCustomerId" placeholder="按客户筛选" clearable filterable @change="loadReport" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-col>
<el-col :span="4">
<el-button @click="loadReport">查询</el-button>
</el-col>
</el-row>
</el-card>
<!-- AI Summary Panel -->
<el-card v-if="aiSummary || aiLoading || aiError" class="ai-summary-card" :class="{ 'ai-summary-card--collapsed': !aiExpanded && aiSummary }">
<template #header>
<div class="ai-header" @click="aiSummary && (aiExpanded = !aiExpanded)" :style="aiSummary ? 'cursor:pointer' : ''">
<div class="ai-header-left">
<span v-if="aiSummary" class="ai-expand-arrow">{{ aiExpanded ? '' : '' }}</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--gold); margin-right:6px">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
</svg>
<span class="ai-header-title">AI 周报摘要</span>
<span v-if="aiSummary && !aiExpanded && aiCached" class="ai-header-hint">生成于 {{ new Date(aiCreatedAt).toLocaleString('zh-CN') }} · 已缓存 · 点击展开</span>
<span v-else-if="aiSummary && aiExpanded && aiCached && aiCreatedAt" class="ai-header-hint">生成于 {{ new Date(aiCreatedAt).toLocaleString('zh-CN') }} · 已缓存</span>
<span v-else-if="aiLoading" class="ai-header-hint">正在分析...</span>
<span v-else class="ai-header-hint">基于本周拜访数据自动生成仅供参考</span>
</div>
<div class="ai-header-actions" @click.stop>
<el-button v-if="aiSummary && aiExpanded" size="small" text @click="copySummary">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:3px">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
复制
</el-button>
<el-button size="small" text @click="aiSummary = ''; aiError = ''; aiExpanded = false">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</el-button>
</div>
</div>
</template>
<div v-if="aiLoading" class="ai-loading">
<span class="ai-loading-text">🤖 AI 正在分析本周拜访数据...</span>
</div>
<div v-else-if="aiError" class="ai-error">
{{ aiError }}
</div>
<div v-else-if="aiExpanded" class="ai-content" v-html="renderMarkdown(aiSummary)"></div>
</el-card>
<!-- Tabs -->
<el-card>
<el-tabs v-model="activeTab">
@@ -170,15 +323,18 @@ const notesByDate = computed(() => {
</svg>
{{ date }}
</h4>
<el-table :data="items" stripe style="width:100%">
<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="100">
<el-table-column label="相关人员" width="120">
<template #default="{ row }">
<span>{{ row.manager_name }}</span>
<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>
</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>
@@ -211,7 +367,7 @@ const notesByDate = computed(() => {
</svg>
{{ date }}
</h4>
<el-table :data="items" stripe style="width:100%">
<el-table :data="items" stripe style="width:100%" v-column-resize>
<el-table-column label="分类" width="100">
<template #default="{ row }">
<el-tag :type="row.category === '行政事务' ? '' : row.category === '内部会议' ? 'danger' : row.category === '培训学习' ? 'info' : 'warning'" size="small">{{ row.category }}</el-tag>
@@ -219,7 +375,7 @@ const notesByDate = computed(() => {
</el-table-column>
<el-table-column prop="content" label="工作内容" min-width="300" show-overflow-tooltip />
<el-table-column prop="time_range" label="时间" width="100" />
<el-table-column prop="manager_name" label="客户经理" width="80" />
<el-table-column prop="manager_name" label="填报人" width="80" />
</el-table>
</div>
</el-tab-pane>
@@ -236,7 +392,7 @@ const notesByDate = computed(() => {
.page-head-row { display: flex; justify-content: space-between; align-items: flex-start; }
.page-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 22px; font-weight: 400;
color: var(--ink); letter-spacing: 0.06em;
}
@@ -244,7 +400,7 @@ const notesByDate = computed(() => {
.week-nav-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 3px 8px; cursor: pointer; color: var(--warm-gray); font-size: 12px; }
.week-nav-btn:hover:not(:disabled) { color: var(--ink); border-color: var(--ink); }
.week-nav-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.week-label { font-family: 'JetBrains Mono', 'SF Mono', monospace; font-size: 12px; color: var(--ink); }
.week-label { font-family: var(--font-mono); font-size: 12px; color: var(--ink); }
.week-nav-reset { background: none; border: 1px solid var(--gold); color: var(--gold); padding: 3px 8px; cursor: pointer; font-size: 12px; }
.week-nav-reset:hover { background: var(--gold); color: #fff; }
@@ -255,11 +411,86 @@ const notesByDate = computed(() => {
.date-title {
display: flex; align-items: center;
margin: 12px 0 8px;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 14px; color: var(--ink); letter-spacing: 0.04em;
}
.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; }
.empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: 'Noto Serif SC', STSong, serif; }
.empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: var(--font-body); }
/* ═══ AI Summary Panel ═══ */
.ai-summary-card {
margin-bottom: 16px;
border-color: var(--gold);
transition: border-color 0.2s;
}
.ai-summary-card--collapsed {
border-color: var(--warm-border);
}
.ai-header {
display: flex; justify-content: space-between; align-items: center;
user-select: none;
}
.ai-expand-arrow {
font-size: 10px; color: var(--gold);
width: 14px; flex-shrink: 0;
transition: transform 0.2s;
}
.ai-header-left {
display: flex; align-items: center;
}
.ai-header-title {
font-family: var(--font-heading);
font-size: 15px; color: var(--ink); letter-spacing: 0.05em;
}
.ai-header-hint {
font-family: var(--font-body);
font-size: 11px; color: var(--warm-gray); margin-left: 10px;
}
.ai-header-actions {
display: flex; gap: 4px;
}
.ai-loading {
text-align: center; padding: 32px 0;
}
.ai-loading-text {
font-family: var(--font-heading);
font-size: 15px; color: var(--warm-gray);
animation: pulse-text 1.8s ease-in-out infinite;
}
@keyframes pulse-text {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
.ai-error {
color: var(--vermilion);
font-family: var(--font-body);
padding: 12px 0;
}
.ai-content {
font-family: var(--font-body);
line-height: 1.85;
color: var(--c-text);
}
.ai-content :deep(.ai-h3) {
font-family: var(--font-heading);
font-size: 16px; color: var(--ink);
margin: 16px 0 8px; letter-spacing: 0.04em;
border-left: 3px solid var(--gold); padding-left: 10px;
}
.ai-content :deep(.ai-h4) {
font-family: var(--font-heading);
font-size: 14px; color: var(--ink);
margin: 12px 0 6px; letter-spacing: 0.03em;
}
.ai-content :deep(ul) {
margin: 6px 0; padding-left: 20px;
}
.ai-content :deep(li) {
margin: 3px 0; font-size: 14px;
}
.ai-content :deep(strong) {
color: var(--ink); font-weight: 600;
}
</style>
+121 -16
View File
@@ -1,15 +1,21 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme'
import { ElMessage, ElMessageBox } from 'element-plus'
import { todayStr } from '@/utils'
import { getMgrStyle, loadManagerColors } from '@/utils/managerColor'
import { useScreenshot } from '@/utils/screenshot'
import api from '@/api/index'
import EditLogPanel from '@/components/EditLogPanel.vue'
const auth = useAuthStore()
const themeStore = useThemeStore()
const loading = ref(false)
const workPlans = ref<any[]>([])
const customers = ref<any[]>([])
const shotRef = ref<HTMLElement | null>(null)
const { capturing, captureEl } = useScreenshot()
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
@@ -17,13 +23,53 @@ const form = ref<any>({})
const statusPick = ref<Record<string, string>>({})
const planStatuses = ['计划中', '已完成', '已取消']
// ── 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 filteredPlans = computed(() => {
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
})
function resetFilters() {
filterManager.value = ''
filterStatus.value = ''
}
async function handleScreenshot() {
const d = new Date().toISOString().slice(0, 10)
await captureEl(shotRef.value, `工作计划_${d}.png`)
}
const managerSummary = computed(() => {
const map: Record<string, number> = {}
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])
})
onMounted(async () => {
await Promise.all([loadPlans(), loadCustomers()])
await Promise.all([loadPlans(), loadCustomers(), loadManagerColors()])
})
async function loadCustomers(q?: string) {
try {
const params: any = { page_size: 100 }
const params: any = { page_size: 500 }
if (q) params.search = q
const res = await api.get('/customers/', { params })
customers.value = res.data.items || []
@@ -48,6 +94,9 @@ function openCreate() {
function openEdit(item: any) {
dialogMode.value = 'edit'
form.value = { ...item }
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 })
}
dialogVisible.value = true
}
@@ -88,24 +137,59 @@ async function quickStatusChange(row: any, newStatus: string) {
<template>
<div class="work-plans-page" v-loading="loading">
<div ref="shotRef">
<div class="page-head">
<div class="page-head-row">
<div>
<h2 class="page-title">工作计划</h2>
<p class="page-desc">面向未来的工作计划安排支持状态流转跟踪</p>
</div>
<el-button type="primary" @click="openCreate">
<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>
<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>
新建计划
</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>
</div>
</div>
<div class="page-rule"></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>
</div>
</div>
<!-- Filter Bar -->
<div class="filter-bar">
<div class="filter-row">
<div class="filter-item">
<label class="filter-label">客户经理</label>
<el-select v-model="filterManager" clearable placeholder="全部" size="small" style="width:150px">
<el-option v-for="m in managerOptions" :key="m" :label="m" :value="m" />
</el-select>
</div>
<div class="filter-item">
<label class="filter-label">状态</label>
<el-select v-model="filterStatus" clearable placeholder="全部" size="small" style="width:120px">
<el-option v-for="s in planStatuses" :key="s" :label="s" :value="s" />
</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>
</div>
</div>
<el-card>
<el-table :data="workPlans" stripe size="small" v-if="workPlans.length">
<el-table :data="filteredPlans" stripe size="small" v-if="filteredPlans.length" v-column-resize>
<el-table-column type="index" label="序号" width="50" />
<el-table-column prop="customer_name" label="客户" width="160">
<template #default="{ row }">
@@ -116,6 +200,11 @@ async function quickStatusChange(row: any, newStatus: string) {
</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>
</el-table-column>
<el-table-column prop="plan_content" label="工作计划" min-width="280" show-overflow-tooltip />
<el-table-column prop="plan_date" label="计划时间" width="110" />
<el-table-column label="状态" width="130">
@@ -132,14 +221,16 @@ async function quickStatusChange(row: any, newStatus: string) {
</el-select>
</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<el-table-column label="操作" width="80" fixed="right" class-name="screenshot-hide">
<template #default="{ row }">
<el-button text size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-else class="empty">暂无工作计划</div>
<div v-if="!workPlans.length" class="empty">暂无工作计划</div>
<div v-else-if="workPlans.length && !filteredPlans.length" class="empty">无匹配结果</div>
</el-card>
</div><!-- /shotRef -->
<!-- Dialog -->
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 工作计划'" width="500px">
@@ -176,8 +267,22 @@ async function quickStatusChange(row: any, newStatus: string) {
.edit-indicator { font-size: 12px; margin-left: 3px; opacity: 0.5; cursor: help; }
.page-head { margin-bottom: 20px; }
.page-head-row { display: flex; justify-content: space-between; align-items: flex-start; }
.page-title { margin: 0; font-family: 'ZCOOL XiaoWei', STSong, serif; 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: 'Noto Serif SC', STSong, serif; }
.page-head-actions { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
.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; }
.empty { text-align: center; color: var(--c-text-muted); padding: 48px 0; font-family: 'Noto Serif SC', STSong, serif; }
.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; }
.summary-chip:hover { opacity: 0.8; transform: translateY(-1px); }
.summary-chip--active { outline: 2px solid var(--gold); outline-offset: 1px; }
.mgr-tag { display: inline-block; padding: 1px 9px; border-radius: 10px; font-size: 12px; color: var(--mgr-chip-text); white-space: nowrap; }
.empty { text-align: center; color: var(--c-text-muted); padding: 48px 0; font-family: var(--font-body); }
/* ── Filter Bar ── */
.filter-bar { margin-bottom: 16px; padding: 12px 16px; background: var(--surface); border: 1px solid var(--warm-border); }
.filter-row { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.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; }
</style>
+7 -7
View File
@@ -189,13 +189,13 @@ async function handleDelete() {
}
.form-title {
margin: 0; font-family: 'ZCOOL XiaoWei', STSong, serif;
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: 'JetBrains Mono', 'SF Mono', monospace;
font-family: var(--font-mono);
font-size: 9px; color: var(--gold); letter-spacing: 0.2em;
}
@@ -210,12 +210,12 @@ async function handleDelete() {
/* ═══ Form Labels ═══ */
.form-label {
font-family: 'ZCOOL XiaoWei', STSong, serif !important;
font-family: var(--font-heading) !important;
font-size: 14px !important; color: var(--ink) !important;
letter-spacing: 0.04em; font-weight: 500 !important;
}
.form-label-hint {
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 11px; color: var(--warm-gray);
letter-spacing: 0.03em; font-weight: 400; margin-left: 6px;
}
@@ -231,7 +231,7 @@ async function handleDelete() {
background: var(--surface);
border: 1px solid var(--warm-border);
color: var(--ink);
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 13px;
letter-spacing: 0.03em;
cursor: pointer;
@@ -249,7 +249,7 @@ async function handleDelete() {
.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: 'ZCOOL XiaoWei', STSong, serif;
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); }
@@ -261,7 +261,7 @@ async function handleDelete() {
padding: 16px 20px;
background: var(--surface); color: var(--vermilion);
border: 1px solid var(--vermilion);
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 14px; letter-spacing: 0.04em; cursor: pointer; transition: all 0.25s;
}
.delete-btn:hover { background: var(--vermilion); color: #fff; }
+25 -19
View File
@@ -123,6 +123,12 @@ onMounted(loadToday)
</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 -->
@@ -226,14 +232,14 @@ onMounted(loadToday)
}
.stat-label-sm {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.2em;
color: var(--warm-gray);
}
.stat-date {
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 12px;
color: var(--warm-gray);
letter-spacing: 0.04em;
@@ -247,7 +253,7 @@ onMounted(loadToday)
}
.stat-number {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 56px;
color: var(--ink);
line-height: 1;
@@ -255,7 +261,7 @@ onMounted(loadToday)
}
.stat-unit {
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 16px;
color: var(--warm-gray);
letter-spacing: 0.06em;
@@ -270,14 +276,14 @@ onMounted(loadToday)
}
.stat-piece {
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 13px;
color: var(--warm-gray);
letter-spacing: 0.03em;
}
.stat-piece strong {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-family: var(--font-mono);
color: var(--ink);
font-size: 15px;
}
@@ -314,7 +320,7 @@ onMounted(loadToday)
padding: 16px 12px;
border: 1px solid var(--warm-border);
cursor: pointer;
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 15px;
letter-spacing: 0.04em;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
@@ -365,7 +371,7 @@ onMounted(loadToday)
background: var(--surface);
border: 1px solid var(--warm-border);
color: var(--ink);
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 13px;
letter-spacing: 0.03em;
cursor: pointer;
@@ -390,7 +396,7 @@ onMounted(loadToday)
}
.section-title {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 18px;
color: var(--ink);
letter-spacing: 0.06em;
@@ -459,7 +465,7 @@ onMounted(loadToday)
width: 22px;
height: 22px;
color: #fff;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 13px;
flex-shrink: 0;
}
@@ -469,7 +475,7 @@ onMounted(loadToday)
}
.record-category {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 13px;
color: var(--ink);
letter-spacing: 0.04em;
@@ -477,7 +483,7 @@ onMounted(loadToday)
}
.record-badge {
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 11px;
padding: 1px 8px;
letter-spacing: 0.04em;
@@ -491,7 +497,7 @@ onMounted(loadToday)
.record-customer {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 15px;
color: var(--ink);
letter-spacing: 0.03em;
@@ -499,7 +505,7 @@ onMounted(loadToday)
}
.record-time {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
color: var(--warm-gray);
letter-spacing: 0.03em;
@@ -507,7 +513,7 @@ onMounted(loadToday)
}
.record-content {
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 14px;
color: var(--c-text);
line-height: 1.7;
@@ -516,7 +522,7 @@ onMounted(loadToday)
}
.record-demand {
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 13px;
color: var(--vermilion);
margin: 0 0 8px;
@@ -559,7 +565,7 @@ onMounted(loadToday)
}
.empty-glyph {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 40px;
color: var(--gold);
opacity: 0.4;
@@ -567,14 +573,14 @@ onMounted(loadToday)
}
.empty-text {
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 15px;
color: var(--warm-gray);
margin: 0 0 4px;
}
.empty-hint {
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 12px;
color: var(--c-text-muted);
margin: 0;
+5 -5
View File
@@ -169,13 +169,13 @@ async function handleSubmit() {
.form-title-group { display: flex; flex-direction: column; gap: 0; flex: 1; }
.form-title {
margin: 0; font-family: 'ZCOOL XiaoWei', STSong, serif;
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: 'JetBrains Mono', 'SF Mono', monospace;
font-family: var(--font-mono);
font-size: 9px; color: var(--gold); letter-spacing: 0.2em;
}
@@ -189,7 +189,7 @@ async function handleSubmit() {
}
.form-label {
font-family: 'ZCOOL XiaoWei', STSong, serif !important;
font-family: var(--font-heading) !important;
font-size: 14px !important; color: var(--ink) !important;
letter-spacing: 0.04em; font-weight: 500 !important;
}
@@ -204,7 +204,7 @@ async function handleSubmit() {
padding: 10px 8px;
background: var(--surface);
border: 1px solid var(--warm-border);
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 13px; letter-spacing: 0.04em;
cursor: pointer; transition: all 0.25s;
text-align: center;
@@ -216,7 +216,7 @@ async function handleSubmit() {
.submit-btn {
width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 16px; background: var(--ink); color: #fff; border: none;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 16px; letter-spacing: 0.08em; cursor: pointer; transition: all 0.25s;
margin-top: 24px;
}
+201
View File
@@ -0,0 +1,201 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { leavesApi } from '@/api/leaves'
import { useAuthStore } from '@/stores/auth'
import api from '@/api/index'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const submitLoading = ref(false)
const isEdit = !!route.params.id
const allUsers = ref<any[]>([])
const leaveTypes = ['年假', '事假', '病假', '调休', '其他']
const leaveTypeColors: Record<string, string> = {
'年假': '#4A6741', '事假': '#5B7FA5', '病假': '#B8472E',
'调休': '#C4934A', '其他': '#7B7568',
}
const form = ref({
manager_id: auth.userId || '',
leave_type: '事假',
start_date: '',
end_date: '',
reason: '',
})
onMounted(async () => {
if (auth.isDirector) {
try {
const res = await api.get('/users/?role=manager')
allUsers.value = Array.isArray(res.data) ? res.data : (res.data.items || [])
} catch (_) {}
}
if (isEdit) {
try {
const res = await leavesApi.list({ page_size: 100 })
const item = (res.data.items || []).find((l: any) => l.id === route.params.id)
if (item) form.value = { ...item }
} catch (_) {}
}
})
async function handleSubmit() {
if (!form.value.start_date || !form.value.end_date) {
ElMessage.warning('请选择日期范围')
return
}
if (form.value.start_date > form.value.end_date) {
ElMessage.warning('结束日期不能早于开始日期')
return
}
submitLoading.value = true
try {
const payload = {
manager_id: form.value.manager_id,
leave_type: form.value.leave_type,
start_date: form.value.start_date,
end_date: form.value.end_date,
reason: form.value.reason || '',
}
if (isEdit) {
await leavesApi.update(route.params.id as string, payload)
ElMessage.success('请假已更新')
} else {
await leavesApi.create(payload)
ElMessage.success('请假已提交')
}
router.push('/m/leaves')
} catch (e: any) {
ElMessage.error((isEdit ? '更新' : '提交') + '失败: ' + (e.response?.data?.detail || e.message))
} finally { submitLoading.value = false }
}
async function handleDelete() {
try {
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
await leavesApi.delete(route.params.id as string)
ElMessage.success('已删除')
router.push('/m/leaves')
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
</script>
<template>
<div class="form-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">{{ isEdit ? '编辑请假' : '提交请假' }}</h2>
<span class="form-subtitle">LEAVE REQUEST</span>
</div>
<div class="form-rule"></div>
</header>
<el-form label-position="top" class="editorial-form">
<el-form-item v-if="auth.isDirector">
<template #label><span class="form-label">客户经理</span></template>
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
<el-option v-for="u in allUsers" :key="u.id" :label="u.name" :value="u.id" />
</el-select>
</el-form-item>
<el-form-item>
<template #label><span class="form-label">请假类型</span></template>
<div class="leave-type-grid">
<button
v-for="t in leaveTypes" :key="t"
type="button"
class="leave-type-chip"
:class="{ 'leave-type-chip--active': form.leave_type === t }"
:style="form.leave_type === t ? { background: leaveTypeColors[t], borderColor: leaveTypeColors[t], color: '#fff' } : {}"
@click="form.leave_type = t"
>{{ t }}</button>
</div>
</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%" />
</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%" />
</el-form-item>
<el-form-item>
<template #label><span class="form-label">原因 <span class="form-label-hint">选填</span></span></template>
<el-input v-model="form.reason" type="textarea" :rows="3" 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>
<button v-if="isEdit" class="delete-btn" @click="handleDelete">删除</button>
</div>
</el-form>
</div>
</template>
<style scoped>
.form-page { max-width: 100%; margin: 0 auto; padding-bottom: 20px; }
.form-editorial-header {
display: flex; align-items: flex-start; gap: 14px;
margin-bottom: 26px; 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); }
.form-label { font-family: var(--font-heading) !important; font-size: 14px !important; color: var(--ink) !important; letter-spacing: 0.04em; font-weight: 500 !important; }
.editorial-form .el-form-item { margin-bottom: 20px; }
.leave-type-grid { display: flex; gap: 8px; flex-wrap: wrap; }
.leave-type-chip {
flex: 1; min-width: 60px; padding: 10px 8px;
background: var(--surface); border: 1px solid var(--warm-border);
font-family: var(--font-body); font-size: 13px; letter-spacing: 0.04em;
cursor: pointer; transition: all 0.25s; text-align: center;
}
.leave-type-chip:hover { border-color: var(--ink); }
.leave-type-chip--active { font-weight: 600; }
.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; }
.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); } }
</style>
+156
View File
@@ -0,0 +1,156 @@
<!-- frontend/src/views/mobile/LeavesList.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { leavesApi } from '@/api/leaves'
const router = useRouter()
const loading = ref(false)
const leaves = ref<any[]>([])
const leaveTypeColors: Record<string, string> = {
'年假': '#4A6741', '事假': '#5B7FA5', '病假': '#B8472E',
'调休': '#C4934A', '其他': '#7B7568',
}
onMounted(loadLeaves)
async function loadLeaves() {
loading.value = true
try {
const res = await leavesApi.list({ page_size: 100 })
leaves.value = res.data.items || []
} catch (e: any) { ElMessage.error('加载失败') }
finally { loading.value = false }
}
function isActive(row: any): boolean {
const today = new Date().toISOString().slice(0, 10)
return row.start_date <= today && row.end_date >= today
}
async function handleDelete(id: string) {
try {
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
await leavesApi.delete(id)
ElMessage.success('已删除')
await loadLeaves()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
</script>
<template>
<div class="leaves-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">LEAVE RECORDS</span>
</div>
<div class="form-rule"></div>
</header>
<button class="new-leave-btn" @click="router.push('/m/leave/new')">
<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="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
新建请假
</button>
<div v-loading="loading" class="leave-cards">
<div v-if="leaves.length === 0 && !loading" class="empty-state">
<div class="empty-glyph"></div>
<p class="empty-text">暂无请假记录</p>
</div>
<article
v-for="l in leaves" :key="l.id"
class="leave-card"
:class="{ 'leave-card--active': isActive(l) }"
@click="router.push(`/m/leave/${l.id}/edit`)"
>
<div class="leave-card-accent" :style="{ background: leaveTypeColors[l.leave_type] || '#7B7568' }"></div>
<div class="leave-card-body">
<div class="leave-card-header">
<span class="leave-card-type" :style="{ background: leaveTypeColors[l.leave_type] || '#7B7568', color: '#fff', padding: '2px 10px', fontSize: '12px' }">{{ l.leave_type }}</span>
<span class="leave-card-days">{{ l.days }}</span>
</div>
<div class="leave-card-dates">{{ l.start_date }} ~ {{ l.end_date }}</div>
<div v-if="l.reason" class="leave-card-reason">{{ l.reason }}</div>
<div class="leave-card-footer">
<span class="leave-card-submitter">提交人: {{ l.submitted_by_name }}</span>
<button class="leave-card-delete" @click.stop="handleDelete(l.id)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path>
</svg>
</button>
</div>
</div>
</article>
</div>
</div>
</template>
<style scoped>
.leaves-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-leave-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-leave-btn:hover { background: var(--ink-light); }
.new-leave-btn:active { transform: scale(0.98); }
.leave-cards { display: flex; flex-direction: column; gap: 10px; }
.leave-card {
background: var(--surface); border: 1px solid var(--warm-border);
display: flex; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s;
}
.leave-card:active { transform: scale(0.99); }
.leave-card:hover { box-shadow: 0 4px 12px rgba(28,55,56,0.06); }
.leave-card--active { border-left: 3px solid #5B7FA5; }
.leave-card-accent { width: 4px; flex-shrink: 0; }
.leave-card-body { flex: 1; padding: 14px 16px; min-width: 0; }
.leave-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.leave-card-days { font-family: var(--font-mono); font-size: 13px; color: var(--warm-gray); }
.leave-card-dates { font-family: var(--font-body); font-size: 14px; color: var(--ink); margin-bottom: 4px; }
.leave-card-reason { font-family: var(--font-body); font-size: 13px; color: var(--c-text-muted); margin-bottom: 8px; }
.leave-card-footer { display: flex; align-items: center; justify-content: space-between; }
.leave-card-submitter { font-size: 11px; color: var(--warm-gray); }
.leave-card-delete { background: none; border: none; color: var(--vermilion); cursor: pointer; padding: 2px; opacity: 0.6; }
.leave-card-delete:hover { opacity: 1; }
.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>
@@ -122,13 +122,13 @@ async function handleSubmit() {
.form-title-group { display: flex; flex-direction: column; gap: 0; flex: 1; }
.form-title {
margin: 0; font-family: 'ZCOOL XiaoWei', STSong, serif;
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: 'JetBrains Mono', 'SF Mono', monospace;
font-family: var(--font-mono);
font-size: 9px; color: var(--gold); letter-spacing: 0.2em;
}
@@ -142,7 +142,7 @@ async function handleSubmit() {
}
.form-label {
font-family: 'ZCOOL XiaoWei', STSong, serif !important;
font-family: var(--font-heading) !important;
font-size: 14px !important; color: var(--ink) !important;
letter-spacing: 0.04em; font-weight: 500 !important;
}
@@ -152,7 +152,7 @@ async function handleSubmit() {
.submit-btn {
width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 16px; background: var(--ink); color: #fff; border: none;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 16px; letter-spacing: 0.08em; cursor: pointer; transition: all 0.25s;
margin-top: 24px;
}
+36 -16
View File
@@ -6,6 +6,7 @@ import { todayStr } from '@/utils'
import { visitsApi } from '@/api/visits'
import { customersApi } from '@/api/customers'
import { uploadApi } from '@/api/upload'
import { compressImage } from '@/utils/image'
import { useAuthStore } from '@/stores/auth'
import ImagePreview from '@/components/ImagePreview.vue'
import EditLogPanel from '@/components/EditLogPanel.vue'
@@ -51,6 +52,10 @@ onMounted(async () => {
try {
const res = await visitsApi.get(route.params.id as string)
const v = res.data
// 确保当前客户在 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 })
}
form.value = {
customer_id: v.customer_id,
visit_date: v.visit_date,
@@ -121,8 +126,10 @@ async function handlePhotoUpload(event: Event) {
const previewUrl = URL.createObjectURL(file)
photoPreviews.value.push(previewUrl)
try {
const res = await uploadApi.getPresignedUrl(file.name, file.type || 'image/jpeg')
await uploadApi.uploadFile(res.data.upload_url, file)
// 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)
uploadedPhotos.value.push(res.data.object_key)
form.value.photos = [...uploadedPhotos.value]
} catch (e: any) {
@@ -154,15 +161,28 @@ function removePhoto(index: number) {
form.value.photos = [...uploadedPhotos.value]
}
function splitCompanions(values: string[]) {
// UUIDs → companions (system users), non-UUID strings → companion_names (external)
const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const sys: string[] = []; const ext: string[] = []
for (const v of values) {
if (uuidRe.test(v)) sys.push(v)
else if (v.trim()) ext.push(v.trim())
}
return { companions: sys, companion_names: ext }
}
async function handleSubmit() {
if (!form.value.customer_id) { ElMessage.warning('请选择客户'); return }
submitLoading.value = true
try {
const { companions, companion_names } = splitCompanions(form.value.companions || [])
const body = { ...form.value, companions, companion_names }
if (isEdit.value) {
await visitsApi.update(route.params.id as string, form.value)
await visitsApi.update(route.params.id as string, body)
ElMessage.success('记录已更新')
} else {
await visitsApi.create(form.value)
await visitsApi.create(body)
ElMessage.success('拜访记录已提交')
}
router.push('/m')
@@ -269,9 +289,9 @@ async function handleDelete() {
<el-form-item>
<template #label>
<span class="form-label">同访人员</span>
<span class="form-label">相关人员 <span class="form-label-hint">可输入外部人员</span></span>
</template>
<el-select v-model="form.companions" multiple filterable placeholder="选择同访人员" style="width:100%">
<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" />
</el-select>
</el-form-item>
@@ -397,7 +417,7 @@ async function handleDelete() {
.form-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 24px;
font-weight: 400;
color: var(--ink);
@@ -406,7 +426,7 @@ async function handleDelete() {
}
.form-subtitle {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-family: var(--font-mono);
font-size: 9px;
color: var(--gold);
letter-spacing: 0.2em;
@@ -432,7 +452,7 @@ async function handleDelete() {
/* ═══ Form Labels ═══ */
.form-label {
font-family: 'ZCOOL XiaoWei', STSong, serif !important;
font-family: var(--font-heading) !important;
font-size: 14px !important;
color: var(--ink) !important;
letter-spacing: 0.04em;
@@ -440,7 +460,7 @@ async function handleDelete() {
}
.form-label-hint {
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 11px;
color: var(--warm-gray);
letter-spacing: 0.03em;
@@ -462,7 +482,7 @@ async function handleDelete() {
border: 1px dashed var(--gold);
padding: 8px 14px;
color: var(--gold-dark);
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
@@ -501,7 +521,7 @@ async function handleDelete() {
}
.photo-label {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-family: var(--font-mono);
font-size: 11px;
color: var(--warm-gray);
letter-spacing: 0.03em;
@@ -529,7 +549,7 @@ async function handleDelete() {
padding: 12px 18px;
border: 1px dashed var(--warm-border);
color: var(--warm-gray);
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 13px;
transition: all 0.2s;
letter-spacing: 0.03em;
@@ -563,7 +583,7 @@ async function handleDelete() {
background: var(--ink);
color: #fff;
border: none;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 16px;
letter-spacing: 0.08em;
cursor: pointer;
@@ -582,7 +602,7 @@ async function handleDelete() {
background: var(--surface);
color: var(--vermilion);
border: 1px solid var(--vermilion);
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 14px;
letter-spacing: 0.04em;
cursor: pointer;
@@ -613,7 +633,7 @@ async function handleDelete() {
padding: 10px 8px;
background: var(--surface);
border: 1px solid var(--warm-border);
font-family: 'Noto Serif SC', STSong, serif;
font-family: var(--font-body);
font-size: 13px; letter-spacing: 0.04em;
cursor: pointer; transition: all 0.25s;
text-align: center;
+4 -4
View File
@@ -102,13 +102,13 @@ async function handleSubmit() {
.form-title-group { display: flex; flex-direction: column; gap: 0; flex: 1; }
.form-title {
margin: 0; font-family: 'ZCOOL XiaoWei', STSong, serif;
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: 'JetBrains Mono', 'SF Mono', monospace;
font-family: var(--font-mono);
font-size: 9px; color: var(--gold); letter-spacing: 0.2em;
}
@@ -122,7 +122,7 @@ async function handleSubmit() {
}
.form-label {
font-family: 'ZCOOL XiaoWei', STSong, serif !important;
font-family: var(--font-heading) !important;
font-size: 14px !important; color: var(--ink) !important;
letter-spacing: 0.04em; font-weight: 500 !important;
}
@@ -132,7 +132,7 @@ async function handleSubmit() {
.submit-btn {
width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 16px; background: var(--ink); color: #fff; border: none;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-family: var(--font-heading);
font-size: 16px; letter-spacing: 0.08em; cursor: pointer; transition: all 0.25s;
margin-top: 24px;
}
+24 -1
View File
@@ -1,14 +1,37 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import ElementPlus from 'unplugin-element-plus/vite'
export default defineConfig({
plugins: [vue()],
plugins: [
vue(),
// Auto-import Element Plus components used in templates
Components({
resolvers: [ElementPlusResolver()],
dts: 'src/components.d.ts',
}),
// Auto-import styles for explicitly imported Element Plus APIs (ElMessage, ElMessageBox, etc.)
ElementPlus({}),
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
build: {
rollupOptions: {
output: {
manualChunks: {
// After tree-shaking, this chunk only contains the components actually used
'element-plus': ['element-plus'],
'vue-vendor': ['vue', 'vue-router', 'pinia'],
},
},
},
},
server: {
port: 5173,
proxy: {
+9 -2
View File
@@ -5,8 +5,15 @@
"name": "ui-design-guide",
"zip_url": "https://api.skillhub.cn/api/v1/download?slug=ui-design-guide",
"source": "community",
"version": "2.24.0",
"installedAt": "2026-06-23T00:18:23Z"
"version": "2.24.2-beta.1",
"installedAt": "2026-07-06T04:51:11Z"
},
"ui-new": {
"name": "UI设计Skill",
"zip_url": "https://api.skillhub.cn/api/v1/download?slug=ui-new",
"source": "community",
"version": "1.0.0",
"installedAt": "2026-07-06T06:39:42Z"
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: ui-design-guide
description: Use when users need visual direction, interface hierarchy, layout decisions, design specifications, or prototypes before implementing a Web or mini program UI.
version: 2.23.0
version: 2.23.1
alwaysApply: false
---
+2 -2
View File
@@ -1,6 +1,6 @@
{
"ownerId": "544116",
"slug": "ui-design-guide",
"version": "2.24.0",
"publishedAt": 1781845782468
"version": "2.24.2-beta.1",
"publishedAt": 1782199074089
}
+386
View File
@@ -0,0 +1,386 @@
---
name: ui-design-system
description: 专业UI生成与现有界面优化技能。当用户请求生成UI界面、设计页面、优化现有界面、创建组件库、设计系统、App界面、Web界面、Dashboard、Landing Page、或任何涉及视觉交互设计时必须触发此技能。参考Google Material Design、Apple HIG、以及Pinterest级别审美标准,输出大气简约、高级美学的UI代码。即使用户只是说"帮我做个界面"、"优化一下UI"、"设计一个页面"也必须触发。
---
# UI Design System Skill
## 📋 背景与设计哲学
### 核心定位
本 Skill 专注于生成**大厂级别、Pinterest 审美、高级简约**的 UI 界面。参考标准:
- **Google Material Design 3** — 色彩系统、动效原则、组件规范
- **Apple Human Interface Guidelines** — 空间设计、排版节奏、细腻质感
- **Fluent Design (Microsoft)** — Acrylic 材质、深度层次
- **Pinterest / Dribbble 高赞设计** — 视觉张力、留白美学、色彩大胆
### 设计价值观
> "少即是多,但每一处都要有意图"
- **克制的奢华**:不是堆砌,而是精准取舍
- **系统性思维**:每个组件都来自同一设计语言
- **情绪传达**:界面有温度,有性格,不是功能机器
- **细节决定品质**:阴影、圆角、间距都是设计语言
---
## 🎯 触发后的工作流程
### Step 1: 需求分析(必须先做)
分析以下维度,在回复前先梳理清楚:
```
用途分类:
├── 生成新UI → 执行「UI Generation Protocol」
├── 优化现有UI → 执行「UI Audit & Optimization Protocol」
└── 组件设计 → 执行「Component Design Protocol」
产品类型:
├── Mobile App (iOS/Android 风格)
├── Web App / Dashboard
├── Landing Page / Marketing
├── Admin / B端系统
└── 创意/作品集页面
```
**必须明确的信息(缺失时主动询问或合理假设并说明):**
- 产品定位(面向谁?解决什么问题?)
- 品牌调性(科技感/温暖/奢华/年轻/专业?)
- 主色倾向(有偏好色系,或给我自由发挥)
- 交付格式(HTML/CSS、React/JSX、SVG 原型图)
---
## 🎨 色彩系统参考体系
### 参考「谷歌 Material You」调色板生成规则
**Tonal Palette 结构:**
```
Primary → 品牌主色,用于关键操作和焦点元素
Secondary → 辅助色,用于标签、图标、次要按钮
Tertiary → 对比色,用于强调差异化内容
Neutral → 灰阶体系,背景/表面/文字层级
Error → 错误/警告状态色
```
**推荐高级色板(可直接使用):**
| 风格 | Primary | Secondary | Accent | Surface |
|------|---------|-----------|--------|---------|
| 极简白 | #1A1A2E | #16213E | #E94560 | #F8F9FA |
| 深空科技 | #6C63FF | #3ECFCF | #FF6584 | #0D0D1A |
| 自然有机 | #2D6A4F | #95D5B2 | #F4A261 | #FEFDF8 |
| 奢华金融 | #1C1C28 | #8B7355 | #C9A84C | #F5F3EF |
| 现代橙红 | #FF4B2B | #FF416C | #FFA07A | #1A1A1A |
| 苹果式灰 | #007AFF | #34C759 | #FF9500 | #F2F2F7 |
| 薰衣草紫 | #7B61FF | #B693FD | #4DC9D9 | #FAFAFF |
| 珊瑚暖调 | #FF6B6B | #FFE66D | #4ECDC4 | #FFFAF0 |
### Apple HIG 间距系统
```css
/* 参考 Apple 8pt 网格系统 */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px; /* 标准间距 */
--space-5: 20px;
--space-6: 24px; /* 组件内间距 */
--space-8: 32px; /* 区块间距 */
--space-10: 40px;
--space-12: 48px; /* 大区块间距 */
--space-16: 64px; /* 页面级间距 */
--space-24: 96px;
```
---
## 📐 排版系统
### 字体层级(参考 Apple SF / Google 字体)
```css
/* 优先使用有个性的字体组合 */
/* 选项 A: 科技感 */
--font-display: 'Space Grotesk', 'DM Sans';
--font-body: 'Inter', system-ui;
/* 选项 B: 人文优雅 */
--font-display: 'Playfair Display', 'Cormorant';
--font-body: 'Source Serif 4', Georgia;
/* 选项 C: 现代极简 */
--font-display: 'Syne', 'Outfit';
--font-body: 'Manrope', sans-serif;
/* 选项 D: 中文友好 */
--font-display: 'Noto Serif SC', serif;
--font-body: 'Noto Sans SC', sans-serif;
/* Type Scale (参考 Material 3) */
--type-display-large: clamp(40px, 5vw, 57px) / 1.12;
--type-display-medium: clamp(32px, 4vw, 45px) / 1.16;
--type-headline-large: clamp(24px, 3vw, 32px) / 1.25;
--type-headline-medium: clamp(20px, 2.5vw, 28px) / 1.29;
--type-title-large: 22px / 1.27;
--type-title-medium: 16px / 1.5;
--type-body-large: 16px / 1.5;
--type-body-medium: 14px / 1.43;
--type-label-large: 14px / 1.43;
--type-label-small: 11px / 1.45;
```
---
## 🧩 组件设计规范
### 卡片组件(Card
```css
/* 参考 Material 3 Card Variants */
.card-elevated {
background: var(--surface);
border-radius: 12px;
box-shadow: 0px 1px 2px rgba(0,0,0,.06),
0px 4px 16px rgba(0,0,0,.08);
transition: box-shadow 200ms ease, transform 200ms ease;
}
.card-elevated:hover {
box-shadow: 0px 4px 8px rgba(0,0,0,.08),
0px 12px 32px rgba(0,0,0,.12);
transform: translateY(-2px);
}
.card-filled {
background: var(--surface-variant);
border-radius: 12px;
border: none;
}
.card-outlined {
background: var(--surface);
border-radius: 12px;
border: 1px solid var(--outline-variant);
}
```
### 按钮系统(Button
```css
/* Apple + Material 融合规范 */
.btn-primary {
height: 44px; /* Apple HIG 最小触控尺寸 */
padding: 0 24px;
border-radius: 10px; /* Apple 风格圆角 */
font-weight: 600;
font-size: 15px;
letter-spacing: -0.01em;
transition: all 150ms ease;
}
/* 按钮层级: Filled > Filled Tonal > Outlined > Text > Icon */
.btn-primary:hover { filter: brightness(1.08); transform: translateY(-1px); }
.btn-primary:active { transform: translateY(0); filter: brightness(0.95); }
```
### 输入框(Input
```css
/* Material 3 风格输入框 */
.input-outlined {
height: 56px;
border: 1.5px solid var(--outline);
border-radius: 4px;
padding: 16px;
transition: border-color 200ms;
}
.input-outlined:focus {
border-color: var(--primary);
border-width: 2px;
}
/* Apple 风格输入框 */
.input-apple {
height: 44px;
background: rgba(120,120,128,0.12);
border: none;
border-radius: 10px;
padding: 0 16px;
}
```
---
## ✨ 动效与交互规范
### 动效曲线(参考 Apple Spring 和 Material Motion
```css
/* Apple 弹性曲线 */
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
/* Material 标准曲线 */
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
/* 强调曲线 */
--ease-emphasized: cubic-bezier(0.2, 0, 0, 1);
/* 快入慢出 */
--ease-decelerate: cubic-bezier(0, 0, 0.2, 1);
/* 慢入快出 */
--ease-accelerate: cubic-bezier(0.3, 0, 1, 1);
/* 时长规范 */
--duration-short1: 50ms;
--duration-short2: 100ms;
--duration-short3: 150ms;
--duration-short4: 200ms; /* 微交互 */
--duration-medium1: 250ms;
--duration-medium2: 300ms; /* 标准转场 */
--duration-long1: 350ms;
--duration-long2: 400ms; /* 复杂动画 */
--duration-extra-long: 600ms+; /* 页面级切换 */
```
### 必须实现的微交互
- **Hover 状态**:背景色变化 + 轻微位移
- **Active 状态**:轻压效果 (scale 0.97)
- **Focus 状态**:清晰的焦点环(无障碍必须)
- **Loading 状态**:骨架屏或脉冲动画
- **页面加载**:元素交错淡入 (stagger delay)
---
## 🏗️ UI Generation Protocol(生成新 UI
### 执行顺序
1. **定义设计 DNA** — 确定调性、主色、字体组合
2. **建立 CSS 变量体系** — 完整的 token 系统
3. **搭建布局骨架** — Grid/Flex 响应式结构
4. **实现核心组件** — 从最重要的交互元素开始
5. **添加视觉质感** — 渐变、阴影、纹理、背景
6. **注入微交互** — 动效、过渡、Hover 状态
7. **精调细节** — 间距、对齐、字重、行高
8. **响应式适配** — Mobile First
### 代码规范
```html
<!-- 必须包含的 meta -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<!-- 结构原则 -->
<!-- 1. CSS Variables 在 :root 统一定义 -->
<!-- 2. 组件用 BEM 或语义化命名 -->
<!-- 3. 响应式用 clamp() 而非 media query 断点堆叠 -->
<!-- 4. 动画性能优先用 transform/opacity -->
```
---
## 🔍 UI Audit & Optimization Protocol(优化现有 UI
### 分析维度(拿到现有 UI 后,必须从这 6 个维度审查)
**1. 视觉层级**
- [ ] 是否有清晰的信息优先级?
- [ ] 用户视线路径是否顺畅?
- [ ] 重要信息是否足够突出?
**2. 色彩系统**
- [ ] 色彩对比度是否达标(WCAG AA: 4.5:1 文字)?
- [ ] 颜色使用是否一致、有系统性?
- [ ] 是否存在色彩混乱或过多颜色?
**3. 排版质量**
- [ ] 字体层级是否清晰(最多 3 个层级)?
- [ ] 行高/字间距是否舒适?
- [ ] 字体选择是否与品牌调性匹配?
**4. 间距与对齐**
- [ ] 是否遵循基准网格(4pt 或 8pt)?
- [ ] 组件内外间距是否统一?
- [ ] 元素对齐是否精准?
**5. 组件一致性**
- [ ] 相同功能的组件样式是否统一?
- [ ] 交互状态是否完整(默认/Hover/Active/Disabled)?
- [ ] 圆角、阴影是否系统化?
**6. 现代感与审美**
- [ ] 是否有过时的 UI 模式(阴影过重、渐变廉价)?
- [ ] 是否有 Pinterest 级别的视觉吸引力?
- [ ] 留白是否足够、有设计感?
### 优化输出格式
```
## UI 审查报告
### 🎯 核心问题(3-5个最重要的)
1. [问题描述] → [具体改进建议]
### 🎨 设计升级方案
[输出改进后的完整代码]
### 📊 改进对比
Before: [原方案问题]
After: [改进后效果]
```
---
## 🎪 Component Design Protocol(单组件设计)
适用于:按钮、卡片、导航栏、表单、Modal、Toast、Tab、Chip 等单一组件。
**输出标准:**
- 包含所有状态(Default / Hover / Active / Focus / Disabled / Loading
- 提供 Dark Mode 变体
- 包含使用示例
- 代码直接可用,不需要额外依赖(除非用户指定框架)
---
## 🌟 高质量 UI 的标志性特征
参考 Pinterest 高赞、Dribbble 精选的共同特征:
### 必须具备
1. **清晰的焦点** — 每个页面只有一个最重要的视觉中心
2. **呼吸感** — 足够的留白,不拥挤
3. **色彩自信** — 不是"安全"的配色,而是有个性的
4. **字体有品位** — 不用默认字体,字重搭配有对比
5. **圆角有度** — 统一的圆角半径,不乱用
6. **阴影克制** — 一两层精准的阴影,不堆叠
7. **动效有意义** — 每个动画都在传达信息
### 绝对避免
- 彩虹色渐变按钮
- 廉价 box-shadow`0 4px 8px rgba(0,0,0,0.5)` 这种)
- 全大写 + 粗体 + 红色 的"重要提示"
- 16 种以上颜色同时出现
- 响应式断点堆叠(用 clamp 代替)
- 图标和文字对不齐
- 移动端点击区域小于 44px
---
## 📦 参考文件
需要深度参考时,读取以下文件:
- `references/color-systems.md` — 完整色彩系统参考(Material 3, Apple, 自定义)
- `references/component-patterns.md` — 高质量组件代码片段库
- `references/layout-templates.md` — 常见页面布局模板(Dashboard, Landing, App
- `references/animation-library.md` — 精选动效代码库
---
## ⚡ 快速决策树
```
用户给了设计稿/截图要优化?
→ 执行 UI Audit Protocol,先分析问题,再输出改进版本
用户要从零生成界面?
→ 问清楚用途和调性,选择色板,执行 Generation Protocol
用户只需要某个组件?
→ 执行 Component Protocol,输出所有状态变体
用户没说清楚要什么?
→ 询问:① 是什么产品 ② 大概什么风格 ③ 要 HTML 还是 React
```
+317
View File
@@ -0,0 +1,317 @@
# Color Systems Reference
## Google Material Design 3 — Dynamic Color
### Tonal Palette 完整结构
Material 3 的色彩系统基于 HCT 色彩空间(Hue, Chroma, Tone)。
**Primary Tonal Palette(主色调)**
```
Tone 100: #FFFFFF (最亮)
Tone 99: 极浅主色背景
Tone 95: 浅色主色容器
Tone 90: Primary Container (Light Mode)
Tone 80: Primary (Dark Mode)
Tone 70:
Tone 60:
Tone 50:
Tone 40: Primary (Light Mode)
Tone 30: On Primary Container (Light)
Tone 20: Primary Container (Dark)
Tone 10: On Primary (Light / On Primary Container Dark)
Tone 0: #000000 (最暗)
```
### 完整 Material 3 语义色 Token
```css
:root {
/* === Light Mode === */
/* Primary */
--md-sys-color-primary: #6750A4;
--md-sys-color-on-primary: #FFFFFF;
--md-sys-color-primary-container: #EADDFF;
--md-sys-color-on-primary-container: #21005D;
/* Secondary */
--md-sys-color-secondary: #625B71;
--md-sys-color-on-secondary: #FFFFFF;
--md-sys-color-secondary-container: #E8DEF8;
--md-sys-color-on-secondary-container: #1D192B;
/* Tertiary */
--md-sys-color-tertiary: #7D5260;
--md-sys-color-on-tertiary: #FFFFFF;
--md-sys-color-tertiary-container: #FFD8E4;
--md-sys-color-on-tertiary-container: #31111D;
/* Error */
--md-sys-color-error: #B3261E;
--md-sys-color-on-error: #FFFFFF;
--md-sys-color-error-container: #F9DEDC;
--md-sys-color-on-error-container: #410E0B;
/* Surface */
--md-sys-color-surface: #FFFBFE;
--md-sys-color-on-surface: #1C1B1F;
--md-sys-color-surface-variant: #E7E0EC;
--md-sys-color-on-surface-variant: #49454F;
--md-sys-color-surface-container-lowest: #FFFFFF;
--md-sys-color-surface-container-low: #F7F2FA;
--md-sys-color-surface-container: #F3EDF7;
--md-sys-color-surface-container-high: #ECE6F0;
--md-sys-color-surface-container-highest: #E6E0E9;
/* Outline */
--md-sys-color-outline: #79747E;
--md-sys-color-outline-variant: #CAC4D0;
/* Inverse */
--md-sys-color-inverse-surface: #313033;
--md-sys-color-inverse-on-surface: #F4EFF4;
--md-sys-color-inverse-primary: #D0BCFF;
/* Background */
--md-sys-color-background: #FFFBFE;
--md-sys-color-on-background: #1C1B1F;
/* Shadow & Scrim */
--md-sys-color-shadow: #000000;
--md-sys-color-scrim: #000000;
}
/* Dark Mode */
[data-theme="dark"] {
--md-sys-color-primary: #D0BCFF;
--md-sys-color-on-primary: #381E72;
--md-sys-color-primary-container: #4F378B;
--md-sys-color-on-primary-container: #EADDFF;
--md-sys-color-secondary: #CCC2DC;
--md-sys-color-on-secondary: #332D41;
--md-sys-color-secondary-container: #4A4458;
--md-sys-color-on-secondary-container: #E8DEF8;
--md-sys-color-surface: #1C1B1F;
--md-sys-color-on-surface: #E6E1E5;
--md-sys-color-surface-container: #211F26;
--md-sys-color-surface-container-high: #2B2930;
--md-sys-color-surface-container-highest: #36343B;
--md-sys-color-outline: #938F99;
--md-sys-color-background: #1C1B1F;
--md-sys-color-on-background: #E6E1E5;
}
```
---
## Apple HIG — System Colors
### iOS/macOS 系统色(完整集)
```css
:root {
/* iOS System Colors (Light) */
--apple-blue: #007AFF;
--apple-green: #34C759;
--apple-indigo: #5856D6;
--apple-orange: #FF9500;
--apple-pink: #FF2D55;
--apple-purple: #AF52DE;
--apple-red: #FF3B30;
--apple-teal: #30B0C7;
--apple-yellow: #FFCC00;
--apple-cyan: #32ADE6;
--apple-mint: #00C7BE;
--apple-brown: #A2845E;
/* iOS System Grays */
--apple-gray1: #8E8E93;
--apple-gray2: #AEAEB2;
--apple-gray3: #C7C7CC;
--apple-gray4: #D1D1D6;
--apple-gray5: #E5E5EA;
--apple-gray6: #F2F2F7;
/* iOS System Backgrounds */
--apple-bg-primary: #FFFFFF;
--apple-bg-secondary: #F2F2F7;
--apple-bg-tertiary: #FFFFFF;
--apple-grouped-bg: #F2F2F7;
--apple-grouped-bg-secondary: #FFFFFF;
/* iOS Separators */
--apple-separator: rgba(60, 60, 67, 0.29);
--apple-separator-opaque: #C6C6C8;
/* iOS Label Colors */
--apple-label: #000000;
--apple-label-secondary: rgba(60, 60, 67, 0.6);
--apple-label-tertiary: rgba(60, 60, 67, 0.3);
--apple-label-quaternary: rgba(60, 60, 67, 0.18);
/* iOS Fill Colors */
--apple-fill: rgba(120, 120, 128, 0.2);
--apple-fill-secondary: rgba(120, 120, 128, 0.16);
--apple-fill-tertiary: rgba(118, 118, 128, 0.12);
--apple-fill-quaternary: rgba(116, 116, 128, 0.08);
}
/* Dark Mode */
@media (prefers-color-scheme: dark) {
:root {
--apple-blue: #0A84FF;
--apple-green: #30D158;
--apple-indigo: #5E5CE6;
--apple-orange: #FF9F0A;
--apple-pink: #FF375F;
--apple-purple: #BF5AF2;
--apple-red: #FF453A;
--apple-teal: #40CBE0;
--apple-yellow: #FFD60A;
--apple-cyan: #64D2FF;
--apple-mint: #63E6E2;
--apple-gray1: #8E8E93;
--apple-gray2: #636366;
--apple-gray3: #48484A;
--apple-gray4: #3A3A3C;
--apple-gray5: #2C2C2E;
--apple-gray6: #1C1C1E;
--apple-bg-primary: #000000;
--apple-bg-secondary: #1C1C1E;
--apple-bg-tertiary: #2C2C2E;
--apple-label: #FFFFFF;
--apple-label-secondary: rgba(235, 235, 245, 0.6);
--apple-label-tertiary: rgba(235, 235, 245, 0.3);
}
}
```
---
## 精选高级自定义色板
### 1. Midnight Studio(深色科技)
```css
:root {
--primary: #7C3AED; /* 紫罗兰主色 */
--primary-light: #A78BFA;
--primary-dark: #5B21B6;
--accent: #10B981; /* 翡翠绿强调 */
--accent-warm: #F59E0B; /* 琥珀暖色 */
--bg: #09090B; /* 近黑背景 */
--bg-2: #18181B; /* 卡片表面 */
--bg-3: #27272A; /* 悬浮层 */
--border: rgba(255,255,255,0.08);
--border-strong: rgba(255,255,255,0.15);
--text-1: #FAFAFA;
--text-2: #A1A1AA;
--text-3: #71717A;
--shadow-glow: 0 0 40px rgba(124, 58, 237, 0.3);
}
```
### 2. Linen & Oak(人文暖调)
```css
:root {
--primary: #92400E; /* 深琥珀 */
--primary-light: #D97706;
--accent: #047857; /* 墨绿 */
--accent-warm: #9D174D; /* 酒红 */
--bg: #FEFCE8; /* 米黄背景 */
--bg-2: #FFFBEB;
--bg-3: #FEF3C7;
--surface: #FFFFFF;
--border: rgba(120, 80, 20, 0.12);
--text-1: #1C1917;
--text-2: #57534E;
--text-3: #A8A29E;
}
```
### 3. Arctic Glass(冷感极简)
```css
:root {
--primary: #0EA5E9; /* 冰蓝 */
--primary-light: #38BDF8;
--primary-dark: #0284C7;
--accent: #8B5CF6; /* 淡紫强调 */
--bg: #F8FAFC; /* 极浅灰蓝背景 */
--bg-2: #F1F5F9;
--bg-3: #E2E8F0;
--surface: #FFFFFF;
--glass: rgba(255, 255, 255, 0.7);
--glass-border: rgba(148, 163, 184, 0.3);
--text-1: #0F172A;
--text-2: #475569;
--text-3: #94A3B8;
}
```
### 4. Tokyo Night(暗色电影感)
```css
:root {
--primary: #7AA2F7; /* 东京蓝 */
--secondary: #BB9AF7; /* 东京紫 */
--accent: #7DCFFF; /* 青蓝 */
--warn: #FF9E64; /* 橙色警告 */
--error: #F7768E; /* 红色错误 */
--success: #9ECE6A; /* 绿色成功 */
--bg: #1A1B26; /* 东京暗背景 */
--bg-2: #16161E;
--bg-3: #24283B;
--bg-highlight: #292E42;
--border: #292E42;
--text-1: #C0CAF5;
--text-2: #9AA5CE;
--text-3: #565F89;
}
```
### 5. Coral Sunrise(活力橙红)
```css
:root {
--primary: #F97316; /* 活力橙 */
--primary-dark: #EA580C;
--secondary: #EC4899; /* 玫瑰粉 */
--accent: #14B8A6; /* 蒂芙尼绿 */
--bg: #FFFAF0; /* 象牙白背景 */
--bg-2: #FFF7ED;
--surface: #FFFFFF;
--border: rgba(249, 115, 22, 0.15);
--text-1: #1C1917;
--text-2: #78716C;
--text-3: #A8A29E;
--gradient: linear-gradient(135deg, #F97316, #EC4899);
}
```
---
## WCAG 对比度检查速查
| 文字/背景组合 | 对比度 | 是否达标 AA |
|-------------|--------|------------|
| #000 / #FFF | 21:1 | ✅ AAA |
| #1C1B1F / #FFFBFE | 18.1:1 | ✅ AAA |
| #6750A4 / #FFF | 4.6:1 | ✅ AA |
| #8E8E93 / #FFF | 3.9:1 | ⚠️ 仅大文字 |
| #007AFF / #FFF | 4.5:1 | ✅ AA |
**规则提醒:**
- 正文 (≤18pt 非粗体): 最低 4.5:1
- 大文字 (≥18pt 或 ≥14pt 粗体): 最低 3:1
- UI 组件和图形: 最低 3:1
@@ -0,0 +1,601 @@
# Component Patterns Library
## Navigation
### Top App Bar (Material 3 + Apple 混合风格)
```html
<header class="top-bar">
<div class="top-bar__inner">
<button class="icon-btn" aria-label="Menu">
<svg><!-- hamburger --></svg>
</button>
<h1 class="top-bar__title">Page Title</h1>
<div class="top-bar__actions">
<button class="icon-btn" aria-label="Search">
<svg><!-- search --></svg>
</button>
<button class="icon-btn" aria-label="More">
<svg><!-- more --></svg>
</button>
</div>
</div>
</header>
<style>
.top-bar {
position: sticky;
top: 0;
z-index: 100;
background: var(--surface);
border-bottom: 1px solid var(--outline-variant);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
background: rgba(255, 251, 254, 0.8);
}
.top-bar__inner {
display: flex;
align-items: center;
gap: 4px;
height: 64px;
padding: 0 8px;
max-width: 1280px;
margin: 0 auto;
}
.top-bar__title {
flex: 1;
font-size: 22px;
font-weight: 400;
letter-spacing: -0.02em;
color: var(--on-surface);
}
.icon-btn {
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
background: transparent;
display: grid;
place-items: center;
cursor: pointer;
transition: background 150ms;
}
.icon-btn:hover { background: var(--on-surface-variant-8, rgba(73,69,79,0.08)); }
.icon-btn:active { background: var(--on-surface-variant-12, rgba(73,69,79,0.12)); }
</style>
```
### Side Navigation Rail (Material 3)
```html
<nav class="nav-rail">
<div class="nav-rail__fab">
<button class="fab-btn">+</button>
</div>
<ul class="nav-rail__list">
<li class="nav-item active">
<div class="nav-item__indicator">
<svg class="nav-item__icon"><!-- icon --></svg>
</div>
<span class="nav-item__label">Home</span>
</li>
<!-- more items -->
</ul>
</nav>
<style>
.nav-rail {
width: 80px;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 0;
background: var(--surface);
}
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 4px 0;
cursor: pointer;
min-width: 72px;
}
.nav-item__indicator {
width: 56px;
height: 32px;
border-radius: 16px;
display: grid;
place-items: center;
transition: background 200ms;
}
.nav-item.active .nav-item__indicator {
background: var(--secondary-container);
}
.nav-item:hover:not(.active) .nav-item__indicator {
background: var(--on-surface-8);
}
.nav-item__label {
font-size: 12px;
font-weight: 500;
letter-spacing: 0.02em;
color: var(--on-surface-variant);
}
.nav-item.active .nav-item__label {
color: var(--on-surface);
font-weight: 700;
}
</style>
```
---
## Cards
### Product Card (Pinterest 风格)
```html
<article class="product-card">
<div class="product-card__img-wrap">
<img class="product-card__img" src="..." alt="...">
<button class="product-card__save"></button>
</div>
<div class="product-card__body">
<p class="product-card__category">Category</p>
<h3 class="product-card__title">Product Name</h3>
<div class="product-card__footer">
<span class="product-card__price">$99</span>
<button class="product-card__cta">Add to Cart</button>
</div>
</div>
</article>
<style>
.product-card {
border-radius: 16px;
overflow: hidden;
background: var(--surface);
transition: transform 300ms var(--ease-spring), box-shadow 300ms;
cursor: pointer;
}
.product-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0,0,0,0.12);
}
.product-card__img-wrap {
position: relative;
aspect-ratio: 4/3;
overflow: hidden;
}
.product-card__img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 600ms var(--ease-decelerate);
}
.product-card:hover .product-card__img {
transform: scale(1.04);
}
.product-card__save {
position: absolute;
top: 12px; right: 12px;
width: 36px; height: 36px;
border-radius: 50%;
background: rgba(255,255,255,0.9);
backdrop-filter: blur(8px);
border: none;
font-size: 16px;
cursor: pointer;
opacity: 0;
transform: scale(0.8);
transition: opacity 200ms, transform 200ms var(--ease-spring);
}
.product-card:hover .product-card__save {
opacity: 1;
transform: scale(1);
}
.product-card__body {
padding: 16px;
}
.product-card__category {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--primary);
margin-bottom: 4px;
}
.product-card__title {
font-size: 16px;
font-weight: 600;
color: var(--on-surface);
margin-bottom: 12px;
line-height: 1.3;
}
.product-card__footer {
display: flex;
align-items: center;
justify-content: space-between;
}
.product-card__price {
font-size: 20px;
font-weight: 700;
color: var(--on-surface);
letter-spacing: -0.02em;
}
.product-card__cta {
padding: 8px 16px;
border-radius: 20px;
background: var(--primary);
color: var(--on-primary);
border: none;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: filter 150ms, transform 150ms;
}
.product-card__cta:hover { filter: brightness(1.1); }
.product-card__cta:active { transform: scale(0.96); }
</style>
```
### Stats Card (Dashboard)
```html
<div class="stat-card">
<div class="stat-card__header">
<span class="stat-card__label">Total Revenue</span>
<div class="stat-card__icon-wrap">
<svg><!-- icon --></svg>
</div>
</div>
<p class="stat-card__value">$48,295</p>
<div class="stat-card__change positive">
<svg><!-- arrow up --></svg>
<span>+12.5% from last month</span>
</div>
</div>
<style>
.stat-card {
background: var(--surface);
border-radius: 20px;
padding: 24px;
border: 1px solid var(--outline-variant);
transition: box-shadow 200ms;
}
.stat-card:hover {
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
}
.stat-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 12px;
}
.stat-card__label {
font-size: 14px;
color: var(--on-surface-variant);
font-weight: 500;
}
.stat-card__icon-wrap {
width: 40px; height: 40px;
border-radius: 12px;
background: var(--primary-container);
display: grid;
place-items: center;
color: var(--primary);
}
.stat-card__value {
font-size: 32px;
font-weight: 700;
letter-spacing: -0.03em;
color: var(--on-surface);
margin-bottom: 8px;
font-feature-settings: 'tnum';
}
.stat-card__change {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
font-weight: 500;
}
.stat-card__change.positive { color: #16A34A; }
.stat-card__change.negative { color: #DC2626; }
</style>
```
---
## Forms
### Floating Label Input (Material 3)
```html
<div class="text-field">
<div class="text-field__container">
<input type="text" id="email" class="text-field__input" placeholder=" ">
<label for="email" class="text-field__label">Email address</label>
<fieldset class="text-field__border"><legend><span>Email address</span></legend></fieldset>
</div>
<span class="text-field__helper">We'll never share your email</span>
</div>
<style>
.text-field { display: flex; flex-direction: column; gap: 4px; }
.text-field__container {
position: relative;
height: 56px;
}
.text-field__input {
width: 100%;
height: 100%;
padding: 16px 16px 0;
font-size: 16px;
background: transparent;
border: none;
outline: none;
color: var(--on-surface);
z-index: 1;
position: relative;
}
.text-field__label {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
font-size: 16px;
color: var(--on-surface-variant);
pointer-events: none;
transition: all 150ms var(--ease-standard);
z-index: 2;
background: var(--surface);
padding: 0 4px;
}
.text-field__input:focus + .text-field__label,
.text-field__input:not(:placeholder-shown) + .text-field__label {
top: 0;
font-size: 12px;
font-weight: 500;
}
.text-field__input:focus + .text-field__label { color: var(--primary); }
.text-field__border {
position: absolute;
inset: 0;
margin: 0;
border: 1.5px solid var(--outline);
border-radius: 4px;
pointer-events: none;
padding: 0 12px;
transition: border-color 150ms, border-width 150ms;
}
.text-field__input:focus ~ .text-field__border {
border-color: var(--primary);
border-width: 2px;
}
.text-field__border legend { height: 0; font-size: 12px; font-weight: 500; padding: 0; }
.text-field__input:focus ~ .text-field__border legend,
.text-field__input:not(:placeholder-shown) ~ .text-field__border legend {
padding: 0 4px;
}
.text-field__helper {
font-size: 12px;
color: var(--on-surface-variant);
padding: 0 16px;
}
</style>
```
---
## Buttons — Complete System
```html
<!-- Primary (Filled) -->
<button class="btn btn--filled">Get Started</button>
<!-- Secondary (Filled Tonal) -->
<button class="btn btn--tonal">Learn More</button>
<!-- Outlined -->
<button class="btn btn--outlined">Cancel</button>
<!-- Text -->
<button class="btn btn--text">Skip</button>
<!-- Elevated -->
<button class="btn btn--elevated">Save Draft</button>
<!-- Icon + Label -->
<button class="btn btn--filled btn--icon">
<svg><!-- icon --></svg>
Add Item
</button>
<!-- Loading State -->
<button class="btn btn--filled btn--loading" disabled>
<span class="btn__spinner"></span>
Loading...
</button>
<style>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 40px;
padding: 0 24px;
border-radius: 20px; /* Material 3 全圆角 */
font-size: 14px;
font-weight: 500;
letter-spacing: 0.01em;
border: none;
cursor: pointer;
white-space: nowrap;
transition: all 150ms var(--ease-standard);
position: relative;
overflow: hidden;
}
.btn:disabled { opacity: 0.38; cursor: not-allowed; }
.btn:active:not(:disabled) { transform: scale(0.97); }
/* State layer */
.btn::after {
content: '';
position: absolute;
inset: 0;
background: currentColor;
opacity: 0;
transition: opacity 150ms;
}
.btn:hover::after { opacity: 0.08; }
.btn:active::after { opacity: 0.12; }
/* Variants */
.btn--filled { background: var(--primary); color: var(--on-primary); }
.btn--filled:hover { box-shadow: 0 1px 2px rgba(0,0,0,.1), 0 2px 6px rgba(0,0,0,.08); }
.btn--tonal { background: var(--secondary-container); color: var(--on-secondary-container); }
.btn--tonal:hover { box-shadow: 0 1px 2px rgba(0,0,0,.06), 0 2px 6px rgba(0,0,0,.06); }
.btn--outlined {
background: transparent;
color: var(--primary);
border: 1px solid var(--outline);
}
.btn--outlined:hover { background: rgba(103, 80, 164, 0.08); }
.btn--text { background: transparent; color: var(--primary); padding: 0 12px; }
.btn--elevated {
background: var(--surface-container-low);
color: var(--primary);
box-shadow: 0 1px 2px rgba(0,0,0,.06), 0 2px 8px rgba(0,0,0,.08);
}
.btn--elevated:hover { box-shadow: 0 2px 4px rgba(0,0,0,.08), 0 4px 12px rgba(0,0,0,.1); }
/* Spinner */
.btn__spinner {
width: 16px; height: 16px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
```
---
## Feedback Components
### Toast / Snackbar (Material 3)
```html
<div class="snackbar" role="status">
<span class="snackbar__message">Changes saved successfully</span>
<button class="snackbar__action">Undo</button>
<button class="snackbar__close" aria-label="Close"></button>
</div>
<style>
.snackbar {
display: inline-flex;
align-items: center;
gap: 12px;
min-width: 288px;
max-width: 568px;
background: var(--inverse-surface);
color: var(--inverse-on-surface);
border-radius: 4px;
padding: 14px 16px;
box-shadow: 0 3px 5px rgba(0,0,0,.1), 0 8px 24px rgba(0,0,0,.14);
font-size: 14px;
animation: snackbar-in 300ms var(--ease-decelerate) forwards;
}
@keyframes snackbar-in {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.snackbar__message { flex: 1; }
.snackbar__action {
background: none; border: none;
color: var(--inverse-primary);
font-size: 14px; font-weight: 500;
letter-spacing: 0.01em;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background 150ms;
}
.snackbar__action:hover { background: rgba(255,255,255,0.1); }
.snackbar__close {
background: none; border: none;
color: var(--inverse-on-surface);
cursor: pointer; font-size: 18px; line-height: 1;
opacity: 0.7; transition: opacity 150ms;
}
</style>
```
### Badge
```css
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: var(--error);
color: var(--on-error);
font-size: 11px;
font-weight: 600;
line-height: 1;
}
.badge--dot {
width: 8px; height: 8px;
padding: 0; min-width: unset;
border-radius: 50%;
}
.badge--large {
min-width: 24px; height: 24px;
border-radius: 12px;
font-size: 12px;
padding: 0 8px;
}
```
---
## Page Skeleton / Loading
```html
<div class="skeleton-card">
<div class="skeleton skeleton--image"></div>
<div class="skeleton-card__body">
<div class="skeleton skeleton--line" style="width: 60%"></div>
<div class="skeleton skeleton--line" style="width: 90%"></div>
<div class="skeleton skeleton--line" style="width: 75%"></div>
</div>
</div>
<style>
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(
90deg,
var(--surface-variant) 25%,
var(--surface-container) 50%,
var(--surface-variant) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
.skeleton--image { width: 100%; aspect-ratio: 16/9; border-radius: 12px 12px 0 0; }
.skeleton--line { height: 14px; margin-bottom: 8px; border-radius: 7px; }
</style>
```
@@ -0,0 +1,499 @@
# Layout Templates Reference
## Dashboard Layout (Material 3 Canonical)
```html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dashboard</title>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<div class="app-shell">
<!-- Side Navigation -->
<aside class="nav-drawer">
<div class="nav-drawer__header">
<div class="brand">
<div class="brand__icon"></div>
<span class="brand__name">AppName</span>
</div>
</div>
<nav class="nav-list">
<a href="#" class="nav-link active">
<svg class="nav-link__icon" viewBox="0 0 24 24">
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>
</svg>
<span>Dashboard</span>
</a>
<!-- more links -->
</nav>
</aside>
<!-- Main Content -->
<div class="main-content">
<!-- Top Bar -->
<header class="top-bar">
<h1 class="page-title">Overview</h1>
<div class="top-bar__actions">
<button class="icon-btn">🔔</button>
<div class="avatar">JD</div>
</div>
</header>
<!-- Content Area -->
<main class="content-area">
<!-- Stats Row -->
<div class="stats-grid">
<!-- stat cards here -->
</div>
<!-- Charts -->
<div class="charts-grid">
<!-- chart cards here -->
</div>
</main>
</div>
</div>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #6750A4;
--primary-container: #EADDFF;
--on-primary-container: #21005D;
--secondary-container: #E8DEF8;
--surface: #FFFBFE;
--surface-container: #F3EDF7;
--surface-container-low: #F7F2FA;
--on-surface: #1C1B1F;
--on-surface-variant: #49454F;
--outline: #79747E;
--outline-variant: #CAC4D0;
--nav-width: 256px;
}
body {
font-family: 'Manrope', system-ui, sans-serif;
background: var(--surface-container-low);
color: var(--on-surface);
min-height: 100vh;
}
.app-shell {
display: flex;
min-height: 100vh;
}
/* Nav Drawer */
.nav-drawer {
width: var(--nav-width);
height: 100vh;
position: sticky;
top: 0;
background: var(--surface-container-low);
display: flex;
flex-direction: column;
border-right: 1px solid var(--outline-variant);
padding: 16px 12px;
flex-shrink: 0;
}
.nav-drawer__header { padding: 8px 12px 24px; }
.brand { display: flex; align-items: center; gap: 12px; }
.brand__icon {
width: 40px; height: 40px;
background: var(--primary);
color: white;
border-radius: 12px;
display: grid;
place-items: center;
font-size: 20px;
}
.brand__name { font-size: 18px; font-weight: 700; color: var(--on-surface); }
.nav-link {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 28px;
text-decoration: none;
color: var(--on-surface-variant);
font-size: 14px;
font-weight: 500;
transition: background 150ms, color 150ms;
margin-bottom: 4px;
}
.nav-link:hover { background: rgba(103,80,164,0.08); color: var(--on-surface); }
.nav-link.active {
background: var(--secondary-container);
color: var(--on-surface);
font-weight: 700;
}
.nav-link__icon { width: 20px; height: 20px; flex-shrink: 0; }
/* Main Content */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.top-bar {
height: 72px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 32px;
background: var(--surface-container-low);
border-bottom: 1px solid var(--outline-variant);
position: sticky;
top: 0;
z-index: 10;
}
.page-title { font-size: 28px; font-weight: 700; letter-spacing: -0.02em; }
.top-bar__actions { display: flex; align-items: center; gap: 8px; }
.avatar {
width: 36px; height: 36px;
background: var(--primary);
color: white;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.content-area { padding: 32px; overflow-y: auto; flex: 1; }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.charts-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 16px;
}
@media (max-width: 768px) {
.nav-drawer { display: none; }
.charts-grid { grid-template-columns: 1fr; }
}
</style>
```
---
## Landing Page Layout
```html
<!-- Hero Section -->
<section class="hero">
<div class="hero__container">
<div class="hero__eyebrow">
<span class="badge-pill">New →</span>
<span>Introducing v2.0</span>
</div>
<h1 class="hero__headline">
Build beautiful apps<br>
<span class="hero__gradient-text">10x faster</span>
</h1>
<p class="hero__description">
The design system that scales with your team.
Beautiful by default, customizable by design.
</p>
<div class="hero__ctas">
<a href="#" class="btn btn--primary btn--lg">Get Started Free</a>
<a href="#" class="btn btn--ghost btn--lg">View Demo ↗</a>
</div>
<div class="hero__social-proof">
<div class="avatar-stack">
<img src="..." alt="">
<img src="..." alt="">
<img src="..." alt="">
<img src="..." alt="">
</div>
<p><strong>2,000+</strong> teams already building</p>
</div>
</div>
<div class="hero__visual">
<!-- Product Screenshot / Illustration -->
</div>
</section>
<style>
.hero {
min-height: 100vh;
display: grid;
grid-template-columns: 1fr 1fr;
align-items: center;
gap: 64px;
padding: 80px clamp(24px, 5vw, 80px);
max-width: 1280px;
margin: 0 auto;
}
.hero__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--on-surface-variant);
margin-bottom: 24px;
}
.badge-pill {
background: var(--primary-container);
color: var(--on-primary-container);
padding: 4px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.hero__headline {
font-size: clamp(36px, 5vw, 64px);
font-weight: 800;
letter-spacing: -0.04em;
line-height: 1.05;
color: var(--on-surface);
margin-bottom: 24px;
}
.hero__gradient-text {
background: linear-gradient(135deg, var(--primary), var(--tertiary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero__description {
font-size: clamp(16px, 2vw, 20px);
line-height: 1.6;
color: var(--on-surface-variant);
max-width: 48ch;
margin-bottom: 40px;
}
.hero__ctas {
display: flex;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 40px;
}
.btn--lg { height: 52px; padding: 0 32px; font-size: 16px; }
.btn--ghost {
background: transparent;
color: var(--on-surface);
border: 1.5px solid var(--outline-variant);
border-radius: 26px;
}
.btn--ghost:hover { background: var(--surface-variant); }
.hero__social-proof {
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
color: var(--on-surface-variant);
}
.avatar-stack {
display: flex;
}
.avatar-stack img {
width: 28px; height: 28px;
border-radius: 50%;
border: 2px solid var(--surface);
margin-right: -8px;
object-fit: cover;
}
@media (max-width: 900px) {
.hero {
grid-template-columns: 1fr;
text-align: center;
min-height: auto;
padding-top: 100px;
}
.hero__visual { display: none; }
.hero__ctas { justify-content: center; }
.hero__social-proof { justify-content: center; }
}
</style>
```
---
## Mobile App Layout (iOS-like)
```html
<div class="app">
<!-- Status Bar placeholder -->
<div class="status-bar"></div>
<!-- Content -->
<main class="app-content">
<!-- Large Title (iOS style) -->
<div class="large-title-area">
<h1 class="large-title">My Library</h1>
<button class="circle-btn">+</button>
</div>
<!-- Search Bar (iOS style) -->
<div class="search-bar">
<span class="search-icon">🔍</span>
<input type="search" placeholder="Search" class="search-input">
</div>
<!-- Horizontal Scroll Section -->
<section class="section">
<div class="section-header">
<h2 class="section-title">Featured</h2>
<a href="#" class="see-all">See All</a>
</div>
<div class="h-scroll">
<!-- cards -->
</div>
</section>
</main>
<!-- Tab Bar (iOS style) -->
<nav class="tab-bar">
<button class="tab-item active">
<svg class="tab-icon"><!-- icon --></svg>
<span class="tab-label">Home</span>
</button>
<button class="tab-item">
<svg class="tab-icon"><!-- icon --></svg>
<span class="tab-label">Explore</span>
</button>
<button class="tab-item">
<svg class="tab-icon"><!-- icon --></svg>
<span class="tab-label">Library</span>
</button>
<button class="tab-item">
<svg class="tab-icon"><!-- icon --></svg>
<span class="tab-label">Profile</span>
</button>
</nav>
</div>
<style>
.app {
width: 390px; /* iPhone 14 width */
height: 844px;
background: var(--bg-primary, #F2F2F7);
border-radius: 44px;
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
box-shadow: 0 32px 80px rgba(0,0,0,0.3);
font-family: -apple-system, 'SF Pro Display', system-ui;
}
.status-bar {
height: 47px;
background: transparent;
flex-shrink: 0;
}
.app-content {
flex: 1;
overflow-y: auto;
padding: 0 20px;
-webkit-overflow-scrolling: touch;
}
.app-content::-webkit-scrollbar { display: none; }
.large-title-area {
display: flex;
align-items: flex-end;
justify-content: space-between;
padding: 8px 0 20px;
}
.large-title {
font-size: 34px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--apple-label, #000);
}
.circle-btn {
width: 32px; height: 32px;
border-radius: 50%;
background: var(--apple-fill, rgba(120,120,128,0.2));
border: none;
font-size: 22px;
line-height: 1;
display: grid;
place-items: center;
cursor: pointer;
color: var(--apple-blue, #007AFF);
}
.search-bar {
display: flex;
align-items: center;
gap: 8px;
background: rgba(118, 118, 128, 0.12);
border-radius: 10px;
padding: 0 12px;
height: 36px;
margin-bottom: 24px;
}
.search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-size: 17px;
color: var(--apple-label, #000);
}
.section { margin-bottom: 32px; }
.section-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
}
.section-title { font-size: 22px; font-weight: 700; letter-spacing: -0.01em; }
.see-all { font-size: 15px; color: var(--apple-blue, #007AFF); text-decoration: none; }
.h-scroll {
display: flex;
gap: 12px;
overflow-x: auto;
margin: 0 -20px;
padding: 0 20px 4px;
scrollbar-width: none;
}
.h-scroll::-webkit-scrollbar { display: none; }
/* Tab Bar */
.tab-bar {
height: 83px;
background: rgba(249, 249, 249, 0.94);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-top: 0.5px solid rgba(0,0,0,0.12);
display: flex;
align-items: flex-start;
padding: 8px 0 0;
flex-shrink: 0;
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
background: none;
border: none;
cursor: pointer;
padding: 4px 0;
color: var(--apple-gray1, #8E8E93);
transition: color 150ms;
}
.tab-item.active { color: var(--apple-blue, #007AFF); }
.tab-icon { width: 26px; height: 26px; }
.tab-label { font-size: 10px; font-weight: 500; letter-spacing: -0.01em; }
</style>
```
Binary file not shown.
Binary file not shown.