Files
zyt/admin/src/views/finance/mubiao-dept-card.vue
T
2026-04-30 14:20:40 +08:00

593 lines
17 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="dept-card" :class="{ 'dept-card--saved': allSaved, 'dept-card--empty': isEmpty }">
<!-- 面包屑路径仅钻取卡显示 -->
<div v-if="!isRoot && breadcrumb" class="dept-card__crumb">
<span
v-for="(seg, i) in breadcrumb"
:key="i"
class="crumb-seg"
>
<span class="crumb-seg__text">{{ seg }}</span>
<el-icon v-if="i < breadcrumb.length - 1" class="crumb-sep"><ArrowRight /></el-icon>
</span>
</div>
<!-- 卡片头 -->
<div class="dept-card__header">
<div class="dept-card__avatar" :style="{ background: avatarBg }">
{{ node.dept_name.slice(0, 1) }}
</div>
<div class="dept-card__meta">
<div class="dept-card__name">{{ node.dept_name }}</div>
<div v-if="node.children?.length" class="dept-card__sub-count">
{{ node.children.length }} 个子部门
<span v-if="childTotal > 0" class="dept-card__sub-total">· ¥{{ formatMoneyShort(childTotal) }}</span>
</div>
</div>
<div class="dept-card__actions">
<span v-if="node.target_id" class="status-badge status-badge--saved">已保存</span>
<span v-else class="status-badge status-badge--new">未设置</span>
<!-- 钻取卡才有关闭按钮 -->
<button v-if="!isRoot" class="close-btn" title="关闭此卡片" @click="emit('close')">
<el-icon><Close /></el-icon>
</button>
</div>
</div>
<!-- 当前部门输入 -->
<div class="dept-card__body">
<div class="field-row">
<div class="field-block">
<label class="field-block__label">目标金额</label>
<el-input-number
v-model="node.target_amount"
:min="0"
:precision="2"
:step="1000"
controls-position="right"
placeholder="0.00"
class="field-block__input"
/>
<span v-if="node.target_amount > 0" class="field-block__hint">
¥ {{ formatMoney(node.target_amount) }}
</span>
</div>
<div class="field-block field-block--remark">
<label class="field-block__label">备注</label>
<el-input
v-model="node.remark"
maxlength="255"
placeholder="备注(选填)"
clearable
/>
</div>
</div>
</div>
<!-- 子部门列表 -->
<template v-if="node.children?.length">
<div class="child-header">
<span class="child-header__title">子部门</span>
<span class="child-header__count">{{ node.children.length }}</span>
</div>
<div class="child-list">
<div
v-for="child in node.children"
:key="child.dept_id"
class="child-row"
:class="{ 'child-row--active': drilledId === child.dept_id }"
>
<div class="child-row__name-wrap">
<div class="child-row__dot" />
<span class="child-row__name">{{ child.dept_name }}</span>
<el-tag
v-if="child.target_id"
size="small" type="success" effect="plain" round
class="child-row__tag"
>已保存</el-tag>
</div>
<div class="child-row__inputs">
<el-input-number
v-model="child.target_amount"
:min="0"
:precision="2"
:step="1000"
controls-position="right"
placeholder="0.00"
class="child-row__amount"
/>
<el-input
v-model="child.remark"
maxlength="255"
placeholder="备注"
clearable
size="small"
class="child-row__remark"
/>
<!-- 子部门若有下级显示子树合计 + 钻取按钮 -->
<template v-if="child.children?.length">
<span
v-if="subtreeSum(child) > 0"
class="child-subtree-total"
:title="`「${child.dept_name}」含下级合计`"
>
={{ formatMoneyShort(subtreeSum(child)) }}
</span>
<button
class="drill-btn"
:class="{ 'drill-btn--active': drilledId === child.dept_id }"
:title="`展开「${child.dept_name}」的 ${child.children.length} 个子部门`"
@click="emit('drill-in', child)"
>
<el-icon><ArrowRight /></el-icon>
<span>{{ child.children.length }}</span>
</button>
</template>
</div>
</div>
</div>
</template>
<!-- 底部合计 -->
<div class="dept-card__footer">
<span class="dept-card__footer-label">本卡合计</span>
<span class="dept-card__footer-total" :class="{ 'is-nonzero': cardTotal > 0 }">
¥ {{ formatMoney(cardTotal) }}
</span>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { ArrowRight, Close } from '@element-plus/icons-vue'
import type { DeptTargetRow } from './mubiao-dept-node.vue'
const props = defineProps<{
node: DeptTargetRow
isRoot: boolean
drilledId: number | null
}>()
const emit = defineEmits<{
(e: 'drill-in', node: DeptTargetRow): void
(e: 'close'): void
}>()
// 递归求一个节点的整棵子树金额合计(含自身)
function subtreeSum(node: DeptTargetRow): number {
let s = Number(node.target_amount ?? 0)
if (node.children?.length) {
for (const c of node.children) s += subtreeSum(c)
}
return s
}
const childTotal = computed(() => {
if (!props.node.children?.length) return 0
return props.node.children.reduce((s, c) => s + subtreeSum(c), 0)
})
const cardTotal = computed(() => Number(props.node.target_amount ?? 0) + childTotal.value)
const isEmpty = computed(() => cardTotal.value === 0)
const allSaved = computed(() => {
if (!props.node.target_id) return false
if (!props.node.children?.length) return true
return props.node.children.every(c => c.target_id > 0)
})
// 面包屑:从 dept_path 中提取,去掉最后一段(就是自己的名字)
const breadcrumb = computed(() => {
if (!props.node.dept_path) return null
const segs = props.node.dept_path.split(/\s*\/\s*/).filter(Boolean)
return segs.length > 1 ? segs : null
})
// 根据名称哈希生成确定性渐变
const avatarBg = computed(() => {
const palettes = [
'linear-gradient(135deg,#667eea,#764ba2)',
'linear-gradient(135deg,#f093fb,#f5576c)',
'linear-gradient(135deg,#4facfe,#00f2fe)',
'linear-gradient(135deg,#43e97b,#38f9d7)',
'linear-gradient(135deg,#fa709a,#fee140)',
'linear-gradient(135deg,#30cfd0,#330867)',
'linear-gradient(135deg,#a18cd1,#fbc2eb)',
'linear-gradient(135deg,#fccb90,#d57eeb)',
'linear-gradient(135deg,#e0c3fc,#8ec5fc)',
'linear-gradient(135deg,#f6d365,#fda085)',
]
let hash = 0
for (let i = 0; i < props.node.dept_name.length; i++) {
hash = (hash * 31 + props.node.dept_name.charCodeAt(i)) & 0xffff
}
return palettes[hash % palettes.length]
})
function formatMoney(v: number): string {
return Number(v || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function formatMoneyShort(v: number): string {
if (v >= 10000) return (v / 10000).toFixed(1) + 'w'
return v.toLocaleString('zh-CN', { maximumFractionDigits: 0 })
}
</script>
<style lang="scss" scoped>
.dept-card {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 14px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transition: box-shadow 0.2s ease, transform 0.15s ease;
display: flex;
flex-direction: column;
&:hover {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.09);
transform: translateY(-1px);
}
&--saved {
border-color: color-mix(in srgb, #67c23a 30%, var(--el-border-color-lighter));
}
&--empty {
opacity: 0.8;
}
}
/* ── 面包屑 ── */
.dept-card__crumb {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 2px;
padding: 8px 14px 0;
font-size: 11px;
.crumb-seg {
display: inline-flex;
align-items: center;
gap: 2px;
&__text {
color: var(--el-text-color-placeholder);
&:last-child {
color: var(--el-color-primary);
font-weight: 600;
}
}
}
.crumb-sep {
font-size: 10px;
color: var(--el-border-color-darker);
}
}
/* ── 卡片头 ── */
.dept-card__header {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 14px 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
background: linear-gradient(180deg, var(--el-fill-color-extra-light), var(--el-bg-color));
}
.dept-card__avatar {
width: 38px;
height: 38px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 800;
color: #fff;
flex-shrink: 0;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.18);
}
.dept-card__meta {
flex: 1;
min-width: 0;
}
.dept-card__name {
font-size: 14px;
font-weight: 700;
color: var(--el-text-color-primary);
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dept-card__sub-count {
font-size: 11.5px;
color: var(--el-text-color-placeholder);
margin-top: 2px;
}
.dept-card__sub-total {
color: var(--el-color-primary);
font-weight: 600;
}
.dept-card__actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.close-btn {
all: unset;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
color: var(--el-text-color-placeholder);
cursor: pointer;
transition: background 0.15s, color 0.15s;
&:hover {
background: var(--el-fill-color);
color: var(--el-color-danger);
}
.el-icon { font-size: 13px; }
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 3px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
&--saved {
background: color-mix(in srgb, #67c23a 12%, transparent);
color: #529b2e;
border: 1px solid color-mix(in srgb, #67c23a 28%, transparent);
}
&--new {
background: var(--el-fill-color);
color: var(--el-text-color-placeholder);
border: 1px solid var(--el-border-color-lighter);
}
}
/* ── 主体输入 ── */
.dept-card__body {
padding: 14px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.field-row {
display: flex;
flex-direction: column;
gap: 10px;
}
.field-block {
display: flex;
flex-direction: column;
gap: 5px;
&__label {
font-size: 10.5px;
font-weight: 700;
color: var(--el-text-color-secondary);
letter-spacing: 0.07em;
text-transform: uppercase;
}
&__input {
width: 100%;
:deep(.el-input__wrapper) {
border-radius: 8px;
}
}
&__hint {
font-size: 11px;
color: var(--el-text-color-placeholder);
margin-top: -2px;
}
}
/* ── 子部门区 ── */
.child-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: var(--el-fill-color-extra-light);
border-bottom: 1px solid var(--el-border-color-lighter);
&__title {
font-size: 11px;
font-weight: 700;
color: var(--el-text-color-secondary);
letter-spacing: 0.07em;
text-transform: uppercase;
}
&__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
background: var(--el-color-primary-light-8);
color: var(--el-color-primary);
}
}
.child-list {
display: flex;
flex-direction: column;
}
.child-row {
padding: 9px 14px;
border-bottom: 1px solid var(--el-border-color-lighter);
transition: background 0.12s;
&:last-child { border-bottom: none; }
&:hover { background: var(--el-fill-color-extra-light); }
&--active {
background: color-mix(in srgb, var(--el-color-primary) 5%, var(--el-bg-color)) !important;
border-left: 2px solid var(--el-color-primary-light-4);
}
&__name-wrap {
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 7px;
}
&__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--el-color-primary-light-4);
flex-shrink: 0;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--el-color-primary) 15%, transparent);
}
&__name {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__tag { flex-shrink: 0; }
&__inputs {
display: flex;
align-items: center;
gap: 8px;
}
&__amount {
flex: 0 0 150px;
:deep(.el-input__wrapper) {
border-radius: 7px;
}
}
&__remark {
flex: 1;
min-width: 0;
:deep(.el-input__wrapper) {
border-radius: 7px;
}
}
}
/* 子树合计标签 */
.child-subtree-total {
flex-shrink: 0;
font-size: 11.5px;
font-weight: 700;
color: var(--el-color-primary);
white-space: nowrap;
letter-spacing: -0.01em;
}
/* 钻取按钮 */
.drill-btn {
all: unset;
display: inline-flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
padding: 5px 10px;
border-radius: 7px;
font-size: 12px;
font-weight: 700;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-7);
cursor: pointer;
transition: background 0.15s, box-shadow 0.15s, transform 0.1s;
&:hover {
background: var(--el-color-primary-light-8);
box-shadow: 0 2px 6px color-mix(in srgb, var(--el-color-primary) 20%, transparent);
transform: translateX(1px);
}
&--active {
background: var(--el-color-primary);
color: #fff;
border-color: var(--el-color-primary);
box-shadow: 0 2px 8px color-mix(in srgb, var(--el-color-primary) 35%, transparent);
}
.el-icon {
font-size: 12px;
transition: transform 0.15s;
}
&:hover .el-icon { transform: translateX(2px); }
}
/* ── 底部 ── */
.dept-card__footer {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: var(--el-fill-color-extra-light);
border-top: 1px solid var(--el-border-color-lighter);
}
.dept-card__footer-label {
font-size: 11.5px;
color: var(--el-text-color-placeholder);
font-weight: 600;
letter-spacing: 0.04em;
}
.dept-card__footer-total {
font-size: 15px;
font-weight: 800;
color: var(--el-text-color-placeholder);
letter-spacing: -0.02em;
transition: color 0.2s;
&.is-nonzero { color: var(--el-color-primary); }
}
</style>