Files
zyt/admin/src/views/finance/mubiao.vue
T
2026-05-05 14:34:39 +08:00

467 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>