feat: 信息架构重组 + 变更追踪 + 图片预览增强

- 信息架构重组: 周报精简为拜访+纪要两个Tab,工作计划/商机/要客独立为侧边栏「工作」分组下的独立页面
- 侧边栏分组: 汇总/工作/管理三层分组,仪表盘四卡可点击跳转
- 变更追踪(edit_log): 5张表新增JSONB edit_log列,POST创建/PUT diff自动记录,编辑弹窗变更时间轴,表格🕐编辑标记
- 图片预览增强: ImagePreview统一组件,支持适应页面/缩放/拖拽平移/滚轮缩放/键盘快捷键
- 修复客户导入500错误(errors变量未初始化)
- 移除工作计划/商机/要客页面冗余编辑按钮

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-24 08:28:46 +08:00
parent aa3fbca710
commit bf67e0575f
27 changed files with 1332 additions and 117 deletions
+61 -19
View File
@@ -8,20 +8,47 @@ const router = useRouter()
const auth = useAuthStore()
const collapsed = ref(false)
const menuItems = computed(() => {
const items: { path: string; label: string; icon: string }[] = [
{ path: '/', label: '仪表盘', icon: '<polyline points="4 7 12 3 20 7"></polyline><polyline points="20 7 20 21 4 21 4 7"></polyline><line x1="8" y1="21" x2="8" y2="12"></line><line x1="16" y1="21" x2="16" y2="12"></line>' },
{ path: '/weekly-report', label: '周报详情', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line>' },
interface MenuGroup { label?: string; items: { path: string; label: string; icon: string }[] }
const menuGroups = computed<MenuGroup[]>(() => {
const groups: MenuGroup[] = [
{
label: '汇总',
items: [
{ path: '/', label: '仪表盘', icon: '<polyline points="4 7 12 3 20 7"></polyline><polyline points="20 7 20 21 4 21 4 7"></polyline><line x1="8" y1="21" x2="8" y2="12"></line><line x1="16" y1="21" x2="16" y2="12"></line>' },
{ path: '/weekly-report', label: '周报', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line>' },
],
},
{
label: '工作',
items: [
{ path: '/work-plans', label: '工作计划', icon: '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line><polyline points="8 14 12 18 16 14"></polyline>' },
{ path: '/mini-business', label: '商机跟单', icon: '<line x1="12" y1="1" x2="12" y2="23"></line><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>' },
{ path: '/key-visits', label: '要客拜访', icon: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>' },
],
},
]
if (auth.isManager || auth.isDirector) {
items.push({ path: '/workspace', label: '我的数据', icon: '<line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line>' })
groups.push({
label: undefined,
items: [
{ path: '/workspace', label: '我的数据', icon: '<line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line>' },
],
})
}
items.push({ path: '/customers', label: '客户管理', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle>' })
groups.push({
label: '管理',
items: [
{ path: '/customers', label: '客户管理', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle>' },
],
})
if (auth.isDirector) {
items.push({ path: '/users', label: '用户管理', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path>' })
items.push({ path: '/settings', label: '系统设置', icon: '<circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>' })
groups[groups.length - 1].items.push(
{ path: '/users', label: '用户管理', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path>' },
{ path: '/settings', label: '系统设置', icon: '<circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>' },
)
}
return items
return groups
})
const roleLabel = computed(() => {
@@ -42,16 +69,19 @@ const roleLabel = computed(() => {
</div>
<nav class="sidebar-nav">
<router-link
v-for="item in menuItems"
:key="item.path"
:to="item.path"
class="nav-item"
:class="{ 'nav-item--active': route.path === item.path }"
>
<svg class="nav-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" v-html="item.icon"></svg>
<span class="nav-label">{{ item.label }}</span>
</router-link>
<template v-for="group in menuGroups" :key="group.label || 'nogroup'">
<div v-if="group.label" class="nav-group-label">{{ group.label }}</div>
<router-link
v-for="item in group.items"
:key="item.path"
:to="item.path"
class="nav-item"
:class="{ 'nav-item--active': route.path === item.path }"
>
<svg class="nav-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" v-html="item.icon"></svg>
<span class="nav-label">{{ item.label }}</span>
</router-link>
</template>
</nav>
<div class="sidebar-footer">
@@ -162,8 +192,20 @@ const roleLabel = computed(() => {
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
}
.nav-group-label {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 10px;
letter-spacing: 0.15em;
color: rgba(255,255,255,0.25);
padding: 12px 14px 4px;
text-transform: uppercase;
}
.sidebar--collapsed .nav-group-label { display: none; }
.nav-item {
display: flex;
align-items: center;
+196
View File
@@ -0,0 +1,196 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
editLog: Array<{ editor: string; time: string; changes: Record<string, [string, string]>; reason?: string }>
}>()
const fieldLabels: Record<string, string> = {
customer_id: '客户单位',
customer_name: '客户名称',
visit_date: '拜访日期',
note_date: '日期',
visit_method: '拜访方式',
time_range: '时间范围',
visitor_name: '拜访人',
visitor_phone: '电话',
communication_content: '沟通内容',
customer_demand: '客户需求',
category: '分类',
content: '工作内容',
plan_content: '计划内容',
plan_date: '计划时间',
status: '状态',
product_type: '产品类型',
amount: '金额',
follow_up_detail: '跟进内容',
expected_revenue_date: '预计列收',
urgency_level: '紧急度',
description: '描述',
progress_status: '进展',
planned_date: '计划日期',
planned_visitor: '拜访人',
visit_target: '拜访对象',
}
const hasHistory = computed(() => (props.editLog || []).length > 0)
function formatTime(t: string) {
try {
const d = new Date(t)
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
} catch { return t }
}
function labelFor(field: string) {
return fieldLabels[field] || field
}
</script>
<template>
<el-collapse v-if="hasHistory" style="margin-top:16px">
<el-collapse-item title="变更记录" name="1">
<div class="log-timeline">
<div v-for="(entry, idx) in editLog" :key="idx" class="log-entry"
:class="{ 'log-entry--create': !entry.changes || Object.keys(entry.changes).length === 0 }">
<div class="log-dot"></div>
<div class="log-body">
<div class="log-meta">
<span class="log-editor">{{ entry.editor }}</span>
<span class="log-time">{{ formatTime(entry.time) }}</span>
<el-tag v-if="!entry.changes || Object.keys(entry.changes).length === 0" size="small" type="info" effect="plain">创建</el-tag>
</div>
<div v-if="entry.changes && Object.keys(entry.changes).length > 0" class="log-changes">
<div v-for="(vals, field) in entry.changes" :key="field" class="log-change">
<span class="change-label">{{ labelFor(field) }}</span>
<span class="change-old">{{ vals[0] || '(空)' }}</span>
<span class="change-arrow"></span>
<span class="change-new">{{ vals[1] || '(空)' }}</span>
</div>
</div>
<div v-if="entry.reason" class="log-reason">
<span class="reason-label">原因</span>{{ entry.reason }}
</div>
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
</template>
<style scoped>
.log-timeline {
padding: 4px 0;
}
.log-entry {
display: flex;
gap: 10px;
padding: 10px 0;
border-left: 2px solid var(--warm-border);
margin-left: 6px;
padding-left: 16px;
}
.log-entry:first-child {
border-left-color: var(--gold);
}
.log-entry--create {
opacity: 0.6;
}
.log-dot {
width: 8px; height: 8px;
background: var(--warm-border);
margin-left: -21px;
margin-top: 5px;
flex-shrink: 0;
}
.log-entry:first-child .log-dot {
background: var(--gold);
}
.log-body {
flex: 1;
min-width: 0;
}
.log-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.log-editor {
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 13px;
color: var(--ink);
font-weight: 600;
}
.log-time {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 11px;
color: var(--c-text-muted);
}
.log-changes {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 4px;
}
.log-change {
font-size: 12px;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.change-label {
font-weight: 600;
color: var(--ink);
min-width: 56px;
}
.change-old {
color: var(--vermilion);
text-decoration: line-through;
text-decoration-color: var(--vermilion);
text-decoration-thickness: 1px;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.change-arrow {
color: var(--gold);
font-weight: 600;
}
.change-new {
color: var(--sage);
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.log-reason {
margin-top: 4px;
font-size: 12px;
color: var(--c-text-secondary);
font-style: italic;
}
.reason-label {
color: var(--warm-gray);
font-weight: 500;
}
</style>
+324
View File
@@ -0,0 +1,324 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
const props = defineProps<{
modelValue: boolean
imageUrl: string
}>()
const emit = defineEmits<{
'update:modelValue': [v: boolean]
}>()
const scale = ref(1)
const fitToPage = ref(true)
const panX = ref(0)
const panY = ref(0)
const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartY = ref(0)
const panStartX = ref(0)
const panStartY = ref(0)
const imageEl = ref<HTMLImageElement | null>(null)
const overlayEl = ref<HTMLDivElement | null>(null)
function close() {
emit('update:modelValue', false)
}
function reset() {
scale.value = 1
fitToPage.value = true
panX.value = 0
panY.value = 0
}
watch(() => props.imageUrl, reset)
watch(() => props.modelValue, (v) => { if (v) reset() })
function zoomIn() {
fitToPage.value = false
scale.value = Math.min(5, +(scale.value + 0.25).toFixed(2))
clampPan()
}
function zoomOut() {
fitToPage.value = false
const next = +(scale.value - 0.25).toFixed(2)
if (next < 0.25) {
scale.value = 1
fitToPage.value = true
panX.value = 0
panY.value = 0
} else {
scale.value = next
clampPan()
}
}
function toggleFit() {
if (fitToPage.value) {
fitToPage.value = false
scale.value = 1
} else {
fitToPage.value = true
scale.value = 1
panX.value = 0
panY.value = 0
}
}
function clampPan() {
if (!imageEl.value || !overlayEl.value) return
const img = imageEl.value
const naturalRatio = img.naturalWidth / img.naturalHeight
const viewW = overlayEl.value.clientWidth
const viewH = overlayEl.value.clientHeight
const displayW = naturalRatio > viewW / viewH ? viewW : viewH * naturalRatio
const displayH = naturalRatio > viewW / viewH ? viewW / naturalRatio : viewH
const scaledW = displayW * scale.value
const scaledH = displayH * scale.value
const maxX = Math.max(0, (scaledW - viewW) / 2)
const maxY = Math.max(0, (scaledH - viewH) / 2)
panX.value = Math.max(-maxX, Math.min(maxX, panX.value))
panY.value = Math.max(-maxY, Math.min(maxY, panY.value))
}
function onWheel(e: WheelEvent) {
e.preventDefault()
if (e.deltaY < 0) zoomIn()
else zoomOut()
}
function onDblClickImg(e: MouseEvent) {
e.preventDefault()
toggleFit()
}
/* ── Drag to pan ── */
function onPointerDown(e: PointerEvent) {
if (fitToPage.value) return
isDragging.value = true
dragStartX.value = e.clientX
dragStartY.value = e.clientY
panStartX.value = panX.value
panStartY.value = panY.value
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
}
function onPointerMove(e: PointerEvent) {
if (!isDragging.value) return
panX.value = panStartX.value + (e.clientX - dragStartX.value)
panY.value = panStartY.value + (e.clientY - dragStartY.value)
clampPan()
}
function onPointerUp(_e: PointerEvent) {
isDragging.value = false
}
function onOverlayClick(e: MouseEvent) {
if (e.target === overlayEl.value) close()
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') close()
if (e.key === '+') zoomIn()
if (e.key === '-') zoomOut()
if (e.key === '0') reset()
}
const scalePercent = computed(() => Math.round(scale.value * 100))
const imgStyle = computed(() => {
if (fitToPage.value) {
return {
maxWidth: '95vw',
maxHeight: '90vh',
objectFit: 'contain' as const,
transform: 'none',
cursor: 'default',
}
}
return {
maxWidth: 'none',
maxHeight: 'none',
objectFit: 'none' as const,
transform: `translate(${panX.value}px, ${panY.value}px) scale(${scale.value})`,
transformOrigin: 'center center',
cursor: isDragging.value ? 'grabbing' : (scale.value > 1 ? 'grab' : 'default'),
}
})
</script>
<template>
<teleport to="body">
<div
v-if="modelValue"
ref="overlayEl"
class="image-preview-overlay"
@click="onOverlayClick"
@wheel="onWheel"
@keydown="onKeydown"
tabindex="0"
>
<!-- Image wrapper for centering -->
<div class="preview-stage" @dblclick="onDblClickImg">
<img
ref="imageEl"
:src="imageUrl"
:style="imgStyle"
class="preview-img"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointerleave="onPointerUp"
@dblclick.stop="onDblClickImg"
@dragstart.prevent
/>
</div>
<!-- Bottom toolbar -->
<div class="preview-toolbar">
<button class="toolbar-btn" title="缩小" @click="zoomOut">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="toolbar-btn toolbar-btn--fit" :class="{ active: fitToPage }" title="适应页面" @click="toggleFit">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
</svg>
<span v-if="fitToPage" class="fit-label">适应</span>
<span v-else class="fit-label">{{ scalePercent }}%</span>
</button>
<button class="toolbar-btn" title="放大" @click="zoomIn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<div class="toolbar-divider"></div>
<button class="toolbar-btn toolbar-btn--close" title="关闭 (Esc)" @click="close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</div>
</teleport>
</template>
<style scoped>
/* ═══ Overlay ═══ */
.image-preview-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
outline: none;
user-select: none;
-webkit-user-select: none;
}
/* ═══ Stage (image container) ═══ */
.preview-stage {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
overflow: hidden;
}
/* ═══ Image ═══ */
.preview-img {
display: block;
transition: transform 0.08s ease-out;
pointer-events: auto;
}
/* ═══ Bottom Toolbar ═══ */
.preview-toolbar {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 2px;
background: rgba(28, 55, 56, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
padding: 6px 8px;
border-radius: 6px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
z-index: 10;
}
.toolbar-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 36px;
height: 36px;
padding: 0 8px;
border: none;
border-radius: 4px;
background: transparent;
color: rgba(255, 255, 255, 0.75);
cursor: pointer;
font-family: 'Noto Serif SC', STSong, serif;
font-size: 12px;
transition: all 0.15s;
white-space: nowrap;
}
.toolbar-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.toolbar-btn.active {
background: rgba(196, 147, 74, 0.3);
color: var(--gold-light, #D4AD6E);
}
.toolbar-btn--fit {
min-width: 56px;
}
.fit-label {
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 11px;
letter-spacing: 0.03em;
}
.toolbar-divider {
width: 1px;
height: 20px;
background: rgba(255, 255, 255, 0.2);
margin: 0 4px;
}
.toolbar-btn--close:hover {
background: rgba(184, 71, 46, 0.5);
color: #fff;
}
/* ── Mobile tweaks ── */
@media (max-width: 768px) {
.preview-toolbar {
bottom: 20px;
padding: 8px 12px;
gap: 4px;
border-radius: 8px;
}
.toolbar-btn {
min-width: 40px;
height: 40px;
border-radius: 6px;
}
.toolbar-btn--fit {
min-width: 60px;
}
}
</style>