From aa3fbca710c7148dbcb52200e2b99c0e1c8ec21d Mon Sep 17 00:00:00 2001 From: v6ole Date: Tue, 23 Jun 2026 16:59:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8E=86=E5=8F=B2=E5=91=A8=E6=8A=A5+?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E9=A2=84=E8=A7=88+=E7=85=A7=E7=89=87?= =?UTF-8?q?=E7=AE=A1=E7=90=86+=E5=AF=BC=E5=85=A5=E4=BC=98=E5=8C=96+?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=90=88=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增: - 历史周报: Dashboard/周报详情支持周选择器翻看往周,归档只读 - 图片预览: 全屏大图(移动端+PC端),点击遮罩关闭 - PC端照片管理: 编辑时可上传/删除照片 - 客户导入改为更新模式: 重名自动更新信息,显示操作明细 - 导入跳过原因: 旧周报导入也显示每条跳过原因 优化: - 填报进度权限: 经理只看到自己,支局长/领导看全员 - 客户经理暖灰色hash标签列 - 用户去重合并(4组),数据完整迁移 - CLAUDE.md 更新到最新状态 Co-Authored-By: Claude --- CLAUDE.md | 9 ++ backend/app/api/customers.py | 66 +++++++----- backend/app/api/dashboard.py | 18 +++- backend/app/api/export.py | 10 +- backend/app/services/dashboard.py | 20 ++-- backend/app/services/excel_import.py | 4 +- frontend/src/App.vue | 19 ++++ frontend/src/api/dashboard.ts | 8 +- frontend/src/views/desktop/CustomerManage.vue | 12 ++- frontend/src/views/desktop/Dashboard.vue | 56 ++++++++-- .../src/views/desktop/ManagerWorkspace.vue | 101 ++++++++++++++++++ frontend/src/views/desktop/Settings.vue | 8 ++ frontend/src/views/desktop/WeeklyReport.vue | 36 +++++-- frontend/src/views/mobile/Home.vue | 13 ++- frontend/src/views/mobile/VisitForm.vue | 17 ++- 15 files changed, 323 insertions(+), 74 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ea7bd69..6161d33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,6 +96,15 @@ qiji/ - [x] **计划拜访人多选** — 支持从系统人员多选 + 手动输入 - [x] **旧周报导入模板** — 四 Sheet Excel 模板下载 + 示例数据 - [x] **数据库迁移** — lifespan 自动 ALTER TABLE 加列 (remarks/visitor_name/visitor_phone) +- [x] **历史周报** — 周选择器翻看往周数据,历史归档只读,导出支持历史周 +- [x] **图片预览** — 全屏大图查看(移动端+PC端统一),点击遮罩关闭 +- [x] **PC端照片管理** — 编辑时可上传新照片、删除已有照片 +- [x] **客户导入更新** — 重名客户自动更新信息而非跳过,显示逐条操作明细 +- [x] **导入跳过原因** — 旧周报导入+客户导入均显示跳过/更新明细 +- [x] **用户去重合并** — 4组重复用户合并,数据完整迁移 +- [x] **填报进度权限** — 经理只看到自己,支局长/领导看全员 +- [x] **客户经理列** — 暖灰色hash标签,每人唯一颜色 +- [x] **列表序号** — 客户管理+用户管理添加序号列 ### 待完善 diff --git a/backend/app/api/customers.py b/backend/app/api/customers.py index 4dba2d2..cd49212 100644 --- a/backend/app/api/customers.py +++ b/backend/app/api/customers.py @@ -194,11 +194,11 @@ async def import_customers( raise HTTPException(status_code=400, detail=f"Excel 解析失败: {str(e)}") ws = wb.active - created, skipped = 0, 0 - errors = [] + created, updated, skipped = 0, 0, 0 + reasons = [] - # Build user name → id lookup - user_rows = await db.execute(select(User.name, User.id).where(User.role == "manager")) + # Build user name → id lookup (all users, not just managers) + user_rows = await db.execute(select(User.name, User.id)) user_map = {name: uid for name, uid in user_rows.all()} default_user_id = uuid_mod.UUID(current_user["user_id"]) @@ -220,37 +220,45 @@ async def import_customers( contact_phone = str(row[9]).strip() if len(row) > 9 and row[9] else "" contact_role = str(row[10]).strip() if len(row) > 10 and row[10] else "" - existing = await db.execute(select(Customer).where(Customer.name == name)) - if existing.scalar_one_or_none(): - skipped += 1 - continue - - # Resolve manager: by name from template, or fallback to current user assignee_id = user_map.get(mgr_name, default_user_id) + existing_result = await db.execute(select(Customer).where(Customer.name == name)) + existing = existing_result.scalar_one_or_none() + try: - customer = Customer( - name=name, industry=industry, address=address, - in_use_services=services, monthly_fee=fee, - remarks=remarks, - created_by=default_user_id, - ) - db.add(customer) - await db.flush() - - if contact_name: - db.add(CustomerContact(customer_id=customer.id, name=contact_name, phone=contact_phone, role_desc=contact_role)) - - db.add(CustomerAssignment( - customer_id=customer.id, manager_id=assignee_id, - role="primary", assigned_by=default_user_id, - )) - created += 1 + if existing: + # Update existing customer + existing.industry = industry or existing.industry + existing.address = address or existing.address + existing.in_use_services = services or existing.in_use_services + existing.monthly_fee = fee or existing.monthly_fee + existing.remarks = remarks or existing.remarks + # Update or create primary assignment if manager changed + if mgr_name: + assign_rows = await db.execute( + select(CustomerAssignment).where(CustomerAssignment.customer_id == existing.id, CustomerAssignment.role == "primary") + ) + first_assign = assign_rows.first() + if first_assign: + first_assign[0].manager_id = assignee_id + else: + db.add(CustomerAssignment(customer_id=existing.id, manager_id=assignee_id, role="primary", assigned_by=default_user_id)) + updated += 1 + reasons.append(f"更新「{name}」的信息") + else: + customer = Customer(name=name, industry=industry, address=address, in_use_services=services, monthly_fee=fee, remarks=remarks, created_by=default_user_id) + db.add(customer) + await db.flush() + if contact_name: + db.add(CustomerContact(customer_id=customer.id, name=contact_name, phone=contact_phone, role_desc=contact_role)) + db.add(CustomerAssignment(customer_id=customer.id, manager_id=assignee_id, role="primary", assigned_by=default_user_id)) + created += 1 + reasons.append(f"新建「{name}」") except Exception as e: - errors.append(f"第{row_idx}行: {str(e)}") + errors.append(f"第{row_idx}行({name}): {str(e)}") await db.commit() - return {"created": created, "skipped": skipped, "errors": errors} + return {"created": created, "updated": updated, "skipped": skipped, "reasons": reasons, "errors": errors} @router.get("/check-duplicate/{name}") diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py index bff1ec7..b04a1e1 100644 --- a/backend/app/api/dashboard.py +++ b/backend/app/api/dashboard.py @@ -1,4 +1,5 @@ import uuid +from datetime import date from typing import Optional from fastapi import APIRouter, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession @@ -11,35 +12,42 @@ router = APIRouter(prefix="/dashboard", tags=["Dashboard"]) @router.get("/stats") async def dashboard_stats( + reference_date: Optional[str] = Query(None), current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - """Get dashboard card statistics for the current week.""" - stats = await get_dashboard_stats(db) + """Get dashboard card statistics. Pass reference_date (YYYY-MM-DD) for historical weeks.""" + ref = date.fromisoformat(reference_date) if reference_date else None + stats = await get_dashboard_stats(db, ref) return stats @router.get("/progress") async def reporting_progress( + reference_date: Optional[str] = Query(None), current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - """Get per-manager reporting progress.""" - return await get_reporting_progress(db) + """Get per-manager reporting progress. Managers only see themselves.""" + ref = date.fromisoformat(reference_date) if reference_date else None + return await get_reporting_progress(db, ref, current_user["user_id"], current_user["role"]) @router.get("/weekly-report") async def weekly_report( manager_id: Optional[str] = Query(None), customer_id: Optional[str] = Query(None), + reference_date: Optional[str] = Query(None), current_user: dict = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - """Get full weekly report data (four modules).""" + """Get full weekly report data. Pass reference_date for historical weeks.""" + ref = date.fromisoformat(reference_date) if reference_date else None return await get_weekly_report( db=db, user_id=uuid.UUID(current_user["user_id"]), role=current_user["role"], filter_manager_id=uuid.UUID(manager_id) if manager_id else None, filter_customer_id=uuid.UUID(customer_id) if customer_id else None, + reference_date=ref, ) diff --git a/backend/app/api/export.py b/backend/app/api/export.py index 35ef63c..abdf632 100644 --- a/backend/app/api/export.py +++ b/backend/app/api/export.py @@ -1,4 +1,6 @@ -from fastapi import APIRouter, Depends +from typing import Optional +from datetime import date +from fastapi import APIRouter, Depends, Query from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db @@ -10,11 +12,13 @@ router = APIRouter(prefix="/export", tags=["Export"]) @router.get("/weekly-report") async def download_weekly_report( + reference_date: Optional[str] = Query(None), current_user: dict = Depends(require_director_or_leader), db: AsyncSession = Depends(get_db), ): - """Export this week's report as a 4-sheet .xlsx file.""" - excel_bytes = await export_weekly_report(db) + """Export week report as 4-sheet .xlsx. Pass reference_date for historical weeks.""" + ref = date.fromisoformat(reference_date) if reference_date else None + excel_bytes = await export_weekly_report(db, ref) return StreamingResponse( excel_bytes, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", diff --git a/backend/app/services/dashboard.py b/backend/app/services/dashboard.py index aa69bf1..3c035d4 100644 --- a/backend/app/services/dashboard.py +++ b/backend/app/services/dashboard.py @@ -20,9 +20,9 @@ def get_week_range(reference_date: date | None = None): return monday, sunday -async def get_dashboard_stats(db: AsyncSession) -> dict: - """Get dashboard statistics for the current week.""" - monday, sunday = get_week_range() +async def get_dashboard_stats(db: AsyncSession, reference_date: date | None = None) -> dict: + """Get dashboard statistics for a given week (defaults to current).""" + monday, sunday = get_week_range(reference_date) today = date.today() visits_count = (await db.execute( @@ -51,13 +51,14 @@ async def get_dashboard_stats(db: AsyncSession) -> dict: } -async def get_reporting_progress(db: AsyncSession) -> list[dict]: - """Get per-manager reporting progress for the current week.""" - monday, sunday = get_week_range() +async def get_reporting_progress(db: AsyncSession, reference_date: date | None = None, user_id: str = "", role: str = "") -> list[dict]: + """Get per-manager reporting progress. Managers only see themselves.""" + monday, sunday = get_week_range(reference_date) - # Get all managers managers_result = await db.execute(select(User).where(User.role == "manager")) - managers = managers_result.scalars().all() + all_managers = managers_result.scalars().all() + # Filter: managers only see themselves + managers = all_managers if role in ("director", "leader") else [m for m in all_managers if str(m.id) == user_id] # Get visit counts per manager this week visits_result = await db.execute( @@ -102,9 +103,10 @@ async def get_weekly_report( db: AsyncSession, user_id: UUID, role: str, filter_manager_id: UUID | None = None, filter_customer_id: UUID | None = None, + reference_date: date | None = None, ) -> dict: """Get full weekly report data organized by module.""" - monday, sunday = get_week_range() + monday, sunday = get_week_range(reference_date) # Base filters respecting role visibility customer_map = {} diff --git a/backend/app/services/excel_import.py b/backend/app/services/excel_import.py index d44916a..b09ae6a 100644 --- a/backend/app/services/excel_import.py +++ b/backend/app/services/excel_import.py @@ -15,7 +15,7 @@ from app.models.user import User async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uuid.UUID) -> dict: """Parse old weekly report Excel and import data. Returns summary stats.""" wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True) - stats = {"visits": 0, "work_plans": 0, "mini_business": 0, "key_visits": 0, "skipped": 0} + stats = {"visits": 0, "work_plans": 0, "mini_business": 0, "key_visits": 0, "skipped": 0, "skip_reasons": []} # Resolve customer name -> id cache customers_result = await db.execute(select(Customer.id, Customer.name)) @@ -38,6 +38,7 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui customer_id = customer_map.get(cust_name) if not customer_id: stats["skipped"] += 1 + stats["skip_reasons"].append(f"客户「{cust_name}」不存在,跳过") continue try: @@ -54,6 +55,7 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui ) if existing.scalar_one_or_none(): stats["skipped"] += 1 + stats["skip_reasons"].append(f"重复:{cust_name} {visit_date} 已存在") continue visit = Visit( diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b5f4cac..2494efe 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -258,4 +258,23 @@ h1, h2, h3, h4, h5, h6 { font-family: 'ZCOOL XiaoWei', STSong, serif; color: var(--warm-gray); } + +/* ── Image Preview Overlay ── */ +.image-preview-overlay { + position: fixed; inset: 0; z-index: 9999; + background: rgba(0,0,0,0.88); + display: flex; align-items: center; justify-content: center; + cursor: zoom-out; +} +.image-preview-full { + max-width: 95vw; max-height: 95vh; + object-fit: contain; cursor: default; +} +.image-preview-close { + position: absolute; top: 16px; right: 16px; + background: rgba(255,255,255,0.15); color: #fff; + border: none; width: 40px; height: 40px; font-size: 20px; + cursor: pointer; border-radius: 50%; transition: background 0.2s; +} +.image-preview-close:hover { background: rgba(255,255,255,0.35); } diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index 71c43cf..b8bad3b 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -1,11 +1,11 @@ import api from './index' export const dashboardApi = { - getStats() { - return api.get('/dashboard/stats') + getStats(params?: any) { + return api.get('/dashboard/stats', { params }) }, - getProgress() { - return api.get('/dashboard/progress') + getProgress(params?: any) { + return api.get('/dashboard/progress', { params }) }, getWeeklyReport(params?: any) { return api.get('/dashboard/weekly-report', { params }) diff --git a/frontend/src/views/desktop/CustomerManage.vue b/frontend/src/views/desktop/CustomerManage.vue index 92fc10d..0a6c5ba 100644 --- a/frontend/src/views/desktop/CustomerManage.vue +++ b/frontend/src/views/desktop/CustomerManage.vue @@ -202,7 +202,7 @@ async function handleImport() { try { const res = await api.post('/customers/import', fd, { headers: { 'Content-Type': 'multipart/form-data' } }) importResult.value = res.data - ElMessage.success(`导入完成:新增 ${res.data.created} 条,跳过 ${res.data.skipped} 条`) + ElMessage.success(`导入完成:新增 ${res.data.created} 条,更新 ${res.data.updated || 0} 条`) await loadCustomers() } catch (e: any) { ElMessage.error('导入失败: ' + (e.response?.data?.detail || e.message)) } finally { importLoading.value = false } @@ -384,8 +384,14 @@ async function handleImport() {
- 新增 {{ importResult.created }} 条,跳过 {{ importResult.skipped }} 条 - + 新增 {{ importResult.created }} 条,更新 {{ importResult.updated || 0 }} 条 + +
diff --git a/frontend/src/views/mobile/VisitForm.vue b/frontend/src/views/mobile/VisitForm.vue index 33dc817..e0f7c12 100644 --- a/frontend/src/views/mobile/VisitForm.vue +++ b/frontend/src/views/mobile/VisitForm.vue @@ -34,6 +34,8 @@ const customers = ref([]) const managers = ref([]) const uploadedPhotos = ref([]) const photoPreviews = ref([]) +const previewDialogVisible = ref(false) +const previewImageUrl = ref('') const uploading = ref(false) const customerSearch = ref('') @@ -136,6 +138,11 @@ function onTimeRangeChange(val: [string, string] | null) { form.value.time_range = val ? val.join('-') : '' } +function previewPhoto(url: string) { + previewImageUrl.value = url + previewDialogVisible.value = true +} + function removePhoto(index: number) { if (index < photoPreviews.value.length) { URL.revokeObjectURL(photoPreviews.value[index]) @@ -301,7 +308,7 @@ async function handleDelete() {
- + 照片{{ idx + 1 }}
+ + + +
+ + +
+