Compare commits

..

5 Commits

Author SHA1 Message Date
v6ole bf67e0575f feat: 信息架构重组 + 变更追踪 + 图片预览增强
- 信息架构重组: 周报精简为拜访+纪要两个Tab,工作计划/商机/要客独立为侧边栏「工作」分组下的独立页面
- 侧边栏分组: 汇总/工作/管理三层分组,仪表盘四卡可点击跳转
- 变更追踪(edit_log): 5张表新增JSONB edit_log列,POST创建/PUT diff自动记录,编辑弹窗变更时间轴,表格🕐编辑标记
- 图片预览增强: ImagePreview统一组件,支持适应页面/缩放/拖拽平移/滚轮缩放/键盘快捷键
- 修复客户导入500错误(errors变量未初始化)
- 移除工作计划/商机/要客页面冗余编辑按钮

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 08:30:12 +08:00
v6ole aa3fbca710 feat: 历史周报+图片预览+照片管理+导入优化+用户合并
新增:
- 历史周报: Dashboard/周报详情支持周选择器翻看往周,归档只读
- 图片预览: 全屏大图(移动端+PC端),点击遮罩关闭
- PC端照片管理: 编辑时可上传/删除照片
- 客户导入改为更新模式: 重名自动更新信息,显示操作明细
- 导入跳过原因: 旧周报导入也显示每条跳过原因

优化:
- 填报进度权限: 经理只看到自己,支局长/领导看全员
- 客户经理暖灰色hash标签列
- 用户去重合并(4组),数据完整迁移
- CLAUDE.md 更新到最新状态

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 08:30:12 +08:00
v6ole 8926475b20 docs: 更新 CLAUDE.md — 9张表+新增功能+设计决策 2026-06-24 08:30:12 +08:00
v6ole e7af0e15ab feat: 完整功能迭代 — 移动端+PC端全面优化
新增:
- 今日纪要 (6分类+彩色标签+时间选择器)
- 客户经理PC端工作台 (我的数据,5模块CRUD)
- 用户管理页 (支局长修改角色)
- 客户备注/收支费用(金额+单位)/联系人管理
- 拜访人姓名+电话字段
- 客户导入导出/旧周报导入模板下载
- 序号列+分页(25/50/100)

修复/优化:
- Hash路由→HTML5 History, Casdoor回调正常
- 时区统一为Asia/Shanghai (today_cst)
- 数据库懒加载→selectinload预加载
- 日期解析兼容ISO datetime字符串
- 文件上传定位修复 (position:relative)
- 表单button type='button'防止误提交
- 分管领导权限(只读客户档案,不可编辑)
- 侧边栏折叠+SVG图标
- 拜访方式/紧急度统一chip风格
- 时间范围统一为el-time-picker(is-range)
- 全局CSS设计变量+box-sizing修复滚动条

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 08:30:12 +08:00
v6ole a1886074dd 企迹(qiji) 政企周报管理系统 — v0.1
后端: FastAPI + SQLAlchemy 2.0 (async) + Alembic + MinIO + Casdoor + 企微
前端: Vue 3 + Vite + TypeScript + Element Plus + Pinia

