ALIEZ 1 月之前
父节点
当前提交
add7b89250

+ 2 - 2
.env.development

@@ -1,4 +1,4 @@
 # 本地开发(npm run dev / dev:local)
 # 后端根地址,不要末尾斜杠;接口路径如 /user/login 会拼在此地址后
-# VITE_API_BASE=http://192.168.0.33:8505
-VITE_API_BASE=http://103.158.191.66:8505
+VITE_API_BASE=http://192.168.0.33:8505
+# VITE_API_BASE=http://103.158.191.66:8505

二进制
dist.tar.gz


+ 61 - 0
src/api/modules/finance/rate.ts

@@ -0,0 +1,61 @@
+import { request } from '../../request'
+
+/** 0 = 存款汇率,1 = 取款汇率 */
+export type FinanceRateType = 0 | 1
+
+export interface FinanceRateItem {
+  id: number
+  currency: string
+  transformCurrency: string
+  rate: string | number
+  money?: string | number
+}
+
+export interface FinanceRateListParams {
+  type: FinanceRateType
+}
+
+export interface FinanceRateBatchUpdateParams {
+  type: FinanceRateType
+  data: FinanceRateItem[]
+}
+
+export interface FinanceRateAddParams {
+  type: FinanceRateType
+  currency: string
+  transformCurrency: string
+  rate: string | number
+}
+
+export interface FinanceRateDeleteParams {
+  ids: number[]
+}
+
+export interface FinanceRateConvertParams {
+  currency: string
+  transformCurrency: string
+}
+
+export interface FinanceRateConvertResult {
+  money: string | number
+}
+
+export function searchFinanceRateList(params: FinanceRateListParams) {
+  return request.post<FinanceRateItem[]>('/finance/rate/searcher/list', params)
+}
+
+export function batchUpdateFinanceRate(params: FinanceRateBatchUpdateParams) {
+  return request.post<unknown>('/finance/rate/batch/update', params)
+}
+
+export function addFinanceRate(params: FinanceRateAddParams) {
+  return request.post<unknown>('/finance/rate/add', params)
+}
+
+export function deleteFinanceRate(params: FinanceRateDeleteParams) {
+  return request.post<unknown>('/finance/rate/delete', params)
+}
+
+export function convertFinanceRate(params: FinanceRateConvertParams) {
+  return request.post<FinanceRateConvertResult>('/finance/rate/convert', params)
+}

+ 0 - 5
src/api/modules/finance/withdraw.ts

