更新
This commit is contained in:
@@ -64,3 +64,16 @@ export function accountCostDetail(params?: any) {
|
||||
export function accountCostDelete(params?: any) {
|
||||
return request.post({ url: '/finance.account_cost/delete', params })
|
||||
}
|
||||
|
||||
/** 制定业绩:按月查看各部门目标 */
|
||||
export function deptPerformanceTargetMonthMatrix(params: { year_month: string }) {
|
||||
return request.get({ url: '/finance.dept_performance_target/monthMatrix', params })
|
||||
}
|
||||
|
||||
/** 制定业绩:批量保存当月目标(target_amount≤0 则清除该部门当月目标) */
|
||||
export function deptPerformanceTargetBatchSave(params: {
|
||||
year_month: string
|
||||
items: Array<{ dept_id: number; target_amount: number; remark?: string }>
|
||||
}) {
|
||||
return request.post({ url: '/finance.dept_performance_target/batchSave', params })
|
||||
}
|
||||
|
||||
@@ -15,6 +15,17 @@ export function qywxCustomerStats() {
|
||||
return request.get({ url: '/qywx.customer/stats' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 标签维度统计:返回 { total_tags, total_relations, total_tagged_customers, groups[] }
|
||||
* - groups[i].group_name / customer_count(组内最热门标签的客户数)
|
||||
* - groups[i].tags[j].tag_id / tag_name / customer_count
|
||||
*
|
||||
* 用于:① 列表"按标签筛选"下拉 ② 标签维度面板抽屉
|
||||
*/
|
||||
export function qywxCustomerTagStats() {
|
||||
return request.get({ url: '/qywx.customer/tagStats' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 今日进入分布(事件流水零误差口径)
|
||||
* 返回 { total, recent_time, hourly: number[24], by_state: [{state,count}] }
|
||||
|
||||
@@ -3,3 +3,42 @@ import request from '@/utils/request'
|
||||
export function getConversionStatsOverview(params: any) {
|
||||
return request.get({ url: '/stats.conversion/overview', params })
|
||||
}
|
||||
|
||||
/** 业绩看板:单区间(部门 × 时间 × 渠道) */
|
||||
export function yejiStatsOverview(params: {
|
||||
start_date: string
|
||||
end_date: string
|
||||
dept_ids?: number[] | string
|
||||
channel_code?: string
|
||||
}) {
|
||||
return request.get({ url: '/stats.yejiStats/overview', params })
|
||||
}
|
||||
|
||||
/** 业绩看板:多区间一次返回(默认 月/周/今日/昨日 四张表) */
|
||||
export function yejiStatsMulti(params: {
|
||||
dept_ids?: number[] | string
|
||||
channel_code?: string
|
||||
ranges?: Array<{ label?: string; start_date: string; end_date: string }>
|
||||
}) {
|
||||
return request.get({ url: '/stats.yejiStats/multi', params })
|
||||
}
|
||||
|
||||
/** 业绩看板:部门下拉 */
|
||||
export function yejiStatsDeptOptions() {
|
||||
return request.get({ url: '/stats.yejiStats/deptOptions' })
|
||||
}
|
||||
|
||||
/** 业绩看板:渠道下拉(按 source_group_name 分组) */
|
||||
export function yejiStatsChannelOptions() {
|
||||
return request.get({ url: '/stats.yejiStats/channelOptions' })
|
||||
}
|
||||
|
||||
/** 业绩看板:医助排行榜(按部门分表;区间须与当前业绩表一致) */
|
||||
export function yejiStatsLeaderboard(params: {
|
||||
start_date: string
|
||||
end_date: string
|
||||
dept_ids?: number[] | string
|
||||
channel_code?: string
|
||||
}) {
|
||||
return request.get({ url: '/stats.yejiStats/leaderboard', params })
|
||||
}
|
||||
|
||||
@@ -364,6 +364,7 @@ export function prescriptionOrderRevokePayAudit(params: { id: number }) {
|
||||
/** 确认发货:将 fulfillment_status 从 2(履约中)推进到 5(已发货) */
|
||||
export function prescriptionOrderShip(params: {
|
||||
id: number
|
||||
ship_mode?: 'gancao' | 'direct'
|
||||
express_company: string
|
||||
tracking_number: string
|
||||
}) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,10 @@
|
||||
<template #icon><DataLine /></template>
|
||||
今日进入明细
|
||||
</el-button>
|
||||
<el-button type="warning" plain @click="openTagDrawer">
|
||||
<template #icon><CollectionTag /></template>
|
||||
标签维度
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="syncing" @click="syncCustomers()">
|
||||
<template #icon><Refresh /></template>
|
||||
立即同步
|
||||
@@ -98,6 +102,38 @@
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="标签">
|
||||
<el-select
|
||||
v-model="queryParams.tag_ids"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
clearable
|
||||
filterable
|
||||
placeholder="按标签筛选(可多选,命中任一即返回)"
|
||||
:loading="tagStatsLoading"
|
||||
style="min-width: 320px"
|
||||
@change="resetPage"
|
||||
>
|
||||
<el-option-group
|
||||
v-for="g in tagStats.groups"
|
||||
:key="g.group_name || '_'"
|
||||
:label="(g.group_name || '(未分组)') + ' · ' + g.customer_count"
|
||||
>
|
||||
<el-option
|
||||
v-for="t in g.tags"
|
||||
:key="t.tag_id"
|
||||
:value="t.tag_id"
|
||||
:label="t.tag_name"
|
||||
>
|
||||
<span class="flex items-center justify-between gap-3">
|
||||
<span>{{ t.tag_name }}</span>
|
||||
<span class="text-xs text-gray-400">{{ t.customer_count }} 人</span>
|
||||
</span>
|
||||
</el-option>
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
@@ -144,6 +180,27 @@
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="标签" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.tags && row.tags.length">
|
||||
<el-tag
|
||||
v-for="t in row.tags"
|
||||
:key="t.tag_id"
|
||||
size="small"
|
||||
effect="light"
|
||||
type="warning"
|
||||
class="mr-1 mb-1 cursor-pointer"
|
||||
@click="addTagToFilter(t.tag_id)"
|
||||
>
|
||||
{{ t.tag_name }}
|
||||
<span v-if="t.group_name" class="ml-1 text-gray-400 text-xs">
|
||||
{{ t.group_name }}
|
||||
</span>
|
||||
</el-tag>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="添加时间" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(firstExternalAddTime(row)) }}
|
||||
@@ -293,6 +350,99 @@
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 标签维度面板 -->
|
||||
<el-drawer
|
||||
v-model="showTagDrawer"
|
||||
title="标签维度统计"
|
||||
direction="rtl"
|
||||
size="560px"
|
||||
:destroy-on-close="false"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between w-full pr-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-base font-semibold">标签维度统计</span>
|
||||
<el-tag type="warning" effect="plain">
|
||||
{{ tagStats.groups.length }} 组 · {{ tagStats.total_tags }} 标签
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-button link type="primary" :loading="tagStatsLoading" @click="loadTagStats(true)">
|
||||
<template #icon><Refresh /></template>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="px-1">
|
||||
<div class="grid grid-cols-3 gap-2 mb-3">
|
||||
<el-card shadow="never" body-class="!p-3">
|
||||
<div class="text-xs text-gray-500">已打标签客户</div>
|
||||
<div class="text-xl font-bold text-warning">{{ tagStats.total_tagged_customers }}</div>
|
||||
</el-card>
|
||||
<el-card shadow="never" body-class="!p-3">
|
||||
<div class="text-xs text-gray-500">标签数</div>
|
||||
<div class="text-xl font-bold">{{ tagStats.total_tags }}</div>
|
||||
</el-card>
|
||||
<el-card shadow="never" body-class="!p-3">
|
||||
<div class="text-xs text-gray-500">关系总数</div>
|
||||
<div class="text-xl font-bold">{{ tagStats.total_relations }}</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-input
|
||||
v-model="tagSearchKw"
|
||||
placeholder="筛选标签名 / 分组名"
|
||||
clearable
|
||||
size="small"
|
||||
class="mb-3"
|
||||
/>
|
||||
|
||||
<el-empty
|
||||
v-if="filteredTagGroups.length === 0"
|
||||
:description="tagStatsLoading ? '加载中…' : '暂无标签数据'"
|
||||
:image-size="64"
|
||||
/>
|
||||
|
||||
<el-collapse v-else v-model="tagDrawerActiveGroups">
|
||||
<el-collapse-item
|
||||
v-for="g in filteredTagGroups"
|
||||
:key="g.group_name || '_'"
|
||||
:name="g.group_name || '_'"
|
||||
>
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<span class="font-medium">{{ g.group_name || '(未分组)' }}</span>
|
||||
<el-tag size="small" effect="plain">{{ g.tags.length }}</el-tag>
|
||||
<span class="ml-auto text-xs text-gray-500">
|
||||
最热 {{ g.customer_count }} 人
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="g.tags" size="small" stripe>
|
||||
<el-table-column label="标签" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="font-medium">{{ row.tag_name }}</span>
|
||||
<div class="text-xs text-gray-400 truncate">{{ row.tag_id }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="客户数" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="text-warning font-bold">{{ row.customer_count }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="filterByTag(row.tag_id)">
|
||||
筛选
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 客户详情对话框 -->
|
||||
<el-dialog
|
||||
v-model="showDetail"
|
||||
@@ -336,6 +486,24 @@
|
||||
</div>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="标签" :span="2">
|
||||
<div v-if="currentCustomer.tags?.length">
|
||||
<el-tag
|
||||
v-for="t in currentCustomer.tags"
|
||||
:key="t.tag_id"
|
||||
size="small"
|
||||
effect="light"
|
||||
type="warning"
|
||||
class="mr-1 mb-1"
|
||||
>
|
||||
{{ t.tag_name }}
|
||||
<span v-if="t.group_name" class="ml-1 text-gray-400 text-xs">
|
||||
{{ t.group_name }}
|
||||
</span>
|
||||
</el-tag>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
</div>
|
||||
@@ -343,7 +511,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { Refresh, Setting, DataLine } from '@element-plus/icons-vue'
|
||||
import { Refresh, Setting, DataLine, CollectionTag } from '@element-plus/icons-vue'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import feedback from '@/utils/feedback'
|
||||
import {
|
||||
@@ -353,7 +521,8 @@ import {
|
||||
qywxSyncSettingsGet,
|
||||
qywxSyncSettingsSave,
|
||||
qywxCustomerTodayArrival,
|
||||
qywxCustomerTodayArrivalList
|
||||
qywxCustomerTodayArrivalList,
|
||||
qywxCustomerTagStats
|
||||
} from '@/api/qywx'
|
||||
|
||||
const syncing = ref(false)
|
||||
@@ -388,11 +557,100 @@ const syncSettings = reactive({
|
||||
interval: 3600
|
||||
})
|
||||
|
||||
const queryParams = reactive({
|
||||
const queryParams = reactive<{ name: string; follow_user: string; tag_ids: string[] }>({
|
||||
name: '',
|
||||
follow_user: ''
|
||||
follow_user: '',
|
||||
tag_ids: []
|
||||
})
|
||||
|
||||
// ── 标签维度(筛选下拉 + 抽屉面板共用同一份数据) ──────────────────────────
|
||||
interface TagItem {
|
||||
tag_id: string
|
||||
tag_name: string
|
||||
customer_count: number
|
||||
}
|
||||
interface TagGroup {
|
||||
group_name: string
|
||||
customer_count: number
|
||||
tags: TagItem[]
|
||||
}
|
||||
interface TagStatsPayload {
|
||||
total_tags: number
|
||||
total_relations: number
|
||||
total_tagged_customers: number
|
||||
groups: TagGroup[]
|
||||
}
|
||||
|
||||
const tagStats = reactive<TagStatsPayload>({
|
||||
total_tags: 0,
|
||||
total_relations: 0,
|
||||
total_tagged_customers: 0,
|
||||
groups: []
|
||||
})
|
||||
const tagStatsLoading = ref(false)
|
||||
const showTagDrawer = ref(false)
|
||||
const tagSearchKw = ref('')
|
||||
const tagDrawerActiveGroups = ref<string[]>([])
|
||||
|
||||
const filteredTagGroups = computed<TagGroup[]>(() => {
|
||||
const kw = tagSearchKw.value.trim().toLowerCase()
|
||||
if (!kw) return tagStats.groups
|
||||
const result: TagGroup[] = []
|
||||
for (const g of tagStats.groups) {
|
||||
const groupHit = (g.group_name || '').toLowerCase().includes(kw)
|
||||
const tags = g.tags.filter((t) => groupHit || (t.tag_name || '').toLowerCase().includes(kw))
|
||||
if (tags.length) {
|
||||
result.push({ ...g, tags })
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
async function loadTagStats(showFeedback = false) {
|
||||
tagStatsLoading.value = true
|
||||
try {
|
||||
const res: any = await qywxCustomerTagStats()
|
||||
if (res && typeof res === 'object') {
|
||||
tagStats.total_tags = Number(res.total_tags ?? 0)
|
||||
tagStats.total_relations = Number(res.total_relations ?? 0)
|
||||
tagStats.total_tagged_customers = Number(res.total_tagged_customers ?? 0)
|
||||
tagStats.groups = Array.isArray(res.groups) ? res.groups : []
|
||||
}
|
||||
if (showFeedback) feedback.msgSuccess('已刷新')
|
||||
} catch (e: any) {
|
||||
if (showFeedback) feedback.msgError(e?.message || '加载标签统计失败')
|
||||
console.error('加载标签统计失败', e)
|
||||
} finally {
|
||||
tagStatsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openTagDrawer() {
|
||||
showTagDrawer.value = true
|
||||
// 默认展开第 1 组(最热门);其余折叠避免首屏太长
|
||||
tagDrawerActiveGroups.value = tagStats.groups[0]?.group_name ? [tagStats.groups[0].group_name] : []
|
||||
if (tagStats.groups.length === 0) {
|
||||
loadTagStats()
|
||||
}
|
||||
}
|
||||
|
||||
/** 列表上点击 tag 徽章 → 把这个 tag 加入筛选并重查 */
|
||||
function addTagToFilter(tagId: string) {
|
||||
if (!tagId) return
|
||||
if (!queryParams.tag_ids.includes(tagId)) {
|
||||
queryParams.tag_ids.push(tagId)
|
||||
resetPage()
|
||||
}
|
||||
}
|
||||
|
||||
/** 抽屉里点「筛选」→ 用这个 tag 替换当前筛选 + 关闭抽屉 */
|
||||
function filterByTag(tagId: string) {
|
||||
if (!tagId) return
|
||||
queryParams.tag_ids = [tagId]
|
||||
showTagDrawer.value = false
|
||||
resetPage()
|
||||
}
|
||||
|
||||
// ── 今日进入分布 / 明细(事件流水零误差口径) ──────────────────────────────
|
||||
interface ArrivalStats {
|
||||
total: number
|
||||
@@ -534,6 +792,7 @@ const { pager, getLists, resetPage, resetParams } = usePaging({
|
||||
function handleReset() {
|
||||
queryParams.name = ''
|
||||
queryParams.follow_user = ''
|
||||
queryParams.tag_ids = []
|
||||
resetParams()
|
||||
}
|
||||
|
||||
@@ -721,6 +980,7 @@ onMounted(() => {
|
||||
loadStats()
|
||||
loadSyncSettings()
|
||||
loadArrival(false)
|
||||
loadTagStats()
|
||||
startArrivalPolling()
|
||||
})
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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