ALIEZ hace 1 semana
padre
commit
b0f155fb60

+ 1 - 1
.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://192.168.0.27:8505
 VITE_API_BASE=http://103.158.191.66:8505

BIN
dist.tar.gz


+ 6 - 0
src/api/modules/finance/customer.ts

@@ -8,6 +8,8 @@ export interface CustomerItem {
   name?: string
   phone?: string
   identity?: string
+  /** 1:初探 2:研习 3:学成 4:深耕 5:传誉 */
+  levelLabel?: number
   totalSpendingAmount?: number | string
   addTime?: string | number
 }
@@ -28,3 +30,7 @@ export async function searchCustomerPage(params: CustomerSearchParams) {
   const raw = await searchCustomerList(params)
   return unwrapPageList<CustomerItem>(raw)
 }
+
+export function updateCustomerLevelLabel(data: { id: number; levelLabel: number }) {
+  return request.post<unknown>('/custom/update/label', data)
+}

+ 31 - 0
src/api/modules/settings/rewardAmount.ts

@@ -0,0 +1,31 @@
+import { request } from '../../request'
+import { type PageParam, unwrapPageList } from '../../types'
+
+export interface RewardAmountItem {
+  id: number
+  email: string
+  amount: number | string
+}
+
+export interface RewardAmountSearchParams {
+  email?: string
+  amount?: number | string
+  page?: PageParam
+}
+
+export function addRewardAmount(data: { email: string; amount: number | string }) {
+  return request.post<unknown>('/reward/amount/add', data)
+}
+
+export function updateRewardAmount(data: { email: string; amount: number | string }) {
+  return request.post<unknown>('/reward/amount/update', data)
+}
+
+export function deleteRewardAmount(data: { ids: number[] }) {
+  return request.post<unknown>('/reward/amount/delete', data)
+}
+
+export async function searchRewardAmountList(params: RewardAmountSearchParams = {}) {
+  const raw = await request.post<unknown>('/reward/amount/search/list', params)
+  return unwrapPageList<RewardAmountItem>(raw)
+}

+ 1 - 0
src/composables/index.ts

@@ -1,3 +1,4 @@
 export * from './usePagedKeywordList'
 export * from './useConfirmRowDelete'
 export * from './useTableColumnsControl'
+export * from './useScrollIncrementalList'

+ 118 - 0
src/composables/useScrollIncrementalList.ts

@@ -0,0 +1,118 @@
+import { computed, nextTick, onMounted, onUnmounted, ref, watch, type Ref } from 'vue'
+
+export interface UseScrollIncrementalListOptions {
+  /** 单行预估高度(px) */
+  rowHeight?: number
+  /** 首批/每批最少条数 */
+  minBatch?: number
+}
+
+export function useScrollIncrementalList<T>(
+  source: Ref<T[]>,
+  containerRef: Ref<HTMLElement | null>,
+  options: UseScrollIncrementalListOptions = {},
+) {
+  const rowHeight = options.rowHeight ?? 96
+  const minBatch = options.minBatch ?? 4
+
+  const batchSize = ref(minBatch)
+  const visibleCount = ref(minBatch)
+  const sentinelRef = ref<HTMLElement | null>(null)
+
+  const visibleItems = computed(() => source.value.slice(0, visibleCount.value))
+  const hasMore = computed(() => visibleCount.value < source.value.length)
+  const total = computed(() => source.value.length)
+
+  function resolveBatchSize() {
+    const el = containerRef.value
+    const fallbackHeight = Math.max(320, window.innerHeight - 220)
+    const height = el && el.clientHeight > 0 ? el.clientHeight : fallbackHeight
+    return Math.max(minBatch, Math.ceil(height / rowHeight) + 1)
+  }
+
+  function reset() {
+    batchSize.value = resolveBatchSize()
+    visibleCount.value = Math.min(batchSize.value, source.value.length)
+  }
+
+  function loadMore() {
+    if (!hasMore.value) return
+    visibleCount.value = Math.min(visibleCount.value + batchSize.value, source.value.length)
+  }
+
+  function onScroll() {
+    const el = containerRef.value
+    if (!el || !hasMore.value) return
+    if (el.scrollTop + el.clientHeight >= el.scrollHeight - 48) {
+      loadMore()
+    }
+  }
+
+  let observer: IntersectionObserver | null = null
+
+  function setupObserver() {
+    observer?.disconnect()
+    observer = null
+    const root = containerRef.value
+    const target = sentinelRef.value
+    if (!root || !target || !hasMore.value) return
+
+    observer = new IntersectionObserver(
+      (entries) => {
+        if (entries.some((entry) => entry.isIntersecting)) loadMore()
+      },
+      { root, rootMargin: '64px' },
+    )
+    observer.observe(target)
+  }
+
+  let resizeObserver: ResizeObserver | null = null
+
+  onMounted(() => {
+    void nextTick(() => {
+      reset()
+      setupObserver()
+    })
+
+    const el = containerRef.value
+    if (el && typeof ResizeObserver !== 'undefined') {
+      resizeObserver = new ResizeObserver(() => {
+        batchSize.value = resolveBatchSize()
+      })
+      resizeObserver.observe(el)
+    }
+
+    window.addEventListener('resize', reset)
+  })
+
+  onUnmounted(() => {
+    observer?.disconnect()
+    resizeObserver?.disconnect()
+    window.removeEventListener('resize', reset)
+  })
+
+  watch(
+    () => source.value.length,
+    () => {
+      void nextTick(() => {
+        reset()
+        setupObserver()
+      })
+    },
+  )
+
+  watch([visibleCount, hasMore], () => {
+    void nextTick(setupObserver)
+  })
+
+  return {
+    visibleItems,
+    hasMore,
+    total,
+    visibleCount,
+    sentinelRef,
+    reset,
+    loadMore,
+    onScroll,
+  }
+}

