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] **旧周报导入模板** — 四 Sheet Excel 模板下载 + 示例数据
- [x] **数据库迁移** — lifespan 自动 ALTER TABLE 加列 (remarks/visitor_name/visitor_phone)
- [x] **历史周报** — 周选择器翻看往周数据,历史归档只读,导出支持历史周
- [x] **图片预览** — 全屏大图查看(移动端+PC端统一),点击遮罩关闭
- [x] **PC端照片管理** — 编辑时可上传新照片、删除已有照片
- [x] **客户导入更新** — 重名客户自动更新信息而非跳过,显示逐条操作明细
- [x] **导入跳过原因** — 旧周报导入+客户导入均显示跳过/更新明细
- [x] **用户去重合并** — 4组重复用户合并,数据完整迁移
- [x] **填报进度权限** — 经理只看到自己,支局长/领导看全员
- [x] **客户经理列** — 暖灰色hash标签,每人唯一颜色
- [x] **列表序号** — 客户管理+用户管理添加序号列
### 待完善
+37 -29
View File
@@ -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}")
+13 -5
View File
@@ -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,
)
+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 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",
+11 -9
View File
@@ -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 = {}
+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:
"""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(
+19
View File
@@ -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); }
</style>
+4 -4
View File
@@ -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 })
@@ -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() {
</el-form>
<div v-if="importResult" style="margin-top:12px">
<el-alert type="success" :closable="false">
新增 {{ importResult.created }} 跳过 {{ importResult.skipped }}
<template v-if="importResult.errors?.length"><br/>错误{{ importResult.errors.join('; ') }}</template>
新增 {{ importResult.created }} 更新 {{ importResult.updated || 0 }}
<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>
</div>
<template #footer>
+45 -11
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { dashboardApi } from '@/api/dashboard'
@@ -10,13 +10,37 @@ const auth = useAuthStore()
const loading = ref(false)
const stats = ref({ week_visits: 0, work_plans: 0, mini_business: 0, key_visits: 0, week_start: '', week_end: '' })
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
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([
dashboardApi.getStats(),
dashboardApi.getProgress(),
dashboardApi.getStats({ reference_date: refStr }),
dashboardApi.getProgress({ reference_date: refStr }),
])
stats.value = sRes.data
progress.value = pRes.data
@@ -25,7 +49,9 @@ onMounted(async () => {
} finally {
loading.value = false
}
})
}
onMounted(loadData)
function goWeeklyReport(managerId?: string) {
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 -->
<div class="page-head">
<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>
@@ -182,11 +214,13 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
font-size: 22px; font-weight: 400;
color: var(--ink); letter-spacing: 0.06em;
}
.page-sub {
margin: 2px 0 0;
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 11px; color: var(--gold); letter-spacing: 0.08em;
}
.week-nav { display: flex; align-items: center; gap: 10px; margin: 6px 0 4px; }
.week-nav-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 4px 10px; cursor: pointer; color: var(--warm-gray); font-size: 12px; }
.week-nav-btn:hover:not(:disabled) { color: var(--ink); border-color: var(--ink); }
.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; }
/* ═══ Stat Grid ═══ */
@@ -16,6 +16,10 @@ const customers = ref<any[]>([])
const allUsers = ref<any[]>([])
const plannedVisitors = ref<string[]>([])
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 dialogMode = ref<'create' | 'edit'>('create')
@@ -66,6 +70,19 @@ async function loadAll() {
visits.value = v.data; workPlans.value = w.data
miniBusiness.value = m.data; dailyNotes.value = d.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('加载失败') }
finally { loading.value = false }
}
@@ -92,9 +109,62 @@ function openEdit(type: string, item: any) {
if (type === 'key') {
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
}
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() {
const t = dialogType.value; const d = form.value
try {
@@ -203,6 +273,12 @@ const notesByDate = computed(() => {
</el-table-column>
<el-table-column prop="visit_method" label="方式" width="60" />
<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 label="操作" width="70">
<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.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 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 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>
@@ -408,6 +501,14 @@ const notesByDate = computed(() => {
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</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>
</template>
+8
View File
@@ -179,6 +179,14 @@ async function handleImportPreview() {
<div v-if="importResult" style="margin-top:12px">
<el-alert type="success" :closable="false">
导入完成拜访 {{ 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>
</div>
</el-card>
+29 -7
View File
@@ -14,6 +14,9 @@ const activeTab = ref('visits')
const filterManagerId = ref('')
const filterCustomerId = ref('')
const managers = ref<any[]>([])
const weekOffset = ref(0)
const isHistoricalWeek = computed(() => weekOffset.value < 0)
const report = ref({
week_start: '', week_end: '',
@@ -37,12 +40,22 @@ onMounted(async () => {
} 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() {
loading.value = true
try {
const params: any = {}
if (filterManagerId.value) params.manager_id = filterManagerId.value
if (filterCustomerId.value) params.customer_id = filterCustomerId.value
params.reference_date = getRefDate()
const res = await dashboardApi.getWeeklyReport(params)
report.value = res.data
for (const v of report.value.visits) {
@@ -66,7 +79,7 @@ async function loadReport() {
async function handleExport() {
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 a = document.createElement('a')
a.href = url; a.download = 'weekly_report.xlsx'; a.click()
@@ -108,7 +121,13 @@ const notesByDate = computed(() => {
<div class="page-head-row">
<div>
<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>
<el-button v-if="auth.isDirector" type="success" @click="handleExport">
@@ -271,11 +290,14 @@ const notesByDate = computed(() => {
font-size: 22px; font-weight: 400;
color: var(--ink); letter-spacing: 0.06em;
}
.page-sub {
margin: 2px 0 0;
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 11px; color: var(--gold); letter-spacing: 0.08em;
}
.week-nav { display: flex; align-items: center; gap: 8px; margin: 4px 0 6px; }
.week-nav-btn { background: var(--surface); border: 1px solid var(--warm-border); padding: 3px 8px; cursor: pointer; color: var(--warm-gray); font-size: 12px; }
.week-nav-btn:hover:not(:disabled) { color: var(--ink); border-color: var(--ink); }
.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; }
/* ═══ Content ═══ */
+12 -1
View File
@@ -11,6 +11,8 @@ const todayVisitCount = ref(0)
const visits = ref<any[]>([])
const todayNoteCount = ref(0)
const dailyNotes = ref<any[]>([])
const previewImageUrl = ref('')
const previewDialogVisible = ref(false)
const photoUrls = ref<Record<string, string>>({})
const loading = ref(false)
@@ -52,6 +54,8 @@ async function loadToday() {
} finally { loading.value = false }
}
function previewPhoto(url: string) { previewImageUrl.value = url; previewDialogVisible.value = true }
onMounted(loadToday)
</script>
@@ -174,7 +178,7 @@ onMounted(loadToday)
<span class="demand-marker"></span> {{ v.customer_demand }}
</p>
<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 class="record-footer" v-if="v.time_range">
<span class="record-time">{{ v.time_range }}</span>
@@ -190,6 +194,13 @@ onMounted(loadToday)
<p class="empty-hint">点击上方按钮开始填报</p>
</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>
</template>
+16 -1
View File
@@ -34,6 +34,8 @@ const customers = ref<any[]>([])
const managers = ref<any[]>([])
const uploadedPhotos = ref<string[]>([])
const photoPreviews = ref<string[]>([])
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() {
</template>
<div class="photo-area">
<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>
<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">
@@ -338,6 +345,14 @@ async function handleDelete() {
</button>
</div>
</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>
</template>