feat: 信息架构重组 + 变更追踪 + 图片预览增强

- 信息架构重组: 周报精简为拜访+纪要两个Tab,工作计划/商机/要客独立为侧边栏「工作」分组下的独立页面
- 侧边栏分组: 汇总/工作/管理三层分组,仪表盘四卡可点击跳转
- 变更追踪(edit_log): 5张表新增JSONB edit_log列,POST创建/PUT diff自动记录,编辑弹窗变更时间轴,表格🕐编辑标记
- 图片预览增强: ImagePreview统一组件,支持适应页面/缩放/拖拽平移/滚轮缩放/键盘快捷键
- 修复客户导入500错误(errors变量未初始化)
- 移除工作计划/商机/要客页面冗余编辑按钮

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-24 08:28:46 +08:00
parent aa3fbca710
commit bf67e0575f
27 changed files with 1332 additions and 117 deletions
+18 -5
View File
@@ -56,11 +56,11 @@ qiji/
| `customers` | id, name, industry, address, in_use_services, monthly_fee, remarks, created_by | | `customers` | id, name, industry, address, in_use_services, monthly_fee, remarks, created_by |
| `customer_contacts` | id, customer_id, name, phone, role_desc | | `customer_contacts` | id, customer_id, name, phone, role_desc |
| `customer_assignments` | id, customer_id, manager_id, role(primary/assistant), assigned_by | | `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 | | `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 | | `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 | | `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 | | `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 | | `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] **填报进度权限** — 经理只看到自己,支局长/领导看全员
- [x] **客户经理列** — 暖灰色hash标签,每人唯一颜色 - [x] **客户经理列** — 暖灰色hash标签,每人唯一颜色
- [x] **列表序号** — 客户管理+用户管理添加序号列 - [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. **列表操作**: 点击客户名→详情弹窗含编辑/删除按钮,移除表格操作栏 11. **列表操作**: 点击客户名→详情弹窗含编辑/删除按钮,移除表格操作栏
12. **侧边栏**: ink 深色渐变 + SVG 内联图标 + 可折叠(64px) + 金色装饰线 12. **侧边栏**: ink 深色渐变 + SVG 内联图标 + 可折叠(64px) + 金色装饰线
13. **客户经理列**: hash 配色标签,每人唯一颜色(8 色暖灰调) 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. **仪表盘跳转**: 四张统计卡片可点击跳转到对应的周报/工作计划/商机/要客页面
+1
View File
@@ -196,6 +196,7 @@ async def import_customers(
ws = wb.active ws = wb.active
created, updated, skipped = 0, 0, 0 created, updated, skipped = 0, 0, 0
reasons = [] reasons = []
errors = []
# Build user name → id lookup (all users, not just managers) # Build user name → id lookup (all users, not just managers)
user_rows = await db.execute(select(User.name, User.id)) user_rows = await db.execute(select(User.name, User.id))
+9 -2
View File
@@ -10,6 +10,7 @@ from app.models.daily_note import DailyNote
from app.models.user import User from app.models.user import User
from app.schemas.daily_note import DailyNoteCreate, DailyNoteUpdate, DailyNoteOut from app.schemas.daily_note import DailyNoteCreate, DailyNoteUpdate, DailyNoteOut
from app.utils.timezone import today_cst, parse_date 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"]) 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, "id": note.id, "manager_id": note.manager_id,
"note_date": note.note_date, "category": note.category, "note_date": note.note_date, "category": note.category,
"content": note.content, "time_range": note.time_range, "content": note.content, "time_range": note.time_range,
"created_at": note.created_at, "updated_at": note.updated_at, "edit_log": note.edit_log or [], "created_at": note.created_at,
"manager_name": mgr.scalar_one_or_none(), "updated_at": note.updated_at, "manager_name": mgr.scalar_one_or_none(),
} }
@@ -85,6 +86,7 @@ async def create_note(
content=data.content, content=data.content,
time_range=data.time_range, time_range=data.time_range,
) )
init_entry(note, current_user["name"])
db.add(note) db.add(note)
await db.commit() await db.commit()
await db.refresh(note) 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"]: if current_user["role"] == "manager" and str(note.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied") 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) update_data = data.model_dump(exclude_unset=True)
if "note_date" in update_data and update_data["note_date"]: if "note_date" in update_data and update_data["note_date"]:
update_data["note_date"] = parse_date(update_data["note_date"]) update_data["note_date"] = parse_date(update_data["note_date"])
for k, v in update_data.items(): for k, v in update_data.items():
setattr(note, k, v) 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.commit()
await db.refresh(note) await db.refresh(note)
return await _enrich(note, db) return await _enrich(note, db)
+8
View File
@@ -9,6 +9,7 @@ from app.models.key_visit import KeyVisit
from app.models.customer import Customer from app.models.customer import Customer
from app.models.user import User from app.models.user import User
from app.schemas.key_visit import KeyVisitCreate, KeyVisitUpdate, KeyVisitOut 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"]) router = APIRouter(prefix="/key-visits", tags=["KeyVisits"])
@@ -27,6 +28,7 @@ async def _enrich(k: KeyVisit, db: AsyncSession) -> dict:
"planned_visitor": k.planned_visitor, "planned_visitor": k.planned_visitor,
"visit_target": k.visit_target, "visit_target": k.visit_target,
"manager_id": str(k.manager_id), "manager_id": str(k.manager_id),
"edit_log": k.edit_log or [],
"manager_name": mgr.scalar_one_or_none(), "manager_name": mgr.scalar_one_or_none(),
} }
@@ -63,6 +65,7 @@ async def create_key_visit(
visit_target=data.visit_target, visit_target=data.visit_target,
manager_id=uuid.UUID(current_user["user_id"]), manager_id=uuid.UUID(current_user["user_id"]),
) )
init_entry(k, current_user["name"])
db.add(k) db.add(k)
await db.commit() await db.commit()
await db.refresh(k) 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"]: if current_user["role"] == "manager" and str(k.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied") 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) update_data = data.model_dump(exclude_unset=True)
for key, v in update_data.items(): for key, v in update_data.items():
setattr(k, key, v) 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.commit()
await db.refresh(k) await db.refresh(k)
return await _enrich(k, db) return await _enrich(k, db)
+8
View File
@@ -9,6 +9,7 @@ from app.models.mini_business import MiniBusiness
from app.models.customer import Customer from app.models.customer import Customer
from app.models.user import User from app.models.user import User
from app.schemas.mini_business import MiniBusinessCreate, MiniBusinessUpdate, MiniBusinessOut 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"]) router = APIRouter(prefix="/mini-business", tags=["MiniBusiness"])
@@ -26,6 +27,7 @@ async def _enrich(m: MiniBusiness, db: AsyncSession) -> dict:
"status": m.status, "status": m.status,
"manager_id": str(m.manager_id), "manager_id": str(m.manager_id),
"manager_name": mgr.scalar_one_or_none(), "manager_name": mgr.scalar_one_or_none(),
"edit_log": m.edit_log or [],
"expected_revenue_date": m.expected_revenue_date, "expected_revenue_date": m.expected_revenue_date,
} }
@@ -61,6 +63,7 @@ async def create_mini_business(
manager_id=uuid.UUID(current_user["user_id"]), manager_id=uuid.UUID(current_user["user_id"]),
expected_revenue_date=data.expected_revenue_date, expected_revenue_date=data.expected_revenue_date,
) )
init_entry(m, current_user["name"])
db.add(m) db.add(m)
await db.commit() await db.commit()
await db.refresh(m) 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"]: if current_user["role"] == "manager" and str(m.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied") 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) update_data = data.model_dump(exclude_unset=True)
for k, v in update_data.items(): for k, v in update_data.items():
setattr(m, k, v) 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.commit()
await db.refresh(m) await db.refresh(m)
return await _enrich(m, db) return await _enrich(m, db)
+22
View File
@@ -12,6 +12,7 @@ from app.models.user import User
from app.schemas.visit import VisitCreate, VisitUpdate, VisitOut, VisitListOut from app.schemas.visit import VisitCreate, VisitUpdate, VisitOut, VisitListOut
from app.utils.timezone import today_cst, parse_date from app.utils.timezone import today_cst, parse_date
from app.services.minio_client import delete_objects 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"]) router = APIRouter(prefix="/visits", tags=["Visits"])
@@ -42,6 +43,7 @@ async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict:
"photos": visit.photos, "photos": visit.photos,
"manager_id": str(visit.manager_id), "manager_id": str(visit.manager_id),
"manager_name": manager_name, "manager_name": manager_name,
"edit_log": visit.edit_log or [],
"created_at": str(visit.created_at), "created_at": str(visit.created_at),
"updated_at": str(visit.updated_at), "updated_at": str(visit.updated_at),
} }
@@ -138,6 +140,7 @@ async def create_visit(
photos=data.photos, photos=data.photos,
manager_id=uuid.UUID(current_user["user_id"]), manager_id=uuid.UUID(current_user["user_id"]),
) )
init_entry(visit, current_user["name"])
db.add(visit) db.add(visit)
# Create draft copies for companions # 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"]: if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied") 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) update_data = data.model_dump(exclude_unset=True)
if "visit_date" in update_data and update_data["visit_date"]: if "visit_date" in update_data and update_data["visit_date"]:
update_data["visit_date"] = parse_date(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(): for key, value in update_data.items():
setattr(visit, key, value) 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.commit()
await db.refresh(visit) await db.refresh(visit)
return await _enrich_visit(visit, db) return await _enrich_visit(visit, db)
+8
View File
@@ -7,6 +7,7 @@ from sqlalchemy import select
from app.database import get_db from app.database import get_db
from app.middleware.auth import get_current_user, require_any_role from app.middleware.auth import get_current_user, require_any_role
from app.utils.timezone import parse_date 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.work_plan import WorkPlan
from app.models.customer import Customer from app.models.customer import Customer
from app.models.user import User 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_id": str(wp.manager_id),
"manager_name": mgr.scalar_one_or_none(), "manager_name": mgr.scalar_one_or_none(),
"status": wp.status, "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"]), manager_id=uuid.UUID(current_user["user_id"]),
status=data.status, status=data.status,
) )
init_entry(wp, current_user["name"])
db.add(wp) db.add(wp)
await db.commit() await db.commit()
await db.refresh(wp) 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"]: if current_user["role"] == "manager" and str(wp.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied") 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) update_data = data.model_dump(exclude_unset=True)
if "plan_date" in update_data and update_data["plan_date"]: if "plan_date" in update_data and update_data["plan_date"]:
update_data["plan_date"] = parse_date(update_data["plan_date"]) update_data["plan_date"] = parse_date(update_data["plan_date"])
for k, v in update_data.items(): for k, v in update_data.items():
setattr(wp, k, v) 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.commit()
await db.refresh(wp) await db.refresh(wp)
return await _enrich(wp, db) return await _enrich(wp, db)
+5
View File
@@ -23,6 +23,11 @@ async def lifespan(app: FastAPI):
await conn.run_sync(lambda c: c.exec_driver_sql( await conn.run_sync(lambda c: c.exec_driver_sql(
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_phone VARCHAR(20) DEFAULT ''" "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 yield
# Shutdown # Shutdown
await engine.dispose() await engine.dispose()
+2 -1
View File
@@ -2,7 +2,7 @@ import uuid
from datetime import date, datetime from datetime import date, datetime
from sqlalchemy import String, Text, Date, DateTime, ForeignKey, func from sqlalchemy import String, Text, Date, DateTime, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column 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 from app.database import Base
@@ -15,5 +15,6 @@ class DailyNote(Base):
category: Mapped[str] = mapped_column(String(20), default="其他") # 行政事务/合同整理/发票处理/内部会议/培训学习/其他 category: Mapped[str] = mapped_column(String(20), default="其他") # 行政事务/合同整理/发票处理/内部会议/培训学习/其他
content: Mapped[str] = mapped_column(Text, default="") content: Mapped[str] = mapped_column(Text, default="")
time_range: Mapped[str] = mapped_column(String(30), 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()) 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()) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+2 -1
View File
@@ -1,7 +1,7 @@
import uuid import uuid
from sqlalchemy import String, Text, ForeignKey from sqlalchemy import String, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column 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 from app.database import Base
@@ -17,3 +17,4 @@ class KeyVisit(Base):
planned_visitor: Mapped[str] = mapped_column(String(100), default="") planned_visitor: Mapped[str] = mapped_column(String(100), default="")
visit_target: 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) 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)
+2 -1
View File
@@ -1,7 +1,7 @@
import uuid import uuid
from sqlalchemy import String, Text, ForeignKey from sqlalchemy import String, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column 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 from app.database import Base
@@ -16,3 +16,4 @@ class MiniBusiness(Base):
status: Mapped[str] = mapped_column(String(50), default="跟进中") status: Mapped[str] = mapped_column(String(50), default="跟进中")
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) 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="") expected_revenue_date: Mapped[str] = mapped_column(String(50), default="")
edit_log: Mapped[list] = mapped_column(JSONB, default=list)
+2 -1
View File
@@ -2,7 +2,7 @@ import uuid
from datetime import datetime, date from datetime import datetime, date
from sqlalchemy import String, Text, Date, DateTime, ForeignKey, func from sqlalchemy import String, Text, Date, DateTime, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column 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 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) companions: Mapped[list | None] = mapped_column(ARRAY(UUID(as_uuid=True)), nullable=True)
photos: Mapped[list | None] = mapped_column(ARRAY(Text), 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) 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()) 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()) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+2 -1
View File
@@ -2,7 +2,7 @@ import uuid
from datetime import date from datetime import date
from sqlalchemy import String, Text, Date, ForeignKey from sqlalchemy import String, Text, Date, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column 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 from app.database import Base
@@ -15,3 +15,4 @@ class WorkPlan(Base):
plan_date: Mapped[date] = mapped_column(Date, index=True) 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) manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
status: Mapped[str] = mapped_column(String(20), default="计划中") # 计划中/已完成/已取消 status: Mapped[str] = mapped_column(String(20), default="计划中") # 计划中/已完成/已取消
edit_log: Mapped[list] = mapped_column(JSONB, default=list)
+43
View File
@@ -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": {},
}]
+61 -19
View File
@@ -8,20 +8,47 @@ const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const collapsed = ref(false) const collapsed = ref(false)
const menuItems = computed(() => { interface MenuGroup { label?: string; items: { path: string; label: string; icon: string }[] }
const items: { path: string; label: string; icon: string }[] = [
{ path: '/', label: '仪表盘', icon: '<polyline points="4 7 12 3 20 7"></polyline><polyline points="20 7 20 21 4 21 4 7"></polyline><line x1="8" y1="21" x2="8" y2="12"></line><line x1="16" y1="21" x2="16" y2="12"></line>' }, const menuGroups = computed<MenuGroup[]>(() => {
{ path: '/weekly-report', label: '周报详情', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line>' }, const groups: MenuGroup[] = [
{
label: '汇总',
items: [
{ path: '/', label: '仪表盘', icon: '<polyline points="4 7 12 3 20 7"></polyline><polyline points="20 7 20 21 4 21 4 7"></polyline><line x1="8" y1="21" x2="8" y2="12"></line><line x1="16" y1="21" x2="16" y2="12"></line>' },
{ path: '/weekly-report', label: '周报', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line>' },
],
},
{
label: '工作',
items: [
{ path: '/work-plans', label: '工作计划', icon: '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line><polyline points="8 14 12 18 16 14"></polyline>' },
{ path: '/mini-business', label: '商机跟单', icon: '<line x1="12" y1="1" x2="12" y2="23"></line><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>' },
{ path: '/key-visits', label: '要客拜访', icon: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>' },
],
},
] ]
if (auth.isManager || auth.isDirector) { if (auth.isManager || auth.isDirector) {
items.push({ path: '/workspace', label: '我的数据', icon: '<line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line>' }) groups.push({
label: undefined,
items: [
{ path: '/workspace', label: '我的数据', icon: '<line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line>' },
],
})
} }
items.push({ path: '/customers', label: '客户管理', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle>' }) groups.push({
label: '管理',
items: [
{ path: '/customers', label: '客户管理', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle>' },
],
})
if (auth.isDirector) { if (auth.isDirector) {
items.push({ 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>' }) groups[groups.length - 1].items.push(
items.push({ 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>' }) { 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>' },
)
} }
return items return groups
}) })
const roleLabel = computed(() => { const roleLabel = computed(() => {
@@ -42,16 +69,19 @@ const roleLabel = computed(() => {
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<router-link <template v-for="group in menuGroups" :key="group.label || 'nogroup'">
v-for="item in menuItems" <div v-if="group.label" class="nav-group-label">{{ group.label }}</div>
:key="item.path" <router-link
:to="item.path" v-for="item in group.items"
class="nav-item" :key="item.path"
:class="{ 'nav-item--active': route.path === item.path }" :to="item.path"
> class="nav-item"
<svg class="nav-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" v-html="item.icon"></svg> :class="{ 'nav-item--active': route.path === item.path }"
<span class="nav-label">{{ item.label }}</span> >
</router-link> <svg class="nav-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" v-html="item.icon"></svg>
<span class="nav-label">{{ item.label }}</span>
</router-link>
</template>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
@@ -162,8 +192,20 @@ const roleLabel = computed(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
overflow-y: auto;
} }
.nav-group-label {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 10px;
letter-spacing: 0.15em;
color: rgba(255,255,255,0.25);
padding: 12px 14px 4px;
text-transform: uppercase;
}
.sidebar--collapsed .nav-group-label { display: none; }
.nav-item { .nav-item {
display: flex; display: flex;
align-items: center; align-items: center;
+196
View File
@@ -0,0 +1,196 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
editLog: Array<{ editor: string; time: string; changes: Record<string, [string, string]>; reason?: string }>
}>()
const fieldLabels: Record<string, string> = {
customer_id: '客户单位',
customer_name: '客户名称',
visit_date: '拜访日期',
note_date: '日期',
visit_method: '拜访方式',
time_range: '时间范围',
visitor_name: '拜访人',
visitor_phone: '电话',
communication_content: '沟通内容',
customer_demand: '客户需求',
category: '分类',
content: '工作内容',
plan_content: '计划内容',
plan_date: '计划时间',
status: '状态',
product_type: '产品类型',
amount: '金额',
follow_up_detail: '跟进内容',
expected_revenue_date: '预计列收',
urgency_level: '紧急度',
description: '描述',
progress_status: '进展',
planned_date: '计划日期',
planned_visitor: '拜访人',
visit_target: '拜访对象',
}
const hasHistory = computed(() => (props.editLog || []).length > 0)
function formatTime(t: string) {
try {
const d = new Date(t)
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
} catch { return t }
}
function labelFor(field: string) {
return fieldLabels[field] || field
}
</script>
<template>
<el-collapse v-if="hasHistory" style="margin-top:16px">
<el-collapse-item title="变更记录" name="1">
<div class="log-timeline">
<div v-for="(entry, idx) in editLog" :key="idx" class="log-entry"
:class="{ 'log-entry--create': !entry.changes || Object.keys(entry.changes).length === 0 }">
<div class="log-dot"></div>
<div class="log-body">
<div class="log-meta">
<span class="log-editor">{{ entry.editor }}</span>
<span class="log-time">{{ formatTime(entry.time) }}</span>
<el-tag v-if="!entry.changes || Object.keys(entry.changes).length === 0" size="small" type="info" effect="plain">创建</el-tag>
</div>
<div v-if="entry.changes && Object.keys(entry.changes).length > 0" class="log-changes">
<div v-for="(vals, field) in entry.changes" :key="field" class="log-change">
<span class="change-label">{{ labelFor(field) }}</span>
<span class="change-old">{{ vals[0] || '(空)' }}</span>
<span class="change-arrow"></span>
<span class="change-new">{{ vals[1] || '(空)' }}</span>
</div>
</div>
<div v-if="entry.reason" class="log-reason">
<span class="reason-label">原因</span>{{ entry.reason }}
</div>
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
</template>
<style scoped>
.log-timeline {
padding: 4px 0;
}
.log-entry {
display: flex;
gap: 10px;
padding: 10px 0;
border-left: 2px solid var(--warm-border);
margin-left: 6px;
padding-left: 16px;
}
.log-entry:first-child {
border-left-color: var(--gold);
}
.log-entry--create {
opacity: 0.6;
}
.log-dot {
width: 8px; height: 8px;
background: var(--warm-border);
margin-left: -21px;
margin-top: 5px;
flex-shrink: 0;
}
.log-entry:first-child .log-dot {
background: var(--gold);
}
.log-body {
flex: 1;
min-width: 0;
}
.log-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.log-editor {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 13px;
color: var(--ink);
font-weight: 600;
}
.log-time {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 11px;
color: var(--c-text-muted);
}
.log-changes {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 4px;
}
.log-change {
font-size: 12px;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.change-label {
font-weight: 600;
color: var(--ink);
min-width: 56px;
}
.change-old {
color: var(--vermilion);
text-decoration: line-through;
text-decoration-color: var(--vermilion);
text-decoration-thickness: 1px;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.change-arrow {
color: var(--gold);
font-weight: 600;
}
.change-new {
color: var(--sage);
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.log-reason {
margin-top: 4px;
font-size: 12px;
color: var(--c-text-secondary);
font-style: italic;
}
.reason-label {
color: var(--warm-gray);
font-weight: 500;
}
</style>
+324
View File
@@ -0,0 +1,324 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
const props = defineProps<{
modelValue: boolean
imageUrl: string
}>()
const emit = defineEmits<{
'update:modelValue': [v: boolean]
}>()
const scale = ref(1)
const fitToPage = ref(true)
const panX = ref(0)
const panY = ref(0)
const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartY = ref(0)
const panStartX = ref(0)
const panStartY = ref(0)
const imageEl = ref<HTMLImageElement | null>(null)
const overlayEl = ref<HTMLDivElement | null>(null)
function close() {
emit('update:modelValue', false)
}
function reset() {
scale.value = 1
fitToPage.value = true
panX.value = 0
panY.value = 0
}
watch(() => props.imageUrl, reset)
watch(() => props.modelValue, (v) => { if (v) reset() })
function zoomIn() {
fitToPage.value = false
scale.value = Math.min(5, +(scale.value + 0.25).toFixed(2))
clampPan()
}
function zoomOut() {
fitToPage.value = false
const next = +(scale.value - 0.25).toFixed(2)
if (next < 0.25) {
scale.value = 1
fitToPage.value = true
panX.value = 0
panY.value = 0
} else {
scale.value = next
clampPan()
}
}
function toggleFit() {
if (fitToPage.value) {
fitToPage.value = false
scale.value = 1
} else {
fitToPage.value = true
scale.value = 1
panX.value = 0
panY.value = 0
}
}
function clampPan() {
if (!imageEl.value || !overlayEl.value) return
const img = imageEl.value
const naturalRatio = img.naturalWidth / img.naturalHeight
const viewW = overlayEl.value.clientWidth
const viewH = overlayEl.value.clientHeight
const displayW = naturalRatio > viewW / viewH ? viewW : viewH * naturalRatio
const displayH = naturalRatio > viewW / viewH ? viewW / naturalRatio : viewH
const scaledW = displayW * scale.value
const scaledH = displayH * scale.value
const maxX = Math.max(0, (scaledW - viewW) / 2)
const maxY = Math.max(0, (scaledH - viewH) / 2)
panX.value = Math.max(-maxX, Math.min(maxX, panX.value))
panY.value = Math.max(-maxY, Math.min(maxY, panY.value))
}
function onWheel(e: WheelEvent) {
e.preventDefault()
if (e.deltaY < 0) zoomIn()
else zoomOut()
}
function onDblClickImg(e: MouseEvent) {
e.preventDefault()
toggleFit()
}
/* ── Drag to pan ── */
function onPointerDown(e: PointerEvent) {
if (fitToPage.value) return
isDragging.value = true
dragStartX.value = e.clientX
dragStartY.value = e.clientY
panStartX.value = panX.value
panStartY.value = panY.value
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
}
function onPointerMove(e: PointerEvent) {
if (!isDragging.value) return
panX.value = panStartX.value + (e.clientX - dragStartX.value)
panY.value = panStartY.value + (e.clientY - dragStartY.value)
clampPan()
}
function onPointerUp(_e: PointerEvent) {
isDragging.value = false
}
function onOverlayClick(e: MouseEvent) {
if (e.target === overlayEl.value) close()
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') close()
if (e.key === '+') zoomIn()
if (e.key === '-') zoomOut()
if (e.key === '0') reset()
}
const scalePercent = computed(() => Math.round(scale.value * 100))
const imgStyle = computed(() => {
if (fitToPage.value) {
return {
maxWidth: '95vw',
maxHeight: '90vh',
objectFit: 'contain' as const,
transform: 'none',
cursor: 'default',
}
}
return {
maxWidth: 'none',
maxHeight: 'none',
objectFit: 'none' as const,
transform: `translate(${panX.value}px, ${panY.value}px) scale(${scale.value})`,
transformOrigin: 'center center',
cursor: isDragging.value ? 'grabbing' : (scale.value > 1 ? 'grab' : 'default'),
}
})
</script>
<template>
<teleport to="body">
<div
v-if="modelValue"
ref="overlayEl"
class="image-preview-overlay"
@click="onOverlayClick"
@wheel="onWheel"
@keydown="onKeydown"
tabindex="0"
>
<!-- Image wrapper for centering -->
<div class="preview-stage" @dblclick="onDblClickImg">
<img
ref="imageEl"
:src="imageUrl"
:style="imgStyle"
class="preview-img"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointerleave="onPointerUp"
@dblclick.stop="onDblClickImg"
@dragstart.prevent
/>
</div>
<!-- Bottom toolbar -->
<div class="preview-toolbar">
<button class="toolbar-btn" title="缩小" @click="zoomOut">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="toolbar-btn toolbar-btn--fit" :class="{ active: fitToPage }" title="适应页面" @click="toggleFit">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
<span v-if="fitToPage" class="fit-label">适应</span>
<span v-else class="fit-label">{{ scalePercent }}%</span>
</button>
<button class="toolbar-btn" title="放大" @click="zoomIn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<div class="toolbar-divider"></div>
<button class="toolbar-btn toolbar-btn--close" title="关闭 (Esc)" @click="close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</div>
</teleport>
</template>
<style scoped>
/* ═══ Overlay ═══ */
.image-preview-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
outline: none;
user-select: none;
-webkit-user-select: none;
}
/* ═══ Stage (image container) ═══ */
.preview-stage {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
overflow: hidden;
}
/* ═══ Image ═══ */
.preview-img {
display: block;
transition: transform 0.08s ease-out;
pointer-events: auto;
}
/* ═══ Bottom Toolbar ═══ */
.preview-toolbar {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 2px;
background: rgba(28, 55, 56, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
padding: 6px 8px;
border-radius: 6px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
z-index: 10;
}
.toolbar-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 36px;
height: 36px;
padding: 0 8px;
border: none;
border-radius: 4px;
background: transparent;
color: rgba(255, 255, 255, 0.75);
cursor: pointer;
font-family: 'Noto Serif SC', STSong, serif;
font-size: 12px;
transition: all 0.15s;
white-space: nowrap;
}
.toolbar-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.toolbar-btn.active {
background: rgba(196, 147, 74, 0.3);
color: var(--gold-light, #D4AD6E);
}
.toolbar-btn--fit {
min-width: 56px;
}
.fit-label {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 11px;
letter-spacing: 0.03em;
}
.toolbar-divider {
width: 1px;
height: 20px;
background: rgba(255, 255, 255, 0.2);
margin: 0 4px;
}
.toolbar-btn--close:hover {
background: rgba(184, 71, 46, 0.5);
color: #fff;
}
/* ── Mobile tweaks ── */
@media (max-width: 768px) {
.preview-toolbar {
bottom: 20px;
padding: 8px 12px;
gap: 4px;
border-radius: 8px;
}
.toolbar-btn {
min-width: 40px;
height: 40px;
border-radius: 6px;
}
.toolbar-btn--fit {
min-width: 60px;
}
}
</style>
+3
View File
@@ -38,6 +38,9 @@ const router = createRouter({
children: [ children: [
{ path: '', name: 'Dashboard', component: () => import('@/views/desktop/Dashboard.vue') }, { path: '', name: 'Dashboard', component: () => import('@/views/desktop/Dashboard.vue') },
{ path: 'weekly-report', name: 'WeeklyReport', component: () => import('@/views/desktop/WeeklyReport.vue') }, { path: 'weekly-report', name: 'WeeklyReport', component: () => import('@/views/desktop/WeeklyReport.vue') },
{ path: 'work-plans', name: 'WorkPlans', component: () => import('@/views/desktop/WorkPlans.vue') },
{ path: 'mini-business', name: 'MiniBusiness', component: () => import('@/views/desktop/MiniBusiness.vue') },
{ path: 'key-visits', name: 'KeyVisits', component: () => import('@/views/desktop/KeyVisits.vue') },
{ path: 'workspace', name: 'ManagerWorkspace', component: () => import('@/views/desktop/ManagerWorkspace.vue') }, { path: 'workspace', name: 'ManagerWorkspace', component: () => import('@/views/desktop/ManagerWorkspace.vue') },
{ path: 'customers', name: 'CustomerManage', component: () => import('@/views/desktop/CustomerManage.vue') }, { path: 'customers', name: 'CustomerManage', component: () => import('@/views/desktop/CustomerManage.vue') },
{ path: 'users', name: 'UserManage', component: () => import('@/views/desktop/UserManage.vue') }, { path: 'users', name: 'UserManage', component: () => import('@/views/desktop/UserManage.vue') },
+6 -4
View File
@@ -82,7 +82,7 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
<!-- Stats Cards --> <!-- Stats Cards -->
<div class="stat-grid"> <div class="stat-grid">
<div class="stat-card"> <div class="stat-card stat-card--clickable" @click="router.push('/weekly-report')">
<div class="stat-glyph stat-glyph--ink"> <div class="stat-glyph stat-glyph--ink">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path> <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
@@ -94,7 +94,7 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
<span class="stat-label">本周拜访</span> <span class="stat-label">本周拜访</span>
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card stat-card--clickable" @click="router.push('/work-plans')">
<div class="stat-glyph stat-glyph--sage"> <div class="stat-glyph stat-glyph--sage">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect> <rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
@@ -108,7 +108,7 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
<span class="stat-label">工作计划</span> <span class="stat-label">工作计划</span>
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card stat-card--clickable" @click="router.push('/mini-business')">
<div class="stat-glyph stat-glyph--gold"> <div class="stat-glyph stat-glyph--gold">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="1" x2="12" y2="23"></line> <line x1="12" y1="1" x2="12" y2="23"></line>
@@ -120,7 +120,7 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
<span class="stat-label">商机跟单</span> <span class="stat-label">商机跟单</span>
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card stat-card--clickable" @click="router.push('/key-visits')">
<div class="stat-glyph stat-glyph--vermilion"> <div class="stat-glyph stat-glyph--vermilion">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon> <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
@@ -241,6 +241,8 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
transition: all var(--transition); transition: all var(--transition);
} }
.stat-card:hover { box-shadow: var(--shadow-md); } .stat-card:hover { box-shadow: var(--shadow-md); }
.stat-card--clickable { cursor: pointer; }
.stat-card--clickable:hover { border-color: var(--gold); }
.stat-glyph { .stat-glyph {
width: 48px; height: 48px; width: 48px; height: 48px;
+217
View File
@@ -0,0 +1,217 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { ElMessage, ElMessageBox } from 'element-plus'
import { todayStr } from '@/utils'
import api from '@/api/index'
import EditLogPanel from '@/components/EditLogPanel.vue'
const auth = useAuthStore()
const loading = ref(false)
const keyVisits = ref<any[]>([])
const customers = ref<any[]>([])
const allUsers = ref<any[]>([])
const plannedVisitors = ref<string[]>([])
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const form = ref<any>({})
const statusPick = ref<Record<string, string>>({})
const keyStatuses = ['未开始', '进行中', '已完成']
onMounted(async () => {
await Promise.all([loadItems(), loadCustomers(), loadUsers()])
})
async function loadUsers() {
try {
const res = await api.get('/users/')
allUsers.value = res.data || []
} catch (_) {}
}
async function loadCustomers(q?: string) {
try {
const params: any = { page_size: 100 }
if (q) params.search = q
const res = await api.get('/customers/', { params })
customers.value = res.data.items || []
} catch (_) {}
}
async function loadItems() {
loading.value = true
try {
const res = await api.get('/key-visits/')
keyVisits.value = res.data
} catch (e: any) { ElMessage.error('加载失败') }
finally { loading.value = false }
}
function openCreate() {
dialogMode.value = 'create'
form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '' }
plannedVisitors.value = []
dialogVisible.value = true
}
function openEdit(item: any) {
dialogMode.value = 'edit'
form.value = { ...item }
plannedVisitors.value = item.planned_visitor ? item.planned_visitor.split('、') : []
dialogVisible.value = true
}
function onVisitorsChange(val: string[]) {
form.value.planned_visitor = val.join('、')
}
async function handleSave() {
try {
if (dialogMode.value === 'create') {
await api.post('/key-visits/', form.value)
} else {
await api.put(`/key-visits/${form.value.id}`, form.value)
}
ElMessage.success(dialogMode.value === 'create' ? '已创建' : '已更新')
dialogVisible.value = false
await loadItems()
} catch (e: any) { ElMessage.error('保存失败: ' + (e.response?.data?.detail || e.message)) }
}
async function handleDelete(id: string) {
try {
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
await api.delete(`/key-visits/${id}`)
ElMessage.success('已删除')
await loadItems()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
async function quickStatusChange(row: any, newStatus: string) {
try {
await ElMessageBox.confirm(`确定将进展改为「${newStatus}」?`, '确认', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' })
await api.put(`/key-visits/${row.id}`, { progress_status: newStatus })
ElMessage.success('状态已更新')
await loadItems()
} catch (e: any) {
if (e !== 'cancel') ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message))
delete statusPick.value[row.id]
}
}
</script>
<template>
<div class="key-visits-page" v-loading="loading">
<div class="page-head">
<div class="page-head-row">
<div>
<h2 class="page-title">要客拜访</h2>
<p class="page-desc">重要客户拜访计划按紧急程度排序跟踪</p>
</div>
<el-button type="primary" @click="openCreate">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
新建要客
</el-button>
</div>
<div class="page-rule"></div>
</div>
<el-card>
<el-table :data="keyVisits" stripe size="small" v-if="keyVisits.length">
<el-table-column type="index" label="序号" width="50" />
<el-table-column prop="customer_name" label="客户" width="160">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="openEdit(row)">{{ row.customer_name }}</el-link>
<el-tooltip v-if="row.edit_log?.length > 1" placement="top">
<template #content>最后编辑{{ row.edit_log[row.edit_log.length-1].editor }} · {{ row.edit_log.length-1 }}次修改</template>
<span class="edit-indicator" title="有过修改">🕐</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="urgency_level" label="重要度" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.urgency_level==='紧急'?'danger':row.urgency_level==='重要'?'warning':''">{{ row.urgency_level }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="250" show-overflow-tooltip />
<el-table-column label="进展" width="130">
<template #default="{ row }">
<el-select
v-model="statusPick[row.id]"
size="small"
style="width:100%"
:placeholder="row.progress_status"
@change="(v: string) => quickStatusChange(row, v)"
@visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.progress_status }"
>
<el-option v-for="s in keyStatuses" :key="s" :label="s" :value="s" />
</el-select>
</template>
</el-table-column>
<el-table-column prop="planned_date" label="计划时间" width="110" />
<el-table-column prop="planned_visitor" label="拜访人" width="80" />
<el-table-column prop="visit_target" label="拜访对象" width="110" />
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button text size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-else class="empty">暂无需重点关注的客户</div>
</el-card>
<!-- Dialog -->
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 要客拜访'" width="520px">
<el-form label-position="top">
<el-form-item label="客户单位">
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="紧急重要度">
<div class="method-grid">
<el-button v-for="u in ['一般','重要','紧急']" :key="u" size="small"
:type="form.urgency_level===u ? (u==='紧急'?'danger':u==='重要'?'warning':'') : ''"
@click="form.urgency_level=u">{{ u }}</el-button>
</div>
</el-form-item>
<el-form-item label="内容描述"><el-input v-model="form.description" type="textarea" :rows="4" placeholder="请输入要客拜访描述" /></el-form-item>
<el-form-item label="进展状态">
<el-select v-model="form.progress_status">
<el-option label="未开始" value="未开始" />
<el-option label="进行中" value="进行中" />
<el-option label="已完成" value="已完成" />
</el-select>
</el-form-item>
<el-form-item label="计划拜访时间">
<el-date-picker v-model="form.planned_date" type="date" placeholder="选择日期" style="width:100%" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="计划拜访人可多选">
<el-select v-model="plannedVisitors" multiple filterable allow-create default-first-option placeholder="选择或输入姓名可多选" style="width:100%" @change="onVisitorsChange">
<el-option v-for="u in allUsers" :key="u.id" :label="u.name + (u.role==='director'?' (支局长)':u.role==='leader'?' (分管领导)':'')" :value="u.name" />
</el-select>
</el-form-item>
<el-form-item label="拜访对象"><el-input v-model="form.visit_target" placeholder="对方联系人/部门" /></el-form-item>
</el-form>
<EditLogPanel v-if="dialogMode === 'edit'" :edit-log="form.edit_log || []" />
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.edit-indicator { font-size: 12px; margin-left: 3px; opacity: 0.5; cursor: help; }
.page-head { margin-bottom: 20px; }
.page-head-row { display: flex; justify-content: space-between; align-items: flex-start; }
.page-title { margin: 0; font-family: 'ZCOOL XiaoWei', STSong, serif; font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
.page-desc { margin: 4px 0 0; font-size: 13px; color: var(--c-text-muted); font-family: 'Noto Serif SC', STSong, serif; }
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
.method-grid { display: flex; gap: 8px; }
.empty { text-align: center; color: var(--c-text-muted); padding: 48px 0; font-family: 'Noto Serif SC', STSong, serif; }
</style>
@@ -3,6 +3,8 @@ import { ref, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { todayStr } from '@/utils' import { todayStr } from '@/utils'
import api from '@/api/index' import api from '@/api/index'
import ImagePreview from '@/components/ImagePreview.vue'
import EditLogPanel from '@/components/EditLogPanel.vue'
const activeTab = ref('visits') const activeTab = ref('visits')
const loading = ref(false) const loading = ref(false)
@@ -496,19 +498,14 @@ const notesByDate = computed(() => {
<el-form-item label="拜访对象"><el-input v-model="form.visit_target" /></el-form-item> <el-form-item label="拜访对象"><el-input v-model="form.visit_target" /></el-form-item>
</template> </template>
</el-form> </el-form>
<EditLogPanel v-if="dialogMode === 'edit'" :edit-log="form.edit_log || []" />
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button> <el-button type="primary" @click="handleSave">保存</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- Image Preview --> <ImagePreview v-model="previewDialogVisible" :image-url="previewImageUrl" />
<teleport to="body">
<div v-if="previewDialogVisible" class="image-preview-overlay" @click="previewDialogVisible = false">
<img :src="previewImageUrl" class="image-preview-full" @click.stop />
<button class="image-preview-close" @click="previewDialogVisible = false"></button>
</div>
</teleport>
</div> </div>
</template> </template>
+185
View File
@@ -0,0 +1,185 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { ElMessage, ElMessageBox } from 'element-plus'
import { todayStr } from '@/utils'
import api from '@/api/index'
import EditLogPanel from '@/components/EditLogPanel.vue'
const auth = useAuthStore()
const loading = ref(false)
const miniBusiness = ref<any[]>([])
const customers = ref<any[]>([])
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const form = ref<any>({})
const statusPick = ref<Record<string, string>>({})
const miniStatuses = ['跟进中', '已签约', '已流失']
onMounted(async () => {
await Promise.all([loadItems(), loadCustomers()])
})
async function loadCustomers(q?: string) {
try {
const params: any = { page_size: 100 }
if (q) params.search = q
const res = await api.get('/customers/', { params })
customers.value = res.data.items || []
} catch (_) {}
}
async function loadItems() {
loading.value = true
try {
const res = await api.get('/mini-business/')
miniBusiness.value = res.data
} catch (e: any) { ElMessage.error('加载失败') }
finally { loading.value = false }
}
function openCreate() {
dialogMode.value = 'create'
form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '' }
dialogVisible.value = true
}
function openEdit(item: any) {
dialogMode.value = 'edit'
form.value = { ...item }
dialogVisible.value = true
}
async function handleSave() {
try {
if (dialogMode.value === 'create') {
await api.post('/mini-business/', form.value)
} else {
await api.put(`/mini-business/${form.value.id}`, form.value)
}
ElMessage.success(dialogMode.value === 'create' ? '已创建' : '已更新')
dialogVisible.value = false
await loadItems()
} catch (e: any) { ElMessage.error('保存失败: ' + (e.response?.data?.detail || e.message)) }
}
async function handleDelete(id: string) {
try {
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
await api.delete(`/mini-business/${id}`)
ElMessage.success('已删除')
await loadItems()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
async function quickStatusChange(row: any, newStatus: string) {
try {
await ElMessageBox.confirm(`确定将状态改为「${newStatus}」?`, '确认', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' })
await api.put(`/mini-business/${row.id}`, { status: newStatus })
ElMessage.success('状态已更新')
await loadItems()
} catch (e: any) {
if (e !== 'cancel') ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message))
delete statusPick.value[row.id]
}
}
</script>
<template>
<div class="mini-biz-page" v-loading="loading">
<div class="page-head">
<div class="page-head-row">
<div>
<h2 class="page-title">商机跟单</h2>
<p class="page-desc">小微业务商机管道按状态跟踪签约进展</p>
</div>
<el-button type="primary" @click="openCreate">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
新建商机
</el-button>
</div>
<div class="page-rule"></div>
</div>
<el-card>
<el-table :data="miniBusiness" stripe size="small" v-if="miniBusiness.length">
<el-table-column type="index" label="序号" width="50" />
<el-table-column prop="customer_name" label="客户" width="160">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="openEdit(row)">{{ row.customer_name }}</el-link>
<el-tooltip v-if="row.edit_log?.length > 1" placement="top">
<template #content>最后编辑{{ row.edit_log[row.edit_log.length-1].editor }} · {{ row.edit_log.length-1 }}次修改</template>
<span class="edit-indicator" title="有过修改">🕐</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="product_type" label="产品类型" width="130" />
<el-table-column prop="amount" label="金额" width="100" />
<el-table-column prop="follow_up_detail" label="跟进内容" min-width="250" show-overflow-tooltip />
<el-table-column label="状态" width="130">
<template #default="{ row }">
<el-select
v-model="statusPick[row.id]"
size="small"
style="width:100%"
:placeholder="row.status"
@change="(v: string) => quickStatusChange(row, v)"
@visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.status }"
>
<el-option v-for="s in miniStatuses" :key="s" :label="s" :value="s" />
</el-select>
</template>
</el-table-column>
<el-table-column prop="expected_revenue_date" label="预计列收" width="110" />
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button text size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-else class="empty">暂无商机记录</div>
</el-card>
<!-- Dialog -->
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 小微商机'" width="500px">
<el-form label-position="top">
<el-form-item label="客户单位">
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="产品类型"><el-input v-model="form.product_type" placeholder="云专线SD-WAN" /></el-form-item>
<el-form-item label="金额"><el-input v-model="form.amount" placeholder="50000/" /></el-form-item>
<el-form-item label="跟进内容"><el-input v-model="form.follow_up_detail" type="textarea" :rows="4" placeholder="请输入跟进详情" /></el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status">
<el-option label="跟进中" value="跟进中" />
<el-option label="已签约" value="已签约" />
<el-option label="已流失" value="已流失" />
</el-select>
</el-form-item>
<el-form-item label="预计列收时间">
<el-date-picker v-model="form.expected_revenue_date" type="month" placeholder="选择月份" style="width:100%" value-format="YYYY-MM" />
</el-form-item>
</el-form>
<EditLogPanel v-if="dialogMode === 'edit'" :edit-log="form.edit_log || []" />
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.edit-indicator { font-size: 12px; margin-left: 3px; opacity: 0.5; cursor: help; }
.page-head { margin-bottom: 20px; }
.page-head-row { display: flex; justify-content: space-between; align-items: flex-start; }
.page-title { margin: 0; font-family: 'ZCOOL XiaoWei', STSong, serif; font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
.page-desc { margin: 4px 0 0; font-size: 13px; color: var(--c-text-muted); font-family: 'Noto Serif SC', STSong, serif; }
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
.empty { text-align: center; color: var(--c-text-muted); padding: 48px 0; font-family: 'Noto Serif SC', STSong, serif; }
</style>
+13 -62
View File
@@ -5,6 +5,7 @@ import { useAuthStore } from '@/stores/auth'
import { dashboardApi } from '@/api/dashboard' import { dashboardApi } from '@/api/dashboard'
import { uploadApi } from '@/api/upload' import { uploadApi } from '@/api/upload'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import ImagePreview from '@/components/ImagePreview.vue'
import api from '@/api/index' import api from '@/api/index'
const auth = useAuthStore() const auth = useAuthStore()
@@ -22,9 +23,6 @@ const report = ref({
week_start: '', week_end: '', week_start: '', week_end: '',
visits: [] as any[], visits: [] as any[],
daily_notes: [] as any[], daily_notes: [] as any[],
work_plans: [] as any[],
mini_business: [] as any[],
key_visits: [] as any[],
}) })
const photoUrls = ref<Record<string, string>>({}) const photoUrls = ref<Record<string, string>>({})
@@ -120,7 +118,7 @@ const notesByDate = computed(() => {
<div class="page-head"> <div class="page-head">
<div class="page-head-row"> <div class="page-head-row">
<div> <div>
<h2 class="page-title">周报详情</h2> <h2 class="page-title">周报</h2>
<div class="week-nav"> <div class="week-nav">
<button class="week-nav-btn" @click="changeWeek(-1)"></button> <button class="week-nav-btn" @click="changeWeek(-1)"></button>
<span class="week-label">{{ report.week_start }} {{ report.week_end }}</span> <span class="week-label">{{ report.week_start }} {{ report.week_end }}</span>
@@ -178,7 +176,15 @@ const notesByDate = computed(() => {
<el-table-column prop="time_range" label="时间" width="100" /> <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="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 prop="customer_demand" label="客户需求" min-width="150" show-overflow-tooltip />
<el-table-column prop="manager_name" label="客户经理" width="80" /> <el-table-column label="客户经理" width="100">
<template #default="{ row }">
<span>{{ row.manager_name }}</span>
<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"> <el-table-column label="照片" width="100">
<template #default="{ row }"> <template #default="{ row }">
<div class="photo-cell" v-if="row.photos?.length"> <div class="photo-cell" v-if="row.photos?.length">
@@ -193,40 +199,6 @@ const notesByDate = computed(() => {
</div> </div>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="工作计划" name="work_plans">
<el-table :data="report.work_plans" stripe>
<el-table-column prop="customer_name" label="客户" width="120" />
<el-table-column prop="plan_content" label="工作计划" min-width="250" />
<el-table-column prop="plan_date" label="计划时间" width="120" />
<el-table-column prop="manager_name" label="客户经理" width="80" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === '已完成' ? 'success' : row.status === '已取消' ? 'info' : ''">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="小微业务商机" name="mini_business">
<el-table :data="report.mini_business" stripe>
<el-table-column prop="customer_name" label="客户" width="120" />
<el-table-column prop="product_type" label="产品类型" width="120" />
<el-table-column prop="amount" label="金额" width="100" />
<el-table-column prop="follow_up_detail" label="跟进内容" min-width="200" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === '已签约' ? 'success' : row.status === '已流失' ? 'danger' : ''">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="manager_name" label="客户经理" width="80" />
<el-table-column prop="expected_revenue_date" label="预计列收" width="120" />
</el-table>
</el-tab-pane>
<el-tab-pane label="今日纪要" name="daily_notes"> <el-tab-pane label="今日纪要" name="daily_notes">
<div v-if="report.daily_notes.length === 0" class="empty">暂无数据</div> <div v-if="report.daily_notes.length === 0" class="empty">暂无数据</div>
<div v-for="[date, items] in notesByDate" :key="date" class="date-group"> <div v-for="[date, items] in notesByDate" :key="date" class="date-group">
@@ -251,32 +223,10 @@ const notesByDate = computed(() => {
</el-table> </el-table>
</div> </div>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="要客拜访计划" name="key_visits">
<el-table :data="report.key_visits" stripe>
<el-table-column prop="customer_name" label="客户" width="120" />
<el-table-column prop="urgency_level" label="重要度" width="80">
<template #default="{ row }">
<el-tag :type="row.urgency_level === '紧急' ? 'danger' : row.urgency_level === '重要' ? 'warning' : ''">
{{ row.urgency_level }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="200" />
<el-table-column prop="progress_status" label="进展" width="80" />
<el-table-column prop="planned_date" label="计划时间" width="120" />
<el-table-column prop="planned_visitor" label="拜访人" width="80" />
<el-table-column prop="visit_target" label="拜访对象" width="100" />
<el-table-column prop="manager_name" label="客户经理" width="80" />
</el-table>
</el-tab-pane>
</el-tabs> </el-tabs>
</el-card> </el-card>
<!-- Photo Viewer --> <ImagePreview v-model="photoDialogVisible" :image-url="currentPhotoUrl" />
<el-dialog v-model="photoDialogVisible" title="照片查看" width="80%">
<img :src="currentPhotoUrl" style="width:100%;" />
</el-dialog>
</div> </div>
</template> </template>
@@ -310,5 +260,6 @@ const notesByDate = computed(() => {
} }
.photo-cell { display: flex; gap: 4px; } .photo-cell { display: flex; gap: 4px; }
.mini-thumb { width: 36px; height: 36px; object-fit: cover; cursor: pointer; } .mini-thumb { width: 36px; height: 36px; object-fit: cover; cursor: pointer; }
.edit-indicator { font-size: 12px; margin-left: 3px; opacity: 0.5; cursor: help; }
.empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: 'Noto Serif SC', STSong, serif; } .empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: 'Noto Serif SC', STSong, serif; }
</style> </style>
+183
View File
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { ElMessage, ElMessageBox } from 'element-plus'
import { todayStr } from '@/utils'
import api from '@/api/index'
import EditLogPanel from '@/components/EditLogPanel.vue'
const auth = useAuthStore()
const loading = ref(false)
const workPlans = ref<any[]>([])
const customers = ref<any[]>([])
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const form = ref<any>({})
const statusPick = ref<Record<string, string>>({})
const planStatuses = ['计划中', '已完成', '已取消']
onMounted(async () => {
await Promise.all([loadPlans(), loadCustomers()])
})
async function loadCustomers(q?: string) {
try {
const params: any = { page_size: 100 }
if (q) params.search = q
const res = await api.get('/customers/', { params })
customers.value = res.data.items || []
} catch (_) {}
}
async function loadPlans() {
loading.value = true
try {
const res = await api.get('/work-plans/')
workPlans.value = res.data
} catch (e: any) { ElMessage.error('加载失败') }
finally { loading.value = false }
}
function openCreate() {
dialogMode.value = 'create'
form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中' }
dialogVisible.value = true
}
function openEdit(item: any) {
dialogMode.value = 'edit'
form.value = { ...item }
dialogVisible.value = true
}
async function handleSave() {
try {
if (dialogMode.value === 'create') {
await api.post('/work-plans/', form.value)
} else {
await api.put(`/work-plans/${form.value.id}`, form.value)
}
ElMessage.success(dialogMode.value === 'create' ? '已创建' : '已更新')
dialogVisible.value = false
await loadPlans()
} catch (e: any) { ElMessage.error('保存失败: ' + (e.response?.data?.detail || e.message)) }
}
async function handleDelete(id: string) {
try {
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
await api.delete(`/work-plans/${id}`)
ElMessage.success('已删除')
await loadPlans()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
async function quickStatusChange(row: any, newStatus: string) {
try {
await ElMessageBox.confirm(`确定将状态改为「${newStatus}」?`, '确认', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' })
await api.put(`/work-plans/${row.id}`, { status: newStatus })
ElMessage.success('状态已更新')
await loadPlans()
} catch (e: any) {
if (e !== 'cancel') ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message))
delete statusPick.value[row.id]
}
}
</script>
<template>
<div class="work-plans-page" v-loading="loading">
<div class="page-head">
<div class="page-head-row">
<div>
<h2 class="page-title">工作计划</h2>
<p class="page-desc">面向未来的工作计划安排支持状态流转跟踪</p>
</div>
<el-button type="primary" @click="openCreate">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
新建计划
</el-button>
</div>
<div class="page-rule"></div>
</div>
<el-card>
<el-table :data="workPlans" stripe size="small" v-if="workPlans.length">
<el-table-column type="index" label="序号" width="50" />
<el-table-column prop="customer_name" label="客户" width="160">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="openEdit(row)">{{ row.customer_name }}</el-link>
<el-tooltip v-if="row.edit_log?.length > 1" placement="top">
<template #content>最后编辑{{ row.edit_log[row.edit_log.length-1].editor }} · {{ row.edit_log.length-1 }}次修改</template>
<span class="edit-indicator" title="有过修改">🕐</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="plan_content" label="工作计划" min-width="280" show-overflow-tooltip />
<el-table-column prop="plan_date" label="计划时间" width="110" />
<el-table-column label="状态" width="130">
<template #default="{ row }">
<el-select
v-model="statusPick[row.id]"
size="small"
style="width:100%"
:placeholder="row.status"
@change="(v: string) => quickStatusChange(row, v)"
@visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.status }"
>
<el-option v-for="s in planStatuses" :key="s" :label="s" :value="s" />
</el-select>
</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button text size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-else class="empty">暂无工作计划</div>
</el-card>
<!-- Dialog -->
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 工作计划'" width="500px">
<el-form label-position="top">
<el-form-item label="客户单位">
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="计划拜访时间">
<el-date-picker v-model="form.plan_date" type="date" style="width:100%" />
</el-form-item>
<el-form-item label="工作计划">
<el-input v-model="form.plan_content" type="textarea" :rows="4" placeholder="请输入计划内容" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status">
<el-option label="计划中" value="计划中" />
<el-option label="已完成" value="已完成" />
<el-option label="已取消" value="已取消" />
</el-select>
</el-form-item>
</el-form>
<EditLogPanel v-if="dialogMode === 'edit'" :edit-log="form.edit_log || []" />
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.edit-indicator { font-size: 12px; margin-left: 3px; opacity: 0.5; cursor: help; }
.page-head { margin-bottom: 20px; }
.page-head-row { display: flex; justify-content: space-between; align-items: flex-start; }
.page-title { margin: 0; font-family: 'ZCOOL XiaoWei', STSong, serif; font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
.page-desc { margin: 4px 0 0; font-size: 13px; color: var(--c-text-muted); font-family: 'Noto Serif SC', STSong, serif; }
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
.empty { text-align: center; color: var(--c-text-muted); padding: 48px 0; font-family: 'Noto Serif SC', STSong, serif; }
</style>
@@ -4,6 +4,7 @@ import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { todayStr } from '@/utils' import { todayStr } from '@/utils'
import api from '@/api/index' import api from '@/api/index'
import EditLogPanel from '@/components/EditLogPanel.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -160,6 +161,7 @@ async function handleDelete() {
删除 删除
</button> </button>
</div> </div>
<EditLogPanel v-if="isEdit" :edit-log="form.edit_log || []" />
</el-form> </el-form>
</div> </div>
</template> </template>
+2 -6
View File
@@ -3,6 +3,7 @@ import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { visitsApi } from '@/api/visits' import { visitsApi } from '@/api/visits'
import { uploadApi } from '@/api/upload' import { uploadApi } from '@/api/upload'
import ImagePreview from '@/components/ImagePreview.vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import api from '@/api/index' import api from '@/api/index'
@@ -195,12 +196,7 @@ onMounted(loadToday)
</div> </div>
</div> </div>
<teleport to="body"> <ImagePreview v-model="previewDialogVisible" :image-url="previewImageUrl" />
<div v-if="previewDialogVisible" class="image-preview-overlay" @click="previewDialogVisible = false">
<img :src="previewImageUrl" class="image-preview-full" @click.stop />
<button class="image-preview-close" @click="previewDialogVisible = false"></button>
</div>
</teleport>
</div> </div>
</template> </template>
+4 -7
View File
@@ -7,6 +7,8 @@ import { visitsApi } from '@/api/visits'
import { customersApi } from '@/api/customers' import { customersApi } from '@/api/customers'
import { uploadApi } from '@/api/upload' import { uploadApi } from '@/api/upload'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import ImagePreview from '@/components/ImagePreview.vue'
import EditLogPanel from '@/components/EditLogPanel.vue'
import api from '@/api/index' import api from '@/api/index'
const router = useRouter() const router = useRouter()
@@ -344,15 +346,10 @@ async function handleDelete() {
删除 删除
</button> </button>
</div> </div>
<EditLogPanel v-if="isEdit" :edit-log="form.edit_log || []" />
</el-form> </el-form>
<!-- Image Preview Dialog --> <ImagePreview v-model="previewDialogVisible" :image-url="previewImageUrl" />
<teleport to="body">
<div v-if="previewDialogVisible" class="image-preview-overlay" @click="previewDialogVisible = false">
<img :src="previewImageUrl" class="image-preview-full" @click.stop />
<button class="image-preview-close" @click="previewDialogVisible = false"></button>
</div>
</teleport>
</div> </div>
</template> </template>