diff --git a/CLAUDE.md b/CLAUDE.md index 8890a26..732ad06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 参数模糊匹配 diff --git a/backend/app/api/customers.py b/backend/app/api/customers.py index 47c21c2..b95f1be 100644 --- a/backend/app/api/customers.py +++ b/backend/app/api/customers.py @@ -51,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), @@ -61,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: @@ -90,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() diff --git a/backend/app/api/daily_notes.py b/backend/app/api/daily_notes.py index 8014bc3..33bed4f 100644 --- a/backend/app/api/daily_notes.py +++ b/backend/app/api/daily_notes.py @@ -31,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), ): @@ -41,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()] @@ -81,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, @@ -112,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(): diff --git a/backend/app/api/key_visits.py b/backend/app/api/key_visits.py index 5e7f4a2..aadc48d 100644 --- a/backend/app/api/key_visits.py +++ b/backend/app/api/key_visits.py @@ -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 @@ -37,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), ): @@ -45,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, @@ -64,7 +87,7 @@ 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) @@ -103,6 +126,8 @@ 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(): diff --git a/backend/app/api/mini_business.py b/backend/app/api/mini_business.py index c8ebd47..7e1f972 100644 --- a/backend/app/api/mini_business.py +++ b/backend/app/api/mini_business.py @@ -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 @@ -36,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), ): @@ -44,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, @@ -61,7 +84,7 @@ 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"]) @@ -101,10 +124,15 @@ 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: diff --git a/backend/app/api/mini_business_logs.py b/backend/app/api/mini_business_logs.py new file mode 100644 index 0000000..a35b7e6 --- /dev/null +++ b/backend/app/api/mini_business_logs.py @@ -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"} diff --git a/backend/app/api/visits.py b/backend/app/api/visits.py index 3050610..b99df03 100644 --- a/backend/app/api/visits.py +++ b/backend/app/api/visits.py @@ -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 @@ -74,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), ): @@ -89,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) @@ -161,7 +169,7 @@ 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"]) @@ -176,18 +184,22 @@ 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 full copies for companions (not blank drafts) creator_name = current_user["name"] for companion_id in data.companions: @@ -215,6 +227,24 @@ async def create_visit( 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"]) @@ -251,6 +281,8 @@ async def update_visit( } 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"]: @@ -289,6 +321,15 @@ async def update_visit( 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) diff --git a/backend/app/api/work_plans.py b/backend/app/api/work_plans.py index 6bf1a90..4fc614a 100644 --- a/backend/app/api/work_plans.py +++ b/backend/app/api/work_plans.py @@ -3,12 +3,11 @@ 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 from app.utils.edit_log import compute_diff, append_entry, init_entry -from app.utils.audit import log_audit from app.models.work_plan import WorkPlan from app.models.customer import Customer from app.models.user import User @@ -36,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), ): @@ -44,59 +45,49 @@ 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, current_user: dict = Depends(require_any_role), db: AsyncSession = Depends(get_db), ): - plan_date = parse_date(data.plan_date) - - # Auto-complete if a visit already exists for this customer on/after the plan date - from app.models.visit import Visit - if data.status == "计划中": - existing_visit = await db.execute( - select(Visit.id).where( - Visit.customer_id == data.customer_id, - Visit.visit_date >= plan_date, - ).limit(1) - ) - if existing_visit.scalar_one_or_none(): - data.status = "已完成" - wp = WorkPlan( customer_id=data.customer_id, plan_content=data.plan_content, - plan_date=plan_date, - manager_id=uuid.UUID(current_user["user_id"]), + plan_date=parse_date(data.plan_date), + 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"]) - if data.status == "已完成": - append_entry(wp, current_user["name"], [{"field": "status", "from": "计划中", "to": "已完成", "reason": "已有拜访记录,自动完成"}]) db.add(wp) await db.commit() await db.refresh(wp) - # Audit log - cust = await db.execute(select(Customer.name).where(Customer.id == wp.customer_id)) - await log_audit(db, "work_plan", wp.id, f"{cust.scalar_one_or_none() or ''} ({wp.plan_date})", "create", current_user["user_id"], current_user["name"]) - await db.commit() - return await _enrich(wp, db) - - -@router.get("/{plan_id}") -async def get_work_plan(plan_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user)): - result = await db.execute(select(WorkPlan).where(WorkPlan.id == plan_id)) - wp = result.scalar_one_or_none() - if not wp: - raise HTTPException(status_code=404, detail="Not found") - if current_user["role"] == "manager" and str(wp.manager_id) != current_user["user_id"]: - raise HTTPException(status_code=403, detail="Access denied") return await _enrich(wp, db) @@ -115,10 +106,10 @@ 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"]) - 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(wp, k, v) new_snapshot = {"customer_id": str(wp.customer_id), "plan_content": wp.plan_content, "plan_date": str(wp.plan_date), "status": wp.status} @@ -127,10 +118,6 @@ async def update_work_plan( append_entry(wp, current_user["name"], changes, getattr(data, "edit_reason", None)) await db.commit() await db.refresh(wp) - # Audit log - cust = await db.execute(select(Customer.name).where(Customer.id == wp.customer_id)) - await log_audit(db, "work_plan", wp.id, f"{cust.scalar_one_or_none() or ''} ({wp.plan_date})", "update", current_user["user_id"], current_user["name"]) - await db.commit() return await _enrich(wp, db) @@ -146,11 +133,6 @@ async def delete_work_plan( 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") - cust = await db.execute(select(Customer.name).where(Customer.id == wp.customer_id)) - entity_name = f"{cust.scalar_one_or_none() or ''} ({wp.plan_date})" await db.delete(wp) await db.commit() - # Audit log - await log_audit(db, "work_plan", uuid.UUID(plan_id), entity_name, "delete", current_user["user_id"], current_user["name"]) - await db.commit() return {"detail": "deleted"} diff --git a/backend/app/main.py b/backend/app/main.py index bb1c64f..d0a71ec 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,7 +5,7 @@ from sqlalchemy import select 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 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 @@ -63,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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8d19a5a..5391100 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -11,6 +11,7 @@ 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", @@ -20,6 +21,7 @@ __all__ = [ "Visit", "WorkPlan", "MiniBusiness", + "MiniBusinessLog", "KeyVisit", "DailyNote", "AISummary", diff --git a/backend/app/models/customer.py b/backend/app/models/customer.py index 1e6a654..990b6f6 100644 --- a/backend/app/models/customer.py +++ b/backend/app/models/customer.py @@ -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="") diff --git a/backend/app/models/mini_business.py b/backend/app/models/mini_business.py index fb242dd..1bf7d9e 100644 --- a/backend/app/models/mini_business.py +++ b/backend/app/models/mini_business.py @@ -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") diff --git a/backend/app/models/mini_business_log.py b/backend/app/models/mini_business_log.py new file mode 100644 index 0000000..0d9bccf --- /dev/null +++ b/backend/app/models/mini_business_log.py @@ -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") diff --git a/backend/app/schemas/customer.py b/backend/app/schemas/customer.py index d779751..a5f8e72 100644 --- a/backend/app/schemas/customer.py +++ b/backend/app/schemas/customer.py @@ -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 diff --git a/backend/app/schemas/daily_note.py b/backend/app/schemas/daily_note.py index 1664ada..3ce960b 100644 --- a/backend/app/schemas/daily_note.py +++ b/backend/app/schemas/daily_note.py @@ -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): diff --git a/backend/app/schemas/key_visit.py b/backend/app/schemas/key_visit.py index 3b5f8ec..91063cc 100644 --- a/backend/app/schemas/key_visit.py +++ b/backend/app/schemas/key_visit.py @@ -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,7 +22,7 @@ class KeyVisitUpdate(BaseModel): planned_date: Optional[str] = None planned_visitor: Optional[str] = None visit_target: Optional[str] = None - manager_id: Optional[str] = None + manager_id: Optional[uuid.UUID] = None class KeyVisitOut(BaseModel): diff --git a/backend/app/schemas/mini_business.py b/backend/app/schemas/mini_business.py index 4ae03ef..0da30e7 100644 --- a/backend/app/schemas/mini_business.py +++ b/backend/app/schemas/mini_business.py @@ -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,7 +20,7 @@ class MiniBusinessUpdate(BaseModel): follow_up_detail: Optional[str] = None status: Optional[str] = None expected_revenue_date: Optional[str] = None - manager_id: Optional[str] = None + manager_id: Optional[uuid.UUID] = None class MiniBusinessOut(BaseModel): diff --git a/backend/app/schemas/mini_business_log.py b/backend/app/schemas/mini_business_log.py new file mode 100644 index 0000000..b14125a --- /dev/null +++ b/backend/app/schemas/mini_business_log.py @@ -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} diff --git a/backend/app/schemas/visit.py b/backend/app/schemas/visit.py index e101abb..853352d 100644 --- a/backend/app/schemas/visit.py +++ b/backend/app/schemas/visit.py @@ -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,6 +29,7 @@ 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 diff --git a/backend/app/schemas/work_plan.py b/backend/app/schemas/work_plan.py index 87e6e43..5b10799 100644 --- a/backend/app/schemas/work_plan.py +++ b/backend/app/schemas/work_plan.py @@ -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,7 +17,7 @@ class WorkPlanUpdate(BaseModel): plan_content: Optional[str] = None plan_date: Optional[str] = None status: Optional[str] = None - manager_id: Optional[str] = None + manager_id: Optional[uuid.UUID] = None class WorkPlanOut(BaseModel): diff --git a/backend/app/services/dashboard.py b/backend/app/services/dashboard.py index fc9f9cc..f0acecc 100644 --- a/backend/app/services/dashboard.py +++ b/backend/app/services/dashboard.py @@ -132,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) ) @@ -173,9 +177,13 @@ 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()) diff --git a/backend/app/services/excel_import.py b/backend/app/services/excel_import.py index 5cad36b..646c7d5 100644 --- a/backend/app/services/excel_import.py +++ b/backend/app/services/excel_import.py @@ -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): diff --git a/backend/app/services/light_board.py b/backend/app/services/light_board.py index 7e0d234..8b29c59 100644 --- a/backend/app/services/light_board.py +++ b/backend/app/services/light_board.py @@ -9,7 +9,7 @@ Status (rolling 30-day window): from datetime import date, timedelta from uuid import UUID -from sqlalchemy import select, and_ +from sqlalchemy import select, func, and_ from sqlalchemy.ext.asyncio import AsyncSession from app.models.user import User from app.models.customer import Customer @@ -85,29 +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). - # DISTINCT ON keeps exactly one row per pair even when multiple visits - # share the same latest date (e.g. companion/collaborative visits), - # preventing fan-out that would duplicate customer cards on the board. + # ── 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, - Visit.visit_date.label("last_visit_date"), - Visit.visit_method.label("last_visit_method"), - ) - .distinct(Visit.manager_id, Visit.customer_id) - .order_by( - Visit.manager_id, - Visit.customer_id, - Visit.visit_date.desc(), - Visit.id.desc(), + combined.c.manager_id, + combined.c.customer_id, + combined.c.visit_date.label("last_visit_date"), + combined.c.visit_method, ) + .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 (date + method) + # Join: assignments → customers → latest_visit rows = await db.execute( select( CustomerAssignment.manager_id, @@ -118,7 +123,7 @@ async def get_light_board( Customer.in_use_services, Customer.monthly_fee, latest_visit.c.last_visit_date, - latest_visit.c.last_visit_method, + latest_visit.c.visit_method, ) .join(Customer, Customer.id == CustomerAssignment.customer_id) .outerjoin(latest_visit, and_( diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index 2d237f4..8333dae 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -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), } diff --git a/backend/app/services/visits.py b/backend/app/services/visits.py new file mode 100644 index 0000000..9808483 --- /dev/null +++ b/backend/app/services/visits.py @@ -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 diff --git a/docs/superpowers/plans/2026-07-13-dark-mode.md b/docs/superpowers/plans/2026-07-13-dark-mode.md new file mode 100644 index 0000000..52308fe --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-dark-mode.md @@ -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 三项 +- 暗色模式时 `` 同时设置 `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 = { + 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((localStorage.getItem('theme-mode') as Mode) || 'light') + const lightTheme = ref((localStorage.getItem('theme-light') as Theme) || 'editorial') + const darkTheme = ref((localStorage.getItem('theme-dark') as Theme) || 'tech-dark') + + const effectiveTheme = computed(() => { + 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 + +``` + +- [ ] **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 + + + +
+
+ ☀️ 亮色模式主题 + + + + 当前 +
+
+ 🌙 暗色模式主题 + + + + 当前 +
+
+
+``` + +**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: 暗黑模式最终验证通过" +``` diff --git a/docs/superpowers/specs/2026-07-12-mini-business-logs-design.md b/docs/superpowers/specs/2026-07-12-mini-business-logs-design.md new file mode 100644 index 0000000..46f2377 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-mini-business-logs-design.md @@ -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` | diff --git a/docs/superpowers/specs/2026-07-12-work-plan-auto-status-design.md b/docs/superpowers/specs/2026-07-12-work-plan-auto-status-design.md new file mode 100644 index 0000000..6af49ed --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-work-plan-auto-status-design.md @@ -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. 逾期但有拜访的计划 → 保持"计划中"(已有拜访覆盖,不算僵尸) diff --git a/frontend/index.html b/frontend/index.html index d4fac6b..e642704 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,6 +6,14 @@ 企迹 - 政企周报管理系统 + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5ed514d..2dda29e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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; +} diff --git a/frontend/src/api/miniBusiness.ts b/frontend/src/api/miniBusiness.ts index 7c12684..11112d7 100644 --- a/frontend/src/api/miniBusiness.ts +++ b/frontend/src/api/miniBusiness.ts @@ -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}`) + }, } diff --git a/frontend/src/components/DesktopLayout.vue b/frontend/src/components/DesktopLayout.vue index adc11ee..1f9b27f 100644 --- a/frontend/src/components/DesktopLayout.vue +++ b/frontend/src/components/DesktopLayout.vue @@ -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,13 +44,15 @@ const menuGroups = computed(() => { label: '管理', items: [ { path: '/customers', label: '客户管理', icon: '' }, + { path: '/settings', label: '系统设置', icon: '' }, ], }) if (auth.isDirector) { + groups[groups.length - 1].items.splice(1, 0, + { path: '/users', label: '用户管理', icon: '' }, + ) groups[groups.length - 1].items.push( { path: '/audit-log', label: '操作日志', icon: '' }, - { path: '/users', label: '用户管理', icon: '' }, - { path: '/settings', label: '系统设置', icon: '' }, ) } return groups @@ -63,8 +65,7 @@ const roleLabel = computed(() => { }) function cycleTheme() { - const next = themeStore.currentTheme === 'editorial' ? 'light' : 'editorial' - themeStore.setTheme(next) + themeStore.toggleMode() } @@ -109,11 +110,11 @@ function cycleTheme() {
-
+ +
+
+ + +
+
+ + +
+ + + + + + + + + + + +
暂无拜访记录
+
+ + + + + + + + +
暂无工作计划
+
+ + + + + + + + + +
暂无商机记录
+
+ + + + + + + + +
暂不要客记录
+
+
+
+ + + + + + + + + + + + + + @@ -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; } diff --git a/frontend/src/views/desktop/Dashboard.vue b/frontend/src/views/desktop/Dashboard.vue index 88c8012..8c91ddc 100644 --- a/frontend/src/views/desktop/Dashboard.vue +++ b/frontend/src/views/desktop/Dashboard.vue @@ -427,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); } diff --git a/frontend/src/views/desktop/KeyVisits.vue b/frontend/src/views/desktop/KeyVisits.vue index 3bfb9a4..f737330 100644 --- a/frontend/src/views/desktop/KeyVisits.vue +++ b/frontend/src/views/desktop/KeyVisits.vue @@ -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([]) const customers = ref([]) +const allManagers = ref([]) const shotRef = ref(null) const { capturing, captureEl } = useScreenshot() const allUsers = ref([]) @@ -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() @@ -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) {
+
+ +
@@ -269,9 +305,20 @@ async function quickStatusChange(row: any, newStatus: string) { - - + + + + + + + + @@ -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; } diff --git a/frontend/src/views/desktop/Leaves.vue b/frontend/src/views/desktop/Leaves.vue index 480ba81..76c43a7 100644 --- a/frontend/src/views/desktop/Leaves.vue +++ b/frontend/src/views/desktop/Leaves.vue @@ -27,7 +27,7 @@ const leaveTypeColors: Record = { 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" /> @@ -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; } diff --git a/frontend/src/views/desktop/LightBoard.vue b/frontend/src/views/desktop/LightBoard.vue index eabf611..b64c923 100644 --- a/frontend/src/views/desktop/LightBoard.vue +++ b/frontend/src/views/desktop/LightBoard.vue @@ -141,6 +141,11 @@ const statusLabel: Record = { green: '近30天已拜访', yellow {{ expandedManagers.has(m.manager_id) ? '▼' : '▶' }} {{ m.manager_name }} {{ m.total_customers }} 个客户 + + 已拜访{{ m.visited_this_month }} · + 未拜访{{ m.not_visited_2months }} · + 完成率{{ (m.coverage_rate * 100).toFixed(0) }}% +
{{ m.visited_this_month }} @@ -304,6 +309,8 @@ const statusLabel: Record = { 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 = { 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; } diff --git a/frontend/src/views/desktop/ManagerWorkspace.vue b/frontend/src/views/desktop/ManagerWorkspace.vue index ce8c0b1..09af9e5 100644 --- a/frontend/src/views/desktop/ManagerWorkspace.vue +++ b/frontend/src/views/desktop/ManagerWorkspace.vue @@ -13,12 +13,29 @@ const auth = useAuthStore() const activeTab = ref('visits') const loading = ref(false) +const allManagers = ref([]) + +// Date range filter +const dateRange = ref(null) +const searchText = ref('') +const pageSize = ref(25) const visits = ref([]) const workPlans = ref([]) const miniBusiness = ref([]) const dailyNotes = ref([]) const keyVisits = ref([]) + +// Pagination per tab +const page = ref>({ visits: 1, notes: 1, plans: 1, mini: 1, key: 1 }) +const total = ref>({ 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([]) const allUsers = ref([]) const plannedVisitors = ref([]) @@ -43,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() { @@ -57,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 } @@ -66,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) { @@ -94,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 } @@ -294,16 +364,24 @@ const notesByDate = computed(() => { @@ -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; } diff --git a/frontend/src/views/desktop/Settings.vue b/frontend/src/views/desktop/Settings.vue index 7913b2b..d826f01 100644 --- a/frontend/src/views/desktop/Settings.vue +++ b/frontend/src/views/desktop/Settings.vue @@ -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([]) const selectedUserIds = ref([]) @@ -165,15 +167,30 @@ async function handleImportPreview() {
- +
+ 模式 + +
+
+ +
- - + @@ -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); } diff --git a/frontend/src/views/desktop/WorkPlans.vue b/frontend/src/views/desktop/WorkPlans.vue index e4a1b0b..d21a898 100644 --- a/frontend/src/views/desktop/WorkPlans.vue +++ b/frontend/src/views/desktop/WorkPlans.vue @@ -14,6 +14,7 @@ const themeStore = useThemeStore() const loading = ref(false) const workPlans = ref([]) const customers = ref([]) +const allManagers = ref([]) const shotRef = ref(null) const { capturing, captureEl } = useScreenshot() @@ -49,8 +50,11 @@ const filterStatus = ref('') const expandedSections = ref>({ thisWeek: true, nextWeek: true, thisMonth: false, later: false, archived: false, }) +const searchText = ref('') -const isLight = computed(() => themeStore.currentTheme === 'light') +function onSearch() { loadPlans() } + +const isLight = computed(() => themeStore.mode === 'light') const statusColors: Record = { '计划中': '#4A6741', @@ -128,17 +132,69 @@ const managerSummary = computed(() => { function resetFilters() { filterManager.value = ''; filterStatus.value = '' } -onMounted(async () => { await Promise.all([loadPlans(), loadCustomers(), loadManagerColors()]) }) +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 loadPlans() { loading.value = true; try { const res = await api.get('/work-plans/'); workPlans.value = res.data } catch (_) {} finally { loading.value = false } } -function openCreate() { dialogMode.value = 'create'; form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中' }; dialogVisible.value = true } -function openEdit(item: any) { dialogMode.value = 'edit'; form.value = { ...item }; 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 }) }; dialogVisible.value = true } +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 loadPlans() { + loading.value = true + try { + 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 } +} + +function openCreate() { + dialogMode.value = 'create' + form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') } + dialogVisible.value = true +} + +function openEdit(item: any) { + dialogMode.value = 'edit' + form.value = { ...item } + 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 }) + } + dialogVisible.value = true +} 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) @@ -190,8 +246,21 @@ async function handleScreenshot() { const d = new Date().toISOString().slice(0,
-
-
+
+ +
+
+ + + + +
+
+ + + + +
重置 {{ workPlans.length }} 条
@@ -246,10 +315,35 @@ async function handleScreenshot() { const d = new Date().toISOString().slice(0, - - - - + + + + + + + + + + + + + + + + + + + + + + + + @@ -306,4 +400,9 @@ async function handleScreenshot() { const d = new Date().toISOString().slice(0, /* ── 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; } diff --git a/frontend/src/views/mobile/DailyNoteForm.vue b/frontend/src/views/mobile/DailyNoteForm.vue index e529a12..df0750a 100644 --- a/frontend/src/views/mobile/DailyNoteForm.vue +++ b/frontend/src/views/mobile/DailyNoteForm.vue @@ -121,6 +121,13 @@ async function handleDelete() { + + + + + + +