From 2187be7aeb5034f483901af6ab44da841e52f672 Mon Sep 17 00:00:00 2001 From: v6ole Date: Thu, 30 Jul 2026 12:01:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=93=8D=E4=BD=9C=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=20+=20=E6=97=A5=E6=9C=9F=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20+=20=E5=8D=8F=E5=90=8C=E5=8E=BB=E9=87=8D=20+=20=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E6=8C=81=E4=B9=85=E5=8C=96=20+=20=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次累积提交包含以下功能: 1. 修复:el-date-picker 缺失 value-format 导致日期保存偏移(8处) 2. 新增:协同拜访 visit_group_id 去重机制(完整副本 + 周报合并 + 亮灯去重) 3. 新增:计划自动完成(创建计划检测已有拜访 / 编辑拜访触发) 4. 新增:拜访表单选择客户后自动预填过期计划内容 5. 新增:移动端主题切换按钮 + 后端持久化 6. 新增:操作日志系统 (audit_logs),全覆盖 8 个模块 CRUD 7. 新增:PC端「我的数据」支局长/领导视角客户经理列 Co-Authored-By: Claude --- backend/app/api/audit.py | 80 +++++++++ backend/app/api/auth.py | 2 + backend/app/api/customers.py | 43 +++++ backend/app/api/daily_notes.py | 11 ++ backend/app/api/key_visits.py | 25 +++ backend/app/api/leaves.py | 3 + backend/app/api/mini_business.py | 25 +++ backend/app/api/users.py | 32 +++- backend/app/api/visits.py | 53 +++++- backend/app/api/work_plans.py | 43 ++++- backend/app/main.py | 3 +- backend/app/models/audit_log.py | 21 +++ backend/app/models/user.py | 1 + backend/app/models/visit.py | 1 + backend/app/schemas/user.py | 1 + backend/app/schemas/visit.py | 1 + backend/app/services/auth.py | 1 + backend/app/services/dashboard.py | 70 +++++--- backend/app/services/leaves.py | 14 ++ backend/app/utils/audit.py | 30 ++++ frontend/src/components/DesktopLayout.vue | 1 + frontend/src/components/MobileLayout.vue | 23 +++ frontend/src/router/index.ts | 10 ++ frontend/src/stores/auth.ts | 7 +- frontend/src/stores/theme.ts | 7 +- frontend/src/views/Login.vue | 15 +- frontend/src/views/desktop/AuditLog.vue | 161 ++++++++++++++++++ .../src/views/desktop/ManagerWorkspace.vue | 33 +++- frontend/src/views/desktop/WeeklyReport.vue | 139 ++++++++++++--- frontend/src/views/desktop/WorkPlans.vue | 2 +- frontend/src/views/mobile/DailyNoteForm.vue | 2 +- frontend/src/views/mobile/KeyVisitForm.vue | 2 +- frontend/src/views/mobile/LeaveForm.vue | 4 +- frontend/src/views/mobile/VisitForm.vue | 51 +++++- frontend/src/views/mobile/WorkPlanForm.vue | 2 +- 35 files changed, 839 insertions(+), 80 deletions(-) create mode 100644 backend/app/api/audit.py create mode 100644 backend/app/models/audit_log.py create mode 100644 backend/app/utils/audit.py create mode 100644 frontend/src/views/desktop/AuditLog.vue diff --git a/backend/app/api/audit.py b/backend/app/api/audit.py new file mode 100644 index 0000000..aaabc60 --- /dev/null +++ b/backend/app/api/audit.py @@ -0,0 +1,80 @@ +import uuid +from typing import Optional +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from app.database import get_db +from app.middleware.auth import get_current_user +from app.models.audit_log import AuditLog + +router = APIRouter(prefix="/audit-logs", tags=["AuditLogs"]) + +ENTITY_LABELS: dict[str, str] = { + "customer": "客户", + "visit": "拜访记录", + "daily_note": "今日纪要", + "work_plan": "工作计划", + "mini_business": "小微商机", + "key_visit": "要客拜访", + "user": "用户", + "leave": "请假", +} + + +@router.get("/") +async def list_audit_logs( + entity_type: Optional[str] = Query(None), + action: Optional[str] = Query(None), + operator_id: Optional[str] = Query(None), + date_from: Optional[str] = Query(None), + date_to: Optional[str] = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=10, le=200), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """List audit logs. Only director/leader can access.""" + if current_user["role"] not in ("director", "leader"): + return {"items": [], "total": 0} + + base = select(AuditLog) + count_q = select(func.count(AuditLog.id)) + + if entity_type: + base = base.where(AuditLog.entity_type == entity_type) + count_q = count_q.where(AuditLog.entity_type == entity_type) + if action: + base = base.where(AuditLog.action == action) + count_q = count_q.where(AuditLog.action == action) + if operator_id: + base = base.where(AuditLog.operator_id == uuid.UUID(operator_id)) + count_q = count_q.where(AuditLog.operator_id == uuid.UUID(operator_id)) + if date_from: + base = base.where(AuditLog.created_at >= date_from) + count_q = count_q.where(AuditLog.created_at >= date_from) + if date_to: + base = base.where(AuditLog.created_at < date_to + "T23:59:59") + count_q = count_q.where(AuditLog.created_at < date_to + "T23:59:59") + + total = (await db.execute(count_q)).scalar() or 0 + + query = base.order_by(AuditLog.created_at.desc()).offset((page - 1) * page_size).limit(page_size) + rows = (await db.execute(query)).scalars().all() + + items = [] + for r in rows: + items.append({ + "id": str(r.id), + "entity_type": r.entity_type, + "entity_type_label": ENTITY_LABELS.get(r.entity_type, r.entity_type), + "entity_id": str(r.entity_id), + "entity_name": r.entity_name, + "action": r.action, + "operator_id": str(r.operator_id), + "operator_name": r.operator_name, + "summary": r.summary or "", + "details": r.details, + "created_at": str(r.created_at), + }) + + return {"items": items, "total": total} diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 10f94bd..9dfeb53 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -34,6 +34,7 @@ async def casdoor_login(req: CasdoorLoginRequest, db: AsyncSession = Depends(get user_id=str(user.id), name=user.name, role=user.role, + theme=user.theme or "editorial", ) @@ -56,6 +57,7 @@ async def wecom_login(req: WecomLoginRequest, db: AsyncSession = Depends(get_db) user_id=str(user.id), name=user.name, role=user.role, + theme=user.theme or "editorial", ) raise HTTPException( diff --git a/backend/app/api/customers.py b/backend/app/api/customers.py index d516300..47c21c2 100644 --- a/backend/app/api/customers.py +++ b/backend/app/api/customers.py @@ -20,6 +20,7 @@ from app.models.mini_business import MiniBusiness from app.models.key_visit import KeyVisit from app.models.daily_note import DailyNote from app.models.user import User +from app.utils.audit import log_audit from app.schemas.customer import ( CustomerCreate, CustomerUpdate, CustomerOut, CustomerListOut, CustomerListResponse, ContactCreate, ContactOut, AssignmentCreate, AssignmentOut, BatchAssignRequest, @@ -399,6 +400,10 @@ async def create_customer( )) await db.commit() + # Audit log + await log_audit(db, "customer", customer.id, customer.name, "create", current_user["user_id"], current_user["name"]) + await db.commit() + result = await db.execute( select(Customer).where(Customer.id == customer.id).options(selectinload(Customer.contacts)) ) @@ -470,6 +475,10 @@ async def update_customer( await db.commit() + # Audit log + await log_audit(db, "customer", uuid_mod.UUID(customer_id), customer.name, "update", current_user["user_id"], current_user["name"]) + await db.commit() + result = await db.execute( select(Customer).where(Customer.id == customer.id).options(selectinload(Customer.contacts)) ) @@ -486,8 +495,12 @@ async def delete_customer( customer = result.scalar_one_or_none() if not customer: raise HTTPException(status_code=404, detail="Customer not found") + cust_name = customer.name await db.delete(customer) await db.commit() + # Audit log + await log_audit(db, "customer", uuid_mod.UUID(customer_id), cust_name, "delete", current_user["user_id"], current_user["name"]) + await db.commit() return {"detail": "deleted"} @@ -715,3 +728,33 @@ async def assign_manager( await db.commit() await db.refresh(assignment) return assignment + + +@router.get("/{customer_id}/overdue-plans") +async def get_overdue_plans( + customer_id: str, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Return the latest overdue (plan_date < today) and still '计划中' plan for a customer. + Used by the frontend to pre-fill visit forms with pending plan content.""" + from app.utils.timezone import today_cst + today = today_cst() + result = await db.execute( + select(WorkPlan).where( + WorkPlan.customer_id == uuid_mod.UUID(customer_id), + WorkPlan.status == "计划中", + WorkPlan.plan_date < today, + ).order_by(WorkPlan.plan_date.desc()).limit(1) + ) + plan = result.scalar_one_or_none() + if not plan: + return {"plan": None} + return { + "plan": { + "id": str(plan.id), + "plan_content": plan.plan_content, + "plan_date": str(plan.plan_date), + "manager_id": str(plan.manager_id), + } + } diff --git a/backend/app/api/daily_notes.py b/backend/app/api/daily_notes.py index bf5f227..8014bc3 100644 --- a/backend/app/api/daily_notes.py +++ b/backend/app/api/daily_notes.py @@ -11,6 +11,7 @@ from app.models.user import User from app.schemas.daily_note import DailyNoteCreate, DailyNoteUpdate, DailyNoteOut from app.utils.timezone import today_cst, parse_date from app.utils.edit_log import compute_diff, append_entry, init_entry +from app.utils.audit import log_audit router = APIRouter(prefix="/daily-notes", tags=["DailyNotes"]) @@ -90,6 +91,9 @@ async def create_note( db.add(note) await db.commit() await db.refresh(note) + # Audit log + await log_audit(db, "daily_note", note.id, f"{note.note_date} 纪要", "create", current_user["user_id"], current_user["name"]) + await db.commit() return await _enrich(note, db) @@ -118,6 +122,9 @@ async def update_note( append_entry(note, current_user["name"], changes, getattr(data, "edit_reason", None)) await db.commit() await db.refresh(note) + # Audit log + await log_audit(db, "daily_note", note.id, f"{note.note_date} 纪要", "update", current_user["user_id"], current_user["name"]) + await db.commit() return await _enrich(note, db) @@ -133,6 +140,10 @@ async def delete_note( raise HTTPException(status_code=404, detail="Not found") if current_user["role"] == "manager" and str(note.manager_id) != current_user["user_id"]: raise HTTPException(status_code=403, detail="Access denied") + note_date = str(note.note_date) await db.delete(note) await db.commit() + # Audit log + await log_audit(db, "daily_note", uuid.UUID(note_id), f"{note_date} 纪要", "delete", current_user["user_id"], current_user["name"]) + await db.commit() return {"detail": "deleted"} diff --git a/backend/app/api/key_visits.py b/backend/app/api/key_visits.py index 2544343..79cbf5e 100644 --- a/backend/app/api/key_visits.py +++ b/backend/app/api/key_visits.py @@ -10,6 +10,7 @@ from app.models.customer import Customer from app.models.user import User from app.schemas.key_visit import KeyVisitCreate, KeyVisitUpdate, KeyVisitOut from app.utils.edit_log import compute_diff, append_entry, init_entry +from app.utils.audit import log_audit router = APIRouter(prefix="/key-visits", tags=["KeyVisits"]) @@ -69,6 +70,21 @@ async def create_key_visit( db.add(k) await db.commit() await db.refresh(k) + # Audit log + cust = await db.execute(select(Customer.name).where(Customer.id == k.customer_id)) + await log_audit(db, "key_visit", k.id, f"{cust.scalar_one_or_none() or ''} ({k.urgency_level})", "create", current_user["user_id"], current_user["name"]) + await db.commit() + return await _enrich(k, db) + + +@router.get("/{item_id}") +async def get_key_visit(item_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user)): + result = await db.execute(select(KeyVisit).where(KeyVisit.id == item_id)) + k = result.scalar_one_or_none() + if not k: + raise HTTPException(status_code=404, detail="Not found") + if current_user["role"] == "manager" and str(k.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") return await _enrich(k, db) @@ -95,6 +111,10 @@ async def update_key_visit( append_entry(k, current_user["name"], changes, getattr(data, "edit_reason", None)) await db.commit() await db.refresh(k) + # Audit log + cust = await db.execute(select(Customer.name).where(Customer.id == k.customer_id)) + await log_audit(db, "key_visit", k.id, f"{cust.scalar_one_or_none() or ''} ({k.urgency_level})", "update", current_user["user_id"], current_user["name"]) + await db.commit() return await _enrich(k, db) @@ -110,6 +130,11 @@ async def delete_key_visit( raise HTTPException(status_code=404, detail="Not found") if current_user["role"] == "manager" and str(k.manager_id) != current_user["user_id"]: raise HTTPException(status_code=403, detail="Access denied") + cust = await db.execute(select(Customer.name).where(Customer.id == k.customer_id)) + entity_name = f"{cust.scalar_one_or_none() or ''} ({k.urgency_level})" await db.delete(k) await db.commit() + # Audit log + await log_audit(db, "key_visit", uuid.UUID(item_id), entity_name, "delete", current_user["user_id"], current_user["name"]) + await db.commit() return {"detail": "deleted"} diff --git a/backend/app/api/leaves.py b/backend/app/api/leaves.py index 322c37d..d42a348 100644 --- a/backend/app/api/leaves.py +++ b/backend/app/api/leaves.py @@ -43,6 +43,7 @@ async def create_leave( data=data.model_dump(), submitted_by=UUID(current_user["user_id"]), role=current_user["role"], + operator_name=current_user["name"], ) return { "id": str(leave.id), @@ -74,6 +75,7 @@ async def update_leave( data=data.model_dump(exclude_none=True), user_id=UUID(current_user["user_id"]), role=current_user["role"], + operator_name=current_user["name"], ) return { "id": str(leave.id), @@ -102,6 +104,7 @@ async def delete_leave( leave_id=leave_id, user_id=UUID(current_user["user_id"]), role=current_user["role"], + operator_name=current_user["name"], ) if not ok: raise HTTPException(status_code=404, detail="请假记录不存在") diff --git a/backend/app/api/mini_business.py b/backend/app/api/mini_business.py index 7cb9fc5..bb2830e 100644 --- a/backend/app/api/mini_business.py +++ b/backend/app/api/mini_business.py @@ -10,6 +10,7 @@ from app.models.customer import Customer from app.models.user import User from app.schemas.mini_business import MiniBusinessCreate, MiniBusinessUpdate, MiniBusinessOut from app.utils.edit_log import compute_diff, append_entry, init_entry +from app.utils.audit import log_audit router = APIRouter(prefix="/mini-business", tags=["MiniBusiness"]) @@ -67,6 +68,21 @@ async def create_mini_business( db.add(m) await db.commit() await db.refresh(m) + # Audit log + cust = await db.execute(select(Customer.name).where(Customer.id == m.customer_id)) + await log_audit(db, "mini_business", m.id, f"{cust.scalar_one_or_none() or ''} ({m.product_type})", "create", current_user["user_id"], current_user["name"]) + await db.commit() + return await _enrich(m, db) + + +@router.get("/{item_id}") +async def get_mini_business(item_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user)): + result = await db.execute(select(MiniBusiness).where(MiniBusiness.id == item_id)) + m = result.scalar_one_or_none() + if not m: + raise HTTPException(status_code=404, detail="Not found") + if current_user["role"] == "manager" and str(m.manager_id) != current_user["user_id"]: + raise HTTPException(status_code=403, detail="Access denied") return await _enrich(m, db) @@ -93,6 +109,10 @@ async def update_mini_business( append_entry(m, current_user["name"], changes, getattr(data, "edit_reason", None)) await db.commit() await db.refresh(m) + # Audit log + cust = await db.execute(select(Customer.name).where(Customer.id == m.customer_id)) + await log_audit(db, "mini_business", m.id, f"{cust.scalar_one_or_none() or ''} ({m.product_type})", "update", current_user["user_id"], current_user["name"]) + await db.commit() return await _enrich(m, db) @@ -108,6 +128,11 @@ async def delete_mini_business( raise HTTPException(status_code=404, detail="Not found") if current_user["role"] == "manager" and str(m.manager_id) != current_user["user_id"]: raise HTTPException(status_code=403, detail="Access denied") + cust = await db.execute(select(Customer.name).where(Customer.id == m.customer_id)) + entity_name = f"{cust.scalar_one_or_none() or ''} ({m.product_type})" await db.delete(m) await db.commit() + # Audit log + await log_audit(db, "mini_business", uuid.UUID(item_id), entity_name, "delete", current_user["user_id"], current_user["name"]) + await db.commit() return {"detail": "deleted"} diff --git a/backend/app/api/users.py b/backend/app/api/users.py index 71bd36e..7f65d8c 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -7,6 +7,7 @@ from sqlalchemy import select from app.database import get_db from app.middleware.auth import get_current_user, require_director from app.models.user import User +from app.utils.audit import log_audit from app.schemas.customer import UserOut router = APIRouter(prefix="/users", tags=["Users"]) @@ -78,6 +79,9 @@ async def update_user_role( user.color = data.color await db.commit() await db.refresh(user) + # Audit log + await log_audit(db, "user", uuid.UUID(user_id), user.name, "update", current_user["user_id"], current_user["name"], f"角色更新为 {data.role}") + await db.commit() return { "id": str(user.id), @@ -143,6 +147,32 @@ async def delete_user( if not user: raise HTTPException(status_code=404, detail="User not found") + user_name = user.name await db.delete(user) await db.commit() - return {"detail": "deleted", "name": user.name} + # Audit log + await log_audit(db, "user", uuid.UUID(user_id), user_name, "delete", current_user["user_id"], current_user["name"]) + await db.commit() + return {"detail": "deleted", "name": user_name} + + +class UpdateThemeRequest(BaseModel): + theme: str # editorial / light + + +@router.put("/me/theme") +async def update_my_theme( + data: UpdateThemeRequest, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Update the current user's theme preference.""" + if data.theme not in ("editorial", "light"): + raise HTTPException(status_code=400, detail="Invalid theme") + result = await db.execute(select(User).where(User.id == uuid.UUID(current_user["user_id"]))) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + user.theme = data.theme + await db.commit() + return {"theme": user.theme} diff --git a/backend/app/api/visits.py b/backend/app/api/visits.py index 5158046..c33541b 100644 --- a/backend/app/api/visits.py +++ b/backend/app/api/visits.py @@ -14,6 +14,7 @@ from app.utils.timezone import today_cst, parse_date from app.services.minio_client import delete_objects from app.api.upload import is_owned_upload_key from app.utils.edit_log import compute_diff, append_entry, init_entry +from app.utils.audit import log_audit router = APIRouter(prefix="/visits", tags=["Visits"]) @@ -61,6 +62,7 @@ async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict: "photos": visit.photos, "manager_id": str(visit.manager_id), "manager_name": manager_name, + "visit_group_id": str(visit.visit_group_id) if visit.visit_group_id else None, "edit_log": visit.edit_log or [], "created_at": str(visit.created_at), "updated_at": str(visit.updated_at), @@ -146,8 +148,9 @@ async def create_visit( current_user: dict = Depends(require_any_role), db: AsyncSession = Depends(get_db), ): - """Create a visit record. If companions are selected, creates draft copies for them.""" + """Create a visit record. If companions are selected, creates full copies for them.""" _validate_photo_keys(data.photos, current_user["user_id"]) + group_id = uuid.uuid4() visit = Visit( customer_id=data.customer_id, visit_date=parse_date(data.visit_date), @@ -159,6 +162,7 @@ async def create_visit( companion_names=data.companion_names, photos=data.photos, manager_id=uuid.UUID(current_user["user_id"]), + visit_group_id=group_id, ) init_entry(visit, current_user["name"]) db.add(visit) @@ -184,22 +188,36 @@ async def create_visit( plan.status = "已完成" append_entry(plan, current_user["name"], [{"field": "status", "from": "计划中", "to": "已完成", "reason": "拜访自动完成"}]) - # Create draft copies for companions + # Create full copies for companions (not blank drafts) + creator_name = current_user["name"] for companion_id in data.companions: if companion_id != uuid.UUID(current_user["user_id"]): + # Build companion's companions list: the creator + other companions excluding self + comp_companions = [uuid.UUID(current_user["user_id"])] + for cid in data.companions: + if cid != companion_id and cid not in comp_companions: + comp_companions.append(cid) draft = Visit( customer_id=data.customer_id, visit_date=parse_date(data.visit_date), visit_method=data.visit_method, time_range=data.time_range, - communication_content="", # Leave blank for companion to fill - customer_demand="", - companions=[], - photos=[], + visitor_name=data.visitor_name, + visitor_phone=data.visitor_phone, + communication_content=(data.communication_content or "") + f"(协同{creator_name})", + customer_demand=data.customer_demand, + companions=comp_companions, + companion_names=[], + photos=data.photos, manager_id=companion_id, + visit_group_id=group_id, ) + init_entry(draft, current_user["name"]) db.add(draft) + # Audit log (before commit — part of same transaction) + cust = await db.execute(select(Customer.name).where(Customer.id == visit.customer_id)) + await log_audit(db, "visit", visit.id, f"{cust.scalar_one_or_none() or ''} ({data.visit_date})", "create", current_user["user_id"], current_user["name"]) await db.commit() await db.refresh(visit) return await _enrich_visit(visit, db) @@ -249,6 +267,22 @@ async def update_visit( if changes: append_entry(visit, current_user["name"], changes, getattr(data, "edit_reason", None)) + # Auto-complete matching work plans when visit date or customer changes + from app.models.work_plan import WorkPlan + plans_result = await db.execute( + select(WorkPlan).where( + WorkPlan.customer_id == visit.customer_id, + WorkPlan.status == "计划中", + WorkPlan.plan_date <= visit.visit_date, + ) + ) + for plan in plans_result.scalars().all(): + plan.status = "已完成" + append_entry(plan, current_user["name"], [{"field": "status", "from": "计划中", "to": "已完成", "reason": "拜访更新自动完成"}]) + + # Audit log (before commit — part of same transaction) + cust = await db.execute(select(Customer.name).where(Customer.id == visit.customer_id)) + await log_audit(db, "visit", visit.id, f"{cust.scalar_one_or_none() or ''} ({visit.visit_date})", "update", current_user["user_id"], current_user["name"], ", ".join([c["field"] for c in changes]) if changes else "") await db.commit() await db.refresh(visit) return await _enrich_visit(visit, db) @@ -275,6 +309,10 @@ async def delete_visit( if visit.photos: delete_objects(visit.photos) + # Snapshot entity name before deletion + cust_name_result = await db.execute(select(Customer.name).where(Customer.id == customer_id)) + entity_name = f"{cust_name_result.scalar_one_or_none() or ''} ({visit.visit_date})" + await db.delete(visit) # Recalculate customer's last_visit_date from remaining visits @@ -288,7 +326,6 @@ async def delete_visit( if cust: if new_latest: cust.last_visit_date = new_latest - # Keep the existing manager if date unchanged, or find who made the latest visit latest_visit = await db.execute( select(Visit).where( Visit.customer_id == customer_id, @@ -302,5 +339,7 @@ async def delete_visit( cust.last_visit_date = None cust.last_visit_manager_id = None + # Audit log (before final commit — part of same transaction) + await log_audit(db, "visit", uuid.UUID(visit_id), entity_name, "delete", current_user["user_id"], current_user["name"]) await db.commit() return {"detail": "deleted"} diff --git a/backend/app/api/work_plans.py b/backend/app/api/work_plans.py index 269e42a..6d83fab 100644 --- a/backend/app/api/work_plans.py +++ b/backend/app/api/work_plans.py @@ -8,6 +8,7 @@ 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 @@ -54,17 +55,48 @@ async def create_work_plan( 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=parse_date(data.plan_date), + plan_date=plan_date, manager_id=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) @@ -93,6 +125,10 @@ 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) @@ -108,6 +144,11 @@ 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 5996404..bb1c64f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,7 @@ from app.config import settings, validate_security_settings from app.database import engine, async_session from app.api import router as api_router from app.api import auth, users, customers, visits, work_plans, mini_business, key_visits -from app.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary, system_config, leaves +from app.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary, system_config, leaves, audit from app.models.system_config import SystemConfig from app.services.holidays import refresh_holidays from app.services.scheduler_manager import start_scheduler, shutdown_scheduler @@ -73,6 +73,7 @@ app.include_router(wecom.router, prefix="/api") app.include_router(daily_notes.router, prefix="/api") app.include_router(ai_summary.router, prefix="/api") app.include_router(system_config.router, prefix="/api") +app.include_router(audit.router, prefix="/api") @app.get("/health") diff --git a/backend/app/models/audit_log.py b/backend/app/models/audit_log.py new file mode 100644 index 0000000..86866b9 --- /dev/null +++ b/backend/app/models/audit_log.py @@ -0,0 +1,21 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, DateTime, func +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID, JSONB +from app.database import Base + + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + entity_type: Mapped[str] = mapped_column(String(30)) + entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True)) + entity_name: Mapped[str] = mapped_column(String(200), default="") + action: Mapped[str] = mapped_column(String(10)) # create / update / delete + operator_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True)) + operator_name: Mapped[str] = mapped_column(String(50), default="") + summary: Mapped[str | None] = mapped_column(String(500), nullable=True) + details: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 40e6772..0da3288 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -17,4 +17,5 @@ class User(Base): wecom_userid: Mapped[str | None] = mapped_column(String(100), unique=True, nullable=True) require_report: Mapped[bool] = mapped_column(default=True, server_default="true") color: Mapped[str | None] = mapped_column(String(7), nullable=True) # e.g. "#C62828" + theme: Mapped[str] = mapped_column(String(20), default="editorial", server_default="editorial") # editorial / light created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/visit.py b/backend/app/models/visit.py index 580c278..7e9a4b3 100644 --- a/backend/app/models/visit.py +++ b/backend/app/models/visit.py @@ -22,6 +22,7 @@ class Visit(Base): companion_names: Mapped[list] = mapped_column(ARRAY(Text), default=list) photos: Mapped[list | None] = mapped_column(ARRAY(Text), nullable=True) manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) + visit_group_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True) edit_log: Mapped[list] = mapped_column(JSONB, default=list) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 0803bd8..7f6acbd 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -9,6 +9,7 @@ class TokenResponse(BaseModel): user_id: str name: str role: str + theme: str = "editorial" class WecomLoginRequest(BaseModel): diff --git a/backend/app/schemas/visit.py b/backend/app/schemas/visit.py index 7984e37..68f5a4e 100644 --- a/backend/app/schemas/visit.py +++ b/backend/app/schemas/visit.py @@ -46,6 +46,7 @@ class VisitOut(BaseModel): companion_names: list[str] = [] photos: Optional[list[str]] = None manager_id: uuid.UUID + visit_group_id: Optional[str] = None created_at: datetime updated_at: datetime # Joined fields diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index 581534c..2153b16 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -103,4 +103,5 @@ def build_token_for_user(user: User) -> str: "name": user.name, "role": user.role, "department": user.department, + "theme": user.theme or "editorial", }) diff --git a/backend/app/services/dashboard.py b/backend/app/services/dashboard.py index 821c9e9..8dd3e03 100644 --- a/backend/app/services/dashboard.py +++ b/backend/app/services/dashboard.py @@ -28,7 +28,8 @@ async def get_dashboard_stats(db: AsyncSession, reference_date: date | None = No today = date.today() visits_count = (await db.execute( - select(func.count(Visit.id)).where(Visit.visit_date >= monday, Visit.visit_date <= sunday) + select(func.count(func.distinct(func.coalesce(Visit.visit_group_id, Visit.id)))) + .where(Visit.visit_date >= monday, Visit.visit_date <= sunday) )).scalar() or 0 plans_count = (await db.execute( @@ -171,31 +172,52 @@ async def get_weekly_report( visits_result = await db.execute(visit_query) visits = visits_result.scalars().all() - visits_data = [] + # Group visits by visit_group_id (or id for solo visits) + groups: dict[str, list] = {} for v in visits: - # Resolve companion names: system users → names, external → direct - companion_names_resolved = [user_map.get(c, str(c)) for c in (v.companions or [])] - companion_names_resolved.extend(v.companion_names or []) - visits_data.append({ - "id": str(v.id), - "customer_id": str(v.customer_id), - "customer_name": customer_map.get(v.customer_id, ""), - "visit_date": str(v.visit_date), - "visit_method": v.visit_method, - "time_range": v.time_range, - "visitor_name": v.visitor_name or "", - "visitor_phone": v.visitor_phone or "", - "communication_content": v.communication_content, - "customer_demand": v.customer_demand, - "companions": [str(c) for c in (v.companions or [])], - "companion_names": v.companion_names or [], + gid = str(v.visit_group_id) if v.visit_group_id else str(v.id) + groups.setdefault(gid, []).append(v) + + visits_data = [] + for gid, gvisits in groups.items(): + # Determine the "primary" — first record in group (the creator's) + primary = gvisits[0] + companion_names_resolved = [user_map.get(c, str(c)) for c in (primary.companions or [])] + companion_names_resolved.extend(primary.companion_names or []) + + merged = { + "id": str(primary.id), + "customer_id": str(primary.customer_id), + "customer_name": customer_map.get(primary.customer_id, ""), + "visit_date": str(primary.visit_date), + "visit_method": primary.visit_method, + "time_range": primary.time_range, + "visitor_name": primary.visitor_name or "", + "visitor_phone": primary.visitor_phone or "", + "visit_group_id": gid if len(gvisits) > 1 else None, + "companions": [str(c) for c in (primary.companions or [])], + "companion_names": primary.companion_names or [], "companion_names_resolved": companion_names_resolved, - "photos": v.photos or [], - "manager_id": str(v.manager_id), - "manager_name": user_map.get(v.manager_id, ""), - "edit_log": v.edit_log or [], - "created_at": str(v.created_at), - }) + "communication_content": primary.communication_content, + "customer_demand": primary.customer_demand, + "photos": primary.photos or [], + "manager_id": str(primary.manager_id), + "manager_name": user_map.get(primary.manager_id, ""), + "edit_log": primary.edit_log or [], + "created_at": str(primary.created_at), + "participants": [ + { + "manager_id": str(v.manager_id), + "manager_name": user_map.get(v.manager_id, ""), + "role": "primary" if v is primary else "companion", + "communication_content": v.communication_content, + "customer_demand": v.customer_demand, + "photos": v.photos or [], + } + for v in gvisits + ], + } + visits_data.append(merged) # ── Work Plans ── wp_query = select(WorkPlan) diff --git a/backend/app/services/leaves.py b/backend/app/services/leaves.py index 10e8e13..9d4e64f 100644 --- a/backend/app/services/leaves.py +++ b/backend/app/services/leaves.py @@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.leave import Leave from app.models.user import User from app.utils.timezone import today_cst +from app.utils.audit import log_audit from app.services.dashboard import get_week_range @@ -88,6 +89,7 @@ async def create_leave( data: dict, submitted_by: UUID, role: str, + operator_name: str = "", ) -> Leave: """Create leave. Manager can only create for self; director can create for anyone.""" if role == "manager": @@ -108,6 +110,9 @@ async def create_leave( db.add(leave) await db.commit() await db.refresh(leave) + # Audit log + await log_audit(db, "leave", leave.id, f"{leave.leave_type} ({leave.start_date}~{leave.end_date})", "create", str(submitted_by), operator_name) + await db.commit() return leave @@ -117,6 +122,7 @@ async def update_leave( data: dict, user_id: UUID, role: str, + operator_name: str = "", ) -> Leave: """Update leave. Director can edit any; manager can edit own submitted leaves.""" leave = await get_leave_by_id(db, leave_id) @@ -136,6 +142,9 @@ async def update_leave( await db.commit() await db.refresh(leave) + # Audit log + await log_audit(db, "leave", leave.id, f"{leave.leave_type} ({leave.start_date}~{leave.end_date})", "update", str(user_id), operator_name) + await db.commit() return leave @@ -144,6 +153,7 @@ async def delete_leave( leave_id: UUID, user_id: UUID, role: str, + operator_name: str = "", ) -> bool: """Delete leave. Director can delete any; manager can delete own submitted leaves.""" leave = await get_leave_by_id(db, leave_id) @@ -154,8 +164,12 @@ async def delete_leave( if str(leave.submitted_by) != str(user_id): raise PermissionError("客户经理只能删除自己提交的请假") + entity_name = f"{leave.leave_type} ({leave.start_date}~{leave.end_date})" await db.delete(leave) await db.commit() + # Audit log + await log_audit(db, "leave", leave_id, entity_name, "delete", str(user_id), operator_name) + await db.commit() return True diff --git a/backend/app/utils/audit.py b/backend/app/utils/audit.py new file mode 100644 index 0000000..e84f749 --- /dev/null +++ b/backend/app/utils/audit.py @@ -0,0 +1,30 @@ +"""Audit log utility — write once, query forever.""" +import uuid +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.audit_log import AuditLog + + +async def log_audit( + db: AsyncSession, + entity_type: str, + entity_id: uuid.UUID, + entity_name: str, + action: str, # create / update / delete + operator_id: str, + operator_name: str, + summary: str = "", + details: dict | None = None, +): + """Write an audit log entry.""" + entry = AuditLog( + entity_type=entity_type, + entity_id=entity_id, + entity_name=entity_name, + action=action, + operator_id=uuid.UUID(operator_id), + operator_name=operator_name, + summary=summary or f"{'创建了' if action == 'create' else '更新了' if action == 'update' else '删除了'} {entity_name}", + details=details or {}, + ) + db.add(entry) + # Don't commit here — let the caller handle it as part of their transaction diff --git a/frontend/src/components/DesktopLayout.vue b/frontend/src/components/DesktopLayout.vue index 153b097..adc11ee 100644 --- a/frontend/src/components/DesktopLayout.vue +++ b/frontend/src/components/DesktopLayout.vue @@ -48,6 +48,7 @@ const menuGroups = computed(() => { }) if (auth.isDirector) { groups[groups.length - 1].items.push( + { path: '/audit-log', label: '操作日志', icon: '' }, { path: '/users', label: '用户管理', icon: '' }, { path: '/settings', label: '系统设置', icon: '' }, ) diff --git a/frontend/src/components/MobileLayout.vue b/frontend/src/components/MobileLayout.vue index 70f91f0..f36b89e 100644 --- a/frontend/src/components/MobileLayout.vue +++ b/frontend/src/components/MobileLayout.vue @@ -2,10 +2,17 @@ import { useRoute, useRouter } from 'vue-router' import { computed } from 'vue' import { useAuthStore } from '@/stores/auth' +import { useThemeStore, themeLabels } from '@/stores/theme' const route = useRoute() const router = useRouter() const auth = useAuthStore() +const themeStore = useThemeStore() + +function cycleTheme() { + const next = themeStore.currentTheme === 'editorial' ? 'light' : 'editorial' + themeStore.setTheme(next) +} const tabs = [ { path: '/m', label: '首页', icon: 'home' }, @@ -33,6 +40,22 @@ const activeTab = computed(() => {
+