Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c38a9e74ad | |||
| e7c3081070 | |||
| 7dcb22f573 | |||
| a63bd6874c | |||
| dcb0b826de | |||
| e6237590c0 | |||
| f9eed82afb | |||
| f4847bc748 | |||
| faeaf7606b | |||
| 4debecd523 | |||
| 82bc0d9e41 | |||
| 7fd0f9b706 | |||
| 709685057b | |||
| eb05a0420e | |||
| 56adc625c0 | |||
| b6105295bb | |||
| 2633513ce3 | |||
| d74af74534 | |||
| 3786d82a27 | |||
| e17536b3e4 | |||
| 531df448c1 | |||
| e8b92a6f99 | |||
| deeafe239f | |||
| 336b60b3af | |||
| cd47d1aed5 | |||
| 3285e22142 | |||
| b343970ecc | |||
| 0e25201ede | |||
| 2bb85bf45c | |||
| 015af18d3c | |||
| 7e31606b45 | |||
| 04aaff8851 | |||
| 447932a8e6 | |||
| e99d0aec8a | |||
| a4904973bf | |||
| 576715f990 | |||
| df5b661d5f | |||
| a580eefd14 | |||
| 989c41da8c | |||
| e69d962521 | |||
| b88b29b745 | |||
| c71a2ac88e | |||
| 861a4cecff | |||
| c4e8a3b790 | |||
| 327637f94c | |||
| ab3384d6a5 | |||
| 313b25c631 | |||
| 9d3a8ae57e | |||
| 19ebb85209 | |||
| 7264184315 | |||
| 1d2dacc3ab | |||
| da1f24c710 | |||
| 5006642354 | |||
| ad0c894f93 | |||
| eae7fdafb8 | |||
| 36ba9338f1 | |||
| 72fb7524f3 | |||
| e71fa6e3ed | |||
| c2a62c29cd | |||
| b3d355179f | |||
| 8867e2f5fe | |||
| 4ae8c69193 | |||
| ce94c6b2ba | |||
| 5b38246801 | |||
| e681beba24 | |||
| 5b50e7ec27 | |||
| 24c7aff093 | |||
| d07311d5a5 | |||
| dd69e902ec | |||
| 1d0f412e4a | |||
| a8ed71fbff | |||
| 305ee5df09 |
@@ -195,8 +195,15 @@ Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Deployment docs (contain credentials, not for git)
|
||||
*部署交接文档.md
|
||||
*部署文档.md
|
||||
|
||||
# Claude
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
|
||||
.cc-connect/
|
||||
|
||||
@@ -0,0 +1,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 fallback,API 路由由 OpenResty 边缘处理。SSH 端口 7072,前端容器仅绑定 127.0.0.1:18063(不直接暴露公网)
|
||||
23. **请假管理**: `leaves` 表存储请假记录,按天粒度(start_date~end_date),五种类型(年假/事假/病假/调休/其他),提交即生效无审批。权限:经理看自己、支局长/领导看全员。填报进度新增 `on_leave` 第四态(蓝「假」印章),催办自动过滤请假人员,AI 摘要注入本周请假数据。PC 端侧边栏「工作」分组下独立页面,移动端首页快捷入口 + 列表页 + 表单页
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)}")
|
||||
@@ -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}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
+214
-9
@@ -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_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")
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
"""System-wide configuration key-value store."""
|
||||
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class SystemConfig(Base):
|
||||
__tablename__ = "system_config"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
value: Mapped[str] = mapped_column(Text, default="")
|
||||
@@ -15,4 +15,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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.models.visit import Visit
|
||||
from app.models.work_plan import WorkPlan
|
||||
from app.models.mini_business import MiniBusiness
|
||||
from app.models.key_visit import KeyVisit
|
||||
from app.models.daily_note import DailyNote
|
||||
from app.models.customer import Customer
|
||||
from app.models.user import User
|
||||
|
||||
@@ -21,7 +22,7 @@ def get_week_range(reference_date: date | None = None):
|
||||
|
||||
|
||||
async def export_weekly_report(db: AsyncSession, reference_date: date | None = None) -> io.BytesIO:
|
||||
"""Generate a 4-sheet xlsx matching the existing weekly report template."""
|
||||
"""Generate a 5-sheet xlsx matching the existing weekly report template."""
|
||||
monday, sunday = get_week_range(reference_date)
|
||||
wb = Workbook()
|
||||
|
||||
@@ -40,7 +41,7 @@ async def export_weekly_report(db: AsyncSession, reference_date: date | None = N
|
||||
# ── Sheet 1: 每日拜访记录 ──
|
||||
ws1 = wb.active
|
||||
ws1.title = "每日拜访记录"
|
||||
headers1 = ["客户单位", "拜访日期", "拜访方式", "时间范围", "拜访人姓名", "拜访人电话", "沟通内容", "客户需求", "同访人员", "客户经理"]
|
||||
headers1 = ["客户单位", "拜访日期", "拜访方式", "时间范围", "拜访人姓名", "拜访人电话", "沟通内容", "客户需求", "相关人员", "客户经理"]
|
||||
ws1.append(headers1)
|
||||
for col in range(1, len(headers1) + 1):
|
||||
cell = ws1.cell(row=1, column=col)
|
||||
@@ -52,6 +53,7 @@ async def export_weekly_report(db: AsyncSession, reference_date: date | None = N
|
||||
)
|
||||
for v in visits_result.scalars():
|
||||
companions_names = [user_map.get(str(cid), str(cid)) for cid in (v.companions or [])]
|
||||
companions_names.extend(v.companion_names or [])
|
||||
ws1.append([
|
||||
customer_map.get(str(v.customer_id), ""),
|
||||
str(v.visit_date),
|
||||
@@ -127,8 +129,29 @@ async def export_weekly_report(db: AsyncSession, reference_date: date | None = N
|
||||
user_map.get(str(k.manager_id), ""),
|
||||
])
|
||||
|
||||
# ── Sheet 5: 今日纪要 ──
|
||||
ws5 = wb.create_sheet("今日纪要")
|
||||
headers5 = ["日期", "分类", "内容", "时间范围", "填报人"]
|
||||
ws5.append(headers5)
|
||||
for col in range(1, len(headers5) + 1):
|
||||
cell = ws5.cell(row=1, column=col)
|
||||
cell.font = header_font
|
||||
cell.border = thin_border
|
||||
|
||||
notes_result = await db.execute(
|
||||
select(DailyNote).where(DailyNote.note_date >= monday, DailyNote.note_date <= sunday)
|
||||
)
|
||||
for n in notes_result.scalars():
|
||||
ws5.append([
|
||||
str(n.note_date),
|
||||
n.category,
|
||||
n.content,
|
||||
n.time_range,
|
||||
user_map.get(str(n.manager_id), ""),
|
||||
])
|
||||
|
||||
# Adjust column widths
|
||||
for ws in [ws1, ws2, ws3, ws4]:
|
||||
for ws in [ws1, ws2, ws3, ws4, ws5]:
|
||||
for col_cells in ws.columns:
|
||||
max_length = max((len(str(cell.value or "")) for cell in col_cells), default=10)
|
||||
ws.column_dimensions[col_cells[0].column_letter].width = min(max_length + 4, 50)
|
||||
|
||||
@@ -1,26 +1,142 @@
|
||||
import io
|
||||
import uuid
|
||||
import re
|
||||
from datetime import datetime, date
|
||||
from typing import Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
import openpyxl
|
||||
from sqlalchemy import select, update
|
||||
from app.models.customer import Customer
|
||||
from app.models.customer_assignment import CustomerAssignment
|
||||
from app.models.visit import Visit
|
||||
from app.models.work_plan import WorkPlan
|
||||
from app.models.mini_business import MiniBusiness
|
||||
from app.models.key_visit import KeyVisit
|
||||
from app.models.daily_note import DailyNote
|
||||
from app.models.user import User
|
||||
|
||||
import openpyxl
|
||||
|
||||
# Companion name separators: Chinese comma, English comma, Chinese semicolon, dun-hao
|
||||
_COMPANION_SEP = re.compile(r'[,,;、;]')
|
||||
|
||||
|
||||
def _parse_companions(raw: str, user_map: dict[str, uuid.UUID]) -> tuple[list[uuid.UUID], list[str]]:
|
||||
"""Split a companion string into system user IDs and external names.
|
||||
|
||||
Returns (system_ids, external_names).
|
||||
"""
|
||||
if not raw or not raw.strip():
|
||||
return [], []
|
||||
|
||||
parts = [p.strip() for p in _COMPANION_SEP.split(raw) if p.strip()]
|
||||
system_ids: list[uuid.UUID] = []
|
||||
external_names: list[str] = []
|
||||
|
||||
for name in parts:
|
||||
uid = user_map.get(name)
|
||||
if uid:
|
||||
system_ids.append(uid)
|
||||
else:
|
||||
external_names.append(name)
|
||||
|
||||
return system_ids, external_names
|
||||
|
||||
|
||||
def _fuzzy_match_customer(
|
||||
cust_name: str,
|
||||
customer_map: dict[str, uuid.UUID],
|
||||
) -> uuid.UUID | None:
|
||||
"""Try to match a customer name with fuzzy rules.
|
||||
|
||||
Rules (in order):
|
||||
1. Exact match (already handled before calling this)
|
||||
2. Normalize whitespace → exact match
|
||||
3. One name fully contains the other
|
||||
"""
|
||||
norm = cust_name.replace(' ', '').replace(' ', '')
|
||||
# Rule 2: whitespace-normalized match
|
||||
for existing_name, cid in customer_map.items():
|
||||
existing_norm = existing_name.replace(' ', '').replace(' ', '')
|
||||
if norm == existing_norm:
|
||||
return cid
|
||||
|
||||
# Rule 3: containment (longer name contains shorter)
|
||||
for existing_name, cid in customer_map.items():
|
||||
if len(norm) >= 4 and len(existing_name.replace(' ', '').replace(' ', '')) >= 4:
|
||||
if norm in existing_name.replace(' ', '').replace(' ', '') or \
|
||||
existing_name.replace(' ', '').replace(' ', '') in norm:
|
||||
return cid
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uuid.UUID) -> dict:
|
||||
"""Parse old weekly report Excel and import data. Returns summary stats."""
|
||||
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True)
|
||||
stats = {"visits": 0, "work_plans": 0, "mini_business": 0, "key_visits": 0, "skipped": 0, "skip_reasons": []}
|
||||
"""Parse old weekly report Excel and import data. Returns summary stats.
|
||||
|
||||
# Resolve customer name -> id cache
|
||||
Features:
|
||||
- Companion parsing: split by comma/dun-hao → system users + external names
|
||||
- Auto-create customers: if customer not found, create from Excel data
|
||||
- Fuzzy name matching: whitespace normalization + containment
|
||||
- Daily notes import (Sheet 5)
|
||||
"""
|
||||
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True)
|
||||
stats = {
|
||||
"visits": 0, "work_plans": 0, "mini_business": 0, "key_visits": 0,
|
||||
"daily_notes": 0, "skipped": 0, "skip_reasons": [],
|
||||
"customers_created": 0, "customers_created_names": [],
|
||||
"external_companions": 0, "name_corrections": [],
|
||||
}
|
||||
|
||||
# ── Pre-fetch lookups ──
|
||||
customers_result = await db.execute(select(Customer.id, Customer.name))
|
||||
customer_map = {c.name: c.id for c in customers_result.all()}
|
||||
|
||||
users_result = await db.execute(select(User.id, User.name))
|
||||
user_map = {u.name: u.id for u in users_result.all()}
|
||||
|
||||
# Customer → assigned primary manager lookup (not from Excel)
|
||||
assign_result = await db.execute(
|
||||
select(CustomerAssignment.customer_id, CustomerAssignment.manager_id)
|
||||
.where(CustomerAssignment.role == "primary")
|
||||
)
|
||||
customer_manager_map: dict[uuid.UUID, uuid.UUID] = {}
|
||||
for cid, mid in assign_result.all():
|
||||
if cid not in customer_manager_map: # first primary wins
|
||||
customer_manager_map[cid] = mid
|
||||
|
||||
def get_assigned_manager(customer_id: uuid.UUID) -> uuid.UUID:
|
||||
"""Return the customer's assigned primary manager, or the importer as fallback."""
|
||||
return customer_manager_map.get(customer_id, manager_id)
|
||||
|
||||
# Helper: resolve or create customer
|
||||
async def resolve_customer(cust_name: str, mgr_name: str = "") -> tuple[uuid.UUID | None, str]:
|
||||
"""Resolve customer by name. Auto-creates if not found. Returns (id, note)."""
|
||||
if not cust_name or not cust_name.strip():
|
||||
return None, ""
|
||||
|
||||
cust_name = cust_name.strip()
|
||||
cid = customer_map.get(cust_name)
|
||||
if cid:
|
||||
return cid, ""
|
||||
|
||||
# Fuzzy match
|
||||
fuzzy_id = _fuzzy_match_customer(cust_name, customer_map)
|
||||
if fuzzy_id:
|
||||
real_name = next((n for n, i in customer_map.items() if i == fuzzy_id), cust_name)
|
||||
stats["name_corrections"].append(f"「{cust_name}」→「{real_name}」")
|
||||
customer_map[cust_name] = fuzzy_id # cache for future rows
|
||||
return fuzzy_id, ""
|
||||
|
||||
# Auto-create customer (no manager assignment — leave unassigned)
|
||||
customer = Customer(name=cust_name, created_by=manager_id)
|
||||
db.add(customer)
|
||||
await db.flush()
|
||||
customer_map[cust_name] = customer.id
|
||||
stats["customers_created"] += 1
|
||||
stats["customers_created_names"].append(cust_name)
|
||||
|
||||
return customer.id, ""
|
||||
|
||||
# ── Parse Sheet 1: 每日拜访记录 ──
|
||||
if "每日拜访记录" in wb.sheetnames:
|
||||
ws = wb["每日拜访记录"]
|
||||
@@ -28,28 +144,36 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui
|
||||
if not row[0]:
|
||||
continue
|
||||
try:
|
||||
cust_name, visit_date_str, visit_method, time_range, visitor_name, visitor_phone, content, demand, companions_str, mgr_str = \
|
||||
row[0], str(row[1]) if row[1] else str(date.today()), \
|
||||
str(row[2]) if row[2] else "上门", str(row[3]) if row[3] else "", \
|
||||
str(row[4]) if row[4] else "", str(row[5]) if row[5] else "", \
|
||||
str(row[6]) if row[6] else "", str(row[7]) if row[7] else "", \
|
||||
str(row[8]) if row[8] else "", str(row[9]) if row[9] else ""
|
||||
cust_name, visit_date_str, visit_method, time_range, visitor_name, visitor_phone, \
|
||||
content, demand, companions_str, mgr_str = (
|
||||
row[0], str(row[1]) if row[1] else str(date.today()),
|
||||
str(row[2]) if row[2] else "上门", str(row[3]) if row[3] else "",
|
||||
str(row[4]) if row[4] else "", str(row[5]) if row[5] else "",
|
||||
str(row[6]) if row[6] else "", str(row[7]) if row[7] else "",
|
||||
str(row[8]) if row[8] else "", str(row[9]) if row[9] else "",
|
||||
)
|
||||
|
||||
customer_id = customer_map.get(cust_name)
|
||||
# Resolve customer (auto-create if needed)
|
||||
customer_id, _ = await resolve_customer(cust_name, str(row[9]) if row[9] else "")
|
||||
if not customer_id:
|
||||
stats["skipped"] += 1
|
||||
stats["skip_reasons"].append(f"客户「{cust_name}」不存在,跳过")
|
||||
stats["skip_reasons"].append(f"客户名称为空,跳过")
|
||||
continue
|
||||
|
||||
# Parse visit date
|
||||
try:
|
||||
visit_date = datetime.strptime(visit_date_str[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
visit_date = date.today()
|
||||
|
||||
# Use customer's assigned primary manager (not Excel column)
|
||||
visit_manager_id = get_assigned_manager(customer_id)
|
||||
|
||||
# Check duplicate: same date + same manager + same customer
|
||||
existing = await db.execute(
|
||||
select(Visit).where(
|
||||
Visit.visit_date == visit_date,
|
||||
Visit.manager_id == manager_id,
|
||||
Visit.manager_id == visit_manager_id,
|
||||
Visit.customer_id == customer_id,
|
||||
)
|
||||
)
|
||||
@@ -58,6 +182,11 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui
|
||||
stats["skip_reasons"].append(f"重复:{cust_name} {visit_date} 已存在")
|
||||
continue
|
||||
|
||||
# Parse companions into system users + external names
|
||||
sys_companions, ext_names = _parse_companions(str(companions_str), user_map)
|
||||
if ext_names:
|
||||
stats["external_companions"] += len(ext_names)
|
||||
|
||||
visit = Visit(
|
||||
customer_id=customer_id,
|
||||
visit_date=visit_date,
|
||||
@@ -67,15 +196,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
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Chinese holiday data service — fetches from apisbo.com and caches to system_config."""
|
||||
|
||||
import json
|
||||
import httpx
|
||||
from datetime import date
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.system_config import SystemConfig
|
||||
|
||||
|
||||
HOLIDAY_API = "https://api.apisbo.com/holidays/year"
|
||||
|
||||
|
||||
async def _get_config(db: AsyncSession, key: str) -> str | None:
|
||||
row = await db.get(SystemConfig, key)
|
||||
return row.value if row else None
|
||||
|
||||
|
||||
async def _set_config(db: AsyncSession, key: str, value: str):
|
||||
row = await db.get(SystemConfig, key)
|
||||
if row:
|
||||
row.value = value
|
||||
else:
|
||||
db.add(SystemConfig(key=key, value=value))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def refresh_holidays(db: AsyncSession, year: int | None = None) -> dict:
|
||||
"""Fetch Chinese holidays for a year from apisbo.com and cache to DB.
|
||||
|
||||
Caches two config keys:
|
||||
- holidays: comma-separated rest-day dates (holidays + weekends already handled by weekday check)
|
||||
We only store holiday dates here, since weekends are auto-detected.
|
||||
- workdays: comma-separated makeup workday dates (调休, when Sat/Sun becomes a workday)
|
||||
|
||||
Returns dict with counts.
|
||||
"""
|
||||
year = year or date.today().year
|
||||
|
||||
# Fetch current year + next year on first load
|
||||
years_to_fetch = {year}
|
||||
existing = await _get_config(db, f"holidays_{year}")
|
||||
if not existing:
|
||||
years_to_fetch.add(year + 1)
|
||||
|
||||
all_holidays: list[str] = []
|
||||
all_workdays: list[str] = []
|
||||
|
||||
for y in years_to_fetch:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(f"{HOLIDAY_API}/{y}")
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
|
||||
if result.get("code") != 0:
|
||||
continue
|
||||
|
||||
holidays = []
|
||||
workdays = []
|
||||
for item in result.get("data", []):
|
||||
d = item["date"]
|
||||
if item["type"] == "holiday" and date.fromisoformat(d).weekday() < 5:
|
||||
# Only cache weekday-holidays (weekend holidays are already skipped)
|
||||
holidays.append(d)
|
||||
elif item["type"] == "workday":
|
||||
# Makeup workday — Saturday/Sunday that becomes a workday
|
||||
workdays.append(d)
|
||||
|
||||
# Store per-year for reference, and accumulate for runtime use
|
||||
await _set_config(db, f"holidays_{y}", ",".join(holidays))
|
||||
await _set_config(db, f"workdays_{y}", ",".join(workdays))
|
||||
all_holidays.extend(holidays)
|
||||
all_workdays.extend(workdays)
|
||||
|
||||
except Exception:
|
||||
# API unavailable — fall back to existing cached data
|
||||
cached_h = await _get_config(db, f"holidays_{y}")
|
||||
if cached_h:
|
||||
all_holidays.extend([d for d in cached_h.split(",") if d.strip()])
|
||||
cached_w = await _get_config(db, f"workdays_{y}")
|
||||
if cached_w:
|
||||
all_workdays.extend([d for d in cached_w.split(",") if d.strip()])
|
||||
|
||||
# Write runtime config keys (used by is_working_day)
|
||||
if all_holidays or all_workdays:
|
||||
await _set_config(db, "holidays", ",".join(sorted(all_holidays)))
|
||||
await _set_config(db, "workdays", ",".join(sorted(all_workdays)))
|
||||
|
||||
return {
|
||||
"holidays": len(all_holidays),
|
||||
"workdays": len(all_workdays),
|
||||
"years": sorted(years_to_fetch),
|
||||
}
|
||||
|
||||
|
||||
async def load_holiday_sets(db: AsyncSession) -> tuple[set[date], set[date]]:
|
||||
"""Load holiday and workday date sets from cached config.
|
||||
|
||||
Returns (holidays_set, workdays_set).
|
||||
- holidays_set: dates that are rest days (weekday holidays)
|
||||
- workdays_set: dates that are workdays despite being weekends (调休)
|
||||
"""
|
||||
holidays: set[date] = set()
|
||||
workdays: set[date] = set()
|
||||
|
||||
# Try runtime config first
|
||||
h_val = await _get_config(db, "holidays")
|
||||
if h_val:
|
||||
for s in h_val.split(","):
|
||||
s = s.strip()
|
||||
if s:
|
||||
try:
|
||||
holidays.add(date.fromisoformat(s))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
w_val = await _get_config(db, "workdays")
|
||||
if w_val:
|
||||
for s in w_val.split(","):
|
||||
s = s.strip()
|
||||
if s:
|
||||
try:
|
||||
workdays.add(date.fromisoformat(s))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# If no data cached yet, try to refresh
|
||||
if not holidays and not workdays:
|
||||
try:
|
||||
await refresh_holidays(db)
|
||||
# Reload after refresh
|
||||
return await load_holiday_sets(db)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return holidays, workdays
|
||||
@@ -0,0 +1,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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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)."""
|
||||
try:
|
||||
token = await self._get_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_token={token}&code={code}"
|
||||
url = f"{settings.WECOM_API_BASE}/cgi-bin/auth/getuserinfo?access_token={token}&code={code}"
|
||||
async with httpx.AsyncClient() as client:
|
||||
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}"
|
||||
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.post(url, json=body, timeout=10)
|
||||
resp = await client.get(url, timeout=10)
|
||||
data = resp.json()
|
||||
if data.get("errcode") == 0:
|
||||
return data
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def delete_menu(self) -> bool:
|
||||
"""Delete the app's custom menu."""
|
||||
if not settings.WECOM_AGENT_ID:
|
||||
return False
|
||||
try:
|
||||
token = await self._get_token()
|
||||
url = f"{settings.WECOM_API_BASE}/cgi-bin/menu/delete?access_token={token}&agentid={settings.WECOM_AGENT_ID}"
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url, timeout=10)
|
||||
data = resp.json()
|
||||
return data.get("errcode") == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
wecom_client = WecomClient()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -5,22 +5,19 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Vue app
|
||||
# Immutable hashed static assets (Vite content-hashed filenames)
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Health check
|
||||
location /health {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
return 200 "ok";
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+662
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"dom-to-image-more": "^3.10.0",
|
||||
"element-plus": "^2.9.1",
|
||||
"pinia": "^2.3.0",
|
||||
"vue": "^3.5.13",
|
||||
@@ -21,6 +22,8 @@
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.6.0",
|
||||
"unplugin-element-plus": "^0.11.2",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^6.0.5",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
@@ -580,6 +583,17 @@
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/remapping": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
@@ -645,6 +659,48 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/kit": {
|
||||
"version": "4.4.8",
|
||||
"resolved": "https://registry.npmmirror.com/@nuxt/kit/-/kit-4.4.8.tgz",
|
||||
"integrity": "sha512-ZUlZ5iYfyfJFDPluhn6ZxFWcsuxWbLnZBc8w3MAROcQ4lYfZ+qFpALBLSNlpc0zhOa++33EE+5PEbOAdVIY+dw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"c12": "^3.3.4",
|
||||
"consola": "^3.4.2",
|
||||
"defu": "^6.1.7",
|
||||
"destr": "^2.0.5",
|
||||
"errx": "^0.1.0",
|
||||
"exsolve": "^1.0.8",
|
||||
"ignore": "^7.0.5",
|
||||
"jiti": "^2.7.0",
|
||||
"klona": "^2.0.6",
|
||||
"mlly": "^1.8.2",
|
||||
"ohash": "^2.0.11",
|
||||
"pathe": "^2.0.3",
|
||||
"pkg-types": "^2.3.1",
|
||||
"rc9": "^3.0.1",
|
||||
"scule": "^1.3.0",
|
||||
"semver": "^7.8.1",
|
||||
"tinyglobby": "^0.2.17",
|
||||
"ufo": "^1.6.4",
|
||||
"unctx": "^2.5.0",
|
||||
"untyped": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxt/kit/node_modules/jiti": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz",
|
||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"name": "@sxzz/popperjs-es",
|
||||
"version": "2.11.8",
|
||||
@@ -1270,6 +1326,19 @@
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.17.0",
|
||||
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.17.0.tgz",
|
||||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
|
||||
@@ -1481,6 +1550,75 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/c12": {
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmmirror.com/c12/-/c12-3.3.4.tgz",
|
||||
"integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^5.0.0",
|
||||
"confbox": "^0.2.4",
|
||||
"defu": "^6.1.6",
|
||||
"dotenv": "^17.3.1",
|
||||
"exsolve": "^1.0.8",
|
||||
"giget": "^3.2.0",
|
||||
"jiti": "^2.6.1",
|
||||
"ohash": "^2.0.11",
|
||||
"pathe": "^2.0.3",
|
||||
"perfect-debounce": "^2.1.0",
|
||||
"pkg-types": "^2.3.0",
|
||||
"rc9": "^3.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"magicast": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"magicast": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/c12/node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/c12/node_modules/jiti": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz",
|
||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/c12/node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz",
|
||||
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@@ -1563,6 +1701,16 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/citty": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/citty/-/citty-0.1.6.tgz",
|
||||
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"consola": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -1585,6 +1733,23 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/confbox": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.2.4.tgz",
|
||||
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/consola": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmmirror.com/consola/-/consola-3.4.2.tgz",
|
||||
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -1634,6 +1799,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/defu": {
|
||||
"version": "6.1.7",
|
||||
"resolved": "https://registry.npmmirror.com/defu/-/defu-6.1.7.tgz",
|
||||
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -1643,6 +1815,13 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/destr": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/destr/-/destr-2.0.5.tgz",
|
||||
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/didyoumean": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
@@ -1657,6 +1836,25 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-to-image-more": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmmirror.com/dom-to-image-more/-/dom-to-image-more-3.10.0.tgz",
|
||||
"integrity": "sha512-APrFEimSmH4phJKs8DlURuSr3BFwDqNi62l14bx73Wrhx0OhE4dd0qzZR+4E3sH8PnHxvax1LOrosC9oXRFA5A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-17.4.2.tgz",
|
||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -1716,6 +1914,13 @@
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/errx": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/errx/-/errx-0.1.0.tgz",
|
||||
"integrity": "sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
@@ -1734,6 +1939,13 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.2.0.tgz",
|
||||
"integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
@@ -1813,12 +2025,32 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
||||
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/exsolve": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.1.0.tgz",
|
||||
"integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
@@ -2001,6 +2233,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/giget": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/giget/-/giget-3.3.0.tgz",
|
||||
"integrity": "sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"giget": "dist/cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@@ -2088,6 +2330,16 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -2160,6 +2412,23 @@
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/klona": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/klona/-/klona-2.0.6.tgz",
|
||||
"integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/knitwork": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/knitwork/-/knitwork-1.3.0.tgz",
|
||||
"integrity": "sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -2180,6 +2449,24 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/local-pkg": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.2.1.tgz",
|
||||
"integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mlly": "^1.7.4",
|
||||
"pkg-types": "^2.3.0",
|
||||
"quansync": "^0.2.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
|
||||
@@ -2301,6 +2588,38 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/mlly": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.2.tgz",
|
||||
"integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.16.0",
|
||||
"pathe": "^2.0.3",
|
||||
"pkg-types": "^1.3.1",
|
||||
"ufo": "^1.6.3"
|
||||
}
|
||||
},
|
||||
"node_modules/mlly/node_modules/confbox": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz",
|
||||
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mlly/node_modules/pkg-types": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz",
|
||||
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.1.8",
|
||||
"mlly": "^1.7.4",
|
||||
"pathe": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
|
||||
@@ -2390,6 +2709,27 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/obug/-/obug-2.1.3.tgz",
|
||||
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ohash": {
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmmirror.com/ohash/-/ohash-2.0.11.tgz",
|
||||
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-browserify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||
@@ -2404,6 +2744,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/perfect-debounce": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
|
||||
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -2465,6 +2819,18 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/pkg-types": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.3.1.tgz",
|
||||
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.2.4",
|
||||
"exsolve": "^1.0.8",
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",
|
||||
@@ -2650,6 +3016,23 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz",
|
||||
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -2671,6 +3054,17 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rc9": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/rc9/-/rc9-3.0.1.tgz",
|
||||
"integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"defu": "^6.1.6",
|
||||
"destr": "^2.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -2740,6 +3134,22 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown-string": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/rolldown-string/-/rolldown-string-0.2.1.tgz",
|
||||
"integrity": "sha512-7H8oH5A8+L96pbBTPCt/rZrwayEhZY5/ejhdk9nRODH32H1v7+bfkaCr+kS15DcGQ7VC1HcWdQVNABFYgrMOzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz",
|
||||
@@ -2809,6 +3219,26 @@
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/scule": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz",
|
||||
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -2980,6 +3410,231 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/ufo": {
|
||||
"version": "1.6.4",
|
||||
"resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.4.tgz",
|
||||
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unctx": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/unctx/-/unctx-2.5.0.tgz",
|
||||
"integrity": "sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.15.0",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21",
|
||||
"unplugin": "^2.3.11"
|
||||
}
|
||||
},
|
||||
"node_modules/unctx/node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin": {
|
||||
"version": "2.3.11",
|
||||
"resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-2.3.11.tgz",
|
||||
"integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"acorn": "^8.15.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"webpack-virtual-modules": "^0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-element-plus": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmmirror.com/unplugin-element-plus/-/unplugin-element-plus-0.11.2.tgz",
|
||||
"integrity": "sha512-jr88ePpv43h8cCmVW0SqM73sTD+g1n9Rmy4uMbTh+pSmceH9ZdKteWX9f+twC4aDlP3svdZuKMqLoUNBT2V6Tg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nuxt/kit": "^4.2.2",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"rolldown-string": "^0.2.1",
|
||||
"unplugin": "^2.3.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-utils": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/unplugin-utils/-/unplugin-utils-0.3.1.tgz",
|
||||
"integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-vue-components": {
|
||||
"version": "32.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/unplugin-vue-components/-/unplugin-vue-components-32.1.0.tgz",
|
||||
"integrity": "sha512-YiUkSxuRjab18XFOrX5VsIxXzccrfmHVGsGeJgSgklb829DQmCy9E4vvDUE4tuvZZdxyFJZX0Oc4TPnnxiiMyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^5.0.0",
|
||||
"local-pkg": "^1.2.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"mlly": "^1.8.2",
|
||||
"obug": "^2.1.1",
|
||||
"picomatch": "^4.0.4",
|
||||
"tinyglobby": "^0.2.16",
|
||||
"unplugin": "^3.0.0",
|
||||
"unplugin-utils": "^0.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nuxt/kit": "^3.2.2 || ^4.0.0",
|
||||
"vue": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@nuxt/kit": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-vue-components/node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-vue-components/node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz",
|
||||
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin-vue-components/node_modules/unplugin": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-3.3.0.tgz",
|
||||
"integrity": "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"picomatch": "^4.0.4",
|
||||
"webpack-virtual-modules": "^0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@farmfe/core": "*",
|
||||
"@rspack/core": "*",
|
||||
"bun-types-no-globals": "*",
|
||||
"esbuild": "*",
|
||||
"rolldown": "*",
|
||||
"rollup": "*",
|
||||
"unloader": "*",
|
||||
"vite": "*",
|
||||
"webpack": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@farmfe/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@rspack/core": {
|
||||
"optional": true
|
||||
},
|
||||
"bun-types-no-globals": {
|
||||
"optional": true
|
||||
},
|
||||
"esbuild": {
|
||||
"optional": true
|
||||
},
|
||||
"rolldown": {
|
||||
"optional": true
|
||||
},
|
||||
"rollup": {
|
||||
"optional": true
|
||||
},
|
||||
"unloader": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
},
|
||||
"webpack": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/untyped": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/untyped/-/untyped-2.0.0.tgz",
|
||||
"integrity": "sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"citty": "^0.1.6",
|
||||
"defu": "^6.1.4",
|
||||
"jiti": "^2.4.2",
|
||||
"knitwork": "^1.2.0",
|
||||
"scule": "^1.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"untyped": "dist/cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/untyped/node_modules/jiti": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz",
|
||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
@@ -3184,6 +3839,13 @@
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-virtual-modules": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"dom-to-image-more": "^3.10.0",
|
||||
"element-plus": "^2.9.1",
|
||||
"pinia": "^2.3.0",
|
||||
"vue": "^3.5.13",
|
||||
@@ -22,6 +23,8 @@
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.6.0",
|
||||
"unplugin-element-plus": "^0.11.2",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^6.0.5",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 989 B |
+199
-10
@@ -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>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import api from './index'
|
||||
|
||||
export const aiApi = {
|
||||
getSummary(params?: { reference_date?: string; period?: string }) {
|
||||
return api.get('/ai/summary', { params })
|
||||
},
|
||||
generateSummary(params?: { reference_date?: string; period?: string }) {
|
||||
return api.post('/ai/summary', null, { params })
|
||||
},
|
||||
}
|
||||
@@ -10,4 +10,7 @@ export const dashboardApi = {
|
||||
getWeeklyReport(params?: any) {
|
||||
return api.get('/dashboard/weekly-report', { params })
|
||||
},
|
||||
getLightBoard(params?: any) {
|
||||
return api.get('/dashboard/light-board', { params })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import api from './index'
|
||||
|
||||
export const leavesApi = {
|
||||
list(params?: Record<string, any>) {
|
||||
return api.get('/leaves/', { params })
|
||||
},
|
||||
create(data: Record<string, any>) {
|
||||
return api.post('/leaves/', data)
|
||||
},
|
||||
update(id: string, data: Record<string, any>) {
|
||||
return api.put(`/leaves/${id}`, data)
|
||||
},
|
||||
delete(id: string) {
|
||||
return api.delete(`/leaves/${id}`)
|
||||
},
|
||||
overview(params?: Record<string, any>) {
|
||||
return api.get('/leaves/overview', { params })
|
||||
},
|
||||
}
|
||||
Vendored
+53
@@ -0,0 +1,53 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
DesktopLayout: typeof import('./components/DesktopLayout.vue')['default']
|
||||
EditLogPanel: typeof import('./components/EditLogPanel.vue')['default']
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElLink: typeof import('element-plus/es')['ElLink']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
|
||||
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
ImagePreview: typeof import('./components/ImagePreview.vue')['default']
|
||||
MobileLayout: typeof import('./components/MobileLayout.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,12 @@
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useThemeStore, 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;
|
||||
|
||||
@@ -125,14 +125,14 @@ function labelFor(field: string) {
|
||||
}
|
||||
|
||||
.log-editor {
|
||||
font-family: 'ZCOOL XiaoWei', STSong, serif;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 13px;
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
@@ -266,7 +266,7 @@ const imgStyle = computed(() => {
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
cursor: pointer;
|
||||
font-family: 'Noto Serif SC', STSong, serif;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
@@ -287,7 +287,7 @@ const imgStyle = computed(() => {
|
||||
}
|
||||
|
||||
.fit-label {
|
||||
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* v-column-resize directive — adds drag-to-resize handles to el-table column headers.
|
||||
*
|
||||
* Usage: <el-table v-column-resize ...>
|
||||
*
|
||||
* Holders drag the right edge of any column header (except the last) to resize.
|
||||
* Works with Element Plus's fixed table-layout by updating <col> widths in
|
||||
* every colgroup (header + body tables stay in sync).
|
||||
*/
|
||||
|
||||
import type { Directive, DirectiveBinding } from 'vue'
|
||||
|
||||
interface DragState {
|
||||
startX: number
|
||||
startWidth: number
|
||||
th: HTMLElement
|
||||
tableWrapper: HTMLElement
|
||||
colIndex: number
|
||||
}
|
||||
|
||||
const drag: DragState = {
|
||||
startX: 0,
|
||||
startWidth: 0,
|
||||
th: null!,
|
||||
tableWrapper: null!,
|
||||
colIndex: -1,
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────
|
||||
|
||||
function getColIndex(th: HTMLElement): number {
|
||||
const cells = Array.from(th.parentElement?.children || [])
|
||||
return cells.indexOf(th)
|
||||
}
|
||||
|
||||
/** All <col> elements across every colgroup inside the wrapper, by col index */
|
||||
function allCols(wrapper: HTMLElement, idx: number): HTMLElement[] {
|
||||
const groups = wrapper.querySelectorAll('colgroup')
|
||||
const result: HTMLElement[] = []
|
||||
groups.forEach((g) => {
|
||||
const col = g.children[idx] as HTMLElement | undefined
|
||||
if (col) result.push(col)
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
function setColumnWidth(wrapper: HTMLElement, colIndex: number, width: number) {
|
||||
const w = Math.max(40, width) + 'px'
|
||||
for (const col of allCols(wrapper, colIndex)) {
|
||||
col.style.width = w
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event handlers ───────────────────────────────────────
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
const diff = e.clientX - drag.startX
|
||||
setColumnWidth(drag.tableWrapper, drag.colIndex, drag.startWidth + diff)
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
const handle = drag.th.querySelector('.col-resize-handle') as HTMLElement | null
|
||||
if (handle) handle.classList.remove('is-resizing')
|
||||
}
|
||||
|
||||
function onHandleDown(e: MouseEvent, th: HTMLElement, wrapper: HTMLElement) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const idx = getColIndex(th)
|
||||
const cols = allCols(wrapper, idx)
|
||||
const currentWidth = cols[0] ? cols[0].getBoundingClientRect().width : th.offsetWidth
|
||||
|
||||
drag.startX = e.clientX
|
||||
drag.startWidth = currentWidth
|
||||
drag.th = th
|
||||
drag.tableWrapper = wrapper
|
||||
drag.colIndex = idx
|
||||
|
||||
const handle = th.querySelector('.col-resize-handle') as HTMLElement | null
|
||||
if (handle) handle.classList.add('is-resizing')
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}
|
||||
|
||||
// ── Setup / Teardown ─────────────────────────────────────
|
||||
|
||||
function injectHandles(wrapper: HTMLElement) {
|
||||
const headerThs = wrapper.querySelectorAll<HTMLElement>(
|
||||
'.el-table__header-wrapper th:not(.el-table__cell--selection)',
|
||||
)
|
||||
if (headerThs.length === 0) return
|
||||
|
||||
headerThs.forEach((th) => {
|
||||
// Don't add handle to the last column (or to columns that already have one)
|
||||
// Actually, allow all columns except the last to be resizable
|
||||
if (th.querySelector('.col-resize-handle')) return
|
||||
|
||||
const handle = document.createElement('div')
|
||||
handle.className = 'col-resize-handle'
|
||||
handle.addEventListener('mousedown', (e: MouseEvent) => onHandleDown(e, th, wrapper))
|
||||
// Prevent sort trigger on header click when grabbing handle
|
||||
handle.addEventListener('click', (e: Event) => e.stopPropagation())
|
||||
th.appendChild(handle)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Directive definition ─────────────────────────────────
|
||||
|
||||
function findWrapper(el: HTMLElement): HTMLElement | null {
|
||||
return el.querySelector<HTMLElement>('.el-table__inner-wrapper')
|
||||
|| el.querySelector<HTMLElement>('.el-table__header-wrapper')?.closest('.el-table') as HTMLElement
|
||||
|| null
|
||||
}
|
||||
|
||||
export const vColumnResize: Directive<HTMLElement> = {
|
||||
mounted(el: HTMLElement, _binding: DirectiveBinding) {
|
||||
const trySetup = () => {
|
||||
const wrapper = findWrapper(el)
|
||||
if (wrapper) {
|
||||
injectHandles(wrapper)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (!trySetup()) {
|
||||
const timer = setTimeout(() => trySetup(), 200)
|
||||
;(el as any).__colResizeTimer = timer
|
||||
}
|
||||
// Re-inject handles when DOM changes (sort, filter, data reload)
|
||||
const observer = new MutationObserver(() => {
|
||||
const wrapper = findWrapper(el)
|
||||
if (wrapper) injectHandles(wrapper)
|
||||
})
|
||||
observer.observe(el, { childList: true, subtree: true })
|
||||
;(el as any).__colResizeObserver = observer
|
||||
},
|
||||
|
||||
unmounted(el: HTMLElement) {
|
||||
clearTimeout((el as any).__colResizeTimer)
|
||||
const observer = (el as any).__colResizeObserver as MutationObserver | undefined
|
||||
observer?.disconnect()
|
||||
},
|
||||
}
|
||||
|
||||
export default vColumnResize
|
||||
@@ -1,14 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
import './tailwind.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { vColumnResize } from '@/directives/columnResize'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn as any })
|
||||
app.directive('column-resize', vColumnResize)
|
||||
app.mount('#app')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { isMobile } from '@/utils'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -16,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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
@@ -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 0–1. 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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,8 +87,10 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
|
||||
|
||||
<template>
|
||||
<div class="dashboard" v-loading="loading">
|
||||
<!-- ═══ Editorial Page Header ═══ -->
|
||||
<!-- ═══ Page Header ═══ -->
|
||||
<div class="page-head">
|
||||
<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>
|
||||
@@ -77,6 +99,25 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
|
||||
<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 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>
|
||||
|
||||
<!-- ═══ 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>
|
||||
|
||||
<!-- ═══ 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>
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
|
||||
@@ -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()
|
||||
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 res = await api.get('/users/', { params: { role: 'manager' } })
|
||||
managers.value = res.data
|
||||
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 }">
|
||||
<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>
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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,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
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"ownerId": "544116",
|
||||
"slug": "ui-design-guide",
|
||||
"version": "2.24.0",
|
||||
"publishedAt": 1781845782468
|
||||
"version": "2.24.2-beta.1",
|
||||
"publishedAt": 1782199074089
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
Reference in New Issue
Block a user