From bf67e0575f8b4d3151b517e2379d8d75a9aa7d8b Mon Sep 17 00:00:00 2001 From: v6ole Date: Wed, 24 Jun 2026 08:28:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=A1=E6=81=AF=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E9=87=8D=E7=BB=84=20+=20=E5=8F=98=E6=9B=B4=E8=BF=BD=E8=B8=AA?= =?UTF-8?q?=20+=20=E5=9B=BE=E7=89=87=E9=A2=84=E8=A7=88=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 信息架构重组: 周报精简为拜访+纪要两个Tab,工作计划/商机/要客独立为侧边栏「工作」分组下的独立页面 - 侧边栏分组: 汇总/工作/管理三层分组,仪表盘四卡可点击跳转 - 变更追踪(edit_log): 5张表新增JSONB edit_log列,POST创建/PUT diff自动记录,编辑弹窗变更时间轴,表格🕐编辑标记 - 图片预览增强: ImagePreview统一组件,支持适应页面/缩放/拖拽平移/滚轮缩放/键盘快捷键 - 修复客户导入500错误(errors变量未初始化) - 移除工作计划/商机/要客页面冗余编辑按钮 Co-Authored-By: Claude --- CLAUDE.md | 23 +- backend/app/api/customers.py | 1 + backend/app/api/daily_notes.py | 11 +- backend/app/api/key_visits.py | 8 + backend/app/api/mini_business.py | 8 + backend/app/api/visits.py | 22 ++ backend/app/api/work_plans.py | 8 + backend/app/main.py | 5 + backend/app/models/daily_note.py | 3 +- backend/app/models/key_visit.py | 3 +- backend/app/models/mini_business.py | 3 +- backend/app/models/visit.py | 3 +- backend/app/models/work_plan.py | 3 +- backend/app/utils/edit_log.py | 43 +++ frontend/src/components/DesktopLayout.vue | 80 ++++- frontend/src/components/EditLogPanel.vue | 196 +++++++++++ frontend/src/components/ImagePreview.vue | 324 ++++++++++++++++++ frontend/src/router/index.ts | 3 + frontend/src/views/desktop/Dashboard.vue | 10 +- frontend/src/views/desktop/KeyVisits.vue | 217 ++++++++++++ .../src/views/desktop/ManagerWorkspace.vue | 11 +- frontend/src/views/desktop/MiniBusiness.vue | 185 ++++++++++ frontend/src/views/desktop/WeeklyReport.vue | 75 +--- frontend/src/views/desktop/WorkPlans.vue | 183 ++++++++++ frontend/src/views/mobile/DailyNoteForm.vue | 2 + frontend/src/views/mobile/Home.vue | 8 +- frontend/src/views/mobile/VisitForm.vue | 11 +- 27 files changed, 1332 insertions(+), 117 deletions(-) create mode 100644 backend/app/utils/edit_log.py create mode 100644 frontend/src/components/EditLogPanel.vue create mode 100644 frontend/src/components/ImagePreview.vue create mode 100644 frontend/src/views/desktop/KeyVisits.vue create mode 100644 frontend/src/views/desktop/MiniBusiness.vue create mode 100644 frontend/src/views/desktop/WorkPlans.vue diff --git a/CLAUDE.md b/CLAUDE.md index 6161d33..95de0af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,11 +56,11 @@ qiji/ | `customers` | id, name, industry, address, in_use_services, monthly_fee, remarks, created_by | | `customer_contacts` | id, customer_id, name, phone, role_desc | | `customer_assignments` | id, customer_id, manager_id, role(primary/assistant), assigned_by | -| `visits` | id, customer_id, visit_date, visit_method, time_range, visitor_name, visitor_phone, communication_content, customer_demand, companions(UUID[]), photos(TEXT[]), manager_id | -| `daily_notes` | id, manager_id, note_date, category, content, time_range | -| `work_plans` | id, customer_id, plan_content, plan_date, manager_id, status | -| `mini_business` | id, customer_id, product_type, amount, follow_up_detail, status, manager_id | -| `key_visits` | id, customer_id, urgency_level, description, progress_status, planned_date, planned_visitor, visit_target, manager_id | +| `visits` | id, customer_id, visit_date, visit_method, time_range, visitor_name, visitor_phone, communication_content, customer_demand, companions(UUID[]), photos(TEXT[]), manager_id, edit_log(JSONB) | +| `daily_notes` | id, manager_id, note_date, category, content, time_range, edit_log(JSONB) | +| `work_plans` | id, customer_id, plan_content, plan_date, manager_id, status, edit_log(JSONB) | +| `mini_business` | id, customer_id, product_type, amount, follow_up_detail, status, manager_id, expected_revenue_date, edit_log(JSONB) | +| `key_visits` | id, customer_id, urgency_level, description, progress_status, planned_date, planned_visitor, visit_target, manager_id, edit_log(JSONB) | ## 当前进度 @@ -105,6 +105,13 @@ qiji/ - [x] **填报进度权限** — 经理只看到自己,支局长/领导看全员 - [x] **客户经理列** — 暖灰色hash标签,每人唯一颜色 - [x] **列表序号** — 客户管理+用户管理添加序号列 +- [x] **客户导入修复** — 修复 import_customers 500 错误(errors 变量未初始化) +- [x] **图片预览增强** — 适应页面/缩放/拖拽平移 + 底部工具栏(PC端+移动端统一 ImagePreview 组件) +- [x] **信息架构重组** — 周报精简为拜访+纪要两个Tab;工作计划/商机/要客独立为侧边栏「工作」分组下的独立页面;仪表盘四卡可点击跳转 +- [x] **侧边栏分组** — 侧边栏分为「汇总」「工作」「管理」三个分组,JetBrains Mono 标签 +- [x] **变更追踪 (edit_log)** — 5 张表新增 JSONB edit_log 列;POST 初始化创建记录;PUT 自动对比新旧值追加 diff;编辑弹窗底部展示变更时间轴(EditLogPanel.vue);表格被修改过的记录显示 🕐 时钟图标 +- [x] **导入模板增强** — 客户导入失败原因明细显示 +- [x] **操作精简** — 工作计划/商机/要客页面移除冗余"编辑"按钮(点击客户名已可编辑) ### 待完善 @@ -209,3 +216,9 @@ docker-compose up -d 11. **列表操作**: 点击客户名→详情弹窗含编辑/删除按钮,移除表格操作栏 12. **侧边栏**: ink 深色渐变 + SVG 内联图标 + 可折叠(64px) + 金色装饰线 13. **客户经理列**: hash 配色标签,每人唯一颜色(8 色暖灰调) +14. **侧边栏分组**: 汇总(仪表盘/周报)、工作(工作计划/商机跟单/要客拜访)、管理(客户/用户/系统设置)三层分组,JetBrains Mono 标签 +15. **周报精简**: 只保留每日拜访记录+今日纪要两个 Tab,工作计划/商机/要客独立为侧边栏项目 +16. **独立工作页**: 工作计划/商机/要客各自拥有独立页面 + CRUD 弹窗 + 状态快速切换 +17. **变更追踪 (edit_log)**: 5 张业务表均含 JSONB edit_log 列;POST 自动插入创建记录;PUT 自动 diff 新旧值追加变更条目(编辑人+时间+字段级 diff+原因);编辑弹窗底部折叠时间轴面板;表格 🕐 图标标记被修改过的记录 +18. **图片预览**: ImagePreview.vue 统一组件,支持适应页面/缩放(0.25x~5x)/拖拽平移/滚轮缩放/键盘快捷键,PC+移动端一致体验 +19. **仪表盘跳转**: 四张统计卡片可点击跳转到对应的周报/工作计划/商机/要客页面 diff --git a/backend/app/api/customers.py b/backend/app/api/customers.py index cd49212..32ccb6c 100644 --- a/backend/app/api/customers.py +++ b/backend/app/api/customers.py @@ -196,6 +196,7 @@ async def import_customers( ws = wb.active created, updated, skipped = 0, 0, 0 reasons = [] + errors = [] # Build user name → id lookup (all users, not just managers) user_rows = await db.execute(select(User.name, User.id)) diff --git a/backend/app/api/daily_notes.py b/backend/app/api/daily_notes.py index 2f5df98..bf5f227 100644 --- a/backend/app/api/daily_notes.py +++ b/backend/app/api/daily_notes.py @@ -10,6 +10,7 @@ from app.models.daily_note import DailyNote from app.models.user import User from app.schemas.daily_note import DailyNoteCreate, DailyNoteUpdate, DailyNoteOut from app.utils.timezone import today_cst, parse_date +from app.utils.edit_log import compute_diff, append_entry, init_entry router = APIRouter(prefix="/daily-notes", tags=["DailyNotes"]) @@ -20,8 +21,8 @@ async def _enrich(note: DailyNote, db: AsyncSession) -> dict: "id": note.id, "manager_id": note.manager_id, "note_date": note.note_date, "category": note.category, "content": note.content, "time_range": note.time_range, - "created_at": note.created_at, "updated_at": note.updated_at, - "manager_name": mgr.scalar_one_or_none(), + "edit_log": note.edit_log or [], "created_at": note.created_at, + "updated_at": note.updated_at, "manager_name": mgr.scalar_one_or_none(), } @@ -85,6 +86,7 @@ async def create_note( content=data.content, time_range=data.time_range, ) + init_entry(note, current_user["name"]) db.add(note) await db.commit() await db.refresh(note) @@ -104,11 +106,16 @@ async def update_note( if current_user["role"] == "manager" and str(note.manager_id) != current_user["user_id"]: raise HTTPException(status_code=403, detail="Access denied") + old_snapshot = {"note_date": str(note.note_date), "category": note.category, "content": note.content, "time_range": note.time_range} update_data = data.model_dump(exclude_unset=True) if "note_date" in update_data and update_data["note_date"]: update_data["note_date"] = parse_date(update_data["note_date"]) for k, v in update_data.items(): setattr(note, k, v) + new_snapshot = {"note_date": str(note.note_date), "category": note.category, "content": note.content, "time_range": note.time_range} + changes = compute_diff(old_snapshot, new_snapshot) + if changes: + append_entry(note, current_user["name"], changes, getattr(data, "edit_reason", None)) await db.commit() await db.refresh(note) return await _enrich(note, db) diff --git a/backend/app/api/key_visits.py b/backend/app/api/key_visits.py index 2fed5d3..2544343 100644 --- a/backend/app/api/key_visits.py +++ b/backend/app/api/key_visits.py @@ -9,6 +9,7 @@ from app.models.key_visit import KeyVisit from app.models.customer import Customer from app.models.user import User from app.schemas.key_visit import KeyVisitCreate, KeyVisitUpdate, KeyVisitOut +from app.utils.edit_log import compute_diff, append_entry, init_entry router = APIRouter(prefix="/key-visits", tags=["KeyVisits"]) @@ -27,6 +28,7 @@ async def _enrich(k: KeyVisit, db: AsyncSession) -> dict: "planned_visitor": k.planned_visitor, "visit_target": k.visit_target, "manager_id": str(k.manager_id), + "edit_log": k.edit_log or [], "manager_name": mgr.scalar_one_or_none(), } @@ -63,6 +65,7 @@ async def create_key_visit( visit_target=data.visit_target, manager_id=uuid.UUID(current_user["user_id"]), ) + init_entry(k, current_user["name"]) db.add(k) await db.commit() await db.refresh(k) @@ -82,9 +85,14 @@ async def update_key_visit( if current_user["role"] == "manager" and str(k.manager_id) != current_user["user_id"]: raise HTTPException(status_code=403, detail="Access denied") + old_snapshot = {"customer_id": str(k.customer_id), "urgency_level": k.urgency_level, "description": k.description, "progress_status": k.progress_status, "planned_date": k.planned_date, "planned_visitor": k.planned_visitor, "visit_target": k.visit_target} update_data = data.model_dump(exclude_unset=True) for key, v in update_data.items(): setattr(k, key, v) + new_snapshot = {"customer_id": str(k.customer_id), "urgency_level": k.urgency_level, "description": k.description, "progress_status": k.progress_status, "planned_date": k.planned_date, "planned_visitor": k.planned_visitor, "visit_target": k.visit_target} + changes = compute_diff(old_snapshot, new_snapshot) + if changes: + append_entry(k, current_user["name"], changes, getattr(data, "edit_reason", None)) await db.commit() await db.refresh(k) return await _enrich(k, db) diff --git a/backend/app/api/mini_business.py b/backend/app/api/mini_business.py index 95c9c37..7cb9fc5 100644 --- a/backend/app/api/mini_business.py +++ b/backend/app/api/mini_business.py @@ -9,6 +9,7 @@ from app.models.mini_business import MiniBusiness from app.models.customer import Customer from app.models.user import User from app.schemas.mini_business import MiniBusinessCreate, MiniBusinessUpdate, MiniBusinessOut +from app.utils.edit_log import compute_diff, append_entry, init_entry router = APIRouter(prefix="/mini-business", tags=["MiniBusiness"]) @@ -26,6 +27,7 @@ async def _enrich(m: MiniBusiness, db: AsyncSession) -> dict: "status": m.status, "manager_id": str(m.manager_id), "manager_name": mgr.scalar_one_or_none(), + "edit_log": m.edit_log or [], "expected_revenue_date": m.expected_revenue_date, } @@ -61,6 +63,7 @@ async def create_mini_business( manager_id=uuid.UUID(current_user["user_id"]), expected_revenue_date=data.expected_revenue_date, ) + init_entry(m, current_user["name"]) db.add(m) await db.commit() await db.refresh(m) @@ -80,9 +83,14 @@ async def update_mini_business( if current_user["role"] == "manager" and str(m.manager_id) != current_user["user_id"]: raise HTTPException(status_code=403, detail="Access denied") + old_snapshot = {"customer_id": str(m.customer_id), "product_type": m.product_type, "amount": m.amount, "follow_up_detail": m.follow_up_detail, "status": m.status, "expected_revenue_date": m.expected_revenue_date} update_data = data.model_dump(exclude_unset=True) for k, v in update_data.items(): setattr(m, k, v) + new_snapshot = {"customer_id": str(m.customer_id), "product_type": m.product_type, "amount": m.amount, "follow_up_detail": m.follow_up_detail, "status": m.status, "expected_revenue_date": m.expected_revenue_date} + changes = compute_diff(old_snapshot, new_snapshot) + if changes: + append_entry(m, current_user["name"], changes, getattr(data, "edit_reason", None)) await db.commit() await db.refresh(m) return await _enrich(m, db) diff --git a/backend/app/api/visits.py b/backend/app/api/visits.py index 6bc5bb6..a95162e 100644 --- a/backend/app/api/visits.py +++ b/backend/app/api/visits.py @@ -12,6 +12,7 @@ from app.models.user import User from app.schemas.visit import VisitCreate, VisitUpdate, VisitOut, VisitListOut from app.utils.timezone import today_cst, parse_date from app.services.minio_client import delete_objects +from app.utils.edit_log import compute_diff, append_entry, init_entry router = APIRouter(prefix="/visits", tags=["Visits"]) @@ -42,6 +43,7 @@ async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict: "photos": visit.photos, "manager_id": str(visit.manager_id), "manager_name": manager_name, + "edit_log": visit.edit_log or [], "created_at": str(visit.created_at), "updated_at": str(visit.updated_at), } @@ -138,6 +140,7 @@ async def create_visit( photos=data.photos, manager_id=uuid.UUID(current_user["user_id"]), ) + init_entry(visit, current_user["name"]) db.add(visit) # Create draft copies for companions @@ -176,6 +179,14 @@ async def update_visit( if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]: raise HTTPException(status_code=403, detail="Access denied") + # Snapshot old values for diff + old_snapshot = { + "customer_id": str(visit.customer_id), "visit_date": str(visit.visit_date), + "visit_method": visit.visit_method, "time_range": visit.time_range, + "visitor_name": visit.visitor_name or "", "visitor_phone": visit.visitor_phone or "", + "communication_content": visit.communication_content, "customer_demand": visit.customer_demand, + } + update_data = data.model_dump(exclude_unset=True) if "visit_date" in update_data and update_data["visit_date"]: update_data["visit_date"] = parse_date(update_data["visit_date"]) @@ -183,6 +194,17 @@ async def update_visit( for key, value in update_data.items(): setattr(visit, key, value) + # Compute diff and append to edit_log + new_snapshot = { + "customer_id": str(visit.customer_id), "visit_date": str(visit.visit_date), + "visit_method": visit.visit_method, "time_range": visit.time_range, + "visitor_name": visit.visitor_name or "", "visitor_phone": visit.visitor_phone or "", + "communication_content": visit.communication_content, "customer_demand": visit.customer_demand, + } + changes = compute_diff(old_snapshot, new_snapshot) + if changes: + append_entry(visit, current_user["name"], changes, getattr(data, "edit_reason", None)) + await db.commit() await db.refresh(visit) return await _enrich_visit(visit, db) diff --git a/backend/app/api/work_plans.py b/backend/app/api/work_plans.py index 386b0ea..269e42a 100644 --- a/backend/app/api/work_plans.py +++ b/backend/app/api/work_plans.py @@ -7,6 +7,7 @@ from sqlalchemy import select from app.database import get_db from app.middleware.auth import get_current_user, require_any_role from app.utils.timezone import parse_date +from app.utils.edit_log import compute_diff, append_entry, init_entry from app.models.work_plan import WorkPlan from app.models.customer import Customer from app.models.user import User @@ -27,6 +28,7 @@ async def _enrich(wp: WorkPlan, db: AsyncSession) -> dict: "manager_id": str(wp.manager_id), "manager_name": mgr.scalar_one_or_none(), "status": wp.status, + "edit_log": wp.edit_log or [], } @@ -59,6 +61,7 @@ async def create_work_plan( manager_id=uuid.UUID(current_user["user_id"]), status=data.status, ) + init_entry(wp, current_user["name"]) db.add(wp) await db.commit() await db.refresh(wp) @@ -78,11 +81,16 @@ async def update_work_plan( if current_user["role"] == "manager" and str(wp.manager_id) != current_user["user_id"]: raise HTTPException(status_code=403, detail="Access denied") + old_snapshot = {"customer_id": str(wp.customer_id), "plan_content": wp.plan_content, "plan_date": str(wp.plan_date), "status": wp.status} update_data = data.model_dump(exclude_unset=True) if "plan_date" in update_data and update_data["plan_date"]: update_data["plan_date"] = parse_date(update_data["plan_date"]) for k, v in update_data.items(): setattr(wp, k, v) + new_snapshot = {"customer_id": str(wp.customer_id), "plan_content": wp.plan_content, "plan_date": str(wp.plan_date), "status": wp.status} + changes = compute_diff(old_snapshot, new_snapshot) + if changes: + append_entry(wp, current_user["name"], changes, getattr(data, "edit_reason", None)) await db.commit() await db.refresh(wp) return await _enrich(wp, db) diff --git a/backend/app/main.py b/backend/app/main.py index 337be54..baa3458 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -23,6 +23,11 @@ async def lifespan(app: FastAPI): await conn.run_sync(lambda c: c.exec_driver_sql( "ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_phone VARCHAR(20) DEFAULT ''" )) + # edit_log columns for change tracking + for tbl in ["visits", "daily_notes", "work_plans", "mini_business", "key_visits"]: + await conn.run_sync(lambda c, t=tbl: c.exec_driver_sql( + f"ALTER TABLE {t} ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'" + )) yield # Shutdown await engine.dispose() diff --git a/backend/app/models/daily_note.py b/backend/app/models/daily_note.py index 96a1ca7..9a1aa7c 100644 --- a/backend/app/models/daily_note.py +++ b/backend/app/models/daily_note.py @@ -2,7 +2,7 @@ import uuid from datetime import date, datetime from sqlalchemy import String, Text, Date, DateTime, ForeignKey, func from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.dialects.postgresql import UUID, JSONB from app.database import Base @@ -15,5 +15,6 @@ class DailyNote(Base): category: Mapped[str] = mapped_column(String(20), default="其他") # 行政事务/合同整理/发票处理/内部会议/培训学习/其他 content: Mapped[str] = mapped_column(Text, default="") time_range: Mapped[str] = mapped_column(String(30), default="") + 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/models/key_visit.py b/backend/app/models/key_visit.py index b864aef..bf02267 100644 --- a/backend/app/models/key_visit.py +++ b/backend/app/models/key_visit.py @@ -1,7 +1,7 @@ import uuid from sqlalchemy import String, Text, ForeignKey from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.dialects.postgresql import UUID, JSONB from app.database import Base @@ -17,3 +17,4 @@ class KeyVisit(Base): planned_visitor: Mapped[str] = mapped_column(String(100), default="") visit_target: Mapped[str] = mapped_column(String(100), default="") manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) + edit_log: Mapped[list] = mapped_column(JSONB, default=list) diff --git a/backend/app/models/mini_business.py b/backend/app/models/mini_business.py index d9e6b8a..fb242dd 100644 --- a/backend/app/models/mini_business.py +++ b/backend/app/models/mini_business.py @@ -1,7 +1,7 @@ import uuid from sqlalchemy import String, Text, ForeignKey from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.dialects.postgresql import UUID, JSONB from app.database import Base @@ -16,3 +16,4 @@ class MiniBusiness(Base): status: Mapped[str] = mapped_column(String(50), default="跟进中") manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) expected_revenue_date: Mapped[str] = mapped_column(String(50), default="") + edit_log: Mapped[list] = mapped_column(JSONB, default=list) diff --git a/backend/app/models/visit.py b/backend/app/models/visit.py index 48a1798..af1b59f 100644 --- a/backend/app/models/visit.py +++ b/backend/app/models/visit.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, date from sqlalchemy import String, Text, Date, DateTime, ForeignKey, func from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy.dialects.postgresql import UUID, ARRAY +from sqlalchemy.dialects.postgresql import UUID, ARRAY, JSONB from app.database import Base @@ -21,5 +21,6 @@ class Visit(Base): companions: Mapped[list | None] = mapped_column(ARRAY(UUID(as_uuid=True)), nullable=True) photos: Mapped[list | None] = mapped_column(ARRAY(Text), nullable=True) manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) + 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/models/work_plan.py b/backend/app/models/work_plan.py index 883f2ba..d21f192 100644 --- a/backend/app/models/work_plan.py +++ b/backend/app/models/work_plan.py @@ -2,7 +2,7 @@ import uuid from datetime import date from sqlalchemy import String, Text, Date, ForeignKey from sqlalchemy.orm import Mapped, mapped_column -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.dialects.postgresql import UUID, JSONB from app.database import Base @@ -15,3 +15,4 @@ class WorkPlan(Base): plan_date: Mapped[date] = mapped_column(Date, index=True) manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) status: Mapped[str] = mapped_column(String(20), default="计划中") # 计划中/已完成/已取消 + edit_log: Mapped[list] = mapped_column(JSONB, default=list) diff --git a/backend/app/utils/edit_log.py b/backend/app/utils/edit_log.py new file mode 100644 index 0000000..c75d5cf --- /dev/null +++ b/backend/app/utils/edit_log.py @@ -0,0 +1,43 @@ +"""Edit log helpers for change tracking.""" +import json +from datetime import datetime, timezone + + +def compute_diff(old: dict, new: dict, exclude: set = None) -> dict: + """Compare old and new values, return {field: [old, new]} for changed fields.""" + if exclude is None: + exclude = {"edit_log", "updated_at", "created_at", "id"} + changes = {} + for key, new_val in new.items(): + if key in exclude: + continue + old_val = old.get(key) + # Normalize for comparison + old_str = str(old_val) if old_val is not None else "" + new_str = str(new_val) if new_val is not None else "" + if old_str != new_str: + changes[key] = [old_str, new_str] + return changes + + +def append_entry(record, editor_name: str, changes: dict, reason: str = None): + """Append an edit log entry to a record's edit_log JSON list.""" + log = list(record.edit_log) if record.edit_log else [] + entry = { + "editor": editor_name, + "time": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "changes": changes, + } + if reason: + entry["reason"] = reason + log.append(entry) + record.edit_log = log + + +def init_entry(record, creator_name: str): + """Initialize edit_log with a creation entry.""" + record.edit_log = [{ + "editor": creator_name, + "time": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "changes": {}, + }] diff --git a/frontend/src/components/DesktopLayout.vue b/frontend/src/components/DesktopLayout.vue index d42b706..b17bae1 100644 --- a/frontend/src/components/DesktopLayout.vue +++ b/frontend/src/components/DesktopLayout.vue @@ -8,20 +8,47 @@ const router = useRouter() const auth = useAuthStore() const collapsed = ref(false) -const menuItems = computed(() => { - const items: { path: string; label: string; icon: string }[] = [ - { path: '/', label: '仪表盘', icon: '' }, - { path: '/weekly-report', label: '周报详情', icon: '' }, +interface MenuGroup { label?: string; items: { path: string; label: string; icon: string }[] } + +const menuGroups = computed(() => { + const groups: MenuGroup[] = [ + { + label: '汇总', + items: [ + { path: '/', label: '仪表盘', icon: '' }, + { path: '/weekly-report', label: '周报', icon: '' }, + ], + }, + { + label: '工作', + items: [ + { path: '/work-plans', label: '工作计划', icon: '' }, + { path: '/mini-business', label: '商机跟单', icon: '' }, + { path: '/key-visits', label: '要客拜访', icon: '' }, + ], + }, ] if (auth.isManager || auth.isDirector) { - items.push({ path: '/workspace', label: '我的数据', icon: '' }) + groups.push({ + label: undefined, + items: [ + { path: '/workspace', label: '我的数据', icon: '' }, + ], + }) } - items.push({ path: '/customers', label: '客户管理', icon: '' }) + groups.push({ + label: '管理', + items: [ + { path: '/customers', label: '客户管理', icon: '' }, + ], + }) if (auth.isDirector) { - items.push({ path: '/users', label: '用户管理', icon: '' }) - items.push({ path: '/settings', label: '系统设置', icon: '' }) + groups[groups.length - 1].items.push( + { path: '/users', label: '用户管理', icon: '' }, + { path: '/settings', label: '系统设置', icon: '' }, + ) } - return items + return groups }) const roleLabel = computed(() => { @@ -42,16 +69,19 @@ const roleLabel = computed(() => {