+ 4 - 1
src/layouts/AdminLayout.vue

@@ -61,7 +61,10 @@ const menuOptions: MenuOption[] = [
     label: '系统设置',
     key: SUBMENU_SETTINGS,
     icon: renderIcon(SettingsOutline),
-    children: [{ label: '存取款设置', key: '/settings/deposit-withdraw' }],
+    children: [
+      { label: '存取款设置', key: '/settings/deposit-withdraw' },
+      { label: '赠金额度设置', key: '/settings/reward-amount' },
+    ],
   },
 ]
 

+ 10 - 0
src/router/index.ts

@@ -103,6 +103,16 @@ const router = createRouter({
           },
           component: () => import('@/views/settings/DepositWithdrawView.vue'),
         },
+        {
+          path: 'settings/reward-amount',
+          name: 'SettingsRewardAmount',
+          meta: {
+            title: '赠金额度设置',
+            breadcrumb: ['系统设置', '赠金额度设置'],
+            requiresAuth: true,
+          },
+          component: () => import('@/views/settings/RewardAmountView.vue'),
+        },
       ],
     },
   ],

+ 42 - 0
src/utils/customerLevelLabel.ts

@@ -0,0 +1,42 @@
+export type CustomerLevel = 1 | 2 | 3 | 4 | 5
+
+export const CUSTOMER_LEVEL_LABELS: Record<CustomerLevel, string> = {
+  1: '初探',
+  2: '研习',
+  3: '学成',
+  4: '深耕',
+  5: '传誉',
+}
+
+export const CUSTOMER_LEVEL_OPTIONS = (Object.entries(CUSTOMER_LEVEL_LABELS) as [string, string][]).map(
+  ([value, label]) => ({
+    label,
+    value: Number(value) as CustomerLevel,
+  }),
+)
+
+const TAG_TYPE_MAP: Record<CustomerLevel, 'default' | 'info' | 'success' | 'warning' | 'error'> = {
+  1: 'default',
+  2: 'info',
+  3: 'success',
+  4: 'warning',
+  5: 'error',
+}
+
+export function parseCustomerLevel(v: unknown): CustomerLevel | null {
+  const n = typeof v === 'number' ? v : Number(v)
+  if (Number.isInteger(n) && n >= 1 && n <= 5) return n as CustomerLevel
+  return null
+}
+
+export function customerLevelText(v: unknown) {
+  const level = parseCustomerLevel(v)
+  if (!level) return '--'
+  return CUSTOMER_LEVEL_LABELS[level]
+}
+
+export function customerLevelTagType(v: unknown) {
+  const level = parseCustomerLevel(v)
+  if (!level) return 'default' as const
+  return TAG_TYPE_MAP[level]
+}

