Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf67e0575f | |||
| aa3fbca710 | |||
| 8926475b20 | |||
| e7af0e15ab | |||
| a1886074dd |
@@ -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. **仪表盘跳转**: 四张统计卡片可点击跳转到对应的周报/工作计划/商机/要客页面
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hash": "e75be2da",
|
||||
"configHash": "ef9a524a",
|
||||
"lockfileHash": "19c2bb83",
|
||||
"browserHash": "dc47218b",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -0,0 +1,3 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
@@ -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"])
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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")
|
||||
@@ -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])
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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": {},
|
||||
}]
|
||||
@@ -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
|
||||
@@ -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])
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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;"]
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+3189
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 })
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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 })
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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}`)
|
||||
},
|
||||
}
|
||||
@@ -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}`)
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -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}`)
|
||||
},
|
||||
}
|
||||
@@ -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}`)
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Vendored
+21
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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')}`
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user