feat(energy): 实现能源账户管理页面(列表+Drawer+充值)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-03-16 01:40:10 +08:00
parent 1a03965c1f
commit be145c476e
4 changed files with 368 additions and 0 deletions

View File

@@ -0,0 +1,103 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { EnergyAccountApi } from '#/api/energy/account';
import { useActionColumn } from '#/utils/table';
import { getRangePickerDefaultProps } from '#/utils';
export const ACCOUNT_STATUS_OPTIONS = [
{ label: '正常', value: 1, color: 'green' },
{ label: '冻结', value: 0, color: 'red' },
];
export const FLOW_TYPE_OPTIONS = [
{ label: '充值', value: 1, color: 'green' },
{ label: '扣款', value: 2, color: 'red' },
{ label: '退款', value: 3, color: 'blue' },
{ label: '调整', value: 4, color: 'orange' },
];
/** 搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'customerName',
label: '客户名称',
component: 'Input',
componentProps: {
placeholder: '请输入客户名称',
allowClear: true,
},
},
{
fieldName: 'accountStatus',
label: '账户状态',
component: 'Select',
componentProps: {
options: ACCOUNT_STATUS_OPTIONS,
placeholder: '请选择账户状态',
allowClear: true,
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
class: 'w-full',
},
},
];
}
/** 表格列配置 */
export function useGridColumns(): VxeTableGridOptions<EnergyAccountApi.Account>['columns'] {
return [
{
field: 'customerName',
title: '客户名称',
minWidth: 150,
fixed: 'left',
},
{
field: 'balance',
title: '当前余额',
minWidth: 120,
align: 'right',
slots: { default: 'balance' },
},
{
field: 'initBalance',
title: '初始余额',
minWidth: 100,
align: 'right',
},
{
field: 'accumulatedRecharge',
title: '累计充值',
minWidth: 120,
align: 'right',
},
{
field: 'accumulatedConsume',
title: '累计消费',
minWidth: 120,
align: 'right',
},
{
field: 'reminderThreshold',
title: '预警阈值',
minWidth: 100,
align: 'right',
},
{
field: 'accountStatus',
title: '状态',
minWidth: 80,
align: 'center',
slots: { default: 'accountStatus' },
},
useActionColumn(3),
];
}

View File

