This commit is contained in:
Your Name
2026-04-30 09:27:42 +08:00
parent dc899e4af4
commit eb782305e6
460 changed files with 10785 additions and 928 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+264 -4
View File
@@ -9,6 +9,10 @@
<template #icon><DataLine /></template>
今日进入明细
</el-button>
<el-button type="warning" plain @click="openTagDrawer">
<template #icon><CollectionTag /></template>
标签维度
</el-button>
<el-button type="primary" :loading="syncing" @click="syncCustomers()">
<template #icon><Refresh /></template>
立即同步
@@ -98,6 +102,38 @@
@keyup.enter="resetPage"
/>
</el-form-item>
<el-form-item label="标签">
<el-select
v-model="queryParams.tag_ids"
multiple
collapse-tags
collapse-tags-tooltip
clearable
filterable
placeholder="按标签筛选(可多选,命中任一即返回)"
:loading="tagStatsLoading"
style="min-width: 320px"
@change="resetPage"
>
<el-option-group
v-for="g in tagStats.groups"
:key="g.group_name || '_'"
:label="(g.group_name || '(未分组)') + ' · ' + g.customer_count"
>
<el-option
v-for="t in g.tags"
:key="t.tag_id"
:value="t.tag_id"
:label="t.tag_name"
>
<span class="flex items-center justify-between gap-3">
<span>{{ t.tag_name }}</span>
<span class="text-xs text-gray-400">{{ t.customer_count }} </span>
</span>
</el-option>
</el-option-group>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="resetPage">查询</el-button>
<el-button @click="handleReset">重置</el-button>
@@ -144,6 +180,27 @@
<span v-else class="text-gray-400"></span>
</template>
</el-table-column>
<el-table-column label="标签" min-width="180">
<template #default="{ row }">
<div v-if="row.tags && row.tags.length">
<el-tag
v-for="t in row.tags"
:key="t.tag_id"
size="small"
effect="light"
type="warning"
class="mr-1 mb-1 cursor-pointer"
@click="addTagToFilter(t.tag_id)"
>
{{ t.tag_name }}
<span v-if="t.group_name" class="ml-1 text-gray-400 text-xs">
{{ t.group_name }}
</span>
</el-tag>
</div>
<span v-else class="text-gray-400"></span>
</template>
</el-table-column>
<el-table-column label="添加时间" width="160">
<template #default="{ row }">
{{ formatTime(firstExternalAddTime(row)) }}
@@ -293,6 +350,99 @@
</div>
</el-drawer>
<!-- 标签维度面板 -->
<el-drawer
v-model="showTagDrawer"
title="标签维度统计"
direction="rtl"
size="560px"
:destroy-on-close="false"
>
<template #header>
<div class="flex items-center justify-between w-full pr-4">
<div class="flex items-center gap-3">
<span class="text-base font-semibold">标签维度统计</span>
<el-tag type="warning" effect="plain">
{{ tagStats.groups.length }} · {{ tagStats.total_tags }} 标签
</el-tag>
</div>
<el-button link type="primary" :loading="tagStatsLoading" @click="loadTagStats(true)">
<template #icon><Refresh /></template>
刷新
</el-button>
</div>
</template>
<div class="px-1">
<div class="grid grid-cols-3 gap-2 mb-3">
<el-card shadow="never" body-class="!p-3">
<div class="text-xs text-gray-500">已打标签客户</div>
<div class="text-xl font-bold text-warning">{{ tagStats.total_tagged_customers }}</div>
</el-card>
<el-card shadow="never" body-class="!p-3">
<div class="text-xs text-gray-500">标签数</div>
<div class="text-xl font-bold">{{ tagStats.total_tags }}</div>
</el-card>
<el-card shadow="never" body-class="!p-3">
<div class="text-xs text-gray-500">关系总数</div>
<div class="text-xl font-bold">{{ tagStats.total_relations }}</div>
</el-card>
</div>
<el-input
v-model="tagSearchKw"
placeholder="筛选标签名 / 分组名"
clearable
size="small"
class="mb-3"
/>
<el-empty
v-if="filteredTagGroups.length === 0"
:description="tagStatsLoading ? '加载中…' : '暂无标签数据'"
:image-size="64"
/>
<el-collapse v-else v-model="tagDrawerActiveGroups">
<el-collapse-item
v-for="g in filteredTagGroups"
:key="g.group_name || '_'"
:name="g.group_name || '_'"
>
<template #title>
<div class="flex items-center gap-2 w-full">
<span class="font-medium">{{ g.group_name || '(未分组)' }}</span>
<el-tag size="small" effect="plain">{{ g.tags.length }}</el-tag>
<span class="ml-auto text-xs text-gray-500">
最热 {{ g.customer_count }}
</span>
</div>
</template>
<el-table :data="g.tags" size="small" stripe>
<el-table-column label="标签" min-width="160">
<template #default="{ row }">
<span class="font-medium">{{ row.tag_name }}</span>
<div class="text-xs text-gray-400 truncate">{{ row.tag_id }}</div>
</template>
</el-table-column>
<el-table-column label="客户数" width="100" align="right">
<template #default="{ row }">
<span class="text-warning font-bold">{{ row.customer_count }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="filterByTag(row.tag_id)">
筛选
</el-button>
</template>
</el-table-column>
</el-table>
</el-collapse-item>
</el-collapse>
</div>
</el-drawer>
<!-- 客户详情对话框 -->
<el-dialog
v-model="showDetail"
@@ -336,6 +486,24 @@
</div>
<span v-else class="text-gray-400"></span>
</el-descriptions-item>
<el-descriptions-item label="标签" :span="2">
<div v-if="currentCustomer.tags?.length">
<el-tag
v-for="t in currentCustomer.tags"
:key="t.tag_id"
size="small"
effect="light"
type="warning"
class="mr-1 mb-1"
>
{{ t.tag_name }}
<span v-if="t.group_name" class="ml-1 text-gray-400 text-xs">
{{ t.group_name }}
</span>
</el-tag>
</div>
<span v-else class="text-gray-400"></span>
</el-descriptions-item>
</el-descriptions>
</el-dialog>
</div>
@@ -343,7 +511,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { Refresh, Setting, DataLine } from '@element-plus/icons-vue'
import { Refresh, Setting, DataLine, CollectionTag } from '@element-plus/icons-vue'
import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback'
import {
@@ -353,7 +521,8 @@ import {
qywxSyncSettingsGet,
qywxSyncSettingsSave,
qywxCustomerTodayArrival,
qywxCustomerTodayArrivalList
qywxCustomerTodayArrivalList,
qywxCustomerTagStats
} from '@/api/qywx'
const syncing = ref(false)
@@ -388,11 +557,100 @@ const syncSettings = reactive({
interval: 3600
})
const queryParams = reactive({
const queryParams = reactive<{ name: string; follow_user: string; tag_ids: string[] }>({
name: '',
follow_user: ''
follow_user: '',
tag_ids: []
})
// ── 标签维度(筛选下拉 + 抽屉面板共用同一份数据) ──────────────────────────
interface TagItem {
tag_id: string
tag_name: string
customer_count: number
}
interface TagGroup {
group_name: string
customer_count: number
tags: TagItem[]
}
interface TagStatsPayload {
total_tags: number
total_relations: number
total_tagged_customers: number
groups: TagGroup[]
}
const tagStats = reactive<TagStatsPayload>({
total_tags: 0,
total_relations: 0,
total_tagged_customers: 0,
groups: []
})
const tagStatsLoading = ref(false)
const showTagDrawer = ref(false)
const tagSearchKw = ref('')
const tagDrawerActiveGroups = ref<string[]>([])
const filteredTagGroups = computed<TagGroup[]>(() => {
const kw = tagSearchKw.value.trim().toLowerCase()
if (!kw) return tagStats.groups
const result: TagGroup[] = []
for (const g of tagStats.groups) {
const groupHit = (g.group_name || '').toLowerCase().includes(kw)
const tags = g.tags.filter((t) => groupHit || (t.tag_name || '').toLowerCase().includes(kw))
if (tags.length) {
result.push({ ...g, tags })
}
}
return result
})
async function loadTagStats(showFeedback = false) {
tagStatsLoading.value = true
try {
const res: any = await qywxCustomerTagStats()
if (res && typeof res === 'object') {
tagStats.total_tags = Number(res.total_tags ?? 0)
tagStats.total_relations = Number(res.total_relations ?? 0)
tagStats.total_tagged_customers = Number(res.total_tagged_customers ?? 0)
tagStats.groups = Array.isArray(res.groups) ? res.groups : []
}
if (showFeedback) feedback.msgSuccess('已刷新')
} catch (e: any) {
if (showFeedback) feedback.msgError(e?.message || '加载标签统计失败')
console.error('加载标签统计失败', e)
} finally {
tagStatsLoading.value = false
}
}
function openTagDrawer() {
showTagDrawer.value = true
// 默认展开第 1 组(最热门);其余折叠避免首屏太长
tagDrawerActiveGroups.value = tagStats.groups[0]?.group_name ? [tagStats.groups[0].group_name] : []
if (tagStats.groups.length === 0) {
loadTagStats()
}
}
/** 列表上点击 tag 徽章 → 把这个 tag 加入筛选并重查 */
function addTagToFilter(tagId: string) {
if (!tagId) return
if (!queryParams.tag_ids.includes(tagId)) {
queryParams.tag_ids.push(tagId)
resetPage()
}
}
/** 抽屉里点「筛选」→ 用这个 tag 替换当前筛选 + 关闭抽屉 */
function filterByTag(tagId: string) {
if (!tagId) return
queryParams.tag_ids = [tagId]
showTagDrawer.value = false
resetPage()
}
// ── 今日进入分布 / 明细(事件流水零误差口径) ──────────────────────────────
interface ArrivalStats {
total: number
@@ -534,6 +792,7 @@ const { pager, getLists, resetPage, resetParams } = usePaging({
function handleReset() {
queryParams.name = ''
queryParams.follow_user = ''
queryParams.tag_ids = []
resetParams()
}
@@ -721,6 +980,7 @@ onMounted(() => {
loadStats()
loadSyncSettings()
loadArrival(false)
loadTagStats()
startArrivalPolling()
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,325 @@
<template>
<!-- 根节点section 分组展示 -->
<template v-if="depth === 0">
<div class="group">
<div class="group__header">
<div class="group__accent" />
<div class="group__name">{{ node.dept_name }}</div>
<div class="group__amount-wrap">
<el-input-number
v-model="node.target_amount"
:min="0"
:precision="2"
:step="1000"
controls-position="right"
placeholder="0.00"
class="group__amount-input"
/>
</div>
<div class="group__remark-wrap">
<el-input
v-model="node.remark"
maxlength="255"
placeholder="备注(选填)"
clearable
/>
</div>
<div class="group__badge-wrap">
<el-tag v-if="node.target_id" size="small" type="success" effect="plain">已保存</el-tag>
</div>
</div>
<div v-if="node.children?.length" class="group__body">
<MubiaoDeptNode
v-for="c in node.children"
:key="c.dept_id"
:node="c"
:depth="1"
/>
</div>
</div>
</template>
<!-- 子节点表格行 -->
<template v-else>
<div class="row" :data-depth="String(Math.min(depth, 4))">
<!-- 缩进空间 + 连接线 -->
<div class="row__indent" :style="{ width: `${(depth - 1) * 20 + 16}px` }">
<div class="row__connector" />
</div>
<!-- 部门名 -->
<div class="row__dept">
<div class="row__dot" />
<div class="row__dept-text">
<span class="row__name">{{ node.dept_name }}</span>
<span v-if="parentPath" class="row__path">{{ parentPath }}</span>
</div>
<el-tag v-if="node.target_id" size="small" type="success" effect="plain" class="row__saved">已保存</el-tag>
</div>
<!-- 金额 -->
<div class="row__amount">
<el-input-number
v-model="node.target_amount"
:min="0"
:precision="2"
:step="1000"
controls-position="right"
placeholder="0.00"
class="row__amount-input"
/>
</div>
<!-- 备注 -->
<div class="row__remark">
<el-input
v-model="node.remark"
maxlength="255"
placeholder="备注(选填)"
clearable
/>
</div>
</div>
<!-- 递归子节点 -->
<MubiaoDeptNode
v-for="c in node.children"
:key="c.dept_id"
:node="c"
:depth="depth + 1"
/>
</template>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
defineOptions({ name: 'MubiaoDeptNode' })
export interface DeptTargetRow {
dept_id: number
dept_name: string
dept_path: string
target_id: number
target_amount: number
remark: string
children?: DeptTargetRow[]
}
const props = defineProps<{
node: DeptTargetRow
depth: number
}>()
const segments = computed(() =>
props.node.dept_path.split(/\s*\/\s*/).filter(Boolean)
)
const parentPath = computed(() =>
segments.value.slice(0, -1).join(' ')
)
</script>
<style lang="scss" scoped>
/* ── 根节点:分组区块 ── */
.group {
border-bottom: 2px solid var(--el-border-color-lighter);
&:last-child {
border-bottom: none;
}
}
.group__header {
display: flex;
align-items: center;
gap: 0;
min-height: 52px;
background: linear-gradient(90deg, var(--el-color-primary-light-9) 0%, var(--el-fill-color-extra-light) 60%);
border-bottom: 1px solid var(--el-color-primary-light-7);
padding-right: 16px;
transition: background 0.15s;
&:hover {
background: linear-gradient(90deg, var(--el-color-primary-light-8) 0%, var(--el-fill-color-light) 60%);
}
}
.group__accent {
flex-shrink: 0;
width: 4px;
align-self: stretch;
background: var(--el-color-primary);
border-radius: 0 2px 2px 0;
margin-right: 12px;
}
.group__name {
flex: 0 0 auto;
width: 220px;
font-size: 14px;
font-weight: 700;
color: var(--el-color-primary-dark-2);
padding-right: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.group__amount-wrap {
flex: 0 0 194px;
border-left: 1px solid var(--el-color-primary-light-6);
padding: 8px 12px;
}
.group__amount-input {
width: 170px;
:deep(.el-input__wrapper) {
border-radius: 6px;
background: var(--el-bg-color);
box-shadow: 0 0 0 1px var(--el-color-primary-light-5) inset;
}
}
.group__remark-wrap {
flex: 1;
border-left: 1px solid var(--el-color-primary-light-6);
padding: 8px 12px;
}
.group__badge-wrap {
flex: 0 0 60px;
display: flex;
justify-content: center;
}
.group__body {
background: var(--el-bg-color);
}
/* ── 子节点:表格行 ── */
.row {
display: flex;
align-items: center;
min-height: 48px;
border-bottom: 1px solid var(--el-border-color-lighter);
transition: background 0.12s;
padding-right: 16px;
&:last-child {
border-bottom: none;
}
&:hover {
background: var(--el-fill-color-extra-light);
}
/* 深度色阶 */
&[data-depth='1'] { --dot: #67c23a; }
&[data-depth='2'] { --dot: #e6a23c; }
&[data-depth='3'] { --dot: #909399; }
&[data-depth='4'] { --dot: var(--el-border-color-darker); }
}
.row__indent {
flex-shrink: 0;
position: relative;
align-self: stretch;
min-width: 16px;
}
.row__connector {
position: absolute;
left: calc(100% - 2px);
top: 0;
bottom: 50%;
width: 10px;
border-left: 1px dashed var(--el-border-color);
border-bottom: 1px dashed var(--el-border-color);
border-radius: 0 0 0 4px;
}
.row__dept {
flex: 0 0 auto;
width: 220px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px 8px 8px;
}
.row__dot {
flex-shrink: 0;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--dot, #909399);
}
.row__dept-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.row__name {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row__path {
font-size: 11px;
color: var(--el-text-color-placeholder);
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row__saved {
flex-shrink: 0;
}
.row__amount {
flex: 0 0 194px;
border-left: 1px solid var(--el-border-color-lighter);
border-right: 1px solid var(--el-border-color-lighter);
padding: 8px 12px;
}
.row__amount-input {
width: 170px;
:deep(.el-input__wrapper) {
border-radius: 6px;
}
}
.row__remark {
flex: 1;
padding: 8px 0 8px 12px;
}
@media (max-width: 900px) {
.group__header,
.row {
flex-wrap: wrap;
padding-bottom: 10px;
}
.group__name,
.row__dept { width: 100%; }
.group__amount-wrap,
.row__amount { border: none; flex: 0 0 auto; }
.group__remark-wrap,
.row__remark { flex: 1 1 180px; border: none; }
.group__amount-input,
.row__amount-input { width: 160px; }
}
</style>
+439
View File
@@ -0,0 +1,439 @@
<template>
<div class="mubiao-page">
<!-- 顶部 Header -->
<div class="header">
<!-- 图标 + 文字 -->
<div class="header__left">
<div class="header__icon">
<el-icon><TrendCharts /></el-icon>
</div>
<div class="header__text">
<span class="header__title">制定业绩目标</span>
<span class="header__desc">按部门层级设置每月销售目标 0 并保存将删除该记录</span>
</div>
</div>
<!-- KPI + 操作 -->
<div class="header__right">
<!-- KPI 胶囊 -->
<div class="kpi-capsule">
<div class="kpi-cell">
<span class="kpi-cell__label">目标月份</span>
<el-date-picker
v-model="yearMonth"
type="month"
value-format="YYYY-MM"
placeholder="选择月份"
:clearable="false"
size="small"
class="kpi-cell__picker"
@change="loadMatrix"
/>
</div>
<div class="kpi-sep" />
<div class="kpi-cell">
<span class="kpi-cell__label">部门数量</span>
<span class="kpi-cell__val">{{ deptCount }}</span>
</div>
<div class="kpi-sep" />
<div class="kpi-cell kpi-cell--primary">
<span class="kpi-cell__label">目标合计</span>
<span class="kpi-cell__val">¥ {{ formatMoney(totalDraft) }}</span>
</div>
</div>
<!-- 操作按钮 -->
<el-button :loading="loading" @click="loadMatrix">
<el-icon><Refresh /></el-icon>刷新
</el-button>
<el-button
v-perms="['finance.dept_performance_target/batchSave']"
type="primary"
:loading="saving"
:disabled="loading || !yearMonth"
@click="handleSave"
>
<el-icon><Check /></el-icon>保存目标
</el-button>
</div>
</div>
<!-- 主内容面板 -->
<div class="panel" v-loading="loading" element-loading-text="数据加载中…">
<!-- 空状态 -->
<div v-if="!loading && formRows.length === 0" class="empty-state">
<div class="empty-state__icon"><el-icon><OfficeBuilding /></el-icon></div>
<p class="empty-state__title">暂无部门数据</p>
<p class="empty-state__desc">请检查部门配置后重试</p>
<el-button size="small" @click="loadMatrix"><el-icon><Refresh /></el-icon>重新加载</el-button>
</div>
<template v-else>
<!-- 列头 - 严格对齐内容列 -->
<div class="col-head">
<div class="col-head__dept">部门</div>
<div class="col-head__amount">目标金额</div>
<div class="col-head__remark">备注</div>
<div class="col-head__badge" />
</div>
<!-- 数据区 -->
<div class="data-scroll">
<MubiaoDeptNode
v-for="r in formRows"
:key="r.dept_id"
:node="r"
:depth="0"
/>
</div>
</template>
</div>
</div>
</template>
<script lang="ts" setup name="financeMubiao">
import { computed, onMounted, ref } from 'vue'
import { TrendCharts, Refresh, Check, OfficeBuilding } from '@element-plus/icons-vue'
import MubiaoDeptNode from './mubiao-dept-node.vue'
import {
deptPerformanceTargetBatchSave,
deptPerformanceTargetMonthMatrix,
} from '@/api/finance'
import feedback from '@/utils/feedback'
import type { DeptTargetRow } from './mubiao-dept-node.vue'
type Row = DeptTargetRow
function normalizeRow(r: Record<string, any>): Row {
const row: Row = {
dept_id: Number(r.dept_id ?? 0),
dept_name: String(r.dept_name ?? ''),
dept_path: String(r.dept_path ?? ''),
target_id: Number(r.target_id ?? 0),
target_amount: Number(r.target_amount ?? 0),
remark: String(r.remark ?? ''),
}
const ch = r.children
if (Array.isArray(ch) && ch.length > 0) row.children = ch.map(normalizeRow)
return row
}
function sumTree(nodes: Row[]): number {
let s = 0
for (const n of nodes) {
s += Number(n.target_amount ?? 0)
if (n.children?.length) s += sumTree(n.children)
}
return Math.round(s * 100) / 100
}
function countTree(nodes: Row[]): number {
let c = 0
for (const n of nodes) {
c += 1
if (n.children?.length) c += countTree(n.children)
}
return c
}
function flattenForSave(nodes: Row[]): Array<{ dept_id: number; target_amount: number; remark: string }> {
const out: Array<{ dept_id: number; target_amount: number; remark: string }> = []
for (const n of nodes) {
out.push({ dept_id: n.dept_id, target_amount: Number(n.target_amount ?? 0), remark: n.remark || '' })
if (n.children?.length) out.push(...flattenForSave(n.children))
}
return out
}
function ymNow(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
const yearMonth = ref<string>(ymNow())
const loading = ref(false)
const saving = ref(false)
const formRows = ref<Row[]>([])
const totalDraft = computed(() => sumTree(formRows.value))
const deptCount = computed(() => countTree(formRows.value))
function formatMoney(v: number) {
return Number(v || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
async function loadMatrix() {
if (!yearMonth.value) return
loading.value = true
try {
const res: any = await deptPerformanceTargetMonthMatrix({ year_month: yearMonth.value })
formRows.value = ((res?.rows || []) as Record<string, any>[]).map(normalizeRow)
} catch {
formRows.value = []
} finally {
loading.value = false
}
}
async function handleSave() {
if (!yearMonth.value) { feedback.msgWarning('请选择月份'); return }
await feedback.confirm('确认保存「' + yearMonth.value + '」各部门业绩目标?')
saving.value = true
try {
const res: any = await deptPerformanceTargetBatchSave({
year_month: yearMonth.value,
items: flattenForSave(formRows.value),
})
if (res?.rows) {
formRows.value = (res.rows as Record<string, any>[]).map(normalizeRow)
} else {
await loadMatrix()
}
} finally {
saving.value = false
}
}
onMounted(loadMatrix)
</script>
<style lang="scss" scoped>
.mubiao-page {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px 20px 40px;
}
/* ════════════════════ Header ════════════════════ */
.header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 14px 20px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.05);
}
.header__left {
display: flex;
align-items: center;
gap: 12px;
}
.header__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 10px;
background: linear-gradient(135deg, var(--el-color-primary-light-7), var(--el-color-primary-light-9));
color: var(--el-color-primary);
font-size: 19px;
flex-shrink: 0;
box-shadow: 0 2px 6px color-mix(in srgb, var(--el-color-primary) 20%, transparent);
}
.header__text {
display: flex;
flex-direction: column;
gap: 2px;
}
.header__title {
font-size: 15px;
font-weight: 700;
color: var(--el-text-color-primary);
line-height: 1.3;
}
.header__desc {
font-size: 12px;
color: var(--el-text-color-placeholder);
line-height: 1.3;
}
.header__right {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
/* KPI 胶囊 */
.kpi-capsule {
display: flex;
align-items: stretch;
background: var(--el-fill-color-extra-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
overflow: hidden;
}
.kpi-sep {
width: 1px;
background: var(--el-border-color-lighter);
align-self: stretch;
}
.kpi-cell {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 15px;
}
.kpi-cell--primary {
background: linear-gradient(135deg, var(--el-color-primary-light-9), var(--el-color-primary-light-8));
.kpi-cell__val {
color: var(--el-color-primary);
font-weight: 700;
}
}
.kpi-cell__label {
font-size: 11px;
color: var(--el-text-color-placeholder);
white-space: nowrap;
line-height: 1.3;
}
.kpi-cell__val {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
white-space: nowrap;
line-height: 1.3;
}
.kpi-cell__picker {
width: 112px;
margin-top: 1px;
:deep(.el-input__wrapper) {
box-shadow: none !important;
background: transparent;
padding: 0 2px;
}
:deep(.el-input__inner) {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
cursor: pointer;
}
:deep(.el-input__prefix) {
display: none;
}
}
/* ════════════════════ Panel ════════════════════ */
.panel {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.05);
overflow: hidden;
min-height: 200px;
}
/* 列头 —— 宽度需与子组件 .group__name/.row__dept (220px) 对齐 */
.col-head {
display: flex;
align-items: center;
height: 38px;
background: var(--el-fill-color-light);
border-bottom: 1px solid var(--el-border-color-lighter);
padding-right: 16px;
position: sticky;
top: 0;
z-index: 10;
font-size: 12px;
font-weight: 600;
color: var(--el-text-color-secondary);
letter-spacing: 0.04em;
user-select: none;
/* 左侧 4px accent 占位 + 12px margin = 16px */
padding-left: 16px;
}
/* 与 group/row 列宽严格对应 */
.col-head__dept {
flex: 0 0 220px;
}
.col-head__amount {
flex: 0 0 194px;
padding-left: 12px;
border-left: 1px solid var(--el-border-color-lighter);
}
.col-head__remark {
flex: 1;
padding-left: 12px;
border-left: 1px solid var(--el-border-color-lighter);
}
.col-head__badge {
flex: 0 0 60px;
}
/* 数据滚动区 */
.data-scroll {
max-height: min(72vh, 820px);
overflow-y: auto;
&::-webkit-scrollbar { width: 5px; }
&::-webkit-scrollbar-track { background: transparent; }
&::-webkit-scrollbar-thumb {
background: var(--el-border-color);
border-radius: 3px;
}
}
/* ════════════════════ Empty ════════════════════ */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 80px 0;
text-align: center;
}
.empty-state__icon {
width: 60px;
height: 60px;
border-radius: 18px;
background: var(--el-fill-color-light);
color: var(--el-text-color-placeholder);
font-size: 28px;
display: flex;
align-items: center;
justify-content: center;
}
.empty-state__title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.empty-state__desc {
margin: -4px 0 0;
font-size: 13px;
color: var(--el-text-color-placeholder);
}
</style>