feat: 操作日志系统 + 日期修复 + 协同去重 + 主题持久化 + 计划自动完成

本次累积提交包含以下功能:

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 12:01:14 +08:00
parent 9b5805447e
commit 2187be7aeb
35 changed files with 839 additions and 80 deletions
+80
View File
@@ -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}
+2
View File
@@ -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(
+43
View File
@@ -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),
}
}
+11
View File
@@ -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"}
+25
View File
@@ -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"}
+3
View File
@@ -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="请假记录不存在")
+25
View File
@@ -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"}
+31 -1
View File
@@ -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}
+46 -7
View File
@@ -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"}
+42 -1
View File
@@ -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"}
+2 -1
View File
@@ -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")
+21
View File
@@ -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())
+1
View File
@@ -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())
+1
View File
@@ -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())
+1
View File
@@ -9,6 +9,7 @@ class TokenResponse(BaseModel):
user_id: str
name: str
role: str
theme: str = "editorial"
class WecomLoginRequest(BaseModel):
+1
View File
@@ -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
+1
View File
@@ -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",
})
+44 -22
View File
@@ -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 [],
"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, ""),
"edit_log": v.edit_log or [],
"created_at": str(v.created_at),
})
"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)
+14
View File
@@ -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
+30
View File
@@ -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
@@ -48,6 +48,7 @@ const menuGroups = computed<MenuGroup[]>(() => {
})
if (auth.isDirector) {
groups[groups.length - 1].items.push(
{ path: '/audit-log', label: '操作日志', icon: '<polyline points="9 11 12 14 22 4"></polyline><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>' },
{ path: '/users', label: '用户管理', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path>' },
{ path: '/settings', label: '系统设置', icon: '<circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>' },
)
+23
View File
@@ -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(() => {
<div class="header-rule"></div>
</div>
<div class="header-actions">
<button class="header-pc-btn" @click="cycleTheme" :title="'切换至' + themeLabels[themeStore.currentTheme === 'editorial' ? 'light' : 'editorial']">
<svg v-if="themeStore.currentTheme === 'editorial'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
<button class="header-pc-btn" @click="router.push('/')" title="PC版">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
+10
View File
@@ -35,13 +35,22 @@ const router = createRouter({
component: () => import('@/components/MobileLayout.vue'),
children: [
{ path: '', name: 'MobileHome', component: () => import('@/views/mobile/Home.vue') },
{ path: 'work', name: 'WorkCenter', component: () => import('@/views/mobile/WorkCenter.vue') },
{ path: 'visit/new', name: 'VisitForm', component: () => import('@/views/mobile/VisitForm.vue') },
{ path: 'visit/:id/edit', name: 'VisitEdit', component: () => import('@/views/mobile/VisitForm.vue') },
{ path: 'work-plan/new', name: 'WorkPlanForm', component: () => import('@/views/mobile/WorkPlanForm.vue') },
{ path: 'work-plan/:id/edit', name: 'WorkPlanEdit', component: () => import('@/views/mobile/WorkPlanForm.vue') },
{ path: 'mini-biz/new', name: 'MiniBusinessForm', component: () => import('@/views/mobile/MiniBusinessForm.vue') },
{ path: 'mini-biz/:id/edit', name: 'MiniBusinessEdit', component: () => import('@/views/mobile/MiniBusinessForm.vue') },
{ path: 'key-visit/new', name: 'KeyVisitForm', component: () => import('@/views/mobile/KeyVisitForm.vue') },
{ path: 'key-visit/:id/edit', name: 'KeyVisitEdit', component: () => import('@/views/mobile/KeyVisitForm.vue') },
{ path: 'note/new', name: 'DailyNoteForm', component: () => import('@/views/mobile/DailyNoteForm.vue') },
{ path: 'note/:id/edit', name: 'DailyNoteEdit', component: () => import('@/views/mobile/DailyNoteForm.vue') },
{ path: 'visits', name: 'VisitsList', component: () => import('@/views/mobile/VisitsList.vue') },
{ path: 'notes', name: 'NotesList', component: () => import('@/views/mobile/NotesList.vue') },
{ path: 'plans', name: 'PlansList', component: () => import('@/views/mobile/PlansList.vue') },
{ path: 'mini-biz', name: 'MiniBizList', component: () => import('@/views/mobile/MiniBizList.vue') },
{ path: 'key-visits', name: 'KeyVisitsList', component: () => import('@/views/mobile/KeyVisitsList.vue') },
{ path: 'leaves', name: 'LeavesList', component: () => import('@/views/mobile/LeavesList.vue') },
{ path: 'leave/new', name: 'LeaveForm', component: () => import('@/views/mobile/LeaveForm.vue') },
{ path: 'leave/:id/edit', name: 'LeaveEdit', component: () => import('@/views/mobile/LeaveForm.vue') },
@@ -62,6 +71,7 @@ const router = createRouter({
{ path: 'workspace', name: 'ManagerWorkspace', component: () => import('@/views/desktop/ManagerWorkspace.vue') },
{ path: 'customers', name: 'CustomerManage', component: () => import('@/views/desktop/CustomerManage.vue') },
{ path: 'users', name: 'UserManage', component: () => import('@/views/desktop/UserManage.vue') },
{ path: 'audit-log', name: 'AuditLog', component: () => import('@/views/desktop/AuditLog.vue') },
{ path: 'settings', name: 'Settings', component: () => import('@/views/desktop/Settings.vue') },
],
},
+5 -2
View File
@@ -7,21 +7,24 @@ export const useAuthStore = defineStore('auth', () => {
const userId = ref(localStorage.getItem('userId') || '')
const name = ref(localStorage.getItem('userName') || '')
const role = ref(localStorage.getItem('userRole') || '')
const theme = ref(localStorage.getItem('theme') || 'editorial')
const isLoggedIn = computed(() => !!token.value)
const isManager = computed(() => role.value === 'manager')
const isDirector = computed(() => role.value === 'director')
const isLeader = computed(() => role.value === 'leader')
function saveLogin(data: { access_token: string; user_id: string; name: string; role: string }) {
function saveLogin(data: { access_token: string; user_id: string; name: string; role: string; theme?: string }) {
token.value = data.access_token
userId.value = data.user_id
name.value = data.name
role.value = data.role
if (data.theme) theme.value = data.theme
localStorage.setItem('token', data.access_token)
localStorage.setItem('userId', data.user_id)
localStorage.setItem('userName', data.name)
localStorage.setItem('userRole', data.role)
if (data.theme) localStorage.setItem('theme', data.theme)
}
function logout() {
@@ -52,7 +55,7 @@ export const useAuthStore = defineStore('auth', () => {
}
return {
token, userId, name, role,
token, userId, name, role, theme,
isLoggedIn, isManager, isDirector, isLeader,
saveLogin, logout, casdoorLogin, wecomLogin, bindWecom,
}
+6 -1
View File
@@ -17,10 +17,15 @@ export const useThemeStore = defineStore('theme', () => {
document.documentElement.setAttribute('data-theme', theme)
}
function setTheme(theme: Theme) {
async function setTheme(theme: Theme) {
currentTheme.value = theme
localStorage.setItem('theme', theme)
applyTheme(theme)
// Sync to backend (fire-and-forget)
try {
const { default: api } = await import('@/api/index')
await api.put('/users/me/theme', { theme })
} catch (_) { /* non-critical */ }
}
// Apply on init
+13 -2
View File
@@ -9,6 +9,17 @@ const route = useRoute()
const auth = useAuthStore()
const loading = ref(false)
function isMobileDevice(): boolean {
const ua = navigator.userAgent || ''
return /Android|iPhone|iPad|iPod|webOS/i.test(ua) || window.innerWidth < 768
}
function getHomePath(): string {
// 客户经理永远走移动端;支局长/分管领导根据设备自动适配
if (auth.isManager) return '/m'
return isMobileDevice() ? '/m' : '/'
}
function goCasdoorLogin() {
const returnUrl = (route.query.redirect as string) || ''
if (returnUrl) {
@@ -32,7 +43,7 @@ onMounted(async () => {
const returnUrl = sessionStorage.getItem('login_return_url')
if (returnUrl) { sessionStorage.removeItem('login_return_url'); router.push(returnUrl); return }
ElMessage.success('登录成功')
router.push(auth.isManager ? '/m' : '/')
router.push(getHomePath())
} catch (e: any) {
ElMessage.error('登录失败: ' + (e.response?.data?.detail || e.message))
} finally {
@@ -60,7 +71,7 @@ onMounted(async () => {
const returnUrl = sessionStorage.getItem('login_return_url')
if (returnUrl) { sessionStorage.removeItem('login_return_url'); router.push(returnUrl); return }
ElMessage.success('登录成功')
router.push(auth.isManager ? '/m' : '/')
router.push(getHomePath())
} catch (e: any) {
ElMessage.error('企微登录失败')
} finally {
+161
View File
@@ -0,0 +1,161 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import api from '@/api/index'
const loading = ref(false)
const items = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(50)
const filterEntityType = ref('')
const filterAction = ref('')
const filterOperatorId = ref('')
const filterDateFrom = ref('')
const filterDateTo = ref('')
const managers = ref<any[]>([])
const entityTypes = [
{ value: '', label: '全部类型' },
{ value: 'customer', label: '客户' },
{ value: 'visit', label: '拜访记录' },
{ value: 'daily_note', label: '今日纪要' },
{ value: 'work_plan', label: '工作计划' },
{ value: 'mini_business', label: '小微商机' },
{ value: 'key_visit', label: '要客拜访' },
{ value: 'user', label: '用户' },
{ value: 'leave', label: '请假' },
]
const actions = [
{ value: '', label: '全部操作' },
{ value: 'create', label: '创建' },
{ value: 'update', label: '更新' },
{ value: 'delete', label: '删除' },
]
const actionColors: Record<string, string> = {
create: '#4A6741',
update: '#5B7FA5',
delete: '#B8472E',
}
const actionLabels: Record<string, string> = {
create: '创建',
update: '更新',
delete: '删除',
}
onMounted(async () => {
try {
const res = await api.get('/users/', { params: { role: 'manager' } })
managers.value = res.data || []
} catch (_) {}
await loadLogs()
})
async function loadLogs() {
loading.value = true
try {
const params: any = { page: page.value, page_size: pageSize.value }
if (filterEntityType.value) params.entity_type = filterEntityType.value
if (filterAction.value) params.action = filterAction.value
if (filterOperatorId.value) params.operator_id = filterOperatorId.value
if (filterDateFrom.value) params.date_from = filterDateFrom.value
if (filterDateTo.value) params.date_to = filterDateTo.value
const res = await api.get('/audit-logs/', { params })
items.value = res.data.items || []
total.value = res.data.total || 0
} catch (e: any) {
ElMessage.error('加载失败: ' + (e.response?.data?.detail || e.message))
} finally {
loading.value = false
}
}
function onSearch() { page.value = 1; loadLogs() }
function onPageChange() { loadLogs() }
function formatTime(s: string): string {
if (!s) return ''
const d = new Date(s)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
</script>
<template>
<div class="audit-log-page" v-loading="loading">
<div class="page-head">
<h2 class="page-title">操作日志</h2>
<span class="page-subtitle">AUDIT LOG</span>
<div class="page-rule"></div>
</div>
<!-- Filters -->
<el-card style="margin-bottom:16px">
<div class="filter-row">
<el-select v-model="filterEntityType" placeholder="实体类型" clearable style="width:120px">
<el-option v-for="e in entityTypes" :key="e.value" :label="e.label" :value="e.value" />
</el-select>
<el-select v-model="filterAction" placeholder="操作" clearable style="width:100px">
<el-option v-for="a in actions" :key="a.value" :label="a.label" :value="a.value" />
</el-select>
<el-select v-model="filterOperatorId" placeholder="操作人" clearable filterable style="width:130px">
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
<el-date-picker v-model="filterDateFrom" type="date" placeholder="开始日期" value-format="YYYY-MM-DD" style="width:140px" />
<el-date-picker v-model="filterDateTo" type="date" placeholder="结束日期" value-format="YYYY-MM-DD" style="width:140px" />
<el-button type="primary" @click="onSearch">查询</el-button>
</div>
</el-card>
<!-- Table -->
<el-card>
<el-table :data="items" stripe size="small" v-column-resize>
<el-table-column label="时间" width="165">
<template #default="{ row }">
<span class="log-time">{{ formatTime(row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column label="操作人" width="90" prop="operator_name" />
<el-table-column label="操作" width="65">
<template #default="{ row }">
<el-tag :color="actionColors[row.action]" effect="dark" size="small" disable-transitions style="border:none;color:#fff">
{{ actionLabels[row.action] }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="类型" width="85">
<template #default="{ row }">
<span class="entity-type">{{ row.entity_type_label }}</span>
</template>
</el-table-column>
<el-table-column label="对象" min-width="250">
<template #default="{ row }">
<span class="log-entity">{{ row.entity_name }}</span>
<span v-if="row.summary" class="log-summary"> {{ row.summary }}</span>
</template>
</el-table-column>
</el-table>
<div v-if="total > pageSize" style="display:flex;justify-content:center;margin-top:16px">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" @current-change="onPageChange" />
</div>
</el-card>
</div>
</template>
<style scoped>
.page-head { margin-bottom: 20px; display: flex; flex-direction: column; gap: 2px; }
.page-title { margin: 0; font-family: var(--font-heading); font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
.page-subtitle { font-family: var(--font-mono); font-size: 9px; color: var(--gold); letter-spacing: 0.2em; }
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
.filter-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.log-time { font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray); }
.entity-type { font-family: var(--font-body); font-size: 12px; color: var(--warm-gray); }
.log-entity { font-family: var(--font-heading); font-size: 13px; color: var(--ink); letter-spacing: 0.03em; }
.log-summary { font-family: var(--font-body); font-size: 12px; color: var(--warm-gray); }
</style>
@@ -2,11 +2,15 @@
import { ref, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { todayStr } from '@/utils'
import { useAuthStore } from '@/stores/auth'
import { getMgrStyle } from '@/utils/managerColor'
import api from '@/api/index'
import { compressImage } from '@/utils/image'
import ImagePreview from '@/components/ImagePreview.vue'
import EditLogPanel from '@/components/EditLogPanel.vue'
const auth = useAuthStore()
const activeTab = ref('visits')
const loading = ref(false)
@@ -238,6 +242,17 @@ function onDialogTimeChange(val: [string, string] | null) {
form.value.time_range = val ? val.join('-') : ''
}
async function onDialogCustomerChange(customerId: string) {
// Only pre-fill for new visit creation (not edit)
if (dialogMode.value !== 'create' || dialogType.value !== 'visit' || !customerId) return
try {
const planRes = await api.get(`/customers/${customerId}/overdue-plans`)
if (planRes.data?.plan?.plan_content) {
form.value.communication_content = planRes.data.plan.plan_content
}
} catch (_) {}
}
// Quick status change
async function quickStatusChange(type: string, row: any, newStatus: string) {
try {
@@ -295,6 +310,11 @@ const notesByDate = computed(() => {
<el-table-column prop="customer_name" label="客户" width="130">
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('visit', row)">{{ row.customer_name }}</el-link></template>
</el-table-column>
<el-table-column v-if="auth.isDirector || auth.isLeader" label="客户经理" width="90">
<template #default="{ row }">
<el-tag :style="getMgrStyle(row.manager_name || '', false)" effect="dark" size="small" disable-transitions>{{ row.manager_name || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="visit_method" label="方式" width="60" />
<el-table-column prop="time_range" label="时间" width="100" />
<el-table-column label="照片" width="60">
@@ -326,6 +346,11 @@ const notesByDate = computed(() => {
</h4>
<el-table :data="items" stripe size="small" v-column-resize>
<el-table-column prop="category" label="分类" width="100"><template #default="{ row }"><el-tag size="small">{{ row.category }}</el-tag></template></el-table-column>
<el-table-column v-if="auth.isDirector || auth.isLeader" label="客户经理" width="90">
<template #default="{ row }">
<el-tag :style="getMgrStyle(row.manager_name || '', false)" effect="dark" size="small" disable-transitions>{{ row.manager_name || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="content" label="工作内容" min-width="250" show-overflow-tooltip>
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('note', row)">{{ row.content }}</el-link></template>
</el-table-column>
@@ -447,13 +472,13 @@ const notesByDate = computed(() => {
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' ' + typeLabel(dialogType)" width="520px">
<el-form label-position="top" v-if="form">
<el-form-item v-if="['visit','plan','mini','key'].includes(dialogType)" label="客户单位">
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%" @change="onDialogCustomerChange">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item v-if="dialogType === 'visit'" label="日期"><el-date-picker v-model="form.visit_date" type="date" style="width:100%" /></el-form-item>
<el-form-item v-if="dialogType === 'note'" label="日期"><el-date-picker v-model="form.note_date" type="date" style="width:100%" /></el-form-item>
<el-form-item v-if="dialogType === 'plan'" label="计划拜访时间"><el-date-picker v-model="form.plan_date" type="date" style="width:100%" /></el-form-item>
<el-form-item v-if="dialogType === 'visit'" label="日期"><el-date-picker v-model="form.visit_date" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
<el-form-item v-if="dialogType === 'note'" label="日期"><el-date-picker v-model="form.note_date" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
<el-form-item v-if="dialogType === 'plan'" label="计划拜访时间"><el-date-picker v-model="form.plan_date" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
<template v-if="dialogType === 'visit'">
<el-form-item label="拜访方式">
<div class="method-grid">
+108 -25
View File
@@ -323,35 +323,50 @@ const notesByDate = computed(() => {
</svg>
{{ date }}
</h4>
<el-table :data="items" stripe style="width:100%" v-column-resize>
<el-table-column prop="customer_name" label="客户" width="120" />
<el-table-column prop="visit_method" label="方式" width="60" />
<el-table-column prop="time_range" label="时间" width="100" />
<el-table-column prop="communication_content" label="沟通内容" min-width="200" show-overflow-tooltip />
<el-table-column prop="customer_demand" label="客户需求" min-width="150" show-overflow-tooltip />
<el-table-column label="相关人员" width="120">
<template #default="{ row }">
<div style="display:flex;flex-wrap:wrap;gap:2px">
<span>{{ row.manager_name }}</span>
<span v-for="n in (row.companion_names_resolved || [])" :key="n" style="color:var(--warm-gray);font-size:12px">, {{ n }}</span>
<!-- Visit cards: merged group visits get participant sections -->
<div v-for="item in items" :key="item.id" class="visit-card">
<div class="visit-card-main">
<div class="visit-card-left">
<span class="visit-card-customer">{{ item.customer_name }}</span>
<span class="visit-card-method">{{ item.visit_method }}</span>
<span v-if="item.time_range" class="visit-card-time">{{ item.time_range }}</span>
</div>
<el-tooltip v-if="row.edit_log?.length > 1" placement="top">
<template #content>最后编辑{{ row.edit_log[row.edit_log.length-1].editor }} · {{ row.edit_log.length-1 }}次修改</template>
<span class="edit-indicator" title="有过修改">🕐</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="照片" width="100">
<template #default="{ row }">
<div class="photo-cell" v-if="row.photos?.length">
<img v-for="key in row.photos.slice(0,2)" :key="key"
<div class="visit-card-right">
<span class="visit-card-mgr">{{ item.manager_name }}</span>
<span v-for="n in (item.companion_names_resolved || [])" :key="n" class="visit-card-companion">, {{ n }}</span>
</div>
</div>
<div class="visit-card-content">
<div class="visit-card-section">
<div class="visit-card-label">沟通内容</div>
<div class="visit-card-text">{{ item.communication_content || '-' }}</div>
</div>
<div v-if="item.customer_demand" class="visit-card-section">
<div class="visit-card-label">客户需求</div>
<div class="visit-card-text">{{ item.customer_demand }}</div>
</div>
<!-- Companion participants -->
<div v-if="item.participants && item.participants.length > 1" class="companion-section">
<div v-for="p in item.participants.filter((p: any) => p.role === 'companion')" :key="p.manager_id" class="companion-row">
<div class="companion-header">
<span class="companion-badge">协同</span>
<span class="companion-name">{{ p.manager_name }}</span>
</div>
<div class="companion-body">
<div class="visit-card-text">{{ p.communication_content || '-' }}</div>
<div v-if="p.customer_demand && p.customer_demand !== item.customer_demand" class="visit-card-text" style="margin-top:4px;color:var(--warm-gray)">需求补充{{ p.customer_demand }}</div>
</div>
</div>
</div>
<!-- Photos -->
<div v-if="item.photos?.length" class="visit-card-photos">
<img v-for="key in item.photos.slice(0,3)" :key="key"
:src="photoUrls[key] || ''" class="mini-thumb"
@click="viewPhoto(photoUrls[key])" />
<span v-if="item.photos.length > 3" class="photo-more">+{{ item.photos.length - 3 }}</span>
</div>
</div>
</div>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
@@ -414,6 +429,74 @@ const notesByDate = computed(() => {
font-family: var(--font-heading);
font-size: 14px; color: var(--ink); letter-spacing: 0.04em;
}
/* ═══ Visit Cards ═══ */
.visit-card {
background: var(--surface);
border: 1px solid var(--warm-border);
margin-bottom: 10px;
transition: border-color 0.2s;
}
.visit-card:hover { border-color: var(--gold); }
.visit-card-main {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--warm-border);
}
.visit-card-left { display: flex; align-items: center; gap: 10px; }
.visit-card-customer {
font-family: var(--font-heading); font-size: 15px; color: var(--ink);
letter-spacing: 0.04em; font-weight: 500;
}
.visit-card-method {
font-family: var(--font-mono); font-size: 11px;
background: var(--paper); padding: 2px 8px; color: var(--warm-gray);
}
.visit-card-time {
font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray);
}
.visit-card-right { display: flex; align-items: center; gap: 2px; }
.visit-card-mgr {
font-family: var(--font-body); font-size: 13px; color: var(--ink);
}
.visit-card-companion {
font-size: 12px; color: var(--warm-gray);
}
.visit-card-content { padding: 14px 16px; }
.visit-card-section { margin-bottom: 10px; }
.visit-card-label {
font-family: var(--font-mono); font-size: 10px; color: var(--gold);
letter-spacing: 0.12em; margin-bottom: 4px; text-transform: uppercase;
}
.visit-card-text {
font-family: var(--font-body); font-size: 14px; color: var(--c-text);
line-height: 1.7; white-space: pre-wrap;
}
/* ═══ Companion Section ═══ */
.companion-section {
margin-top: 12px;
border-top: 1px dashed var(--warm-border);
padding-top: 10px;
}
.companion-row { margin-bottom: 8px; }
.companion-row:last-child { margin-bottom: 0; }
.companion-header {
display: flex; align-items: center; gap: 6px; margin-bottom: 4px;
}
.companion-badge {
font-family: var(--font-mono); font-size: 9px;
background: var(--gold); color: #fff; padding: 1px 6px;
letter-spacing: 0.1em;
}
.companion-name {
font-family: var(--font-body); font-size: 13px; color: var(--ink);
}
.companion-body {
padding-left: 28px;
border-left: 2px solid var(--gold);
margin-left: 6px;
}
.visit-card-photos { display: flex; gap: 4px; margin-top: 8px; align-items: center; }
.photo-more { font-family: var(--font-mono); font-size: 11px; color: var(--warm-gray); }
.photo-cell { display: flex; gap: 4px; }
.mini-thumb { width: 36px; height: 36px; object-fit: cover; cursor: pointer; }
.edit-indicator { font-size: 12px; margin-left: 3px; opacity: 0.5; cursor: help; }
+1 -1
View File
@@ -241,7 +241,7 @@ async function quickStatusChange(row: any, newStatus: string) {
</el-select>
</el-form-item>
<el-form-item label="计划拜访时间">
<el-date-picker v-model="form.plan_date" type="date" style="width:100%" />
<el-date-picker v-model="form.plan_date" type="date" value-format="YYYY-MM-DD" style="width:100%" />
</el-form-item>
<el-form-item label="工作计划">
<el-input v-model="form.plan_content" type="textarea" :rows="4" placeholder="请输入计划内容" />
+1 -1
View File
@@ -111,7 +111,7 @@ async function handleDelete() {
<template #label>
<span class="form-label">日期 <span class="required-star">*</span></span>
</template>
<el-date-picker v-model="form.note_date" type="date" style="width:100%" />
<el-date-picker v-model="form.note_date" type="date" value-format="YYYY-MM-DD" style="width:100%" />
</el-form-item>
<el-form-item v-if="auth.isDirector || auth.isLeader">
+1 -1
View File
@@ -189,7 +189,7 @@ async function handleDelete() {
<el-form-item>
<template #label><span class="form-label">计划拜访时间</span></template>
<el-date-picker v-model="form.planned_date" type="date" placeholder="选择日期" style="width:100%" />
<el-date-picker v-model="form.planned_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width:100%" />
</el-form-item>
<el-form-item>
+2 -2
View File
@@ -124,12 +124,12 @@ async function handleDelete() {
<el-form-item>
<template #label><span class="form-label">开始日期</span></template>
<el-date-picker v-model="form.start_date" type="date" placeholder="选择开始日期" style="width:100%" />
<el-date-picker v-model="form.start_date" type="date" value-format="YYYY-MM-DD" placeholder="选择开始日期" style="width:100%" />
</el-form-item>
<el-form-item>
<template #label><span class="form-label">结束日期</span></template>
<el-date-picker v-model="form.end_date" type="date" placeholder="选择结束日期" style="width:100%" />
<el-date-picker v-model="form.end_date" type="date" value-format="YYYY-MM-DD" placeholder="选择结束日期" style="width:100%" />
</el-form-item>
<el-form-item>
+48 -3
View File
@@ -42,6 +42,8 @@ const previewDialogVisible = ref(false)
const previewImageUrl = ref('')
const uploading = ref(false)
const customerSearch = ref('')
const isCompanionRecord = ref(false)
const companionOf = ref('')
onMounted(async () => {
await loadCustomers()
@@ -76,6 +78,16 @@ onMounted(async () => {
const parts = v.time_range.split('-')
timeRangeValue.value = [parts[0], parts[1]]
}
// Check if this is a companion record
if (v.visit_group_id) {
isCompanionRecord.value = true
// The companions field on a companion record contains the primary creator
const creatorId = (v.companions || [])[0]
if (creatorId) {
const creator = managers.value.find((m: any) => m.id === String(creatorId))
companionOf.value = creator?.name || ''
}
}
for (const key of (v.photos || [])) {
try {
const urlRes = await uploadApi.getDownloadUrl(key)
@@ -117,6 +129,17 @@ async function handleQuickCreate() {
}
}
async function onCustomerChange(customerId: string) {
if (!customerId) return
try {
const res = await api.get(`/customers/${customerId}/overdue-plans`)
if (res.data?.plan?.plan_content) {
form.value.communication_content = res.data.plan.plan_content
form.value.customer_demand = ''
}
} catch (_) {}
}
async function handlePhotoUpload(event: Event) {
const target = event.target as HTMLInputElement
if (!target.files?.length) return
@@ -130,8 +153,7 @@ async function handlePhotoUpload(event: Event) {
try {
// Compress before upload to reduce storage & transfer
const compressed = await compressImage(file, { maxPixels: 1920, quality: 0.8 })
const res = await uploadApi.getPresignedUrl(compressed.name, compressed.type || 'image/jpeg')
await uploadApi.uploadFile(res.data.upload_url, compressed)
const res = await uploadApi.uploadImage(compressed)
uploadedPhotos.value.push(res.data.object_key)
form.value.photos = [...uploadedPhotos.value]
} catch (e: any) {
@@ -223,6 +245,16 @@ async function handleDelete() {
<div class="form-rule"></div>
</header>
<!-- Companion record notification -->
<div v-if="isCompanionRecord" class="companion-notice">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;color:var(--gold)">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
<span>此记录为协同 <strong>{{ companionOf }}</strong> 拜访的副本你可以补充或修改内容不会影响主记录</span>
</div>
<el-form label-position="top" class="editorial-form">
<el-form-item>
<template #label>
@@ -235,6 +267,7 @@ async function handleDelete() {
:remote-method="handleCustomerSearch"
placeholder="搜索/选择客户"
style="width:100%"
@change="onCustomerChange"
>
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
<template #empty>
@@ -263,7 +296,7 @@ async function handleDelete() {
<template #label>
<span class="form-label">拜访日期</span>
</template>
<el-date-picker v-model="form.visit_date" type="date" style="width:100%" />
<el-date-picker v-model="form.visit_date" type="date" value-format="YYYY-MM-DD" style="width:100%" />
</el-form-item>
<el-form-item>
@@ -660,4 +693,16 @@ async function handleDelete() {
.method--微信.method-chip--active { background: #22c55e; border-color: #22c55e; color: #fff; }
.method--出差.method-chip--active { background: #C4934A; border-color: #C4934A; color: #fff; }
.required-star { color: var(--vermilion); font-weight: 700; }
/* ═══ Companion Notice ═══ */
.companion-notice {
display: flex; align-items: flex-start; gap: 10px;
padding: 14px 16px; margin-bottom: 20px;
background: rgba(196,147,74,0.06);
border-left: 3px solid var(--gold);
font-family: var(--font-body);
font-size: 13px; color: var(--ink);
line-height: 1.6; letter-spacing: 0.03em;
}
.companion-notice strong { color: var(--gold-dark); }
</style>
+1 -1
View File
@@ -147,7 +147,7 @@ async function handleDelete() {
<el-form-item>
<template #label><span class="form-label">计划拜访时间 <span class="required-star">*</span></span></template>
<el-date-picker v-model="form.plan_date" type="date" style="width:100%" />
<el-date-picker v-model="form.plan_date" type="date" value-format="YYYY-MM-DD" style="width:100%" />
</el-form-item>
<div class="form-actions">