@@ -0,0 +1,130 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { EnergyAccountApi } from '#/api/energy/account';
import { ref, onMounted, h } from 'vue';
import { Page, SummaryCard } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { message, Tag, Modal, InputNumber } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getAccountPage, getAccountSummary, exportAccount, updateThreshold } from '#/api/energy/account';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema, ACCOUNT_STATUS_OPTIONS } from './data';
import AccountDrawer from './modules/account-drawer.vue';
import RechargeModal from './modules/recharge-modal.vue';
const summary = ref<EnergyAccountApi.Summary>({ totalCount: 0, totalBalance: 0, totalRecharge: 0, warningCount: 0 });
const drawerRef = ref();
const rechargeRef = ref();
async function loadSummary() {
summary.value = await getAccountSummary();
}
function handleRefresh() {
gridApi.query();
loadSummary();
}
function handleDetail(row: EnergyAccountApi.Account) {
drawerRef.value?.open(row);
}
function handleRecharge(row: EnergyAccountApi.Account) {
rechargeRef.value?.open(row);
}
// Set threshold — simple modal with InputNumber
function handleSetThreshold(row: EnergyAccountApi.Account) {
let thresholdValue = row.reminderThreshold || 0;
Modal.confirm({
title: '设置预警阈值',
content: () => {
return h(InputNumber, {
value: thresholdValue,
min: 0,
precision: 2,
style: { width: '100%' },
placeholder: '请输入预警阈值',
'onUpdate:value': (val: number) => { thresholdValue = val; },
});
},
async onOk() {
await updateThreshold(row.id!, thresholdValue);
message.success('阈值设置成功');
handleRefresh();
},
});
}
async function handleExport() {
const data = await exportAccount(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '能源账户.xls', source: data });
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: { collapsed: true, schema: useGridFormSchema() },
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getAccountPage({ pageNo: page.currentPage, pageSize: page.pageSize, ...formValues });
},
},
},
rowConfig: { keyField: 'id', isHover: true },
rowClassName: ({ row }: any) => (row.balance < 0 ? 'bg-red-50' : ''),
toolbarConfig: { refresh: true, search: true },
} as VxeTableGridOptions<EnergyAccountApi.Account>,
});
onMounted(loadSummary);
</script>
<template>
<Page auto-content-height>
<!-- Summary Cards -->
<div class="mb-4 grid grid-cols-4 gap-4 px-4 pt-4">
<SummaryCard title="账户总数" :value="summary.totalCount" icon="lucide:users" icon-color="#1890ff" icon-bg-color="#e6f7ff" />
<SummaryCard title="总余额" :value="summary.totalBalance" prefix="¥" :decimals="2" icon="lucide:wallet" icon-color="#52c41a" icon-bg-color="#f6ffed" />
<SummaryCard title="累计充值" :value="summary.totalRecharge" prefix="¥" :decimals="2" icon="lucide:trending-up" icon-color="#fa8c16" icon-bg-color="#fff7e6" />
<SummaryCard title="预警账户" :value="summary.warningCount" icon="lucide:alert-triangle" icon-color="#ff4d4f" icon-bg-color="#fff2f0" tooltip="余额低于阈值" />
</div>
<!-- Table -->
<Grid table-title="账户列表">
<template #toolbar-tools>
<TableAction
:actions="[
{ label: '导出', type: 'primary', icon: ACTION_ICON.DOWNLOAD, auth: ['energy:account:export'], onClick: handleExport },
]"
/>
</template>
<template #balance="{ row }">
<span :class="row.balance < 0 ? 'font-bold text-red-500' : row.balance <= row.reminderThreshold ? 'font-bold text-orange-500' : 'font-bold text-green-500'">
¥{{ row.balance?.toFixed(2) }}
</span>
</template>
<template #accountStatus="{ row }">
<Tag :color="ACCOUNT_STATUS_OPTIONS.find(o => o.value === row.accountStatus)?.color">
{{ ACCOUNT_STATUS_OPTIONS.find(o => o.value === row.accountStatus)?.label }}
</Tag>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{ label: '详情', type: 'link', icon: ACTION_ICON.VIEW, onClick: () => handleDetail(row) },
{ label: '充值', type: 'link', auth: ['energy:account:update'], onClick: () => handleRecharge(row) },
{ label: '设置阈值', type: 'link', auth: ['energy:account:update'], onClick: () => handleSetThreshold(row) },
]"
/>
</template>
</Grid>
<AccountDrawer ref="drawerRef" />
<RechargeModal ref="rechargeRef" @success="handleRefresh" />
</Page>
</template>

View File

