企迹(qiji) 政企周报管理系统 — v0.1
后端: FastAPI + SQLAlchemy 2.0 (async) + Alembic + MinIO + Casdoor + 企微 前端: Vue 3 + Vite + TypeScript + Element Plus + Pinia 功能清单: - 8 张数据表自动建表 / Casdoor OIDC 登录 / 企微静默登录 - 双布局: 移动端(填报) + PC端(汇总管理) - 拜访记录 CRUD + MinIO 照片直传 + 缩略图预览 + 同访人草稿 - 今日纪要 (6 分类) / 工作计划 / 小微商机 / 要客拜访 CRUD - 客户档案: 备注/收支费用/联系人/归属分配/批量转移 - 客户导入导出 + 模板下载 + 搜索/分页/筛选 - 仪表盘: 四卡统计 + 填报进度 (拜访+纪要双维度) - 周报详情: 五 Tab + 按人/客户筛选 + 时间轴 - 用户管理 / 客户经理 PC 端工作台 - 企微: 催办/公告/定时提醒 / 时区修正 - Docker 部署配置 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
## 数据模型 (8 张 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, communication_content, customer_demand, companions(UUID[]), photos(TEXT[]), manager_id |
|
||||
| `work_plans` | id, customer_id, plan_content, plan_date, manager_id, status |
|
||||
| `mini_business` | id, customer_id, product_type, amount, follow_up_detail, status, manager_id |
|
||||
| `key_visits` | id, customer_id, urgency_level, description, progress_status, planned_date, planned_visitor, visit_target, manager_id |
|
||||
|
||||
## 当前进度
|
||||
|
||||
### 已完成 ✅
|
||||
|
||||
- [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,防火墙已开放
|
||||
|
||||
### 待完善
|
||||
|
||||
- [ ] 实际对接企业微信(需填写 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:8000`
|
||||
@@ -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,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,507 @@
|
||||
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)
|
||||
|
||||
return CustomerListResponse(
|
||||
items=result.scalars().all(),
|
||||
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, skipped = 0, 0
|
||||
errors = []
|
||||
|
||||
# Build user name → id lookup
|
||||
user_rows = await db.execute(select(User.name, User.id).where(User.role == "manager"))
|
||||
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 ""
|
||||
|
||||
existing = await db.execute(select(Customer).where(Customer.name == name))
|
||||
if existing.scalar_one_or_none():
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Resolve manager: by name from template, or fallback to current user
|
||||
assignee_id = user_map.get(mgr_name, default_user_id)
|
||||
|
||||
try:
|
||||
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
|
||||
except Exception as e:
|
||||
errors.append(f"第{row_idx}行: {str(e)}")
|
||||
|
||||
await db.commit()
|
||||
return {"created": created, "skipped": skipped, "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),
|
||||
):
|
||||
"""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),
|
||||
):
|
||||
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,131 @@
|
||||
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
|
||||
|
||||
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,
|
||||
"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,
|
||||
)
|
||||
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")
|
||||
|
||||
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)
|
||||
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,45 @@
|
||||
import uuid
|
||||
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(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get dashboard card statistics for the current week."""
|
||||
stats = await get_dashboard_stats(db)
|
||||
return stats
|
||||
|
||||
|
||||
@router.get("/progress")
|
||||
async def reporting_progress(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get per-manager reporting progress."""
|
||||
return await get_reporting_progress(db)
|
||||
|
||||
|
||||
@router.get("/weekly-report")
|
||||
async def weekly_report(
|
||||
manager_id: Optional[str] = Query(None),
|
||||
customer_id: Optional[str] = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get full weekly report data (four modules)."""
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
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(
|
||||
current_user: dict = Depends(require_director_or_leader),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Export this week's report as a 4-sheet .xlsx file."""
|
||||
excel_bytes = await export_weekly_report(db)
|
||||
return StreamingResponse(
|
||||
excel_bytes,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": "attachment; filename=weekly_report.xlsx"},
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, File, UploadFile, HTTPException
|
||||
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.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()
|
||||
|
||||
# Preview first: parse headers
|
||||
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 # minus header
|
||||
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)}")
|
||||
|
||||
# Import data
|
||||
stats = await import_from_excel(db, content, uuid.UUID(current_user["user_id"]))
|
||||
stats["preview"] = preview
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
import io
|
||||
@@ -0,0 +1,107 @@
|
||||
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
|
||||
|
||||
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),
|
||||
"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"]),
|
||||
)
|
||||
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")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, v in update_data.items():
|
||||
setattr(k, key, v)
|
||||
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,105 @@
|
||||
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
|
||||
|
||||
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(),
|
||||
"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,
|
||||
)
|
||||
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")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for k, v in update_data.items():
|
||||
setattr(m, k, v)
|
||||
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,209 @@
|
||||
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
|
||||
|
||||
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,
|
||||
"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,
|
||||
"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"]),
|
||||
)
|
||||
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")
|
||||
|
||||
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)
|
||||
|
||||
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,105 @@
|
||||
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.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,
|
||||
}
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
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")
|
||||
|
||||
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)
|
||||
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,58 @@
|
||||
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 ''"
|
||||
))
|
||||
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,19 @@
|
||||
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
|
||||
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="")
|
||||
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,19 @@
|
||||
import uuid
|
||||
from sqlalchemy import String, Text, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
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)
|
||||
@@ -0,0 +1,18 @@
|
||||
import uuid
|
||||
from sqlalchemy import String, Text, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
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="")
|
||||
@@ -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,23 @@
|
||||
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
|
||||
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="")
|
||||
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)
|
||||
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,17 @@
|
||||
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
|
||||
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="计划中") # 计划中/已完成/已取消
|
||||
@@ -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,118 @@
|
||||
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
|
||||
|
||||
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,60 @@
|
||||
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 = ""
|
||||
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
|
||||
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
|
||||
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,253 @@
|
||||
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) -> dict:
|
||||
"""Get dashboard statistics for the current week."""
|
||||
monday, sunday = get_week_range()
|
||||
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) -> list[dict]:
|
||||
"""Get per-manager reporting progress for the current week."""
|
||||
monday, sunday = get_week_range()
|
||||
|
||||
# Get all managers
|
||||
managers_result = await db.execute(select(User).where(User.role == "manager"))
|
||||
managers = managers_result.scalars().all()
|
||||
|
||||
# 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,
|
||||
) -> dict:
|
||||
"""Get full weekly report data organized by module."""
|
||||
monday, sunday = get_week_range()
|
||||
|
||||
# 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,137 @@
|
||||
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.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,78 @@
|
||||
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}
|
||||
|
||||
# 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, 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 ""
|
||||
|
||||
customer_id = customer_map.get(cust_name)
|
||||
if not customer_id:
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
# Parse date
|
||||
try:
|
||||
visit_date = datetime.strptime(visit_date_str[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
visit_date = date.today()
|
||||
|
||||
# Check for duplicates (same day, same person, same customer)
|
||||
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
|
||||
continue
|
||||
|
||||
visit = Visit(
|
||||
customer_id=customer_id,
|
||||
visit_date=visit_date,
|
||||
visit_method=visit_method if visit_method in ["上门", "电话", "微信", "出差"] else "上门",
|
||||
time_range=time_range,
|
||||
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,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
+2096
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"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": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "~5.6.0",
|
||||
"vite": "^6.0.5",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const isMobile = computed(() => {
|
||||
// Simple mobile detection — can be refined
|
||||
return /Android|iPhone|iPad|iPod|webOS/i.test(navigator.userAgent) || window.innerWidth < 768
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
html, body, #app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
</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() {
|
||||
return api.get('/dashboard/stats')
|
||||
},
|
||||
getProgress() {
|
||||
return api.get('/dashboard/progress')
|
||||
},
|
||||
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,101 @@
|
||||
<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 menuItems = computed(() => {
|
||||
const items = [
|
||||
{ path: '/', label: '仪表盘', icon: 'Odometer' },
|
||||
{ path: '/weekly-report', label: '周报详情', icon: 'Document' },
|
||||
]
|
||||
if (auth.isManager || auth.isDirector) {
|
||||
items.push({ path: '/workspace', label: '我的数据', icon: 'List' })
|
||||
}
|
||||
items.push({ path: '/customers', label: '客户管理', icon: 'UserFilled' })
|
||||
if (auth.isDirector) {
|
||||
items.push({ path: '/users', label: '用户管理', icon: 'Avatar' })
|
||||
items.push({ path: '/settings', label: '系统设置', icon: 'Setting' })
|
||||
}
|
||||
return items
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="desktop-layout">
|
||||
<aside class="sidebar">
|
||||
<div class="logo">
|
||||
<h2>企迹</h2>
|
||||
<p>政企周报管理</p>
|
||||
</div>
|
||||
<el-menu
|
||||
:default-active="route.path"
|
||||
router
|
||||
background-color="#304156"
|
||||
text-color="#bfcbd9"
|
||||
active-text-color="#409EFF"
|
||||
>
|
||||
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<span>{{ item.label }}</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</aside>
|
||||
<div class="main-area">
|
||||
<header class="topbar">
|
||||
<span class="greeting">{{ auth.isDirector ? '支局长' : auth.isLeader ? '分管领导' : '客户经理' }} {{ auth.name }}</span>
|
||||
<div style="display:flex; align-items:center; gap:8px">
|
||||
<el-button text @click="router.push('/m')">📱 移动端</el-button>
|
||||
<el-button text @click="auth.logout(); router.push('/login')">退出</el-button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.desktop-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background: #304156;
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.logo {
|
||||
padding: 20px 16px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #4a5a6a;
|
||||
}
|
||||
.logo h2 { margin: 0; font-size: 18px; color: #fff; }
|
||||
.logo p { margin: 4px 0 0; font-size: 11px; color: #bfcbd9; }
|
||||
.main-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
.topbar {
|
||||
background: white;
|
||||
padding: 12px 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,92 @@
|
||||
<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: 'HomeFilled' },
|
||||
{ path: '/m/visit/new', label: '今日拜访', icon: 'Edit' },
|
||||
{ path: '/m/note/new', label: '今日纪要', icon: 'Notebook' },
|
||||
]
|
||||
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">
|
||||
<header class="mobile-header">
|
||||
<span class="header-title">企迹</span>
|
||||
<div style="display:flex; align-items:center; gap:8px">
|
||||
<el-button text size="small" style="color:white" @click="router.push('/')">💻 PC</el-button>
|
||||
<span class="header-user">{{ auth.name }}</span>
|
||||
</div>
|
||||
</header>
|
||||
<main class="mobile-main">
|
||||
<router-view />
|
||||
</main>
|
||||
<nav class="mobile-tabbar">
|
||||
<div
|
||||
v-for="tab in tabs" :key="tab.path"
|
||||
class="tab-item" :class="{ active: activeTab === tab.path }"
|
||||
@click="router.push(tab.path)"
|
||||
>
|
||||
<el-icon><component :is="tab.icon" /></el-icon>
|
||||
<span>{{ tab.label }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mobile-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.mobile-header {
|
||||
background: linear-gradient(135deg, #1a73e8, #4080ff);
|
||||
color: white;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
.mobile-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
.mobile-tabbar {
|
||||
display: flex;
|
||||
background: white;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tab-item.active {
|
||||
color: #1a73e8;
|
||||
}
|
||||
.tab-item .el-icon {
|
||||
font-size: 22px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
</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,13 @@
|
||||
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 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,63 @@
|
||||
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: '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,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,124 @@
|
||||
<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, #1a73e8 0%, #4080ff 50%, #69a0ff 100%);
|
||||
}
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 48px 40px;
|
||||
width: 360px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
|
||||
}
|
||||
.brand {
|
||||
text-align: center;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
.brand h1 {
|
||||
margin: 0;
|
||||
font-size: 32px;
|
||||
color: #1a73e8;
|
||||
}
|
||||
.brand p {
|
||||
margin: 8px 0 0;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
.hint {
|
||||
text-align: center;
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,424 @@
|
||||
<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('')
|
||||
|
||||
// Detail
|
||||
const detailVisible = ref(false)
|
||||
const detailCustomer = ref<any>(null)
|
||||
|
||||
// Managers
|
||||
const managers = ref<any[]>([])
|
||||
const selectedIds = ref<string[]>([])
|
||||
const batchManagerId = ref('')
|
||||
|
||||
// Import
|
||||
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/', { params: { role: 'manager' } })
|
||||
managers.value = res.data
|
||||
} 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() }
|
||||
|
||||
// ── Form helpers ──
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
// Try to separate trailing non-numeric chars as custom unit
|
||||
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 = '自定义'
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create / Edit Dialog ──
|
||||
|
||||
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('删除失败') }
|
||||
}
|
||||
|
||||
// ── Detail ──
|
||||
|
||||
async function openDetail(customer: any) {
|
||||
try {
|
||||
const res = await customersApi.get(customer.id)
|
||||
detailCustomer.value = res.data
|
||||
detailVisible.value = true
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Delete ──
|
||||
|
||||
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('删除失败') }
|
||||
}
|
||||
|
||||
// ── Batch Assign ──
|
||||
|
||||
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('批量分配失败') }
|
||||
}
|
||||
|
||||
// ── Import / Export ──
|
||||
|
||||
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.skipped} 条`)
|
||||
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">
|
||||
<div class="page-header">
|
||||
<h3>客户档案管理</h3>
|
||||
<div class="header-btns">
|
||||
<el-button v-if="auth.isDirector" @click="downloadTemplate">📋 模板</el-button>
|
||||
<el-button v-if="auth.isDirector" @click="openImportDialog">📤 导入</el-button>
|
||||
<el-button v-if="auth.isDirector" @click="handleExport">📥 导出</el-button>
|
||||
<el-button v-if="auth.isDirector" type="primary" @click="openCreate">+ 新建客户</el-button>
|
||||
</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:#606266">已选 {{ 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="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button v-if="auth.isDirector" text size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</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">联系人</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:#c0c4cc; text-align:center; padding:12px">暂无联系人</div>
|
||||
</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.skipped }} 条
|
||||
<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-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.page-header h3 { margin: 0; }
|
||||
.header-btns { display: flex; gap: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } 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[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [sRes, pRes] = await Promise.all([
|
||||
dashboardApi.getStats(),
|
||||
dashboardApi.getProgress(),
|
||||
])
|
||||
stats.value = sRes.data
|
||||
progress.value = pRes.data
|
||||
} catch (e: any) {
|
||||
ElMessage.error('加载仪表盘失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function goWeeklyReport() {
|
||||
router.push('/weekly-report')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard" v-loading="loading">
|
||||
<h3>仪表盘</h3>
|
||||
<p class="week-range">{{ stats.week_start }} ~ {{ stats.week_end }}</p>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<el-row :gutter="16" style="margin-bottom: 24px">
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-value">{{ stats.week_visits }}</div>
|
||||
<div class="stat-label">本周拜访次数</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-value">{{ stats.work_plans }}</div>
|
||||
<div class="stat-label">工作计划条数</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-value">{{ stats.mini_business }}</div>
|
||||
<div class="stat-label">商机跟单条数</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-value">{{ stats.key_visits }}</div>
|
||||
<div class="stat-label">要客拜访条数</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="actions">
|
||||
<el-button type="primary" @click="goWeeklyReport">📋 本周周报详情</el-button>
|
||||
<el-button type="success" v-if="auth.isDirector" @click="router.push('/weekly-report')">📥 导出 Excel</el-button>
|
||||
<el-button @click="router.push('/customers')">👥 客户管理</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Reporting Progress -->
|
||||
<el-card class="progress-card">
|
||||
<template #header>填报进度</template>
|
||||
<div v-if="progress.length === 0" class="empty">暂无数据</div>
|
||||
<div v-for="p in progress" :key="p.manager_id" class="progress-row">
|
||||
<div class="progress-info">
|
||||
<span class="progress-name">
|
||||
{{ p.manager_name }}
|
||||
<el-tag v-if="!p.has_reported_today" type="danger" size="small">⚠ 今日未填</el-tag>
|
||||
<el-tag v-else type="success" size="small">✓ 已填</el-tag>
|
||||
</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="p.completed ? '#67c23a' : '#f56c6c'"
|
||||
:stroke-width="18"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard h3 { margin: 0 0 4px; }
|
||||
.week-range { color: #909399; font-size: 13px; margin-bottom: 20px; }
|
||||
.stat-card { text-align: center; }
|
||||
.stat-value { font-size: 36px; font-weight: 700; color: #1a73e8; }
|
||||
.stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
|
||||
.actions { display: flex; gap: 12px; margin-bottom: 24px; }
|
||||
.progress-card { margin-top: 20px; }
|
||||
.progress-row { margin-bottom: 16px; }
|
||||
.progress-info { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
||||
.progress-name { display: flex; align-items: center; gap: 8px; font-weight: 500; }
|
||||
.progress-count { font-size: 13px; color: #606266; }
|
||||
.empty { text-align: center; color: #c0c4cc; padding: 24px 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,319 @@
|
||||
<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'
|
||||
|
||||
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[]>([])
|
||||
|
||||
// Dialog
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogType = ref('')
|
||||
const form = ref<any>({})
|
||||
|
||||
const categories = ['行政事务', '合同整理', '发票处理', '内部会议', '培训学习', '其他']
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadAll(), loadCustomers()])
|
||||
})
|
||||
|
||||
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
|
||||
} catch (e: any) { ElMessage.error('加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openCreate(type: string) {
|
||||
dialogMode.value = 'create'; dialogType.value = type
|
||||
if (type === 'visit') form.value = { customer_id: '', visit_date: todayStr(), visit_method: '上门', time_range: '', 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: '' }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(type: string, item: any) {
|
||||
dialogMode.value = 'edit'; dialogType.value = type
|
||||
form.value = { ...item }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
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('删除失败') }
|
||||
}
|
||||
|
||||
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">
|
||||
<h3>我的工作数据</h3>
|
||||
<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>📅 {{ 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">{{ 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 prop="communication_content" label="沟通内容" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button text size="small" @click="openEdit('visit', row)">编辑</el-button>
|
||||
<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>📅 {{ 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 />
|
||||
<el-table-column prop="time_range" label="时间" width="100" />
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button text size="small" @click="openEdit('note', row)">编辑</el-button>
|
||||
<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">{{ row.customer_name }}</el-link></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="plan_content" label="工作计划" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column prop="plan_date" label="计划时间" width="110" />
|
||||
<el-table-column prop="status" label="状态" width="90"><template #default="{ row }"><el-tag size="small">{{ row.status }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button text size="small" @click="openEdit('plan', row)">编辑</el-button>
|
||||
<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">{{ 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 prop="status" label="状态" width="90"><template #default="{ row }"><el-tag size="small">{{ row.status }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="expected_revenue_date" label="预计列收" width="110" />
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button text size="small" @click="openEdit('mini', row)">编辑</el-button>
|
||||
<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">{{ 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 prop="progress_status" label="进展" width="80" />
|
||||
<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="120">
|
||||
<template #default="{ row }">
|
||||
<el-button text size="small" @click="openEdit('key', row)">编辑</el-button>
|
||||
<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">
|
||||
<!-- Customer selector for types that need it -->
|
||||
<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>
|
||||
<!-- Date -->
|
||||
<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>
|
||||
<!-- Visit specific -->
|
||||
<template v-if="dialogType === 'visit'">
|
||||
<el-form-item label="拜访方式">
|
||||
<el-radio-group v-model="form.visit_method">
|
||||
<el-radio-button value="上门">上门</el-radio-button><el-radio-button value="电话">电话</el-radio-button><el-radio-button value="微信">微信</el-radio-button><el-radio-button value="出差">出差</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="时间范围"><el-input v-model="form.time_range" /></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>
|
||||
</template>
|
||||
<!-- Note specific -->
|
||||
<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-input v-model="form.time_range" /></el-form-item>
|
||||
<el-form-item label="工作内容"><el-input v-model="form.content" type="textarea" :rows="4" /></el-form-item>
|
||||
</template>
|
||||
<!-- Plan specific -->
|
||||
<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>
|
||||
<!-- Mini biz specific -->
|
||||
<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-input v-model="form.expected_revenue_date" /></el-form-item>
|
||||
</template>
|
||||
<!-- Key visit specific -->
|
||||
<template v-if="dialogType === 'key'">
|
||||
<el-form-item label="紧急重要度"><el-radio-group v-model="form.urgency_level"><el-radio-button value="一般">一般</el-radio-button><el-radio-button value="重要">重要</el-radio-button><el-radio-button value="紧急">紧急</el-radio-button></el-radio-group></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-input v-model="form.planned_date" /></el-form-item>
|
||||
<el-form-item label="计划拜访人"><el-input v-model="form.planned_visitor" /></el-form-item>
|
||||
<el-form-item label="拜访对象"><el-input v-model="form.visit_target" /></el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workspace h3 { margin: 0 0 16px; }
|
||||
.date-group { margin-bottom: 20px; }
|
||||
.date-group h4 { margin: 12px 0 8px; font-size: 14px; color: #303133; }
|
||||
.empty { text-align: center; color: #c0c4cc; padding: 40px 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
<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)
|
||||
|
||||
// Import
|
||||
const importDialogVisible = ref(false)
|
||||
const importFile = ref<File | null>(null)
|
||||
const importPreview = ref<any>(null)
|
||||
const importLoading = ref(false)
|
||||
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 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">
|
||||
<h3>系统设置</h3>
|
||||
|
||||
<!-- Manual Remind -->
|
||||
<el-card class="setting-card">
|
||||
<template #header>📢 手动催办</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>📣 全员公告</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>⏰ 填报检查</template>
|
||||
<p>手动触发今日填报检查(通常每日 18:00 自动执行)</p>
|
||||
<el-button :loading="dailyCheckLoading" @click="handleDailyCheck">立即检查</el-button>
|
||||
</el-card>
|
||||
|
||||
<!-- Import -->
|
||||
<el-card class="setting-card">
|
||||
<template #header>📥 Excel 数据导入</template>
|
||||
<el-form>
|
||||
<el-form-item label="上传旧周报 Excel">
|
||||
<input type="file" accept=".xlsx,.xls" @change="handleImportFile" />
|
||||
</el-form-item>
|
||||
<el-button type="success" :loading="importLoading" @click="handleImportPreview" :disabled="!importFile">
|
||||
预览并导入
|
||||
</el-button>
|
||||
</el-form>
|
||||
<!-- Preview Results -->
|
||||
<div v-if="importPreview" style="margin-top:16px">
|
||||
<h4>文件预览</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 }} 条
|
||||
</el-alert>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-page h3 { margin: 0 0 20px; }
|
||||
.setting-card { margin-bottom: 16px; }
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<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-header">
|
||||
<h3>用户管理</h3>
|
||||
</div>
|
||||
<el-card>
|
||||
<el-table :data="users" stripe>
|
||||
<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="department" label="部门" min-width="150" />
|
||||
<el-table-column prop="casdoor_id" label="Casdoor ID" min-width="200" />
|
||||
<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>
|
||||
|
||||
<!-- Edit Dialog -->
|
||||
<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-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.page-header h3 { margin: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,257 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { dashboardApi } from '@/api/dashboard'
|
||||
import { uploadApi } from '@/api/upload'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import api from '@/api/index'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const activeTab = ref('visits')
|
||||
const filterManagerId = ref('')
|
||||
const filterCustomerId = ref('')
|
||||
const managers = ref<any[]>([])
|
||||
|
||||
const report = ref({
|
||||
week_start: '', week_end: '',
|
||||
visits: [] as any[],
|
||||
daily_notes: [] as any[],
|
||||
work_plans: [] as any[],
|
||||
mini_business: [] as any[],
|
||||
key_visits: [] as any[],
|
||||
})
|
||||
|
||||
const photoUrls = ref<Record<string, string>>({})
|
||||
const photoDialogVisible = ref(false)
|
||||
const currentPhotoUrl = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
await loadReport()
|
||||
try {
|
||||
const res = await api.get('/users/', { params: { role: 'manager' } })
|
||||
managers.value = res.data
|
||||
} catch (_) {}
|
||||
})
|
||||
|
||||
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
|
||||
const res = await dashboardApi.getWeeklyReport(params)
|
||||
report.value = res.data
|
||||
// Load photo URLs
|
||||
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', { 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
|
||||
}
|
||||
|
||||
// Group visits by date for timeline
|
||||
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">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h3>周报详情</h3>
|
||||
<p>{{ report.week_start }} ~ {{ report.week_end }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<el-button v-if="auth.isDirector" type="success" @click="handleExport">📥 导出 Excel</el-button>
|
||||
</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>
|
||||
|
||||
<!-- 4 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">📅 {{ 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 prop="manager_name" label="客户经理" width="80" />
|
||||
<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="work_plans">
|
||||
<el-table :data="report.work_plans" stripe>
|
||||
<el-table-column prop="customer_name" label="客户" width="120" />
|
||||
<el-table-column prop="plan_content" label="工作计划" min-width="250" />
|
||||
<el-table-column prop="plan_date" label="计划时间" width="120" />
|
||||
<el-table-column prop="manager_name" label="客户经理" width="80" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === '已完成' ? 'success' : row.status === '已取消' ? 'info' : ''">
|
||||
{{ row.status }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="小微业务商机" name="mini_business">
|
||||
<el-table :data="report.mini_business" stripe>
|
||||
<el-table-column prop="customer_name" label="客户" width="120" />
|
||||
<el-table-column prop="product_type" label="产品类型" width="120" />
|
||||
<el-table-column prop="amount" label="金额" width="100" />
|
||||
<el-table-column prop="follow_up_detail" label="跟进内容" min-width="200" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === '已签约' ? 'success' : row.status === '已流失' ? 'danger' : ''">
|
||||
{{ row.status }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="manager_name" label="客户经理" width="80" />
|
||||
<el-table-column prop="expected_revenue_date" label="预计列收" width="120" />
|
||||
</el-table>
|
||||
</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">📅 {{ 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-tab-pane label="要客拜访计划" name="key_visits">
|
||||
<el-table :data="report.key_visits" stripe>
|
||||
<el-table-column prop="customer_name" label="客户" width="120" />
|
||||
<el-table-column prop="urgency_level" label="重要度" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :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" />
|
||||
<el-table-column prop="progress_status" label="进展" width="80" />
|
||||
<el-table-column prop="planned_date" label="计划时间" width="120" />
|
||||
<el-table-column prop="planned_visitor" label="拜访人" width="80" />
|
||||
<el-table-column prop="visit_target" label="拜访对象" width="100" />
|
||||
<el-table-column prop="manager_name" label="客户经理" width="80" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<!-- Photo Viewer Dialog -->
|
||||
<el-dialog v-model="photoDialogVisible" title="照片查看" width="80%">
|
||||
<img :src="currentPhotoUrl" style="width:100%; border-radius:8px" />
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.page-header h3 { margin: 0; }
|
||||
.page-header p { margin: 4px 0 0; color: #909399; font-size: 13px; }
|
||||
.date-group { margin-bottom: 20px; }
|
||||
.date-title { margin: 12px 0 8px; font-size: 14px; color: #303133; }
|
||||
.photo-cell { display: flex; gap: 4px; }
|
||||
.mini-thumb { width: 36px; height: 36px; object-fit: cover; border-radius: 4px; cursor: pointer; }
|
||||
.empty { text-align: center; color: #c0c4cc; padding: 40px 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<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'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
const submitLoading = ref(false)
|
||||
|
||||
const categories = ['行政事务', '合同整理', '发票处理', '内部会议', '培训学习', '其他']
|
||||
|
||||
const form = ref({
|
||||
note_date: todayStr(),
|
||||
category: '其他',
|
||||
content: '',
|
||||
time_range: '',
|
||||
})
|
||||
|
||||
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 || '',
|
||||
}
|
||||
} 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">
|
||||
<div class="form-header">
|
||||
<el-button text @click="router.back()">← 返回</el-button>
|
||||
<h3>{{ isEdit ? '编辑纪要' : '今日纪要' }}</h3>
|
||||
<span style="width:60px"></span>
|
||||
</div>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="日期">
|
||||
<el-date-picker v-model="form.note_date" type="date" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<div class="category-grid">
|
||||
<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-input v-model="form.time_range" placeholder="如 9:00-11:00" />
|
||||
</el-form-item>
|
||||
<el-form-item label="工作内容" required>
|
||||
<el-input v-model="form.content" type="textarea" :rows="5" placeholder="今天做了什么..." />
|
||||
</el-form-item>
|
||||
<div style="margin-top:24px; display:flex; gap:12px">
|
||||
<el-button type="primary" size="large" :loading="submitLoading" @click="handleSubmit" style="flex:1">提交</el-button>
|
||||
<el-button v-if="isEdit" type="danger" size="large" @click="handleDelete">删除</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-page { max-width: 500px; margin: 0 auto; }
|
||||
.form-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.form-header h3 { margin: 0; }
|
||||
.category-grid { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<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 { 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 photoUrls = ref<Record<string, string>>({})
|
||||
const loading = ref(false)
|
||||
|
||||
const todayTotal = computed(() => todayVisitCount.value + todayNoteCount.value)
|
||||
|
||||
const categoryColors: Record<string, string> = {
|
||||
'行政事务': '#409EFF', '合同整理': '#67C23A', '发票处理': '#E6A23C',
|
||||
'内部会议': '#F56C6C', '培训学习': '#909399', '其他': '#B37FEB',
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
onMounted(loadToday)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mobile-home">
|
||||
<!-- Today Summary -->
|
||||
<div class="today-badge">
|
||||
<el-icon :size="48"><Notebook /></el-icon>
|
||||
<div class="badge-text">
|
||||
<div class="badge-count">{{ todayTotal }}</div>
|
||||
<div class="badge-label">今日已录入(拜访{{ todayVisitCount }} + 纪要{{ todayNoteCount }})</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quick-actions">
|
||||
<el-button type="primary" size="large" @click="router.push('/m/visit/new')" style="flex:1">+ 今日拜访</el-button>
|
||||
<el-button type="success" size="large" @click="router.push('/m/note/new')" style="flex:1">📝 今日纪要</el-button>
|
||||
</div>
|
||||
|
||||
<div class="quick-links">
|
||||
<el-button @click="router.push('/m/work-plan/new')">📋 工作计划</el-button>
|
||||
<el-button @click="router.push('/m/mini-biz/new')">💼 商机跟单</el-button>
|
||||
<el-button @click="router.push('/m/key-visit/new')">⭐ 要客拜访</el-button>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading">
|
||||
<!-- Daily Notes -->
|
||||
<template v-if="dailyNotes.length">
|
||||
<h4>📝 今日纪要</h4>
|
||||
<el-card v-for="n in dailyNotes" :key="n.id" class="note-card" shadow="hover">
|
||||
<div class="card-header">
|
||||
<el-tag :color="categoryColors[n.category]" effect="dark" size="small">{{ n.category }}</el-tag>
|
||||
<span v-if="n.time_range" class="time">{{ n.time_range }}</span>
|
||||
</div>
|
||||
<div class="card-body">{{ n.content }}</div>
|
||||
<div class="card-footer">
|
||||
<el-button text size="small" @click="router.push(`/m/note/${n.id}/edit`)">编辑</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<!-- Visits -->
|
||||
<h4 v-if="visits.length">🏢 今日拜访记录</h4>
|
||||
<div v-if="visits.length === 0 && dailyNotes.length === 0" class="empty">今日暂无记录,点击上方按钮开始填报</div>
|
||||
<el-card v-for="v in visits" :key="v.id" class="visit-card" shadow="hover">
|
||||
<div class="card-header">
|
||||
<span class="customer-name">{{ v.customer_name || '-' }}</span>
|
||||
<el-tag size="small">{{ v.visit_method }}</el-tag>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div>{{ v.communication_content || '暂无沟通内容' }}</div>
|
||||
<div v-if="v.customer_demand" class="demand">📌 需求: {{ v.customer_demand }}</div>
|
||||
</div>
|
||||
<div class="card-photos" v-if="v.photos?.length">
|
||||
<img v-for="key in v.photos" :key="key" :src="photoUrls[key] || ''" class="photo-thumb" />
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span class="time">{{ v.time_range }}</span>
|
||||
<el-button text size="small" @click="router.push(`/m/visit/${v.id}/edit`)">编辑</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mobile-home { padding-bottom: 20px; }
|
||||
.today-badge {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
background: linear-gradient(135deg, #e8f0fe, #f0f5ff); border-radius: 16px; padding: 20px; margin-bottom: 16px;
|
||||
}
|
||||
.badge-count { font-size: 36px; font-weight: 700; color: #1a73e8; }
|
||||
.badge-label { font-size: 13px; color: #606266; }
|
||||
.quick-actions { display: flex; gap: 12px; margin-bottom: 12px; }
|
||||
.quick-links { display: flex; gap: 8px; margin-bottom: 20px; }
|
||||
h4 { margin: 16px 0 12px; color: #303133; }
|
||||
.empty { text-align: center; color: #c0c4cc; padding: 40px 0; }
|
||||
.visit-card, .note-card { margin-bottom: 12px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.customer-name { font-weight: 600; font-size: 15px; }
|
||||
.demand { color: #e6a23c; margin-top: 4px; font-size: 13px; }
|
||||
.card-photos { display: flex; gap: 6px; margin-top: 8px; overflow-x: auto; }
|
||||
.photo-thumb { width: 72px; height: 72px; object-fit: cover; border-radius: 8px; }
|
||||
.card-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; font-size: 12px; color: #909399; }
|
||||
.card-body { font-size: 14px; color: #303133; }
|
||||
.time { font-size: 12px; color: #909399; }
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { keyVisitsApi } from '@/api/keyVisits'
|
||||
import { customersApi } from '@/api/customers'
|
||||
|
||||
const router = useRouter()
|
||||
const submitLoading = ref(false)
|
||||
const customers = ref<any[]>([])
|
||||
|
||||
const form = ref({
|
||||
customer_id: '',
|
||||
urgency_level: '一般',
|
||||
description: '',
|
||||
progress_status: '未开始',
|
||||
planned_date: '',
|
||||
planned_visitor: '',
|
||||
visit_target: '',
|
||||
})
|
||||
|
||||
onMounted(() => { loadCustomers() })
|
||||
|
||||
async function loadCustomers(q?: string) {
|
||||
try {
|
||||
const params: any = { page_size: 100 }
|
||||
if (q) params.search = q
|
||||
const res = await customersApi.list(params)
|
||||
customers.value = res.data.items || []
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.customer_id) { ElMessage.warning('请选择客户'); return }
|
||||
submitLoading.value = true
|
||||
try {
|
||||
await keyVisitsApi.create(form.value)
|
||||
ElMessage.success('要客拜访计划已提交')
|
||||
router.push('/m')
|
||||
} catch (e: any) {
|
||||
ElMessage.error('提交失败')
|
||||
} finally { submitLoading.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-page">
|
||||
<div class="form-header">
|
||||
<el-button text @click="router.back()">← 返回</el-button>
|
||||
<h3>添加要客拜访</h3>
|
||||
<span style="width:60px"></span>
|
||||
</div>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="客户单位">
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="loadCustomers" 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-radio-group v-model="form.urgency_level">
|
||||
<el-radio-button value="一般">一般</el-radio-button>
|
||||
<el-radio-button value="重要">重要</el-radio-button>
|
||||
<el-radio-button value="紧急">紧急</el-radio-button>
|
||||
</el-radio-group>
|
||||
</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" style="width:100%">
|
||||
<el-option label="未开始" value="未开始" />
|
||||
<el-option label="进行中" value="进行中" />
|
||||
<el-option label="已完成" value="已完成" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="计划拜访时间">
|
||||
<el-input v-model="form.planned_date" placeholder="如: 2026-06-25" />
|
||||
</el-form-item>
|
||||
<el-form-item label="计划拜访人">
|
||||
<el-input v-model="form.planned_visitor" placeholder="负责拜访的人员" />
|
||||
</el-form-item>
|
||||
<el-form-item label="拜访对象">
|
||||
<el-input v-model="form.visit_target" placeholder="如: 王局长、张处长" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" :loading="submitLoading" @click="handleSubmit" style="width:100%">提交</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-page { max-width: 500px; margin: 0 auto; }
|
||||
.form-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.form-header h3 { margin: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { miniBusinessApi } from '@/api/miniBusiness'
|
||||
import { customersApi } from '@/api/customers'
|
||||
|
||||
const router = useRouter()
|
||||
const submitLoading = ref(false)
|
||||
const customers = ref<any[]>([])
|
||||
|
||||
const form = ref({
|
||||
customer_id: '',
|
||||
product_type: '',
|
||||
amount: '',
|
||||
follow_up_detail: '',
|
||||
status: '跟进中',
|
||||
expected_revenue_date: '',
|
||||
})
|
||||
|
||||
onMounted(() => { loadCustomers() })
|
||||
|
||||
async function loadCustomers(q?: string) {
|
||||
try {
|
||||
const params: any = { page_size: 100 }
|
||||
if (q) params.search = q
|
||||
const res = await customersApi.list(params)
|
||||
customers.value = res.data.items || []
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.customer_id) { ElMessage.warning('请选择客户'); return }
|
||||
submitLoading.value = true
|
||||
try {
|
||||
await miniBusinessApi.create(form.value)
|
||||
ElMessage.success('商机已提交')
|
||||
router.push('/m')
|
||||
} catch (e: any) {
|
||||
ElMessage.error('提交失败')
|
||||
} finally { submitLoading.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-page">
|
||||
<div class="form-header">
|
||||
<el-button text @click="router.back()">← 返回</el-button>
|
||||
<h3>添加商机跟单</h3>
|
||||
<span style="width:60px"></span>
|
||||
</div>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="客户单位">
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="loadCustomers" 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="例如: 云桌面、专线..." />
|
||||
</el-form-item>
|
||||
<el-form-item label="金额">
|
||||
<el-input v-model="form.amount" placeholder="预估金额" />
|
||||
</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" style="width:100%">
|
||||
<el-option label="跟进中" value="跟进中" />
|
||||
<el-option label="已签约" value="已签约" />
|
||||
<el-option label="已流失" value="已流失" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="预计列收时间">
|
||||
<el-input v-model="form.expected_revenue_date" placeholder="如: 2026Q3" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" :loading="submitLoading" @click="handleSubmit" style="width:100%">提交</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-page { max-width: 500px; margin: 0 auto; }
|
||||
.form-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.form-header h3 { margin: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,269 @@
|
||||
<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 { visitsApi } from '@/api/visits'
|
||||
import { customersApi } from '@/api/customers'
|
||||
import { uploadApi } from '@/api/upload'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import api from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
const submitLoading = ref(false)
|
||||
const deleting = ref(false)
|
||||
|
||||
const form = ref({
|
||||
customer_id: '',
|
||||
visit_date: todayStr(),
|
||||
visit_method: '上门',
|
||||
time_range: '',
|
||||
communication_content: '',
|
||||
customer_demand: '',
|
||||
companions: [] as string[],
|
||||
photos: [] as string[],
|
||||
})
|
||||
|
||||
const customers = ref<any[]>([])
|
||||
const managers = ref<any[]>([])
|
||||
const uploadedPhotos = ref<string[]>([])
|
||||
const photoPreviews = ref<string[]>([]) // blob URLs for local preview
|
||||
const uploading = ref(false)
|
||||
const customerSearch = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCustomers()
|
||||
try {
|
||||
const res = await api.get('/users/', { params: { role: 'manager' } })
|
||||
managers.value = res.data
|
||||
} catch (_) {}
|
||||
if (isEdit.value) {
|
||||
try {
|
||||
const res = await visitsApi.get(route.params.id as string)
|
||||
const v = res.data
|
||||
form.value = {
|
||||
customer_id: v.customer_id,
|
||||
visit_date: v.visit_date,
|
||||
visit_method: v.visit_method,
|
||||
time_range: v.time_range,
|
||||
communication_content: v.communication_content || '',
|
||||
customer_demand: v.customer_demand || '',
|
||||
companions: (v.companions || []).map(String),
|
||||
photos: v.photos || [],
|
||||
}
|
||||
uploadedPhotos.value = v.photos || []
|
||||
// Load photo previews for existing photos
|
||||
for (const key of (v.photos || [])) {
|
||||
try {
|
||||
const urlRes = await uploadApi.getDownloadUrl(key)
|
||||
photoPreviews.value.push(urlRes.data.download_url)
|
||||
} catch (_) { photoPreviews.value.push('') }
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
})
|
||||
|
||||
async function loadCustomers(q?: string) {
|
||||
try {
|
||||
const params: any = { page_size: 100 }
|
||||
if (q) params.search = q
|
||||
const res = await customersApi.list(params)
|
||||
customers.value = res.data.items || []
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function handleCustomerSearch(query: string) {
|
||||
customerSearch.value = query
|
||||
if (query) {
|
||||
await loadCustomers(query)
|
||||
} else {
|
||||
await loadCustomers()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQuickCreate() {
|
||||
const name = customerSearch.value.trim()
|
||||
if (!name) { ElMessage.warning('请输入单位名称'); return }
|
||||
try {
|
||||
const res = await api.post('/customers/quick-create', null, { params: { name } })
|
||||
const newCust = res.data
|
||||
customers.value.unshift(newCust)
|
||||
form.value.customer_id = newCust.id
|
||||
customerSearch.value = ''
|
||||
ElMessage.success(`已创建客户:${name}`)
|
||||
await loadCustomers()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('创建失败: ' + (e.response?.data?.detail || e.message))
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePhotoUpload(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (!target.files?.length) return
|
||||
if (uploadedPhotos.value.length >= 9) { ElMessage.warning('最多9张照片'); return }
|
||||
|
||||
uploading.value = true
|
||||
for (const file of Array.from(target.files)) {
|
||||
if (uploadedPhotos.value.length >= 9) break
|
||||
// Show local preview immediately
|
||||
const previewUrl = URL.createObjectURL(file)
|
||||
photoPreviews.value.push(previewUrl)
|
||||
try {
|
||||
const res = await uploadApi.getPresignedUrl(file.name, file.type || 'image/jpeg')
|
||||
await uploadApi.uploadFile(res.data.upload_url, file)
|
||||
uploadedPhotos.value.push(res.data.object_key)
|
||||
form.value.photos = [...uploadedPhotos.value]
|
||||
} catch (e: any) {
|
||||
// Remove preview on failure
|
||||
const idx = photoPreviews.value.indexOf(previewUrl)
|
||||
if (idx >= 0) photoPreviews.value.splice(idx, 1)
|
||||
URL.revokeObjectURL(previewUrl)
|
||||
ElMessage.error('上传失败: ' + (e.response?.data?.detail || e.message))
|
||||
}
|
||||
}
|
||||
uploading.value = false
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
function removePhoto(index: number) {
|
||||
// Clean up preview URL
|
||||
if (index < photoPreviews.value.length) {
|
||||
URL.revokeObjectURL(photoPreviews.value[index])
|
||||
photoPreviews.value.splice(index, 1)
|
||||
}
|
||||
uploadedPhotos.value.splice(index, 1)
|
||||
form.value.photos = [...uploadedPhotos.value]
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.customer_id) { ElMessage.warning('请选择客户'); return }
|
||||
submitLoading.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await visitsApi.update(route.params.id as string, form.value)
|
||||
ElMessage.success('记录已更新')
|
||||
} else {
|
||||
await visitsApi.create(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' })
|
||||
deleting.value = true
|
||||
await visitsApi.delete(route.params.id as string)
|
||||
ElMessage.success('已删除')
|
||||
router.push('/m')
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') ElMessage.error('删除失败')
|
||||
} finally { deleting.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="visit-form">
|
||||
<div class="form-header">
|
||||
<el-button text @click="router.back()">← 返回</el-button>
|
||||
<h3>{{ isEdit ? '编辑拜访记录' : '今日拜访' }}</h3>
|
||||
<span style="width:60px"></span>
|
||||
</div>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="客户单位">
|
||||
<el-select
|
||||
v-model="form.customer_id"
|
||||
filterable
|
||||
remote
|
||||
:remote-method="handleCustomerSearch"
|
||||
placeholder="搜索/选择客户"
|
||||
style="width:100%"
|
||||
popper-class="customer-select-popper"
|
||||
>
|
||||
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
<div v-if="customerSearch && !customers.some(c => c.name === customerSearch)" style="margin-top:6px">
|
||||
<el-button size="small" type="success" @click="handleQuickCreate">
|
||||
+ 新建客户「{{ customerSearch }}」
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="拜访日期">
|
||||
<el-date-picker v-model="form.visit_date" type="date" style="width:100%" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="拜访方式">
|
||||
<el-radio-group v-model="form.visit_method">
|
||||
<el-radio-button value="上门">上门</el-radio-button>
|
||||
<el-radio-button value="电话">电话</el-radio-button>
|
||||
<el-radio-button value="微信">微信</el-radio-button>
|
||||
<el-radio-button value="出差">出差</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="时间范围">
|
||||
<el-input v-model="form.time_range" placeholder="如 9:00-10:00" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="同访人员">
|
||||
<el-select v-model="form.companions" multiple filterable placeholder="选择同访人员" style="width:100%">
|
||||
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" :disabled="m.id === auth.userId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="沟通内容">
|
||||
<el-input v-model="form.communication_content" type="textarea" :rows="4" placeholder="本次拜访沟通内容..." />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="客户需求">
|
||||
<el-input v-model="form.customer_demand" type="textarea" :rows="2" placeholder="客户提出的需求..." />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="拜访照片 (最多9张)">
|
||||
<div class="photo-area">
|
||||
<div v-for="(key, idx) in uploadedPhotos" :key="key" class="photo-item">
|
||||
<img :src="photoPreviews[idx]" class="photo-thumb" v-if="photoPreviews[idx]" />
|
||||
<span class="photo-label">照片{{ idx + 1 }}</span>
|
||||
<el-button text size="small" type="danger" @click="removePhoto(idx)">移除</el-button>
|
||||
</div>
|
||||
<label v-if="uploadedPhotos.length < 9" class="upload-btn">
|
||||
<el-button :loading="uploading" type="primary" plain>+ 拍照/选图</el-button>
|
||||
<input type="file" accept="image/*" multiple @change="handlePhotoUpload" class="file-input-hidden" />
|
||||
</label>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<div style="margin-top:24px; display:flex; gap:12px">
|
||||
<el-button type="primary" size="large" :loading="submitLoading" @click="handleSubmit" style="flex:1">
|
||||
提交
|
||||
</el-button>
|
||||
<el-button v-if="isEdit" type="danger" size="large" :loading="deleting" @click="handleDelete">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.visit-form { max-width: 500px; margin: 0 auto; }
|
||||
.form-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.form-header h3 { margin: 0; }
|
||||
.photo-area { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.photo-item { display: flex; align-items: center; gap: 6px; background: #f5f7fa; padding: 4px 10px; border-radius: 8px; }
|
||||
.photo-thumb { width: 48px; height: 48px; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
|
||||
.photo-label { font-size: 12px; }
|
||||
.upload-btn { cursor: pointer; position: relative; display: inline-block; }
|
||||
.upload-btn .file-input-hidden {
|
||||
position: absolute; left: 0; top: 0; width: 100%; height: 100%;
|
||||
opacity: 0; cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { todayStr } from '@/utils'
|
||||
import { workPlansApi } from '@/api/workPlans'
|
||||
import { customersApi } from '@/api/customers'
|
||||
|
||||
const router = useRouter()
|
||||
const submitLoading = ref(false)
|
||||
const customers = ref<any[]>([])
|
||||
|
||||
const form = ref({
|
||||
customer_id: '',
|
||||
plan_content: '',
|
||||
plan_date: todayStr(),
|
||||
status: '计划中',
|
||||
})
|
||||
|
||||
onMounted(() => { loadCustomers() })
|
||||
|
||||
async function loadCustomers(q?: string) {
|
||||
try {
|
||||
const params: any = { page_size: 100 }
|
||||
if (q) params.search = q
|
||||
const res = await customersApi.list(params)
|
||||
customers.value = res.data.items || []
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.customer_id) { ElMessage.warning('请选择客户'); return }
|
||||
submitLoading.value = true
|
||||
try {
|
||||
await workPlansApi.create(form.value)
|
||||
ElMessage.success('计划已提交')
|
||||
router.push('/m')
|
||||
} catch (e: any) {
|
||||
ElMessage.error('提交失败')
|
||||
} finally { submitLoading.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-page">
|
||||
<div class="form-header">
|
||||
<el-button text @click="router.back()">← 返回</el-button>
|
||||
<h3>添加工作计划</h3>
|
||||
<span style="width:60px"></span>
|
||||
</div>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="客户单位">
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="loadCustomers" 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.plan_content" type="textarea" :rows="4" placeholder="描述下周工作计划..." />
|
||||
</el-form-item>
|
||||
<el-form-item label="计划拜访时间">
|
||||
<el-date-picker v-model="form.plan_date" type="date" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" :loading="submitLoading" @click="handleSubmit" style="width:100%">提交</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-page { max-width: 500px; margin: 0 auto; }
|
||||
.form-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.form-header h3 { margin: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8002',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/health': {
|
||||
target: 'http://localhost:8002',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Executable
+268
@@ -0,0 +1,268 @@
|
||||
# 企迹 (qiji) — 政企周报管理系统 设计文档
|
||||
|
||||
## 项目背景
|
||||
|
||||
中国电信政企客户经理团队目前使用 Excel 进行周报管理,包含四个 Sheet:每日拜访记录、下周工作计划、小微业务商机跟单、要客拜访计划。现有方式存在填报效率低、无法挂载照片、汇总查看不便、考核靠人工点名扣分、客户数据无法沉淀等问题。
|
||||
|
||||
本系统以 FastAPI + Vue 3 构建,部署在本地服务器 + 云服务器的混合拓扑上,对接已有的 Casdoor(认证)、PostgreSQL(数据库)、MinIO(文件存储)、企业微信(消息推送与快捷入口)。
|
||||
|
||||
## 用户规模与角色
|
||||
|
||||
小型支局,约 5-8 人:3-5 名客户经理 + 1 名支局长 + 偶尔分管领导查看。
|
||||
|
||||
### 角色权限矩阵
|
||||
|
||||
| 角色 | 数据可见性 | 操作权限 |
|
||||
|------|-----------|---------|
|
||||
| 客户经理 | 只看自己的拜访记录、工作计划、商机、要客拜访 | 填/改/删自己的记录,上传照片,新建客户 |
|
||||
| 支局长 | 全支局所有人的所有数据 | 增删改所有人记录,导出汇总,查看填报统计,手动触发催办/推送公告,管理客户档案与归属分配 |
|
||||
| 分管领导 | 同支局长全量视图 | 仅查看和导出,不可编辑 |
|
||||
|
||||
## 技术架构
|
||||
|
||||
```
|
||||
本地服务器(内网)
|
||||
├── FastAPI (uvicorn, port 8000)
|
||||
├── PostgreSQL(已有,复用)
|
||||
└── MinIO(已有,复用)
|
||||
|
||||
云服务器
|
||||
└── Vue 3 前端 (nginx, port 443, 域名)
|
||||
↕ API 请求走 HTTPS → 本地服务器公网映射端口
|
||||
|
||||
Casdoor(已有部署,OIDC)
|
||||
企业微信(管理后台创建自建应用)
|
||||
```
|
||||
|
||||
开发阶段前后端均在本地:`localhost:8000` + `localhost:5173` 联调。
|
||||
|
||||
### 后端:FastAPI
|
||||
|
||||
- 单体应用,FastAPI + SQLAlchemy (async) + Alembic 做数据迁移
|
||||
- JWT 鉴权,token 签发与验证对接 Casdoor OIDC
|
||||
- 文件上传:客户端获取 MinIO 预签名 URL 直传,API 只记录 object key
|
||||
- 定时任务:APScheduler,每日 18:00 检查填报情况触发企微提醒
|
||||
|
||||
### 前端:Vue 3
|
||||
|
||||
- Vue 3 + Vite + TypeScript
|
||||
- 组件库:Element Plus(中文生态好,表格/表单组件成熟)
|
||||
- 两套适配布局:PC Web 端(支局长/分管领导汇总查看)+ 移动端(客户经理外勤快速填报,也是企微应用落地页)
|
||||
|
||||
### 存储:MinIO
|
||||
|
||||
- 组织方式:`{date}/{manager}/{uuid}.jpg`
|
||||
- 单张限制 10MB,一次拜访最多 9 张
|
||||
- 上传走预签名 URL,绕过 API 中转
|
||||
- 查看走预签名下载 URL(有效期 1 小时)
|
||||
- 缩略图由 MinIO 策略自动生成
|
||||
- 删除拜访记录时同步清理关联照片
|
||||
|
||||
### 认证:Casdoor
|
||||
|
||||
- FastAPI 作为 OIDC Relying Party
|
||||
- 用户在 Casdoor 已分配角色(客户经理 / 支局长 / 分管领导),JWT 携带角色信息
|
||||
- 企业微信用户绑定 Casdoor 账号后,可实现企微内静默登录
|
||||
|
||||
## 数据模型
|
||||
|
||||
共 7 张核心表,全部使用 PostgreSQL。
|
||||
|
||||
### customers — 客户档案
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| name | VARCHAR(200) | 单位名称 |
|
||||
| industry | VARCHAR(100) | 所属行业 |
|
||||
| address | VARCHAR(500) | 单位地址 |
|
||||
| in_use_services | TEXT | 在用业务(枚举文本) |
|
||||
| monthly_fee | VARCHAR(100) | 月使用费 |
|
||||
| created_by | UUID → users.id | 创建人 |
|
||||
| created_at | TIMESTAMP | |
|
||||
| updated_at | TIMESTAMP | |
|
||||
|
||||
### customer_contacts — 客户联系人
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| customer_id | UUID → customers.id | 所属客户 |
|
||||
| name | VARCHAR(50) | 联系人姓名 |
|
||||
| phone | VARCHAR(20) | 联系方式 |
|
||||
| role_desc | VARCHAR(100) | 角色描述(如"技术对接人") |
|
||||
|
||||
### customer_assignments — 客户经理归属
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| customer_id | UUID → customers.id | |
|
||||
| manager_id | UUID → users.id | |
|
||||
| role | VARCHAR(20) | 'primary' / 'assistant' |
|
||||
| assigned_at | TIMESTAMP | 分配时间 |
|
||||
| assigned_by | UUID → users.id | 操作人(支局长) |
|
||||
|
||||
支持归属转移:支局长修改 `manager_id` 即可,历史拜访记录保留在 `visits.manager_id` 原值不变。
|
||||
|
||||
### visits — 每日拜访记录(对应"周报"sheet)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| customer_id | UUID → customers.id | 走访单位 |
|
||||
| visit_date | DATE | 拜访日期 |
|
||||
| visit_method | VARCHAR(20) | 上门/电话/微信/出差 |
|
||||
| time_range | VARCHAR(30) | 如 "9:00-10:00" |
|
||||
| communication_content | TEXT | 沟通内容 |
|
||||
| customer_demand | TEXT | 客户需求 |
|
||||
| companions | UUID[] | 同访人员 ID 数组 |
|
||||
| photos | TEXT[] | MinIO object key 数组 |
|
||||
| manager_id | UUID → users.id | 客户经理 |
|
||||
| created_at | TIMESTAMP | |
|
||||
| updated_at | TIMESTAMP | |
|
||||
|
||||
### work_plans — 下周工作计划
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| customer_id | UUID → customers.id | 计划走访单位 |
|
||||
| plan_content | TEXT | 工作计划 |
|
||||
| plan_date | DATE | 计划拜访时间 |
|
||||
| manager_id | UUID → users.id | |
|
||||
| status | VARCHAR(20) | 计划中/已完成/已取消 |
|
||||
|
||||
### mini_business — 小微业务商机(对应"小微业务"sheet)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| customer_id | UUID → customers.id | 单位名称 |
|
||||
| product_type | VARCHAR(200) | 产品类型 |
|
||||
| amount | VARCHAR(100) | 金额 |
|
||||
| follow_up_detail | TEXT | 跟进内容具体情况 |
|
||||
| status | VARCHAR(50) | 跟进状态 |
|
||||
| manager_id | UUID → users.id | |
|
||||
| expected_revenue_date | VARCHAR(50) | 预计列收时间 |
|
||||
|
||||
### key_visits — 要客拜访计划
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| customer_id | UUID → customers.id | 单位 |
|
||||
| urgency_level | VARCHAR(10) | 紧急重要度:重要/一般/紧急 |
|
||||
| description | TEXT | 内容描述 |
|
||||
| progress_status | VARCHAR(20) | 进展状态 |
|
||||
| planned_date | VARCHAR(50) | 计划拜访时间 |
|
||||
| planned_visitor | VARCHAR(100) | 计划拜访人 |
|
||||
| visit_target | VARCHAR(100) | 拜访对象 |
|
||||
| manager_id | UUID → users.id | |
|
||||
|
||||
### users — 用户(对接 Casdoor)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID | 主键 |
|
||||
| casdoor_id | VARCHAR(100) | Casdoor 用户 ID |
|
||||
| name | VARCHAR(50) | 姓名 |
|
||||
| role | VARCHAR(30) | manager / director / leader |
|
||||
| department | VARCHAR(100) | 部门 |
|
||||
| wecom_userid | VARCHAR(100) | 企业微信 userid |
|
||||
|
||||
## 核心功能设计
|
||||
|
||||
### 一、客户经理填报
|
||||
|
||||
**入口**:移动端「今日拜访」按钮 + 企微工作台菜单「今日拜访」。
|
||||
|
||||
**表单字段**:
|
||||
|
||||
- 客户单位:下拉选择(仅显示自己负责的客户 + 自己新建的客户),支持搜索,支持快速新建客户
|
||||
- 选择客户后自动带出联系人信息
|
||||
- 拜访日期:默认今天
|
||||
- 拜访方式:单选(上门 / 电话 / 微信 / 出差)
|
||||
- 时间范围:两个时间选择器
|
||||
- 同访人员:多选,选了谁自动在对方账号下创建一条预填草稿(沟通内容留空让对方补)
|
||||
- 沟通内容:自由文本
|
||||
- 客户需求:自由文本(用于汇总时的关键字提取)
|
||||
- 拜访照片:最多 9 张,拍照或从相册选,走 MinIO 预签名直传
|
||||
|
||||
**首页状态**:显示「今天已录入 N 条」,下方为当天已填记录的摘要卡片列表。
|
||||
|
||||
### 二、支局长汇总视图
|
||||
|
||||
**仪表盘首页**:
|
||||
|
||||
- 四张数字卡片:本周拜访次数、工作计划条数、商机跟单条数、要客拜访条数
|
||||
- 填报进度条:每个客户经理的填报情况(已填/未填),未填人员红色标记 ⚠
|
||||
- 快捷入口:本周周报详情、导出 Excel、历史周报
|
||||
|
||||
**周报详情页**:四个 Tab 对应四个业务模块
|
||||
|
||||
- 支持按人筛选、按客户筛选
|
||||
- 时间轴模式按天分组
|
||||
- 照片缩略图,点击放大查看
|
||||
- 一键导出 Excel(格式与现有模板一致)
|
||||
|
||||
**考核与催办**:
|
||||
|
||||
- 每日 18:00 自动检查,企微推送提醒未填报的客户经理
|
||||
- 支局长可手动点击「催办」按钮,选择人员发送应用消息
|
||||
- 支局长可编辑一条自定义公告,推送到全员企微
|
||||
- 支局长可设置"当日最低拜访条数"阈值,低于阈值也预警
|
||||
|
||||
### 三、分管领导视图
|
||||
|
||||
与支局长相同的全量数据视图,所有编辑按钮灰色禁用。核心目标是随时打开看,首屏加载 1 秒以内。
|
||||
|
||||
### 四、客户档案管理
|
||||
|
||||
- 独立客户档案,包含单位基本信息、在用业务、月使用费、关键联系人
|
||||
- 客户经理—客户归属关系(主负责 / 协办),支局长统一管理分配
|
||||
- 支持批量导入:从存量收入清单 Excel 导入客户和归属关系
|
||||
- 客户查重:建客户时自动提示疑似重复,支局长定期合并
|
||||
- **归属转移**:客户经理离职时,支局长将客户批量转移给新同事,历史拜访记录完整保留在原客户经理名下
|
||||
|
||||
### 五、数据导入导出
|
||||
|
||||
- **导入**:上传旧周报 Excel → 系统解析 sheet → 预览匹配字段 → 确认导入;同一天同一人同一客户自动跳过不重复
|
||||
- **导出**:支局长点击「导出本周周报」,生成与原 Excel 模板格式完全一致的四 sheet .xlsx 文件
|
||||
|
||||
## 企业微信集成
|
||||
|
||||
### 工作台菜单
|
||||
|
||||
| 菜单名称 | 跳转目标 |
|
||||
|---------|---------|
|
||||
| 今日拜访 | 移动端填报页 |
|
||||
| 本周周报 | 支局长汇总视图 |
|
||||
| 客户查询 | 客户档案搜索页 |
|
||||
|
||||
### 登录流程
|
||||
|
||||
1. 用户从企微菜单进入,前端拿企微 `code` 换取 `userid`
|
||||
2. 服务端用 `userid` 查找已绑定的 Casdoor 账号
|
||||
3. 已绑定:直接签发 JWT,用户无感进入系统
|
||||
4. 未绑定:引导去 Casdoor 完成一次账号绑定,之后永久生效
|
||||
|
||||
### 消息推送
|
||||
|
||||
| 场景 | 方式 | 触发 |
|
||||
|------|------|------|
|
||||
| 每日填报提醒 | 应用消息 → 未填人员 | 每日 18:00 自动 |
|
||||
| 手动催办 | 支局长选人 → 发送应用消息 | 手动 |
|
||||
| 支局长公告 | 编辑公告 → 发应用消息 → 全员 | 手动 |
|
||||
|
||||
### 后续扩展(首期不做)
|
||||
|
||||
- 企微聊天侧边栏:在跟客户的聊天窗口右侧弹出拜访记录入口,自动带入客户信息
|
||||
|
||||
## 部署说明
|
||||
|
||||
1. FastAPI 后端部署于本地服务器,通过内网访问 PostgreSQL 和 MinIO
|
||||
2. API 端口通过公网映射暴露给云服务器上的前端
|
||||
3. Vue 前端部署于云服务器 nginx,HTTPS 访问
|
||||
4. 开发阶段前后端均在本地运行,联调结构与生产一致
|
||||
5. Casdoor、PostgreSQL、MinIO、企业微信应用均为已有基础设施,本系统作为新接入方
|
||||
Reference in New Issue
Block a user