企迹(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:
2026-06-23 01:35:46 +08:00
parent 2a8225c31f
commit a1886074dd
97 changed files with 8830 additions and 0 deletions
+182
View File
@@ -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`
+41
View File
@@ -0,0 +1,41 @@
# ============================================
# 企迹 (qiji) — 环境变量配置文件
# 复制此文件为 .env 并填入真实值
# ============================================
# ── 应用 ──
APP_NAME=企迹-政企周报管理系统
DEBUG=true
SECRET_KEY=change-me-to-a-random-string-in-production
# ── PostgreSQL (已有基础设施,填写实际连接信息) ──
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/qiji
# ── JWT ──
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=480
# ── Casdoor (已有,填写实际部署地址和密钥) ──
CASDOOR_ENDPOINT=http://your-casdoor-server:8000
CASDOOR_CLIENT_ID=your-client-id
CASDOOR_CLIENT_SECRET=your-client-secret
# CASDOOR_CERTIFICATE= # 如果 Casdoor 使用自签名证书,填证书内容
CASDOOR_ORG_NAME=qiji
CASDOOR_APPLICATION=qiji-weekly-report
# ── MinIO (已有,填写实际部署地址和密钥) ──
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=qiji-photos
MINIO_SECURE=false
# ── 企业微信 (需在企微管理后台创建自建应用后获取) ──
WECOM_CORP_ID=your-corp-id
WECOM_AGENT_ID=your-agent-id
WECOM_SECRET=your-app-secret
WECOM_TOKEN=your-token
WECOM_ENCODING_AES_KEY=your-encoding-aes-key
# ── CORS (前端地址) ──
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
+12
View File
@@ -0,0 +1,12 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+36
View File
@@ -0,0 +1,36 @@
[alembic]
script_location = alembic
sqlalchemy.url = postgresql://postgres:postgres@localhost:5432/qiji
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+57
View File
@@ -0,0 +1,57 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.database import Base
from app.models import * # noqa: import all models
from app.config import settings
config = context.config
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
View File
+3
View File
@@ -0,0 +1,3 @@
from fastapi import APIRouter
router = APIRouter()
+93
View File
@@ -0,0 +1,93 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas.user import TokenResponse, WecomLoginRequest, CasdoorLoginRequest, WecomBindRequest
from app.services.auth import (
exchange_casdoor_code, get_or_create_user_from_casdoor,
get_user_by_wecom_id, bind_wecom_user, build_token_for_user,
)
from app.services.wecom import wecom_client
from app.config import settings
router = APIRouter(prefix="/auth", tags=["Authentication"])
@router.post("/casdoor-login", response_model=TokenResponse)
async def casdoor_login(req: CasdoorLoginRequest, db: AsyncSession = Depends(get_db)):
"""Standard Casdoor OIDC login — exchange code for userinfo, get or create user, return JWT."""
userinfo = await exchange_casdoor_code(req.code)
if not userinfo:
raise HTTPException(status_code=400, detail="Failed to exchange code with Casdoor")
casdoor_id = userinfo.get("sub") or userinfo.get("id")
if not casdoor_id:
raise HTTPException(status_code=400, detail="Invalid userinfo from Casdoor")
name = userinfo.get("name") or userinfo.get("preferred_username") or casdoor_id
role = userinfo.get("role", "manager")
user = await get_or_create_user_from_casdoor(db, casdoor_id, name, role)
token = build_token_for_user(user)
return TokenResponse(
access_token=token,
user_id=str(user.id),
name=user.name,
role=user.role,
)
@router.post("/wecom-login")
async def wecom_login(req: WecomLoginRequest, db: AsyncSession = Depends(get_db)):
"""WeChat Work silent login — exchange wecom code for userid, find bound user, return JWT."""
userinfo = await wecom_client.get_userinfo_by_code(req.code)
if not userinfo:
raise HTTPException(status_code=400, detail="Failed to exchange wecom code")
wecom_userid = userinfo.get("UserId") or userinfo.get("userid")
if not wecom_userid:
raise HTTPException(status_code=400, detail="Could not get userid from wecom")
user = await get_user_by_wecom_id(db, wecom_userid)
if user:
token = build_token_for_user(user)
return TokenResponse(
access_token=token,
user_id=str(user.id),
name=user.name,
role=user.role,
)
# Not bound yet — return a redirect URL to Casdoor for binding
casdoor_auth_url = (
f"{settings.CASDOOR_ENDPOINT}/login/oauth/authorize"
f"?client_id={settings.CASDOOR_CLIENT_ID}"
f"&response_type=code"
f"&redirect_uri={settings.CORS_ORIGINS[0]}/bind-wecom"
f"&scope=openid+profile"
f"&state={wecom_userid}"
)
return {"need_bind": True, "casdoor_url": casdoor_auth_url, "wecom_userid": wecom_userid}
@router.post("/bind-wecom")
async def bind_wecom(req: WecomBindRequest, db: AsyncSession = Depends(get_db)):
"""Bind Casdoor account with WeChat Work userid after OIDC redirect."""
userinfo = await exchange_casdoor_code(req.casdoor_code)
if not userinfo:
raise HTTPException(status_code=400, detail="Failed to exchange casdoor code")
casdoor_id = userinfo.get("sub") or userinfo.get("id")
wecom_userid = req.wecom_userid or userinfo.get("state", "")
user = await bind_wecom_user(db, casdoor_id, wecom_userid)
if not user:
raise HTTPException(status_code=404, detail="User not found")
token = build_token_for_user(user)
return TokenResponse(
access_token=token,
user_id=str(user.id),
name=user.name,
role=user.role,
)
+507
View File
@@ -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
+131
View File
@@ -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"}
+45
View File
@@ -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,
)
+22
View File
@@ -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"},
)
+43
View File
@@ -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
+107
View File
@@ -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"}
+105
View File
@@ -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"}
+36
View File
@@ -0,0 +1,36 @@
import uuid
from fastapi import APIRouter, Depends
from app.middleware.auth import get_current_user, require_any_role
from app.services.minio_client import generate_presigned_upload_url, generate_presigned_download_url
router = APIRouter(prefix="/upload", tags=["Upload"])
@router.post("/presigned-url")
async def get_presigned_upload_url(
filename: str,
content_type: str = "image/jpeg",
current_user: dict = Depends(require_any_role),
):
"""Get a presigned PUT URL for direct MinIO upload."""
import datetime
today = datetime.date.today().isoformat()
user_id = current_user["user_id"][:8]
object_key = f"{today}/{user_id}/{uuid.uuid4()}.jpg"
url = generate_presigned_upload_url(object_key)
return {
"upload_url": url,
"object_key": object_key,
}
@router.get("/download-url")
async def get_presigned_download_url(
object_key: str,
current_user: dict = Depends(get_current_user),
):
"""Get a presigned GET URL for viewing a photo (1 hour validity)."""
url = generate_presigned_download_url(object_key)
return {"download_url": url}
+82
View File
@@ -0,0 +1,82 @@
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.middleware.auth import get_current_user, require_director
from app.models.user import User
from app.schemas.customer import UserOut
router = APIRouter(prefix="/users", tags=["Users"])
class UpdateUserRoleRequest(BaseModel):
role: str # manager / director / leader
department: str = ""
@router.get("/", response_model=list[UserOut])
async def list_users(
role: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""List all users. Used for companion selection, assignment, etc."""
query = select(User)
if role:
query = query.where(User.role == role)
query = query.order_by(User.name)
result = await db.execute(query)
return result.scalars().all()
@router.get("/me")
async def get_current_user_info(
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get current user's full profile."""
result = await db.execute(select(User).where(User.id == uuid.UUID(current_user["user_id"])))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return {
"id": str(user.id),
"casdoor_id": user.casdoor_id,
"name": user.name,
"role": user.role,
"department": user.department,
"wecom_userid": user.wecom_userid,
}
@router.put("/{user_id}/role")
async def update_user_role(
user_id: str,
data: UpdateUserRoleRequest,
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Director updates a user's role. Only director can do this."""
if data.role not in ("manager", "director", "leader"):
raise HTTPException(status_code=400, detail="Invalid role. Must be manager/director/leader")
result = await db.execute(select(User).where(User.id == uuid.UUID(user_id)))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.role = data.role
if data.department:
user.department = data.department
await db.commit()
await db.refresh(user)
return {
"id": str(user.id),
"name": user.name,
"role": user.role,
"department": user.department,
}
+209
View File
@@ -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"}
+68
View File
@@ -0,0 +1,68 @@
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.middleware.auth import get_current_user, require_director
from app.models.user import User
from app.services.wecom import wecom_client
from app.services.scheduler import check_daily_reporting
router = APIRouter(prefix="/wecom", tags=["WeChatWork"])
class RemindRequest(BaseModel):
user_ids: list[str]
message: Optional[str] = None
class AnnouncementRequest(BaseModel):
content: str
@router.post("/remind")
async def send_reminder(
data: RemindRequest,
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Director manually sends reminder to specific managers."""
# Get wecom_userids for the selected users
result = await db.execute(
select(User.wecom_userid).where(User.id.in_([uuid.UUID(uid) for uid in data.user_ids]))
)
wecom_ids = [r[0] for r in result.all() if r[0]]
content = data.message or "📋 请及时完成今日拜访记录填报。"
success = await wecom_client.send_text_message(wecom_ids, content)
return {"success": success, "sent_to": len(wecom_ids)}
@router.post("/announcement")
async def send_announcement(
data: AnnouncementRequest,
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Director sends an announcement to all team members."""
# Get all wecom_userids in the department
result = await db.execute(select(User.wecom_userid).where(User.wecom_userid.isnot(None)))
wecom_ids = [r[0] for r in result.all()]
content = f"📢 支局长公告\n\n{data.content}"
success = await wecom_client.send_text_message(wecom_ids, content)
return {"success": success, "sent_to": len(wecom_ids)}
@router.post("/trigger-daily-check")
async def trigger_daily_check(
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Manually trigger the daily reporting check (for testing or manual use)."""
result = await check_daily_reporting(db)
return result
+105
View File
@@ -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"}
+48
View File
@@ -0,0 +1,48 @@
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
# App
APP_NAME: str = "企迹-政企周报管理系统"
DEBUG: bool = True
SECRET_KEY: str = "change-me-in-production"
# Database
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/qiji"
# JWT
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRE_MINUTES: int = 480
# Casdoor
CASDOOR_ENDPOINT: str = "http://localhost:8001"
CASDOOR_CLIENT_ID: str = ""
CASDOOR_CLIENT_SECRET: str = ""
CASDOOR_CERTIFICATE: Optional[str] = None
CASDOOR_ORG_NAME: str = "qiji"
CASDOOR_APPLICATION: str = "qiji-weekly-report"
# MinIO
MINIO_ENDPOINT: str = "localhost:9000"
MINIO_ACCESS_KEY: str = "minioadmin"
MINIO_SECRET_KEY: str = "minioadmin"
MINIO_BUCKET: str = "qiji-photos"
MINIO_SECURE: bool = False
# WeChat Work
WECOM_CORP_ID: str = ""
WECOM_AGENT_ID: str = ""
WECOM_SECRET: str = ""
WECOM_TOKEN: str = ""
WECOM_ENCODING_AES_KEY: str = ""
# CORS
CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:3000"]
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()
+19
View File
@@ -0,0 +1,19 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
async with async_session() as session:
try:
yield session
finally:
await session.close()
+58
View File
@@ -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}
View File
+31
View File
@@ -0,0 +1,31 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.utils.security import decode_token
bearer_scheme = HTTPBearer()
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme)) -> dict:
payload = decode_token(credentials.credentials)
if payload is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")
return payload
class RoleChecker:
def __init__(self, allowed_roles: list[str]):
self.allowed_roles = allowed_roles
async def __call__(self, current_user: dict = Depends(get_current_user)) -> dict:
role = current_user.get("role", "")
if role not in self.allowed_roles:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
return current_user
# Pre-built checkers
require_manager = RoleChecker(["manager"])
require_director = RoleChecker(["director"])
require_leader = RoleChecker(["leader"])
require_director_or_leader = RoleChecker(["director", "leader"])
require_any_role = RoleChecker(["manager", "director", "leader"])
+21
View File
@@ -0,0 +1,21 @@
from app.models.user import User
from app.models.customer import Customer
from app.models.customer_contact import CustomerContact
from app.models.customer_assignment import CustomerAssignment
from app.models.visit import Visit
from app.models.work_plan import WorkPlan
from app.models.mini_business import MiniBusiness
from app.models.key_visit import KeyVisit
from app.models.daily_note import DailyNote
__all__ = [
"User",
"Customer",
"CustomerContact",
"CustomerAssignment",
"Visit",
"WorkPlan",
"MiniBusiness",
"KeyVisit",
"DailyNote",
]
+24
View File
@@ -0,0 +1,24 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Text, DateTime, func, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class Customer(Base):
__tablename__ = "customers"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(200), index=True)
industry: Mapped[str] = mapped_column(String(100), default="")
address: Mapped[str] = mapped_column(String(500), default="")
in_use_services: Mapped[str] = mapped_column(Text, default="")
monthly_fee: Mapped[str] = mapped_column(String(100), default="")
remarks: Mapped[str] = mapped_column(Text, default="")
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
contacts: Mapped[list["CustomerContact"]] = relationship("CustomerContact", back_populates="customer", cascade="all, delete-orphan")
assignments: Mapped[list["CustomerAssignment"]] = relationship("CustomerAssignment", back_populates="customer", cascade="all, delete-orphan")
+20
View File
@@ -0,0 +1,20 @@
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class CustomerAssignment(Base):
__tablename__ = "customer_assignments"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
customer_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("customers.id"), index=True)
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"))
role: Mapped[str] = mapped_column(String(20), default="primary") # 'primary' / 'assistant'
assigned_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
assigned_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"))
customer: Mapped["Customer"] = relationship("Customer", back_populates="assignments")
manager: Mapped["User"] = relationship("User", foreign_keys=[manager_id])
+17
View File
@@ -0,0 +1,17 @@
import uuid
from sqlalchemy import String, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class CustomerContact(Base):
__tablename__ = "customer_contacts"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
customer_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("customers.id"), index=True)
name: Mapped[str] = mapped_column(String(50))
phone: Mapped[str] = mapped_column(String(20), default="")
role_desc: Mapped[str] = mapped_column(String(100), default="")
customer: Mapped["Customer"] = relationship("Customer", back_populates="contacts")
+19
View File
@@ -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())
+19
View File
@@ -0,0 +1,19 @@
import uuid
from sqlalchemy import String, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
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)
+18
View File
@@ -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="")
+18
View File
@@ -0,0 +1,18 @@
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
casdoor_id: Mapped[str] = mapped_column(String(100), unique=True, index=True)
name: Mapped[str] = mapped_column(String(50))
role: Mapped[str] = mapped_column(String(30)) # manager / director / leader
department: Mapped[str] = mapped_column(String(100), default="")
wecom_userid: Mapped[str | None] = mapped_column(String(100), unique=True, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+23
View File
@@ -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())
+17
View File
@@ -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="计划中") # 计划中/已完成/已取消
+10
View File
@@ -0,0 +1,10 @@
from app.schemas.customer import (
CustomerCreate, CustomerUpdate, CustomerOut, CustomerListOut,
ContactCreate, ContactOut, AssignmentCreate, AssignmentOut, BatchAssignRequest,
)
from app.schemas.visit import VisitCreate, VisitUpdate, VisitOut, VisitListOut
from app.schemas.work_plan import WorkPlanCreate, WorkPlanUpdate, WorkPlanOut
from app.schemas.mini_business import MiniBusinessCreate, MiniBusinessUpdate, MiniBusinessOut
from app.schemas.key_visit import KeyVisitCreate, KeyVisitUpdate, KeyVisitOut
from app.schemas.user import TokenResponse, WecomLoginRequest, CasdoorLoginRequest, WecomBindRequest
from app.schemas.customer import UserCreate, UserOut
+118
View File
@@ -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
+32
View File
@@ -0,0 +1,32 @@
from pydantic import BaseModel
from typing import Optional
import uuid
from datetime import date, datetime
class DailyNoteCreate(BaseModel):
note_date: str # YYYY-MM-DD
category: str = "其他"
content: str = ""
time_range: str = ""
class DailyNoteUpdate(BaseModel):
note_date: Optional[str] = None
category: Optional[str] = None
content: Optional[str] = None
time_range: Optional[str] = None
class DailyNoteOut(BaseModel):
id: uuid.UUID
manager_id: uuid.UUID
note_date: date
category: str
content: str
time_range: str
created_at: datetime
updated_at: datetime
manager_name: Optional[str] = None
model_config = {"from_attributes": True}
+39
View File
@@ -0,0 +1,39 @@
from pydantic import BaseModel
from typing import Optional
import uuid
class KeyVisitCreate(BaseModel):
customer_id: uuid.UUID
urgency_level: str = "一般" # 重要/一般/紧急
description: str = ""
progress_status: str = "未开始"
planned_date: str = ""
planned_visitor: str = ""
visit_target: str = ""
class KeyVisitUpdate(BaseModel):
customer_id: Optional[uuid.UUID] = None
urgency_level: Optional[str] = None
description: Optional[str] = None
progress_status: Optional[str] = None
planned_date: Optional[str] = None
planned_visitor: Optional[str] = None
visit_target: Optional[str] = None
class KeyVisitOut(BaseModel):
id: uuid.UUID
customer_id: uuid.UUID
urgency_level: str
description: str
progress_status: str
planned_date: str
planned_visitor: str
visit_target: str
manager_id: uuid.UUID
customer_name: Optional[str] = None
manager_name: Optional[str] = None
model_config = {"from_attributes": True}
+36
View File
@@ -0,0 +1,36 @@
from pydantic import BaseModel
from typing import Optional
import uuid
class MiniBusinessCreate(BaseModel):
customer_id: uuid.UUID
product_type: str = ""
amount: str = ""
follow_up_detail: str = ""
status: str = "跟进中"
expected_revenue_date: str = ""
class MiniBusinessUpdate(BaseModel):
customer_id: Optional[uuid.UUID] = None
product_type: Optional[str] = None
amount: Optional[str] = None
follow_up_detail: Optional[str] = None
status: Optional[str] = None
expected_revenue_date: Optional[str] = None
class MiniBusinessOut(BaseModel):
id: uuid.UUID
customer_id: uuid.UUID
product_type: str
amount: str
follow_up_detail: str
status: str
manager_id: uuid.UUID
expected_revenue_date: str
customer_name: Optional[str] = None
manager_name: Optional[str] = None
model_config = {"from_attributes": True}
+25
View File
@@ -0,0 +1,25 @@
from pydantic import BaseModel
from typing import Optional
import uuid
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user_id: str
name: str
role: str
class WecomLoginRequest(BaseModel):
code: str
class CasdoorLoginRequest(BaseModel):
code: str
state: str
class WecomBindRequest(BaseModel):
casdoor_code: str
wecom_userid: Optional[str] = None
+60
View File
@@ -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}
+31
View File
@@ -0,0 +1,31 @@
from pydantic import BaseModel
from typing import Optional
import uuid
from datetime import date
class WorkPlanCreate(BaseModel):
customer_id: uuid.UUID
plan_content: str = ""
plan_date: str # "YYYY-MM-DD"
status: str = "计划中"
class WorkPlanUpdate(BaseModel):
customer_id: Optional[uuid.UUID] = None
plan_content: Optional[str] = None
plan_date: Optional[str] = None
status: Optional[str] = None
class WorkPlanOut(BaseModel):
id: uuid.UUID
customer_id: uuid.UUID
plan_content: str
plan_date: date
manager_id: uuid.UUID
status: str
customer_name: Optional[str] = None
manager_name: Optional[str] = None
model_config = {"from_attributes": True}
View File
+106
View File
@@ -0,0 +1,106 @@
from uuid import uuid4
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import httpx
from app.config import settings
from app.models.user import User
from app.utils.security import create_access_token
async def get_or_create_user_from_casdoor(
db: AsyncSession, casdoor_id: str, name: str,
role: str = "manager", department: str = ""
) -> User:
"""Find existing user by casdoor_id, or create a new one."""
result = await db.execute(select(User).where(User.casdoor_id == casdoor_id))
user = result.scalar_one_or_none()
if user is None:
user = User(
casdoor_id=casdoor_id,
name=name,
role=role,
department=department,
)
db.add(user)
await db.commit()
await db.refresh(user)
else:
# Update name/department if changed
if user.name != name or user.department != department:
user.name = name
user.department = department
await db.commit()
await db.refresh(user)
return user
async def exchange_casdoor_code(code: str) -> dict | None:
"""Exchange Casdoor OIDC authorization code for user info."""
import logging
logger = logging.getLogger("uvicorn")
token_url = f"{settings.CASDOOR_ENDPOINT}/api/login/oauth/access_token"
async with httpx.AsyncClient() as client:
try:
resp = await client.post(token_url, data={
"grant_type": "authorization_code",
"client_id": settings.CASDOOR_CLIENT_ID,
"client_secret": settings.CASDOOR_CLIENT_SECRET,
"code": code,
}, timeout=10)
if resp.status_code != 200:
logger.error(f"[Casdoor] token exchange failed: status={resp.status_code}, body={resp.text[:500]}")
return None
token_data = resp.json()
access_token = token_data.get("access_token", "")
if not access_token:
logger.error(f"[Casdoor] no access_token in response: {token_data}")
return None
except Exception as e:
logger.error(f"[Casdoor] token exchange exception: {e}")
return None
userinfo_url = f"{settings.CASDOOR_ENDPOINT}/api/userinfo"
async with httpx.AsyncClient() as client:
try:
resp = await client.get(userinfo_url, headers={
"Authorization": f"Bearer {access_token}"
}, timeout=10)
if resp.status_code != 200:
logger.error(f"[Casdoor] userinfo failed: status={resp.status_code}, body={resp.text[:500]}")
return None
return resp.json()
except Exception as e:
logger.error(f"[Casdoor] userinfo exception: {e}")
return None
return None
async def bind_wecom_user(db: AsyncSession, casdoor_id: str, wecom_userid: str) -> User | None:
"""Bind a WeChat Work userid to a Casdoor user."""
result = await db.execute(select(User).where(User.casdoor_id == casdoor_id))
user = result.scalar_one_or_none()
if user is None:
return None
user.wecom_userid = wecom_userid
await db.commit()
await db.refresh(user)
return user
async def get_user_by_wecom_id(db: AsyncSession, wecom_userid: str) -> User | None:
"""Find user by wecom_userid."""
result = await db.execute(select(User).where(User.wecom_userid == wecom_userid))
return result.scalar_one_or_none()
def build_token_for_user(user: User) -> str:
"""Build a JWT token for the given user."""
return create_access_token(data={
"sub": str(user.id),
"user_id": str(user.id),
"casdoor_id": user.casdoor_id,
"name": user.name,
"role": user.role,
"department": user.department,
})
+253
View File
@@ -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,
}
+137
View File
@@ -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
+78
View File
@@ -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
+43
View File
@@ -0,0 +1,43 @@
from datetime import timedelta
from minio import Minio
from app.config import settings
_client: Minio | None = None
def get_minio_client() -> Minio:
global _client
if _client is None:
_client = Minio(
settings.MINIO_ENDPOINT,
access_key=settings.MINIO_ACCESS_KEY,
secret_key=settings.MINIO_SECRET_KEY,
secure=settings.MINIO_SECURE,
)
# Ensure bucket exists
if not _client.bucket_exists(settings.MINIO_BUCKET):
_client.make_bucket(settings.MINIO_BUCKET)
return _client
def generate_presigned_upload_url(object_key: str, expires: int = 600) -> str:
"""Generate a presigned PUT URL for direct upload to MinIO."""
client = get_minio_client()
return client.presigned_put_object(settings.MINIO_BUCKET, object_key, expires=timedelta(seconds=expires))
def generate_presigned_download_url(object_key: str, expires: int = 3600) -> str:
"""Generate a presigned GET URL for viewing/downloading an object."""
client = get_minio_client()
return client.presigned_get_object(settings.MINIO_BUCKET, object_key, expires=timedelta(seconds=expires))
def delete_objects(object_keys: list[str]) -> None:
"""Delete multiple objects from MinIO."""
if not object_keys:
return
client = get_minio_client()
from minio.deleteobjects import DeleteObject
errors = client.remove_objects(settings.MINIO_BUCKET, [DeleteObject(k) for k in object_keys])
for err in errors:
pass # Log errors in production
+54
View File
@@ -0,0 +1,54 @@
from datetime import date, datetime
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.visit import Visit
from app.models.daily_note import DailyNote
from app.models.user import User
from app.services.wecom import wecom_client
from app.utils.timezone import today_cst
async def check_daily_reporting(db: AsyncSession) -> dict:
"""Check today's reporting progress and send wecom reminders to managers who haven't reported."""
today = today_cst()
weekday = today.weekday()
if weekday >= 5: # Skip weekends
return {"status": "weekend", "date": str(today)}
# Get all managers
result = await db.execute(select(User).where(User.role == "manager"))
managers = result.scalars().all()
# Get managers who have reported today
reported_visits = await db.execute(
select(Visit.manager_id).where(Visit.visit_date == today)
)
reported_notes = await db.execute(
select(DailyNote.manager_id).where(DailyNote.note_date == today)
)
reported_map = {str(uid): True for uid, in reported_visits.all()}
for uid, in reported_notes.all():
reported_map[str(uid)] = True
not_reported = []
for m in managers:
if str(m.id) not in reported_map:
not_reported.append(m)
if not_reported and managers:
content = f"📋 今日填报提醒({today}\n\n以下同事尚未提交今日拜访记录:\n"
for m in not_reported:
content += f"{m.name}\n"
content += "\n请尽快完成今日拜访填报 🙏"
user_ids = [m.wecom_userid for m in not_reported if m.wecom_userid]
if user_ids:
await wecom_client.send_text_message(user_ids, content)
return {
"status": "ok",
"date": str(today),
"total_managers": len(managers),
"reported": len(reported_map),
"not_reported": len(not_reported),
}
+55
View File
@@ -0,0 +1,55 @@
import httpx
from app.config import settings
class WecomClient:
"""Minimal WeChat Work API client for sending app messages."""
def __init__(self):
self.corp_id = settings.WECOM_CORP_ID
self.agent_id = settings.WECOM_AGENT_ID
self.secret = settings.WECOM_SECRET
self._access_token: str | None = None
async def _get_token(self) -> str:
if self._access_token:
return self._access_token
url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corp_id}&corpsecret={self.secret}"
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=10)
data = resp.json()
if data.get("errcode") == 0:
self._access_token = data["access_token"]
return self._access_token
raise Exception(f"Failed to get wecom token: {data}")
async def get_userinfo_by_code(self, code: str) -> dict | None:
"""Exchange OAuth2 code for userid (used in silent login)."""
token = await self._get_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_token={token}&code={code}"
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=10)
data = resp.json()
if data.get("errcode") == 0:
return data
return None
async def send_text_message(self, user_ids: list[str], content: str) -> bool:
"""Send a text app message to specified users."""
if not settings.WECOM_AGENT_ID:
return False # Not configured, skip silently in dev
token = await self._get_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}"
body = {
"touser": "|".join(user_ids),
"msgtype": "text",
"agentid": int(settings.WECOM_AGENT_ID),
"text": {"content": content},
}
async with httpx.AsyncClient() as client:
resp = await client.post(url, json=body, timeout=10)
data = resp.json()
return data.get("errcode") == 0
wecom_client = WecomClient()
View File
+19
View File
@@ -0,0 +1,19 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import jwt, JWTError
from app.config import settings
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.JWT_EXPIRE_MINUTES))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
def decode_token(token: str) -> Optional[dict]:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
return payload
except JWTError:
return None
+13
View File
@@ -0,0 +1,13 @@
from datetime import date, datetime, timezone, timedelta
CST = timezone(timedelta(hours=8)) # China Standard Time
def today_cst() -> date:
"""Get today's date in Asia/Shanghai timezone."""
return datetime.now(timezone.utc).astimezone(CST).date()
def parse_date(s: str) -> date:
"""Parse a date string that may be YYYY-MM-DD or a full ISO datetime."""
return date.fromisoformat(s[:10])
+14
View File
@@ -0,0 +1,14 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
alembic==1.14.0
pydantic==2.10.3
pydantic-settings==2.7.0
python-jose[cryptography]==3.3.0
httpx==0.28.1
python-multipart==0.0.18
minio==7.2.10
openpyxl==3.1.5
apscheduler==3.11.0
python-dotenv==1.0.1
+46
View File
@@ -0,0 +1,46 @@
version: "3.8"
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: qiji
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000"
- "9001:9001"
volumes:
- miniodata:/data
backend:
build: ./backend
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/qiji
MINIO_ENDPOINT: minio:9000
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
MINIO_BUCKET: qiji-photos
depends_on:
- postgres
- minio
volumes:
- ./backend:/app
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
volumes:
pgdata:
miniodata:
+10
View File
@@ -0,0 +1,10 @@
# ============================================
# 企迹 (qiji) — 前端环境变量
# 复制此文件为 .env 并填入真实值
# ============================================
# Casdoor 认证服务地址(与后端 CASDOOR_ENDPOINT 一致)
VITE_CASDOOR_ENDPOINT=http://localhost:8001
# Casdoor 应用 Client ID(与后端 CASDOOR_CLIENT_ID 一致)
VITE_CASDOOR_CLIENT_ID=e715d85bf5b2dc8988ac
+12
View File
@@ -0,0 +1,12 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>企迹 - 政企周报管理系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Vue app
location / {
try_files $uri $uri/ /index.html;
}
# API proxy
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /health {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
}
}
+2096
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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"
}
}
+24
View File
@@ -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>
+13
View File
@@ -0,0 +1,13 @@
import api from './index'
export const authApi = {
casdoorLogin(code: string, state: string) {
return api.post('/auth/casdoor-login', { code, state })
},
wecomLogin(code: string) {
return api.post('/auth/wecom-login', { code })
},
bindWecom(casdoorCode: string, wecomUserid: string) {
return api.post('/auth/bind-wecom', { casdoor_code: casdoorCode, wecom_userid: wecomUserid })
},
}
+32
View File
@@ -0,0 +1,32 @@
import api from './index'
export const customersApi = {
list(params?: any) {
return api.get('/customers/', { params })
},
get(id: string) {
return api.get(`/customers/${id}`)
},
create(data: any) {
return api.post('/customers/', data)
},
update(id: string, data: any) {
return api.put(`/customers/${id}`, data)
},
delete(id: string) {
return api.delete(`/customers/${id}`)
},
checkDuplicate(name: string) {
return api.get(`/customers/check-duplicate/${encodeURIComponent(name)}`)
},
// Assignments
listAssignments(customerId: string) {
return api.get(`/customers/${customerId}/assignments`)
},
assignManager(customerId: string, data: any) {
return api.post(`/customers/${customerId}/assignments`, data)
},
batchAssign(data: any) {
return api.post('/customers/batch-assign', data)
},
}
+13
View File
@@ -0,0 +1,13 @@
import api from './index'
export const dashboardApi = {
getStats() {
return api.get('/dashboard/stats')
},
getProgress() {
return api.get('/dashboard/progress')
},
getWeeklyReport(params?: any) {
return api.get('/dashboard/weekly-report', { params })
},
}
+30
View File
@@ -0,0 +1,30 @@
import axios from 'axios'
import { useAuthStore } from '@/stores/auth'
import router from '@/router'
const api = axios.create({
baseURL: '/api',
timeout: 30000,
})
api.interceptors.request.use((config) => {
const auth = useAuthStore()
if (auth.token) {
config.headers.Authorization = `Bearer ${auth.token}`
}
return config
})
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
const auth = useAuthStore()
auth.logout()
router.push('/login')
}
return Promise.reject(error)
}
)
export default api
+16
View File
@@ -0,0 +1,16 @@
import api from './index'
export const keyVisitsApi = {
list(params?: any) {
return api.get('/key-visits/', { params })
},
create(data: any) {
return api.post('/key-visits/', data)
},
update(id: string, data: any) {
return api.put(`/key-visits/${id}`, data)
},
delete(id: string) {
return api.delete(`/key-visits/${id}`)
},
}
+16
View File
@@ -0,0 +1,16 @@
import api from './index'
export const miniBusinessApi = {
list(params?: any) {
return api.get('/mini-business/', { params })
},
create(data: any) {
return api.post('/mini-business/', data)
},
update(id: string, data: any) {
return api.put(`/mini-business/${id}`, data)
},
delete(id: string) {
return api.delete(`/mini-business/${id}`)
},
}
+20
View File
@@ -0,0 +1,20 @@
import api from './index'
import axios from 'axios'
export const uploadApi = {
async getPresignedUrl(filename: string, contentType: string = 'image/jpeg') {
return api.post('/upload/presigned-url', null, {
params: { filename, content_type: contentType },
})
},
getDownloadUrl(objectKey: string) {
return api.get('/upload/download-url', { params: { object_key: objectKey } })
},
// Direct upload to MinIO
async uploadFile(uploadUrl: string, file: File) {
return axios.put(uploadUrl, file, {
headers: { 'Content-Type': file.type },
timeout: 60000,
})
},
}
+22
View File
@@ -0,0 +1,22 @@
import api from './index'
export const visitsApi = {
list(params?: any) {
return api.get('/visits/', { params })
},
getToday() {
return api.get('/visits/today')
},
get(id: string) {
return api.get(`/visits/${id}`)
},
create(data: any) {
return api.post('/visits/', data)
},
update(id: string, data: any) {
return api.put(`/visits/${id}`, data)
},
delete(id: string) {
return api.delete(`/visits/${id}`)
},
}
+16
View File
@@ -0,0 +1,16 @@
import api from './index'
export const workPlansApi = {
list(params?: any) {
return api.get('/work-plans/', { params })
},
create(data: any) {
return api.post('/work-plans/', data)
},
update(id: string, data: any) {
return api.put(`/work-plans/${id}`, data)
},
delete(id: string) {
return api.delete(`/work-plans/${id}`)
},
}
+101
View File
@@ -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>
+92
View File
@@ -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>
+21
View File
@@ -0,0 +1,21 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_CASDOOR_ENDPOINT: string
readonly VITE_CASDOOR_CLIENT_ID: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module 'element-plus/dist/locale/zh-cn.mjs' {
const zhCn: Record<string, any>
export default zhCn
}
+13
View File
@@ -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')
+63
View File
@@ -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
+56
View File
@@ -0,0 +1,56 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authApi } from '@/api/auth'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token') || '')
const userId = ref(localStorage.getItem('userId') || '')
const name = ref(localStorage.getItem('userName') || '')
const role = ref(localStorage.getItem('userRole') || '')
const isLoggedIn = computed(() => !!token.value)
const isManager = computed(() => role.value === 'manager')
const isDirector = computed(() => role.value === 'director')
const isLeader = computed(() => role.value === 'leader')
function saveLogin(data: { access_token: string; user_id: string; name: string; role: string }) {
token.value = data.access_token
userId.value = data.user_id
name.value = data.name
role.value = data.role
localStorage.setItem('token', data.access_token)
localStorage.setItem('userId', data.user_id)
localStorage.setItem('userName', data.name)
localStorage.setItem('userRole', data.role)
}
function logout() {
token.value = ''
userId.value = ''
name.value = ''
role.value = ''
localStorage.clear()
}
async function casdoorLogin(code: string, state: string) {
const res = await authApi.casdoorLogin(code, state)
saveLogin(res.data)
return res.data
}
async function wecomLogin(code: string) {
return await authApi.wecomLogin(code)
}
async function bindWecom(casdoorCode: string, wecomUserid: string) {
const res = await authApi.bindWecom(casdoorCode, wecomUserid)
saveLogin(res.data)
return res.data
}
return {
token, userId, name, role,
isLoggedIn, isManager, isDirector, isLeader,
saveLogin, logout, casdoorLogin, wecomLogin, bindWecom,
}
})
+5
View File
@@ -0,0 +1,5 @@
/** Get today's date in YYYY-MM-DD format (local timezone). */
export function todayStr(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const loading = ref(true)
onMounted(async () => {
const code = route.query.code as string
const state = route.query.state as string // This is wecom_userid
if (!code) {
ElMessage.error('缺少授权参数')
router.push('/login')
return
}
try {
await auth.bindWecom(code, state || '')
ElMessage.success('企微绑定成功')
router.push(auth.isManager ? '/m' : '/')
} catch (e: any) {
ElMessage.error('绑定失败: ' + (e.response?.data?.detail || e.message))
router.push('/login')
} finally {
loading.value = false
}
})
</script>
<template>
<div class="bind-page">
<el-card v-loading="loading">
<template #header>正在完成企微账号绑定...</template>
<p v-if="loading">请稍候正在验证您的身份</p>
</el-card>
</div>
</template>
<style scoped>
.bind-page {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: #f5f7fa;
}
</style>
+124
View File
@@ -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>
+111
View File
@@ -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>
+172
View File
@@ -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>
+126
View File
@@ -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>
+257
View File
@@ -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>
+106
View File
@@ -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>
+136
View File
@@ -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>
+269
View File
@@ -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>
+24
View File
@@ -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"]
}
+25
View File
@@ -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,
},
},
},
})
+268
View File
@@ -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 前端部署于云服务器 nginxHTTPS 访问
4. 开发阶段前后端均在本地运行,联调结构与生产一致
5. Casdoor、PostgreSQL、MinIO、企业微信应用均为已有基础设施,本系统作为新接入方