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
+6 -4
View File
@@ -82,7 +82,7 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
<!-- Stats Cards -->
<div class="stat-grid">
<div class="stat-card">
<div class="stat-card stat-card--clickable" @click="router.push('/weekly-report')">
<div class="stat-glyph stat-glyph--ink">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
@@ -94,7 +94,7 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
<span class="stat-label">本周拜访</span>
</div>
</div>
<div class="stat-card">
<div class="stat-card stat-card--clickable" @click="router.push('/work-plans')">
<div class="stat-glyph stat-glyph--sage">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
@@ -108,7 +108,7 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
<span class="stat-label">工作计划</span>
</div>
</div>
<div class="stat-card">
<div class="stat-card stat-card--clickable" @click="router.push('/mini-business')">
<div class="stat-glyph stat-glyph--gold">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="1" x2="12" y2="23"></line>
@@ -120,7 +120,7 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
<span class="stat-label">商机跟单</span>
</div>
</div>
<div class="stat-card">
<div class="stat-card stat-card--clickable" @click="router.push('/key-visits')">
<div class="stat-glyph stat-glyph--vermilion">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<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>
@@ -241,6 +241,8 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
transition: all var(--transition);
}
.stat-card:hover { box-shadow: var(--shadow-md); }
.stat-card--clickable { cursor: pointer; }
.stat-card--clickable:hover { border-color: var(--gold); }
.stat-glyph {
width: 48px; height: 48px;
+217
View File
@@ -0,0 +1,217 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { ElMessage, ElMessageBox } from 'element-plus'
import { todayStr } from '@/utils'
import api from '@/api/index'
import EditLogPanel from '@/components/EditLogPanel.vue'
const auth = useAuthStore()
const loading = ref(false)
const keyVisits = ref<any[]>([])
const customers = ref<any[]>([])
const allUsers = ref<any[]>([])
const plannedVisitors = ref<string[]>([])
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const form = ref<any>({})
const statusPick = ref<Record<string, string>>({})
const keyStatuses = ['未开始', '进行中', '已完成']
onMounted(async () => {
await Promise.all([loadItems(), loadCustomers(), loadUsers()])
})
async function loadUsers() {
try {
const res = await api.get('/users/')
allUsers.value = res.data || []
} catch (_) {}
}
async function loadCustomers(q?: string) {
try {
const params: any = { page_size: 100 }
if (q) params.search = q
const res = await api.get('/customers/', { params })
customers.value = res.data.items || []
} catch (_) {}
}
async function loadItems() {
loading.value = true
try {
const res = await api.get('/key-visits/')
keyVisits.value = res.data
} catch (e: any) { ElMessage.error('加载失败') }
finally { loading.value = false }
}
function openCreate() {
dialogMode.value = 'create'
form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '' }
plannedVisitors.value = []
dialogVisible.value = true
}
function openEdit(item: any) {
dialogMode.value = 'edit'
form.value = { ...item }
plannedVisitors.value = item.planned_visitor ? item.planned_visitor.split('、') : []
dialogVisible.value = true
}
function onVisitorsChange(val: string[]) {
form.value.planned_visitor = val.join('、')
}
async function handleSave() {
try {
if (dialogMode.value === 'create') {
await api.post('/key-visits/', form.value)
} else {
await api.put(`/key-visits/${form.value.id}`, form.value)
}
ElMessage.success(dialogMode.value === 'create' ? '已创建' : '已更新')
dialogVisible.value = false
await loadItems()
} catch (e: any) { ElMessage.error('保存失败: ' + (e.response?.data?.detail || e.message)) }
}
async function handleDelete(id: string) {
try {
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
await api.delete(`/key-visits/${id}`)
ElMessage.success('已删除')
await loadItems()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
async function quickStatusChange(row: any, newStatus: string) {
try {
await ElMessageBox.confirm(`确定将进展改为「${newStatus}」?`, '确认', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' })
await api.put(`/key-visits/${row.id}`, { progress_status: newStatus })
ElMessage.success('状态已更新')
await loadItems()
} catch (e: any) {
if (e !== 'cancel') ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message))
delete statusPick.value[row.id]
}
}
</script>
<template>
<div class="key-visits-page" v-loading="loading">
<div class="page-head">
<div class="page-head-row">
<div>
<h2 class="page-title">要客拜访</h2>
<p class="page-desc">重要客户拜访计划按紧急程度排序跟踪</p>
</div>
<el-button type="primary" @click="openCreate">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
新建要客
</el-button>
</div>
<div class="page-rule"></div>
</div>
<el-card>
<el-table :data="keyVisits" stripe size="small" v-if="keyVisits.length">
<el-table-column type="index" label="序号" width="50" />
<el-table-column prop="customer_name" label="客户" width="160">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="openEdit(row)">{{ row.customer_name }}</el-link>
<el-tooltip v-if="row.edit_log?.length > 1" placement="top">
<template #content>最后编辑{{ row.edit_log[row.edit_log.length-1].editor }} · {{ row.edit_log.length-1 }}次修改</template>
<span class="edit-indicator" title="有过修改">🕐</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="urgency_level" label="重要度" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.urgency_level==='紧急'?'danger':row.urgency_level==='重要'?'warning':''">{{ row.urgency_level }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="250" show-overflow-tooltip />
<el-table-column label="进展" width="130">
<template #default="{ row }">
<el-select
v-model="statusPick[row.id]"
size="small"
style="width:100%"
:placeholder="row.progress_status"
@change="(v: string) => quickStatusChange(row, v)"
@visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.progress_status }"
>
<el-option v-for="s in keyStatuses" :key="s" :label="s" :value="s" />
</el-select>
</template>
</el-table-column>
<el-table-column prop="planned_date" label="计划时间" width="110" />
<el-table-column prop="planned_visitor" label="拜访人" width="80" />
<el-table-column prop="visit_target" label="拜访对象" width="110" />
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button text size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-else class="empty">暂无需重点关注的客户</div>
</el-card>
<!-- Dialog -->
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 要客拜访'" width="520px">
<el-form label-position="top">
<el-form-item label="客户单位">
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="紧急重要度">
<div class="method-grid">
<el-button v-for="u in ['一般','重要','紧急']" :key="u" size="small"
:type="form.urgency_level===u ? (u==='紧急'?'danger':u==='重要'?'warning':'') : ''"
@click="form.urgency_level=u">{{ u }}</el-button>
</div>
</el-form-item>
<el-form-item label="内容描述"><el-input v-model="form.description" type="textarea" :rows="4" placeholder="请输入要客拜访描述" /></el-form-item>
<el-form-item label="进展状态">
<el-select v-model="form.progress_status">
<el-option label="未开始" value="未开始" />
<el-option label="进行中" value="进行中" />
<el-option label="已完成" value="已完成" />
</el-select>
</el-form-item>
<el-form-item label="计划拜访时间">
<el-date-picker v-model="form.planned_date" type="date" placeholder="选择日期" style="width:100%" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="计划拜访人可多选">
<el-select v-model="plannedVisitors" multiple filterable allow-create default-first-option placeholder="选择或输入姓名可多选" style="width:100%" @change="onVisitorsChange">
<el-option v-for="u in allUsers" :key="u.id" :label="u.name + (u.role==='director'?' (支局长)':u.role==='leader'?' (分管领导)':'')" :value="u.name" />
</el-select>
</el-form-item>
<el-form-item label="拜访对象"><el-input v-model="form.visit_target" placeholder="对方联系人/部门" /></el-form-item>
</el-form>
<EditLogPanel v-if="dialogMode === 'edit'" :edit-log="form.edit_log || []" />
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.edit-indicator { font-size: 12px; margin-left: 3px; opacity: 0.5; cursor: help; }
.page-head { margin-bottom: 20px; }
.page-head-row { display: flex; justify-content: space-between; align-items: flex-start; }
.page-title { margin: 0; font-family: 'ZCOOL XiaoWei', STSong, serif; font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
.page-desc { margin: 4px 0 0; font-size: 13px; color: var(--c-text-muted); font-family: 'Noto Serif SC', STSong, serif; }
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
.method-grid { display: flex; gap: 8px; }
.empty { text-align: center; color: var(--c-text-muted); padding: 48px 0; font-family: 'Noto Serif SC', STSong, serif; }
</style>
@@ -3,6 +3,8 @@ import { ref, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { todayStr } from '@/utils'
import api from '@/api/index'
import ImagePreview from '@/components/ImagePreview.vue'
import EditLogPanel from '@/components/EditLogPanel.vue'
const activeTab = ref('visits')
const loading = ref(false)
@@ -496,19 +498,14 @@ const notesByDate = computed(() => {
<el-form-item label="拜访对象"><el-input v-model="form.visit_target" /></el-form-item>
</template>
</el-form>
<EditLogPanel v-if="dialogMode === 'edit'" :edit-log="form.edit_log || []" />
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<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>
<ImagePreview v-model="previewDialogVisible" :image-url="previewImageUrl" />
</div>
</template>
+185
View File
@@ -0,0 +1,185 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { ElMessage, ElMessageBox } from 'element-plus'
import { todayStr } from '@/utils'
import api from '@/api/index'
import EditLogPanel from '@/components/EditLogPanel.vue'
const auth = useAuthStore()
const loading = ref(false)
const miniBusiness = ref<any[]>([])
const customers = ref<any[]>([])
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const form = ref<any>({})
const statusPick = ref<Record<string, string>>({})
const miniStatuses = ['跟进中', '已签约', '已流失']
onMounted(async () => {
await Promise.all([loadItems(), loadCustomers()])
})
async function loadCustomers(q?: string) {
try {
const params: any = { page_size: 100 }
if (q) params.search = q
const res = await api.get('/customers/', { params })
customers.value = res.data.items || []
} catch (_) {}
}
async function loadItems() {
loading.value = true
try {
const res = await api.get('/mini-business/')
miniBusiness.value = res.data
} catch (e: any) { ElMessage.error('加载失败') }
finally { loading.value = false }
}
function openCreate() {
dialogMode.value = 'create'
form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '' }
dialogVisible.value = true
}
function openEdit(item: any) {
dialogMode.value = 'edit'
form.value = { ...item }
dialogVisible.value = true
}
async function handleSave() {
try {
if (dialogMode.value === 'create') {
await api.post('/mini-business/', form.value)
} else {
await api.put(`/mini-business/${form.value.id}`, form.value)
}
ElMessage.success(dialogMode.value === 'create' ? '已创建' : '已更新')
dialogVisible.value = false
await loadItems()
} catch (e: any) { ElMessage.error('保存失败: ' + (e.response?.data?.detail || e.message)) }
}
async function handleDelete(id: string) {
try {
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
await api.delete(`/mini-business/${id}`)
ElMessage.success('已删除')
await loadItems()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
async function quickStatusChange(row: any, newStatus: string) {
try {
await ElMessageBox.confirm(`确定将状态改为「${newStatus}」?`, '确认', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' })
await api.put(`/mini-business/${row.id}`, { status: newStatus })
ElMessage.success('状态已更新')
await loadItems()
} catch (e: any) {
if (e !== 'cancel') ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message))
delete statusPick.value[row.id]
}
}
</script>
<template>
<div class="mini-biz-page" v-loading="loading">
<div class="page-head">
<div class="page-head-row">
<div>
<h2 class="page-title">商机跟单</h2>
<p class="page-desc">小微业务商机管道按状态跟踪签约进展</p>
</div>
<el-button type="primary" @click="openCreate">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
新建商机
</el-button>
</div>
<div class="page-rule"></div>
</div>
<el-card>
<el-table :data="miniBusiness" stripe size="small" v-if="miniBusiness.length">
<el-table-column type="index" label="序号" width="50" />
<el-table-column prop="customer_name" label="客户" width="160">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="openEdit(row)">{{ row.customer_name }}</el-link>
<el-tooltip v-if="row.edit_log?.length > 1" placement="top">
<template #content>最后编辑{{ row.edit_log[row.edit_log.length-1].editor }} · {{ row.edit_log.length-1 }}次修改</template>
<span class="edit-indicator" title="有过修改">🕐</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="product_type" label="产品类型" width="130" />
<el-table-column prop="amount" label="金额" width="100" />
<el-table-column prop="follow_up_detail" label="跟进内容" min-width="250" show-overflow-tooltip />
<el-table-column label="状态" width="130">
<template #default="{ row }">
<el-select
v-model="statusPick[row.id]"
size="small"
style="width:100%"
:placeholder="row.status"
@change="(v: string) => quickStatusChange(row, v)"
@visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.status }"
>
<el-option v-for="s in miniStatuses" :key="s" :label="s" :value="s" />
</el-select>
</template>
</el-table-column>
<el-table-column prop="expected_revenue_date" label="预计列收" width="110" />
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button text size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-else class="empty">暂无商机记录</div>
</el-card>
<!-- Dialog -->
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 小微商机'" width="500px">
<el-form label-position="top">
<el-form-item label="客户单位">
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="产品类型"><el-input v-model="form.product_type" placeholder="云专线SD-WAN" /></el-form-item>
<el-form-item label="金额"><el-input v-model="form.amount" placeholder="50000/" /></el-form-item>
<el-form-item label="跟进内容"><el-input v-model="form.follow_up_detail" type="textarea" :rows="4" placeholder="请输入跟进详情" /></el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status">
<el-option label="跟进中" value="跟进中" />
<el-option label="已签约" value="已签约" />
<el-option label="已流失" value="已流失" />
</el-select>
</el-form-item>
<el-form-item label="预计列收时间">
<el-date-picker v-model="form.expected_revenue_date" type="month" placeholder="选择月份" style="width:100%" value-format="YYYY-MM" />
</el-form-item>
</el-form>
<EditLogPanel v-if="dialogMode === 'edit'" :edit-log="form.edit_log || []" />
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.edit-indicator { font-size: 12px; margin-left: 3px; opacity: 0.5; cursor: help; }
.page-head { margin-bottom: 20px; }
.page-head-row { display: flex; justify-content: space-between; align-items: flex-start; }
.page-title { margin: 0; font-family: 'ZCOOL XiaoWei', STSong, serif; font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
.page-desc { margin: 4px 0 0; font-size: 13px; color: var(--c-text-muted); font-family: 'Noto Serif SC', STSong, serif; }
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
.empty { text-align: center; color: var(--c-text-muted); padding: 48px 0; font-family: 'Noto Serif SC', STSong, serif; }
</style>
+13 -62
View File
@@ -5,6 +5,7 @@ import { useAuthStore } from '@/stores/auth'
import { dashboardApi } from '@/api/dashboard'
import { uploadApi } from '@/api/upload'
import { ElMessage } from 'element-plus'
import ImagePreview from '@/components/ImagePreview.vue'
import api from '@/api/index'
const auth = useAuthStore()
@@ -22,9 +23,6 @@ const report = ref({
week_start: '', week_end: '',
visits: [] as any[],
daily_notes: [] as any[],
work_plans: [] as any[],
mini_business: [] as any[],
key_visits: [] as any[],
})
const photoUrls = ref<Record<string, string>>({})
@@ -120,7 +118,7 @@ const notesByDate = computed(() => {
<div class="page-head">
<div class="page-head-row">
<div>
<h2 class="page-title">周报详情</h2>
<h2 class="page-title">周报</h2>
<div class="week-nav">
<button class="week-nav-btn" @click="changeWeek(-1)"></button>
<span class="week-label">{{ report.week_start }} {{ report.week_end }}</span>
@@ -178,7 +176,15 @@ const notesByDate = computed(() => {
<el-table-column prop="time_range" label="时间" width="100" />
<el-table-column prop="communication_content" label="沟通内容" min-width="200" show-overflow-tooltip />
<el-table-column prop="customer_demand" label="客户需求" min-width="150" show-overflow-tooltip />
<el-table-column prop="manager_name" label="客户经理" width="80" />
<el-table-column label="客户经理" width="100">
<template #default="{ row }">
<span>{{ row.manager_name }}</span>
<el-tooltip v-if="row.edit_log?.length > 1" placement="top">
<template #content>最后编辑{{ row.edit_log[row.edit_log.length-1].editor }} · {{ row.edit_log.length-1 }}次修改</template>
<span class="edit-indicator" title="有过修改">🕐</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="照片" width="100">
<template #default="{ row }">
<div class="photo-cell" v-if="row.photos?.length">
@@ -193,40 +199,6 @@ const notesByDate = computed(() => {
</div>
</el-tab-pane>
<el-tab-pane label="工作计划" name="work_plans">
<el-table :data="report.work_plans" stripe>
<el-table-column prop="customer_name" label="客户" width="120" />
<el-table-column prop="plan_content" label="工作计划" min-width="250" />
<el-table-column prop="plan_date" label="计划时间" width="120" />
<el-table-column prop="manager_name" label="客户经理" width="80" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === '已完成' ? 'success' : row.status === '已取消' ? 'info' : ''">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="小微业务商机" name="mini_business">
<el-table :data="report.mini_business" stripe>
<el-table-column prop="customer_name" label="客户" width="120" />
<el-table-column prop="product_type" label="产品类型" width="120" />
<el-table-column prop="amount" label="金额" width="100" />
<el-table-column prop="follow_up_detail" label="跟进内容" min-width="200" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === '已签约' ? 'success' : row.status === '已流失' ? 'danger' : ''">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="manager_name" label="客户经理" width="80" />
<el-table-column prop="expected_revenue_date" label="预计列收" width="120" />
</el-table>
</el-tab-pane>
<el-tab-pane label="今日纪要" name="daily_notes">
<div v-if="report.daily_notes.length === 0" class="empty">暂无数据</div>
<div v-for="[date, items] in notesByDate" :key="date" class="date-group">
@@ -251,32 +223,10 @@ const notesByDate = computed(() => {
</el-table>
</div>
</el-tab-pane>
<el-tab-pane label="要客拜访计划" name="key_visits">
<el-table :data="report.key_visits" stripe>
<el-table-column prop="customer_name" label="客户" width="120" />
<el-table-column prop="urgency_level" label="重要度" width="80">
<template #default="{ row }">
<el-tag :type="row.urgency_level === '紧急' ? 'danger' : row.urgency_level === '重要' ? 'warning' : ''">
{{ row.urgency_level }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="200" />
<el-table-column prop="progress_status" label="进展" width="80" />
<el-table-column prop="planned_date" label="计划时间" width="120" />
<el-table-column prop="planned_visitor" label="拜访人" width="80" />
<el-table-column prop="visit_target" label="拜访对象" width="100" />
<el-table-column prop="manager_name" label="客户经理" width="80" />
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- Photo Viewer -->
<el-dialog v-model="photoDialogVisible" title="照片查看" width="80%">
<img :src="currentPhotoUrl" style="width:100%;" />
</el-dialog>
<ImagePreview v-model="photoDialogVisible" :image-url="currentPhotoUrl" />
</div>
</template>
@@ -310,5 +260,6 @@ const notesByDate = computed(() => {
}
.photo-cell { display: flex; gap: 4px; }
.mini-thumb { width: 36px; height: 36px; object-fit: cover; cursor: pointer; }
.edit-indicator { font-size: 12px; margin-left: 3px; opacity: 0.5; cursor: help; }
.empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: 'Noto Serif SC', STSong, serif; }
</style>
+183
View File
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { ElMessage, ElMessageBox } from 'element-plus'
import { todayStr } from '@/utils'
import api from '@/api/index'
import EditLogPanel from '@/components/EditLogPanel.vue'
const auth = useAuthStore()
const loading = ref(false)
const workPlans = ref<any[]>([])
const customers = ref<any[]>([])
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const form = ref<any>({})
const statusPick = ref<Record<string, string>>({})
const planStatuses = ['计划中', '已完成', '已取消']
onMounted(async () => {
await Promise.all([loadPlans(), loadCustomers()])
})
async function loadCustomers(q?: string) {
try {
const params: any = { page_size: 100 }
if (q) params.search = q
const res = await api.get('/customers/', { params })
customers.value = res.data.items || []
} catch (_) {}
}
async function loadPlans() {
loading.value = true
try {
const res = await api.get('/work-plans/')
workPlans.value = res.data
} catch (e: any) { ElMessage.error('加载失败') }
finally { loading.value = false }
}
function openCreate() {
dialogMode.value = 'create'
form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中' }
dialogVisible.value = true
}
function openEdit(item: any) {
dialogMode.value = 'edit'
form.value = { ...item }
dialogVisible.value = true
}
async function handleSave() {
try {
if (dialogMode.value === 'create') {
await api.post('/work-plans/', form.value)
} else {
await api.put(`/work-plans/${form.value.id}`, form.value)
}
ElMessage.success(dialogMode.value === 'create' ? '已创建' : '已更新')
dialogVisible.value = false
await loadPlans()
} catch (e: any) { ElMessage.error('保存失败: ' + (e.response?.data?.detail || e.message)) }
}
async function handleDelete(id: string) {
try {
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
await api.delete(`/work-plans/${id}`)
ElMessage.success('已删除')
await loadPlans()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
async function quickStatusChange(row: any, newStatus: string) {
try {
await ElMessageBox.confirm(`确定将状态改为「${newStatus}」?`, '确认', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' })
await api.put(`/work-plans/${row.id}`, { status: newStatus })
ElMessage.success('状态已更新')
await loadPlans()
} catch (e: any) {
if (e !== 'cancel') ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message))
delete statusPick.value[row.id]
}
}
</script>
<template>
<div class="work-plans-page" v-loading="loading">
<div class="page-head">
<div class="page-head-row">
<div>
<h2 class="page-title">工作计划</h2>
<p class="page-desc">面向未来的工作计划安排支持状态流转跟踪</p>
</div>
<el-button type="primary" @click="openCreate">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
新建计划
</el-button>
</div>
<div class="page-rule"></div>
</div>
<el-card>
<el-table :data="workPlans" stripe size="small" v-if="workPlans.length">
<el-table-column type="index" label="序号" width="50" />
<el-table-column prop="customer_name" label="客户" width="160">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="openEdit(row)">{{ row.customer_name }}</el-link>
<el-tooltip v-if="row.edit_log?.length > 1" placement="top">
<template #content>最后编辑{{ row.edit_log[row.edit_log.length-1].editor }} · {{ row.edit_log.length-1 }}次修改</template>
<span class="edit-indicator" title="有过修改">🕐</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="plan_content" label="工作计划" min-width="280" show-overflow-tooltip />
<el-table-column prop="plan_date" label="计划时间" width="110" />
<el-table-column label="状态" width="130">
<template #default="{ row }">
<el-select
v-model="statusPick[row.id]"
size="small"
style="width:100%"
:placeholder="row.status"
@change="(v: string) => quickStatusChange(row, v)"
@visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.status }"
>
<el-option v-for="s in planStatuses" :key="s" :label="s" :value="s" />
</el-select>
</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button text size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-else class="empty">暂无工作计划</div>
</el-card>
<!-- Dialog -->
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 工作计划'" width="500px">
<el-form label-position="top">
<el-form-item label="客户单位">
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="计划拜访时间">
<el-date-picker v-model="form.plan_date" type="date" style="width:100%" />
</el-form-item>
<el-form-item label="工作计划">
<el-input v-model="form.plan_content" type="textarea" :rows="4" placeholder="请输入计划内容" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status">
<el-option label="计划中" value="计划中" />
<el-option label="已完成" value="已完成" />
<el-option label="已取消" value="已取消" />
</el-select>
</el-form-item>
</el-form>
<EditLogPanel v-if="dialogMode === 'edit'" :edit-log="form.edit_log || []" />
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.edit-indicator { font-size: 12px; margin-left: 3px; opacity: 0.5; cursor: help; }
.page-head { margin-bottom: 20px; }
.page-head-row { display: flex; justify-content: space-between; align-items: flex-start; }
.page-title { margin: 0; font-family: 'ZCOOL XiaoWei', STSong, serif; font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
.page-desc { margin: 4px 0 0; font-size: 13px; color: var(--c-text-muted); font-family: 'Noto Serif SC', STSong, serif; }
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
.empty { text-align: center; color: var(--c-text-muted); padding: 48px 0; font-family: 'Noto Serif SC', STSong, serif; }
</style>
@@ -4,6 +4,7 @@ import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { todayStr } from '@/utils'
import api from '@/api/index'
import EditLogPanel from '@/components/EditLogPanel.vue'
const router = useRouter()
const route = useRoute()
@@ -160,6 +161,7 @@ async function handleDelete() {
删除
</button>
</div>
<EditLogPanel v-if="isEdit" :edit-log="form.edit_log || []" />
</el-form>
</div>
</template>
+2 -6
View File
@@ -3,6 +3,7 @@ import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { visitsApi } from '@/api/visits'
import { uploadApi } from '@/api/upload'
import ImagePreview from '@/components/ImagePreview.vue'
import { ElMessage } from 'element-plus'
import api from '@/api/index'
@@ -195,12 +196,7 @@ onMounted(loadToday)
</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>
<ImagePreview v-model="previewDialogVisible" :image-url="previewImageUrl" />
</div>
</template>
+4 -7
View File
@@ -7,6 +7,8 @@ import { visitsApi } from '@/api/visits'
import { customersApi } from '@/api/customers'
import { uploadApi } from '@/api/upload'
import { useAuthStore } from '@/stores/auth'
import ImagePreview from '@/components/ImagePreview.vue'
import EditLogPanel from '@/components/EditLogPanel.vue'
import api from '@/api/index'
const router = useRouter()
@@ -344,15 +346,10 @@ async function handleDelete() {
删除
</button>
</div>
<EditLogPanel v-if="isEdit" :edit-log="form.edit_log || []" />
</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>
<ImagePreview v-model="previewDialogVisible" :image-url="previewImageUrl" />
</div>
</template>