@@ -0,0 +1,76 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { Drawer, Tabs } from 'ant-design-vue';
import type { EnergyAccountApi } from '#/api/energy/account';
import { getProjectAccountList, getFlowPage } from '#/api/energy/account';
import { FLOW_TYPE_OPTIONS } from '../data';
const visible = ref(false);
const account = ref<EnergyAccountApi.Account>();
const projectAccounts = ref<EnergyAccountApi.ProjectAccount[]>([]);
const flows = ref<EnergyAccountApi.Flow[]>([]);
const activeTab = ref('project');
function open(data: EnergyAccountApi.Account) {
account.value = data;
visible.value = true;
loadProjectAccounts();
loadFlows();
}
async function loadProjectAccounts() {
if (!account.value?.id) return;
projectAccounts.value = await getProjectAccountList(account.value.id);
}
async function loadFlows() {
if (!account.value?.id) return;
const result = await getFlowPage({ pageNo: 1, pageSize: 50, accountId: account.value.id } as any);
flows.value = result?.list || [];
}
// Project account columns for a-table
const projectColumns = [
{ title: '项目名称', dataIndex: 'projectName', width: 150 },
{ title: '合同编号', dataIndex: 'contractCode', width: 120 },
{ title: '项目余额', dataIndex: 'balance', width: 100, align: 'right' as const },
{ title: '已划账', dataIndex: 'accumulatedRecharge', width: 100, align: 'right' as const },
{ title: '已消费', dataIndex: 'accumulatedConsume', width: 100, align: 'right' as const },
];
// Flow columns for a-table
const flowColumns = [
{ title: '时间', dataIndex: 'createTime', width: 180 },
{ title: '流水类型', dataIndex: 'flowType', width: 80, customRender: ({ text }: any) => FLOW_TYPE_OPTIONS.find(o => o.value === text)?.label || text },
{ title: '变动金额', dataIndex: 'amount', width: 100, align: 'right' as const },
{ title: '变动前余额', dataIndex: 'balanceBefore', width: 100, align: 'right' as const },
{ title: '变动后余额', dataIndex: 'balanceAfter', width: 100, align: 'right' as const },
{ title: '关联单据号', dataIndex: 'bizCode', width: 120 },
{ title: '备注', dataIndex: 'remark', width: 150 },
];
defineExpose({ open });
</script>
<template>
<Drawer v-model:open="visible" :title="account?.customerName + ' - 账户详情'" width="720">
<!-- Balance card -->
<div class="mb-4 rounded-lg p-6 text-white" style="background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%)">
<div class="text-sm opacity-80">当前余额</div>
<div class="text-3xl font-bold">¥{{ account?.balance?.toFixed(2) }}</div>
<div class="mt-2 flex gap-4 text-sm opacity-80">
<span>累计充值 ¥{{ account?.accumulatedRecharge?.toFixed(2) }}</span>
<span>累计消费 ¥{{ account?.accumulatedConsume?.toFixed(2) }}</span>
</div>
</div>
<Tabs v-model:activeKey="activeTab">
<Tabs.TabPane key="project" tab="项目账户">
<a-table :data-source="projectAccounts" :columns="projectColumns" :pagination="false" size="small" row-key="id" />
</Tabs.TabPane>
<Tabs.TabPane key="flow" tab="账户流水">
<a-table :data-source="flows" :columns="flowColumns" :pagination="false" size="small" row-key="id" :scroll="{ y: 400 }" />
</Tabs.TabPane>
</Tabs>
</Drawer>
</template>

View File

@@ -0,0 +1,59 @@
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { Modal, InputNumber, Input, Descriptions, message } from 'ant-design-vue';
import { rechargeAccount } from '#/api/energy/account';
import type { EnergyAccountApi } from '#/api/energy/account';
const emit = defineEmits(['success']);
const visible = ref(false);
const loading = ref(false);
const account = ref<EnergyAccountApi.Account>();
const amount = ref<number>(0);
const remark = ref('');
const afterBalance = computed(() => (account.value?.balance || 0) + (amount.value || 0));
function open(data: EnergyAccountApi.Account) {
account.value = data;
amount.value = 0;
remark.value = '';
visible.value = true;
}
async function handleConfirm() {
if (!amount.value || amount.value <= 0) { message.warning('请输入充值金额'); return; }
loading.value = true;
try {
await rechargeAccount(account.value!.customerId!, amount.value, remark.value || undefined);
message.success('充值成功');
visible.value = false;
emit('success');
} catch (e: any) {
message.error(e.message || '充值失败');
} finally {
loading.value = false;
}
}
defineExpose({ open });
</script>
<template>
<Modal v-model:open="visible" title="账户充值" :confirm-loading="loading" @ok="handleConfirm">
<Descriptions :column="1" bordered size="small" class="mb-4">
<Descriptions.Item label="客户名称">{{ account?.customerName }}</Descriptions.Item>
<Descriptions.Item label="当前余额">¥{{ account?.balance?.toFixed(2) }}</Descriptions.Item>
</Descriptions>
<div class="mb-4">
<label class="mb-1 block font-medium">充值金额 <span class="text-red-500">*</span></label>
<InputNumber v-model:value="amount" :min="0.01" :precision="2" placeholder="请输入充值金额" class="w-full" addon-after="" />
</div>
<div class="mb-4">
<label class="mb-1 block font-medium">备注</label>
<Input.TextArea v-model:value="remark" :rows="2" placeholder="请输入备注(选填)" />
</div>
<div class="rounded bg-blue-50 p-3 text-center">
充值后余额<strong class="text-blue-600">¥{{ afterBalance.toFixed(2) }}</strong>
</div>
</Modal>
</template>