Files
qiji/frontend/src/views/desktop/WorkPlans.vue
T
v6ole 1ec20ee53e chore: 保存进行中的未提交改动(企微定向推送+搜索筛选UI等)
保存合并前工作树中遗留的进行中改动,避免分支合并时丢失:
- scheduler: 填报汇总改为定向推送给支局长/领导(而非广播@all)
- router: JWT token 校验修复,bind token(UUID)不被误剥离
- 工作计划/计划列表: 搜索+筛选+分页 UI
- 各列表 API 增加 search 参数支持
- docker-compose.backend.yml + backend/.dockerignore 纳入版本管理

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 09:17:03 +08:00

310 lines
18 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 planStatuses = ['计划中', '已完成', '已取消']
const statusPick = ref<Record<string, string>>({})
// Quick reschedule dialog
const rescheduleVisible = ref(false)
const rescheduleRow = ref<any>(null)
const rescheduleDate = ref('')
function openReschedule(row: any) {
rescheduleRow.value = row
rescheduleDate.value = row.plan_date
rescheduleVisible.value = true
}
async function confirmReschedule() {
if (!rescheduleDate.value || !rescheduleRow.value) return
try {
await api.put(`/work-plans/${rescheduleRow.value.id}`, { plan_date: rescheduleDate.value })
ElMessage.success('计划日期已更新')
rescheduleVisible.value = false
await loadPlans()
} catch (e: any) { ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message)) }
}
// ── Filters ──
const filterManager = ref('')
const filterStatus = ref('')
const expandedSections = ref<Record<string, boolean>>({
thisWeek: true, nextWeek: true, thisMonth: false, later: false, archived: false,
})
const isLight = computed(() => themeStore.currentTheme === 'light')
const statusColors: Record<string, string> = {
'计划中': '#4A6741',
'已完成': '#5B7FA5',
'已取消': '#909399',
}
// ── Week boundaries ──
const today = new Date()
const weekStart = (d: Date) => { const s = new Date(d); s.setDate(s.getDate() - s.getDay() + 1); s.setHours(0,0,0,0); return s }
const weekEnd = (d: Date) => { const e = weekStart(d); e.setDate(e.getDate() + 6); return e }
const thisWeekStart = weekStart(today)
const nextWeekStart = new Date(thisWeekStart); nextWeekStart.setDate(nextWeekStart.getDate() + 7)
const thisMonthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0)
function classifyPlan(planDate: string, status: string): string {
if (status !== '计划中') return 'archived'
const d = new Date(planDate)
if (d < thisWeekStart) return 'overdue' // falls into thisWeek section with overdue flag
if (d <= weekEnd(today)) return 'thisWeek'
if (d <= weekEnd(nextWeekStart)) return 'nextWeek'
if (d <= thisMonthEnd) return 'thisMonth'
return 'later'
}
function sectionLabel(key: string): string {
const labels: Record<string, string> = { overdue: '已过期', thisWeek: '本周', nextWeek: '下周', thisMonth: '本月', later: '更远', archived: '已归档' }
return labels[key] || key
}
function isOverdue(row: any): boolean {
return row.status === '计划中' && row.plan_date < todayStr()
}
function overdueDays(row: any): number {
return Math.floor((today.getTime() - new Date(row.plan_date).getTime()) / 86400000)
}
// ── Group plans ──
const groupedPlans = computed(() => {
const groups: Record<string, any[]> = { thisWeek: [], nextWeek: [], thisMonth: [], later: [], archived: [] }
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)
for (const p of list) {
const key = classifyPlan(p.plan_date, p.status)
const g = key === 'overdue' ? 'thisWeek' : key
if (!groups[g]) groups[g] = []
groups[g].push(p)
}
// Sort each group by plan_date asc
for (const g of Object.values(groups)) {
g.sort((a: any, b: any) => a.plan_date.localeCompare(b.plan_date))
}
return Object.entries(groups)
.filter(([_, items]) => items.length > 0)
.map(([key, items]) => ({ key, label: sectionLabel(key), items }))
})
const allOverdue = computed(() => workPlans.value.filter((w: any) => isOverdue(w)))
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 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])
})
function resetFilters() { filterManager.value = ''; filterStatus.value = '' }
onMounted(async () => { await Promise.all([loadPlans(), loadCustomers(), loadManagerColors()]) })
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 loadPlans() { loading.value = true; try { const res = await api.get('/work-plans/'); workPlans.value = res.data } catch (_) {} 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('更新失败') }
}
async function quickReschedule(row: any) { openReschedule(row) }
async function quickCancel(row: any) { await quickStatusChange(row, '已取消') }
async function handleScreenshot() { const d = new Date().toISOString().slice(0, 10); await captureEl(shotRef.value, `工作计划_${d}.png`) }
</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">截图导出</el-button>
</div>
</div>
<div class="page-rule"></div>
<!-- Overdue alert banner -->
<div v-if="allOverdue.length" class="overdue-banner">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<span> <strong>{{ allOverdue.length }}</strong> 条计划已过期请及时改期或取消</span>
</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">{{ workPlans.length }} </span>
</div>
</div>
<!-- Grouped sections -->
<div v-if="!workPlans.length" class="empty">暂无工作计划</div>
<div v-for="group in groupedPlans" :key="group.key" class="plan-section">
<div class="section-header" @click="expandedSections[group.key] = !expandedSections[group.key]">
<span class="section-arrow">{{ expandedSections[group.key] ? '▼' : '▶' }}</span>
<span class="section-title">{{ group.label }}</span>
<span class="section-count">{{ group.items.length }} </span>
</div>
<div v-show="expandedSections[group.key]">
<el-table :data="group.items" stripe size="small" v-column-resize>
<el-table-column type="index" label="#" width="45" />
<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">🕐</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="220" show-overflow-tooltip />
<el-table-column prop="plan_date" label="计划时间" width="110" />
<el-table-column label="状态" width="170">
<template #default="{ row }">
<div style="display:flex;align-items:center;gap:6px">
<el-select v-model="statusPick[row.id]" size="small" style="width:110px" :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>
<span v-if="isOverdue(row)" class="overdue-badge">过期{{ overdueDays(row) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right" class-name="screenshot-hide">
<template #default="{ row }">
<template v-if="isOverdue(row)">
<el-button size="small" type="warning" @click="quickReschedule(row)">改期</el-button>
<el-button size="small" @click="quickCancel(row)">取消</el-button>
</template>
<el-button v-else size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</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="(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" value-format="YYYY-MM-DD" 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>
<!-- Quick reschedule dialog -->
<el-dialog v-model="rescheduleVisible" title="修改计划日期" width="360px">
<el-form label-position="top">
<el-form-item label="新日期">
<el-date-picker v-model="rescheduleDate" type="date" value-format="YYYY-MM-DD" style="width:100%" />
</el-form-item>
</el-form>
<template #footer><el-button @click="rescheduleVisible = false">取消</el-button><el-button type="primary" @click="confirmReschedule">确认改期</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; }
/* ── Overdue Banner ── */
.overdue-banner { display: flex; align-items: center; gap: 8px; margin-top: 12px; padding: 10px 16px; background: rgba(184,71,46,0.06); border-left: 3px solid var(--vermilion); font-family: var(--font-body); font-size: 13px; color: var(--ink); }
.overdue-banner strong { color: var(--vermilion); }
/* ── Summary Bar ── */
.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; }
/* ── Sections ── */
.plan-section { margin-bottom: 16px; border: 1px solid var(--warm-border); background: var(--surface); }
.section-header { display: flex; align-items: center; gap: 10px; padding: 14px 18px; cursor: pointer; user-select: none; border-bottom: 1px solid var(--warm-border); transition: background 0.2s; }
.section-header:hover { background: var(--c-bg-light, #faf9f6); }
.section-arrow { font-size: 10px; color: var(--gold); width: 14px; transition: transform 0.2s; }
.section-title { font-family: var(--font-heading); font-size: 15px; color: var(--ink); letter-spacing: 0.04em; }
.section-count { font-family: var(--font-mono); font-size: 12px; color: var(--warm-gray); }
/* ── Overdue Badge ── */
.overdue-badge { display: inline-block; padding: 1px 7px; border-radius: 8px; background: var(--vermilion); color: #fff; font-family: var(--font-mono); font-size: 10px; white-space: nowrap; }
</style>