Compare commits
79 Commits
c38a9e74ad
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 4afa796ddc | |||
| ab20bc5a1f | |||
| 1ec20ee53e | |||
| c9df6066f5 | |||
| 804812dd7e | |||
| 4139b54a57 | |||
| 2187be7aeb | |||
| 9b5805447e | |||
| 80d2716b7c | |||
| c7bb49776c | |||
| 687d7cc131 | |||
| 4bfe2900fb | |||
| 6b43b77c3b | |||
| 29e4e4a804 | |||
| 7f87aa7353 | |||
| ccb436664b | |||
| af3d3bfc89 | |||
| 5b55029576 | |||
| e14308b757 | |||
| ce24937ba0 | |||
| 6fd9cfbff9 | |||
| ee3f76d960 | |||
| a9344eadae | |||
| 392380ab51 | |||
| 7699e88578 | |||
| f89638b3f0 | |||
| 04f701b27c | |||
| 07c6f65378 | |||
| cdbe59e97f | |||
| e7125ff930 | |||
| 72d869bafb | |||
| 3059f0e7e4 | |||
| d6dec7f9af | |||
| ff07e9c470 | |||
| c55ea18aaf | |||
| 32ca029580 | |||
| ae29a17825 | |||
| 1fddc35119 | |||
| 9a39b96316 | |||
| 83c87cad3d | |||
| 9f3067b412 | |||
| 7c75164323 | |||
| 6504a393e3 | |||
| 835e5d38fd | |||
| 03198557f7 | |||
| 98a0d4f7c8 | |||
| 05d33d518f | |||
| 9354752b89 | |||
| 82755e14a1 | |||
| 20f5b01987 | |||
| 78962e45d7 | |||
| fade9e62e2 | |||
| 7b91cb4230 | |||
| 6c76b34d1b | |||
| 1e0f77d4e6 | |||
| ff0a38a369 | |||
| 6d1ccbb246 | |||
| 216406326a | |||
| 33fb6a63c0 | |||
| 637a0949a8 | |||
| aed9182dbe | |||
| 24fb5f8f71 | |||
| bc6c7076fe | |||
| e06574df38 | |||
| 10835ef15c | |||
| 3ce77ed606 | |||
| 9eae965858 | |||
| 87876f11fd | |||
| 19aa9153ff | |||
| 17a0218b32 | |||
| 13f8b9c512 | |||
| 03f14df244 | |||
| 3879cdc7ef | |||
| 4453ee2ca0 | |||
| c489a8d6fd | |||
| 5682c12085 | |||
| be26ea5588 | |||
| 750ce8d8eb | |||
| efc200b6de |
@@ -0,0 +1,53 @@
|
||||
stages:
|
||||
- verify
|
||||
- build
|
||||
|
||||
backend_verify:
|
||||
stage: verify
|
||||
image: python:3.12-slim
|
||||
script:
|
||||
- pip install --no-cache-dir -r backend/requirements.txt pytest ruff bandit pip-audit
|
||||
- PYTHONPATH=backend python -m compileall -q backend/app
|
||||
- ruff check backend/app backend/tests
|
||||
- PYTHONPATH=backend pytest -q backend/tests --junitxml=backend-test-report.xml
|
||||
- bandit -q -r backend/app -lll
|
||||
- pip-audit -r backend/requirements.txt
|
||||
artifacts:
|
||||
reports:
|
||||
junit: backend-test-report.xml
|
||||
paths:
|
||||
- backend-test-report.xml
|
||||
expire_in: 180 days
|
||||
|
||||
frontend_build:
|
||||
stage: build
|
||||
image: node:20-alpine
|
||||
script:
|
||||
- cd frontend
|
||||
- npm ci
|
||||
- npm run build
|
||||
artifacts:
|
||||
name: "qiji-frontend-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- frontend/dist/
|
||||
expire_in: 30 days
|
||||
|
||||
backend_sbom:
|
||||
stage: verify
|
||||
image: python:3.12-slim
|
||||
script:
|
||||
- pip install --no-cache-dir cyclonedx-bom
|
||||
- python -m cyclonedx_py requirements backend/requirements.txt --output-reproducible --of JSON -o sbom-backend.cdx.json
|
||||
artifacts:
|
||||
name: "qiji-sbom-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- sbom-backend.cdx.json
|
||||
expire_in: 180 days
|
||||
|
||||
filesystem_security:
|
||||
stage: verify
|
||||
image:
|
||||
name: aquasec/trivy:0.67.2
|
||||
entrypoint: [""]
|
||||
script:
|
||||
- trivy fs --exit-code 1 --ignore-unfixed --severity HIGH,CRITICAL --scanners vuln,misconfig,secret .
|
||||
@@ -53,7 +53,7 @@ qiji/
|
||||
| 表 | 核心字段 |
|
||||
|----|---------|
|
||||
| `users` | id, casdoor_id, name, role(manager/director/leader), wecom_userid |
|
||||
| `customers` | id, name, industry, address, in_use_services, monthly_fee, remarks, created_by |
|
||||
| `customers` | id, name, customer_type(unit/individual), industry, address, in_use_services, monthly_fee, remarks, created_by |
|
||||
| `customer_contacts` | id, customer_id, name, phone, role_desc |
|
||||
| `customer_assignments` | id, customer_id, manager_id, role(primary/assistant), assigned_by |
|
||||
| `visits` | id, customer_id, visit_date, visit_method, time_range, visitor_name, visitor_phone, communication_content, customer_demand, companions(UUID[]), photos(TEXT[]), manager_id, edit_log(JSONB) |
|
||||
@@ -120,6 +120,10 @@ qiji/
|
||||
- [x] **工作页客户经理列+汇总栏** — 工作计划/商机跟单/要客拜访三个页面新增"客户经理"列(哈希色标签)+ 页面顶部汇总栏(按经理分组统计条数)
|
||||
- [x] **云端部署** — FRP 隧道 `qiji-backend` (本机 8002→远程 18061),前端 Docker 容器 `qiji-frontend` (远程 18063),OpenResty 反代 `qj.dhdx.fun`,生产模式运行(去掉 --reload),SECRET_KEY 已加固,CORS 已更新
|
||||
- [x] **请假管理** — `leaves` 表 + 完整 CRUD API + 填报进度免考核(on_leave 第四态) + 催办豁免 + AI 摘要注入 + PC 端独立管理页 + 移动端列表/表单
|
||||
- [x] **商机跟单优化** — 新增跟进记录子表 `mini_business_logs`,时间线展示+跟进方式chip。拜访创建时自动联动关联商机追加跟进。状态流转优化(默认筛选跟进中、已流失置底自动清空列收)
|
||||
- [x] **个人用户** — `customers.customer_type` 列区分 unit/individual。单位客户和个人用户双Tab分离管理。个人用户Tab内嵌4Tab聚合视图(拜访/计划/商机/要客),显示所有个人用户的关联数据。个人用户通过业务模块快速新建产生,客户搜索下拉中置顶
|
||||
- [x] **移动端导航重构** — 底部TabBar从3项扩展为4项(首页/工作/拜访/纪要)。新增工作中心页面(6个模块卡片入口)。新增5个历史列表页(拜访/纪要/计划/商机/要客),支持搜索+分页。3个表单支持新增+编辑双模式
|
||||
- [x] **我的数据页面优化** — 日期筛选改为灵活daterange选择器,支持搜索+分页(25/50/100条每页)。每个Tab独立维护分页状态。日期选择器+搜索框移至标题行
|
||||
|
||||
### 待完善
|
||||
|
||||
@@ -266,3 +270,6 @@ docker-compose up -d
|
||||
21. **AI 周报摘要**: 接入 DeepSeek V4 (模型 `deepseek-v4-flash`,端点 `/v1/chat/completions`,OpenAI 兼容协议)。`build_summary_prompt()` 注入拜访总览+各经理明细+客户需求汇总+纪要分类+亮灯表覆盖数据,生成四段式 Markdown。WeeklyReport.vue 集成:按钮(闪电图标)→loading 脉冲→渲染→一键复制(安全上下文检测降级)。权限:director/leader 可见按钮
|
||||
22. **云端部署**: FRP 隧道 (8002→18061) + Docker 前端容器 (18063) + OpenResty 反代。frpc 由 1Panel Docker 容器管理,配置在 `/opt/1panel/apps/frpc/frpc/data/frpc.toml`。前端生产构建用 `.env.production` 指向 Casdoor 公网地址。nginx.conf 只保留 SPA fallback,API 路由由 OpenResty 边缘处理。SSH 端口 7072,前端容器仅绑定 127.0.0.1:18063(不直接暴露公网)
|
||||
23. **请假管理**: `leaves` 表存储请假记录,按天粒度(start_date~end_date),五种类型(年假/事假/病假/调休/其他),提交即生效无审批。权限:经理看自己、支局长/领导看全员。填报进度新增 `on_leave` 第四态(蓝「假」印章),催办自动过滤请假人员,AI 摘要注入本周请假数据。PC 端侧边栏「工作」分组下独立页面,移动端首页快捷入口 + 列表页 + 表单页
|
||||
24. **个人用户**: `customers` 表 `customer_type` 列区分 unit/individual。单位客户管理页支持双Tab(单位客户/个人用户)切换。个人用户Tab 内嵌4Tab聚合视图(拜访/计划/商机/要客),显示所有个人用户的关联数据。个人用户通过业务模块快速新建产生,客户搜索下拉中置顶显示。单位客户列表默认只显示单位类型
|
||||
25. **移动端信息架构**: 底部TabBar 4项(首页/工作/拜访/纪要)。工作中心页面6个模块卡片(历史拜访/历史纪要/工作计划/商机跟单/要客拜访/请假管理)。各模块列表页支持搜索+25条/页分页。表单复用新增/编辑双模式(通过 route.params.id 判断)
|
||||
26. **我的数据优化**: 日期范围用 el-date-picker daterange 灵活选择。搜索框+日期选择器移至标题行同行。每个Tab独立维护分页状态(25/50/100)。5个后端列表API均支持 search 参数模糊匹配
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|---|------|
|
||||
| 后端 | FastAPI + SQLAlchemy 2.0 (async) + asyncpg |
|
||||
| 数据库 | PostgreSQL |
|
||||
| 文件存储 | MinIO (预签名直传) |
|
||||
| 文件存储 | MinIO(后端受控图片上传) |
|
||||
| 认证 | Casdoor OIDC + JWT |
|
||||
| 前端 | Vue 3 + Vite + TypeScript + Element Plus |
|
||||
| 状态管理 | Pinia |
|
||||
@@ -98,14 +98,17 @@ npm run dev -- --host 0.0.0.0
|
||||
# 页面 → http://localhost:5173
|
||||
```
|
||||
|
||||
### 一键部署
|
||||
### 本地集成环境
|
||||
|
||||
```bash
|
||||
# 先在 .env 中设置 POSTGRES_USER、POSTGRES_PASSWORD、MINIO_ROOT_USER、
|
||||
# MINIO_ROOT_PASSWORD、SECRET_KEY 和固定版本的 MINIO_IMAGE
|
||||
docker-compose up -d
|
||||
# 后端 :8000 + PostgreSQL :5432 + MinIO :9000/:9001
|
||||
# 前端需单独部署到 nginx,见 frontend/Dockerfile
|
||||
# 服务仅绑定到 127.0.0.1;前端需单独部署到 nginx,见 frontend/Dockerfile
|
||||
```
|
||||
|
||||
生产环境必须使用批准的部署流水线、密钥管理、HTTPS 反向代理和上线审批流程;不得直接将本 Compose 文件作为生产部署方案。
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 配置项 | 说明 | 必须 |
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info
|
||||
.eggs
|
||||
dist
|
||||
build
|
||||
.venv
|
||||
venv
|
||||
env
|
||||
.pytest_cache
|
||||
tests
|
||||
htmlcov
|
||||
.coverage
|
||||
node_modules
|
||||
.vite
|
||||
.git
|
||||
.gitignore
|
||||
.vscode
|
||||
.idea
|
||||
.env
|
||||
.env.*
|
||||
.ruff_cache
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -5,11 +5,13 @@
|
||||
|
||||
# ── 应用 ──
|
||||
APP_NAME=企迹-政企周报管理系统
|
||||
DEBUG=true
|
||||
SECRET_KEY=change-me-to-a-random-string-in-production
|
||||
ENVIRONMENT=development
|
||||
DEBUG=false
|
||||
# Use a random value of at least 32 characters. Never use a default in production.
|
||||
SECRET_KEY=replace-with-a-random-secret-of-at-least-32-characters
|
||||
|
||||
# ── PostgreSQL (已有基础设施,填写实际连接信息) ──
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/qiji
|
||||
DATABASE_URL=postgresql+asyncpg://qiji_app:replace-with-a-strong-password@localhost:5432/qiji
|
||||
|
||||
# ── JWT ──
|
||||
JWT_ALGORITHM=HS256
|
||||
@@ -25,10 +27,11 @@ CASDOOR_APPLICATION=qiji-weekly-report
|
||||
|
||||
# ── MinIO (已有,填写实际部署地址和密钥) ──
|
||||
MINIO_ENDPOINT=localhost:9000
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
MINIO_ACCESS_KEY=qiji-app
|
||||
MINIO_SECRET_KEY=replace-with-a-strong-minio-secret
|
||||
MINIO_BUCKET=qiji-photos
|
||||
MINIO_SECURE=false
|
||||
# Set false only when connecting to a local development MinIO instance over the internal Docker network.
|
||||
MINIO_SECURE=true
|
||||
|
||||
# ── 企业微信 (需在企微管理后台创建自建应用后获取) ──
|
||||
WECOM_CORP_ID=your-corp-id
|
||||
|
||||
+4
-1
@@ -1,5 +1,8 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
@@ -9,4 +12,4 @@ COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["sh", "-c", "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8000"]
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import asyncio
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
from pathlib import Path
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
from alembic import context
|
||||
|
||||
# Ensure Alembic works both in the container and when invoked from a virtualenv
|
||||
# on a server where the console-script directory would otherwise be sys.path[0].
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from app.database import Base
|
||||
from app.models import * # noqa: import all models
|
||||
from app.config import settings
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Establish the tracked baseline schema and replace application-startup DDL.
|
||||
|
||||
Revision ID: 20260728_01
|
||||
Revises:
|
||||
Create Date: 2026-07-28
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.database import Base
|
||||
import app.models # noqa: F401 # Register every model with Base.metadata.
|
||||
|
||||
|
||||
revision: str = "20260728_01"
|
||||
down_revision: str | None = None
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create the current schema and upgrade tables created by older app versions."""
|
||||
bind = op.get_bind()
|
||||
Base.metadata.create_all(bind=bind, checkfirst=True)
|
||||
|
||||
# These columns were historically added in application startup code. Keep this
|
||||
# compatibility path so existing deployments can adopt Alembic safely.
|
||||
statements = (
|
||||
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS remarks TEXT DEFAULT ''",
|
||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_name VARCHAR(50) DEFAULT ''",
|
||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_phone VARCHAR(20) DEFAULT ''",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS require_report BOOLEAN DEFAULT TRUE",
|
||||
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_date DATE",
|
||||
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_manager_id UUID",
|
||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS companion_names TEXT[] DEFAULT '{}'",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS color VARCHAR(7)",
|
||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'",
|
||||
"ALTER TABLE daily_notes ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'",
|
||||
"ALTER TABLE work_plans ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'",
|
||||
"ALTER TABLE mini_business ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'",
|
||||
"ALTER TABLE key_visits ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_users_wecom_userid "
|
||||
"ON users (wecom_userid) WHERE wecom_userid IS NOT NULL",
|
||||
)
|
||||
for statement in statements:
|
||||
op.execute(statement)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove only objects introduced by the tracked security hardening revision.
|
||||
|
||||
The historical base tables pre-date Alembic and may contain production data, so
|
||||
they are deliberately not dropped by a downgrade operation.
|
||||
"""
|
||||
op.execute("DROP INDEX IF EXISTS uq_users_wecom_userid")
|
||||
op.execute("DROP TABLE IF EXISTS wecom_bind_tokens")
|
||||
@@ -0,0 +1 @@
|
||||
"""Alembic migration revisions for the Qiji backend."""
|
||||
@@ -0,0 +1,80 @@
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, 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
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
router = APIRouter(prefix="/audit-logs", tags=["AuditLogs"])
|
||||
|
||||
ENTITY_LABELS: dict[str, str] = {
|
||||
"customer": "客户",
|
||||
"visit": "拜访记录",
|
||||
"daily_note": "今日纪要",
|
||||
"work_plan": "工作计划",
|
||||
"mini_business": "小微商机",
|
||||
"key_visit": "要客拜访",
|
||||
"user": "用户",
|
||||
"leave": "请假",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_audit_logs(
|
||||
entity_type: Optional[str] = Query(None),
|
||||
action: Optional[str] = Query(None),
|
||||
operator_id: Optional[str] = Query(None),
|
||||
date_from: Optional[str] = Query(None),
|
||||
date_to: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=10, le=200),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List audit logs. Only director/leader can access."""
|
||||
if current_user["role"] not in ("director", "leader"):
|
||||
return {"items": [], "total": 0}
|
||||
|
||||
base = select(AuditLog)
|
||||
count_q = select(func.count(AuditLog.id))
|
||||
|
||||
if entity_type:
|
||||
base = base.where(AuditLog.entity_type == entity_type)
|
||||
count_q = count_q.where(AuditLog.entity_type == entity_type)
|
||||
if action:
|
||||
base = base.where(AuditLog.action == action)
|
||||
count_q = count_q.where(AuditLog.action == action)
|
||||
if operator_id:
|
||||
base = base.where(AuditLog.operator_id == uuid.UUID(operator_id))
|
||||
count_q = count_q.where(AuditLog.operator_id == uuid.UUID(operator_id))
|
||||
if date_from:
|
||||
base = base.where(AuditLog.created_at >= date_from)
|
||||
count_q = count_q.where(AuditLog.created_at >= date_from)
|
||||
if date_to:
|
||||
base = base.where(AuditLog.created_at < date_to + "T23:59:59")
|
||||
count_q = count_q.where(AuditLog.created_at < date_to + "T23:59:59")
|
||||
|
||||
total = (await db.execute(count_q)).scalar() or 0
|
||||
|
||||
query = base.order_by(AuditLog.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
rows = (await db.execute(query)).scalars().all()
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
items.append({
|
||||
"id": str(r.id),
|
||||
"entity_type": r.entity_type,
|
||||
"entity_type_label": ENTITY_LABELS.get(r.entity_type, r.entity_type),
|
||||
"entity_id": str(r.entity_id),
|
||||
"entity_name": r.entity_name,
|
||||
"action": r.action,
|
||||
"operator_id": str(r.operator_id),
|
||||
"operator_name": r.operator_name,
|
||||
"summary": r.summary or "",
|
||||
"details": r.details,
|
||||
"created_at": str(r.created_at),
|
||||
})
|
||||
|
||||
return {"items": items, "total": total}
|
||||
+9
-27
@@ -34,6 +34,7 @@ async def casdoor_login(req: CasdoorLoginRequest, db: AsyncSession = Depends(get
|
||||
user_id=str(user.id),
|
||||
name=user.name,
|
||||
role=user.role,
|
||||
theme=user.theme or "editorial",
|
||||
)
|
||||
|
||||
|
||||
@@ -56,38 +57,19 @@ async def wecom_login(req: WecomLoginRequest, db: AsyncSession = Depends(get_db)
|
||||
user_id=str(user.id),
|
||||
name=user.name,
|
||||
role=user.role,
|
||||
theme=user.theme or "editorial",
|
||||
)
|
||||
|
||||
# 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}"
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="企业微信账号尚未绑定。请在企业微信内使用一次性绑定链接完成绑定。",
|
||||
)
|
||||
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,
|
||||
"""Deprecated unsafe binding flow. Use /wecom/bind-confirm with a one-time token."""
|
||||
raise HTTPException(
|
||||
status_code=410,
|
||||
detail="该绑定接口已停用,请使用企业微信一次性绑定链接。",
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.models.mini_business import MiniBusiness
|
||||
from app.models.key_visit import KeyVisit
|
||||
from app.models.daily_note import DailyNote
|
||||
from app.models.user import User
|
||||
from app.utils.audit import log_audit
|
||||
from app.schemas.customer import (
|
||||
CustomerCreate, CustomerUpdate, CustomerOut, CustomerListOut, CustomerListResponse,
|
||||
ContactCreate, ContactOut, AssignmentCreate, AssignmentOut, BatchAssignRequest,
|
||||
@@ -50,6 +51,7 @@ async def list_customers(
|
||||
industry: Optional[str] = Query(None),
|
||||
service: Optional[str] = Query(None),
|
||||
manager_id: Optional[str] = Query(None),
|
||||
customer_type: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(100, ge=1, le=1000),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
@@ -60,6 +62,8 @@ async def list_customers(
|
||||
|
||||
base_query = select(Customer)
|
||||
|
||||
if customer_type:
|
||||
base_query = base_query.where(Customer.customer_type == customer_type)
|
||||
if industry:
|
||||
base_query = base_query.where(Customer.industry.ilike(f"%{industry}%"))
|
||||
if service:
|
||||
@@ -89,7 +93,7 @@ async def list_customers(
|
||||
|
||||
# Paginate
|
||||
offset = (page - 1) * page_size
|
||||
query = base_query.order_by(Customer.name).offset(offset).limit(page_size)
|
||||
query = base_query.order_by(Customer.customer_type, Customer.name).offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
items = result.scalars().all()
|
||||
|
||||
@@ -99,10 +103,14 @@ async def list_customers(
|
||||
select(CustomerAssignment.customer_id, User.name)
|
||||
.join(User, CustomerAssignment.manager_id == User.id)
|
||||
.where(CustomerAssignment.customer_id.in_([c.id for c in items]), CustomerAssignment.role == "primary")
|
||||
.order_by(CustomerAssignment.customer_id)
|
||||
)
|
||||
mgr_map = {str(cid): name for cid, name in mgr_result.all()}
|
||||
mgr_map: dict[str, list] = {}
|
||||
for cid, name in mgr_result.all():
|
||||
mgr_map.setdefault(str(cid), []).append(name)
|
||||
for item in items:
|
||||
item.primary_manager_name = mgr_map.get(str(item.id), None)
|
||||
names = mgr_map.get(str(item.id), [])
|
||||
item.primary_manager_name = "、".join(names) if names else None
|
||||
|
||||
return CustomerListResponse(
|
||||
items=items,
|
||||
@@ -399,6 +407,10 @@ async def create_customer(
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
# Audit log
|
||||
await log_audit(db, "customer", customer.id, customer.name, "create", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(
|
||||
select(Customer).where(Customer.id == customer.id).options(selectinload(Customer.contacts))
|
||||
)
|
||||
@@ -470,6 +482,10 @@ async def update_customer(
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Audit log
|
||||
await log_audit(db, "customer", uuid_mod.UUID(customer_id), customer.name, "update", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(
|
||||
select(Customer).where(Customer.id == customer.id).options(selectinload(Customer.contacts))
|
||||
)
|
||||
@@ -486,8 +502,12 @@ async def delete_customer(
|
||||
customer = result.scalar_one_or_none()
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
cust_name = customer.name
|
||||
await db.delete(customer)
|
||||
await db.commit()
|
||||
# Audit log
|
||||
await log_audit(db, "customer", uuid_mod.UUID(customer_id), cust_name, "delete", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return {"detail": "deleted"}
|
||||
|
||||
|
||||
@@ -715,3 +735,33 @@ async def assign_manager(
|
||||
await db.commit()
|
||||
await db.refresh(assignment)
|
||||
return assignment
|
||||
|
||||
|
||||
@router.get("/{customer_id}/overdue-plans")
|
||||
async def get_overdue_plans(
|
||||
customer_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return the latest overdue (plan_date < today) and still '计划中' plan for a customer.
|
||||
Used by the frontend to pre-fill visit forms with pending plan content."""
|
||||
from app.utils.timezone import today_cst
|
||||
today = today_cst()
|
||||
result = await db.execute(
|
||||
select(WorkPlan).where(
|
||||
WorkPlan.customer_id == uuid_mod.UUID(customer_id),
|
||||
WorkPlan.status == "计划中",
|
||||
WorkPlan.plan_date < today,
|
||||
).order_by(WorkPlan.plan_date.desc()).limit(1)
|
||||
)
|
||||
plan = result.scalar_one_or_none()
|
||||
if not plan:
|
||||
return {"plan": None}
|
||||
return {
|
||||
"plan": {
|
||||
"id": str(plan.id),
|
||||
"plan_content": plan.plan_content,
|
||||
"plan_date": str(plan.plan_date),
|
||||
"manager_id": str(plan.manager_id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.models.user import User
|
||||
from app.schemas.daily_note import DailyNoteCreate, DailyNoteUpdate, DailyNoteOut
|
||||
from app.utils.timezone import today_cst, parse_date
|
||||
from app.utils.edit_log import compute_diff, append_entry, init_entry
|
||||
from app.utils.audit import log_audit
|
||||
|
||||
router = APIRouter(prefix="/daily-notes", tags=["DailyNotes"])
|
||||
|
||||
@@ -30,6 +31,7 @@ async def _enrich(note: DailyNote, db: AsyncSession) -> dict:
|
||||
async def list_notes(
|
||||
date_from: Optional[str] = Query(None),
|
||||
date_to: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -40,6 +42,8 @@ async def list_notes(
|
||||
query = query.where(DailyNote.note_date >= parse_date(date_from))
|
||||
if date_to:
|
||||
query = query.where(DailyNote.note_date <= parse_date(date_to))
|
||||
if search:
|
||||
query = query.where(DailyNote.content.ilike(f"%{search}%"))
|
||||
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()]
|
||||
@@ -80,7 +84,7 @@ async def create_note(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
note = DailyNote(
|
||||
manager_id=uuid.UUID(current_user["user_id"]),
|
||||
manager_id=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else uuid.UUID(current_user["user_id"]),
|
||||
note_date=parse_date(data.note_date),
|
||||
category=data.category,
|
||||
content=data.content,
|
||||
@@ -90,6 +94,9 @@ async def create_note(
|
||||
db.add(note)
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
# Audit log
|
||||
await log_audit(db, "daily_note", note.id, f"{note.note_date} 纪要", "create", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return await _enrich(note, db)
|
||||
|
||||
|
||||
@@ -108,6 +115,8 @@ async def update_note(
|
||||
|
||||
old_snapshot = {"note_date": str(note.note_date), "category": note.category, "content": note.content, "time_range": note.time_range}
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if current_user["role"] not in ("director", "leader"):
|
||||
update_data.pop("manager_id", None)
|
||||
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():
|
||||
@@ -118,6 +127,9 @@ async def update_note(
|
||||
append_entry(note, current_user["name"], changes, getattr(data, "edit_reason", None))
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
# Audit log
|
||||
await log_audit(db, "daily_note", note.id, f"{note.note_date} 纪要", "update", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return await _enrich(note, db)
|
||||
|
||||
|
||||
@@ -133,6 +145,10 @@ async def delete_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")
|
||||
note_date = str(note.note_date)
|
||||
await db.delete(note)
|
||||
await db.commit()
|
||||
# Audit log
|
||||
await log_audit(db, "daily_note", uuid.UUID(note_id), f"{note_date} 纪要", "delete", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return {"detail": "deleted"}
|
||||
|
||||
@@ -19,7 +19,7 @@ async def dashboard_stats(
|
||||
):
|
||||
"""Get dashboard card statistics. Pass reference_date (YYYY-MM-DD) for historical weeks."""
|
||||
ref = date.fromisoformat(reference_date) if reference_date else None
|
||||
stats = await get_dashboard_stats(db, ref)
|
||||
stats = await get_dashboard_stats(db, ref, current_user["user_id"], current_user["role"])
|
||||
return stats
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import uuid
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, or_
|
||||
from app.database import get_db
|
||||
from app.middleware.auth import get_current_user, require_any_role
|
||||
from app.models.key_visit import KeyVisit
|
||||
@@ -10,6 +10,7 @@ from app.models.customer import Customer
|
||||
from app.models.user import User
|
||||
from app.schemas.key_visit import KeyVisitCreate, KeyVisitUpdate, KeyVisitOut
|
||||
from app.utils.edit_log import compute_diff, append_entry, init_entry
|
||||
from app.utils.audit import log_audit
|
||||
|
||||
router = APIRouter(prefix="/key-visits", tags=["KeyVisits"])
|
||||
|
||||
@@ -36,6 +37,8 @@ async def _enrich(k: KeyVisit, db: AsyncSession) -> dict:
|
||||
@router.get("/")
|
||||
async def list_key_visits(
|
||||
customer_id: Optional[str] = Query(None),
|
||||
customer_type: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -44,11 +47,32 @@ async def list_key_visits(
|
||||
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))
|
||||
if customer_type:
|
||||
query = query.join(Customer, KeyVisit.customer_id == Customer.id).where(Customer.customer_type == customer_type)
|
||||
if search:
|
||||
query = query.outerjoin(Customer, KeyVisit.customer_id == Customer.id).where(
|
||||
or_(KeyVisit.description.ilike(f"%{search}%"), KeyVisit.planned_visitor.ilike(f"%{search}%"), Customer.name.ilike(f"%{search}%"))
|
||||
)
|
||||
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.get("/{item_id}")
|
||||
async def get_key_visit(
|
||||
item_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
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")
|
||||
return await _enrich(k, db)
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_key_visit(
|
||||
data: KeyVisitCreate,
|
||||
@@ -63,12 +87,27 @@ async def create_key_visit(
|
||||
planned_date=data.planned_date,
|
||||
planned_visitor=data.planned_visitor,
|
||||
visit_target=data.visit_target,
|
||||
manager_id=uuid.UUID(current_user["user_id"]),
|
||||
manager_id=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else uuid.UUID(current_user["user_id"]),
|
||||
)
|
||||
init_entry(k, current_user["name"])
|
||||
db.add(k)
|
||||
await db.commit()
|
||||
await db.refresh(k)
|
||||
# Audit log
|
||||
cust = await db.execute(select(Customer.name).where(Customer.id == k.customer_id))
|
||||
await log_audit(db, "key_visit", k.id, f"{cust.scalar_one_or_none() or ''} ({k.urgency_level})", "create", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return await _enrich(k, db)
|
||||
|
||||
|
||||
@router.get("/{item_id}")
|
||||
async def get_key_visit(item_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user)):
|
||||
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")
|
||||
return await _enrich(k, db)
|
||||
|
||||
|
||||
@@ -87,6 +126,10 @@ async def update_key_visit(
|
||||
|
||||
old_snapshot = {"customer_id": str(k.customer_id), "urgency_level": k.urgency_level, "description": k.description, "progress_status": k.progress_status, "planned_date": k.planned_date, "planned_visitor": k.planned_visitor, "visit_target": k.visit_target}
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if current_user["role"] not in ("director", "leader"):
|
||||
update_data.pop("manager_id", None)
|
||||
if "manager_id" in update_data and update_data["manager_id"]:
|
||||
update_data["manager_id"] = uuid.UUID(update_data["manager_id"])
|
||||
for key, v in update_data.items():
|
||||
setattr(k, key, v)
|
||||
new_snapshot = {"customer_id": str(k.customer_id), "urgency_level": k.urgency_level, "description": k.description, "progress_status": k.progress_status, "planned_date": k.planned_date, "planned_visitor": k.planned_visitor, "visit_target": k.visit_target}
|
||||
@@ -95,6 +138,10 @@ async def update_key_visit(
|
||||
append_entry(k, current_user["name"], changes, getattr(data, "edit_reason", None))
|
||||
await db.commit()
|
||||
await db.refresh(k)
|
||||
# Audit log
|
||||
cust = await db.execute(select(Customer.name).where(Customer.id == k.customer_id))
|
||||
await log_audit(db, "key_visit", k.id, f"{cust.scalar_one_or_none() or ''} ({k.urgency_level})", "update", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return await _enrich(k, db)
|
||||
|
||||
|
||||
@@ -110,6 +157,11 @@ async def delete_key_visit(
|
||||
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")
|
||||
cust = await db.execute(select(Customer.name).where(Customer.id == k.customer_id))
|
||||
entity_name = f"{cust.scalar_one_or_none() or ''} ({k.urgency_level})"
|
||||
await db.delete(k)
|
||||
await db.commit()
|
||||
# Audit log
|
||||
await log_audit(db, "key_visit", uuid.UUID(item_id), entity_name, "delete", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return {"detail": "deleted"}
|
||||
|
||||
@@ -43,6 +43,7 @@ async def create_leave(
|
||||
data=data.model_dump(),
|
||||
submitted_by=UUID(current_user["user_id"]),
|
||||
role=current_user["role"],
|
||||
operator_name=current_user["name"],
|
||||
)
|
||||
return {
|
||||
"id": str(leave.id),
|
||||
@@ -74,6 +75,7 @@ async def update_leave(
|
||||
data=data.model_dump(exclude_none=True),
|
||||
user_id=UUID(current_user["user_id"]),
|
||||
role=current_user["role"],
|
||||
operator_name=current_user["name"],
|
||||
)
|
||||
return {
|
||||
"id": str(leave.id),
|
||||
@@ -102,6 +104,7 @@ async def delete_leave(
|
||||
leave_id=leave_id,
|
||||
user_id=UUID(current_user["user_id"]),
|
||||
role=current_user["role"],
|
||||
operator_name=current_user["name"],
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="请假记录不存在")
|
||||
|
||||
@@ -2,7 +2,7 @@ import uuid
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, or_
|
||||
from app.database import get_db
|
||||
from app.middleware.auth import get_current_user, require_any_role
|
||||
from app.models.mini_business import MiniBusiness
|
||||
@@ -10,6 +10,7 @@ from app.models.customer import Customer
|
||||
from app.models.user import User
|
||||
from app.schemas.mini_business import MiniBusinessCreate, MiniBusinessUpdate, MiniBusinessOut
|
||||
from app.utils.edit_log import compute_diff, append_entry, init_entry
|
||||
from app.utils.audit import log_audit
|
||||
|
||||
router = APIRouter(prefix="/mini-business", tags=["MiniBusiness"])
|
||||
|
||||
@@ -35,6 +36,8 @@ async def _enrich(m: MiniBusiness, db: AsyncSession) -> dict:
|
||||
@router.get("/")
|
||||
async def list_mini_business(
|
||||
customer_id: Optional[str] = Query(None),
|
||||
customer_type: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -43,11 +46,32 @@ async def list_mini_business(
|
||||
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))
|
||||
if customer_type:
|
||||
query = query.join(Customer, MiniBusiness.customer_id == Customer.id).where(Customer.customer_type == customer_type)
|
||||
if search:
|
||||
query = query.outerjoin(Customer, MiniBusiness.customer_id == Customer.id).where(
|
||||
or_(MiniBusiness.product_type.ilike(f"%{search}%"), MiniBusiness.follow_up_detail.ilike(f"%{search}%"), Customer.name.ilike(f"%{search}%"))
|
||||
)
|
||||
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.get("/{item_id}")
|
||||
async def get_mini_business(
|
||||
item_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
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")
|
||||
return await _enrich(m, db)
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_mini_business(
|
||||
data: MiniBusinessCreate,
|
||||
@@ -60,13 +84,28 @@ async def create_mini_business(
|
||||
amount=data.amount,
|
||||
follow_up_detail=data.follow_up_detail,
|
||||
status=data.status,
|
||||
manager_id=uuid.UUID(current_user["user_id"]),
|
||||
manager_id=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else uuid.UUID(current_user["user_id"]),
|
||||
expected_revenue_date=data.expected_revenue_date,
|
||||
)
|
||||
init_entry(m, current_user["name"])
|
||||
db.add(m)
|
||||
await db.commit()
|
||||
await db.refresh(m)
|
||||
# Audit log
|
||||
cust = await db.execute(select(Customer.name).where(Customer.id == m.customer_id))
|
||||
await log_audit(db, "mini_business", m.id, f"{cust.scalar_one_or_none() or ''} ({m.product_type})", "create", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return await _enrich(m, db)
|
||||
|
||||
|
||||
@router.get("/{item_id}")
|
||||
async def get_mini_business(item_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user)):
|
||||
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")
|
||||
return await _enrich(m, db)
|
||||
|
||||
|
||||
@@ -85,14 +124,25 @@ async def update_mini_business(
|
||||
|
||||
old_snapshot = {"customer_id": str(m.customer_id), "product_type": m.product_type, "amount": m.amount, "follow_up_detail": m.follow_up_detail, "status": m.status, "expected_revenue_date": m.expected_revenue_date}
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if current_user["role"] not in ("director", "leader"):
|
||||
update_data.pop("manager_id", None)
|
||||
if "manager_id" in update_data and update_data["manager_id"]:
|
||||
update_data["manager_id"] = uuid.UUID(update_data["manager_id"])
|
||||
for k, v in update_data.items():
|
||||
setattr(m, k, v)
|
||||
# Auto-clear expected revenue date when status becomes 已流失
|
||||
if m.status == "已流失" and m.expected_revenue_date:
|
||||
m.expected_revenue_date = ""
|
||||
new_snapshot = {"customer_id": str(m.customer_id), "product_type": m.product_type, "amount": m.amount, "follow_up_detail": m.follow_up_detail, "status": m.status, "expected_revenue_date": m.expected_revenue_date}
|
||||
changes = compute_diff(old_snapshot, new_snapshot)
|
||||
if changes:
|
||||
append_entry(m, current_user["name"], changes, getattr(data, "edit_reason", None))
|
||||
await db.commit()
|
||||
await db.refresh(m)
|
||||
# Audit log
|
||||
cust = await db.execute(select(Customer.name).where(Customer.id == m.customer_id))
|
||||
await log_audit(db, "mini_business", m.id, f"{cust.scalar_one_or_none() or ''} ({m.product_type})", "update", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return await _enrich(m, db)
|
||||
|
||||
|
||||
@@ -108,6 +158,11 @@ async def delete_mini_business(
|
||||
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")
|
||||
cust = await db.execute(select(Customer.name).where(Customer.id == m.customer_id))
|
||||
entity_name = f"{cust.scalar_one_or_none() or ''} ({m.product_type})"
|
||||
await db.delete(m)
|
||||
await db.commit()
|
||||
# Audit log
|
||||
await log_audit(db, "mini_business", uuid.UUID(item_id), entity_name, "delete", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return {"detail": "deleted"}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""MiniBusinessLog CRUD API — follow-up log entries for mini business opportunities."""
|
||||
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
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.mini_business_log import MiniBusinessLog
|
||||
from app.models.user import User
|
||||
from app.schemas.mini_business_log import (
|
||||
MiniBusinessLogCreate, MiniBusinessLogUpdate, MiniBusinessLogOut,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/mini-business", tags=["MiniBusiness Logs"])
|
||||
|
||||
|
||||
async def _get_log(db: AsyncSession, log_id: str) -> MiniBusinessLog:
|
||||
result = await db.execute(select(MiniBusinessLog).where(MiniBusinessLog.id == log_id))
|
||||
log = result.scalar_one_or_none()
|
||||
if not log:
|
||||
raise HTTPException(status_code=404, detail="跟进记录不存在")
|
||||
return log
|
||||
|
||||
|
||||
@router.get("/{business_id}/logs")
|
||||
async def list_logs(
|
||||
business_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List follow-up logs for a mini business, newest first."""
|
||||
# Verify business exists and user has access
|
||||
mb = await db.get(MiniBusiness, business_id)
|
||||
if not mb:
|
||||
raise HTTPException(status_code=404, detail="商机不存在")
|
||||
if current_user["role"] == "manager" and str(mb.manager_id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
result = await db.execute(
|
||||
select(MiniBusinessLog)
|
||||
.where(MiniBusinessLog.business_id == business_id)
|
||||
.order_by(MiniBusinessLog.log_date.desc(), MiniBusinessLog.created_at.desc())
|
||||
)
|
||||
logs = result.scalars().all()
|
||||
|
||||
# Resolve creator names
|
||||
user_ids = {log.created_by for log in logs}
|
||||
users = (await db.execute(select(User).where(User.id.in_(user_ids)))).scalars().all()
|
||||
user_map = {u.id: u.name for u in users}
|
||||
|
||||
return [
|
||||
{
|
||||
"id": log.id,
|
||||
"business_id": log.business_id,
|
||||
"log_date": str(log.log_date),
|
||||
"method": log.method,
|
||||
"content": log.content,
|
||||
"created_by": log.created_by,
|
||||
"created_by_name": user_map.get(log.created_by, ""),
|
||||
"created_at": str(log.created_at) if log.created_at else None,
|
||||
}
|
||||
for log in logs
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{business_id}/logs")
|
||||
async def create_log(
|
||||
business_id: str,
|
||||
data: MiniBusinessLogCreate,
|
||||
current_user: dict = Depends(require_any_role),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add a follow-up log entry."""
|
||||
mb = await db.get(MiniBusiness, business_id)
|
||||
if not mb:
|
||||
raise HTTPException(status_code=404, detail="商机不存在")
|
||||
if current_user["role"] == "manager" and str(mb.manager_id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
log = MiniBusinessLog(
|
||||
business_id=uuid.UUID(business_id),
|
||||
log_date=data.log_date,
|
||||
method=data.method,
|
||||
content=data.content,
|
||||
created_by=uuid.UUID(current_user["user_id"]),
|
||||
)
|
||||
db.add(log)
|
||||
await db.commit()
|
||||
await db.refresh(log)
|
||||
|
||||
creator = await db.get(User, log.created_by)
|
||||
return {
|
||||
"id": log.id,
|
||||
"business_id": log.business_id,
|
||||
"log_date": str(log.log_date),
|
||||
"method": log.method,
|
||||
"content": log.content,
|
||||
"created_by": log.created_by,
|
||||
"created_by_name": creator.name if creator else "",
|
||||
"created_at": str(log.created_at) if log.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/logs/{log_id}")
|
||||
async def update_log(
|
||||
log_id: str,
|
||||
data: MiniBusinessLogUpdate,
|
||||
current_user: dict = Depends(require_any_role),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a follow-up log entry."""
|
||||
log = await _get_log(db, log_id)
|
||||
|
||||
# Managers can only edit their own logs; directors/leaders can edit any
|
||||
if current_user["role"] == "manager" and str(log.created_by) != 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(log, k, v)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(log)
|
||||
return {"id": log.id, "log_date": str(log.log_date), "method": log.method, "content": log.content}
|
||||
|
||||
|
||||
@router.delete("/logs/{log_id}")
|
||||
async def delete_log(
|
||||
log_id: str,
|
||||
current_user: dict = Depends(require_any_role),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a follow-up log entry."""
|
||||
log = await _get_log(db, log_id)
|
||||
|
||||
if current_user["role"] == "manager" and str(log.created_by) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
await db.delete(log)
|
||||
await db.commit()
|
||||
return {"status": "deleted"}
|
||||
@@ -46,7 +46,10 @@ async def get_notification_time(db: AsyncSession) -> str:
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_config(db: AsyncSession = Depends(get_db)):
|
||||
async def list_config(
|
||||
current_user: dict = Depends(require_director),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return all system config as {key: value} dict."""
|
||||
result = await db.execute(select(SystemConfig))
|
||||
rows = result.scalars().all()
|
||||
|
||||
+82
-18
@@ -1,36 +1,100 @@
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends
|
||||
import io
|
||||
import warnings
|
||||
from datetime import date
|
||||
from pathlib import PurePosixPath
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
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
|
||||
from app.services.minio_client import generate_presigned_download_url, put_validated_image
|
||||
from app.models.visit import Visit
|
||||
|
||||
router = APIRouter(prefix="/upload", tags=["Upload"])
|
||||
|
||||
_IMAGE_TYPES = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
}
|
||||
MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
MAX_IMAGE_PIXELS = 25_000_000
|
||||
|
||||
@router.post("/presigned-url")
|
||||
async def get_presigned_upload_url(
|
||||
filename: str,
|
||||
content_type: str = "image/jpeg",
|
||||
|
||||
def validate_image_upload(filename: str, content_type: str) -> str:
|
||||
"""Validate image metadata before accepting bytes for server-side verification."""
|
||||
if content_type.lower() not in _IMAGE_TYPES:
|
||||
raise HTTPException(status_code=400, detail="Only JPEG, PNG, and WebP images are supported")
|
||||
if PurePosixPath(filename).suffix.lower() not in {".jpg", ".jpeg", ".png", ".webp"}:
|
||||
raise HTTPException(status_code=400, detail="Invalid image filename extension")
|
||||
return _IMAGE_TYPES[content_type.lower()]
|
||||
|
||||
|
||||
def build_object_key(user_id: str, extension: str) -> str:
|
||||
return f"uploads/{user_id}/{date.today().isoformat()}/{uuid.uuid4().hex}.{extension}"
|
||||
|
||||
|
||||
def is_owned_upload_key(object_key: str, user_id: str) -> bool:
|
||||
path = PurePosixPath(object_key)
|
||||
parts = path.parts
|
||||
return (
|
||||
len(parts) == 4
|
||||
and parts[0] == "uploads"
|
||||
and parts[1] == user_id
|
||||
and ".." not in parts
|
||||
and path.suffix.lower() in {".jpg", ".png", ".webp"}
|
||||
)
|
||||
|
||||
|
||||
def normalize_image(image_bytes: bytes) -> bytes:
|
||||
"""Verify image bytes, enforce a pixel limit, and strip active/metadata content by re-encoding."""
|
||||
if not image_bytes or len(image_bytes) > MAX_IMAGE_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Image must be between 1 byte and 10 MB")
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", Image.DecompressionBombWarning)
|
||||
with Image.open(io.BytesIO(image_bytes)) as image:
|
||||
image.verify()
|
||||
with Image.open(io.BytesIO(image_bytes)) as image:
|
||||
if image.width * image.height > MAX_IMAGE_PIXELS:
|
||||
raise HTTPException(status_code=413, detail="Image dimensions are too large")
|
||||
normalized = image.convert("RGB")
|
||||
output = io.BytesIO()
|
||||
normalized.save(output, format="JPEG", quality=85, optimize=True)
|
||||
return output.getvalue()
|
||||
except HTTPException:
|
||||
raise
|
||||
except (Image.DecompressionBombError, Image.DecompressionBombWarning, OSError, UnidentifiedImageError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid image content") from exc
|
||||
|
||||
|
||||
@router.post("/image")
|
||||
async def upload_image(
|
||||
file: UploadFile = File(...),
|
||||
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,
|
||||
}
|
||||
"""Validate and normalize an image before persisting it to private object storage."""
|
||||
validate_image_upload(file.filename or "upload.jpg", file.content_type or "")
|
||||
normalized = normalize_image(await file.read(MAX_IMAGE_BYTES + 1))
|
||||
object_key = build_object_key(current_user["user_id"], "jpg")
|
||||
put_validated_image(object_key, normalized)
|
||||
return {"object_key": object_key}
|
||||
|
||||
|
||||
@router.get("/download-url")
|
||||
async def get_presigned_download_url(
|
||||
object_key: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a presigned GET URL for viewing a photo (1 hour validity)."""
|
||||
result = await db.execute(select(Visit).where(Visit.photos.any(object_key)).limit(1))
|
||||
visit = result.scalar_one_or_none()
|
||||
if not visit:
|
||||
raise HTTPException(status_code=404, detail="Photo not found")
|
||||
if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
url = generate_presigned_download_url(object_key)
|
||||
return {"download_url": url}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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.utils.audit import log_audit
|
||||
from app.schemas.customer import UserOut
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["Users"])
|
||||
@@ -78,6 +79,9 @@ async def update_user_role(
|
||||
user.color = data.color
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
# Audit log
|
||||
await log_audit(db, "user", uuid.UUID(user_id), user.name, "update", current_user["user_id"], current_user["name"], f"角色更新为 {data.role}")
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"id": str(user.id),
|
||||
@@ -143,6 +147,32 @@ async def delete_user(
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
user_name = user.name
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
return {"detail": "deleted", "name": user.name}
|
||||
# Audit log
|
||||
await log_audit(db, "user", uuid.UUID(user_id), user_name, "delete", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return {"detail": "deleted", "name": user_name}
|
||||
|
||||
|
||||
class UpdateThemeRequest(BaseModel):
|
||||
theme: str # editorial / light
|
||||
|
||||
|
||||
@router.put("/me/theme")
|
||||
async def update_my_theme(
|
||||
data: UpdateThemeRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update the current user's theme preference."""
|
||||
if data.theme not in ("editorial", "light"):
|
||||
raise HTTPException(status_code=400, detail="Invalid theme")
|
||||
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")
|
||||
user.theme = data.theme
|
||||
await db.commit()
|
||||
return {"theme": user.theme}
|
||||
|
||||
+117
-19
@@ -3,7 +3,7 @@ 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 sqlalchemy import select, func, or_
|
||||
from app.database import get_db
|
||||
from app.middleware.auth import get_current_user, require_any_role
|
||||
from app.models.visit import Visit
|
||||
@@ -12,11 +12,22 @@ from app.models.user import User
|
||||
from app.schemas.visit import VisitCreate, VisitUpdate, VisitOut, VisitListOut
|
||||
from app.utils.timezone import today_cst, parse_date
|
||||
from app.services.minio_client import delete_objects
|
||||
from app.api.upload import is_owned_upload_key
|
||||
from app.utils.edit_log import compute_diff, append_entry, init_entry
|
||||
from app.utils.audit import log_audit
|
||||
|
||||
router = APIRouter(prefix="/visits", tags=["Visits"])
|
||||
|
||||
|
||||
def _validate_photo_keys(photo_keys: list[str], user_id: str, existing_keys: list[str] | None = None) -> None:
|
||||
if len(photo_keys) > 9:
|
||||
raise HTTPException(status_code=400, detail="A visit may contain at most 9 photos")
|
||||
existing = set(existing_keys or [])
|
||||
for key in photo_keys:
|
||||
if key not in existing and not is_owned_upload_key(key, user_id):
|
||||
raise HTTPException(status_code=400, detail="Invalid or unauthorized photo reference")
|
||||
|
||||
|
||||
async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict:
|
||||
"""Enrich a visit record with customer/manager names."""
|
||||
customer_name = None
|
||||
@@ -51,6 +62,7 @@ async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict:
|
||||
"photos": visit.photos,
|
||||
"manager_id": str(visit.manager_id),
|
||||
"manager_name": manager_name,
|
||||
"visit_group_id": str(visit.visit_group_id) if visit.visit_group_id else None,
|
||||
"edit_log": visit.edit_log or [],
|
||||
"created_at": str(visit.created_at),
|
||||
"updated_at": str(visit.updated_at),
|
||||
@@ -62,6 +74,8 @@ async def list_visits(
|
||||
date_from: Optional[str] = Query(None),
|
||||
date_to: Optional[str] = Query(None),
|
||||
customer_id: Optional[str] = Query(None),
|
||||
customer_type: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -77,6 +91,12 @@ async def list_visits(
|
||||
query = query.where(Visit.visit_date <= parse_date(date_to))
|
||||
if customer_id:
|
||||
query = query.where(Visit.customer_id == uuid.UUID(customer_id))
|
||||
if customer_type:
|
||||
query = query.join(Customer, Visit.customer_id == Customer.id).where(Customer.customer_type == customer_type)
|
||||
if search:
|
||||
query = query.outerjoin(Customer, Visit.customer_id == Customer.id).where(
|
||||
or_(Visit.communication_content.ilike(f"%{search}%"), Visit.customer_demand.ilike(f"%{search}%"), Visit.visitor_name.ilike(f"%{search}%"), Customer.name.ilike(f"%{search}%"))
|
||||
)
|
||||
|
||||
query = query.order_by(Visit.visit_date.desc(), Visit.created_at.desc()).limit(200)
|
||||
result = await db.execute(query)
|
||||
@@ -136,7 +156,9 @@ async def create_visit(
|
||||
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."""
|
||||
"""Create a visit record. If companions are selected, creates full copies for them."""
|
||||
_validate_photo_keys(data.photos, current_user["user_id"])
|
||||
group_id = uuid.uuid4()
|
||||
visit = Visit(
|
||||
customer_id=data.customer_id,
|
||||
visit_date=parse_date(data.visit_date),
|
||||
@@ -147,7 +169,8 @@ async def create_visit(
|
||||
companions=data.companions,
|
||||
companion_names=data.companion_names,
|
||||
photos=data.photos,
|
||||
manager_id=uuid.UUID(current_user["user_id"]),
|
||||
manager_id=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else uuid.UUID(current_user["user_id"]),
|
||||
visit_group_id=group_id,
|
||||
)
|
||||
init_entry(visit, current_user["name"])
|
||||
db.add(visit)
|
||||
@@ -161,34 +184,70 @@ async def create_visit(
|
||||
db.add(customer)
|
||||
|
||||
# Auto-complete matching work plans for this customer
|
||||
from app.models.work_plan import WorkPlan
|
||||
plans_result = await db.execute(
|
||||
select(WorkPlan).where(
|
||||
WorkPlan.customer_id == data.customer_id,
|
||||
WorkPlan.status == "计划中",
|
||||
WorkPlan.plan_date <= parse_date(data.visit_date),
|
||||
from app.services.visits import auto_complete_work_plans
|
||||
await auto_complete_work_plans(
|
||||
db, data.customer_id, parse_date(data.visit_date),
|
||||
current_user["name"], "拜访自动完成",
|
||||
)
|
||||
|
||||
# Auto-create follow-up logs for active mini business opportunities
|
||||
from app.models.mini_business import MiniBusiness
|
||||
from app.models.mini_business_log import MiniBusinessLog
|
||||
method_map = {"上门": "上门", "电话": "电话", "微信": "微信", "出差": "其他"}
|
||||
active_biz = await db.execute(
|
||||
select(MiniBusiness).where(
|
||||
MiniBusiness.customer_id == data.customer_id,
|
||||
MiniBusiness.status == "跟进中",
|
||||
)
|
||||
)
|
||||
for plan in plans_result.scalars().all():
|
||||
plan.status = "已完成"
|
||||
append_entry(plan, current_user["name"], [{"field": "status", "from": "计划中", "to": "已完成", "reason": "拜访自动完成"}])
|
||||
|
||||
# Create draft copies for companions
|
||||
# Create full copies for companions (not blank drafts)
|
||||
creator_name = current_user["name"]
|
||||
for companion_id in data.companions:
|
||||
if companion_id != uuid.UUID(current_user["user_id"]):
|
||||
# Build companion's companions list: the creator + other companions excluding self
|
||||
comp_companions = [uuid.UUID(current_user["user_id"])]
|
||||
for cid in data.companions:
|
||||
if cid != companion_id and cid not in comp_companions:
|
||||
comp_companions.append(cid)
|
||||
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=[],
|
||||
visitor_name=data.visitor_name,
|
||||
visitor_phone=data.visitor_phone,
|
||||
communication_content=(data.communication_content or "") + f"(协同{creator_name})",
|
||||
customer_demand=data.customer_demand,
|
||||
companions=comp_companions,
|
||||
companion_names=[],
|
||||
photos=data.photos,
|
||||
manager_id=companion_id,
|
||||
visit_group_id=group_id,
|
||||
)
|
||||
init_entry(draft, current_user["name"])
|
||||
db.add(draft)
|
||||
|
||||
# Auto-create follow-up logs for active mini business opportunities
|
||||
for biz in active_biz.scalars().all():
|
||||
parts = []
|
||||
if data.communication_content and data.communication_content.strip():
|
||||
parts.append(f"沟通内容:{data.communication_content.strip()}")
|
||||
if data.customer_demand and data.customer_demand.strip():
|
||||
parts.append(f"客户需求:{data.customer_demand.strip()}")
|
||||
log_content = "\n".join(parts) if parts else "拜访跟进"
|
||||
follow_method = method_map.get(data.visit_method, "其他")
|
||||
log = MiniBusinessLog(
|
||||
business_id=biz.id,
|
||||
log_date=parse_date(data.visit_date),
|
||||
method=follow_method,
|
||||
content=log_content,
|
||||
created_by=uuid.UUID(current_user["user_id"]),
|
||||
)
|
||||
db.add(log)
|
||||
|
||||
# Audit log (before commit — part of same transaction)
|
||||
cust = await db.execute(select(Customer.name).where(Customer.id == visit.customer_id))
|
||||
await log_audit(db, "visit", visit.id, f"{cust.scalar_one_or_none() or ''} ({data.visit_date})", "create", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
await db.refresh(visit)
|
||||
return await _enrich_visit(visit, db)
|
||||
@@ -209,17 +268,25 @@ async def update_visit(
|
||||
if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
if data.photos is not None:
|
||||
_validate_photo_keys(data.photos, current_user["user_id"], visit.photos or [])
|
||||
|
||||
# Snapshot old values for diff
|
||||
old_snapshot = {
|
||||
"customer_id": str(visit.customer_id), "visit_date": str(visit.visit_date),
|
||||
"visit_method": visit.visit_method, "time_range": visit.time_range,
|
||||
"visitor_name": visit.visitor_name or "", "visitor_phone": visit.visitor_phone or "",
|
||||
"communication_content": visit.communication_content, "customer_demand": visit.customer_demand,
|
||||
"manager_id": str(visit.manager_id) if visit.manager_id else "",
|
||||
}
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if current_user["role"] not in ("director", "leader"):
|
||||
update_data.pop("manager_id", None)
|
||||
if "visit_date" in update_data and update_data["visit_date"]:
|
||||
update_data["visit_date"] = parse_date(update_data["visit_date"])
|
||||
if "manager_id" in update_data and update_data["manager_id"]:
|
||||
update_data["manager_id"] = uuid.UUID(update_data["manager_id"])
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(visit, key, value)
|
||||
@@ -230,13 +297,39 @@ async def update_visit(
|
||||
"visit_method": visit.visit_method, "time_range": visit.time_range,
|
||||
"visitor_name": visit.visitor_name or "", "visitor_phone": visit.visitor_phone or "",
|
||||
"communication_content": visit.communication_content, "customer_demand": visit.customer_demand,
|
||||
"manager_id": str(visit.manager_id) if visit.manager_id else "",
|
||||
}
|
||||
changes = compute_diff(old_snapshot, new_snapshot)
|
||||
if changes:
|
||||
append_entry(visit, current_user["name"], changes, getattr(data, "edit_reason", None))
|
||||
|
||||
# Auto-complete matching work plans when visit date or customer changes
|
||||
from app.models.work_plan import WorkPlan
|
||||
plans_result = await db.execute(
|
||||
select(WorkPlan).where(
|
||||
WorkPlan.customer_id == visit.customer_id,
|
||||
WorkPlan.status == "计划中",
|
||||
WorkPlan.plan_date <= visit.visit_date,
|
||||
)
|
||||
)
|
||||
for plan in plans_result.scalars().all():
|
||||
plan.status = "已完成"
|
||||
append_entry(plan, current_user["name"], [{"field": "status", "from": "计划中", "to": "已完成", "reason": "拜访更新自动完成"}])
|
||||
|
||||
# Audit log (before commit — part of same transaction)
|
||||
cust = await db.execute(select(Customer.name).where(Customer.id == visit.customer_id))
|
||||
await log_audit(db, "visit", visit.id, f"{cust.scalar_one_or_none() or ''} ({visit.visit_date})", "update", current_user["user_id"], current_user["name"], ", ".join(changes.keys()) if changes else "")
|
||||
await db.commit()
|
||||
await db.refresh(visit)
|
||||
|
||||
# Auto-complete matching work plans after visit update
|
||||
from app.services.visits import auto_complete_work_plans
|
||||
await auto_complete_work_plans(
|
||||
db, visit.customer_id, visit.visit_date,
|
||||
current_user["name"], "拜访更新自动完成",
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return await _enrich_visit(visit, db)
|
||||
|
||||
|
||||
@@ -261,6 +354,10 @@ async def delete_visit(
|
||||
if visit.photos:
|
||||
delete_objects(visit.photos)
|
||||
|
||||
# Snapshot entity name before deletion
|
||||
cust_name_result = await db.execute(select(Customer.name).where(Customer.id == customer_id))
|
||||
entity_name = f"{cust_name_result.scalar_one_or_none() or ''} ({visit.visit_date})"
|
||||
|
||||
await db.delete(visit)
|
||||
|
||||
# Recalculate customer's last_visit_date from remaining visits
|
||||
@@ -274,7 +371,6 @@ async def delete_visit(
|
||||
if cust:
|
||||
if new_latest:
|
||||
cust.last_visit_date = new_latest
|
||||
# Keep the existing manager if date unchanged, or find who made the latest visit
|
||||
latest_visit = await db.execute(
|
||||
select(Visit).where(
|
||||
Visit.customer_id == customer_id,
|
||||
@@ -288,5 +384,7 @@ async def delete_visit(
|
||||
cust.last_visit_date = None
|
||||
cust.last_visit_manager_id = None
|
||||
|
||||
# Audit log (before final commit — part of same transaction)
|
||||
await log_audit(db, "visit", uuid.UUID(visit_id), entity_name, "delete", current_user["user_id"], current_user["name"])
|
||||
await db.commit()
|
||||
return {"detail": "deleted"}
|
||||
|
||||
+118
-29
@@ -1,16 +1,19 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import struct
|
||||
import base64
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Optional
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from defusedxml import ElementTree as ET
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from fastapi.responses import PlainTextResponse, HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.database import async_session, 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, store_bind_token, consume_bind_token
|
||||
@@ -37,17 +40,20 @@ class BindConfirmRequest(BaseModel):
|
||||
def _verify_signature(token: str, timestamp: str, nonce: str, encrypted: str, signature: str) -> bool:
|
||||
items = sorted([token, timestamp, nonce, encrypted])
|
||||
raw = "".join(items)
|
||||
return hashlib.sha1(raw.encode()).hexdigest() == signature
|
||||
# The WeCom callback protocol mandates SHA-1; compare_digest avoids timing leaks.
|
||||
expected = hashlib.sha1(raw.encode(), usedforsecurity=False).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
def _decrypt_msg(encrypted: str) -> str:
|
||||
"""Decrypt WeChat Work callback message. Returns plaintext XML."""
|
||||
key = base64.b64decode(settings.WECOM_ENCODING_AES_KEY + "=")
|
||||
ciphertext = base64.b64decode(encrypted)
|
||||
from Crypto.Cipher import AES
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv=key[:16])
|
||||
plaintext = cipher.decrypt(ciphertext)
|
||||
decryptor = Cipher(algorithms.AES(key), modes.CBC(key[:16])).decryptor()
|
||||
plaintext = decryptor.update(ciphertext) + decryptor.finalize()
|
||||
pad_len = plaintext[-1]
|
||||
if pad_len < 1 or pad_len > 32 or plaintext[-pad_len:] != bytes([pad_len]) * pad_len:
|
||||
raise ValueError("Invalid callback padding")
|
||||
plaintext = plaintext[:-pad_len]
|
||||
msg_len = struct.unpack(">I", plaintext[16:20])[0]
|
||||
return plaintext[20:20 + msg_len].decode("utf-8")
|
||||
@@ -111,26 +117,25 @@ async def wecom_callback_event(request: Request):
|
||||
|
||||
import logging
|
||||
_log = logging.getLogger("wecom_callback")
|
||||
_log.warning(f"wecom msg: type={msg_type} from={from_user} content={repr(content)} event={event} key={event_key}")
|
||||
_log.info("wecom callback received: type=%s event=%s key=%s", msg_type, event, event_key)
|
||||
|
||||
# Triggers for binding
|
||||
if (msg_type == "text" and content == "绑定") or (msg_type == "event" and event == "click" and event_key == "BIND_ACCOUNT"):
|
||||
_log.warning(f"wecom bind triggered for {from_user}")
|
||||
_handle_bind_request(from_user)
|
||||
_log.info("wecom bind triggered")
|
||||
await _handle_bind_request(from_user)
|
||||
return PlainTextResponse(content="success")
|
||||
|
||||
_log.warning(f"wecom msg ignored (no match)")
|
||||
_log.info("wecom message ignored (no matching action)")
|
||||
return PlainTextResponse(content="success")
|
||||
|
||||
|
||||
def _handle_bind_request(wecom_userid: str):
|
||||
async def _handle_bind_request(wecom_userid: str):
|
||||
"""Generate bind token, store it, and push a bind link to the user."""
|
||||
if not wecom_userid:
|
||||
return
|
||||
|
||||
import asyncio
|
||||
|
||||
bind_token = store_bind_token(wecom_userid)
|
||||
async with async_session() as db:
|
||||
bind_token = await store_bind_token(db, wecom_userid)
|
||||
|
||||
content = (
|
||||
f"【账号绑定】\n\n"
|
||||
@@ -139,16 +144,7 @@ def _handle_bind_request(wecom_userid: str):
|
||||
f"绑定后可使用企微一键登录,并接收填报提醒通知。"
|
||||
)
|
||||
|
||||
try:
|
||||
# Must run async in sync context
|
||||
loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
loop.run_until_complete(
|
||||
wecom_client.send_text_message([wecom_userid], content)
|
||||
)
|
||||
await wecom_client.send_text_message([wecom_userid], content)
|
||||
|
||||
|
||||
# ── Bind confirmation (JWT-protected) ──
|
||||
@@ -163,7 +159,7 @@ async def bind_confirm(
|
||||
if not data.token:
|
||||
raise HTTPException(status_code=400, detail="token 不能为空")
|
||||
|
||||
wecom_userid = consume_bind_token(data.token)
|
||||
wecom_userid = await consume_bind_token(db, data.token)
|
||||
if not wecom_userid:
|
||||
raise HTTPException(status_code=400, detail="绑定链接已过期或无效,请重新在企微发送「绑定」")
|
||||
|
||||
@@ -181,7 +177,11 @@ async def bind_confirm(
|
||||
)
|
||||
user = result.scalar_one()
|
||||
user.wecom_userid = wecom_userid
|
||||
await db.commit()
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=409, detail="该企业微信已绑定其他账号") from exc
|
||||
|
||||
return {"code": 200, "msg": "绑定成功", "wecom_userid": wecom_userid}
|
||||
|
||||
@@ -248,12 +248,13 @@ async def test_message(
|
||||
@router.post("/setup-menu")
|
||||
async def setup_menu(current_user: dict = Depends(require_director)):
|
||||
"""Create the standard app menu (开始填报 view + 绑定账号 click)."""
|
||||
oauth_url = _build_oauth_url()
|
||||
buttons = [
|
||||
{"type": "view", "name": "开始填报", "url": "https://qj.dhdx.fun/m"},
|
||||
{"type": "view", "name": "开始填报", "url": oauth_url},
|
||||
{"type": "click", "name": "绑定账号", "key": "BIND_ACCOUNT"},
|
||||
]
|
||||
success = await wecom_client.create_menu(buttons)
|
||||
return {"success": success, "message": "菜单已部署 (view + click)" if success else "菜单创建失败"}
|
||||
return {"success": success, "message": "菜单已部署 (含静默登录)" if success else "菜单创建失败"}
|
||||
|
||||
|
||||
@router.get("/menu")
|
||||
@@ -271,3 +272,91 @@ async def trigger_daily_check(
|
||||
"""Manually trigger the daily reporting check (for testing or manual use)."""
|
||||
result = await check_daily_reporting(db)
|
||||
return result
|
||||
|
||||
|
||||
# ── OAuth silent login ──
|
||||
|
||||
import urllib.parse
|
||||
from app.services.auth import build_token_for_user
|
||||
|
||||
|
||||
def _build_oauth_url(redirect_path: str = "/m") -> str:
|
||||
"""Build the WeChat Work OAuth2 authorize URL (snsapi_base = silent).
|
||||
The redirect target is encoded in the 'state' param to avoid query-string issues
|
||||
with the OAuth redirect_uri validation."""
|
||||
if not settings.WECOM_CORP_ID:
|
||||
return f"https://qj.dhdx.fun{redirect_path}"
|
||||
params = urllib.parse.urlencode({
|
||||
"appid": settings.WECOM_CORP_ID,
|
||||
"redirect_uri": "https://qj.dhdx.fun/api/wecom/oauth-callback",
|
||||
"response_type": "code",
|
||||
"scope": "snsapi_base",
|
||||
"agentid": settings.WECOM_AGENT_ID,
|
||||
"state": f"r={urllib.parse.quote(redirect_path)}",
|
||||
})
|
||||
return f"https://open.weixin.qq.com/connect/oauth2/authorize?{params}#wechat_redirect"
|
||||
|
||||
|
||||
@router.get("/oauth-url")
|
||||
async def get_oauth_url(redirect: str = Query("/m")):
|
||||
"""Redirect to the WeChat Work OAuth authorize URL (silent login)."""
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url=_build_oauth_url(redirect))
|
||||
|
||||
|
||||
@router.get("/oauth-callback")
|
||||
async def oauth_callback(
|
||||
code: str = Query(...),
|
||||
state: str = Query(""),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""WeChat Work OAuth2 callback — exchange code for userid, find bound user, auto-login."""
|
||||
# Parse redirect target from state param: "r=%2Fm"
|
||||
redirect = "/m"
|
||||
if state.startswith("r="):
|
||||
redirect = urllib.parse.unquote(state[2:])
|
||||
|
||||
# Exchange code for wecom userinfo
|
||||
userinfo = await wecom_client.get_userinfo_by_code(code)
|
||||
if not userinfo:
|
||||
raise HTTPException(status_code=400, detail="Failed to exchange code with WeChat Work")
|
||||
|
||||
wecom_userid = userinfo.get("UserId") or userinfo.get("userid")
|
||||
if not wecom_userid:
|
||||
raise HTTPException(status_code=400, detail="Could not get userid from WeChat Work")
|
||||
|
||||
# Find bound user (case-insensitive — WeChat Work userids may vary in case)
|
||||
from sqlalchemy import func as sa_func
|
||||
result = await db.execute(
|
||||
select(User).where(sa_func.lower(User.wecom_userid) == wecom_userid.lower())
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
# Not bound — generate a one-time bind token and redirect to bind page
|
||||
bind_token = await store_bind_token(db, wecom_userid)
|
||||
bind_url = f"https://qj.dhdx.fun/wecom-bind?token={bind_token}&wecom_userid={wecom_userid}"
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url=bind_url)
|
||||
|
||||
# Issue JWT and auto-login via HTML page — saves credentials to localStorage, then redirects
|
||||
token = build_token_for_user(user)
|
||||
import json as _json
|
||||
auth_data = _json.dumps({
|
||||
"token": token,
|
||||
"userId": str(user.id),
|
||||
"userName": user.name,
|
||||
"userRole": user.role,
|
||||
"theme": user.theme or "editorial",
|
||||
}, ensure_ascii=False)
|
||||
frontend_url = f"https://qj.dhdx.fun{redirect}"
|
||||
html = f"""<!DOCTYPE html><html><head><meta charset="utf-8"><title>登录中...</title></head>
|
||||
<body><script>
|
||||
var d = {auth_data};
|
||||
localStorage.setItem('token', d.token);
|
||||
localStorage.setItem('userId', d.userId);
|
||||
localStorage.setItem('userName', d.userName);
|
||||
localStorage.setItem('userRole', d.userRole);
|
||||
localStorage.setItem('theme', d.theme);
|
||||
window.location.replace({_json.dumps(frontend_url)});
|
||||
</script></body></html>"""
|
||||
return HTMLResponse(content=html, status_code=200)
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 sqlalchemy import select, or_
|
||||
from app.database import get_db
|
||||
from app.middleware.auth import get_current_user, require_any_role
|
||||
from app.utils.timezone import parse_date
|
||||
@@ -35,6 +35,8 @@ async def _enrich(wp: WorkPlan, db: AsyncSession) -> dict:
|
||||
@router.get("/")
|
||||
async def list_work_plans(
|
||||
customer_id: Optional[str] = Query(None),
|
||||
customer_type: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -43,11 +45,32 @@ async def list_work_plans(
|
||||
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))
|
||||
if customer_type:
|
||||
query = query.join(Customer, WorkPlan.customer_id == Customer.id).where(Customer.customer_type == customer_type)
|
||||
if search:
|
||||
query = query.outerjoin(Customer, WorkPlan.customer_id == Customer.id).where(
|
||||
or_(WorkPlan.plan_content.ilike(f"%{search}%"), Customer.name.ilike(f"%{search}%"))
|
||||
)
|
||||
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.get("/{item_id}")
|
||||
async def get_work_plan(
|
||||
item_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(WorkPlan).where(WorkPlan.id == item_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")
|
||||
return await _enrich(wp, db)
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_work_plan(
|
||||
data: WorkPlanCreate,
|
||||
@@ -58,7 +81,7 @@ async def create_work_plan(
|
||||
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"]),
|
||||
manager_id=data.manager_id if (current_user["role"] in ("director", "leader") and data.manager_id) else uuid.UUID(current_user["user_id"]),
|
||||
status=data.status,
|
||||
)
|
||||
init_entry(wp, current_user["name"])
|
||||
@@ -83,6 +106,8 @@ async def update_work_plan(
|
||||
|
||||
old_snapshot = {"customer_id": str(wp.customer_id), "plan_content": wp.plan_content, "plan_date": str(wp.plan_date), "status": wp.status}
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if current_user["role"] not in ("director", "leader"):
|
||||
update_data.pop("manager_id", None)
|
||||
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():
|
||||
|
||||
+32
-13
@@ -1,15 +1,18 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
# App
|
||||
APP_NAME: str = "企迹-政企周报管理系统"
|
||||
DEBUG: bool = True
|
||||
SECRET_KEY: str = "change-me-in-production"
|
||||
ENVIRONMENT: str = "production"
|
||||
DEBUG: bool = False
|
||||
SECRET_KEY: str = ""
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/qiji"
|
||||
# A syntactically valid non-working placeholder keeps tooling imports side-effect free.
|
||||
DATABASE_URL: str = "postgresql+asyncpg://qiji_app:invalid@localhost:5432/qiji"
|
||||
|
||||
# JWT
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
@@ -24,11 +27,11 @@ class Settings(BaseSettings):
|
||||
CASDOOR_APPLICATION: str = "qiji-weekly-report"
|
||||
|
||||
# MinIO
|
||||
MINIO_ENDPOINT: str = "localhost:9000"
|
||||
MINIO_ACCESS_KEY: str = "minioadmin"
|
||||
MINIO_SECRET_KEY: str = "minioadmin"
|
||||
MINIO_ENDPOINT: str = ""
|
||||
MINIO_ACCESS_KEY: str = ""
|
||||
MINIO_SECRET_KEY: str = ""
|
||||
MINIO_BUCKET: str = "qiji-photos"
|
||||
MINIO_SECURE: bool = False
|
||||
MINIO_SECURE: bool = True
|
||||
|
||||
# WeChat Work
|
||||
WECOM_CORP_ID: str = ""
|
||||
@@ -47,9 +50,25 @@ class Settings(BaseSettings):
|
||||
# CORS
|
||||
CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
def validate_security_settings() -> None:
|
||||
"""Reject unsafe or incomplete configuration before the application starts."""
|
||||
if settings.ENVIRONMENT.lower() == "development":
|
||||
return
|
||||
|
||||
invalid = []
|
||||
if len(settings.SECRET_KEY) < 32 or settings.SECRET_KEY in {"change-me-in-production", ""}:
|
||||
invalid.append("SECRET_KEY")
|
||||
if not settings.DATABASE_URL or "postgres:postgres@" in settings.DATABASE_URL or ":invalid@" in settings.DATABASE_URL:
|
||||
invalid.append("DATABASE_URL")
|
||||
if not settings.MINIO_ENDPOINT or not settings.MINIO_ACCESS_KEY or not settings.MINIO_SECRET_KEY:
|
||||
invalid.append("MINIO configuration")
|
||||
if settings.MINIO_ACCESS_KEY == "minioadmin" or settings.MINIO_SECRET_KEY == "minioadmin":
|
||||
invalid.append("MINIO default credentials")
|
||||
if not settings.MINIO_SECURE:
|
||||
invalid.append("MINIO_SECURE")
|
||||
|
||||
if invalid:
|
||||
raise RuntimeError(f"Unsafe production configuration: {', '.join(invalid)}")
|
||||
|
||||
+7
-65
@@ -2,11 +2,11 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import select
|
||||
from app.config import settings
|
||||
from app.database import engine, Base, async_session
|
||||
from app.config import settings, validate_security_settings
|
||||
from app.database import engine, async_session
|
||||
from app.api import router as api_router
|
||||
from app.api import auth, users, customers, visits, work_plans, mini_business, key_visits
|
||||
from app.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary, system_config, leaves
|
||||
from app.api import auth, users, customers, visits, work_plans, mini_business, mini_business_logs, key_visits
|
||||
from app.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary, system_config, leaves, audit
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.services.holidays import refresh_holidays
|
||||
from app.services.scheduler_manager import start_scheduler, shutdown_scheduler
|
||||
@@ -14,67 +14,7 @@ from app.services.scheduler_manager import start_scheduler, shutdown_scheduler
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup: create tables if not exists (for dev convenience)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# Add columns that may be missing from older tables
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS remarks TEXT DEFAULT ''"
|
||||
))
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_name VARCHAR(50) DEFAULT ''"
|
||||
))
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_phone VARCHAR(20) DEFAULT ''"
|
||||
))
|
||||
# edit_log columns for change tracking
|
||||
for tbl in ["visits", "daily_notes", "work_plans", "mini_business", "key_visits"]:
|
||||
await conn.run_sync(lambda c, t=tbl: c.exec_driver_sql(
|
||||
f"ALTER TABLE {t} ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'"
|
||||
))
|
||||
# New columns for v0.3
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS require_report BOOLEAN DEFAULT TRUE"
|
||||
))
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_date DATE"
|
||||
))
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_manager_id UUID"
|
||||
))
|
||||
# New columns for v0.4
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS companion_names TEXT[] DEFAULT '{}'"
|
||||
))
|
||||
# v0.6 — manager color tag
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS color VARCHAR(7)"
|
||||
))
|
||||
# system_config table for v0.5
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"CREATE TABLE IF NOT EXISTS system_config (key VARCHAR(64) PRIMARY KEY, value TEXT DEFAULT '')"
|
||||
))
|
||||
# leaves table for v0.7
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"CREATE TABLE IF NOT EXISTS leaves ("
|
||||
" id UUID PRIMARY KEY DEFAULT gen_random_uuid(),"
|
||||
" manager_id UUID NOT NULL REFERENCES users(id),"
|
||||
" leave_type VARCHAR(20) NOT NULL DEFAULT '事假',"
|
||||
" start_date DATE NOT NULL,"
|
||||
" end_date DATE NOT NULL,"
|
||||
" reason VARCHAR(500) DEFAULT '',"
|
||||
" submitted_by UUID NOT NULL REFERENCES users(id),"
|
||||
" created_at TIMESTAMPTZ DEFAULT now(),"
|
||||
" updated_at TIMESTAMPTZ DEFAULT now()"
|
||||
")"
|
||||
))
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_leaves_manager_id ON leaves(manager_id)"
|
||||
))
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_leaves_dates ON leaves(start_date, end_date)"
|
||||
))
|
||||
|
||||
validate_security_settings()
|
||||
# Read notification_time from DB (or use default 17:30)
|
||||
notification_hour, notification_minute = 17, 30
|
||||
async with async_session() as db:
|
||||
@@ -123,6 +63,7 @@ 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(mini_business_logs.router, prefix="/api")
|
||||
app.include_router(key_visits.router, prefix="/api")
|
||||
app.include_router(dashboard.router, prefix="/api")
|
||||
app.include_router(leaves.router, prefix="/api")
|
||||
@@ -133,6 +74,7 @@ app.include_router(wecom.router, prefix="/api")
|
||||
app.include_router(daily_notes.router, prefix="/api")
|
||||
app.include_router(ai_summary.router, prefix="/api")
|
||||
app.include_router(system_config.router, prefix="/api")
|
||||
app.include_router(audit.router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -10,6 +10,8 @@ from app.models.daily_note import DailyNote
|
||||
from app.models.ai_summary import AISummary
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.models.leave import Leave
|
||||
from app.models.wecom_bind_token import WecomBindToken
|
||||
from app.models.mini_business_log import MiniBusinessLog
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -19,9 +21,11 @@ __all__ = [
|
||||
"Visit",
|
||||
"WorkPlan",
|
||||
"MiniBusiness",
|
||||
"MiniBusinessLog",
|
||||
"KeyVisit",
|
||||
"DailyNote",
|
||||
"AISummary",
|
||||
"SystemConfig",
|
||||
"Leave",
|
||||
"WecomBindToken",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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, JSONB
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
entity_type: Mapped[str] = mapped_column(String(30))
|
||||
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
entity_name: Mapped[str] = mapped_column(String(200), default="")
|
||||
action: Mapped[str] = mapped_column(String(10)) # create / update / delete
|
||||
operator_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
operator_name: Mapped[str] = mapped_column(String(50), default="")
|
||||
summary: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
details: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -11,6 +11,7 @@ class Customer(Base):
|
||||
|
||||
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)
|
||||
customer_type: Mapped[str] = mapped_column(String(20), default="unit")
|
||||
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="")
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
from sqlalchemy import String, Text, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from app.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.mini_business_log import MiniBusinessLog
|
||||
|
||||
|
||||
class MiniBusiness(Base):
|
||||
__tablename__ = "mini_business"
|
||||
@@ -17,3 +21,5 @@ class MiniBusiness(Base):
|
||||
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
|
||||
expected_revenue_date: Mapped[str] = mapped_column(String(50), default="")
|
||||
edit_log: Mapped[list] = mapped_column(JSONB, default=list)
|
||||
|
||||
logs: Mapped[list["MiniBusinessLog"]] = relationship(back_populates="business", cascade="all, delete-orphan")
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""MiniBusinessLog — follow-up log entries for mini business opportunities."""
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from sqlalchemy import String, Date, DateTime, ForeignKey, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class MiniBusinessLog(Base):
|
||||
__tablename__ = "mini_business_logs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
business_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("mini_business.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
log_date: Mapped[date] = mapped_column(Date)
|
||||
method: Mapped[str] = mapped_column(String(20), default="电话")
|
||||
content: 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()
|
||||
)
|
||||
|
||||
business: Mapped["MiniBusiness"] = relationship(back_populates="logs")
|
||||
@@ -17,4 +17,5 @@ class User(Base):
|
||||
wecom_userid: Mapped[str | None] = mapped_column(String(100), unique=True, nullable=True)
|
||||
require_report: Mapped[bool] = mapped_column(default=True, server_default="true")
|
||||
color: Mapped[str | None] = mapped_column(String(7), nullable=True) # e.g. "#C62828"
|
||||
theme: Mapped[str] = mapped_column(String(20), default="editorial", server_default="editorial") # editorial / light
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
@@ -22,6 +22,7 @@ class Visit(Base):
|
||||
companion_names: Mapped[list] = mapped_column(ARRAY(Text), default=list)
|
||||
photos: Mapped[list | None] = mapped_column(ARRAY(Text), nullable=True)
|
||||
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
|
||||
visit_group_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True)
|
||||
edit_log: Mapped[list] = mapped_column(JSONB, default=list)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class WecomBindToken(Base):
|
||||
"""A single-use, expiring binding token issued after a verified WeCom callback."""
|
||||
|
||||
__tablename__ = "wecom_bind_tokens"
|
||||
|
||||
token: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
wecom_userid: Mapped[str] = mapped_column(String(100), index=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -45,6 +45,7 @@ class ContactOut(BaseModel):
|
||||
# ── Customer ──
|
||||
class CustomerCreate(BaseModel):
|
||||
name: str
|
||||
customer_type: str = "unit"
|
||||
industry: str = ""
|
||||
address: str = ""
|
||||
in_use_services: str = ""
|
||||
@@ -56,6 +57,7 @@ class CustomerCreate(BaseModel):
|
||||
|
||||
class CustomerUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
customer_type: Optional[str] = None
|
||||
industry: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
in_use_services: Optional[str] = None
|
||||
@@ -67,6 +69,7 @@ class CustomerUpdate(BaseModel):
|
||||
class CustomerOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
customer_type: str = "unit"
|
||||
industry: str
|
||||
address: str
|
||||
in_use_services: str
|
||||
@@ -84,6 +87,7 @@ class CustomerOut(BaseModel):
|
||||
class CustomerListOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
customer_type: str = "unit"
|
||||
industry: str
|
||||
in_use_services: str
|
||||
primary_manager_name: Optional[str] = None
|
||||
|
||||
@@ -9,6 +9,7 @@ class DailyNoteCreate(BaseModel):
|
||||
category: str = "其他"
|
||||
content: str = ""
|
||||
time_range: str = ""
|
||||
manager_id: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class DailyNoteUpdate(BaseModel):
|
||||
@@ -16,6 +17,7 @@ class DailyNoteUpdate(BaseModel):
|
||||
category: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
time_range: Optional[str] = None
|
||||
manager_id: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class DailyNoteOut(BaseModel):
|
||||
|
||||
@@ -11,6 +11,7 @@ class KeyVisitCreate(BaseModel):
|
||||
planned_date: str = ""
|
||||
planned_visitor: str = ""
|
||||
visit_target: str = ""
|
||||
manager_id: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class KeyVisitUpdate(BaseModel):
|
||||
@@ -21,6 +22,7 @@ class KeyVisitUpdate(BaseModel):
|
||||
planned_date: Optional[str] = None
|
||||
planned_visitor: Optional[str] = None
|
||||
visit_target: Optional[str] = None
|
||||
manager_id: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class KeyVisitOut(BaseModel):
|
||||
|
||||
@@ -10,6 +10,7 @@ class MiniBusinessCreate(BaseModel):
|
||||
follow_up_detail: str = ""
|
||||
status: str = "跟进中"
|
||||
expected_revenue_date: str = ""
|
||||
manager_id: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class MiniBusinessUpdate(BaseModel):
|
||||
@@ -19,6 +20,7 @@ class MiniBusinessUpdate(BaseModel):
|
||||
follow_up_detail: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
expected_revenue_date: Optional[str] = None
|
||||
manager_id: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class MiniBusinessOut(BaseModel):
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""MiniBusinessLog Pydantic schemas."""
|
||||
|
||||
from datetime import date, datetime
|
||||
from uuid import UUID
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
FOLLOW_UP_METHODS = ["电话", "微信", "上门", "邮件", "其他"]
|
||||
|
||||
|
||||
class MiniBusinessLogCreate(BaseModel):
|
||||
log_date: date
|
||||
method: str = Field(default="电话", pattern="^(电话|微信|上门|邮件|其他)$")
|
||||
content: str = ""
|
||||
|
||||
|
||||
class MiniBusinessLogUpdate(BaseModel):
|
||||
log_date: Optional[date] = None
|
||||
method: Optional[str] = Field(default=None, pattern="^(电话|微信|上门|邮件|其他)$")
|
||||
content: Optional[str] = None
|
||||
|
||||
|
||||
class MiniBusinessLogOut(BaseModel):
|
||||
id: UUID
|
||||
business_id: UUID
|
||||
log_date: date
|
||||
method: str
|
||||
content: str
|
||||
created_by: UUID
|
||||
created_by_name: str = ""
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -9,6 +9,7 @@ class TokenResponse(BaseModel):
|
||||
user_id: str
|
||||
name: str
|
||||
role: str
|
||||
theme: str = "editorial"
|
||||
|
||||
|
||||
class WecomLoginRequest(BaseModel):
|
||||
|
||||
@@ -16,6 +16,7 @@ class VisitCreate(BaseModel):
|
||||
companions: list[uuid.UUID] = []
|
||||
companion_names: list[str] = []
|
||||
photos: list[str] = []
|
||||
manager_id: Optional[uuid.UUID] = None # director/leader can specify
|
||||
|
||||
|
||||
class VisitUpdate(BaseModel):
|
||||
@@ -28,8 +29,10 @@ class VisitUpdate(BaseModel):
|
||||
communication_content: Optional[str] = None
|
||||
customer_demand: Optional[str] = None
|
||||
companions: Optional[list[uuid.UUID]] = None
|
||||
manager_id: Optional[uuid.UUID] = None
|
||||
companion_names: Optional[list[str]] = None
|
||||
photos: Optional[list[str]] = None
|
||||
manager_id: Optional[str] = None
|
||||
|
||||
|
||||
class VisitOut(BaseModel):
|
||||
@@ -46,6 +49,7 @@ class VisitOut(BaseModel):
|
||||
companion_names: list[str] = []
|
||||
photos: Optional[list[str]] = None
|
||||
manager_id: uuid.UUID
|
||||
visit_group_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
# Joined fields
|
||||
|
||||
@@ -9,6 +9,7 @@ class WorkPlanCreate(BaseModel):
|
||||
plan_content: str = ""
|
||||
plan_date: str # "YYYY-MM-DD"
|
||||
status: str = "计划中"
|
||||
manager_id: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class WorkPlanUpdate(BaseModel):
|
||||
@@ -16,6 +17,7 @@ class WorkPlanUpdate(BaseModel):
|
||||
plan_content: Optional[str] = None
|
||||
plan_date: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
manager_id: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class WorkPlanOut(BaseModel):
|
||||
|
||||
@@ -49,12 +49,12 @@ async def exchange_casdoor_code(code: str) -> dict | None:
|
||||
"code": code,
|
||||
}, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"[Casdoor] token exchange failed: status={resp.status_code}, body={resp.text[:500]}")
|
||||
logger.error("[Casdoor] token exchange failed: status=%s", resp.status_code)
|
||||
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}")
|
||||
logger.error("[Casdoor] token exchange response did not contain an access token")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"[Casdoor] token exchange exception: {e}")
|
||||
@@ -67,7 +67,7 @@ async def exchange_casdoor_code(code: str) -> dict | None:
|
||||
"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]}")
|
||||
logger.error("[Casdoor] userinfo failed: status=%s", resp.status_code)
|
||||
return None
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
@@ -103,4 +103,5 @@ def build_token_for_user(user: User) -> str:
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"department": user.department,
|
||||
"theme": user.theme or "editorial",
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ from app.models.key_visit import KeyVisit
|
||||
from app.models.daily_note import DailyNote
|
||||
from app.models.leave import Leave
|
||||
from app.services.holidays import load_holiday_sets
|
||||
from app.utils.timezone import today_cst, is_working_day
|
||||
from app.utils.timezone import today_cst, is_working_day, parse_date
|
||||
|
||||
|
||||
def get_week_range(reference_date: date | None = None):
|
||||
@@ -22,13 +22,14 @@ def get_week_range(reference_date: date | None = None):
|
||||
return monday, sunday
|
||||
|
||||
|
||||
async def get_dashboard_stats(db: AsyncSession, reference_date: date | None = None) -> dict:
|
||||
async def get_dashboard_stats(db: AsyncSession, reference_date: date | None = None, user_id: str = "", role: str = "") -> dict:
|
||||
"""Get dashboard statistics for a given week (defaults to current)."""
|
||||
monday, sunday = get_week_range(reference_date)
|
||||
today = date.today()
|
||||
|
||||
visits_count = (await db.execute(
|
||||
select(func.count(Visit.id)).where(Visit.visit_date >= monday, Visit.visit_date <= sunday)
|
||||
select(func.count(func.distinct(func.coalesce(Visit.visit_group_id, Visit.id))))
|
||||
.where(Visit.visit_date >= monday, Visit.visit_date <= sunday)
|
||||
)).scalar() or 0
|
||||
|
||||
plans_count = (await db.execute(
|
||||
@@ -50,12 +51,22 @@ async def get_dashboard_stats(db: AsyncSession, reference_date: date | None = No
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
# Overdue plans (status=计划中, plan_date < today). Managers see only own.
|
||||
overdue_q = select(func.count(WorkPlan.id)).where(
|
||||
WorkPlan.status == "计划中",
|
||||
WorkPlan.plan_date < today,
|
||||
)
|
||||
if role == "manager":
|
||||
overdue_q = overdue_q.where(WorkPlan.manager_id == UUID(user_id))
|
||||
overdue_plans = (await db.execute(overdue_q)).scalar() or 0
|
||||
|
||||
return {
|
||||
"week_visits": visits_count,
|
||||
"work_plans": plans_count,
|
||||
"mini_business": mini_biz_count,
|
||||
"key_visits": key_visit_count,
|
||||
"week_leaves": leaves_count,
|
||||
"overdue_plans": overdue_plans,
|
||||
"week_start": str(monday),
|
||||
"week_end": str(sunday),
|
||||
}
|
||||
@@ -121,10 +132,14 @@ async def get_reporting_progress(db: AsyncSession, reference_date: date | None =
|
||||
} if leave else None,
|
||||
})
|
||||
|
||||
# Check today's reporting — visits OR daily notes
|
||||
today_visits = await db.execute(
|
||||
select(Visit.manager_id).where(Visit.visit_date == today)
|
||||
# Check today's reporting — visits (direct + companions) OR daily notes
|
||||
from sqlalchemy import union_all
|
||||
today_direct = select(Visit.manager_id).where(Visit.visit_date == today)
|
||||
today_companion = select(func.unnest(Visit.companions).label("manager_id")).where(
|
||||
Visit.visit_date == today, Visit.companions.isnot(None)
|
||||
)
|
||||
today_combined = union_all(today_direct, today_companion).subquery()
|
||||
today_visits = await db.execute(select(today_combined.c.manager_id))
|
||||
today_notes = await db.execute(
|
||||
select(DailyNote.manager_id).where(DailyNote.note_date == today)
|
||||
)
|
||||
@@ -162,40 +177,75 @@ async def get_weekly_report(
|
||||
# ── 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)
|
||||
visit_query = visit_query.where(
|
||||
(Visit.manager_id == user_id) | (Visit.companions.any(user_id))
|
||||
)
|
||||
elif filter_manager_id:
|
||||
visit_query = visit_query.where(Visit.manager_id == filter_manager_id)
|
||||
visit_query = visit_query.where(
|
||||
(Visit.manager_id == filter_manager_id) | (Visit.companions.any(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 = []
|
||||
# Group visits by visit_group_id (or id for solo visits)
|
||||
groups: dict[str, list] = {}
|
||||
for v in visits:
|
||||
# Resolve companion names: system users → names, external → direct
|
||||
companion_names_resolved = [user_map.get(c, str(c)) for c in (v.companions or [])]
|
||||
companion_names_resolved.extend(v.companion_names or [])
|
||||
visits_data.append({
|
||||
"id": str(v.id),
|
||||
"customer_id": str(v.customer_id),
|
||||
"customer_name": customer_map.get(v.customer_id, ""),
|
||||
"visit_date": str(v.visit_date),
|
||||
"visit_method": v.visit_method,
|
||||
"time_range": v.time_range,
|
||||
"visitor_name": v.visitor_name or "",
|
||||
"visitor_phone": v.visitor_phone or "",
|
||||
"communication_content": v.communication_content,
|
||||
"customer_demand": v.customer_demand,
|
||||
"companions": [str(c) for c in (v.companions or [])],
|
||||
"companion_names": v.companion_names or [],
|
||||
gid = str(v.visit_group_id) if v.visit_group_id else str(v.id)
|
||||
groups.setdefault(gid, []).append(v)
|
||||
|
||||
visits_data = []
|
||||
for gid, gvisits in groups.items():
|
||||
# Determine the "primary" — the creator's record (not a companion copy)
|
||||
def _is_companion_copy(v) -> bool:
|
||||
content = (v.communication_content or "").strip()
|
||||
# New-style: has "(协同XXX)" suffix
|
||||
if content.endswith(")") and "(协同" in content:
|
||||
return True
|
||||
# Old-style: empty draft created for companion
|
||||
if not content:
|
||||
return True
|
||||
return False
|
||||
creator_records = [v for v in gvisits if not _is_companion_copy(v)]
|
||||
primary = creator_records[0] if creator_records else gvisits[0]
|
||||
companion_names_resolved = [user_map.get(c, str(c)) for c in (primary.companions or [])]
|
||||
companion_names_resolved.extend(primary.companion_names or [])
|
||||
|
||||
merged = {
|
||||
"id": str(primary.id),
|
||||
"customer_id": str(primary.customer_id),
|
||||
"customer_name": customer_map.get(primary.customer_id, ""),
|
||||
"visit_date": str(primary.visit_date),
|
||||
"visit_method": primary.visit_method,
|
||||
"time_range": primary.time_range,
|
||||
"visitor_name": primary.visitor_name or "",
|
||||
"visitor_phone": primary.visitor_phone or "",
|
||||
"visit_group_id": gid if len(gvisits) > 1 else None,
|
||||
"companions": [str(c) for c in (primary.companions or [])],
|
||||
"companion_names": primary.companion_names or [],
|
||||
"companion_names_resolved": companion_names_resolved,
|
||||
"photos": v.photos or [],
|
||||
"manager_id": str(v.manager_id),
|
||||
"manager_name": user_map.get(v.manager_id, ""),
|
||||
"edit_log": v.edit_log or [],
|
||||
"created_at": str(v.created_at),
|
||||
})
|
||||
"communication_content": primary.communication_content,
|
||||
"customer_demand": primary.customer_demand,
|
||||
"photos": primary.photos or [],
|
||||
"manager_id": str(primary.manager_id),
|
||||
"manager_name": user_map.get(primary.manager_id, ""),
|
||||
"edit_log": primary.edit_log or [],
|
||||
"created_at": str(primary.created_at),
|
||||
"participants": [
|
||||
{
|
||||
"manager_id": str(v.manager_id),
|
||||
"manager_name": user_map.get(v.manager_id, ""),
|
||||
"role": "primary" if v is primary else "companion",
|
||||
"communication_content": v.communication_content,
|
||||
"customer_demand": v.customer_demand,
|
||||
"photos": v.photos or [],
|
||||
}
|
||||
for v in gvisits
|
||||
],
|
||||
}
|
||||
visits_data.append(merged)
|
||||
|
||||
# ── Work Plans ──
|
||||
wp_query = select(WorkPlan)
|
||||
|
||||
@@ -203,6 +203,13 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui
|
||||
db.add(visit)
|
||||
stats["visits"] += 1
|
||||
|
||||
# Auto-complete matching work plans
|
||||
from app.services.visits import auto_complete_work_plans
|
||||
await auto_complete_work_plans(
|
||||
db, customer_id, visit_date,
|
||||
mgr_name, "旧周报导入自动完成",
|
||||
)
|
||||
|
||||
# Update customer's last_visit_date for light board
|
||||
cust = await db.get(Customer, customer_id)
|
||||
if cust and (not cust.last_visit_date or visit_date > cust.last_visit_date):
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.leave import Leave
|
||||
from app.models.user import User
|
||||
from app.utils.timezone import today_cst
|
||||
from app.utils.audit import log_audit
|
||||
from app.services.dashboard import get_week_range
|
||||
|
||||
|
||||
@@ -88,6 +89,7 @@ async def create_leave(
|
||||
data: dict,
|
||||
submitted_by: UUID,
|
||||
role: str,
|
||||
operator_name: str = "",
|
||||
) -> Leave:
|
||||
"""Create leave. Manager can only create for self; director can create for anyone."""
|
||||
if role == "manager":
|
||||
@@ -108,6 +110,9 @@ async def create_leave(
|
||||
db.add(leave)
|
||||
await db.commit()
|
||||
await db.refresh(leave)
|
||||
# Audit log
|
||||
await log_audit(db, "leave", leave.id, f"{leave.leave_type} ({leave.start_date}~{leave.end_date})", "create", str(submitted_by), operator_name)
|
||||
await db.commit()
|
||||
return leave
|
||||
|
||||
|
||||
@@ -117,6 +122,7 @@ async def update_leave(
|
||||
data: dict,
|
||||
user_id: UUID,
|
||||
role: str,
|
||||
operator_name: str = "",
|
||||
) -> Leave:
|
||||
"""Update leave. Director can edit any; manager can edit own submitted leaves."""
|
||||
leave = await get_leave_by_id(db, leave_id)
|
||||
@@ -136,6 +142,9 @@ async def update_leave(
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(leave)
|
||||
# Audit log
|
||||
await log_audit(db, "leave", leave.id, f"{leave.leave_type} ({leave.start_date}~{leave.end_date})", "update", str(user_id), operator_name)
|
||||
await db.commit()
|
||||
return leave
|
||||
|
||||
|
||||
@@ -144,6 +153,7 @@ async def delete_leave(
|
||||
leave_id: UUID,
|
||||
user_id: UUID,
|
||||
role: str,
|
||||
operator_name: str = "",
|
||||
) -> bool:
|
||||
"""Delete leave. Director can delete any; manager can delete own submitted leaves."""
|
||||
leave = await get_leave_by_id(db, leave_id)
|
||||
@@ -154,8 +164,12 @@ async def delete_leave(
|
||||
if str(leave.submitted_by) != str(user_id):
|
||||
raise PermissionError("客户经理只能删除自己提交的请假")
|
||||
|
||||
entity_name = f"{leave.leave_type} ({leave.start_date}~{leave.end_date})"
|
||||
await db.delete(leave)
|
||||
await db.commit()
|
||||
# Audit log
|
||||
await log_audit(db, "leave", leave_id, entity_name, "delete", str(user_id), operator_name)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -85,19 +85,34 @@ async def get_light_board(
|
||||
for m in visible_managers
|
||||
}
|
||||
|
||||
# ── All customers with their last visit per manager ──
|
||||
# Subquery: latest visit date + method per (manager_id, customer_id)
|
||||
# ── All customers with their last visit per manager (including companions) ──
|
||||
# UNION direct + companions (unnested), then DISTINCT ON to get the latest per (manager, customer)
|
||||
from sqlalchemy import union_all, distinct
|
||||
direct = select(
|
||||
Visit.manager_id, Visit.customer_id, Visit.visit_date, Visit.visit_method,
|
||||
)
|
||||
companion = select(
|
||||
func.unnest(Visit.companions).label("manager_id"),
|
||||
Visit.customer_id,
|
||||
Visit.visit_date,
|
||||
Visit.visit_method,
|
||||
).where(Visit.companions.isnot(None))
|
||||
combined = union_all(direct, companion).subquery()
|
||||
|
||||
# Get latest visit (date + method) per (manager_id, customer_id) using DISTINCT ON
|
||||
latest_visit = (
|
||||
select(
|
||||
Visit.manager_id,
|
||||
Visit.customer_id,
|
||||
func.max(Visit.visit_date).label("last_visit_date"),
|
||||
combined.c.manager_id,
|
||||
combined.c.customer_id,
|
||||
combined.c.visit_date.label("last_visit_date"),
|
||||
combined.c.visit_method,
|
||||
)
|
||||
.group_by(Visit.manager_id, Visit.customer_id)
|
||||
.distinct(combined.c.manager_id, combined.c.customer_id)
|
||||
.order_by(combined.c.manager_id, combined.c.customer_id, combined.c.visit_date.desc())
|
||||
.subquery()
|
||||
)
|
||||
|
||||
# Join: assignments → customers → latest_visit → visits (for method)
|
||||
# Join: assignments → customers → latest_visit
|
||||
rows = await db.execute(
|
||||
select(
|
||||
CustomerAssignment.manager_id,
|
||||
@@ -108,18 +123,13 @@ async def get_light_board(
|
||||
Customer.in_use_services,
|
||||
Customer.monthly_fee,
|
||||
latest_visit.c.last_visit_date,
|
||||
Visit.visit_method,
|
||||
latest_visit.c.visit_method,
|
||||
)
|
||||
.join(Customer, Customer.id == CustomerAssignment.customer_id)
|
||||
.outerjoin(latest_visit, and_(
|
||||
latest_visit.c.manager_id == CustomerAssignment.manager_id,
|
||||
latest_visit.c.customer_id == CustomerAssignment.customer_id,
|
||||
))
|
||||
.outerjoin(Visit, and_(
|
||||
Visit.manager_id == CustomerAssignment.manager_id,
|
||||
Visit.customer_id == CustomerAssignment.customer_id,
|
||||
Visit.visit_date == latest_visit.c.last_visit_date,
|
||||
))
|
||||
.where(CustomerAssignment.role == "primary")
|
||||
.order_by(Customer.name)
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from datetime import timedelta
|
||||
from io import BytesIO
|
||||
from minio import Minio
|
||||
from app.config import settings
|
||||
|
||||
@@ -20,10 +21,16 @@ def get_minio_client() -> Minio:
|
||||
return _client
|
||||
|
||||
|
||||
def generate_presigned_upload_url(object_key: str, expires: int = 600) -> str:
|
||||
"""Generate a presigned PUT URL for direct upload to MinIO."""
|
||||
def put_validated_image(object_key: str, image_bytes: bytes) -> None:
|
||||
"""Store a normalized image only after the API has validated its content."""
|
||||
client = get_minio_client()
|
||||
return client.presigned_put_object(settings.MINIO_BUCKET, object_key, expires=timedelta(seconds=expires))
|
||||
client.put_object(
|
||||
settings.MINIO_BUCKET,
|
||||
object_key,
|
||||
BytesIO(image_bytes),
|
||||
length=len(image_bytes),
|
||||
content_type="image/jpeg",
|
||||
)
|
||||
|
||||
|
||||
def generate_presigned_download_url(object_key: str, expires: int = 3600) -> str:
|
||||
|
||||
@@ -80,33 +80,33 @@ async def check_daily_reporting(db: AsyncSession) -> dict:
|
||||
f"📋 今日填报提醒\n\n{desc}\n未填报:{names_text}\n\n请尽快完成填报 🙏\nhttps://qj.dhdx.fun/m",
|
||||
)
|
||||
|
||||
# 2. Send summary to director
|
||||
# 2. Send summary to directors/leaders only (not broadcast to @all)
|
||||
directors = await db.execute(
|
||||
select(User).where(User.role.in_(["director", "leader"]), User.wecom_userid.isnot(None))
|
||||
)
|
||||
for d in directors.scalars().all():
|
||||
if managers:
|
||||
effective_total = len(managers) - len(on_leave_names)
|
||||
if effective_total > 0:
|
||||
pct = len(reported_map) / effective_total * 100
|
||||
else:
|
||||
pct = 100.0
|
||||
leave_note = ""
|
||||
if on_leave_names:
|
||||
leave_note = f"\n> 请假中(已豁免):{len(on_leave_names)}人\n"
|
||||
leave_note += "".join(f"- {n} (请假)\n" for n in on_leave_names)
|
||||
summary = (
|
||||
f"## 📊 今日填报汇总\n\n"
|
||||
f"> 日期:{today}\n"
|
||||
f"> 填报率:**{pct:.0f}%** ({len(reported_map)}/{effective_total})\n"
|
||||
)
|
||||
if leave_note:
|
||||
summary += leave_note + "\n"
|
||||
if not_reported:
|
||||
summary += "**未填报:**\n" + "".join(f"- {m.name}\n" for m in not_reported)
|
||||
else:
|
||||
summary += "✅ 全体已完成今日填报"
|
||||
await wecom_client.send_markdown_message(summary) # broadcast for director visibility
|
||||
director_ids = [d.wecom_userid for d in directors.scalars().all()]
|
||||
if managers and director_ids:
|
||||
effective_total = len(managers) - len(on_leave_names)
|
||||
if effective_total > 0:
|
||||
pct = len(reported_map) / effective_total * 100
|
||||
else:
|
||||
pct = 100.0
|
||||
leave_note = ""
|
||||
if on_leave_names:
|
||||
leave_note = f"\n> 请假中(已豁免):{len(on_leave_names)}人\n"
|
||||
leave_note += "".join(f"- {n} (请假)\n" for n in on_leave_names)
|
||||
summary = (
|
||||
f"## 📊 今日填报汇总\n\n"
|
||||
f"> 日期:{today}\n"
|
||||
f"> 填报率:**{pct:.0f}%** ({len(reported_map)}/{effective_total})\n"
|
||||
)
|
||||
if leave_note:
|
||||
summary += leave_note + "\n"
|
||||
if not_reported:
|
||||
summary += "**未填报:**\n" + "".join(f"- {m.name}\n" for m in not_reported)
|
||||
else:
|
||||
summary += "✅ 全体已完成今日填报"
|
||||
await wecom_client.send_markdown_message(summary, user_ids=director_ids)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
@@ -168,9 +168,33 @@ async def check_overdue_plans(db: AsyncSession) -> dict:
|
||||
|
||||
await wecom_client.send_text_message([user.wecom_userid], content)
|
||||
|
||||
# ── Auto-cancel overdue plans that have no matching visit ──
|
||||
from app.utils.edit_log import append_entry as append_edit_log
|
||||
auto_cancelled = 0
|
||||
for plan in overdue:
|
||||
has_visit = await db.execute(
|
||||
select(Visit).where(
|
||||
Visit.customer_id == plan.customer_id,
|
||||
Visit.visit_date >= plan.plan_date,
|
||||
)
|
||||
)
|
||||
if not has_visit.scalar():
|
||||
plan.status = "已取消"
|
||||
append_edit_log(plan, "系统", [{
|
||||
"field": "status",
|
||||
"from": "计划中",
|
||||
"to": "已取消",
|
||||
"reason": "逾期自动取消",
|
||||
}])
|
||||
auto_cancelled += 1
|
||||
|
||||
if auto_cancelled:
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"date": str(today),
|
||||
"overdue": len(overdue),
|
||||
"auto_cancelled": auto_cancelled,
|
||||
"managers_affected": len(by_manager),
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ def get_scheduler() -> AsyncIOScheduler:
|
||||
"""Lazy-init and return the global scheduler."""
|
||||
global _scheduler
|
||||
if _scheduler is None:
|
||||
_scheduler = AsyncIOScheduler()
|
||||
_scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
|
||||
return _scheduler
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ async def reschedule_daily_check(hour: int, minute: int):
|
||||
async with async_session() as db:
|
||||
await check_daily_reporting(db)
|
||||
|
||||
sched.add_job(_wrapper, "cron", hour=hour, minute=minute, id="daily_check")
|
||||
sched.add_job(_wrapper, "cron", hour=hour, minute=minute, id="daily_check", timezone="Asia/Shanghai")
|
||||
|
||||
|
||||
def start_scheduler(notification_hour: int = 17, notification_minute: int = 30):
|
||||
@@ -46,8 +46,8 @@ def start_scheduler(notification_hour: int = 17, notification_minute: int = 30):
|
||||
async with async_session() as db:
|
||||
await check_overdue_plans(db)
|
||||
|
||||
sched.add_job(_daily, "cron", hour=notification_hour, minute=notification_minute, id="daily_check")
|
||||
sched.add_job(_overdue, "cron", hour=9, minute=0, id="overdue_check")
|
||||
sched.add_job(_daily, "cron", hour=notification_hour, minute=notification_minute, id="daily_check", timezone="Asia/Shanghai")
|
||||
sched.add_job(_overdue, "cron", hour=9, minute=0, id="overdue_check", timezone="Asia/Shanghai")
|
||||
sched.start()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Visit service — shared logic for visit creation, update, and import."""
|
||||
|
||||
from uuid import UUID
|
||||
from datetime import date
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.work_plan import WorkPlan
|
||||
from app.utils.edit_log import append_entry
|
||||
|
||||
|
||||
async def auto_complete_work_plans(
|
||||
db: AsyncSession,
|
||||
customer_id: UUID,
|
||||
visit_date: date,
|
||||
editor_name: str,
|
||||
reason: str = "拜访自动完成",
|
||||
) -> int:
|
||||
"""Auto-complete matching work plans when a visit is created/updated/imported.
|
||||
|
||||
Matches by: same customer_id + status=="计划中" + plan_date <= visit_date.
|
||||
Returns the number of plans completed.
|
||||
"""
|
||||
plans_result = await db.execute(
|
||||
select(WorkPlan).where(
|
||||
WorkPlan.customer_id == customer_id,
|
||||
WorkPlan.status == "计划中",
|
||||
WorkPlan.plan_date <= visit_date,
|
||||
)
|
||||
)
|
||||
count = 0
|
||||
for plan in plans_result.scalars().all():
|
||||
plan.status = "已完成"
|
||||
append_entry(plan, editor_name, [{
|
||||
"field": "status",
|
||||
"from": "计划中",
|
||||
"to": "已完成",
|
||||
"reason": reason,
|
||||
}])
|
||||
count += 1
|
||||
return count
|
||||
@@ -1,30 +1,42 @@
|
||||
import asyncio
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import httpx
|
||||
from sqlalchemy import delete, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.models.wecom_bind_token import WecomBindToken
|
||||
|
||||
# In-memory bind token store (TTL 600s). Replace with Redis if scaling to multiple workers.
|
||||
_bind_tokens: dict[str, tuple[str, float]] = {} # token → (wecom_userid, expires_at)
|
||||
|
||||
|
||||
def store_bind_token(wecom_userid: str, ttl: int = 600) -> str:
|
||||
"""Store a bind token → wecom_userid mapping. Returns the token."""
|
||||
async def store_bind_token(db: AsyncSession, wecom_userid: str, ttl: int = 600) -> str:
|
||||
"""Persist a one-time bind token so it works across application workers."""
|
||||
token = uuid.uuid4().hex
|
||||
_bind_tokens[token] = (wecom_userid, time.time() + ttl)
|
||||
# Cleanup expired tokens
|
||||
now = time.time()
|
||||
for k in list(_bind_tokens):
|
||||
if _bind_tokens[k][1] < now:
|
||||
del _bind_tokens[k]
|
||||
now = datetime.now(timezone.utc)
|
||||
await db.execute(delete(WecomBindToken).where(WecomBindToken.expires_at < now))
|
||||
db.add(WecomBindToken(
|
||||
token=token,
|
||||
wecom_userid=wecom_userid,
|
||||
expires_at=now + timedelta(seconds=ttl),
|
||||
))
|
||||
await db.commit()
|
||||
return token
|
||||
|
||||
|
||||
def consume_bind_token(token: str) -> str | None:
|
||||
"""Lookup and consume a bind token. Returns wecom_userid or None."""
|
||||
entry = _bind_tokens.pop(token, None)
|
||||
if entry and entry[1] > time.time():
|
||||
return entry[0]
|
||||
return None
|
||||
async def consume_bind_token(db: AsyncSession, token: str) -> str | None:
|
||||
"""Atomically consume an unexpired binding token and return its WeCom user ID."""
|
||||
now = datetime.now(timezone.utc)
|
||||
result = await db.execute(
|
||||
update(WecomBindToken)
|
||||
.where(
|
||||
WecomBindToken.token == token,
|
||||
WecomBindToken.consumed_at.is_(None),
|
||||
WecomBindToken.expires_at > now,
|
||||
)
|
||||
.values(consumed_at=now)
|
||||
.returning(WecomBindToken.wecom_userid)
|
||||
)
|
||||
await db.commit()
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
class WecomClient:
|
||||
@@ -80,14 +92,14 @@ class WecomClient:
|
||||
|
||||
if errcode == 45009:
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(1 * (attempt + 1))
|
||||
await asyncio.sleep(1 * (attempt + 1))
|
||||
continue
|
||||
|
||||
last_error = data
|
||||
except (httpx.TimeoutException, httpx.ConnectError) as e:
|
||||
last_error = {"errcode": -1, "errmsg": str(e)}
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(0.5 * (attempt + 1))
|
||||
await asyncio.sleep(0.5 * (attempt + 1))
|
||||
|
||||
raise Exception(f"WeCom API error after {max_retries} retries: {last_error}")
|
||||
|
||||
@@ -127,13 +139,14 @@ class WecomClient:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def send_markdown_message(self, content: str) -> bool:
|
||||
"""Send a markdown message to all users in the app (broadcast)."""
|
||||
async def send_markdown_message(self, content: str, user_ids: list[str] | None = None) -> bool:
|
||||
"""Send a markdown message. If user_ids is provided, send to those users; otherwise broadcast to @all."""
|
||||
if not settings.WECOM_AGENT_ID:
|
||||
return False
|
||||
try:
|
||||
touser = "|".join(user_ids) if user_ids else "@all"
|
||||
body = {
|
||||
"touser": "@all",
|
||||
"touser": touser,
|
||||
"msgtype": "markdown",
|
||||
"agentid": int(settings.WECOM_AGENT_ID),
|
||||
"markdown": {"content": content},
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Audit log utility — write once, query forever."""
|
||||
import uuid
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
|
||||
async def log_audit(
|
||||
db: AsyncSession,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
entity_name: str,
|
||||
action: str, # create / update / delete
|
||||
operator_id: str,
|
||||
operator_name: str,
|
||||
summary: str = "",
|
||||
details: dict | None = None,
|
||||
):
|
||||
"""Write an audit log entry."""
|
||||
entry = AuditLog(
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
entity_name=entity_name,
|
||||
action=action,
|
||||
operator_id=uuid.UUID(operator_id),
|
||||
operator_name=operator_name,
|
||||
summary=summary or f"{'创建了' if action == 'create' else '更新了' if action == 'update' else '删除了'} {entity_name}",
|
||||
details=details or {},
|
||||
)
|
||||
db.add(entry)
|
||||
# Don't commit here — let the caller handle it as part of their transaction
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from jose import jwt, JWTError
|
||||
import jwt
|
||||
from jwt import InvalidTokenError
|
||||
from app.config import settings
|
||||
|
||||
|
||||
@@ -15,5 +16,5 @@ def decode_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
except InvalidTokenError:
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
# Fail CI for parse errors and undefined names. Broader style cleanup is tracked
|
||||
# separately because the existing codebase has a substantial legacy backlog.
|
||||
select = ["E9", "F63", "F7", "F82"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# SQLAlchemy resolves these relationship targets from strings after model import.
|
||||
"app/models/*.py" = ["F821"]
|
||||
@@ -1,15 +1,17 @@
|
||||
fastapi==0.115.6
|
||||
fastapi==0.140.7
|
||||
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
|
||||
PyJWT==2.13.0
|
||||
httpx==0.28.1
|
||||
python-multipart==0.0.18
|
||||
python-multipart==0.0.32
|
||||
minio==7.2.10
|
||||
openpyxl==3.1.5
|
||||
apscheduler==3.11.0
|
||||
python-dotenv==1.0.1
|
||||
pycryptodome
|
||||
python-dotenv==1.2.2
|
||||
cryptography==49.0.0
|
||||
defusedxml==0.7.1
|
||||
Pillow==12.3.0
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from io import BytesIO
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
from app.api import system_config, upload
|
||||
|
||||
|
||||
def _png_bytes() -> bytes:
|
||||
buffer = BytesIO()
|
||||
Image.new("RGB", (8, 8), color=(10, 20, 30)).save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _upload_client(authenticated: bool) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(upload.router)
|
||||
if authenticated:
|
||||
app.dependency_overrides[upload.require_any_role] = lambda: {
|
||||
"user_id": "a2d4447f-7b67-4304-ad9a-953007e75ef2",
|
||||
"role": "manager",
|
||||
}
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_upload_endpoint_requires_bearer_authentication():
|
||||
response = _upload_client(authenticated=False).post(
|
||||
"/upload/image",
|
||||
files={"file": ("photo.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_upload_endpoint_validates_and_normalizes_before_storage():
|
||||
with patch("app.api.upload.put_validated_image") as put_image:
|
||||
response = _upload_client(authenticated=True).post(
|
||||
"/upload/image",
|
||||
files={"file": ("photo.png", _png_bytes(), "image/png")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
object_key = response.json()["object_key"]
|
||||
assert object_key.startswith("uploads/a2d4447f-7b67-4304-ad9a-953007e75ef2/")
|
||||
assert object_key.endswith(".jpg")
|
||||
stored_bytes = put_image.call_args.args[1]
|
||||
with Image.open(BytesIO(stored_bytes)) as image:
|
||||
assert image.format == "JPEG"
|
||||
|
||||
|
||||
def test_system_configuration_cannot_be_read_without_director_authentication():
|
||||
app = FastAPI()
|
||||
app.include_router(system_config.router)
|
||||
|
||||
response = TestClient(app).get("/system-config")
|
||||
|
||||
assert response.status_code == 401
|
||||
@@ -0,0 +1,10 @@
|
||||
from app.config import settings
|
||||
from app.utils.security import create_access_token, decode_token
|
||||
|
||||
|
||||
def test_jwt_round_trip_and_tamper_rejection(monkeypatch):
|
||||
monkeypatch.setattr(settings, "SECRET_KEY", "x" * 32)
|
||||
token = create_access_token({"user_id": "user-1", "role": "manager"})
|
||||
|
||||
assert decode_token(token)["user_id"] == "user-1"
|
||||
assert decode_token(f"{token}tampered") is None
|
||||
@@ -0,0 +1,95 @@
|
||||
import asyncio
|
||||
from io import BytesIO
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from fastapi import HTTPException
|
||||
from PIL import Image
|
||||
|
||||
from app.api.upload import build_object_key, is_owned_upload_key, normalize_image, validate_image_upload
|
||||
from app.config import settings, validate_security_settings
|
||||
from app.services.wecom import consume_bind_token, store_bind_token
|
||||
|
||||
|
||||
USER_ID = "a2d4447f-7b67-4304-ad9a-953007e75ef2"
|
||||
|
||||
|
||||
def test_build_object_key_is_scoped_to_the_authenticated_user():
|
||||
object_key = build_object_key(USER_ID, "jpg")
|
||||
|
||||
assert is_owned_upload_key(object_key, USER_ID)
|
||||
assert not is_owned_upload_key(object_key, "cf610b28-51a7-423d-a55a-730e193c9971")
|
||||
|
||||
|
||||
def test_upload_rejects_non_image_content_type():
|
||||
try:
|
||||
validate_image_upload("payload.exe", "application/octet-stream")
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 400
|
||||
else:
|
||||
raise AssertionError("non-image uploads must be rejected")
|
||||
|
||||
|
||||
def test_upload_rejects_an_invalid_filename_extension():
|
||||
try:
|
||||
validate_image_upload("payload.txt", "image/jpeg")
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 400
|
||||
else:
|
||||
raise AssertionError("invalid extensions must be rejected")
|
||||
|
||||
|
||||
def test_image_content_is_normalized_to_jpeg():
|
||||
source = BytesIO()
|
||||
Image.new("RGBA", (8, 8), color=(255, 0, 0, 128)).save(source, format="PNG")
|
||||
|
||||
normalized = normalize_image(source.getvalue())
|
||||
|
||||
with Image.open(BytesIO(normalized)) as image:
|
||||
assert image.format == "JPEG"
|
||||
assert image.mode == "RGB"
|
||||
|
||||
|
||||
def test_image_content_rejects_non_images():
|
||||
try:
|
||||
normalize_image(b"not an image")
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 400
|
||||
else:
|
||||
raise AssertionError("non-image bytes must be rejected")
|
||||
|
||||
|
||||
def test_wecom_binding_tokens_are_persisted_and_consumed_atomically():
|
||||
session = MagicMock()
|
||||
session.execute = AsyncMock()
|
||||
session.commit = AsyncMock()
|
||||
|
||||
token = asyncio.run(store_bind_token(session, "wecom-user"))
|
||||
|
||||
assert len(token) == 32
|
||||
assert session.add.call_args.args[0].wecom_userid == "wecom-user"
|
||||
session.commit.assert_awaited_once()
|
||||
|
||||
result = MagicMock()
|
||||
result.scalar_one_or_none.return_value = "wecom-user"
|
||||
session.execute = AsyncMock(return_value=result)
|
||||
session.commit = AsyncMock()
|
||||
|
||||
assert asyncio.run(consume_bind_token(session, token)) == "wecom-user"
|
||||
session.commit.assert_awaited_once()
|
||||
|
||||
|
||||
def test_production_rejects_placeholder_database_configuration(monkeypatch):
|
||||
monkeypatch.setattr(settings, "ENVIRONMENT", "production")
|
||||
monkeypatch.setattr(settings, "SECRET_KEY", "a" * 32)
|
||||
monkeypatch.setattr(settings, "DATABASE_URL", "postgresql+asyncpg://qiji_app:invalid@localhost:5432/qiji")
|
||||
monkeypatch.setattr(settings, "MINIO_ENDPOINT", "minio.example.test:9000")
|
||||
monkeypatch.setattr(settings, "MINIO_ACCESS_KEY", "qiji-app")
|
||||
monkeypatch.setattr(settings, "MINIO_SECRET_KEY", "a-secure-minio-secret")
|
||||
monkeypatch.setattr(settings, "MINIO_SECURE", True)
|
||||
|
||||
try:
|
||||
validate_security_settings()
|
||||
except RuntimeError as exc:
|
||||
assert "DATABASE_URL" in str(exc)
|
||||
else:
|
||||
raise AssertionError("production configuration must reject placeholders")
|
||||
@@ -0,0 +1,24 @@
|
||||
[Unit]
|
||||
Description=Qiji backend API
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=v6ole
|
||||
Group=v6ole
|
||||
WorkingDirectory=/home/v6ole/pyproject/qiji/backend
|
||||
EnvironmentFile=/home/v6ole/pyproject/qiji/backend/.env
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
ExecStart=/home/v6ole/pyproject/qiji/backend/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8002
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectKernelTunables=true
|
||||
ProtectControlGroups=true
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,22 @@
|
||||
# 企迹 (qiji) — 后端生产部署
|
||||
# PostgreSQL / MinIO / Casdoor / 前端 均为外部服务,仅容器化运行 FastAPI 后端
|
||||
# 用法: docker compose -f docker-compose.backend.yml up -d
|
||||
#
|
||||
# 使用 host 网络模式:
|
||||
# - 可直接访问 10.10.10.x 外部服务 (DB/MinIO/Casdoor)
|
||||
# - FRP frpc 同为 host 网络,127.0.0.1:8002 直达
|
||||
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
container_name: qiji-backend
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
command: sh -c "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8002"
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
+29
-18
@@ -1,45 +1,56 @@
|
||||
# This compose file is for a single-host development/integration environment.
|
||||
# Production deployment must use the approved platform pipeline and secret manager.
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: qiji
|
||||
POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER must be set}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-qiji}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "127.0.0.1:5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
image: ${MINIO_IMAGE:?MINIO_IMAGE must be a pinned MinIO version or digest}
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
- "127.0.0.1:9000:9000"
|
||||
- "127.0.0.1:9001:9001"
|
||||
volumes:
|
||||
- miniodata:/data
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "127.0.0.1:8000:8000"
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/qiji
|
||||
ENVIRONMENT: development
|
||||
DEBUG: "false"
|
||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set}
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:?POSTGRES_USER must be set}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}@postgres:5432/${POSTGRES_DB:-qiji}
|
||||
MINIO_ENDPOINT: minio:9000
|
||||
MINIO_ACCESS_KEY: minioadmin
|
||||
MINIO_SECRET_KEY: minioadmin
|
||||
MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
|
||||
MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
|
||||
MINIO_BUCKET: qiji-photos
|
||||
MINIO_SECURE: "false"
|
||||
depends_on:
|
||||
- postgres
|
||||
- minio
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_started
|
||||
command: sh -c "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8000"
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
# 暗黑模式 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 为企迹系统新增双选暗黑模式:用户在设置页分别选择亮色/暗色偏好主题,顶部栏一键切换亮↔暗。
|
||||
|
||||
**Architecture:** 扩展 theme store 支持 mode(light/dark) + lightTheme + darkTheme 三字段,新增 minimal-dark/tech-dark 两套 CSS 变量,Element Plus 通过 `html.dark` class 适配暗色。
|
||||
|
||||
**Tech Stack:** Vue 3 + Pinia + TypeScript + Element Plus + CSS Custom Properties
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 四大主题: editorial(已有), light(已有), minimal-dark(新增), tech-dark(新增)
|
||||
- localStorage 持久化: mode, lightTheme, darkTheme 三项
|
||||
- 暗色模式时 `<html>` 同时设置 `data-theme` 和 `class="dark"`
|
||||
- 移动端暂不加主题切换(仅 PC 端顶部栏按钮)
|
||||
- 保持现有 editorial/light 的 CSS 变量不变
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 改造 Theme Store
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/stores/theme.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `useThemeStore()` with `mode`, `lightTheme`, `darkTheme`, `effectiveTheme`, `toggleMode()`, `setLightTheme(t)`, `setDarkTheme(t)`, `applyTheme(t)`
|
||||
- Theme type: `'editorial' | 'light' | 'minimal-dark' | 'tech-dark'`
|
||||
|
||||
- [ ] **Step 1: 重写 theme store**
|
||||
|
||||
```typescript
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export type Theme = 'editorial' | 'light' | 'minimal-dark' | 'tech-dark'
|
||||
type Mode = 'light' | 'dark'
|
||||
|
||||
export const themeLabels: Record<Theme, string> = {
|
||||
editorial: '编辑风',
|
||||
light: '极简亮色',
|
||||
'minimal-dark': '极简深色',
|
||||
'tech-dark': '科技深色',
|
||||
}
|
||||
|
||||
export const lightThemes: Theme[] = ['editorial', 'light']
|
||||
export const darkThemes: Theme[] = ['minimal-dark', 'tech-dark']
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const mode = ref<Mode>((localStorage.getItem('theme-mode') as Mode) || 'light')
|
||||
const lightTheme = ref<Theme>((localStorage.getItem('theme-light') as Theme) || 'editorial')
|
||||
const darkTheme = ref<Theme>((localStorage.getItem('theme-dark') as Theme) || 'tech-dark')
|
||||
|
||||
const effectiveTheme = computed<Theme>(() => {
|
||||
return mode.value === 'light' ? lightTheme.value : darkTheme.value
|
||||
})
|
||||
|
||||
function applyTheme(theme: Theme, isDark: boolean) {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
function apply() {
|
||||
applyTheme(effectiveTheme.value, mode.value === 'dark')
|
||||
}
|
||||
|
||||
function setLightTheme(theme: Theme) {
|
||||
lightTheme.value = theme
|
||||
localStorage.setItem('theme-light', theme)
|
||||
if (mode.value === 'light') apply()
|
||||
}
|
||||
|
||||
function setDarkTheme(theme: Theme) {
|
||||
darkTheme.value = theme
|
||||
localStorage.setItem('theme-dark', theme)
|
||||
if (mode.value === 'dark') apply()
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
mode.value = mode.value === 'light' ? 'dark' : 'light'
|
||||
localStorage.setItem('theme-mode', mode.value)
|
||||
apply()
|
||||
}
|
||||
|
||||
// Apply on init
|
||||
apply()
|
||||
|
||||
return { mode, lightTheme, darkTheme, effectiveTheme, setLightTheme, setDarkTheme, toggleMode }
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/stores/theme.ts
|
||||
git commit -m "feat: 改造 theme store 支持暗黑模式双选架构"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 新增两套暗色 CSS 变量
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/App.vue` (在 `:root[data-theme="light"]` 块之后添加两个新块)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Theme type `'minimal-dark'`, `'tech-dark'` from store
|
||||
- Produces: CSS variables for both dark themes + Element Plus dark overrides
|
||||
|
||||
- [ ] **Step 1: 添加 minimal-dark 主题 CSS**
|
||||
|
||||
在 `:root[data-theme="light"]` 块结束后(第178行 `}` 之后),插入:
|
||||
|
||||
```css
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
THEME: Minimal Dark — 极简深色
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
:root[data-theme="minimal-dark"] {
|
||||
/* ── Core palette ── */
|
||||
--ink: #E5E5E5;
|
||||
--ink-light: #CCCCCC;
|
||||
--ink-dark: #FFFFFF;
|
||||
--vermilion: #F87171;
|
||||
--vermilion-light: #FCA5A5;
|
||||
--vermilion-dark: #EF4444;
|
||||
--gold: #A78BFA;
|
||||
--gold-light: #C4B5FD;
|
||||
--gold-dark: #8B5CF6;
|
||||
--paper: #1A1A1A;
|
||||
--paper-dark: #141414;
|
||||
--surface: #2D2D2D;
|
||||
--sage: #4ADE80;
|
||||
--amber: #FBBF24;
|
||||
--warm-gray: #999999;
|
||||
--warm-border: #3A3A3A;
|
||||
|
||||
/* ── Semantic tokens ── */
|
||||
--c-primary: var(--ink);
|
||||
--c-primary-light: var(--ink-light);
|
||||
--c-primary-bg: #262626;
|
||||
--c-accent: var(--vermilion);
|
||||
--c-accent-light: var(--vermilion-light);
|
||||
--c-gold: var(--gold);
|
||||
--c-success: var(--sage);
|
||||
--c-warning: var(--amber);
|
||||
--c-danger: var(--vermilion);
|
||||
--c-info: #60A5FA;
|
||||
--c-text: #E5E5E5;
|
||||
--c-text-secondary: #999999;
|
||||
--c-text-muted: #666666;
|
||||
--c-bg: var(--paper);
|
||||
--c-bg-card: var(--surface);
|
||||
--c-border: var(--warm-border);
|
||||
|
||||
/* ── Effects ── */
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.20);
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.30);
|
||||
--shadow-md: 0 2px 6px rgba(0,0,0,0.40);
|
||||
--shadow-lg: 0 4px 12px rgba(0,0,0,0.50);
|
||||
--radius: 0px;
|
||||
--radius-sm: 0px;
|
||||
--radius-xs: 0px;
|
||||
--transition: 0.18s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ── Sidebar — dark minimal ── */
|
||||
--sidebar-bg: #141414;
|
||||
--sidebar-color: #E5E5E5;
|
||||
--sidebar-accent: var(--gold);
|
||||
--sidebar-nav-text: #777777;
|
||||
--sidebar-nav-text-hover: #CCCCCC;
|
||||
--sidebar-nav-text-active: #FFFFFF;
|
||||
--sidebar-nav-bg-hover: rgba(255,255,255,0.04);
|
||||
--sidebar-nav-bg-active: rgba(255,255,255,0.06);
|
||||
--sidebar-nav-border-active: var(--gold);
|
||||
--sidebar-divider: #2A2A2A;
|
||||
--sidebar-brand-color: #FFFFFF;
|
||||
--sidebar-brand-sub-color: #666666;
|
||||
--sidebar-group-label-color: #555555;
|
||||
--sidebar-footer-role-color: var(--gold);
|
||||
--sidebar-footer-name-color: #999999;
|
||||
|
||||
/* ── Mobile page accent ── */
|
||||
--page-accent-color: rgba(167,139,250,0.06);
|
||||
|
||||
/* ── Manager chip tags ── */
|
||||
--mgr-chip-text: #E5E5E5;
|
||||
|
||||
/* ── Fonts — sans-serif minimal ── */
|
||||
--font-body: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--font-heading: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 添加 tech-dark 主题 CSS**
|
||||
|
||||
在 minimal-dark 块之后插入:
|
||||
|
||||
```css
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
THEME: Tech Dark — 科技深色 (编辑风暗色版)
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
:root[data-theme="tech-dark"] {
|
||||
/* ── Core palette ── */
|
||||
--ink: #C8D6E5;
|
||||
--ink-light: #A0B4CC;
|
||||
--ink-dark: #E8EEF4;
|
||||
--vermilion: #F26B6B;
|
||||
--vermilion-light: #F59898;
|
||||
--vermilion-dark: #E55555;
|
||||
--gold: #5B8DEF;
|
||||
--gold-light: #7BA5F5;
|
||||
--gold-dark: #4070D0;
|
||||
--paper: #0B1120;
|
||||
--paper-dark: #080C18;
|
||||
--surface: #111B2E;
|
||||
--sage: #4EC9B0;
|
||||
--amber: #E5C07B;
|
||||
--warm-gray: #6B7A8D;
|
||||
--warm-border: #1E2D45;
|
||||
|
||||
/* ── Semantic tokens ── */
|
||||
--c-primary: var(--ink);
|
||||
--c-primary-light: var(--ink-light);
|
||||
--c-primary-bg: #131D30;
|
||||
--c-accent: var(--vermilion);
|
||||
--c-accent-light: var(--vermilion-light);
|
||||
--c-gold: var(--gold);
|
||||
--c-success: var(--sage);
|
||||
--c-warning: var(--amber);
|
||||
--c-danger: var(--vermilion);
|
||||
--c-info: #5B8DEF;
|
||||
--c-text: #C8D6E5;
|
||||
--c-text-secondary: #6B7A8D;
|
||||
--c-text-muted: #455570;
|
||||
--c-bg: var(--paper);
|
||||
--c-bg-card: var(--surface);
|
||||
--c-border: var(--warm-border);
|
||||
|
||||
/* ── Effects ── */
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.25);
|
||||
--shadow: 0 2px 6px rgba(0,0,0,0.35);
|
||||
--shadow-md: 0 6px 18px rgba(0,0,0,0.45);
|
||||
--shadow-lg: 0 12px 36px rgba(0,0,0,0.55);
|
||||
--radius: 2px;
|
||||
--radius-sm: 2px;
|
||||
--radius-xs: 2px;
|
||||
--transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ── Sidebar — dark tech ── */
|
||||
--sidebar-bg: #070D1A;
|
||||
--sidebar-color: #C8D6E5;
|
||||
--sidebar-accent: var(--gold);
|
||||
--sidebar-nav-text: rgba(200,214,229,0.45);
|
||||
--sidebar-nav-text-hover: rgba(200,214,229,0.80);
|
||||
--sidebar-nav-text-active: #FFFFFF;
|
||||
--sidebar-nav-bg-hover: rgba(91,141,239,0.06);
|
||||
--sidebar-nav-bg-active: rgba(91,141,239,0.10);
|
||||
--sidebar-nav-border-active: var(--gold);
|
||||
--sidebar-divider: rgba(200,214,229,0.06);
|
||||
--sidebar-brand-color: #FFFFFF;
|
||||
--sidebar-brand-sub-color: rgba(200,214,229,0.35);
|
||||
--sidebar-group-label-color: rgba(200,214,229,0.20);
|
||||
--sidebar-footer-role-color: var(--gold);
|
||||
--sidebar-footer-name-color: rgba(200,214,229,0.65);
|
||||
|
||||
/* ── Mobile page accent ── */
|
||||
--page-accent-color: rgba(91,141,239,0.06);
|
||||
|
||||
/* ── Manager chip tags ── */
|
||||
--mgr-chip-text: #C8D6E5;
|
||||
|
||||
/* ── Fonts — editorial Chinese ── */
|
||||
--font-body: 'Noto Serif SC', STSong, Songti SC, '宋体', serif;
|
||||
--font-heading: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
|
||||
--font-mono: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/App.vue
|
||||
git commit -m "feat: 新增 minimal-dark 和 tech-dark 两套暗色 CSS 变量"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 改造 DesktopLayout 顶部栏按钮
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/DesktopLayout.vue` (script 和 template 中的主题切换按钮)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `useThemeStore()` — `mode`, `toggleMode()`
|
||||
|
||||
- [ ] **Step 1: 替换主题按钮为亮/暗切换**
|
||||
|
||||
将 `cycleTheme` 函数替换为 `toggleMode`,按钮改为 sun/moon 图标切换:
|
||||
|
||||
```typescript
|
||||
// 替换 cycleTheme 函数:
|
||||
function toggleDarkMode() {
|
||||
themeStore.toggleMode()
|
||||
}
|
||||
```
|
||||
|
||||
Template 中替换按钮:
|
||||
|
||||
```html
|
||||
<button
|
||||
class="topbar-btn"
|
||||
@click="toggleDarkMode"
|
||||
:title="themeStore.mode === 'light' ? '切换至暗色模式' : '切换至亮色模式'"
|
||||
>
|
||||
<!-- Sun icon (light mode) -->
|
||||
<svg v-if="themeStore.mode === 'light'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
<!-- Moon icon (dark mode) -->
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 触发构建验证**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build -- --emptyOutDir
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/DesktopLayout.vue
|
||||
git commit -m "feat: 桌面端顶部栏改为亮/暗模式切换按钮"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 改造设置页面主题面板
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/desktop/Settings.vue` (替换 Theme 卡片为双选下拉)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `useThemeStore()` — `lightTheme`, `darkTheme`, `mode`, `setLightTheme()`, `setDarkTheme()`
|
||||
|
||||
- [ ] **Step 1: 替换主题选择区域**
|
||||
|
||||
Replace the el-card template section (lines 158-173) and the `themeLabels` import:
|
||||
|
||||
**Script 部分修改 import:**
|
||||
```typescript
|
||||
import { useThemeStore, themeLabels, lightThemes, darkThemes, type Theme } from '@/stores/theme'
|
||||
```
|
||||
|
||||
**Template 部分替换主题卡片:**
|
||||
```html
|
||||
<!-- Theme -->
|
||||
<el-card class="setting-card">
|
||||
<template #header>
|
||||
<div class="card-header-title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px; color: var(--gold)">
|
||||
<circle cx="12" cy="12" r="5"></circle><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"></path>
|
||||
</svg>
|
||||
界面主题
|
||||
</div>
|
||||
</template>
|
||||
<div class="theme-config">
|
||||
<div class="theme-row">
|
||||
<span class="theme-label">☀️ 亮色模式主题</span>
|
||||
<el-select :model-value="themeStore.lightTheme" @update:model-value="themeStore.setLightTheme($event as Theme)" style="width:200px">
|
||||
<el-option v-for="t in lightThemes" :key="t" :label="themeLabels[t]" :value="t" />
|
||||
</el-select>
|
||||
<span v-if="themeStore.mode === 'light'" class="theme-badge theme-badge--active">当前</span>
|
||||
</div>
|
||||
<div class="theme-row">
|
||||
<span class="theme-label">🌙 暗色模式主题</span>
|
||||
<el-select :model-value="themeStore.darkTheme" @update:model-value="themeStore.setDarkTheme($event as Theme)" style="width:200px">
|
||||
<el-option v-for="t in darkThemes" :key="t" :label="themeLabels[t]" :value="t" />
|
||||
</el-select>
|
||||
<span v-if="themeStore.mode === 'dark'" class="theme-badge theme-badge--active">当前</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
```
|
||||
|
||||
**Style 部分替换 `.theme-options` 样式:**
|
||||
```css
|
||||
/* ── Theme Config ── */
|
||||
.theme-config { display: flex; flex-direction: column; gap: 12px; }
|
||||
.theme-row { display: flex; align-items: center; gap: 12px; }
|
||||
.theme-label { font-family: var(--font-body); font-size: 14px; color: var(--c-text); min-width: 140px; }
|
||||
.theme-badge { font-size: 11px; padding: 2px 8px; border-radius: 2px; font-family: var(--font-mono); }
|
||||
.theme-badge--active { background: var(--c-primary); color: #fff; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 触发构建验证**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build -- --emptyOutDir
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/desktop/Settings.vue
|
||||
git commit -m "feat: 设置页改为亮/暗双选主题配置面板"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 验证与收尾
|
||||
|
||||
- [ ] **Step 1: 完整构建**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 检查 localStorage 兼容性**
|
||||
|
||||
验证旧 `localStorage.setItem('theme', ...)` 数据不会导致新系统崩溃。在 theme store 的 `apply()` 中已有兜底逻辑。
|
||||
|
||||
- [ ] **Step 3: 快速手动测试检查清单**
|
||||
- [ ] 默认加载为 editorial 亮色
|
||||
- [ ] 点击顶部栏 ☀️→🌙 切换到 tech-dark
|
||||
- [ ] 设置页切换亮色主题为 light,暗色主题为 minimal-dark
|
||||
- [ ] 点击顶部栏按钮在两主题间切换
|
||||
- [ ] 刷新页面偏好保持
|
||||
- [ ] Element Plus 组件(表格/对话框/按钮)在暗色下正常
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: 暗黑模式最终验证通过"
|
||||
```
|
||||
@@ -0,0 +1,130 @@
|
||||
# 商机跟单优化 — 跟进记录子表 — 设计文档
|
||||
|
||||
> 日期:2026-07-12 | 状态:已确认
|
||||
|
||||
## 一、需求概述
|
||||
|
||||
当前商机跟单只有一条静态记录(产品+金额+跟进内容文本+状态),缺少"持续跟进"的时间线感。新增跟进记录子表,将每个商机从"快照"变为"持续跟单"。
|
||||
|
||||
## 二、核心决策
|
||||
|
||||
| 决策项 | 结论 |
|
||||
|--------|------|
|
||||
| 方案 | 新增 `mini_business_logs` 子表 |
|
||||
| 跟进方式 | 电话/微信/上门/邮件/其他(chip 按钮选择) |
|
||||
| 原 follow_up_detail | 保留,含义改为"商机概述/背景" |
|
||||
| 状态 | 不变(跟进中/已签约/已流失) |
|
||||
|
||||
## 三、新增表 `mini_business_logs`
|
||||
|
||||
```sql
|
||||
CREATE TABLE mini_business_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
business_id UUID NOT NULL REFERENCES mini_business(id) ON DELETE CASCADE,
|
||||
log_date DATE NOT NULL,
|
||||
method VARCHAR(20) NOT NULL DEFAULT '电话',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_mbl_business_id ON mini_business_logs(business_id);
|
||||
CREATE INDEX idx_mbl_log_date ON mini_business_logs(log_date);
|
||||
```
|
||||
|
||||
### SQLAlchemy 模型
|
||||
|
||||
```python
|
||||
class MiniBusinessLog(Base):
|
||||
__tablename__ = "mini_business_logs"
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
business_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("mini_business.id", ondelete="CASCADE"), index=True)
|
||||
log_date: Mapped[date] = mapped_column(Date)
|
||||
method: Mapped[str] = mapped_column(String(20), default="电话")
|
||||
content: 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())
|
||||
```
|
||||
|
||||
### MiniBusiness 模型添加 relationship
|
||||
|
||||
```python
|
||||
logs: Mapped[list["MiniBusinessLog"]] = relationship(back_populates="business", cascade="all, delete-orphan")
|
||||
```
|
||||
|
||||
## 四、商机主体字段调整
|
||||
|
||||
| 字段 | 变更 | 前端 label |
|
||||
|------|------|-----------|
|
||||
| `follow_up_detail` | 保留,含义改为商机概述 | "商机概述" |
|
||||
| 其他字段 | 不变 | |
|
||||
|
||||
## 五、API
|
||||
|
||||
### 子路由
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| `GET` | `/mini-business/{id}/logs` | 获取跟进记录列表(date DESC) | 经理仅看自己 |
|
||||
| `POST` | `/mini-business/{id}/logs` | 新增跟进记录 | 经理本人或支局长/领导 |
|
||||
| `PUT` | `/mini-business/logs/{log_id}` | 编辑跟进记录 | 创建人或支局长/领导 |
|
||||
| `DELETE` | `/mini-business/logs/{log_id}` | 删除跟进记录 | 创建人或支局长/领导 |
|
||||
|
||||
### 请求体 (POST/PUT)
|
||||
|
||||
```json
|
||||
{
|
||||
"log_date": "2026-07-12",
|
||||
"method": "电话",
|
||||
"content": "客户反馈价格偏高,需进一步沟通方案"
|
||||
}
|
||||
```
|
||||
|
||||
### 跟进方式枚举
|
||||
|
||||
`["电话", "微信", "上门", "邮件", "其他"]`
|
||||
|
||||
## 六、前端
|
||||
|
||||
### PC 端 — 商机列表页增强
|
||||
|
||||
- 表格新增「最近跟进」列(最近一次跟进日期 + 跟进次数 badge)
|
||||
- 点击客户名打开详情弹窗(替代直接编辑)
|
||||
- 详情弹窗:上部=商机基本信息(客户/产品/金额/状态/概述)+ 编辑按钮,下部=跟进时间线 + 新增跟进入口
|
||||
|
||||
### PC 端 — 跟进时间线组件
|
||||
|
||||
```
|
||||
📞 07-10 电话 — 客户反馈价格偏高,需进一步沟通 [编辑] [删除]
|
||||
🏢 07-05 上门 — 现场演示产品功能,客户初步认可 [编辑] [删除]
|
||||
📧 07-01 邮件 — 发送报价方案 [编辑] [删除]
|
||||
```
|
||||
|
||||
- 按日期倒序,最新在上
|
||||
- 每条显示:方式图标 + 日期 + 内容 + 操作按钮
|
||||
- 底部「新增跟进」按钮展开内联表单(日期+方式 chip+内容 textarea)
|
||||
|
||||
### 移动端 — 商机表单页改为详情页
|
||||
|
||||
- 现有新建表单保留
|
||||
- 新增商机详情页(路由 `/m/mini-biz/:id`)
|
||||
- 上部:商机信息卡片(可点击编辑)
|
||||
- 下部:跟进时间线 + 底部固定「新增跟进」按钮
|
||||
|
||||
### 移动端 — 首页入口
|
||||
|
||||
- Home.vue 的「商机跟单」chip 改为跳转商机列表(需新增 `/m/mini-biz` 路由)
|
||||
|
||||
## 七、文件变更
|
||||
|
||||
| 类型 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| 新增 | `models/mini_business_log.py` | Log 模型 |
|
||||
| 新增 | `schemas/mini_business_log.py` | Log Schema |
|
||||
| 新增 | `api/mini_business_logs.py` | Log CRUD API |
|
||||
| 修改 | `models/mini_business.py` | 添加 logs relationship |
|
||||
| 修改 | `models/__init__.py` | 注册新模型 |
|
||||
| 修改 | `main.py` | lifespan 自动建表 |
|
||||
| 修改 | `MiniBusiness.vue` (PC) | 详情弹窗 + 时间线 |
|
||||
| 修改 | `MiniBusinessForm.vue` (mobile) | 改为详情页模式 |
|
||||
| 新增 | `frontend/src/api/miniBusiness.ts` | 追加 logs API |
|
||||
| 新增 | 移动端商机列表页 | `/m/mini-biz` |
|
||||
@@ -0,0 +1,122 @@
|
||||
# 工作计划状态自动流转 — 设计文档
|
||||
|
||||
> 日期:2026-07-12 | 状态:已确认
|
||||
|
||||
## 一、需求概述
|
||||
|
||||
工作计划目前有三种状态(计划中/已完成/已取消),完全依赖手动切换。利用已有的拜访记录数据,实现状态的自动流转,减少客户经理手动操作。
|
||||
|
||||
## 二、核心决策
|
||||
|
||||
| 决策项 | 结论 |
|
||||
|--------|------|
|
||||
| 匹配精度 | 仅匹配 `customer_id`(任何人拜访该客户即触发,与现有逻辑一致) |
|
||||
| 增强范围 | 创建/更新触发完成 + 逾期自动取消 + Excel 导入联动 |
|
||||
| 删除回退 | 不做(边缘场景少,复杂度高) |
|
||||
|
||||
## 三、现状
|
||||
|
||||
- `POST /visits/` 已有自动完成逻辑:同一 `customer_id` + `plan_date <= visit_date` → 标记"已完成"
|
||||
- 该逻辑嵌在 API 层,不可复用
|
||||
- 更新拜访、Excel 导入均不触发自动完成
|
||||
- 逾期计划(`plan_date < today`)只发企微提醒,不自动取消
|
||||
|
||||
## 四、设计
|
||||
|
||||
### 4.1 抽取可复用函数
|
||||
|
||||
**文件:** `backend/app/services/visits.py`(如不存在则新建,如已有则追加)
|
||||
|
||||
```python
|
||||
async def auto_complete_work_plans(
|
||||
db: AsyncSession,
|
||||
customer_id: UUID,
|
||||
visit_date: date,
|
||||
editor_name: str,
|
||||
reason: str = "拜访自动完成",
|
||||
) -> int:
|
||||
"""将匹配的工作计划自动标记为已完成。返回完成数量。"""
|
||||
from app.models.work_plan import WorkPlan
|
||||
from app.utils.edit_log import append_entry as append_edit_log
|
||||
|
||||
plans_result = await db.execute(
|
||||
select(WorkPlan).where(
|
||||
WorkPlan.customer_id == customer_id,
|
||||
WorkPlan.status == "计划中",
|
||||
WorkPlan.plan_date <= visit_date,
|
||||
)
|
||||
)
|
||||
count = 0
|
||||
for plan in plans_result.scalars().all():
|
||||
plan.status = "已完成"
|
||||
append_edit_log(plan, editor_name, [{
|
||||
"field": "status", "from": "计划中", "to": "已完成",
|
||||
"reason": reason,
|
||||
}])
|
||||
count += 1
|
||||
return count
|
||||
```
|
||||
|
||||
### 4.2 三处调用点
|
||||
|
||||
| 调用点 | 文件 | 触发时机 | reason 参数 |
|
||||
|--------|------|---------|------------|
|
||||
| POST /visits/ | `api/visits.py` | 创建拜访后 | `"拜访自动完成"` |
|
||||
| PUT /visits/{id} | `api/visits.py` | 更新拜访后 | `"拜访更新自动完成"` |
|
||||
| excel_import.py | `services/excel_import.py` | 导入每条拜访后 | `"旧周报导入自动完成"` |
|
||||
|
||||
### 4.3 逾期计划自动取消
|
||||
|
||||
**文件:** `backend/app/services/scheduler.py`,`check_overdue_plans` 函数
|
||||
|
||||
在现有「发送企微提醒」逻辑后新增:
|
||||
|
||||
```python
|
||||
# 自动取消:逾期且无匹配拜访记录的计划
|
||||
for plan in overdue:
|
||||
has_visit = await db.execute(
|
||||
select(Visit.id).where(
|
||||
Visit.customer_id == plan.customer_id,
|
||||
Visit.visit_date >= plan.plan_date,
|
||||
)
|
||||
)
|
||||
if not has_visit.scalar():
|
||||
plan.status = "已取消"
|
||||
append_edit_log(plan, "系统", [{
|
||||
"field": "status", "from": "计划中", "to": "已取消",
|
||||
"reason": "逾期自动取消",
|
||||
}])
|
||||
```
|
||||
|
||||
每日 09:00 执行,与现有逾期检查共用一个调度任务。
|
||||
|
||||
### 4.4 状态流转总图
|
||||
|
||||
```
|
||||
计划中 ──┬── 拜访创建/更新/导入(customer_id + plan_date <= visit_date)──→ 已完成
|
||||
│
|
||||
└── 每日 09:00 调度(plan_date < today 且无匹配拜访)───────────→ 已取消
|
||||
```
|
||||
|
||||
## 五、文件变更
|
||||
|
||||
### 后端新增/修改
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `backend/app/services/visits.py` | 新增 `auto_complete_work_plans()` 函数 |
|
||||
| `backend/app/api/visits.py` | POST 重构为调用 service 函数;PUT 新增调用 |
|
||||
| `backend/app/services/excel_import.py` | 导入拜访后调用自动完成 |
|
||||
| `backend/app/services/scheduler.py` | `check_overdue_plans` 新增自动取消逻辑 |
|
||||
|
||||
### 前端
|
||||
|
||||
无需改动。
|
||||
|
||||
## 六、验证要点
|
||||
|
||||
1. 创建拜访 → 匹配的计划自动变为"已完成",edit_log 有记录
|
||||
2. 更新拜访日期 → 匹配的计划自动完成
|
||||
3. 导入旧周报 → 导入的拜访触发计划自动完成
|
||||
4. 逾期且无拜访的计划 → 每日 09:00 自动变为"已取消"
|
||||
5. 逾期但有拜访的计划 → 保持"计划中"(已有拜访覆盖,不算僵尸)
|
||||
@@ -0,0 +1,156 @@
|
||||
# 协同拜访记录去重 — 设计方案
|
||||
|
||||
## 背景
|
||||
|
||||
当前机制:客户经理 A 创建拜访记录并选择协同人员 B 时,后端为 B 生成一条空内容的草稿副本。B 填写后,同一客户、同一天出现两条独立拜访记录,导致周报重复展示、亮灯表重复计数。
|
||||
|
||||
## 设计目标
|
||||
|
||||
1. 周报中同一客户同一次拜访合并展示为一张卡片
|
||||
2. 亮灯表不重复计数
|
||||
3. 每个参与人的填报进度不受影响(各自计入)
|
||||
4. 每个参与人能独立编辑自己的副本
|
||||
5. 历史数据兼容,不做强制迁移
|
||||
|
||||
## 方案概要
|
||||
|
||||
### 1. 数据库:新增 `visit_group_id`
|
||||
|
||||
```sql
|
||||
ALTER TABLE visits ADD COLUMN visit_group_id UUID;
|
||||
CREATE INDEX ix_visits_visit_group_id ON visits(visit_group_id);
|
||||
```
|
||||
|
||||
- 单人拜访:`visit_group_id = NULL`
|
||||
- 多人协同拜访:同一次拜访的所有记录共享同一个 `visit_group_id`
|
||||
|
||||
### 2. 后端:POST /visits/ 改造
|
||||
|
||||
**原逻辑:** 为每个协同人生成一条空内容草稿(`communication_content=""`, `photos=[]`, `companions=[]`)
|
||||
|
||||
**新逻辑:** 为每个协同人生成一条完整副本:
|
||||
|
||||
| 副本字段 | 值 | 说明 |
|
||||
|----------|-----|------|
|
||||
| `customer_id` | 同主记录 | |
|
||||
| `visit_date` | 同主记录 | |
|
||||
| `visit_method` | 同主记录 | |
|
||||
| `time_range` | 同主记录 | |
|
||||
| `visitor_name` | 同主记录 | 实际拜访人不变 |
|
||||
| `visitor_phone` | 同主记录 | |
|
||||
| `communication_content` | 原内容 + `(协同XXX)` | 如:`了解项目进度...(协同韦佶秀)` |
|
||||
| `customer_demand` | 同主记录 | |
|
||||
| `companions` | `[主记录创建人]` | 互换:协同人看到主访人 |
|
||||
| `companion_names` | 同主记录 | |
|
||||
| `photos` | 同主记录 | |
|
||||
| `manager_id` | 协同人的 UUID | 归属到协同人名下 |
|
||||
| `visit_group_id` | 同主记录 | **同一 UUID,关联所有副本** |
|
||||
|
||||
**前端表现(创建人韦佶秀视角):**
|
||||
|
||||
```
|
||||
相关人员选择了 [韦伦]
|
||||
→ 保存后韦伦收到一条完整拜访记录
|
||||
→ 韦伦记录内容末尾自动追加"(协同韦佶秀)"
|
||||
```
|
||||
|
||||
### 3. 后端:PUT /visits/{id} 不变
|
||||
|
||||
协同人编辑自己的副本时不影响主记录,各自独立。
|
||||
|
||||
### 4. 仪表盘统计:按 visit_group_id 去重
|
||||
|
||||
`get_dashboard_stats` 中的 `week_visits` 改为:
|
||||
|
||||
```python
|
||||
# 原:count 所有 visit 行
|
||||
select count(distinct coalesce(visit_group_id, id))
|
||||
from visits
|
||||
where visit_date between monday and sunday
|
||||
```
|
||||
|
||||
每个 visit_group_id 只算一次,跳过 NULL(单人拜访用 id 做 fallback key)。
|
||||
|
||||
### 5. 填报进度:不变
|
||||
|
||||
`get_reporting_progress` 按 `manager_id` 分组计数,每个参与人各算一次,不受去重影响。
|
||||
|
||||
### 6. 亮灯表:按 (customer_id, visit_date, visit_group_id) 去重
|
||||
|
||||
同一客户同一天同一次拜访只亮一盏灯。
|
||||
|
||||
### 7. 周报展示:按 visit_group_id 合并
|
||||
|
||||
`get_weekly_report` 查询时,同一个 `visit_group_id` 的记录合并成一条输出:
|
||||
|
||||
```json
|
||||
{
|
||||
"customer_name": "污水处理厂",
|
||||
"visit_date": "2026-07-30",
|
||||
"visit_method": "上门",
|
||||
"time_range": "09:00-10:30",
|
||||
"visit_group_id": "abc-123",
|
||||
"participants": [
|
||||
{
|
||||
"manager_name": "韦佶秀",
|
||||
"manager_id": "uuid-1",
|
||||
"role": "primary",
|
||||
"communication_content": "了解污水处理新项目...",
|
||||
"customer_demand": "专线扩容至100M...",
|
||||
"photos": ["photo_001", "photo_002"]
|
||||
},
|
||||
{
|
||||
"manager_name": "韦伦",
|
||||
"manager_id": "uuid-2",
|
||||
"role": "companion",
|
||||
"communication_content": "了解污水处理新项目...(协同韦佶秀)",
|
||||
"customer_demand": "专线扩容至100M...",
|
||||
"photos": ["photo_001", "photo_002"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**前端周报卡片展示:**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 污水处理厂 2026-07-30 上门 │
|
||||
│ 09:00-10:30 │
|
||||
│ │
|
||||
│ ▸ 韦佶秀(主访) │
|
||||
│ 了解污水处理新项目建设进度,客户计划新增... │
|
||||
│ 客户需求:专线扩容至100M,新增视频监控... │
|
||||
│ 📷 2张 │
|
||||
│ │
|
||||
│ ▸ 韦伦(协同) │
|
||||
│ 了解污水处理新项目建设进度...(协同韦佶秀) │
|
||||
│ 客户需求:专线扩容至100M... │
|
||||
│ 📷 2张 │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 8. 前端:协同记录编辑提示
|
||||
|
||||
协同人打开自己的副本编辑时,沟通内容区域上方显示一行提示:
|
||||
|
||||
> 💡 此记录为协同韦佶秀拜访「污水处理厂」的副本。你可以补充或修改内容,不会影响主记录。
|
||||
|
||||
## 改动清单
|
||||
|
||||
| 层 | 文件 | 改动 |
|
||||
|----|------|------|
|
||||
| DB | migration | `ALTER TABLE visits ADD visit_group_id UUID` |
|
||||
| 后端 | `models/visit.py` | 新增 `visit_group_id` 字段 |
|
||||
| 后端 | `schemas/visit.py` | `VisitOut` 新增 `visit_group_id` |
|
||||
| 后端 | `api/visits.py` POST | 副本改为完整复制 + `(协同XXX)` 后缀 + 互换 companions + 设置 visit_group_id |
|
||||
| 后端 | `services/dashboard.py` | `get_dashboard_stats` 的 week_visits 改为 distinct 去重 |
|
||||
| 后端 | `services/dashboard.py` | `get_weekly_report` 合并同 visit_group_id 记录 |
|
||||
| 后端 | `services/light_board.py` | 亮灯计数去重 |
|
||||
| 前端 | 周报 `WeeklyReport.vue` | 拜访卡片支持 participants 展开/折叠 |
|
||||
| 前端 | 移动端 `VisitForm.vue` | 协同记录编辑时显示提示条 |
|
||||
|
||||
## 兼容性
|
||||
|
||||
- 历史数据 `visit_group_id = NULL`,行为与现在完全一致
|
||||
- 前端 `participants` 字段回退:单 participant 时展示与现在无差异
|
||||
@@ -0,0 +1,153 @@
|
||||
# 企业微信 OAuth 静默登录 — 踩坑记录
|
||||
|
||||
## 目标
|
||||
|
||||
用户在企微内点击菜单 → 自动登录 → 直达应用首页,零手动操作。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
企微菜单(view) → OAuth authorize URL(snsapi_base)
|
||||
→ 企微静默获取 code
|
||||
→ 302 到 callback URL
|
||||
→ 后端换 wecom_userid → 查 DB → 签 JWT
|
||||
→ 返回 HTML 页面(JS 写 localStorage + 跳转)
|
||||
→ 前端启动 → auth store 读到 localStorage → 已登录
|
||||
```
|
||||
|
||||
## 踩坑清单
|
||||
|
||||
### 坑 1:`redirect_uri` 不能夹带自定义 query 参数
|
||||
|
||||
**错误做法:**
|
||||
|
||||
```
|
||||
redirect_uri = https://xxx.com/api/callback?redirect=/m
|
||||
```
|
||||
|
||||
企微 OAuth 完成后会在 `redirect_uri` 后追加 `?code=xxx&state=xxx`,但可能覆盖或丢弃原有的 query 参数。
|
||||
|
||||
**正确做法:** 把自定义参数编码到 `state` 里:
|
||||
|
||||
```python
|
||||
state = f"r={urllib.parse.quote('/m')}"
|
||||
redirect_uri = "https://xxx.com/api/callback" # 干净,无额外 query
|
||||
```
|
||||
|
||||
回调时从 `state` 中解析:
|
||||
|
||||
```python
|
||||
redirect = "/m"
|
||||
if state.startswith("r="):
|
||||
redirect = urllib.parse.unquote(state[2:])
|
||||
```
|
||||
|
||||
### 坑 2:必须配置可信域名
|
||||
|
||||
企微后台 → 应用管理 → 网页授权及 JS-SDK → 设置可信域名。
|
||||
|
||||
不加的话 OAuth 直接报错:`redirect_uri 需使用应用可信域名`。
|
||||
|
||||
验证文件(`WW_verify_xxx.txt`)需要放在前端可访问的静态目录下。
|
||||
|
||||
### 坑 3:`agentid` 参数
|
||||
|
||||
内部应用使用 `snsapi_base` 静默授权时,OAuth URL 必须带 `agentid`:
|
||||
|
||||
```
|
||||
?appid=CORPID&agentid=AGENTID&redirect_uri=...
|
||||
```
|
||||
|
||||
不加的话企微可能静默授权失败(表现:跳到登录页而不是自动登录)。
|
||||
|
||||
### 坑 4:服务端 302/307 重定向在 webview 中不可靠
|
||||
|
||||
**这是最大的坑。** 后端回调返回 HTTP 重定向(307/302),企微内置浏览器可能不跟进,或跟进时丢掉 query 参数。
|
||||
|
||||
**解决方案:** 回调返回 HTML 页面,用 JS 做客户端跳转。
|
||||
|
||||
```python
|
||||
html = """<!DOCTYPE html><html><head><meta charset="utf-8"></head>
|
||||
<body><script>
|
||||
var d = {auth_data_json};
|
||||
localStorage.setItem('token', d.token);
|
||||
localStorage.setItem('userId', d.userId);
|
||||
localStorage.setItem('userName', d.userName);
|
||||
localStorage.setItem('userRole', d.userRole);
|
||||
window.location.replace({frontend_url_json});
|
||||
</script></body></html>"""
|
||||
return HTMLResponse(content=html, status_code=200)
|
||||
```
|
||||
|
||||
**为什么直接写 localStorage 而不是 URL 传 token?**
|
||||
|
||||
- URL `?token=xxx` 需要前端路由守卫解析 JWT(`atob` 解码)
|
||||
- 企微 webview 中 `atob` 可能不可靠
|
||||
- `localStorage` 是浏览器原生 API,前端 auth store 启动时直接读取
|
||||
|
||||
### 坑 5:用户名字符串中的特殊字符
|
||||
|
||||
如果直接把用户名拼进 JS 字符串,包含 `'` `"` `\` 会破坏 JS 语法。
|
||||
|
||||
**安全做法:** 整个 auth data 用 `json.dumps` 序列化后直接作为 JS 对象字面量:
|
||||
|
||||
```python
|
||||
import json
|
||||
auth_data = json.dumps({
|
||||
"token": token,
|
||||
"userId": str(user.id),
|
||||
"userName": user.name, # json.dumps 会自动转义特殊字符
|
||||
"userRole": user.role,
|
||||
"theme": user.theme or "editorial",
|
||||
}, ensure_ascii=False)
|
||||
# 在 HTML 模板中:
|
||||
# var d = {auth_data}; ← 直接是 JS 对象,不需要 JSON.parse
|
||||
```
|
||||
|
||||
### 坑 6:`wecom_userid` 大小写
|
||||
|
||||
数据库中存的 `wecom_userid` 可能与 OAuth 返回的大小写不一致(如 `WeiJueSen` vs `weijuesen`)。
|
||||
|
||||
**修复:** 查询时做大小写不敏感匹配:
|
||||
|
||||
```python
|
||||
from sqlalchemy import func
|
||||
result = await db.execute(
|
||||
select(User).where(func.lower(User.wecom_userid) == wecom_userid.lower())
|
||||
)
|
||||
```
|
||||
|
||||
### 坑 7:Login 页面死循环
|
||||
|
||||
Login.vue 检测到企微环境后自动跳 OAuth,但如果 OAuth 回调又回到 Login 页,就形成死循环。
|
||||
|
||||
**原因分析:**
|
||||
- OAuth 成功 → 回调 → 写 localStorage → 跳到首页
|
||||
- 如果 localStorage 写入失败或 token 无效 → 路由守卫 → /login
|
||||
- Login 检测 isWecom() → 又跳 OAuth → 死循环
|
||||
|
||||
**防护:** 在 Login 页跳 OAuth 前检查是否刚从 OAuth 回来(如 URL 带 `?from_wecom=1`),避免重复跳转。本项目通过 localStorage 写入 + 前端 auth store 从 localStorage 初始化避开了这个问题。
|
||||
|
||||
## 调试技巧
|
||||
|
||||
1. **后端日志看回调是否被触发:** `grep oauth-callback /var/log/app.log`
|
||||
2. **HTTP 状态码:** 回调返回 307 说明走到了 RedirectResponse,返回 200 说明走了 HTML 方案
|
||||
3. **检查绑定状态:** 查数据库确认 `users.wecom_userid` 有值
|
||||
4. **OAuth URL 自测:** 浏览器直接访问 `/api/wecom/oauth-url`,看 302 跳转的目标 URL 是否正确
|
||||
|
||||
## 完整代码量
|
||||
|
||||
| 文件 | 新增行数 | 说明 |
|
||||
|------|---------|------|
|
||||
| 后端 OAuth 回调 | ~30 行 | `_build_oauth_url` + `oauth-callback` 端点 |
|
||||
| 前端路由守卫 | ~15 行 | 处理 `?token=` 参数自动登录(本方案中作为 fallback) |
|
||||
| 前端 Login 页 | ~8 行 | 企微环境检测 + 跳 OAuth |
|
||||
|
||||
## 最终效果
|
||||
|
||||
用户视角:企微点击「开始填报」→ 白屏 0.5 秒 → 首页。
|
||||
|
||||
技术视角:
|
||||
```
|
||||
点菜单 → OAuth(snsapi_base静默) → 回调HTML → JS写localStorage → 跳首页 → 已登录
|
||||
```
|
||||
@@ -0,0 +1,53 @@
|
||||
# 企迹系统发布与回滚检查表
|
||||
|
||||
> 每次生产发布由发布负责人逐项勾选,并将结果随变更单、流水线和制品一并归档。
|
||||
|
||||
## 1. 发布前
|
||||
|
||||
- [ ] 变更已合并至受保护分支,MR 审核记录完整。
|
||||
- [ ] 已创建发布 Tag,记录提交 SHA、镜像摘要和发布日期。
|
||||
- [ ] CI 全部通过:后端测试、前端构建、Ruff、Bandit、依赖漏洞审计、SBOM、Trivy 文件系统扫描。
|
||||
- [ ] 已归档 `sbom-backend.cdx.json`、测试报告和扫描报告。
|
||||
- [ ] 生产密钥由受控环境或密钥管理系统注入,未写入 Git、镜像层或发布记录。
|
||||
- [ ] 数据库已完成备份,并验证备份可读取;回滚负责人和窗口已确认。
|
||||
- [ ] 对象存储桶为私有,应用服务账号采用最小读写权限。
|
||||
|
||||
## 2. 数据库迁移
|
||||
|
||||
在维护窗口、使用生产应用运行身份执行:
|
||||
|
||||
```bash
|
||||
cd /path/to/qiji/backend
|
||||
alembic current
|
||||
alembic upgrade head
|
||||
alembic current
|
||||
```
|
||||
|
||||
- [ ] `alembic current` 显示预期修订版本。
|
||||
- [ ] 核验 `users.wecom_userid` 的唯一索引已创建。
|
||||
- [ ] 如迁移失败,停止发布;不得通过手工修改表结构绕过迁移记录。
|
||||
|
||||
## 3. 发布与验证
|
||||
|
||||
- [ ] 使用指定 Tag/镜像摘要部署;禁止直接从未审核工作区运行。
|
||||
- [ ] 服务以受控 systemd 服务或编排平台运行,未使用开发热重载。
|
||||
- [ ] 仅反向代理对公网开放 HTTPS;数据库、缓存、MinIO 管理端口不对公网监听。
|
||||
- [ ] 健康检查通过:`GET /health`。
|
||||
- [ ] 回归登录、角色授权、企微绑定、受控图片上传和图片下载授权。
|
||||
- [ ] 抽查应用日志,确认未输出口令、密钥、令牌或用户输入正文。
|
||||
|
||||
## 4. 回滚
|
||||
|
||||
触发条件:迁移失败、健康检查失败、关键业务回归失败、出现高危安全告警。
|
||||
|
||||
1. 停止本次发布流量切换,恢复上一稳定版本的镜像/制品。
|
||||
2. 仅在已验证可逆且有审批的情况下执行 `alembic downgrade`;基线迁移不允许通过盲目降级删除历史表。
|
||||
3. 如数据不兼容,按已验证的备份恢复方案处理,并保留操作审计。
|
||||
4. 复测健康接口和关键业务,记录故障、影响范围、回滚时间和后续整改项。
|
||||
|
||||
## 5. 发布归档
|
||||
|
||||
- [ ] 变更单与审批记录
|
||||
- [ ] Git Tag、提交 SHA、镜像摘要和 SBOM
|
||||
- [ ] 测试、扫描、迁移和部署日志
|
||||
- [ ] 上线验证与回滚演练记录
|
||||
@@ -6,6 +6,14 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>企迹 - 政企周报管理系统</title>
|
||||
|
||||
<!-- Apply saved theme before page render to prevent flash -->
|
||||
<script>
|
||||
(function() {
|
||||
var theme = localStorage.getItem('theme') || 'editorial';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Preconnect to Google Fonts origins for faster font loading -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
|
||||
@@ -177,6 +177,215 @@ const isMobile = computed(() => {
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
THEME: Minimal Dark — 极简深色
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
:root[data-theme="minimal-dark"] {
|
||||
/* ── Core palette ── */
|
||||
--ink: #E5E5E5;
|
||||
--ink-light: #CCCCCC;
|
||||
--ink-dark: #FFFFFF;
|
||||
--vermilion: #F87171;
|
||||
--vermilion-light: #FCA5A5;
|
||||
--vermilion-dark: #EF4444;
|
||||
--gold: #A78BFA;
|
||||
--gold-light: #C4B5FD;
|
||||
--gold-dark: #8B5CF6;
|
||||
--paper: #141414;
|
||||
--paper-dark: #0D0D0D;
|
||||
--surface: #2D2D2D;
|
||||
--sage: #4ADE80;
|
||||
--amber: #FBBF24;
|
||||
--warm-gray: #999999;
|
||||
--warm-border: #3A3A3A;
|
||||
|
||||
/* ── Semantic tokens ── */
|
||||
--c-primary: var(--ink);
|
||||
--c-primary-light: var(--ink-light);
|
||||
--c-primary-bg: #262626;
|
||||
--c-accent: var(--vermilion);
|
||||
--c-accent-light: var(--vermilion-light);
|
||||
--c-gold: var(--gold);
|
||||
--c-success: var(--sage);
|
||||
--c-warning: var(--amber);
|
||||
--c-danger: var(--vermilion);
|
||||
--c-info: #60A5FA;
|
||||
--c-text: #E5E5E5;
|
||||
--c-text-secondary: #999999;
|
||||
--c-text-muted: #666666;
|
||||
--c-bg: var(--paper);
|
||||
--c-bg-card: var(--surface);
|
||||
--c-border: var(--warm-border);
|
||||
|
||||
/* ── Effects ── */
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.20);
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.30);
|
||||
--shadow-md: 0 2px 6px rgba(0,0,0,0.40);
|
||||
--shadow-lg: 0 4px 12px rgba(0,0,0,0.50);
|
||||
--radius: 0px;
|
||||
--radius-sm: 0px;
|
||||
--radius-xs: 0px;
|
||||
--transition: 0.18s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ── Misc dark tokens ── */
|
||||
--c-bg-light: #1E1E1E;
|
||||
|
||||
/* ── Sidebar — dark minimal ── */
|
||||
--sidebar-bg: #1A1A1A;
|
||||
--sidebar-color: #E5E5E5;
|
||||
--sidebar-accent: var(--gold);
|
||||
--sidebar-nav-text: #777777;
|
||||
--sidebar-nav-text-hover: #CCCCCC;
|
||||
--sidebar-nav-text-active: #FFFFFF;
|
||||
--sidebar-nav-bg-hover: rgba(255,255,255,0.04);
|
||||
--sidebar-nav-bg-active: rgba(255,255,255,0.06);
|
||||
--sidebar-nav-border-active: var(--gold);
|
||||
--sidebar-divider: #2A2A2A;
|
||||
--sidebar-brand-color: #FFFFFF;
|
||||
--sidebar-brand-sub-color: #666666;
|
||||
--sidebar-group-label-color: #555555;
|
||||
--sidebar-footer-role-color: var(--gold);
|
||||
--sidebar-footer-name-color: #999999;
|
||||
|
||||
/* ── Mobile page accent ── */
|
||||
--page-accent-color: rgba(167,139,250,0.06);
|
||||
|
||||
/* ── Manager chip tags ── */
|
||||
--mgr-chip-text: #E5E5E5;
|
||||
|
||||
/* ── Fonts — sans-serif minimal ── */
|
||||
--font-body: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--font-heading: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
THEME: Tech Dark — 科技深色 (编辑风暗色版)
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
:root[data-theme="tech-dark"] {
|
||||
/* ── Core palette ── */
|
||||
--ink: #C8D6E5;
|
||||
--ink-light: #A0B4CC;
|
||||
--ink-dark: #E8EEF4;
|
||||
--vermilion: #F26B6B;
|
||||
--vermilion-light: #F59898;
|
||||
--vermilion-dark: #E55555;
|
||||
--gold: #5B8DEF;
|
||||
--gold-light: #7BA5F5;
|
||||
--gold-dark: #4070D0;
|
||||
--paper: #070D1A;
|
||||
--paper-dark: #050910;
|
||||
--surface: #111B2E;
|
||||
--sage: #4EC9B0;
|
||||
--amber: #E5C07B;
|
||||
--warm-gray: #6B7A8D;
|
||||
--warm-border: #1E2D45;
|
||||
|
||||
/* ── Semantic tokens ── */
|
||||
--c-primary: var(--ink);
|
||||
--c-primary-light: var(--ink-light);
|
||||
--c-primary-bg: #131D30;
|
||||
--c-accent: var(--vermilion);
|
||||
--c-accent-light: var(--vermilion-light);
|
||||
--c-gold: var(--gold);
|
||||
--c-success: var(--sage);
|
||||
--c-warning: var(--amber);
|
||||
--c-danger: var(--vermilion);
|
||||
--c-info: #5B8DEF;
|
||||
--c-text: #C8D6E5;
|
||||
--c-text-secondary: #6B7A8D;
|
||||
--c-text-muted: #455570;
|
||||
--c-bg: var(--paper);
|
||||
--c-bg-card: var(--surface);
|
||||
--c-border: var(--warm-border);
|
||||
|
||||
/* ── Effects ── */
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.25);
|
||||
--shadow: 0 2px 6px rgba(0,0,0,0.35);
|
||||
--shadow-md: 0 6px 18px rgba(0,0,0,0.45);
|
||||
--shadow-lg: 0 12px 36px rgba(0,0,0,0.55);
|
||||
--radius: 2px;
|
||||
--radius-sm: 2px;
|
||||
--radius-xs: 2px;
|
||||
--transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ── Misc dark tokens ── */
|
||||
--c-bg-light: #101A28;
|
||||
|
||||
/* ── Sidebar — dark tech ── */
|
||||
--sidebar-bg: #0B1120;
|
||||
--sidebar-color: #C8D6E5;
|
||||
--sidebar-accent: var(--gold);
|
||||
--sidebar-nav-text: rgba(200,214,229,0.45);
|
||||
--sidebar-nav-text-hover: rgba(200,214,229,0.80);
|
||||
--sidebar-nav-text-active: #FFFFFF;
|
||||
--sidebar-nav-bg-hover: rgba(91,141,239,0.06);
|
||||
--sidebar-nav-bg-active: rgba(91,141,239,0.10);
|
||||
--sidebar-nav-border-active: var(--gold);
|
||||
--sidebar-divider: rgba(200,214,229,0.06);
|
||||
--sidebar-brand-color: #FFFFFF;
|
||||
--sidebar-brand-sub-color: rgba(200,214,229,0.35);
|
||||
--sidebar-group-label-color: rgba(200,214,229,0.20);
|
||||
--sidebar-footer-role-color: var(--gold);
|
||||
--sidebar-footer-name-color: rgba(200,214,229,0.65);
|
||||
|
||||
/* ── Mobile page accent ── */
|
||||
--page-accent-color: rgba(91,141,239,0.06);
|
||||
|
||||
/* ── Manager chip tags ── */
|
||||
--mgr-chip-text: #C8D6E5;
|
||||
|
||||
/* ── Fonts — editorial Chinese ── */
|
||||
--font-body: 'Noto Serif SC', STSong, Songti SC, '宋体', serif;
|
||||
--font-heading: 'ZCOOL XiaoWei', STSong, Songti SC, serif;
|
||||
--font-mono: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
Dark Mode — Element Plus CSS Variable Overrides
|
||||
Override --el-* variables so ALL components render dark
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
html.dark {
|
||||
--el-color-white: var(--surface);
|
||||
--el-color-black: var(--c-text);
|
||||
--el-color-primary: var(--c-gold);
|
||||
--el-color-primary-light-3: var(--gold-light);
|
||||
--el-color-primary-light-5: var(--gold-light);
|
||||
--el-color-primary-light-7: var(--c-primary-bg);
|
||||
--el-color-primary-light-8: var(--c-primary-bg);
|
||||
--el-color-primary-light-9: var(--paper-dark);
|
||||
--el-color-primary-dark-2: var(--gold-dark);
|
||||
--el-color-success: var(--c-success);
|
||||
--el-color-warning: var(--c-warning);
|
||||
--el-color-danger: var(--c-danger);
|
||||
--el-color-info: var(--c-info);
|
||||
--el-bg-color: var(--paper);
|
||||
--el-bg-color-page: var(--paper);
|
||||
--el-bg-color-overlay: var(--surface);
|
||||
--el-text-color-primary: var(--c-text);
|
||||
--el-text-color-regular: var(--c-text);
|
||||
--el-text-color-secondary: var(--c-text-secondary);
|
||||
--el-text-color-placeholder: var(--c-text-muted);
|
||||
--el-text-color-disabled: var(--c-text-muted);
|
||||
--el-border-color: var(--warm-border);
|
||||
--el-border-color-light: var(--warm-border);
|
||||
--el-border-color-lighter: var(--warm-border);
|
||||
--el-border-color-extra-light: var(--warm-border);
|
||||
--el-border-color-dark: var(--c-text-muted);
|
||||
--el-fill-color: var(--c-primary-bg);
|
||||
--el-fill-color-light: var(--c-primary-bg);
|
||||
--el-fill-color-lighter: var(--paper-dark);
|
||||
--el-fill-color-blank: var(--surface);
|
||||
--el-disabled-bg-color: var(--paper-dark);
|
||||
--el-disabled-text-color: var(--c-text-muted);
|
||||
--el-disabled-border-color: var(--warm-border);
|
||||
--el-mask-color: rgba(0,0,0,0.6);
|
||||
--el-mask-color-extra-light: rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* ── Reset & Base ── */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
@@ -466,4 +675,211 @@ body.col-resizing {
|
||||
cursor: col-resize !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
Dark Mode — Element Plus Global Overrides
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
html.dark .el-input__wrapper {
|
||||
background: var(--surface) !important;
|
||||
box-shadow: 0 0 0 1px var(--warm-border) inset !important;
|
||||
}
|
||||
html.dark .el-input__inner {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-input__wrapper:hover {
|
||||
box-shadow: 0 0 0 1px var(--c-text-muted) inset !important;
|
||||
}
|
||||
html.dark .el-input__wrapper.is-focus {
|
||||
box-shadow: 0 0 0 2px var(--gold) inset !important;
|
||||
}
|
||||
|
||||
/* Select dropdown */
|
||||
html.dark .el-select-dropdown {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--warm-border) !important;
|
||||
}
|
||||
html.dark .el-select-dropdown__item {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-select-dropdown__item:hover {
|
||||
background: var(--c-primary-bg) !important;
|
||||
}
|
||||
html.dark .el-select-dropdown__item.selected {
|
||||
color: var(--gold) !important;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
html.dark .el-table {
|
||||
--el-table-bg-color: var(--surface) !important;
|
||||
--el-table-tr-bg-color: var(--surface) !important;
|
||||
--el-table-header-bg-color: var(--c-primary-bg) !important;
|
||||
--el-table-row-hover-bg-color: var(--c-primary-bg) !important;
|
||||
--el-table-border-color: var(--warm-border) !important;
|
||||
--el-table-text-color: var(--c-text) !important;
|
||||
--el-table-header-text-color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-table th.el-table__cell {
|
||||
background: var(--c-primary-bg) !important;
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-table td.el-table__cell {
|
||||
background: var(--surface) !important;
|
||||
}
|
||||
html.dark .el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
|
||||
background: var(--c-primary-bg) !important;
|
||||
}
|
||||
html.dark .el-table__body tr:hover > td.el-table__cell {
|
||||
background: var(--c-primary-bg) !important;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
html.dark .el-pagination button,
|
||||
html.dark .el-pager li {
|
||||
background: var(--surface) !important;
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-pager li.is-active {
|
||||
background: var(--ink) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
html.dark .el-pagination button:hover,
|
||||
html.dark .el-pager li:hover {
|
||||
color: var(--gold) !important;
|
||||
}
|
||||
|
||||
/* Button — default / text variant */
|
||||
html.dark .el-button--default {
|
||||
--el-button-bg-color: var(--surface);
|
||||
--el-button-border-color: var(--warm-border);
|
||||
--el-button-text-color: var(--c-text);
|
||||
--el-button-hover-bg-color: var(--c-primary-bg);
|
||||
--el-button-hover-border-color: var(--c-text-muted);
|
||||
--el-button-hover-text-color: var(--c-text);
|
||||
}
|
||||
|
||||
/* Dialog */
|
||||
html.dark .el-dialog {
|
||||
--el-dialog-bg-color: var(--surface);
|
||||
}
|
||||
html.dark .el-dialog__title {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-dialog__header {
|
||||
border-bottom: 1px solid var(--warm-border);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
html.dark .el-tabs__item {
|
||||
color: var(--c-text-secondary) !important;
|
||||
}
|
||||
html.dark .el-tabs__item.is-active {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-tabs__header {
|
||||
border-bottom-color: var(--warm-border);
|
||||
}
|
||||
|
||||
/* Tag */
|
||||
html.dark .el-tag {
|
||||
--el-tag-bg-color: var(--c-primary-bg);
|
||||
--el-tag-border-color: var(--warm-border);
|
||||
--el-tag-text-color: var(--c-text);
|
||||
}
|
||||
|
||||
/* Form label */
|
||||
html.dark .el-form-item__label {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
|
||||
/* Progress */
|
||||
html.dark .el-progress-bar__outer {
|
||||
background: var(--paper-dark) !important;
|
||||
}
|
||||
|
||||
/* Date picker panel */
|
||||
html.dark .el-picker-panel {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--warm-border) !important;
|
||||
}
|
||||
html.dark .el-date-picker__header-label {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-date-table td {
|
||||
color: var(--c-text);
|
||||
}
|
||||
html.dark .el-date-table td.current:not(.disabled) .el-date-table-cell__text {
|
||||
background: var(--gold) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
html.dark .el-date-table td.today .el-date-table-cell__text {
|
||||
color: var(--gold) !important;
|
||||
}
|
||||
|
||||
/* Time picker panel */
|
||||
html.dark .el-time-panel {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--warm-border) !important;
|
||||
}
|
||||
html.dark .el-time-spinner__item {
|
||||
color: var(--c-text-muted) !important;
|
||||
}
|
||||
html.dark .el-time-spinner__item.is-active {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
|
||||
/* Popover / tooltip */
|
||||
html.dark .el-popover,
|
||||
html.dark .el-tooltip__popper {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--warm-border) !important;
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
|
||||
/* Message box confirm */
|
||||
html.dark .el-message-box {
|
||||
--el-messagebox-bg-color: var(--surface);
|
||||
--el-messagebox-title-color: var(--c-text);
|
||||
--el-messagebox-content-color: var(--c-text-secondary);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
html.dark .el-empty__description p {
|
||||
color: var(--c-text-muted) !important;
|
||||
}
|
||||
|
||||
/* Radio / Checkbox label */
|
||||
html.dark .el-radio__label,
|
||||
html.dark .el-checkbox__label {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
|
||||
/* Cascader / Tree select */
|
||||
html.dark .el-cascader-node__label {
|
||||
color: var(--c-text) !important;
|
||||
}
|
||||
html.dark .el-cascader-menu {
|
||||
background: var(--surface) !important;
|
||||
border-color: var(--warm-border) !important;
|
||||
}
|
||||
|
||||
/* Alert */
|
||||
html.dark .el-alert {
|
||||
--el-alert-bg-color: var(--c-primary-bg);
|
||||
}
|
||||
|
||||
/* Drawer */
|
||||
html.dark .el-drawer {
|
||||
background: var(--surface) !important;
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
html.dark .el-divider__text {
|
||||
background: var(--surface) !important;
|
||||
}
|
||||
|
||||
/* Loading mask */
|
||||
html.dark .el-loading-mask {
|
||||
background-color: rgba(0,0,0,0.5) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,4 +13,17 @@ export const miniBusinessApi = {
|
||||
delete(id: string) {
|
||||
return api.delete(`/mini-business/${id}`)
|
||||
},
|
||||
// Follow-up logs
|
||||
getLogs(businessId: string) {
|
||||
return api.get(`/mini-business/${businessId}/logs`)
|
||||
},
|
||||
createLog(businessId: string, data: any) {
|
||||
return api.post(`/mini-business/${businessId}/logs`, data)
|
||||
},
|
||||
updateLog(logId: string, data: any) {
|
||||
return api.put(`/mini-business/logs/${logId}`, data)
|
||||
},
|
||||
deleteLog(logId: string) {
|
||||
return api.delete(`/mini-business/logs/${logId}`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
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 },
|
||||
async uploadImage(file: File) {
|
||||
const data = new FormData()
|
||||
data.append('file', file)
|
||||
return api.post('/upload/image', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 60000,
|
||||
})
|
||||
},
|
||||
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,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useThemeStore, themeLabels } from '@/stores/theme'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -44,12 +44,15 @@ const menuGroups = computed<MenuGroup[]>(() => {
|
||||
label: '管理',
|
||||
items: [
|
||||
{ path: '/customers', label: '客户管理', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle>' },
|
||||
{ path: '/settings', label: '系统设置', icon: '<circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>' },
|
||||
],
|
||||
})
|
||||
if (auth.isDirector) {
|
||||
groups[groups.length - 1].items.push(
|
||||
groups[groups.length - 1].items.splice(1, 0,
|
||||
{ path: '/users', label: '用户管理', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path>' },
|
||||
{ path: '/settings', label: '系统设置', icon: '<circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>' },
|
||||
)
|
||||
groups[groups.length - 1].items.push(
|
||||
{ path: '/audit-log', label: '操作日志', icon: '<polyline points="9 11 12 14 22 4"></polyline><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>' },
|
||||
)
|
||||
}
|
||||
return groups
|
||||
@@ -62,8 +65,7 @@ const roleLabel = computed(() => {
|
||||
})
|
||||
|
||||
function cycleTheme() {
|
||||
const next = themeStore.currentTheme === 'editorial' ? 'light' : 'editorial'
|
||||
themeStore.setTheme(next)
|
||||
themeStore.toggleMode()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -108,11 +110,11 @@ function cycleTheme() {
|
||||
</svg>
|
||||
</button>
|
||||
<div class="topbar-actions">
|
||||
<button class="topbar-btn" @click="cycleTheme" :title="'切换至' + themeLabels[themeStore.currentTheme === 'editorial' ? 'light' : 'editorial']">
|
||||
<button class="topbar-btn" @click="cycleTheme" :title="'切换至' + (themeStore.mode === 'light' ? '深色模式' : '亮色模式')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle v-if="themeStore.currentTheme === 'editorial'" cx="12" cy="12" r="5"></circle>
|
||||
<path v-if="themeStore.currentTheme === 'editorial'" d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"></path>
|
||||
<path v-if="themeStore.currentTheme === 'light'" d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
<path v-if="themeStore.mode === 'light'" d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
<circle v-if="themeStore.mode === 'dark'" cx="12" cy="12" r="5"></circle>
|
||||
<path v-if="themeStore.mode === 'dark'" d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="topbar-btn" @click="router.push('/m')" title="移动端">
|
||||
|
||||
@@ -2,31 +2,28 @@
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useThemeStore, themeLabels } from '@/stores/theme'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
function cycleTheme() {
|
||||
const next = themeStore.currentTheme === 'editorial' ? 'light' : 'editorial'
|
||||
themeStore.setTheme(next)
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
path: '/m',
|
||||
label: '首页',
|
||||
icon: 'home',
|
||||
},
|
||||
{
|
||||
path: '/m/visit/new',
|
||||
label: '拜访',
|
||||
icon: 'visit',
|
||||
},
|
||||
{
|
||||
path: '/m/note/new',
|
||||
label: '纪要',
|
||||
icon: 'note',
|
||||
},
|
||||
{ path: '/m', label: '首页', icon: 'home' },
|
||||
{ path: '/m/work', label: '工作', icon: 'work' },
|
||||
{ path: '/m/visit/new', label: '拜访', icon: 'visit' },
|
||||
{ path: '/m/note/new', label: '纪要', icon: 'note' },
|
||||
]
|
||||
|
||||
const activeTab = computed(() => {
|
||||
if (route.path === '/m') return '/m'
|
||||
if (route.path === '/m/work' || route.path.startsWith('/m/plans') || route.path.startsWith('/m/mini-biz') || route.path.startsWith('/m/key-visits') || route.path.startsWith('/m/leaves') || route.path.startsWith('/m/leave')) return '/m/work'
|
||||
if (route.path.includes('/visit')) return '/m/visit/new'
|
||||
if (route.path.includes('/note')) return '/m/note/new'
|
||||
return route.path
|
||||
@@ -43,6 +40,22 @@ const activeTab = computed(() => {
|
||||
<div class="header-rule"></div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="header-pc-btn" @click="cycleTheme" :title="'切换至' + themeLabels[themeStore.currentTheme === 'editorial' ? 'light' : 'editorial']">
|
||||
<svg v-if="themeStore.currentTheme === 'editorial'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="header-pc-btn" @click="router.push('/')" title="PC版">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
|
||||
@@ -76,6 +89,11 @@ const activeTab = computed(() => {
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
|
||||
<polyline points="9 22 9 12 15 12 15 22"></polyline>
|
||||
</svg>
|
||||
<!-- Work icon -->
|
||||
<svg v-else-if="tab.icon === 'work'" class="tab-icon" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect>
|
||||
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path>
|
||||
</svg>
|
||||
<!-- Visit icon -->
|
||||
<svg v-else-if="tab.icon === 'visit'" class="tab-icon" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
|
||||
|
||||
@@ -35,13 +35,22 @@ const router = createRouter({
|
||||
component: () => import('@/components/MobileLayout.vue'),
|
||||
children: [
|
||||
{ path: '', name: 'MobileHome', component: () => import('@/views/mobile/Home.vue') },
|
||||
{ path: 'work', name: 'WorkCenter', component: () => import('@/views/mobile/WorkCenter.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: 'work-plan/:id/edit', name: 'WorkPlanEdit', component: () => import('@/views/mobile/WorkPlanForm.vue') },
|
||||
{ path: 'mini-biz/new', name: 'MiniBusinessForm', component: () => import('@/views/mobile/MiniBusinessForm.vue') },
|
||||
{ path: 'mini-biz/:id/edit', name: 'MiniBusinessEdit', component: () => import('@/views/mobile/MiniBusinessForm.vue') },
|
||||
{ path: 'key-visit/new', name: 'KeyVisitForm', component: () => import('@/views/mobile/KeyVisitForm.vue') },
|
||||
{ path: 'key-visit/:id/edit', name: 'KeyVisitEdit', component: () => import('@/views/mobile/KeyVisitForm.vue') },
|
||||
{ path: 'note/new', name: 'DailyNoteForm', component: () => import('@/views/mobile/DailyNoteForm.vue') },
|
||||
{ path: 'note/:id/edit', name: 'DailyNoteEdit', component: () => import('@/views/mobile/DailyNoteForm.vue') },
|
||||
{ path: 'visits', name: 'VisitsList', component: () => import('@/views/mobile/VisitsList.vue') },
|
||||
{ path: 'notes', name: 'NotesList', component: () => import('@/views/mobile/NotesList.vue') },
|
||||
{ path: 'plans', name: 'PlansList', component: () => import('@/views/mobile/PlansList.vue') },
|
||||
{ path: 'mini-biz', name: 'MiniBizList', component: () => import('@/views/mobile/MiniBizList.vue') },
|
||||
{ path: 'key-visits', name: 'KeyVisitsList', component: () => import('@/views/mobile/KeyVisitsList.vue') },
|
||||
{ path: 'leaves', name: 'LeavesList', component: () => import('@/views/mobile/LeavesList.vue') },
|
||||
{ path: 'leave/new', name: 'LeaveForm', component: () => import('@/views/mobile/LeaveForm.vue') },
|
||||
{ path: 'leave/:id/edit', name: 'LeaveEdit', component: () => import('@/views/mobile/LeaveForm.vue') },
|
||||
@@ -62,6 +71,7 @@ const router = createRouter({
|
||||
{ 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: 'audit-log', name: 'AuditLog', component: () => import('@/views/desktop/AuditLog.vue') },
|
||||
{ path: 'settings', name: 'Settings', component: () => import('@/views/desktop/Settings.vue') },
|
||||
],
|
||||
},
|
||||
@@ -70,6 +80,30 @@ const router = createRouter({
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
// Handle WeChat Work OAuth silent login callback: ?token=JWT
|
||||
// Only strip the token if it's a valid JWT — bind tokens (UUID hex) must
|
||||
// survive so pages like WecomBind can read them.
|
||||
const tokenParam = to.query.token as string
|
||||
if (tokenParam && tokenParam.split('.').length === 3) {
|
||||
// Save token to auth store (the JWT contains user_id, name, role, theme)
|
||||
try {
|
||||
const payload = JSON.parse(atob(tokenParam.split('.')[1]))
|
||||
auth.saveLogin({
|
||||
access_token: tokenParam,
|
||||
user_id: payload.user_id,
|
||||
name: payload.name,
|
||||
role: payload.role,
|
||||
theme: payload.theme,
|
||||
})
|
||||
// Remove token from URL only on successful JWT decode
|
||||
const cleanQuery = { ...to.query }
|
||||
delete cleanQuery.token
|
||||
next({ path: to.path, query: cleanQuery, replace: true })
|
||||
return
|
||||
} catch (_) { /* invalid JWT — leave token for page to handle */ }
|
||||
}
|
||||
|
||||
if (to.meta.public) {
|
||||
next()
|
||||
return
|
||||
|
||||
@@ -7,21 +7,24 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const userId = ref(localStorage.getItem('userId') || '')
|
||||
const name = ref(localStorage.getItem('userName') || '')
|
||||
const role = ref(localStorage.getItem('userRole') || '')
|
||||
const theme = ref(localStorage.getItem('theme') || 'editorial')
|
||||
|
||||
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 }) {
|
||||
function saveLogin(data: { access_token: string; user_id: string; name: string; role: string; theme?: string }) {
|
||||
token.value = data.access_token
|
||||
userId.value = data.user_id
|
||||
name.value = data.name
|
||||
role.value = data.role
|
||||
if (data.theme) theme.value = data.theme
|
||||
localStorage.setItem('token', data.access_token)
|
||||
localStorage.setItem('userId', data.user_id)
|
||||
localStorage.setItem('userName', data.name)
|
||||
localStorage.setItem('userRole', data.role)
|
||||
if (data.theme) localStorage.setItem('theme', data.theme)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
@@ -29,10 +32,14 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
userId.value = ''
|
||||
name.value = ''
|
||||
role.value = ''
|
||||
// Preserve theme preference across logout
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
// Preserve theme preferences across logout
|
||||
const savedMode = localStorage.getItem('theme-mode')
|
||||
const savedLight = localStorage.getItem('theme-light')
|
||||
const savedDark = localStorage.getItem('theme-dark')
|
||||
localStorage.clear()
|
||||
if (savedTheme) localStorage.setItem('theme', savedTheme)
|
||||
if (savedMode) localStorage.setItem('theme-mode', savedMode)
|
||||
if (savedLight) localStorage.setItem('theme-light', savedLight)
|
||||
if (savedDark) localStorage.setItem('theme-dark', savedDark)
|
||||
}
|
||||
|
||||
async function casdoorLogin(code: string, state: string) {
|
||||
@@ -52,7 +59,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
token, userId, name, role,
|
||||
token, userId, name, role, theme,
|
||||
isLoggedIn, isManager, isDirector, isLeader,
|
||||
saveLogin, logout, casdoorLogin, wecomLogin, bindWecom,
|
||||
}
|
||||
|
||||
@@ -1,30 +1,61 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export type Theme = 'editorial' | 'light'
|
||||
export type Theme = 'editorial' | 'light' | 'minimal-dark' | 'tech-dark'
|
||||
type Mode = 'light' | 'dark'
|
||||
|
||||
export const themeLabels: Record<Theme, string> = {
|
||||
editorial: '编辑风',
|
||||
light: '极简亮色',
|
||||
'minimal-dark': '极简深色',
|
||||
'tech-dark': '科技深色',
|
||||
}
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const currentTheme = ref<Theme>(
|
||||
(localStorage.getItem('theme') as Theme) || 'editorial'
|
||||
)
|
||||
export const lightThemes: Theme[] = ['editorial', 'light']
|
||||
export const darkThemes: Theme[] = ['minimal-dark', 'tech-dark']
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const mode = ref<Mode>((localStorage.getItem('theme-mode') as Mode) || 'light')
|
||||
const lightTheme = ref<Theme>((localStorage.getItem('theme-light') as Theme) || 'editorial')
|
||||
const darkTheme = ref<Theme>((localStorage.getItem('theme-dark') as Theme) || 'tech-dark')
|
||||
|
||||
const effectiveTheme = computed<Theme>(() => {
|
||||
return mode.value === 'light' ? lightTheme.value : darkTheme.value
|
||||
})
|
||||
|
||||
function applyTheme(theme: Theme, isDark: boolean) {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
function setTheme(theme: Theme) {
|
||||
currentTheme.value = theme
|
||||
localStorage.setItem('theme', theme)
|
||||
applyTheme(theme)
|
||||
function apply() {
|
||||
applyTheme(effectiveTheme.value, mode.value === 'dark')
|
||||
}
|
||||
|
||||
function setLightTheme(theme: Theme) {
|
||||
lightTheme.value = theme
|
||||
localStorage.setItem('theme-light', theme)
|
||||
if (mode.value === 'light') apply()
|
||||
}
|
||||
|
||||
function setDarkTheme(theme: Theme) {
|
||||
darkTheme.value = theme
|
||||
localStorage.setItem('theme-dark', theme)
|
||||
if (mode.value === 'dark') apply()
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
mode.value = mode.value === 'light' ? 'dark' : 'light'
|
||||
localStorage.setItem('theme-mode', mode.value)
|
||||
apply()
|
||||
}
|
||||
|
||||
// Apply on init
|
||||
applyTheme(currentTheme.value)
|
||||
apply()
|
||||
|
||||
return { currentTheme, setTheme }
|
||||
return { mode, lightTheme, darkTheme, effectiveTheme, setLightTheme, setDarkTheme, toggleMode }
|
||||
})
|
||||
|
||||
@@ -9,6 +9,22 @@ const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
|
||||
function isWecom(): boolean {
|
||||
const ua = navigator.userAgent || ''
|
||||
return /wxwork/i.test(ua)
|
||||
}
|
||||
|
||||
function isMobileDevice(): boolean {
|
||||
const ua = navigator.userAgent || ''
|
||||
return /Android|iPhone|iPad|iPod|webOS/i.test(ua) || window.innerWidth < 768
|
||||
}
|
||||
|
||||
function getHomePath(): string {
|
||||
// 客户经理永远走移动端;支局长/分管领导根据设备自动适配
|
||||
if (auth.isManager) return '/m'
|
||||
return isMobileDevice() ? '/m' : '/'
|
||||
}
|
||||
|
||||
function goCasdoorLogin() {
|
||||
const returnUrl = (route.query.redirect as string) || ''
|
||||
if (returnUrl) {
|
||||
@@ -32,7 +48,7 @@ onMounted(async () => {
|
||||
const returnUrl = sessionStorage.getItem('login_return_url')
|
||||
if (returnUrl) { sessionStorage.removeItem('login_return_url'); router.push(returnUrl); return }
|
||||
ElMessage.success('登录成功')
|
||||
router.push(auth.isManager ? '/m' : '/')
|
||||
router.push(getHomePath())
|
||||
} catch (e: any) {
|
||||
ElMessage.error('登录失败: ' + (e.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
@@ -60,7 +76,7 @@ onMounted(async () => {
|
||||
const returnUrl = sessionStorage.getItem('login_return_url')
|
||||
if (returnUrl) { sessionStorage.removeItem('login_return_url'); router.push(returnUrl); return }
|
||||
ElMessage.success('登录成功')
|
||||
router.push(auth.isManager ? '/m' : '/')
|
||||
router.push(getHomePath())
|
||||
} catch (e: any) {
|
||||
ElMessage.error('企微登录失败')
|
||||
} finally {
|
||||
@@ -69,6 +85,18 @@ onMounted(async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// ── WeChat Work silent login: redirect to OAuth (no user interaction needed) ──
|
||||
// Skip OAuth when user is on the bind flow — OAuth for unbound users redirects
|
||||
// right back to /wecom-bind without logging them in, creating an infinite loop.
|
||||
if (isWecom()) {
|
||||
const targetPath = (route.query.redirect as string) || '/m'
|
||||
if (!targetPath.startsWith('/wecom-bind')) {
|
||||
const apiBase = window.location.origin
|
||||
window.location.href = `${apiBase}/api/wecom/oauth-url?redirect=${encodeURIComponent(targetPath)}`
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-redirect to Casdoor (no button needed) ──
|
||||
loading.value = true
|
||||
goCasdoorLogin()
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import api from '@/api/index'
|
||||
|
||||
const loading = ref(false)
|
||||
const items = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(50)
|
||||
|
||||
const filterEntityType = ref('')
|
||||
const filterAction = ref('')
|
||||
const filterOperatorId = ref('')
|
||||
const filterDateFrom = ref('')
|
||||
const filterDateTo = ref('')
|
||||
const managers = ref<any[]>([])
|
||||
|
||||
const entityTypes = [
|
||||
{ value: '', label: '全部类型' },
|
||||
{ value: 'customer', label: '客户' },
|
||||
{ value: 'visit', label: '拜访记录' },
|
||||
{ value: 'daily_note', label: '今日纪要' },
|
||||
{ value: 'work_plan', label: '工作计划' },
|
||||
{ value: 'mini_business', label: '小微商机' },
|
||||
{ value: 'key_visit', label: '要客拜访' },
|
||||
{ value: 'user', label: '用户' },
|
||||
{ value: 'leave', label: '请假' },
|
||||
]
|
||||
|
||||
const actions = [
|
||||
{ value: '', label: '全部操作' },
|
||||
{ value: 'create', label: '创建' },
|
||||
{ value: 'update', label: '更新' },
|
||||
{ value: 'delete', label: '删除' },
|
||||
]
|
||||
|
||||
const actionColors: Record<string, string> = {
|
||||
create: '#4A6741',
|
||||
update: '#5B7FA5',
|
||||
delete: '#B8472E',
|
||||
}
|
||||
|
||||
const actionLabels: Record<string, string> = {
|
||||
create: '创建',
|
||||
update: '更新',
|
||||
delete: '删除',
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await api.get('/users/', { params: { role: 'manager' } })
|
||||
managers.value = res.data || []
|
||||
} catch (_) {}
|
||||
await loadLogs()
|
||||
})
|
||||
|
||||
async function loadLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { page: page.value, page_size: pageSize.value }
|
||||
if (filterEntityType.value) params.entity_type = filterEntityType.value
|
||||
if (filterAction.value) params.action = filterAction.value
|
||||
if (filterOperatorId.value) params.operator_id = filterOperatorId.value
|
||||
if (filterDateFrom.value) params.date_from = filterDateFrom.value
|
||||
if (filterDateTo.value) params.date_to = filterDateTo.value
|
||||
const res = await api.get('/audit-logs/', { params })
|
||||
items.value = res.data.items || []
|
||||
total.value = res.data.total || 0
|
||||
} catch (e: any) {
|
||||
ElMessage.error('加载失败: ' + (e.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; loadLogs() }
|
||||
function onPageChange() { loadLogs() }
|
||||
|
||||
function formatTime(s: string): string {
|
||||
if (!s) return ''
|
||||
const d = new Date(s)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="audit-log-page" v-loading="loading">
|
||||
<div class="page-head">
|
||||
<h2 class="page-title">操作日志</h2>
|
||||
<span class="page-subtitle">AUDIT LOG</span>
|
||||
<div class="page-rule"></div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<el-card style="margin-bottom:16px">
|
||||
<div class="filter-row">
|
||||
<el-select v-model="filterEntityType" placeholder="实体类型" clearable style="width:120px">
|
||||
<el-option v-for="e in entityTypes" :key="e.value" :label="e.label" :value="e.value" />
|
||||
</el-select>
|
||||
<el-select v-model="filterAction" placeholder="操作" clearable style="width:100px">
|
||||
<el-option v-for="a in actions" :key="a.value" :label="a.label" :value="a.value" />
|
||||
</el-select>
|
||||
<el-select v-model="filterOperatorId" placeholder="操作人" clearable filterable style="width:130px">
|
||||
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
<el-date-picker v-model="filterDateFrom" type="date" placeholder="开始日期" value-format="YYYY-MM-DD" style="width:140px" />
|
||||
<el-date-picker v-model="filterDateTo" type="date" placeholder="结束日期" value-format="YYYY-MM-DD" style="width:140px" />
|
||||
<el-button type="primary" @click="onSearch">查询</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Table -->
|
||||
<el-card>
|
||||
<el-table :data="items" stripe size="small" v-column-resize>
|
||||
<el-table-column label="时间" width="165">
|
||||
<template #default="{ row }">
|
||||
<span class="log-time">{{ formatTime(row.created_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作人" width="90" prop="operator_name" />
|
||||
<el-table-column label="操作" width="65">
|
||||
<template #default="{ row }">
|
||||
<el-tag :color="actionColors[row.action]" effect="dark" size="small" disable-transitions style="border:none;color:#fff">
|
||||
{{ actionLabels[row.action] }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="85">
|
||||
<template #default="{ row }">
|
||||
<span class="entity-type">{{ row.entity_type_label }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="对象" min-width="250">
|
||||
<template #default="{ row }">
|
||||
<span class="log-entity">{{ row.entity_name }}</span>
|
||||
<span v-if="row.summary" class="log-summary"> — {{ row.summary }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="total > pageSize" style="display:flex;justify-content:center;margin-top:16px">
|
||||
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" @current-change="onPageChange" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-head { margin-bottom: 20px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.page-title { margin: 0; font-family: var(--font-heading); font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
|
||||
.page-subtitle { font-family: var(--font-mono); font-size: 9px; color: var(--gold); letter-spacing: 0.2em; }
|
||||
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
|
||||
|
||||
.filter-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
.log-time { font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray); }
|
||||
.entity-type { font-family: var(--font-body); font-size: 12px; color: var(--warm-gray); }
|
||||
.log-entity { font-family: var(--font-heading); font-size: 13px; color: var(--ink); letter-spacing: 0.03em; }
|
||||
.log-summary { font-family: var(--font-body); font-size: 12px; color: var(--warm-gray); }
|
||||
</style>
|
||||
@@ -5,17 +5,124 @@ import { useThemeStore } from '@/stores/theme'
|
||||
import { customersApi } from '@/api/customers'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getManagerColor as mgrColorFn, getMgrTextColor, loadManagerColors } from '@/utils/managerColor'
|
||||
import { compressImage } from '@/utils/image'
|
||||
import api from '@/api/index'
|
||||
import axios from 'axios'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const themeStore = useThemeStore()
|
||||
const isLight = computed(() => themeStore.currentTheme === 'light')
|
||||
const isLight = computed(() => themeStore.mode === 'light')
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
const filterIndustry = ref('')
|
||||
const filterService = ref('')
|
||||
const filterManagerId = ref('')
|
||||
const customerType = ref('unit')
|
||||
const customers = ref<any[]>([])
|
||||
|
||||
// Individual view tab data
|
||||
const indivTab = ref('visits')
|
||||
const indivVisits = ref<any[]>([])
|
||||
const indivPlans = ref<any[]>([])
|
||||
const indivMini = ref<any[]>([])
|
||||
const indivKeyVisits = ref<any[]>([])
|
||||
const indivLoading = ref(false)
|
||||
|
||||
// Individual record edit
|
||||
const indivEditVisible = ref(false)
|
||||
const indivEditType = ref('')
|
||||
const indivEditForm = ref<any>({})
|
||||
const indivEditSaving = ref(false)
|
||||
const indivPhotoUrls = ref<Record<string, string>>({})
|
||||
|
||||
async function openIndivEdit(type: string, row: any) {
|
||||
indivEditType.value = type
|
||||
indivEditForm.value = { ...row }
|
||||
indivEditVisible.value = true
|
||||
// Load photo URLs for visit editing
|
||||
if (type === 'visit' && row.photos?.length) {
|
||||
for (const key of row.photos) {
|
||||
if (!indivPhotoUrls.value[key]) {
|
||||
try {
|
||||
const urlRes = await api.get('/upload/download-url', { params: { object_key: key } })
|
||||
indivPhotoUrls.value[key] = urlRes.data.download_url
|
||||
} catch (_) { indivPhotoUrls.value[key] = '' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIndivPhotoUpload(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (!target.files?.length) return
|
||||
const form = indivEditForm.value
|
||||
if (!form.photos) form.photos = []
|
||||
if (form.photos.length >= 9) { ElMessage.warning('最多9张照片'); return }
|
||||
for (const file of Array.from(target.files)) {
|
||||
if (form.photos.length >= 9) break
|
||||
try {
|
||||
const compressed = await compressImage(file, { maxPixels: 1920, quality: 0.8 })
|
||||
const presignRes = await api.post('/upload/presigned-url', null, { params: { filename: compressed.name, content_type: compressed.type || 'image/jpeg' } })
|
||||
await axios.put(presignRes.data.upload_url, compressed, { headers: { 'Content-Type': compressed.type || 'image/jpeg' } })
|
||||
form.photos.push(presignRes.data.object_key)
|
||||
indivPhotoUrls.value[presignRes.data.object_key] = URL.createObjectURL(compressed)
|
||||
} catch (_) { ElMessage.error('上传失败') }
|
||||
}
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
function removeIndivPhoto(idx: number) {
|
||||
const form = indivEditForm.value
|
||||
const key = form.photos[idx]
|
||||
delete indivPhotoUrls.value[key]
|
||||
form.photos.splice(idx, 1)
|
||||
}
|
||||
|
||||
async function handleIndivEditSave() {
|
||||
indivEditSaving.value = true
|
||||
try {
|
||||
const type = indivEditType.value
|
||||
const f = indivEditForm.value
|
||||
if (type === 'visit') {
|
||||
await api.put(`/visits/${f.id}`, {
|
||||
communication_content: f.communication_content,
|
||||
customer_demand: f.customer_demand || '',
|
||||
visitor_name: f.visitor_name || '',
|
||||
visitor_phone: f.visitor_phone || '',
|
||||
visit_method: f.visit_method,
|
||||
time_range: f.time_range || '',
|
||||
visit_date: f.visit_date,
|
||||
manager_id: f.manager_id,
|
||||
photos: f.photos || [],
|
||||
})
|
||||
} else if (type === 'plan') {
|
||||
await api.put(`/work-plans/${f.id}`, { plan_content: f.plan_content, status: f.status, plan_date: f.plan_date, manager_id: f.manager_id })
|
||||
} else if (type === 'mini') {
|
||||
await api.put(`/mini-business/${f.id}`, { product_type: f.product_type, amount: f.amount, follow_up_detail: f.follow_up_detail, status: f.status, expected_revenue_date: f.expected_revenue_date, manager_id: f.manager_id })
|
||||
} else if (type === 'key') {
|
||||
await api.put(`/key-visits/${f.id}`, { description: f.description, urgency_level: f.urgency_level, progress_status: f.progress_status, planned_date: f.planned_date, planned_visitor: f.planned_visitor, visit_target: f.visit_target, manager_id: f.manager_id })
|
||||
}
|
||||
ElMessage.success('已更新')
|
||||
indivEditVisible.value = false
|
||||
loadIndividualData()
|
||||
} catch (e: any) { ElMessage.error('保存失败') }
|
||||
finally { indivEditSaving.value = false }
|
||||
}
|
||||
|
||||
async function handleIndivEditDelete() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
|
||||
const type = indivEditType.value
|
||||
const id = indivEditForm.value.id
|
||||
if (type === 'visit') await api.delete(`/visits/${id}`)
|
||||
else if (type === 'plan') await api.delete(`/work-plans/${id}`)
|
||||
else if (type === 'mini') await api.delete(`/mini-business/${id}`)
|
||||
else if (type === 'key') await api.delete(`/key-visits/${id}`)
|
||||
ElMessage.success('已删除')
|
||||
indivEditVisible.value = false
|
||||
loadIndividualData()
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(25)
|
||||
const total = ref(0)
|
||||
@@ -36,6 +143,12 @@ const feeCustomUnit = ref('')
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailCustomer = ref<any>(null)
|
||||
const detailActiveTab = ref('visits')
|
||||
const detailVisits = ref<any[]>([])
|
||||
const detailPlans = ref<any[]>([])
|
||||
const detailMini = ref<any[]>([])
|
||||
const detailKeyVisits = ref<any[]>([])
|
||||
const detailLoading = ref(false)
|
||||
|
||||
const managers = ref<any[]>([])
|
||||
const selectedIds = ref<string[]>([])
|
||||
@@ -62,6 +175,7 @@ async function loadCustomers() {
|
||||
if (filterIndustry.value) params.industry = filterIndustry.value
|
||||
if (filterService.value) params.service = filterService.value
|
||||
if (filterManagerId.value) params.manager_id = filterManagerId.value
|
||||
if (customerType.value) params.customer_type = customerType.value
|
||||
const res = await customersApi.list(params)
|
||||
customers.value = res.data.items
|
||||
total.value = res.data.total
|
||||
@@ -73,6 +187,32 @@ function onPageChange(page: number) { currentPage.value = page; loadCustomers()
|
||||
function onPageSizeChange(size: number) { pageSize.value = size; currentPage.value = 1; loadCustomers() }
|
||||
function onFilterChange() { currentPage.value = 1; loadCustomers() }
|
||||
|
||||
function switchCustomerType(type: string) {
|
||||
customerType.value = type
|
||||
if (type === 'unit') {
|
||||
onFilterChange()
|
||||
} else {
|
||||
loadIndividualData()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIndividualData() {
|
||||
indivLoading.value = true
|
||||
try {
|
||||
const [visits, plans, mini, keyVisits] = await Promise.all([
|
||||
api.get('/visits/', { params: { customer_type: 'individual' } }),
|
||||
api.get('/work-plans/', { params: { customer_type: 'individual' } }),
|
||||
api.get('/mini-business/', { params: { customer_type: 'individual' } }),
|
||||
api.get('/key-visits/', { params: { customer_type: 'individual' } }),
|
||||
])
|
||||
indivVisits.value = Array.isArray(visits.data) ? visits.data : (visits.data.items || [])
|
||||
indivPlans.value = Array.isArray(plans.data) ? plans.data : (plans.data.items || [])
|
||||
indivMini.value = Array.isArray(mini.data) ? mini.data : (mini.data.items || [])
|
||||
indivKeyVisits.value = Array.isArray(keyVisits.data) ? keyVisits.data : (keyVisits.data.items || [])
|
||||
} catch (_) {}
|
||||
finally { indivLoading.value = false }
|
||||
}
|
||||
|
||||
function buildMonthlyFee(): string {
|
||||
const amt = form.value.fee_amount.trim()
|
||||
if (!amt) return ''
|
||||
@@ -205,7 +345,30 @@ function mgrTextColor(name: string) {
|
||||
}
|
||||
|
||||
async function openDetail(customer: any) {
|
||||
try { const res = await customersApi.get(customer.id); detailCustomer.value = res.data; detailVisible.value = true } catch (_) {}
|
||||
try {
|
||||
const res = await customersApi.get(customer.id)
|
||||
detailCustomer.value = res.data
|
||||
detailActiveTab.value = 'visits'
|
||||
detailVisible.value = true
|
||||
loadDetailData(customer.id)
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function loadDetailData(customerId: string) {
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const [visits, plans, mini, keyVisits] = await Promise.all([
|
||||
api.get('/visits/', { params: { customer_id: customerId } }),
|
||||
api.get('/work-plans/', { params: { customer_id: customerId } }),
|
||||
api.get('/mini-business/', { params: { customer_id: customerId } }),
|
||||
api.get('/key-visits/', { params: { customer_id: customerId } }),
|
||||
])
|
||||
detailVisits.value = Array.isArray(visits.data) ? visits.data : (visits.data.items || [])
|
||||
detailPlans.value = Array.isArray(plans.data) ? plans.data : (plans.data.items || [])
|
||||
detailMini.value = Array.isArray(mini.data) ? mini.data : (mini.data.items || [])
|
||||
detailKeyVisits.value = Array.isArray(keyVisits.data) ? keyVisits.data : (keyVisits.data.items || [])
|
||||
} catch (_) {}
|
||||
finally { detailLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleDelete(customer: any) {
|
||||
@@ -294,12 +457,72 @@ async function handleImport() {
|
||||
</svg>
|
||||
导出
|
||||
</el-button>
|
||||
<el-button v-if="auth.isDirector" type="primary" @click="openCreate">+ 新建客户</el-button>
|
||||
<el-button v-if="auth.isDirector" type="primary" @click="openCreate" :disabled="customerType === 'individual'">+ 新建客户</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-rule"></div>
|
||||
</div>
|
||||
|
||||
<!-- Type Toggle -->
|
||||
<div style="margin-bottom:14px">
|
||||
<div class="type-tabs">
|
||||
<button :class="['type-tab', { 'type-tab--active': customerType === 'unit' }]" @click="switchCustomerType('unit')">单位客户</button>
|
||||
<button :class="['type-tab', { 'type-tab--active': customerType === 'individual' }]" @click="switchCustomerType('individual')">个人用户</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Individual View: 4 Tabs -->
|
||||
<div v-if="customerType === 'individual'" v-loading="indivLoading">
|
||||
<el-tabs v-model="indivTab">
|
||||
<el-tab-pane label="拜访记录" name="visits">
|
||||
<el-table :data="indivVisits" size="small" max-height="400" v-if="indivVisits.length">
|
||||
<el-table-column prop="visit_date" label="日期" width="110" />
|
||||
<el-table-column prop="visit_method" label="方式" width="80" />
|
||||
<el-table-column prop="communication_content" label="沟通内容" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="visitor_name" label="拜访人" width="80" />
|
||||
<el-table-column prop="manager_name" label="客户经理" width="80" />
|
||||
<el-table-column prop="customer_name" label="客户" width="100" />
|
||||
<el-table-column label="操作" width="60" fixed="right"><template #default="{ row }"><el-button size="small" @click="openIndivEdit('visit', row)">编辑</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<div v-else class="empty-tab">暂无拜访记录</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="工作计划" name="plans">
|
||||
<el-table :data="indivPlans" size="small" max-height="400" v-if="indivPlans.length">
|
||||
<el-table-column prop="plan_date" label="计划时间" width="110" />
|
||||
<el-table-column prop="plan_content" label="工作计划" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="80" />
|
||||
<el-table-column prop="customer_name" label="客户" width="120" />
|
||||
<el-table-column label="操作" width="60" fixed="right"><template #default="{ row }"><el-button size="small" @click="openIndivEdit('plan', row)">编辑</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<div v-else class="empty-tab">暂无工作计划</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="商机跟单" name="mini">
|
||||
<el-table :data="indivMini" size="small" max-height="400" v-if="indivMini.length">
|
||||
<el-table-column prop="product_type" label="产品类型" width="110" />
|
||||
<el-table-column prop="amount" label="金额" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="80" />
|
||||
<el-table-column prop="follow_up_detail" label="跟进内容" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="customer_name" label="客户" width="120" />
|
||||
<el-table-column label="操作" width="60" fixed="right"><template #default="{ row }"><el-button size="small" @click="openIndivEdit('mini', row)">编辑</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<div v-else class="empty-tab">暂无商机记录</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="要客拜访" name="keyVisits">
|
||||
<el-table :data="indivKeyVisits" size="small" max-height="400" v-if="indivKeyVisits.length">
|
||||
<el-table-column prop="planned_date" label="计划时间" width="110" />
|
||||
<el-table-column prop="description" label="内容" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column prop="urgency_level" label="紧急度" width="80" />
|
||||
<el-table-column prop="customer_name" label="客户" width="120" />
|
||||
<el-table-column label="操作" width="60" fixed="right"><template #default="{ row }"><el-button size="small" @click="openIndivEdit('key', row)">编辑</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<div v-else class="empty-tab">暂不要客记录</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<!-- Unit View: Search + Table -->
|
||||
<template v-if="customerType === 'unit'">
|
||||
|
||||
<!-- Search & Filter -->
|
||||
<el-card style="margin-bottom: 16px;">
|
||||
<el-row :gutter="12" align="middle">
|
||||
@@ -403,25 +626,76 @@ async function handleImport() {
|
||||
</el-dialog>
|
||||
|
||||
<!-- Detail Dialog -->
|
||||
<el-dialog v-model="detailVisible" title="客户详情" width="500px">
|
||||
<template v-if="detailCustomer">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="单位名称">{{ detailCustomer.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="行业">{{ detailCustomer.industry || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="地址">{{ detailCustomer.address || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="在用业务">{{ detailCustomer.in_use_services || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="收支费用">{{ detailCustomer.monthly_fee || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">{{ detailCustomer.remarks || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户经理">{{ detailCustomer.primary_manager_name || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h4 style="margin-top:16px; font-family: var(--font-heading); color: var(--ink)">联系人</h4>
|
||||
<div v-if="detailCustomer.contacts?.length">
|
||||
<el-tag v-for="c in detailCustomer.contacts" :key="c.id" style="margin:4px">
|
||||
{{ c.name }}{{ c.phone ? ' · '+c.phone : '' }}{{ c.role_desc ? ' ('+c.role_desc+')' : '' }}
|
||||
</el-tag>
|
||||
<el-dialog v-model="detailVisible" :title="detailCustomer?.name || '客户详情'" width="800px" v-if="detailCustomer">
|
||||
<!-- Basic Info Card -->
|
||||
<div class="cust-info-card">
|
||||
<div class="cust-info-name">{{ detailCustomer.name }}</div>
|
||||
<div class="cust-info-bar">
|
||||
<span class="cust-info-item">行业:{{ detailCustomer.industry || '-' }}</span>
|
||||
<span class="cust-info-sep">|</span>
|
||||
<span class="cust-info-item">地址:{{ detailCustomer.address || '-' }}</span>
|
||||
<span class="cust-info-sep">|</span>
|
||||
<span class="cust-info-item">费用:{{ detailCustomer.monthly_fee || '-' }}</span>
|
||||
<span class="cust-info-sep">|</span>
|
||||
<span class="cust-info-item">客户经理:{{ detailCustomer.primary_manager_name || '-' }}</span>
|
||||
</div>
|
||||
<div v-else style="color: var(--c-text-muted); text-align:center; padding:12px">暂无联系人</div>
|
||||
</template>
|
||||
<div v-if="detailCustomer.in_use_services" class="cust-info-bar">
|
||||
<span class="cust-info-item">在用业务:{{ detailCustomer.in_use_services }}</span>
|
||||
</div>
|
||||
<div v-if="detailCustomer.contacts?.length" class="cust-info-bar">
|
||||
<span class="cust-info-item">联系人:{{ detailCustomer.contacts.map((c:any) => c.name + (c.phone ? ' '+c.phone : '')).join('、') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<el-tabs v-model="detailActiveTab" style="margin-top:16px">
|
||||
<el-tab-pane label="拜访记录" name="visits">
|
||||
<div v-loading="detailLoading">
|
||||
<el-table :data="detailVisits" size="small" max-height="300" v-if="detailVisits.length">
|
||||
<el-table-column prop="visit_date" label="日期" width="110" />
|
||||
<el-table-column prop="visit_method" label="方式" width="80" />
|
||||
<el-table-column prop="communication_content" label="沟通内容" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="manager_name" label="客户经理" width="90" />
|
||||
</el-table>
|
||||
<div v-else class="empty-tab">暂无拜访记录</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="工作计划" name="plans">
|
||||
<div v-loading="detailLoading">
|
||||
<el-table :data="detailPlans" size="small" max-height="300" v-if="detailPlans.length">
|
||||
<el-table-column prop="plan_date" label="计划时间" width="110" />
|
||||
<el-table-column prop="plan_content" label="工作计划" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="90" />
|
||||
<el-table-column prop="manager_name" label="客户经理" width="90" />
|
||||
</el-table>
|
||||
<div v-else class="empty-tab">暂无工作计划</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="商机跟单" name="mini">
|
||||
<div v-loading="detailLoading">
|
||||
<el-table :data="detailMini" size="small" max-height="300" v-if="detailMini.length">
|
||||
<el-table-column prop="product_type" label="产品类型" width="110" />
|
||||
<el-table-column prop="amount" label="金额" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="80" />
|
||||
<el-table-column prop="follow_up_detail" label="跟进内容" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="manager_name" label="客户经理" width="90" />
|
||||
</el-table>
|
||||
<div v-else class="empty-tab">暂无商机记录</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="要客拜访" name="keyVisits">
|
||||
<div v-loading="detailLoading">
|
||||
<el-table :data="detailKeyVisits" size="small" max-height="300" v-if="detailKeyVisits.length">
|
||||
<el-table-column prop="planned_date" label="计划时间" width="110" />
|
||||
<el-table-column prop="description" label="内容" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="urgency_level" label="紧急度" width="80" />
|
||||
<el-table-column prop="manager_name" label="客户经理" width="90" />
|
||||
</el-table>
|
||||
<div v-else class="empty-tab">暂不要客记录</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<template #footer>
|
||||
<el-button v-if="!auth.isLeader" type="primary" @click="detailVisible=false; openEdit(detailCustomer)">编辑</el-button>
|
||||
<el-button v-if="auth.isDirector" type="danger" @click="detailVisible=false; handleDelete(detailCustomer)">删除</el-button>
|
||||
@@ -454,6 +728,92 @@ async function handleImport() {
|
||||
<el-button type="primary" :loading="importLoading" @click="handleImport" :disabled="!importFile">确认导入</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<!-- Individual Record Edit Dialog -->
|
||||
<el-dialog v-model="indivEditVisible" :title="'编辑' + ({visit:'拜访',plan:'计划',mini:'商机',key:'要客'}[indivEditType]||'')" width="550px">
|
||||
<el-form label-position="top" v-if="indivEditForm">
|
||||
<template v-if="indivEditType === 'visit'">
|
||||
<el-form-item label="客户经理">
|
||||
<el-select v-model="indivEditForm.manager_id" filterable 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-date-picker v-model="indivEditForm.visit_date" type="date" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="拜访方式">
|
||||
<div style="display:flex;gap:8px">
|
||||
<el-button v-for="m in ['上门','电话','微信','出差']" :key="m" size="small" :type="indivEditForm.visit_method === m ? 'primary' : ''" @click="indivEditForm.visit_method = m">{{ m }}</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="时间范围"><el-input v-model="indivEditForm.time_range" placeholder="如: 10:00-11:30" /></el-form-item>
|
||||
<el-form-item label="拜访人姓名"><el-input v-model="indivEditForm.visitor_name" /></el-form-item>
|
||||
<el-form-item label="拜访人电话"><el-input v-model="indivEditForm.visitor_phone" /></el-form-item>
|
||||
<el-form-item label="相关人员"><el-input v-model="indivEditForm.companion_names_str" placeholder="多个姓名用、分隔" /></el-form-item>
|
||||
<el-form-item label="沟通内容"><el-input v-model="indivEditForm.communication_content" type="textarea" :rows="4" /></el-form-item>
|
||||
<el-form-item label="客户需求"><el-input v-model="indivEditForm.customer_demand" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item v-if="indivEditForm.photos?.length" label="照片">
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<div v-for="(key, idx) in indivEditForm.photos" :key="key" style="position:relative">
|
||||
<img :src="indivPhotoUrls[key]" style="width:80px;height:80px;object-fit:cover;border:1px solid var(--warm-border)" v-if="indivPhotoUrls[key]" />
|
||||
<span v-else style="width:80px;height:80px;display:flex;align-items:center;justify-content:center;background:var(--paper);border:1px solid var(--warm-border);font-size:12px;color:var(--warm-gray)">加载中</span>
|
||||
<button type="button" style="position:absolute;top:-6px;right:-6px;background:var(--vermilion);color:#fff;border:none;width:18px;height:18px;cursor:pointer;font-size:12px;line-height:1" @click="removeIndivPhoto(idx)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="添加照片">
|
||||
<label style="cursor:pointer;display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border:1px dashed var(--warm-border);color:var(--warm-gray);font-size:13px">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>
|
||||
上传照片(最多9张)
|
||||
<input type="file" accept="image/*" multiple style="display:none" @change="handleIndivPhotoUpload" />
|
||||
</label>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<template v-else-if="indivEditType === 'plan'">
|
||||
<el-form-item label="客户经理">
|
||||
<el-select v-model="indivEditForm.manager_id" filterable 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-date-picker v-model="indivEditForm.plan_date" type="date" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="工作计划"><el-input v-model="indivEditForm.plan_content" type="textarea" :rows="4" /></el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="indivEditForm.status" style="width:100%"><el-option v-for="s in ['计划中','已完成','已取消']" :key="s" :label="s" :value="s" /></el-select></el-form-item>
|
||||
</template>
|
||||
<template v-else-if="indivEditType === 'mini'">
|
||||
<el-form-item label="客户经理">
|
||||
<el-select v-model="indivEditForm.manager_id" filterable 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="indivEditForm.product_type" /></el-form-item>
|
||||
<el-form-item label="金额"><el-input v-model="indivEditForm.amount" /></el-form-item>
|
||||
<el-form-item label="跟进内容"><el-input v-model="indivEditForm.follow_up_detail" type="textarea" :rows="4" /></el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="indivEditForm.status" style="width:100%"><el-option v-for="s in ['跟进中','已签约','已流失']" :key="s" :label="s" :value="s" /></el-select></el-form-item>
|
||||
<el-form-item label="预计列收时间"><el-date-picker v-model="indivEditForm.expected_revenue_date" type="month" value-format="YYYY-MM" placeholder="选择月份" style="width:100%" /></el-form-item>
|
||||
</template>
|
||||
<template v-else-if="indivEditType === 'key'">
|
||||
<el-form-item label="客户经理">
|
||||
<el-select v-model="indivEditForm.manager_id" filterable 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 style="display:flex;gap:8px">
|
||||
<el-button v-for="u in ['一般','重要','紧急']" :key="u" size="small" :type="indivEditForm.urgency_level === u ? (u==='紧急'?'danger':u==='重要'?'warning':'') : ''" @click="indivEditForm.urgency_level = u">{{ u }}</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="内容描述"><el-input v-model="indivEditForm.description" type="textarea" :rows="4" /></el-form-item>
|
||||
<el-form-item label="进展状态"><el-select v-model="indivEditForm.progress_status" style="width:100%"><el-option v-for="s in ['未开始','进行中','已完成']" :key="s" :label="s" :value="s" /></el-select></el-form-item>
|
||||
<el-form-item label="计划拜访时间"><el-date-picker v-model="indivEditForm.planned_date" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="计划拜访人"><el-input v-model="indivEditForm.planned_visitor" placeholder="多个用、分隔" /></el-form-item>
|
||||
<el-form-item label="拜访对象"><el-input v-model="indivEditForm.visit_target" /></el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button type="danger" @click="handleIndivEditDelete" style="float:left">删除</el-button>
|
||||
<el-button @click="indivEditVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="indivEditSaving" @click="handleIndivEditSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Merge confirmation dialog -->
|
||||
<el-dialog v-model="mergeDialogVisible" title="合并客户" width="520px" :close-on-click-modal="false">
|
||||
@@ -499,4 +859,16 @@ async function handleImport() {
|
||||
}
|
||||
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 8px; }
|
||||
.header-btns { display: flex; gap: 8px; }
|
||||
.cust-info-bar { display: flex; align-items: center; gap: 4px; font-size: 13px; color: var(--ink); padding: 2px 0; }
|
||||
.cust-info-item { white-space: nowrap; }
|
||||
.cust-info-sep { color: var(--warm-border); margin: 0 6px; }
|
||||
.empty-tab { text-align: center; color: var(--c-text-muted); padding: 24px 0 12px; font-size: 13px; }
|
||||
.type-tabs { display: inline-flex; border: 1px solid var(--warm-border); }
|
||||
.type-tab { padding: 8px 22px; border: none; background: var(--surface); font-family: var(--font-heading); font-size: 14px; color: var(--warm-gray); cursor: pointer; transition: all 0.2s; letter-spacing: 0.04em; }
|
||||
.type-tab:not(:last-child) { border-right: 1px solid var(--warm-border); }
|
||||
.type-tab:hover { color: var(--ink); }
|
||||
.type-tab--active { background: var(--ink); color: #fff; }
|
||||
.type-tab--active:hover { color: #fff; }
|
||||
.cust-info-card { background: var(--c-bg-light, #faf9f6); border: 1px solid var(--warm-border); border-radius: 6px; padding: 14px 18px; margin-bottom: 4px; }
|
||||
.cust-info-name { font-family: var(--font-heading); font-size: 16px; color: var(--ink); letter-spacing: 0.04em; margin-bottom: 6px; }
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,7 @@ import api from '@/api/index'
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const stats = ref({ week_visits: 0, work_plans: 0, mini_business: 0, key_visits: 0, week_leaves: 0, week_start: '', week_end: '' })
|
||||
const stats = ref({ week_visits: 0, work_plans: 0, mini_business: 0, key_visits: 0, week_leaves: 0, overdue_plans: 0, week_start: '', week_end: '' })
|
||||
const progress = ref<any[]>([])
|
||||
const weekOffset = ref(0) // 0 = current week, -1 = last week, etc.
|
||||
|
||||
@@ -145,8 +145,8 @@ function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' | 'rest_
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-body">
|
||||
<span class="stat-num">{{ stats.work_plans }}</span>
|
||||
<span class="stat-label">工作计划</span>
|
||||
<span class="stat-num">{{ stats.work_plans }}<span v-if="stats.overdue_plans" class="stat-overdue">/{{ stats.overdue_plans }}</span></span>
|
||||
<span class="stat-label">工作计划<template v-if="stats.overdue_plans"> · <span style="color:var(--vermilion)">{{ stats.overdue_plans }}条过期</span></template></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card--clickable" @click="router.push('/mini-business')">
|
||||
@@ -296,6 +296,7 @@ function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' | 'rest_
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px; color: var(--warm-gray); letter-spacing: 0.04em;
|
||||
}
|
||||
.stat-overdue { font-size: 16px; color: var(--vermilion); font-family: var(--font-mono); }
|
||||
|
||||
/* ═══ Progress Grid — responsive 2-column ═══ */
|
||||
.progress-grid {
|
||||
@@ -426,4 +427,17 @@ function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' | 'rest_
|
||||
.leave-text { font-size: 13px; color: #5B7FA5; font-family: var(--font-body); }
|
||||
.rest-text { font-size: 13px; color: #9CA3AF; font-family: var(--font-body); }
|
||||
.empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: var(--font-body); }
|
||||
|
||||
/* ═══ Dark Mode — Dashboard ═══ */
|
||||
html.dark .stat-glyph--ink,
|
||||
html.dark .stat-glyph--sage,
|
||||
html.dark .stat-glyph--gold,
|
||||
html.dark .stat-glyph--vermilion {
|
||||
background: transparent;
|
||||
}
|
||||
html.dark .progress-row--missing { background: rgba(248,113,113,0.06); }
|
||||
html.dark .progress-row--catching { background: rgba(251,191,36,0.05); }
|
||||
html.dark .progress-row--full { background: rgba(74,222,128,0.05); }
|
||||
html.dark .progress-row--on_leave { background: rgba(96,165,250,0.05); }
|
||||
html.dark .progress-row--rest_day { background: rgba(156,163,175,0.03); }
|
||||
</style>
|
||||
|
||||
@@ -12,9 +12,10 @@ import EditLogPanel from '@/components/EditLogPanel.vue'
|
||||
const auth = useAuthStore()
|
||||
const themeStore = useThemeStore()
|
||||
const loading = ref(false)
|
||||
const isLight = computed(() => themeStore.currentTheme === 'light')
|
||||
const isLight = computed(() => themeStore.mode === 'light')
|
||||
const keyVisits = ref<any[]>([])
|
||||
const customers = ref<any[]>([])
|
||||
const allManagers = ref<any[]>([])
|
||||
const shotRef = ref<HTMLElement | null>(null)
|
||||
const { capturing, captureEl } = useScreenshot()
|
||||
const allUsers = ref<any[]>([])
|
||||
@@ -29,6 +30,8 @@ const keyStatuses = ['未开始', '进行中', '已完成']
|
||||
// ── Filters ──
|
||||
const filterManager = ref('')
|
||||
const filterStatus = ref('')
|
||||
const searchText = ref('')
|
||||
function onSearch() { loadItems() }
|
||||
|
||||
const managerOptions = computed(() => {
|
||||
const seen = new Set<string>()
|
||||
@@ -79,6 +82,9 @@ const managerSummary = computed(() => {
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadItems(), loadCustomers(), loadUsers(), loadManagerColors()])
|
||||
if (auth.isDirector || auth.isLeader) {
|
||||
api.get('/users/', { params: { role: 'manager' } }).then(r => { allManagers.value = r.data }).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
async function loadUsers() {
|
||||
@@ -88,6 +94,8 @@ async function loadUsers() {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const customerSearch = ref('')
|
||||
|
||||
async function loadCustomers(q?: string) {
|
||||
try {
|
||||
const params: any = { page_size: 500 }
|
||||
@@ -97,10 +105,34 @@ async function loadCustomers(q?: string) {
|
||||
} 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 loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.get('/key-visits/')
|
||||
const params: any = {}
|
||||
if (searchText.value) params.search = searchText.value
|
||||
const res = await api.get('/key-visits/', { params })
|
||||
keyVisits.value = res.data
|
||||
} catch (e: any) { ElMessage.error('加载失败') }
|
||||
finally { loading.value = false }
|
||||
@@ -108,7 +140,7 @@ async function loadItems() {
|
||||
|
||||
function openCreate() {
|
||||
dialogMode.value = 'create'
|
||||
form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '' }
|
||||
form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
|
||||
plannedVisitors.value = []
|
||||
dialogVisible.value = true
|
||||
}
|
||||
@@ -128,6 +160,7 @@ function onVisitorsChange(val: string[]) {
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.customer_id) { ElMessage.warning('请选择客户单位'); return }
|
||||
try {
|
||||
if (dialogMode.value === 'create') {
|
||||
await api.post('/key-visits/', form.value)
|
||||
@@ -198,6 +231,9 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
<!-- Filter Bar -->
|
||||
<div class="filter-bar">
|
||||
<div class="filter-row">
|
||||
<div class="filter-item">
|
||||
<el-input v-model="searchText" placeholder="搜索..." clearable size="small" style="width:180px" @keyup.enter="onSearch" @clear="onSearch" />
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">客户经理</label>
|
||||
<el-select v-model="filterManager" clearable placeholder="全部" size="small" style="width:150px">
|
||||
@@ -269,9 +305,20 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
<!-- Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 要客拜访'" width="520px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="客户单位">
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
|
||||
<el-form-item label="客户单位 *">
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="handleCustomerSearch" placeholder="搜索选择客户" style="width:100%">
|
||||
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||
<template #empty>
|
||||
<div v-if="customerSearch" class="select-empty-create">
|
||||
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到「{{ customerSearch }}」</p>
|
||||
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">+ 新建客户「{{ customerSearch }}」</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="auth.isDirector || auth.isLeader" label="客户经理">
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="紧急重要度">
|
||||
@@ -329,4 +376,8 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
.filter-item { display: flex; align-items: center; gap: 8px; }
|
||||
.filter-label { font-size: 13px; color: var(--warm-gray); font-family: var(--font-body); white-space: nowrap; }
|
||||
.filter-count { font-size: 12px; color: var(--c-text-muted); font-family: var(--font-mono); margin-left: auto; }
|
||||
.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 6px 12px; color: var(--gold-dark); font-family: var(--font-body); font-size: 12px; cursor: pointer; transition: all 0.2s; }
|
||||
.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); }
|
||||
.select-empty-create { padding: 8px 12px; text-align: center; }
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -27,7 +27,7 @@ const leaveTypeColors: Record<string, string> = {
|
||||
|
||||
const filterManager = ref('')
|
||||
const filterStatus = ref('')
|
||||
const isLight = computed(() => themeStore.currentTheme === 'light')
|
||||
const isLight = computed(() => themeStore.mode === 'light')
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
@@ -138,7 +138,9 @@ async function handleSave() {
|
||||
dialogVisible.value = false
|
||||
await loadLeaves()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('保存失败: ' + (e.response?.data?.detail || e.message))
|
||||
const detail = e.response?.data?.detail
|
||||
const msg = Array.isArray(detail) ? detail.map((d: any) => d.msg).join('; ') : (detail || e.message)
|
||||
ElMessage.error('保存失败: ' + msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,12 +278,14 @@ function onStatusChange(val: string) {
|
||||
type="date"
|
||||
placeholder="开始日期"
|
||||
style="width:100%; margin-bottom: 8px"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
<el-date-picker
|
||||
v-model="form.end_date"
|
||||
type="date"
|
||||
placeholder="结束日期"
|
||||
style="width:100%"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="原因(选填)">
|
||||
@@ -329,4 +333,5 @@ function onStatusChange(val: string) {
|
||||
border-left: 3px solid #5B7FA5 !important;
|
||||
background: rgba(91, 127, 165, 0.04) !important;
|
||||
}
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -141,6 +141,11 @@ const statusLabel: Record<string, string> = { green: '近30天已拜访', yellow
|
||||
<span class="manager-expand">{{ expandedManagers.has(m.manager_id) ? '▼' : '▶' }}</span>
|
||||
<span class="manager-name" @click.stop="goManagerReport(m.manager_id)">{{ m.manager_name }}</span>
|
||||
<span class="manager-count">{{ m.total_customers }} 个客户</span>
|
||||
<span class="manager-metrics">
|
||||
已拜访<strong>{{ m.visited_this_month }}</strong> ·
|
||||
未拜访<strong>{{ m.not_visited_2months }}</strong> ·
|
||||
完成率<strong :style="{ color: coverageColor(m.coverage_rate) }">{{ (m.coverage_rate * 100).toFixed(0) }}%</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="manager-lights">
|
||||
<span class="light-dot light-dot--green">{{ m.visited_this_month }}</span>
|
||||
@@ -304,6 +309,8 @@ const statusLabel: Record<string, string> = { green: '近30天已拜访', yellow
|
||||
.manager-name { font-family: var(--font-heading); font-size: 15px; color: var(--ink); letter-spacing: 0.04em; cursor: pointer; }
|
||||
.manager-name:hover { color: var(--gold); }
|
||||
.manager-count { font-family: var(--font-body); font-size: 12px; color: var(--warm-gray); }
|
||||
.manager-metrics { font-family: var(--font-body); font-size: 12px; color: var(--warm-gray); white-space: nowrap; }
|
||||
.manager-metrics strong { font-weight: 600; color: var(--ink); margin: 0 1px; }
|
||||
.manager-lights { display: flex; gap: 6px; }
|
||||
.light-dot { display: inline-flex; align-items: center; justify-content: center; min-width: 26px; height: 26px; border-radius: 4px; font-family: var(--font-mono); font-size: 12px; font-weight: 600; cursor: help; }
|
||||
.light-dot--green { background: #EDF2EC; color: var(--sage); }
|
||||
@@ -382,4 +389,17 @@ const statusLabel: Record<string, string> = { green: '近30天已拜访', yellow
|
||||
.dlg-plan-item.dlg-plan-overdue { background: #FBF1EE; margin: 2px -4px; padding: 4px; border-radius: 4px; }
|
||||
.dlg-plan-form { background: var(--c-bg-light, #faf9f6); padding: 10px 12px; border-radius: 6px; }
|
||||
.plan-form-row { display: flex; gap: 8px; align-items: center; }
|
||||
|
||||
/* ═══ Dark Mode — light card text stays dark ═══ */
|
||||
html.dark .customer-card .card-name { color: #1A1A1A; }
|
||||
html.dark .customer-card .card-industry,
|
||||
html.dark .customer-card .card-services,
|
||||
html.dark .customer-card .card-last-visit { color: #555; }
|
||||
html.dark .customer-card .card-method { color: #555; border-color: #999; }
|
||||
html.dark .customer-card .card-last-visit--never { color: #B8472E; }
|
||||
html.dark .plan-badge { background: #2D2D2D; color: var(--sage); }
|
||||
html.dark .plan-badge.plan-overdue { background: #3D2020; }
|
||||
html.dark .light-dot--green { background: #1E2F1E; }
|
||||
html.dark .light-dot--yellow { background: #2F2818; }
|
||||
html.dark .light-dot--red { background: #2F1E1E; }
|
||||
</style>
|
||||
|
||||
@@ -2,19 +2,40 @@
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { todayStr } from '@/utils'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { getMgrStyle } from '@/utils/managerColor'
|
||||
import api from '@/api/index'
|
||||
import { compressImage } from '@/utils/image'
|
||||
import ImagePreview from '@/components/ImagePreview.vue'
|
||||
import EditLogPanel from '@/components/EditLogPanel.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const activeTab = ref('visits')
|
||||
const loading = ref(false)
|
||||
const allManagers = ref<any[]>([])
|
||||
|
||||
// Date range filter
|
||||
const dateRange = ref<any>(null)
|
||||
const searchText = ref('')
|
||||
const pageSize = ref(25)
|
||||
|
||||
const visits = ref<any[]>([])
|
||||
const workPlans = ref<any[]>([])
|
||||
const miniBusiness = ref<any[]>([])
|
||||
const dailyNotes = ref<any[]>([])
|
||||
const keyVisits = ref<any[]>([])
|
||||
|
||||
// Pagination per tab
|
||||
const page = ref<Record<string, number>>({ visits: 1, notes: 1, plans: 1, mini: 1, key: 1 })
|
||||
const total = ref<Record<string, number>>({ visits: 0, notes: 0, plans: 0, mini: 0, key: 0 })
|
||||
|
||||
function getDateRange() {
|
||||
if (dateRange.value && Array.isArray(dateRange.value) && dateRange.value.length === 2) {
|
||||
return { from: dateRange.value[0], to: dateRange.value[1] }
|
||||
}
|
||||
return { from: '', to: '' }
|
||||
}
|
||||
const customers = ref<any[]>([])
|
||||
const allUsers = ref<any[]>([])
|
||||
const plannedVisitors = ref<string[]>([])
|
||||
@@ -39,6 +60,9 @@ const keyStatuses = ['未开始', '进行中', '已完成']
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadAll(), loadCustomers(), loadUsers()])
|
||||
if (auth.isDirector || auth.isLeader) {
|
||||
api.get('/users/', { params: { role: 'manager' } }).then(r => { allManagers.value = r.data }).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
async function loadUsers() {
|
||||
@@ -53,6 +77,8 @@ function typeLabel(t: string) {
|
||||
return labels[t] || ''
|
||||
}
|
||||
|
||||
const customerSearch = ref('')
|
||||
|
||||
async function loadCustomers(q?: string) {
|
||||
try {
|
||||
const params: any = { page_size: 100 }
|
||||
@@ -62,18 +88,61 @@ async function loadCustomers(q?: string) {
|
||||
} 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))
|
||||
}
|
||||
}
|
||||
|
||||
function buildParams(tabKey: string) {
|
||||
const { from, to } = getDateRange()
|
||||
const params: any = { page: page.value[tabKey] || 1, page_size: pageSize.value }
|
||||
if (from) { params.date_from = from; params.date_to = to }
|
||||
if (searchText.value) params.search = searchText.value
|
||||
return params
|
||||
}
|
||||
|
||||
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/'),
|
||||
const [v, d] = await Promise.all([
|
||||
api.get('/visits/', { params: buildParams('visits') }),
|
||||
api.get('/daily-notes/', { params: buildParams('notes') }),
|
||||
])
|
||||
visits.value = v.data; workPlans.value = w.data
|
||||
miniBusiness.value = m.data; dailyNotes.value = d.data
|
||||
keyVisits.value = k.data
|
||||
// Load photo previews
|
||||
visits.value = Array.isArray(v.data) ? v.data : (v.data.items || [])
|
||||
dailyNotes.value = Array.isArray(d.data) ? d.data : (d.data.items || [])
|
||||
total.value.visits = v.data.total || visits.value.length
|
||||
total.value.notes = d.data.total || dailyNotes.value.length
|
||||
|
||||
const [w, m, k] = await Promise.all([
|
||||
api.get('/work-plans/', { params: buildParams('plans') }),
|
||||
api.get('/mini-business/', { params: buildParams('mini') }),
|
||||
api.get('/key-visits/', { params: buildParams('key') }),
|
||||
])
|
||||
workPlans.value = Array.isArray(w.data) ? w.data : (w.data.items || [])
|
||||
miniBusiness.value = Array.isArray(m.data) ? m.data : (m.data.items || [])
|
||||
keyVisits.value = Array.isArray(k.data) ? k.data : (k.data.items || [])
|
||||
total.value.plans = w.data.total || workPlans.value.length
|
||||
total.value.mini = m.data.total || miniBusiness.value.length
|
||||
total.value.key = k.data.total || keyVisits.value.length
|
||||
|
||||
// Load photo previews for visits
|
||||
for (const visit of visits.value) {
|
||||
if (visit.photos?.length) {
|
||||
for (const key of visit.photos) {
|
||||
@@ -90,14 +159,19 @@ async function loadAll() {
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function onTabChange(tab: string) { activeTab.value = tab; loadAll() }
|
||||
function onFilterChange() { Object.keys(page.value).forEach(k => page.value[k] = 1); loadAll() }
|
||||
function onPageChange(tabKey: string, p: number) { page.value[tabKey] = p; loadAll() }
|
||||
function onSizeChange() { Object.keys(page.value).forEach(k => page.value[k] = 1); loadAll() }
|
||||
|
||||
function openCreate(type: string) {
|
||||
dialogMode.value = 'create'; dialogType.value = type
|
||||
dialogTimeRange.value = null
|
||||
if (type === 'visit') form.value = { customer_id: '', visit_date: todayStr(), visit_method: '上门', time_range: '', visitor_name: '', visitor_phone: '', communication_content: '', customer_demand: '', companions: [], companion_names: [] }
|
||||
else if (type === 'note') form.value = { note_date: todayStr(), category: '其他', content: '', time_range: '' }
|
||||
else if (type === 'plan') form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中' }
|
||||
else if (type === 'mini') form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '' }
|
||||
else if (type === 'key') { form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '' }; plannedVisitors.value = [] }
|
||||
if (type === 'visit') form.value = { customer_id: '', visit_date: todayStr(), visit_method: '上门', time_range: '', visitor_name: '', visitor_phone: '', communication_content: '', customer_demand: '', companions: [], companion_names: [], manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
|
||||
else if (type === 'note') form.value = { note_date: todayStr(), category: '其他', content: '', time_range: '', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
|
||||
else if (type === 'plan') form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
|
||||
else if (type === 'mini') form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
|
||||
else if (type === 'key') { form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }; plannedVisitors.value = [] }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -107,6 +181,10 @@ function openEdit(type: string, item: any) {
|
||||
if (item.customer_id && item.customer_name && !customers.value.find((c: any) => c.id === item.customer_id)) {
|
||||
customers.value.unshift({ id: item.customer_id, name: item.customer_name })
|
||||
}
|
||||
// Ensure assigned manager is in the select options (avoid showing raw UUID)
|
||||
if (item.manager_id && item.manager_name && !allUsers.value.find((u: any) => u.id === item.manager_id)) {
|
||||
allUsers.value.unshift({ id: item.manager_id, name: item.manager_name })
|
||||
}
|
||||
dialogTimeRange.value = null
|
||||
if ((type === 'visit' || type === 'note') && item.time_range && item.time_range.includes('-')) {
|
||||
const parts = item.time_range.split('-')
|
||||
@@ -140,12 +218,13 @@ async function handleDialogPhotoUpload(event: Event) {
|
||||
try {
|
||||
// Compress before upload to reduce storage & transfer
|
||||
const compressed = await compressImage(file, { maxPixels: 1920, quality: 0.8 })
|
||||
// Get presigned URL
|
||||
const presignRes = await api.post('/upload/presigned-url', null, { params: { filename: compressed.name, content_type: compressed.type || 'image/jpeg' } })
|
||||
// Upload directly to MinIO (not through our API)
|
||||
const axios = (await import('axios')).default
|
||||
await axios.put(presignRes.data.upload_url, compressed, { headers: { 'Content-Type': compressed.type || 'image/jpeg' } })
|
||||
const key = presignRes.data.object_key
|
||||
const data = new FormData()
|
||||
data.append('file', compressed)
|
||||
const uploadRes = await api.post('/upload/image', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 60000,
|
||||
})
|
||||
const key = uploadRes.data.object_key
|
||||
if (!form.value.photos) form.value.photos = []
|
||||
form.value.photos.push(key)
|
||||
form.value.photos = [...form.value.photos]
|
||||
@@ -237,6 +316,17 @@ function onDialogTimeChange(val: [string, string] | null) {
|
||||
form.value.time_range = val ? val.join('-') : ''
|
||||
}
|
||||
|
||||
async function onDialogCustomerChange(customerId: string) {
|
||||
// Only pre-fill for new visit creation (not edit)
|
||||
if (dialogMode.value !== 'create' || dialogType.value !== 'visit' || !customerId) return
|
||||
try {
|
||||
const planRes = await api.get(`/customers/${customerId}/overdue-plans`)
|
||||
if (planRes.data?.plan?.plan_content) {
|
||||
form.value.communication_content = planRes.data.plan.plan_content
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Quick status change
|
||||
async function quickStatusChange(type: string, row: any, newStatus: string) {
|
||||
try {
|
||||
@@ -274,16 +364,24 @@ const notesByDate = computed(() => {
|
||||
<template>
|
||||
<div class="workspace" v-loading="loading">
|
||||
<div class="page-head">
|
||||
<h2 class="page-title">我的工作数据</h2>
|
||||
<div class="page-head-row">
|
||||
<h2 class="page-title">我的工作数据</h2>
|
||||
<div class="page-head-controls">
|
||||
<el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" size="small" style="width:240px" value-format="YYYY-MM-DD" @change="onFilterChange" />
|
||||
<el-button v-if="dateRange" size="small" @click="dateRange=null;onFilterChange()">清除</el-button>
|
||||
<el-input v-model="searchText" placeholder="搜索..." clearable size="small" style="width:240px" @keyup.enter="onFilterChange" @clear="onFilterChange">
|
||||
<template #append><el-button size="small" @click="onFilterChange">搜索</el-button></template>
|
||||
</el-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-rule"></div>
|
||||
</div>
|
||||
|
||||
<el-card>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
||||
<!-- ═══ 拜访记录 ═══ -->
|
||||
<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">
|
||||
<div v-for="[date, items] in visitsByDate" :key="date" class="date-group">
|
||||
<h4 class="date-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px; color: var(--gold)">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
@@ -294,6 +392,11 @@ const notesByDate = computed(() => {
|
||||
<el-table-column prop="customer_name" label="客户" width="130">
|
||||
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('visit', row)">{{ row.customer_name }}</el-link></template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="auth.isDirector || auth.isLeader" label="客户经理" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :style="getMgrStyle(row.manager_name || '', false)" effect="dark" size="small" disable-transitions>{{ row.manager_name || '-' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="visit_method" label="方式" width="60" />
|
||||
<el-table-column prop="time_range" label="时间" width="100" />
|
||||
<el-table-column label="照片" width="60">
|
||||
@@ -315,7 +418,6 @@ const notesByDate = computed(() => {
|
||||
|
||||
<!-- ═══ 今日纪要 ═══ -->
|
||||
<el-tab-pane label="今日纪要" name="daily_notes">
|
||||
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('note')">+ 新建纪要</el-button></div>
|
||||
<div v-for="[date, items] in notesByDate" :key="date" class="date-group">
|
||||
<h4 class="date-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px; color: var(--gold)">
|
||||
@@ -325,6 +427,11 @@ const notesByDate = computed(() => {
|
||||
</h4>
|
||||
<el-table :data="items" stripe size="small" v-column-resize>
|
||||
<el-table-column prop="category" label="分类" width="100"><template #default="{ row }"><el-tag size="small">{{ row.category }}</el-tag></template></el-table-column>
|
||||
<el-table-column v-if="auth.isDirector || auth.isLeader" label="客户经理" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :style="getMgrStyle(row.manager_name || '', false)" effect="dark" size="small" disable-transitions>{{ row.manager_name || '-' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="content" label="工作内容" min-width="250" show-overflow-tooltip>
|
||||
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('note', row)">{{ row.content }}</el-link></template>
|
||||
</el-table-column>
|
||||
@@ -341,7 +448,6 @@ const notesByDate = computed(() => {
|
||||
|
||||
<!-- ═══ 工作计划 ═══ -->
|
||||
<el-tab-pane label="工作计划" name="work_plans">
|
||||
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('plan')">+ 新建计划</el-button></div>
|
||||
<el-table :data="workPlans" stripe size="small" v-column-resize>
|
||||
<el-table-column prop="customer_name" label="客户" width="130">
|
||||
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('plan', row)">{{ row.customer_name }}</el-link></template>
|
||||
@@ -373,7 +479,6 @@ const notesByDate = computed(() => {
|
||||
|
||||
<!-- ═══ 小微商机 ═══ -->
|
||||
<el-tab-pane label="小微商机" name="mini_biz">
|
||||
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('mini')">+ 新建商机</el-button></div>
|
||||
<el-table :data="miniBusiness" stripe size="small" v-column-resize>
|
||||
<el-table-column prop="customer_name" label="客户" width="130">
|
||||
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('mini', row)">{{ row.customer_name }}</el-link></template>
|
||||
@@ -407,7 +512,6 @@ const notesByDate = computed(() => {
|
||||
|
||||
<!-- ═══ 要客拜访 ═══ -->
|
||||
<el-tab-pane label="要客拜访" name="key_visits">
|
||||
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('key')">+ 新建要客拜访</el-button></div>
|
||||
<el-table :data="keyVisits" stripe size="small" v-column-resize>
|
||||
<el-table-column prop="customer_name" label="客户" width="130">
|
||||
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('key', row)">{{ row.customer_name }}</el-link></template>
|
||||
@@ -439,20 +543,53 @@ const notesByDate = computed(() => {
|
||||
</el-table>
|
||||
<div v-if="!keyVisits.length" class="empty">暂无数据</div>
|
||||
</el-tab-pane>
|
||||
<div style="margin-top:16px;display:flex;align-items:center;gap:12px">
|
||||
<el-pagination
|
||||
v-model:current-page="page[activeTab === 'notes' ? 'notes' : activeTab === 'plans' ? 'plans' : activeTab === 'mini' ? 'mini' : activeTab === 'key' ? 'key' : 'visits']"
|
||||
:page-size="pageSize"
|
||||
:total="total[activeTab === 'notes' ? 'notes' : activeTab === 'plans' ? 'plans' : activeTab === 'mini' ? 'mini' : activeTab === 'key' ? 'key' : 'visits']"
|
||||
:page-sizes="[25, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
small
|
||||
@current-change="(p: number) => onPageChange(activeTab === 'notes' ? 'notes' : activeTab === 'plans' ? 'plans' : activeTab === 'mini' ? 'mini' : activeTab === 'key' ? 'key' : 'visits', p)"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' ' + typeLabel(dialogType)" width="520px">
|
||||
<el-form label-position="top" v-if="form">
|
||||
<el-form-item v-if="['visit','plan','mini','key'].includes(dialogType)" label="客户单位">
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
|
||||
<el-form-item v-if="['visit','plan','mini','key'].includes(dialogType)" label="客户单位 *">
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="handleCustomerSearch" placeholder="搜索选择客户" style="width:100%" @change="onDialogCustomerChange">
|
||||
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||
<template #empty>
|
||||
<div v-if="customerSearch" class="select-empty-create">
|
||||
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到「{{ customerSearch }}」</p>
|
||||
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">+ 新建客户「{{ customerSearch }}」</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="(auth.isDirector || auth.isLeader) && ['visit','plan','mini','key','note'].includes(dialogType)" label="客户经理">
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="dialogType === 'visit'" label="日期"><el-date-picker v-model="form.visit_date" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
|
||||
<el-form-item v-if="dialogType === 'note'" label="日期"><el-date-picker v-model="form.note_date" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
|
||||
<el-form-item v-if="dialogType === 'plan'" label="计划拜访时间"><el-date-picker v-model="form.plan_date" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
|
||||
<el-form-item v-if="dialogType === 'visit' && (auth.isDirector || auth.isLeader)" label="客户经理">
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="u in allUsers.filter((u: any) => u.role === 'manager')" :key="u.id" :label="u.name" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="(dialogType === 'plan' || dialogType === 'mini' || dialogType === 'key') && (auth.isDirector || auth.isLeader)" label="客户经理">
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="u in allUsers.filter((u: any) => u.role === 'manager')" :key="u.id" :label="u.name" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="dialogType === 'visit'" label="日期"><el-date-picker v-model="form.visit_date" type="date" style="width:100%" /></el-form-item>
|
||||
<el-form-item v-if="dialogType === 'note'" label="日期"><el-date-picker v-model="form.note_date" type="date" style="width:100%" /></el-form-item>
|
||||
<el-form-item v-if="dialogType === 'plan'" label="计划拜访时间"><el-date-picker v-model="form.plan_date" type="date" style="width:100%" /></el-form-item>
|
||||
<template v-if="dialogType === 'visit'">
|
||||
<el-form-item label="拜访方式">
|
||||
<div class="method-grid">
|
||||
@@ -537,9 +674,15 @@ const notesByDate = computed(() => {
|
||||
|
||||
<style scoped>
|
||||
.page-head { margin-bottom: 20px; }
|
||||
.page-title { margin: 0; font-family: var(--font-heading); font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
|
||||
.page-head-row { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
||||
.page-head-controls { display: flex; align-items: center; gap: 8px; }
|
||||
.page-title { margin: 0; font-family: var(--font-heading); font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; white-space: nowrap; }
|
||||
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
|
||||
.date-group { margin-bottom: 20px; }
|
||||
.date-title { display: flex; align-items: center; margin: 12px 0 8px; font-family: var(--font-heading); font-size: 14px; color: var(--ink); letter-spacing: 0.04em; }
|
||||
.empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: var(--font-body); }
|
||||
.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 6px 12px; color: var(--gold-dark); font-family: var(--font-body); font-size: 12px; cursor: pointer; transition: all 0.2s; }
|
||||
.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); }
|
||||
.select-empty-create { padding: 8px 12px; text-align: center; }
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -7,14 +7,16 @@ import { todayStr } from '@/utils'
|
||||
import { getMgrStyle, loadManagerColors } from '@/utils/managerColor'
|
||||
import { useScreenshot } from '@/utils/screenshot'
|
||||
import api from '@/api/index'
|
||||
import { miniBusinessApi } from '@/api/miniBusiness'
|
||||
import EditLogPanel from '@/components/EditLogPanel.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const themeStore = useThemeStore()
|
||||
const loading = ref(false)
|
||||
const isLight = computed(() => themeStore.currentTheme === 'light')
|
||||
const isLight = computed(() => themeStore.mode === 'light')
|
||||
const miniBusiness = ref<any[]>([])
|
||||
const customers = ref<any[]>([])
|
||||
const allManagers = ref<any[]>([])
|
||||
const shotRef = ref<HTMLElement | null>(null)
|
||||
const { capturing, captureEl } = useScreenshot()
|
||||
|
||||
@@ -24,9 +26,22 @@ const form = ref<any>({})
|
||||
const statusPick = ref<Record<string, string>>({})
|
||||
const miniStatuses = ['跟进中', '已签约', '已流失']
|
||||
|
||||
// ── Detail + Follow-up logs ──
|
||||
const detailVisible = ref(false)
|
||||
const selectedBusiness = ref<any>(null)
|
||||
const logs = ref<any[]>([])
|
||||
const logForm = ref({ log_date: '', method: '电话', content: '' })
|
||||
const logMethods = ['电话', '微信', '上门', '邮件', '其他']
|
||||
const logMethodIcons: Record<string, string> = { '电话': '📞', '微信': '💬', '上门': '🏢', '邮件': '📧', '其他': '📋' }
|
||||
const logMethodColors: Record<string, string> = { '电话': '#5B7FA5', '微信': '#22c55e', '上门': '#4A6741', '邮件': '#C4934A', '其他': '#7B7568' }
|
||||
const logLoading = ref(false)
|
||||
const logSaving = ref(false)
|
||||
|
||||
// ── Filters ──
|
||||
const filterManager = ref('')
|
||||
const filterStatus = ref('')
|
||||
const filterStatus = ref('跟进中')
|
||||
const searchText = ref('')
|
||||
function onSearch() { loadItems() }
|
||||
|
||||
const managerOptions = computed(() => {
|
||||
const seen = new Set<string>()
|
||||
@@ -36,10 +51,12 @@ const managerOptions = computed(() => {
|
||||
.sort()
|
||||
})
|
||||
|
||||
const statusOrder: Record<string, number> = { '跟进中': 0, '已签约': 1, '已流失': 2 }
|
||||
const filteredItems = computed(() => {
|
||||
let list = miniBusiness.value
|
||||
if (filterManager.value) list = list.filter((m: any) => (m.manager_name || '未知') === filterManager.value)
|
||||
if (filterStatus.value) list = list.filter((m: any) => m.status === filterStatus.value)
|
||||
list.sort((a: any, b: any) => (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9))
|
||||
return list
|
||||
})
|
||||
|
||||
@@ -64,8 +81,13 @@ const managerSummary = computed(() => {
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadItems(), loadCustomers(), loadManagerColors()])
|
||||
if (auth.isDirector || auth.isLeader) {
|
||||
api.get('/users/', { params: { role: 'manager' } }).then(r => { allManagers.value = r.data }).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
const customerSearch = ref('')
|
||||
|
||||
async function loadCustomers(q?: string) {
|
||||
try {
|
||||
const params: any = { page_size: 500 }
|
||||
@@ -75,10 +97,34 @@ async function loadCustomers(q?: string) {
|
||||
} 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 loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.get('/mini-business/')
|
||||
const params: any = {}
|
||||
if (searchText.value) params.search = searchText.value
|
||||
const res = await api.get('/mini-business/', { params })
|
||||
miniBusiness.value = res.data
|
||||
} catch (e: any) { ElMessage.error('加载失败') }
|
||||
finally { loading.value = false }
|
||||
@@ -86,7 +132,7 @@ async function loadItems() {
|
||||
|
||||
function openCreate() {
|
||||
dialogMode.value = 'create'
|
||||
form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '' }
|
||||
form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -100,7 +146,9 @@ function openEdit(item: any) {
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.customer_id) { ElMessage.warning('请选择客户单位'); return }
|
||||
try {
|
||||
if (form.value.status === '已流失') form.value.expected_revenue_date = ''
|
||||
if (dialogMode.value === 'create') {
|
||||
await api.post('/mini-business/', form.value)
|
||||
} else {
|
||||
@@ -121,10 +169,50 @@ async function handleDelete(id: string) {
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
|
||||
// ── Detail + Follow-up logs ──
|
||||
async function openDetail(item: any) {
|
||||
selectedBusiness.value = item
|
||||
detailVisible.value = true
|
||||
logForm.value = { log_date: new Date().toISOString().slice(0, 10), method: '电话', content: '' }
|
||||
await loadLogs()
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
logLoading.value = true
|
||||
try {
|
||||
const res = await miniBusinessApi.getLogs(selectedBusiness.value.id)
|
||||
logs.value = res.data || []
|
||||
} catch (_) { logs.value = [] }
|
||||
finally { logLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleCreateLog() {
|
||||
if (!logForm.value.content.trim()) { ElMessage.warning('请输入跟进内容'); return }
|
||||
logSaving.value = true
|
||||
try {
|
||||
await miniBusinessApi.createLog(selectedBusiness.value.id, logForm.value)
|
||||
ElMessage.success('跟进记录已添加')
|
||||
logForm.value = { log_date: new Date().toISOString().slice(0, 10), method: '电话', content: '' }
|
||||
await loadLogs()
|
||||
} catch (e: any) { ElMessage.error('添加失败: ' + (e.response?.data?.detail || e.message)) }
|
||||
finally { logSaving.value = false }
|
||||
}
|
||||
|
||||
async function handleDeleteLog(logId: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该跟进记录?', '确认', { type: 'warning' })
|
||||
await miniBusinessApi.deleteLog(logId)
|
||||
ElMessage.success('已删除')
|
||||
await loadLogs()
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
|
||||
async function quickStatusChange(row: any, newStatus: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定将状态改为「${newStatus}」?`, '确认', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' })
|
||||
await api.put(`/mini-business/${row.id}`, { status: newStatus })
|
||||
const payload: any = { status: newStatus }
|
||||
if (newStatus === '已流失') payload.expected_revenue_date = ''
|
||||
await api.put(`/mini-business/${row.id}`, payload)
|
||||
ElMessage.success('状态已更新')
|
||||
await loadItems()
|
||||
} catch (e: any) {
|
||||
@@ -170,6 +258,9 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
<!-- Filter Bar -->
|
||||
<div class="filter-bar">
|
||||
<div class="filter-row">
|
||||
<div class="filter-item">
|
||||
<el-input v-model="searchText" placeholder="搜索..." clearable size="small" style="width:180px" @keyup.enter="onSearch" @clear="onSearch" />
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">客户经理</label>
|
||||
<el-select v-model="filterManager" clearable placeholder="全部" size="small" style="width:150px">
|
||||
@@ -192,7 +283,7 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
<el-table-column type="index" label="序号" width="50" />
|
||||
<el-table-column prop="customer_name" label="客户" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" :underline="false" @click="openEdit(row)">{{ row.customer_name }}</el-link>
|
||||
<el-link type="primary" :underline="false" @click="openDetail(row)">{{ row.customer_name }}</el-link>
|
||||
<el-tooltip v-if="row.edit_log?.length > 1" placement="top">
|
||||
<template #content>最后编辑:{{ row.edit_log[row.edit_log.length-1].editor }} · {{ row.edit_log.length-1 }}次修改</template>
|
||||
<span class="edit-indicator" title="有过修改">🕐</span>
|
||||
@@ -236,9 +327,20 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
<!-- Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 小微商机'" width="500px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="客户单位">
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
|
||||
<el-form-item label="客户单位 *">
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="handleCustomerSearch" placeholder="搜索选择客户" style="width:100%">
|
||||
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||
<template #empty>
|
||||
<div v-if="customerSearch" class="select-empty-create">
|
||||
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到「{{ customerSearch }}」</p>
|
||||
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">+ 新建客户「{{ customerSearch }}」</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="auth.isDirector || auth.isLeader" label="客户经理">
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="产品类型"><el-input v-model="form.product_type" placeholder="如:云专线、SD-WAN" /></el-form-item>
|
||||
@@ -261,6 +363,59 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Detail Dialog with Follow-up Timeline -->
|
||||
<el-dialog v-model="detailVisible" :title="selectedBusiness?.customer_name + ' — 商机跟单'" width="650px" v-if="selectedBusiness">
|
||||
<!-- Business Info -->
|
||||
<div class="detail-info">
|
||||
<div class="detail-row"><span class="detail-label">产品类型</span><span>{{ selectedBusiness.product_type }}</span></div>
|
||||
<div class="detail-row"><span class="detail-label">金额</span><span>{{ selectedBusiness.amount }}</span></div>
|
||||
<div class="detail-row"><span class="detail-label">状态</span><span :style="{ color: selectedBusiness.status === '已签约' ? '#4A6741' : selectedBusiness.status === '已流失' ? '#B8472E' : '#C4934A' }">{{ selectedBusiness.status }}</span></div>
|
||||
<div class="detail-row"><span class="detail-label">预计列收</span><span>{{ selectedBusiness.expected_revenue_date }}</span></div>
|
||||
<div class="detail-row"><span class="detail-label">客户经理</span><span class="mgr-tag" :style="getMgrStyle(selectedBusiness.manager_name, isLight)">{{ selectedBusiness.manager_name }}</span></div>
|
||||
<div class="detail-row" v-if="selectedBusiness.follow_up_detail"><span class="detail-label">商机概述</span><span>{{ selectedBusiness.follow_up_detail }}</span></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:12px">
|
||||
<el-button size="small" @click="detailVisible = false; openEdit(selectedBusiness)">编辑商机</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Follow-up Timeline -->
|
||||
<div class="log-section">
|
||||
<h4 class="log-title">跟进记录</h4>
|
||||
<div v-loading="logLoading" class="log-list">
|
||||
<div v-if="logs.length === 0 && !logLoading" class="log-empty">暂无跟进记录</div>
|
||||
<div v-for="l in logs" :key="l.id" class="log-item">
|
||||
<span class="log-icon">{{ logMethodIcons[l.method] || '📋' }}</span>
|
||||
<div class="log-body">
|
||||
<div class="log-header">
|
||||
<span class="log-date">{{ l.log_date }}</span>
|
||||
<span class="log-method-chip" :style="{ background: logMethodColors[l.method] || '#7B7568', color: '#fff', padding: '1px 8px', fontSize: '11px' }">{{ l.method }}</span>
|
||||
<span class="log-author">{{ l.created_by_name }}</span>
|
||||
</div>
|
||||
<div class="log-content">{{ l.content }}</div>
|
||||
</div>
|
||||
<el-button size="small" type="danger" text @click="handleDeleteLog(l.id)" class="log-del">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Log Form -->
|
||||
<div class="log-add">
|
||||
<div class="log-add-row">
|
||||
<el-date-picker v-model="logForm.log_date" type="date" value-format="YYYY-MM-DD" size="small" style="width:140px" />
|
||||
<div class="log-method-chips">
|
||||
<button v-for="m in logMethods" :key="m" type="button" class="log-chip" :class="{ 'log-chip--active': logForm.method === m }" :style="logForm.method === m ? { background: logMethodColors[m], borderColor: logMethodColors[m], color: '#fff' } : {}" @click="logForm.method = m">{{ m }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<el-input v-model="logForm.content" type="textarea" :rows="2" placeholder="输入跟进内容..." size="small" style="margin-top:6px" />
|
||||
<el-button type="primary" size="small" :loading="logSaving" @click="handleCreateLog" style="margin-top:6px">添加跟进</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -284,4 +439,37 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
.filter-item { display: flex; align-items: center; gap: 8px; }
|
||||
.filter-label { font-size: 13px; color: var(--warm-gray); font-family: var(--font-body); white-space: nowrap; }
|
||||
.filter-count { font-size: 12px; color: var(--c-text-muted); font-family: var(--font-mono); margin-left: auto; }
|
||||
.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 6px 12px; color: var(--gold-dark); font-family: var(--font-body); font-size: 12px; cursor: pointer; transition: all 0.2s; }
|
||||
.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); }
|
||||
.select-empty-create { padding: 8px 12px; text-align: center; }
|
||||
|
||||
/* ── Detail Dialog ── */
|
||||
.detail-info { background: var(--c-bg-light, #faf9f6); border-radius: 6px; padding: 14px 18px; }
|
||||
.detail-row { display: flex; gap: 12px; padding: 4px 0; font-size: 13px; }
|
||||
.detail-label { color: var(--warm-gray); min-width: 70px; flex-shrink: 0; }
|
||||
|
||||
/* ── Log Timeline ── */
|
||||
.log-section { margin-top: 18px; border-top: 1px solid var(--warm-border); padding-top: 14px; }
|
||||
.log-title { margin: 0 0 12px; font-family: var(--font-heading); font-size: 16px; color: var(--ink); }
|
||||
.log-list { display: flex; flex-direction: column; gap: 10px; max-height: 300px; overflow-y: auto; margin-bottom: 14px; }
|
||||
.log-empty { text-align: center; color: var(--warm-gray); font-size: 13px; padding: 20px 0; }
|
||||
.log-item { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; background: var(--surface); border: 1px solid var(--warm-border); }
|
||||
.log-icon { font-size: 18px; flex-shrink: 0; margin-top: 2px; }
|
||||
.log-body { flex: 1; min-width: 0; }
|
||||
.log-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||
.log-date { font-family: var(--font-mono); font-size: 12px; color: var(--ink); }
|
||||
.log-author { font-size: 11px; color: var(--warm-gray); margin-left: auto; }
|
||||
.log-content { font-size: 13px; color: var(--c-text); line-height: 1.6; }
|
||||
.log-del { flex-shrink: 0; opacity: 0.5; }
|
||||
.log-del:hover { opacity: 1; }
|
||||
.log-method-chip { border-radius: 3px; white-space: nowrap; }
|
||||
|
||||
/* ── Add Log Form ── */
|
||||
.log-add { border-top: 1px solid var(--warm-border); padding-top: 12px; }
|
||||
.log-add-row { display: flex; gap: 8px; align-items: center; }
|
||||
.log-method-chips { display: flex; gap: 4px; }
|
||||
.log-chip { padding: 4px 10px; border: 1px solid var(--warm-border); background: var(--surface); font-size: 12px; cursor: pointer; transition: all 0.2s; }
|
||||
.log-chip:hover { border-color: var(--ink); }
|
||||
.log-chip--active { font-weight: 600; }
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import api from '@/api/index'
|
||||
import { useThemeStore, themeLabels, type Theme } from '@/stores/theme'
|
||||
import { useThemeStore, themeLabels, type Theme, lightThemes, darkThemes } from '@/stores/theme'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const managers = ref<any[]>([])
|
||||
const selectedUserIds = ref<string[]>([])
|
||||
@@ -165,15 +167,30 @@ async function handleImportPreview() {
|
||||
</div>
|
||||
</template>
|
||||
<div class="theme-options">
|
||||
<label v-for="t in (['editorial', 'light'] as Theme[])" :key="t" class="theme-opt" :class="{ 'theme-opt--active': themeStore.currentTheme === t }" @click="themeStore.setTheme(t)">
|
||||
<span class="theme-opt-radio">{{ themeStore.currentTheme === t ? '●' : '○' }}</span>
|
||||
<span class="theme-opt-label">{{ themeLabels[t] }}</span>
|
||||
</label>
|
||||
<div class="theme-mode-row">
|
||||
<span class="theme-mode-label">模式</span>
|
||||
<button class="theme-mode-btn" @click="themeStore.toggleMode()">
|
||||
{{ themeStore.mode === 'light' ? '☀️ 亮色模式' : '🌙 深色模式' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="theme-sub-options">
|
||||
<label
|
||||
v-for="t in (themeStore.mode === 'light' ? lightThemes : darkThemes)"
|
||||
:key="t"
|
||||
class="theme-opt"
|
||||
:class="{ 'theme-opt--active': (themeStore.mode === 'light' ? themeStore.lightTheme : themeStore.darkTheme) === t }"
|
||||
@click="themeStore.mode === 'light' ? themeStore.setLightTheme(t) : themeStore.setDarkTheme(t)"
|
||||
>
|
||||
<span class="theme-opt-radio">{{ (themeStore.mode === 'light' ? themeStore.lightTheme : themeStore.darkTheme) === t ? '●' : '○' }}</span>
|
||||
<span class="theme-opt-label">{{ themeLabels[t] }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Manual Remind -->
|
||||
<el-card class="setting-card">
|
||||
<template v-if="auth.isDirector">
|
||||
<!-- Manual Remind -->
|
||||
<el-card class="setting-card">
|
||||
<template #header>
|
||||
<div class="card-header-title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px; color: var(--gold)">
|
||||
@@ -371,6 +388,7 @@ async function handleImportPreview() {
|
||||
</el-alert>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -395,7 +413,12 @@ async function handleImportPreview() {
|
||||
.setting-label { font-family: var(--font-body); font-size: 13px; color: var(--ink); }
|
||||
|
||||
/* ── Theme Selector ── */
|
||||
.theme-options { display: flex; gap: 16px; }
|
||||
.theme-options { display: flex; flex-direction: column; gap: 12px; }
|
||||
.theme-mode-row { display: flex; align-items: center; gap: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--warm-border); margin-bottom: 4px; }
|
||||
.theme-mode-label { font-family: var(--font-heading); font-size: 13px; color: var(--warm-gray); }
|
||||
.theme-mode-btn { font-family: var(--font-body); font-size: 14px; cursor: pointer; padding: 6px 16px; border: 1px solid var(--warm-border); border-radius: 6px; background: var(--c-primary-bg); color: var(--c-text); transition: all var(--transition); }
|
||||
.theme-mode-btn:hover { border-color: var(--gold); }
|
||||
.theme-sub-options { display: flex; gap: 12px; padding-left: 4px; }
|
||||
.theme-opt { display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 8px 16px; border: 1px solid var(--warm-border); transition: all var(--transition); }
|
||||
.theme-opt:hover { border-color: var(--ink); }
|
||||
.theme-opt--active { border-color: var(--ink); background: var(--c-primary-bg); }
|
||||
|
||||
@@ -323,35 +323,50 @@ const notesByDate = computed(() => {
|
||||
</svg>
|
||||
{{ date }}
|
||||
</h4>
|
||||
<el-table :data="items" stripe style="width:100%" v-column-resize>
|
||||
<el-table-column prop="customer_name" label="客户" width="120" />
|
||||
<el-table-column prop="visit_method" label="方式" width="60" />
|
||||
<el-table-column prop="time_range" label="时间" width="100" />
|
||||
<el-table-column prop="communication_content" label="沟通内容" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="customer_demand" label="客户需求" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="相关人员" width="120">
|
||||
<template #default="{ row }">
|
||||
<div style="display:flex;flex-wrap:wrap;gap:2px">
|
||||
<span>{{ row.manager_name }}</span>
|
||||
<span v-for="n in (row.companion_names_resolved || [])" :key="n" style="color:var(--warm-gray);font-size:12px">, {{ n }}</span>
|
||||
<!-- Visit cards: merged group visits get participant sections -->
|
||||
<div v-for="item in items" :key="item.id" class="visit-card">
|
||||
<div class="visit-card-main">
|
||||
<div class="visit-card-left">
|
||||
<span class="visit-card-customer">{{ item.customer_name }}</span>
|
||||
<span class="visit-card-method">{{ item.visit_method }}</span>
|
||||
<span v-if="item.time_range" class="visit-card-time">{{ item.time_range }}</span>
|
||||
</div>
|
||||
<div class="visit-card-right">
|
||||
<span class="visit-card-mgr">{{ item.manager_name }}</span>
|
||||
<span v-for="n in (item.companion_names_resolved || [])" :key="n" class="visit-card-companion">, {{ n }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="visit-card-content">
|
||||
<div class="visit-card-section">
|
||||
<div class="visit-card-label">沟通内容</div>
|
||||
<div class="visit-card-text">{{ item.communication_content || '-' }}</div>
|
||||
</div>
|
||||
<div v-if="item.customer_demand" class="visit-card-section">
|
||||
<div class="visit-card-label">客户需求</div>
|
||||
<div class="visit-card-text">{{ item.customer_demand }}</div>
|
||||
</div>
|
||||
<!-- Companion participants -->
|
||||
<div v-if="item.participants && item.participants.length > 1" class="companion-section">
|
||||
<div v-for="p in item.participants.filter((p: any) => p.role === 'companion')" :key="p.manager_id" class="companion-row">
|
||||
<div class="companion-header">
|
||||
<span class="companion-badge">协同</span>
|
||||
<span class="companion-name">{{ p.manager_name }}</span>
|
||||
</div>
|
||||
<div class="companion-body">
|
||||
<div class="visit-card-text">{{ p.communication_content || '-' }}</div>
|
||||
<div v-if="p.customer_demand && p.customer_demand !== item.customer_demand" class="visit-card-text" style="margin-top:4px;color:var(--warm-gray)">需求补充:{{ p.customer_demand }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-tooltip v-if="row.edit_log?.length > 1" placement="top">
|
||||
<template #content>最后编辑:{{ row.edit_log[row.edit_log.length-1].editor }} · {{ row.edit_log.length-1 }}次修改</template>
|
||||
<span class="edit-indicator" title="有过修改">🕐</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="照片" width="100">
|
||||
<template #default="{ row }">
|
||||
<div class="photo-cell" v-if="row.photos?.length">
|
||||
<img v-for="key in row.photos.slice(0,2)" :key="key"
|
||||
:src="photoUrls[key] || ''" class="mini-thumb"
|
||||
@click="viewPhoto(photoUrls[key])" />
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<!-- Photos -->
|
||||
<div v-if="item.photos?.length" class="visit-card-photos">
|
||||
<img v-for="key in item.photos.slice(0,3)" :key="key"
|
||||
:src="photoUrls[key] || ''" class="mini-thumb"
|
||||
@click="viewPhoto(photoUrls[key])" />
|
||||
<span v-if="item.photos.length > 3" class="photo-more">+{{ item.photos.length - 3 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
@@ -414,6 +429,74 @@ const notesByDate = computed(() => {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 14px; color: var(--ink); letter-spacing: 0.04em;
|
||||
}
|
||||
/* ═══ Visit Cards ═══ */
|
||||
.visit-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--warm-border);
|
||||
margin-bottom: 10px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.visit-card:hover { border-color: var(--gold); }
|
||||
.visit-card-main {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--warm-border);
|
||||
}
|
||||
.visit-card-left { display: flex; align-items: center; gap: 10px; }
|
||||
.visit-card-customer {
|
||||
font-family: var(--font-heading); font-size: 15px; color: var(--ink);
|
||||
letter-spacing: 0.04em; font-weight: 500;
|
||||
}
|
||||
.visit-card-method {
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
background: var(--paper); padding: 2px 8px; color: var(--warm-gray);
|
||||
}
|
||||
.visit-card-time {
|
||||
font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray);
|
||||
}
|
||||
.visit-card-right { display: flex; align-items: center; gap: 2px; }
|
||||
.visit-card-mgr {
|
||||
font-family: var(--font-body); font-size: 13px; color: var(--ink);
|
||||
}
|
||||
.visit-card-companion {
|
||||
font-size: 12px; color: var(--warm-gray);
|
||||
}
|
||||
.visit-card-content { padding: 14px 16px; }
|
||||
.visit-card-section { margin-bottom: 10px; }
|
||||
.visit-card-label {
|
||||
font-family: var(--font-mono); font-size: 10px; color: var(--gold);
|
||||
letter-spacing: 0.12em; margin-bottom: 4px; text-transform: uppercase;
|
||||
}
|
||||
.visit-card-text {
|
||||
font-family: var(--font-body); font-size: 14px; color: var(--c-text);
|
||||
line-height: 1.7; white-space: pre-wrap;
|
||||
}
|
||||
/* ═══ Companion Section ═══ */
|
||||
.companion-section {
|
||||
margin-top: 12px;
|
||||
border-top: 1px dashed var(--warm-border);
|
||||
padding-top: 10px;
|
||||
}
|
||||
.companion-row { margin-bottom: 8px; }
|
||||
.companion-row:last-child { margin-bottom: 0; }
|
||||
.companion-header {
|
||||
display: flex; align-items: center; gap: 6px; margin-bottom: 4px;
|
||||
}
|
||||
.companion-badge {
|
||||
font-family: var(--font-mono); font-size: 9px;
|
||||
background: var(--gold); color: #fff; padding: 1px 6px;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.companion-name {
|
||||
font-family: var(--font-body); font-size: 13px; color: var(--ink);
|
||||
}
|
||||
.companion-body {
|
||||
padding-left: 28px;
|
||||
border-left: 2px solid var(--gold);
|
||||
margin-left: 6px;
|
||||
}
|
||||
.visit-card-photos { display: flex; gap: 4px; margin-top: 8px; align-items: center; }
|
||||
.photo-more { font-family: var(--font-mono); font-size: 11px; color: var(--warm-gray); }
|
||||
.photo-cell { display: flex; gap: 4px; }
|
||||
.mini-thumb { width: 36px; height: 36px; object-fit: cover; cursor: pointer; }
|
||||
.edit-indicator { font-size: 12px; margin-left: 3px; opacity: 0.5; cursor: help; }
|
||||
|
||||
@@ -14,72 +14,165 @@ const themeStore = useThemeStore()
|
||||
const loading = ref(false)
|
||||
const workPlans = ref<any[]>([])
|
||||
const customers = ref<any[]>([])
|
||||
const allManagers = ref<any[]>([])
|
||||
const shotRef = ref<HTMLElement | null>(null)
|
||||
const { capturing, captureEl } = useScreenshot()
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const form = ref<any>({})
|
||||
const statusPick = ref<Record<string, string>>({})
|
||||
const planStatuses = ['计划中', '已完成', '已取消']
|
||||
const statusPick = ref<Record<string, string>>({})
|
||||
|
||||
// Quick reschedule dialog
|
||||
const rescheduleVisible = ref(false)
|
||||
const rescheduleRow = ref<any>(null)
|
||||
const rescheduleDate = ref('')
|
||||
|
||||
function openReschedule(row: any) {
|
||||
rescheduleRow.value = row
|
||||
rescheduleDate.value = row.plan_date
|
||||
rescheduleVisible.value = true
|
||||
}
|
||||
async function confirmReschedule() {
|
||||
if (!rescheduleDate.value || !rescheduleRow.value) return
|
||||
try {
|
||||
await api.put(`/work-plans/${rescheduleRow.value.id}`, { plan_date: rescheduleDate.value })
|
||||
ElMessage.success('计划日期已更新')
|
||||
rescheduleVisible.value = false
|
||||
await loadPlans()
|
||||
} catch (e: any) { ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message)) }
|
||||
}
|
||||
|
||||
// ── Filters ──
|
||||
const filterManager = ref('')
|
||||
const filterStatus = ref('')
|
||||
|
||||
const isLight = computed(() => themeStore.currentTheme === 'light')
|
||||
|
||||
const managerOptions = computed(() => {
|
||||
const seen = new Set<string>()
|
||||
return workPlans.value
|
||||
.map((w: any) => w.manager_name || '未知')
|
||||
.filter((n: string) => { if (seen.has(n)) return false; seen.add(n); return true })
|
||||
.sort()
|
||||
const expandedSections = ref<Record<string, boolean>>({
|
||||
thisWeek: true, nextWeek: true, thisMonth: false, later: false, archived: false,
|
||||
})
|
||||
const searchText = ref('')
|
||||
|
||||
const filteredPlans = computed(() => {
|
||||
function onSearch() { loadPlans() }
|
||||
|
||||
const isLight = computed(() => themeStore.mode === 'light')
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
'计划中': '#4A6741',
|
||||
'已完成': '#5B7FA5',
|
||||
'已取消': '#909399',
|
||||
}
|
||||
|
||||
// ── Week boundaries ──
|
||||
const today = new Date()
|
||||
const weekStart = (d: Date) => { const s = new Date(d); s.setDate(s.getDate() - s.getDay() + 1); s.setHours(0,0,0,0); return s }
|
||||
const weekEnd = (d: Date) => { const e = weekStart(d); e.setDate(e.getDate() + 6); return e }
|
||||
const thisWeekStart = weekStart(today)
|
||||
const nextWeekStart = new Date(thisWeekStart); nextWeekStart.setDate(nextWeekStart.getDate() + 7)
|
||||
const thisMonthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0)
|
||||
|
||||
function classifyPlan(planDate: string, status: string): string {
|
||||
if (status !== '计划中') return 'archived'
|
||||
const d = new Date(planDate)
|
||||
if (d < thisWeekStart) return 'overdue' // falls into thisWeek section with overdue flag
|
||||
if (d <= weekEnd(today)) return 'thisWeek'
|
||||
if (d <= weekEnd(nextWeekStart)) return 'nextWeek'
|
||||
if (d <= thisMonthEnd) return 'thisMonth'
|
||||
return 'later'
|
||||
}
|
||||
|
||||
function sectionLabel(key: string): string {
|
||||
const labels: Record<string, string> = { overdue: '已过期', thisWeek: '本周', nextWeek: '下周', thisMonth: '本月', later: '更远', archived: '已归档' }
|
||||
return labels[key] || key
|
||||
}
|
||||
|
||||
function isOverdue(row: any): boolean {
|
||||
return row.status === '计划中' && row.plan_date < todayStr()
|
||||
}
|
||||
|
||||
function overdueDays(row: any): number {
|
||||
return Math.floor((today.getTime() - new Date(row.plan_date).getTime()) / 86400000)
|
||||
}
|
||||
|
||||
// ── Group plans ──
|
||||
const groupedPlans = computed(() => {
|
||||
const groups: Record<string, any[]> = { thisWeek: [], nextWeek: [], thisMonth: [], later: [], archived: [] }
|
||||
let list = workPlans.value
|
||||
if (filterManager.value) list = list.filter((w: any) => (w.manager_name || '未知') === filterManager.value)
|
||||
if (filterStatus.value) list = list.filter((w: any) => w.status === filterStatus.value)
|
||||
return list
|
||||
|
||||
for (const p of list) {
|
||||
const key = classifyPlan(p.plan_date, p.status)
|
||||
const g = key === 'overdue' ? 'thisWeek' : key
|
||||
if (!groups[g]) groups[g] = []
|
||||
groups[g].push(p)
|
||||
}
|
||||
|
||||
// Sort each group by plan_date asc
|
||||
for (const g of Object.values(groups)) {
|
||||
g.sort((a: any, b: any) => a.plan_date.localeCompare(b.plan_date))
|
||||
}
|
||||
|
||||
return Object.entries(groups)
|
||||
.filter(([_, items]) => items.length > 0)
|
||||
.map(([key, items]) => ({ key, label: sectionLabel(key), items }))
|
||||
})
|
||||
|
||||
function resetFilters() {
|
||||
filterManager.value = ''
|
||||
filterStatus.value = ''
|
||||
}
|
||||
const allOverdue = computed(() => workPlans.value.filter((w: any) => isOverdue(w)))
|
||||
|
||||
async function handleScreenshot() {
|
||||
const d = new Date().toISOString().slice(0, 10)
|
||||
await captureEl(shotRef.value, `工作计划_${d}.png`)
|
||||
}
|
||||
const managerOptions = computed(() => {
|
||||
const seen = new Set<string>()
|
||||
return workPlans.value.map((w: any) => w.manager_name || '未知').filter((n: string) => { if (seen.has(n)) return false; seen.add(n); return true }).sort()
|
||||
})
|
||||
|
||||
const managerSummary = computed(() => {
|
||||
const map: Record<string, number> = {}
|
||||
workPlans.value.forEach((w: any) => {
|
||||
const n = w.manager_name || '未知'
|
||||
map[n] = (map[n] || 0) + 1
|
||||
})
|
||||
workPlans.value.forEach((w: any) => { const n = w.manager_name || '未知'; map[n] = (map[n] || 0) + 1 })
|
||||
return Object.entries(map).sort((a, b) => b[1] - a[1])
|
||||
})
|
||||
|
||||
function resetFilters() { filterManager.value = ''; filterStatus.value = '' }
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadPlans(), loadCustomers(), loadManagerColors()])
|
||||
if (auth.isDirector || auth.isLeader) {
|
||||
api.get('/users/', { params: { role: 'manager' } }).then(r => { allManagers.value = r.data }).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
const customerSearch = ref('')
|
||||
|
||||
async function loadCustomers(q?: string) {
|
||||
try { const params: any = { page_size: 500 }; if (q) params.search = q; const res = await api.get('/customers/', { 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 params: any = { page_size: 500 }
|
||||
if (q) params.search = q
|
||||
const res = await api.get('/customers/', { params })
|
||||
customers.value = res.data.items || []
|
||||
} catch (_) {}
|
||||
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 loadPlans() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.get('/work-plans/')
|
||||
const params: any = {}
|
||||
if (searchText.value) params.search = searchText.value
|
||||
const res = await api.get('/work-plans/', { params })
|
||||
workPlans.value = res.data
|
||||
} catch (e: any) { ElMessage.error('加载失败') }
|
||||
finally { loading.value = false }
|
||||
@@ -87,7 +180,7 @@ async function loadPlans() {
|
||||
|
||||
function openCreate() {
|
||||
dialogMode.value = 'create'
|
||||
form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中' }
|
||||
form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -101,38 +194,24 @@ function openEdit(item: any) {
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.customer_id) { ElMessage.warning('请选择客户单位'); return }
|
||||
try {
|
||||
if (dialogMode.value === 'create') {
|
||||
await api.post('/work-plans/', form.value)
|
||||
} else {
|
||||
await api.put(`/work-plans/${form.value.id}`, form.value)
|
||||
}
|
||||
ElMessage.success(dialogMode.value === 'create' ? '已创建' : '已更新')
|
||||
dialogVisible.value = false
|
||||
await loadPlans()
|
||||
if (dialogMode.value === 'create') await api.post('/work-plans/', form.value)
|
||||
else await api.put(`/work-plans/${form.value.id}`, form.value)
|
||||
ElMessage.success(dialogMode.value === 'create' ? '已创建' : '已更新'); dialogVisible.value = false; await loadPlans()
|
||||
} catch (e: any) { ElMessage.error('保存失败: ' + (e.response?.data?.detail || e.message)) }
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
|
||||
await api.delete(`/work-plans/${id}`)
|
||||
ElMessage.success('已删除')
|
||||
await loadPlans()
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
async function handleDelete(id: string) { try { await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' }); await api.delete(`/work-plans/${id}`); ElMessage.success('已删除'); await loadPlans() } catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') } }
|
||||
|
||||
async function quickStatusChange(row: any, newStatus: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定将状态改为「${newStatus}」?`, '确认', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' })
|
||||
await api.put(`/work-plans/${row.id}`, { status: newStatus })
|
||||
ElMessage.success('状态已更新')
|
||||
await loadPlans()
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message))
|
||||
delete statusPick.value[row.id]
|
||||
}
|
||||
try { await ElMessageBox.confirm(`确定将状态改为「${newStatus}」?`, '确认', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }); await api.put(`/work-plans/${row.id}`, { status: newStatus }); ElMessage.success('状态已更新'); await loadPlans() } catch (e: any) { if (e !== 'cancel') ElMessage.error('更新失败') }
|
||||
}
|
||||
|
||||
async function quickReschedule(row: any) { openReschedule(row) }
|
||||
async function quickCancel(row: any) { await quickStatusChange(row, '已取消') }
|
||||
|
||||
async function handleScreenshot() { const d = new Date().toISOString().slice(0, 10); await captureEl(shotRef.value, `工作计划_${d}.png`) }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -142,26 +221,22 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
<div class="page-head-row">
|
||||
<div>
|
||||
<h2 class="page-title">工作计划</h2>
|
||||
<p class="page-desc">面向未来的工作计划安排,支持状态流转跟踪。</p>
|
||||
<p class="page-desc">按周分组展示,过期计划高亮提醒。💡 拜访客户后计划自动完成,无需手动改状态。</p>
|
||||
</div>
|
||||
<div class="page-head-actions">
|
||||
<el-button type="primary" @click="openCreate" class="screenshot-hide">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
||||
新建计划
|
||||
</el-button>
|
||||
<el-button type="warning" :loading="capturing" @click="handleScreenshot" class="screenshot-hide">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
||||
<polyline points="21 15 16 10 5 21"></polyline>
|
||||
</svg>
|
||||
截图导出
|
||||
</el-button>
|
||||
<el-button type="warning" :loading="capturing" @click="handleScreenshot" class="screenshot-hide">截图导出</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-rule"></div>
|
||||
<!-- Overdue alert banner -->
|
||||
<div v-if="allOverdue.length" class="overdue-banner">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
|
||||
<span>有 <strong>{{ allOverdue.length }}</strong> 条计划已过期,请及时改期或取消</span>
|
||||
</div>
|
||||
<div v-if="managerSummary.length" class="summary-bar">
|
||||
<span class="summary-label">客户经理汇总</span>
|
||||
<span v-for="[name, count] in managerSummary" :key="name" class="summary-chip" :class="{ 'summary-chip--active': filterManager === name }" :style="getMgrStyle(name, isLight)" @click="filterManager = filterManager === name ? '' : name">{{ name }} · {{ count }}</span>
|
||||
@@ -171,6 +246,9 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
<!-- Filter Bar -->
|
||||
<div class="filter-bar">
|
||||
<div class="filter-row">
|
||||
<div class="filter-item">
|
||||
<el-input v-model="searchText" placeholder="搜索..." clearable size="small" style="width:180px" @keyup.enter="onSearch" @clear="onSearch" />
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">客户经理</label>
|
||||
<el-select v-model="filterManager" clearable placeholder="全部" size="small" style="width:150px">
|
||||
@@ -184,64 +262,77 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
</el-select>
|
||||
</div>
|
||||
<el-button v-if="filterManager || filterStatus" size="small" plain @click="resetFilters">重置</el-button>
|
||||
<span class="filter-count">{{ filteredPlans.length }} / {{ workPlans.length }} 条</span>
|
||||
<span class="filter-count">{{ workPlans.length }} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card>
|
||||
<el-table :data="filteredPlans" stripe size="small" v-if="filteredPlans.length" v-column-resize>
|
||||
<el-table-column type="index" label="序号" width="50" />
|
||||
<el-table-column prop="customer_name" label="客户" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" :underline="false" @click="openEdit(row)">{{ row.customer_name }}</el-link>
|
||||
<el-tooltip v-if="row.edit_log?.length > 1" placement="top">
|
||||
<template #content>最后编辑:{{ row.edit_log[row.edit_log.length-1].editor }} · {{ row.edit_log.length-1 }}次修改</template>
|
||||
<span class="edit-indicator" title="有过修改">🕐</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="客户经理" width="100">
|
||||
<template #default="{ row }">
|
||||
<span class="mgr-tag" :style="getMgrStyle(row.manager_name, isLight)">{{ row.manager_name || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="plan_content" label="工作计划" min-width="280" show-overflow-tooltip />
|
||||
<el-table-column prop="plan_date" label="计划时间" width="110" />
|
||||
<el-table-column label="状态" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="statusPick[row.id]"
|
||||
size="small"
|
||||
style="width:100%"
|
||||
:placeholder="row.status"
|
||||
@change="(v: string) => quickStatusChange(row, v)"
|
||||
@visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.status }"
|
||||
>
|
||||
<el-option v-for="s in planStatuses" :key="s" :label="s" :value="s" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" fixed="right" class-name="screenshot-hide">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!workPlans.length" class="empty">暂无工作计划</div>
|
||||
<div v-else-if="workPlans.length && !filteredPlans.length" class="empty">无匹配结果</div>
|
||||
</el-card>
|
||||
<!-- Grouped sections -->
|
||||
<div v-if="!workPlans.length" class="empty">暂无工作计划</div>
|
||||
<div v-for="group in groupedPlans" :key="group.key" class="plan-section">
|
||||
<div class="section-header" @click="expandedSections[group.key] = !expandedSections[group.key]">
|
||||
<span class="section-arrow">{{ expandedSections[group.key] ? '▼' : '▶' }}</span>
|
||||
<span class="section-title">{{ group.label }}</span>
|
||||
<span class="section-count">{{ group.items.length }} 条</span>
|
||||
</div>
|
||||
<div v-show="expandedSections[group.key]">
|
||||
<el-table :data="group.items" stripe size="small" v-column-resize>
|
||||
<el-table-column type="index" label="#" width="45" />
|
||||
<el-table-column prop="customer_name" label="客户" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" :underline="false" @click="openEdit(row)">{{ row.customer_name }}</el-link>
|
||||
<el-tooltip v-if="row.edit_log?.length > 1" placement="top"><template #content>最后编辑:{{ row.edit_log[row.edit_log.length-1].editor }} · {{ row.edit_log.length-1 }}次修改</template><span class="edit-indicator">🕐</span></el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="客户经理" width="100">
|
||||
<template #default="{ row }"><span class="mgr-tag" :style="getMgrStyle(row.manager_name, isLight)">{{ row.manager_name || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="plan_content" label="工作计划" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="plan_date" label="计划时间" width="110" />
|
||||
<el-table-column label="状态" width="170">
|
||||
<template #default="{ row }">
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<el-select v-model="statusPick[row.id]" size="small" style="width:110px" :placeholder="row.status" @change="(v: string) => quickStatusChange(row, v)" @visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.status }">
|
||||
<el-option v-for="s in planStatuses" :key="s" :label="s" :value="s" />
|
||||
</el-select>
|
||||
<span v-if="isOverdue(row)" class="overdue-badge">过期{{ overdueDays(row) }}天</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right" class-name="screenshot-hide">
|
||||
<template #default="{ row }">
|
||||
<template v-if="isOverdue(row)">
|
||||
<el-button size="small" type="warning" @click="quickReschedule(row)">改期</el-button>
|
||||
<el-button size="small" @click="quickCancel(row)">取消</el-button>
|
||||
</template>
|
||||
<el-button v-else size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /shotRef -->
|
||||
|
||||
<!-- Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 工作计划'" width="500px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="客户单位">
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
|
||||
<el-form-item label="客户单位 *">
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="handleCustomerSearch" placeholder="搜索选择客户" style="width:100%">
|
||||
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||
<template #empty>
|
||||
<div v-if="customerSearch" class="select-empty-create">
|
||||
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到「{{ customerSearch }}」</p>
|
||||
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">+ 新建客户「{{ customerSearch }}」</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="auth.isDirector || auth.isLeader" label="客户经理">
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="计划拜访时间">
|
||||
<el-date-picker v-model="form.plan_date" type="date" style="width:100%" />
|
||||
<el-date-picker v-model="form.plan_date" type="date" value-format="YYYY-MM-DD" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="工作计划">
|
||||
<el-input v-model="form.plan_content" type="textarea" :rows="4" placeholder="请输入计划内容" />
|
||||
@@ -255,10 +346,17 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<EditLogPanel v-if="dialogMode === 'edit'" :edit-log="form.edit_log || []" />
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" @click="handleSave">保存</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Quick reschedule dialog -->
|
||||
<el-dialog v-model="rescheduleVisible" title="修改计划日期" width="360px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="新日期">
|
||||
<el-date-picker v-model="rescheduleDate" type="date" value-format="YYYY-MM-DD" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="rescheduleVisible = false">取消</el-button><el-button type="primary" @click="confirmReschedule">确认改期</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -271,6 +369,12 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
.page-title { margin: 0; font-family: var(--font-heading); font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
|
||||
.page-desc { margin: 4px 0 0; font-size: 13px; color: var(--c-text-muted); font-family: var(--font-body); }
|
||||
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
|
||||
|
||||
/* ── Overdue Banner ── */
|
||||
.overdue-banner { display: flex; align-items: center; gap: 8px; margin-top: 12px; padding: 10px 16px; background: rgba(184,71,46,0.06); border-left: 3px solid var(--vermilion); font-family: var(--font-body); font-size: 13px; color: var(--ink); }
|
||||
.overdue-banner strong { color: var(--vermilion); }
|
||||
|
||||
/* ── Summary Bar ── */
|
||||
.summary-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 14px; padding: 10px 14px; background: var(--c-bg-light, #faf9f6); border-radius: 6px; border: 1px solid var(--c-border, #e8e5df); }
|
||||
.summary-label { font-size: 12px; color: var(--c-text-muted); font-family: var(--font-body); margin-right: 4px; }
|
||||
.summary-chip { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; color: var(--mgr-chip-text); white-space: nowrap; cursor: pointer; transition: opacity 0.2s, transform 0.15s; }
|
||||
@@ -285,4 +389,20 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
.filter-item { display: flex; align-items: center; gap: 8px; }
|
||||
.filter-label { font-size: 13px; color: var(--warm-gray); font-family: var(--font-body); white-space: nowrap; }
|
||||
.filter-count { font-size: 12px; color: var(--c-text-muted); font-family: var(--font-mono); margin-left: auto; }
|
||||
|
||||
/* ── Sections ── */
|
||||
.plan-section { margin-bottom: 16px; border: 1px solid var(--warm-border); background: var(--surface); }
|
||||
.section-header { display: flex; align-items: center; gap: 10px; padding: 14px 18px; cursor: pointer; user-select: none; border-bottom: 1px solid var(--warm-border); transition: background 0.2s; }
|
||||
.section-header:hover { background: var(--c-bg-light, #faf9f6); }
|
||||
.section-arrow { font-size: 10px; color: var(--gold); width: 14px; transition: transform 0.2s; }
|
||||
.section-title { font-family: var(--font-heading); font-size: 15px; color: var(--ink); letter-spacing: 0.04em; }
|
||||
.section-count { font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray); }
|
||||
|
||||
/* ── Overdue Badge ── */
|
||||
.overdue-badge { display: inline-block; padding: 1px 7px; border-radius: 8px; background: var(--vermilion); color: #fff; font-family: var(--font-mono); font-size: 10px; white-space: nowrap; }
|
||||
|
||||
.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 6px 12px; color: var(--gold-dark); font-family: var(--font-body); font-size: 12px; cursor: pointer; transition: all 0.2s; }
|
||||
.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); }
|
||||
.select-empty-create { padding: 8px 12px; text-align: center; }
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -3,13 +3,16 @@ import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { todayStr } from '@/utils'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import api from '@/api/index'
|
||||
import EditLogPanel from '@/components/EditLogPanel.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
const submitLoading = ref(false)
|
||||
const allManagers = ref<any[]>([])
|
||||
|
||||
const categories = ['行政事务', '合同整理', '发票处理', '内部会议', '培训学习', '其他']
|
||||
|
||||
@@ -24,6 +27,7 @@ const form = ref({
|
||||
category: '其他',
|
||||
content: '',
|
||||
time_range: '',
|
||||
manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || ''),
|
||||
})
|
||||
|
||||
function onTimeRangeChange(val: [string, string] | null) {
|
||||
@@ -36,6 +40,9 @@ function parseTimeRange(str: string): [string, string] | null {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (auth.isDirector || auth.isLeader) {
|
||||
try { const res = await api.get('/users/', { params: { role: 'manager' } }); allManagers.value = res.data } catch (_) {}
|
||||
}
|
||||
if (isEdit.value) {
|
||||
try {
|
||||
const res = await api.get(`/daily-notes/${route.params.id}`)
|
||||
@@ -45,6 +52,7 @@ onMounted(async () => {
|
||||
category: n.category,
|
||||
content: n.content || '',
|
||||
time_range: n.time_range || '',
|
||||
manager_id: n.manager_id || auth.userId || '',
|
||||
}
|
||||
timeRangeValue.value = parseTimeRange(n.time_range)
|
||||
} catch (_) {}
|
||||
@@ -52,6 +60,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.note_date) { ElMessage.warning('请选择日期'); return }
|
||||
if (!form.value.content.trim()) { ElMessage.warning('请输入纪要内容'); return }
|
||||
submitLoading.value = true
|
||||
try {
|
||||
@@ -100,9 +109,23 @@ async function handleDelete() {
|
||||
<el-form label-position="top" class="editorial-form">
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<span class="form-label">日期</span>
|
||||
<span class="form-label">日期 <span class="required-star">*</span></span>
|
||||
</template>
|
||||
<el-date-picker v-model="form.note_date" type="date" style="width:100%" />
|
||||
<el-date-picker v-model="form.note_date" type="date" value-format="YYYY-MM-DD" style="width:100%" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="auth.isDirector || auth.isLeader">
|
||||
<template #label><span class="form-label">客户经理</span></template>
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="auth.isDirector || auth.isLeader">
|
||||
<template #label><span class="form-label">客户经理</span></template>
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
@@ -143,7 +166,7 @@ async function handleDelete() {
|
||||
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<span class="form-label">工作内容 <span class="form-label-required">*</span></span>
|
||||
<span class="form-label">工作内容 <span class="required-star">*</span></span>
|
||||
</template>
|
||||
<el-input v-model="form.content" type="textarea" :rows="5" placeholder="今天做了什么..." />
|
||||
</el-form-item>
|
||||
@@ -273,4 +296,5 @@ async function handleDelete() {
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -16,6 +16,7 @@ const previewImageUrl = ref('')
|
||||
const previewDialogVisible = ref(false)
|
||||
const photoUrls = ref<Record<string, string>>({})
|
||||
const loading = ref(false)
|
||||
const overduePlans = ref(0)
|
||||
|
||||
const todayTotal = computed(() => todayVisitCount.value + todayNoteCount.value)
|
||||
|
||||
@@ -57,7 +58,10 @@ async function loadToday() {
|
||||
|
||||
function previewPhoto(url: string) { previewImageUrl.value = url; previewDialogVisible.value = true }
|
||||
|
||||
onMounted(loadToday)
|
||||
async function loadOverdue() {
|
||||
try { const res = await api.get('/dashboard/stats'); overduePlans.value = res.data.overdue_plans || 0 } catch (_) {}
|
||||
}
|
||||
onMounted(() => { loadToday(); loadOverdue() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -81,6 +85,13 @@ onMounted(loadToday)
|
||||
<div class="stat-ornament"></div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Overdue Plans Alert ═══ -->
|
||||
<div v-if="overduePlans" class="overdue-alert" @click="router.push('/m/plans')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
|
||||
<span>{{ overduePlans }} 条工作计划已过期,点击处理</span>
|
||||
<span class="overdue-arrow">→</span>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Primary Quick Actions ═══ -->
|
||||
<div class="quick-actions">
|
||||
<button class="action-btn action-btn--primary" @click="router.push('/m/visit/new')">
|
||||
@@ -99,38 +110,6 @@ onMounted(loadToday)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Secondary Quick Links ═══ -->
|
||||
<div class="quick-links">
|
||||
<button class="link-chip" @click="router.push('/m/work-plan/new')">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
工作计划
|
||||
</button>
|
||||
<button class="link-chip" @click="router.push('/m/mini-biz/new')">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="1" x2="12" y2="23"></line>
|
||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
|
||||
</svg>
|
||||
商机跟单
|
||||
</button>
|
||||
<button class="link-chip" @click="router.push('/m/key-visit/new')">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
|
||||
</svg>
|
||||
要客拜访
|
||||
</button>
|
||||
<button class="link-chip" @click="router.push('/m/leaves')">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
请假
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Section Divider ═══ -->
|
||||
<div class="section-head" v-if="dailyNotes.length || visits.length">
|
||||
<span class="section-title">今日记录</span>
|
||||
@@ -303,6 +282,9 @@ onMounted(loadToday)
|
||||
background: linear-gradient(135deg, transparent 50%, rgba(196,147,74,0.08) 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.overdue-arrow { margin-left: auto; color: var(--vermilion); }
|
||||
.overdue-alert:hover { background: rgba(184,71,46,0.1); }
|
||||
.overdue-alert { display: flex; align-items: center; gap: 8px; padding: 12px 16px; margin-bottom: 16px; background: rgba(184,71,46,0.06); border-left: 3px solid var(--vermilion); font-family: var(--font-body); font-size: 13px; color: var(--ink); cursor: pointer; transition: background 0.2s; }
|
||||
|
||||
/* ═══ Quick Actions ═══ */
|
||||
.quick-actions {
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { keyVisitsApi } from '@/api/keyVisits'
|
||||
import { customersApi } from '@/api/customers'
|
||||
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 customers = ref<any[]>([])
|
||||
const allUsers = ref<any[]>([])
|
||||
const allManagers = ref<any[]>([])
|
||||
|
||||
const urgencyLevels = ['一般', '重要', '紧急']
|
||||
const urgencyColors: Record<string, string> = {
|
||||
@@ -25,6 +30,7 @@ const form = ref({
|
||||
planned_date: '',
|
||||
planned_visitor: '',
|
||||
visit_target: '',
|
||||
manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || ''),
|
||||
})
|
||||
|
||||
function onVisitorsChange(val: string[]) {
|
||||
@@ -33,8 +39,23 @@ function onVisitorsChange(val: string[]) {
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadCustomers(), loadUsers()])
|
||||
if (auth.isDirector || auth.isLeader) {
|
||||
api.get('/users/', { params: { role: 'manager' } }).then(r => { allManagers.value = r.data }).catch(() => {})
|
||||
}
|
||||
if (isEdit.value) {
|
||||
try {
|
||||
const res = await api.get(`/key-visits/${route.params.id}`)
|
||||
const d = res.data
|
||||
if (d.customer_id && d.customer_name && !customers.value.find((c: any) => c.id === d.customer_id)) {
|
||||
customers.value.unshift({ id: d.customer_id, name: d.customer_name })
|
||||
}
|
||||
form.value = { customer_id: d.customer_id, urgency_level: d.urgency_level, description: d.description || '', progress_status: d.progress_status, planned_date: d.planned_date, planned_visitor: d.planned_visitor, visit_target: d.visit_target, manager_id: d.manager_id }
|
||||
} catch (_) {}
|
||||
}
|
||||
})
|
||||
|
||||
const customerSearch = ref('')
|
||||
|
||||
async function loadCustomers(q?: string) {
|
||||
try {
|
||||
const params: any = { page_size: 100 }
|
||||
@@ -44,6 +65,28 @@ async function loadCustomers(q?: string) {
|
||||
} 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 loadUsers() {
|
||||
try {
|
||||
const res = await api.get('/users/')
|
||||
@@ -55,13 +98,27 @@ 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')
|
||||
if (isEdit.value) {
|
||||
await keyVisitsApi.update(route.params.id as string, form.value)
|
||||
ElMessage.success('已更新')
|
||||
} else {
|
||||
await keyVisitsApi.create(form.value)
|
||||
ElMessage.success('要客拜访计划已提交')
|
||||
}
|
||||
router.push('/m/key-visits')
|
||||
} catch (e: any) {
|
||||
ElMessage.error('提交失败')
|
||||
ElMessage.error((isEdit.value ? '更新' : '提交') + '失败')
|
||||
} finally { submitLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
|
||||
await keyVisitsApi.delete(route.params.id as string)
|
||||
ElMessage.success('已删除')
|
||||
router.push('/m/key-visits')
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -74,17 +131,31 @@ async function handleSubmit() {
|
||||
</svg>
|
||||
</button>
|
||||
<div class="form-title-group">
|
||||
<h2 class="form-title">添加要客拜访</h2>
|
||||
<h2 class="form-title">{{ isEdit ? '编辑要客拜访' : '添加要客拜访' }}</h2>
|
||||
<span class="form-subtitle">KEY VISIT PLAN</span>
|
||||
</div>
|
||||
<button v-if="isEdit" class="header-delete-btn" @click="handleDelete">删除</button>
|
||||
<div class="form-rule"></div>
|
||||
</header>
|
||||
|
||||
<el-form label-position="top" class="editorial-form">
|
||||
<el-form-item>
|
||||
<template #label><span class="form-label">客户单位</span></template>
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="loadCustomers" placeholder="搜索客户" style="width:100%">
|
||||
<template #label><span class="form-label">客户单位 <span class="required-star">*</span></span></template>
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="handleCustomerSearch" placeholder="搜索客户" style="width:100%">
|
||||
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||
<template #empty>
|
||||
<div v-if="customerSearch" class="select-empty-create">
|
||||
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到「{{ customerSearch }}」</p>
|
||||
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">新建客户「{{ customerSearch }}」</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="auth.isDirector || auth.isLeader">
|
||||
<template #label><span class="form-label">客户经理</span></template>
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
@@ -118,7 +189,7 @@ async function handleSubmit() {
|
||||
|
||||
<el-form-item>
|
||||
<template #label><span class="form-label">计划拜访时间</span></template>
|
||||
<el-input v-model="form.planned_date" placeholder="如: 2026-06-25" />
|
||||
<el-date-picker v-model="form.planned_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width:100%" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
@@ -142,10 +213,12 @@ async function handleSubmit() {
|
||||
<el-input v-model="form.visit_target" placeholder="如: 王局长、张处长" />
|
||||
</el-form-item>
|
||||
|
||||
<button class="submit-btn" :disabled="submitLoading" @click="handleSubmit">
|
||||
<span v-if="submitLoading" class="btn-loading"></span>
|
||||
提交计划
|
||||
</button>
|
||||
<div class="form-actions">
|
||||
<button class="submit-btn" :disabled="submitLoading" @click="handleSubmit">
|
||||
<span v-if="submitLoading" class="btn-loading"></span>
|
||||
{{ isEdit ? '保存修改' : '提交计划' }}
|
||||
</button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -231,4 +304,13 @@ async function handleSubmit() {
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 8px 14px; color: var(--gold-dark); font-family: var(--font-body); font-size: 13px; cursor: pointer; transition: all 0.2s; letter-spacing: 0.03em; }
|
||||
.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); }
|
||||
.select-empty-create { padding: 8px 12px; text-align: center; }
|
||||
.form-actions { display: flex; gap: 10px; margin-top: 24px; }
|
||||
.delete-btn { padding: 16px 24px; background: var(--surface); color: var(--vermilion); border: 1px solid var(--vermilion); font-family: var(--font-heading); font-size: 16px; letter-spacing: 0.08em; cursor: pointer; transition: all 0.25s; }
|
||||
.delete-btn:hover { background: var(--vermilion); color: #fff; }
|
||||
.header-delete-btn { margin-left: auto; margin-top: 4px; padding: 6px 14px; background: none; color: var(--vermilion); border: 1px solid var(--vermilion); font-family: var(--font-body); font-size: 13px; cursor: pointer; transition: all 0.2s; white-space: nowrap; }
|
||||
.header-delete-btn:hover { background: var(--vermilion); color: #fff; }
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { keyVisitsApi } from '@/api/keyVisits'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const searchText = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(25)
|
||||
const total = ref(0)
|
||||
const items = ref<any[]>([])
|
||||
|
||||
onMounted(loadItems)
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { page: page.value, page_size: pageSize.value }
|
||||
if (searchText.value) params.search = searchText.value
|
||||
const res = await keyVisitsApi.list(params)
|
||||
const raw = Array.isArray(res.data) ? res.data : (res.data.items || [])
|
||||
items.value = raw; total.value = res.data.total || raw.length
|
||||
} catch (_) {}
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; loadItems() }
|
||||
|
||||
const urgencyColors: Record<string, string> = { '一般': '#5B7FA5', '重要': '#C4934A', '紧急': '#B8472E' }
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
|
||||
await keyVisitsApi.delete(id)
|
||||
ElMessage.success('已删除')
|
||||
await loadItems()
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="list-page">
|
||||
<header class="form-editorial-header">
|
||||
<button type="button" class="form-back-btn" @click="router.back()">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
|
||||
</button>
|
||||
<div class="form-title-group"><h2 class="form-title">要客拜访</h2><span class="form-subtitle">KEY VISITS</span></div>
|
||||
<div class="form-rule"></div>
|
||||
</header>
|
||||
|
||||
<button class="new-btn" @click="router.push('/m/key-visit/new')">+ 新建要客</button>
|
||||
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px">
|
||||
<el-input v-model="searchText" placeholder="搜索..." clearable @keyup.enter="onSearch" @clear="onSearch" />
|
||||
<el-button @click="onSearch">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<div class="cards">
|
||||
<div v-if="items.length === 0 && !loading" class="empty-state"><div class="empty-glyph">—</div><p class="empty-text">暂不要客记录</p></div>
|
||||
<article v-for="item in items" :key="item.id" class="card" @click="router.push(`/m/key-visit/${item.id}/edit`)">
|
||||
<div class="card-accent" :style="{ background: urgencyColors[item.urgency_level] || '#5B7FA5' }"></div>
|
||||
<div class="card-body">
|
||||
<div class="card-header">
|
||||
<span class="card-urgency" :style="{ color: urgencyColors[item.urgency_level] || '#5B7FA5' }">{{ item.urgency_level }}</span>
|
||||
<span class="card-status">{{ item.progress_status }}</span>
|
||||
</div>
|
||||
<div class="card-content">{{ item.description }}</div>
|
||||
<div class="card-footer">
|
||||
<span class="card-customer">{{ item.customer_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="total > pageSize" style="display:flex;justify-content:center;margin-top:14px">
|
||||
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" small @current-change="loadItems" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-page { max-width: 100%; padding-bottom: 20px; }
|
||||
.form-editorial-header { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.form-back-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 8px 10px; cursor: pointer; color: var(--warm-gray); display: flex; align-items: center; transition: all var(--transition); flex-shrink: 0; margin-top: 2px; }
|
||||
.form-back-btn:hover { color: var(--ink); border-color: var(--ink); }
|
||||
.form-title-group { display: flex; flex-direction: column; gap: 0; flex: 1; }
|
||||
.form-title { margin: 0; font-family: var(--font-heading); font-size: 24px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; line-height: 1.3; }
|
||||
.form-subtitle { font-family: var(--font-mono); font-size: 9px; color: var(--gold); letter-spacing: 0.2em; }
|
||||
.form-rule { width: 100%; height: 2px; background: var(--warm-border); margin-top: 6px; position: relative; }
|
||||
.form-rule::after { content: ''; position: absolute; left: 0; top: 0; width: 32px; height: 2px; background: var(--gold); }
|
||||
.new-btn { width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 14px; background: var(--ink); color: #fff; border: none; font-family: var(--font-heading); font-size: 15px; letter-spacing: 0.06em; cursor: pointer; transition: all 0.25s; margin-bottom: 18px; }
|
||||
.new-btn:hover { background: var(--ink-light); }
|
||||
.new-btn:active { transform: scale(0.98); }
|
||||
.cards { display: flex; flex-direction: column; gap: 10px; }
|
||||
.card { background: var(--surface); border: 1px solid var(--warm-border); display: flex; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; }
|
||||
.card:active { transform: scale(0.99); }
|
||||
.card:hover { box-shadow: 0 4px 12px rgba(28,55,56,0.06); }
|
||||
.card-accent { width: 4px; flex-shrink: 0; }
|
||||
.card-body { flex: 1; padding: 14px 16px; min-width: 0; }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.card-urgency { font-family: var(--font-heading); font-size: 13px; }
|
||||
.card-status { font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray); }
|
||||
.card-content { font-family: var(--font-body); font-size: 14px; color: var(--ink); margin-bottom: 6px; line-height: 1.5; }
|
||||
.card-footer { display: flex; align-items: center; justify-content: space-between; }
|
||||
.card-customer { font-size: 12px; color: var(--warm-gray); }
|
||||
.empty-state { text-align: center; padding: 48px 0; }
|
||||
.empty-glyph { font-family: var(--font-heading); font-size: 40px; color: var(--gold); opacity: 0.4; margin-bottom: 8px; }
|
||||
.empty-text { font-family: var(--font-body); font-size: 15px; color: var(--warm-gray); margin: 0; }
|
||||
</style>
|
||||
@@ -70,7 +70,9 @@ async function handleSubmit() {
|
||||
}
|
||||
router.push('/m/leaves')
|
||||
} catch (e: any) {
|
||||
ElMessage.error((isEdit ? '更新' : '提交') + '失败: ' + (e.response?.data?.detail || e.message))
|
||||
const detail = e.response?.data?.detail
|
||||
const msg = Array.isArray(detail) ? detail.map((d: any) => d.msg).join('; ') : (detail || e.message)
|
||||
ElMessage.error((isEdit ? '更新' : '提交') + '失败: ' + msg)
|
||||
} finally { submitLoading.value = false }
|
||||
}
|
||||
|
||||
@@ -123,13 +125,13 @@ async function handleDelete() {
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<template #label><span class="form-label">开始日期</span></template>
|
||||
<el-date-picker v-model="form.start_date" type="date" placeholder="选择开始日期" style="width:100%" />
|
||||
<template #label><span class="form-label">开始日期 <span class="required-star">*</span></span></template>
|
||||
<el-date-picker v-model="form.start_date" type="date" placeholder="选择开始日期" style="width:100%" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<template #label><span class="form-label">结束日期</span></template>
|
||||
<el-date-picker v-model="form.end_date" type="date" placeholder="选择结束日期" style="width:100%" />
|
||||
<template #label><span class="form-label">结束日期 <span class="required-star">*</span></span></template>
|
||||
<el-date-picker v-model="form.end_date" type="date" placeholder="选择结束日期" style="width:100%" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
@@ -198,4 +200,5 @@ async function handleDelete() {
|
||||
.delete-btn:hover { background: var(--vermilion); color: #fff; }
|
||||
.btn-loading { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { miniBusinessApi } from '@/api/miniBusiness'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const searchText = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(25)
|
||||
const total = ref(0)
|
||||
const items = ref<any[]>([])
|
||||
const statusOrder: Record<string, number> = { '跟进中': 0, '已签约': 1, '已流失': 2 }
|
||||
|
||||
onMounted(loadItems)
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { page: page.value, page_size: pageSize.value }
|
||||
if (searchText.value) params.search = searchText.value
|
||||
const res = await miniBusinessApi.list(params)
|
||||
const raw = Array.isArray(res.data) ? res.data : (res.data.items || [])
|
||||
items.value = raw; total.value = res.data.total || raw.length
|
||||
} catch (_) {}
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; loadItems() }
|
||||
|
||||
const statusColors: Record<string, string> = { '跟进中': '#C4934A', '已签约': '#4A6741', '已流失': '#B8472E' }
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
|
||||
await miniBusinessApi.delete(id)
|
||||
ElMessage.success('已删除')
|
||||
await loadItems()
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="list-page">
|
||||
<header class="form-editorial-header">
|
||||
<button type="button" class="form-back-btn" @click="router.back()">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
|
||||
</button>
|
||||
<div class="form-title-group"><h2 class="form-title">商机跟单</h2><span class="form-subtitle">MINI BUSINESS</span></div>
|
||||
<div class="form-rule"></div>
|
||||
</header>
|
||||
|
||||
<button class="new-btn" @click="router.push('/m/mini-biz/new')">+ 新建商机</button>
|
||||
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px">
|
||||
<el-input v-model="searchText" placeholder="搜索..." clearable @keyup.enter="onSearch" @clear="onSearch" />
|
||||
<el-button @click="onSearch">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<div class="cards">
|
||||
<div v-if="items.length === 0 && !loading" class="empty-state"><div class="empty-glyph">—</div><p class="empty-text">暂无商机记录</p></div>
|
||||
<article v-for="item in items" :key="item.id" class="card" @click="router.push(`/m/mini-biz/${item.id}/edit`)">
|
||||
<div class="card-accent" :style="{ background: statusColors[item.status] || '#C4934A' }"></div>
|
||||
<div class="card-body">
|
||||
<div class="card-header">
|
||||
<span class="card-status" :style="{ color: statusColors[item.status] || '#C4934A' }">{{ item.status }}</span>
|
||||
<span class="card-amount">{{ item.amount }}</span>
|
||||
</div>
|
||||
<div class="card-content">{{ item.product_type }}</div>
|
||||
<div class="card-footer">
|
||||
<span class="card-customer">{{ item.customer_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="total > pageSize" style="display:flex;justify-content:center;margin-top:14px">
|
||||
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" small @current-change="loadItems" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-page { max-width: 100%; padding-bottom: 20px; }
|
||||
.form-editorial-header { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.form-back-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 8px 10px; cursor: pointer; color: var(--warm-gray); display: flex; align-items: center; transition: all var(--transition); flex-shrink: 0; margin-top: 2px; }
|
||||
.form-back-btn:hover { color: var(--ink); border-color: var(--ink); }
|
||||
.form-title-group { display: flex; flex-direction: column; gap: 0; flex: 1; }
|
||||
.form-title { margin: 0; font-family: var(--font-heading); font-size: 24px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; line-height: 1.3; }
|
||||
.form-subtitle { font-family: var(--font-mono); font-size: 9px; color: var(--gold); letter-spacing: 0.2em; }
|
||||
.form-rule { width: 100%; height: 2px; background: var(--warm-border); margin-top: 6px; position: relative; }
|
||||
.form-rule::after { content: ''; position: absolute; left: 0; top: 0; width: 32px; height: 2px; background: var(--gold); }
|
||||
.new-btn { width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 14px; background: var(--ink); color: #fff; border: none; font-family: var(--font-heading); font-size: 15px; letter-spacing: 0.06em; cursor: pointer; transition: all 0.25s; margin-bottom: 18px; }
|
||||
.new-btn:hover { background: var(--ink-light); }
|
||||
.new-btn:active { transform: scale(0.98); }
|
||||
.cards { display: flex; flex-direction: column; gap: 10px; }
|
||||
.card { background: var(--surface); border: 1px solid var(--warm-border); display: flex; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; }
|
||||
.card:active { transform: scale(0.99); }
|
||||
.card:hover { box-shadow: 0 4px 12px rgba(28,55,56,0.06); }
|
||||
.card-accent { width: 4px; flex-shrink: 0; }
|
||||
.card-body { flex: 1; padding: 14px 16px; min-width: 0; }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.card-status { font-family: var(--font-heading); font-size: 13px; }
|
||||
.card-amount { font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray); }
|
||||
.card-content { font-family: var(--font-body); font-size: 14px; color: var(--ink); margin-bottom: 6px; line-height: 1.5; }
|
||||
.card-footer { display: flex; align-items: center; justify-content: space-between; }
|
||||
.card-customer { font-size: 12px; color: var(--warm-gray); }
|
||||
.empty-state { text-align: center; padding: 48px 0; }
|
||||
.empty-glyph { font-family: var(--font-heading); font-size: 40px; color: var(--gold); opacity: 0.4; margin-bottom: 8px; }
|
||||
.empty-text { font-family: var(--font-body); font-size: 15px; color: var(--warm-gray); margin: 0; }
|
||||
</style>
|
||||
@@ -1,13 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { miniBusinessApi } from '@/api/miniBusiness'
|
||||
import { customersApi } from '@/api/customers'
|
||||
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 customers = ref<any[]>([])
|
||||
const allManagers = ref<any[]>([])
|
||||
|
||||
const form = ref({
|
||||
customer_id: '',
|
||||
@@ -16,9 +22,28 @@ const form = ref({
|
||||
follow_up_detail: '',
|
||||
status: '跟进中',
|
||||
expected_revenue_date: '',
|
||||
manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || ''),
|
||||
})
|
||||
|
||||
onMounted(() => { loadCustomers() })
|
||||
onMounted(async () => {
|
||||
loadCustomers()
|
||||
if (auth.isDirector || auth.isLeader) {
|
||||
api.get('/users/', { params: { role: 'manager' } }).then(r => { allManagers.value = r.data }).catch(() => {})
|
||||
}
|
||||
if (isEdit.value) {
|
||||
try {
|
||||
const res = await api.get(`/mini-business/${route.params.id}`)
|
||||
const d = res.data
|
||||
if (d.customer_id && d.customer_name && !customers.value.find((c: any) => c.id === d.customer_id)) {
|
||||
customers.value.unshift({ id: d.customer_id, name: d.customer_name })
|
||||
}
|
||||
form.value = { customer_id: d.customer_id, product_type: d.product_type, amount: d.amount, follow_up_detail: d.follow_up_detail || '', status: d.status, expected_revenue_date: d.expected_revenue_date, manager_id: d.manager_id }
|
||||
} catch (_) {}
|
||||
loadLogs()
|
||||
}
|
||||
})
|
||||
|
||||
const customerSearch = ref('')
|
||||
|
||||
async function loadCustomers(q?: string) {
|
||||
try {
|
||||
@@ -29,17 +54,93 @@ async function loadCustomers(q?: string) {
|
||||
} 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 handleSubmit() {
|
||||
if (!form.value.customer_id) { ElMessage.warning('请选择客户'); return }
|
||||
if (form.value.status === '已流失') form.value.expected_revenue_date = ''
|
||||
submitLoading.value = true
|
||||
try {
|
||||
await miniBusinessApi.create(form.value)
|
||||
ElMessage.success('商机已提交')
|
||||
router.push('/m')
|
||||
if (isEdit.value) {
|
||||
await miniBusinessApi.update(route.params.id as string, form.value)
|
||||
ElMessage.success('已更新')
|
||||
} else {
|
||||
await miniBusinessApi.create(form.value)
|
||||
ElMessage.success('商机已提交')
|
||||
}
|
||||
router.push('/m/mini-biz')
|
||||
} catch (e: any) {
|
||||
ElMessage.error('提交失败')
|
||||
ElMessage.error((isEdit.value ? '更新' : '提交') + '失败')
|
||||
} finally { submitLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
|
||||
await miniBusinessApi.delete(route.params.id as string)
|
||||
ElMessage.success('已删除')
|
||||
router.push('/m/mini-biz')
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
|
||||
// ── Follow-up logs ──
|
||||
const logs = ref<any[]>([])
|
||||
const logForm = ref({ log_date: '', method: '电话', content: '' })
|
||||
const logMethods = ['电话', '微信', '上门', '邮件', '其他']
|
||||
const logMethodIcons: Record<string, string> = { '电话': '📞', '微信': '💬', '上门': '🏢', '邮件': '📧', '其他': '📋' }
|
||||
const logMethodColors: Record<string, string> = { '电话': '#5B7FA5', '微信': '#22c55e', '上门': '#4A6741', '邮件': '#C4934A', '其他': '#7B7568' }
|
||||
const logLoading = ref(false)
|
||||
const logSaving = ref(false)
|
||||
|
||||
async function loadLogs() {
|
||||
logLoading.value = true
|
||||
try {
|
||||
const res = await miniBusinessApi.getLogs(route.params.id as string)
|
||||
logs.value = res.data || []
|
||||
} catch (_) { logs.value = [] }
|
||||
finally { logLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleCreateLog() {
|
||||
if (!logForm.value.content.trim()) { ElMessage.warning('请输入跟进内容'); return }
|
||||
logSaving.value = true
|
||||
try {
|
||||
await miniBusinessApi.createLog(route.params.id as string, logForm.value)
|
||||
ElMessage.success('跟进记录已添加')
|
||||
logForm.value = { log_date: new Date().toISOString().slice(0, 10), method: '电话', content: '' }
|
||||
await loadLogs()
|
||||
} catch (e: any) { ElMessage.error('添加失败: ' + (e.response?.data?.detail || e.message)) }
|
||||
finally { logSaving.value = false }
|
||||
}
|
||||
|
||||
async function handleDeleteLog(logId: string) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该跟进记录?', '确认', { type: 'warning' })
|
||||
await miniBusinessApi.deleteLog(logId)
|
||||
ElMessage.success('已删除')
|
||||
await loadLogs()
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -52,17 +153,31 @@ async function handleSubmit() {
|
||||
</svg>
|
||||
</button>
|
||||
<div class="form-title-group">
|
||||
<h2 class="form-title">添加商机跟单</h2>
|
||||
<h2 class="form-title">{{ isEdit ? '编辑商机跟单' : '添加商机跟单' }}</h2>
|
||||
<span class="form-subtitle">BUSINESS OPPORTUNITY</span>
|
||||
</div>
|
||||
<button v-if="isEdit" class="header-delete-btn" @click="handleDelete">删除</button>
|
||||
<div class="form-rule"></div>
|
||||
</header>
|
||||
|
||||
<el-form label-position="top" class="editorial-form">
|
||||
<el-form-item>
|
||||
<template #label><span class="form-label">客户单位</span></template>
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="loadCustomers" placeholder="搜索客户" style="width:100%">
|
||||
<template #label><span class="form-label">客户单位 <span class="required-star">*</span></span></template>
|
||||
<el-select v-model="form.customer_id" filterable remote :remote-method="handleCustomerSearch" placeholder="搜索客户" style="width:100%">
|
||||
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||
<template #empty>
|
||||
<div v-if="customerSearch" class="select-empty-create">
|
||||
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到「{{ customerSearch }}」</p>
|
||||
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">新建客户「{{ customerSearch }}」</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="auth.isDirector || auth.isLeader">
|
||||
<template #label><span class="form-label">客户经理</span></template>
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
@@ -95,10 +210,43 @@ async function handleSubmit() {
|
||||
<el-input v-model="form.expected_revenue_date" placeholder="如: 2026Q3" />
|
||||
</el-form-item>
|
||||
|
||||
<button class="submit-btn" :disabled="submitLoading" @click="handleSubmit">
|
||||
<span v-if="submitLoading" class="btn-loading"></span>
|
||||
提交商机
|
||||
</button>
|
||||
<div class="form-actions">
|
||||
<button class="submit-btn" :disabled="submitLoading" @click="handleSubmit">
|
||||
<span v-if="submitLoading" class="btn-loading"></span>
|
||||
{{ isEdit ? '保存修改' : '提交商机' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Follow-up Timeline (edit mode only) -->
|
||||
<div v-if="isEdit" class="log-section">
|
||||
<div class="section-head"><span class="section-title">跟进记录</span><span class="section-line"></span></div>
|
||||
<div v-loading="logLoading" class="log-list">
|
||||
<div v-if="logs.length === 0 && !logLoading" class="log-empty">暂无跟进记录</div>
|
||||
<div v-for="l in logs" :key="l.id" class="log-item">
|
||||
<span class="log-icon">{{ logMethodIcons[l.method] || '📋' }}</span>
|
||||
<div class="log-body">
|
||||
<div class="log-header">
|
||||
<span class="log-date">{{ l.log_date }}</span>
|
||||
<span class="log-chip" :style="{ background: logMethodColors[l.method] || '#7B7568', color: '#fff', padding: '1px 6px', fontSize: '11px' }">{{ l.method }}</span>
|
||||
<span class="log-author">{{ l.created_by_name }}</span>
|
||||
</div>
|
||||
<div class="log-content">{{ l.content }}</div>
|
||||
</div>
|
||||
<button class="log-del" @click="handleDeleteLog(l.id)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add Log -->
|
||||
<div class="log-add">
|
||||
<div class="log-add-row">
|
||||
<el-date-picker v-model="logForm.log_date" type="date" value-format="YYYY-MM-DD" size="small" style="width:130px" />
|
||||
<div class="log-chips">
|
||||
<button v-for="m in logMethods" :key="m" type="button" class="log-chip-btn" :class="{ 'log-chip-btn--active': logForm.method === m }" :style="logForm.method === m ? { background: logMethodColors[m], borderColor: logMethodColors[m], color: '#fff' } : {}" @click="logForm.method = m">{{ m }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<el-input v-model="logForm.content" type="textarea" :rows="2" placeholder="输入跟进内容..." size="small" style="margin-top:6px" />
|
||||
<button class="log-submit" :disabled="logSaving" @click="handleCreateLog">{{ logSaving ? '添加中...' : '添加跟进' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -167,4 +315,46 @@ async function handleSubmit() {
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 8px 14px; color: var(--gold-dark); font-family: var(--font-body); font-size: 13px; cursor: pointer; transition: all 0.2s; letter-spacing: 0.03em; }
|
||||
.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); }
|
||||
.select-empty-create { padding: 8px 12px; text-align: center; }
|
||||
.form-actions { display: flex; gap: 10px; margin-top: 24px; }
|
||||
.submit-btn { flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 16px; background: var(--ink); color: #fff; border: none; font-family: var(--font-heading); font-size: 16px; letter-spacing: 0.08em; cursor: pointer; transition: all 0.25s; }
|
||||
.submit-btn:hover { background: var(--ink-light); }
|
||||
.submit-btn:active { transform: scale(0.98); }
|
||||
.submit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.delete-btn { padding: 16px 24px; background: var(--surface); color: var(--vermilion); border: 1px solid var(--vermilion); font-family: var(--font-heading); font-size: 16px; letter-spacing: 0.08em; cursor: pointer; transition: all 0.25s; }
|
||||
.delete-btn:hover { background: var(--vermilion); color: #fff; }
|
||||
.header-delete-btn { margin-left: auto; margin-top: 4px; padding: 6px 14px; background: none; color: var(--vermilion); border: 1px solid var(--vermilion); font-family: var(--font-body); font-size: 13px; cursor: pointer; transition: all 0.2s; white-space: nowrap; }
|
||||
.header-delete-btn:hover { background: var(--vermilion); color: #fff; }
|
||||
.btn-loading { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
|
||||
/* ── Follow-up Log Section ── */
|
||||
.log-section { margin-top: 20px; border-top: 1px solid var(--warm-border); padding-top: 16px; }
|
||||
.section-head { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
|
||||
.section-title { font-family: var(--font-heading); font-size: 18px; color: var(--ink); letter-spacing: 0.06em; white-space: nowrap; }
|
||||
.section-line { flex: 1; height: 2px; background: var(--gold); }
|
||||
.log-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px; max-height: 260px; overflow-y: auto; }
|
||||
.log-empty { text-align: center; color: var(--warm-gray); font-size: 13px; padding: 16px 0; }
|
||||
.log-item { display: flex; gap: 8px; align-items: flex-start; padding: 10px; background: var(--surface); border: 1px solid var(--warm-border); }
|
||||
.log-icon { font-size: 16px; flex-shrink: 0; margin-top: 2px; }
|
||||
.log-body { flex: 1; min-width: 0; }
|
||||
.log-header { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
|
||||
.log-date { font-family: var(--font-mono); font-size: 12px; color: var(--ink); }
|
||||
.log-author { font-size: 11px; color: var(--warm-gray); margin-left: auto; }
|
||||
.log-content { font-size: 13px; color: var(--c-text); line-height: 1.5; }
|
||||
.log-del { flex-shrink: 0; background: none; border: none; color: var(--warm-gray); cursor: pointer; font-size: 14px; padding: 2px; }
|
||||
.log-del:hover { color: var(--vermilion); }
|
||||
.log-chip { border-radius: 2px; white-space: nowrap; }
|
||||
.log-add { border-top: 1px solid var(--warm-border); padding-top: 12px; }
|
||||
.log-add-row { display: flex; gap: 6px; align-items: center; }
|
||||
.log-chips { display: flex; gap: 3px; }
|
||||
.log-chip-btn { padding: 4px 8px; border: 1px solid var(--warm-border); background: var(--surface); font-size: 11px; cursor: pointer; transition: all 0.2s; }
|
||||
.log-chip-btn:hover { border-color: var(--ink); }
|
||||
.log-chip-btn--active { font-weight: 600; }
|
||||
.log-submit { width: 100%; margin-top: 8px; padding: 10px; background: var(--ink); color: #fff; border: none; font-family: var(--font-heading); font-size: 14px; letter-spacing: 0.06em; cursor: pointer; }
|
||||
.log-submit:hover { background: var(--ink-light); }
|
||||
.log-submit:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import api from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const searchText = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(25)
|
||||
const total = ref(0)
|
||||
const items = ref<any[]>([])
|
||||
|
||||
onMounted(loadItems)
|
||||
|
||||
const catColors: Record<string, string> = {
|
||||
'行政事务': '#1C3738', '合同整理': '#4A6741', '发票处理': '#C4934A',
|
||||
'内部会议': '#B8472E', '培训学习': '#5B7FA5', '其他': '#7B7568',
|
||||
}
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { page: page.value, page_size: pageSize.value }
|
||||
if (searchText.value) params.search = searchText.value
|
||||
const res = await api.get('/daily-notes/', { params })
|
||||
const raw = Array.isArray(res.data) ? res.data : (res.data.items || [])
|
||||
items.value = raw; total.value = res.data.total || raw.length
|
||||
} catch (_) {}
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; loadItems() }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="list-page">
|
||||
<header class="form-editorial-header">
|
||||
<button type="button" class="form-back-btn" @click="router.back()">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
|
||||
</button>
|
||||
<div class="form-title-group"><h2 class="form-title">历史纪要</h2><span class="form-subtitle">NOTE HISTORY</span></div>
|
||||
<div class="form-rule"></div>
|
||||
</header>
|
||||
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px">
|
||||
<el-input v-model="searchText" placeholder="搜索..." clearable @keyup.enter="onSearch" @clear="onSearch" />
|
||||
<el-button @click="onSearch">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="cards">
|
||||
<div v-if="items.length === 0 && !loading" class="empty-state"><div class="empty-glyph">—</div><p class="empty-text">暂无纪要记录</p></div>
|
||||
<article v-for="item in items" :key="item.id" class="card" @click="router.push(`/m/note/${item.id}/edit`)">
|
||||
<div class="card-accent" :style="{ background: catColors[item.category] || '#7B7568' }"></div>
|
||||
<div class="card-body">
|
||||
<div class="card-header">
|
||||
<span class="card-cat" :style="{ color: catColors[item.category] || '#7B7568' }">{{ item.category }}</span>
|
||||
<span class="card-date">{{ item.note_date }}</span>
|
||||
</div>
|
||||
<div class="card-content">{{ item.content || '暂无内容' }}</div>
|
||||
<div class="card-footer">
|
||||
<span v-if="item.time_range" class="card-time">{{ item.time_range }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="total > pageSize" style="display:flex;justify-content:center;margin-top:14px">
|
||||
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" small @current-change="loadItems" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-page { max-width: 100%; padding-bottom: 20px; }
|
||||
.form-editorial-header { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.form-back-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 8px 10px; cursor: pointer; color: var(--warm-gray); display: flex; align-items: center; transition: all var(--transition); flex-shrink: 0; margin-top: 2px; }
|
||||
.form-back-btn:hover { color: var(--ink); border-color: var(--ink); }
|
||||
.form-title-group { display: flex; flex-direction: column; gap: 0; flex: 1; }
|
||||
.form-title { margin: 0; font-family: var(--font-heading); font-size: 24px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; line-height: 1.3; }
|
||||
.form-subtitle { font-family: var(--font-mono); font-size: 9px; color: var(--gold); letter-spacing: 0.2em; }
|
||||
.form-rule { width: 100%; height: 2px; background: var(--warm-border); margin-top: 6px; position: relative; }
|
||||
.form-rule::after { content: ''; position: absolute; left: 0; top: 0; width: 32px; height: 2px; background: var(--gold); }
|
||||
.cards { display: flex; flex-direction: column; gap: 10px; }
|
||||
.card { background: var(--surface); border: 1px solid var(--warm-border); display: flex; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; }
|
||||
.card:active { transform: scale(0.99); }
|
||||
.card:hover { box-shadow: 0 4px 12px rgba(28,55,56,0.06); }
|
||||
.card-accent { width: 4px; flex-shrink: 0; }
|
||||
.card-body { flex: 1; padding: 14px 16px; min-width: 0; }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.card-cat { font-family: var(--font-heading); font-size: 13px; }
|
||||
.card-date { font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray); }
|
||||
.card-content { font-family: var(--font-body); font-size: 14px; color: var(--ink); margin-bottom: 6px; line-height: 1.5; }
|
||||
.card-footer { display: flex; align-items: center; }
|
||||
.card-time { font-size: 12px; color: var(--warm-gray); }
|
||||
.empty-state { text-align: center; padding: 48px 0; }
|
||||
.empty-glyph { font-family: var(--font-heading); font-size: 40px; color: var(--gold); opacity: 0.4; margin-bottom: 8px; }
|
||||
.empty-text { font-family: var(--font-body); font-size: 15px; color: var(--warm-gray); margin: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,207 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import api from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const searchText = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(25)
|
||||
const total = ref(0)
|
||||
const items = ref<any[]>([])
|
||||
|
||||
const today = new Date()
|
||||
const weekStart = (d: Date) => { const s = new Date(d); s.setDate(s.getDate() - s.getDay() + 1); s.setHours(0,0,0,0); return s }
|
||||
const weekEnd = (d: Date) => { const e = weekStart(d); e.setDate(e.getDate() + 6); return e }
|
||||
const thisWeekStart = weekStart(today)
|
||||
const nextWeekStart = new Date(thisWeekStart); nextWeekStart.setDate(nextWeekStart.getDate() + 7)
|
||||
const thisMonthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0)
|
||||
const todayStr = computed(() => today.toISOString().slice(0, 10))
|
||||
|
||||
function classifyPlan(planDate: string, status: string): string {
|
||||
if (status !== '计划中') return 'archived'
|
||||
const d = new Date(planDate)
|
||||
if (d < thisWeekStart) return 'thisWeek'
|
||||
if (d <= weekEnd(today)) return 'thisWeek'
|
||||
if (d <= weekEnd(nextWeekStart)) return 'nextWeek'
|
||||
if (d <= thisMonthEnd) return 'thisMonth'
|
||||
return 'later'
|
||||
}
|
||||
|
||||
function isOverdue(row: any): boolean {
|
||||
return row.status === '计划中' && row.plan_date < todayStr.value
|
||||
}
|
||||
|
||||
function overdueDays(row: any): number {
|
||||
return Math.floor((today.getTime() - new Date(row.plan_date).getTime()) / 86400000)
|
||||
}
|
||||
|
||||
const sectionLabels: Record<string, string> = {
|
||||
thisWeek: '本周', nextWeek: '下周', thisMonth: '本月', later: '更远', archived: '已归档',
|
||||
}
|
||||
|
||||
const groupedPlans = computed(() => {
|
||||
const groups: Record<string, any[]> = { thisWeek: [], nextWeek: [], thisMonth: [], later: [], archived: [] }
|
||||
for (const p of items.value) {
|
||||
const key = classifyPlan(p.plan_date, p.status)
|
||||
if (!groups[key]) groups[key] = []
|
||||
groups[key].push(p)
|
||||
}
|
||||
for (const g of Object.values(groups)) g.sort((a: any, b: any) => a.plan_date.localeCompare(b.plan_date))
|
||||
return Object.entries(groups).filter(([_, list]) => list.length > 0).map(([key, list]) => ({ key, label: sectionLabels[key] || key, list }))
|
||||
})
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
'计划中': '#4A6741', '已完成': '#5B7FA5', '已取消': '#909399',
|
||||
}
|
||||
onMounted(loadItems)
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { page: page.value, page_size: pageSize.value }
|
||||
if (searchText.value) params.search = searchText.value
|
||||
const res = await api.get('/work-plans/', { params })
|
||||
const raw = Array.isArray(res.data) ? res.data : (res.data.items || [])
|
||||
items.value = raw; total.value = res.data.total || raw.length
|
||||
} catch (_) {}
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; loadItems() }
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try { await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' }); await api.delete(`/work-plans/${id}`); ElMessage.success('已删除'); await loadItems() }
|
||||
catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||||
}
|
||||
|
||||
async function quickCancel(item: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定将此计划标记为「已取消」?', '确认取消', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '返回' })
|
||||
await api.put(`/work-plans/${item.id}`, { status: '已取消' }); ElMessage.success('已取消'); await loadItems()
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('操作失败') }
|
||||
}
|
||||
|
||||
const rescheduleVisible = ref(false)
|
||||
const rescheduleItem = ref<any>(null)
|
||||
const rescheduleDate = ref('')
|
||||
function openReschedule(item: any) { rescheduleItem.value = item; rescheduleDate.value = item.plan_date; rescheduleVisible.value = true }
|
||||
async function confirmReschedule() {
|
||||
if (!rescheduleDate.value || !rescheduleItem.value) return
|
||||
try { await api.put(`/work-plans/${rescheduleItem.value.id}`, { plan_date: rescheduleDate.value }); ElMessage.success('日期已更新'); rescheduleVisible.value = false; await loadItems() }
|
||||
catch (e: any) { ElMessage.error('更新失败') }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="list-page">
|
||||
<header class="form-editorial-header">
|
||||
<button type="button" class="form-back-btn" @click="router.back()">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
|
||||
</button>
|
||||
<div class="form-title-group"><h2 class="form-title">工作计划</h2><span class="form-subtitle">WORK PLANS</span></div>
|
||||
<div class="form-rule"></div>
|
||||
</header>
|
||||
|
||||
<button class="new-btn" @click="router.push('/m/work-plan/new')">+ 新建计划</button>
|
||||
|
||||
<div class="auto-hint">💡 拜访客户后计划会自动完成,无需手动改状态</div>
|
||||
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px">
|
||||
<el-input v-model="searchText" placeholder="搜索..." clearable @keyup.enter="onSearch" @clear="onSearch" />
|
||||
<el-button @click="onSearch">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="items.length === 0 && !loading" class="empty-state"><div class="empty-glyph">—</div><p class="empty-text">暂无工作计划</p></div>
|
||||
|
||||
<div v-for="group in groupedPlans" :key="group.key" class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">{{ group.label }}</span>
|
||||
<span class="section-count">{{ group.list.length }} 条</span>
|
||||
</div>
|
||||
<div class="cards">
|
||||
<article v-for="item in group.list" :key="item.id" class="card" :class="{ 'card--overdue': isOverdue(item) }" @click="router.push(`/m/work-plan/${item.id}/edit`)">
|
||||
<div class="card-accent" :style="{ background: isOverdue(item) ? '#B8472E' : statusColors[item.status] || '#909399' }"></div>
|
||||
<div class="card-body">
|
||||
<div class="card-header">
|
||||
<span class="card-status" :style="{ color: statusColors[item.status] || '#909399' }">{{ item.status }}</span>
|
||||
<span v-if="isOverdue(item)" class="overdue-badge">过期{{ overdueDays(item) }}天</span>
|
||||
<span class="card-date">{{ item.plan_date }}</span>
|
||||
</div>
|
||||
<div class="card-content">{{ item.plan_content }}</div>
|
||||
<div class="card-footer">
|
||||
<span class="card-customer">{{ item.customer_name }}</span>
|
||||
<div v-if="isOverdue(item)" style="display:flex;gap:6px">
|
||||
<button class="quick-reschedule-btn" @click.stop="openReschedule(item)">改期</button>
|
||||
<button class="quick-cancel-btn" @click.stop="quickCancel(item)">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="total > pageSize" style="display:flex;justify-content:center;margin-top:14px">
|
||||
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" small @current-change="loadItems" />
|
||||
</div>
|
||||
|
||||
<!-- Quick reschedule dialog -->
|
||||
<el-dialog v-model="rescheduleVisible" title="修改计划日期" width="90%">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="新日期">
|
||||
<el-date-picker v-model="rescheduleDate" type="date" value-format="YYYY-MM-DD" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="rescheduleVisible = false">取消</el-button><el-button type="primary" @click="confirmReschedule">确认改期</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-page { max-width: 100%; padding-bottom: 20px; }
|
||||
.form-editorial-header { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.form-back-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 8px 10px; cursor: pointer; color: var(--warm-gray); display: flex; align-items: center; transition: all var(--transition); flex-shrink: 0; margin-top: 2px; }
|
||||
.form-back-btn:hover { color: var(--ink); border-color: var(--ink); }
|
||||
.form-title-group { display: flex; flex-direction: column; gap: 0; flex: 1; }
|
||||
.form-title { margin: 0; font-family: var(--font-heading); font-size: 24px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; line-height: 1.3; }
|
||||
.form-subtitle { font-family: var(--font-mono); font-size: 9px; color: var(--gold); letter-spacing: 0.2em; }
|
||||
.form-rule { width: 100%; height: 2px; background: var(--warm-border); margin-top: 6px; position: relative; }
|
||||
.form-rule::after { content: ''; position: absolute; left: 0; top: 0; width: 32px; height: 2px; background: var(--gold); }
|
||||
.new-btn { width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 14px; background: var(--ink); color: #fff; border: none; font-family: var(--font-heading); font-size: 15px; letter-spacing: 0.06em; cursor: pointer; transition: all 0.25s; margin-bottom: 18px; }
|
||||
.new-btn:hover { background: var(--ink-light); }
|
||||
.new-btn:active { transform: scale(0.98); }
|
||||
|
||||
.auto-hint { padding: 10px 14px; margin-bottom: 14px; background: rgba(74,103,65,0.06); border-left: 3px solid #4A6741; font-family: var(--font-body); font-size: 12px; color: var(--ink); line-height: 1.5; }
|
||||
|
||||
/* ── Sections ── */
|
||||
.section { margin-bottom: 20px; }
|
||||
.section-header { display: flex; align-items: baseline; gap: 8px; padding: 8px 0 10px; border-bottom: 1px solid var(--warm-border); margin-bottom: 10px; }
|
||||
.section-title { font-family: var(--font-heading); font-size: 14px; color: var(--ink); letter-spacing: 0.04em; }
|
||||
.section-count { font-family: var(--font-mono); font-size: 11px; color: var(--warm-gray); }
|
||||
|
||||
.cards { display: flex; flex-direction: column; gap: 10px; }
|
||||
.card { background: var(--surface); border: 1px solid var(--warm-border); display: flex; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; }
|
||||
.card--overdue { border-left: 3px solid var(--vermilion); background: rgba(184,71,46,0.03); }
|
||||
.card:active { transform: scale(0.99); }
|
||||
.card:hover { box-shadow: 0 4px 12px rgba(28,55,56,0.06); }
|
||||
.card-accent { width: 4px; flex-shrink: 0; }
|
||||
.card-body { flex: 1; padding: 14px 16px; min-width: 0; }
|
||||
.card-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.card-status { font-family: var(--font-heading); font-size: 13px; }
|
||||
.card-date { font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray); margin-left: auto; }
|
||||
.card-content { font-family: var(--font-body); font-size: 14px; color: var(--ink); margin-bottom: 6px; line-height: 1.5; }
|
||||
.card-footer { display: flex; align-items: center; justify-content: space-between; }
|
||||
.card-customer { font-size: 12px; color: var(--warm-gray); }
|
||||
|
||||
/* ── Overdue ── */
|
||||
.overdue-badge { padding: 1px 6px; border-radius: 6px; background: var(--vermilion); color: #fff; font-family: var(--font-mono); font-size: 10px; white-space: nowrap; }
|
||||
.quick-reschedule-btn { padding: 4px 10px; background: none; border: 1px solid var(--gold); color: var(--gold-dark); font-family: var(--font-body); font-size: 11px; cursor: pointer; transition: all 0.2s; }
|
||||
.quick-reschedule-btn:hover { background: var(--gold); color: #fff; }
|
||||
.quick-cancel-btn { padding: 4px 10px; background: none; border: 1px solid var(--vermilion); color: var(--vermilion); font-family: var(--font-body); font-size: 11px; cursor: pointer; transition: all 0.2s; }
|
||||
.quick-cancel-btn:hover { background: var(--vermilion); color: #fff; }
|
||||
.empty-state { text-align: center; padding: 48px 0; }
|
||||
.empty-glyph { font-family: var(--font-heading); font-size: 40px; color: var(--gold); opacity: 0.4; margin-bottom: 8px; }
|
||||
.empty-text { font-family: var(--font-body); font-size: 15px; color: var(--warm-gray); margin: 0; }
|
||||
</style>
|
||||
@@ -30,6 +30,7 @@ const form = ref({
|
||||
customer_demand: '',
|
||||
companions: [] as string[],
|
||||
photos: [] as string[],
|
||||
manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || ''),
|
||||
})
|
||||
|
||||
const timeRangeValue = ref<any>(null)
|
||||
@@ -41,6 +42,8 @@ const previewDialogVisible = ref(false)
|
||||
const previewImageUrl = ref('')
|
||||
const uploading = ref(false)
|
||||
const customerSearch = ref('')
|
||||
const isCompanionRecord = ref(false)
|
||||
const companionOf = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCustomers()
|
||||
@@ -52,10 +55,13 @@ onMounted(async () => {
|
||||
try {
|
||||
const res = await visitsApi.get(route.params.id as string)
|
||||
const v = res.data
|
||||
// 确保当前客户在 select 选项列表中,避免显示 UUID
|
||||
// 确保当前客户/客户经理在 select 选项列表中,避免显示 UUID
|
||||
if (v.customer_id && v.customer_name && !customers.value.find(c => c.id === v.customer_id)) {
|
||||
customers.value.unshift({ id: v.customer_id, name: v.customer_name })
|
||||
}
|
||||
if (v.manager_id && v.manager_name && !managers.value.find((m: any) => m.id === v.manager_id)) {
|
||||
managers.value.unshift({ id: v.manager_id, name: v.manager_name })
|
||||
}
|
||||
form.value = {
|
||||
customer_id: v.customer_id,
|
||||
visit_date: v.visit_date,
|
||||
@@ -67,6 +73,7 @@ onMounted(async () => {
|
||||
customer_demand: v.customer_demand || '',
|
||||
companions: (v.companions || []).map(String),
|
||||
photos: v.photos || [],
|
||||
manager_id: v.manager_id || auth.userId || '',
|
||||
}
|
||||
uploadedPhotos.value = v.photos || []
|
||||
// Parse time range back into picker
|
||||
@@ -74,6 +81,16 @@ onMounted(async () => {
|
||||
const parts = v.time_range.split('-')
|
||||
timeRangeValue.value = [parts[0], parts[1]]
|
||||
}
|
||||
// Check if this is a companion record
|
||||
if (v.visit_group_id) {
|
||||
isCompanionRecord.value = true
|
||||
// The companions field on a companion record contains the primary creator
|
||||
const creatorId = (v.companions || [])[0]
|
||||
if (creatorId) {
|
||||
const creator = managers.value.find((m: any) => m.id === String(creatorId))
|
||||
companionOf.value = creator?.name || ''
|
||||
}
|
||||
}
|
||||
for (const key of (v.photos || [])) {
|
||||
try {
|
||||
const urlRes = await uploadApi.getDownloadUrl(key)
|
||||
@@ -115,6 +132,17 @@ async function handleQuickCreate() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onCustomerChange(customerId: string) {
|
||||
if (!customerId) return
|
||||
try {
|
||||
const res = await api.get(`/customers/${customerId}/overdue-plans`)
|
||||
if (res.data?.plan?.plan_content) {
|
||||
form.value.communication_content = res.data.plan.plan_content
|
||||
form.value.customer_demand = ''
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function handlePhotoUpload(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (!target.files?.length) return
|
||||
@@ -128,8 +156,7 @@ async function handlePhotoUpload(event: Event) {
|
||||
try {
|
||||
// Compress before upload to reduce storage & transfer
|
||||
const compressed = await compressImage(file, { maxPixels: 1920, quality: 0.8 })
|
||||
const res = await uploadApi.getPresignedUrl(compressed.name, compressed.type || 'image/jpeg')
|
||||
await uploadApi.uploadFile(res.data.upload_url, compressed)
|
||||
const res = await uploadApi.uploadImage(compressed)
|
||||
uploadedPhotos.value.push(res.data.object_key)
|
||||
form.value.photos = [...uploadedPhotos.value]
|
||||
} catch (e: any) {
|
||||
@@ -221,10 +248,20 @@ async function handleDelete() {
|
||||
<div class="form-rule"></div>
|
||||
</header>
|
||||
|
||||
<!-- Companion record notification -->
|
||||
<div v-if="isCompanionRecord" class="companion-notice">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;color:var(--gold)">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||
</svg>
|
||||
<span>此记录为协同 <strong>{{ companionOf }}</strong> 拜访的副本。你可以补充或修改内容,不会影响主记录。</span>
|
||||
</div>
|
||||
|
||||
<el-form label-position="top" class="editorial-form">
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<span class="form-label">客户单位</span>
|
||||
<span class="form-label">客户单位 <span class="required-star">*</span></span>
|
||||
</template>
|
||||
<el-select
|
||||
v-model="form.customer_id"
|
||||
@@ -233,25 +270,36 @@ async function handleDelete() {
|
||||
:remote-method="handleCustomerSearch"
|
||||
placeholder="搜索/选择客户"
|
||||
style="width:100%"
|
||||
@change="onCustomerChange"
|
||||
>
|
||||
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||||
<template #empty>
|
||||
<div v-if="customerSearch" class="select-empty-create">
|
||||
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到「{{ customerSearch }}」</p>
|
||||
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
新建客户「{{ customerSearch }}」
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="auth.isDirector || auth.isLeader">
|
||||
<template #label><span class="form-label">客户经理</span></template>
|
||||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||||
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
<div v-if="customerSearch && !customers.some(c => c.name === customerSearch)" style="margin-top:8px">
|
||||
<button type="button" class="quick-create-btn" @click="handleQuickCreate">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
新建客户「{{ customerSearch }}」
|
||||
</button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<span class="form-label">拜访日期</span>
|
||||
</template>
|
||||
<el-date-picker v-model="form.visit_date" type="date" style="width:100%" />
|
||||
<el-date-picker v-model="form.visit_date" type="date" value-format="YYYY-MM-DD" style="width:100%" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
@@ -289,7 +337,7 @@ async function handleDelete() {
|
||||
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<span class="form-label">相关人员 <span class="form-label-hint">可输入外部人员</span></span>
|
||||
<span class="form-label">相关人员 <span class="form-label-hint">同访人周报中也会显示此记录</span></span>
|
||||
</template>
|
||||
<el-select v-model="form.companions" multiple filterable allow-create default-first-option placeholder="选择或输入姓名(可多选)" style="width:100%">
|
||||
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" :disabled="m.id === auth.userId" />
|
||||
@@ -494,6 +542,7 @@ async function handleDelete() {
|
||||
color: var(--vermilion);
|
||||
background: rgba(184,71,46,0.03);
|
||||
}
|
||||
.select-empty-create { padding: 8px 12px; text-align: center; }
|
||||
|
||||
/* ═══ Photo Area ═══ */
|
||||
.photo-area {
|
||||
@@ -646,4 +695,17 @@ async function handleDelete() {
|
||||
.method--电话.method-chip--active { background: #5B7FA5; border-color: #5B7FA5; color: #fff; }
|
||||
.method--微信.method-chip--active { background: #22c55e; border-color: #22c55e; color: #fff; }
|
||||
.method--出差.method-chip--active { background: #C4934A; border-color: #C4934A; color: #fff; }
|
||||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||||
|
||||
/* ═══ Companion Notice ═══ */
|
||||
.companion-notice {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
padding: 14px 16px; margin-bottom: 20px;
|
||||
background: rgba(196,147,74,0.06);
|
||||
border-left: 3px solid var(--gold);
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px; color: var(--ink);
|
||||
line-height: 1.6; letter-spacing: 0.03em;
|
||||
}
|
||||
.companion-notice strong { color: var(--gold-dark); }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import api from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const searchText = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(25)
|
||||
const total = ref(0)
|
||||
const items = ref<any[]>([])
|
||||
|
||||
onMounted(loadItems)
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { page: page.value, page_size: pageSize.value }
|
||||
if (searchText.value) params.search = searchText.value
|
||||
const res = await api.get('/visits/', { params })
|
||||
const raw = Array.isArray(res.data) ? res.data : (res.data.items || [])
|
||||
items.value = raw; total.value = res.data.total || raw.length
|
||||
} catch (_) {}
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; loadItems() }
|
||||
|
||||
|
||||
const methodColors: Record<string, string> = { '上门': '#4A6741', '电话': '#5B7FA5', '微信': '#22c55e', '出差': '#C4934A' }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="list-page">
|
||||
<header class="form-editorial-header">
|
||||
<button type="button" class="form-back-btn" @click="router.back()">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
|
||||
</button>
|
||||
<div class="form-title-group"><h2 class="form-title">历史拜访</h2><span class="form-subtitle">VISIT HISTORY</span></div>
|
||||
<div class="form-rule"></div>
|
||||
</header>
|
||||
|
||||
<div style="margin-bottom:12px;display:flex;gap:8px">
|
||||
<el-input v-model="searchText" placeholder="搜索..." clearable @keyup.enter="onSearch" @clear="onSearch" />
|
||||
<el-button @click="onSearch">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="cards">
|
||||
<div v-if="items.length === 0 && !loading" class="empty-state"><div class="empty-glyph">—</div><p class="empty-text">暂无拜访记录</p></div>
|
||||
<article v-for="item in items" :key="item.id" class="card" @click="router.push(`/m/visit/${item.id}/edit`)">
|
||||
<div class="card-accent" :style="{ background: methodColors[item.visit_method] || '#7B7568' }"></div>
|
||||
<div class="card-body">
|
||||
<div class="card-header">
|
||||
<span class="card-method" :style="{ color: methodColors[item.visit_method] || '#7B7568' }">{{ item.visit_method }}</span>
|
||||
<span class="card-date">{{ item.visit_date }}</span>
|
||||
</div>
|
||||
<div class="card-content">{{ item.communication_content || '暂无沟通内容' }}</div>
|
||||
<div class="card-footer">
|
||||
<span class="card-customer">{{ item.customer_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="total > pageSize" style="display:flex;justify-content:center;margin-top:14px">
|
||||
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" small @current-change="loadItems" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-page { max-width: 100%; padding-bottom: 20px; }
|
||||
.form-editorial-header { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.form-back-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 8px 10px; cursor: pointer; color: var(--warm-gray); display: flex; align-items: center; transition: all var(--transition); flex-shrink: 0; margin-top: 2px; }
|
||||
.form-back-btn:hover { color: var(--ink); border-color: var(--ink); }
|
||||
.form-title-group { display: flex; flex-direction: column; gap: 0; flex: 1; }
|
||||
.form-title { margin: 0; font-family: var(--font-heading); font-size: 24px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; line-height: 1.3; }
|
||||
.form-subtitle { font-family: var(--font-mono); font-size: 9px; color: var(--gold); letter-spacing: 0.2em; }
|
||||
.form-rule { width: 100%; height: 2px; background: var(--warm-border); margin-top: 6px; position: relative; }
|
||||
.form-rule::after { content: ''; position: absolute; left: 0; top: 0; width: 32px; height: 2px; background: var(--gold); }
|
||||
.cards { display: flex; flex-direction: column; gap: 10px; }
|
||||
.card { background: var(--surface); border: 1px solid var(--warm-border); display: flex; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; }
|
||||
.card:active { transform: scale(0.99); }
|
||||
.card:hover { box-shadow: 0 4px 12px rgba(28,55,56,0.06); }
|
||||
.card-accent { width: 4px; flex-shrink: 0; }
|
||||
.card-body { flex: 1; padding: 14px 16px; min-width: 0; }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.card-method { font-family: var(--font-heading); font-size: 13px; }
|
||||
.card-date { font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray); }
|
||||
.card-content { font-family: var(--font-body); font-size: 14px; color: var(--ink); margin-bottom: 6px; line-height: 1.5; }
|
||||
.card-footer { display: flex; align-items: center; justify-content: space-between; }
|
||||
.card-customer { font-size: 12px; color: var(--warm-gray); }
|
||||
.empty-state { text-align: center; padding: 48px 0; }
|
||||
.empty-glyph { font-family: var(--font-heading); font-size: 40px; color: var(--gold); opacity: 0.4; margin-bottom: 8px; }
|
||||
.empty-text { font-family: var(--font-body); font-size: 15px; color: var(--warm-gray); margin: 0; }
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user