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.errors.join('; ') }}
+ 新增 {{ importResult.created }} 条,更新 {{ importResult.updated || 0 }} 条
+
+
+
• {{ r }}
+
...还有 {{ importResult.reasons.length - 20 }} 条
+
+
+
⚠ 错误:{{ importResult.errors.join('; ') }}
diff --git a/frontend/src/views/desktop/Dashboard.vue b/frontend/src/views/desktop/Dashboard.vue
index 1b650fd..6e6b54e 100644
--- a/frontend/src/views/desktop/Dashboard.vue
+++ b/frontend/src/views/desktop/Dashboard.vue
@@ -1,5 +1,5 @@
@@ -174,7 +178,7 @@ onMounted(loadToday)
◆ {{ v.customer_demand }}
-
![]()
+
+
+
+
+
![]()
+
+
+
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 }}
+
+
+
+
+
![]()
+
+
+