feat: 历史周报+图片预览+照片管理+导入优化+用户合并

新增:
- 历史周报: Dashboard/周报详情支持周选择器翻看往周,归档只读
- 图片预览: 全屏大图(移动端+PC端),点击遮罩关闭
- PC端照片管理: 编辑时可上传/删除照片
- 客户导入改为更新模式: 重名自动更新信息,显示操作明细
- 导入跳过原因: 旧周报导入也显示每条跳过原因

优化:
- 填报进度权限: 经理只看到自己,支局长/领导看全员
- 客户经理暖灰色hash标签列
- 用户去重合并(4组),数据完整迁移
- CLAUDE.md 更新到最新状态

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-23 16:59:31 +08:00
parent 8926475b20
commit aa3fbca710
15 changed files with 323 additions and 74 deletions
+9
View File
@@ -96,6 +96,15 @@ qiji/
- [x] **计划拜访人多选** — 支持从系统人员多选 + 手动输入 - [x] **计划拜访人多选** — 支持从系统人员多选 + 手动输入
- [x] **旧周报导入模板** — 四 Sheet Excel 模板下载 + 示例数据 - [x] **旧周报导入模板** — 四 Sheet Excel 模板下载 + 示例数据
- [x] **数据库迁移** — lifespan 自动 ALTER TABLE 加列 (remarks/visitor_name/visitor_phone) - [x] **数据库迁移** — lifespan 自动 ALTER TABLE 加列 (remarks/visitor_name/visitor_phone)
- [x] **历史周报** — 周选择器翻看往周数据,历史归档只读,导出支持历史周
- [x] **图片预览** — 全屏大图查看(移动端+PC端统一),点击遮罩关闭
- [x] **PC端照片管理** — 编辑时可上传新照片、删除已有照片
- [x] **客户导入更新** — 重名客户自动更新信息而非跳过,显示逐条操作明细
- [x] **导入跳过原因** — 旧周报导入+客户导入均显示跳过/更新明细
- [x] **用户去重合并** — 4组重复用户合并,数据完整迁移
- [x] **填报进度权限** — 经理只看到自己,支局长/领导看全员
- [x] **客户经理列** — 暖灰色hash标签,每人唯一颜色
- [x] **列表序号** — 客户管理+用户管理添加序号列
### 待完善 ### 待完善
+31 -23
View File
@@ -194,11 +194,11 @@ async def import_customers(
raise HTTPException(status_code=400, detail=f"Excel 解析失败: {str(e)}") raise HTTPException(status_code=400, detail=f"Excel 解析失败: {str(e)}")
ws = wb.active ws = wb.active
created, skipped = 0, 0 created, updated, skipped = 0, 0, 0
errors = [] reasons = []
# Build user name → id lookup # Build user name → id lookup (all users, not just managers)
user_rows = await db.execute(select(User.name, User.id).where(User.role == "manager")) user_rows = await db.execute(select(User.name, User.id))
user_map = {name: uid for name, uid in user_rows.all()} user_map = {name: uid for name, uid in user_rows.all()}
default_user_id = uuid_mod.UUID(current_user["user_id"]) 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_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 "" 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) 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: try:
customer = Customer( if existing:
name=name, industry=industry, address=address, # Update existing customer
in_use_services=services, monthly_fee=fee, existing.industry = industry or existing.industry
remarks=remarks, existing.address = address or existing.address
created_by=default_user_id, 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) db.add(customer)
await db.flush() await db.flush()
if contact_name: if contact_name:
db.add(CustomerContact(customer_id=customer.id, name=contact_name, phone=contact_phone, role_desc=contact_role)) 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))
db.add(CustomerAssignment(
customer_id=customer.id, manager_id=assignee_id,
role="primary", assigned_by=default_user_id,
))
created += 1 created += 1
reasons.append(f"新建「{name}")
except Exception as e: except Exception as e:
errors.append(f"{row_idx}行: {str(e)}") errors.append(f"{row_idx}({name}): {str(e)}")
await db.commit() 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}") @router.get("/check-duplicate/{name}")
+13 -5
View File
@@ -1,4 +1,5 @@
import uuid import uuid
from datetime import date
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,35 +12,42 @@ router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
@router.get("/stats") @router.get("/stats")
async def dashboard_stats( async def dashboard_stats(
reference_date: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Get dashboard card statistics for the current week.""" """Get dashboard card statistics. Pass reference_date (YYYY-MM-DD) for historical weeks."""
stats = await get_dashboard_stats(db) ref = date.fromisoformat(reference_date) if reference_date else None
stats = await get_dashboard_stats(db, ref)
return stats return stats
@router.get("/progress") @router.get("/progress")
async def reporting_progress( async def reporting_progress(
reference_date: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Get per-manager reporting progress.""" """Get per-manager reporting progress. Managers only see themselves."""
return await get_reporting_progress(db) 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") @router.get("/weekly-report")
async def weekly_report( async def weekly_report(
manager_id: Optional[str] = Query(None), manager_id: Optional[str] = Query(None),
customer_id: Optional[str] = Query(None), customer_id: Optional[str] = Query(None),
reference_date: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db), 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( return await get_weekly_report(
db=db, db=db,
user_id=uuid.UUID(current_user["user_id"]), user_id=uuid.UUID(current_user["user_id"]),
role=current_user["role"], role=current_user["role"],
filter_manager_id=uuid.UUID(manager_id) if manager_id else None, filter_manager_id=uuid.UUID(manager_id) if manager_id else None,
filter_customer_id=uuid.UUID(customer_id) if customer_id else None, filter_customer_id=uuid.UUID(customer_id) if customer_id else None,
reference_date=ref,
) )
+7 -3
View File
@@ -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 fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
@@ -10,11 +12,13 @@ router = APIRouter(prefix="/export", tags=["Export"])
@router.get("/weekly-report") @router.get("/weekly-report")
async def download_weekly_report( async def download_weekly_report(
reference_date: Optional[str] = Query(None),
current_user: dict = Depends(require_director_or_leader), current_user: dict = Depends(require_director_or_leader),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Export this week's report as a 4-sheet .xlsx file.""" """Export week report as 4-sheet .xlsx. Pass reference_date for historical weeks."""
excel_bytes = await export_weekly_report(db) ref = date.fromisoformat(reference_date) if reference_date else None
excel_bytes = await export_weekly_report(db, ref)
return StreamingResponse( return StreamingResponse(
excel_bytes, excel_bytes,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+11 -9
View File
@@ -20,9 +20,9 @@ def get_week_range(reference_date: date | None = None):
return monday, sunday return monday, sunday
async def get_dashboard_stats(db: AsyncSession) -> dict: async def get_dashboard_stats(db: AsyncSession, reference_date: date | None = None) -> dict:
"""Get dashboard statistics for the current week.""" """Get dashboard statistics for a given week (defaults to current)."""
monday, sunday = get_week_range() monday, sunday = get_week_range(reference_date)
today = date.today() today = date.today()
visits_count = (await db.execute( 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]: async def get_reporting_progress(db: AsyncSession, reference_date: date | None = None, user_id: str = "", role: str = "") -> list[dict]:
"""Get per-manager reporting progress for the current week.""" """Get per-manager reporting progress. Managers only see themselves."""
monday, sunday = get_week_range() monday, sunday = get_week_range(reference_date)
# Get all managers
managers_result = await db.execute(select(User).where(User.role == "manager")) 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 # Get visit counts per manager this week
visits_result = await db.execute( visits_result = await db.execute(
@@ -102,9 +103,10 @@ async def get_weekly_report(
db: AsyncSession, user_id: UUID, role: str, db: AsyncSession, user_id: UUID, role: str,
filter_manager_id: UUID | None = None, filter_manager_id: UUID | None = None,
filter_customer_id: UUID | None = None, filter_customer_id: UUID | None = None,
reference_date: date | None = None,
) -> dict: ) -> dict:
"""Get full weekly report data organized by module.""" """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 # Base filters respecting role visibility
customer_map = {} customer_map = {}
+3 -1
View File
@@ -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: 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.""" """Parse old weekly report Excel and import data. Returns summary stats."""
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True) 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 # Resolve customer name -> id cache
customers_result = await db.execute(select(Customer.id, Customer.name)) 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) customer_id = customer_map.get(cust_name)
if not customer_id: if not customer_id:
stats["skipped"] += 1 stats["skipped"] += 1
stats["skip_reasons"].append(f"客户「{cust_name}」不存在,跳过")
continue continue
try: try:
@@ -54,6 +55,7 @@ async def import_from_excel(db: AsyncSession, file_bytes: bytes, manager_id: uui
) )
if existing.scalar_one_or_none(): if existing.scalar_one_or_none():
stats["skipped"] += 1 stats["skipped"] += 1
stats["skip_reasons"].append(f"重复:{cust_name} {visit_date} 已存在")
continue continue
visit = Visit( visit = Visit(
+19
View File
@@ -258,4 +258,23 @@ h1, h2, h3, h4, h5, h6 {
font-family: 'ZCOOL XiaoWei', STSong, serif; font-family: 'ZCOOL XiaoWei', STSong, serif;
color: var(--warm-gray); 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); }
</style> </style>
+4 -4
View File
@@ -1,11 +1,11 @@
import api from './index' import api from './index'
export const dashboardApi = { export const dashboardApi = {
getStats() { getStats(params?: any) {
return api.get('/dashboard/stats') return api.get('/dashboard/stats', { params })
}, },
getProgress() { getProgress(params?: any) {
return api.get('/dashboard/progress') return api.get('/dashboard/progress', { params })
}, },
getWeeklyReport(params?: any) { getWeeklyReport(params?: any) {
return api.get('/dashboard/weekly-report', { params }) return api.get('/dashboard/weekly-report', { params })
@@ -202,7 +202,7 @@ async function handleImport() {
try { try {
const res = await api.post('/customers/import', fd, { headers: { 'Content-Type': 'multipart/form-data' } }) const res = await api.post('/customers/import', fd, { headers: { 'Content-Type': 'multipart/form-data' } })
importResult.value = res.data importResult.value = res.data
ElMessage.success(`导入完成:新增 ${res.data.created} 条,跳过 ${res.data.skipped}`) ElMessage.success(`导入完成:新增 ${res.data.created} 条,更新 ${res.data.updated || 0}`)
await loadCustomers() await loadCustomers()
} catch (e: any) { ElMessage.error('导入失败: ' + (e.response?.data?.detail || e.message)) } } catch (e: any) { ElMessage.error('导入失败: ' + (e.response?.data?.detail || e.message)) }
finally { importLoading.value = false } finally { importLoading.value = false }
@@ -384,8 +384,14 @@ async function handleImport() {
</el-form> </el-form>
<div v-if="importResult" style="margin-top:12px"> <div v-if="importResult" style="margin-top:12px">
<el-alert type="success" :closable="false"> <el-alert type="success" :closable="false">
新增 {{ importResult.created }} 跳过 {{ importResult.skipped }} 新增 {{ importResult.created }} 更新 {{ importResult.updated || 0 }}
<template v-if="importResult.errors?.length"><br/>错误{{ importResult.errors.join('; ') }}</template> <template v-if="importResult.reasons?.length">
<div style="margin-top:8px;font-size:12px;max-height:200px;overflow-y:auto">
<div v-for="(r,i) in importResult.reasons.slice(0,20)" :key="i"> {{ r }}</div>
<div v-if="importResult.reasons.length > 20" style="color:var(--c-text-muted)">...还有 {{ importResult.reasons.length - 20 }} </div>
</div>
</template>
<template v-if="importResult.errors?.length"><br/> 错误{{ importResult.errors.join('; ') }}</template>
</el-alert> </el-alert>
</div> </div>
<template #footer> <template #footer>
+45 -11
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { dashboardApi } from '@/api/dashboard' import { dashboardApi } from '@/api/dashboard'
@@ -10,13 +10,37 @@ const auth = useAuthStore()
const loading = ref(false) const loading = ref(false)
const stats = ref({ week_visits: 0, work_plans: 0, mini_business: 0, key_visits: 0, week_start: '', week_end: '' }) const stats = ref({ week_visits: 0, work_plans: 0, mini_business: 0, key_visits: 0, week_start: '', week_end: '' })
const progress = ref<any[]>([]) const progress = ref<any[]>([])
const weekOffset = ref(0) // 0 = current week, -1 = last week, etc.
onMounted(async () => { const isHistoricalWeek = computed(() => weekOffset.value < 0)
const weekPickerDate = computed({
get: () => {
const d = new Date()
d.setDate(d.getDate() + weekOffset.value * 7)
return d.toISOString().slice(0, 10)
},
set: (_val: string) => {} // placeholder, actual change via buttons
})
function changeWeek(delta: number) {
weekOffset.value += delta
loadData()
}
function goCurrentWeek() {
weekOffset.value = 0
loadData()
}
async function loadData() {
loading.value = true loading.value = true
try { try {
const refDate = new Date()
refDate.setDate(refDate.getDate() + weekOffset.value * 7)
const refStr = refDate.toISOString().slice(0, 10)
const [sRes, pRes] = await Promise.all([ const [sRes, pRes] = await Promise.all([
dashboardApi.getStats(), dashboardApi.getStats({ reference_date: refStr }),
dashboardApi.getProgress(), dashboardApi.getProgress({ reference_date: refStr }),
]) ])
stats.value = sRes.data stats.value = sRes.data
progress.value = pRes.data progress.value = pRes.data
@@ -25,7 +49,9 @@ onMounted(async () => {
} finally { } finally {
loading.value = false loading.value = false
} }
}) }
onMounted(loadData)
function goWeeklyReport(managerId?: string) { function goWeeklyReport(managerId?: string) {
if (managerId) router.push({ path: '/weekly-report', query: { manager_id: managerId } }) if (managerId) router.push({ path: '/weekly-report', query: { manager_id: managerId } })
@@ -44,7 +70,13 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
<!-- Editorial Page Header --> <!-- Editorial Page Header -->
<div class="page-head"> <div class="page-head">
<h2 class="page-title">仪表盘</h2> <h2 class="page-title">仪表盘</h2>
<p class="page-sub">{{ stats.week_start }} {{ stats.week_end }}</p> <div class="week-nav">
<button class="week-nav-btn" @click="changeWeek(-1)" title="上一周"></button>
<span class="week-label">{{ stats.week_start }} {{ stats.week_end }}</span>
<button class="week-nav-btn" @click="changeWeek(1)" :disabled="weekOffset >= 0" title="下一周"></button>
<button v-if="isHistoricalWeek" class="week-nav-reset" @click="goCurrentWeek">回到本周</button>
<el-tag v-if="isHistoricalWeek" type="info" size="small" style="margin-left:8px">📦 历史归档 · 只读</el-tag>
</div>
<div class="page-rule"></div> <div class="page-rule"></div>
</div> </div>
@@ -182,11 +214,13 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
font-size: 22px; font-weight: 400; font-size: 22px; font-weight: 400;
color: var(--ink); letter-spacing: 0.06em; color: var(--ink); letter-spacing: 0.06em;
} }
.page-sub { .week-nav { display: flex; align-items: center; gap: 10px; margin: 6px 0 4px; }
margin: 2px 0 0; .week-nav-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 4px 10px; cursor: pointer; color: var(--warm-gray); font-size: 12px; }
font-family: 'JetBrains Mono', 'SF Mono', monospace; .week-nav-btn:hover:not(:disabled) { color: var(--ink); border-color: var(--ink); }
font-size: 11px; color: var(--gold); letter-spacing: 0.08em; .week-nav-btn:disabled { opacity: 0.3; cursor: not-allowed; }
} .week-label { font-family: 'JetBrains Mono', 'SF Mono', monospace; font-size: 12px; color: var(--ink); letter-spacing: 0.04em; }
.week-nav-reset { background: none; border: 1px solid var(--gold); color: var(--gold); padding: 4px 10px; cursor: pointer; font-size: 12px; }
.week-nav-reset:hover { background: var(--gold); color: #fff; }
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; } .page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
/* ═══ Stat Grid ═══ */ /* ═══ Stat Grid ═══ */
@@ -16,6 +16,10 @@ const customers = ref<any[]>([])
const allUsers = ref<any[]>([]) const allUsers = ref<any[]>([])
const plannedVisitors = ref<string[]>([]) const plannedVisitors = ref<string[]>([])
const dialogTimeRange = ref<any>(null) const dialogTimeRange = ref<any>(null)
const previewImageUrl = ref('')
const uploading = ref(false)
const previewDialogVisible = ref(false)
const photoUrls = ref<Record<string, string>>({})
const dialogVisible = ref(false) const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create') const dialogMode = ref<'create' | 'edit'>('create')
@@ -66,6 +70,19 @@ async function loadAll() {
visits.value = v.data; workPlans.value = w.data visits.value = v.data; workPlans.value = w.data
miniBusiness.value = m.data; dailyNotes.value = d.data miniBusiness.value = m.data; dailyNotes.value = d.data
keyVisits.value = k.data keyVisits.value = k.data
// Load photo previews
for (const visit of visits.value) {
if (visit.photos?.length) {
for (const key of visit.photos) {
if (!photoUrls.value[key]) {
try {
const urlRes = await api.get('/upload/download-url', { params: { object_key: key } })
photoUrls.value[key] = urlRes.data.download_url
} catch (_) {}
}
}
}
}
} catch (e: any) { ElMessage.error('加载失败') } } catch (e: any) { ElMessage.error('加载失败') }
finally { loading.value = false } finally { loading.value = false }
} }
@@ -92,9 +109,62 @@ function openEdit(type: string, item: any) {
if (type === 'key') { if (type === 'key') {
plannedVisitors.value = item.planned_visitor ? item.planned_visitor.split('、') : [] plannedVisitors.value = item.planned_visitor ? item.planned_visitor.split('、') : []
} }
// Load photo URLs for visit editing
if (type === 'visit' && item.photos?.length) {
for (const key of item.photos) {
if (!photoUrls.value[key]) {
api.get('/upload/download-url', { params: { object_key: key } }).then(r => {
photoUrls.value[key] = r.data.download_url
}).catch(() => {})
}
}
}
dialogVisible.value = true dialogVisible.value = true
} }
function previewPhoto(url: string) { previewImageUrl.value = url; previewDialogVisible.value = true }
async function handleDialogPhotoUpload(event: Event) {
const target = event.target as HTMLInputElement
if (!target.files?.length) return
if ((form.value.photos || []).length >= 9) { ElMessage.warning('最多9张照片'); return }
uploading.value = true
for (const file of Array.from(target.files)) {
if ((form.value.photos || []).length >= 9) break
try {
// Get presigned URL
const presignRes = await api.post('/upload/presigned-url', null, { params: { filename: file.name, content_type: file.type || 'image/jpeg' } })
// Upload directly to MinIO (not through our API)
const axios = (await import('axios')).default
await axios.put(presignRes.data.upload_url, file, { headers: { 'Content-Type': file.type || 'image/jpeg' } })
const key = presignRes.data.object_key
if (!form.value.photos) form.value.photos = []
form.value.photos.push(key)
form.value.photos = [...form.value.photos]
// Load preview
try {
const urlRes = await api.get('/upload/download-url', { params: { object_key: key } })
photoUrls.value[key] = urlRes.data.download_url
} catch (_) {}
} catch (e: any) {
ElMessage.error('上传失败: ' + (e.response?.data?.detail || e.message))
}
}
uploading.value = false
target.value = ''
}
function removePhotoFromEdit(idx: number) {
if (!form.value.photos) return
const key = form.value.photos[idx]
form.value.photos.splice(idx, 1)
form.value.photos = [...form.value.photos]
// Clean up preview URL
if (photoUrls.value[key]) {
delete photoUrls.value[key]
}
}
async function handleSave() { async function handleSave() {
const t = dialogType.value; const d = form.value const t = dialogType.value; const d = form.value
try { try {
@@ -203,6 +273,12 @@ const notesByDate = computed(() => {
</el-table-column> </el-table-column>
<el-table-column prop="visit_method" label="方式" width="60" /> <el-table-column prop="visit_method" label="方式" width="60" />
<el-table-column prop="time_range" label="时间" width="100" /> <el-table-column prop="time_range" label="时间" width="100" />
<el-table-column label="照片" width="60">
<template #default="{ row }">
<span v-if="row.photos?.length">📷{{ row.photos.length }}</span>
<span v-else style="color:var(--c-text-muted)">-</span>
</template>
</el-table-column>
<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 label="操作" width="70"> <el-table-column label="操作" width="70">
<template #default="{ row }"> <template #default="{ row }">
@@ -367,6 +443,23 @@ const notesByDate = computed(() => {
<el-form-item label="拜访人电话"><el-input v-model="form.visitor_phone" placeholder="联系电话可选" /></el-form-item> <el-form-item label="拜访人电话"><el-input v-model="form.visitor_phone" placeholder="联系电话可选" /></el-form-item>
<el-form-item label="沟通内容"><el-input v-model="form.communication_content" type="textarea" :rows="3" /></el-form-item> <el-form-item label="沟通内容"><el-input v-model="form.communication_content" type="textarea" :rows="3" /></el-form-item>
<el-form-item label="客户需求"><el-input v-model="form.customer_demand" type="textarea" :rows="2" /></el-form-item> <el-form-item label="客户需求"><el-input v-model="form.customer_demand" type="textarea" :rows="2" /></el-form-item>
<el-form-item v-if="dialogMode === 'edit' && form.photos?.length" label="照片">
<div style="display:flex;gap:8px;flex-wrap:wrap">
<div v-for="(key, idx) in form.photos" :key="key" style="position:relative">
<img :src="photoUrls[key]" style="width:80px;height:80px;object-fit:cover;border:1px solid var(--warm-border);cursor:pointer" v-if="photoUrls[key]" @click.stop="previewPhoto(photoUrls[key])" />
<span v-else style="width:80px;height:80px;display:flex;align-items:center;justify-content:center;background:var(--paper);border:1px solid var(--warm-border);font-size:11px;color:var(--warm-gray)">加载中...</span>
<button type="button" style="position:absolute;top:-6px;right:-6px;background:var(--vermilion);color:#fff;border:none;width:18px;height:18px;font-size:12px;cursor:pointer;line-height:1;border-radius:50%" @click="removePhotoFromEdit(idx)" title="删除照片">×</button>
</div>
</div>
<div style="margin-top:4px;font-size:11px;color:var(--warm-gray)">点击 × 移除照片,点击「保存」提交更改</div>
</el-form-item>
<el-form-item v-if="dialogType === 'visit'" label="添加照片">
<label style="cursor:pointer;display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border:1px dashed var(--warm-border);color:var(--warm-gray);font-size:13px">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
{{ uploading ? '上传中...' : '上传照片(最多9张)' }}
<input type="file" accept="image/*" multiple style="display:none" @change="handleDialogPhotoUpload" />
</label>
</el-form-item>
</template> </template>
<template v-if="dialogType === 'note'"> <template v-if="dialogType === 'note'">
<el-form-item label="分类"><div style="display:flex;flex-wrap:wrap;gap:6px"><el-button v-for="c in categories" :key="c" :type="form.category===c?'primary':''" size="small" @click="form.category=c">{{ c }}</el-button></div></el-form-item> <el-form-item label="分类"><div style="display:flex;flex-wrap:wrap;gap:6px"><el-button v-for="c in categories" :key="c" :type="form.category===c?'primary':''" size="small" @click="form.category=c">{{ c }}</el-button></div></el-form-item>
@@ -408,6 +501,14 @@ const notesByDate = computed(() => {
<el-button type="primary" @click="handleSave">保存</el-button> <el-button type="primary" @click="handleSave">保存</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- Image Preview -->
<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>
+8
View File
@@ -179,6 +179,14 @@ async function handleImportPreview() {
<div v-if="importResult" style="margin-top:12px"> <div v-if="importResult" style="margin-top:12px">
<el-alert type="success" :closable="false"> <el-alert type="success" :closable="false">
导入完成拜访 {{ importResult.visits }} 跳过 {{ importResult.skipped }} 导入完成拜访 {{ importResult.visits }} 跳过 {{ importResult.skipped }}
<template v-if="importResult.skip_reasons?.length">
<div style="margin-top:8px; font-size:12px; max-height:200px; overflow-y:auto">
<div v-for="(r, i) in importResult.skip_reasons.slice(0, 20)" :key="i"> {{ r }}</div>
<div v-if="importResult.skip_reasons.length > 20" style="color:var(--c-text-muted)">
...还有 {{ importResult.skip_reasons.length - 20 }}
</div>
</div>
</template>
</el-alert> </el-alert>
</div> </div>
</el-card> </el-card>
+29 -7
View File
@@ -14,6 +14,9 @@ const activeTab = ref('visits')
const filterManagerId = ref('') const filterManagerId = ref('')
const filterCustomerId = ref('') const filterCustomerId = ref('')
const managers = ref<any[]>([]) const managers = ref<any[]>([])
const weekOffset = ref(0)
const isHistoricalWeek = computed(() => weekOffset.value < 0)
const report = ref({ const report = ref({
week_start: '', week_end: '', week_start: '', week_end: '',
@@ -37,12 +40,22 @@ onMounted(async () => {
} catch (_) {} } catch (_) {}
}) })
function changeWeek(delta: number) { weekOffset.value += delta; loadReport() }
function goCurrentWeek() { weekOffset.value = 0; loadReport() }
function getRefDate(): string {
const d = new Date()
d.setDate(d.getDate() + weekOffset.value * 7)
return d.toISOString().slice(0, 10)
}
async function loadReport() { async function loadReport() {
loading.value = true loading.value = true
try { try {
const params: any = {} const params: any = {}
if (filterManagerId.value) params.manager_id = filterManagerId.value if (filterManagerId.value) params.manager_id = filterManagerId.value
if (filterCustomerId.value) params.customer_id = filterCustomerId.value if (filterCustomerId.value) params.customer_id = filterCustomerId.value
params.reference_date = getRefDate()
const res = await dashboardApi.getWeeklyReport(params) const res = await dashboardApi.getWeeklyReport(params)
report.value = res.data report.value = res.data
for (const v of report.value.visits) { for (const v of report.value.visits) {
@@ -66,7 +79,7 @@ async function loadReport() {
async function handleExport() { async function handleExport() {
try { try {
const res = await api.get('/export/weekly-report', { responseType: 'blob' }) const res = await api.get('/export/weekly-report', { params: { reference_date: getRefDate() }, responseType: 'blob' })
const url = URL.createObjectURL(res.data) const url = URL.createObjectURL(res.data)
const a = document.createElement('a') const a = document.createElement('a')
a.href = url; a.download = 'weekly_report.xlsx'; a.click() a.href = url; a.download = 'weekly_report.xlsx'; a.click()
@@ -108,7 +121,13 @@ const notesByDate = computed(() => {
<div class="page-head-row"> <div class="page-head-row">
<div> <div>
<h2 class="page-title">周报详情</h2> <h2 class="page-title">周报详情</h2>
<p class="page-sub">{{ report.week_start }} {{ report.week_end }}</p> <div class="week-nav">
<button class="week-nav-btn" @click="changeWeek(-1)"></button>
<span class="week-label">{{ report.week_start }} {{ report.week_end }}</span>
<button class="week-nav-btn" @click="changeWeek(1)" :disabled="weekOffset >= 0"></button>
<button v-if="isHistoricalWeek" class="week-nav-reset" @click="goCurrentWeek">回到本周</button>
<el-tag v-if="isHistoricalWeek" type="info" size="small">📦 已归档</el-tag>
</div>
</div> </div>
<div> <div>
<el-button v-if="auth.isDirector" type="success" @click="handleExport"> <el-button v-if="auth.isDirector" type="success" @click="handleExport">
@@ -271,11 +290,14 @@ const notesByDate = computed(() => {
font-size: 22px; font-weight: 400; font-size: 22px; font-weight: 400;
color: var(--ink); letter-spacing: 0.06em; color: var(--ink); letter-spacing: 0.06em;
} }
.page-sub { .week-nav { display: flex; align-items: center; gap: 8px; margin: 4px 0 6px; }
margin: 2px 0 0; .week-nav-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 3px 8px; cursor: pointer; color: var(--warm-gray); font-size: 12px; }
font-family: 'JetBrains Mono', 'SF Mono', monospace; .week-nav-btn:hover:not(:disabled) { color: var(--ink); border-color: var(--ink); }
font-size: 11px; color: var(--gold); letter-spacing: 0.08em; .week-nav-btn:disabled { opacity: 0.3; cursor: not-allowed; }
} .week-label { font-family: 'JetBrains Mono', 'SF Mono', monospace; font-size: 12px; color: var(--ink); }
.week-nav-reset { background: none; border: 1px solid var(--gold); color: var(--gold); padding: 3px 8px; cursor: pointer; font-size: 12px; }
.week-nav-reset:hover { background: var(--gold); color: #fff; }
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; } .page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
/* ═══ Content ═══ */ /* ═══ Content ═══ */
+12 -1
View File
@@ -11,6 +11,8 @@ const todayVisitCount = ref(0)
const visits = ref<any[]>([]) const visits = ref<any[]>([])
const todayNoteCount = ref(0) const todayNoteCount = ref(0)
const dailyNotes = ref<any[]>([]) const dailyNotes = ref<any[]>([])
const previewImageUrl = ref('')
const previewDialogVisible = ref(false)
const photoUrls = ref<Record<string, string>>({}) const photoUrls = ref<Record<string, string>>({})
const loading = ref(false) const loading = ref(false)
@@ -52,6 +54,8 @@ async function loadToday() {
} finally { loading.value = false } } finally { loading.value = false }
} }
function previewPhoto(url: string) { previewImageUrl.value = url; previewDialogVisible.value = true }
onMounted(loadToday) onMounted(loadToday)
</script> </script>
@@ -174,7 +178,7 @@ onMounted(loadToday)
<span class="demand-marker"></span> {{ v.customer_demand }} <span class="demand-marker"></span> {{ v.customer_demand }}
</p> </p>
<div v-if="v.photos?.length" class="record-photos"> <div v-if="v.photos?.length" class="record-photos">
<img v-for="key in v.photos" :key="key" :src="photoUrls[key] || ''" class="record-photo" /> <img v-for="key in v.photos" :key="key" :src="photoUrls[key] || ''" class="record-photo" @click.stop="previewPhoto(photoUrls[key])" />
</div> </div>
<div class="record-footer" v-if="v.time_range"> <div class="record-footer" v-if="v.time_range">
<span class="record-time">{{ v.time_range }}</span> <span class="record-time">{{ v.time_range }}</span>
@@ -190,6 +194,13 @@ onMounted(loadToday)
<p class="empty-hint">点击上方按钮开始填报</p> <p class="empty-hint">点击上方按钮开始填报</p>
</div> </div>
</div> </div>
<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>
+16 -1
View File
@@ -34,6 +34,8 @@ const customers = ref<any[]>([])
const managers = ref<any[]>([]) const managers = ref<any[]>([])
const uploadedPhotos = ref<string[]>([]) const uploadedPhotos = ref<string[]>([])
const photoPreviews = ref<string[]>([]) const photoPreviews = ref<string[]>([])
const previewDialogVisible = ref(false)
const previewImageUrl = ref('')
const uploading = ref(false) const uploading = ref(false)
const customerSearch = ref('') const customerSearch = ref('')
@@ -136,6 +138,11 @@ function onTimeRangeChange(val: [string, string] | null) {
form.value.time_range = val ? val.join('-') : '' form.value.time_range = val ? val.join('-') : ''
} }
function previewPhoto(url: string) {
previewImageUrl.value = url
previewDialogVisible.value = true
}
function removePhoto(index: number) { function removePhoto(index: number) {
if (index < photoPreviews.value.length) { if (index < photoPreviews.value.length) {
URL.revokeObjectURL(photoPreviews.value[index]) URL.revokeObjectURL(photoPreviews.value[index])
@@ -301,7 +308,7 @@ async function handleDelete() {
</template> </template>
<div class="photo-area"> <div class="photo-area">
<div v-for="(key, idx) in uploadedPhotos" :key="key" class="photo-item"> <div v-for="(key, idx) in uploadedPhotos" :key="key" class="photo-item">
<img :src="photoPreviews[idx]" class="photo-thumb" v-if="photoPreviews[idx]" /> <img :src="photoPreviews[idx]" class="photo-thumb" v-if="photoPreviews[idx]" @click.stop="previewPhoto(photoPreviews[idx])" />
<span class="photo-label">照片{{ idx + 1 }}</span> <span class="photo-label">照片{{ idx + 1 }}</span>
<button type="button" class="photo-remove-btn" @click="removePhoto(idx)"> <button type="button" class="photo-remove-btn" @click="removePhoto(idx)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -338,6 +345,14 @@ async function handleDelete() {
</button> </button>
</div> </div>
</el-form> </el-form>
<!-- Image Preview Dialog -->
<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>