From e8b92a6f994228895400bea77e205a251574fd12 Mon Sep 17 00:00:00 2001 From: v6ole Date: Sat, 11 Jul 2026 22:17:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=97=E5=AE=BD=E6=8B=96=E6=8B=BD=20?= =?UTF-8?q?+=20=E7=AD=9B=E9=80=89=E6=A0=8F=20+=20=E6=88=AA=E5=9B=BE?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=20+=20UI=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 v-column-resize 指令,11 张表格均支持拖拽调整列宽 - 工作计划/商机跟单/要客拜访新增筛选栏(客户经理 + 状态) - 汇总栏 chip 可点击筛选,支持重置与计数 - 新增截图导出功能(dom-to-image-more,像素级文字渲染) - 截图范围从标题开始,自动隐藏操作按钮,5px白边 - 截图时页面闪烁动画反馈 - 全局 CSS 完善(列宽手柄、截图模式、捕获动画) - 仪表盘 UI 细节优化 + 亮灯表标签调整 - 登录页 UI 改进 Co-Authored-By: Claude --- frontend/package-lock.json | 7 + frontend/package.json | 1 + frontend/src/App.vue | 78 ++++++++ frontend/src/directives/columnResize.ts | 152 ++++++++++++++++ frontend/src/main.ts | 2 + frontend/src/stores/auth.ts | 3 + frontend/src/utils/screenshot.ts | 72 ++++++++ frontend/src/views/Login.vue | 171 +++++++++++------- frontend/src/views/desktop/CustomerManage.vue | 2 +- frontend/src/views/desktop/Dashboard.vue | 97 ++++++---- frontend/src/views/desktop/KeyVisits.vue | 95 ++++++++-- frontend/src/views/desktop/LightBoard.vue | 35 ++-- .../src/views/desktop/ManagerWorkspace.vue | 10 +- frontend/src/views/desktop/MiniBusiness.vue | 95 ++++++++-- frontend/src/views/desktop/UserManage.vue | 2 +- frontend/src/views/desktop/WeeklyReport.vue | 4 +- frontend/src/views/desktop/WorkPlans.vue | 97 ++++++++-- 17 files changed, 767 insertions(+), 156 deletions(-) create mode 100644 frontend/src/directives/columnResize.ts create mode 100644 frontend/src/utils/screenshot.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2dfa454..258b8b6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "axios": "^1.7.9", + "dom-to-image-more": "^3.10.0", "element-plus": "^2.9.1", "pinia": "^2.3.0", "vue": "^3.5.13", @@ -1835,6 +1836,12 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-to-image-more": { + "version": "3.10.0", + "resolved": "https://registry.npmmirror.com/dom-to-image-more/-/dom-to-image-more-3.10.0.tgz", + "integrity": "sha512-APrFEimSmH4phJKs8DlURuSr3BFwDqNi62l14bx73Wrhx0OhE4dd0qzZR+4E3sH8PnHxvax1LOrosC9oXRFA5A==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-17.4.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 30ac61a..e07e566 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "axios": "^1.7.9", + "dom-to-image-more": "^3.10.0", "element-plus": "^2.9.1", "pinia": "^2.3.0", "vue": "^3.5.13", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c9d8d5d..5ed514d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -388,4 +388,82 @@ h1, h2, h3, h4, h5, h6 { cursor: pointer; border-radius: 50%; transition: background 0.2s; } .image-preview-close:hover { background: rgba(255,255,255,0.35); } + +/* ── Screenshot capture mode ── */ +.taking-screenshot { background: #fff !important; } +.taking-screenshot * { background-color: transparent; } +.taking-screenshot .el-card, +.taking-screenshot .el-table, +.taking-screenshot .el-table__header, +.taking-screenshot .el-table__body, +.taking-screenshot .filter-bar, +.taking-screenshot .summary-bar, +.taking-screenshot .page-head { background: #fff !important; } +.taking-screenshot .screenshot-hide { display: none !important; } +.taking-screenshot .el-table__fixed-right { display: none !important; } + +/* ── Screenshot capture animation ── */ +.body-capturing .work-plans-page, +.body-capturing .mini-biz-page, +.body-capturing .key-visits-page { + position: relative; +} +.body-capturing .work-plans-page::after, +.body-capturing .mini-biz-page::after, +.body-capturing .key-visits-page::after { + content: ''; + position: fixed; inset: 0; z-index: 9998; + background: rgba(255,255,255,0.6); + pointer-events: none; + animation: capture-flash 0.35s ease-out; +} +@keyframes capture-flash { + 0% { opacity: 0; } + 50% { opacity: 1; } + 100% { opacity: 0; } +} + +/* ── Column resize handle ── */ +.el-table__header th { + position: relative !important; +} +/* Exclude gutter (scrollbar spacer) and selection column from resize */ +.el-table__header th.el-table__cell--selection .col-resize-handle, +.el-table__header th.gutter .col-resize-handle { + display: none !important; +} +.col-resize-handle { + position: absolute; + right: 0; + top: 0; + bottom: 0; + width: 6px; + cursor: col-resize; + z-index: 1; + transition: background 0.15s; +} +.col-resize-handle::after { + content: ''; + position: absolute; + right: 1px; + top: 20%; + bottom: 20%; + width: 2px; + border-radius: 1px; + background: transparent; + transition: background 0.15s; +} +.el-table__header th:hover .col-resize-handle::after, +.col-resize-handle.is-resizing::after { + background: var(--gold); +} +.col-resize-handle:hover::after, +.col-resize-handle.is-resizing::after { + background: var(--gold) !important; +} +/* Prevent text selection while resizing columns */ +body.col-resizing { + cursor: col-resize !important; + user-select: none !important; +} diff --git a/frontend/src/directives/columnResize.ts b/frontend/src/directives/columnResize.ts new file mode 100644 index 0000000..facec84 --- /dev/null +++ b/frontend/src/directives/columnResize.ts @@ -0,0 +1,152 @@ +/** + * v-column-resize directive — adds drag-to-resize handles to el-table column headers. + * + * Usage: + * + * Holders drag the right edge of any column header (except the last) to resize. + * Works with Element Plus's fixed table-layout by updating widths in + * every colgroup (header + body tables stay in sync). + */ + +import type { Directive, DirectiveBinding } from 'vue' + +interface DragState { + startX: number + startWidth: number + th: HTMLElement + tableWrapper: HTMLElement + colIndex: number +} + +const drag: DragState = { + startX: 0, + startWidth: 0, + th: null!, + tableWrapper: null!, + colIndex: -1, +} + +// ── Helpers ────────────────────────────────────────────── + +function getColIndex(th: HTMLElement): number { + const cells = Array.from(th.parentElement?.children || []) + return cells.indexOf(th) +} + +/** All elements across every colgroup inside the wrapper, by col index */ +function allCols(wrapper: HTMLElement, idx: number): HTMLElement[] { + const groups = wrapper.querySelectorAll('colgroup') + const result: HTMLElement[] = [] + groups.forEach((g) => { + const col = g.children[idx] as HTMLElement | undefined + if (col) result.push(col) + }) + return result +} + +function setColumnWidth(wrapper: HTMLElement, colIndex: number, width: number) { + const w = Math.max(40, width) + 'px' + for (const col of allCols(wrapper, colIndex)) { + col.style.width = w + } +} + +// ── Event handlers ─────────────────────────────────────── + +function onMouseMove(e: MouseEvent) { + const diff = e.clientX - drag.startX + setColumnWidth(drag.tableWrapper, drag.colIndex, drag.startWidth + diff) +} + +function onMouseUp() { + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + document.body.style.cursor = '' + document.body.style.userSelect = '' + const handle = drag.th.querySelector('.col-resize-handle') as HTMLElement | null + if (handle) handle.classList.remove('is-resizing') +} + +function onHandleDown(e: MouseEvent, th: HTMLElement, wrapper: HTMLElement) { + e.preventDefault() + e.stopPropagation() + const idx = getColIndex(th) + const cols = allCols(wrapper, idx) + const currentWidth = cols[0] ? cols[0].getBoundingClientRect().width : th.offsetWidth + + drag.startX = e.clientX + drag.startWidth = currentWidth + drag.th = th + drag.tableWrapper = wrapper + drag.colIndex = idx + + const handle = th.querySelector('.col-resize-handle') as HTMLElement | null + if (handle) handle.classList.add('is-resizing') + + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' +} + +// ── Setup / Teardown ───────────────────────────────────── + +function injectHandles(wrapper: HTMLElement) { + const headerThs = wrapper.querySelectorAll( + '.el-table__header-wrapper th:not(.el-table__cell--selection)', + ) + if (headerThs.length === 0) return + + headerThs.forEach((th) => { + // Don't add handle to the last column (or to columns that already have one) + // Actually, allow all columns except the last to be resizable + if (th.querySelector('.col-resize-handle')) return + + const handle = document.createElement('div') + handle.className = 'col-resize-handle' + handle.addEventListener('mousedown', (e: MouseEvent) => onHandleDown(e, th, wrapper)) + // Prevent sort trigger on header click when grabbing handle + handle.addEventListener('click', (e: Event) => e.stopPropagation()) + th.appendChild(handle) + }) +} + +// ── Directive definition ───────────────────────────────── + +function findWrapper(el: HTMLElement): HTMLElement | null { + return el.querySelector('.el-table__inner-wrapper') + || el.querySelector('.el-table__header-wrapper')?.closest('.el-table') as HTMLElement + || null +} + +export const vColumnResize: Directive = { + mounted(el: HTMLElement, _binding: DirectiveBinding) { + const trySetup = () => { + const wrapper = findWrapper(el) + if (wrapper) { + injectHandles(wrapper) + return true + } + return false + } + if (!trySetup()) { + const timer = setTimeout(() => trySetup(), 200) + ;(el as any).__colResizeTimer = timer + } + // Re-inject handles when DOM changes (sort, filter, data reload) + const observer = new MutationObserver(() => { + const wrapper = findWrapper(el) + if (wrapper) injectHandles(wrapper) + }) + observer.observe(el, { childList: true, subtree: true }) + ;(el as any).__colResizeObserver = observer + }, + + unmounted(el: HTMLElement) { + clearTimeout((el as any).__colResizeTimer) + const observer = (el as any).__colResizeObserver as MutationObserver | undefined + observer?.disconnect() + }, +} + +export default vColumnResize diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 37b7c46..ebdda83 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -3,8 +3,10 @@ import { createPinia } from 'pinia' import './tailwind.css' import App from './App.vue' import router from './router' +import { vColumnResize } from '@/directives/columnResize' const app = createApp(App) app.use(createPinia()) app.use(router) +app.directive('column-resize', vColumnResize) app.mount('#app') diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index e81c7df..49a6e09 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -29,7 +29,10 @@ export const useAuthStore = defineStore('auth', () => { userId.value = '' name.value = '' role.value = '' + // Preserve theme preference across logout + const savedTheme = localStorage.getItem('theme') localStorage.clear() + if (savedTheme) localStorage.setItem('theme', savedTheme) } async function casdoorLogin(code: string, state: string) { diff --git a/frontend/src/utils/screenshot.ts b/frontend/src/utils/screenshot.ts new file mode 100644 index 0000000..caf9d38 --- /dev/null +++ b/frontend/src/utils/screenshot.ts @@ -0,0 +1,72 @@ +/** + * useScreenshot — capture a DOM element as PNG and trigger download. + * + * Uses dom-to-image-more (SVG foreignObject) for pixel-perfect text rendering — + * unlike html2canvas, the browser itself paints the text so there's zero offset. + * + * Usage: + * const { capturing, captureEl } = useScreenshot() + *
...
+ * + */ + +import { ref, nextTick } from 'vue' +import domtoimage from 'dom-to-image-more' + +export function useScreenshot() { + const capturing = ref(false) + + async function captureEl(el: HTMLElement | null, filename: string): Promise { + if (!el || capturing.value) return false + capturing.value = true + await nextTick() + + el.classList.add('taking-screenshot') + document.body.classList.add('body-capturing') + await new Promise((r) => setTimeout(r, 80)) + + try { + const dataUrl = await domtoimage.toPng(el, { + bgColor: '#ffffff', + width: el.scrollWidth, + height: el.scrollHeight, + quality: 1, + cacheBust: true, + }) + + // Pad 5px white border around the image + const img = await new Promise((resolve, reject) => { + const i = new Image() + i.onload = () => resolve(i) + i.onerror = reject + i.src = dataUrl + }) + + const PAD = 5 + const canvas = document.createElement('canvas') + canvas.width = img.width + PAD * 2 + canvas.height = img.height + PAD * 2 + const ctx = canvas.getContext('2d')! + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, canvas.width, canvas.height) + ctx.drawImage(img, PAD, PAD) + + const link = document.createElement('a') + link.download = filename + link.href = canvas.toDataURL('image/png') + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + return true + } catch (e) { + console.error('Screenshot failed:', e) + return false + } finally { + el.classList.remove('taking-screenshot') + document.body.classList.remove('body-capturing') + capturing.value = false + } + } + + return { capturing, captureEl } +} diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index f06ece9..08953fa 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -9,11 +9,22 @@ const route = useRoute() const auth = useAuthStore() const loading = ref(false) -// Check for Casdoor callback +function goCasdoorLogin() { + const returnUrl = (route.query.redirect as string) || '' + if (returnUrl) { + sessionStorage.setItem('login_return_url', returnUrl) + } + const endpoint = import.meta.env.VITE_CASDOOR_ENDPOINT + const clientId = import.meta.env.VITE_CASDOOR_CLIENT_ID + const redirectUri = encodeURIComponent(window.location.origin + '/login') + const casdoorUrl = `${endpoint}/login/oauth/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&scope=openid+profile&state=login` + window.location.href = casdoorUrl +} + onMounted(async () => { + // ── Casdoor callback ── const code = route.query.code as string const state = route.query.state as string - if (code) { loading.value = true try { @@ -30,14 +41,13 @@ onMounted(async () => { return } - // Check for wecom code + // ── Wecom callback ── const wecomCode = route.query.wecom_code as string if (wecomCode) { loading.value = true try { const res = await auth.wecomLogin(wecomCode) if (res.data.need_bind) { - // Redirect to Casdoor for binding window.location.href = res.data.casdoor_url return } @@ -56,98 +66,133 @@ onMounted(async () => { } finally { loading.value = false } + return } -}) -function goCasdoorLogin() { - // Save return URL before redirecting to Casdoor - const returnUrl = (route.query.redirect as string) || '' - if (returnUrl) { - sessionStorage.setItem('login_return_url', returnUrl) - } - const endpoint = import.meta.env.VITE_CASDOOR_ENDPOINT - const clientId = import.meta.env.VITE_CASDOOR_CLIENT_ID - const redirectUri = encodeURIComponent(window.location.origin + '/login') - const casdoorUrl = `${endpoint}/login/oauth/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&scope=openid+profile&state=login` - window.location.href = casdoorUrl -} + // ── Auto-redirect to Casdoor (no button needed) ── + loading.value = true + goCasdoorLogin() +}) diff --git a/frontend/src/views/desktop/CustomerManage.vue b/frontend/src/views/desktop/CustomerManage.vue index 111b74d..4db2c65 100644 --- a/frontend/src/views/desktop/CustomerManage.vue +++ b/frontend/src/views/desktop/CustomerManage.vue @@ -331,7 +331,7 @@ async function handleImport() { - + diff --git a/frontend/src/views/desktop/Dashboard.vue b/frontend/src/views/desktop/Dashboard.vue index a0ec31b..fb51313 100644 --- a/frontend/src/views/desktop/Dashboard.vue +++ b/frontend/src/views/desktop/Dashboard.vue @@ -4,6 +4,7 @@ import { useRouter } from 'vue-router' import { useAuthStore } from '@/stores/auth' import { dashboardApi } from '@/api/dashboard' import { ElMessage } from 'element-plus' +import api from '@/api/index' const router = useRouter() const auth = useAuthStore() @@ -58,6 +59,23 @@ function goWeeklyReport(managerId?: string) { else router.push('/weekly-report') } +function getRefDate(): string { + const d = new Date() + d.setDate(d.getDate() + weekOffset.value * 7) + return d.toISOString().slice(0, 10) +} + +async function handleExport() { + try { + 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() + URL.revokeObjectURL(url) + ElMessage.success('导出成功') + } catch (e: any) { ElMessage.error('导出失败') } +} + function rowState(p: any): 'full' | 'catching' | 'missing' { if (p.has_reported_today && p.completed) return 'full' if (p.has_reported_today && !p.completed) return 'catching' @@ -67,15 +85,36 @@ function rowState(p: any): 'full' | 'catching' | 'missing' { diff --git a/frontend/src/views/desktop/LightBoard.vue b/frontend/src/views/desktop/LightBoard.vue index e99ec95..eabf611 100644 --- a/frontend/src/views/desktop/LightBoard.vue +++ b/frontend/src/views/desktop/LightBoard.vue @@ -318,23 +318,34 @@ const statusLabel: Record = { green: '近30天已拜访', yellow .customer-grid { display: flex; flex-wrap: wrap; gap: 10px; padding: 0 18px 16px; border-top: 1px solid var(--warm-border); padding-top: 14px; } /* ═══ Customer Card ═══ */ -.customer-card { width: 220px; background: var(--paper); border: 1px solid var(--warm-border); cursor: pointer; transition: all 0.2s; display: flex; overflow: hidden; position: relative; } -.customer-card:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(28,55,56,0.06); } +.customer-card { width: 220px; background: var(--paper); border: 1px solid var(--warm-border); cursor: pointer; transition: all 0.2s; display: flex; flex-direction: column; overflow: hidden; position: relative; } +.customer-card:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(28,55,56,0.08); } .customer-card:active { transform: scale(0.99); } -.card-status-stripe { width: 4px; flex-shrink: 0; } -.customer-card--green .card-status-stripe { background: var(--sage); } + +/* ── Card background by status (Option A: full-color tint) ── */ +.customer-card--green { background: #ECF7EE; border-color: #B7DFC2; } +.customer-card--yellow { background: #FFF8EB; border-color: #F0D9A0; } +.customer-card--red { background: #FFF0EF; border-color: #F5C4C0; } +.customer-card--gray { background: #F5F5F5; border-color: #DDDDDD; } + +/* ── Top accent stripe (subtle, secondary indicator) ── */ +.card-status-stripe { height: 3px; width: 100%; flex-shrink: 0; } +.customer-card--green .card-status-stripe { background: var(--sage); } .customer-card--yellow .card-status-stripe { background: var(--gold); } -.customer-card--red .card-status-stripe { background: var(--vermilion); } -.customer-card--gray .card-status-stripe { background: var(--warm-gray); } -.customer-card--red { animation: pulse-warn 3s ease-in-out infinite; } -@keyframes pulse-warn { 0%,100% { border-color: var(--warm-border); } 50% { border-color: rgba(184,71,46,0.35); } } +.customer-card--red .card-status-stripe { background: var(--vermilion); } +.customer-card--gray .card-status-stripe { background: var(--warm-gray); } + +/* ── Red card pulse ── */ +.customer-card--red { animation: pulse-card 3s ease-in-out infinite; } +@keyframes pulse-card { 0%,100% { box-shadow: 0 0 0 0 rgba(184,71,46,0); } 50% { box-shadow: 0 0 0 4px rgba(184,71,46,0.12); } } + .card-body { padding: 12px 14px; flex: 1; min-width: 0; } .card-name-row { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 6px; } -.status-circle { display: inline-block; width: 16px; height: 16px; border-radius: 50%; flex-shrink: 0; margin-top: 2px; } -.status-circle--green { background: var(--sage); box-shadow: 0 0 0 2px rgba(74,103,65,0.15); } +.status-circle { display: inline-block; width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0; margin-top: 2px; } +.status-circle--green { background: var(--sage); } .status-circle--yellow { background: conic-gradient(var(--gold) 50%, transparent 50%); box-shadow: inset 0 0 0 1.5px var(--gold); } -.status-circle--red { background: transparent; border: 2px solid var(--vermilion); box-shadow: 0 0 0 2px rgba(184,71,46,0.08); } -.status-circle--gray { background: transparent; border: 1.5px dashed var(--warm-gray); } +.status-circle--red { background: transparent; border: 2px solid var(--vermilion); } +.status-circle--gray { background: transparent; border: 1.5px dashed var(--warm-gray); } .card-name { font-family: var(--font-heading); font-size: 13px; color: var(--ink); letter-spacing: 0.03em; line-height: 1.4; word-break: break-all; } .card-meta { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; } .card-industry,.card-services { font-family: var(--font-body); font-size: 10px; color: var(--warm-gray); } diff --git a/frontend/src/views/desktop/ManagerWorkspace.vue b/frontend/src/views/desktop/ManagerWorkspace.vue index 09f95a4..e9dbb3f 100644 --- a/frontend/src/views/desktop/ManagerWorkspace.vue +++ b/frontend/src/views/desktop/ManagerWorkspace.vue @@ -287,7 +287,7 @@ const notesByDate = computed(() => { {{ date }} - + @@ -320,7 +320,7 @@ const notesByDate = computed(() => { {{ date }} - + @@ -339,7 +339,7 @@ const notesByDate = computed(() => {
+ 新建计划
- + @@ -371,7 +371,7 @@ const notesByDate = computed(() => {
+ 新建商机
- + @@ -405,7 +405,7 @@ const notesByDate = computed(() => {
+ 新建要客拜访
- + diff --git a/frontend/src/views/desktop/MiniBusiness.vue b/frontend/src/views/desktop/MiniBusiness.vue index df50466..8636761 100644 --- a/frontend/src/views/desktop/MiniBusiness.vue +++ b/frontend/src/views/desktop/MiniBusiness.vue @@ -5,6 +5,7 @@ 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' @@ -14,6 +15,8 @@ const loading = ref(false) const isLight = computed(() => themeStore.currentTheme === 'light') const miniBusiness = ref([]) const customers = ref([]) +const shotRef = ref(null) +const { capturing, captureEl } = useScreenshot() const dialogVisible = ref(false) const dialogMode = ref<'create' | 'edit'>('create') @@ -21,6 +24,35 @@ const form = ref({}) const statusPick = ref>({}) const miniStatuses = ['跟进中', '已签约', '已流失'] +// ── Filters ── +const filterManager = ref('') +const filterStatus = ref('') + +const managerOptions = computed(() => { + const seen = new Set() + return miniBusiness.value + .map((m: any) => m.manager_name || '未知') + .filter((n: string) => { if (seen.has(n)) return false; seen.add(n); return true }) + .sort() +}) + +const filteredItems = computed(() => { + let list = miniBusiness.value + if (filterManager.value) list = list.filter((m: any) => (m.manager_name || '未知') === filterManager.value) + if (filterStatus.value) list = list.filter((m: any) => m.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 = {} miniBusiness.value.forEach((m: any) => { @@ -101,28 +133,59 @@ async function quickStatusChange(row: any, newStatus: string) {