shinnosuke
2026-08-03 e2b3605106c81ec21a45dfcd6f163ed174d295c0
fix: resolve admin merchant management defects
10个文件已修改
318 ■■■■■ 已修改文件
talent-card-admin/src/layouts/AdminLayout.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/style.css 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/LoginView.vue 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantCreateView.vue 170 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantSettlementView.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/OrderView.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/ProductView.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/RefundRecordView.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantCreateRequest.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/layouts/AdminLayout.vue
@@ -12,6 +12,7 @@
  SwitchButton,
  Tickets,
  User,
  Wallet,
} from '@element-plus/icons-vue'
import { logoutAdmin } from '../api/auth'
import { useAppStore } from '../stores/app'
@@ -78,6 +79,9 @@
const merchantMenus: MenuItem[] = [
  { path: '/merchants', label: '商家查询' },
  { path: '/products', label: '团单审核' },
]
const financeMenus: MenuItem[] = [
  { path: '/merchant-settlement', label: '商户结算审核' },
]
@@ -121,6 +125,7 @@
  { index: 'merchant-management', label: '商家管理', icon: OfficeBuilding, items: merchantMenus },
  { index: 'order-management', label: '订单管理', icon: Tickets, items: orderMenus },
  { index: 'reservation-management', label: '预约管理', icon: Calendar, items: reservationMenus },
  { index: 'finance-management', label: '财务管理', icon: Wallet, items: financeMenus },
  { index: 'operation-management', label: '运营管理', icon: Operation, items: operationMenus },
  { index: 'system-management', label: '系统管理', icon: Setting, items: systemMenus },
]
talent-card-admin/src/style.css
@@ -542,6 +542,15 @@
  align-items: start;
}
.merchant-create-content.service-merchant-content {
  grid-template-columns: minmax(470px, 1.15fr) minmax(320px, 0.88fr);
  justify-content: start;
}
.service-merchant-content .merchant-hours-block {
  margin-top: 18px;
}
.merchant-form-row,
.merchant-cover-row,
.merchant-hours-block {
@@ -621,7 +630,7 @@
  align-items: center;
}
.merchant-hour-row .el-input {
.merchant-hour-row .el-date-editor {
  width: 210px;
}
@@ -1154,6 +1163,19 @@
  margin: 46px 34px 0 0;
}
.merchant-coupons-filter .el-input__wrapper,
.merchant-coupons-filter .el-select__wrapper,
.merchant-coupons-filter .el-range-editor.el-input__wrapper,
.merchant-coupon-edit-form .el-input__wrapper,
.merchant-coupon-edit-form .el-select__wrapper,
.merchant-coupon-edit-form .el-range-editor.el-input__wrapper {
  background: #ffffff;
}
.merchant-coupons-filter .coupon-created-time-picker {
  width: 280px;
}
.merchant-product-form-panel {
  margin-bottom: 18px;
  padding: 20px 18px 26px;
@@ -1389,6 +1411,10 @@
  margin-right: 8px;
}
.product-right-row .right-limit-field .el-input-number {
  width: 100%;
}
.product-right-row .right-unit {
  flex: 0 0 22px;
}
@@ -1399,7 +1425,7 @@
}
.product-right-row .right-reset-select {
  flex: 0 0 64px;
  flex: 0 0 96px;
  margin-right: 12px;
}
@@ -1429,6 +1455,10 @@
}
.merchant-product-form-panel .product-right-sub .el-input {
  width: 100px;
}
.merchant-product-form-panel .product-right-sub .right-exceed-discount-input {
  width: 100px;
}
@@ -1505,12 +1535,12 @@
.merchant-coupon-edit-form .el-date-editor.el-input__wrapper {
  min-height: 28px;
  border-radius: 0;
  background: #f7f8fb;
  background: #ffffff;
  box-shadow: none;
}
.merchant-coupon-edit-form .el-date-editor.el-input__wrapper {
  width: 200px;
  width: 280px;
}
.merchant-coupon-edit-form .el-date-editor .el-range-input,
talent-card-admin/src/views/LoginView.vue
@@ -5,11 +5,26 @@
import { loginAdmin } from '../api/auth'
import { useAppStore } from '../stores/app'
const rememberedCredentialsKey = 'talent-card-admin-remembered-credentials'
function rememberedCredentials() {
  try {
    return JSON.parse(localStorage.getItem(rememberedCredentialsKey) || '{}') as { account?: string; password?: string }
  } catch {
    return {}
  }
}
const router = useRouter()
const route = useRoute()
const appStore = useAppStore()
const submitting = ref(false)
const form = reactive({ account: '', password: '', remember: true })
const storedCredentials = rememberedCredentials()
const form = reactive({
  account: storedCredentials.account || '',
  password: storedCredentials.password || '',
  remember: Boolean(storedCredentials.account && storedCredentials.password),
})
const errors = reactive({ account: '', password: '' })
function clearError(field: 'account' | 'password') {
@@ -24,6 +39,11 @@
  submitting.value = true
  try {
    const result = await loginAdmin(account, form.password)
    if (form.remember) {
      localStorage.setItem(rememberedCredentialsKey, JSON.stringify({ account, password: form.password }))
    } else {
      localStorage.removeItem(rememberedCredentialsKey)
    }
    appStore.login(
      result.accessToken,
      { name: result.operator.name, role: result.operator.roleCode === 'platform_admin' ? '平台管理' : result.operator.roleCode },
talent-card-admin/src/views/MerchantCreateView.vue
@@ -27,7 +27,7 @@
interface BusinessHour {
  id: number
  value: string
  value: string[]
}
interface MerchantRightConfig {
@@ -58,10 +58,16 @@
const couponMode = ref<'list' | 'edit'>('list')
const isEditMode = computed(() => route.query.mode === 'edit')
const isFoodMerchant = computed(() => form.merchantType === '餐饮服务')
const isServiceMerchant = computed(() => isFoodMerchant.value || form.merchantType === '景区服务')
function queryString(key: string, fallback = '') {
  const value = route.query[key]
  return typeof value === 'string' && value ? value : fallback
}
function defaultMerchantRecommend() {
  const merchantType = queryString('merchantType')
  return merchantType === '餐饮服务' || merchantType === '景区服务' ? 1 : 0
}
const tabs = [
@@ -86,8 +92,8 @@
  usageInstructions: '',
  rightsText: '',
  serviceLabel: '',
  recommend: 0,
  sortNo: 0,
  recommend: defaultMerchantRecommend(),
  sortNo: undefined,
  status: 1,
})
@@ -159,6 +165,8 @@
  availableTime: '',
  otherRule: '',
})
const productValidPeriodRange = ref<string[]>([])
const productAvailableTimeRange = ref<string[]>([])
const productRights = reactive([
  {
@@ -170,6 +178,7 @@
    exceedDiscount: '',
    resetCycle: '每天',
    subsidy: true,
    exceedSubsidy: true,
  },
  {
    level: '杰出人才卡',
@@ -180,6 +189,7 @@
    exceedDiscount: '',
    resetCycle: '每天',
    subsidy: true,
    exceedSubsidy: true,
  },
  {
    level: '菁英人才卡',
@@ -190,6 +200,7 @@
    exceedDiscount: '',
    resetCycle: '每天',
    subsidy: true,
    exceedSubsidy: true,
  },
])
@@ -204,6 +215,7 @@
      exceedDiscount: '',
      resetCycle: '每天',
      subsidy: true,
      exceedSubsidy: true,
    },
    {
      level: '杰出人才卡',
@@ -214,6 +226,7 @@
      exceedDiscount: '',
      resetCycle: '每天',
      subsidy: true,
      exceedSubsidy: true,
    },
    {
      level: '菁英人才卡',
@@ -224,6 +237,7 @@
      exceedDiscount: '',
      resetCycle: '每天',
      subsidy: true,
      exceedSubsidy: true,
    },
  ]
}
@@ -262,7 +276,7 @@
const couponProducts = ref<Product[]>([])
const selectedCouponProductIds = ref<Array<number | string>>([])
const businessHours = reactive<BusinessHour[]>([{ id: 1, value: '' }])
const businessHours = reactive<BusinessHour[]>([{ id: 1, value: [] }])
function defaultMerchantRights(): MerchantRightConfig[] {
  return [
@@ -335,12 +349,19 @@
const mapMarkerIcon = L.divIcon({ className: 'merchant-map-marker', html: '<span></span>', iconSize: [26, 26], iconAnchor: [13, 26] })
function addBusinessHour() {
  businessHours.push({ id: Date.now(), value: '' })
  businessHours.push({ id: Date.now(), value: [] })
}
function removeBusinessHour(index: number) {
  if (businessHours.length <= 1) return
  businessHours.splice(index, 1)
}
function hasValidBusinessHours() {
  return businessHours.length > 0 && businessHours.every((item) => {
    const [startTime, endTime] = item.value
    return Boolean(startTime && endTime && startTime < endTime)
  })
}
function selectedCoordinates() {
@@ -549,6 +570,14 @@
  }
}
function rangeValues(value: string, pattern: RegExp) {
  return (value.match(pattern) || []).slice(0, 2)
}
function rangeText(value: string[]) {
  return value.length === 2 && value[0] && value[1] ? `${value[0]} 至 ${value[1]}` : ''
}
function resetProductRights(config?: Product['rightsConfig']) {
  const next = parseJsonValue(config, defaultProductRights())
  productRights.splice(0, productRights.length, ...defaultProductRights().map((item) => {
@@ -581,6 +610,8 @@
  productNoticeForm.useMethod = notice.useMethod || ''
  productNoticeForm.availableTime = notice.availableTime || ''
  productNoticeForm.otherRule = notice.otherRule || ''
  productValidPeriodRange.value = rangeValues(productNoticeForm.validPeriod, /\d{4}-\d{2}-\d{2}/g)
  productAvailableTimeRange.value = rangeValues(productNoticeForm.availableTime, /\d{2}:\d{2}/g)
  resetProductRights(row?.rightsConfig)
}
@@ -640,6 +671,20 @@
  productMode.value = 'list'
}
function validExceedDiscount(value: unknown) {
  const text = String(value ?? '').trim()
  if (!/^\d+(?:\.\d)?$/.test(text)) return false
  const amount = Number(text)
  return amount > 0 && amount <= 10
}
function validFreeLimitCount(value: unknown) {
  const text = String(value ?? '').trim()
  if (!/^\d+$/.test(text)) return false
  const count = Number(text)
  return count >= 1 && count <= 999
}
async function saveProduct() {
  if (!productForm.productName?.trim()) {
    ElMessage.warning('请输入商品名称')
@@ -661,6 +706,14 @@
    ElMessage.warning('请输入商品详情')
    return
  }
  if (productRights.some((item) => item.free && !validFreeLimitCount(item.limitCount))) {
    ElMessage.warning('免费次数请输入 1 至 999 的整数,999 表示不限次数')
    return
  }
  if (productRights.some((item) => item.free && !validExceedDiscount(item.exceedDiscount))) {
    ElMessage.warning('超过次数折扣请输入大于 0 且不超过 10 的一位小数')
    return
  }
  productSaving.value = true
  try {
    const payload = {
@@ -670,7 +723,11 @@
      originalPrice: productForm.originalPrice === undefined ? undefined : Number(productForm.originalPrice),
      stock: productForm.stock === undefined ? undefined : Number(productForm.stock),
      sortNo: productForm.sortNo === undefined ? 0 : Number(productForm.sortNo),
      purchaseNotice: { ...productNoticeForm },
      purchaseNotice: {
        ...productNoticeForm,
        validPeriod: rangeText(productValidPeriodRange.value),
        availableTime: rangeText(productAvailableTimeRange.value),
      },
      productImages: [...productImages.value],
      rightsConfig: productRights.map((item) => ({ ...item })),
    }
@@ -893,6 +950,14 @@
    ElMessage.warning('请输入使用门槛')
    return
  }
  if (!couponEditForm.validTime || couponEditForm.validTime.length !== 2 || !couponEditForm.validTime[0] || !couponEditForm.validTime[1]) {
    ElMessage.warning('请选择优惠券有效期')
    return
  }
  if (couponEditForm.productScope === 'selected' && selectedCouponProductIds.value.length === 0) {
    ElMessage.warning('请至少选择一个可用商品')
    return
  }
  couponSaving.value = true
  try {
    const payload = couponPayload()
@@ -918,7 +983,27 @@
    ElMessage.warning('请输入商家名称')
    return
  }
    if (!isEditMode.value && isFoodMerchant.value && Number(form.canLoginMerchantApp) === 1) {
    if (!form.contactMobile?.trim()) {
        ElMessage.warning('请输入联系电话')
        return
    }
    if (!form.address?.trim()) {
        ElMessage.warning('请输入商家地址')
        return
    }
    if (!form.coverUrl?.trim()) {
        ElMessage.warning('请上传商家封面图')
        return
    }
    if (!hasValidBusinessHours()) {
        ElMessage.warning('请选择有效的营业时间')
        return
    }
    if (form.sortNo === undefined || form.sortNo === null || String(form.sortNo).trim() === '') {
        ElMessage.warning('请输入排序')
        return
    }
    if (!isEditMode.value && isServiceMerchant.value && Number(form.canLoginMerchantApp) === 1) {
        if (!/^[A-Za-z0-9]{1,32}$/.test(accountForm.loginAccount)) {
            ElMessage.warning('登录账号仅支持英文和数字,长度不超过32位')
            return
@@ -984,6 +1069,12 @@
watch(() => form.coverUrl, resetMerchantCoverPreview)
watch(() => productForm.coverUrl, resetProductCoverPreview)
watch(productValidPeriodRange, (value) => {
  productNoticeForm.validPeriod = rangeText(value)
})
watch(productAvailableTimeRange, (value) => {
  productNoticeForm.availableTime = rangeText(value)
})
onMounted(() => {
    if (isEditMode.value && queryString('merchantId')) {
@@ -1027,7 +1118,7 @@
      <section class="merchant-create-panel">
        <h3>商家信息</h3>
        <div class="merchant-create-content">
        <div class="merchant-create-content" :class="{ 'service-merchant-content': isServiceMerchant }">
          <div class="merchant-create-left">
            <div class="merchant-form-row three-columns">
              <label>商家名称</label>
@@ -1055,13 +1146,13 @@
            </div>
          </div>
          <div class="merchant-create-middle">
          <div v-if="!isServiceMerchant" class="merchant-create-middle">
            <div class="merchant-form-row">
              <label>联系电话</label>
              <el-input v-model="form.contactMobile" />
            </div>
            <template v-if="!isFoodMerchant">
            <template>
                <div class="merchant-form-row recommend-row">
                    <label>开启团购</label>
                    <el-switch v-model="form.groupBuyingEnabled" :active-value="1" :inactive-value="0" />
@@ -1081,7 +1172,7 @@
          </div>
          <div class="merchant-create-right">
            <div class="merchant-form-row">
            <div v-if="isEditMode" class="merchant-form-row">
              <label>创建时间</label>
              <el-input v-model="form.createdAt" readonly />
            </div>
@@ -1090,7 +1181,16 @@
              <label>营业时间</label>
              <div class="merchant-hours-list">
                <div v-for="(item, index) in businessHours" :key="item.id" class="merchant-hour-row">
                  <el-input v-model="item.value" />
                  <el-time-picker
                    v-model="item.value"
                    is-range
                    arrow-control
                    format="HH:mm"
                    value-format="HH:mm"
                    range-separator="至"
                    start-placeholder="开始时间"
                    end-placeholder="结束时间"
                  />
                  <button class="merchant-hour-action" type="button" @click="removeBusinessHour(index)">
                    <el-icon><Minus /></el-icon>
                  </button>
@@ -1128,7 +1228,7 @@
      <template #footer><el-button @click="locationDialogVisible = false">取消</el-button><el-button type="primary" @click="saveLocation">确认</el-button></template>
    </el-dialog>
        <section class="merchant-create-panel">
        <section v-if="!isServiceMerchant" class="merchant-create-panel">
            <h3>小程序详情配置</h3>
            <div class="merchant-form-row">
                <label>使用说明</label>
@@ -1143,7 +1243,7 @@
      <section class="merchant-account-panel">
        <h3>账户设置</h3>
        <el-form class="merchant-account-form" :model="accountForm" label-width="72px" label-position="left">
          <el-form-item v-if="isFoodMerchant" label="登录商户端" label-width="100px">
          <el-form-item v-if="isServiceMerchant" label="登录商户端" label-width="100px">
            <el-switch v-model="form.canLoginMerchantApp" :active-value="1" :inactive-value="0" />
          </el-form-item>
          <el-form-item label="登录账号" :error="accountTaken ? '登录账号已存在,请更换' : ''">
@@ -1438,7 +1538,14 @@
          <div class="product-notice-grid">
            <div class="product-notice-field">
              <label>有效期</label>
              <el-input v-model="productNoticeForm.validPeriod" placeholder="请输入" />
              <el-date-picker
                v-model="productValidPeriodRange"
                type="daterange"
                start-placeholder="开始日期"
                end-placeholder="结束日期"
                range-separator="至"
                value-format="YYYY-MM-DD"
              />
            </div>
            <div class="product-notice-field">
              <label>使用方式</label>
@@ -1446,7 +1553,15 @@
            </div>
            <div class="product-notice-field">
              <label>可用时间</label>
              <el-input v-model="productNoticeForm.availableTime" placeholder="请输入" />
              <el-time-picker
                v-model="productAvailableTimeRange"
                is-range
                start-placeholder="开始时间"
                end-placeholder="结束时间"
                range-separator="至"
                value-format="HH:mm"
                format="HH:mm"
              />
            </div>
            <div class="product-notice-field">
              <label>其他规则</label>
@@ -1467,7 +1582,14 @@
                <template v-if="item.free">
                  <div class="right-limit-field">
                    <span class="product-right-tip">设置成999不限次数</span>
                    <el-input v-model="item.limitCount" placeholder="请输入" />
                    <el-input-number
                      v-model="item.limitCount"
                      :min="1"
                      :max="999"
                      :precision="0"
                      :controls="false"
                      placeholder="请输入"
                    />
                  </div>
                  <span class="right-unit">次</span>
                  <span class="right-reset-label">重置周期</span>
@@ -1488,10 +1610,19 @@
              </div>
              <div v-if="item.free" class="product-right-sub">
                <span>超过次数</span>
                <el-input v-model="item.exceedDiscount" placeholder="请输入" />
                <el-input-number
                  v-model="item.exceedDiscount"
                  class="right-exceed-discount-input"
                  :min="0.1"
                  :max="10"
                  :step="0.1"
                  :precision="1"
                  :controls="false"
                  placeholder="请输入"
                />
                <span>折</span>
                <span>是否政府补贴</span>
                <el-switch v-model="item.subsidy" />
                <el-switch v-model="item.exceedSubsidy" />
              </div>
            </div>
          </div>
@@ -1523,6 +1654,7 @@
            <label>创建时间</label>
            <el-date-picker
              v-model="couponFilters.createdTime"
              class="coupon-created-time-picker"
              type="daterange"
              start-placeholder="开始日期"
              end-placeholder="结束日期"
talent-card-admin/src/views/MerchantSettlementView.vue
@@ -1,11 +1,18 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getMerchantSettlement, reviewMerchantSettlement } from '../api/admin'
type Kind = 'onboardings' | 'bank-cards' | 'withdrawals'
const kind = ref<Kind>('onboardings'), loading = ref(false), rows = ref<any[]>([])
const labels: Record<string,string> = { pending: '待审核', approved: '已通过', rejected: '已驳回', processing: '取现处理中', paid: '已到账', failed: '失败' }
const filters = reactive({ merchantName: '', applicationNo: '', status: '' })
const filteredRows = computed(() => rows.value.filter((row) => {
  const merchantMatched = !filters.merchantName || String(row.merchantName || '').includes(filters.merchantName.trim())
  const applicationMatched = !filters.applicationNo || String(row.applicationNo || '').includes(filters.applicationNo.trim())
  return merchantMatched && applicationMatched && (!filters.status || row.status === filters.status)
}))
const load = async () => { loading.value = true; try { rows.value = (await getMerchantSettlement(kind.value)).records || [] } finally { loading.value = false } }
const resetFilters = () => { filters.merchantName = ''; filters.applicationNo = ''; filters.status = '' }
const review = async (row: any, status: string) => {
  let reason = '', providerValue = ''
  if (status === 'approved') { const isOnboarding = kind.value === 'onboardings'; const result = await ElMessageBox.prompt(isOnboarding ? '请输入汇付进件成功后返回的商户 huifu_id' : '请输入汇付绑卡成功后返回的取现卡 token_no', isOnboarding ? '配置汇付商户号' : '配置汇付取现卡序列号', { inputPattern: /^[A-Za-z0-9]{1,32}$/, inputErrorMessage: '请输入有效的汇付标识' }); providerValue = result.value }
@@ -15,5 +22,5 @@
}
onMounted(load)
</script>
<template><div class="page-stack"><el-card><div class="toolbar"><el-radio-group v-model="kind" @change="load"><el-radio-button label="onboardings">商户进件</el-radio-button><el-radio-button label="bank-cards">银行卡审核</el-radio-button><el-radio-button label="withdrawals">提现审核</el-radio-button></el-radio-group><el-button @click="load">刷新</el-button></div><el-table v-loading="loading" :data="rows" border><el-table-column prop="merchantName" label="商户"/><el-table-column prop="storeName" label="门店"/><el-table-column prop="applicationNo" label="申请单号"/><el-table-column prop="bankName" label="银行"/><el-table-column label="金额"><template #default="{row}">{{row.amountCents == null ? '-' : '¥' + (Number(row.amountCents)/100).toFixed(2)}}</template></el-table-column><el-table-column label="状态"><template #default="{row}">{{labels[row.status] || row.status}}</template></el-table-column><el-table-column label="操作" width="180"><template #default="{row}"><template v-if="row.status==='pending'"><el-button size="small" type="primary" @click="review(row,kind==='withdrawals'?'paid':'approved')">{{kind==='withdrawals'?'发起取现':'通过'}}</el-button><el-button size="small" type="danger" @click="review(row,kind==='withdrawals'?'failed':'rejected')">驳回</el-button></template></template></el-table-column></el-table></el-card></div></template>
<style scoped>.toolbar{display:flex;justify-content:space-between;margin-bottom:18px}</style>
<template><div class="page-stack"><el-card><div class="toolbar"><el-radio-group v-model="kind" @change="load"><el-radio-button label="onboardings">商户进件</el-radio-button><el-radio-button label="bank-cards">银行卡审核</el-radio-button><el-radio-button label="withdrawals">提现审核</el-radio-button></el-radio-group><el-button @click="load">刷新</el-button></div><el-form class="settlement-filters" inline><el-form-item label="商户"><el-input v-model="filters.merchantName" clearable placeholder="请输入商户名称" /></el-form-item><el-form-item label="申请单号"><el-input v-model="filters.applicationNo" clearable placeholder="请输入申请单号" /></el-form-item><el-form-item label="状态"><el-select v-model="filters.status" clearable placeholder="全部"><el-option label="待审核" value="pending" /><el-option label="已通过" value="approved" /><el-option label="已驳回" value="rejected" /><el-option label="处理中" value="processing" /><el-option label="已到账" value="paid" /><el-option label="失败" value="failed" /></el-select></el-form-item><el-form-item><el-button @click="resetFilters">重置</el-button></el-form-item></el-form><el-table v-loading="loading" :data="filteredRows" border><el-table-column prop="merchantName" label="商户"/><el-table-column prop="storeName" label="门店"/><el-table-column prop="applicationNo" label="申请单号"/><el-table-column prop="bankName" label="银行"/><el-table-column label="金额"><template #default="{row}">{{row.amountCents == null ? '-' : '¥' + (Number(row.amountCents)/100).toFixed(2)}}</template></el-table-column><el-table-column label="状态"><template #default="{row}">{{labels[row.status] || row.status}}</template></el-table-column><el-table-column label="操作" width="180"><template #default="{row}"><template v-if="row.status==='pending'"><el-button size="small" type="primary" @click="review(row,kind==='withdrawals'?'paid':'approved')">{{kind==='withdrawals'?'发起取现':'通过'}}</el-button><el-button size="small" type="danger" @click="review(row,kind==='withdrawals'?'failed':'rejected')">驳回</el-button></template></template></el-table-column></el-table></el-card></div></template>
<style scoped>.toolbar{display:flex;justify-content:space-between;margin-bottom:18px}.settlement-filters{padding:14px 0 4px}</style>
talent-card-admin/src/views/OrderView.vue
@@ -41,6 +41,10 @@
  return Number.isInteger(yuan) ? String(yuan) : yuan.toFixed(2)
}
function formatDateTime(value?: string) {
  return value ? value.replace('T', ' ') : ''
}
function orderStatusText(row: Order) {
  if (row.orderStatus === 0) return '待支付'
  if (row.orderStatus === 2) return '已退款'
@@ -114,7 +118,7 @@
    { title: '手机号', value: row => row.talentMobile }, { title: '人才等级', value: row => row.talentLevel },
    { title: '商家', value: row => row.merchantName }, { title: '商品/权益', value: row => row.productName || row.benefitName },
    { title: '订单金额(元)', value: row => moneyText(row.orderAmount) }, { title: '补贴金额(元)', value: row => moneyText(row.subsidyAmount) },
    { title: '下单时间', value: row => row.createdAt }, { title: '支付时间', value: row => row.payTime },
    { title: '下单时间', value: row => formatDateTime(row.createdAt) }, { title: '支付时间', value: row => formatDateTime(row.payTime) },
    { title: '订单状态', value: row => orderStatusText(row) },
  ], result.records)
  ElMessage.success(`已导出 ${result.records.length} 条订单`)
@@ -263,8 +267,8 @@
        <el-table-column label="政府补贴" width="82" align="center">
          <template #default="{ row }">{{ moneyText(row.subsidyAmount) }}</template>
        </el-table-column>
        <el-table-column prop="createdAt" label="下单时间" width="116" align="center" />
        <el-table-column prop="payTime" label="支付时间" width="116" align="center" />
        <el-table-column label="下单时间" width="116" align="center"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
        <el-table-column label="支付时间" width="116" align="center"><template #default="{ row }">{{ formatDateTime(row.payTime) }}</template></el-table-column>
        <el-table-column label="状态" width="86" align="center">
          <template #default="{ row }">{{ orderStatusText(row) }}</template>
        </el-table-column>
@@ -302,7 +306,7 @@
          <el-descriptions-item :label="amountLabel">{{ moneyText(activeOrder.orderAmount) }}</el-descriptions-item>
          <el-descriptions-item label="实际支付">{{ moneyText(activeOrder.payAmount) }}</el-descriptions-item>
          <el-descriptions-item label="政府补贴">{{ moneyText(activeOrder.subsidyAmount) }}</el-descriptions-item>
          <el-descriptions-item label="下单时间">{{ activeOrder.createdAt || '-' }}</el-descriptions-item>
          <el-descriptions-item label="下单时间">{{ formatDateTime(activeOrder.createdAt) || '-' }}</el-descriptions-item>
          <el-descriptions-item label="状态">{{ orderStatusText(activeOrder) }}</el-descriptions-item>
        </el-descriptions>
      </div>
talent-card-admin/src/views/ProductView.vue
@@ -55,6 +55,10 @@
  return Number(amount).toFixed(2).replace(/\.00$/, '')
}
function formatDateTime(value?: string) {
  return value ? value.replace('T', ' ') : ''
}
async function loadData() {
  loading.value = true
  try {
@@ -189,7 +193,7 @@
          <template #default="{ row }">{{ row.salesCount || '' }}</template>
        </el-table-column>
        <el-table-column label="提交修改时间" width="145" align="center">
          <template #default="{ row }">{{ row.submitTime || row.createdAt || '' }}</template>
          <template #default="{ row }">{{ formatDateTime(row.submitTime || row.createdAt) }}</template>
        </el-table-column>
        <el-table-column label="状态" width="120" align="center">
          <template #default="{ row }">{{ auditStatusText(row.auditStatus) }}</template>
talent-card-admin/src/views/RefundRecordView.vue
@@ -34,6 +34,10 @@
  return Number.isInteger(yuan) ? String(yuan) : yuan.toFixed(2)
}
function formatDateTime(value?: string) {
  return value ? value.replace('T', ' ') : ''
}
async function loadData() {
  loading.value = true
  try {
@@ -85,7 +89,7 @@
    { title: '持卡人', value: row => row.talentName }, { title: '手机号', value: row => row.talentMobile },
    { title: '人才等级', value: row => row.talentLevel }, { title: '商家', value: row => row.merchantName },
    { title: '已付金额(元)', value: row => moneyText(row.paidAmount) }, { title: '退款金额(元)', value: row => moneyText(row.refundAmount) },
    { title: '退款时间', value: row => row.refundTime }, { title: '操作人', value: row => row.operatorName },
    { title: '退款时间', value: row => formatDateTime(row.refundTime) }, { title: '操作人', value: row => row.operatorName },
  ], result.records)
  ElMessage.success(`已导出 ${result.records.length} 条退款记录`)
}
@@ -184,7 +188,7 @@
        <el-table-column label="剩余可退金额" width="82" align="center">
          <template #default="{ row }">{{ moneyText(row.refundableAmount) }}</template>
        </el-table-column>
        <el-table-column prop="refundTime" label="退款时间" width="116" align="center" />
        <el-table-column label="退款时间" width="116" align="center"><template #default="{ row }">{{ formatDateTime(row.refundTime) }}</template></el-table-column>
        <el-table-column prop="operatorName" label="操作人" width="104" align="center" />
        <el-table-column label="操作" width="104" fixed="right" align="center">
          <template #default="{ row }">
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java
@@ -1,6 +1,8 @@
package com.talentcard.server.business.admin;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
@@ -312,7 +314,7 @@
                insert into merchant(merchant_name, merchant_type, contact_name, contact_mobile, address,
                                         logo_url, cover_url, business_status, longitude, latitude, can_login_merchant_app, group_buying_enabled,
                                         usage_instructions, rights_text, merchant_rights_config, service_label, is_recommend, status, sort_no)
                values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0)
                values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
                """,
                request.merchantName(),
                request.merchantType(),
@@ -330,9 +332,10 @@
                request.rightsText(),
                toJson(request.merchantRightsConfig()),
                request.serviceLabel(),
                request.recommend() == null ? 0 : request.recommend());
                request.recommend() == null ? defaultRecommend(request.merchantType()) : request.recommend(),
                request.sortNo() == null ? 0 : request.sortNo());
        Long id = jdbcTemplate.queryForObject("select last_insert_id()", Long.class);
        if (isFoodMerchantWithAppAccess(request)) {
        if (hasMerchantAppAccess(request)) {
            String account = request.loginAccount().trim();
            if (!account.matches("^[A-Za-z0-9]{1,32}$")) {
                throw new BusinessException(ErrorCode.BAD_REQUEST, "商户登录账号只能包含英文字母和数字");
@@ -352,8 +355,13 @@
        return one("select * from merchant where id = ?", id);
    }
    private boolean isFoodMerchantWithAppAccess(MerchantCreateRequest request) {
        return "餐饮服务".equals(request.merchantType()) && (request.canLoginMerchantApp() == null || request.canLoginMerchantApp() == 1);
    private boolean hasMerchantAppAccess(MerchantCreateRequest request) {
        return ("餐饮服务".equals(request.merchantType()) || "景区服务".equals(request.merchantType()))
                && (request.canLoginMerchantApp() == null || request.canLoginMerchantApp() == 1);
    }
    private byte defaultRecommend(String merchantType) {
        return (byte) (("餐饮服务".equals(merchantType) || "景区服务".equals(merchantType)) ? 1 : 0);
    }
    @Transactional
@@ -696,6 +704,7 @@
    @Transactional
    public Map<String, Object> createCoupon(CouponCreateRequest request) {
        ensureMerchantExists(request.merchantId());
        validateCouponConfiguration(request.validStart(), request.validEnd(), request.productScope(), request.productIds());
        jdbcTemplate.update("""
                insert into merchant_coupon(merchant_id, coupon_name, condition_type, condition_amount,
                                            coupon_amount, talent_levels, valid_start, valid_end,
@@ -721,6 +730,7 @@
    @Transactional
    public Map<String, Object> updateCoupon(Long id, CouponUpdateRequest request) {
        ensureMerchantExists(request.merchantId());
        validateCouponConfiguration(request.validStart(), request.validEnd(), request.productScope(), request.productIds());
        int updated = jdbcTemplate.update("""
                update merchant_coupon
                set merchant_id = ?, coupon_name = ?, condition_type = ?, condition_amount = ?,
@@ -859,6 +869,24 @@
        return "ALL".equalsIgnoreCase(value) ? "ALL" : "SELECTED";
    }
    private void validateCouponConfiguration(String validStart, String validEnd, String productScope, List<Long> productIds) {
        if (validStart == null || validStart.isBlank() || validEnd == null || validEnd.isBlank()) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "优惠券有效期不能为空");
        }
        try {
            LocalDate start = LocalDate.parse(validStart);
            LocalDate end = LocalDate.parse(validEnd);
            if (start.isAfter(end)) {
                throw new BusinessException(ErrorCode.BAD_REQUEST, "优惠券结束日期不能早于开始日期");
            }
        } catch (DateTimeParseException exception) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "优惠券有效期格式错误");
        }
        if (!"ALL".equalsIgnoreCase(productScope) && (productIds == null || productIds.isEmpty())) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "请至少选择一个可用商品");
        }
    }
    private int defaultNewsStatus(Integer status) {
        return status == null ? 1 : status;
    }
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantCreateRequest.java
@@ -1,6 +1,7 @@
package com.talentcard.server.business.admin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
@@ -14,15 +15,20 @@
        String merchantType,
        @Size(max = 64)
        String contactName,
        @NotBlank(message = "请输入联系电话")
        @Size(max = 20)
        String contactMobile,
        @NotBlank(message = "请输入商家地址")
        @Size(max = 255)
        String address,
        String logoUrl,
        @NotBlank(message = "请上传商家封面图")
        String coverUrl,
        @Size(max = 32)
        String businessStatus,
        Byte recommend,
        @NotNull(message = "请输入排序")
        Integer sortNo,
        Double longitude,
        Double latitude,
        Byte groupBuyingEnabled,