+ 135 - 2
src/views/finance/CustomerListView.vue

@@ -1,11 +1,31 @@
 <script setup lang="ts">
 import type { DataTableColumns } from 'naive-ui'
-import { NDataTable, NFormItemGi, NInput, NPagination } from 'naive-ui'
+import {
+  NButton,
+  NDataTable,
+  NFormItem,
+  NFormItemGi,
+  NInput,
+  NModal,
+  NPagination,
+  NSelect,
+  NTag,
+  useMessage,
+} from 'naive-ui'
 import AdminSearchPanel from '@/components/AdminSearchPanel.vue'
 import AdminTablePageBar from '@/components/AdminTablePageBar.vue'
 import * as customerApi from '@/api/modules/finance/customer'
 import type { CustomerItem, CustomerSearchParams } from '@/api/modules/finance/customer'
 import { useTableColumnsControl } from '@/composables'
+import {
+  CUSTOMER_LEVEL_OPTIONS,
+  customerLevelTagType,
+  customerLevelText,
+  parseCustomerLevel,
+  type CustomerLevel,
+} from '@/utils/customerLevelLabel'
+
+const message = useMessage()
 
 const loading = ref(false)
 const list = ref<CustomerItem[]>([])
@@ -25,6 +45,11 @@ const search = ref<{
   phone: '',
 })
 
