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
@@ -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>