功能清单:
- 8 张数据表自动建表 / Casdoor OIDC 登录 / 企微静默登录
- 双布局: 移动端(填报) + PC端(汇总管理)
- 拜访记录 CRUD + MinIO 照片直传 + 缩略图预览 + 同访人草稿
- 今日纪要 (6 分类) / 工作计划 / 小微商机 / 要客拜访 CRUD
- 客户档案: 备注/收支费用/联系人/归属分配/批量转移
- 客户导入导出 + 模板下载 + 搜索/分页/筛选
- 仪表盘: 四卡统计 + 填报进度 (拜访+纪要双维度)
- 周报详情: 五 Tab + 按人/客户筛选 + 时间轴
- 用户管理 / 客户经理 PC 端工作台
- 企微: 催办/公告/定时提醒 / 时区修正
- Docker 部署配置

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 08:30:12 +08:00
112 changed files with 14232 additions and 0 deletions
+224
View File
@@ -0,0 +1,224 @@
# 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
```
## 数据模型 (9 张 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) |
## 当前进度
### 已完成 ✅
- [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] **操作精简** — 工作计划/商机/要客页面移除冗余"编辑"按钮(点击客户名已可编辑)
### 待完善
- [ ] 实际对接企业微信(需填写 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://10.10.10.14:5173` |
| PostgreSQL | ✅ `10.10.10.14:5432/qiji` |
| MinIO | ✅ `10.10.10.13:17051`bucket `qiji-photos` |
| Casdoor | ✅ `10.10.10.14:18000`,登录/回调正常 |
| 企微推送 | ⚠️ 待填写有效 Token/AESKey 后测试 |
| `docker-compose up` | ⚠️ 需要本地 PostgreSQL + MinIO 实例 |
## 启动命令
### 第一步:配置环境变量
```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 | ⚠️ 企微功能需要 |
| `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. **仪表盘跳转**: 四张统计卡片可点击跳转到对应的周报/工作计划/商机/要客页面
+41
View File
@@ -0,0 +1,41 @@
# ============================================
# 企迹 (qiji) — 环境变量配置文件
# 复制此文件为 .env 并填入真实值
# ============================================
# ── 应用 ──
APP_NAME=企迹-政企周报管理系统
DEBUG=true
SECRET_KEY=change-me-to-a-random-string-in-production
# ── PostgreSQL (已有基础设施,填写实际连接信息) ──
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/qiji
# ── JWT ──
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=480
# ── Casdoor (已有,填写实际部署地址和密钥) ──
CASDOOR_ENDPOINT=http://your-casdoor-server:8000
CASDOOR_CLIENT_ID=your-client-id
CASDOOR_CLIENT_SECRET=your-client-secret
# CASDOOR_CERTIFICATE= # 如果 Casdoor 使用自签名证书,填证书内容
CASDOOR_ORG_NAME=qiji
CASDOOR_APPLICATION=qiji-weekly-report
# ── MinIO (已有,填写实际部署地址和密钥) ──
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=qiji-photos
MINIO_SECURE=false
# ── 企业微信 (需在企微管理后台创建自建应用后获取) ──
WECOM_CORP_ID=your-corp-id
WECOM_AGENT_ID=your-agent-id
WECOM_SECRET=your-app-secret
WECOM_TOKEN=your-token
WECOM_ENCODING_AES_KEY=your-encoding-aes-key
# ── CORS (前端地址) ──
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
+8
View File
@@ -0,0 +1,8 @@
{
"hash": "e75be2da",
"configHash": "ef9a524a",
"lockfileHash": "19c2bb83",
"browserHash": "dc47218b",
"optimized": {},
"chunks": {}
}
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+12
View File
@@ -0,0 +1,12 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+36
View File
@@ -0,0 +1,36 @@
[alembic]
script_location = alembic
sqlalchemy.url = postgresql://postgres:postgres@localhost:5432/qiji
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+57
View File
@@ -0,0 +1,57 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.database import Base
from app.models import * # noqa: import all models
from app.config import settings
config = context.config
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
View File
+3
View File
@@ -0,0 +1,3 @@
from fastapi import APIRouter
router = APIRouter()
+93
View File
@@ -0,0 +1,93 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas.user import TokenResponse, WecomLoginRequest, CasdoorLoginRequest, WecomBindRequest
from app.services.auth import (
exchange_casdoor_code, get_or_create_user_from_casdoor,
get_user_by_wecom_id, bind_wecom_user, build_token_for_user,
)
from app.services.wecom import wecom_client
from app.config import settings
router = APIRouter(prefix="/auth", tags=["Authentication"])
@router.post("/casdoor-login", response_model=TokenResponse)
async def casdoor_login(req: CasdoorLoginRequest, db: AsyncSession = Depends(get_db)):
"""Standard Casdoor OIDC login — exchange code for userinfo, get or create user, return JWT."""
userinfo = await exchange_casdoor_code(req.code)
if not userinfo:
raise HTTPException(status_code=400, detail="Failed to exchange code with Casdoor")
casdoor_id = userinfo.get("sub") or userinfo.get("id")
if not casdoor_id:
raise HTTPException(status_code=400, detail="Invalid userinfo from Casdoor")
name = userinfo.get("name") or userinfo.get("preferred_username") or casdoor_id
role = userinfo.get("role", "manager")
user = await get_or_create_user_from_casdoor(db, casdoor_id, name, role)
token = build_token_for_user(user)
return TokenResponse(
access_token=token,
user_id=str(user.id),
name=user.name,
role=user.role,
)
@router.post("/wecom-login")
async def wecom_login(req: WecomLoginRequest, db: AsyncSession = Depends(get_db)):
"""WeChat Work silent login — exchange wecom code for userid, find bound user, return JWT."""
userinfo = await wecom_client.get_userinfo_by_code(req.code)
if not userinfo:
raise HTTPException(status_code=400, detail="Failed to exchange wecom code")
wecom_userid = userinfo.get("UserId") or userinfo.get("userid")
if not wecom_userid:
raise HTTPException(status_code=400, detail="Could not get userid from wecom")
user = await get_user_by_wecom_id(db, wecom_userid)
if user:
token = build_token_for_user(user)
return TokenResponse(
access_token=token,
user_id=str(user.id),
name=user.name,
role=user.role,
)
# Not bound yet — return a redirect URL to Casdoor for binding
casdoor_auth_url = (
f"{settings.CASDOOR_ENDPOINT}/login/oauth/authorize"
f"?client_id={settings.CASDOOR_CLIENT_ID}"
f"&response_type=code"
f"&redirect_uri={settings.CORS_ORIGINS[0]}/bind-wecom"
f"&scope=openid+profile"
f"&state={wecom_userid}"
)
return {"need_bind": True, "casdoor_url": casdoor_auth_url, "wecom_userid": wecom_userid}
@router.post("/bind-wecom")
async def bind_wecom(req: WecomBindRequest, db: AsyncSession = Depends(get_db)):
"""Bind Casdoor account with WeChat Work userid after OIDC redirect."""
userinfo = await exchange_casdoor_code(req.casdoor_code)
if not userinfo:
raise HTTPException(status_code=400, detail="Failed to exchange casdoor code")
casdoor_id = userinfo.get("sub") or userinfo.get("id")
wecom_userid = req.wecom_userid or userinfo.get("state", "")
user = await bind_wecom_user(db, casdoor_id, wecom_userid)
if not user:
raise HTTPException(status_code=404, detail="User not found")
token = build_token_for_user(user)
return TokenResponse(
access_token=token,
user_id=str(user.id),
name=user.name,
role=user.role,
)
+532
View File
@@ -0,0 +1,532 @@
import io
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.orm import selectinload
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.user import User
from app.schemas.customer import (
CustomerCreate, CustomerUpdate, CustomerOut, CustomerListOut, CustomerListResponse,
ContactCreate, ContactOut, AssignmentCreate, AssignmentOut, BatchAssignRequest,
)
router = APIRouter(prefix="/customers", tags=["Customers"])
def _split_fee(fee: str) -> tuple[str, str]:
"""Split '5000元/月' into ('5000', '元/月')."""
if not fee:
return ("", "")
for u in ["元/月", "元/年"]:
if fee.endswith(u):
return (fee[:-len(u)].strip(), u)
# Custom unit: separate trailing non-digit+non-space chars
m = __import__('re').match(r'^(.+?)\s*([^\d\s]+)$', fee)
if m:
return (m.group(1).strip(), m.group(2).strip())
return (fee, "")
# ══════ Fixed-path routes (must come before /{customer_id}) ══════
@router.get("/", response_model=CustomerListResponse)
async def list_customers(
search: Optional[str] = Query(None),
industry: Optional[str] = Query(None),
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),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""List customers with filters and pagination. Managers only see their assigned."""
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}%"))
if service:
base_query = base_query.where(Customer.in_use_services.ilike(f"%{service}%"))
if manager_id:
assign_subq = select(CustomerAssignment.customer_id).where(
CustomerAssignment.manager_id == manager_id
)
base_query = base_query.where(Customer.id.in_(assign_subq))
if search:
contact_subq = select(CustomerContact.customer_id).where(
or_(
CustomerContact.name.ilike(f"%{search}%"),
CustomerContact.phone.ilike(f"%{search}%"),
)
)
base_query = base_query.where(or_(
Customer.name.ilike(f"%{search}%"),
Customer.industry.ilike(f"%{search}%"),
Customer.address.ilike(f"%{search}%"),
Customer.id.in_(contact_subq),
))
# Count total
count_query = select(func.count()).select_from(base_query.subquery())
total = (await db.execute(count_query)).scalar() or 0
# Paginate
offset = (page - 1) * page_size
query = base_query.order_by(Customer.name).offset(offset).limit(page_size)
result = await db.execute(query)
items = result.scalars().all()
# Enrich with manager names
if items:
mgr_result = await db.execute(
select(CustomerAssignment.customer_id, User.name)
.join(User, CustomerAssignment.manager_id == User.id)
.where(CustomerAssignment.customer_id.in_([c.id for c in items]), CustomerAssignment.role == "primary")
)
mgr_map = {str(cid): name for cid, name in mgr_result.all()}
for item in items:
item.primary_manager_name = mgr_map.get(str(item.id), None)
return CustomerListResponse(
items=items,
total=total,
page=page,
page_size=page_size,
)
@router.get("/export")
async def export_customers(
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Export all customers (name, industry, address, services, fee, contacts) as Excel."""
from openpyxl import Workbook
result = await db.execute(select(Customer).options(selectinload(Customer.contacts)))
customers = result.scalars().all()
# Build manager lookup
mgr_result = await db.execute(
select(CustomerAssignment.customer_id, User.name)
.join(User, CustomerAssignment.manager_id == User.id)
.where(CustomerAssignment.role == "primary")
)
mgr_map = {str(cid): name for cid, name in mgr_result.all()}
wb = Workbook()
ws = wb.active
ws.title = "客户档案"
ws.append(["单位名称", "所属行业", "单位地址", "在用业务", "收支费用-金额", "收支费用-单位", "客户经理", "备注", "联系人姓名", "联系人电话", "联系人角色"])
for c in customers:
mgr_name = mgr_map.get(str(c.id), "")
amt, unit = _split_fee(c.monthly_fee)
if c.contacts:
for ct in c.contacts:
ws.append([c.name, c.industry, c.address, c.in_use_services, amt, unit, mgr_name, c.remarks or "", ct.name, ct.phone, ct.role_desc])
else:
ws.append([c.name, c.industry, c.address, c.in_use_services, amt, unit, mgr_name, c.remarks or "", "", "", ""])
for col_cells in ws.columns:
ws.column_dimensions[col_cells[0].column_letter].width = 22
output = io.BytesIO()
wb.save(output)
output.seek(0)
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=customers.xlsx"},
)
@router.get("/template")
async def download_import_template():
"""Download a blank customer import template (public, no auth required for download)."""
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "客户档案导入模板"
ws.append(["单位名称*", "所属行业", "单位地址", "在用业务", "收支费用-金额", "收支费用-单位", "客户经理", "备注", "联系人姓名", "联系人电话", "联系人角色"])
ws.append(["XX科技有限公司", "信息技术", "XX市XX路100号", "云桌面、专线", "5000", "元/月", "韦柳柏", "重点客户,季度回访", "张三", "13800000000", "技术负责人"])
for col_cells in ws.columns:
ws.column_dimensions[col_cells[0].column_letter].width = 22
output = io.BytesIO()
wb.save(output)
output.seek(0)
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=customer_import_template.xlsx"},
)
@router.post("/import")
async def import_customers(
file: UploadFile = File(...),
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Import customers from Excel file. Director only."""
import uuid as uuid_mod
content = await file.read()
try:
wb = openpyxl.load_workbook(io.BytesIO(content), data_only=True)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Excel 解析失败: {str(e)}")
ws = wb.active
created, updated, skipped = 0, 0, 0
reasons = []
errors = []
# Build user name → id lookup (all users, not just managers)
user_rows = await db.execute(select(User.name, User.id))
user_map = {name: uid for name, uid in user_rows.all()}
default_user_id = uuid_mod.UUID(current_user["user_id"])
for row_idx, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2):
if not row or not row[0]:
continue
name = str(row[0]).strip() if row[0] else ""
if not name:
continue
industry = str(row[1]).strip() if len(row) > 1 and row[1] else ""
address = str(row[2]).strip() if len(row) > 2 and row[2] else ""
services = str(row[3]).strip() if len(row) > 3 and row[3] else ""
fee_amt = str(row[4]).strip() if len(row) > 4 and row[4] else ""
fee_unit = str(row[5]).strip() if len(row) > 5 and row[5] else ""
fee = (fee_amt + fee_unit).strip() if fee_amt else ""
mgr_name = str(row[6]).strip() if len(row) > 6 and row[6] else ""
remarks = str(row[7]).strip() if len(row) > 7 and row[7] else ""
contact_name = str(row[8]).strip() if len(row) > 8 and row[8] else ""
contact_phone = str(row[9]).strip() if len(row) > 9 and row[9] else ""
contact_role = str(row[10]).strip() if len(row) > 10 and row[10] else ""
assignee_id = user_map.get(mgr_name, default_user_id)
existing_result = await db.execute(select(Customer).where(Customer.name == name))
existing = existing_result.scalar_one_or_none()
try:
if existing:
# Update existing customer
existing.industry = industry or existing.industry
existing.address = address or existing.address
existing.in_use_services = services or existing.in_use_services
existing.monthly_fee = fee or existing.monthly_fee
existing.remarks = remarks or existing.remarks
# Update or create primary assignment if manager changed
if mgr_name:
assign_rows = await db.execute(
select(CustomerAssignment).where(CustomerAssignment.customer_id == existing.id, CustomerAssignment.role == "primary")
)
first_assign = assign_rows.first()
if first_assign:
first_assign[0].manager_id = assignee_id
else:
db.add(CustomerAssignment(customer_id=existing.id, manager_id=assignee_id, role="primary", assigned_by=default_user_id))
updated += 1
reasons.append(f"更新「{name}」的信息")
else:
customer = Customer(name=name, industry=industry, address=address, in_use_services=services, monthly_fee=fee, remarks=remarks, created_by=default_user_id)
db.add(customer)
await db.flush()
if contact_name:
db.add(CustomerContact(customer_id=customer.id, name=contact_name, phone=contact_phone, role_desc=contact_role))
db.add(CustomerAssignment(customer_id=customer.id, manager_id=assignee_id, role="primary", assigned_by=default_user_id))
created += 1
reasons.append(f"新建「{name}")
except Exception as e:
errors.append(f"{row_idx}行({name}): {str(e)}")
await db.commit()
return {"created": created, "updated": updated, "skipped": skipped, "reasons": reasons, "errors": errors}
@router.get("/check-duplicate/{name}")
async def check_duplicate(
name: str,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Check for duplicate customer names before creating."""
result = await db.execute(
select(Customer.id, Customer.name, Customer.industry)
.where(Customer.name.ilike(f"%{name}%"))
.limit(10)
)
matches = [{"id": str(r.id), "name": r.name, "industry": r.industry} for r in result.all()]
return {"matches": matches}
@router.post("/batch-assign")
async def batch_assign(
data: BatchAssignRequest,
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Batch transfer customers to a new manager."""
import uuid as uuid_mod
for cid in data.customer_ids:
result = await db.execute(
select(CustomerAssignment).where(
CustomerAssignment.customer_id == cid,
CustomerAssignment.role == "primary",
)
)
all_rows = result.all()
if all_rows:
# Update the first one, delete any duplicates
first = all_rows[0][0]
first.manager_id = data.manager_id
first.assigned_by = uuid_mod.UUID(current_user["user_id"])
for dup in all_rows[1:]:
await db.delete(dup[0])
else:
db.add(CustomerAssignment(
customer_id=cid, manager_id=data.manager_id,
role="primary", assigned_by=uuid_mod.UUID(current_user["user_id"]),
))
await db.commit()
return {"detail": f"Assigned {len(data.customer_ids)} customers"}
@router.post("/quick-create")
async def quick_create_customer(
name: str = Query(...),
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
"""Quick-create a customer with just a name. Returns the new customer."""
import uuid
customer = Customer(name=name, created_by=uuid.UUID(current_user["user_id"]))
db.add(customer)
await db.flush()
db.add(CustomerAssignment(
customer_id=customer.id, manager_id=uuid.UUID(current_user["user_id"]),
role="primary", assigned_by=uuid.UUID(current_user["user_id"]),
))
await db.commit()
return {"id": str(customer.id), "name": customer.name}
# ══════ Parameterized routes (/{customer_id}) ══════
@router.get("/{customer_id}", response_model=CustomerOut)
async def get_customer(
customer_id: str,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Customer).where(Customer.id == customer_id).options(selectinload(Customer.contacts))
)
customer = result.scalar_one_or_none()
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
# Load primary manager name
from sqlalchemy.orm import selectinload as sl
assign_result = await db.execute(
select(CustomerAssignment, User.name).join(User, CustomerAssignment.manager_id == User.id)
.where(CustomerAssignment.customer_id == customer_id, CustomerAssignment.role == "primary")
)
row = assign_result.first()
manager_name = row[1] if row else None
# Attach to response via a dict
out = {
"id": customer.id, "name": customer.name, "industry": customer.industry,
"address": customer.address, "in_use_services": customer.in_use_services,
"monthly_fee": customer.monthly_fee, "remarks": customer.remarks or "",
"created_by": customer.created_by,
"created_at": customer.created_at, "updated_at": customer.updated_at,
"contacts": customer.contacts, "primary_manager_name": manager_name,
}
return out
@router.post("/", response_model=CustomerOut)
async def create_customer(
data: CustomerCreate,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
if current_user["role"] == "leader":
raise HTTPException(status_code=403, detail="分管领导无法创建客户")
"""Create a new customer with optional contacts and manager assignment."""
import uuid
customer = Customer(
name=data.name, industry=data.industry, address=data.address,
in_use_services=data.in_use_services, monthly_fee=data.monthly_fee,
remarks=data.remarks,
created_by=uuid.UUID(current_user["user_id"]),
)
db.add(customer)
await db.flush()
for contact_data in data.contacts:
if not contact_data.name.strip():
continue
db.add(CustomerContact(
customer_id=customer.id,
name=contact_data.name.strip(),
phone=contact_data.phone.strip(),
role_desc=contact_data.role_desc.strip(),
))
assignee_id = data.assignee_id or uuid.UUID(current_user["user_id"])
db.add(CustomerAssignment(
customer_id=customer.id, manager_id=assignee_id,
role="primary", assigned_by=uuid.UUID(current_user["user_id"]),
))
await db.commit()
result = await db.execute(
select(Customer).where(Customer.id == customer.id).options(selectinload(Customer.contacts))
)
return result.scalar_one()
@router.put("/{customer_id}", response_model=CustomerOut)
async def update_customer(
customer_id: str, data: CustomerUpdate,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
if current_user["role"] == "leader":
raise HTTPException(status_code=403, detail="分管领导无法编辑客户")
result = await db.execute(
select(Customer).where(Customer.id == customer_id).options(selectinload(Customer.contacts))
)
customer = result.scalar_one_or_none()
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
update_data = data.model_dump(exclude_unset=True)
assignee_id = update_data.pop("assignee_id", None) # Handle separately
for key, value in update_data.items():
setattr(customer, key, value)
# Update primary manager assignment if changed (director only)
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"])
else:
db.add(CustomerAssignment(
customer_id=customer.id, manager_id=assignee_id,
role="primary", assigned_by=uuid_mod.UUID(current_user["user_id"]),
))
await db.commit()
result = await db.execute(
select(Customer).where(Customer.id == customer.id).options(selectinload(Customer.contacts))
)
return result.scalar_one()
@router.delete("/{customer_id}")
async def delete_customer(
customer_id: str,
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Customer).where(Customer.id == customer_id))
customer = result.scalar_one_or_none()
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
await db.delete(customer)
await db.commit()
return {"detail": "deleted"}
# ── Contacts ──
@router.post("/{customer_id}/contacts", response_model=ContactOut)
async def add_contact(
customer_id: str, data: ContactCreate,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
contact = CustomerContact(customer_id=customer_id, name=data.name, phone=data.phone, role_desc=data.role_desc)
db.add(contact)
await db.commit()
await db.refresh(contact)
return contact
@router.delete("/{customer_id}/contacts/{contact_id}")
async def delete_contact(
customer_id: str, contact_id: str,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(CustomerContact).where(CustomerContact.id == contact_id, CustomerContact.customer_id == customer_id)
)
contact = result.scalar_one_or_none()
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
await db.delete(contact)
await db.commit()
return {"detail": "deleted"}
# ── Assignments ──
@router.get("/{customer_id}/assignments", response_model=list[AssignmentOut])
async def list_assignments(
customer_id: str,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(CustomerAssignment).where(CustomerAssignment.customer_id == customer_id))
return result.scalars().all()
@router.post("/{customer_id}/assignments", response_model=AssignmentOut)
async def assign_manager(
customer_id: str, data: AssignmentCreate,
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
assignment = CustomerAssignment(
customer_id=customer_id, manager_id=data.manager_id,
role=data.role, assigned_by=current_user["user_id"],
)
db.add(assignment)
await db.commit()
await db.refresh(assignment)
return assignment
+138
View File
@@ -0,0 +1,138 @@
import uuid
from datetime import date
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.middleware.auth import get_current_user, require_any_role
from app.models.daily_note import DailyNote
from app.models.user import User
from app.schemas.daily_note import DailyNoteCreate, DailyNoteUpdate, DailyNoteOut
from app.utils.timezone import today_cst, parse_date
from app.utils.edit_log import compute_diff, append_entry, init_entry
router = APIRouter(prefix="/daily-notes", tags=["DailyNotes"])
async def _enrich(note: DailyNote, db: AsyncSession) -> dict:
mgr = await db.execute(select(User.name).where(User.id == note.manager_id))
return {
"id": note.id, "manager_id": note.manager_id,
"note_date": note.note_date, "category": note.category,
"content": note.content, "time_range": note.time_range,
"edit_log": note.edit_log or [], "created_at": note.created_at,
"updated_at": note.updated_at, "manager_name": mgr.scalar_one_or_none(),
}
@router.get("/")
async def list_notes(
date_from: Optional[str] = Query(None),
date_to: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(DailyNote)
if current_user["role"] == "manager":
query = query.where(DailyNote.manager_id == uuid.UUID(current_user["user_id"]))
if date_from:
query = query.where(DailyNote.note_date >= parse_date(date_from))
if date_to:
query = query.where(DailyNote.note_date <= parse_date(date_to))
query = query.order_by(DailyNote.note_date.desc(), DailyNote.created_at.desc()).limit(100)
result = await db.execute(query)
return [await _enrich(n, db) for n in result.scalars().all()]
@router.get("/today")
async def list_today_notes(
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(DailyNote).where(DailyNote.note_date == today_cst())
if current_user["role"] == "manager":
query = query.where(DailyNote.manager_id == uuid.UUID(current_user["user_id"]))
result = await db.execute(query)
notes = [await _enrich(n, db) for n in result.scalars().all()]
return {"count": len(notes), "notes": notes}
@router.get("/{note_id}")
async def get_note(
note_id: str,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(DailyNote).where(DailyNote.id == note_id))
note = result.scalar_one_or_none()
if not note:
raise HTTPException(status_code=404, detail="Not found")
if current_user["role"] == "manager" and str(note.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
return await _enrich(note, db)
@router.post("/")
async def create_note(
data: DailyNoteCreate,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
note = DailyNote(
manager_id=uuid.UUID(current_user["user_id"]),
note_date=parse_date(data.note_date),
category=data.category,
content=data.content,
time_range=data.time_range,
)
init_entry(note, current_user["name"])
db.add(note)
await db.commit()
await db.refresh(note)
return await _enrich(note, db)
@router.put("/{note_id}")
async def update_note(
note_id: str, data: DailyNoteUpdate,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(DailyNote).where(DailyNote.id == note_id))
note = result.scalar_one_or_none()
if not note:
raise HTTPException(status_code=404, detail="Not found")
if current_user["role"] == "manager" and str(note.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
old_snapshot = {"note_date": str(note.note_date), "category": note.category, "content": note.content, "time_range": note.time_range}
update_data = data.model_dump(exclude_unset=True)
if "note_date" in update_data and update_data["note_date"]:
update_data["note_date"] = parse_date(update_data["note_date"])
for k, v in update_data.items():
setattr(note, k, v)
new_snapshot = {"note_date": str(note.note_date), "category": note.category, "content": note.content, "time_range": note.time_range}
changes = compute_diff(old_snapshot, new_snapshot)
if changes:
append_entry(note, current_user["name"], changes, getattr(data, "edit_reason", None))
await db.commit()
await db.refresh(note)
return await _enrich(note, db)
@router.delete("/{note_id}")
async def delete_note(
note_id: str,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(DailyNote).where(DailyNote.id == note_id))
note = result.scalar_one_or_none()
if not note:
raise HTTPException(status_code=404, detail="Not found")
if current_user["role"] == "manager" and str(note.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
await db.delete(note)
await db.commit()
return {"detail": "deleted"}
+53
View File
@@ -0,0 +1,53 @@
import uuid
from datetime import date
from typing import Optional
from fastapi import APIRouter, Depends, Query
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
router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
@router.get("/stats")
async def dashboard_stats(
reference_date: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get dashboard card statistics. Pass reference_date (YYYY-MM-DD) for historical weeks."""
ref = date.fromisoformat(reference_date) if reference_date else None
stats = await get_dashboard_stats(db, ref)
return stats
@router.get("/progress")
async def reporting_progress(
reference_date: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get per-manager reporting progress. Managers only see themselves."""
ref = date.fromisoformat(reference_date) if reference_date else None
return await get_reporting_progress(db, ref, current_user["user_id"], current_user["role"])
@router.get("/weekly-report")
async def weekly_report(
manager_id: Optional[str] = Query(None),
customer_id: Optional[str] = Query(None),
reference_date: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get full weekly report data. Pass reference_date for historical weeks."""
ref = date.fromisoformat(reference_date) if reference_date else None
return await get_weekly_report(
db=db,
user_id=uuid.UUID(current_user["user_id"]),
role=current_user["role"],
filter_manager_id=uuid.UUID(manager_id) if manager_id else None,
filter_customer_id=uuid.UUID(customer_id) if customer_id else None,
reference_date=ref,
)
+26
View File
@@ -0,0 +1,26 @@
from typing import Optional
from datetime import date
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.auth import get_current_user, require_director_or_leader
from app.services.excel_export import export_weekly_report
router = APIRouter(prefix="/export", tags=["Export"])
@router.get("/weekly-report")
async def download_weekly_report(
reference_date: Optional[str] = Query(None),
current_user: dict = Depends(require_director_or_leader),
db: AsyncSession = Depends(get_db),
):
"""Export week report as 4-sheet .xlsx. Pass reference_date for historical weeks."""
ref = date.fromisoformat(reference_date) if reference_date else None
excel_bytes = await export_weekly_report(db, ref)
return StreamingResponse(
excel_bytes,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=weekly_report.xlsx"},
)
+88
View File
@@ -0,0 +1,88 @@
import io
import uuid
from fastapi import APIRouter, Depends, File, UploadFile, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.auth import get_current_user, require_director
from app.services.excel_import import import_from_excel
router = APIRouter(prefix="/import", tags=["Import"])
@router.get("/template")
async def download_weekly_report_template():
"""Download a 4-sheet weekly report import template."""
from openpyxl import Workbook
from openpyxl.styles import Font
wb = Workbook()
header_font = Font(bold=True)
# Sheet 1: 每日拜访记录
ws1 = wb.active
ws1.title = "每日拜访记录"
ws1.append(["客户单位", "拜访日期", "拜访方式", "时间范围", "拜访人姓名", "拜访人电话", "沟通内容", "客户需求", "同访人员", "客户经理"])
for c in ws1[1]: c.font = header_font
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: 下周工作计划
ws2 = wb.create_sheet("下周工作计划")
ws2.append(["客户单位", "工作计划", "计划拜访时间", "客户经理", "状态"])
for c in ws2[1]: c.font = header_font
ws2.append(["XX科技有限公司", "跟进云桌面扩容方案", "2026-06-30", "韦柳柏", "计划中"])
ws2.column_dimensions['A'].width = 20; ws2.column_dimensions['B'].width = 35
# Sheet 3: 小微业务商机
ws3 = wb.create_sheet("小微业务商机")
ws3.append(["客户单位", "产品类型", "金额", "跟进内容具体情况", "跟进状态", "客户经理", "预计列收时间"])
for c in ws3[1]: c.font = header_font
ws3.append(["XX科技有限公司", "云桌面", "5000元/月", "确认技术方案中", "跟进中", "韦柳柏", "2026Q3"])
ws3.column_dimensions['A'].width = 20; ws3.column_dimensions['D'].width = 30
# Sheet 4: 要客拜访计划
ws4 = wb.create_sheet("要客拜访计划")
ws4.append(["客户单位", "紧急重要度", "内容描述", "进展状态", "计划拜访时间", "计划拜访人", "拜访对象", "客户经理"])
for c in ws4[1]: c.font = header_font
ws4.append(["XX科技有限公司", "重要", "拜访技术负责人确认方案", "未开始", "2026-07-01", "韦柳柏", "王局长", "韦矍森"])
ws4.column_dimensions['A'].width = 20; ws4.column_dimensions['C'].width = 30
output = io.BytesIO()
wb.save(output)
output.seek(0)
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=weekly_report_template.xlsx"},
)
@router.post("/weekly-report")
async def import_weekly_report(
file: UploadFile = File(...),
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Upload old weekly report Excel and import data. Only director can do this."""
if not file.filename or not file.filename.endswith(('.xlsx', '.xls')):
raise HTTPException(status_code=400, detail="Only .xlsx and .xls files are supported")
content = await file.read()
try:
import openpyxl
wb = openpyxl.load_workbook(io.BytesIO(content), data_only=True)
preview = {}
for sheet_name in wb.sheetnames:
ws = wb[sheet_name]
headers = [str(cell.value) for cell in ws[1]]
row_count = ws.max_row - 1
preview[sheet_name] = {"headers": headers, "row_count": row_count}
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to parse Excel: {str(e)}")
stats = await import_from_excel(db, content, uuid.UUID(current_user["user_id"]))
stats["preview"] = preview
return stats
+115
View File
@@ -0,0 +1,115 @@
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
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_any_role
from app.models.key_visit import KeyVisit
from app.models.customer import Customer
from app.models.user import User
from app.schemas.key_visit import KeyVisitCreate, KeyVisitUpdate, KeyVisitOut
from app.utils.edit_log import compute_diff, append_entry, init_entry
router = APIRouter(prefix="/key-visits", tags=["KeyVisits"])
async def _enrich(k: KeyVisit, db: AsyncSession) -> dict:
cust = await db.execute(select(Customer.name).where(Customer.id == k.customer_id))
mgr = await db.execute(select(User.name).where(User.id == k.manager_id))
return {
"id": str(k.id),
"customer_id": str(k.customer_id),
"customer_name": cust.scalar_one_or_none(),
"urgency_level": k.urgency_level,
"description": k.description,
"progress_status": k.progress_status,
"planned_date": k.planned_date,
"planned_visitor": k.planned_visitor,
"visit_target": k.visit_target,
"manager_id": str(k.manager_id),
"edit_log": k.edit_log or [],
"manager_name": mgr.scalar_one_or_none(),
}
@router.get("/")
async def list_key_visits(
customer_id: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(KeyVisit)
if current_user["role"] == "manager":
query = query.where(KeyVisit.manager_id == uuid.UUID(current_user["user_id"]))
if customer_id:
query = query.where(KeyVisit.customer_id == uuid.UUID(customer_id))
query = query.order_by(KeyVisit.planned_date.desc()).limit(200)
result = await db.execute(query)
return [await _enrich(k, db) for k in result.scalars().all()]
@router.post("/")
async def create_key_visit(
data: KeyVisitCreate,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
k = KeyVisit(
customer_id=data.customer_id,
urgency_level=data.urgency_level,
description=data.description,
progress_status=data.progress_status,
planned_date=data.planned_date,
planned_visitor=data.planned_visitor,
visit_target=data.visit_target,
manager_id=uuid.UUID(current_user["user_id"]),
)
init_entry(k, current_user["name"])
db.add(k)
await db.commit()
await db.refresh(k)
return await _enrich(k, db)
@router.put("/{item_id}")
async def update_key_visit(
item_id: str, data: KeyVisitUpdate,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(KeyVisit).where(KeyVisit.id == item_id))
k = result.scalar_one_or_none()
if not k:
raise HTTPException(status_code=404, detail="Not found")
if current_user["role"] == "manager" and str(k.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
old_snapshot = {"customer_id": str(k.customer_id), "urgency_level": k.urgency_level, "description": k.description, "progress_status": k.progress_status, "planned_date": k.planned_date, "planned_visitor": k.planned_visitor, "visit_target": k.visit_target}
update_data = data.model_dump(exclude_unset=True)
for key, v in update_data.items():
setattr(k, key, v)
new_snapshot = {"customer_id": str(k.customer_id), "urgency_level": k.urgency_level, "description": k.description, "progress_status": k.progress_status, "planned_date": k.planned_date, "planned_visitor": k.planned_visitor, "visit_target": k.visit_target}
changes = compute_diff(old_snapshot, new_snapshot)
if changes:
append_entry(k, current_user["name"], changes, getattr(data, "edit_reason", None))
await db.commit()
await db.refresh(k)
return await _enrich(k, db)
@router.delete("/{item_id}")
async def delete_key_visit(
item_id: str,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(KeyVisit).where(KeyVisit.id == item_id))
k = result.scalar_one_or_none()
if not k:
raise HTTPException(status_code=404, detail="Not found")
if current_user["role"] == "manager" and str(k.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
await db.delete(k)
await db.commit()
return {"detail": "deleted"}
+113
View File
@@ -0,0 +1,113 @@
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
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_any_role
from app.models.mini_business import MiniBusiness
from app.models.customer import Customer
from app.models.user import User
from app.schemas.mini_business import MiniBusinessCreate, MiniBusinessUpdate, MiniBusinessOut
from app.utils.edit_log import compute_diff, append_entry, init_entry
router = APIRouter(prefix="/mini-business", tags=["MiniBusiness"])
async def _enrich(m: MiniBusiness, db: AsyncSession) -> dict:
cust = await db.execute(select(Customer.name).where(Customer.id == m.customer_id))
mgr = await db.execute(select(User.name).where(User.id == m.manager_id))
return {
"id": str(m.id),
"customer_id": str(m.customer_id),
"customer_name": cust.scalar_one_or_none(),
"product_type": m.product_type,
"amount": m.amount,
"follow_up_detail": m.follow_up_detail,
"status": m.status,
"manager_id": str(m.manager_id),
"manager_name": mgr.scalar_one_or_none(),
"edit_log": m.edit_log or [],
"expected_revenue_date": m.expected_revenue_date,
}
@router.get("/")
async def list_mini_business(
customer_id: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(MiniBusiness)
if current_user["role"] == "manager":
query = query.where(MiniBusiness.manager_id == uuid.UUID(current_user["user_id"]))
if customer_id:
query = query.where(MiniBusiness.customer_id == uuid.UUID(customer_id))
query = query.order_by(MiniBusiness.expected_revenue_date.desc()).limit(200)
result = await db.execute(query)
return [await _enrich(m, db) for m in result.scalars().all()]
@router.post("/")
async def create_mini_business(
data: MiniBusinessCreate,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
m = MiniBusiness(
customer_id=data.customer_id,
product_type=data.product_type,
amount=data.amount,
follow_up_detail=data.follow_up_detail,
status=data.status,
manager_id=uuid.UUID(current_user["user_id"]),
expected_revenue_date=data.expected_revenue_date,
)
init_entry(m, current_user["name"])
db.add(m)
await db.commit()
await db.refresh(m)
return await _enrich(m, db)
@router.put("/{item_id}")
async def update_mini_business(
item_id: str, data: MiniBusinessUpdate,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(MiniBusiness).where(MiniBusiness.id == item_id))
m = result.scalar_one_or_none()
if not m:
raise HTTPException(status_code=404, detail="Not found")
if current_user["role"] == "manager" and str(m.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
old_snapshot = {"customer_id": str(m.customer_id), "product_type": m.product_type, "amount": m.amount, "follow_up_detail": m.follow_up_detail, "status": m.status, "expected_revenue_date": m.expected_revenue_date}
update_data = data.model_dump(exclude_unset=True)
for k, v in update_data.items():
setattr(m, k, v)
new_snapshot = {"customer_id": str(m.customer_id), "product_type": m.product_type, "amount": m.amount, "follow_up_detail": m.follow_up_detail, "status": m.status, "expected_revenue_date": m.expected_revenue_date}
changes = compute_diff(old_snapshot, new_snapshot)
if changes:
append_entry(m, current_user["name"], changes, getattr(data, "edit_reason", None))
await db.commit()
await db.refresh(m)
return await _enrich(m, db)
@router.delete("/{item_id}")
async def delete_mini_business(
item_id: str,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(MiniBusiness).where(MiniBusiness.id == item_id))
m = result.scalar_one_or_none()
if not m:
raise HTTPException(status_code=404, detail="Not found")
if current_user["role"] == "manager" and str(m.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
await db.delete(m)
await db.commit()
return {"detail": "deleted"}
+36
View File
@@ -0,0 +1,36 @@
import uuid
from fastapi import APIRouter, Depends
from app.middleware.auth import get_current_user, require_any_role
from app.services.minio_client import generate_presigned_upload_url, generate_presigned_download_url
router = APIRouter(prefix="/upload", tags=["Upload"])
@router.post("/presigned-url")
async def get_presigned_upload_url(
filename: str,
content_type: str = "image/jpeg",
current_user: dict = Depends(require_any_role),
):
"""Get a presigned PUT URL for direct MinIO upload."""
import datetime
today = datetime.date.today().isoformat()
user_id = current_user["user_id"][:8]
object_key = f"{today}/{user_id}/{uuid.uuid4()}.jpg"
url = generate_presigned_upload_url(object_key)
return {
"upload_url": url,
"object_key": object_key,
}
@router.get("/download-url")
async def get_presigned_download_url(
object_key: str,
current_user: dict = Depends(get_current_user),
):
"""Get a presigned GET URL for viewing a photo (1 hour validity)."""
url = generate_presigned_download_url(object_key)
return {"download_url": url}
+82
View File
@@ -0,0 +1,82 @@
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
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.user import User
from app.schemas.customer import UserOut
router = APIRouter(prefix="/users", tags=["Users"])
class UpdateUserRoleRequest(BaseModel):
role: str # manager / director / leader
department: str = ""
@router.get("/", response_model=list[UserOut])
async def list_users(
role: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""List all users. Used for companion selection, assignment, etc."""
query = select(User)
if role:
query = query.where(User.role == role)
query = query.order_by(User.name)
result = await db.execute(query)
return result.scalars().all()
@router.get("/me")
async def get_current_user_info(
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get current user's full profile."""
result = await db.execute(select(User).where(User.id == uuid.UUID(current_user["user_id"])))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return {
"id": str(user.id),
"casdoor_id": user.casdoor_id,
"name": user.name,
"role": user.role,
"department": user.department,
"wecom_userid": user.wecom_userid,
}
@router.put("/{user_id}/role")
async def update_user_role(
user_id: str,
data: UpdateUserRoleRequest,
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Director updates a user's role. Only director can do this."""
if data.role not in ("manager", "director", "leader"):
raise HTTPException(status_code=400, detail="Invalid role. Must be manager/director/leader")
result = await db.execute(select(User).where(User.id == uuid.UUID(user_id)))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.role = data.role
if data.department:
user.department = data.department
await db.commit()
await db.refresh(user)
return {
"id": str(user.id),
"name": user.name,
"role": user.role,
"department": user.department,
}
+233
View File
@@ -0,0 +1,233 @@
import uuid
from datetime import date, datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.database import get_db
from app.middleware.auth import get_current_user, require_any_role
from app.models.visit import Visit
from app.models.customer import Customer
from app.models.user import User
from app.schemas.visit import VisitCreate, VisitUpdate, VisitOut, VisitListOut
from app.utils.timezone import today_cst, parse_date
from app.services.minio_client import delete_objects
from app.utils.edit_log import compute_diff, append_entry, init_entry
router = APIRouter(prefix="/visits", tags=["Visits"])
async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict:
"""Enrich a visit record with customer/manager names."""
customer_name = None
manager_name = None
if visit.customer_id:
cust_result = await db.execute(select(Customer.name).where(Customer.id == visit.customer_id))
customer_name = cust_result.scalar_one_or_none()
if visit.manager_id:
mgr_result = await db.execute(select(User.name).where(User.id == visit.manager_id))
manager_name = mgr_result.scalar_one_or_none()
return {
"id": str(visit.id),
"customer_id": str(visit.customer_id),
"customer_name": customer_name,
"visit_date": visit.visit_date,
"visit_method": visit.visit_method,
"time_range": visit.time_range,
"visitor_name": visit.visitor_name or "",
"visitor_phone": visit.visitor_phone or "",
"communication_content": visit.communication_content,
"customer_demand": visit.customer_demand,
"companions": visit.companions,
"photos": visit.photos,
"manager_id": str(visit.manager_id),
"manager_name": manager_name,
"edit_log": visit.edit_log or [],
"created_at": str(visit.created_at),
"updated_at": str(visit.updated_at),
}
@router.get("/")
async def list_visits(
date_from: Optional[str] = Query(None),
date_to: Optional[str] = Query(None),
customer_id: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""List visits. Managers see only their own, directors/leaders see all."""
query = select(Visit)
if current_user["role"] == "manager":
query = query.where(Visit.manager_id == uuid.UUID(current_user["user_id"]))
if date_from:
query = query.where(Visit.visit_date >= parse_date(date_from))
if date_to:
query = query.where(Visit.visit_date <= parse_date(date_to))
if customer_id:
query = query.where(Visit.customer_id == uuid.UUID(customer_id))
query = query.order_by(Visit.visit_date.desc(), Visit.created_at.desc()).limit(200)
result = await db.execute(query)
visits = result.scalars().all()
# Enrich
enriched = []
for v in visits:
enriched.append(await _enrich_visit(v, db))
return enriched
@router.get("/today")
async def list_today_visits(
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get today's visits for the current user's mobile home screen."""
query = select(Visit).where(Visit.visit_date == today_cst())
if current_user["role"] == "manager":
query = query.where(Visit.manager_id == uuid.UUID(current_user["user_id"]))
query = query.order_by(Visit.created_at.desc())
result = await db.execute(query)
visits = result.scalars().all()
enriched = []
for v in visits:
enriched.append(await _enrich_visit(v, db))
count = len(enriched)
return {"count": count, "visits": enriched}
@router.get("/{visit_id}")
async def get_visit(
visit_id: str,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Visit).where(Visit.id == visit_id))
visit = result.scalar_one_or_none()
if not visit:
raise HTTPException(status_code=404, detail="Visit not found")
# Permission check
if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
return await _enrich_visit(visit, db)
@router.post("/")
async def create_visit(
data: VisitCreate,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
"""Create a visit record. If companions are selected, creates draft copies for them."""
visit = Visit(
customer_id=data.customer_id,
visit_date=parse_date(data.visit_date),
visit_method=data.visit_method,
time_range=data.time_range,
communication_content=data.communication_content,
customer_demand=data.customer_demand,
companions=data.companions,
photos=data.photos,
manager_id=uuid.UUID(current_user["user_id"]),
)
init_entry(visit, current_user["name"])
db.add(visit)
# Create draft copies for companions
for companion_id in data.companions:
if companion_id != uuid.UUID(current_user["user_id"]):
draft = Visit(
customer_id=data.customer_id,
visit_date=parse_date(data.visit_date),
visit_method=data.visit_method,
time_range=data.time_range,
communication_content="", # Leave blank for companion to fill
customer_demand="",
companions=[],
photos=[],
manager_id=companion_id,
)
db.add(draft)
await db.commit()
await db.refresh(visit)
return await _enrich_visit(visit, db)
@router.put("/{visit_id}")
async def update_visit(
visit_id: str,
data: VisitUpdate,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Visit).where(Visit.id == visit_id))
visit = result.scalar_one_or_none()
if not visit:
raise HTTPException(status_code=404, detail="Visit not found")
if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
# Snapshot old values for diff
old_snapshot = {
"customer_id": str(visit.customer_id), "visit_date": str(visit.visit_date),
"visit_method": visit.visit_method, "time_range": visit.time_range,
"visitor_name": visit.visitor_name or "", "visitor_phone": visit.visitor_phone or "",
"communication_content": visit.communication_content, "customer_demand": visit.customer_demand,
}
update_data = data.model_dump(exclude_unset=True)
if "visit_date" in update_data and update_data["visit_date"]:
update_data["visit_date"] = parse_date(update_data["visit_date"])
for key, value in update_data.items():
setattr(visit, key, value)
# Compute diff and append to edit_log
new_snapshot = {
"customer_id": str(visit.customer_id), "visit_date": str(visit.visit_date),
"visit_method": visit.visit_method, "time_range": visit.time_range,
"visitor_name": visit.visitor_name or "", "visitor_phone": visit.visitor_phone or "",
"communication_content": visit.communication_content, "customer_demand": visit.customer_demand,
}
changes = compute_diff(old_snapshot, new_snapshot)
if changes:
append_entry(visit, current_user["name"], changes, getattr(data, "edit_reason", None))
await db.commit()
await db.refresh(visit)
return await _enrich_visit(visit, db)
@router.delete("/{visit_id}")
async def delete_visit(
visit_id: str,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Visit).where(Visit.id == visit_id))
visit = result.scalar_one_or_none()
if not visit:
raise HTTPException(status_code=404, detail="Visit not found")
if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
# Clean up photos in MinIO
if visit.photos:
delete_objects(visit.photos)
await db.delete(visit)
await db.commit()
return {"detail": "deleted"}
+68
View File
@@ -0,0 +1,68 @@
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
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.user import User
from app.services.wecom import wecom_client
from app.services.scheduler import check_daily_reporting
router = APIRouter(prefix="/wecom", tags=["WeChatWork"])
class RemindRequest(BaseModel):
user_ids: list[str]
message: Optional[str] = None
class AnnouncementRequest(BaseModel):
content: str
@router.post("/remind")
async def send_reminder(
data: RemindRequest,
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Director manually sends reminder to specific managers."""
# Get wecom_userids for the selected users
result = await db.execute(
select(User.wecom_userid).where(User.id.in_([uuid.UUID(uid) for uid in data.user_ids]))
)
wecom_ids = [r[0] for r in result.all() if r[0]]
content = data.message or "📋 请及时完成今日拜访记录填报。"
success = await wecom_client.send_text_message(wecom_ids, content)
return {"success": success, "sent_to": len(wecom_ids)}
@router.post("/announcement")
async def send_announcement(
data: AnnouncementRequest,
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
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)
return {"success": success, "sent_to": len(wecom_ids)}
@router.post("/trigger-daily-check")
async def trigger_daily_check(
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Manually trigger the daily reporting check (for testing or manual use)."""
result = await check_daily_reporting(db)
return result
+113
View File
@@ -0,0 +1,113 @@
import uuid
from datetime import date
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.middleware.auth import get_current_user, require_any_role
from app.utils.timezone import parse_date
from app.utils.edit_log import compute_diff, append_entry, init_entry
from app.models.work_plan import WorkPlan
from app.models.customer import Customer
from app.models.user import User
from app.schemas.work_plan import WorkPlanCreate, WorkPlanUpdate, WorkPlanOut
router = APIRouter(prefix="/work-plans", tags=["WorkPlans"])
async def _enrich(wp: WorkPlan, db: AsyncSession) -> dict:
cust = await db.execute(select(Customer.name).where(Customer.id == wp.customer_id))
mgr = await db.execute(select(User.name).where(User.id == wp.manager_id))
return {
"id": str(wp.id),
"customer_id": str(wp.customer_id),
"customer_name": cust.scalar_one_or_none(),
"plan_content": wp.plan_content,
"plan_date": wp.plan_date,
"manager_id": str(wp.manager_id),
"manager_name": mgr.scalar_one_or_none(),
"status": wp.status,
"edit_log": wp.edit_log or [],
}
@router.get("/")
async def list_work_plans(
customer_id: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(WorkPlan)
if current_user["role"] == "manager":
query = query.where(WorkPlan.manager_id == uuid.UUID(current_user["user_id"]))
if customer_id:
query = query.where(WorkPlan.customer_id == uuid.UUID(customer_id))
query = query.order_by(WorkPlan.plan_date.desc()).limit(200)
result = await db.execute(query)
return [await _enrich(w, db) for w in result.scalars().all()]
@router.post("/")
async def create_work_plan(
data: WorkPlanCreate,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
wp = WorkPlan(
customer_id=data.customer_id,
plan_content=data.plan_content,
plan_date=parse_date(data.plan_date),
manager_id=uuid.UUID(current_user["user_id"]),
status=data.status,
)
init_entry(wp, current_user["name"])
db.add(wp)
await db.commit()
await db.refresh(wp)
return await _enrich(wp, db)
@router.put("/{plan_id}")
async def update_work_plan(
plan_id: str, data: WorkPlanUpdate,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(WorkPlan).where(WorkPlan.id == plan_id))
wp = result.scalar_one_or_none()
if not wp:
raise HTTPException(status_code=404, detail="Not found")
if current_user["role"] == "manager" and str(wp.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
old_snapshot = {"customer_id": str(wp.customer_id), "plan_content": wp.plan_content, "plan_date": str(wp.plan_date), "status": wp.status}
update_data = data.model_dump(exclude_unset=True)
if "plan_date" in update_data and update_data["plan_date"]:
update_data["plan_date"] = parse_date(update_data["plan_date"])
for k, v in update_data.items():
setattr(wp, k, v)
new_snapshot = {"customer_id": str(wp.customer_id), "plan_content": wp.plan_content, "plan_date": str(wp.plan_date), "status": wp.status}
changes = compute_diff(old_snapshot, new_snapshot)
if changes:
append_entry(wp, current_user["name"], changes, getattr(data, "edit_reason", None))
await db.commit()
await db.refresh(wp)
return await _enrich(wp, db)
@router.delete("/{plan_id}")
async def delete_work_plan(
plan_id: str,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(WorkPlan).where(WorkPlan.id == plan_id))
wp = result.scalar_one_or_none()
if not wp:
raise HTTPException(status_code=404, detail="Not found")
if current_user["role"] == "manager" and str(wp.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
await db.delete(wp)
await db.commit()
return {"detail": "deleted"}
+48
View File
@@ -0,0 +1,48 @@
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
# App
APP_NAME: str = "企迹-政企周报管理系统"
DEBUG: bool = True
SECRET_KEY: str = "change-me-in-production"
# Database
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/qiji"
# JWT
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRE_MINUTES: int = 480
# Casdoor
CASDOOR_ENDPOINT: str = "http://localhost:8001"
CASDOOR_CLIENT_ID: str = ""
CASDOOR_CLIENT_SECRET: str = ""
CASDOOR_CERTIFICATE: Optional[str] = None
CASDOOR_ORG_NAME: str = "qiji"
CASDOOR_APPLICATION: str = "qiji-weekly-report"
# MinIO
MINIO_ENDPOINT: str = "localhost:9000"
MINIO_ACCESS_KEY: str = "minioadmin"
MINIO_SECRET_KEY: str = "minioadmin"
MINIO_BUCKET: str = "qiji-photos"
MINIO_SECURE: bool = False
# WeChat Work
WECOM_CORP_ID: str = ""
WECOM_AGENT_ID: str = ""
WECOM_SECRET: str = ""
WECOM_TOKEN: str = ""
WECOM_ENCODING_AES_KEY: str = ""
# CORS
CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:3000"]
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()
+19
View File
@@ -0,0 +1,19 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
async with async_session() as session:
try:
yield session
finally:
await session.close()
+69
View File
@@ -0,0 +1,69 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.database import engine, Base
from 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
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: create tables if not exists (for dev convenience)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Add columns that may be missing from older tables
await conn.run_sync(lambda c: c.exec_driver_sql(
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS remarks TEXT DEFAULT ''"
))
await conn.run_sync(lambda c: c.exec_driver_sql(
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_name VARCHAR(50) DEFAULT ''"
))
await conn.run_sync(lambda c: c.exec_driver_sql(
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_phone VARCHAR(20) DEFAULT ''"
))
# edit_log columns for change tracking
for tbl in ["visits", "daily_notes", "work_plans", "mini_business", "key_visits"]:
await conn.run_sync(lambda c, t=tbl: c.exec_driver_sql(
f"ALTER TABLE {t} ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'"
))
yield
# Shutdown
await engine.dispose()
app = FastAPI(
title=settings.APP_NAME,
version="0.1.0",
lifespan=lifespan,
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Mount all routers
app.include_router(auth.router, prefix="/api")
app.include_router(users.router, prefix="/api")
app.include_router(customers.router, prefix="/api")
app.include_router(visits.router, prefix="/api")
app.include_router(work_plans.router, prefix="/api")
app.include_router(mini_business.router, prefix="/api")
app.include_router(key_visits.router, prefix="/api")
app.include_router(dashboard.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.get("/health")
async def health_check():
return {"status": "ok", "app": settings.APP_NAME}
View File
+31
View File
@@ -0,0 +1,31 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.utils.security import decode_token
bearer_scheme = HTTPBearer()
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme)) -> dict:
payload = decode_token(credentials.credentials)
if payload is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")
return payload
class RoleChecker:
def __init__(self, allowed_roles: list[str]):
self.allowed_roles = allowed_roles
async def __call__(self, current_user: dict = Depends(get_current_user)) -> dict:
role = current_user.get("role", "")
if role not in self.allowed_roles:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
return current_user
# Pre-built checkers
require_manager = RoleChecker(["manager"])
require_director = RoleChecker(["director"])
require_leader = RoleChecker(["leader"])
require_director_or_leader = RoleChecker(["director", "leader"])
require_any_role = RoleChecker(["manager", "director", "leader"])
+21
View File
@@ -0,0 +1,21 @@
from app.models.user import User
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
__all__ = [
"User",
"Customer",
"CustomerContact",
"CustomerAssignment",
"Visit",
"WorkPlan",
"MiniBusiness",
"KeyVisit",
"DailyNote",
]
+24
View File
@@ -0,0 +1,24 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Text, DateTime, func, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class Customer(Base):
__tablename__ = "customers"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200), index=True)
industry: Mapped[str] = mapped_column(String(100), default="")
address: Mapped[str] = mapped_column(String(500), default="")
in_use_services: Mapped[str] = mapped_column(Text, default="")
monthly_fee: Mapped[str] = mapped_column(String(100), default="")
remarks: Mapped[str] = mapped_column(Text, default="")
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
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")
+20
View File
@@ -0,0 +1,20 @@
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class CustomerAssignment(Base):
__tablename__ = "customer_assignments"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
customer_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("customers.id"), index=True)
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"))
role: Mapped[str] = mapped_column(String(20), default="primary") # 'primary' / 'assistant'
assigned_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
assigned_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"))
customer: Mapped["Customer"] = relationship("Customer", back_populates="assignments")
manager: Mapped["User"] = relationship("User", foreign_keys=[manager_id])
+17
View File
@@ -0,0 +1,17 @@
import uuid
from sqlalchemy import String, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class CustomerContact(Base):
__tablename__ = "customer_contacts"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
customer_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("customers.id"), index=True)
name: Mapped[str] = mapped_column(String(50))
phone: Mapped[str] = mapped_column(String(20), default="")
role_desc: Mapped[str] = mapped_column(String(100), default="")
customer: Mapped["Customer"] = relationship("Customer", back_populates="contacts")
+20
View File
@@ -0,0 +1,20 @@
import uuid
from datetime import date, datetime
from sqlalchemy import String, Text, Date, DateTime, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class DailyNote(Base):
__tablename__ = "daily_notes"
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)
note_date: Mapped[date] = mapped_column(Date, index=True)
category: Mapped[str] = mapped_column(String(20), default="其他") # 行政事务/合同整理/发票处理/内部会议/培训学习/其他
content: Mapped[str] = mapped_column(Text, default="")
time_range: Mapped[str] = mapped_column(String(30), default="")
edit_log: Mapped[list] = mapped_column(JSONB, default=list)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+20
View File
@@ -0,0 +1,20 @@
import uuid
from sqlalchemy import String, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class KeyVisit(Base):
__tablename__ = "key_visits"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
customer_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("customers.id"), index=True)
urgency_level: Mapped[str] = mapped_column(String(10), default="一般") # 重要/一般/紧急
description: Mapped[str] = mapped_column(Text, default="")
progress_status: Mapped[str] = mapped_column(String(20), default="未开始")
planned_date: Mapped[str] = mapped_column(String(50), default="")
planned_visitor: Mapped[str] = mapped_column(String(100), default="")
visit_target: Mapped[str] = mapped_column(String(100), default="")
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
View File
@@ -0,0 +1,19 @@
import uuid
from sqlalchemy import String, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class MiniBusiness(Base):
__tablename__ = "mini_business"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
customer_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("customers.id"), index=True)
product_type: Mapped[str] = mapped_column(String(200), default="")
amount: Mapped[str] = mapped_column(String(100), default="")
follow_up_detail: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(String(50), default="跟进中")
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
expected_revenue_date: Mapped[str] = mapped_column(String(50), default="")
edit_log: Mapped[list] = mapped_column(JSONB, default=list)
+18
View File
@@ -0,0 +1,18 @@
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
casdoor_id: Mapped[str] = mapped_column(String(100), unique=True, index=True)
name: Mapped[str] = mapped_column(String(50))
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)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+26
View File
@@ -0,0 +1,26 @@
import uuid
from datetime import datetime, date
from sqlalchemy import String, Text, Date, DateTime, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, ARRAY, JSONB
from app.database import Base
class Visit(Base):
__tablename__ = "visits"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
customer_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("customers.id"), index=True)
visit_date: Mapped[date] = mapped_column(Date, index=True)
visit_method: Mapped[str] = mapped_column(String(20), default="上门") # 上门/电话/微信/出差
time_range: Mapped[str] = mapped_column(String(30), default="")
communication_content: Mapped[str] = mapped_column(Text, default="")
customer_demand: Mapped[str] = mapped_column(Text, default="")
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)
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)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+18
View File
@@ -0,0 +1,18 @@
import uuid
from datetime import date
from sqlalchemy import String, Text, Date, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class WorkPlan(Base):
__tablename__ = "work_plans"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
customer_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("customers.id"), index=True)
plan_content: Mapped[str] = mapped_column(Text, default="")
plan_date: Mapped[date] = mapped_column(Date, index=True)
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
status: Mapped[str] = mapped_column(String(20), default="计划中") # 计划中/已完成/已取消
edit_log: Mapped[list] = mapped_column(JSONB, default=list)
+10
View File
@@ -0,0 +1,10 @@
from app.schemas.customer import (
CustomerCreate, CustomerUpdate, CustomerOut, CustomerListOut,
ContactCreate, ContactOut, AssignmentCreate, AssignmentOut, BatchAssignRequest,
)
from app.schemas.visit import VisitCreate, VisitUpdate, VisitOut, VisitListOut
from app.schemas.work_plan import WorkPlanCreate, WorkPlanUpdate, WorkPlanOut
from app.schemas.mini_business import MiniBusinessCreate, MiniBusinessUpdate, MiniBusinessOut
from app.schemas.key_visit import KeyVisitCreate, KeyVisitUpdate, KeyVisitOut
from app.schemas.user import TokenResponse, WecomLoginRequest, CasdoorLoginRequest, WecomBindRequest
from app.schemas.customer import UserCreate, UserOut
+119
View File
@@ -0,0 +1,119 @@
from pydantic import BaseModel, Field
from typing import Optional
import uuid
from datetime import date, datetime
# ── User ──
class UserCreate(BaseModel):
name: str
role: str = "manager"
department: str = ""
wecom_userid: Optional[str] = None
class UserOut(BaseModel):
id: uuid.UUID
casdoor_id: str
name: str
role: str
department: str
wecom_userid: Optional[str] = None
model_config = {"from_attributes": True}
# ── CustomerContact ──
class ContactCreate(BaseModel):
name: str
phone: str = ""
role_desc: str = ""
class ContactOut(BaseModel):
id: uuid.UUID
customer_id: uuid.UUID
name: str
phone: str
role_desc: str
model_config = {"from_attributes": True}
# ── Customer ──
class CustomerCreate(BaseModel):
name: str
industry: str = ""
address: str = ""
in_use_services: str = ""
monthly_fee: str = ""
remarks: str = ""
contacts: list[ContactCreate] = []
assignee_id: Optional[uuid.UUID] = None
class CustomerUpdate(BaseModel):
name: Optional[str] = None
industry: Optional[str] = None
address: Optional[str] = None
in_use_services: Optional[str] = None
monthly_fee: Optional[str] = None
remarks: Optional[str] = None
assignee_id: Optional[uuid.UUID] = None
class CustomerOut(BaseModel):
id: uuid.UUID
name: str
industry: str
address: str
in_use_services: str
monthly_fee: str
remarks: str = ""
created_by: uuid.UUID
created_at: datetime
updated_at: datetime
contacts: list[ContactOut] = []
primary_manager_name: Optional[str] = None
model_config = {"from_attributes": True}
class CustomerListOut(BaseModel):
id: uuid.UUID
name: str
industry: str
in_use_services: str
primary_manager_name: Optional[str] = None
model_config = {"from_attributes": True}
class CustomerListResponse(BaseModel):
items: list[CustomerListOut]
total: int
page: int
page_size: int
# ── CustomerAssignment ──
class AssignmentCreate(BaseModel):
customer_id: uuid.UUID
manager_id: uuid.UUID
role: str = "primary"
class AssignmentOut(BaseModel):
id: uuid.UUID
customer_id: uuid.UUID
manager_id: uuid.UUID
role: str
assigned_at: datetime
assigned_by: uuid.UUID
model_config = {"from_attributes": True}
class BatchAssignRequest(BaseModel):
customer_ids: list[uuid.UUID]
manager_id: uuid.UUID
+32
View File
@@ -0,0 +1,32 @@
from pydantic import BaseModel
from typing import Optional
import uuid
from datetime import date, datetime
class DailyNoteCreate(BaseModel):
note_date: str # YYYY-MM-DD
category: str = "其他"
content: str = ""
time_range: str = ""
class DailyNoteUpdate(BaseModel):
note_date: Optional[str] = None
category: Optional[str] = None
content: Optional[str] = None
time_range: Optional[str] = None
class DailyNoteOut(BaseModel):
id: uuid.UUID
manager_id: uuid.UUID
note_date: date
category: str
content: str
time_range: str
created_at: datetime
updated_at: datetime
manager_name: Optional[str] = None
model_config = {"from_attributes": True}
+39
View File
@@ -0,0 +1,39 @@
from pydantic import BaseModel
from typing import Optional
import uuid
class KeyVisitCreate(BaseModel):
customer_id: uuid.UUID
urgency_level: str = "一般" # 重要/一般/紧急
description: str = ""
progress_status: str = "未开始"
planned_date: str = ""
planned_visitor: str = ""
visit_target: str = ""
class KeyVisitUpdate(BaseModel):
customer_id: Optional[uuid.UUID] = None
urgency_level: Optional[str] = None
description: Optional[str] = None
progress_status: Optional[str] = None
planned_date: Optional[str] = None
planned_visitor: Optional[str] = None
visit_target: Optional[str] = None
class KeyVisitOut(BaseModel):
id: uuid.UUID
customer_id: uuid.UUID
urgency_level: str
description: str
progress_status: str
planned_date: str
planned_visitor: str
visit_target: str
manager_id: uuid.UUID
customer_name: Optional[str] = None
manager_name: Optional[str] = None
model_config = {"from_attributes": True}
+36
View File
@@ -0,0 +1,36 @@
from pydantic import BaseModel
from typing import Optional
import uuid
class MiniBusinessCreate(BaseModel):
customer_id: uuid.UUID
product_type: str = ""
amount: str = ""
follow_up_detail: str = ""
status: str = "跟进中"
expected_revenue_date: str = ""
class MiniBusinessUpdate(BaseModel):
customer_id: Optional[uuid.UUID] = None
product_type: Optional[str] = None
amount: Optional[str] = None
follow_up_detail: Optional[str] = None
status: Optional[str] = None
expected_revenue_date: Optional[str] = None
class MiniBusinessOut(BaseModel):
id: uuid.UUID
customer_id: uuid.UUID
product_type: str
amount: str
follow_up_detail: str
status: str
manager_id: uuid.UUID
expected_revenue_date: str
customer_name: Optional[str] = None
manager_name: Optional[str] = None
model_config = {"from_attributes": True}
+25
View File
@@ -0,0 +1,25 @@
from pydantic import BaseModel
from typing import Optional
import uuid
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user_id: str
name: str
role: str
class WecomLoginRequest(BaseModel):
code: str
class CasdoorLoginRequest(BaseModel):
code: str
state: str
class WecomBindRequest(BaseModel):
casdoor_code: str
wecom_userid: Optional[str] = None
+66
View File
@@ -0,0 +1,66 @@
from pydantic import BaseModel, Field
from typing import Optional
import uuid
from datetime import date, datetime
class VisitCreate(BaseModel):
customer_id: uuid.UUID
visit_date: str # "YYYY-MM-DD"
visit_method: str = "上门"
time_range: str = ""
visitor_name: str = ""
visitor_phone: str = ""
communication_content: str = ""
customer_demand: str = ""
companions: list[uuid.UUID] = []
photos: list[str] = []
class VisitUpdate(BaseModel):
customer_id: Optional[uuid.UUID] = None
visit_date: Optional[str] = None
visit_method: Optional[str] = None
time_range: Optional[str] = None
visitor_name: Optional[str] = None
visitor_phone: Optional[str] = None
communication_content: Optional[str] = None
customer_demand: Optional[str] = None
companions: Optional[list[uuid.UUID]] = None
photos: Optional[list[str]] = None
class VisitOut(BaseModel):
id: uuid.UUID
customer_id: uuid.UUID
visit_date: date
visit_method: str
time_range: str
visitor_name: str = ""
visitor_phone: str = ""
communication_content: str
customer_demand: str
companions: Optional[list[uuid.UUID]] = None
photos: Optional[list[str]] = None
manager_id: uuid.UUID
created_at: datetime
updated_at: datetime
# Joined fields
customer_name: Optional[str] = None
manager_name: Optional[str] = None
model_config = {"from_attributes": True}
class VisitListOut(BaseModel):
id: uuid.UUID
customer_id: uuid.UUID
visit_date: date
visit_method: str
time_range: str
manager_id: uuid.UUID
customer_name: Optional[str] = None
manager_name: Optional[str] = None
has_photos: bool = False
model_config = {"from_attributes": True}
+31
View File
@@ -0,0 +1,31 @@
from pydantic import BaseModel
from typing import Optional
import uuid
from datetime import date
class WorkPlanCreate(BaseModel):
customer_id: uuid.UUID
plan_content: str = ""
plan_date: str # "YYYY-MM-DD"
status: str = "计划中"
class WorkPlanUpdate(BaseModel):
customer_id: Optional[uuid.UUID] = None
plan_content: Optional[str] = None
plan_date: Optional[str] = None
status: Optional[str] = None
class WorkPlanOut(BaseModel):
id: uuid.UUID
customer_id: uuid.UUID
plan_content: str
plan_date: date
manager_id: uuid.UUID
status: str
customer_name: Optional[str] = None
manager_name: Optional[str] = None
model_config = {"from_attributes": True}
View File
+106
View File
@@ -0,0 +1,106 @@
from uuid import uuid4
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import httpx
from app.config import settings
from app.models.user import User
from app.utils.security import create_access_token
async def get_or_create_user_from_casdoor(
db: AsyncSession, casdoor_id: str, name: str,
role: str = "manager", department: str = ""
) -> User:
"""Find existing user by casdoor_id, or create a new one."""
result = await db.execute(select(User).where(User.casdoor_id == casdoor_id))
user = result.scalar_one_or_none()
if user is None:
user = User(
casdoor_id=casdoor_id,
name=name,
role=role,
department=department,
)
db.add(user)
await db.commit()
await db.refresh(user)
else:
# Update name/department if changed
if user.name != name or user.department != department:
user.name = name
user.department = department
await db.commit()
await db.refresh(user)
return user
async def exchange_casdoor_code(code: str) -> dict | None:
"""Exchange Casdoor OIDC authorization code for user info."""
import logging
logger = logging.getLogger("uvicorn")
token_url = f"{settings.CASDOOR_ENDPOINT}/api/login/oauth/access_token"
async with httpx.AsyncClient() as client:
try:
resp = await client.post(token_url, data={
"grant_type": "authorization_code",
"client_id": settings.CASDOOR_CLIENT_ID,
"client_secret": settings.CASDOOR_CLIENT_SECRET,
"code": code,
}, timeout=10)
if resp.status_code != 200:
logger.error(f"[Casdoor] token exchange failed: status={resp.status_code}, body={resp.text[:500]}")
return None
token_data = resp.json()
access_token = token_data.get("access_token", "")
if not access_token:
logger.error(f"[Casdoor] no access_token in response: {token_data}")
return None
except Exception as e:
logger.error(f"[Casdoor] token exchange exception: {e}")
return None
userinfo_url = f"{settings.CASDOOR_ENDPOINT}/api/userinfo"
async with httpx.AsyncClient() as client:
try:
resp = await client.get(userinfo_url, headers={
"Authorization": f"Bearer {access_token}"
}, timeout=10)
if resp.status_code != 200:
logger.error(f"[Casdoor] userinfo failed: status={resp.status_code}, body={resp.text[:500]}")
return None
return resp.json()
except Exception as e:
logger.error(f"[Casdoor] userinfo exception: {e}")
return None
return None
async def bind_wecom_user(db: AsyncSession, casdoor_id: str, wecom_userid: str) -> User | None:
"""Bind a WeChat Work userid to a Casdoor user."""
result = await db.execute(select(User).where(User.casdoor_id == casdoor_id))
user = result.scalar_one_or_none()
if user is None:
return None
user.wecom_userid = wecom_userid
await db.commit()
await db.refresh(user)
return user
async def get_user_by_wecom_id(db: AsyncSession, wecom_userid: str) -> User | None:
"""Find user by wecom_userid."""
result = await db.execute(select(User).where(User.wecom_userid == wecom_userid))
return result.scalar_one_or_none()
def build_token_for_user(user: User) -> str:
"""Build a JWT token for the given user."""
return create_access_token(data={
"sub": str(user.id),
"user_id": str(user.id),
"casdoor_id": user.casdoor_id,
"name": user.name,
"role": user.role,
"department": user.department,
})
+255
View File
@@ -0,0 +1,255 @@
from datetime import date, timedelta
from uuid import UUID
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from app.models.customer import Customer
from app.models.user import User
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.utils.timezone import today_cst
def get_week_range(reference_date: date | None = None):
today = reference_date or date.today()
monday = today - timedelta(days=today.weekday())
sunday = monday + timedelta(days=6)
return monday, sunday
async def get_dashboard_stats(db: AsyncSession, reference_date: date | None = None) -> dict:
"""Get dashboard statistics for a given week (defaults to current)."""
monday, sunday = get_week_range(reference_date)
today = date.today()
visits_count = (await db.execute(
select(func.count(Visit.id)).where(Visit.visit_date >= monday, Visit.visit_date <= sunday)
)).scalar() or 0
plans_count = (await db.execute(
select(func.count(WorkPlan.id))
)).scalar() or 0
mini_biz_count = (await db.execute(
select(func.count(MiniBusiness.id))
)).scalar() or 0
key_visit_count = (await db.execute(
select(func.count(KeyVisit.id))
)).scalar() or 0
return {
"week_visits": visits_count,
"work_plans": plans_count,
"mini_business": mini_biz_count,
"key_visits": key_visit_count,
"week_start": str(monday),
"week_end": str(sunday),
}
async def get_reporting_progress(db: AsyncSession, reference_date: date | None = None, user_id: str = "", role: str = "") -> list[dict]:
"""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()
# 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]
# Get visit counts per manager this week
visits_result = await db.execute(
select(Visit.manager_id, func.count(Visit.id))
.where(Visit.visit_date >= monday, Visit.visit_date <= sunday)
.group_by(Visit.manager_id)
)
visit_map = {str(uid): cnt for uid, cnt in visits_result.all()}
progress = []
for m in managers:
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
progress.append({
"manager_id": str(m.id),
"manager_name": m.name,
"department": m.department,
"visit_count": count,
"expected": expected,
"completed": count >= expected,
"has_reported_today": False, # Will be set below
})
# 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)
)
today_notes = await db.execute(
select(DailyNote.manager_id).where(DailyNote.note_date == today)
)
reported_today = {str(uid) for uid, in today_visits.all()} | {str(uid) for uid, in today_notes.all()}
for p in progress:
p["has_reported_today"] = p["manager_id"] in reported_today
return progress
async def get_weekly_report(
db: AsyncSession, user_id: UUID, role: str,
filter_manager_id: UUID | None = None,
filter_customer_id: UUID | None = None,
reference_date: date | None = None,
) -> dict:
"""Get full weekly report data organized by module."""
monday, sunday = get_week_range(reference_date)
# Base filters respecting role visibility
customer_map = {}
user_map = {}
customers_result = await db.execute(select(Customer.id, Customer.name))
customer_map = {c.id: c.name for c in customers_result.all()}
users_result = await db.execute(select(User.id, User.name))
user_map = {u.id: u.name for u in users_result.all()}
def build_manager_filter(existing_filter=None):
"""If role is manager, only see own data. Otherwise optionally filter by manager_id."""
if role == "manager":
return str(user_id)
return str(filter_manager_id) if filter_manager_id else None
# ── Visits ──
visit_query = select(Visit).where(Visit.visit_date >= monday, Visit.visit_date <= sunday)
if role == "manager":
visit_query = visit_query.where(Visit.manager_id == user_id)
elif filter_manager_id:
visit_query = visit_query.where(Visit.manager_id == filter_manager_id)
if filter_customer_id:
visit_query = visit_query.where(Visit.customer_id == filter_customer_id)
visit_query = visit_query.order_by(Visit.visit_date.desc())
visits_result = await db.execute(visit_query)
visits = visits_result.scalars().all()
visits_data = []
for v in visits:
visits_data.append({
"id": str(v.id),
"customer_id": str(v.customer_id),
"customer_name": customer_map.get(v.customer_id, ""),
"visit_date": str(v.visit_date),
"visit_method": v.visit_method,
"time_range": v.time_range,
"communication_content": v.communication_content,
"customer_demand": v.customer_demand,
"companions": [str(c) for c in (v.companions or [])],
"photos": v.photos or [],
"manager_id": str(v.manager_id),
"manager_name": user_map.get(v.manager_id, ""),
"created_at": str(v.created_at),
})
# ── Work Plans ──
wp_query = select(WorkPlan)
if role == "manager":
wp_query = wp_query.where(WorkPlan.manager_id == user_id)
elif filter_manager_id:
wp_query = wp_query.where(WorkPlan.manager_id == filter_manager_id)
if filter_customer_id:
wp_query = wp_query.where(WorkPlan.customer_id == filter_customer_id)
wp_result = await db.execute(wp_query)
work_plans_data = []
for w in wp_result.scalars():
work_plans_data.append({
"id": str(w.id),
"customer_id": str(w.customer_id),
"customer_name": customer_map.get(w.customer_id, ""),
"plan_content": w.plan_content,
"plan_date": str(w.plan_date),
"manager_id": str(w.manager_id),
"manager_name": user_map.get(w.manager_id, ""),
"status": w.status,
})
# ── Mini Business ──
mb_query = select(MiniBusiness)
if role == "manager":
mb_query = mb_query.where(MiniBusiness.manager_id == user_id)
elif filter_manager_id:
mb_query = mb_query.where(MiniBusiness.manager_id == filter_manager_id)
if filter_customer_id:
mb_query = mb_query.where(MiniBusiness.customer_id == filter_customer_id)
mb_result = await db.execute(mb_query)
mini_biz_data = []
for m in mb_result.scalars():
mini_biz_data.append({
"id": str(m.id),
"customer_id": str(m.customer_id),
"customer_name": customer_map.get(m.customer_id, ""),
"product_type": m.product_type,
"amount": m.amount,
"follow_up_detail": m.follow_up_detail,
"status": m.status,
"manager_id": str(m.manager_id),
"manager_name": user_map.get(m.manager_id, ""),
"expected_revenue_date": m.expected_revenue_date,
})
# ── Key Visits ──
kv_query = select(KeyVisit)
if role == "manager":
kv_query = kv_query.where(KeyVisit.manager_id == user_id)
elif filter_manager_id:
kv_query = kv_query.where(KeyVisit.manager_id == filter_manager_id)
if filter_customer_id:
kv_query = kv_query.where(KeyVisit.customer_id == filter_customer_id)
kv_result = await db.execute(kv_query)
key_visits_data = []
for k in kv_result.scalars():
key_visits_data.append({
"id": str(k.id),
"customer_id": str(k.customer_id),
"customer_name": customer_map.get(k.customer_id, ""),
"urgency_level": k.urgency_level,
"description": k.description,
"progress_status": k.progress_status,
"planned_date": k.planned_date,
"planned_visitor": k.planned_visitor,
"visit_target": k.visit_target,
"manager_id": str(k.manager_id),
"manager_name": user_map.get(k.manager_id, ""),
})
# ── Daily Notes ──
dn_query = select(DailyNote).where(DailyNote.note_date >= monday, DailyNote.note_date <= sunday)
if role == "manager":
dn_query = dn_query.where(DailyNote.manager_id == user_id)
elif filter_manager_id:
dn_query = dn_query.where(DailyNote.manager_id == filter_manager_id)
dn_query = dn_query.order_by(DailyNote.note_date.desc())
dn_result = await db.execute(dn_query)
daily_notes_data = []
for d in dn_result.scalars():
daily_notes_data.append({
"id": str(d.id),
"note_date": str(d.note_date),
"category": d.category,
"content": d.content,
"time_range": d.time_range,
"manager_id": str(d.manager_id),
"manager_name": user_map.get(d.manager_id, ""),
})
return {
"week_start": str(monday),
"week_end": str(sunday),
"visits": visits_data,
"daily_notes": daily_notes_data,
"work_plans": work_plans_data,
"mini_business": mini_biz_data,
"key_visits": key_visits_data,
}
+139
View File
@@ -0,0 +1,139 @@
import io
from datetime import date, timedelta
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, Border, Side
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
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.customer import Customer
from app.models.user import User
def get_week_range(reference_date: date | None = None):
"""Get the Monday and Sunday of the week containing reference_date (defaults to today)."""
today = reference_date or date.today()
monday = today - timedelta(days=today.weekday())
sunday = monday + timedelta(days=6)
return monday, sunday
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."""
monday, sunday = get_week_range(reference_date)
wb = Workbook()
# Pre-fetch lookups
customers_result = await db.execute(select(Customer.id, Customer.name))
customer_map = {str(c.id): c.name for c in customers_result.all()}
users_result = await db.execute(select(User.id, User.name))
user_map = {str(u.id): u.name for u in users_result.all()}
thin_border = Border(
left=Side(style='thin'), right=Side(style='thin'),
top=Side(style='thin'), bottom=Side(style='thin')
)
header_font = Font(bold=True)
# ── Sheet 1: 每日拜访记录 ──
ws1 = wb.active
ws1.title = "每日拜访记录"
headers1 = ["客户单位", "拜访日期", "拜访方式", "时间范围", "拜访人姓名", "拜访人电话", "沟通内容", "客户需求", "同访人员", "客户经理"]
ws1.append(headers1)
for col in range(1, len(headers1) + 1):
cell = ws1.cell(row=1, column=col)
cell.font = header_font
cell.border = thin_border
visits_result = await db.execute(
select(Visit).where(Visit.visit_date >= monday, Visit.visit_date <= sunday)
)
for v in visits_result.scalars():
companions_names = [user_map.get(str(cid), str(cid)) for cid in (v.companions or [])]
ws1.append([
customer_map.get(str(v.customer_id), ""),
str(v.visit_date),
v.visit_method,
v.time_range,
v.visitor_name or "",
v.visitor_phone or "",
v.communication_content,
v.customer_demand,
", ".join(companions_names),
user_map.get(str(v.manager_id), ""),
])
# ── Sheet 2: 下周工作计划 ──
ws2 = wb.create_sheet("下周工作计划")
headers2 = ["客户单位", "工作计划", "计划拜访时间", "客户经理", "状态"]
ws2.append(headers2)
for col in range(1, len(headers2) + 1):
cell = ws2.cell(row=1, column=col)
cell.font = header_font
cell.border = thin_border
plans_result = await db.execute(select(WorkPlan))
for p in plans_result.scalars():
ws2.append([
customer_map.get(str(p.customer_id), ""),
p.plan_content,
str(p.plan_date),
user_map.get(str(p.manager_id), ""),
p.status,
])
# ── Sheet 3: 小微业务商机 ──
ws3 = wb.create_sheet("小微业务商机")
headers3 = ["客户单位", "产品类型", "金额", "跟进内容具体情况", "跟进状态", "客户经理", "预计列收时间"]
ws3.append(headers3)
for col in range(1, len(headers3) + 1):
cell = ws3.cell(row=1, column=col)
cell.font = header_font
cell.border = thin_border
mb_result = await db.execute(select(MiniBusiness))
for m in mb_result.scalars():
ws3.append([
customer_map.get(str(m.customer_id), ""),
m.product_type,
m.amount,
m.follow_up_detail,
m.status,
user_map.get(str(m.manager_id), ""),
m.expected_revenue_date,
])
# ── Sheet 4: 要客拜访计划 ──
ws4 = wb.create_sheet("要客拜访计划")
headers4 = ["客户单位", "紧急重要度", "内容描述", "进展状态", "计划拜访时间", "计划拜访人", "拜访对象", "客户经理"]
ws4.append(headers4)
for col in range(1, len(headers4) + 1):
cell = ws4.cell(row=1, column=col)
cell.font = header_font
cell.border = thin_border
kv_result = await db.execute(select(KeyVisit))
for k in kv_result.scalars():
ws4.append([
customer_map.get(str(k.customer_id), ""),
k.urgency_level,
k.description,
k.progress_status,
k.planned_date,
k.planned_visitor,
k.visit_target,
user_map.get(str(k.manager_id), ""),
])
# Adjust column widths
for ws in [ws1, ws2, ws3, ws4]:
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)
output = io.BytesIO()
wb.save(output)
output.seek(0)
return output
+81
View File
@@ -0,0 +1,81 @@
import uuid
from datetime import datetime, date
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import openpyxl
from app.models.customer import Customer
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.user import User
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": []}
# Resolve customer name -> id cache
customers_result = await db.execute(select(Customer.id, Customer.name))
customer_map = {c.name: c.id for c in customers_result.all()}
# ── Parse Sheet 1: 每日拜访记录 ──
if "每日拜访记录" in wb.sheetnames:
ws = wb["每日拜访记录"]
for row in ws.iter_rows(min_row=2, values_only=True):
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 ""
customer_id = customer_map.get(cust_name)
if not customer_id:
stats["skipped"] += 1
stats["skip_reasons"].append(f"客户「{cust_name}」不存在,跳过")
continue
try:
visit_date = datetime.strptime(visit_date_str[:10], "%Y-%m-%d").date()
except ValueError:
visit_date = date.today()
existing = await db.execute(
select(Visit).where(
Visit.visit_date == visit_date,
Visit.manager_id == manager_id,
Visit.customer_id == customer_id,
)
)
if existing.scalar_one_or_none():
stats["skipped"] += 1
stats["skip_reasons"].append(f"重复:{cust_name} {visit_date} 已存在")
continue
visit = Visit(
customer_id=customer_id,
visit_date=visit_date,
visit_method=visit_method if visit_method in ["上门", "电话", "微信", "出差"] else "上门",
time_range=time_range,
visitor_name=visitor_name,
visitor_phone=visitor_phone,
communication_content=content,
customer_demand=demand,
manager_id=manager_id,
)
db.add(visit)
stats["visits"] += 1
except Exception:
stats["skipped"] += 1
await db.commit()
return stats
import io
+43
View File
@@ -0,0 +1,43 @@
from datetime import timedelta
from minio import Minio
from app.config import settings
_client: Minio | None = None
def get_minio_client() -> Minio:
global _client
if _client is None:
_client = Minio(
settings.MINIO_ENDPOINT,
access_key=settings.MINIO_ACCESS_KEY,
secret_key=settings.MINIO_SECRET_KEY,
secure=settings.MINIO_SECURE,
)
# Ensure bucket exists
if not _client.bucket_exists(settings.MINIO_BUCKET):
_client.make_bucket(settings.MINIO_BUCKET)
return _client
def generate_presigned_upload_url(object_key: str, expires: int = 600) -> str:
"""Generate a presigned PUT URL for direct upload to MinIO."""
client = get_minio_client()
return client.presigned_put_object(settings.MINIO_BUCKET, object_key, expires=timedelta(seconds=expires))
def generate_presigned_download_url(object_key: str, expires: int = 3600) -> str:
"""Generate a presigned GET URL for viewing/downloading an object."""
client = get_minio_client()
return client.presigned_get_object(settings.MINIO_BUCKET, object_key, expires=timedelta(seconds=expires))
def delete_objects(object_keys: list[str]) -> None:
"""Delete multiple objects from MinIO."""
if not object_keys:
return
client = get_minio_client()
from minio.deleteobjects import DeleteObject
errors = client.remove_objects(settings.MINIO_BUCKET, [DeleteObject(k) for k in object_keys])
for err in errors:
pass # Log errors in production
+54
View File
@@ -0,0 +1,54 @@
from datetime import date, datetime
from sqlalchemy import select, func
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.services.wecom import wecom_client
from app.utils.timezone import today_cst
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"))
managers = result.scalars().all()
# Get managers who have reported today
reported_visits = await db.execute(
select(Visit.manager_id).where(Visit.visit_date == today)
)
reported_notes = await db.execute(
select(DailyNote.manager_id).where(DailyNote.note_date == today)
)
reported_map = {str(uid): True for uid, in reported_visits.all()}
for uid, in reported_notes.all():
reported_map[str(uid)] = True
not_reported = []
for m in managers:
if str(m.id) not in reported_map:
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请尽快完成今日拜访填报 🙏"
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)
return {
"status": "ok",
"date": str(today),
"total_managers": len(managers),
"reported": len(reported_map),
"not_reported": len(not_reported),
}
+55
View File
@@ -0,0 +1,55 @@
import httpx
from app.config import settings
class WecomClient:
"""Minimal WeChat Work API client for sending app messages."""
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
async def _get_token(self) -> str:
if self._access_token:
return self._access_token
url = f"https://qyapi.weixin.qq.com/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"]
return self._access_token
raise Exception(f"Failed to get wecom token: {data}")
async def get_userinfo_by_code(self, code: str) -> dict | None:
"""Exchange OAuth2 code for userid (used in silent login)."""
token = await self._get_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_token={token}&code={code}"
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=10)
data = resp.json()
if data.get("errcode") == 0:
return data
return None
async def send_text_message(self, user_ids: list[str], content: str) -> bool:
"""Send a text app message to specified users."""
if not settings.WECOM_AGENT_ID:
return False # Not configured, skip silently in dev
token = await self._get_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}"
body = {
"touser": "|".join(user_ids),
"msgtype": "text",
"agentid": int(settings.WECOM_AGENT_ID),
"text": {"content": content},
}
async with httpx.AsyncClient() as client:
resp = await client.post(url, json=body, timeout=10)
data = resp.json()
return data.get("errcode") == 0
wecom_client = WecomClient()
View File
+43
View File
@@ -0,0 +1,43 @@
"""Edit log helpers for change tracking."""
import json
from datetime import datetime, timezone
def compute_diff(old: dict, new: dict, exclude: set = None) -> dict:
"""Compare old and new values, return {field: [old, new]} for changed fields."""
if exclude is None:
exclude = {"edit_log", "updated_at", "created_at", "id"}
changes = {}
for key, new_val in new.items():
if key in exclude:
continue
old_val = old.get(key)
# Normalize for comparison
old_str = str(old_val) if old_val is not None else ""
new_str = str(new_val) if new_val is not None else ""
if old_str != new_str:
changes[key] = [old_str, new_str]
return changes
def append_entry(record, editor_name: str, changes: dict, reason: str = None):
"""Append an edit log entry to a record's edit_log JSON list."""
log = list(record.edit_log) if record.edit_log else []
entry = {
"editor": editor_name,
"time": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"changes": changes,
}
if reason:
entry["reason"] = reason
log.append(entry)
record.edit_log = log
def init_entry(record, creator_name: str):
"""Initialize edit_log with a creation entry."""
record.edit_log = [{
"editor": creator_name,
"time": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"changes": {},
}]
+19
View File
@@ -0,0 +1,19 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import jwt, JWTError
from app.config import settings
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.JWT_EXPIRE_MINUTES))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
def decode_token(token: str) -> Optional[dict]:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
return payload
except JWTError:
return None
+13
View File
@@ -0,0 +1,13 @@
from datetime import date, datetime, timezone, timedelta
CST = timezone(timedelta(hours=8)) # China Standard Time
def today_cst() -> date:
"""Get today's date in Asia/Shanghai timezone."""
return datetime.now(timezone.utc).astimezone(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])
+14
View File
@@ -0,0 +1,14 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
alembic==1.14.0
pydantic==2.10.3
pydantic-settings==2.7.0
python-jose[cryptography]==3.3.0
httpx==0.28.1
python-multipart==0.0.18
minio==7.2.10
openpyxl==3.1.5
apscheduler==3.11.0
python-dotenv==1.0.1
+46
View File
@@ -0,0 +1,46 @@
version: "3.8"
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: qiji
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000"
- "9001:9001"
volumes:
- miniodata:/data
backend:
build: ./backend
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/qiji
MINIO_ENDPOINT: minio:9000
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
MINIO_BUCKET: qiji-photos
depends_on:
- postgres
- minio
volumes:
- ./backend:/app
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
volumes:
pgdata:
miniodata:
+10
View File
@@ -0,0 +1,10 @@
# ============================================
# 企迹 (qiji) — 前端环境变量
# 复制此文件为 .env 并填入真实值
# ============================================
# Casdoor 认证服务地址(与后端 CASDOOR_ENDPOINT 一致)
VITE_CASDOOR_ENDPOINT=http://localhost:8001
# Casdoor 应用 Client ID(与后端 CASDOOR_CLIENT_ID 一致)
VITE_CASDOOR_CLIENT_ID=e715d85bf5b2dc8988ac
+12
View File
@@ -0,0 +1,12 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<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" />
<title>企迹 - 政企周报管理系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Vue app
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;
}
location /health {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
}
}
+3189
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "qiji-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"element-plus": "^2.9.1",
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.20",
"@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.5.0",
"postcss": "^8.5.15",
"tailwindcss": "^3.4.19",
"typescript": "~5.6.0",
"vite": "^6.0.5",
"vue-tsc": "^2.2.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+280
View File
@@ -0,0 +1,280 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
import { computed } from 'vue'
const auth = useAuthStore()
const isMobile = computed(() => {
return /Android|iPhone|iPad|iPod|webOS/i.test(navigator.userAgent) || window.innerWidth < 768
})
</script>
<template>
<router-view />
</template>
<style>
/* ═══════════════════════════════════════════════════════════
企迹 (qiji) — Editorial/Magazine Design System
Aesthetic: 编辑/杂志风格 | 墨色+朱砂+印泥金 | 站酷小薇体+思源宋体
═══════════════════════════════════════════════════════════ */
:root {
/* ── Core palette ── */
--ink: #1C3738;
--ink-light: #2A4F50;
--ink-dark: #0F1F20;
--vermilion: #B8472E;
--vermilion-light: #D46A4F;
--vermilion-dark: #8B3522;
--gold: #C4934A;
--gold-light: #D4AD6E;
--gold-dark: #A67736;
--paper: #F5F0E8;
--paper-dark: #EBE4D8;
--surface: #FFFFFF;
--sage: #4A6741;
--amber: #C68B3C;
--warm-gray: #7B7568;
--warm-border: #E5DFD3;
/* ── Semantic tokens ── */
--c-primary: var(--ink);
--c-primary-light: var(--ink-light);
--c-primary-bg: #EEF2F2;
--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: #5B7FA5;
--c-text: #1A1A1A;
--c-text-secondary: var(--warm-gray);
--c-text-muted: #AAA59C;
--c-bg: var(--paper);
--c-bg-card: var(--surface);
--c-border: var(--warm-border);
/* ── Effects ── */
--shadow-sm: 0 1px 2px rgba(28,55,56,0.04);
--shadow: 0 2px 6px rgba(28,55,56,0.06);
--shadow-md: 0 6px 18px rgba(28,55,56,0.08);
--shadow-lg: 0 12px 36px rgba(28,55,56,0.10);
--radius: 2px;
--radius-sm: 2px;
--radius-xs: 2px;
--transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
}
/* ── Reset & Base ── */
*, *::before, *::after { box-sizing: border-box; }
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-size: 15px;
line-height: 1.7;
color: var(--c-text);
background: var(--c-bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ── Editorial heading style ── */
h1, h2, h3, h4, h5, h6 {
font-family: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
letter-spacing: 0.02em;
}
/* ═══════════════════════════════════════════════════════════
Element Plus Global Overrides — Editorial Remap
═══════════════════════════════════════════════════════════ */
/* ── Card: sharp corners, subtle border, refined shadow ── */
.el-card {
border-radius: 2px !important;
border: 1px solid var(--c-border) !important;
box-shadow: var(--shadow) !important;
transition: box-shadow var(--transition);
background: var(--surface) !important;
}
.el-card:hover { box-shadow: var(--shadow-md) !important; }
.el-card__header {
border-bottom: 2px solid var(--gold) !important;
padding: 16px 20px !important;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-weight: 600;
font-size: 16px;
color: var(--ink);
letter-spacing: 0.03em;
}
.el-card__body { padding: 20px !important; }
/* ── Button: sharp, decisive ── */
.el-button {
border-radius: 2px !important;
font-weight: 500;
letter-spacing: 0.02em;
transition: all var(--transition);
font-family: 'Noto Serif SC', STSong, Songti SC, serif;
}
.el-button--primary {
background: var(--ink) !important;
border-color: var(--ink) !important;
color: #fff !important;
}
.el-button--primary:hover {
background: var(--ink-light) !important;
border-color: var(--ink-light) !important;
}
.el-button--success {
background: var(--sage) !important;
border-color: var(--sage) !important;
}
.el-button--danger {
background: var(--vermilion) !important;
border-color: var(--vermilion) !important;
}
.el-button--warning {
background: var(--amber) !important;
border-color: var(--amber) !important;
}
/* ── Tag: editorial label style ── */
.el-tag {
border-radius: 2px !important;
font-weight: 500;
font-family: 'Noto Serif SC', STSong, serif;
letter-spacing: 0.02em;
}
/* ── Tabs ── */
.el-tabs__item {
font-weight: 500;
font-family: 'ZCOOL XiaoWei', STSong, serif;
}
.el-tabs__active-bar { background: var(--gold) !important; }
.el-tabs__item.is-active { color: var(--ink) !important; }
/* ── Table ── */
.el-table th.el-table__cell {
background: var(--c-primary-bg);
color: var(--ink);
font-weight: 600;
font-size: 13px;
font-family: 'ZCOOL XiaoWei', STSong, serif;
letter-spacing: 0.03em;
}
.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
background: var(--c-primary-bg);
}
/* ── Pagination ── */
.el-pagination { margin-top: 16px; }
/* ── Dialog ── */
.el-dialog {
border-radius: 2px !important;
}
.el-dialog__header {
padding: 20px 24px 0 !important;
font-weight: 600;
font-family: 'ZCOOL XiaoWei', STSong, serif;
}
.el-dialog__body { padding: 20px 24px !important; }
/* ── Menu ── */
.el-menu { border-right: none !important; }
/* ── Progress ── */
.el-progress-bar__outer { border-radius: 0 !important; }
.el-progress-bar__inner { border-radius: 0 !important; background: var(--ink) !important; }
/* ── Form ── */
.el-form { max-width: 100%; }
.el-select, .el-date-editor { max-width: 100%; }
.el-table { max-width: 100%; }
.el-table__body-wrapper { overflow-x: auto; }
.el-radio-button__inner {
min-height: 36px;
line-height: 36px;
border-radius: 2px !important;
}
.el-radio-button__orig-radio:checked + .el-radio-button__inner {
background: var(--ink) !important;
border-color: var(--ink) !important;
}
/* ── Input & Select borders ── */
.el-input__wrapper {
border-radius: 2px !important;
box-shadow: 0 0 0 1px var(--c-border) inset !important;
}
.el-input__wrapper:hover { box-shadow: 0 0 0 1px var(--ink) inset !important; }
.el-input__wrapper.is-focus { box-shadow: 0 0 0 2px var(--ink) inset !important; }
.el-select .el-input__wrapper { border-radius: 2px !important; }
/* ── Date picker ── */
.el-date-editor.el-input { border-radius: 2px !important; }
/* ── Textarea ── */
.el-textarea__inner {
border-radius: 2px !important;
font-family: 'Noto Serif SC', STSong, serif;
}
/* ── Radio ── */
.el-radio__input.is-checked .el-radio__inner {
background: var(--ink) !important;
border-color: var(--ink) !important;
}
.el-radio__input.is-checked + .el-radio__label { color: var(--ink) !important; }
/* ── Checkbox ── */
.el-checkbox__input.is-checked .el-checkbox__inner {
background: var(--ink) !important;
border-color: var(--ink) !important;
}
/* ── Switch ── */
.el-switch.is-checked .el-switch__core {
background: var(--ink) !important;
border-color: var(--ink) !important;
}
/* ── Loading spinner ── */
.el-loading-spinner .circular circle { stroke: var(--gold) !important; }
/* ── Message box ── */
.el-message-box { border-radius: 2px !important; }
/* ── Notification ── */
.el-notification { border-radius: 2px !important; }
/* ── Divider ── */
.el-divider__text {
font-family: 'ZCOOL XiaoWei', STSong, serif;
color: var(--warm-gray);
}
/* ── Image Preview Overlay ── */
.image-preview-overlay {
position: fixed; inset: 0; z-index: 9999;
background: rgba(0,0,0,0.88);
display: flex; align-items: center; justify-content: center;
cursor: zoom-out;
}
.image-preview-full {
max-width: 95vw; max-height: 95vh;
object-fit: contain; cursor: default;
}
.image-preview-close {
position: absolute; top: 16px; right: 16px;
background: rgba(255,255,255,0.15); color: #fff;
border: none; width: 40px; height: 40px; font-size: 20px;
cursor: pointer; border-radius: 50%; transition: background 0.2s;
}
.image-preview-close:hover { background: rgba(255,255,255,0.35); }
</style>
+13
View File
@@ -0,0 +1,13 @@
import api from './index'
export const authApi = {
casdoorLogin(code: string, state: string) {
return api.post('/auth/casdoor-login', { code, state })
},
wecomLogin(code: string) {
return api.post('/auth/wecom-login', { code })
},
bindWecom(casdoorCode: string, wecomUserid: string) {
return api.post('/auth/bind-wecom', { casdoor_code: casdoorCode, wecom_userid: wecomUserid })
},
}
+32
View File
@@ -0,0 +1,32 @@
import api from './index'
export const customersApi = {
list(params?: any) {
return api.get('/customers/', { params })
},
get(id: string) {
return api.get(`/customers/${id}`)
},
create(data: any) {
return api.post('/customers/', data)
},
update(id: string, data: any) {
return api.put(`/customers/${id}`, data)
},
delete(id: string) {
return api.delete(`/customers/${id}`)
},
checkDuplicate(name: string) {
return api.get(`/customers/check-duplicate/${encodeURIComponent(name)}`)
},
// Assignments
listAssignments(customerId: string) {
return api.get(`/customers/${customerId}/assignments`)
},
assignManager(customerId: string, data: any) {
return api.post(`/customers/${customerId}/assignments`, data)
},
batchAssign(data: any) {
return api.post('/customers/batch-assign', data)
},
}
+13
View File
@@ -0,0 +1,13 @@
import api from './index'
export const dashboardApi = {
getStats(params?: any) {
return api.get('/dashboard/stats', { params })
},
getProgress(params?: any) {
return api.get('/dashboard/progress', { params })
},
getWeeklyReport(params?: any) {
return api.get('/dashboard/weekly-report', { params })
},
}
+30
View File
@@ -0,0 +1,30 @@
import axios from 'axios'
import { useAuthStore } from '@/stores/auth'
import router from '@/router'
const api = axios.create({
baseURL: '/api',
timeout: 30000,
})
api.interceptors.request.use((config) => {
const auth = useAuthStore()
if (auth.token) {
config.headers.Authorization = `Bearer ${auth.token}`
}
return config
})
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
const auth = useAuthStore()
auth.logout()
router.push('/login')
}
return Promise.reject(error)
}
)
export default api
+16
View File
@@ -0,0 +1,16 @@
import api from './index'
export const keyVisitsApi = {
list(params?: any) {
return api.get('/key-visits/', { params })
},
create(data: any) {
return api.post('/key-visits/', data)
},
update(id: string, data: any) {
return api.put(`/key-visits/${id}`, data)
},
delete(id: string) {
return api.delete(`/key-visits/${id}`)
},
}
+16
View File
@@ -0,0 +1,16 @@
import api from './index'
export const miniBusinessApi = {
list(params?: any) {
return api.get('/mini-business/', { params })
},
create(data: any) {
return api.post('/mini-business/', data)
},
update(id: string, data: any) {
return api.put(`/mini-business/${id}`, data)
},
delete(id: string) {
return api.delete(`/mini-business/${id}`)
},
}
+20
View File
@@ -0,0 +1,20 @@
import api from './index'
import axios from 'axios'
export const uploadApi = {
async getPresignedUrl(filename: string, contentType: string = 'image/jpeg') {
return api.post('/upload/presigned-url', null, {
params: { filename, content_type: contentType },
})
},
getDownloadUrl(objectKey: string) {
return api.get('/upload/download-url', { params: { object_key: objectKey } })
},
// Direct upload to MinIO
async uploadFile(uploadUrl: string, file: File) {
return axios.put(uploadUrl, file, {
headers: { 'Content-Type': file.type },
timeout: 60000,
})
},
}
+22
View File
@@ -0,0 +1,22 @@
import api from './index'
export const visitsApi = {
list(params?: any) {
return api.get('/visits/', { params })
},
getToday() {
return api.get('/visits/today')
},
get(id: string) {
return api.get(`/visits/${id}`)
},
create(data: any) {
return api.post('/visits/', data)
},
update(id: string, data: any) {
return api.put(`/visits/${id}`, data)
},
delete(id: string) {
return api.delete(`/visits/${id}`)
},
}
+16
View File
@@ -0,0 +1,16 @@
import api from './index'
export const workPlansApi = {
list(params?: any) {
return api.get('/work-plans/', { params })
},
create(data: any) {
return api.post('/work-plans/', data)
},
update(id: string, data: any) {
return api.put(`/work-plans/${id}`, data)
},
delete(id: string) {
return api.delete(`/work-plans/${id}`)
},
}
+340
View File
@@ -0,0 +1,340 @@
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { computed, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const collapsed = ref(false)
interface MenuGroup { label?: string; items: { path: string; label: string; icon: string }[] }
const menuGroups = computed<MenuGroup[]>(() => {
const groups: MenuGroup[] = [
{
label: '汇总',
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>' },
],
},
{
label: '工作',
items: [
{ 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>' },
],
},
]
if (auth.isManager || auth.isDirector) {
groups.push({
label: undefined,
items: [
{ path: '/workspace', label: '我的数据', icon: '<line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line>' },
],
})
}
groups.push({
label: '管理',
items: [
{ path: '/customers', label: '客户管理', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle>' },
],
})
if (auth.isDirector) {
groups[groups.length - 1].items.push(
{ path: '/users', label: '用户管理', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path>' },
{ path: '/settings', label: '系统设置', icon: '<circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>' },
)
}
return groups
})
const roleLabel = computed(() => {
if (auth.isDirector) return '支局长'
if (auth.isLeader) return '分管领导'
return '客户经理'
})
</script>
<template>
<div class="desktop-layout">
<!-- Sidebar Ink & Gold Editorial -->
<aside class="sidebar" :class="{ 'sidebar--collapsed': collapsed }">
<div class="sidebar-brand">
<h1 class="brand-title">企迹</h1>
<p class="brand-sub">政企周报管理</p>
<div class="brand-rule"></div>
</div>
<nav class="sidebar-nav">
<template v-for="group in menuGroups" :key="group.label || 'nogroup'">
<div v-if="group.label" class="nav-group-label">{{ group.label }}</div>
<router-link
v-for="item in group.items"
:key="item.path"
:to="item.path"
class="nav-item"
:class="{ 'nav-item--active': route.path === item.path }"
>
<svg class="nav-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" v-html="item.icon"></svg>
<span class="nav-label">{{ item.label }}</span>
</router-link>
</template>
</nav>
<div class="sidebar-footer">
<span class="footer-role">{{ roleLabel }}</span>
<span class="footer-name">{{ auth.name }}</span>
</div>
</aside>
<!-- Main Area -->
<div class="main-area">
<header class="topbar">
<button class="collapse-btn" @click="collapsed = !collapsed" :title="collapsed ? '展开菜单' : '折叠菜单'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline :points="collapsed ? '13 17 18 12 13 7' : '11 7 6 12 11 17'"></polyline>
</svg>
</button>
<div class="topbar-actions">
<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>
<line x1="12" y1="18" x2="12.01" y2="18"></line>
</svg>
移动端
</button>
<button class="topbar-btn topbar-btn--logout" @click="auth.logout(); router.push('/login')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
退出
</button>
</div>
</header>
<main class="content">
<router-view />
</main>
</div>
</div>
</template>
<style scoped>
/* ═══ Layout ═══ */
.desktop-layout {
display: flex;
height: 100vh;
overflow: hidden;
}
/* ═══ Sidebar ═══ */
.sidebar {
width: 240px;
background: var(--ink);
color: #fff;
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
transition: width 0.25s ease;
}
.sidebar--collapsed {
width: 64px;
}
.sidebar--collapsed .brand-sub,
.sidebar--collapsed .brand-rule,
.sidebar--collapsed .nav-label,
.sidebar--collapsed .footer-role,
.sidebar--collapsed .footer-name { display: none; }
.sidebar--collapsed .brand-title { font-size: 20px; text-align: center; }
.sidebar--collapsed .nav-item { justify-content: center; padding: 11px 0; }
.sidebar--collapsed .nav-icon { font-size: 20px; margin: 0; }
.sidebar--collapsed .sidebar-footer { text-align: center; padding: 10px; }
.sidebar-brand {
padding: 26px 22px 18px;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.brand-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
font-size: 24px;
font-weight: 400;
letter-spacing: 0.12em;
color: #fff;
line-height: 1.2;
}
.brand-sub {
margin: 2px 0 0;
font-family: 'Noto Serif SC', STSong, serif;
font-size: 10px;
color: rgba(255,255,255,0.4);
letter-spacing: 0.1em;
}
.brand-rule {
width: 28px;
height: 3px;
background: var(--gold);
margin-top: 14px;
}
/* ═══ Navigation ═══ */
.sidebar-nav {
flex: 1;
padding: 12px 10px;
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
}
.nav-group-label {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 10px;
letter-spacing: 0.15em;
color: rgba(255,255,255,0.25);
padding: 12px 14px 4px;
text-transform: uppercase;
}
.sidebar--collapsed .nav-group-label { display: none; }
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 14px;
color: rgba(255,255,255,0.55);
text-decoration: none;
font-family: 'Noto Serif SC', STSong, serif;
font-size: 14px;
letter-spacing: 0.04em;
transition: all 0.22s;
border-left: 2px solid transparent;
}
.nav-item:hover {
color: rgba(255,255,255,0.85);
background: rgba(255,255,255,0.04);
}
.nav-item--active {
color: #fff;
background: rgba(255,255,255,0.06);
border-left-color: var(--gold);
}
.nav-icon {
font-size: 18px;
flex-shrink: 0;
}
.nav-label {
font-weight: 500;
}
/* ═══ Sidebar Footer ═══ */
.sidebar-footer {
padding: 14px 22px;
border-top: 1px solid rgba(255,255,255,0.06);
display: flex;
flex-direction: column;
gap: 2px;
}
.footer-role {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 10px;
color: var(--gold);
letter-spacing: 0.1em;
}
.footer-name {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 13px;
color: rgba(255,255,255,0.7);
letter-spacing: 0.04em;
}
/* ═══ Main Area ═══ */
.main-area {
flex: 1;
display: flex;
flex-direction: column;
background: var(--paper);
min-width: 0;
}
/* ═══ Topbar ═══ */
.topbar {
background: var(--surface);
padding: 10px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--warm-border);
flex-shrink: 0;
}
.topbar-greeting {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 14px;
color: var(--ink);
letter-spacing: 0.04em;
}
.collapse-btn {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px;
background: none; border: none;
color: var(--warm-gray); cursor: pointer;
transition: all 0.2s; flex-shrink: 0; margin-right: 8px;
opacity: 0.5;
}
.collapse-btn:hover { color: var(--ink); opacity: 1; }
.topbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.topbar-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
background: none;
border: 1px solid var(--warm-border);
color: var(--warm-gray);
font-family: 'Noto Serif SC', STSong, serif;
font-size: 12px;
letter-spacing: 0.03em;
cursor: pointer;
transition: all 0.2s;
}
.topbar-btn:hover {
color: var(--ink);
border-color: var(--ink);
}
.topbar-btn--logout:hover {
color: var(--vermilion);
border-color: var(--vermilion);
}
/* ═══ Content ═══ */
.content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
</style>
+196
View File
@@ -0,0 +1,196 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
editLog: Array<{ editor: string; time: string; changes: Record<string, [string, string]>; reason?: string }>
}>()
const fieldLabels: Record<string, string> = {
customer_id: '客户单位',
customer_name: '客户名称',
visit_date: '拜访日期',
note_date: '日期',
visit_method: '拜访方式',
time_range: '时间范围',
visitor_name: '拜访人',
visitor_phone: '电话',
communication_content: '沟通内容',
customer_demand: '客户需求',
category: '分类',
content: '工作内容',
plan_content: '计划内容',
plan_date: '计划时间',
status: '状态',
product_type: '产品类型',
amount: '金额',
follow_up_detail: '跟进内容',
expected_revenue_date: '预计列收',
urgency_level: '紧急度',
description: '描述',
progress_status: '进展',
planned_date: '计划日期',
planned_visitor: '拜访人',
visit_target: '拜访对象',
}
const hasHistory = computed(() => (props.editLog || []).length > 0)
function formatTime(t: string) {
try {
const d = new Date(t)
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
} catch { return t }
}
function labelFor(field: string) {
return fieldLabels[field] || field
}
</script>
<template>
<el-collapse v-if="hasHistory" style="margin-top:16px">
<el-collapse-item title="变更记录" name="1">
<div class="log-timeline">
<div v-for="(entry, idx) in editLog" :key="idx" class="log-entry"
:class="{ 'log-entry--create': !entry.changes || Object.keys(entry.changes).length === 0 }">
<div class="log-dot"></div>
<div class="log-body">
<div class="log-meta">
<span class="log-editor">{{ entry.editor }}</span>
<span class="log-time">{{ formatTime(entry.time) }}</span>
<el-tag v-if="!entry.changes || Object.keys(entry.changes).length === 0" size="small" type="info" effect="plain">创建</el-tag>
</div>
<div v-if="entry.changes && Object.keys(entry.changes).length > 0" class="log-changes">
<div v-for="(vals, field) in entry.changes" :key="field" class="log-change">
<span class="change-label">{{ labelFor(field) }}</span>
<span class="change-old">{{ vals[0] || '(空)' }}</span>
<span class="change-arrow"></span>
<span class="change-new">{{ vals[1] || '(空)' }}</span>
</div>
</div>
<div v-if="entry.reason" class="log-reason">
<span class="reason-label">原因</span>{{ entry.reason }}
</div>
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
</template>
<style scoped>
.log-timeline {
padding: 4px 0;
}
.log-entry {
display: flex;
gap: 10px;
padding: 10px 0;
border-left: 2px solid var(--warm-border);
margin-left: 6px;
padding-left: 16px;
}
.log-entry:first-child {
border-left-color: var(--gold);
}
.log-entry--create {
opacity: 0.6;
}
.log-dot {
width: 8px; height: 8px;
background: var(--warm-border);
margin-left: -21px;
margin-top: 5px;
flex-shrink: 0;
}
.log-entry:first-child .log-dot {
background: var(--gold);
}
.log-body {
flex: 1;
min-width: 0;
}
.log-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.log-editor {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 13px;
color: var(--ink);
font-weight: 600;
}
.log-time {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 11px;
color: var(--c-text-muted);
}
.log-changes {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 4px;
}
.log-change {
font-size: 12px;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.change-label {
font-weight: 600;
color: var(--ink);
min-width: 56px;
}
.change-old {
color: var(--vermilion);
text-decoration: line-through;
text-decoration-color: var(--vermilion);
text-decoration-thickness: 1px;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.change-arrow {
color: var(--gold);
font-weight: 600;
}
.change-new {
color: var(--sage);
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.log-reason {
margin-top: 4px;
font-size: 12px;
color: var(--c-text-secondary);
font-style: italic;
}
.reason-label {
color: var(--warm-gray);
font-weight: 500;
}
</style>
+324
View File
@@ -0,0 +1,324 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
const props = defineProps<{
modelValue: boolean
imageUrl: string
}>()
const emit = defineEmits<{
'update:modelValue': [v: boolean]
}>()
const scale = ref(1)
const fitToPage = ref(true)
const panX = ref(0)
const panY = ref(0)
const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartY = ref(0)
const panStartX = ref(0)
const panStartY = ref(0)
const imageEl = ref<HTMLImageElement | null>(null)
const overlayEl = ref<HTMLDivElement | null>(null)
function close() {
emit('update:modelValue', false)
}
function reset() {
scale.value = 1
fitToPage.value = true
panX.value = 0
panY.value = 0
}
watch(() => props.imageUrl, reset)
watch(() => props.modelValue, (v) => { if (v) reset() })
function zoomIn() {
fitToPage.value = false
scale.value = Math.min(5, +(scale.value + 0.25).toFixed(2))
clampPan()
}
function zoomOut() {
fitToPage.value = false
const next = +(scale.value - 0.25).toFixed(2)
if (next < 0.25) {
scale.value = 1
fitToPage.value = true
panX.value = 0
panY.value = 0
} else {
scale.value = next
clampPan()
}
}
function toggleFit() {
if (fitToPage.value) {
fitToPage.value = false
scale.value = 1
} else {
fitToPage.value = true
scale.value = 1
panX.value = 0
panY.value = 0
}
}
function clampPan() {
if (!imageEl.value || !overlayEl.value) return
const img = imageEl.value
const naturalRatio = img.naturalWidth / img.naturalHeight
const viewW = overlayEl.value.clientWidth
const viewH = overlayEl.value.clientHeight
const displayW = naturalRatio > viewW / viewH ? viewW : viewH * naturalRatio
const displayH = naturalRatio > viewW / viewH ? viewW / naturalRatio : viewH
const scaledW = displayW * scale.value
const scaledH = displayH * scale.value
const maxX = Math.max(0, (scaledW - viewW) / 2)
const maxY = Math.max(0, (scaledH - viewH) / 2)
panX.value = Math.max(-maxX, Math.min(maxX, panX.value))
panY.value = Math.max(-maxY, Math.min(maxY, panY.value))
}
function onWheel(e: WheelEvent) {
e.preventDefault()
if (e.deltaY < 0) zoomIn()
else zoomOut()
}
function onDblClickImg(e: MouseEvent) {
e.preventDefault()
toggleFit()
}
/* ── Drag to pan ── */
function onPointerDown(e: PointerEvent) {
if (fitToPage.value) return
isDragging.value = true
dragStartX.value = e.clientX
dragStartY.value = e.clientY
panStartX.value = panX.value
panStartY.value = panY.value
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
}
function onPointerMove(e: PointerEvent) {
if (!isDragging.value) return
panX.value = panStartX.value + (e.clientX - dragStartX.value)
panY.value = panStartY.value + (e.clientY - dragStartY.value)
clampPan()
}
function onPointerUp(_e: PointerEvent) {
isDragging.value = false
}
function onOverlayClick(e: MouseEvent) {
if (e.target === overlayEl.value) close()
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') close()
if (e.key === '+') zoomIn()
if (e.key === '-') zoomOut()
if (e.key === '0') reset()
}
const scalePercent = computed(() => Math.round(scale.value * 100))
const imgStyle = computed(() => {
if (fitToPage.value) {
return {
maxWidth: '95vw',
maxHeight: '90vh',
objectFit: 'contain' as const,
transform: 'none',
cursor: 'default',
}
}
return {
maxWidth: 'none',
maxHeight: 'none',
objectFit: 'none' as const,
transform: `translate(${panX.value}px, ${panY.value}px) scale(${scale.value})`,
transformOrigin: 'center center',
cursor: isDragging.value ? 'grabbing' : (scale.value > 1 ? 'grab' : 'default'),
}
})
</script>
<template>
<teleport to="body">
<div
v-if="modelValue"
ref="overlayEl"
class="image-preview-overlay"
@click="onOverlayClick"
@wheel="onWheel"
@keydown="onKeydown"
tabindex="0"
>
<!-- Image wrapper for centering -->
<div class="preview-stage" @dblclick="onDblClickImg">
<img
ref="imageEl"
:src="imageUrl"
:style="imgStyle"
class="preview-img"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointerleave="onPointerUp"
@dblclick.stop="onDblClickImg"
@dragstart.prevent
/>
</div>
<!-- Bottom toolbar -->
<div class="preview-toolbar">
<button class="toolbar-btn" title="缩小" @click="zoomOut">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="toolbar-btn toolbar-btn--fit" :class="{ active: fitToPage }" title="适应页面" @click="toggleFit">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
<span v-if="fitToPage" class="fit-label">适应</span>
<span v-else class="fit-label">{{ scalePercent }}%</span>
</button>
<button class="toolbar-btn" title="放大" @click="zoomIn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<div class="toolbar-divider"></div>
<button class="toolbar-btn toolbar-btn--close" title="关闭 (Esc)" @click="close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</div>
</teleport>
</template>
<style scoped>
/* ═══ Overlay ═══ */
.image-preview-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
outline: none;
user-select: none;
-webkit-user-select: none;
}
/* ═══ Stage (image container) ═══ */
.preview-stage {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
overflow: hidden;
}
/* ═══ Image ═══ */
.preview-img {
display: block;
transition: transform 0.08s ease-out;
pointer-events: auto;
}
/* ═══ Bottom Toolbar ═══ */
.preview-toolbar {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 2px;
background: rgba(28, 55, 56, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
padding: 6px 8px;
border-radius: 6px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
z-index: 10;
}
.toolbar-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 36px;
height: 36px;
padding: 0 8px;
border: none;
border-radius: 4px;
background: transparent;
color: rgba(255, 255, 255, 0.75);
cursor: pointer;
font-family: 'Noto Serif SC', STSong, serif;
font-size: 12px;
transition: all 0.15s;
white-space: nowrap;
}
.toolbar-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.toolbar-btn.active {
background: rgba(196, 147, 74, 0.3);
color: var(--gold-light, #D4AD6E);
}
.toolbar-btn--fit {
min-width: 56px;
}
.fit-label {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 11px;
letter-spacing: 0.03em;
}
.toolbar-divider {
width: 1px;
height: 20px;
background: rgba(255, 255, 255, 0.2);
margin: 0 4px;
}
.toolbar-btn--close:hover {
background: rgba(184, 71, 46, 0.5);
color: #fff;
}
/* ── Mobile tweaks ── */
@media (max-width: 768px) {
.preview-toolbar {
bottom: 20px;
padding: 8px 12px;
gap: 4px;
border-radius: 8px;
}
.toolbar-btn {
min-width: 40px;
height: 40px;
border-radius: 6px;
}
.toolbar-btn--fit {
min-width: 60px;
}
}
</style>
+281
View File
@@ -0,0 +1,281 @@
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const tabs = [
{
path: '/m',
label: '首页',
icon: 'home',
},
{
path: '/m/visit/new',
label: '拜访',
icon: 'visit',
},
{
path: '/m/note/new',
label: '纪要',
icon: 'note',
},
]
const activeTab = computed(() => {
if (route.path === '/m') return '/m'
if (route.path.includes('/visit')) return '/m/visit/new'
if (route.path.includes('/note')) return '/m/note/new'
return route.path
})
</script>
<template>
<div class="mobile-layout">
<!-- Magazine Header -->
<header class="mobile-header">
<div class="header-brand">
<h1 class="header-title">企迹</h1>
<span class="header-subtitle">政企周报</span>
<div class="header-rule"></div>
</div>
<div class="header-actions">
<button class="header-pc-btn" @click="router.push('/')" title="PC版">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>
</button>
<span class="header-user">{{ auth.name }}</span>
</div>
</header>
<!-- Main Content -->
<main class="mobile-main">
<!-- Gold corner accent (decorative) -->
<div class="page-accent"></div>
<router-view />
</main>
<!-- Floating Pill TabBar -->
<nav class="mobile-tabbar">
<div class="tabbar-pill">
<div
v-for="tab in tabs"
:key="tab.path"
class="tab-item"
:class="{ active: activeTab === tab.path }"
@click="router.push(tab.path)"
>
<!-- Home icon -->
<svg v-if="tab.icon === 'home'" class="tab-icon" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
<!-- Visit icon -->
<svg v-else-if="tab.icon === 'visit'" class="tab-icon" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect>
<line x1="12" y1="11" x2="12" y2="17"></line>
<line x1="9" y1="14" x2="15" y2="14"></line>
</svg>
<!-- Note icon -->
<svg v-else-if="tab.icon === 'note'" class="tab-icon" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="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>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
<span class="tab-label">{{ tab.label }}</span>
</div>
</div>
</nav>
</div>
</template>
<style scoped>
/* ═══ Layout ═══ */
.mobile-layout {
display: flex;
flex-direction: column;
height: 100dvh;
width: 100%;
overflow: hidden;
background: var(--paper);
position: relative;
}
/* ═══ Header — Editorial Style ═══ */
.mobile-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 18px 20px 10px;
background: var(--paper);
flex-shrink: 0;
position: relative;
}
.header-brand {
display: flex;
flex-direction: column;
gap: 2px;
}
.header-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
font-size: 26px;
font-weight: 400;
color: var(--ink);
letter-spacing: 0.08em;
line-height: 1.2;
}
.header-subtitle {
font-family: 'Noto Serif SC', STSong, Songti SC, serif;
font-size: 11px;
color: var(--warm-gray);
letter-spacing: 0.12em;
text-transform: uppercase;
margin-left: 1px;
}
.header-rule {
width: 28px;
height: 3px;
background: var(--gold);
margin-top: 8px;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
padding-top: 2px;
}
.header-pc-btn {
background: none;
border: 1px solid var(--warm-border);
border-radius: 2px;
padding: 4px 6px;
cursor: pointer;
color: var(--warm-gray);
display: flex;
align-items: center;
transition: all var(--transition);
}
.header-pc-btn:hover {
color: var(--ink);
border-color: var(--ink);
}
.header-user {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 13px;
color: var(--warm-gray);
letter-spacing: 0.03em;
}
/* ═══ Main Scroll Area ═══ */
.mobile-main {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 0 16px 20px;
-webkit-overflow-scrolling: touch;
position: relative;
}
/* Decorative accent — subtle diagonal line in corner */
.page-accent {
position: fixed;
top: 60px;
right: 0;
width: 60px;
height: 60px;
background: linear-gradient(135deg, transparent 60%, rgba(196,147,74,0.06) 60%);
pointer-events: none;
z-index: 0;
}
/* ═══ Floating Pill TabBar ═══ */
.mobile-tabbar {
display: flex;
justify-content: center;
padding: 0 16px 12px;
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
flex-shrink: 0;
position: relative;
z-index: 10;
}
.tabbar-pill {
display: flex;
background: var(--surface);
border: 1px solid var(--warm-border);
border-radius: 40px;
padding: 6px 8px;
box-shadow: 0 4px 18px rgba(28,55,56,0.08);
gap: 4px;
}
.tab-item {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 32px;
color: var(--warm-gray);
font-size: 13px;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
font-family: 'Noto Serif SC', STSong, serif;
letter-spacing: 0.03em;
}
.tab-item:active {
transform: scale(0.95);
}
.tab-item.active {
background: var(--ink);
color: #fff;
box-shadow: 0 2px 8px rgba(28,55,56,0.25);
}
.tab-item.active .tab-icon {
stroke: #fff;
}
.tab-icon {
flex-shrink: 0;
transition: stroke 0.25s;
}
.tab-label {
font-weight: 500;
}
/* Active tab has gold accent dot */
.tab-item.active .tab-label::after {
content: '';
display: inline-block;
width: 4px;
height: 4px;
background: var(--gold);
border-radius: 50%;
margin-left: 6px;
vertical-align: middle;
position: relative;
top: -1px;
}
</style>
+21
View File
@@ -0,0 +1,21 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_CASDOOR_ENDPOINT: string
readonly VITE_CASDOOR_CLIENT_ID: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module 'element-plus/dist/locale/zh-cn.mjs' {
const zhCn: Record<string, any>
export default zhCn
}
+14
View File
@@ -0,0 +1,14 @@
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'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn as any })
app.mount('#app')
+66
View File
@@ -0,0 +1,66 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { public: true },
},
{
path: '/bind-wecom',
name: 'BindWecom',
component: () => import('@/views/BindWecom.vue'),
meta: { public: true },
},
// Mobile routes (manager-facing)
{
path: '/m',
component: () => import('@/components/MobileLayout.vue'),
children: [
{ path: '', name: 'MobileHome', component: () => import('@/views/mobile/Home.vue') },
{ path: 'visit/new', name: 'VisitForm', component: () => import('@/views/mobile/VisitForm.vue') },
{ path: 'visit/:id/edit', name: 'VisitEdit', component: () => import('@/views/mobile/VisitForm.vue') },
{ path: 'work-plan/new', name: 'WorkPlanForm', component: () => import('@/views/mobile/WorkPlanForm.vue') },
{ path: 'mini-biz/new', name: 'MiniBusinessForm', component: () => import('@/views/mobile/MiniBusinessForm.vue') },
{ 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') },
],
},
// Desktop routes (director/leader-facing)
{
path: '/',
component: () => import('@/components/DesktopLayout.vue'),
children: [
{ path: '', name: 'Dashboard', component: () => import('@/views/desktop/Dashboard.vue') },
{ path: 'weekly-report', name: 'WeeklyReport', component: () => import('@/views/desktop/WeeklyReport.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: 'workspace', name: 'ManagerWorkspace', component: () => import('@/views/desktop/ManagerWorkspace.vue') },
{ path: 'customers', name: 'CustomerManage', component: () => import('@/views/desktop/CustomerManage.vue') },
{ path: 'users', name: 'UserManage', component: () => import('@/views/desktop/UserManage.vue') },
{ path: 'settings', name: 'Settings', component: () => import('@/views/desktop/Settings.vue') },
],
},
],
})
router.beforeEach((to, _from, next) => {
const auth = useAuthStore()
if (to.meta.public) {
next()
return
}
if (!auth.isLoggedIn) {
next('/login')
return
}
next()
})
export default router
+56
View File
@@ -0,0 +1,56 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authApi } from '@/api/auth'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token') || '')
const userId = ref(localStorage.getItem('userId') || '')
const name = ref(localStorage.getItem('userName') || '')
const role = ref(localStorage.getItem('userRole') || '')
const isLoggedIn = computed(() => !!token.value)
const isManager = computed(() => role.value === 'manager')
const isDirector = computed(() => role.value === 'director')
const isLeader = computed(() => role.value === 'leader')
function saveLogin(data: { access_token: string; user_id: string; name: string; role: string }) {
token.value = data.access_token
userId.value = data.user_id
name.value = data.name
role.value = data.role
localStorage.setItem('token', data.access_token)
localStorage.setItem('userId', data.user_id)
localStorage.setItem('userName', data.name)
localStorage.setItem('userRole', data.role)
}
function logout() {
token.value = ''
userId.value = ''
name.value = ''
role.value = ''
localStorage.clear()
}
async function casdoorLogin(code: string, state: string) {
const res = await authApi.casdoorLogin(code, state)
saveLogin(res.data)
return res.data
}
async function wecomLogin(code: string) {
return await authApi.wecomLogin(code)
}
async function bindWecom(casdoorCode: string, wecomUserid: string) {
const res = await authApi.bindWecom(casdoorCode, wecomUserid)
saveLogin(res.data)
return res.data
}
return {
token, userId, name, role,
isLoggedIn, isManager, isDirector, isLeader,
saveLogin, logout, casdoorLogin, wecomLogin, bindWecom,
}
})
+34
View File
@@ -0,0 +1,34 @@
/* ── 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');
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ── Base layer custom styles ── */
@layer base {
:root {
--ink: #1C3738;
--ink-light: #2A4F50;
--vermilion: #B8472E;
--vermilion-light: #D46A4F;
--gold: #C4934A;
--gold-light: #D4AD6E;
--paper: #F5F0E8;
--paper-dark: #EBE4D8;
--sage: #4A6741;
--amber: #C68B3C;
--warm-gray: #7B7568;
--warm-border: #E5DFD3;
--text-primary: #1A1A1A;
}
html, body, #app {
margin: 0;
padding: 0;
height: 100%;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
background: var(--paper);
}
}
+5
View File
@@ -0,0 +1,5 @@
/** Get today's date in YYYY-MM-DD format (local timezone). */
export function todayStr(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const loading = ref(true)
onMounted(async () => {
const code = route.query.code as string
const state = route.query.state as string // This is wecom_userid
if (!code) {
ElMessage.error('缺少授权参数')
router.push('/login')
return
}
try {
await auth.bindWecom(code, state || '')
ElMessage.success('企微绑定成功')
router.push(auth.isManager ? '/m' : '/')
} catch (e: any) {
ElMessage.error('绑定失败: ' + (e.response?.data?.detail || e.message))
router.push('/login')
} finally {
loading.value = false
}
})
</script>
<template>
<div class="bind-page">
<el-card v-loading="loading">
<template #header>正在完成企微账号绑定...</template>
<p v-if="loading">请稍候正在验证您的身份</p>
</el-card>
</div>
</template>
<style scoped>
.bind-page {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: #f5f7fa;
}
</style>
+145
View File
@@ -0,0 +1,145 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const loading = ref(false)
// Check for Casdoor callback
onMounted(async () => {
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)
ElMessage.success('登录成功')
router.push(auth.isManager ? '/m' : '/')
} catch (e: any) {
ElMessage.error('登录失败: ' + (e.response?.data?.detail || e.message))
} finally {
loading.value = false
}
return
}
// Check for wecom code
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
}
auth.saveLogin({
access_token: res.data.access_token,
user_id: res.data.user_id,
name: res.data.name,
role: res.data.role,
})
ElMessage.success('登录成功')
router.push(auth.isManager ? '/m' : '/')
} catch (e: any) {
ElMessage.error('企微登录失败')
} finally {
loading.value = false
}
}
})
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
}
</script>
<template>
<div class="login-page">
<div class="login-card">
<div class="brand">
<h1>企迹</h1>
<p>政企周报管理系统</p>
</div>
<el-button
type="primary"
size="large"
:loading="loading"
@click="goCasdoorLogin"
style="width: 100%"
>
登录 / 注册
</el-button>
<p class="hint">使用 Casdoor 账号登录</p>
</div>
</div>
</template>
<style scoped>
.login-page {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: linear-gradient(135deg, #4f6ef7 0%, #7b93fa 40%, #a5b4fc 100%);
position: relative;
overflow: hidden;
}
.login-page::before {
content: ''; position: absolute; width: 600px; height: 600px;
background: rgba(255,255,255,0.05); border-radius: 50%;
top: -200px; right: -200px;
}
.login-page::after {
content: ''; position: absolute; width: 400px; height: 400px;
background: rgba(255,255,255,0.04); border-radius: 50%;
bottom: -100px; left: -100px;
}
.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);
position: relative;
z-index: 1;
}
.brand {
text-align: center;
margin-bottom: 36px;
}
.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;
}
.brand p {
margin: 10px 0 0;
color: var(--c-text-secondary);
font-size: 14px;
letter-spacing: 1px;
}
.hint {
text-align: center;
color: var(--c-text-muted);
font-size: 12px;
margin-top: 20px;
}
</style>
@@ -0,0 +1,416 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { customersApi } from '@/api/customers'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api/index'
const auth = useAuthStore()
const loading = ref(false)
const search = ref('')
const filterIndustry = ref('')
const filterService = ref('')
const filterManagerId = ref('')
const customers = ref<any[]>([])
const currentPage = ref(1)
const pageSize = ref(25)
const total = ref(0)
const dialogVisible = ref(false)
const dialogTitle = ref('新建客户')
const editId = ref('')
const form = ref({
name: '', industry: '', address: '', in_use_services: '',
fee_amount: '', fee_unit: '元/月',
remarks: '',
contacts: [] as any[],
assignee_id: '' as string,
})
const existingContacts = ref<any[]>([])
const feeUnits = ['元/月', '元/年', '自定义']
const feeCustomUnit = ref('')
const detailVisible = ref(false)
const detailCustomer = ref<any>(null)
const managers = ref<any[]>([])
const selectedIds = ref<string[]>([])
const batchManagerId = ref('')
const importDialogVisible = ref(false)
const importFile = ref<File | null>(null)
const importLoading = ref(false)
const importResult = ref<any>(null)
onMounted(async () => {
await loadCustomers()
try {
const res = await api.get('/users/')
managers.value = (res.data || []).filter((u: any) => u.role !== 'leader')
} catch (_) {}
})
async function loadCustomers() {
loading.value = true
try {
const params: any = { page: currentPage.value, page_size: pageSize.value }
if (search.value) params.search = search.value
if (filterIndustry.value) params.industry = filterIndustry.value
if (filterService.value) params.service = filterService.value
if (filterManagerId.value) params.manager_id = filterManagerId.value
const res = await customersApi.list(params)
customers.value = res.data.items
total.value = res.data.total
} catch (e: any) { ElMessage.error('加载失败') }
finally { loading.value = false }
}
function onPageChange(page: number) { currentPage.value = page; loadCustomers() }
function onPageSizeChange(size: number) { pageSize.value = size; currentPage.value = 1; loadCustomers() }
function onFilterChange() { currentPage.value = 1; loadCustomers() }
function buildMonthlyFee(): string {
const amt = form.value.fee_amount.trim()
if (!amt) return ''
if (form.value.fee_unit === '自定义') {
const u = feeCustomUnit.value.trim()
return u ? amt + u : amt
}
return amt + form.value.fee_unit
}
function parseMonthlyFee(fee: string) {
feeCustomUnit.value = ''
if (!fee) { form.value.fee_amount = ''; form.value.fee_unit = '元/月'; return }
for (const u of ['元/月', '元/年']) {
if (fee.endsWith(u)) { form.value.fee_amount = fee.slice(0, -u.length).trim(); form.value.fee_unit = u; return }
}
const m = fee.match(/^(.+?)\s*([^\d]+)$/)
if (m) { form.value.fee_amount = m[1].trim(); feeCustomUnit.value = m[2].trim(); form.value.fee_unit = '自定义' }
else { form.value.fee_amount = fee; form.value.fee_unit = '自定义' }
}
function resetForm() {
form.value = { name: '', industry: '', address: '', in_use_services: '', fee_amount: '', fee_unit: '元/月', remarks: '', contacts: [], assignee_id: '' }
feeCustomUnit.value = ''
existingContacts.value = []
}
function openCreate() { dialogTitle.value = '新建客户'; editId.value = ''; resetForm(); dialogVisible.value = true }
async function openEdit(customer: any) {
dialogTitle.value = '编辑客户'; editId.value = customer.id
try {
const res = await customersApi.get(customer.id)
const c = res.data
resetForm()
form.value.name = c.name; form.value.industry = c.industry; form.value.address = c.address
form.value.in_use_services = c.in_use_services; form.value.remarks = c.remarks || ''
parseMonthlyFee(c.monthly_fee)
existingContacts.value = c.contacts || []
dialogVisible.value = true
} catch (e: any) { ElMessage.error('加载客户详情失败') }
}
async function handleSubmit() {
if (!form.value.name) { ElMessage.warning('请输入单位名称'); return }
const monthly_fee = buildMonthlyFee()
try {
if (editId.value) {
const body: any = { name: form.value.name, industry: form.value.industry, address: form.value.address, in_use_services: form.value.in_use_services, monthly_fee, remarks: form.value.remarks }
if (form.value.assignee_id) body.assignee_id = form.value.assignee_id
await customersApi.update(editId.value, body)
for (const c of form.value.contacts) {
if (c.name.trim()) await api.post(`/customers/${editId.value}/contacts`, { name: c.name.trim(), phone: c.phone.trim(), role_desc: c.role_desc.trim() })
}
ElMessage.success('已更新')
} else {
await customersApi.create({ name: form.value.name, industry: form.value.industry, address: form.value.address, in_use_services: form.value.in_use_services, monthly_fee, remarks: form.value.remarks, contacts: form.value.contacts, assignee_id: form.value.assignee_id || undefined })
ElMessage.success('已创建')
}
dialogVisible.value = false
await loadCustomers()
} catch (e: any) { ElMessage.error('操作失败: ' + (e.response?.data?.detail || e.message)) }
}
async function removeExistingContact(contactId: string) {
try {
await ElMessageBox.confirm('确定删除该联系人?', '确认', { type: 'warning' })
await api.delete(`/customers/${editId.value}/contacts/${contactId}`)
existingContacts.value = existingContacts.value.filter(c => c.id !== contactId)
ElMessage.success('已删除')
} 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]
}
async function openDetail(customer: any) {
try { const res = await customersApi.get(customer.id); detailCustomer.value = res.data; detailVisible.value = true } catch (_) {}
}
async function handleDelete(customer: any) {
try {
await ElMessageBox.confirm(`确定删除客户${customer.name}该操作不可恢复`, '确认删除', { type: 'warning' })
await customersApi.delete(customer.id)
ElMessage.success('已删除')
await loadCustomers()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
function onSelectionChange(rows: any[]) { selectedIds.value = rows.map(r => r.id) }
async function handleBatchAssign() {
if (!selectedIds.value.length) { ElMessage.warning('请先勾选客户'); return }
if (!batchManagerId.value) { ElMessage.warning('请选择目标客户经理'); return }
try {
await customersApi.batchAssign({ customer_ids: selectedIds.value, manager_id: batchManagerId.value })
ElMessage.success(`已将 ${selectedIds.value.length} 个客户分配给新经理`)
selectedIds.value = []; batchManagerId.value = ''
await loadCustomers()
} catch (e: any) { ElMessage.error('批量分配失败') }
}
async function handleExport() {
try {
const res = await api.get('/customers/export', { responseType: 'blob' })
const url = URL.createObjectURL(res.data)
const a = document.createElement('a'); a.href = url; a.download = 'customers.xlsx'; a.click()
URL.revokeObjectURL(url); ElMessage.success('导出成功')
} catch (e: any) { ElMessage.error('导出失败') }
}
async function downloadTemplate() {
try {
const res = await api.get('/customers/template', { responseType: 'blob' })
const url = URL.createObjectURL(res.data)
const a = document.createElement('a'); a.href = url; a.download = 'customer_import_template.xlsx'; a.click()
URL.revokeObjectURL(url)
} catch (e: any) { ElMessage.error('下载模板失败') }
}
function openImportDialog() { importFile.value = null; importResult.value = null; importDialogVisible.value = true }
function onImportFileChange(e: Event) { const t = e.target as HTMLInputElement; if (t.files?.[0]) importFile.value = t.files[0] }
async function handleImport() {
if (!importFile.value) return
importLoading.value = true
const fd = new FormData(); fd.append('file', importFile.value)
try {
const res = await api.post('/customers/import', fd, { headers: { 'Content-Type': 'multipart/form-data' } })
importResult.value = res.data
ElMessage.success(`导入完成:新增 ${res.data.created} 条,更新 ${res.data.updated || 0}`)
await loadCustomers()
} catch (e: any) { ElMessage.error('导入失败: ' + (e.response?.data?.detail || e.message)) }
finally { importLoading.value = false }
}
</script>
<template>
<div class="customer-manage" v-loading="loading">
<!-- Page Header -->
<div class="page-head">
<div class="page-head-row">
<h2 class="page-title">客户档案管理</h2>
<div class="header-btns">
<el-button v-if="auth.isDirector" @click="downloadTemplate">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<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 v-if="auth.isDirector" @click="openImportDialog">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
导入
</el-button>
<el-button v-if="auth.isDirector" @click="handleExport">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<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>
导出
</el-button>
<el-button v-if="auth.isDirector" type="primary" @click="openCreate">+ 新建客户</el-button>
</div>
</div>
<div class="page-rule"></div>
</div>
<!-- Search & Filter -->
<el-card style="margin-bottom: 16px;">
<el-row :gutter="12" align="middle">
<el-col :span="5">
<el-input v-model="search" placeholder="搜索名称/行业/地址/联系人" clearable @keyup.enter="loadCustomers">
<template #append><el-button @click="onFilterChange">搜索</el-button></template>
</el-input>
</el-col>
<el-col :span="4">
<el-input v-model="filterIndustry" placeholder="行业筛选" clearable @change="onFilterChange" />
</el-col>
<el-col :span="4">
<el-input v-model="filterService" placeholder="在用业务筛选" clearable @change="onFilterChange" />
</el-col>
<el-col :span="4">
<el-select v-model="filterManagerId" placeholder="负责人筛选" clearable @change="onFilterChange" 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="7" v-if="auth.isDirector && selectedIds.length > 0">
<span style="margin-right:8px; color: var(--warm-gray)">已选 {{ selectedIds.length }} </span>
<el-select v-model="batchManagerId" placeholder="目标客户经理" style="width:160px">
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
<el-button type="warning" @click="handleBatchAssign" style="margin-left:8px">批量转移</el-button>
</el-col>
</el-row>
</el-card>
<!-- Customer Table -->
<el-card>
<el-table :data="customers" stripe @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">
<template #default="{ row }">
<el-link type="primary" @click="openDetail(row)">{{ row.name }}</el-link>
</template>
</el-table-column>
<el-table-column prop="industry" label="行业" width="100" />
<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>
</template>
</el-table-column>
</el-table>
<div style="display:flex; justify-content:flex-end; margin-top:16px">
<el-pagination
v-model:current-page="currentPage" v-model:page-size="pageSize"
:page-sizes="[25, 50, 100]" :total="total"
layout="total, sizes, prev, pager, next, jumper" background
@current-change="onPageChange" @size-change="onPageSizeChange"
/>
</div>
</el-card>
<!-- 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="所属行业"><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>
<el-form-item label="收支费用">
<el-row :gutter="8">
<el-col :span="8"><el-input v-model="form.fee_amount" placeholder="金额" /></el-col>
<el-col :span="form.fee_unit === '自定义' ? 8 : 16">
<el-select v-model="form.fee_unit" style="width:100%" @change="form.fee_unit !== '自定义' && (feeCustomUnit = '')">
<el-option v-for="u in feeUnits" :key="u" :label="u" :value="u" />
</el-select>
</el-col>
<el-col v-if="form.fee_unit === '自定义'" :span="8"><el-input v-model="feeCustomUnit" placeholder="单位" /></el-col>
</el-row>
</el-form-item>
<el-form-item label="备注"><el-input v-model="form.remarks" type="textarea" :rows="2" placeholder="备注信息..." /></el-form-item>
<el-form-item label="归属客户经理" v-if="auth.isDirector">
<el-select v-model="form.assignee_id" :placeholder="editId ? '留空则不修改' : '不选则默认分配给自己'" clearable style="width:100%">
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item label="联系人">
<div v-if="editId && existingContacts.length" style="margin-bottom:8px">
<el-tag v-for="c in existingContacts" :key="c.id" closable @close="removeExistingContact(c.id)" style="margin: 2px 4px">
{{ c.name }}{{ c.phone ? ' · '+c.phone : '' }}{{ c.role_desc ? ' ('+c.role_desc+')' : '' }}
</el-tag>
</div>
<div v-for="(c, idx) in form.contacts" :key="'new-'+idx" style="display:flex; gap:8px; margin-bottom:4px;">
<el-input v-model="c.name" placeholder="姓名" size="small" style="flex:1" />
<el-input v-model="c.phone" placeholder="电话" size="small" style="flex:1" />
<el-input v-model="c.role_desc" placeholder="角色" size="small" style="flex:1" />
<el-button size="small" @click="form.contacts.splice(idx,1)">-</el-button>
</div>
<el-button size="small" @click="form.contacts.push({name:'',phone:'',role_desc:''})">+ 添加联系人</el-button>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
<!-- Detail Dialog -->
<el-dialog v-model="detailVisible" title="客户详情" width="500px">
<template v-if="detailCustomer">
<el-descriptions :column="1" border>
<el-descriptions-item label="单位名称">{{ detailCustomer.name }}</el-descriptions-item>
<el-descriptions-item label="行业">{{ detailCustomer.industry || '-' }}</el-descriptions-item>
<el-descriptions-item label="地址">{{ detailCustomer.address || '-' }}</el-descriptions-item>
<el-descriptions-item label="在用业务">{{ detailCustomer.in_use_services || '-' }}</el-descriptions-item>
<el-descriptions-item label="收支费用">{{ detailCustomer.monthly_fee || '-' }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ detailCustomer.remarks || '-' }}</el-descriptions-item>
<el-descriptions-item label="客户经理">{{ detailCustomer.primary_manager_name || '-' }}</el-descriptions-item>
</el-descriptions>
<h4 style="margin-top:16px; font-family: 'ZCOOL XiaoWei', STSong, serif; color: var(--ink)">联系人</h4>
<div v-if="detailCustomer.contacts?.length">
<el-tag v-for="c in detailCustomer.contacts" :key="c.id" style="margin:4px">
{{ c.name }}{{ c.phone ? ' · '+c.phone : '' }}{{ c.role_desc ? ' ('+c.role_desc+')' : '' }}
</el-tag>
</div>
<div v-else style="color: var(--c-text-muted); text-align:center; padding:12px">暂无联系人</div>
</template>
<template #footer>
<el-button v-if="!auth.isLeader" type="primary" @click="detailVisible=false; openEdit(detailCustomer)">编辑</el-button>
<el-button v-if="auth.isDirector" type="danger" @click="detailVisible=false; handleDelete(detailCustomer)">删除</el-button>
<el-button @click="detailVisible=false">关闭</el-button>
</template>
</el-dialog>
<!-- Import Dialog -->
<el-dialog v-model="importDialogVisible" title="导入客户档案" width="480px">
<p>请使用模板格式上传 Excel 文件</p>
<el-form>
<el-form-item label="上传文件">
<input type="file" accept=".xlsx,.xls" @change="onImportFileChange" />
</el-form-item>
</el-form>
<div v-if="importResult" style="margin-top:12px">
<el-alert type="success" :closable="false">
新增 {{ importResult.created }} 更新 {{ importResult.updated || 0 }}
<template v-if="importResult.reasons?.length">
<div style="margin-top:8px;font-size:12px;max-height:200px;overflow-y:auto">
<div v-for="(r,i) in importResult.reasons.slice(0,20)" :key="i"> {{ r }}</div>
<div v-if="importResult.reasons.length > 20" style="color:var(--c-text-muted)">...还有 {{ importResult.reasons.length - 20 }} </div>
</div>
</template>
<template v-if="importResult.errors?.length"><br/> 错误{{ importResult.errors.join('; ') }}</template>
</el-alert>
</div>
<template #footer>
<el-button @click="importDialogVisible = false">关闭</el-button>
<el-button type="primary" :loading="importLoading" @click="handleImport" :disabled="!importFile">确认导入</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: center; }
.page-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 22px; font-weight: 400;
color: var(--ink); letter-spacing: 0.06em;
}
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 8px; }
.header-btns { display: flex; gap: 8px; }
</style>
+374
View File
@@ -0,0 +1,374 @@
<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'
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 progress = ref<any[]>([])
const weekOffset = ref(0) // 0 = current week, -1 = last week, etc.
const isHistoricalWeek = computed(() => weekOffset.value < 0)
const weekPickerDate = computed({
get: () => {
const d = new Date()
d.setDate(d.getDate() + weekOffset.value * 7)
return d.toISOString().slice(0, 10)
},
set: (_val: string) => {} // placeholder, actual change via buttons
})
function changeWeek(delta: number) {
weekOffset.value += delta
loadData()
}
function goCurrentWeek() {
weekOffset.value = 0
loadData()
}
async function loadData() {
loading.value = true
try {
const refDate = new Date()
refDate.setDate(refDate.getDate() + weekOffset.value * 7)
const refStr = refDate.toISOString().slice(0, 10)
const [sRes, pRes] = await Promise.all([
dashboardApi.getStats({ reference_date: refStr }),
dashboardApi.getProgress({ reference_date: refStr }),
])
stats.value = sRes.data
progress.value = pRes.data
} catch (e: any) {
ElMessage.error('加载仪表盘失败')
} finally {
loading.value = false
}
}
onMounted(loadData)
function goWeeklyReport(managerId?: string) {
if (managerId) router.push({ path: '/weekly-report', query: { manager_id: managerId } })
else router.push('/weekly-report')
}
function rowState(p: any): 'full' | 'catching' | 'missing' {
if (p.has_reported_today && p.completed) return 'full'
if (p.has_reported_today && !p.completed) return 'catching'
return 'missing'
}
</script>
<template>
<div class="dashboard" v-loading="loading">
<!-- Editorial Page Header -->
<div class="page-head">
<h2 class="page-title">仪表盘</h2>
<div class="week-nav">
<button class="week-nav-btn" @click="changeWeek(-1)" title="上一周"></button>
<span class="week-label">{{ stats.week_start }} {{ stats.week_end }}</span>
<button class="week-nav-btn" @click="changeWeek(1)" :disabled="weekOffset >= 0" title="下一周"></button>
<button v-if="isHistoricalWeek" class="week-nav-reset" @click="goCurrentWeek">回到本周</button>
<el-tag v-if="isHistoricalWeek" type="info" size="small" style="margin-left:8px">📦 历史归档 · 只读</el-tag>
</div>
<div class="page-rule"></div>
</div>
<!-- Stats Cards -->
<div class="stat-grid">
<div class="stat-card stat-card--clickable" @click="router.push('/weekly-report')">
<div class="stat-glyph stat-glyph--ink">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
</div>
<div class="stat-body">
<span class="stat-num">{{ stats.week_visits }}</span>
<span class="stat-label">本周拜访</span>
</div>
</div>
<div class="stat-card stat-card--clickable" @click="router.push('/work-plans')">
<div class="stat-glyph stat-glyph--sage">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
</div>
<div class="stat-body">
<span class="stat-num">{{ stats.work_plans }}</span>
<span class="stat-label">工作计划</span>
</div>
</div>
<div class="stat-card stat-card--clickable" @click="router.push('/mini-business')">
<div class="stat-glyph stat-glyph--gold">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="1" x2="12" y2="23"></line>
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
</svg>
</div>
<div class="stat-body">
<span class="stat-num">{{ stats.mini_business }}</span>
<span class="stat-label">商机跟单</span>
</div>
</div>
<div class="stat-card stat-card--clickable" @click="router.push('/key-visits')">
<div class="stat-glyph stat-glyph--vermilion">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
</svg>
</div>
<div class="stat-body">
<span class="stat-num">{{ stats.key_visits }}</span>
<span class="stat-label">要客拜访</span>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="actions">
<el-button type="primary" @click="goWeeklyReport">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
本周周报详情
</el-button>
<el-button type="success" v-if="auth.isDirector" @click="router.push('/weekly-report')">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
导出 Excel
</el-button>
<el-button plain @click="router.push('/customers')">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
客户管理
</el-button>
</div>
<!-- Reporting Progress -->
<el-card class="progress-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)">
<line x1="18" y1="20" x2="18" y2="10"></line>
<line x1="12" y1="20" x2="12" y2="4"></line>
<line x1="6" y1="20" x2="6" y2="14"></line>
</svg>
填报进度
</div>
</template>
<div v-if="progress.length === 0" class="empty">暂无数据</div>
<div
v-for="p in progress"
:key="p.manager_id"
class="progress-row"
:class="`progress-row--${rowState(p)}`"
>
<!-- 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>
</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)] }}
</span>
</span>
<span class="progress-count">{{ p.visit_count }} / {{ p.expected }} </span>
</div>
<el-progress
: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"
/>
</div>
</el-card>
</div>
</template>
<style scoped>
/* ═══ Page Header ═══ */
.page-head { margin-bottom: 24px; }
.page-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 22px; font-weight: 400;
color: var(--ink); letter-spacing: 0.06em;
}
.week-nav { display: flex; align-items: center; gap: 10px; margin: 6px 0 4px; }
.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-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; }
/* ═══ Stat Grid ═══ */
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
display: flex;
align-items: center;
gap: 16px;
background: var(--surface);
border: 1px solid var(--warm-border);
padding: 20px 22px;
transition: all var(--transition);
}
.stat-card:hover { box-shadow: var(--shadow-md); }
.stat-card--clickable { cursor: pointer; }
.stat-card--clickable:hover { border-color: var(--gold); }
.stat-glyph {
width: 48px; height: 48px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.stat-glyph--ink { background: #EEF2F2; color: var(--ink); }
.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-body { display: flex; flex-direction: column; }
.stat-num {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 28px; color: var(--ink); line-height: 1.1; letter-spacing: 0.04em;
}
.stat-label {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 12px; color: var(--warm-gray); letter-spacing: 0.04em;
}
/* ═══ Actions ═══ */
.actions { display: flex; gap: 10px; margin-bottom: 24px; }
/* ═══ Progress ═══ */
.progress-card { margin-top: 4px; }
.card-header-title {
display: flex; align-items: center;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 15px; color: var(--ink); letter-spacing: 0.05em;
}
.progress-row {
position: relative;
margin-bottom: 24px;
padding: 16px 18px 14px;
overflow: hidden;
border-left: 4px solid transparent;
transition: border-color 0.3s;
}
.progress-row:last-child { margin-bottom: 0; }
/* Left edge accent — three states */
.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); }
/* ═══ Chinese Seal Stamp Watermark ═══ */
.seal-stamp {
position: absolute;
right: 24px;
top: 50%;
transform: translateY(-52%) rotate(-10deg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 3px solid;
border-radius: 8px;
outline: 1.5px solid;
outline-offset: -6px;
pointer-events: none;
user-select: none;
opacity: 0.16;
transition: opacity 0.3s;
/* Single-char stamps (满/追/未) are square; double-char (未填) is slightly wider */
width: 56px;
height: 56px;
}
.progress-row:hover .seal-stamp {
opacity: 0.24;
}
/* Missing stamp — vermilion red ink */
.seal-stamp--missing {
border-color: var(--vermilion);
outline-color: var(--vermilion);
color: var(--vermilion);
}
/* Catching stamp — gold ink */
.seal-stamp--catching {
border-color: var(--gold);
outline-color: var(--gold);
color: var(--gold);
}
/* Full stamp — sage green ink */
.seal-stamp--full {
border-color: var(--sage);
outline-color: var(--sage);
color: var(--sage);
}
.seal-char {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 20px;
font-weight: 400;
line-height: 1.1;
letter-spacing: 0.06em;
}
/* ═══ Progress Info ═══ */
.progress-info {
display: flex; justify-content: space-between;
align-items: center; margin-bottom: 10px;
position: relative; z-index: 1;
}
.progress-name { display: flex; align-items: center; gap: 10px; font-weight: 500; }
/* Inline status text (replaces el-tag) */
.progress-status {
font-family: 'Noto Serif SC', STSong, serif;
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-count {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
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; }
</style>
+217
View File
@@ -0,0 +1,217 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { ElMessage, ElMessageBox } from 'element-plus'
import { todayStr } from '@/utils'
import api from '@/api/index'
import EditLogPanel from '@/components/EditLogPanel.vue'
const auth = useAuthStore()
const loading = ref(false)
const keyVisits = ref<any[]>([])
const customers = ref<any[]>([])
const allUsers = ref<any[]>([])
const plannedVisitors = ref<string[]>([])
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const form = ref<any>({})
const statusPick = ref<Record<string, string>>({})
const keyStatuses = ['未开始', '进行中', '已完成']
onMounted(async () => {
await Promise.all([loadItems(), loadCustomers(), loadUsers()])
})
async function loadUsers() {
try {
const res = await api.get('/users/')
allUsers.value = res.data || []
} catch (_) {}
}
async function loadCustomers(q?: string) {
try {
const params: any = { page_size: 100 }
if (q) params.search = q
const res = await api.get('/customers/', { params })
customers.value = res.data.items || []
} catch (_) {}
}
async function loadItems() {
loading.value = true
try {
const res = await api.get('/key-visits/')
keyVisits.value = res.data
} catch (e: any) { ElMessage.error('加载失败') }
finally { loading.value = false }
}
function openCreate() {
dialogMode.value = 'create'
form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '' }
plannedVisitors.value = []
dialogVisible.value = true
}
function openEdit(item: any) {
dialogMode.value = 'edit'
form.value = { ...item }
plannedVisitors.value = item.planned_visitor ? item.planned_visitor.split('、') : []
dialogVisible.value = true
}
function onVisitorsChange(val: string[]) {
form.value.planned_visitor = val.join('、')
}
async function handleSave() {
try {
if (dialogMode.value === 'create') {
await api.post('/key-visits/', form.value)
} else {
await api.put(`/key-visits/${form.value.id}`, form.value)
}
ElMessage.success(dialogMode.value === 'create' ? '已创建' : '已更新')
dialogVisible.value = false
await loadItems()
} catch (e: any) { ElMessage.error('保存失败: ' + (e.response?.data?.detail || e.message)) }
}
async function handleDelete(id: string) {
try {
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
await api.delete(`/key-visits/${id}`)
ElMessage.success('已删除')
await loadItems()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
async function quickStatusChange(row: any, newStatus: string) {
try {
await ElMessageBox.confirm(`确定将进展改为「${newStatus}」?`, '确认', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' })
await api.put(`/key-visits/${row.id}`, { progress_status: newStatus })
ElMessage.success('状态已更新')
await loadItems()
} catch (e: any) {
if (e !== 'cancel') ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message))
delete statusPick.value[row.id]
}
}
</script>
<template>
<div class="key-visits-page" v-loading="loading">
<div class="page-head">
<div class="page-head-row">
<div>
<h2 class="page-title">要客拜访</h2>
<p class="page-desc">重要客户拜访计划按紧急程度排序跟踪</p>
</div>
<el-button type="primary" @click="openCreate">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
新建要客
</el-button>
</div>
<div class="page-rule"></div>
</div>
<el-card>
<el-table :data="keyVisits" stripe size="small" v-if="keyVisits.length">
<el-table-column type="index" label="序号" width="50" />
<el-table-column prop="customer_name" label="客户" width="160">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="openEdit(row)">{{ row.customer_name }}</el-link>
<el-tooltip v-if="row.edit_log?.length > 1" placement="top">
<template #content>最后编辑{{ row.edit_log[row.edit_log.length-1].editor }} · {{ row.edit_log.length-1 }}次修改</template>
<span class="edit-indicator" title="有过修改">🕐</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column 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>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="250" show-overflow-tooltip />
<el-table-column label="进展" width="130">
<template #default="{ row }">
<el-select
v-model="statusPick[row.id]"
size="small"
style="width:100%"
:placeholder="row.progress_status"
@change="(v: string) => quickStatusChange(row, v)"
@visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.progress_status }"
>
<el-option v-for="s in keyStatuses" :key="s" :label="s" :value="s" />
</el-select>
</template>
</el-table-column>
<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">
<template #default="{ row }">
<el-button text size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-else class="empty">暂无需重点关注的客户</div>
</el-card>
<!-- Dialog -->
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 要客拜访'" width="520px">
<el-form label-position="top">
<el-form-item label="客户单位">
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="紧急重要度">
<div class="method-grid">
<el-button v-for="u in ['一般','重要','紧急']" :key="u" size="small"
:type="form.urgency_level===u ? (u==='紧急'?'danger':u==='重要'?'warning':'') : ''"
@click="form.urgency_level=u">{{ u }}</el-button>
</div>
</el-form-item>
<el-form-item label="内容描述"><el-input v-model="form.description" type="textarea" :rows="4" placeholder="请输入要客拜访描述" /></el-form-item>
<el-form-item label="进展状态">
<el-select v-model="form.progress_status">
<el-option label="未开始" value="未开始" />
<el-option label="进行中" value="进行中" />
<el-option label="已完成" value="已完成" />
</el-select>
</el-form-item>
<el-form-item label="计划拜访时间">
<el-date-picker v-model="form.planned_date" type="date" placeholder="选择日期" style="width:100%" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="计划拜访人可多选">
<el-select v-model="plannedVisitors" multiple filterable allow-create default-first-option placeholder="选择或输入姓名可多选" style="width:100%" @change="onVisitorsChange">
<el-option v-for="u in allUsers" :key="u.id" :label="u.name + (u.role==='director'?' (支局长)':u.role==='leader'?' (分管领导)':'')" :value="u.name" />
</el-select>
</el-form-item>
<el-form-item label="拜访对象"><el-input v-model="form.visit_target" placeholder="对方联系人/部门" /></el-form-item>
</el-form>
<EditLogPanel v-if="dialogMode === 'edit'" :edit-log="form.edit_log || []" />
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.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-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
.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; }
</style>
@@ -0,0 +1,519 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { todayStr } from '@/utils'
import api from '@/api/index'
import ImagePreview from '@/components/ImagePreview.vue'
import EditLogPanel from '@/components/EditLogPanel.vue'
const activeTab = ref('visits')
const loading = ref(false)
const visits = ref<any[]>([])
const workPlans = ref<any[]>([])
const miniBusiness = ref<any[]>([])
const dailyNotes = ref<any[]>([])
const keyVisits = ref<any[]>([])
const customers = ref<any[]>([])
const allUsers = ref<any[]>([])
const plannedVisitors = ref<string[]>([])
const dialogTimeRange = ref<any>(null)
const previewImageUrl = ref('')
const uploading = ref(false)
const previewDialogVisible = ref(false)
const photoUrls = ref<Record<string, string>>({})
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const dialogType = ref('')
const form = ref<any>({})
const categories = ['行政事务', '合同整理', '发票处理', '内部会议', '培训学习', '其他']
const statusPick = ref<Record<string, string>>({})
const planStatuses = ['计划中', '已完成', '已取消']
const miniStatuses = ['跟进中', '已签约', '已流失']
const keyStatuses = ['未开始', '进行中', '已完成']
onMounted(async () => {
await Promise.all([loadAll(), loadCustomers(), loadUsers()])
})
async function loadUsers() {
try {
const res = await api.get('/users/')
allUsers.value = res.data || []
} catch (_) {}
}
function typeLabel(t: string) {
const labels: Record<string, string> = { visit: '拜访记录', note: '今日纪要', plan: '工作计划', mini: '小微商机', key: '要客拜访' }
return labels[t] || ''
}
async function loadCustomers(q?: string) {
try {
const params: any = { page_size: 100 }
if (q) params.search = q
const res = await api.get('/customers/', { params })
customers.value = res.data.items || []
} catch (_) {}
}
async function loadAll() {
loading.value = true
try {
const [v, w, m, d, k] = await Promise.all([
api.get('/visits/'), api.get('/work-plans/'),
api.get('/mini-business/'), api.get('/daily-notes/'),
api.get('/key-visits/'),
])
visits.value = v.data; workPlans.value = w.data
miniBusiness.value = m.data; dailyNotes.value = d.data
keyVisits.value = k.data
// Load photo previews
for (const visit of visits.value) {
if (visit.photos?.length) {
for (const key of visit.photos) {
if (!photoUrls.value[key]) {
try {
const urlRes = await api.get('/upload/download-url', { params: { object_key: key } })
photoUrls.value[key] = urlRes.data.download_url
} catch (_) {}
}
}
}
}
} catch (e: any) { ElMessage.error('加载失败') }
finally { loading.value = false }
}
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: '' }
else if (type === 'note') form.value = { note_date: todayStr(), category: '其他', content: '', time_range: '' }
else if (type === 'plan') form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中' }
else if (type === 'mini') form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '' }
else if (type === 'key') { form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '' }; plannedVisitors.value = [] }
dialogVisible.value = true
}
function openEdit(type: string, item: any) {
dialogMode.value = 'edit'; dialogType.value = type
form.value = { ...item }
dialogTimeRange.value = null
if ((type === 'visit' || type === 'note') && item.time_range && item.time_range.includes('-')) {
const parts = item.time_range.split('-')
dialogTimeRange.value = [parts[0], parts[1]]
}
if (type === 'key') {
plannedVisitors.value = item.planned_visitor ? item.planned_visitor.split('、') : []
}
// Load photo URLs for visit editing
if (type === 'visit' && item.photos?.length) {
for (const key of item.photos) {
if (!photoUrls.value[key]) {
api.get('/upload/download-url', { params: { object_key: key } }).then(r => {
photoUrls.value[key] = r.data.download_url
}).catch(() => {})
}
}
}
dialogVisible.value = true
}
function previewPhoto(url: string) { previewImageUrl.value = url; previewDialogVisible.value = true }
async function handleDialogPhotoUpload(event: Event) {
const target = event.target as HTMLInputElement
if (!target.files?.length) return
if ((form.value.photos || []).length >= 9) { ElMessage.warning('最多9张照片'); return }
uploading.value = true
for (const file of Array.from(target.files)) {
if ((form.value.photos || []).length >= 9) break
try {
// Get presigned URL
const presignRes = await api.post('/upload/presigned-url', null, { params: { filename: file.name, content_type: file.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' } })
const key = presignRes.data.object_key
if (!form.value.photos) form.value.photos = []
form.value.photos.push(key)
form.value.photos = [...form.value.photos]
// Load preview
try {
const urlRes = await api.get('/upload/download-url', { params: { object_key: key } })
photoUrls.value[key] = urlRes.data.download_url
} catch (_) {}
} catch (e: any) {
ElMessage.error('上传失败: ' + (e.response?.data?.detail || e.message))
}
}
uploading.value = false
target.value = ''
}
function removePhotoFromEdit(idx: number) {
if (!form.value.photos) return
const key = form.value.photos[idx]
form.value.photos.splice(idx, 1)
form.value.photos = [...form.value.photos]
// Clean up preview URL
if (photoUrls.value[key]) {
delete photoUrls.value[key]
}
}
async function handleSave() {
const t = dialogType.value; const d = form.value
try {
if (dialogMode.value === 'create') {
switch (t) {
case 'visit': await api.post('/visits/', d); break
case 'note': await api.post('/daily-notes/', d); break
case 'plan': await api.post('/work-plans/', d); break
case 'mini': await api.post('/mini-business/', d); break
case 'key': await api.post('/key-visits/', d); break
}
} else {
switch (t) {
case 'visit': await api.put(`/visits/${d.id}`, d); break
case 'note': await api.put(`/daily-notes/${d.id}`, d); break
case 'plan': await api.put(`/work-plans/${d.id}`, d); break
case 'mini': await api.put(`/mini-business/${d.id}`, d); break
case 'key': await api.put(`/key-visits/${d.id}`, d); break
}
}
ElMessage.success(dialogMode.value === 'create' ? '已创建' : '已更新')
dialogVisible.value = false
await loadAll()
} catch (e: any) { ElMessage.error('保存失败: ' + (e.response?.data?.detail || e.message)) }
}
async function handleDelete(type: string, id: string) {
try {
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
switch (type) {
case 'visit': await api.delete(`/visits/${id}`); break
case 'note': await api.delete(`/daily-notes/${id}`); break
case 'plan': await api.delete(`/work-plans/${id}`); break
case 'mini': await api.delete(`/mini-business/${id}`); break
case 'key': await api.delete(`/key-visits/${id}`); break
}
ElMessage.success('已删除')
await loadAll()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
function onVisitorsChange(val: string[]) {
form.value.planned_visitor = val.join('、')
}
function onDialogTimeChange(val: [string, string] | null) {
form.value.time_range = val ? val.join('-') : ''
}
// Quick status change
async function quickStatusChange(type: string, row: any, newStatus: string) {
try {
await ElMessageBox.confirm(`确定将状态改为「${newStatus}」?`, '确认', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' })
const payload: any = {}
if (type === 'plan') payload.status = newStatus
else if (type === 'mini') payload.status = newStatus
else if (type === 'key') payload.progress_status = newStatus
let url = ''
if (type === 'plan') url = `/work-plans/${row.id}`
else if (type === 'mini') url = `/mini-business/${row.id}`
else if (type === 'key') url = `/key-visits/${row.id}`
await api.put(url, payload)
ElMessage.success('状态已更新')
await loadAll()
} catch (e: any) {
if (e !== 'cancel') ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message))
// Reset statusPick back to original on cancel/error
delete statusPick.value[row.id]
}
}
const visitsByDate = computed(() => {
const g: Record<string, any[]> = {}
for (const v of visits.value) { const d = v.visit_date; if (!g[d]) g[d] = []; g[d].push(v) }
return Object.entries(g).sort((a, b) => b[0].localeCompare(a[0]))
})
const notesByDate = computed(() => {
const g: Record<string, any[]> = {}
for (const n of dailyNotes.value) { const d = n.note_date; if (!g[d]) g[d] = []; g[d].push(n) }
return Object.entries(g).sort((a, b) => b[0].localeCompare(a[0]))
})
</script>
<template>
<div class="workspace" v-loading="loading">
<div class="page-head">
<h2 class="page-title">我的工作数据</h2>
<div class="page-rule"></div>
</div>
<el-card>
<el-tabs v-model="activeTab">
<!-- 拜访记录 -->
<el-tab-pane label="拜访记录" name="visits">
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('visit')">+ 新建拜访</el-button></div>
<div v-for="[date, items] in visitsByDate" :key="date" class="date-group">
<h4 class="date-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px; color: var(--gold)">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
{{ date }}
</h4>
<el-table :data="items" stripe size="small">
<el-table-column prop="customer_name" label="客户" width="130">
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('visit', row)">{{ row.customer_name }}</el-link></template>
</el-table-column>
<el-table-column prop="visit_method" label="方式" width="60" />
<el-table-column prop="time_range" label="时间" width="100" />
<el-table-column label="照片" width="60">
<template #default="{ row }">
<span v-if="row.photos?.length">📷{{ row.photos.length }}</span>
<span v-else style="color:var(--c-text-muted)">-</span>
</template>
</el-table-column>
<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>
</template>
</el-table-column>
</el-table>
</div>
<div v-if="!visits.length" class="empty">暂无数据</div>
</el-tab-pane>
<!-- 今日纪要 -->
<el-tab-pane label="今日纪要" name="daily_notes">
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('note')">+ 新建纪要</el-button></div>
<div v-for="[date, items] in notesByDate" :key="date" class="date-group">
<h4 class="date-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px; color: var(--gold)">
<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>
{{ date }}
</h4>
<el-table :data="items" stripe size="small">
<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>
</el-table-column>
<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>
</template>
</el-table-column>
</el-table>
</div>
<div v-if="!dailyNotes.length" class="empty">暂无数据</div>
</el-tab-pane>
<!-- 工作计划 -->
<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-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>
<el-table-column prop="plan_content" label="工作计划" min-width="200" show-overflow-tooltip />
<el-table-column prop="plan_date" label="计划时间" width="110" />
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-select
v-model="statusPick[row.id]"
size="small"
style="width:100%"
:placeholder="row.status"
@change="(v: string) => quickStatusChange('plan', row, v)"
@visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.status }"
>
<el-option v-for="s in planStatuses" :key="s" :label="s" :value="s" />
</el-select>
</template>
</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>
</template>
</el-table-column>
</el-table>
<div v-if="!workPlans.length" class="empty">暂无数据</div>
</el-tab-pane>
<!-- ═══ 小微商机 ═══ -->
<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-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>
<el-table-column prop="product_type" label="产品类型" width="120" />
<el-table-column prop="amount" label="金额" width="90" />
<el-table-column prop="follow_up_detail" label="跟进内容" min-width="200" show-overflow-tooltip />
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-select
v-model="statusPick[row.id]"
size="small"
style="width:100%"
:placeholder="row.status"
@change="(v: string) => quickStatusChange('mini', row, v)"
@visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.status }"
>
<el-option v-for="s in miniStatuses" :key="s" :label="s" :value="s" />
</el-select>
</template>
</el-table-column>
<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>
</template>
</el-table-column>
</el-table>
<div v-if="!miniBusiness.length" class="empty">暂无数据</div>
</el-tab-pane>
<!-- ═══ 要客拜访 ═══ -->
<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-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>
<el-table-column prop="urgency_level" label="重要度" width="80"><template #default="{ row }"><el-tag size="small" :type="row.urgency_level==='紧急'?'danger':row.urgency_level==='重要'?'warning':''">{{ row.urgency_level }}</el-tag></template></el-table-column>
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column label="进展" width="120">
<template #default="{ row }">
<el-select
v-model="statusPick[row.id]"
size="small"
style="width:100%"
:placeholder="row.progress_status"
@change="(v: string) => quickStatusChange('key', row, v)"
@visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.progress_status }"
>
<el-option v-for="s in keyStatuses" :key="s" :label="s" :value="s" />
</el-select>
</template>
</el-table-column>
<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="100" />
<el-table-column label="操作" width="70">
<template #default="{ row }">
<el-button text size="small" type="danger" @click="handleDelete('key', row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="!keyVisits.length" class="empty">暂无数据</div>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- Create/Edit Dialog -->
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' ' + typeLabel(dialogType)" width="520px">
<el-form label-position="top" v-if="form">
<el-form-item v-if="['visit','plan','mini','key'].includes(dialogType)" label="客户单位">
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item v-if="dialogType === 'visit'" label="日期"><el-date-picker v-model="form.visit_date" type="date" style="width:100%" /></el-form-item>
<el-form-item v-if="dialogType === 'note'" label="日期"><el-date-picker v-model="form.note_date" type="date" style="width:100%" /></el-form-item>
<el-form-item v-if="dialogType === 'plan'" label="计划拜访时间"><el-date-picker v-model="form.plan_date" type="date" style="width:100%" /></el-form-item>
<template v-if="dialogType === 'visit'">
<el-form-item label="拜访方式">
<div class="method-grid">
<el-button v-for="m in ['上门','电话','微信','出差']" :key="m" size="small" :type="form.visit_method===m?'primary':''" @click="form.visit_method=m">{{ m }}</el-button>
</div>
</el-form-item>
<el-form-item label="时间范围">
<el-time-picker v-model="dialogTimeRange" is-range format="HH:mm" value-format="HH:mm" range-separator="" start-placeholder="开始" end-placeholder="结束" style="width:100%" @change="onDialogTimeChange" />
</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-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="照片">
<div style="display:flex;gap:8px;flex-wrap:wrap">
<div v-for="(key, idx) in form.photos" :key="key" style="position:relative">
<img :src="photoUrls[key]" style="width:80px;height:80px;object-fit:cover;border:1px solid var(--warm-border);cursor:pointer" v-if="photoUrls[key]" @click.stop="previewPhoto(photoUrls[key])" />
<span v-else style="width:80px;height:80px;display:flex;align-items:center;justify-content:center;background:var(--paper);border:1px solid var(--warm-border);font-size:11px;color:var(--warm-gray)">加载中...</span>
<button type="button" style="position:absolute;top:-6px;right:-6px;background:var(--vermilion);color:#fff;border:none;width:18px;height:18px;font-size:12px;cursor:pointer;line-height:1;border-radius:50%" @click="removePhotoFromEdit(idx)" title="删除照片">×</button>
</div>
</div>
<div style="margin-top:4px;font-size:11px;color:var(--warm-gray)">点击 × 移除照片,点击「保存」提交更改</div>
</el-form-item>
<el-form-item v-if="dialogType === 'visit'" label="添加照片">
<label style="cursor:pointer;display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border:1px dashed var(--warm-border);color:var(--warm-gray);font-size:13px">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
{{ uploading ? '上传中...' : '上传照片(最多9张)' }}
<input type="file" accept="image/*" multiple style="display:none" @change="handleDialogPhotoUpload" />
</label>
</el-form-item>
</template>
<template v-if="dialogType === 'note'">
<el-form-item label="分类"><div style="display:flex;flex-wrap:wrap;gap:6px"><el-button v-for="c in categories" :key="c" :type="form.category===c?'primary':''" size="small" @click="form.category=c">{{ c }}</el-button></div></el-form-item>
<el-form-item label="时间范围">
<el-time-picker v-model="dialogTimeRange" is-range format="HH:mm" value-format="HH:mm" range-separator="" start-placeholder="开始" end-placeholder="结束" style="width:100%" @change="onDialogTimeChange" />
</el-form-item>
<el-form-item label="工作内容"><el-input v-model="form.content" type="textarea" :rows="4" /></el-form-item>
</template>
<template v-if="dialogType === 'plan'">
<el-form-item label="工作计划"><el-input v-model="form.plan_content" type="textarea" :rows="4" /></el-form-item>
<el-form-item label="状态"><el-select v-model="form.status"><el-option label="计划中" value="计划中" /><el-option label="已完成" value="已完成" /><el-option label="已取消" value="已取消" /></el-select></el-form-item>
</template>
<template v-if="dialogType === 'mini'">
<el-form-item label="产品类型"><el-input v-model="form.product_type" /></el-form-item>
<el-form-item label="金额"><el-input v-model="form.amount" /></el-form-item>
<el-form-item label="跟进内容"><el-input v-model="form.follow_up_detail" type="textarea" :rows="4" /></el-form-item>
<el-form-item label="状态"><el-select v-model="form.status"><el-option label="跟进中" value="跟进中" /><el-option label="已签约" value="已签约" /><el-option label="已流失" value="已流失" /></el-select></el-form-item>
<el-form-item label="预计列收时间"><el-date-picker v-model="form.expected_revenue_date" type="month" placeholder="选择月份" style="width:100%" value-format="YYYY-MM" /></el-form-item>
</template>
<template v-if="dialogType === 'key'">
<el-form-item label="紧急重要度">
<div class="method-grid">
<el-button v-for="u in ['一般','重要','紧急']" :key="u" size="small" :type="form.urgency_level===u ? (u==='紧急'?'danger':u==='重要'?'warning':'') : ''" @click="form.urgency_level=u">{{ u }}</el-button>
</div>
</el-form-item>
<el-form-item label="内容描述"><el-input v-model="form.description" type="textarea" :rows="4" /></el-form-item>
<el-form-item label="进展状态"><el-select v-model="form.progress_status"><el-option label="未开始" value="未开始" /><el-option label="进行中" value="进行中" /><el-option label="已完成" value="已完成" /></el-select></el-form-item>
<el-form-item label="计划拜访时间"><el-date-picker v-model="form.planned_date" type="date" placeholder="选择日期" style="width:100%" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="计划拜访人可多选">
<el-select v-model="plannedVisitors" multiple filterable allow-create default-first-option placeholder="选择或输入姓名可多选" style="width:100%" @change="onVisitorsChange">
<el-option v-for="u in allUsers" :key="u.id" :label="u.name + (u.role==='director'?' (支局长)':u.role==='leader'?' (分管领导)':'')" :value="u.name" />
</el-select>
</el-form-item>
<el-form-item label="拜访对象"><el-input v-model="form.visit_target" /></el-form-item>
</template>
</el-form>
<EditLogPanel v-if="dialogMode === 'edit'" :edit-log="form.edit_log || []" />
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
<ImagePreview v-model="previewDialogVisible" :image-url="previewImageUrl" />
</div>
</template>
<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-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; }
</style>
+185
View File
@@ -0,0 +1,185 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { ElMessage, ElMessageBox } from 'element-plus'
import { todayStr } from '@/utils'
import api from '@/api/index'
import EditLogPanel from '@/components/EditLogPanel.vue'
const auth = useAuthStore()
const loading = ref(false)
const miniBusiness = ref<any[]>([])
const customers = ref<any[]>([])
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const form = ref<any>({})
const statusPick = ref<Record<string, string>>({})
const miniStatuses = ['跟进中', '已签约', '已流失']
onMounted(async () => {
await Promise.all([loadItems(), loadCustomers()])
})
async function loadCustomers(q?: string) {
try {
const params: any = { page_size: 100 }
if (q) params.search = q
const res = await api.get('/customers/', { params })
customers.value = res.data.items || []
} catch (_) {}
}
async function loadItems() {
loading.value = true
try {
const res = await api.get('/mini-business/')
miniBusiness.value = res.data
} catch (e: any) { ElMessage.error('加载失败') }
finally { loading.value = false }
}
function openCreate() {
dialogMode.value = 'create'
form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '' }
dialogVisible.value = true
}
function openEdit(item: any) {
dialogMode.value = 'edit'
form.value = { ...item }
dialogVisible.value = true
}
async function handleSave() {
try {
if (dialogMode.value === 'create') {
await api.post('/mini-business/', form.value)
} else {
await api.put(`/mini-business/${form.value.id}`, form.value)
}
ElMessage.success(dialogMode.value === 'create' ? '已创建' : '已更新')
dialogVisible.value = false
await loadItems()
} catch (e: any) { ElMessage.error('保存失败: ' + (e.response?.data?.detail || e.message)) }
}
async function handleDelete(id: string) {
try {
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
await api.delete(`/mini-business/${id}`)
ElMessage.success('已删除')
await loadItems()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
async function quickStatusChange(row: any, newStatus: string) {
try {
await ElMessageBox.confirm(`确定将状态改为「${newStatus}」?`, '确认', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' })
await api.put(`/mini-business/${row.id}`, { status: newStatus })
ElMessage.success('状态已更新')
await loadItems()
} catch (e: any) {
if (e !== 'cancel') ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message))
delete statusPick.value[row.id]
}
}
</script>
<template>
<div class="mini-biz-page" v-loading="loading">
<div class="page-head">
<div class="page-head-row">
<div>
<h2 class="page-title">商机跟单</h2>
<p class="page-desc">小微业务商机管道按状态跟踪签约进展</p>
</div>
<el-button type="primary" @click="openCreate">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
新建商机
</el-button>
</div>
<div class="page-rule"></div>
</div>
<el-card>
<el-table :data="miniBusiness" stripe size="small" v-if="miniBusiness.length">
<el-table-column type="index" label="序号" width="50" />
<el-table-column prop="customer_name" label="客户" width="160">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="openEdit(row)">{{ row.customer_name }}</el-link>
<el-tooltip v-if="row.edit_log?.length > 1" placement="top">
<template #content>最后编辑{{ row.edit_log[row.edit_log.length-1].editor }} · {{ row.edit_log.length-1 }}次修改</template>
<span class="edit-indicator" title="有过修改">🕐</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column 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 />
<el-table-column label="状态" width="130">
<template #default="{ row }">
<el-select
v-model="statusPick[row.id]"
size="small"
style="width:100%"
:placeholder="row.status"
@change="(v: string) => quickStatusChange(row, v)"
@visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.status }"
>
<el-option v-for="s in miniStatuses" :key="s" :label="s" :value="s" />
</el-select>
</template>
</el-table-column>
<el-table-column prop="expected_revenue_date" label="预计列收" width="110" />
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button text size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-else class="empty">暂无商机记录</div>
</el-card>
<!-- Dialog -->
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 小微商机'" width="500px">
<el-form label-position="top">
<el-form-item label="客户单位">
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="产品类型"><el-input v-model="form.product_type" placeholder="云专线SD-WAN" /></el-form-item>
<el-form-item label="金额"><el-input v-model="form.amount" placeholder="50000/" /></el-form-item>
<el-form-item label="跟进内容"><el-input v-model="form.follow_up_detail" type="textarea" :rows="4" placeholder="请输入跟进详情" /></el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status">
<el-option label="跟进中" value="跟进中" />
<el-option label="已签约" value="已签约" />
<el-option label="已流失" value="已流失" />
</el-select>
</el-form-item>
<el-form-item label="预计列收时间">
<el-date-picker v-model="form.expected_revenue_date" type="month" placeholder="选择月份" style="width:100%" value-format="YYYY-MM" />
</el-form-item>
</el-form>
<EditLogPanel v-if="dialogMode === 'edit'" :edit-log="form.edit_log || []" />
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.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-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; }
</style>
+212
View File
@@ -0,0 +1,212 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import api from '@/api/index'
const managers = ref<any[]>([])
const selectedUserIds = ref<string[]>([])
const remindMessage = ref('')
const announcementContent = ref('')
const remindLoading = ref(false)
const announceLoading = ref(false)
const dailyCheckLoading = ref(false)
const importLoading = ref(false)
const importFile = ref<File | null>(null)
const importPreview = ref<any>(null)
const importResult = ref<any>(null)
onMounted(async () => {
try {
const res = await api.get('/users/', { params: { role: 'manager' } })
managers.value = res.data
} catch (_) {}
})
async function handleRemind() {
if (!selectedUserIds.value.length) { ElMessage.warning('请选择要提醒的人员'); return }
remindLoading.value = true
try {
const res = await api.post('/wecom/remind', { user_ids: selectedUserIds.value, message: remindMessage.value || undefined })
ElMessage.success(`已发送提醒给 ${res.data.sent_to}`)
} catch (e: any) { ElMessage.error('发送失败') }
finally { remindLoading.value = false }
}
async function handleAnnouncement() {
if (!announcementContent.value) { ElMessage.warning('请输入公告内容'); return }
announceLoading.value = true
try {
const res = await api.post('/wecom/announcement', { content: announcementContent.value })
ElMessage.success(`公告已推送给 ${res.data.sent_to}`)
announcementContent.value = ''
} catch (e: any) { ElMessage.error('推送失败') }
finally { announceLoading.value = false }
}
async function handleDailyCheck() {
dailyCheckLoading.value = true
try {
const res = await api.post('/wecom/trigger-daily-check')
ElMessage.success(`已执行:${res.data.reported}/${res.data.total_managers} 人已填报`)
} catch (e: any) { ElMessage.error('执行失败') }
finally { dailyCheckLoading.value = false }
}
function handleImportFile(e: Event) {
const target = e.target as HTMLInputElement
if (target.files?.[0]) importFile.value = target.files[0]
}
async function downloadWeeklyTemplate() {
try {
const res = await api.get('/import/template', { responseType: 'blob' })
const url = URL.createObjectURL(res.data)
const a = document.createElement('a'); a.href = url; a.download = 'weekly_report_template.xlsx'; a.click()
URL.revokeObjectURL(url)
} catch (e: any) { ElMessage.error('下载失败') }
}
async function handleImportPreview() {
if (!importFile.value) return
importLoading.value = true
const formData = new FormData()
formData.append('file', importFile.value)
try {
const res = await api.post('/import/weekly-report', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
importPreview.value = res.data.preview
importResult.value = res.data
} catch (e: any) { ElMessage.error('解析失败') }
finally { importLoading.value = false }
}
</script>
<template>
<div class="settings-page">
<div class="page-head">
<h2 class="page-title">系统设置</h2>
<div class="page-rule"></div>
</div>
<!-- Manual Remind -->
<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)">
<path d="M22 17H2a3 3 0 0 0 3-3V9a7 7 0 0 1 14 0v5a3 3 0 0 0 3 3zm-8.27 4a2 2 0 0 1-3.46 0"></path>
</svg>
手动催办
</div>
</template>
<el-form>
<el-form-item label="选择人员">
<el-select v-model="selectedUserIds" multiple placeholder="选择客户经理" style="width:100%">
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item label="提醒内容">
<el-input v-model="remindMessage" type="textarea" :rows="2" placeholder="可选,留空使用默认提醒语" />
</el-form-item>
<el-button type="primary" :loading="remindLoading" @click="handleRemind">发送催办</el-button>
</el-form>
</el-card>
<!-- Announcement -->
<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)">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
</svg>
全员公告
</div>
</template>
<el-form>
<el-form-item label="公告内容">
<el-input v-model="announcementContent" type="textarea" :rows="3" placeholder="输入公告内容..." />
</el-form-item>
<el-button type="warning" :loading="announceLoading" @click="handleAnnouncement">推送公告</el-button>
</el-form>
</el-card>
<!-- Daily Check -->
<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="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
填报检查
</div>
</template>
<p style="color: var(--warm-gray); font-family: 'Noto Serif SC', STSong, serif;">手动触发今日填报检查通常每日 18:00 自动执行</p>
<el-button :loading="dailyCheckLoading" @click="handleDailyCheck">立即检查</el-button>
</el-card>
<!-- Import -->
<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)">
<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 数据导入
</div>
</template>
<el-form>
<el-form-item label="上传旧周报 Excel">
<div style="display:flex;gap:8px;align-items:center">
<input type="file" accept=".xlsx,.xls" @change="handleImportFile" />
<el-button size="small" @click="downloadWeeklyTemplate">📋 下载模板</el-button>
</div>
</el-form-item>
<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>
<el-table :data="Object.entries(importPreview)" stripe>
<el-table-column prop="0" label="Sheet" />
<el-table-column prop="1.row_count" label="数据行数" />
<el-table-column label="表头">
<template #default="{ row }">{{ (row[1] as any).headers.join(', ') }}</template>
</el-table-column>
</el-table>
</div>
<div v-if="importResult" style="margin-top:12px">
<el-alert type="success" :closable="false">
导入完成拜访 {{ importResult.visits }} 跳过 {{ importResult.skipped }}
<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>
<div v-if="importResult.skip_reasons.length > 20" style="color:var(--c-text-muted)">
...还有 {{ importResult.skip_reasons.length - 20 }}
</div>
</div>
</template>
</el-alert>
</div>
</el-card>
</div>
</template>
<style scoped>
.page-head { margin-bottom: 24px; }
.page-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, serif;
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; }
.setting-card { margin-bottom: 16px; }
.card-header-title {
display: flex; align-items: center;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 15px; color: var(--ink); letter-spacing: 0.05em;
}
</style>
+110
View File
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api/index'
const loading = ref(false)
const users = ref<any[]>([])
const editDialogVisible = ref(false)
const editUser = ref<any>(null)
const editRole = ref('')
const editDepartment = ref('')
const roleOptions = [
{ label: '客户经理', value: 'manager' },
{ label: '支局长', value: 'director' },
{ label: '分管领导', value: 'leader' },
]
const roleTagType: Record<string, string> = {
manager: '', director: 'warning', leader: 'info',
}
const roleLabel: Record<string, string> = {
manager: '客户经理', director: '支局长', leader: '分管领导',
}
async function loadUsers() {
loading.value = true
try {
const res = await api.get('/users/')
users.value = res.data
} catch (e: any) { ElMessage.error('加载用户列表失败') }
finally { loading.value = false }
}
onMounted(loadUsers)
function openEdit(user: any) {
editUser.value = user
editRole.value = user.role
editDepartment.value = user.department || ''
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('角色已更新')
editDialogVisible.value = false
await loadUsers()
} catch (e: any) { ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message)) }
}
</script>
<template>
<div class="user-manage" v-loading="loading">
<div class="page-head">
<h2 class="page-title">用户管理</h2>
<div class="page-rule"></div>
</div>
<el-card>
<el-table :data="users" stripe>
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column label="角色" width="120">
<template #default="{ row }">
<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">
<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">
<template v-if="editUser">
<el-form label-position="top">
<el-form-item label="用户"><el-input :value="editUser.name" disabled /></el-form-item>
<el-form-item label="角色">
<el-select v-model="editRole" style="width:100%">
<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>
</template>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<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-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
</style>
+265
View File
@@ -0,0 +1,265 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { dashboardApi } from '@/api/dashboard'
import { uploadApi } from '@/api/upload'
import { ElMessage } from 'element-plus'
import ImagePreview from '@/components/ImagePreview.vue'
import api from '@/api/index'
const auth = useAuthStore()
const route = useRoute()
const loading = ref(false)
const activeTab = ref('visits')
const filterManagerId = ref('')
const filterCustomerId = ref('')
const managers = ref<any[]>([])
const weekOffset = ref(0)
const isHistoricalWeek = computed(() => weekOffset.value < 0)
const report = ref({
week_start: '', week_end: '',
visits: [] as any[],
daily_notes: [] as any[],
})
const photoUrls = ref<Record<string, string>>({})
const photoDialogVisible = ref(false)
const currentPhotoUrl = ref('')
onMounted(async () => {
if (route.query.manager_id) filterManagerId.value = route.query.manager_id as string
await loadReport()
try {
const res = await api.get('/users/', { params: { role: 'manager' } })
managers.value = res.data
} catch (_) {}
})
function changeWeek(delta: number) { weekOffset.value += delta; loadReport() }
function goCurrentWeek() { weekOffset.value = 0; loadReport() }
function getRefDate(): string {
const d = new Date()
d.setDate(d.getDate() + weekOffset.value * 7)
return d.toISOString().slice(0, 10)
}
async function loadReport() {
loading.value = true
try {
const params: any = {}
if (filterManagerId.value) params.manager_id = filterManagerId.value
if (filterCustomerId.value) params.customer_id = filterCustomerId.value
params.reference_date = getRefDate()
const res = await dashboardApi.getWeeklyReport(params)
report.value = res.data
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 (_) {}
}
}
}
}
} catch (e: any) {
ElMessage.error('加载周报失败')
} finally {
loading.value = false
}
}
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 viewPhoto(url: string) {
currentPhotoUrl.value = url
photoDialogVisible.value = true
}
const visitsByDate = computed(() => {
const grouped: Record<string, any[]> = {}
for (const v of report.value.visits) {
const d = v.visit_date
if (!grouped[d]) grouped[d] = []
grouped[d].push(v)
}
return Object.entries(grouped).sort((a, b) => b[0].localeCompare(a[0]))
})
const notesByDate = computed(() => {
const grouped: Record<string, any[]> = {}
for (const n of report.value.daily_notes || []) {
const d = n.note_date
if (!grouped[d]) grouped[d] = []
grouped[d].push(n)
}
return Object.entries(grouped).sort((a, b) => b[0].localeCompare(a[0]))
})
</script>
<template>
<div class="weekly-report" v-loading="loading">
<!-- 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)"></button>
<span class="week-label">{{ report.week_start }} {{ report.week_end }}</span>
<button class="week-nav-btn" @click="changeWeek(1)" :disabled="weekOffset >= 0"></button>
<button v-if="isHistoricalWeek" class="week-nav-reset" @click="goCurrentWeek">回到本周</button>
<el-tag v-if="isHistoricalWeek" type="info" size="small">📦 已归档</el-tag>
</div>
</div>
<div>
<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>
<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>
<!-- Filters -->
<el-card style="margin-bottom: 16px;">
<el-row :gutter="16">
<el-col :span="8">
<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="4">
<el-button @click="loadReport">查询</el-button>
</el-col>
</el-row>
</el-card>
<!-- Tabs -->
<el-card>
<el-tabs v-model="activeTab">
<el-tab-pane label="每日拜访记录" name="visits">
<div v-if="report.visits.length === 0" class="empty">暂无数据</div>
<div v-for="[date, items] in visitsByDate" :key="date" class="date-group">
<h4 class="date-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px; color: var(--gold)">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
{{ date }}
</h4>
<el-table :data="items" stripe style="width:100%">
<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">
<template #default="{ row }">
<span>{{ row.manager_name }}</span>
<el-tooltip v-if="row.edit_log?.length > 1" placement="top">
<template #content>最后编辑{{ row.edit_log[row.edit_log.length-1].editor }} · {{ row.edit_log.length-1 }}次修改</template>
<span class="edit-indicator" title="有过修改">🕐</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="照片" width="100">
<template #default="{ row }">
<div class="photo-cell" v-if="row.photos?.length">
<img v-for="key in row.photos.slice(0,2)" :key="key"
:src="photoUrls[key] || ''" class="mini-thumb"
@click="viewPhoto(photoUrls[key])" />
</div>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<el-tab-pane label="今日纪要" name="daily_notes">
<div v-if="report.daily_notes.length === 0" class="empty">暂无数据</div>
<div v-for="[date, items] in notesByDate" :key="date" class="date-group">
<h4 class="date-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px; color: var(--gold)">
<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>
{{ date }}
</h4>
<el-table :data="items" stripe style="width:100%">
<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>
</template>
</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>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
<ImagePreview v-model="photoDialogVisible" :image-url="currentPhotoUrl" />
</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: 'ZCOOL XiaoWei', STSong, serif;
font-size: 22px; font-weight: 400;
color: var(--ink); letter-spacing: 0.06em;
}
.week-nav { display: flex; align-items: center; gap: 8px; margin: 4px 0 6px; }
.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-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; }
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
/* ═══ Content ═══ */
.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;
}
.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; }
</style>
+183
View File
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { ElMessage, ElMessageBox } from 'element-plus'
import { todayStr } from '@/utils'
import api from '@/api/index'
import EditLogPanel from '@/components/EditLogPanel.vue'
const auth = useAuthStore()
const loading = ref(false)
const workPlans = ref<any[]>([])
const customers = ref<any[]>([])
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const form = ref<any>({})
const statusPick = ref<Record<string, string>>({})
const planStatuses = ['计划中', '已完成', '已取消']
onMounted(async () => {
await Promise.all([loadPlans(), loadCustomers()])
})
async function loadCustomers(q?: string) {
try {
const params: any = { page_size: 100 }
if (q) params.search = q
const res = await api.get('/customers/', { params })
customers.value = res.data.items || []
} catch (_) {}
}
async function loadPlans() {
loading.value = true
try {
const res = await api.get('/work-plans/')
workPlans.value = res.data
} catch (e: any) { ElMessage.error('加载失败') }
finally { loading.value = false }
}
function openCreate() {
dialogMode.value = 'create'
form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中' }
dialogVisible.value = true
}
function openEdit(item: any) {
dialogMode.value = 'edit'
form.value = { ...item }
dialogVisible.value = true
}
async function handleSave() {
try {
if (dialogMode.value === 'create') {
await api.post('/work-plans/', form.value)
} else {
await api.put(`/work-plans/${form.value.id}`, form.value)
}
ElMessage.success(dialogMode.value === 'create' ? '已创建' : '已更新')
dialogVisible.value = false
await loadPlans()
} catch (e: any) { ElMessage.error('保存失败: ' + (e.response?.data?.detail || e.message)) }
}
async function handleDelete(id: string) {
try {
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
await api.delete(`/work-plans/${id}`)
ElMessage.success('已删除')
await loadPlans()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
async function quickStatusChange(row: any, newStatus: string) {
try {
await ElMessageBox.confirm(`确定将状态改为「${newStatus}」?`, '确认', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' })
await api.put(`/work-plans/${row.id}`, { status: newStatus })
ElMessage.success('状态已更新')
await loadPlans()
} catch (e: any) {
if (e !== 'cancel') ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message))
delete statusPick.value[row.id]
}
}
</script>
<template>
<div class="work-plans-page" v-loading="loading">
<div class="page-head">
<div class="page-head-row">
<div>
<h2 class="page-title">工作计划</h2>
<p class="page-desc">面向未来的工作计划安排支持状态流转跟踪</p>
</div>
<el-button type="primary" @click="openCreate">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
新建计划
</el-button>
</div>
<div class="page-rule"></div>
</div>
<el-card>
<el-table :data="workPlans" stripe size="small" v-if="workPlans.length">
<el-table-column type="index" label="序号" width="50" />
<el-table-column prop="customer_name" label="客户" width="160">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="openEdit(row)">{{ row.customer_name }}</el-link>
<el-tooltip v-if="row.edit_log?.length > 1" placement="top">
<template #content>最后编辑{{ row.edit_log[row.edit_log.length-1].editor }} · {{ row.edit_log.length-1 }}次修改</template>
<span class="edit-indicator" title="有过修改">🕐</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column 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">
<template #default="{ row }">
<el-select
v-model="statusPick[row.id]"
size="small"
style="width:100%"
:placeholder="row.status"
@change="(v: string) => quickStatusChange(row, v)"
@visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.status }"
>
<el-option v-for="s in planStatuses" :key="s" :label="s" :value="s" />
</el-select>
</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button text size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-else class="empty">暂无工作计划</div>
</el-card>
<!-- Dialog -->
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 工作计划'" width="500px">
<el-form label-position="top">
<el-form-item label="客户单位">
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="计划拜访时间">
<el-date-picker v-model="form.plan_date" type="date" style="width:100%" />
</el-form-item>
<el-form-item label="工作计划">
<el-input v-model="form.plan_content" type="textarea" :rows="4" placeholder="请输入计划内容" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status">
<el-option label="计划中" value="计划中" />
<el-option label="已完成" value="已完成" />
<el-option label="已取消" value="已取消" />
</el-select>
</el-form-item>
</el-form>
<EditLogPanel v-if="dialogMode === 'edit'" :edit-log="form.edit_log || []" />
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.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-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; }
</style>
+276
View File
@@ -0,0 +1,276 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { todayStr } from '@/utils'
import api from '@/api/index'
import EditLogPanel from '@/components/EditLogPanel.vue'
const router = useRouter()
const route = useRoute()
const isEdit = computed(() => !!route.params.id)
const submitLoading = ref(false)
const categories = ['行政事务', '合同整理', '发票处理', '内部会议', '培训学习', '其他']
const catColors: Record<string, string> = {
'行政事务': '#1C3738', '合同整理': '#4A6741', '发票处理': '#C4934A',
'内部会议': '#B8472E', '培训学习': '#5B7FA5', '其他': '#7B7568',
}
const timeRangeValue = ref<any>(null)
const form = ref({
note_date: todayStr(),
category: '其他',
content: '',
time_range: '',
})
function onTimeRangeChange(val: [string, string] | null) {
form.value.time_range = val ? val.join('-') : ''
}
function parseTimeRange(str: string): [string, string] | null {
if (!str || !str.includes('-')) return null
const parts = str.split('-')
return parts.length >= 2 ? [parts[0], parts[1]] as [string, string] : null
}
onMounted(async () => {
if (isEdit.value) {
try {
const res = await api.get(`/daily-notes/${route.params.id}`)
const n = res.data
form.value = {
note_date: n.note_date,
category: n.category,
content: n.content || '',
time_range: n.time_range || '',
}
timeRangeValue.value = parseTimeRange(n.time_range)
} catch (_) {}
}
})
async function handleSubmit() {
if (!form.value.content.trim()) { ElMessage.warning('请输入纪要内容'); return }
submitLoading.value = true
try {
if (isEdit.value) {
await api.put(`/daily-notes/${route.params.id}`, form.value)
ElMessage.success('纪要已更新')
} else {
await api.post('/daily-notes/', form.value)
ElMessage.success('纪要已提交')
}
router.push('/m')
} catch (e: any) {
ElMessage.error('提交失败: ' + (e.response?.data?.detail || e.message))
} finally { submitLoading.value = false }
}
async function handleDelete() {
try {
await ElMessageBox.confirm('确定删除这条纪要?', '确认', { type: 'warning' })
await api.delete(`/daily-notes/${route.params.id}`)
ElMessage.success('已删除')
router.push('/m')
} catch (e: any) {
if (e !== 'cancel') ElMessage.error('删除失败')
}
}
</script>
<template>
<div class="form-page">
<!-- Editorial Header -->
<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">DAILY NOTE</span>
</div>
<div class="form-rule"></div>
</header>
<el-form label-position="top" class="editorial-form">
<el-form-item>
<template #label>
<span class="form-label">日期</span>
</template>
<el-date-picker v-model="form.note_date" type="date" style="width:100%" />
</el-form-item>
<el-form-item>
<template #label>
<span class="form-label">分类</span>
</template>
<div class="category-grid">
<button
v-for="c in categories"
:key="c"
type="button"
class="cat-chip"
:class="{ 'cat-chip--active': form.category === c }"
:style="form.category === c ? { background: catColors[c], borderColor: catColors[c], color: '#fff' } : {}"
@click="form.category = c"
>
{{ c }}
</button>
</div>
</el-form-item>
<el-form-item>
<template #label>
<span class="form-label">时间范围 <span class="form-label-hint">可选</span></span>
</template>
<el-time-picker
v-model="timeRangeValue"
is-range
format="HH:mm"
value-format="HH:mm"
range-separator=""
start-placeholder="开始"
end-placeholder="结束"
style="width:100%"
@change="onTimeRangeChange"
/>
</el-form-item>
<el-form-item>
<template #label>
<span class="form-label">工作内容 <span class="form-label-required">*</span></span>
</template>
<el-input v-model="form.content" type="textarea" :rows="5" 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" type="button" class="delete-btn" @click="handleDelete">
<svg width="16" height="16" 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 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
删除
</button>
</div>
<EditLogPanel v-if="isEdit" :edit-log="form.edit_log || []" />
</el-form>
</div>
</template>
<style scoped>
/* ═══ Page ═══ */
.form-page { max-width: 100%; margin: 0 auto; padding-bottom: 20px; }
/* ═══ Editorial Header ═══ */
.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: 'ZCOOL XiaoWei', STSong, serif;
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-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 Labels ═══ */
.form-label {
font-family: 'ZCOOL XiaoWei', STSong, serif !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-size: 11px; color: var(--warm-gray);
letter-spacing: 0.03em; font-weight: 400; margin-left: 6px;
}
.form-label-required { color: var(--vermilion); margin-left: 2px; }
.editorial-form .el-form-item { margin-bottom: 20px; }
/* ═══ Category Grid ═══ */
.category-grid { display: flex; flex-wrap: wrap; gap: 8px; }
.cat-chip {
padding: 10px 16px;
background: var(--surface);
border: 1px solid var(--warm-border);
color: var(--ink);
font-family: 'Noto Serif SC', STSong, serif;
font-size: 13px;
letter-spacing: 0.03em;
cursor: pointer;
transition: all 0.25s;
}
.cat-chip:hover { border-color: var(--gold); }
.cat-chip--active { font-weight: 600; }
/* ═══ Actions ═══ */
.form-actions {
display: flex; gap: 10px; margin-top: 28px;
}
.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-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 {
display: flex; align-items: center; gap: 6px;
padding: 16px 20px;
background: var(--surface); color: var(--vermilion);
border: 1px solid var(--vermilion);
font-family: 'Noto Serif SC', STSong, serif;
font-size: 14px; letter-spacing: 0.04em; 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>
+582
View File
@@ -0,0 +1,582 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { visitsApi } from '@/api/visits'
import { uploadApi } from '@/api/upload'
import ImagePreview from '@/components/ImagePreview.vue'
import { ElMessage } from 'element-plus'
import api from '@/api/index'
const router = useRouter()
const todayVisitCount = ref(0)
const visits = ref<any[]>([])
const todayNoteCount = ref(0)
const dailyNotes = ref<any[]>([])
const previewImageUrl = ref('')
const previewDialogVisible = ref(false)
const photoUrls = ref<Record<string, string>>({})
const loading = ref(false)
const todayTotal = computed(() => todayVisitCount.value + todayNoteCount.value)
const categoryColors: Record<string, string> = {
'行政事务': '#1C3738', '合同整理': '#4A6741', '发票处理': '#C4934A',
'内部会议': '#B8472E', '培训学习': '#5B7FA5', '其他': '#7B7568',
}
const categoryGlyphs: Record<string, string> = {
'行政事务': '政', '合同整理': '约', '发票处理': '票',
'内部会议': '议', '培训学习': '学', '其他': '杂',
}
async function loadToday() {
loading.value = true
try {
const [vRes, nRes] = await Promise.all([
visitsApi.getToday(),
api.get('/daily-notes/today'),
])
todayVisitCount.value = vRes.data.count
visits.value = vRes.data.visits
todayNoteCount.value = nRes.data.count
dailyNotes.value = nRes.data.notes
for (const v of visits.value) {
if (v.photos?.length > 0) {
for (const key of v.photos) {
try {
const urlRes = await uploadApi.getDownloadUrl(key)
photoUrls.value[key] = urlRes.data.download_url
} catch (_) {}
}
}
}
} catch (e: any) {
ElMessage.error('加载失败')
} finally { loading.value = false }
}
function previewPhoto(url: string) { previewImageUrl.value = url; previewDialogVisible.value = true }
onMounted(loadToday)
</script>
<template>
<div class="mobile-home">
<!-- Editorial Stat Banner -->
<div class="stat-banner">
<div class="stat-masthead">
<span class="stat-label-sm">TODAY</span>
<span class="stat-date">{{ new Date().toLocaleDateString('zh-CN', { month:'long', day:'numeric', weekday:'short' }) }}</span>
</div>
<div class="stat-main">
<span class="stat-number">{{ todayTotal }}</span>
<span class="stat-unit">条记录</span>
<div class="stat-breakdown">
<span class="stat-piece">拜访 <strong>{{ todayVisitCount }}</strong></span>
<span class="stat-sep">·</span>
<span class="stat-piece">纪要 <strong>{{ todayNoteCount }}</strong></span>
</div>
</div>
<!-- Decorative ink-wash corner -->
<div class="stat-ornament"></div>
</div>
<!-- Primary Quick Actions -->
<div class="quick-actions">
<button class="action-btn action-btn--primary" @click="router.push('/m/visit/new')">
<svg width="20" height="20" 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>
<span class="action-btn__text">今日拜访</span>
</button>
<button class="action-btn action-btn--secondary" @click="router.push('/m/note/new')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
<span class="action-btn__text">今日纪要</span>
</button>
</div>
<!-- Secondary Quick Links -->
<div class="quick-links">
<button class="link-chip" @click="router.push('/m/work-plan/new')">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
工作计划
</button>
<button class="link-chip" @click="router.push('/m/mini-biz/new')">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="1" x2="12" y2="23"></line>
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
</svg>
商机跟单
</button>
<button class="link-chip" @click="router.push('/m/key-visit/new')">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
</svg>
要客拜访
</button>
</div>
<!-- Section Divider -->
<div class="section-head" v-if="dailyNotes.length || visits.length">
<span class="section-title">今日记录</span>
<span class="section-line"></span>
</div>
<div v-loading="loading" class="records-area">
<!-- Daily Notes -->
<template v-if="dailyNotes.length">
<article
v-for="(n, i) in dailyNotes"
:key="n.id"
class="record-card record-card--note"
:class="{ 'record-card--offset': i % 2 === 1 }"
@click="router.push(`/m/note/${n.id}/edit`)"
>
<div class="record-accent" :style="{ background: categoryColors[n.category] || '#7B7568' }"></div>
<div class="record-body">
<div class="record-header">
<span class="record-glyph" :style="{ background: categoryColors[n.category] || '#7B7568' }">
{{ categoryGlyphs[n.category] || '记' }}
</span>
<span class="record-category">{{ n.category }}</span>
<span v-if="n.time_range" class="record-time">{{ n.time_range }}</span>
</div>
<p class="record-content">{{ n.content }}</p>
<div class="record-footer" v-if="n.time_range">
<span class="record-time">{{ n.time_range }}</span>
</div>
</div>
</article>
</template>
<!-- Visit Records -->
<template v-if="visits.length">
<article
v-for="(v, i) in visits"
:key="v.id"
class="record-card record-card--visit"
:class="{ 'record-card--offset': (dailyNotes.length + i) % 2 === 1 }"
@click="router.push(`/m/visit/${v.id}/edit`)"
>
<div class="record-accent record-accent--gold"></div>
<div class="record-body">
<div class="record-header">
<span class="record-glyph record-glyph--visit">访</span>
<strong class="record-customer">{{ v.customer_name || '未知客户' }}</strong>
<span class="record-badge" :class="'badge--' + v.visit_method">{{ v.visit_method }}</span>
</div>
<p class="record-content">{{ v.communication_content || '暂无沟通内容' }}</p>
<p v-if="v.customer_demand" class="record-demand">
<span class="demand-marker"></span> {{ v.customer_demand }}
</p>
<div v-if="v.photos?.length" class="record-photos">
<img v-for="key in v.photos" :key="key" :src="photoUrls[key] || ''" class="record-photo" @click.stop="previewPhoto(photoUrls[key])" />
</div>
<div class="record-footer" v-if="v.time_range">
<span class="record-time">{{ v.time_range }}</span>
</div>
</div>
</article>
</template>
<!-- Empty State -->
<div v-if="visits.length === 0 && dailyNotes.length === 0 && !loading" class="empty-state">
<div class="empty-glyph"></div>
<p class="empty-text">今日暂无记录</p>
<p class="empty-hint">点击上方按钮开始填报</p>
</div>
</div>
<ImagePreview v-model="previewDialogVisible" :image-url="previewImageUrl" />
</div>
</template>
<style scoped>
/* ═══ Page ═══ */
.mobile-home {
padding-bottom: 24px;
max-width: 100%;
position: relative;
}
/* ═══ Stat Banner — Editorial Masthead ═══ */
.stat-banner {
position: relative;
background: var(--surface);
border: 1px solid var(--warm-border);
padding: 20px 22px;
margin-bottom: 18px;
overflow: hidden;
}
.stat-masthead {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.stat-label-sm {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 10px;
letter-spacing: 0.2em;
color: var(--warm-gray);
}
.stat-date {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 12px;
color: var(--warm-gray);
letter-spacing: 0.04em;
}
.stat-main {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.stat-number {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 56px;
color: var(--ink);
line-height: 1;
letter-spacing: 0.04em;
}
.stat-unit {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 16px;
color: var(--warm-gray);
letter-spacing: 0.06em;
}
.stat-breakdown {
width: 100%;
display: flex;
gap: 10px;
align-items: center;
margin-top: 6px;
}
.stat-piece {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 13px;
color: var(--warm-gray);
letter-spacing: 0.03em;
}
.stat-piece strong {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
color: var(--ink);
font-size: 15px;
}
.stat-sep {
color: var(--gold);
font-weight: 300;
}
/* Decorative gold slash */
.stat-ornament {
position: absolute;
top: -20px;
right: -20px;
width: 80px;
height: 80px;
background: linear-gradient(135deg, transparent 50%, rgba(196,147,74,0.08) 50%);
pointer-events: none;
}
/* ═══ Quick Actions ═══ */
.quick-actions {
display: flex;
gap: 10px;
margin-bottom: 12px;
}
.action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px 12px;
border: 1px solid var(--warm-border);
cursor: pointer;
font-family: 'Noto Serif SC', STSong, serif;
font-size: 15px;
letter-spacing: 0.04em;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.action-btn:active {
transform: scale(0.97);
}
.action-btn--primary {
background: var(--ink);
color: #fff;
border-color: var(--ink);
}
.action-btn--primary:hover {
background: var(--ink-light);
}
.action-btn--secondary {
background: var(--surface);
color: var(--ink);
border-color: var(--ink);
}
.action-btn--secondary:hover {
background: var(--c-primary-bg);
}
.action-btn__text {
font-weight: 500;
}
/* ═══ Quick Links (Chips) ═══ */
.quick-links {
display: flex;
gap: 8px;
margin-bottom: 22px;
flex-wrap: wrap;
}
.link-chip {
display: flex;
align-items: center;
gap: 5px;
padding: 9px 15px;
background: var(--surface);
border: 1px solid var(--warm-border);
color: var(--ink);
font-family: 'Noto Serif SC', STSong, serif;
font-size: 13px;
letter-spacing: 0.03em;
cursor: pointer;
transition: all 0.2s;
}
.link-chip:hover {
border-color: var(--gold);
color: var(--vermilion);
}
.link-chip:active {
background: var(--paper-dark);
}
/* ═══ Section Head ═══ */
.section-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.section-title {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 18px;
color: var(--ink);
letter-spacing: 0.06em;
white-space: nowrap;
}
.section-line {
flex: 1;
height: 2px;
background: var(--gold);
}
/* ═══ Record Cards ═══ */
.records-area {
display: flex;
flex-direction: column;
gap: 12px;
}
.record-card {
background: var(--surface);
border: 1px solid var(--warm-border);
display: flex;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.record-card:active { transform: scale(0.99); }
.record-card--offset {
margin-left: 16px;
}
.record-card:hover {
box-shadow: 0 4px 12px rgba(28,55,56,0.06);
}
/* Left accent stripe */
.record-accent {
width: 4px;
flex-shrink: 0;
}
.record-accent--gold {
background: var(--gold);
}
.record-body {
flex: 1;
padding: 14px 16px;
min-width: 0;
}
.record-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
}
/* Category glyph — single Chinese character badge */
.record-glyph {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
color: #fff;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 13px;
flex-shrink: 0;
}
.record-glyph--visit {
background: var(--gold);
}
.record-category {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 13px;
color: var(--ink);
letter-spacing: 0.04em;
font-weight: 600;
}
.record-badge {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 11px;
padding: 1px 8px;
letter-spacing: 0.04em;
margin-left: auto;
border: 1px solid;
}
.badge--上门 { color: #4A6741; border-color: #4A6741; }
.badge--电话 { color: #5B7FA5; border-color: #5B7FA5; }
.badge--微信 { color: #22c55e; border-color: #22c55e; }
.badge--出差 { color: #C4934A; border-color: #C4934A; }
.record-customer {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 15px;
color: var(--ink);
letter-spacing: 0.03em;
flex: 1;
}
.record-time {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 11px;
color: var(--warm-gray);
letter-spacing: 0.03em;
margin-left: auto;
}
.record-content {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 14px;
color: var(--c-text);
line-height: 1.7;
margin: 0 0 8px;
word-break: break-all;
}
.record-demand {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 13px;
color: var(--vermilion);
margin: 0 0 8px;
line-height: 1.6;
}
.demand-marker {
color: var(--gold);
font-size: 10px;
}
.record-photos {
display: flex;
gap: 6px;
margin: 8px 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 2px;
}
.record-photo {
width: 68px;
height: 68px;
object-fit: cover;
flex-shrink: 0;
border: 2px solid var(--warm-border);
}
.record-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
}
/* ═══ Empty State ═══ */
.empty-state {
text-align: center;
padding: 48px 0;
}
.empty-glyph {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 40px;
color: var(--gold);
opacity: 0.4;
margin-bottom: 8px;
}
.empty-text {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 15px;
color: var(--warm-gray);
margin: 0 0 4px;
}
.empty-hint {
font-family: 'Noto Serif SC', STSong, serif;
font-size: 12px;
color: var(--c-text-muted);
margin: 0;
}
</style>

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