+const levelModalVisible = ref(false)
+const levelSubmitting = ref(false)
+const editingCustomer = ref<CustomerItem | null>(null)
+const editingLevel = ref<CustomerLevel | null>(null)
+
 function amountText(v: unknown) {
   const n = Number(v ?? 0)
   if (Number.isNaN(n)) return '0.00'
@@ -89,12 +114,56 @@ function resetSearch() {
   void fetchList()
 }
 
+function openLevelEdit(row: CustomerItem) {
+  editingCustomer.value = row
+  editingLevel.value = parseCustomerLevel(row.levelLabel)
+  levelModalVisible.value = true
+}
+
+async function submitLevelEdit() {
+  const row = editingCustomer.value
+  if (!row?.id) {
+    message.warning('缺少客户 ID,无法修改')
+    return
+  }
+  if (editingLevel.value == null) {
+    message.warning('请选择标签等级')
+    return
+  }
+  levelSubmitting.value = true
+  try {
+    await customerApi.updateCustomerLevelLabel({
+      id: row.id,
+      levelLabel: editingLevel.value,
+    })
+    message.success('标签等级已更新')
+    levelModalVisible.value = false
+    await fetchList()
+  } finally {
+    levelSubmitting.value = false
+  }
+}
+
 const columns = ref<DataTableColumns<CustomerItem>>([
   { title: 'CID', key: 'cId', width: 120, ellipsis: { tooltip: true } },
   { title: '邮箱', key: 'email', width: 220, ellipsis: { tooltip: true } },
   { title: '姓名', key: 'name', width: 120, ellipsis: { tooltip: true } },
   { title: '手机号', key: 'phone', width: 140, ellipsis: { tooltip: true } },
   { title: '身份证号', key: 'identity', width: 220, ellipsis: { tooltip: true } },
+  {
+    title: '标签等级',
+    key: 'levelLabel',
+    width: 110,
+    render: (row) => {
+      const level = parseCustomerLevel(row.levelLabel)
+      if (!level) return '--'
+      return h(
+        NTag,
+        { size: 'small', type: customerLevelTagType(level), bordered: false },
+        { default: () => customerLevelText(level) },
+      )
+    },
+  },
   {
     title: '累计消费',
     key: 'totalSpendingAmount',
@@ -107,6 +176,24 @@ const columns = ref<DataTableColumns<CustomerItem>>([
     width: 180,
     render: (row) => timeCell(row.addTime),
   },
+  {
+    title: '操作',
+    key: 'actions',
+    width: 110,
+    fixed: 'right',
+    render(row) {
+      return h(
+        NButton,
+        {
+          size: 'small',
+          quaternary: true,
+          type: 'primary',
+          onClick: () => openLevelEdit(row),
+        },
+        { default: () => '修改等级' },
+      )
+    },
+  },
 ])
 
 const { visibleKeys, displayColumns, columnOptions } = useTableColumnsControl(columns)
@@ -180,7 +267,7 @@ onActivated(() => {
             :single-line="false"
             :row-key="(row: CustomerItem) => `${row.id ?? ''}-${row.cId ?? ''}-${row.email ?? ''}-${row.phone ?? ''}`"
             class="data-table-fill"
-            :scroll-x="1110"
+            :scroll-x="1340"
           />
         </div>
 
@@ -198,5 +285,51 @@ onActivated(() => {
         </div>
       </div>
     </NCard>
+
+    <NModal
+      v-model:show="levelModalVisible"
+      preset="card"
+      title="修改标签等级"
+      style="width: min(420px, 92vw)"
+      :mask-closable="false"
+      @after-leave="() => { editingCustomer = null; editingLevel = null }"
+    >
+      <div v-if="editingCustomer" class="level-edit-meta">
+        <p><span class="level-edit-meta__label">CID:</span>{{ editingCustomer.cId ?? '--' }}</p>
+        <p><span class="level-edit-meta__label">姓名:</span>{{ editingCustomer.name ?? '--' }}</p>
+      </div>
+      <NFormItem label="标签等级" label-placement="top">
+        <NSelect
+          v-model:value="editingLevel"
+          :options="CUSTOMER_LEVEL_OPTIONS"
+          placeholder="请选择标签等级"
+        />
+      </NFormItem>
+      <template #footer>
+        <div class="level-edit-footer">
+          <NButton @click="levelModalVisible = false">取消</NButton>
+          <NButton type="primary" :loading="levelSubmitting" @click="submitLevelEdit">保存</NButton>
+        </div>
+      </template>
+    </NModal>
   </div>
 </template>
+
+<style scoped>
+.level-edit-meta {
+  margin-bottom: 12px;
+  color: var(--n-text-color-2);
+  font-size: 13px;
+  line-height: 1.8;
+}
+
+.level-edit-meta__label {
+  color: var(--n-text-color-3);
+}
+
+.level-edit-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+}
+</style>

+ 98 - 5
src/views/settings/DepositWithdrawView.vue

@@ -17,6 +17,7 @@ import {
 import { AddOutline, CreateOutline } from '@vicons/ionicons5'
 import type { FinanceRateItem, FinanceRateType } from '@/api/modules/finance/rate'
 import * as rateApi from '@/api/modules/finance/rate'
+import { useScrollIncrementalList } from '@/composables'
 
 type EditSection = 'deposit' | 'withdraw' | null
 
@@ -32,6 +33,8 @@ const dialog = useDialog()
 const loading = ref(false)
 const depositRates = ref<FinanceRateItem[]>([])
 const withdrawRates = ref<FinanceRateItem[]>([])
+const depositScrollRef = ref<HTMLElement | null>(null)
+const withdrawScrollRef = ref<HTMLElement | null>(null)
 const editSection = ref<EditSection>(null)
 const convertingKey = ref('')
 const savingSection = ref<'deposit' | 'withdraw' | null>(null)
@@ -72,6 +75,25 @@ function displayValue(value?: string | number) {
   return String(value)
 }
 
+const depositList = useScrollIncrementalList(depositRates, depositScrollRef, { rowHeight: 96 })
+const withdrawList = useScrollIncrementalList(withdrawRates, withdrawScrollRef, { rowHeight: 88 })
+
+const {
+  visibleItems: depositVisibleItems,
+  hasMore: depositHasMore,
+  visibleCount: depositVisibleCount,
+  total: depositTotal,
+  onScroll: onDepositScroll,
+} = depositList
+
+const {
+  visibleItems: withdrawVisibleItems,
+  hasMore: withdrawHasMore,
+  visibleCount: withdrawVisibleCount,
+  total: withdrawTotal,
+  onScroll: onWithdrawScroll,
+} = withdrawList
+
 async function fetchDepositRates() {
   depositRates.value = await rateApi.searchFinanceRateList({ type: 0 })
 }
@@ -216,9 +238,14 @@ onMounted(() => {
           </template>
 
           <NEmpty v-if="!depositRates.length" description="暂无存款汇率" class="rate-empty" />
-          <div v-else class="rate-list">
+          <div
+            v-else
+            ref="depositScrollRef"
+            class="rate-list"
+            @scroll="onDepositScroll"
+          >
             <div
-              v-for="item in depositRates"
+              v-for="item in depositVisibleItems"
               :key="rowKey(item)"
               class="rate-row"
             >
@@ -252,6 +279,14 @@ onMounted(() => {
                 <NInput :value="calcDepositMoney(item.money)" readonly placeholder="×1.02" />
               </NInputGroup>
             </div>
+            <div
+              v-if="depositHasMore"
+              :ref="(el) => { depositList.sentinelRef.value = el as HTMLElement | null }"
+              class="rate-list__sentinel"
+            />
+            <div v-if="depositRates.length" class="rate-list__footer">
+              已显示 {{ depositVisibleCount }} / {{ depositTotal }} 条
+            </div>
           </div>
         </NCard>
 
@@ -289,9 +324,14 @@ onMounted(() => {
           </template>
 
           <NEmpty v-if="!withdrawRates.length" description="暂无取款汇率" class="rate-empty" />
-          <div v-else class="rate-list">
+          <div
+            v-else
+            ref="withdrawScrollRef"
+            class="rate-list"
+            @scroll="onWithdrawScroll"
+          >
             <div
-              v-for="item in withdrawRates"
+              v-for="item in withdrawVisibleItems"
               :key="rowKey(item)"
               class="rate-row"
             >
@@ -324,6 +364,14 @@ onMounted(() => {
                 <NInput :value="displayValue(item.money)" readonly placeholder="实时汇率" />
               </NInputGroup>
             </div>
+            <div
+              v-if="withdrawHasMore"
+              :ref="(el) => { withdrawList.sentinelRef.value = el as HTMLElement | null }"
+              class="rate-list__sentinel"
+            />
+            <div v-if="withdrawRates.length" class="rate-list__footer">
+              已显示 {{ withdrawVisibleCount }} / {{ withdrawTotal }} 条
+            </div>
           </div>
         </NCard>
       </div>
@@ -359,20 +407,42 @@ onMounted(() => {
 
 <style scoped>
 .page--settings {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+}
+
+.page--settings :deep(.n-spin-container) {
+  flex: 1;
+  min-height: 0;
   display: flex;
   flex-direction: column;
-  gap: 16px;
 }
 
 .rate-grid {
   display: grid;
   grid-template-columns: repeat(2, minmax(0, 1fr));
   gap: 16px;
+  flex: 1;
+  min-height: 0;
+  align-items: stretch;
 }
 
 .rate-panel {
   border-radius: 12px;
   padding: 4px;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+}
+
+.rate-panel :deep(.n-card__content) {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
 }
 
 .panel-actions {
@@ -382,15 +452,21 @@ onMounted(() => {
 }
 
 .rate-list {
+  flex: 1;
+  min-height: 0;
+  max-height: calc(100vh - 220px);
   display: flex;
   flex-direction: column;
   gap: 12px;
+  overflow-y: auto;
+  padding-right: 4px;
 }
 
 .rate-row {
   display: flex;
   flex-direction: column;
   gap: 8px;
+  flex-shrink: 0;
 }
 
 .rate-row__main,
@@ -412,6 +488,19 @@ onMounted(() => {
   padding: 24px 0;
 }
 
+.rate-list__sentinel {
+  height: 1px;
+  flex-shrink: 0;
+}
+
+.rate-list__footer {
+  flex-shrink: 0;
+  padding: 4px 0 8px;
+  text-align: center;
+  font-size: 12px;
+  color: var(--admin-text-muted);
+}
+
 .modal-footer {
   display: flex;
   justify-content: flex-end;
@@ -422,5 +511,9 @@ onMounted(() => {
   .rate-grid {
     grid-template-columns: 1fr;
   }
+
+  .rate-list {
+    max-height: calc(50vh - 80px);
+  }
 }
 </style>

+ 307 - 0
src/views/settings/RewardAmountView.vue

@@ -0,0 +1,307 @@
+<script setup lang="ts">
+import type { DataTableColumns, FormInst, FormRules } from 'naive-ui'
+import {
+  NButton,
+  NDataTable,
+  NForm,
+  NFormItem,
+  NFormItemGi,
+  NInput,
+  NInputNumber,
+  NModal,
+  NPagination,
+  NSpace,
+  useDialog,
+  useMessage,
+} from 'naive-ui'
+import AdminSearchPanel from '@/components/AdminSearchPanel.vue'
+import AdminTablePageBar from '@/components/AdminTablePageBar.vue'
+import { useConfirmRowDelete, useTableColumnsControl } from '@/composables'
+import type { RewardAmountItem, RewardAmountSearchParams } from '@/api/modules/settings/rewardAmount'
+import * as rewardAmountApi from '@/api/modules/settings/rewardAmount'
+
+const message = useMessage()
+const dialog = useDialog()
+
+const loading = ref(false)
+const list = ref<RewardAmountItem[]>([])
+const total = ref(0)
+const page = ref(1)
+const pageSize = ref(10)
+
+const search = ref<{ email: string; amount: string }>({
+  email: '',
+  amount: '',
+})
+
+function amountText(v: unknown) {
+  const n = Number(v ?? 0)
+  if (Number.isNaN(n)) return '0.00'
+  return n.toLocaleString(undefined, {
+    minimumFractionDigits: 2,
+    maximumFractionDigits: 2,
+  })
+}
+
+function buildSearchPayload(): RewardAmountSearchParams {
+  const amountRaw = search.value.amount.trim()
+  let amount: number | string | undefined
+  if (amountRaw) {
+    const n = Number(amountRaw)
+    amount = Number.isNaN(n) ? amountRaw : n
+  }
+  return {
+    email: search.value.email.trim() || undefined,
+    amount,
+    page: { current: page.value, row: pageSize.value },
+  }
+}
+
+async function fetchList() {
+  loading.value = true
+  try {
+    const { list: rows, total: count } = await rewardAmountApi.searchRewardAmountList(
+      buildSearchPayload(),
+    )
+    list.value = rows
+    total.value = count
+  } finally {
+    loading.value = false
+  }
+}
+
+function onSearch() {
+  page.value = 1
+  void fetchList()
+}
+
+function resetSearch() {
+  search.value = { email: '', amount: '' }
+  page.value = 1
+  void fetchList()
+}
+
+const { confirmDelete } = useConfirmRowDelete<RewardAmountItem>({
+  dialog,
+  message,
+  title: '删除赠金额度',
+  content: '确定删除该条赠金额度吗?',
+  deleteRow: (row) => rewardAmountApi.deleteRewardAmount({ ids: [row.id] }),
+  onAfterDelete: () => fetchList(),
+})
+
+const showModal = ref(false)
+const submitting = ref(false)
+const editingRow = ref<RewardAmountItem | null>(null)
+const formRef = ref<FormInst | null>(null)
+const form = ref<{ email: string; amount: number | null }>({
+  email: '',
+  amount: null,
+})
+
+const rules: FormRules = {
+  email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }],
+  amount: [
+    {
+      required: true,
+      type: 'number',
+      message: '请输入金额',
+      trigger: ['blur', 'change'],
+    },
+  ],
+}
+
+function openAdd() {
+  editingRow.value = null
+  form.value = { email: '', amount: null }
+  showModal.value = true
+}
+
+function openEdit(row: RewardAmountItem) {
+  editingRow.value = row
+  form.value = {
+    email: row.email ?? '',
+    amount: Number(row.amount ?? 0),
+  }
+  showModal.value = true
+}
+
+async function submit() {
+  await formRef.value?.validate()
+  if (form.value.amount == null) {
+    message.warning('请输入金额')
+    return
+  }
+  submitting.value = true
+  try {
+    const payload = {
+      email: form.value.email.trim(),
+      amount: form.value.amount,
+    }
+    if (editingRow.value) {
+      await rewardAmountApi.updateRewardAmount(payload)
+      message.success('已更新')
+    } else {
+      await rewardAmountApi.addRewardAmount(payload)
+      message.success('已添加')
+    }
+    showModal.value = false
+    await fetchList()
+  } finally {
+    submitting.value = false
+  }
+}
+
+const allColumns = ref<DataTableColumns<RewardAmountItem>>([
+  {
+    title: '邮箱',
+    key: 'email',
+    ellipsis: { tooltip: true },
+    // minWidth: 220,
+  },
+  {
+    title: '金额',
+    key: 'amount',
+    // width: 140,
+    render: (row) => amountText(row.amount),
+  },
+  {
+    title: '操作',
+    key: 'actions',
+    width: 140,
+    fixed: 'right',
+    render(row) {
+      return h('div', { style: 'display:flex;gap:8px' }, [
+        h(
+          NButton,
+          {
+            size: 'small',
+            quaternary: true,
+            type: 'info',
+            onClick: () => openEdit(row),
+          },
+          { default: () => '编辑' },
+        ),
+        h(
+          NButton,
+          {
+            size: 'small',
+            quaternary: true,
+            type: 'error',
+            onClick: () => confirmDelete(row),
+          },
+          { default: () => '删除' },
+        ),
+      ])
+    },
+  },
+])
+
+const { visibleKeys, displayColumns, columnOptions } = useTableColumnsControl(allColumns)
+
+watch([page, pageSize], () => {
+  void fetchList()
+})
+
+onMounted(() => {
+  void fetchList()
+})
+
+onActivated(() => {
+  void fetchList()
+})
+</script>
+
+<template>
+  <div class="page page--table">
+    <AdminSearchPanel :field-count="2" @search="onSearch" @reset="resetSearch">
+      <NFormItemGi :span="1" label="邮箱">
+        <NInput
+          v-model:value="search.email"
+          clearable
+          placeholder="邮箱"
+          @keyup.enter="onSearch"
+        />
+      </NFormItemGi>
+      <NFormItemGi :span="1" label="金额">
+        <NInput
+          v-model:value="search.amount"
+          clearable
+          placeholder="金额"
+          @keyup.enter="onSearch"
+        />
+      </NFormItemGi>
+    </AdminSearchPanel>
+
+    <NCard :bordered="false" class="table-card table-card--fill">
+      <div class="table-card-inner">
+        <AdminTablePageBar
+          title="赠金额度设置"
+          v-model:visible-keys="visibleKeys"
+          :column-options="columnOptions"
+          :refresh-loading="loading"
+          @refresh="fetchList"
+        >
+          <NButton @click="openAdd">新建</NButton>
+        </AdminTablePageBar>
+
+        <div class="table-card__body">
+          <NDataTable
+            :columns="displayColumns"
+            :data="list"
+            :loading="loading"
+            :bordered="false"
+            :single-line="false"
+            flex-height
+            class="data-table-fill"
+            :row-key="(row: RewardAmountItem) => String(row.id)"
+            :scroll-x="520"
+          />
+        </div>
+
+        <div class="pager-wrap">
+          <div class="pager-inline">
+            <span class="pager-total">共 {{ total }} 条</span>
+            <NPagination
+              v-model:page="page"
+              v-model:page-size="pageSize"
+              :item-count="total"
+              :page-sizes="[10, 20, 50]"
+              show-size-picker
+            />
+          </div>
+        </div>
+      </div>
+    </NCard>
+
+    <NModal
+      v-model:show="showModal"
+      preset="card"
+      :title="editingRow ? '编辑赠金额度' : '新建赠金额度'"
+      style="width: min(480px, 92vw)"
+      :mask-closable="false"
+      @after-leave="() => { formRef?.restoreValidation(); editingRow = null }"
+    >
+      <NForm ref="formRef" :model="form" :rules="rules" label-placement="top">
+        <NFormItem label="邮箱" path="email">
+          <NInput v-model:value="form.email" placeholder="请输入邮箱" />
+        </NFormItem>
+        <NFormItem label="金额" path="amount">
+          <NInputNumber
+            v-model:value="form.amount"
+            :min="0"
+            :precision="2"
+            placeholder="请输入金额"
+            style="width: 100%"
+          />
+        </NFormItem>
+      </NForm>
+      <template #footer>
+        <NSpace justify="end">
+          <NButton @click="showModal = false">取消</NButton>
+          <NButton type="primary" :loading="submitting" @click="submit">保存</NButton>
+        </NSpace>
+      </template>
+    </NModal>
+  </div>
+</template>