467 lines
14 KiB
Vue
467 lines
14 KiB
Vue
<template>
|
||
<div class="mubiao-page">
|
||
|
||
<!-- ── 顶部工具栏 ── -->
|
||
<div class="toolbar">
|
||
<div class="toolbar__left">
|
||
<div class="toolbar__icon">
|
||
<el-icon><TrendCharts /></el-icon>
|
||
</div>
|
||
<div>
|
||
<div class="toolbar__title">制定业绩目标</div>
|
||
<div class="toolbar__desc">
|
||
按部门设置每月销售目标,填 0 并保存将删除该记录。列表按当前账号
|
||
<strong>角色数据权限</strong>
|
||
仅展示<strong>本部门及下级</strong>(不展示权限外的上级组织;口径与业绩看板数据范围一致)。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="toolbar__center">
|
||
<div class="month-picker-wrap">
|
||
<span class="month-picker-wrap__label">目标月份</span>
|
||
<el-date-picker
|
||
v-model="yearMonth"
|
||
type="month"
|
||
value-format="YYYY-MM"
|
||
placeholder="选择月份"
|
||
:clearable="false"
|
||
size="default"
|
||
class="month-picker"
|
||
@change="loadMatrix"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="toolbar__right">
|
||
<div class="summary-strip">
|
||
<div class="summary-item">
|
||
<span class="summary-item__val">{{ deptCount }}</span>
|
||
<span class="summary-item__label">部门</span>
|
||
</div>
|
||
<div class="summary-divider" />
|
||
<div class="summary-item">
|
||
<span class="summary-item__val primary">¥{{ formatMoneyShort(totalDraft) }}</span>
|
||
<span class="summary-item__label">目标合计</span>
|
||
</div>
|
||
</div>
|
||
<el-button circle :loading="loading" @click="loadMatrix" title="刷新">
|
||
<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 v-loading="loading" element-loading-text="数据加载中…" class="board-wrap">
|
||
|
||
<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">
|
||
{{ dataScopeLimited ? '数据权限内无可选部门' : '暂无部门数据' }}
|
||
</p>
|
||
<p class="empty-state__desc">
|
||
{{
|
||
dataScopeLimited
|
||
? '当前角色数据范围未包含任何部门,或需在权限中绑定部门后再试。'
|
||
: '请检查部门配置后重试'
|
||
}}
|
||
</p>
|
||
<el-button size="small" @click="loadMatrix"><el-icon><Refresh /></el-icon>重新加载</el-button>
|
||
</div>
|
||
|
||
<div v-else class="board-grid">
|
||
<!-- 根级卡片 -->
|
||
<TransitionGroup name="card" tag="div" class="board-grid__inner">
|
||
<MubiaoDeptCard
|
||
v-for="r in formRows"
|
||
:key="'root-' + r.dept_id"
|
||
:node="r"
|
||
:is-root="true"
|
||
:drilled-id="drilledId"
|
||
@drill-in="handleDrillIn"
|
||
/>
|
||
<!-- 钻取卡片(顺序追加在根卡片后) -->
|
||
<MubiaoDeptCard
|
||
v-for="item in drillStack"
|
||
:key="'drill-' + item.dept_id"
|
||
:node="item"
|
||
:is-root="false"
|
||
:drilled-id="drilledId"
|
||
class="card--drilled"
|
||
@drill-in="handleDrillIn"
|
||
@close="handleClose(item.dept_id)"
|
||
/>
|
||
</TransitionGroup>
|
||
</div>
|
||
</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 MubiaoDeptCard from './mubiao-dept-card.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')}`
|
||
}
|
||
|
||
function formatMoneyShort(v: number): string {
|
||
if (v >= 10000) return (v / 10000).toFixed(1) + 'w'
|
||
return v.toLocaleString('zh-CN', { maximumFractionDigits: 0 })
|
||
}
|
||
|
||
const yearMonth = ref<string>(ymNow())
|
||
const loading = ref(false)
|
||
const saving = ref(false)
|
||
const formRows = ref<Row[]>([])
|
||
/** 接口返回:当前账号是否受数据权限收窄(非「全部」等) */
|
||
const dataScopeLimited = ref(false)
|
||
|
||
// 钻取卡片栈:每次 drill-in 追加,close 时移除及其后续
|
||
const drillStack = ref<Row[]>([])
|
||
// 当前被钻取高亮的 dept_id(让来源卡片的那一行高亮)
|
||
const drilledId = ref<number | null>(null)
|
||
|
||
const totalDraft = computed(() => sumTree(formRows.value))
|
||
const deptCount = computed(() => countTree(formRows.value))
|
||
|
||
function handleDrillIn(node: Row) {
|
||
// 已存在则不重复开
|
||
if (drillStack.value.some(d => d.dept_id === node.dept_id)) {
|
||
drilledId.value = node.dept_id
|
||
return
|
||
}
|
||
drillStack.value.push(node)
|
||
drilledId.value = node.dept_id
|
||
}
|
||
|
||
function handleClose(deptId: number) {
|
||
const idx = drillStack.value.findIndex(d => d.dept_id === deptId)
|
||
if (idx !== -1) {
|
||
// 关闭该卡及其之后所有钻取卡(子级)
|
||
drillStack.value.splice(idx)
|
||
}
|
||
drilledId.value = drillStack.value.length > 0
|
||
? drillStack.value[drillStack.value.length - 1].dept_id
|
||
: null
|
||
}
|
||
|
||
async function loadMatrix() {
|
||
if (!yearMonth.value) return
|
||
loading.value = true
|
||
drillStack.value = []
|
||
drilledId.value = null
|
||
try {
|
||
const res: any = await deptPerformanceTargetMonthMatrix({ year_month: yearMonth.value })
|
||
formRows.value = ((res?.rows || []) as Record<string, any>[]).map(normalizeRow)
|
||
dataScopeLimited.value = Boolean(res?.data_scope_limited)
|
||
} catch {
|
||
formRows.value = []
|
||
dataScopeLimited.value = false
|
||
} 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)
|
||
dataScopeLimited.value = Boolean(res?.data_scope_limited)
|
||
drillStack.value = []
|
||
drilledId.value = null
|
||
} 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;
|
||
}
|
||
|
||
/* ── 工具栏 ── */
|
||
.toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
padding: 14px 20px;
|
||
background: var(--el-bg-color);
|
||
border: 1px solid var(--el-border-color-lighter);
|
||
border-radius: 14px;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.toolbar__left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.toolbar__icon {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 12px;
|
||
background: linear-gradient(135deg, var(--el-color-primary), var(--el-color-primary-light-3));
|
||
color: #fff;
|
||
font-size: 19px;
|
||
flex-shrink: 0;
|
||
box-shadow: 0 4px 10px color-mix(in srgb, var(--el-color-primary) 30%, transparent);
|
||
}
|
||
|
||
.toolbar__title {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: var(--el-text-color-primary);
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.toolbar__desc {
|
||
font-size: 11.5px;
|
||
color: var(--el-text-color-placeholder);
|
||
line-height: 1.4;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.toolbar__center {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.month-picker-wrap {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 7px 14px;
|
||
background: var(--el-fill-color-extra-light);
|
||
border: 1px solid var(--el-border-color-lighter);
|
||
border-radius: 10px;
|
||
|
||
&__label {
|
||
font-size: 12px;
|
||
color: var(--el-text-color-secondary);
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
}
|
||
}
|
||
|
||
.month-picker {
|
||
width: 130px;
|
||
|
||
:deep(.el-input__wrapper) {
|
||
box-shadow: none !important;
|
||
background: transparent;
|
||
padding: 0 2px;
|
||
}
|
||
|
||
:deep(.el-input__inner) {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: var(--el-text-color-primary);
|
||
cursor: pointer;
|
||
}
|
||
|
||
:deep(.el-input__prefix) {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
.toolbar__right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.summary-strip {
|
||
display: flex;
|
||
align-items: center;
|
||
background: var(--el-fill-color-extra-light);
|
||
border: 1px solid var(--el-border-color-lighter);
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.summary-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 6px 16px;
|
||
gap: 1px;
|
||
|
||
&__val {
|
||
font-size: 16px;
|
||
font-weight: 800;
|
||
color: var(--el-text-color-primary);
|
||
line-height: 1.2;
|
||
letter-spacing: -0.02em;
|
||
|
||
&.primary { color: var(--el-color-primary); }
|
||
}
|
||
|
||
&__label {
|
||
font-size: 10.5px;
|
||
color: var(--el-text-color-placeholder);
|
||
letter-spacing: 0.04em;
|
||
}
|
||
}
|
||
|
||
.summary-divider {
|
||
width: 1px;
|
||
height: 32px;
|
||
background: var(--el-border-color-lighter);
|
||
}
|
||
|
||
/* ── 看板 ── */
|
||
.board-wrap {
|
||
min-height: 200px;
|
||
}
|
||
|
||
.board-grid__inner {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||
gap: 14px;
|
||
}
|
||
|
||
/* 钻取卡片入场动画 */
|
||
.card-enter-active {
|
||
animation: card-in 0.25s ease;
|
||
}
|
||
.card-leave-active {
|
||
animation: card-in 0.2s ease reverse;
|
||
}
|
||
|
||
@keyframes card-in {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(10px) scale(0.97);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0) scale(1);
|
||
}
|
||
}
|
||
|
||
/* 钻取来源高亮标记 */
|
||
:deep(.card--drilled) {
|
||
--drill-glow: color-mix(in srgb, var(--el-color-primary) 18%, transparent);
|
||
box-shadow: 0 0 0 2px var(--el-color-primary-light-5), 0 4px 16px var(--drill-glow);
|
||
}
|
||
|
||
/* ── 空状态 ── */
|
||
.empty-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 80px 0;
|
||
text-align: center;
|
||
}
|
||
|
||
.empty-state__icon {
|
||
width: 64px;
|
||
height: 64px;
|
||
border-radius: 20px;
|
||
background: var(--el-fill-color-light);
|
||
color: var(--el-text-color-placeholder);
|
||
font-size: 30px;
|
||
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>
|