2个文件已删除
3 文件已重命名
1个文件已添加
62个文件已修改
| | |
| | | "mp-weixin": { |
| | | "appid": "wxcfee477acb54d179", |
| | | "setting": { |
| | | "urlCheck": false |
| | | "urlCheck": false, |
| | | "sourceMap": false |
| | | }, |
| | | "usingComponents": true |
| | | }, |
| | |
| | | "mode": "" |
| | | } |
| | | } |
| | | } |
| | | } |
| | |
| | | const payAmount = ref(0) |
| | | const subsidyAmount = ref(0) |
| | | const createdAt = ref('') |
| | | const loadError = ref('') |
| | | const submitting = ref(false) |
| | | |
| | | const formatAmount = (amount: number): string => (amount / 100).toFixed(2) |
| | |
| | | |
| | | const loadOrder = () => { |
| | | if (orderNo.value.length == 0) return |
| | | loadMerchantOrderDetail(orderNo.value, (order: MerchantOrderDetail, _traceId: string) => applyOrder(order), () => {}) |
| | | loadMerchantOrderDetail(orderNo.value, (order: MerchantOrderDetail, _traceId: string) => { |
| | | loadError.value = '' |
| | | applyOrder(order) |
| | | }, (error: any) => { |
| | | loadError.value = error != null && error.message != null ? '' + error.message : '订单详情加载失败,请返回重新扫码' |
| | | uni.showToast({ title: loadError.value, icon: 'none' }) |
| | | }) |
| | | } |
| | | |
| | | const confirm = () => { |
| | | if (submitting.value || verificationId.value.length == 0) return |
| | | if (loadError.value.length > 0) { |
| | | uni.showToast({ title: '订单详情未加载完成,请返回重新扫码', icon: 'none' }) |
| | | return |
| | | } |
| | | uni.showModal({ |
| | | title: '确认核销', |
| | | content: '确认后将完成本次订单核销,是否继续?', |
| | |
| | | }, onError) |
| | | } |
| | | |
| | | const numberValue = (value: any): number => value == null ? 0 : Number(value) |
| | | const fieldValue = (value: any, camelCaseName: string, snakeCaseName: string): any => { |
| | | if (value == null) return null |
| | | if (value[camelCaseName] != null) return value[camelCaseName] |
| | | return value[snakeCaseName] != null ? value[snakeCaseName] : null |
| | | } |
| | | |
| | | const numberValue = (value: any): number => { |
| | | if (value == null || value === '') return 0 |
| | | const number = Number(value) |
| | | return isNaN(number) ? 0 : number |
| | | } |
| | | |
| | | const textValue = (value: any, camelCaseName: string, snakeCaseName: string, fallback: string = ''): string => { |
| | | const field = fieldValue(value, camelCaseName, snakeCaseName) |
| | | return field != null ? '' + field : fallback |
| | | } |
| | | |
| | | const toOrder = (value: any): MerchantOrder => ({ |
| | | id: value != null && value.id != null ? '' + value.id : '', |
| | | orderNo: value != null && value.orderNo != null ? '' + value.orderNo : '', |
| | | category: value != null && value.category != null ? '' + value.category : 'group', |
| | | status: value != null && value.status != null ? '' + value.status : 'pending_verification', |
| | | statusText: value != null && value.statusText != null ? '' + value.statusText : '待核销', |
| | | title: value != null && value.title != null ? '' + value.title : '订单', |
| | | customerName: value != null && value.customerName != null ? '' + value.customerName : '', |
| | | customerMobile: value != null && value.customerMobile != null ? '' + value.customerMobile : '', |
| | | cardLevel: value != null && value.cardLevel != null ? '' + value.cardLevel : '卓越人才卡', |
| | | orderAmount: numberValue(value != null ? value.orderAmount : 0), |
| | | basePayAmount: numberValue(value != null ? value.basePayAmount : 0), |
| | | couponAmount: numberValue(value != null ? value.couponAmount : 0), |
| | | talentDiscountAmount: numberValue(value != null ? value.talentDiscountAmount : 0), |
| | | payAmount: numberValue(value != null ? value.payAmount : 0), |
| | | subsidyAmount: numberValue(value != null ? value.subsidyAmount : 0), |
| | | createdAt: value != null && value.createdAt != null ? '' + value.createdAt : '', |
| | | verifyTime: value != null && value.verifyTime != null ? '' + value.verifyTime : '' |
| | | id: textValue(value, 'id', 'id'), |
| | | orderNo: textValue(value, 'orderNo', 'order_no'), |
| | | category: textValue(value, 'category', 'category', 'group'), |
| | | status: textValue(value, 'status', 'status', 'pending_verification'), |
| | | statusText: textValue(value, 'statusText', 'status_text', '待核销'), |
| | | title: textValue(value, 'title', 'title', '订单'), |
| | | customerName: textValue(value, 'customerName', 'customer_name'), |
| | | customerMobile: textValue(value, 'customerMobile', 'customer_mobile'), |
| | | cardLevel: textValue(value, 'cardLevel', 'card_level', '卓越人才卡'), |
| | | orderAmount: numberValue(fieldValue(value, 'orderAmount', 'order_amount')), |
| | | basePayAmount: numberValue(fieldValue(value, 'basePayAmount', 'base_pay_amount')), |
| | | couponAmount: numberValue(fieldValue(value, 'couponAmount', 'coupon_amount')), |
| | | talentDiscountAmount: numberValue(fieldValue(value, 'talentDiscountAmount', 'talent_discount_amount')), |
| | | payAmount: numberValue(fieldValue(value, 'payAmount', 'pay_amount')), |
| | | subsidyAmount: numberValue(fieldValue(value, 'subsidyAmount', 'subsidy_amount')), |
| | | createdAt: textValue(value, 'createdAt', 'created_at'), |
| | | verifyTime: textValue(value, 'verifyTime', 'verify_time') |
| | | }) |
| | | |
| | | const toOrderDetail = (value: any): MerchantOrderDetail => { |
| | |
| | | basePayAmount: order.basePayAmount != null ? order.basePayAmount : order.orderAmount, |
| | | couponAmount: order.couponAmount != null ? order.couponAmount : 0, |
| | | talentDiscountAmount: order.talentDiscountAmount != null ? order.talentDiscountAmount : 0, |
| | | benefitName: value != null && value.benefitName != null ? '' + value.benefitName : order.title, |
| | | merchantName: value != null && value.merchantName != null ? '' + value.merchantName : '', |
| | | canRevoke: value != null && value.canRevoke == true, |
| | | revokeDeadline: value != null && value.revokeDeadline != null ? '' + value.revokeDeadline : '', |
| | | revokeReason: value != null && value.revokeReason != null ? '' + value.revokeReason : '' |
| | | benefitName: textValue(value, 'benefitName', 'benefit_name', order.title), |
| | | merchantName: textValue(value, 'merchantName', 'merchant_name'), |
| | | canRevoke: fieldValue(value, 'canRevoke', 'can_revoke') == true, |
| | | revokeDeadline: textValue(value, 'revokeDeadline', 'revoke_deadline'), |
| | | revokeReason: textValue(value, 'revokeReason', 'revoke_reason') |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | export function getPlatformSettings() { return http.get<Record<string, string>, Record<string, string>>('/api/admin/management/settings') } |
| | | export interface AdminPendingNotice { |
| | | id: string |
| | | category: string |
| | | content: string |
| | | time: string |
| | | path: string |
| | | } |
| | | export function getAdminPendingNotices() { return http.get<AdminPendingNotice[], AdminPendingNotice[]>('/api/admin/business/pending-notices') } |
| | | export function savePlatformSettings(payload: Record<string, string>) { return http.put<Record<string, string>, Record<string, string>>('/api/admin/management/settings', payload) } |
| | | export function getProtocolSetting(key: string) { return http.get<Record<string, any>, Record<string, any>>(`/api/admin/management/protocols/${key}`) } |
| | | export function saveProtocolSetting(key: string, contentHtml: string) { return http.put<Record<string, any>, Record<string, any>>(`/api/admin/management/protocols/${key}`, { contentHtml }) } |
| | |
| | | export function updateAdminRoleStatus(id: number | string, status: number) { return http.patch<AdminRole, AdminRole>(`/api/admin/management/roles/${id}/status`, { status }) } |
| | | export function deleteAdminRole(id: number | string) { return http.delete<any, any>(`/api/admin/management/roles/${id}`) } |
| | | export function getAdminStaff() { return http.get<AdminStaff[], AdminStaff[]>('/api/admin/management/staff') } |
| | | export function checkAdminStaffAccount(account: string) { return http.get<{ available: boolean }, { available: boolean }>('/api/admin/management/staff/account-available', { params: { account } }) } |
| | | export function createAdminStaff(payload: Omit<AdminStaff, 'id' | 'createdAt'> & { password: string }) { return http.post<AdminStaff, AdminStaff>('/api/admin/management/staff', payload) } |
| | | export function updateAdminStaff(id: number | string, payload: Omit<AdminStaff, 'id' | 'createdAt'> & { password?: string }) { return http.put<AdminStaff, AdminStaff>(`/api/admin/management/staff/${id}`, payload) } |
| | | export function resetAdminStaffPassword(id: number | string, password: string) { return http.patch<any, any>(`/api/admin/management/staff/${id}/password`, { password }) } |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, ref } from 'vue' |
| | | import { computed, nextTick, onMounted, ref, watch } from 'vue' |
| | | import { useRoute, useRouter } from 'vue-router' |
| | | import { ElMessageBox } from 'element-plus' |
| | | import { |
| | |
| | | Wallet, |
| | | } from '@element-plus/icons-vue' |
| | | import { logoutAdmin } from '../api/auth' |
| | | import { getAdminPendingNotices } from '../api/admin' |
| | | import type { AdminPendingNotice } from '../api/admin' |
| | | import { useAppStore } from '../stores/app' |
| | | |
| | | interface MenuItem { |
| | |
| | | const appStore = useAppStore() |
| | | const avatarText = computed(() => (appStore.user.name || 'A').trim().slice(0, 1).toUpperCase()) |
| | | |
| | | interface HeaderNotice { |
| | | id: number |
| | | content: string |
| | | time: string |
| | | read: boolean |
| | | const notices = ref<AdminPendingNotice[]>([]) |
| | | const adminMenuRef = ref<{ open: (index: string) => void; close: (index: string) => void }>() |
| | | |
| | | function formatNoticeTime(value: string) { |
| | | return value ? value.replace('T', ' ').replace(/\.\d+$/, '') : '-' |
| | | } |
| | | |
| | | const notices = ref<HeaderNotice[]>([ |
| | | { |
| | | id: 1, |
| | | content: '上午好,欢迎登录攀枝花市东区人才服务平台。', |
| | | time: '2026/6/25 09:50:54', |
| | | read: false, |
| | | }, |
| | | { |
| | | id: 2, |
| | | content: '您有一条新的酒店预约,请及时查看。', |
| | | time: '2026/6/25 09:54:08', |
| | | read: false, |
| | | }, |
| | | ]) |
| | | |
| | | const unreadNoticeCount = computed(() => notices.value.filter((item) => !item.read).length) |
| | | |
| | | function markNoticeRead(notice: HeaderNotice) { |
| | | notice.read = true |
| | | async function loadNotices() { |
| | | try { |
| | | notices.value = await getAdminPendingNotices() |
| | | } catch { |
| | | notices.value = [] |
| | | } |
| | | } |
| | | |
| | | function markAllNoticesRead() { |
| | | notices.value.forEach((item) => { |
| | | item.read = true |
| | | }) |
| | | function openNotice(notice: AdminPendingNotice) { |
| | | router.push(notice.path) |
| | | } |
| | | |
| | | const cardMenus: MenuItem[] = [ |
| | |
| | | ] |
| | | |
| | | const financeMenus: MenuItem[] = [ |
| | | { path: '/merchant-settlement', label: '提现管理' }, |
| | | { path: '/merchant-settlement', label: '商户结算审核' }, |
| | | ] |
| | | |
| | | const orderMenus: MenuItem[] = [ |
| | |
| | | // Keep the navigation in sync with the permissions returned at login. Newer |
| | | // routes without a dedicated permission code intentionally remain visible. |
| | | const routePermissions: Record<string, string> = { |
| | | '/talents': 'admin.talents.view', '/family-card-applications': 'admin.family-cards.view', '/upgrade-applications': 'admin.talents.view', '/family-cards': 'admin.family-cards.view', '/child-cards': 'admin.family-cards.view', |
| | | '/merchants': 'admin.merchants.view', '/products': 'admin.products.view', '/orders': 'admin.orders.view', '/refund-records': 'admin.refunds.view', '/hotel-free-verifications': 'admin.orders.view', |
| | | '/medical-reservations': 'admin.medical-reservations.view', '/medical-applications': 'admin.medical-reservations.view', '/parking-plates': 'admin.parking-plates.view', '/fuel-services': 'admin.parking-plates.view', '/spouse-employment': 'admin.parking-plates.view', '/hotel-reservations': 'admin.medical-reservations.view', |
| | | '/merchant-settlement': 'admin.merchants.view', '/equity-images': 'admin.equity-images.view', '/talent-news-management': 'admin.news.view', '/talent-policy-management': 'admin.news.view', '/movie-tickets': 'admin.news.view', |
| | | '/system-settings': 'admin.settings.view', '/protocol-settings': 'admin.settings.view', '/roles': 'admin.roles.view', '/staff': 'admin.staff.view', |
| | | '/talents': 'admin.talents.view', '/family-card-applications': 'admin.family-card-applications.view', '/upgrade-applications': 'admin.upgrade-applications.view', '/family-cards': 'admin.family-cards.view', '/child-cards': 'admin.child-cards.view', |
| | | '/merchants': 'admin.merchants.view', '/products': 'admin.products.view', '/orders': 'admin.orders.view', '/refund-records': 'admin.refunds.view', '/hotel-free-verifications': 'admin.hotel-free-verifications.view', |
| | | '/appointment-management': 'admin.appointment-management.view', '/medical-reservations': 'admin.medical-reservations.view', '/medical-applications': 'admin.medical-applications.view', '/parking-plates': 'admin.parking-plates.view', '/car-wash-benefits': 'admin.car-wash-benefits.view', '/fuel-services': 'admin.fuel-services.view', '/spouse-employment': 'admin.spouse-employment.view', '/hotel-reservations': 'admin.hotel-reservations.view', |
| | | '/merchant-settlement': 'admin.merchant-settlement.view', '/equity-images': 'admin.equity-images.view', '/talent-news-management': 'admin.news.view', '/talent-policy-management': 'admin.policies.view', '/movie-tickets': 'admin.movie-tickets.view', |
| | | '/system-settings': 'admin.settings.view', '/protocol-settings': 'admin.protocol-settings.view', '/roles': 'admin.roles.view', '/staff': 'admin.staff.view', |
| | | } |
| | | const canAccess = (path?: string) => { |
| | | if (!path || appStore.user.role === '平台管理') return true |
| | |
| | | return route.path |
| | | }) |
| | | |
| | | function currentMenuGroupIndex() { |
| | | return menuGroups.find((group) => findMenuLabel(group.items, activeMenu.value))?.index |
| | | } |
| | | |
| | | function closeOtherMenuGroups(openedGroupIndex?: string) { |
| | | menuGroups.forEach((group) => { |
| | | if (group.index !== openedGroupIndex) adminMenuRef.value?.close(group.index) |
| | | }) |
| | | } |
| | | |
| | | function handleMenuOpen(_index: string, indexPath: string[]) { |
| | | // indexPath 的第一个值始终是一级模块;无论点击一级或嵌套菜单,均只保留该模块展开。 |
| | | const openedGroupIndex = indexPath[0] |
| | | if (menuGroups.some((group) => group.index === openedGroupIndex)) { |
| | | closeOtherMenuGroups(openedGroupIndex) |
| | | } |
| | | } |
| | | |
| | | watch(activeMenu, () => { |
| | | const groupIndex = currentMenuGroupIndex() |
| | | nextTick(() => { |
| | | closeOtherMenuGroups(groupIndex) |
| | | if (groupIndex) adminMenuRef.value?.open(groupIndex) |
| | | }) |
| | | }, { immediate: true }) |
| | | |
| | | function findMenuLabel(items: MenuItem[], path: string): string | undefined { |
| | | for (const item of items) { |
| | | if (item.path === path) return item.label |
| | |
| | | router.push('/login') |
| | | } |
| | | } |
| | | |
| | | onMounted(loadNotices) |
| | | </script> |
| | | |
| | | <template> |
| | |
| | | </div> |
| | | |
| | | <div class="header-right"> |
| | | <el-popover placement="bottom-end" :width="330" trigger="click" popper-class="notice-popover"> |
| | | <el-popover placement="bottom-end" :width="330" trigger="click" popper-class="notice-popover" @show="loadNotices"> |
| | | <template #reference> |
| | | <el-badge :is-dot="unreadNoticeCount > 0" class="notice-badge"> |
| | | <el-badge :is-dot="notices.length > 0" class="notice-badge"> |
| | | <el-button :icon="Bell" circle aria-label="消息通知" /> |
| | | </el-badge> |
| | | </template> |
| | | |
| | | <div class="notice-panel"> |
| | | <div class="notice-panel-header"> |
| | | <strong>通知公告</strong> |
| | | <button type="button" :disabled="unreadNoticeCount === 0" @click="markAllNoticesRead"> |
| | | 全部已读 |
| | | </button> |
| | | </div> |
| | | <div class="notice-panel"> |
| | | <div class="notice-panel-header"> |
| | | <strong>待办通知</strong> |
| | | </div> |
| | | <div v-if="notices.length" class="notice-list"> |
| | | <button |
| | | v-for="notice in notices" |
| | | :key="notice.id" |
| | | type="button" |
| | | class="notice-item" |
| | | @click="markNoticeRead(notice)" |
| | | @click="openNotice(notice)" |
| | | > |
| | | <span class="notice-content">{{ notice.content }}</span> |
| | | <span v-if="!notice.read" class="notice-unread">未读</span> |
| | | <time>{{ notice.time }}</time> |
| | | <time>{{ formatNoticeTime(notice.time) }}</time> |
| | | </button> |
| | | </div> |
| | | <div v-else class="notice-empty">暂无通知</div> |
| | |
| | | <el-container class="admin-body"> |
| | | <el-aside width="220px" class="admin-aside"> |
| | | <el-menu |
| | | ref="adminMenuRef" |
| | | router |
| | | :default-active="activeMenu" |
| | | :default-openeds="defaultOpeneds" |
| | | unique-opened |
| | | @open="handleMenuOpen" |
| | | class="admin-menu" |
| | | > |
| | | <el-menu-item index="/dashboard"> |
| | |
| | | path: 'merchant-settlement', |
| | | name: 'merchant-settlement', |
| | | component: () => import('../views/MerchantSettlementView.vue'), |
| | | meta: { title: '提现管理' }, |
| | | meta: { title: '商户结算审核' }, |
| | | }, |
| | | { |
| | | path: 'merchant-onboardings/:id', |
| | |
| | | .merchant-rights-detail-row { |
| | | display: grid; |
| | | grid-template-columns: 96px minmax(360px, 520px) 270px; |
| | | gap: 14px 24px; |
| | | gap: 6px 24px; |
| | | align-items: start; |
| | | padding: 14px 0 0; |
| | | } |
| | |
| | | |
| | | .merchant-rights-detail-row label { |
| | | grid-column: 1 / 3; |
| | | margin: 0; |
| | | color: #334155; |
| | | font-size: 13px; |
| | | line-height: 20px; |
| | | } |
| | | |
| | | .merchant-rights-level-row + .merchant-rights-detail-row { |
| | |
| | | align-items: start; |
| | | color: #111827; |
| | | font-size: 13px; |
| | | } |
| | | |
| | | /* 商家权益仅保留后台配置表单,不展示小程序示例预览。 */ |
| | | .merchant-rights-panel .merchant-rights-example { |
| | | display: none !important; |
| | | } |
| | | |
| | | .merchant-rights-phone-preview { |
| | |
| | | |
| | | .consume-desc-row { |
| | | padding-left: 186px; |
| | | padding-top: 14px; |
| | | padding-top: 10px; |
| | | } |
| | | |
| | | .merchant-products-panel, |
| | |
| | | .merchant-coupons-filter .el-select__wrapper { |
| | | min-height: 28px; |
| | | border-radius: 0; |
| | | background: #f7f8fb; |
| | | box-shadow: none; |
| | | background: #ffffff; |
| | | box-shadow: 0 0 0 1px #d9dee8 inset; |
| | | } |
| | | |
| | | .merchant-products-filter .el-date-editor.el-input__wrapper, |
| | | .merchant-coupons-filter .el-date-editor.el-input__wrapper { |
| | | background: #ffffff; |
| | | box-shadow: 0 0 0 1px #d9dee8 inset; |
| | | } |
| | | |
| | | .merchant-products-filter .el-date-editor.el-input__wrapper, |
| | |
| | | } |
| | | |
| | | .merchant-coupons-filter .coupon-created-time-picker { |
| | | width: 320px; |
| | | width: 320px !important; |
| | | } |
| | | |
| | | .merchant-products-filter .product-created-time-picker { |
| | |
| | | |
| | | .product-form-grid { |
| | | display: flex; |
| | | gap: 26px; |
| | | gap: 34px; |
| | | align-items: start; |
| | | } |
| | | |
| | | .product-info-left { |
| | | width: 360px; |
| | | width: 430px; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | |
| | | .product-form-row, |
| | | .product-static-row { |
| | | display: grid; |
| | | grid-template-columns: 54px minmax(0, 1fr); |
| | | grid-template-columns: 68px minmax(0, 1fr); |
| | | align-items: center; |
| | | gap: 10px; |
| | | gap: 16px; |
| | | margin-bottom: 18px; |
| | | } |
| | | |
| | | .product-form-row.amount { |
| | | grid-template-columns: 54px 134px 24px; |
| | | grid-template-columns: 68px minmax(0, 1fr); |
| | | gap: 16px; |
| | | } |
| | | |
| | | .product-form-row.amount .el-form-item__content { |
| | | display: flex; |
| | | flex-wrap: nowrap; |
| | | align-items: center; |
| | | gap: 8px; |
| | | min-width: 0; |
| | | margin-left: 0 !important; |
| | | } |
| | | |
| | | .product-form-row.amount .el-form-item__content > span { |
| | | flex: 0 0 auto; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .product-form-row.amount.hotel-amount { |
| | |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .merchant-product-edit-form .el-form-item__label { |
| | | width: 68px !important; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .product-static-row span { |
| | | color: #475569; |
| | | font-weight: 600; |
| | |
| | | |
| | | .product-image-row { |
| | | display: grid; |
| | | grid-template-columns: 54px 256px 78px; |
| | | gap: 10px; |
| | | align-items: end; |
| | | grid-template-columns: 68px 256px 78px; |
| | | column-gap: 16px; |
| | | align-items: center; |
| | | margin-bottom: 20px; |
| | | } |
| | | |
| | | .product-image-row.compact { |
| | | grid-template-columns: 54px minmax(172px, 1fr) 78px; |
| | | align-items: start; |
| | | grid-template-columns: 68px minmax(172px, 1fr) 78px; |
| | | column-gap: 16px; |
| | | align-items: center; |
| | | } |
| | | |
| | | .product-image-list { |
| | |
| | | } |
| | | |
| | | .product-image-row > label { |
| | | align-self: start; |
| | | align-self: center; |
| | | } |
| | | |
| | | .product-image-row > .el-button { |
| | | align-self: center; |
| | | justify-self: start; |
| | | } |
| | | |
| | | .product-image-row .el-form-item__content { |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: flex-start; |
| | | gap: 12px; |
| | | min-width: 0; |
| | | margin-left: 0 !important; |
| | | } |
| | | |
| | | .product-cover-preview, |
| | |
| | | |
| | | .product-detail-row { |
| | | display: grid; |
| | | grid-template-columns: 54px minmax(0, 1fr); |
| | | gap: 10px; |
| | | grid-template-columns: 68px minmax(0, 1fr); |
| | | gap: 16px; |
| | | margin-top: 4px; |
| | | } |
| | | |
| | | .product-detail-row label { |
| | | align-self: start; |
| | | align-self: center; |
| | | } |
| | | |
| | | .product-detail-row .el-textarea { |
| | |
| | | gap: 8px; |
| | | } |
| | | |
| | | .product-notice-field .el-date-editor, |
| | | .product-notice-field .el-time-editor, |
| | | .product-notice-field .el-input { |
| | | width: 100%; |
| | | } |
| | | |
| | | .product-rights-list { |
| | | display: grid; |
| | | gap: 28px; |
| | |
| | | } |
| | | |
| | | .product-right-main { |
| | | display: flex; |
| | | /* |
| | | * 三种权益状态使用同一套列:避免免费、超过次数、打折三行因为 |
| | | * 内容宽度或 margin 不同而把“是否政府补贴”挤到不同的位置。 |
| | | */ |
| | | display: grid; |
| | | grid-template-columns: 86px 42px 58px 64px 42px 40px 88px 8px 22px 24px 58px 100px 12px 88px 42px; |
| | | align-items: center; |
| | | min-height: 30px; |
| | | } |
| | | |
| | | .product-right-sub { |
| | | display: flex; |
| | | display: grid; |
| | | /* 与主行共用列定义,输入框和补贴开关逐列对齐。 */ |
| | | grid-template-columns: 86px 42px 58px 64px 42px 40px 88px 8px 22px 24px 58px 100px 12px 88px 42px; |
| | | align-items: center; |
| | | min-height: 28px; |
| | | padding-left: 186px; |
| | | } |
| | | |
| | | .product-right-sub > span:first-child { |
| | | width: 64px; |
| | | margin-right: 18px; |
| | | .product-right-main .right-level { grid-column: 1; } |
| | | .product-right-main .right-enabled { grid-column: 2; margin: 0; } |
| | | .product-right-main .right-free-label { grid-column: 4; } |
| | | .product-right-main .right-free-switch { grid-column: 5; margin: 0; } |
| | | .product-right-main.free .right-limit-field { grid-column: 7; margin: 0; } |
| | | .product-right-main.free .right-unit { grid-column: 9; margin: 0; } |
| | | .product-right-main.free .right-reset-label { grid-column: 11; margin: 0; } |
| | | .product-right-main.free .right-reset-select { grid-column: 12; margin: 0; } |
| | | /* 打折行使用一个独立的紧凑控件组,补贴列仍固定在第 14 列。 */ |
| | | .product-right-main:not(.free) .right-discount-control { |
| | | grid-column: 7 / span 6; |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .product-right-sub > span:nth-child(3) { |
| | | width: 22px; |
| | | margin-left: 8px; |
| | | .product-right-main:not(.free) .right-discount-label { |
| | | flex: 0 0 38px; |
| | | } |
| | | |
| | | .product-right-sub > span:nth-child(4) { |
| | | width: 88px; |
| | | margin-left: 10px; |
| | | .product-right-main:not(.free) .right-discount-input { |
| | | flex: 0 0 100px; |
| | | width: 100px; |
| | | margin: 0; |
| | | } |
| | | |
| | | .product-right-main:not(.free) .right-unit { |
| | | flex: 0 0 22px; |
| | | } |
| | | .product-right-main .right-subsidy-label { grid-column: 14; margin: 0 !important; white-space: nowrap; } |
| | | .product-right-main .right-subsidy-switch { grid-column: 15; margin: 0; } |
| | | |
| | | .product-right-sub .right-exceed-label { grid-column: 4 / span 3; } |
| | | .product-right-sub .right-exceed-discount-field { grid-column: 7; } |
| | | .product-right-sub .right-exceed-unit { grid-column: 9; } |
| | | .product-right-sub .right-exceed-subsidy-label { grid-column: 14; white-space: nowrap; } |
| | | .product-right-sub .right-exceed-subsidy-switch { grid-column: 15; } |
| | | |
| | | .product-right-row .right-level { |
| | | flex: 0 0 86px; |
| | | color: #111827; |
| | | font-size: 12px; |
| | | } |
| | | |
| | | .product-right-row .right-enabled { |
| | | flex: 0 0 42px; |
| | | margin-right: 58px; |
| | | } |
| | | |
| | | .product-right-row .right-free-label { |
| | | flex: 0 0 64px; |
| | | } |
| | | |
| | | .product-right-row .right-free-switch { |
| | | flex: 0 0 42px; |
| | | margin-right: 40px; |
| | | } |
| | | |
| | | .product-right-row .right-limit-field { |
| | | position: relative; |
| | | flex: 0 0 88px; |
| | | margin-right: 8px; |
| | | width: 88px; |
| | | } |
| | | |
| | | .product-right-row .right-limit-field .el-input-number { |
| | |
| | | } |
| | | |
| | | .product-right-row .right-unit { |
| | | flex: 0 0 22px; |
| | | } |
| | | |
| | | .product-right-row .right-reset-label { |
| | | flex: 0 0 58px; |
| | | margin-left: 24px; |
| | | } |
| | | |
| | | .product-right-row .right-reset-select { |
| | | flex: 0 0 96px; |
| | | margin-right: 12px; |
| | | width: 100px; |
| | | } |
| | | |
| | | .product-right-row .right-discount-label { |
| | | flex: 0 0 38px; |
| | | } |
| | | |
| | | .product-right-row .right-discount-input { |
| | | flex: 0 0 100px; |
| | | margin-right: 8px; |
| | | } |
| | | |
| | | .product-right-row .right-subsidy-label { |
| | | flex: 0 0 88px; |
| | | } |
| | | |
| | | .product-right-row .right-subsidy-switch { |
| | | flex: 0 0 42px; |
| | | } |
| | | |
| | | .product-right-main:not(.free) .right-subsidy-label { |
| | | margin-left: 30px; |
| | | } |
| | | |
| | | .merchant-product-form-panel .product-right-row .el-input { |
| | |
| | | } |
| | | |
| | | .merchant-product-form-panel .product-right-sub .right-exceed-discount-input { |
| | | width: 100px; |
| | | width: 88px; |
| | | } |
| | | |
| | | .product-right-sub .right-exceed-discount-field { |
| | | position: relative; |
| | | width: 88px; |
| | | } |
| | | |
| | | .product-right-sub .field-error { |
| | | position: absolute; |
| | | top: calc(100% + 4px); |
| | | left: 0; |
| | | width: max-content; |
| | | color: #f56c6c; |
| | | font-size: 12px; |
| | | line-height: 16px; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .merchant-product-form-panel .product-right-row .right-discount-input { |
| | |
| | | |
| | | .merchant-product-form-panel .product-right-row .el-select { |
| | | width: 64px; |
| | | } |
| | | |
| | | /* 团单免费次数的重置周期需完整展示“每天/每周/每月/每年”。 */ |
| | | .merchant-product-form-panel .product-right-row .right-reset-select { |
| | | flex-basis: 100px; |
| | | width: 100px; |
| | | } |
| | | |
| | | .product-right-tip { |
| | |
| | | .merchant-product-form-panel .el-textarea__inner { |
| | | min-height: 28px; |
| | | border-radius: 0; |
| | | background: #f7f8fb; |
| | | background: #ffffff; |
| | | box-shadow: 0 0 0 1px #d9dee8 inset; |
| | | } |
| | | |
| | | /* Element Plus 会为禁用/只读控件注入灰底;团单编辑页仍需和其他表单控件保持同一白底与边框。 */ |
| | | .merchant-product-form-panel .el-input.is-disabled .el-input__wrapper, |
| | | .merchant-product-form-panel .el-input.is-readonly .el-input__wrapper, |
| | | .merchant-product-form-panel .el-input-number.is-disabled .el-input__wrapper, |
| | | .merchant-product-form-panel .el-select.is-disabled .el-select__wrapper, |
| | | .merchant-product-form-panel .el-date-editor.is-disabled .el-input__wrapper, |
| | | .merchant-product-form-panel .el-textarea.is-disabled .el-textarea__inner { |
| | | background: #ffffff !important; |
| | | box-shadow: 0 0 0 1px #d9dee8 inset !important; |
| | | } |
| | | |
| | | .merchant-product-form-panel .el-button, |
| | |
| | | } |
| | | |
| | | .coupon-requirement-row .coupon-full-amount { |
| | | width: 108px; |
| | | margin: 0 6px 0 10px; |
| | | width: 168px; |
| | | margin: 0 50px 0 10px; |
| | | } |
| | | |
| | | .coupon-requirement-row .coupon-full-unit { |
| | | display: inline-flex; |
| | | flex: 0 0 44px; |
| | | align-items: center; |
| | | margin: 0 50px 0 0; |
| | | line-height: 28px; |
| | | .coupon-requirement-row .coupon-full-amount .el-input-group__append { |
| | | padding: 0 0 0 8px; |
| | | color: #334155; |
| | | background: transparent; |
| | | border: 0; |
| | | box-shadow: none; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .coupon-requirement-row .coupon-full-amount .el-input__wrapper { |
| | | border-radius: var(--el-border-radius-base); |
| | | } |
| | | |
| | | .coupon-requirement-row .coupon-none-radio { |
| | |
| | | |
| | | .order-filter-form .el-form-item { |
| | | margin-bottom: 0; |
| | | } |
| | | |
| | | .order-filter-form .family-order-filter .el-form-item__label { |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .order-filter-form .el-input, |
| | |
| | | margin-bottom: 0; |
| | | } |
| | | |
| | | /* 角色管理筛选项与标题左边缘对齐。 */ |
| | | .role-management-filter-form .el-form-item__label { |
| | | justify-content: flex-start; |
| | | } |
| | | |
| | | .business-fill-filter-form .el-input, |
| | | .business-fill-filter-form .el-select, |
| | | .business-fill-filter-form .el-date-editor { |
| | |
| | | color: #334155; |
| | | font-size: 13px; |
| | | line-height: 32px; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .news-publish-form .el-input { |
| | |
| | | padding: 16px 0 10px; |
| | | } |
| | | |
| | | .news-cover-preview { |
| | | width: 120px; |
| | | height: 72px; |
| | | margin-left: 12px; |
| | | vertical-align: middle; |
| | | border: 1px solid #d9dee8; |
| | | border-radius: 4px; |
| | | .article-cover-upload .el-upload { |
| | | display: block; |
| | | } |
| | | |
| | | .policy-cover-preview { |
| | | width: 120px; |
| | | height: 72px; |
| | | margin-left: 12px; |
| | | vertical-align: middle; |
| | | border: 1px solid #d9dee8; |
| | | border-radius: 4px; |
| | | .article-cover-upload-card, |
| | | .article-cover-preview { |
| | | width: 76px; |
| | | height: 76px; |
| | | box-sizing: border-box; |
| | | border: 1px dashed #d7dce4; |
| | | } |
| | | |
| | | .article-cover-upload-card { |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: center; |
| | | justify-content: center; |
| | | color: #a8b0bb; |
| | | background: #ffffff; |
| | | } |
| | | |
| | | .article-cover-upload-card span { |
| | | height: 30px; |
| | | font-size: 31px; |
| | | font-weight: 200; |
| | | line-height: 26px; |
| | | } |
| | | |
| | | .article-cover-upload-card small { |
| | | margin-top: 5px; |
| | | font-size: 12px; |
| | | line-height: 16px; |
| | | } |
| | | |
| | | .article-cover-preview { |
| | | display: block; |
| | | border-style: solid; |
| | | object-fit: cover; |
| | | } |
| | | |
| | | .news-editor-file-input { |
| | |
| | | width: 100%; |
| | | } |
| | | |
| | | /* 管理后台的日期/时间选择控件统一为 320px,保证范围内容完整且各页面一致。 */ |
| | | .page-stack .el-date-editor, |
| | | .prototype-page .el-date-editor, |
| | | .merchant-prototype-page .el-date-editor { |
| | | width: 320px !important; |
| | | } |
| | | |
| | | .prototype-table-tools { |
| | | display: flex; |
| | | align-items: center; |
| | |
| | | <div class="page-stack prototype-page business-fill-page"> |
| | | <section class="panel"> |
| | | <div class="panel-header"><h3>{{ title }}</h3></div> |
| | | <el-form class="business-fill-filter-form" label-width="86px"> |
| | | <el-form class="business-fill-filter-form" :class="{ 'role-management-filter-form': isRole }" label-width="86px"> |
| | | <el-form-item :label="isUpgrade ? '持卡人' : isRole ? '角色' : '员工'"> |
| | | <el-input v-model="filters.keyword" :placeholder="isUpgrade ? '持卡人姓名、手机号、卡号' : '请输入关键词'" clearable /> |
| | | </el-form-item> |
| | |
| | | <el-table-column label="操作" width="150" fixed="right" align="center"> |
| | | <template #default="{ row }"> |
| | | <el-button link type="primary" :icon="View" @click="view(row)">查看</el-button> |
| | | <el-button v-if="!isUpgrade" link type="primary" :icon="Edit" @click="openEdit(row)">编辑</el-button> |
| | | <el-button v-if="!isUpgrade && !(isRole && isPlatformAdminRole(row))" link type="primary" :icon="Edit" @click="openEdit(row)">编辑</el-button> |
| | | <template v-if="isRole && !isPlatformAdminRole(row)"> |
| | | <el-button link type="primary" @click="changeRoleStatus(row)">{{ Number(row.status) === 1 ? '停用' : '启用' }}</el-button> |
| | | <el-button link type="danger" @click="removeRole(row)">删除</el-button> |
| | |
| | | </el-dialog> |
| | | <el-dialog v-model="editVisible" :title="`编辑${title}`" width="480px" destroy-on-close :close-on-click-modal="false"> |
| | | <el-form ref="formRef" :model="form" :rules="rules" label-width="92px"> |
| | | <el-form-item :label="isRole ? '角色名称' : '员工姓名'" prop="name"><el-input v-model="form.name" /></el-form-item> |
| | | <el-form-item :label="isRole ? '角色编码' : '登录账号'" prop="code"><el-input v-model="form.code" :placeholder="isRole ? '' : '仅支持英文和数字'" maxlength="32" /></el-form-item> |
| | | <el-form-item v-if="!isRole" label="登录密码" prop="password"><el-input v-model="form.password" type="password" :placeholder="editingId ? '留空则不修改' : '请输入登录密码'" /></el-form-item> |
| | | <el-form-item :label="isRole ? '角色名称' : '员工姓名'" prop="name" required><el-input v-model="form.name" /></el-form-item> |
| | | <el-form-item :label="isRole ? '角色编码' : '登录账号'" prop="code" required><el-input v-model="form.code" :placeholder="isRole ? '' : '仅支持英文和数字'" maxlength="32" /></el-form-item> |
| | | <el-form-item v-if="!isRole" label="登录密码" prop="password" :required="!editingId"><el-input v-model="form.password" type="password" :placeholder="editingId ? '留空则不修改' : '请输入登录密码'" /></el-form-item> |
| | | <el-form-item v-if="isRole" label="备注"><el-input v-model="form.remark" /></el-form-item> |
| | | <el-form-item label="状态"><el-switch v-model="form.status" :active-value="1" :inactive-value="0" /></el-form-item> |
| | | </el-form> |
| | |
| | | const routeTab = typeof route.query.tab === 'string' ? route.query.tab : 'settings' |
| | | const activeTab = ref(['settings', 'rights', 'products', 'coupons'].includes(routeTab) ? routeTab : 'settings') |
| | | const couponMode = ref<'list' | 'edit'>('list') |
| | | const isEditMode = computed(() => route.query.mode === 'edit') |
| | | const hasValidMerchantId = computed(() => /^\d+$/.test(queryString('merchantId'))) |
| | | const isEditMode = computed(() => route.query.mode === 'edit' && hasValidMerchantId.value) |
| | | const hasExistingMerchant = computed(() => isEditMode.value) |
| | | const normalizedMerchantType = computed(() => String(form.merchantType || '').trim()) |
| | | const isFoodMerchant = computed(() => normalizedMerchantType.value === '餐饮服务') |
| | | const isApartmentMerchant = computed(() => normalizedMerchantType.value === '公寓租赁') |
| | |
| | | const isSpouseEmploymentMerchant = computed(() => normalizedMerchantType.value === '配偶推荐就业') |
| | | const isApprovalMerchant = computed(() => normalizedMerchantType.value === '行政审批') |
| | | const isServiceMerchant = computed(() => isFoodMerchant.value || normalizedMerchantType.value === '景区服务') |
| | | const requiresFoodMerchantSettings = computed(() => !isEditMode.value && isFoodMerchant.value) |
| | | const supportsStoreConsumption = computed(() => [ |
| | | '酒店服务', '景区服务', '餐饮服务', '口腔治疗保健', '免费体检', |
| | | ].includes(normalizedMerchantType.value) || (isCarWashMerchant.value && form.miniProgramJumpType === 'normal')) |
| | |
| | | '购房优惠', '购车服务', '家电服务', '数码服务', '文化服务', '电信服务', '口腔治疗保健', '加油服务', |
| | | ] |
| | | const usesSimplifiedMerchantSettings = computed(() => simplifiedMerchantSettingsTypes.includes(normalizedMerchantType.value)) |
| | | const settingsAndRightsOnlyMerchantTypes = [ |
| | | '加油服务', '购房优惠', '购车服务', '家电服务', '数码服务', '文化服务', '电信服务', |
| | | ] |
| | | const usesSettingsAndRightsOnlyTabs = computed(() => settingsAndRightsOnlyMerchantTypes.includes(normalizedMerchantType.value)) |
| | | const supportsMerchantAppLogin = computed(() => ( |
| | | isServiceMerchant.value |
| | | || isApartmentMerchant.value |
| | |
| | | return merchantType === '餐饮服务' || merchantType === '景区服务' ? 1 : 0 |
| | | } |
| | | |
| | | function applyNewMerchantDefaults() { |
| | | if (isEditMode.value) return |
| | | |
| | | const merchantType = queryString('merchantType') |
| | | form.merchantType = merchantType |
| | | form.recommend = merchantType === '餐饮服务' || merchantType === '景区服务' ? 1 : 0 |
| | | form.sortNo = undefined |
| | | } |
| | | |
| | | const tabs = computed(() => { |
| | | if (usesSettingsAndRightsOnlyTabs.value) { |
| | | return [ |
| | | { label: '商家设置', value: 'settings' }, |
| | | { label: '商家权益', value: 'rights' }, |
| | | ] |
| | | } |
| | | if (isApartmentMerchant.value) { |
| | | return [ |
| | | { label: '商家设置', value: 'settings' }, |
| | |
| | | trigger: 'change', |
| | | }], |
| | | sortNo: [{ |
| | | validator: (_rule, value, callback) => value === undefined || value === null || String(value).trim() === '' |
| | | ? callback(new Error('请输入排序')) |
| | | : callback(), |
| | | validator: (_rule, value, callback) => { |
| | | const text = String(value ?? '').trim() |
| | | if (!text) return callback(new Error('请输入排序')) |
| | | if (!/^\d+$/.test(text)) return callback(new Error('排序只能输入非负整数')) |
| | | callback() |
| | | }, |
| | | trigger: 'blur', |
| | | }], |
| | | })) |
| | |
| | | validPeriod: '', |
| | | useMethod: '', |
| | | availableTime: '', |
| | | otherRule: '', |
| | | otherRules: '', |
| | | }) |
| | | const productValidPeriodRange = ref<string[]>([]) |
| | | const productAvailableTimeRange = ref<string[]>([]) |
| | |
| | | exceedSubsidy: true, |
| | | }, |
| | | ]) |
| | | const productExceedDiscountErrors = reactive<Record<string, string>>({}) |
| | | |
| | | function defaultProductRights() { |
| | | return [ |
| | |
| | | '提供包间、免费停车、WiFi等服务', |
| | | '菜品以家常菜、特色川菜为主,具体以门店实际为准', |
| | | ] |
| | | |
| | | const locationDialogVisible = ref(false) |
| | | const pendingLongitude = ref('') |
| | | const pendingLatitude = ref('') |
| | |
| | | productNoticeForm.validPeriod = notice.validPeriod || '' |
| | | productNoticeForm.useMethod = notice.useMethod || '' |
| | | productNoticeForm.availableTime = notice.availableTime || '' |
| | | productNoticeForm.otherRule = notice.otherRule || '' |
| | | productNoticeForm.otherRules = notice.otherRules || notice.otherRule || '' |
| | | productValidPeriodRange.value = rangeValues(productNoticeForm.validPeriod, /\d{4}-\d{2}-\d{2}/g) |
| | | productAvailableTimeRange.value = rangeValues(productNoticeForm.availableTime, /\d{2}:\d{2}/g) |
| | | if (isHealthCheckMerchant.value) { |
| | |
| | | return amount > 0 && amount <= 10 |
| | | } |
| | | |
| | | function validateProductExceedDiscount(item: { level: string, exceedDiscount: unknown }) { |
| | | const key = item.level |
| | | if (validExceedDiscount(item.exceedDiscount)) { |
| | | delete productExceedDiscountErrors[key] |
| | | return true |
| | | } |
| | | productExceedDiscountErrors[key] = '请输入大于 0 且不超过 10 的一位小数' |
| | | return false |
| | | } |
| | | |
| | | function validFreeLimitCount(value: unknown) { |
| | | const text = String(value ?? '').trim() |
| | | if (!/^\d+$/.test(text)) return false |
| | |
| | | ElMessage.warning('免费次数请输入 1 至 999 的整数,999 表示不限次数') |
| | | return |
| | | } |
| | | if (productRights.some((item) => item.free && !validExceedDiscount(item.exceedDiscount))) { |
| | | ElMessage.warning('超过次数折扣请输入大于 0 且不超过 10 的一位小数') |
| | | if (productRights.some((item) => item.free && !validateProductExceedDiscount(item))) { |
| | | return |
| | | } |
| | | } else { |
| | |
| | | } |
| | | }) |
| | | |
| | | watch(tabs, (availableTabs) => { |
| | | if (!availableTabs.some((item) => item.value === activeTab.value)) { |
| | | activeTab.value = 'settings' |
| | | } |
| | | }, { immediate: true }) |
| | | |
| | | watch(() => form.coverUrl, resetMerchantCoverPreview) |
| | | watch(() => productForm.coverUrl, resetProductCoverPreview) |
| | | watch(productValidPeriodRange, (value) => { |
| | |
| | | watch(() => form.miniProgramJumpUrl, () => { |
| | | if (isCarWashMerchant.value) syncCarWashJumpMeta() |
| | | }) |
| | | |
| | | // The create page is reused when the service type changes, so reset these |
| | | // create-only defaults from the current route instead of retaining old values. |
| | | watch(() => [route.query.mode, route.query.merchantType], applyNewMerchantDefaults, { immediate: true }) |
| | | |
| | | onMounted(() => { |
| | | if (isEditMode.value && queryString('merchantId')) { |
| | |
| | | <el-form ref="merchantFormRef" :model="form" :rules="merchantRules" class="merchant-settings-form" label-width="58px" label-position="left"> |
| | | <div class="merchant-create-content" :class="{ 'service-merchant-content': isServiceMerchant || isApartmentMerchant || isHotelMerchant || isCarWashMerchant || isHealthCheckMerchant || isParkingMerchant || isChildcareMerchant || isFinancialMerchant || isMovieMerchant || isSpouseEmploymentMerchant || isApprovalMerchant }"> |
| | | <div class="merchant-create-left" :style="isApartmentMerchant || isFoodMerchant || isHotelMerchant || isCarWashMerchant || isHealthCheckMerchant || isParkingMerchant || isChildcareMerchant || isFinancialMerchant || isMovieMerchant || isSpouseEmploymentMerchant || isApprovalMerchant ? 'flex: 1' : ''"> |
| | | <el-form-item class="merchant-form-row three-columns" label="商家名称" prop="merchantName"> |
| | | <el-form-item class="merchant-form-row three-columns" label="商家名称" prop="merchantName" required> |
| | | <el-input v-model="form.merchantName" /> |
| | | </el-form-item> |
| | | |
| | | <el-form-item v-if="!isApartmentMerchant" class="merchant-form-row address-row" :label="isCarWashMerchant || isHealthCheckMerchant || isFinancialMerchant || isMovieMerchant || isSpouseEmploymentMerchant || isApprovalMerchant ? '服务点' : '地址'" prop="address"> |
| | | <el-form-item v-if="!isApartmentMerchant" class="merchant-form-row address-row" :label="isCarWashMerchant || isHealthCheckMerchant || isFinancialMerchant || isMovieMerchant || isSpouseEmploymentMerchant || isApprovalMerchant ? '服务点' : '地址'" prop="address" required> |
| | | <el-input v-model="form.address" /> |
| | | <el-button type="primary" @click="markLocation">地图标点</el-button> |
| | | </el-form-item> |
| | | |
| | | <el-form-item class="merchant-cover-row" label="封面图" prop="coverUrl"> |
| | | <el-form-item class="merchant-cover-row" label="封面图" prop="coverUrl" required> |
| | | <div class="merchant-cover-preview"> |
| | | <img |
| | | v-if="form.coverUrl && !merchantCoverPreviewFailed" |
| | |
| | | </div> |
| | | |
| | | <div v-if="!isServiceMerchant && !isApartmentMerchant && !isHotelMerchant && !isCarWashMerchant && !isHealthCheckMerchant && !isParkingMerchant && !isChildcareMerchant && !isFinancialMerchant && !isMovieMerchant && !isSpouseEmploymentMerchant && !isApprovalMerchant" class="merchant-create-middle"> |
| | | <el-form-item class="merchant-form-row" label="联系电话" prop="contactMobile"> |
| | | <el-form-item class="merchant-form-row" label="联系电话" prop="contactMobile" required> |
| | | <el-input v-model="form.contactMobile" /> |
| | | </el-form-item> |
| | | |
| | |
| | | </div> |
| | | |
| | | <div class="merchant-create-right" :style="isApartmentMerchant || isFoodMerchant || isHotelMerchant || isCarWashMerchant || isHealthCheckMerchant || isParkingMerchant || isChildcareMerchant || isFinancialMerchant || isMovieMerchant || isSpouseEmploymentMerchant || isApprovalMerchant ? 'flex: 1' : ''"> |
| | | <el-form-item v-if="isServiceMerchant || isApartmentMerchant || isHotelMerchant || isCarWashMerchant || isHealthCheckMerchant || isParkingMerchant || isChildcareMerchant || isFinancialMerchant || isMovieMerchant || isSpouseEmploymentMerchant || isApprovalMerchant" class="merchant-form-row service-contact-row" label="联系电话" prop="contactMobile"> |
| | | <el-form-item v-if="isServiceMerchant || isApartmentMerchant || isHotelMerchant || isCarWashMerchant || isHealthCheckMerchant || isParkingMerchant || isChildcareMerchant || isFinancialMerchant || isMovieMerchant || isSpouseEmploymentMerchant || isApprovalMerchant" class="merchant-form-row service-contact-row" label="联系电话" prop="contactMobile" required> |
| | | <el-input v-model="form.contactMobile" placeholder="请输入联系电话" /> |
| | | </el-form-item> |
| | | |
| | | <div v-if="isEditMode" class="merchant-form-row"> |
| | | <div v-if="hasExistingMerchant" class="merchant-form-row"> |
| | | <label>创建时间</label> |
| | | <el-input v-model="form.createdAt" readonly /> |
| | | </div> |
| | | |
| | | <el-form-item v-if="!isApprovalMerchant" class="merchant-hours-block" label="营业时间" prop="businessHours"> |
| | | <el-form-item v-if="!isApprovalMerchant" class="merchant-hours-block" label="营业时间" prop="businessHours" required> |
| | | <div class="merchant-hours-list"> |
| | | <div v-for="(item, index) in businessHours" :key="item.id" class="merchant-hour-row"> |
| | | <el-time-picker |
| | |
| | | <el-switch v-model="form.recommend" :active-value="1" :inactive-value="0" /> |
| | | </div> |
| | | |
| | | <el-form-item class="merchant-form-row" label="排序" prop="sortNo"> |
| | | <el-form-item class="merchant-form-row" label="排序" prop="sortNo" required> |
| | | <el-input v-model="form.sortNo" placeholder="请输入" /> |
| | | </el-form-item> |
| | | </div> |
| | |
| | | </template> |
| | | |
| | | <template v-else> |
| | | <el-form ref="productFormRef" :model="productForm" :rules="productRules" class="merchant-product-edit-form" label-width="54px" label-position="left"> |
| | | <el-form ref="productFormRef" :model="productForm" :rules="productRules" class="merchant-product-edit-form" label-width="68px" label-position="left"> |
| | | <section class="merchant-product-form-panel"> |
| | | <h3>商品信息</h3> |
| | | |
| | | <div class="product-form-grid"> |
| | | <div class="product-info-left"> |
| | | <el-form-item class="product-form-row" label="商品名称" prop="productName"> |
| | | <el-form-item class="product-form-row" label="商品名称" prop="productName" required> |
| | | <el-input v-model="productForm.productName" placeholder="请输入商品名称" /> |
| | | </el-form-item> |
| | | |
| | | <el-form-item class="product-image-row" label="封面图" prop="coverUrl"> |
| | | <el-form-item class="product-image-row" label="封面图" prop="coverUrl" required> |
| | | <div class="product-cover-preview"> |
| | | <img |
| | | v-if="productForm.coverUrl && !productCoverPreviewFailed" |
| | |
| | | <el-button type="primary" :loading="imageUploading" @click="changeCover('productCover')">更换封面</el-button> |
| | | </el-form-item> |
| | | |
| | | <el-form-item v-if="!isHealthCheckMerchant" class="product-image-row compact" label="商品图片" prop="productImages"> |
| | | <el-form-item v-if="!isHealthCheckMerchant" class="product-image-row compact" label="商品图片" prop="productImages" required> |
| | | <div class="product-image-list"> |
| | | <div v-for="(image, index) in productImages" :key="image" class="product-thumb-preview"> |
| | | <img :src="image" alt="商品图片" /> |
| | |
| | | </div> |
| | | |
| | | <div v-if="!isHealthCheckMerchant" class="product-info-middle"> |
| | | <el-form-item class="product-form-row amount" :class="{ 'hotel-amount': isHotelMerchant }" label="商品金额" prop="salePrice"> |
| | | <el-form-item class="product-form-row amount" :class="{ 'hotel-amount': isHotelMerchant }" label="商品金额" prop="salePrice" required> |
| | | <template v-if="isHotelMerchant"> |
| | | <el-input-number |
| | | v-model="productForm.salePrice" |
| | |
| | | |
| | | </div> |
| | | |
| | | <el-form-item v-if="!isHealthCheckMerchant" class="product-detail-row" label="商品详情" prop="detailText"> |
| | | <el-form-item v-if="!isHealthCheckMerchant" class="product-detail-row" label="商品详情" prop="detailText" required> |
| | | <AdminRichTextEditor :model-value="productForm.detailText || ''" class="merchant-product-rich-editor" placeholder="请输入商品详情" :height="260" @update:model-value="value => { productForm.detailText = value; productFormRef?.validateField('detailText') }" /> |
| | | </el-form-item> |
| | | |
| | |
| | | </div> |
| | | <div class="product-notice-field"> |
| | | <label>其他规则</label> |
| | | <el-input v-model="productNoticeForm.otherRule" placeholder="请输入" /> |
| | | <el-input v-model="productNoticeForm.otherRules" placeholder="请输入" /> |
| | | </div> |
| | | </div> |
| | | </section> |
| | |
| | | </el-select> |
| | | </template> |
| | | <template v-else> |
| | | <span class="right-discount-label">折扣</span> |
| | | <el-input v-model="item.discount" class="right-discount-input" placeholder="请输入" /> |
| | | <span class="right-unit">折</span> |
| | | <div class="right-discount-control"> |
| | | <span class="right-discount-label">折扣</span> |
| | | <el-input v-model="item.discount" class="right-discount-input" placeholder="请输入" /> |
| | | <span class="right-unit">折</span> |
| | | </div> |
| | | </template> |
| | | <span class="right-subsidy-label">是否政府补贴</span> |
| | | <el-switch v-model="item.subsidy" class="right-subsidy-switch" /> |
| | | </div> |
| | | <div v-if="item.free" class="product-right-sub"> |
| | | <span>超过次数</span> |
| | | <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.exceedSubsidy" /> |
| | | <span class="right-exceed-label">超过次数</span> |
| | | <div class="right-exceed-discount-field"> |
| | | <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="请输入" |
| | | @change="validateProductExceedDiscount(item)" |
| | | @blur="validateProductExceedDiscount(item)" |
| | | /> |
| | | <span v-if="productExceedDiscountErrors[item.level]" class="field-error">{{ productExceedDiscountErrors[item.level] }}</span> |
| | | </div> |
| | | <span class="right-exceed-unit">折</span> |
| | | <span class="right-exceed-subsidy-label">是否政府补贴</span> |
| | | <el-switch v-model="item.exceedSubsidy" class="right-exceed-subsidy-switch" /> |
| | | </div> |
| | | </div> |
| | | </div> |
| | |
| | | |
| | | <template v-else> |
| | | <el-form ref="couponFormRef" :model="couponEditForm" :rules="couponRules" class="merchant-coupon-edit-form" label-width="72px" label-position="left"> |
| | | <el-form-item class="coupon-edit-row" label="优惠券名称" prop="couponName"> |
| | | <el-form-item class="coupon-edit-row" label="优惠券名称" prop="couponName" required> |
| | | <el-input v-model="couponEditForm.couponName" placeholder="请输入优惠券名称" /> |
| | | </el-form-item> |
| | | |
| | | <el-form-item class="coupon-edit-row coupon-amount-row" label="优惠券金额" prop="couponAmount"> |
| | | <el-form-item class="coupon-edit-row coupon-amount-row" label="优惠券金额" prop="couponAmount" required> |
| | | <div class="coupon-amount-control"><el-input-number v-model="couponEditForm.couponAmount" :min="0.01" :precision="2" :step="1" controls-position="right" placeholder="请输入优惠券金额" /><span>元</span></div> |
| | | </el-form-item> |
| | | |
| | | <el-form-item class="coupon-edit-row coupon-requirement-row" label="使用要求" prop="fullAmount"> |
| | | <el-form-item class="coupon-edit-row coupon-requirement-row" label="使用要求" prop="fullAmount" :required="couponEditForm.useRequirement === 'full'"> |
| | | <el-radio-group v-model="couponEditForm.useRequirement"> |
| | | <el-radio label="full" class="coupon-full-radio">满</el-radio> |
| | | <el-input v-model="couponEditForm.fullAmount" class="coupon-full-amount" /> |
| | | <span class="coupon-full-unit">元可用</span> |
| | | <el-input v-model="couponEditForm.fullAmount" class="coupon-full-amount"> |
| | | <template #append>元可用</template> |
| | | </el-input> |
| | | <el-radio label="none" class="coupon-none-radio">不限</el-radio> |
| | | </el-radio-group> |
| | | </el-form-item> |
| | |
| | | </div> |
| | | </div> |
| | | |
| | | <el-form-item class="coupon-edit-row" label="有效期" prop="validTime"> |
| | | <el-form-item class="coupon-edit-row" label="有效期" prop="validTime" required> |
| | | <el-date-picker |
| | | v-model="couponEditForm.validTime" |
| | | class="coupon-valid-time-picker" |
| | |
| | | /> |
| | | </el-form-item> |
| | | |
| | | <el-form-item class="coupon-edit-row coupon-product-scope-row" label="可用商品" prop="productScope"> |
| | | <el-form-item class="coupon-edit-row coupon-product-scope-row" label="可用商品" prop="productScope" :required="couponEditForm.productScope === 'selected'"> |
| | | <el-radio-group v-model="couponEditForm.productScope"> |
| | | <el-radio label="all">不限</el-radio> |
| | | <el-radio label="selected">选择</el-radio> |
| | |
| | | |
| | | <el-dialog v-model="schoolDialogVisible" :title="editingSchoolIndex >= 0 ? '编辑学校' : '新增学校'" width="820px" destroy-on-close :close-on-click-modal="false"> |
| | | <el-form ref="schoolFormRef" :model="editingSchool" :rules="schoolRules" label-width="100px" style="padding: 20px 20px 0;"> |
| | | <el-form-item label="学校名称" prop="name"> |
| | | <el-form-item label="学校名称" prop="name" required> |
| | | <el-input v-model="editingSchool.name" placeholder="请输入" style="width: 320px" /> |
| | | </el-form-item> |
| | | <el-form-item label="地址" prop="address"> |
| | | <el-form-item label="地址" prop="address" required> |
| | | <el-input v-model="editingSchool.address" placeholder="请输入" style="width: 320px" /> |
| | | </el-form-item> |
| | | <el-form-item label="定位点"> |
| | |
| | | .detail-panel h3 { margin: 0; color: #1d2129; font-size: 15px; } |
| | | .status-value { margin-top: 16px; color: #1d2129; font-size: 13px; font-weight: 600; } |
| | | .onboarding-heading { margin-top: 24px !important; } |
| | | .detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 28px 46px; margin-top: 22px; max-width: 860px; } |
| | | .detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 28px 46px; margin-top: 22px; max-width: 1032px; } |
| | | .common-grid { margin-bottom: -2px; } |
| | | .detail-field { display: grid; grid-template-columns: 118px minmax(0, 1fr); align-items: center; gap: 12px; min-height: 32px; } |
| | | .detail-field { display: grid; grid-template-columns: 144px minmax(0, 1fr); align-items: center; gap: 12px; min-height: 32px; } |
| | | .detail-field > span { color: #303133; font-size: 13px; font-weight: 500; white-space: nowrap; } |
| | | .detail-field > strong { min-height: 32px; box-sizing: border-box; padding: 7px 10px; color: #606266; font-size: 13px; font-weight: 400; line-height: 18px; word-break: break-all; background: #f5f7fa; } |
| | | .placeholder-field { visibility: hidden; } |
| | |
| | | <script setup lang="ts"> |
| | | import { computed, onMounted, reactive, ref } from 'vue' |
| | | import { useRouter } from 'vue-router' |
| | | import { computed, onMounted, reactive, ref, watch } from 'vue' |
| | | import { useRoute, useRouter } from 'vue-router' |
| | | import { ElMessage, ElMessageBox } from 'element-plus' |
| | | import { Refresh, Search } from '@element-plus/icons-vue' |
| | | import { getMerchantSettlement, reviewMerchantSettlement } from '../api/admin' |
| | | |
| | | type Kind = 'onboardings' | 'bank-cards' | 'withdrawals' |
| | | |
| | | const kind = ref<Kind>('onboardings') |
| | | const kind = ref<Kind>('withdrawals') |
| | | const router = useRouter() |
| | | const route = useRoute() |
| | | const loading = ref(false) |
| | | const rows = ref<Record<string, any>[]>([]) |
| | | const total = ref(0) |
| | |
| | | load() |
| | | } |
| | | |
| | | function applyRouteKind() { |
| | | const value = String(route.query.kind || '') |
| | | if (value === 'onboardings' || value === 'bank-cards' || value === 'withdrawals') { |
| | | kind.value = value |
| | | } |
| | | } |
| | | |
| | | function openReview(row: Record<string, any>) { |
| | | if (kind.value === 'onboardings') { |
| | | router.push({ name: 'merchant-onboarding-detail', params: { id: row.id } }) |
| | |
| | | } |
| | | } |
| | | |
| | | onMounted(load) |
| | | watch(() => route.query.kind, () => { |
| | | applyRouteKind() |
| | | page.value = 1 |
| | | filters.status = '' |
| | | load() |
| | | }) |
| | | |
| | | onMounted(() => { |
| | | applyRouteKind() |
| | | load() |
| | | }) |
| | | </script> |
| | | |
| | | <template> |
| | |
| | | <el-table-column prop="bankName" label="开户银行" min-width="145" show-overflow-tooltip /> |
| | | <el-table-column prop="branchName" label="开户支行" min-width="180" show-overflow-tooltip /> |
| | | <el-table-column prop="cityName" label="开户城市" min-width="120" show-overflow-tooltip /> |
| | | <el-table-column prop="cardMasked" label="银行卡号" min-width="150" /> |
| | | <el-table-column prop="cardNo" label="银行卡号" min-width="180" /> |
| | | </template> |
| | | <el-table-column label="申请时间" min-width="165"> |
| | | <template #default="{ row }">{{ formatDate(row.createdAt) }}</template> |
| | |
| | | typeDialogVisible.value = false |
| | | router.push({ |
| | | path: '/merchants/new', |
| | | query: { merchantType: selectedCreateType.value }, |
| | | // Explicitly mark this as a create route so a previously opened edit page |
| | | // cannot leave its mode or data in the reused route component. |
| | | query: { mode: 'create', merchantType: selectedCreateType.value }, |
| | | }) |
| | | } |
| | | |
| | |
| | | |
| | | <el-drawer v-model="drawerVisible" :title="drawerTitle" size="520px" @closed="resetForm"> |
| | | <el-form ref="formRef" :model="form" :rules="rules" label-width="96px" class="drawer-form"> |
| | | <el-form-item label="商家名称" prop="merchantName"> |
| | | <el-form-item label="商家名称" prop="merchantName" required> |
| | | <el-input v-model="form.merchantName" placeholder="请输入商家名称" /> |
| | | </el-form-item> |
| | | <el-form-item label="商家类型"> |
| | |
| | | return value.verifyStatus === 1 ? '已核销' : '待核销' |
| | | } |
| | | |
| | | function paymentStatus(value: string) { |
| | | return ({ pending: '待支付', processing: '支付中', paid: '支付成功', failed: '支付失败', closed: '已关闭' } as Record<string, string>)[value] || value || '-' |
| | | } |
| | | |
| | | function previewImage(url: string) { |
| | | if (!url) return |
| | | previewUrl.value = url |
| | |
| | | <div><span>支付方式</span><strong>{{ payment.provider === 'HUIFU' ? '微信支付' : payment.provider || '-' }}</strong></div> |
| | | <div><span>支付流水号</span><strong>{{ payment.providerTransactionNo || payment.paymentNo || '-' }}</strong></div> |
| | | <div><span>支付金额</span><strong class="amount-strong">{{ money(payment.amount) }}</strong></div> |
| | | <div><span>支付状态</span><strong>{{ paymentStatus(payment.status) }}</strong></div> |
| | | </div> |
| | | </div> |
| | | <div v-else class="detail-empty">暂无支付流水</div> |
| | |
| | | const orders = ref<Order[]>([]) |
| | | const router = useRouter() |
| | | |
| | | const cardLevels = ['钻石卡', '卓越人才卡', '精英人才卡', '青年人才卡'] |
| | | const cardLevels = ['卓越人才卡', '杰出人才卡', '菁英人才卡', '普通游客'] |
| | | |
| | | const filters = reactive({ |
| | | talentKeyword: '', |
| | |
| | | <el-option label="已退款" :value="2" /> |
| | | </el-select> |
| | | </el-form-item> |
| | | <el-form-item v-if="isGroupOrder" label="是否家属下单"> |
| | | <el-form-item v-if="isGroupOrder" class="family-order-filter" label="是否家属下单"> |
| | | <el-select v-model="filters.familyOrder" placeholder="全部" clearable> |
| | | <el-option label="全部" :value="undefined" /> |
| | | <el-option label="是" :value="1" /> |
| | |
| | | |
| | | try { |
| | | const parsed = typeof value === 'string' ? JSON.parse(value) : value |
| | | notice.validPeriod = parsed.validPeriod || parsed.valid_period || '' |
| | | notice.useMethod = parsed.useMethod || parsed.use_method || '' |
| | | notice.validPeriod = parsed.validPeriod || parsed.valid_period || parsed.validity || '' |
| | | notice.useMethod = parsed.useMethod || parsed.use_method || parsed.usage || '' |
| | | notice.availableTime = parsed.availableTime || parsed.available_time || '' |
| | | notice.otherRules = parsed.otherRules || parsed.other_rules || '' |
| | | notice.otherRules = parsed.otherRules || parsed.other_rules || parsed.otherRule || parsed.other_rule || '' |
| | | } catch { |
| | | notice.otherRules = typeof value === 'string' ? value : '' |
| | | } |
| | |
| | | const loading = ref(false) |
| | | const rows = ref<RefundRecord[]>([]) |
| | | |
| | | const cardLevels = ['钻石卡', '卓越人才卡', '精英人才卡', '青年人才卡'] |
| | | const cardLevels = ['卓越人才卡', '杰出人才卡', '菁英人才卡', '普通游客'] |
| | | const itemNameLabel = computed(() => (activeTab.value === 'GROUP' ? '团购名称' : '消费项目')) |
| | | |
| | | const filters = reactive({ |
| | |
| | | type Module = { name: string; pages: Page[] } |
| | | const modules: Module[] = [ |
| | | { name: '首页', pages: [{ code: 'dashboard', name: '首页', actions: ['查看'] }] }, |
| | | { name: '账户权限管理', pages: [{ code: 'staff', name: '账号管理', actions: ['查看', '新增', '编辑', '启用/禁用', '删除', '重置密码'] }] }, |
| | | { name: '角色管理', pages: [{ code: 'roles', name: '角色管理', actions: ['查看', '新增', '编辑', '删除'] }] }, |
| | | { name: '持卡管理', pages: [{ code: 'talents', name: '持卡人管理', actions: ['查看', '新增', '编辑', '启用/禁用'] }] }, |
| | | { name: '家属卡管理', pages: [{ code: 'family-cards', name: '家属卡管理', actions: ['查看', '审核'] }] }, |
| | | { name: '商家管理', pages: [{ code: 'merchants', name: '商家查询', actions: ['查看', '新增', '编辑'] }] }, |
| | | { name: '团单审核', pages: [{ code: 'products', name: '团单审核', actions: ['查看', '审核'] }] }, |
| | | { name: '订单管理', pages: [{ code: 'orders', name: '订单列表', actions: ['查看', '退款处理'] }] }, |
| | | { name: '退款记录', pages: [{ code: 'refunds', name: '退款记录', actions: ['查看'] }] }, |
| | | { name: '预约管理', pages: [{ code: 'medical-reservations', name: '体检预约管理', actions: ['查看', '审核'] }] }, |
| | | { name: '停车车牌管理', pages: [{ code: 'parking-plates', name: '停车车牌管理', actions: ['查看'] }] }, |
| | | { name: '权益图片管理', pages: [{ code: 'equity-images', name: '权益图片管理', actions: ['查看', '编辑'] }] }, |
| | | { name: '人才资讯管理', pages: [{ code: 'news', name: '人才资讯管理', actions: ['查看', '新增', '编辑'] }] }, |
| | | { name: '系统管理', pages: [{ code: 'settings', name: '平台设置', actions: ['查看', '编辑'] }] }, |
| | | { |
| | | name: '持卡管理', |
| | | pages: [ |
| | | { code: 'talents', name: '持卡人管理', actions: ['查看', '新增', '编辑', '启用/停用'] }, |
| | | { code: 'family-card-applications', name: '家属卡申请', actions: ['查看', '审核'] }, |
| | | { code: 'upgrade-applications', name: '升级申请', actions: ['查看', '审核'] }, |
| | | { code: 'family-cards', name: '家属卡管理', actions: ['查看', '启用/停用'] }, |
| | | { code: 'child-cards', name: '子女管理', actions: ['查看', '审核'] }, |
| | | ], |
| | | }, |
| | | { |
| | | name: '商家管理', |
| | | pages: [ |
| | | { code: 'merchants', name: '商家查询', actions: ['查看', '新增', '编辑', '启用/停用'] }, |
| | | { code: 'products', name: '团单审核', actions: ['查看', '审核'] }, |
| | | ], |
| | | }, |
| | | { |
| | | name: '订单管理', |
| | | pages: [ |
| | | { code: 'orders', name: '订单列表', actions: ['查看'] }, |
| | | { code: 'refunds', name: '退款记录', actions: ['查看'] }, |
| | | { code: 'hotel-free-verifications', name: '酒店免费核销记录', actions: ['查看'] }, |
| | | ], |
| | | }, |
| | | { |
| | | name: '预约管理', |
| | | pages: [ |
| | | { code: 'appointment-management', name: '预约服务管理', actions: ['查看', '审核'] }, |
| | | { code: 'medical-reservations', name: '体检预约管理', actions: ['查看', '审核'] }, |
| | | { code: 'medical-applications', name: '就医申请管理', actions: ['查看', '审核'] }, |
| | | { code: 'parking-plates', name: '停车车牌管理', actions: ['查看', '审核'] }, |
| | | { code: 'car-wash-benefits', name: '洗车权益管理', actions: ['查看', '审核'] }, |
| | | { code: 'fuel-services', name: '加油服务管理', actions: ['查看', '审核'] }, |
| | | { code: 'spouse-employment', name: '配偶就业管理', actions: ['查看', '审核'] }, |
| | | { code: 'hotel-reservations', name: '酒店预约管理', actions: ['查看', '审核'] }, |
| | | ], |
| | | }, |
| | | { name: '财务管理', pages: [{ code: 'merchant-settlement', name: '商户结算审核', actions: ['查看', '审核'] }] }, |
| | | { |
| | | name: '运营管理', |
| | | pages: [ |
| | | { code: 'equity-images', name: '权益图片管理', actions: ['查看', '编辑'] }, |
| | | { code: 'news', name: '人才资讯管理', actions: ['查看', '新增', '编辑', '删除'] }, |
| | | { code: 'policies', name: '人才政策管理', actions: ['查看', '新增', '编辑', '删除'] }, |
| | | { code: 'movie-tickets', name: '电影票管理', actions: ['查看', '新增', '编辑', '审核'] }, |
| | | ], |
| | | }, |
| | | { |
| | | name: '系统管理', |
| | | pages: [ |
| | | { code: 'settings', name: '平台设置', actions: ['查看', '编辑'] }, |
| | | { code: 'protocol-settings', name: '协议设置', actions: ['查看', '编辑'] }, |
| | | { code: 'roles', name: '角色管理', actions: ['查看', '新增', '编辑', '删除'] }, |
| | | { code: 'staff', name: '员工管理', actions: ['查看', '新增', '编辑', '启用/停用', '删除', '重置密码'] }, |
| | | ], |
| | | }, |
| | | ] |
| | | const route = useRoute(), router = useRouter(), saving = ref(false) |
| | | const id = computed(() => route.params.id ? String(route.params.id) : '') |
| | | const form = reactive({ roleName: '', remark: '', status: 1, permissionCodes: [] as string[] }) |
| | | const formRef = ref<FormInstance>() |
| | | const rules: FormRules = { roleName: [{ required: true, message: '请输入角色名称', trigger: 'blur' }] } |
| | | const code = (page: string, action: string) => `admin.${page}.${action === '启用/禁用' ? 'disable' : action === '重置密码' ? 'reset' : action === '退款处理' ? 'refund' : action === '查看' ? 'view' : action === '新增' ? 'create' : action === '编辑' ? 'edit' : action === '删除' ? 'delete' : 'audit'}` |
| | | const code = (page: string, action: string) => `admin.${page}.${({ |
| | | '查看': 'view', |
| | | '新增': 'create', |
| | | '编辑': 'edit', |
| | | '启用/停用': 'disable', |
| | | '删除': 'delete', |
| | | '重置密码': 'reset', |
| | | '审核': 'audit', |
| | | } as Record<string, string>)[action] || 'view'}` |
| | | const pageCodes = (page: Page) => page.actions.map((action) => code(page.code, action)) |
| | | const moduleCodes = (module: Module) => module.pages.flatMap(pageCodes) |
| | | const checked = (values: string[]) => values.every((value) => form.permissionCodes.includes(value)) |
| | |
| | | import { Plus, Refresh, Search } from "@element-plus/icons-vue"; |
| | | import { |
| | | createAdminStaff, |
| | | checkAdminStaffAccount, |
| | | deleteAdminStaff, |
| | | getAdminRoles, |
| | | getAdminStaff, |
| | |
| | | status: 1, |
| | | }); |
| | | const formRef = ref<FormInstance>(); |
| | | const accountTaken = ref(false); |
| | | const accountPattern = /^[A-Za-z0-9]+$/; |
| | | const rules = computed<FormRules>(() => ({ |
| | | displayName: [{ required: true, message: "请输入人员名称", trigger: "blur" }], |
| | |
| | | password: [ |
| | | { |
| | | validator: (_rule, value, callback) => { |
| | | if (editId.value) return callback(); |
| | | if (!value) return callback(new Error("请输入登录密码")); |
| | | if (String(value).length < 6) return callback(new Error("新建员工密码至少 6 位")); |
| | | if (!value) { |
| | | if (editId.value) return callback(); |
| | | return callback(new Error("请输入登录密码")); |
| | | } |
| | | if (String(value).length < 6) return callback(new Error("密码至少 6 位")); |
| | | callback(); |
| | | }, |
| | | trigger: "blur", |
| | |
| | | confirmPassword: [ |
| | | { |
| | | validator: (_rule, value, callback) => { |
| | | if (editId.value) return callback(); |
| | | if (!form.password) { |
| | | if (editId.value) return callback(); |
| | | return callback(new Error("请输入确认密码")); |
| | | } |
| | | if (!value) return callback(new Error("请输入确认密码")); |
| | | if (value !== form.password) return callback(new Error("两次输入的密码不一致")); |
| | | callback(); |
| | |
| | | const keepMobileDigits = () => { |
| | | form.mobile = form.mobile.replace(/\D/g, "").slice(0, 11); |
| | | }; |
| | | const normalizeAccount = () => { |
| | | form.account = form.account.replace(/[^A-Za-z0-9]/g, "").slice(0, 32); |
| | | accountTaken.value = false; |
| | | }; |
| | | async function checkAccountAvailable() { |
| | | if (editId.value || !accountPattern.test(form.account.trim())) return; |
| | | const result = await checkAdminStaffAccount(form.account.trim()); |
| | | accountTaken.value = !result.available; |
| | | } |
| | | async function load() { |
| | | loading.value = true; |
| | | try { |
| | |
| | | } |
| | | function create() { |
| | | editId.value = undefined; |
| | | accountTaken.value = false; |
| | | Object.assign(form, { |
| | | displayName: "", |
| | | mobile: "", |
| | |
| | | } |
| | | function edit(row: AdminStaff) { |
| | | editId.value = row.id; |
| | | accountTaken.value = false; |
| | | Object.assign(form, { |
| | | displayName: row.displayName, |
| | | mobile: row.mobile || "", |
| | |
| | | async function save() { |
| | | const valid = await formRef.value?.validate().catch(() => false); |
| | | if (!valid) return; |
| | | if (!editId.value) { |
| | | await checkAccountAvailable(); |
| | | if (accountTaken.value) return; |
| | | } |
| | | saving.value = true; |
| | | try { |
| | | const { confirmPassword, ...formPayload } = form; |
| | |
| | | }}</template></el-table-column |
| | | ><el-table-column label="操作" width="225" fixed="right" |
| | | ><template #default="{ row }" |
| | | ><el-button link type="primary" @click="edit(row)">编辑</el-button |
| | | ><el-button v-if="!isDefaultAdmin(row)" link type="primary" @click="edit(row)">编辑</el-button |
| | | ><el-button |
| | | v-if="!isDefaultAdmin(row)" |
| | | link |
| | |
| | | destroy-on-close |
| | | :close-on-click-modal="false" |
| | | ><el-form ref="formRef" :model="form" :rules="rules" label-width="92px" |
| | | ><el-form-item label="人员名称" prop="displayName" |
| | | ><el-form-item label="人员名称" prop="displayName" required |
| | | ><el-input v-model="form.displayName" /></el-form-item |
| | | ><el-form-item label="联系电话" |
| | | ><el-input |
| | |
| | | inputmode="numeric" |
| | | maxlength="11" |
| | | @input="keepMobileDigits" /></el-form-item |
| | | ><el-form-item label="登录账号" prop="account" |
| | | ><el-form-item label="登录账号" prop="account" required :error="accountTaken ? '登录账号已存在,请更换' : ''" |
| | | ><el-input |
| | | v-model="form.account" |
| | | :disabled="Boolean(editId)" |
| | | maxlength="32" |
| | | placeholder="仅支持英文和数字" |
| | | /></el-form-item |
| | | @input="normalizeAccount" |
| | | @blur="checkAccountAvailable" /></el-form-item |
| | | ><el-form-item label="登录密码" prop="password" :required="!editId" |
| | | ><el-input |
| | | v-model="form.password" |
| | |
| | | :placeholder=" |
| | | editId ? '留空则不修改' : '至少 6 位' |
| | | " /></el-form-item |
| | | ><el-form-item v-if="!editId" label="确认密码" prop="confirmPassword" required |
| | | ><el-form-item v-if="!editId || form.password" label="确认密码" prop="confirmPassword" :required="!editId || Boolean(form.password)" |
| | | ><el-input |
| | | v-model="form.confirmPassword" |
| | | type="password" |
| | | show-password |
| | | placeholder="请再次输入密码" /></el-form-item |
| | | ><el-form-item label="角色" prop="roleCode" |
| | | ><el-form-item label="角色" prop="roleCode" required |
| | | ><el-select v-model="form.roleCode" class="full" |
| | | ><el-option |
| | | v-for="role in roles" |
| | | :key="role.id" |
| | | :label="role.roleName" |
| | | :value="role.roleCode" /></el-select></el-form-item |
| | | ><el-form-item label="职务" prop="positionName" |
| | | ><el-form-item label="职务" prop="positionName" required |
| | | ><el-input v-model="form.positionName" /></el-form-item |
| | | ><el-form-item label="客服微信" |
| | | ><el-input v-model="form.customerWechat" /></el-form-item |
| | |
| | | .filters { |
| | | margin: 14px 20px 0; |
| | | padding: 16px; |
| | | border: 1px solid #dce3ea; |
| | | border-radius: 4px; |
| | | } |
| | | .filters :deep(.el-input), |
| | | .filters :deep(.el-select) { |
| | |
| | | <div class="talent-detail-page talent-create-page"> |
| | | <el-form ref="formRef" :model="form" :rules="rules" label-width="104px"> |
| | | <section class="detail-block create-mobile-summary"> |
| | | <el-form-item label="手机号码:" prop="mobile"> |
| | | <el-form-item label="手机号码:" prop="mobile" required> |
| | | <el-input |
| | | v-model="form.mobile" |
| | | placeholder="请输入" |
| | |
| | | <h3>持卡人认证</h3> |
| | | <div class="identity-edit-card"> |
| | | <div class="identity-edit-form"> |
| | | <el-form-item label="持卡人姓名:" prop="name"> |
| | | <el-form-item label="持卡人姓名:" prop="name" required> |
| | | <el-input v-model="form.name" placeholder="请输入" /> |
| | | </el-form-item> |
| | | <el-form-item label="身份证号:" prop="idCardNo"> |
| | | <el-form-item label="身份证号:" prop="idCardNo" required> |
| | | <el-input |
| | | v-model="form.idCardNo" |
| | | placeholder="请输入18位身份证号" |
| | |
| | | @input="normalizeIdCardNo" |
| | | /> |
| | | </el-form-item> |
| | | <el-form-item label="联系方式:" prop="mobile"> |
| | | <el-form-item label="联系方式:" prop="mobile" required> |
| | | <el-input |
| | | v-model="form.mobile" |
| | | placeholder="请输入" |
| | |
| | | @input="normalizeMobile" |
| | | /> |
| | | </el-form-item> |
| | | <el-form-item label="联系地址:" prop="contactAddress"> |
| | | <el-form-item label="联系地址:" prop="contactAddress" required> |
| | | <el-input v-model="form.contactAddress" placeholder="请输入" /> |
| | | </el-form-item> |
| | | <el-form-item label="工作地址:" prop="workAddress"> |
| | | <el-form-item label="工作地址:" prop="workAddress" required> |
| | | <el-input v-model="form.workAddress" placeholder="请输入" /> |
| | | </el-form-item> |
| | | <el-form-item label="性别:" prop="gender"> |
| | | <el-form-item label="性别:" prop="gender" required> |
| | | <el-select v-model="form.gender" placeholder="选择"> |
| | | <el-option label="男" :value="1" /> |
| | | <el-option label="女" :value="2" /> |
| | |
| | | |
| | | <section class="detail-block"> |
| | | <h3>人才卡管理</h3> |
| | | <el-form-item label="人才等级/卡号" prop="talentCardNo"> |
| | | <el-form-item label="人才等级/卡号" prop="talentCardNo" required> |
| | | <div class="card-manage-panel"> |
| | | <div class="talent-card-stack"> |
| | | <button type="button" class="talent-card-preview create-card-preview" @click="openCardDialog"> |
| | |
| | | |
| | | <section class="detail-block"> |
| | | <h3>专属服务人员</h3> |
| | | <el-form-item label="专属服务人员" prop="serviceStaff" class="service-staff-form-item"> |
| | | <el-form-item label="专属服务人员" prop="serviceStaff" class="service-staff-form-item" required> |
| | | <div class="service-staff-panel"> |
| | | <el-select v-model="serviceStaff" clearable placeholder="请选择专属服务人员" @change="formRef?.validateField('serviceStaff')"> |
| | | <el-option |
| | |
| | | :close-on-click-modal="false" |
| | | > |
| | | <el-form ref="cardFormRef" :model="cardForm" :rules="cardRules" label-width="100px"> |
| | | <el-form-item label="人才等级" prop="talentLevel"> |
| | | <el-form-item label="人才等级" prop="talentLevel" required> |
| | | <div class="talent-level-options"> |
| | | <button |
| | | v-for="level in levels" |
| | |
| | | </el-radio-group> |
| | | |
| | | <div class="talent-card-number-field"> |
| | | <el-form-item label="人才卡号" prop="talentCardNo"> |
| | | <el-form-item label="人才卡号" prop="talentCardNo" required> |
| | | <el-input |
| | | v-model="cardForm.talentCardNo" |
| | | maxlength="64" |
| | |
| | | :close-on-click-modal="false" |
| | | > |
| | | <el-form ref="levelFormRef" :model="{ talentLevel: pendingTalentLevel }" :rules="levelRules" label-width="88px"> |
| | | <el-form-item label="人才等级" prop="talentLevel"> |
| | | <el-form-item label="人才等级" prop="talentLevel" required> |
| | | <div class="talent-level-options"> |
| | | <button |
| | | v-for="level in talentLevels" |
| | |
| | | <section class="news-publish-panel"> |
| | | <h3>资讯内容</h3> |
| | | |
| | | <el-form ref="formRef" class="news-publish-form" :model="form" :rules="rules" label-width="72px" label-position="left"> |
| | | <el-form-item label="资讯标题" prop="title"> |
| | | <el-form ref="formRef" class="news-publish-form" :model="form" :rules="rules" label-width="96px" label-position="left"> |
| | | <el-form-item label="资讯标题" prop="title" required> |
| | | <el-input v-model="form.title" placeholder="资讯标题" /> |
| | | </el-form-item> |
| | | |
| | | <el-form-item label="资讯标签" prop="summary"> |
| | | <el-input v-model="form.summary" placeholder="请输入资讯标签" /> |
| | | <el-form-item label="资讯标签" prop="summary" required> |
| | | <el-input v-model="form.summary" placeholder="资讯标签" /> |
| | | </el-form-item> |
| | | |
| | | <el-form-item class="news-editor-form-item" label="资讯内容" prop="content"> |
| | | <el-form-item class="news-editor-form-item" label="资讯内容" prop="content" required> |
| | | <AdminRichTextEditor v-model="form.content" placeholder="请输入资讯内容" @update:model-value="validateContent" /> |
| | | </el-form-item> |
| | | |
| | |
| | | <el-switch v-model="form.pinned" /> |
| | | </el-form-item> |
| | | |
| | | <el-form-item label="封面图" prop="coverUrl"> |
| | | <el-upload accept="image/*" :auto-upload="false" :show-file-list="false" :on-change="uploadCover"> |
| | | <el-button>上传图片</el-button> |
| | | <el-form-item label="封面图" prop="coverUrl" required> |
| | | <el-upload class="article-cover-upload" accept="image/*" :auto-upload="false" :show-file-list="false" :on-change="uploadCover"> |
| | | <el-image v-if="form.coverUrl" class="article-cover-preview" :src="form.coverUrl" fit="cover" /> |
| | | <div v-else class="article-cover-upload-card"><span>+</span><small>上传图片</small></div> |
| | | </el-upload> |
| | | <el-image v-if="form.coverUrl" class="news-cover-preview" :src="form.coverUrl" fit="cover" /> |
| | | </el-form-item> |
| | | |
| | | <el-form-item> |
| | |
| | | <section class="news-publish-panel"> |
| | | <h3>资讯内容</h3> |
| | | |
| | | <el-form ref="formRef" class="news-publish-form" :model="form" :rules="rules" label-width="72px" label-position="left"> |
| | | <el-form-item label="资讯标题" prop="title"> |
| | | <el-form ref="formRef" class="news-publish-form" :model="form" :rules="rules" label-width="96px" label-position="left"> |
| | | <el-form-item label="资讯标题" prop="title" required> |
| | | <el-input v-model="form.title" placeholder="资讯标题" /> |
| | | </el-form-item> |
| | | |
| | | <el-form-item label="资讯标签" prop="summary"> |
| | | <el-input v-model="form.summary" placeholder="请输入资讯标签" /> |
| | | <el-form-item label="资讯标签" prop="summary" required> |
| | | <el-input v-model="form.summary" placeholder="资讯标签" /> |
| | | </el-form-item> |
| | | |
| | | <el-form-item class="news-editor-form-item" label="资讯内容" prop="content"> |
| | | <el-form-item class="news-editor-form-item" label="资讯内容" prop="content" required> |
| | | <AdminRichTextEditor v-model="form.content" placeholder="请输入资讯内容" @update:model-value="validateContent" /> |
| | | </el-form-item> |
| | | |
| | |
| | | <el-switch v-model="form.pinned" /> |
| | | </el-form-item> |
| | | |
| | | <el-form-item label="封面图" prop="coverUrl"> |
| | | <el-upload accept="image/*" :auto-upload="false" :show-file-list="false" :on-change="uploadCover"> |
| | | <el-button>上传图片</el-button> |
| | | <el-form-item label="封面图" prop="coverUrl" required> |
| | | <el-upload class="article-cover-upload" accept="image/*" :auto-upload="false" :show-file-list="false" :on-change="uploadCover"> |
| | | <el-image v-if="form.coverUrl" class="article-cover-preview" :src="form.coverUrl" fit="cover" /> |
| | | <div v-else class="article-cover-upload-card"><span>+</span><small>上传图片</small></div> |
| | | </el-upload> |
| | | <el-image v-if="form.coverUrl" class="news-cover-preview" :src="form.coverUrl" fit="cover" /> |
| | | </el-form-item> |
| | | |
| | | <el-form-item> |
| | |
| | | </script> |
| | | <template> |
| | | <div class="policy-edit-page" v-loading="loading"><section class="news-meta-panel"><h3>发布信息</h3><div class="news-meta-grid"><div><span>发布时间</span><strong>{{ createdAt }}</strong></div><div><span>修改时间</span><strong>{{ updatedAt }}</strong></div></div></section><section class="policy-publish-panel"><h3>政策内容</h3><el-form ref="formRef" class="policy-publish-form" :model="form" :rules="rules" label-width="88px"> |
| | | <el-form-item label="政策标题" prop="title"><el-input v-model="form.title" /></el-form-item><el-form-item label="政策标签" prop="subtitle"><el-input v-model="form.subtitle" placeholder="请输入政策标签" /></el-form-item><el-form-item class="news-editor-form-item" label="政策内容" prop="content"><AdminRichTextEditor v-model="form.content" @update:model-value="validateContent" /></el-form-item><el-form-item label="封面图" prop="coverUrl"><el-upload accept="image/*" :auto-upload="false" :show-file-list="false" :on-change="uploadCover"><el-button>上传图片</el-button></el-upload><el-image v-if="form.coverUrl" class="policy-cover-preview" :src="form.coverUrl" fit="cover" /></el-form-item><el-form-item label="排序" prop="sortNo"><el-input v-model.number="form.sortNo" type="number" placeholder="请输入非负整数" /></el-form-item><el-form-item><el-button type="primary" @click="save">保存</el-button></el-form-item> |
| | | <el-form-item label="政策标题" prop="title"><el-input v-model="form.title" /></el-form-item><el-form-item label="政策标签" prop="subtitle"><el-input v-model="form.subtitle" placeholder="请输入政策标签" /></el-form-item><el-form-item class="news-editor-form-item" label="政策内容" prop="content"><AdminRichTextEditor v-model="form.content" @update:model-value="validateContent" /></el-form-item><el-form-item label="封面图" prop="coverUrl"><el-upload class="article-cover-upload" accept="image/*" :auto-upload="false" :show-file-list="false" :on-change="uploadCover"><el-image v-if="form.coverUrl" class="article-cover-preview" :src="form.coverUrl" fit="cover" /><div v-else class="article-cover-upload-card"><span>+</span><small>上传图片</small></div></el-upload></el-form-item><el-form-item label="排序" prop="sortNo"><el-input v-model.number="form.sortNo" type="number" placeholder="请输入非负整数" /></el-form-item><el-form-item><el-button type="primary" @click="save">保存</el-button></el-form-item> |
| | | </el-form></section></div> |
| | | </template> |
| | |
| | | </script> |
| | | <template> |
| | | <div class="policy-publish-page"><section class="policy-publish-panel"><h3>政策内容</h3><el-form ref="formRef" class="policy-publish-form" :model="form" :rules="rules" label-width="88px"> |
| | | <el-form-item label="政策标题" prop="title"><el-input v-model="form.title" placeholder="请输入政策标题" /></el-form-item> |
| | | <el-form-item label="政策标签" prop="subtitle"><el-input v-model="form.subtitle" placeholder="请输入政策标签" /></el-form-item> |
| | | <el-form-item class="news-editor-form-item" label="政策内容" prop="content"><AdminRichTextEditor v-model="form.content" @update:model-value="validateContent" /></el-form-item> |
| | | <el-form-item label="封面图" prop="coverUrl"><el-upload accept="image/*" :auto-upload="false" :show-file-list="false" :on-change="uploadCover"><el-button>上传图片</el-button></el-upload><el-image v-if="form.coverUrl" class="policy-cover-preview" :src="form.coverUrl" fit="cover" /></el-form-item> |
| | | <el-form-item label="排序" prop="sortNo"><el-input v-model.number="form.sortNo" type="number" placeholder="请输入非负整数" /></el-form-item> |
| | | <el-form-item label="政策标题" prop="title" required><el-input v-model="form.title" placeholder="请输入政策标题" /></el-form-item> |
| | | <el-form-item label="政策标签" prop="subtitle" required><el-input v-model="form.subtitle" placeholder="请输入政策标签" /></el-form-item> |
| | | <el-form-item class="news-editor-form-item" label="政策内容" prop="content" required><AdminRichTextEditor v-model="form.content" @update:model-value="validateContent" /></el-form-item> |
| | | <el-form-item label="封面图" prop="coverUrl" required><el-upload class="article-cover-upload" accept="image/*" :auto-upload="false" :show-file-list="false" :on-change="uploadCover"><el-image v-if="form.coverUrl" class="article-cover-preview" :src="form.coverUrl" fit="cover" /><div v-else class="article-cover-upload-card"><span>+</span><small>上传图片</small></div></el-upload></el-form-item> |
| | | <el-form-item label="排序" prop="sortNo" required><el-input v-model.number="form.sortNo" type="number" placeholder="请输入非负整数" /></el-form-item> |
| | | <el-form-item><el-button type="primary" @click="save">保存</el-button></el-form-item> |
| | | </el-form></section></div> |
| | | </template> |
| | |
| | | <div>申请时间:<strong>{{ formatDate(detail.applyTime) }}</strong></div> |
| | | <div class="full-line">申请理由:<strong>{{ text(detail.applyReason) }}</strong></div> |
| | | </div> |
| | | <div v-for="honor in (detail.honors || [])" :key="honor.id" class="honor-block"> |
| | | <div v-for="honor in (detail.honors || [])" :key="honor.id" class="upgrade-detail-grid honor-block"> |
| | | <div>新获得的荣誉称号/大奖项:<strong>{{ text(honor.honorName) }}</strong></div> |
| | | <div>获得时间:<strong>{{ text(honor.obtainedDate) }}</strong></div> |
| | | <div class="honor-images"> |
| | | <div class="honor-images full-line"> |
| | | <el-image |
| | | v-if="imageUrl(honor)" |
| | | :src="resolveImageUrl(imageUrl(honor))" |
| | |
| | | .upgrade-detail-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px 36px; padding: 18px; background: #f5f6f8; color: #606266; } |
| | | .upgrade-detail-grid strong { color: #303133; font-weight: 500; } |
| | | .full-line { grid-column: 1 / -1; } |
| | | .honor-block { margin-top: 14px; padding: 16px; background: #f5f6f8; color: #606266; } |
| | | .honor-images { display: flex; gap: 20px; min-height: 130px; margin-top: 14px; } |
| | | .honor-block { margin-top: 14px; } |
| | | .honor-images { display: flex; gap: 20px; align-items: flex-start; } |
| | | .honor-image { width: 150px; height: 110px; background: #e9eaec; cursor: zoom-in; } |
| | | .honor-images span { display: flex; align-items: center; justify-content: center; width: 150px; height: 110px; color: #909399; background: #e9eaec; } |
| | | .upgrade-detail-actions { display: flex; align-items: center; gap: 12px; padding: 18px 0; } |
| | |
| | | return ApiResponse.success(adminBusinessService.dashboard()); |
| | | } |
| | | |
| | | @GetMapping("/pending-notices") |
| | | public ApiResponse<java.util.List<Map<String, Object>>> pendingNotices() { |
| | | return ApiResponse.success(adminBusinessService.pendingNotices()); |
| | | } |
| | | |
| | | @GetMapping("/news") |
| | | public ApiResponse<PageResult<Map<String, Object>>> news( |
| | | @RequestParam(required = false) String title, |
| | |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.time.LocalDate; |
| | | import java.time.LocalTime; |
| | | import java.time.ZoneId; |
| | | import java.time.format.DateTimeParseException; |
| | | import java.util.ArrayList; |
| | | import java.util.Arrays; |
| | | import java.util.HashSet; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | |
| | | StringBuilder sql = new StringBuilder(""" |
| | | select m.id, m.merchant_name, m.merchant_type, m.contact_name, m.contact_mobile, |
| | | m.logo_url, m.cover_url, m.address, m.is_recommend, m.sort_no, m.status, |
| | | case |
| | | when m.status <> 1 then '休息中' |
| | | when lower(coalesce(m.business_status, 'open')) in ('closed', '休息中') then '休息中' |
| | | when exists ( |
| | | select 1 |
| | | from merchant_business_hours h |
| | | join merchant_store s on s.id = h.store_id and s.status = 1 and s.deleted = 0 |
| | | where h.merchant_id = m.id |
| | | and ( |
| | | (h.start_time <= h.end_time and current_time() >= h.start_time and current_time() < h.end_time) |
| | | or (h.start_time > h.end_time and (current_time() >= h.start_time or current_time() < h.end_time)) |
| | | ) |
| | | ) then '营业中' |
| | | else '休息中' |
| | | end as business_status, m.created_at, |
| | | m.created_at, |
| | | (select count(*) from merchant_product p where p.merchant_id = m.id and p.deleted = 0) as product_count, |
| | | (select count(*) from benefit_order o where o.merchant_id = m.id and o.deleted = 0) as order_count, |
| | | 0 as reservation_count |
| | |
| | | args.add(createdEnd); |
| | | } |
| | | sql.append(" order by m.is_recommend desc, m.sort_no desc, m.id desc"); |
| | | return page(sql.toString(), countSql.toString(), page, size, args.toArray()); |
| | | PageResult<Map<String, Object>> result = page(sql.toString(), countSql.toString(), page, size, args.toArray()); |
| | | applyBusinessStatus(result.records()); |
| | | return result; |
| | | } |
| | | |
| | | @Transactional(readOnly = true) |
| | | public List<Map<String, Object>> pendingNotices() { |
| | | List<Map<String, Object>> notices = new ArrayList<>(); |
| | | addPendingNotice(notices, "product-audit", "团单审核", "您有 %d 条团单待审核", "/products", |
| | | "select count(*) as pending_count, max(coalesce(submit_time, created_at)) as latest_time from merchant_product where deleted = 0 and audit_status = 1"); |
| | | addPendingNotice(notices, "family-card", "家属卡申请", "您有 %d 条家属卡申请待审核", "/family-card-applications", |
| | | "select count(*) as pending_count, max(apply_time) as latest_time from family_card_application where deleted = 0 and status = '待审核'"); |
| | | addPendingNotice(notices, "upgrade", "升级申请", "您有 %d 条升级申请待审核", "/upgrade-applications", |
| | | "select count(*) as pending_count, max(apply_time) as latest_time from upgrade_application where deleted = 0 and status = '待审核'"); |
| | | addPendingNotice(notices, "merchant-onboarding", "商户进件", "您有 %d 条商户进件待审核", "/merchant-settlement?kind=onboardings", |
| | | "select count(*) as pending_count, max(created_at) as latest_time from merchant_onboarding_application where status = 'pending'"); |
| | | addPendingNotice(notices, "merchant-bank-card", "银行卡申请", "您有 %d 条银行卡申请待审核", "/merchant-settlement?kind=bank-cards", |
| | | "select count(*) as pending_count, max(created_at) as latest_time from merchant_bank_card where status = 'pending'"); |
| | | addPendingNotice(notices, "merchant-withdrawal", "提现审核", "您有 %d 条提现申请待审核", "/merchant-settlement?kind=withdrawals", |
| | | "select count(*) as pending_count, max(created_at) as latest_time from merchant_withdrawal where status = 'pending'"); |
| | | addPendingNotice(notices, "medical-reservation", "体检预约", "您有 %d 条体检预约待处理", "/medical-reservations", |
| | | "select count(*) as pending_count, max(submitted_at) as latest_time from service_application where deleted = 0 and application_type = 'MEDICAL_RESERVATION' and status = '待处理'"); |
| | | addPendingNotice(notices, "medical-application", "就医申请", "您有 %d 条就医申请待处理", "/medical-applications", |
| | | "select count(*) as pending_count, max(submitted_at) as latest_time from service_application where deleted = 0 and application_type = 'MEDICAL_APPLICATION' and status = '待处理'"); |
| | | notices.sort((left, right) -> String.valueOf(right.get("time")).compareTo(String.valueOf(left.get("time")))); |
| | | return notices; |
| | | } |
| | | |
| | | private void addPendingNotice(List<Map<String, Object>> notices, String id, String category, String template, String path, String sql) { |
| | | Map<String, Object> summary = DatabaseRows.normalize(jdbcTemplate.queryForMap(sql)); |
| | | long count = summary.get("pendingCount") instanceof Number number ? number.longValue() : 0; |
| | | if (count == 0) return; |
| | | Map<String, Object> notice = new LinkedHashMap<>(); |
| | | notice.put("id", id); |
| | | notice.put("category", category); |
| | | notice.put("content", template.formatted(count)); |
| | | notice.put("time", summary.get("latestTime")); |
| | | notice.put("path", path); |
| | | notices.add(notice); |
| | | } |
| | | |
| | | @Transactional(readOnly = true) |
| | |
| | | @Transactional |
| | | public Map<String, Object> createMerchant(MerchantCreateRequest request) { |
| | | validateMerchantAddress(request.merchantType(), request.address()); |
| | | validateScenicMerchantCreate(request); |
| | | validateRequiredMerchantCreateFields(request); |
| | | jdbcTemplate.update(""" |
| | | 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, |
| | |
| | | return MERCHANT_APP_LOGIN_TYPES.contains(merchantType); |
| | | } |
| | | |
| | | private void validateScenicMerchantCreate(MerchantCreateRequest request) { |
| | | if (!"景区服务".equals(request.merchantType())) { |
| | | private void validateRequiredMerchantCreateFields(MerchantCreateRequest request) { |
| | | if (!Set.of("景区服务", "餐饮服务").contains(request.merchantType())) { |
| | | return; |
| | | } |
| | | if (request.businessHours() == null || request.businessHours().isEmpty() |
| | |
| | | return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total); |
| | | } |
| | | |
| | | private void applyBusinessStatus(List<Map<String, Object>> merchants) { |
| | | if (merchants.isEmpty()) return; |
| | | List<Long> merchantIds = merchants.stream().map(row -> Long.valueOf(String.valueOf(row.get("id")))).toList(); |
| | | String placeholders = String.join(",", java.util.Collections.nCopies(merchantIds.size(), "?")); |
| | | List<Map<String, Object>> periods = DatabaseRows.normalize(jdbcTemplate.queryForList(""" |
| | | select h.merchant_id, h.start_time, h.end_time |
| | | from merchant_business_hours h |
| | | join merchant_store s on s.id = h.store_id |
| | | where s.status = 1 and s.deleted = 0 and h.merchant_id in (""" + placeholders + ")", merchantIds.toArray())); |
| | | LocalTime now = LocalTime.now(ZoneId.of("Asia/Shanghai")); |
| | | Set<Long> openMerchantIds = new HashSet<>(); |
| | | for (Map<String, Object> period : periods) { |
| | | if (isWithinBusinessPeriod(now, String.valueOf(period.get("startTime")), String.valueOf(period.get("endTime")))) { |
| | | openMerchantIds.add(Long.valueOf(String.valueOf(period.get("merchantId")))); |
| | | } |
| | | } |
| | | for (Map<String, Object> merchant : merchants) { |
| | | Long merchantId = Long.valueOf(String.valueOf(merchant.get("id"))); |
| | | boolean enabled = "1".equals(String.valueOf(merchant.get("status"))); |
| | | merchant.put("businessStatus", enabled && openMerchantIds.contains(merchantId) ? "营业中" : "休息中"); |
| | | } |
| | | } |
| | | |
| | | private boolean isWithinBusinessPeriod(LocalTime now, String startValue, String endValue) { |
| | | try { |
| | | LocalTime start = LocalTime.parse(startValue); |
| | | LocalTime end = LocalTime.parse(endValue); |
| | | return start.isBefore(end) |
| | | ? !now.isBefore(start) && now.isBefore(end) |
| | | : !now.isBefore(start) || now.isBefore(end); |
| | | } catch (DateTimeParseException ignored) { |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | private void ensureUpdated(int updated) { |
| | | if (updated == 0) { |
| | | throw new BusinessException(ErrorCode.NOT_FOUND, "Data not found"); |
| | |
| | | @PatchMapping("/roles/{id}/status") public ApiResponse<Map<String,Object>> updateRoleStatus(@PathVariable Long id, @Valid @RequestBody AdminRoleStatusRequest request) { return ApiResponse.success(service.updateRoleStatus(id, request.status())); } |
| | | @DeleteMapping("/roles/{id}") public ApiResponse<Map<String,Object>> deleteRole(@PathVariable Long id) { return ApiResponse.success(service.deleteRole(id)); } |
| | | @GetMapping("/staff") public ApiResponse<List<Map<String,Object>>> staff() { return ApiResponse.success(service.staff()); } |
| | | @GetMapping("/staff/account-available") public ApiResponse<Map<String,Object>> staffAccountAvailable(@RequestParam String account) { return ApiResponse.success(service.staffAccountAvailable(account)); } |
| | | @PostMapping("/staff") public ApiResponse<Map<String,Object>> createStaff(@Valid @RequestBody AdminStaffRequest request) { return ApiResponse.success(service.createStaff(request)); } |
| | | @PutMapping("/staff/{id}") public ApiResponse<Map<String,Object>> updateStaff(@PathVariable Long id, @Valid @RequestBody AdminStaffRequest request) { return ApiResponse.success(service.updateStaff(id, request)); } |
| | | @PatchMapping("/staff/{id}/password") public ApiResponse<Map<String,Object>> resetStaffPassword(@PathVariable Long id, @RequestBody Map<String,String> body) { return ApiResponse.success(service.resetStaffPassword(id, body.get("password"))); } |
| | |
| | | |
| | | public List<Map<String, Object>> roles() { return rows("select id, role_name, role_code, remark, permission_codes, status, created_at from admin_role order by id"); } |
| | | @Transactional public Map<String, Object> createRole(AdminRoleRequest request) { String code = roleCode(request.roleCode()); jdbc.update("insert into admin_role(role_name,role_code,remark,permission_codes,status) values (?,?,?,?,?)", request.roleName().trim(), code, request.remark(), permissions(request.permissionCodes()), request.status()); return one("select id, role_name, role_code, remark, permission_codes, status, created_at from admin_role where id=last_insert_id()"); } |
| | | @Transactional public Map<String, Object> updateRole(Long id, AdminRoleRequest request) { String code = request.roleCode() == null || request.roleCode().isBlank() ? existingRoleCode(id) : request.roleCode().trim(); if (jdbc.update("update admin_role set role_name=?,role_code=?,remark=?,permission_codes=?,status=? where id=?", request.roleName().trim(), code, request.remark(), permissions(request.permissionCodes()), request.status(), id) == 0) throw new BusinessException(ErrorCode.NOT_FOUND, "Role does not exist"); return one("select id, role_name, role_code, remark, permission_codes, status, created_at from admin_role where id=?", id); } |
| | | @Transactional public Map<String, Object> updateRole(Long id, AdminRoleRequest request) { if ("platform_admin".equals(existingRoleCode(id))) throw new BusinessException(ErrorCode.CONFLICT, "超级管理员角色不可编辑"); String code = request.roleCode() == null || request.roleCode().isBlank() ? existingRoleCode(id) : request.roleCode().trim(); if (jdbc.update("update admin_role set role_name=?,role_code=?,remark=?,permission_codes=?,status=? where id=?", request.roleName().trim(), code, request.remark(), permissions(request.permissionCodes()), request.status(), id) == 0) throw new BusinessException(ErrorCode.NOT_FOUND, "Role does not exist"); return one("select id, role_name, role_code, remark, permission_codes, status, created_at from admin_role where id=?", id); } |
| | | @Transactional public Map<String, Object> updateRoleStatus(Long id, Byte status) { String code = existingRoleCode(id); if (status == 0 && "platform_admin".equals(code)) throw new BusinessException(ErrorCode.CONFLICT, "平台管理员角色不可禁用"); jdbc.update("update admin_role set status=? where id=?", status, id); return one("select id, role_name, role_code, remark, permission_codes, status, created_at from admin_role where id=?", id); } |
| | | @Transactional public Map<String, Object> deleteRole(Long id) { String code = existingRoleCode(id); if ("platform_admin".equals(code)) throw new BusinessException(ErrorCode.CONFLICT, "平台管理员角色不可删除"); if (Boolean.TRUE.equals(jdbc.queryForObject("select exists(select 1 from admin_operator where role_code=? and deleted=0)", Boolean.class, code))) throw new BusinessException(ErrorCode.CONFLICT, "该角色仍有员工使用,不能删除"); jdbc.update("delete from admin_role where id=?", id); return Map.of("id", id, "deleted", true); } |
| | | |
| | | public List<Map<String, Object>> staff() { return rows("select id,account,display_name,mobile,role_code,position_name,customer_wechat,status,created_at from admin_operator where deleted=0 order by id"); } |
| | | @Transactional public Map<String, Object> createStaff(AdminStaffRequest request) { if (request.password() == null || request.password().isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "A password is required for a new staff account"); jdbc.update("insert into admin_operator(account,password_hash,display_name,mobile,role_code,position_name,customer_wechat,status) values (?,?,?,?,?,?,?,?)", request.account().trim(), MerchantPasswordHasher.encode(request.password()), request.displayName().trim(), blankToNull(request.mobile()), request.roleCode(), request.positionName().trim(), blankToNull(request.customerWechat()), request.status()); return staffOne("last_insert_id()"); } |
| | | @Transactional public Map<String, Object> updateStaff(Long id, AdminStaffRequest request) { if (!matchesExistingAccount(id, request.account())) throw new BusinessException(ErrorCode.CONFLICT, "登录账号不可修改"); if (request.status() == 0 && isDefaultAdmin(id)) throw new BusinessException(ErrorCode.CONFLICT, "超级管理员不可禁用"); String sql = request.password() == null || request.password().isBlank() ? "update admin_operator set account=?,display_name=?,mobile=?,role_code=?,position_name=?,customer_wechat=?,status=? where id=?" : "update admin_operator set account=?,password_hash=?,display_name=?,mobile=?,role_code=?,position_name=?,customer_wechat=?,status=? where id=?"; int updated = request.password() == null || request.password().isBlank() ? jdbc.update(sql, request.account().trim(), request.displayName().trim(), blankToNull(request.mobile()), request.roleCode(), request.positionName().trim(), blankToNull(request.customerWechat()), request.status(), id) : jdbc.update(sql, request.account().trim(), MerchantPasswordHasher.encode(request.password()), request.displayName().trim(), blankToNull(request.mobile()), request.roleCode(), request.positionName().trim(), blankToNull(request.customerWechat()), request.status(), id); if (updated == 0) throw new BusinessException(ErrorCode.NOT_FOUND, "Staff account does not exist"); return staffOne("?", id); } |
| | | public Map<String, Object> staffAccountAvailable(String account) { String normalized = account == null ? "" : account.trim(); if (!normalized.matches("^[A-Za-z0-9]{1,32}$")) return Map.of("available", false); Long count = jdbc.queryForObject("select count(*) from admin_operator where account=?", Long.class, normalized); return Map.of("available", count == null || count == 0); } |
| | | @Transactional public Map<String, Object> createStaff(AdminStaffRequest request) { if (request.password() == null || request.password().isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "A password is required for a new staff account"); if (!Boolean.TRUE.equals(staffAccountAvailable(request.account()).get("available"))) throw new BusinessException(ErrorCode.CONFLICT, "登录账号已存在,请更换"); jdbc.update("insert into admin_operator(account,password_hash,display_name,mobile,role_code,position_name,customer_wechat,status) values (?,?,?,?,?,?,?,?)", request.account().trim(), MerchantPasswordHasher.encode(request.password()), request.displayName().trim(), blankToNull(request.mobile()), request.roleCode(), request.positionName().trim(), blankToNull(request.customerWechat()), request.status()); return staffOne("last_insert_id()"); } |
| | | @Transactional public Map<String, Object> updateStaff(Long id, AdminStaffRequest request) { if (isDefaultAdmin(id)) throw new BusinessException(ErrorCode.CONFLICT, "超级管理员账号不可编辑"); if (!matchesExistingAccount(id, request.account())) throw new BusinessException(ErrorCode.CONFLICT, "登录账号不可修改"); String sql = request.password() == null || request.password().isBlank() ? "update admin_operator set account=?,display_name=?,mobile=?,role_code=?,position_name=?,customer_wechat=?,status=? where id=?" : "update admin_operator set account=?,password_hash=?,display_name=?,mobile=?,role_code=?,position_name=?,customer_wechat=?,status=? where id=?"; int updated = request.password() == null || request.password().isBlank() ? jdbc.update(sql, request.account().trim(), request.displayName().trim(), blankToNull(request.mobile()), request.roleCode(), request.positionName().trim(), blankToNull(request.customerWechat()), request.status(), id) : jdbc.update(sql, request.account().trim(), MerchantPasswordHasher.encode(request.password()), request.displayName().trim(), blankToNull(request.mobile()), request.roleCode(), request.positionName().trim(), blankToNull(request.customerWechat()), request.status(), id); if (updated == 0) throw new BusinessException(ErrorCode.NOT_FOUND, "Staff account does not exist"); return staffOne("?", id); } |
| | | @Transactional public Map<String, Object> resetStaffPassword(Long id, String password) { if (password == null || password.length() < 6 || password.length() > 64) throw new BusinessException(ErrorCode.BAD_REQUEST, "Password must be 6-64 characters"); int updated = jdbc.update("update admin_operator set password_hash=?, login_failed_count=0, locked_until=null where id=? and deleted=0", MerchantPasswordHasher.encode(password), id); if (updated == 0) throw new BusinessException(ErrorCode.NOT_FOUND, "Staff account does not exist"); return Map.of("id", id, "reset", true); } |
| | | @Transactional public Map<String, Object> deleteStaff(Long id) { int updated = jdbc.update("update admin_operator set deleted=1, status=0 where id=? and deleted=0 and account <> 'admin'", id); if (updated == 0) throw new BusinessException(ErrorCode.CONFLICT, "The account cannot be deleted or does not exist"); return Map.of("id", id, "deleted", true); } |
| | | |
| | |
| | | |
| | | import jakarta.validation.constraints.NotBlank; |
| | | import jakarta.validation.constraints.NotNull; |
| | | import jakarta.validation.constraints.Min; |
| | | import jakarta.validation.constraints.Size; |
| | | import jakarta.validation.Valid; |
| | | |
| | |
| | | String businessStatus, |
| | | Byte recommend, |
| | | @NotNull(message = "请输入排序") |
| | | @Min(value = 0, message = "排序只能输入非负整数") |
| | | Integer sortNo, |
| | | Double longitude, |
| | | Double latitude, |
| | |
| | | List<Map<String, Object>> rows = rows("select a.*, m.merchant_name, m.merchant_type, m.contact_mobile, s.store_name from merchant_onboarding_application a join merchant m on m.id = a.merchant_id join merchant_store s on s.id = a.store_id where a.id = ?", id); |
| | | if (rows.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "商户进件不存在"); |
| | | Map<String, Object> result = rows.get(0); |
| | | if ("individual".equalsIgnoreCase(String.valueOf(result.get("subjectType")))) { |
| | | String encrypted = String.valueOf(result.get("subjectIdNoEncrypted")); |
| | | if (!encrypted.isBlank() && !"null".equals(encrypted)) result.put("subjectIdNo", sensitiveDataCrypto.decrypt(encrypted)); |
| | | } |
| | | // 个人主体的历史记录可能使用 individual、personal 或中文类型值; |
| | | // 只要已保存加密身份证号,后台详情均应解密后完整展示。 |
| | | String encrypted = String.valueOf(result.get("subjectIdNoEncrypted")); |
| | | if (!encrypted.isBlank() && !"null".equals(encrypted)) result.put("subjectIdNo", sensitiveDataCrypto.decrypt(encrypted)); |
| | | return result; |
| | | } |
| | | @Transactional(readOnly = true) |
| | |
| | | PageResult<Map<String, Object>> result = page("select c.*, m.merchant_name, m.merchant_type, m.contact_mobile, s.store_name from merchant_bank_card c join merchant m on m.id = c.merchant_id join merchant_store s on s.id = c.store_id", "select count(*) from merchant_bank_card c join merchant m on m.id = c.merchant_id join merchant_store s on s.id = c.store_id", "c", page, size, merchantName, contactMobile, merchantType, status, startDate, endDate); |
| | | result.records().forEach(row -> { |
| | | String encrypted = String.valueOf(row.remove("cardNoEncrypted")); |
| | | if (!encrypted.isBlank() && !"null".equals(encrypted)) row.put("cardMasked", sensitiveDataCrypto.decrypt(encrypted)); |
| | | if (!encrypted.isBlank() && !"null".equals(encrypted)) row.put("cardNo", sensitiveDataCrypto.decrypt(encrypted)); |
| | | else row.put("cardNo", row.get("cardMasked")); |
| | | row.put("cityName", bankCardCityName(row.get("cityCode"))); |
| | | }); |
| | | return result; |
| | |
| | | Map<String, Object> merchant = merchant(principal); |
| | | String orderNo = BusinessIdGenerator.next("MC"); |
| | | jdbc.update(""" |
| | | insert into benefit_order(order_no, talent_id, card_id, merchant_id, store_id, order_type, |
| | | insert into benefit_order(order_no, talent_id, card_id, card_level_snapshot, merchant_id, store_id, order_type, |
| | | order_amount, subsidy_amount, base_pay_amount, pay_amount, coupon_id, coupon_amount, expires_at, price_version, |
| | | merchant_name_snapshot, merchant_address_snapshot, merchant_mobile_snapshot, |
| | | merchant_longitude_snapshot, merchant_latitude_snapshot, product_name_snapshot, |
| | | order_status, verify_status, merchant_verification_attempt_id, client_idempotency_key) |
| | | values (?, ?, ?, ?, ?, 'STORE_CONSUME', ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?) |
| | | """, orderNo, number(source.get("talentId")), number(source.get("cardId")), principal.merchantId(), principal.storeId(), |
| | | values (?, ?, ?, ?, ?, ?, 'STORE_CONSUME', ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?) |
| | | """, orderNo, number(source.get("talentId")), number(source.get("cardId")), string(source.get("cardLevel")), principal.merchantId(), principal.storeId(), |
| | | amounts.get("originalAmount"), amounts.get("subsidyAmount"), amounts.get("basePayAmount"), amounts.get("payAmount"), request.couponId(), amounts.get("couponAmount"), |
| | | LocalDateTime.now().plusMinutes(15), merchant.get("merchantName"), merchant.get("address"), merchant.get("contactMobile"), |
| | | merchant.get("longitude"), merchant.get("latitude"), "到店消费 - " + rule.get("ruleName"), |
| | |
| | | |
| | | String recordSql = """ |
| | | select o.id, o.order_no, tu.name as talent_name, tu.mobile as talent_mobile, tu.gender, |
| | | coalesce(nullif(tu.talent_level, ''), tc.card_level) as card_level, |
| | | coalesce(nullif(o.card_level_snapshot, ''), tc.card_level) as card_level, |
| | | b.benefit_name, o.product_name_snapshot, o.order_type, o.order_amount, o.subsidy_amount, o.pay_amount, |
| | | o.order_status, o.verify_status, o.created_at, verified.verify_time |
| | | from benefit_order o |
| | |
| | | public Map<String, Object> order(String orderNo, Long merchantId, Long storeId) { |
| | | List<Map<String, Object>> rows = query(""" |
| | | select o.id, o.order_no, o.talent_id, tu.name as talent_name, tu.mobile as talent_mobile, tu.gender, |
| | | o.card_id, tc.card_no, coalesce(nullif(tu.talent_level, ''), tc.card_level) as card_level, |
| | | o.card_id, tc.card_no, coalesce(nullif(o.card_level_snapshot, ''), tc.card_level) as card_level, |
| | | o.benefit_id, b.benefit_name, |
| | | o.merchant_id, m.merchant_name, o.product_id, o.product_name_snapshot, p.rights_config, |
| | | o.order_type, o.order_amount, o.base_pay_amount, o.coupon_amount, |
| | |
| | | @Service |
| | | public class OrderService { |
| | | |
| | | private static final String ORDER_CARD_LEVEL_SQL = |
| | | "coalesce(nullif(o.card_level_snapshot, ''), tc.card_level, tu.talent_level)"; |
| | | |
| | | private final JdbcTemplate jdbcTemplate; |
| | | private final HuifuRefundService refundService; |
| | | |
| | |
| | | long subsidyAmount = MoneyUtils.yuanToCent(request.subsidyAmount()); |
| | | long payAmount = MoneyUtils.yuanToCent(request.payAmount()); |
| | | LocalDateTime expiresAt = payAmount == 0 ? null : LocalDateTime.now().plusMinutes(15); |
| | | String cardLevel = jdbcTemplate.query("select card_level from talent_card where id = ? and deleted = 0", rs -> rs.next() ? rs.getString(1) : null, request.cardId()); |
| | | jdbcTemplate.update(""" |
| | | insert into benefit_order(order_no, talent_id, card_id, benefit_id, merchant_id, store_id, |
| | | insert into benefit_order(order_no, talent_id, card_id, card_level_snapshot, benefit_id, merchant_id, store_id, |
| | | order_type, order_amount, subsidy_amount, base_pay_amount, pay_amount, expires_at, order_status, verify_status) |
| | | values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) |
| | | values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) |
| | | """, |
| | | orderNo, |
| | | request.talentId(), |
| | | request.cardId(), |
| | | cardLevel, |
| | | request.benefitId(), |
| | | request.merchantId(), |
| | | request.storeId(), |
| | |
| | | public Map<String, Object> detail(String orderNo) { |
| | | List<Map<String, Object>> rows = query(""" |
| | | select o.id, o.order_no, o.talent_id, tu.name as talent_name, tu.mobile as talent_mobile, |
| | | o.card_id, tc.card_no, coalesce(tc.card_level, tu.talent_level) as talent_level, o.benefit_id, b.benefit_name, |
| | | o.card_id, tc.card_no, %s as talent_level, o.benefit_id, b.benefit_name, |
| | | o.product_id, o.product_name_snapshot, o.product_cover_url_snapshot, |
| | | p.product_name, p.cover_url as product_cover_url, |
| | | o.merchant_id, m.merchant_name, m.merchant_type, m.contact_mobile, |
| | |
| | | left join merchant_coupon mc on mc.id = o.coupon_id and mc.deleted = 0 |
| | | left join merchant m on m.id = o.merchant_id |
| | | where o.deleted = 0 and o.order_no = ? |
| | | """, orderNo); |
| | | """.formatted(ORDER_CARD_LEVEL_SQL), orderNo); |
| | | if (rows.isEmpty()) { |
| | | throw new BusinessException(ErrorCode.NOT_FOUND, "订单不存在"); |
| | | } |
| | |
| | | where order_id = ? |
| | | order by id desc limit 1 |
| | | """, detail.get("id")); |
| | | detail.put("verifyTime", verificationRows.isEmpty() ? null : verificationRows.get(0).get("verifyTime")); |
| | | Object verifyTime = verificationRows.isEmpty() ? null : verificationRows.get(0).get("verifyTime"); |
| | | // 到店消费在支付成功后即自动核销,历史数据未必有人工核销记录;此时以成功支付时间作为核销时间。 |
| | | if (verifyTime == null |
| | | && "STORE_CONSUME".equalsIgnoreCase(String.valueOf(detail.get("orderType"))) |
| | | && detail.get("verifyStatus") instanceof Number verifyStatus |
| | | && verifyStatus.intValue() == 1) { |
| | | List<Map<String, Object>> paidTransactions = query(""" |
| | | select paid_at as verify_time |
| | | from payment_transaction |
| | | where order_no = ? and status = 'SUCCEEDED' and paid_at is not null |
| | | order by id desc limit 1 |
| | | """, orderNo); |
| | | verifyTime = paidTransactions.isEmpty() ? null : paidTransactions.get(0).get("verifyTime"); |
| | | } |
| | | detail.put("verifyTime", verifyTime); |
| | | Object itemId = detail.get("productId") == null ? detail.get("benefitId") : detail.get("productId"); |
| | | Object itemName = firstNonNull(detail.get("productNameSnapshot"), detail.get("productName"), detail.get("benefitName")); |
| | | Object itemCover = firstNonNull(detail.get("productCoverUrlSnapshot"), detail.get("productCoverUrl"), detail.get("merchantCoverUrl")); |
| | |
| | | where.append(" and m.merchant_name like ?"); |
| | | args.add("%" + merchantName.trim() + "%"); |
| | | } |
| | | if (cardLevel != null && !cardLevel.isBlank()) { |
| | | where.append(" and (tc.card_level = ? or tu.talent_level = ?)"); |
| | | args.add(cardLevel); |
| | | args.add(cardLevel); |
| | | } |
| | | appendCardLevelFilter(where, args, cardLevel); |
| | | if (hasSubsidy != null) { |
| | | where.append(hasSubsidy == 1 ? " and o.subsidy_amount > 0" : " and o.subsidy_amount = 0"); |
| | | } |
| | |
| | | select r.id, r.refund_no, r.order_no, r.order_type, r.paid_amount, r.refund_amount, |
| | | r.refundable_amount, r.refund_status, r.reason, r.operator_name, r.refund_time, |
| | | tu.name as talent_name, tu.mobile as talent_mobile, |
| | | coalesce(tc.card_level, tu.talent_level) as talent_level, tc.card_no, |
| | | m.merchant_name, m.merchant_type, coalesce(b.benefit_name, o.product_name_snapshot) as benefit_name, |
| | | %s as talent_level, tc.card_no, |
| | | m.merchant_name, m.merchant_type, |
| | | coalesce(nullif(o.product_name_snapshot, ''), nullif(p.product_name, ''), b.benefit_name) as benefit_name, |
| | | o.order_amount, o.subsidy_amount, o.pay_amount, o.created_at as order_time |
| | | from order_refund r |
| | | left join benefit_order o on o.id = r.order_id |
| | | left join talent_user tu on tu.id = r.talent_id |
| | | left join talent_card tc on tc.id = r.card_id |
| | | left join benefit b on b.id = r.benefit_id |
| | | left join merchant_product p on p.id = o.product_id and p.deleted = 0 |
| | | left join merchant m on m.id = r.merchant_id |
| | | """ + where + " order by r.id desc limit ? offset ?", pageArgs.toArray()); |
| | | """.formatted(ORDER_CARD_LEVEL_SQL) + where + " order by r.id desc limit ? offset ?", pageArgs.toArray()); |
| | | Long total = jdbcTemplate.queryForObject(""" |
| | | select count(*) |
| | | from order_refund r |
| | |
| | | where.append(" and m.merchant_name like ?"); |
| | | args.add("%" + merchantName.trim() + "%"); |
| | | } |
| | | if (cardLevel != null && !cardLevel.isBlank()) { |
| | | where.append(" and (tc.card_level = ? or tu.talent_level = ?)"); |
| | | args.add(cardLevel); |
| | | args.add(cardLevel); |
| | | } |
| | | appendCardLevelFilter(where, args, cardLevel); |
| | | if ("未核销".equals(status)) { |
| | | return new PageResult<>(List.of(), safePage, safeSize, 0); |
| | | } |
| | |
| | | select r.id, r.verify_no, r.verify_type, r.verify_amount, r.subsidy_amount, r.verify_time, |
| | | r.remark, o.order_no, o.order_type, |
| | | tu.name as talent_name, tu.mobile as talent_mobile, |
| | | coalesce(tc.card_level, tu.talent_level) as talent_level, tc.card_no, |
| | | %s as talent_level, tc.card_no, |
| | | m.merchant_name, m.merchant_type, b.benefit_name, |
| | | '已核销' as status |
| | | from verify_record r |
| | |
| | | left join talent_card tc on tc.id = r.card_id |
| | | left join benefit b on b.id = r.benefit_id |
| | | left join merchant m on m.id = r.merchant_id |
| | | """ + where + " order by r.id desc limit ? offset ?", pageArgs.toArray()); |
| | | """.formatted(ORDER_CARD_LEVEL_SQL) + where + " order by r.id desc limit ? offset ?", pageArgs.toArray()); |
| | | Long total = jdbcTemplate.queryForObject(""" |
| | | select count(*) |
| | | from verify_record r |
| | |
| | | where.append(" and m.merchant_name like ?"); |
| | | args.add("%" + merchantName.trim() + "%"); |
| | | } |
| | | if (cardLevel != null && !cardLevel.isBlank()) { |
| | | where.append(" and (tc.card_level = ? or tu.talent_level = ?)"); |
| | | args.add(cardLevel); |
| | | args.add(cardLevel); |
| | | } |
| | | appendCardLevelFilter(where, args, cardLevel); |
| | | if (hasSubsidy != null) { |
| | | where.append(hasSubsidy == 1 ? " and o.subsidy_amount > 0" : " and o.subsidy_amount = 0"); |
| | | } |
| | |
| | | pageArgs.add((safePage - 1) * safeSize); |
| | | List<Map<String, Object>> records = query(""" |
| | | select o.id, o.order_no, o.talent_id, tu.name as talent_name, tu.mobile as talent_mobile, |
| | | coalesce(tc.card_level, tu.talent_level) as talent_level, |
| | | %s as talent_level, |
| | | o.card_id, tc.card_no, tc.card_type, |
| | | o.merchant_id, m.merchant_name, m.merchant_type, |
| | | o.benefit_id, b.benefit_name, o.order_type, |
| | |
| | | left join talent_card tc on tc.id = o.card_id |
| | | left join benefit b on b.id = o.benefit_id |
| | | left join merchant m on m.id = o.merchant_id |
| | | """ + where + " order by o.id desc limit ? offset ?", pageArgs.toArray()); |
| | | """.formatted(ORDER_CARD_LEVEL_SQL) + where + " order by o.id desc limit ? offset ?", pageArgs.toArray()); |
| | | Long total = jdbcTemplate.queryForObject(""" |
| | | select count(*) |
| | | from benefit_order o |
| | |
| | | return rows.get(0); |
| | | } |
| | | |
| | | private void appendCardLevelFilter(StringBuilder where, List<Object> args, String cardLevel) { |
| | | if (cardLevel == null || cardLevel.isBlank()) { |
| | | return; |
| | | } |
| | | where.append(" and ").append(ORDER_CARD_LEVEL_SQL).append(" = ?"); |
| | | args.add(cardLevel.trim()); |
| | | } |
| | | |
| | | private void appendOrderTypeFilter(StringBuilder where, List<Object> args, String alias, String orderType) { |
| | | if (orderType == null || orderType.isBlank()) { |
| | | return; |
| | |
| | | |
| | | try { |
| | | jdbc.update(""" |
| | | insert into benefit_order(order_no, talent_id, card_id, merchant_id, order_type, |
| | | insert into benefit_order(order_no, talent_id, card_id, card_level_snapshot, merchant_id, order_type, |
| | | order_amount, subsidy_amount, base_pay_amount, pay_amount, |
| | | coupon_id, coupon_amount, expires_at, price_version, |
| | | merchant_name_snapshot, merchant_address_snapshot, merchant_mobile_snapshot, |
| | | merchant_longitude_snapshot, merchant_latitude_snapshot, |
| | | product_id, product_name_snapshot, product_cover_url_snapshot, |
| | | order_status, verify_status) |
| | | values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
| | | values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
| | | """, |
| | | orderNo, |
| | | talentId, |
| | | talentCard.get("id"), |
| | | talentCard.get("cardLevel"), |
| | | request.merchantId(), |
| | | normalizeOrderType(product.get("productType")), |
| | | originalAmount, |
| | |
| | | system-id: 6666000212191071 |
| | | product-id: KAZX |
| | | merchant-id: 6666000212191071 |
| | | mini-program-app-id: wx6ae2cadfea28fce1 |
| | | mini-program-app-id: wxcfee477acb54d179 |
| | | private-key: MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCcsR3R3nUFCv/4l6vczGk6j4UgZhuMm/fTs2MQQqFkIuoISd38TOiIMaLS3KLYL+D+qhUCps56svOK/3M/kdyXfPsBphJyXB5Ar7il8pJ6BDa38StzTrlRm+IXX+4diJvqxUimetjpx0OfnqRkNYQWc/ZVeoEdQbITqLyxogy0R3rkR/wnOTwxHBbkCYsDCxSon4W0SoM5fLs3o947INbPUbUsf0vL5l6NiqlV9YsZ3qAWvvPoPX4HI1s5maMiVKfUwe27P3ARW9n6n3tadBzbB6lrmAEcFoihXpZjNN9UaRKjYfYjTS9JRkDqRyYGhz5cHp9GthEksMvhLP84oEanAgMBAAECggEAKV6d5+Ya8TzpPiIVTxNtCPgRhu9OgL5BHVS0Z/ufjselEJEoGc/YaPTp71AvcnKZrcMMDwopY73RjTZKoFMLcbeesd296xGlLyD12GKGB88K/1+eQDBdv00v0UoTnRqPR9nUh5fRo09n7EG/V3HFsQPm2sSgSDArKwOW5jNJtWLsGIVCW8gg3RQ9YlZ5HW85neBnjBFnrMA9S/hxwZD336M8CiViIa0v9o5CS7qxAqXMzzoOC7Vr3hZ/X0i/5GrSH9DRO7ougSmP4F/zCEc2dViA0rOJ6m3OuS0osh5FulihOBOjI5uLETEt4K/7PuV9xOd4oAz0OBpJ2bhPf3YpGQKBgQDQdEcmnlULa3jTSrbr0vYA3iMpcls/8F8+I/QymMXHAUeVyc8XKuGzblx+/n3x8ZoAvdVg1ZAO5wgLh/P0BEL74iWhfHedjt8xcdk2R5U0RuOnx1TUTKrmLKRZ5gStNFnGlK4yVwpiNqHn/cr+gYYeZSPARFVi00WdXbqVbEEf2wKBgQDAbmo4uHrmHN5GYvt8lVmT9aha1IpKOOLenjd+Y/vKGJcvHzOVCHnOh8OGO/4p7CxOq0sshER713jW2cFGO3xpFEFpIc45pDQ8Ut0XuSoOjKtwPzid8vKX7A1If6FDLMNy7bYR1qqnIJNJnN/f3lsksX+es7QTuRShhA0X0mnEJQKBgBE5OlPHi1FZPO+AYeLUWH0z3YXkLAP5cXUp9Vhk5RqPUYHf3duVMHbp7HyVvIRMKSRciw/oNXH1xuVlA1QrMfmr8t4H8b4z+t8UABWLDpn2LYNEZg7wksJX9/Bxn/tLswtEZ6xDItmAmBoaDza+Lv2975lITCHRQRRClVMZUbw3AoGAVFrSs6SjM2aqRZ60ccuipgrFT3PmX10Kjt46o2w+/zvCYoTB/u3lP5UiMCDybPw7/Ax4+r3Mq6EsNrrkT95ZzlJzzhtkpL4Hq/4DL5gbCJ+/s4fzUMIkeFahCAvy7FieIS51ZuJ8kqDkEBv1w9RcjfxFXpOWOCMlGRyjBLNXbAUCgYEAjcTEuXctlGEjtO1MBaHc4hkkpiPG7gxV/Bq1XXLszv4LTaOfDEpjGGBpVYtb3URYrif/HzX8T1KHELvPziuqP2ghaJQzbztep1TGQ48A5VpOvHdA/w/+k4JJl0PyKBPrFXiHIw1if7rnvOLt27TymwXSaJquSz7Sz2+wXFxzKJk= |
| | | platform-public-key: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAgxOuXt1UEdZNPk6sntqiIlcs7e+0fZ3CVgKMV2Qv4ixwKmha5etL5WyIONmjoGEHePHpanYkXvm0kHkbbDkjpyHDFdEshuP1eGx5tRUTISRvXY/iFuD3xyeWSsvvjW6hS29OvqVCPcnBUC7jcRj15dJS2IpYR0iDGNS+/M0TyOFwRQME07At+qO+MSjKu4aRIjlglukjD7YbE2s35uV066nsBUCZUNpGPuju1pieUW4zyoIW6K70dyXCQy61oRI6JmA4JRCOJV1BE80N3GrLI4Pbq1C3ZBTgOPi5RWYNq1hfu807BDkPLc3dJKYt22Ea1jPJsg6e/B9Xbzh6JaUNAQIDAQAB |
| | | notify-url: ${HUIFU_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/notify} |
| | |
| | | wechat: |
| | | mini-program: |
| | | # The app id must be the same mini-program that invokes uni.login and uni.requestPayment. |
| | | app-id: ${WECHAT_MINI_PROGRAM_APP_ID:wx6ae2cadfea28fce1} |
| | | app-id: ${WECHAT_MINI_PROGRAM_APP_ID:wxcfee477acb54d179} |
| | | # Keep this only in the deployment environment. It is required to exchange a wx.login code for an OpenID. |
| | | app-secret: ${WECHAT_MINI_PROGRAM_APP_SECRET:7bf743b066523bbb2d6de80decabc15b} |
| | | app-secret: ${WECHAT_MINI_PROGRAM_APP_SECRET:716aae42f04d3c5f17904e894f1c1ee9} |
| | | |
| | | management: |
| | | endpoints: |
| New file |
| | |
| | | alter table benefit_order |
| | | add column card_level_snapshot varchar(64) default null comment '下单时人才卡等级快照' after card_id; |
| | | |
| | | update benefit_order o |
| | | left join talent_card c on c.id = o.card_id |
| | | set o.card_level_snapshot = c.card_level |
| | | where (o.card_level_snapshot is null or o.card_level_snapshot = '') |
| | | and c.card_level is not null |
| | | and c.card_level <> ''; |