Files
xk-admin/apps/web-antd/src/components/store-card/StoreFinancePanel.vue

423 lines
13 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.
<script lang="ts" setup>
/**
* 门店详情 - 资金账户面板(余额 + 资金流水 + 账户变动)
* 仅超管/系统管理员在 StoreCardModal 中可见,数据按门店 ID 查询
* 口径说明:资金流水按履约店 store_id;账户变动/余额按分账利润归属店 user_id
*/
import type { Dayjs } from 'dayjs';
import { ref, watch } from 'vue';
import { DatePicker, Empty, Segmented, Spin, Table, Tag, message } from 'ant-design-vue';
import { monthToTodayRangeDayjs } from '#/utils/search-time-range';
import {
getStoreAccountChangeLogListApi,
getStoreBalanceApi,
getStoreFundWaterListApi,
} from '#/views/system/store/api';
const props = defineProps<{
storeId: number;
}>();
const RangePicker = DatePicker.RangePicker;
const balanceLoading = ref(false);
const balance = ref<Record<string, any> | null>(null);
const fundWaterLoading = ref(false);
const fundWaterList = ref<any[]>([]);
const fundWaterPage = ref(1);
const fundWaterPageSize = ref(10);
const fundWaterTotal = ref(0);
const changeLogLoading = ref(false);
const changeLogList = ref<any[]>([]);
const changeLogPage = ref(1);
const changeLogPageSize = ref(10);
const changeLogTotal = ref(0);
const fundWaterTime = ref<[Dayjs, Dayjs]>(monthToTodayRangeDayjs());
const changeLogTime = ref<[Dayjs, Dayjs]>(monthToTodayRangeDayjs());
/** 流水列表切换:资金流水 / 账户变动(用 Segmented 避免窄容器下 Tab 折叠成 ...) */
const activeListTab = ref<'fundWater' | 'changeLog'>('fundWater');
const changeLogLoaded = ref(false);
const listTabOptions = [
{ label: '资金流水', value: 'fundWater' },
{ label: '账户变动', value: 'changeLog' },
];
/** 余额概览卡片配置 */
const balanceCards = [
{ key: 'balance', label: '可提现余额' },
{ key: 'total', label: '累计收益' },
{ key: 'pending_earnings', label: '待结算' },
{ key: 'withdrawn', label: '已提现' },
{ key: 'withdrawn_frozen', label: '审核中' },
{ key: 'public_account_amount', label: '公账累计' },
{ key: 'tcm_balance', label: '中药可提' },
{ key: 'western_balance', label: '西药可提' },
{ key: 'other_balance', label: '其他可提' },
];
const fundWaterColumns = [
{ title: '金额', dataIndex: 'price', key: 'price', width: 110 },
{ title: '流水类型', dataIndex: 'type', key: 'type', width: 110 },
{ title: '订单类型', dataIndex: 'order_type', key: 'order_type', width: 110 },
// store_id 便于核对是否本店履约(委托复诊时流水店与利润店可能不同)
{ title: '门店ID', dataIndex: 'store_id', key: 'store_id', width: 90 },
{ title: '诊所', dataIndex: 'store_name', key: 'store_name', width: 120, ellipsis: true },
{ title: '订单号', dataIndex: 'order_no', key: 'order_no', ellipsis: true },
{ title: '医生', dataIndex: 'doctor_name', key: 'doctor_name', width: 100 },
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
];
const changeLogColumns = [
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
{ title: '来源', dataIndex: 'source_type_txt', key: 'source_type_txt', width: 100 },
{ title: '字段', dataIndex: 'field_name_txt', key: 'field_name_txt', width: 140 },
{ title: '变动前', dataIndex: 'before_amount', key: 'before_amount', width: 100 },
{ title: '变动后', dataIndex: 'after_amount', key: 'after_amount', width: 100 },
{ title: '变动值', dataIndex: 'change_amount', key: 'change_amount', width: 100 },
{ title: '主体ID', dataIndex: 'user_id', key: 'user_id', width: 90 },
{ title: '订单号', dataIndex: 'order_no', key: 'order_no', ellipsis: true },
{ title: '来源表', dataIndex: 'source_table_txt', key: 'source_table_txt', width: 110 },
{ title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true },
];
/** 时间范围转接口参数 */
function timeParam(range: [Dayjs, Dayjs]): [string, string] {
return [
range[0].format('YYYY-MM-DD 00:00:00'),
range[1].format('YYYY-MM-DD 23:59:59'),
];
}
function formatMoney(val: unknown) {
return `¥${Number(val || 0).toFixed(2)}`;
}
/** 拉取门店余额概览 */
async function loadBalance() {
if (!props.storeId) return;
balanceLoading.value = true;
try {
balance.value = await getStoreBalanceApi({ id: props.storeId });
} catch (e: any) {
balance.value = null;
message.error(e?.message || '加载余额失败');
} finally {
balanceLoading.value = false;
}
}
/** 拉取资金流水 */
async function loadFundWater(page = fundWaterPage.value) {
if (!props.storeId) return;
fundWaterLoading.value = true;
fundWaterPage.value = page;
try {
const res = await getStoreFundWaterListApi({
id: props.storeId,
page,
pageSize: fundWaterPageSize.value,
search_time: timeParam(fundWaterTime.value),
});
fundWaterList.value = (res?.items || []).map((item: any) => ({
...item,
doctor_name: item?.doctor_info?.name || '-',
store_name: item?.store?.name || '-',
}));
fundWaterTotal.value = Number(res?.total || 0);
} catch (e: any) {
fundWaterList.value = [];
fundWaterTotal.value = 0;
message.error(e?.message || '加载资金流水失败');
} finally {
fundWaterLoading.value = false;
}
}
/** 拉取账户变动 */
async function loadChangeLog(page = changeLogPage.value) {
if (!props.storeId) return;
changeLogLoading.value = true;
changeLogPage.value = page;
try {
const res = await getStoreAccountChangeLogListApi({
id: props.storeId,
page,
pageSize: changeLogPageSize.value,
search_time: timeParam(changeLogTime.value),
});
changeLogList.value = res?.items || [];
changeLogTotal.value = Number(res?.total || 0);
} catch (e: any) {
changeLogList.value = [];
changeLogTotal.value = 0;
message.error(e?.message || '加载账户变动失败');
} finally {
changeLogLoading.value = false;
}
}
/** 重置分页并加载余额与当前列表 Tab 数据 */
function loadAll() {
fundWaterPage.value = 1;
changeLogPage.value = 1;
changeLogLoaded.value = false;
activeListTab.value = 'fundWater';
loadBalance();
loadFundWater(1);
}
/** 列表切换:懒加载账户变动 */
function onListTabChange(value: string | number) {
activeListTab.value = value === 'changeLog' ? 'changeLog' : 'fundWater';
if (activeListTab.value === 'changeLog' && !changeLogLoaded.value) {
loadChangeLog(1);
changeLogLoaded.value = true;
}
}
/** 重置面板状态(弹窗关闭时由父组件调用) */
function reset() {
balance.value = null;
fundWaterList.value = [];
changeLogList.value = [];
fundWaterTotal.value = 0;
changeLogTotal.value = 0;
fundWaterPage.value = 1;
changeLogPage.value = 1;
fundWaterTime.value = monthToTodayRangeDayjs();
changeLogTime.value = monthToTodayRangeDayjs();
activeListTab.value = 'fundWater';
changeLogLoaded.value = false;
}
defineExpose({ loadAll, reset });
watch(
() => props.storeId,
(id) => {
if (id) loadAll();
},
{ immediate: true },
);
function fundWaterTypeTag(type: string) {
if (type === 'refund') return { color: 'purple', text: '出账' };
if (type === 'enter') return { color: 'green', text: '入账' };
if (type === 'withdraw') return { color: 'orange', text: '提现' };
if (type === 'charge') return { color: 'red', text: '代付手续费' };
return { color: 'default', text: type || '-' };
}
function orderTypeTag(orderType: number) {
if (orderType === 1) return { color: 'purple', text: '产品订单' };
if (orderType === 2) return { color: 'green', text: '挂号订单' };
if (orderType === 3) return { color: 'blue', text: '问诊订单' };
return { color: 'default', text: '-' };
}
</script>
<template>
<div class="store-finance-panel pl-4">
<section class="finance-section">
<div class="section-title">余额概览</div>
<Spin :spinning="balanceLoading">
<div v-if="balance" class="balance-grid">
<div
v-for="card in balanceCards"
:key="card.key"
class="balance-card"
>
<span class="balance-card__label">{{ card.label }}</span>
<span class="balance-card__value">{{ formatMoney(balance[card.key]) }}</span>
</div>
</div>
<Empty v-else-if="!balanceLoading" description="暂无余额数据" />
</Spin>
</section>
<section class="finance-section finance-section--list">
<p class="finance-hint">
资金流水按订单履约店;账户变动/余额按分账利润归属店。委托复诊等场景两边主体可能不同,属业务口径而非串店。
</p>
<Segmented
v-model:value="activeListTab"
block
class="finance-list-segment"
:options="listTabOptions"
@change="onListTabChange"
/>
<div v-show="activeListTab === 'fundWater'">
<div class="list-toolbar">
<RangePicker
v-model:value="fundWaterTime"
format="YYYY-MM-DD"
@change="loadFundWater(1)"
/>
</div>
<Table
:columns="fundWaterColumns"
:data-source="fundWaterList"
:loading="fundWaterLoading"
:pagination="{
current: fundWaterPage,
pageSize: fundWaterPageSize,
total: fundWaterTotal,
showSizeChanger: true,
showTotal: (total: number) => `共 ${total} 条`,
}"
row-key="id"
size="small"
:scroll="{ x: 1000, y: 320 }"
@change="(pag: any) => {
fundWaterPageSize = pag.pageSize || fundWaterPageSize;
loadFundWater(pag.current || 1);
}"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'price'">
{{ formatMoney(record.price) }}
</template>
<template v-else-if="column.key === 'type'">
<Tag :color="fundWaterTypeTag(record.type).color">
{{ fundWaterTypeTag(record.type).text }}
</Tag>
</template>
<template v-else-if="column.key === 'order_type'">
<Tag :color="orderTypeTag(Number(record.order_type)).color">
{{ orderTypeTag(Number(record.order_type)).text }}
</Tag>
</template>
</template>
</Table>
</div>
<div v-show="activeListTab === 'changeLog'">
<div class="list-toolbar">
<RangePicker
v-model:value="changeLogTime"
format="YYYY-MM-DD"
@change="loadChangeLog(1)"
/>
</div>
<Table
:columns="changeLogColumns"
:data-source="changeLogList"
:loading="changeLogLoading"
:pagination="{
current: changeLogPage,
pageSize: changeLogPageSize,
total: changeLogTotal,
showSizeChanger: true,
showTotal: (total: number) => `共 ${total} 条`,
}"
row-key="id"
size="small"
:scroll="{ x: 1080, y: 320 }"
@change="(pag: any) => {
changeLogPageSize = pag.pageSize || changeLogPageSize;
loadChangeLog(pag.current || 1);
}"
>
<template #bodyCell="{ column, record }">
<template
v-if="
column.key === 'before_amount' ||
column.key === 'after_amount' ||
column.key === 'change_amount'
"
>
{{ formatMoney(record[column.key]) }}
</template>
</template>
</Table>
</div>
</section>
</div>
</template>
<style scoped>
.store-finance-panel {
display: flex;
flex-direction: column;
gap: 24px;
}
.finance-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.section-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: hsl(var(--foreground));
}
.finance-section--list {
width: 100%;
min-width: 0;
}
.finance-hint {
margin: 0 0 8px;
font-size: 12px;
line-height: 1.5;
color: hsl(var(--muted-foreground));
}
.finance-list-segment {
margin-bottom: 4px;
}
.list-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
.balance-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
}
.balance-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
border: 1px solid hsl(var(--primary) / 30%);
border-radius: 10px;
background: hsl(var(--card, var(--background)));
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
}
.balance-card__label {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.balance-card__value {
font-size: 18px;
font-weight: 600;
color: hsl(var(--foreground));
}
</style>