feat: 列宽拖拽 + 筛选栏 + 截图导出 + UI优化
- 新增 v-column-resize 指令,11 张表格均支持拖拽调整列宽 - 工作计划/商机跟单/要客拜访新增筛选栏(客户经理 + 状态) - 汇总栏 chip 可点击筛选,支持重置与计数 - 新增截图导出功能(dom-to-image-more,像素级文字渲染) - 截图范围从标题开始,自动隐藏操作按钮,5px白边 - 截图时页面闪烁动画反馈 - 全局 CSS 完善(列宽手柄、截图模式、捕获动画) - 仪表盘 UI 细节优化 + 亮灯表标签调整 - 登录页 UI 改进 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* v-column-resize directive — adds drag-to-resize handles to el-table column headers.
|
||||
*
|
||||
* Usage: <el-table v-column-resize ...>
|
||||
*
|
||||
* Holders drag the right edge of any column header (except the last) to resize.
|
||||
* Works with Element Plus's fixed table-layout by updating <col> 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 <col> 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<HTMLElement>(
|
||||
'.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<HTMLElement>('.el-table__inner-wrapper')
|
||||
|| el.querySelector<HTMLElement>('.el-table__header-wrapper')?.closest('.el-table') as HTMLElement
|
||||
|| null
|
||||
}
|
||||
|
||||
export const vColumnResize: Directive<HTMLElement> = {
|
||||
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
|
||||
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
* <div ref="shotRef">...</div>
|
||||
* <button @click="captureEl(shotRef, 'filename')">截图</button>
|
||||
*/
|
||||
|
||||
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<boolean> {
|
||||
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<HTMLImageElement>((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 }
|
||||
}
|
||||
+108
-63
@@ -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()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<!-- Decorative ink-wash circles -->
|
||||
<div class="bg-orb bg-orb--1"></div>
|
||||
<div class="bg-orb bg-orb--2"></div>
|
||||
<div class="bg-orb bg-orb--3"></div>
|
||||
|
||||
<div class="splash-card">
|
||||
<div class="brand">
|
||||
<h1>企迹</h1>
|
||||
<div class="brand-rule"></div>
|
||||
<p>政企周报管理系统</p>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="goCasdoorLogin"
|
||||
style="width: 100%"
|
||||
>
|
||||
登录 / 注册
|
||||
</el-button>
|
||||
<p class="hint">使用 Casdoor 账号登录</p>
|
||||
<div class="redirect-hint">
|
||||
<span class="dot-pulse"></span>
|
||||
正在跳转至统一认证...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ═══ Background — editorial ink wash ═══ */
|
||||
.login-page {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
background: linear-gradient(135deg, #4f6ef7 0%, #7b93fa 40%, #a5b4fc 100%);
|
||||
background: var(--ink);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
.login-page::before {
|
||||
content: ''; position: absolute; width: 600px; height: 600px;
|
||||
background: rgba(255,255,255,0.05); border-radius: 50%;
|
||||
top: -200px; right: -200px;
|
||||
|
||||
/* Decorative orbs — subtle ink-wash circles */
|
||||
.bg-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.login-page::after {
|
||||
content: ''; position: absolute; width: 400px; height: 400px;
|
||||
background: rgba(255,255,255,0.04); border-radius: 50%;
|
||||
bottom: -100px; left: -100px;
|
||||
.bg-orb--1 {
|
||||
width: 600px; height: 600px;
|
||||
background: radial-gradient(circle, rgba(196,147,74,0.06) 0%, transparent 70%);
|
||||
top: -180px; right: -180px;
|
||||
}
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 48px 40px;
|
||||
width: 380px;
|
||||
max-width: 90vw;
|
||||
box-shadow: 0 25px 60px rgba(0,0,0,0.15);
|
||||
.bg-orb--2 {
|
||||
width: 400px; height: 400px;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.03) 0%, transparent 70%);
|
||||
bottom: -100px; left: -80px;
|
||||
}
|
||||
.bg-orb--3 {
|
||||
width: 200px; height: 200px;
|
||||
background: radial-gradient(circle, rgba(196,147,74,0.08) 0%, transparent 60%);
|
||||
top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* ═══ Splash Card ═══ */
|
||||
.splash-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.brand {
|
||||
text-align: center;
|
||||
margin-bottom: 36px;
|
||||
animation: fade-in 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ═══ Brand ═══ */
|
||||
.brand {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.brand h1 {
|
||||
margin: 0;
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 4px;
|
||||
background: linear-gradient(135deg, #4f6ef7, #7b93fa);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 48px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.15em;
|
||||
color: #fff;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.brand-rule {
|
||||
width: 36px; height: 3px;
|
||||
background: var(--gold);
|
||||
margin: 16px auto;
|
||||
}
|
||||
.brand p {
|
||||
margin: 10px 0 0;
|
||||
color: var(--c-text-secondary);
|
||||
font-size: 14px;
|
||||
letter-spacing: 1px;
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
color: rgba(255,255,255,0.35);
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
.hint {
|
||||
text-align: center;
|
||||
color: var(--c-text-muted);
|
||||
font-size: 12px;
|
||||
margin-top: 20px;
|
||||
|
||||
/* ═══ Redirect Hint ═══ */
|
||||
.redirect-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
color: rgba(255,255,255,0.45);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
/* Pulsing dot animation */
|
||||
.dot-pulse {
|
||||
display: inline-block;
|
||||
width: 6px; height: 6px;
|
||||
background: var(--gold);
|
||||
border-radius: 50%;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
50% { opacity: 1; transform: scale(1.2); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -331,7 +331,7 @@ async function handleImport() {
|
||||
|
||||
<!-- Customer Table -->
|
||||
<el-card>
|
||||
<el-table :data="customers" stripe @selection-change="onSelectionChange">
|
||||
<el-table :data="customers" stripe v-column-resize @selection-change="onSelectionChange">
|
||||
<el-table-column v-if="auth.isDirector" type="selection" width="50" />
|
||||
<el-table-column type="index" label="序号" width="60" :index="(idx: number) => (currentPage - 1) * pageSize + idx + 1" />
|
||||
<el-table-column prop="name" label="单位名称" min-width="180">
|
||||
|
||||
@@ -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' {
|
||||
|
||||
<template>
|
||||
<div class="dashboard" v-loading="loading">
|
||||
<!-- ═══ Editorial Page Header ═══ -->
|
||||
<!-- ═══ Page Header ═══ -->
|
||||
<div class="page-head">
|
||||
<h2 class="page-title">仪表盘</h2>
|
||||
<div class="week-nav">
|
||||
<button class="week-nav-btn" @click="changeWeek(-1)" title="上一周">◀</button>
|
||||
<span class="week-label">{{ stats.week_start }} — {{ stats.week_end }}</span>
|
||||
<button class="week-nav-btn" @click="changeWeek(1)" :disabled="weekOffset >= 0" title="下一周">▶</button>
|
||||
<button v-if="isHistoricalWeek" class="week-nav-reset" @click="goCurrentWeek">回到本周</button>
|
||||
<el-tag v-if="isHistoricalWeek" type="info" size="small" style="margin-left:8px">📦 历史归档 · 只读</el-tag>
|
||||
<div class="page-head-row">
|
||||
<div>
|
||||
<h2 class="page-title">仪表盘</h2>
|
||||
<div class="week-nav">
|
||||
<button class="week-nav-btn" @click="changeWeek(-1)" title="上一周">◀</button>
|
||||
<span class="week-label">{{ stats.week_start }} — {{ stats.week_end }}</span>
|
||||
<button class="week-nav-btn" @click="changeWeek(1)" :disabled="weekOffset >= 0" title="下一周">▶</button>
|
||||
<button v-if="isHistoricalWeek" class="week-nav-reset" @click="goCurrentWeek">回到本周</button>
|
||||
<el-tag v-if="isHistoricalWeek" type="info" size="small" style="margin-left:8px">📦 历史归档 · 只读</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-head-actions">
|
||||
<el-button type="primary" @click="goWeeklyReport()">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
|
||||
<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>
|
||||
</svg>
|
||||
本周周报详情
|
||||
</el-button>
|
||||
<el-button type="success" v-if="auth.isDirector" @click="handleExport">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
导出 Excel
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-rule"></div>
|
||||
</div>
|
||||
@@ -133,34 +172,6 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Quick Actions ═══ -->
|
||||
<div class="actions">
|
||||
<el-button type="primary" @click="goWeeklyReport">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
|
||||
<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>
|
||||
</svg>
|
||||
本周周报详情
|
||||
</el-button>
|
||||
<el-button type="success" v-if="auth.isDirector" @click="router.push('/weekly-report')">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
导出 Excel
|
||||
</el-button>
|
||||
<el-button plain @click="router.push('/customers')">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
|
||||
<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>
|
||||
</svg>
|
||||
客户管理
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Reporting Progress ═══ -->
|
||||
<el-card class="progress-card">
|
||||
<template #header>
|
||||
@@ -174,6 +185,7 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="progress.length === 0" class="empty">暂无数据</div>
|
||||
<div class="progress-grid">
|
||||
<div
|
||||
v-for="p in progress"
|
||||
:key="p.manager_id"
|
||||
@@ -201,13 +213,16 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
|
||||
:stroke-width="12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ═══ Page Header ═══ */
|
||||
.page-head { margin-bottom: 24px; }
|
||||
.page-head { margin-bottom: 20px; }
|
||||
.page-head-row { display: flex; justify-content: space-between; align-items: center; }
|
||||
.page-head-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
@@ -264,8 +279,12 @@ function rowState(p: any): 'full' | 'catching' | 'missing' {
|
||||
font-size: 12px; color: var(--warm-gray); letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ═══ Actions ═══ */
|
||||
.actions { display: flex; gap: 10px; margin-bottom: 24px; }
|
||||
/* ═══ Progress Grid — responsive 2-column ═══ */
|
||||
.progress-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
|
||||
gap: 8px 24px;
|
||||
}
|
||||
|
||||
/* ═══ Progress ═══ */
|
||||
.progress-card { margin-top: 4px; }
|
||||
|
||||
@@ -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 keyVisits = ref<any[]>([])
|
||||
const customers = ref<any[]>([])
|
||||
const shotRef = ref<HTMLElement | null>(null)
|
||||
const { capturing, captureEl } = useScreenshot()
|
||||
const allUsers = ref<any[]>([])
|
||||
const plannedVisitors = ref<string[]>([])
|
||||
|
||||
@@ -23,6 +26,35 @@ const form = ref<any>({})
|
||||
const statusPick = ref<Record<string, string>>({})
|
||||
const keyStatuses = ['未开始', '进行中', '已完成']
|
||||
|
||||
// ── Filters ──
|
||||
const filterManager = ref('')
|
||||
const filterStatus = ref('')
|
||||
|
||||
const managerOptions = computed(() => {
|
||||
const seen = new Set<string>()
|
||||
return keyVisits.value
|
||||
.map((k: any) => k.manager_name || '未知')
|
||||
.filter((n: string) => { if (seen.has(n)) return false; seen.add(n); return true })
|
||||
.sort()
|
||||
})
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
let list = keyVisits.value
|
||||
if (filterManager.value) list = list.filter((k: any) => (k.manager_name || '未知') === filterManager.value)
|
||||
if (filterStatus.value) list = list.filter((k: any) => k.progress_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> = {}
|
||||
keyVisits.value.forEach((k: any) => {
|
||||
@@ -116,28 +148,59 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
|
||||
<template>
|
||||
<div class="key-visits-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>
|
||||
<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 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" :style="getMgrStyle(name, isLight)">{{ name }} · {{ count }}</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 keyStatuses" :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">{{ filteredItems.length }} / {{ keyVisits.length }} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card>
|
||||
<el-table :data="keyVisits" stripe size="small" v-if="keyVisits.length">
|
||||
<el-table :data="filteredItems" stripe size="small" v-if="filteredItems.length" v-column-resize>
|
||||
<el-table-column type="index" label="序号" width="50" />
|
||||
<el-table-column prop="customer_name" label="客户" width="160">
|
||||
<template #default="{ row }">
|
||||
@@ -176,14 +239,16 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
<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">
|
||||
<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-else class="empty">暂无需重点关注的客户</div>
|
||||
<div v-if="!keyVisits.length" class="empty">暂无需重点关注的客户</div>
|
||||
<div v-else-if="keyVisits.length && !filteredItems.length" class="empty">无匹配结果</div>
|
||||
</el-card>
|
||||
</div><!-- /shotRef -->
|
||||
|
||||
<!-- Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 要客拜访'" width="520px">
|
||||
@@ -231,13 +296,21 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
.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; }
|
||||
.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; }
|
||||
.method-grid { display: flex; gap: 8px; }
|
||||
.empty { text-align: center; color: var(--c-text-muted); padding: 48px 0; font-family: var(--font-body); }
|
||||
.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; }
|
||||
</style>
|
||||
|
||||
@@ -318,23 +318,34 @@ const statusLabel: Record<string, string> = { 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); }
|
||||
|
||||
@@ -287,7 +287,7 @@ const notesByDate = computed(() => {
|
||||
</svg>
|
||||
{{ date }}
|
||||
</h4>
|
||||
<el-table :data="items" stripe size="small">
|
||||
<el-table :data="items" stripe size="small" v-column-resize>
|
||||
<el-table-column prop="customer_name" label="客户" width="130">
|
||||
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('visit', row)">{{ row.customer_name }}</el-link></template>
|
||||
</el-table-column>
|
||||
@@ -320,7 +320,7 @@ const notesByDate = computed(() => {
|
||||
</svg>
|
||||
{{ date }}
|
||||
</h4>
|
||||
<el-table :data="items" stripe size="small">
|
||||
<el-table :data="items" stripe size="small" v-column-resize>
|
||||
<el-table-column prop="category" label="分类" width="100"><template #default="{ row }"><el-tag size="small">{{ row.category }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="content" label="工作内容" min-width="250" show-overflow-tooltip>
|
||||
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('note', row)">{{ row.content }}</el-link></template>
|
||||
@@ -339,7 +339,7 @@ const notesByDate = computed(() => {
|
||||
<!-- ═══ 工作计划 ═══ -->
|
||||
<el-tab-pane label="工作计划" name="work_plans">
|
||||
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('plan')">+ 新建计划</el-button></div>
|
||||
<el-table :data="workPlans" stripe size="small">
|
||||
<el-table :data="workPlans" stripe size="small" v-column-resize>
|
||||
<el-table-column prop="customer_name" label="客户" width="130">
|
||||
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('plan', row)">{{ row.customer_name }}</el-link></template>
|
||||
</el-table-column>
|
||||
@@ -371,7 +371,7 @@ const notesByDate = computed(() => {
|
||||
<!-- ═══ 小微商机 ═══ -->
|
||||
<el-tab-pane label="小微商机" name="mini_biz">
|
||||
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('mini')">+ 新建商机</el-button></div>
|
||||
<el-table :data="miniBusiness" stripe size="small">
|
||||
<el-table :data="miniBusiness" stripe size="small" v-column-resize>
|
||||
<el-table-column prop="customer_name" label="客户" width="130">
|
||||
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('mini', row)">{{ row.customer_name }}</el-link></template>
|
||||
</el-table-column>
|
||||
@@ -405,7 +405,7 @@ const notesByDate = computed(() => {
|
||||
<!-- ═══ 要客拜访 ═══ -->
|
||||
<el-tab-pane label="要客拜访" name="key_visits">
|
||||
<div style="margin-bottom:12px"><el-button type="primary" size="small" @click="openCreate('key')">+ 新建要客拜访</el-button></div>
|
||||
<el-table :data="keyVisits" stripe size="small">
|
||||
<el-table :data="keyVisits" stripe size="small" v-column-resize>
|
||||
<el-table-column prop="customer_name" label="客户" width="130">
|
||||
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('key', row)">{{ row.customer_name }}</el-link></template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -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<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')
|
||||
@@ -21,6 +24,35 @@ const form = ref<any>({})
|
||||
const statusPick = ref<Record<string, string>>({})
|
||||
const miniStatuses = ['跟进中', '已签约', '已流失']
|
||||
|
||||
// ── Filters ──
|
||||
const filterManager = ref('')
|
||||
const filterStatus = ref('')
|
||||
|
||||
const managerOptions = computed(() => {
|
||||
const seen = new Set<string>()
|
||||
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<string, number> = {}
|
||||
miniBusiness.value.forEach((m: any) => {
|
||||
@@ -101,28 +133,59 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
|
||||
<template>
|
||||
<div class="mini-biz-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>
|
||||
<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 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" :style="getMgrStyle(name, isLight)">{{ name }} · {{ count }}</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 miniStatuses" :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">{{ filteredItems.length }} / {{ miniBusiness.length }} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card>
|
||||
<el-table :data="miniBusiness" stripe size="small" v-if="miniBusiness.length">
|
||||
<el-table :data="filteredItems" stripe size="small" v-if="filteredItems.length" v-column-resize>
|
||||
<el-table-column type="index" label="序号" width="50" />
|
||||
<el-table-column prop="customer_name" label="客户" width="160">
|
||||
<template #default="{ row }">
|
||||
@@ -156,14 +219,16 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="expected_revenue_date" label="预计列收" width="110" />
|
||||
<el-table-column label="操作" width="80" fixed="right">
|
||||
<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-else class="empty">暂无商机记录</div>
|
||||
<div v-if="!miniBusiness.length" class="empty">暂无商机记录</div>
|
||||
<div v-else-if="miniBusiness.length && !filteredItems.length" class="empty">无匹配结果</div>
|
||||
</el-card>
|
||||
</div><!-- /shotRef -->
|
||||
|
||||
<!-- Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 小微商机'" width="500px">
|
||||
@@ -200,12 +265,20 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
.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; }
|
||||
.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 { 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; }
|
||||
</style>
|
||||
|
||||
@@ -82,7 +82,7 @@ async function handleDelete(row: any) {
|
||||
<div class="page-rule"></div>
|
||||
</div>
|
||||
<el-card>
|
||||
<el-table :data="users" stripe>
|
||||
<el-table :data="users" stripe v-column-resize>
|
||||
<el-table-column type="index" label="序号" width="60" />
|
||||
<el-table-column prop="name" label="姓名" width="120" />
|
||||
<el-table-column label="角色" width="120">
|
||||
|
||||
@@ -323,7 +323,7 @@ const notesByDate = computed(() => {
|
||||
</svg>
|
||||
{{ date }}
|
||||
</h4>
|
||||
<el-table :data="items" stripe style="width:100%">
|
||||
<el-table :data="items" stripe style="width:100%" v-column-resize>
|
||||
<el-table-column prop="customer_name" label="客户" width="120" />
|
||||
<el-table-column prop="visit_method" label="方式" width="60" />
|
||||
<el-table-column prop="time_range" label="时间" width="100" />
|
||||
@@ -367,7 +367,7 @@ const notesByDate = computed(() => {
|
||||
</svg>
|
||||
{{ date }}
|
||||
</h4>
|
||||
<el-table :data="items" stripe style="width:100%">
|
||||
<el-table :data="items" stripe style="width:100%" v-column-resize>
|
||||
<el-table-column label="分类" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.category === '行政事务' ? '' : row.category === '内部会议' ? 'danger' : row.category === '培训学习' ? 'info' : 'warning'" size="small">{{ row.category }}</el-tag>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -13,6 +14,8 @@ 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')
|
||||
@@ -20,8 +23,37 @@ 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) => {
|
||||
@@ -102,28 +134,59 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
|
||||
<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>
|
||||
<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 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" :style="getMgrStyle(name, isLight)">{{ name }} · {{ count }}</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="workPlans" stripe size="small" v-if="workPlans.length">
|
||||
<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 }">
|
||||
@@ -155,14 +218,16 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" fixed="right">
|
||||
<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-else class="empty">暂无工作计划</div>
|
||||
<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">
|
||||
@@ -199,12 +264,22 @@ async function quickStatusChange(row: any, newStatus: string) {
|
||||
.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; }
|
||||
.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; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user