更新
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user