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() {