@@ -64,7 +64,6 @@ export interface WithdrawSearchParams {
   pIbNo?: string
   name?: string
   status?: number | null
-  mtStatus?: number | null
   submitStatus?: number | null
   backstageStatus?: number | null
   infoStatus?: number | null
@@ -155,10 +154,6 @@ export function approveWithdraw(data: { id: number; status: 2 | 3; approveDesc?:
   return request.post<unknown>('/finance/withdraw/approve', data)
 }
 
-export function approveWithdrawMT(data: { id: number; withdrawStatus: 2 | 3; approveDesc?: string; withdrawTicket?: string }) {
-  return request.post<unknown>('/finance/withdraw/approve/manager', data)
-}
-
 export function approveWithdrawReceipt(data: { id: number; callbackStatus: 1 | 2; approveDesc?: string }) {
   return request.post<unknown>('/finance/withdraw/approve/channel', data)
 }

+ 1 - 0
src/components/RouteTabBar.vue

@@ -23,6 +23,7 @@ const pathIconMap: Record<string, typeof GridOutline> = {
   '/courses/common-questions': HelpCircleOutline,
   '/courses/reward-questions': TrophyOutline,
   '/courses/goods': BookOutline,
+  '/settings/deposit-withdraw': SettingsOutline,
   '/settings': SettingsOutline,
 }
 

+ 12 - 1
src/layouts/AdminLayout.vue

@@ -1,7 +1,7 @@
 <script setup lang="ts">
 import type { MenuOption } from 'naive-ui'
 import { NIcon, useMessage } from 'naive-ui'
-import { BookOutline, LogoUsd, MenuOutline, PeopleOutline } from '@vicons/ionicons5'
+import { BookOutline, LogoUsd, MenuOutline, PeopleOutline, SettingsOutline } from '@vicons/ionicons5'
 import RouteTabBar from '@/components/RouteTabBar.vue'
 import { setToken } from '@/api/request'
 import { useAppStore } from '@/stores/app'
@@ -29,6 +29,7 @@ function renderIcon(icon: typeof BookOutline) {
 const SUBMENU_COURSES = 'submenu-courses'
 const SUBMENU_CUSTOMERS = 'submenu-customers'
 const SUBMENU_FINANCE = 'submenu-finance'
+const SUBMENU_SETTINGS = 'submenu-settings'
 
 const menuOptions: MenuOption[] = [
   {
@@ -56,6 +57,12 @@ const menuOptions: MenuOption[] = [
       { label: '订单列表', key: '/finance/order-list' },
     ],
   },
+  {
+    label: '系统设置',
+    key: SUBMENU_SETTINGS,
+    icon: renderIcon(SettingsOutline),
+    children: [{ label: '存取款设置', key: '/settings/deposit-withdraw' }],
+  },
 ]
 
 const expandedKeys = ref<string[]>([])
@@ -75,6 +82,10 @@ watch(
       expandedKeys.value = [SUBMENU_FINANCE]
       return
     }
+    if (p.startsWith('/settings')) {
+      expandedKeys.value = [SUBMENU_SETTINGS]
+      return
+    }
     expandedKeys.value = []
   },
   { immediate: true },

+ 11 - 1
src/router/index.ts

@@ -91,7 +91,17 @@ const router = createRouter({
         },
         {
           path: 'settings',
-          redirect: '/courses/common-questions',
+          redirect: '/settings/deposit-withdraw',
+        },
+        {
+          path: 'settings/deposit-withdraw',
+          name: 'SettingsDepositWithdraw',
+          meta: {
+            title: '存取款设置',
+            breadcrumb: ['系统设置', '存取款设置'],
+            requiresAuth: true,
+          },
+          component: () => import('@/views/settings/DepositWithdrawView.vue'),
         },
       ],
     },

+ 94 - 126
src/views/finance/WithdrawApplyView.vue

@@ -1,7 +1,15 @@
 <script setup lang="ts">
 import type { DataTableColumns, FormInst, FormRules } from "naive-ui";
 import { EllipsisVertical } from "@vicons/ionicons5";
-import { NButton, NDropdown, NFormItemGi, NIcon, NInput, NTag, useMessage } from "naive-ui";
+import {
+  NButton,
+  NDropdown,
+  NFormItemGi,
+  NIcon,
+  NInput,
+  NTag,
+  useMessage,
+} from "naive-ui";
 import AdminSearchPanel from "@/components/AdminSearchPanel.vue";
 import AdminTablePageBar from "@/components/AdminTablePageBar.vue";
 import type {
@@ -28,7 +36,6 @@ interface SearchModel {
 
 type ActionType =
   | "approve"
-  | "mt"
   | "receipt"
   | "submit"
   | "backstage"
@@ -140,13 +147,11 @@ const actionForm = ref({
   id: 0,
   type: "approve" as ActionType,
   status: null as string | null,
-  withdrawStatus: null as string | null,
   callbackStatus: null as string | null,
   submitStatus: null as string | null,
   backstageStatus: null as string | null,
   infoStatus: null as string | null,
   approveDesc: "",
-  withdrawTicket: "",
 });
 
 const batchModalVisible = ref(false);
@@ -192,9 +197,6 @@ const withdrawalRecordPageSize = ref(10);
 
 const actionRules: FormRules = {
   status: [{ required: true, message: "请选择处理状态", trigger: "change" }],
-  withdrawStatus: [
-    { required: true, message: "请选择处理状态", trigger: "change" },
-  ],
   callbackStatus: [
     { required: true, message: "请选择处理状态", trigger: "change" },
   ],
@@ -208,9 +210,6 @@ const actionRules: FormRules = {
     { required: true, message: "请选择处理状态", trigger: "change" },
   ],
   approveDesc: [{ required: true, message: "请输入拒绝原因", trigger: "blur" }],
-  withdrawTicket: [
-    { required: true, message: "请输入 MT 订单号", trigger: "blur" },
-  ],
 };
 const batchRules: FormRules = {
   submitStatus: [
@@ -226,7 +225,6 @@ const batchRules: FormRules = {
 };
 const actionTitleMap: Record<ActionType, string> = {
   approve: "取款审核",
-  mt: "MT 扣款",
   receipt: "回执处理",
   submit: "汇款提交",
   backstage: "后台审核",
@@ -268,7 +266,10 @@ function resolveStatus(row: WithdrawItem) {
     "3": "已拒绝",
     "4": "已取消",
   };
-  const statusTypeMap: Record<string, "warning" | "info" | "success" | "error"> = {
+  const statusTypeMap: Record<
+    string,
+    "warning" | "info" | "success" | "error"
+  > = {
     "0": "warning",
     "1": "info",
     "2": "success",
@@ -278,8 +279,18 @@ function resolveStatus(row: WithdrawItem) {
   const toDisplayStatusValue = () => {
     // 与查询条件状态保持一致:0-待处理 1-处理中 2-已通过 3-已拒绝 4-已取消
     if (row.status === 5 || row.backstageStatus === 5) return "4";
-    if (row.status === 3 || row.withdrawStatus === 3 || row.callbackStatus === 2) return "3";
-    if (row.status === 2 && row.withdrawStatus === 2 && row.callbackStatus === 1) return "2";
+    if (
+      row.status === 3 ||
+      row.withdrawStatus === 3 ||
+      row.callbackStatus === 2
+    )
+      return "3";
+    if (
+      row.status === 2 &&
+      row.withdrawStatus === 2 &&
+      row.callbackStatus === 1
+    )
+      return "2";
     if (row.status === 1) return "0";
     return "1";
   };
@@ -290,18 +301,18 @@ function resolveStatus(row: WithdrawItem) {
   };
 }
 
-function feeReductionText(v?: number) {
-  if (v === 1) return "是";
-  if (v === 0) return "否";
-  return "--";
-}
+// function feeReductionText(v?: number) {
+//   if (v === 1) return "是";
+//   if (v === 0) return "否";
+//   return "--";
+// }
 
-function salesLevelText(v?: number) {
-  if (v === 1) return "Level 1";
-  if (v === 2) return "Level 2";
-  if (v === 3) return "Level 3";
-  return "--";
-}
+// function salesLevelText(v?: number) {
+//   if (v === 1) return "Level 1";
+//   if (v === 2) return "Level 2";
+//   if (v === 3) return "Level 3";
+//   return "--";
+// }
 
 function buildSearchPayload(): WithdrawSearchParams {
   const toOptionalNumber = (v: string) => {
@@ -353,13 +364,11 @@ function openActionModal(type: ActionType, row: WithdrawItem) {
     id: row.id,
     type,
     status: null,
-    withdrawStatus: null,
     callbackStatus: null,
     submitStatus: type === "submit" ? "2" : null,
     backstageStatus: null,
     infoStatus: null,
     approveDesc: "",
-    withdrawTicket: "",
   };
   actionModalVisible.value = true;
   nextTick(() => actionFormRef.value?.restoreValidation());
@@ -409,7 +418,10 @@ async function fetchDepositRecords() {
   try {
     const { list, total } = await withdrawApi.searchDepositPage({
       cId: row.cId,
-      page: { current: depositRecordPage.value, row: depositRecordPageSize.value },
+      page: {
+        current: depositRecordPage.value,
+        row: depositRecordPageSize.value,
+      },
     });
     depositRecords.value = list;
     depositRecordTotal.value = total;
@@ -425,7 +437,10 @@ async function fetchWithdrawalRecords() {
   try {
     const { list, total } = await withdrawApi.searchWithdrawPage({
       cId: row.cId,
-      page: { current: withdrawalRecordPage.value, row: withdrawalRecordPageSize.value },
+      page: {
+        current: withdrawalRecordPage.value,
+        row: withdrawalRecordPageSize.value,
+      },
     });
     withdrawalRecords.value = list;
     withdrawalRecordTotal.value = total;
@@ -462,10 +477,12 @@ const groupedRemitChannels = computed(() => {
 
 function parseSelectOptions(selectObj: unknown) {
   if (!selectObj || typeof selectObj !== "object") return [];
-  return Object.entries(selectObj as Record<string, unknown>).map(([label, value]) => ({
-    label,
-    value: String(value ?? ""),
-  }));
+  return Object.entries(selectObj as Record<string, unknown>).map(
+    ([label, value]) => ({
+      label,
+      value: String(value ?? ""),
+    }),
+  );
 }
 
 async function openRemitModal(row: WithdrawItem) {
@@ -493,11 +510,16 @@ async function chooseRemitChannel(channel: RemitChannelItem) {
     });
     remitTitle.value = channel.name || channel.enName || channel.code;
     remitBase.value = data as Record<string, unknown>;
-    const labels =
-      ((data?.params as Record<string, unknown>) ??
-        (data?.paramsEn as Record<string, unknown>) ??
-        {}) as Record<string, unknown>;
-    const blacklist = new Set(["params", "paramsEn", "id", "serial", "withdrawUrl"]);
+    const labels = ((data?.params as Record<string, unknown>) ??
+      (data?.paramsEn as Record<string, unknown>) ??
+      {}) as Record<string, unknown>;
+    const blacklist = new Set([
+      "params",
+      "paramsEn",
+      "id",
+      "serial",
+      "withdrawUrl",
+    ]);
     const fields: Array<{
       name: string;
       label: string;
@@ -512,7 +534,9 @@ async function chooseRemitChannel(channel: RemitChannelItem) {
         name,
         label,
         value: String((data as Record<string, unknown>)[name] ?? ""),
-        options: parseSelectOptions((data as Record<string, unknown>)[`${name}Select`]),
+        options: parseSelectOptions(
+          (data as Record<string, unknown>)[`${name}Select`],
+        ),
       });
     });
     remitFields.value = fields;
@@ -544,8 +568,11 @@ async function submitRemit() {
   }
 }
 
-function getActionOptions(row: WithdrawItem): Array<{ label: string; key: ActionType }> {
-  const callbackPending = row.callbackStatus === 0 || row.callbackStatus == null;
+function getActionOptions(
+  row: WithdrawItem,
+): Array<{ label: string; key: ActionType }> {
+  const callbackPending =
+    row.callbackStatus === 0 || row.callbackStatus == null;
   const options: Array<{ label: string; key: ActionType }> = [];
 
   // 旧页逻辑:待处理时显示「审核」
@@ -553,11 +580,6 @@ function getActionOptions(row: WithdrawItem): Array<{ label: string; key: Action
     options.push({ label: "审核", key: "approve" });
   }
 
-  // 旧页逻辑:待 MT 扣款时显示「MT」
-  if (row.withdrawStatus === 1) {
-    options.push({ label: "MT", key: "mt" });
-  }
-
   // 旧页逻辑:通道回执
   if (
     row.withdrawStatus === 2 &&
@@ -658,7 +680,10 @@ function mapFields(
 ) {
   return defs
     .filter((def) => hasDisplayValue(source[def.key]))
-    .map((def) => ({ label: def.label, value: toDetailValue(source[def.key]) }));
+    .map((def) => ({
+      label: def.label,
+      value: toDetailValue(source[def.key]),
+    }));
 }
 
 const basicDetailFields = computed<DetailFieldItem[]>(() => {
@@ -713,7 +738,10 @@ const bankDetailFields = computed<DetailFieldItem[]>(() => {
 
   if (remitType === "BANK" || remitType === "BANK_TELEGRAPHIC") {
     fields.push(...mapFields(rec, bankTransferFields));
-  } else if (remitType === "CHANNEL_TYPE_CARD" || remitType === "UCARD_WALLET") {
+  } else if (
+    remitType === "CHANNEL_TYPE_CARD" ||
+    remitType === "UCARD_WALLET"
+  ) {
     fields.push(...mapFields(rec, cardFields));
   } else if (
     remitType === "CHANNEL_TYPE_WALLET" ||
@@ -744,10 +772,6 @@ const withdrawDetailFields = computed<DetailFieldItem[]>(() => {
       value: `${amountText(row.transformAmount)} (${displayText(row.transformCurrency)})`,
     },
     { label: "取款方式", value: toDetailValue(row.remitChannelName) },
-    { label: "手续费减免", value: feeReductionText(row.feeReduction) },
-    { label: "手续费", value: amountText(row.feeAmount) },
-    { label: "手续费减免金额", value: amountText(row.feeReductionAmount) },
-    { label: "风险等级", value: salesLevelText(row.salesSettingLevel) },
     { label: "汇款提交时间", value: toDetailValue(row.submitTime) },
     { label: "状态", value: resolveStatus(row).label },
     { label: "拒绝/备注", value: toDetailValue(row.approveDesc) },
@@ -765,13 +789,6 @@ async function submitAction() {
         status: (Number(f.status) as 2 | 3) ?? 2,
         approveDesc: f.status === "3" ? f.approveDesc.trim() : "",
       });
-    } else if (f.type === "mt") {
-      await withdrawApi.approveWithdrawMT({
-        id: f.id,
-        withdrawStatus: (Number(f.withdrawStatus) as 2 | 3) ?? 2,
-        approveDesc: f.withdrawStatus === "3" ? f.approveDesc.trim() : "",
-        withdrawTicket: f.withdrawStatus === "2" ? f.withdrawTicket.trim() : "",
-      });
     } else if (f.type === "receipt") {
       await withdrawApi.approveWithdrawReceipt({
         id: f.id,
@@ -890,7 +907,6 @@ const allColumns = ref<DataTableColumns<WithdrawItem>>([
   {
     title: "流水号",
     key: "serial",
-    width: 200,
     render(row) {
       return h(
         NButton,
@@ -907,23 +923,14 @@ const allColumns = ref<DataTableColumns<WithdrawItem>>([
   {
     title: "申请金额",
     key: "amount",
-    
     render(row) {
       return `${amountText(row.amount)} ${row.currency || ""}`.trim();
     },
   },
-  {
-    title: "平台出金",
-    key: "withdrawAmount",
-    
-    render(row) {
-      return `${amountText(row.withdrawAmount)} ${row.withdrawCurrency || ""}`.trim();
-    },
-  },
   {
     title: "汇款金额",
     key: "transformAmount",
-    
+
     render(row) {
       return `${amountText(row.transformAmount)} ${row.transformCurrency || ""}`.trim();
     },
@@ -931,43 +938,14 @@ const allColumns = ref<DataTableColumns<WithdrawItem>>([
   {
     title: "取款方式",
     key: "remitChannelName",
-    
     ellipsis: { tooltip: true },
   },
-  { title: "申请时间", key: "addTime", },
-  { title: "放款时间", key: "submitTime", },
-  {
-    title: "手续费减免",
-    key: "feeReduction",
-    render(row) {
-      return feeReductionText(row.feeReduction);
-    },
-  },
-  {
-    title: "手续费",
-    key: "feeAmount",
-    render(row) {
-      return amountText(row.feeAmount);
-    },
-  },
-  {
-    title: "手续费减免金额",
-    key: "feeReductionAmount",
-    render(row) {
-      return amountText(row.feeReductionAmount);
-    },
-  },
-  {
-    title: "风险等级",
-    key: "salesSettingLevel",
-    render(row) {
-      return salesLevelText(row.salesSettingLevel);
-    },
-  },
+  { title: "申请时间", key: "addTime" },
+  { title: "放款时间", key: "submitTime" },
   {
     title: "状态",
     key: "statusTag",
-   
+
     render(row) {
       const s = resolveStatus(row);
       return h(
@@ -997,7 +975,14 @@ const allColumns = ref<DataTableColumns<WithdrawItem>>([
               h(
                 NButton,
                 { text: true, circle: true, class: "action-cell__more-btn" },
-                { icon: () => h(NIcon, { size: 16 }, { default: () => h(EllipsisVertical) }) },
+                {
+                  icon: () =>
+                    h(
+                      NIcon,
+                      { size: 16 },
+                      { default: () => h(EllipsisVertical) },
+                    ),
+                },
               ),
           },
         ),
@@ -1231,7 +1216,9 @@ watch([withdrawalRecordPage, withdrawalRecordPageSize], () => {
           <!-- <NButton size="small" @click="openDepositRecords">存款记录</NButton> -->
           <!-- <NButton size="small" @click="openWithdrawalRecords">取款记录</NButton> -->
           <NButton
-            v-for="item in getActionOptions(detailRow).filter((x) => x.key !== 'detail')"
+            v-for="item in getActionOptions(detailRow).filter(
+              (x) => x.key !== 'detail',
+            )"
             :key="item.key"
             size="small"
             @click="openActionFromDetail(item.key)"
@@ -1306,7 +1293,9 @@ watch([withdrawalRecordPage, withdrawalRecordPageSize], () => {
     >
       <div v-if="!remitFields.length && !remitFormLoading">
         <div v-if="remitChannelsLoading" class="remit-tip">正在加载通道...</div>
-        <div v-else-if="!groupedRemitChannels.length" class="remit-tip">暂无可用出金通道</div>
+        <div v-else-if="!groupedRemitChannels.length" class="remit-tip">
+          暂无可用出金通道
+        </div>
         <div v-else class="remit-groups">
           <section
             v-for="group in groupedRemitChannels"
@@ -1450,26 +1439,6 @@ watch([withdrawalRecordPage, withdrawalRecordPageSize], () => {
             :options="processOptions"
           />
         </NFormItem>
-        <NFormItem
-          v-if="actionForm.type === 'mt'"
-          label="处理状态"
-          path="withdrawStatus"
-        >
-          <NSelect
-            v-model:value="actionForm.withdrawStatus"
-            :options="processOptions"
-          />
-        </NFormItem>
-        <NFormItem
-          v-if="actionForm.type === 'mt' && actionForm.withdrawStatus === '2'"
-          label="MT 订单号"
-          path="withdrawTicket"
-        >
-          <NInput
-            v-model:value="actionForm.withdrawTicket"
-            placeholder="请输入 MT 订单号"
-          />
-        </NFormItem>
         <NFormItem
           v-if="actionForm.type === 'receipt'"
           label="回执状态"
@@ -1513,7 +1482,6 @@ watch([withdrawalRecordPage, withdrawalRecordPageSize], () => {
         <NFormItem
           v-if="
             (actionForm.type === 'approve' && actionForm.status === '3') ||
-            (actionForm.type === 'mt' && actionForm.withdrawStatus === '3') ||
             (actionForm.type === 'receipt' &&
               actionForm.callbackStatus === '2') ||
             (actionForm.type === 'submit' && actionForm.submitStatus === '3') ||

+ 426 - 0
src/views/settings/DepositWithdrawView.vue

@@ -0,0 +1,426 @@
+<script setup lang="ts">
+import type { FormInst, FormRules } from 'naive-ui'
+import {
+  NButton,
+  NCard,
+  NEmpty,
+  NForm,
+  NFormItem,
+  NIcon,
+  NInput,
+  NInputGroup,
+  NModal,
+  NSpin,
+  useDialog,
+  useMessage,
+} from 'naive-ui'
+import { AddOutline, CreateOutline } from '@vicons/ionicons5'
+import type { FinanceRateItem, FinanceRateType } from '@/api/modules/finance/rate'
+import * as rateApi from '@/api/modules/finance/rate'
+
+type EditSection = 'deposit' | 'withdraw' | null
+
+interface AddRateForm {
+  currency: string
+  transformCurrency: string
+  rate: string
+}
+
+const message = useMessage()
+const dialog = useDialog()
+
+const loading = ref(false)
+const depositRates = ref<FinanceRateItem[]>([])
+const withdrawRates = ref<FinanceRateItem[]>([])
+const editSection = ref<EditSection>(null)
+const convertingKey = ref('')
+const savingSection = ref<'deposit' | 'withdraw' | null>(null)
+
+const addModalVisible = ref(false)
+const addSubmitting = ref(false)
+const addFormRef = ref<FormInst | null>(null)
+const addRateType = ref<FinanceRateType>(0)
+const addForm = ref<AddRateForm>({
+  currency: '',
+  transformCurrency: '',
+  rate: '',
+})
+
+const addRules: FormRules = {
+  currency: [{ required: true, message: '请输入货币', trigger: 'blur' }],
+  transformCurrency: [{ required: true, message: '请输入转换货币', trigger: 'blur' }],
+  rate: [{ required: true, message: '请输入汇率', trigger: 'blur' }],
+}
+
+function pairLabel(item: FinanceRateItem) {
+  return `${item.currency}/${item.transformCurrency}`
+}
+
+function rowKey(item: FinanceRateItem) {
+  return `${item.id}-${item.currency}-${item.transformCurrency}`
+}
+
+function calcDepositMoney(money?: string | number) {
+  if (money === undefined || money === null || money === '') return ''
+  const value = Number(money)
+  if (Number.isNaN(value)) return ''
+  return String(value * 1.02)
+}
+
+function displayValue(value?: string | number) {
+  if (value === undefined || value === null) return ''
+  return String(value)
+}
+
+async function fetchDepositRates() {
+  depositRates.value = await rateApi.searchFinanceRateList({ type: 0 })
+}
+
+async function fetchWithdrawRates() {
+  withdrawRates.value = await rateApi.searchFinanceRateList({ type: 1 })
+}
+
+async function fetchAllRates() {
+  loading.value = true
+  try {
+    await Promise.all([fetchDepositRates(), fetchWithdrawRates()])
+  } finally {
+    loading.value = false
+  }
+}
+
+function startEdit(section: 'deposit' | 'withdraw') {
+  editSection.value = section
+}
+
+function cancelEdit(section: 'deposit' | 'withdraw') {
+  editSection.value = null
+  if (section === 'deposit') void fetchDepositRates()
+  else void fetchWithdrawRates()
+}
+
+function openAddModal(type: FinanceRateType) {
+  addRateType.value = type
+  addForm.value = { currency: '', transformCurrency: '', rate: '' }
+  addModalVisible.value = true
+}
+
+async function submitAddForm() {
+  await addFormRef.value?.validate()
+  addSubmitting.value = true
+  try {
+    await rateApi.addFinanceRate({
+      type: addRateType.value,
+      currency: addForm.value.currency.trim(),
+      transformCurrency: addForm.value.transformCurrency.trim(),
+      rate: addForm.value.rate.trim(),
+    })
+    message.success('汇率已添加')
+    addModalVisible.value = false
+    editSection.value = null
+    await fetchAllRates()
+  } finally {
+    addSubmitting.value = false
+  }
+}
+
+async function requestBatchSave(section: 'deposit' | 'withdraw') {
+  const type: FinanceRateType = section === 'deposit' ? 0 : 1
+  const data = section === 'deposit' ? depositRates.value : withdrawRates.value
+  savingSection.value = section
+  try {
+    await rateApi.batchUpdateFinanceRate({ type, data })
+    message.success('汇率已保存')
+    editSection.value = null
+    if (section === 'deposit') await fetchDepositRates()
+    else await fetchWithdrawRates()
+  } finally {
+    savingSection.value = null
+  }
+}
+
+function requestDelete(id: number) {
+  dialog.warning({
+    title: '删除汇率',
+    content: '确定删除该条汇率吗?',
+    positiveText: '删除',
+    negativeText: '取消',
+    onPositiveClick: async () => {
+      try {
+        await rateApi.deleteFinanceRate({ ids: [id] })
+        message.success('汇率已删除')
+        editSection.value = null
+        await fetchAllRates()
+        return true
+      } catch {
+        return false
+      }
+    },
+  })
+}
+
+async function convertRate(item: FinanceRateItem) {
+  const key = rowKey(item)
+  convertingKey.value = key
+  try {
+    const result = await rateApi.convertFinanceRate({
+      currency: item.currency,
+      transformCurrency: item.transformCurrency,
+    })
+    item.money = result.money
+  } finally {
+    convertingKey.value = ''
+  }
+}
+
+onMounted(() => {
+  void fetchAllRates()
+})
+</script>
+
+<template>
+  <div class="page page--settings">
+    <NSpin :show="loading">
+      <div class="rate-grid">
+        <NCard class="settings-card rate-panel" :bordered="false" title="存款汇率设置">
+          <template #header-extra>
+            <div class="panel-actions">
+              <NButton quaternary circle type="primary" title="新增汇率" @click="openAddModal(0)">
+                <template #icon>
+                  <NIcon :component="AddOutline" />
+                </template>
+              </NButton>
+              <template v-if="editSection === 'deposit'">
+                <NButton
+                  size="small"
+                  type="primary"
+                  :loading="savingSection === 'deposit'"
+                  @click="requestBatchSave('deposit')"
+                >
+                  保存
+                </NButton>
+                <NButton size="small" @click="cancelEdit('deposit')">取消</NButton>
+              </template>
+              <NButton
+                v-else
+                quaternary
+                circle
+                title="编辑"
+                @click="startEdit('deposit')"
+              >
+                <template #icon>
+                  <NIcon :component="CreateOutline" />
+                </template>
+              </NButton>
+            </div>
+          </template>
+
+          <NEmpty v-if="!depositRates.length" description="暂无存款汇率" class="rate-empty" />
+          <div v-else class="rate-list">
+            <div
+              v-for="item in depositRates"
+              :key="rowKey(item)"
+              class="rate-row"
+            >
+              <NInputGroup class="rate-row__main">
+                <NInput :value="pairLabel(item)" readonly class="rate-row__pair" />
+                <NInput
+                  :value="displayValue(item.rate)"
+                  :disabled="editSection !== 'deposit'"
+                  placeholder="汇率"
+                  class="rate-row__value"
+                  @update:value="(v) => { item.rate = v }"
+                />
+                <NButton
+                  :disabled="editSection !== 'deposit'"
+                  :type="editSection === 'deposit' ? 'error' : 'default'"
+                  ghost
+                  @click="editSection === 'deposit' && requestDelete(item.id)"
+                >
+                  删除
+                </NButton>
+              </NInputGroup>
+
+              <NInputGroup class="rate-row__convert">
+                <NButton
+                  :loading="convertingKey === rowKey(item)"
+                  @click="convertRate(item)"
+                >
+                  汇率
+                </NButton>
+                <NInput :value="displayValue(item.money)" readonly placeholder="实时汇率" />
+                <NInput :value="calcDepositMoney(item.money)" readonly placeholder="×1.02" />
+              </NInputGroup>
+            </div>
+          </div>
+        </NCard>
+
+        <NCard class="settings-card rate-panel" :bordered="false" title="取款汇率设置">
+          <template #header-extra>
+            <div class="panel-actions">
+              <NButton quaternary circle type="primary" title="新增汇率" @click="openAddModal(1)">
+                <template #icon>
+                  <NIcon :component="AddOutline" />
+                </template>
+              </NButton>
+              <template v-if="editSection === 'withdraw'">
+                <NButton
+                  size="small"
+                  type="primary"
+                  :loading="savingSection === 'withdraw'"
+                  @click="requestBatchSave('withdraw')"
+                >
+                  保存
+                </NButton>
+                <NButton size="small" @click="cancelEdit('withdraw')">取消</NButton>
+              </template>
+              <NButton
+                v-else
+                quaternary
+                circle
+                title="编辑"
+                @click="startEdit('withdraw')"
+              >
+                <template #icon>
+                  <NIcon :component="CreateOutline" />
+                </template>
+              </NButton>
+            </div>
+          </template>
+
+          <NEmpty v-if="!withdrawRates.length" description="暂无取款汇率" class="rate-empty" />
+          <div v-else class="rate-list">
+            <div
+              v-for="item in withdrawRates"
+              :key="rowKey(item)"
+              class="rate-row"
+            >
+              <NInputGroup class="rate-row__main">
+                <NInput :value="pairLabel(item)" readonly class="rate-row__pair" />
+                <NInput
+                  :value="displayValue(item.rate)"
+                  :disabled="editSection !== 'withdraw'"
+                  placeholder="汇率"
+                  class="rate-row__value"
+                  @update:value="(v) => { item.rate = v }"
+                />
+                <NButton
+                  :disabled="editSection !== 'withdraw'"
+                  :type="editSection === 'withdraw' ? 'error' : 'default'"
+                  ghost
+                  @click="editSection === 'withdraw' && requestDelete(item.id)"
+                >
+                  删除
+                </NButton>
+              </NInputGroup>
+
+              <NInputGroup class="rate-row__convert">
+                <NButton
+                  :loading="convertingKey === rowKey(item)"
+                  @click="convertRate(item)"
+                >
+                  汇率
+                </NButton>
+                <NInput :value="displayValue(item.money)" readonly placeholder="实时汇率" />
+              </NInputGroup>
+            </div>
+          </div>
+        </NCard>
+      </div>
+    </NSpin>
+
+    <NModal
+      v-model:show="addModalVisible"
+      preset="card"
+      :title="addRateType === 0 ? '新增存款汇率' : '新增取款汇率'"
+      style="width: min(480px, 92vw)"
+      :mask-closable="false"
+    >
+      <NForm ref="addFormRef" :model="addForm" :rules="addRules" label-placement="left" label-width="96">
+        <NFormItem label="货币" path="currency">
+          <NInput v-model:value="addForm.currency" placeholder="如 USD" />
+        </NFormItem>
+        <NFormItem label="转换货币" path="transformCurrency">
+          <NInput v-model:value="addForm.transformCurrency" placeholder="如 CNY" />
+        </NFormItem>
+        <NFormItem label="汇率" path="rate">
+          <NInput v-model:value="addForm.rate" placeholder="请输入汇率" />
+        </NFormItem>
+      </NForm>
+      <template #footer>
+        <div class="modal-footer">
+          <NButton @click="addModalVisible = false">取消</NButton>
+          <NButton type="primary" :loading="addSubmitting" @click="submitAddForm">确定</NButton>
+        </div>
+      </template>
+    </NModal>
+  </div>
+</template>
+
+<style scoped>
+.page--settings {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.rate-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 16px;
+}
+
+.rate-panel {
+  border-radius: 12px;
+  padding: 4px;
+}
+
+.panel-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.rate-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.rate-row {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.rate-row__main,
+.rate-row__convert {
+  width: 100%;
+}
+
+.rate-row__pair {
+  width: 140px;
+  flex-shrink: 0;
+}
+
+.rate-row__value {
+  flex: 1;
+  min-width: 0;
+}
+
+.rate-empty {
+  padding: 24px 0;
+}
+
+.modal-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+}
+
+@media (max-width: 1100px) {
+  .rate-grid {
+    grid-template-columns: 1fr;
+  }
+}
+</style>