This commit is contained in:
2026-08-20 18:02:52 +08:00
parent 4ae5e7c672
commit 80947cd9ce
65 changed files with 1386 additions and 1083 deletions
@@ -1,68 +1,58 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Plus } from '@lucide/vue'
import { onMounted, reactive, ref, watch } from 'vue'
import { Download, Pencil, Plus, Power } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import type { MonthlyExchangeRate } from '../types'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { changeExchangeRateStatus, createExchangeRate, exportExchangeRates, getExchangeRate, getExchangeRateSummary, listExchangeRates, updateExchangeRate } from '../api/overseasMasterData'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { saveCsv } from '../downloadCsv'
import { masterDataErrorMessage } from '../apiError'
import type { MasterDataStatus, MonthlyExchangeRate } from '../types'
const rows = ref<MonthlyExchangeRate[]>([])
const page = ref(1)
const pageSize = ref(20)
const filters = ref<QueryModel>({ month: '' })
const access = useAccessPreviewStore()
const rows = ref<MonthlyExchangeRate[]>([]), total = ref(0), page = ref(1), pageSize = ref(20), loading = ref(false), saving = ref(false), exporting = ref(false)
const error = ref<ApiErrorDetails | null>(null), editorOpen = ref(false), editingMonth = ref('')
const filters = ref<QueryModel>({ month: '', status: '' })
const fields: QueryField[] = [
{ key: 'month', type: 'date', label: '查询月份', placeholder: '请选择月份', width: 200, props: { type: 'month', 'value-format': 'YYYY-MM' } },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 130, options: [{ label: '启用', value: 'ACTIVE' }, { label: '停用', value: 'INACTIVE' }] },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' },
{ key: 'month', prop: 'month', label: '汇率月份', minWidth: 120 },
{ key: 'usdToCnyAverage', prop: 'usdToCnyAverage', label: '平均汇率', minWidth: 120 },
{ key: 'conversion', label: '换算说明', minWidth: 190, slotName: 'conversion' },
{ key: 'recordStatus', label: '状态', minWidth: 110 },
{ key: 'updatedBy', prop: 'updatedBy', label: '更新人', minWidth: 120 },
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 150 },
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 200 },
]
const summaries = [
{ label: '最新维护汇率', value: '—' },
{ label: '汇率月份', value: '—' },
{ label: '最后更新人', value: '—' },
{ label: '最后更新时间', value: '—' },
{ key: 'actions', label: '操作', width: 230, fixed: 'left', slotName: 'actions' }, { key: 'month', prop: 'month', label: '汇率月份', minWidth: 120 },
{ key: 'usdToCnyAverage', prop: 'usdToCnyAverage', label: '平均汇率', minWidth: 120 }, { key: 'conversion', label: '换算说明', minWidth: 190, slotName: 'conversion' },
{ key: 'status', label: '状态', width: 90, slotName: 'status' }, { key: 'updatedBy', prop: 'updatedBy', label: '更新人', minWidth: 120 },
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 150 }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 200 },
]
const summary = reactive({ latestMonth: '', latestUsdToCnyAverage: null as number | null, updatedBy: '', updatedAt: '' })
const form = reactive({ month: '', usdToCnyAverage: 0, remark: '', recordVersion: 0 })
function params() { const month = String(filters.value.month || ''); return { page: page.value, size: pageSize.value, monthFrom: month || undefined, monthTo: month || undefined, status: (filters.value.status || undefined) as MasterDataStatus | undefined } }
async function load() { loading.value = true; error.value = null; try { const [result, latest] = await Promise.all([listExchangeRates(params()), getExchangeRateSummary()]); rows.value = result.records; total.value = result.total; Object.assign(summary, latest) } catch (cause) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '月度汇率加载失败') } finally { loading.value = false } }
function search() { if (page.value === 1) void load(); else page.value = 1 }
function openCreate() { editingMonth.value = ''; Object.assign(form, { month: '', usdToCnyAverage: 0, remark: '', recordVersion: 0 }); editorOpen.value = true }
async function openEdit(row: MonthlyExchangeRate) { try { const value = await getExchangeRate(row.month); editingMonth.value = value.month; Object.assign(form, { month: value.month, usdToCnyAverage: Number(value.usdToCnyAverage), remark: value.remark || '', recordVersion: value.recordVersion }); editorOpen.value = true } catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '汇率详情加载失败')) } }
async function save() { if (!form.month || form.usdToCnyAverage <= 0) { ElMessage.warning('请选择月份并填写大于 0 的平均汇率'); return } saving.value = true; try { if (editingMonth.value) await updateExchangeRate(editingMonth.value, { recordVersion: form.recordVersion, usdToCnyAverage: form.usdToCnyAverage, remark: form.remark || null }); else await createExchangeRate({ month: form.month, usdToCnyAverage: form.usdToCnyAverage, remark: form.remark || null }); ElMessage.success(editingMonth.value ? '月度汇率已更新' : '月度汇率已创建'); editorOpen.value = false; await load() } catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '月度汇率保存失败')) } finally { saving.value = false } }
async function toggle(row: MonthlyExchangeRate) { const next: MasterDataStatus = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'; try { await ElMessageBox.confirm(`确认${next === 'ACTIVE' ? '启用' : '停用'} ${row.month} 汇率吗?`, '汇率状态', { type: 'warning' }); await changeExchangeRateStatus(row.month, next, row.recordVersion, null); ElMessage.success('汇率状态已更新'); await load() } catch (cause) { if (cause !== 'cancel' && cause !== 'close') ElMessage.error(masterDataErrorMessage(cause, '汇率状态更新失败')) } }
async function exportCsv() { exporting.value = true; try { const { page: _page, size: _size, ...query } = params(); saveCsv(await exportExchangeRates(query), 'monthly-exchange-rates.csv') } catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '汇率导出失败')) } finally { exporting.value = false } }
watch([page, pageSize], load); onMounted(load)
</script>
<template>
<AdminPage>
<ListPageLayout aria-label="汇率管理">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" aria-label="汇率月份筛选" ariaLabel="汇率月份筛选" preference-key="overseas-exchange-rates-query" @search="page = 1" /></template>
<template #metrics>
<section class="rate-summary" aria-label="汇率摘要">
<article v-for="item in summaries" :key="item.label" class="surface-panel rate-summary__item"><span>{{ item.label }}</span><strong>{{ item.value }}</strong></article>
</section>
</template>
<template #state><ContractPendingNotice domain="汇率管理" /></template>
<section class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-exchange-rates-table" row-key="month" empty-text="月度汇率查询契约待接入">
<template #toolbar-start><div><h2>美元兑人民币月平均汇率</h2><p>统一口径:1 USD = X CNY</p></div></template>
<template #toolbar-actions><el-tooltip content="海外区域写接口待接入"><span><el-button type="primary" disabled><Plus :size="16" />更新月度汇率</el-button></span></el-tooltip></template>
<template #actions><span class="muted-value">—</span></template>
<template #conversion>—</template>
<template #pagination-left>共 0 条月度汇率</template>
</DataTable>
</section>
</ListPageLayout>
</AdminPage>
</template>
<style scoped>
.rate-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.rate-summary__item { position: relative; min-width: 0; padding: 16px 18px; overflow: hidden; }
.rate-summary__item::before { position: absolute; inset: 0 auto 0 0; width: 4px; background: var(--cc-primary); content: ''; }
.rate-summary__item span { display: block; color: var(--cc-muted); font-size: 12px; }
.rate-summary__item strong { display: block; margin-top: 8px; color: var(--cc-text); font-size: 22px; }
.table-panel { min-width: 0; overflow: hidden; }
.muted-value { color: var(--cc-muted); }
@media (max-width: 720px) { .rate-summary { grid-template-columns: minmax(0, 1fr); } }
</style>
<template><AdminPage><ListPageLayout aria-label="汇率管理">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="汇率月份筛选" preference-key="overseas-exchange-rates-query" :loading="loading" @search="search" /></template>
<template #metrics><section class="rate-summary"><article v-for="item in [{label:'最新维护汇率',value:summary.latestUsdToCnyAverage??'—'},{label:'汇率月份',value:summary.latestMonth||'—'},{label:'最后更新人',value:summary.updatedBy||'—'},{label:'最后更新时间',value:summary.updatedAt||'—'}]" :key="item.label" class="surface-panel"><span>{{ item.label }}</span><strong>{{ item.value }}</strong></article></section></template>
<template #state><LoadErrorState v-if="error" title="月度汇率加载失败" :message="error.message" @retry="load" /></template>
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="month" preference-key="overseas-exchange-rates-table" empty-text="暂无月度汇率">
<template #toolbar-start><div><h2>美元兑人民币月平均汇率</h2><p>统一口径:1 USD = X CNY</p></div></template>
<template #toolbar-actions><el-button v-if="access.can(permission.exchangeRateCreate)" type="primary" @click="openCreate"><Plus :size="16" />更新月度汇率</el-button><el-button v-if="access.can(permission.exchangeRateExport)" :loading="exporting" @click="exportCsv"><Download :size="16" />导出</el-button></template>
<template #actions="{ row }"><el-button v-if="access.can(permission.exchangeRateEdit)" link type="primary" @click="openEdit(row as MonthlyExchangeRate)"><Pencil :size="15" />编辑</el-button><el-button v-if="access.can(permission.exchangeRateStatus)" link :type="(row as MonthlyExchangeRate).status === 'ACTIVE' ? 'danger' : 'success'" @click="toggle(row as MonthlyExchangeRate)"><Power :size="15" />{{ (row as MonthlyExchangeRate).status === 'ACTIVE' ? '停用' : '启用' }}</el-button></template>
<template #conversion="{ row }">1 USD = {{ (row as MonthlyExchangeRate).usdToCnyAverage }} CNY</template><template #status="{ row }"><el-tag :type="(row as MonthlyExchangeRate).status === 'ACTIVE' ? 'success' : 'info'">{{ (row as MonthlyExchangeRate).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
</DataTable></section>
<AppDialog v-model="editorOpen" :title="editingMonth ? '编辑月度汇率' : '更新月度汇率'" width="min(560px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"><el-form label-position="top"><el-form-item label="汇率月份"><el-date-picker v-model="form.month" type="month" value-format="YYYY-MM" :disabled="!!editingMonth" /></el-form-item><el-form-item label="美元兑人民币平均汇率"><el-input-number v-model="form.usdToCnyAverage" :min="0.00000001" :precision="8" /><p>汇率方向:1 USD = X CNY</p></el-form-item><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" /></el-form-item></el-form></AppDialog>
</ListPageLayout></AdminPage></template>
<style scoped>.rate-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.rate-summary article{padding:16px 18px}.rate-summary span{display:block;color:var(--cc-muted);font-size:12px}.rate-summary strong{display:block;margin-top:8px;font-size:22px}.table-panel{min-width:0;overflow:hidden}</style>