Files
qiji/frontend/src/views/desktop/WorkPlans.vue
T
v6ole 5682c12085 feat: 桌面端工作页面支持搜索时直接新建客户
- WorkPlans/MiniBusiness/KeyVisits/ManagerWorkspace 四个页面新增快速新建客户功能
- 客户搜索框输入时,若无匹配结果,直接显示「+ 新建客户」按钮
- 点击即调用 POST /customers/quick-create 创建并自动选中
- 与移动端 VisitForm 保持一致体验

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 18:00:35 +08:00

318 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme'
import { ElMessage, ElMessageBox } from 'element-plus'
import { todayStr } from '@/utils'
import { getMgrStyle, loadManagerColors } from '@/utils/managerColor'
import { useScreenshot } from '@/utils/screenshot'
import api from '@/api/index'
import EditLogPanel from '@/components/EditLogPanel.vue'
const auth = useAuthStore()
const themeStore = useThemeStore()
const loading = ref(false)
const workPlans = ref<any[]>([])
const customers = ref<any[]>([])
const shotRef = ref<HTMLElement | null>(null)
const { capturing, captureEl } = useScreenshot()
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const form = ref<any>({})
const statusPick = ref<Record<string, string>>({})
const planStatuses = ['计划中', '已完成', '已取消']
// ── Filters ──
const filterManager = ref('')
const filterStatus = ref('')
const isLight = computed(() => themeStore.currentTheme === 'light')
const managerOptions = computed(() => {
const seen = new Set<string>()
return workPlans.value
.map((w: any) => w.manager_name || '未知')
.filter((n: string) => { if (seen.has(n)) return false; seen.add(n); return true })
.sort()
})
const filteredPlans = computed(() => {
let list = workPlans.value
if (filterManager.value) list = list.filter((w: any) => (w.manager_name || '未知') === filterManager.value)
if (filterStatus.value) list = list.filter((w: any) => w.status === filterStatus.value)
return list
})
function resetFilters() {
filterManager.value = ''
filterStatus.value = ''
}
async function handleScreenshot() {
const d = new Date().toISOString().slice(0, 10)
await captureEl(shotRef.value, `工作计划_${d}.png`)
}
const managerSummary = computed(() => {
const map: Record<string, number> = {}
workPlans.value.forEach((w: any) => {
const n = w.manager_name || '未知'
map[n] = (map[n] || 0) + 1
})
return Object.entries(map).sort((a, b) => b[1] - a[1])
})
onMounted(async () => {
await Promise.all([loadPlans(), loadCustomers(), loadManagerColors()])
})
const customerSearch = ref('')
async function loadCustomers(q?: string) {
try {
const params: any = { page_size: 500 }
if (q) params.search = q
const res = await api.get('/customers/', { params })
customers.value = res.data.items || []
} catch (_) {}
}
async function handleCustomerSearch(query: string) {
customerSearch.value = query
if (query) { await loadCustomers(query) }
else { await loadCustomers() }
}
async function handleQuickCreate() {
const name = customerSearch.value.trim()
if (!name) { ElMessage.warning('请输入单位名称'); return }
try {
const res = await api.post('/customers/quick-create', null, { params: { name } })
const newCust = res.data
customers.value.unshift(newCust)
form.value.customer_id = newCust.id
customerSearch.value = ''
ElMessage.success(`已创建客户:${name}`)
await loadCustomers()
} catch (e: any) {
ElMessage.error('创建失败: ' + (e.response?.data?.detail || e.message))
}
}
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 }
if (item.customer_id && item.customer_name && !customers.value.find((c: any) => c.id === item.customer_id)) {
customers.value.unshift({ id: item.customer_id, name: item.customer_name })
}
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 ref="shotRef">
<div class="page-head">
<div class="page-head-row">
<div>
<h2 class="page-title">工作计划</h2>
<p class="page-desc">面向未来的工作计划安排支持状态流转跟踪</p>
</div>
<div class="page-head-actions">
<el-button type="primary" @click="openCreate" class="screenshot-hide">
<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>
<el-button type="warning" :loading="capturing" @click="handleScreenshot" class="screenshot-hide">
<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">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
截图导出
</el-button>
</div>
</div>
<div class="page-rule"></div>
<div v-if="managerSummary.length" class="summary-bar">
<span class="summary-label">客户经理汇总</span>
<span v-for="[name, count] in managerSummary" :key="name" class="summary-chip" :class="{ 'summary-chip--active': filterManager === name }" :style="getMgrStyle(name, isLight)" @click="filterManager = filterManager === name ? '' : name">{{ name }} · {{ count }}</span>
</div>
</div>
<!-- Filter Bar -->
<div class="filter-bar">
<div class="filter-row">
<div class="filter-item">
<label class="filter-label">客户经理</label>
<el-select v-model="filterManager" clearable placeholder="全部" size="small" style="width:150px">
<el-option v-for="m in managerOptions" :key="m" :label="m" :value="m" />
</el-select>
</div>
<div class="filter-item">
<label class="filter-label">状态</label>
<el-select v-model="filterStatus" clearable placeholder="全部" size="small" style="width:120px">
<el-option v-for="s in planStatuses" :key="s" :label="s" :value="s" />
</el-select>
</div>
<el-button v-if="filterManager || filterStatus" size="small" plain @click="resetFilters">重置</el-button>
<span class="filter-count">{{ filteredPlans.length }} / {{ workPlans.length }} </span>
</div>
</div>
<el-card>
<el-table :data="filteredPlans" stripe size="small" v-if="filteredPlans.length" v-column-resize>
<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 label="客户经理" width="100">
<template #default="{ row }">
<span class="mgr-tag" :style="getMgrStyle(row.manager_name, isLight)">{{ row.manager_name || '-' }}</span>
</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" class-name="screenshot-hide">
<template #default="{ row }">
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="!workPlans.length" class="empty">暂无工作计划</div>
<div v-else-if="workPlans.length && !filteredPlans.length" class="empty">无匹配结果</div>
</el-card>
</div><!-- /shotRef -->
<!-- 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="handleCustomerSearch" placeholder="搜索选择客户" style="width:100%">
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
<div v-if="customerSearch && !customers.some(c => c.name === customerSearch)" style="margin-top:6px">
<button type="button" class="quick-create-btn" @click="handleQuickCreate">+ 新建客户「{{ customerSearch }}」</button>
</div>
</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-head-actions { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
.page-title { margin: 0; font-family: var(--font-heading); 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: var(--font-body); }
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
.summary-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 14px; padding: 10px 14px; background: var(--c-bg-light, #faf9f6); border-radius: 6px; border: 1px solid var(--c-border, #e8e5df); }
.summary-label { font-size: 12px; color: var(--c-text-muted); font-family: var(--font-body); margin-right: 4px; }
.summary-chip { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; color: var(--mgr-chip-text); white-space: nowrap; cursor: pointer; transition: opacity 0.2s, transform 0.15s; }
.summary-chip:hover { opacity: 0.8; transform: translateY(-1px); }
.summary-chip--active { outline: 2px solid var(--gold); outline-offset: 1px; }
.mgr-tag { display: inline-block; padding: 1px 9px; border-radius: 10px; font-size: 12px; color: var(--mgr-chip-text); white-space: nowrap; }
.empty { text-align: center; color: var(--c-text-muted); padding: 48px 0; font-family: var(--font-body); }
/* ── Filter Bar ── */
.filter-bar { margin-bottom: 16px; padding: 12px 16px; background: var(--surface); border: 1px solid var(--warm-border); }
.filter-row { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.filter-item { display: flex; align-items: center; gap: 8px; }
.filter-label { font-size: 13px; color: var(--warm-gray); font-family: var(--font-body); white-space: nowrap; }
.filter-count { font-size: 12px; color: var(--c-text-muted); font-family: var(--font-mono); margin-left: auto; }
.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 6px 12px; color: var(--gold-dark); font-family: var(--font-body); font-size: 12px; cursor: pointer; transition: all 0.2s; }
.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); }
</style>