wenminjie
2026-08-12 420515bf0be54446afb779de5f7dbce5264fa542
小程序对接
9个文件已添加
1116 ■■■■■ 已修改文件
rencaika/pages/display-policy/display-policy.uvue 200 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/ticket-package/ticket-package.uvue 177 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/static/fonts/stay-calligraphy-subset.ttf 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/settlement-management/settlement-management.uvue 329 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/services/settlement-statistics.uts 233 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/AppointmentManagementView.vue 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/BenefitCouponManagementView.vue 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/CarWashBenefitView.vue 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/DisplayServiceManagementView.vue 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/display-policy/display-policy.uvue
New file
@@ -0,0 +1,200 @@
<template>
    <view class="page">
        <view class="page-header">
            <view class="safe-space" :style="'height:' + navTop + 'px'"></view>
            <view class="nav-row" :style="'height:' + navHeight + 'px;padding-right:' + navRight + 'px'">
                <view class="back-button" hover-class="back-button-active" @tap="goBack"><image src="/static/news-assets/arrow-left.svg" mode="aspectFit"></image></view>
                <text class="nav-title">{{detailMode ? '政策详情' : '展示类服务政策'}}</text>
                <view class="capsule-space" :style="'width:' + navWidth + 'px'"></view>
            </view>
        </view>
        <view v-if="!detailMode" class="list-content">
            <view v-if="listLoading" class="list-state"><text>正在加载展示服务</text></view>
            <view v-else-if="listError" class="list-state error-state"><text>展示服务加载失败</text><text class="retry-text" @tap="loadList">重新加载</text></view>
            <view v-else-if="records.length == 0" class="list-state"><text>暂无展示类服务</text></view>
            <view v-else class="policy-list">
                <view class="policy-item" v-for="item in records" :key="item.merchantId" @tap="openDetail(item)">
                    <image class="policy-image" :src="item.coverUrl != '' ? resolveApiUrl(item.coverUrl) : ''" mode="aspectFill" @error="replaceImage"></image>
                    <view class="policy-info">
                        <text class="policy-name">{{item.merchantName}}</text>
                        <text v-if="item.serviceLabel != ''" class="policy-label">{{item.serviceLabel}}</text>
                        <text class="policy-text">{{item.policyText}}</text>
                    </view>
                </view>
            </view>
        </view>
        <view v-else class="detail-content">
            <view v-if="detailLoading" class="list-state"><text>正在加载政策详情</text></view>
            <view v-else-if="detailError" class="list-state error-state"><text>政策详情加载失败</text><text class="retry-text" @tap="reloadDetail">重新加载</text></view>
            <view v-else class="detail-card">
                <view class="detail-hero">
                    <image class="detail-image" :src="detail.coverUrl != '' ? resolveApiUrl(detail.coverUrl) : ''" mode="aspectFill" @error="replaceImage"></image>
                    <view class="detail-shade"></view>
                    <text class="detail-name">{{detail.merchantName}}</text>
                </view>
                <view class="detail-body">
                    <view class="detail-section">
                        <text class="detail-title">优惠政策</text>
                        <text class="detail-text">{{detail.policyText != '' ? detail.policyText : detail.rightsText}}</text>
                    </view>
                    <view v-if="detail.cardName != '' || detail.cardLevel != ''" class="detail-section">
                        <text class="detail-title">适用人才卡</text>
                        <text class="detail-text">{{detail.cardName != '' ? detail.cardName : detail.cardLevel}}</text>
                    </view>
                    <view v-if="detail.handlingInstructions != ''" class="detail-section">
                        <text class="detail-title">办理说明</text>
                        <text class="detail-text">{{detail.handlingInstructions}}</text>
                    </view>
                    <view v-if="detail.address != ''" class="detail-section">
                        <text class="detail-title">商户地址</text>
                        <text class="detail-text">{{detail.address}}</text>
                    </view>
                </view>
            </view>
        </view>
        <view v-if="detailMode && !detailLoading && !detailError" class="action-bar">
            <view class="action-button" @tap="openMap"><text>导航</text></view>
            <view class="action-button primary" @tap="consult"><text>电话咨询</text></view>
        </view>
    </view>
</template>
<script setup lang="uts">
    import { apiEnvironment } from '../../config/api.uts'
    import { getDisplayMerchantPolicies, getDisplayMerchantPolicyDetail, recordDisplayMerchantPolicyEvent } from '../../services/portal.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { getSession } from '../../services/session.uts'
    import { safeBack } from '../../utils/navigation.uts'
    const navTop = ref(28)
    const navHeight = ref(32)
    const navWidth = ref(88)
    const navRight = ref(10)
    const talentId = ref('')
    const cardId = ref('')
    const detailMode = ref(false)
    const records = ref<any[]>([])
    const listLoading = ref(true)
    const listError = ref(false)
    const detailLoading = ref(false)
    const detailError = ref(false)
    const currentMerchantId = ref('')
    const detail = ref({ merchantName: '', coverUrl: '', policyText: '', rightsText: '', handlingInstructions: '', address: '', contactMobile: '', cardName: '', cardLevel: '', serviceLabel: '', latitude: 0, longitude: 0 })
    onLoad((options: any) => {
        // #ifdef MP-WEIXIN
        const capsule = uni.getMenuButtonBoundingClientRect()
        const systemInfo = uni.getSystemInfoSync()
        navTop.value = capsule.top
        navHeight.value = capsule.height
        navWidth.value = capsule.width
        navRight.value = systemInfo.windowWidth - capsule.right
        // #endif
        const session = getSession()
        if (session != null && session.talentId != null) talentId.value = session.talentId as string
        if (talentId.value == '' && apiEnvironment == 'development') talentId.value = '2'
        if (options != null && options['cardId'] != null) cardId.value = options['cardId'] as string
        if (options != null && options['merchantId'] != null && options['merchantId'] != '') {
            detailMode.value = true
            loadDetail('' + options['merchantId'])
        } else {
            loadList()
        }
    })
    const loadList = () => {
        listLoading.value = true
        listError.value = false
        getDisplayMerchantPolicies(talentId.value, cardId.value, '', '', 1, 50, (data: any) => {
            records.value = data != null && data.records instanceof Array ? data.records as any[] : []
            listLoading.value = false
        }, (error) => { listLoading.value = false; listError.value = true })
    }
    const openDetail = (item: any) => {
        detailMode.value = true
        loadDetail('' + item.merchantId)
    }
    const reloadDetail = () => loadDetail(currentMerchantId.value)
    const loadDetail = (merchantId: string) => {
        if (merchantId == '') return
        currentMerchantId.value = merchantId
        detailLoading.value = true
        detailError.value = false
        getDisplayMerchantPolicyDetail(merchantId, talentId.value, cardId.value, (data: any) => {
            detailLoading.value = false
            detail.value = {
                merchantName: data != null && data.merchantName != null ? String(data.merchantName) : '',
                coverUrl: data != null && data.coverUrl != null ? String(data.coverUrl) : '',
                policyText: data != null && data.policyText != null ? String(data.policyText) : '',
                rightsText: data != null && data.rightsText != null ? String(data.rightsText) : '',
                handlingInstructions: data != null && data.handlingInstructions != null ? String(data.handlingInstructions) : '',
                address: data != null && data.address != null ? String(data.address) : '',
                contactMobile: data != null && data.contactMobile != null ? String(data.contactMobile) : '',
                cardName: data != null && data.cardName != null ? String(data.cardName) : '',
                cardLevel: data != null && data.cardLevel != null ? String(data.cardLevel) : '',
                serviceLabel: data != null && data.serviceLabel != null ? String(data.serviceLabel) : '',
                latitude: data != null && data.latitude != null ? Number(data.latitude) : 0,
                longitude: data != null && data.longitude != null ? Number(data.longitude) : 0
            }
            recordDisplayMerchantPolicyEvent(merchantId, talentId.value, cardId.value, 'VIEW', '', () => {}, () => {})
        }, (error) => { detailLoading.value = false; detailError.value = true })
    }
    const consult = () => {
        if (detail.value.contactMobile == '') {
            uni.showToast({ title: '该商户暂未提供联系电话', icon: 'none' })
            return
        }
        recordDisplayMerchantPolicyEvent(currentMerchantId.value, talentId.value, cardId.value, 'CONSULT', '', () => {}, () => {})
        uni.makePhoneCall({ phoneNumber: detail.value.contactMobile, fail: () => uni.showToast({ title: '暂时无法拨打电话', icon: 'none' }) })
    }
    const openMap = () => {
        if (detail.value.latitude <= 0 || detail.value.longitude <= 0) {
            uni.showToast({ title: '该商户暂未配置导航地址', icon: 'none' })
            return
        }
        uni.openLocation({ latitude: detail.value.latitude, longitude: detail.value.longitude, name: detail.value.merchantName, address: detail.value.address, scale: 18, fail: () => uni.showToast({ title: '暂时无法打开地图导航', icon: 'none' }) })
    }
    const replaceImage = () => {}
    const goBack = () => safeBack('/pages/benefits/benefits')
</script>
<style>
    .page { display: flex; width: 100%; height: 100vh; background-color: #F5F7FA; flex-direction: column; }
    .page-header { position: relative; z-index: 10; flex-shrink: 0; }
    .safe-space { height: 28px; }
    .nav-row { display: flex; height: 32px; box-sizing: border-box; padding-left: 20rpx; padding-right: 10px; position: relative; flex-direction: row; align-items: center; justify-content: space-between; }
    .back-button { display: flex; width: 70rpx; height: 64rpx; margin-left: -4rpx; justify-content: center; align-items: center; }
    .back-button image { width: 36rpx; height: 36rpx; }
    .back-button-active { opacity: .55; }
    .nav-title { position: absolute; left: 50%; top: 50%; color: #171B22; font-size: 37rpx; font-weight: 700; transform: translate(-50%, -50%); white-space: nowrap; }
    .capsule-space { width: 88px; height: 100%; flex-shrink: 0; }
    .list-content { min-height: 0; padding: 40rpx 40rpx 60rpx; flex: 1; overflow-y: auto; }
    .list-state { display: flex; min-height: 240rpx; color: #8A94A1; font-size: 25rpx; flex-direction: column; justify-content: center; align-items: center; }
    .error-state { flex-direction: column; }
    .retry-text { margin-top: 18rpx; color: #F26A38; }
    .policy-list { display: flex; flex-direction: column; }
    .policy-item { display: flex; min-height: 150rpx; margin-bottom: 22rpx; box-sizing: border-box; padding: 22rpx; border-radius: 22rpx; background-color: #FFFFFF; flex-direction: row; align-items: center; box-shadow: 0 9rpx 25rpx rgba(68,78,91,.04); }
    .policy-image { width: 116rpx; height: 116rpx; flex-shrink: 0; border-radius: 14rpx; background-color: #F0F2F5; }
    .policy-info { min-width: 0; margin-left: 20rpx; flex: 1; }
    .policy-name { display: block; color: #20252D; font-size: 29rpx; font-weight: 700; }
    .policy-label { display: block; margin-top: 8rpx; color: #F26A38; font-size: 23rpx; }
    .policy-text { display: block; margin-top: 10rpx; color: #7B8592; font-size: 23rpx; line-height: 34rpx; }
    .detail-content { min-height: 0; padding: 0 0 130rpx; flex: 1; overflow-y: auto; }
    .detail-card { display: flex; flex-direction: column; }
    .detail-hero { position: relative; height: 300rpx; }
    .detail-image { width: 100%; height: 100%; background-color: #E7EAEF; }
    .detail-shade { position: absolute; left: 0; right: 0; bottom: 0; height: 130rpx; background-image: linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,.5) 100%); }
    .detail-name { position: absolute; left: 36rpx; bottom: 26rpx; color: #FFFFFF; font-size: 36rpx; font-weight: 700; }
    .detail-body { margin-top: -26rpx; box-sizing: border-box; padding: 30rpx 36rpx 40rpx; border-radius: 26rpx 26rpx 0 0; background-color: #F5F7FA; }
    .detail-section { margin-top: 26rpx; }
    .detail-title { display: block; color: #242A33; font-size: 27rpx; font-weight: 700; }
    .detail-text { display: block; margin-top: 14rpx; color: #697381; font-size: 25rpx; line-height: 38rpx; }
    .action-bar { display: flex; position: fixed; z-index: 20; left: 0; right: 0; bottom: 0; box-sizing: border-box; padding: 18rpx 40rpx calc(18rpx + env(safe-area-inset-bottom)); background-color: #FFFFFF; flex-direction: row; }
    .action-button { display: flex; height: 80rpx; margin-right: 20rpx; border-radius: 40rpx; background-color: #F3F5F8; flex: 1; justify-content: center; align-items: center; }
    .action-button text { color: #46505E; font-size: 27rpx; font-weight: 600; }
    .action-button.primary { margin-right: 0; background-image: linear-gradient(90deg, #FF4E35 0%, #FF8318 100%); }
    .action-button.primary text { color: #FFFFFF; }
</style>
rencaika/pages/ticket-package/ticket-package.uvue
New file
@@ -0,0 +1,177 @@
<template>
    <view class="page">
        <view class="page-header">
            <view class="safe-space" :style="'height:' + navTop + 'px'"></view>
            <view class="nav-row" :style="'height:' + navHeight + 'px;padding-right:' + navRight + 'px'">
                <view class="back-button" hover-class="back-button-active" @tap="goBack"><image src="/static/news-assets/arrow-left.svg" mode="aspectFit"></image></view>
                <text class="nav-title">电影票兑换券</text>
                <view class="capsule-space" :style="'width:' + navWidth + 'px'"></view>
            </view>
        </view>
        <view class="content">
            <view v-if="loading" class="list-state"><text>正在加载可领取兑换券</text></view>
            <view v-else-if="loadError" class="list-state error-state"><text>兑换券加载失败</text><text class="retry-text" @tap="loadPackages">重新加载</text></view>
            <view v-else-if="packages.length == 0" class="list-state"><text>暂无可领取的电影票兑换券</text></view>
            <view v-else class="package-list">
                <view class="package-card" v-for="item in packages" :key="item.packageId">
                    <view class="package-head">
                        <image class="package-image" :src="item.coverUrl != '' ? resolveApiUrl(item.coverUrl) : ''" mode="aspectFill" @error="replaceImage"></image>
                        <view class="package-title-area">
                            <text class="package-name">{{item.packageName}}</text>
                            <text class="package-merchant">{{item.merchantName}}</text>
                        </view>
                    </view>
                    <view class="package-row"><text>可兑换额度</text><text class="quota-value">{{item.quotaRemaining}} / {{item.quotaTotal}}</text></view>
                    <view v-if="item.validEnd != null && item.validEnd != ''" class="package-row"><text>有效期至</text><text>{{item.validEnd}}</text></view>
                    <view v-if="item.appName != ''" class="package-row"><text>兑换渠道</text><text>{{item.appName}}APP</text></view>
                    <view v-if="item.usageText != ''" class="package-usage">{{item.usageText}}</view>
                    <view class="claim-button" :class="item.available === false ? 'is-disabled' : ''" @tap="claimPackage(item)">
                        <text>{{item.available === false ? (item.unavailableReason != '' ? item.unavailableReason : '不可领取') : '领取兑换码'}}</text>
                    </view>
                </view>
            </view>
        </view>
        <view v-if="showResult" class="result-mask" @tap="closeResult">
            <view class="result-card" @tap.stop>
                <text class="result-title">领取成功</text>
                <text class="result-label">兑换码</text>
                <text class="result-code">{{result.exchangeCode}}</text>
                <text class="result-tip">复制兑换码后前往{{result.appName != '' ? result.appName : '指定商家'}}APP兑换电影票{{result.validEnd != '' ? ',有效期至' + result.validEnd : ''}}</text>
                <view class="copy-button" @tap="copyCode"><text>复制兑换码</text></view>
                <view class="close-button" @tap="closeResult"><text>完成</text></view>
            </view>
        </view>
    </view>
</template>
<script setup lang="uts">
    import { apiEnvironment } from '../../config/api.uts'
    import { getTicketPackages, claimTicketPackage } from '../../services/portal.uts'
    import { resolveApiUrl } from '../../services/http.uts'
    import { getSession } from '../../services/session.uts'
    import { safeBack } from '../../utils/navigation.uts'
    const navTop = ref(28)
    const navHeight = ref(32)
    const navWidth = ref(88)
    const navRight = ref(10)
    const talentId = ref('')
    const cardId = ref('')
    const packages = ref<any[]>([])
    const loading = ref(true)
    const loadError = ref(false)
    const isClaiming = ref(false)
    const showResult = ref(false)
    const result = ref({ exchangeCode: '', appName: '', validEnd: '', statusName: '' })
    onLoad((options: any) => {
        // #ifdef MP-WEIXIN
        const capsule = uni.getMenuButtonBoundingClientRect()
        const systemInfo = uni.getSystemInfoSync()
        navTop.value = capsule.top
        navHeight.value = capsule.height
        navWidth.value = capsule.width
        navRight.value = systemInfo.windowWidth - capsule.right
        // #endif
        const session = getSession()
        if (session != null && session.talentId != null) talentId.value = session.talentId as string
        if (talentId.value == '' && apiEnvironment == 'development') talentId.value = '2'
        if (options != null && options['cardId'] != null) cardId.value = options['cardId'] as string
        loadPackages()
    })
    const loadPackages = () => {
        loading.value = true
        loadError.value = false
        getTicketPackages(talentId.value, cardId.value, '', (data: any) => {
            const records: any[] = data instanceof Array ? data as any[] : []
            const next: any[] = []
            for (let index = 0; index < records.length; index++) {
                const item = records[index]
                item.quotaTotal = item.packageQuotaTotal != null ? Number(item.packageQuotaTotal) : (item.quotaTotal != null ? Number(item.quotaTotal) : 0)
                item.quotaRemaining = item.packageQuotaRemaining != null ? Number(item.packageQuotaRemaining) : (item.quotaRemaining != null ? Number(item.quotaRemaining) : 0)
                next.push(item)
            }
            packages.value = next
            loading.value = false
        }, (error) => { loading.value = false; loadError.value = true })
    }
    const claimPackage = (item: any) => {
        if (item.available === false) return
        if (isClaiming.value) return
        uni.showModal({
            title: '领取电影票兑换券',
            content: '确认领取【' + item.packageName + '】兑换码吗?领取后请在有效期内前往商家APP兑换。',
            confirmText: '领取',
            confirmColor: '#FF5935',
            success: (res: any) => {
                if (res == null || res.confirm !== true) return
                if (isClaiming.value) return
                isClaiming.value = true
                claimTicketPackage('' + item.packageId, talentId.value, cardId.value, (data: any) => {
                    isClaiming.value = false
                    result.value = {
                        exchangeCode: data != null && data.exchangeCode != null ? String(data.exchangeCode) : '',
                        appName: item.appName != null ? String(item.appName) : '',
                        validEnd: data != null && data.validEnd != null ? String(data.validEnd) : '',
                        statusName: data != null && data.statusName != null ? String(data.statusName) : ''
                    }
                    showResult.value = true
                    loadPackages()
                }, (error) => {
                    isClaiming.value = false
                    uni.showToast({ title: error.message, icon: 'none' })
                })
            }
        })
    }
    const copyCode = () => {
        if (result.value.exchangeCode == '') return
        uni.setClipboardData({ data: result.value.exchangeCode, success: () => uni.showToast({ title: '兑换码已复制', icon: 'success' }) })
    }
    const closeResult = () => { showResult.value = false }
    const replaceImage = () => {}
    const goBack = () => safeBack('/pages/benefits/benefits')
</script>
<style>
    .page { display: flex; width: 100%; height: 100vh; background-color: #F5F7FA; flex-direction: column; }
    .page-header { position: relative; z-index: 10; flex-shrink: 0; }
    .safe-space { height: 28px; }
    .nav-row { display: flex; height: 32px; box-sizing: border-box; padding-left: 20rpx; padding-right: 10px; position: relative; flex-direction: row; align-items: center; justify-content: space-between; }
    .back-button { display: flex; width: 70rpx; height: 64rpx; margin-left: -4rpx; justify-content: center; align-items: center; }
    .back-button image { width: 36rpx; height: 36rpx; }
    .back-button-active { opacity: .55; }
    .nav-title { position: absolute; left: 50%; top: 50%; color: #171B22; font-size: 37rpx; font-weight: 700; transform: translate(-50%, -50%); white-space: nowrap; }
    .capsule-space { width: 88px; height: 100%; flex-shrink: 0; }
    .content { min-height: 0; padding: 40rpx 40rpx 60rpx; flex: 1; overflow-y: auto; }
    .list-state { display: flex; min-height: 240rpx; color: #8A94A1; font-size: 25rpx; flex-direction: column; justify-content: center; align-items: center; }
    .error-state { flex-direction: column; }
    .retry-text { margin-top: 18rpx; color: #F26A38; }
    .package-list { display: flex; flex-direction: column; }
    .package-card { margin-bottom: 24rpx; box-sizing: border-box; padding: 26rpx; border-radius: 24rpx; background-color: #FFFFFF; box-shadow: 0 9rpx 25rpx rgba(68,78,91,.04); }
    .package-head { display: flex; flex-direction: row; align-items: center; }
    .package-image { width: 120rpx; height: 120rpx; flex-shrink: 0; border-radius: 16rpx; background-color: #F0F2F5; }
    .package-title-area { min-width: 0; margin-left: 22rpx; flex: 1; }
    .package-name { display: block; color: #20252D; font-size: 30rpx; font-weight: 700; }
    .package-merchant { display: block; margin-top: 10rpx; color: #697381; font-size: 24rpx; }
    .package-row { display: flex; height: 46rpx; margin-top: 14rpx; flex-direction: row; align-items: center; justify-content: space-between; }
    .package-row text { color: #7B8592; font-size: 24rpx; }
    .package-row .quota-value { color: #F26A38; font-weight: 700; }
    .package-usage { margin-top: 12rpx; color: #9AA3AE; font-size: 22rpx; line-height: 34rpx; }
    .claim-button { display: flex; height: 78rpx; margin-top: 22rpx; border-radius: 40rpx; background-image: linear-gradient(90deg, #FF4E35 0%, #FF8318 100%); justify-content: center; align-items: center; }
    .claim-button text { color: #FFFFFF; font-size: 27rpx; font-weight: 600; }
    .claim-button.is-disabled { background-image: none; background-color: #C9CED6; }
    .result-mask { display: flex; position: fixed; z-index: 50; left: 0; right: 0; top: 0; bottom: 0; background-color: rgba(0,0,0,.45); justify-content: center; align-items: center; }
    .result-card { width: 590rpx; box-sizing: border-box; padding: 48rpx 44rpx 40rpx; border-radius: 28rpx; background-color: #FFFFFF; flex-direction: column; align-items: center; }
    .result-title { color: #232933; font-size: 34rpx; font-weight: 700; text-align: center; }
    .result-label { margin-top: 40rpx; color: #8B95A2; font-size: 24rpx; text-align: center; }
    .result-code { margin-top: 14rpx; color: #F26A38; font-size: 42rpx; font-weight: 700; letter-spacing: 3rpx; text-align: center; }
    .result-tip { margin-top: 22rpx; color: #75808F; font-size: 24rpx; line-height: 36rpx; text-align: center; }
    .copy-button { display: flex; height: 80rpx; margin-top: 40rpx; border-radius: 40rpx; background-image: linear-gradient(90deg, #FF4E35 0%, #FF8318 100%); justify-content: center; align-items: center; }
    .copy-button text { color: #FFFFFF; font-size: 27rpx; font-weight: 600; }
    .close-button { display: flex; height: 72rpx; margin-top: 18rpx; border-radius: 36rpx; background-color: #F3F5F8; justify-content: center; align-items: center; }
    .close-button text { color: #697381; font-size: 26rpx; }
</style>
rencaika/static/fonts/stay-calligraphy-subset.ttf
Binary files differ
rencaika_merchant/pages/settlement-management/settlement-management.uvue
New file
@@ -0,0 +1,329 @@
<template>
    <view class="page">
        <view class="navigation-bar">
            <view class="back-button" @tap="goBack"><image class="back-icon" src="/static/icons/lucide/back-left.svg" mode="aspectFit"></image></view>
            <text class="page-title">结算管理</text>
            <view class="export-button" :class="exporting ? 'exporting' : ''" @tap="handleExport">
                <image class="export-icon" src="/static/icons/lucide/file-text.svg" mode="aspectFit"></image>
            </view>
        </view>
        <scroll-view class="content-scroll" scroll-y="true" :show-scrollbar="false" :enhanced="false" lower-threshold="80" @scrolltolower="loadMore">
            <view class="top-card">
                <view class="period-switch">
                    <view class="period-tab" :class="periodType == 'DAY' ? 'active' : ''" @tap="switchPeriod('DAY')"><text>按日</text></view>
                    <view class="period-tab" :class="periodType == 'MONTH' ? 'active' : ''" @tap="switchPeriod('MONTH')"><text>按月</text></view>
                </view>
                <view class="range-row">
                    <view class="range-box">
                        <text class="range-label">开始</text>
                        <text class="range-value">{{startDateText}}</text>
                    </view>
                    <view class="range-divider"></view>
                    <view class="range-box">
                        <text class="range-label">结束</text>
                        <text class="range-value">{{endDateText}}</text>
                    </view>
                </view>
                <view class="summary-grid">
                    <view class="summary-item">
                        <text class="summary-label">交易总额</text>
                        <text class="summary-value">¥{{formatAmount(overviewTotals.transactionAmountCents)}}</text>
                    </view>
                    <view class="summary-item">
                        <text class="summary-label">补贴总额</text>
                        <text class="summary-value">¥{{formatAmount(overviewTotals.subsidyAmountCents)}}</text>
                    </view>
                    <view class="summary-item">
                        <text class="summary-label">应结算额</text>
                        <text class="summary-value highlight">¥{{formatAmount(overviewTotals.settlementAmountCents)}}</text>
                    </view>
                    <view class="summary-item">
                        <text class="summary-label">订单数</text>
                        <text class="summary-value">{{overviewTotals.orderCount}} 笔</text>
                    </view>
                </view>
                <text v-if="overviewErrorText.length > 0" class="basis-error">{{overviewErrorText}}</text>
                <text class="basis-text">{{statisticsBasis}}</text>
            </view>
            <page-feedback v-if="listLoadState != 'success'" :state="listLoadState" loading-text="结算数据加载中" error-text="结算数据加载失败,点击重试" :trace-id="traceId" @retry="reloadSummaries"></page-feedback>
            <view v-else class="record-list">
                <view class="record-card" v-for="item in records" :key="item.periodKey">
                    <view class="record-head">
                        <view class="record-title-wrap">
                            <text class="record-title">{{periodType == 'MONTH' ? item.periodKey + '月' : item.periodKey}}</text>
                            <text class="record-subtitle">{{item.merchantName}}</text>
                        </view>
                        <text class="record-count">{{item.orderCount}} 笔</text>
                    </view>
                    <view class="record-grid">
                        <view class="record-cell"><text class="record-cell-label">交易额</text><text class="record-cell-value">¥{{formatAmount(item.transactionAmountCents)}}</text></view>
                        <view class="record-cell"><text class="record-cell-label">补贴额</text><text class="record-cell-value">¥{{formatAmount(item.subsidyAmountCents)}}</text></view>
                        <view class="record-cell"><text class="record-cell-label">应结算额</text><text class="record-cell-value highlight">¥{{formatAmount(item.settlementAmountCents)}}</text></view>
                        <view class="record-cell"><text class="record-cell-label">券金额</text><text class="record-cell-value">¥{{formatAmount(item.couponAmountCents)}}</text></view>
                    </view>
                    <text class="record-time">统计区间:{{formatTimeRange(item.periodStartTime, item.periodEndTime)}}</text>
                </view>
                <view class="footer-state" @tap="retryLoadMore">
                    <text v-if="loadMoreState == 'loading'" class="footer-state-text">正在加载更多…</text>
                    <text v-else-if="loadMoreState == 'error'" class="footer-state-text footer-state-retry">加载失败,点击重试</text>
                    <text v-else-if="loadMoreState == 'no-more'" class="footer-state-text">没有更多结算数据了</text>
                    <text v-else class="footer-state-text">上滑加载更多</text>
                </view>
            </view>
        </scroll-view>
    </view>
</template>
<script setup lang="uts">
    import { hasActiveSession } from '../../services/session.uts'
    import { exportSettlementDetails, loadSettlementOverview, loadSettlementSummaries, SettlementStatisticsRow } from '../../services/settlement-statistics.uts'
    const periodType = ref('DAY')
    const listLoadState = ref('loading')
    const loadMoreState = ref('more')
    const traceId = ref('')
    const exporting = ref(false)
    const records = ref<Array<SettlementStatisticsRow>>([])
    const overviewErrorText = ref('')
    const overviewTotals = ref<SettlementStatisticsRow>({
        periodKey: '',
        periodType: '',
        merchantId: '',
        merchantName: '',
        merchantType: '',
        orderCount: 0,
        transactionAmountCents: 0,
        transactionAmount: '',
        subsidyAmountCents: 0,
        subsidyAmount: '',
        settlementAmountCents: 0,
        settlementAmount: '',
        originalAmountCents: 0,
        originalAmount: '',
        couponAmountCents: 0,
        couponAmount: '',
        governmentSubsidyOrderCount: 0,
        periodStartTime: '',
        periodEndTime: '',
        statisticsBasis: ''
    })
    const statisticsBasis = ref('')
    const page = ref(1)
    const pageSize = 8
    const currentDate = new Date()
    const defaultStartDate = currentDate.getFullYear().toString() + '-' + (currentDate.getMonth() + 1 < 10 ? '0' : '') + (currentDate.getMonth() + 1).toString() + '-01'
    const defaultEndDate = currentDate.getFullYear().toString() + '-' + (currentDate.getMonth() + 1 < 10 ? '0' : '') + (currentDate.getMonth() + 1).toString() + '-' + (currentDate.getDate() < 10 ? '0' : '') + currentDate.getDate().toString()
    const startDate = ref(defaultStartDate)
    const endDate = ref(defaultEndDate)
    let cancelListRequest: (() => void) | null = null
    let cancelOverviewRequest: (() => void) | null = null
    let cancelExportRequest: (() => void) | null = null
    const startDateText = computed(() => startDate.value.length > 0 ? startDate.value : '未设置')
    const endDateText = computed(() => endDate.value.length > 0 ? endDate.value : '未设置')
    const goBack = () => uni.navigateBack()
    const formatAmount = (value: number): string => (Number(value || 0) / 100).toFixed(2)
    const formatDate = (value: string): string => value.length > 0 ? value.replace('T', ' ').substring(0, 10) : '-'
    const formatTimeRange = (start: string, end: string): string => {
        if (start.length == 0 && end.length == 0) return '—'
        return formatDate(start) + ' 至 ' + formatDate(end)
    }
    const clearRequests = () => {
        if (cancelListRequest != null) {
            cancelListRequest()
            cancelListRequest = null
        }
        if (cancelOverviewRequest != null) {
            cancelOverviewRequest()
            cancelOverviewRequest = null
        }
    }
    const applyOverview = (value: any) => {
        overviewErrorText.value = ''
        const total = value != null && value.total != null ? value.total : null
        if (total != null) {
            overviewTotals.value = total as SettlementStatisticsRow
        }
        statisticsBasis.value = value != null && value.total != null && value.total.statisticsBasis != null ? value.total.statisticsBasis : (value != null && value.records instanceof Array && value.records.length > 0 ? value.records[0].statisticsBasis : '')
        if (value != null && value.startDate != null && value.startDate.length > 0) startDate.value = value.startDate
        if (value != null && value.endDate != null && value.endDate.length > 0) endDate.value = value.endDate
    }
    const loadOverview = () => {
        if (cancelOverviewRequest != null) {
            cancelOverviewRequest()
            cancelOverviewRequest = null
        }
        if (!hasActiveSession()) return
        overviewErrorText.value = ''
        cancelOverviewRequest = loadSettlementOverview(startDate.value, endDate.value, (value: any, _traceId: string) => {
            cancelOverviewRequest = null
            applyOverview(value)
        }, (error: any) => {
            cancelOverviewRequest = null
            traceId.value = error != null && error.traceId != null ? error.traceId : ''
            overviewErrorText.value = error != null && error.message != null ? error.message as string : '结算总览加载失败'
        })
    }
    const applyPage = (result: any, append: boolean) => {
        const nextRecords = append ? records.value.concat(result.records) : result.records
        records.value = nextRecords
        traceId.value = ''
        listLoadState.value = nextRecords.length == 0 ? 'empty' : 'success'
        loadMoreState.value = result.page * result.size >= result.total ? 'no-more' : 'more'
        page.value = result.page
    }
    const showListError = (error: any, append: boolean) => {
        const message = error != null && error.message != null ? error.message as string : '结算数据加载失败,点击重试'
        if (append && records.value.length > 0) {
            loadMoreState.value = 'error'
            traceId.value = error != null && error.traceId != null ? error.traceId : ''
            return
        }
        listLoadState.value = 'error'
        traceId.value = error != null && error.traceId != null ? error.traceId : ''
        uni.showToast({ title: message, icon: 'none' })
    }
    const requestSummaries = (targetPage: number, append: boolean) => {
        if (cancelListRequest != null) {
            cancelListRequest()
            cancelListRequest = null
        }
        if (!append) {
            records.value = []
            listLoadState.value = 'loading'
            loadMoreState.value = 'more'
        } else {
            loadMoreState.value = 'loading'
        }
        if (!hasActiveSession()) {
            listLoadState.value = 'error'
            traceId.value = ''
            return
        }
        cancelListRequest = loadSettlementSummaries(periodType.value, startDate.value, endDate.value, targetPage, pageSize, (result: any, _traceId: string) => {
            cancelListRequest = null
            applyPage(result, append)
        }, (error: any) => {
            cancelListRequest = null
            showListError(error, append)
        })
    }
    const reloadSummaries = () => {
        loadOverview()
        requestSummaries(1, false)
    }
    const loadMore = () => {
        if (listLoadState.value != 'success' || loadMoreState.value != 'more') return
        requestSummaries(page.value + 1, true)
    }
    const retryLoadMore = () => {
        if (loadMoreState.value == 'error') requestSummaries(page.value + 1, true)
    }
    const switchPeriod = (value: string) => {
        if (periodType.value == value) return
        periodType.value = value
        reloadSummaries()
    }
    const handleExport = () => {
        if (exporting.value) return
        if (!hasActiveSession()) {
            uni.showToast({ title: '请先登录后再导出', icon: 'none' })
            return
        }
        exporting.value = true
        if (cancelExportRequest != null) {
            cancelExportRequest()
            cancelExportRequest = null
        }
        cancelExportRequest = exportSettlementDetails('', startDate.value, endDate.value, (tempPath: string) => {
            exporting.value = false
            cancelExportRequest = null
            uni.openDocument({
                filePath: tempPath,
                showMenu: true,
                success: () => {},
                fail: () => {
                    uni.showToast({ title: '文件已下载,但无法直接打开', icon: 'none' })
                }
            })
        }, (error: any) => {
            exporting.value = false
            cancelExportRequest = null
            uni.showToast({ title: error != null && error.message != null ? error.message : '导出失败,请重试', icon: 'none' })
        })
    }
    onShow(() => {
        reloadSummaries()
    })
    onUnload(() => {
        clearRequests()
        if (cancelExportRequest != null) {
            cancelExportRequest()
            cancelExportRequest = null
        }
    })
</script>
<style>
    page { background-color: #F4F6F9; }
    .page { display: flex; width: 100%; height: 100vh; flex-direction: column; overflow: hidden; background-color: #F4F6F9; }
    .navigation-bar { display: flex; height: 174rpx; padding: 68rpx 38rpx 0; flex: none; flex-direction: row; align-items: center; justify-content: space-between; position: relative; box-sizing: border-box; }
    .back-button, .export-button { display: flex; width: 64rpx; height: 64rpx; justify-content: center; align-items: center; }
    .back-icon, .export-icon { width: 38rpx; height: 38rpx; }
    .page-title { color: #202B3E; font-size: 34rpx; line-height: 48rpx; text-align: center; font-weight: 700; flex: 1; }
    .content-scroll { flex: 1; height: 0; padding: 0 34rpx; box-sizing: border-box; }
    .top-card { padding: 26rpx 28rpx 30rpx; border-radius: 20rpx; background-color: #FFFFFF; box-shadow: 0 8rpx 24rpx rgba(49,55,66,.05); }
    .period-switch { display: flex; width: 250rpx; padding: 6rpx; border-radius: 16rpx; background-color: #F3F5F8; box-sizing: border-box; }
    .period-tab { display: flex; flex: 1; height: 60rpx; justify-content: center; align-items: center; border-radius: 12rpx; color: #6D7786; font-size: 25rpx; font-weight: 600; }
    .period-tab.active { background-color: #FF7625; color: #FFFFFF; }
    .range-row { display: flex; align-items: center; margin-top: 22rpx; }
    .range-box { display: flex; flex: 1; flex-direction: column; }
    .range-label { color: #8B94A2; font-size: 22rpx; line-height: 32rpx; }
    .range-value { margin-top: 6rpx; color: #293547; font-size: 26rpx; line-height: 36rpx; font-weight: 600; }
    .range-divider { width: 24rpx; height: 2rpx; margin: 0 18rpx; background-color: #DDE3EA; }
    .summary-grid { display: flex; flex-wrap: wrap; margin-top: 20rpx; }
    .summary-item { width: calc(50% - 10rpx); padding: 18rpx 18rpx 16rpx; margin-top: 18rpx; border-radius: 16rpx; background-color: #F8FAFC; box-sizing: border-box; }
    .summary-item:nth-child(2n) { margin-left: 20rpx; }
    .summary-label { color: #7A8595; font-size: 22rpx; line-height: 32rpx; }
    .summary-value { display: block; margin-top: 10rpx; color: #1F2A3A; font-size: 30rpx; line-height: 42rpx; font-weight: 700; }
    .summary-value.highlight { color: #FF7625; }
    .basis-text { display: block; margin-top: 18rpx; color: #98A2B1; font-size: 21rpx; line-height: 30rpx; }
    .basis-error { display: block; margin-top: 18rpx; color: #E95B55; font-size: 21rpx; line-height: 30rpx; }
    .record-list { padding-bottom: 34rpx; }
    .record-card { margin-top: 18rpx; padding: 24rpx 26rpx 22rpx; border-radius: 20rpx; background-color: #FFFFFF; box-shadow: 0 8rpx 24rpx rgba(49,55,66,.05); }
    .record-head { display: flex; justify-content: space-between; align-items: flex-start; }
    .record-title-wrap { display: flex; flex-direction: column; }
    .record-title { color: #1F2A3A; font-size: 30rpx; line-height: 42rpx; font-weight: 700; }
    .record-subtitle { margin-top: 4rpx; color: #6E7786; font-size: 22rpx; line-height: 32rpx; }
    .record-count { color: #FF7625; font-size: 24rpx; line-height: 34rpx; font-weight: 700; }
    .record-grid { display: flex; flex-wrap: wrap; margin-top: 16rpx; }
    .record-cell { width: calc(50% - 10rpx); padding: 14rpx 16rpx; margin-top: 14rpx; border-radius: 14rpx; background-color: #F8FAFC; box-sizing: border-box; }
    .record-cell:nth-child(2n) { margin-left: 20rpx; }
    .record-cell-label { color: #7A8595; font-size: 20rpx; line-height: 30rpx; }
    .record-cell-value { display: block; margin-top: 6rpx; color: #1F2A3A; font-size: 27rpx; line-height: 38rpx; font-weight: 700; }
    .record-cell-value.highlight { color: #FF7625; }
    .record-time { display: block; margin-top: 14rpx; color: #96A0AF; font-size: 21rpx; line-height: 30rpx; }
    .footer-state { display: block; width: 100%; padding: 16rpx 0 30rpx; box-sizing: border-box; }
    .footer-state-text { display: block; width: 100%; color: #A4AFBE; font-size: 23rpx; line-height: 34rpx; text-align: center; }
    .footer-state-retry { color: #FF7625; }
    .exporting { opacity: 0.55; }
</style>
rencaika_merchant/services/settlement-statistics.uts
New file
@@ -0,0 +1,233 @@
import { getApiBaseUrl, getApiEndpoint, isApiConfigured } from '../config/api.uts'
import { HttpError, request } from './http.uts'
import { refreshMerchantSession } from './session-refresh.uts'
import { getAccessToken, getCurrentStoreId } from './session.uts'
export type SettlementStatisticsRow = {
    periodKey: string
    periodType: string
    merchantId: string
    merchantName: string
    merchantType: string
    orderCount: number
    transactionAmountCents: number
    transactionAmount: string
    subsidyAmountCents: number
    subsidyAmount: string
    settlementAmountCents: number
    settlementAmount: string
    originalAmountCents: number
    originalAmount: string
    couponAmountCents: number
    couponAmount: string
    governmentSubsidyOrderCount: number
    periodStartTime: string
    periodEndTime: string
    statisticsBasis: string
}
export type SettlementStatisticsPage = {
    records: Array<SettlementStatisticsRow>
    page: number
    size: number
    total: number
}
export type SettlementStatisticsOverview = {
    startDate: string
    endDate: string
    records: Array<SettlementStatisticsRow>
    total: SettlementStatisticsRow | null
}
export type SettlementStatisticsDashboardCard = {
    title: string
    amountCents?: number
    amount?: string
    count?: number
    unit: string
}
export type SettlementStatisticsDashboard = {
    merchantId: string
    statisticsDate: string
    weekStartDate: string
    transactionTotal: SettlementStatisticsDashboardCard
    todayVerification: SettlementStatisticsDashboardCard
    weekVerification: SettlementStatisticsDashboardCard
    subsidyTotal: SettlementStatisticsDashboardCard
    transactionAmountCents: number
    transactionAmount: string
    todayVerificationCount: number
    weekVerificationCount: number
    subsidyAmountCents: number
    subsidyAmount: string
    hasData: boolean
    statisticsBasis: string
}
const stringValue = (value: any): string => value == null ? '' : '' + value
const numberValue = (value: any): number => value == null ? 0 : Number(value)
const toRow = (value: any): SettlementStatisticsRow => ({
    periodKey: stringValue(value != null ? value.periodKey : ''),
    periodType: stringValue(value != null ? value.periodType : ''),
    merchantId: stringValue(value != null ? value.merchantId : ''),
    merchantName: stringValue(value != null ? value.merchantName : ''),
    merchantType: stringValue(value != null ? value.merchantType : ''),
    orderCount: numberValue(value != null ? value.orderCount : 0),
    transactionAmountCents: numberValue(value != null ? value.transactionAmountCents : 0),
    transactionAmount: stringValue(value != null ? value.transactionAmount : ''),
    subsidyAmountCents: numberValue(value != null ? value.subsidyAmountCents : 0),
    subsidyAmount: stringValue(value != null ? value.subsidyAmount : ''),
    settlementAmountCents: numberValue(value != null ? value.settlementAmountCents : 0),
    settlementAmount: stringValue(value != null ? value.settlementAmount : ''),
    originalAmountCents: numberValue(value != null ? value.originalAmountCents : 0),
    originalAmount: stringValue(value != null ? value.originalAmount : ''),
    couponAmountCents: numberValue(value != null ? value.couponAmountCents : 0),
    couponAmount: stringValue(value != null ? value.couponAmount : ''),
    governmentSubsidyOrderCount: numberValue(value != null ? value.governmentSubsidyOrderCount : 0),
    periodStartTime: stringValue(value != null ? value.periodStartTime : ''),
    periodEndTime: stringValue(value != null ? value.periodEndTime : ''),
    statisticsBasis: stringValue(value != null ? value.statisticsBasis : '')
})
const toPage = (value: any): SettlementStatisticsPage => {
    const source = value != null ? value : {}
    const records = source.records instanceof Array ? source.records : []
    return {
        records: records.map((item: any) => toRow(item)),
        page: numberValue(source.page),
        size: numberValue(source.size),
        total: numberValue(source.total)
    }
}
const toOverview = (value: any): SettlementStatisticsOverview => {
    const source = value != null ? value : {}
    const records = source.records instanceof Array ? source.records : []
    return {
        startDate: stringValue(source.startDate),
        endDate: stringValue(source.endDate),
        records: records.map((item: any) => toRow(item)),
        total: source.total != null ? toRow(source.total) : null
    }
}
const toDashboard = (value: any): SettlementStatisticsDashboard => ({
    merchantId: stringValue(value != null ? value.merchantId : ''),
    statisticsDate: stringValue(value != null ? value.statisticsDate : ''),
    weekStartDate: stringValue(value != null ? value.weekStartDate : ''),
    transactionTotal: value != null && value.transactionTotal != null ? value.transactionTotal as SettlementStatisticsDashboardCard : { title: '交易总额', amountCents: 0, amount: '0.00', unit: '元' },
    todayVerification: value != null && value.todayVerification != null ? value.todayVerification as SettlementStatisticsDashboardCard : { title: '今日核销', count: 0, unit: '笔' },
    weekVerification: value != null && value.weekVerification != null ? value.weekVerification as SettlementStatisticsDashboardCard : { title: '本周核销', count: 0, unit: '笔' },
    subsidyTotal: value != null && value.subsidyTotal != null ? value.subsidyTotal as SettlementStatisticsDashboardCard : { title: '补贴总额', amountCents: 0, amount: '0.00', unit: '元' },
    transactionAmountCents: numberValue(value != null ? value.transactionAmountCents : 0),
    transactionAmount: stringValue(value != null ? value.transactionAmount : ''),
    todayVerificationCount: numberValue(value != null ? value.todayVerificationCount : 0),
    weekVerificationCount: numberValue(value != null ? value.weekVerificationCount : 0),
    subsidyAmountCents: numberValue(value != null ? value.subsidyAmountCents : 0),
    subsidyAmount: stringValue(value != null ? value.subsidyAmount : ''),
    hasData: value != null && value.hasData == true,
    statisticsBasis: stringValue(value != null ? value.statisticsBasis : '')
})
export const loadSettlementSummaries = (
    periodType: string,
    startDate: string,
    endDate: string,
    page: number,
    size: number,
    onSuccess: (value: SettlementStatisticsPage, traceId: string) => void,
    onError: (error: HttpError) => void
): (() => void) => request<any>({
    path: getApiEndpoint('merchantSettlementStatistics') + '/summaries',
    method: 'GET',
    data: { periodType: periodType, startDate: startDate, endDate: endDate, page: page, size: size }
}, (data: any, traceId: string) => onSuccess(toPage(data), traceId), onError)
export const loadSettlementOverview = (
    startDate: string,
    endDate: string,
    onSuccess: (value: SettlementStatisticsOverview, traceId: string) => void,
    onError: (error: HttpError) => void
): (() => void) => request<any>({
    path: getApiEndpoint('merchantSettlementStatistics') + '/overview',
    method: 'GET',
    data: { startDate: startDate, endDate: endDate }
}, (data: any, traceId: string) => onSuccess(toOverview(data), traceId), onError)
export const loadSettlementCurrentMonth = (
    onSuccess: (value: SettlementStatisticsOverview, traceId: string) => void,
    onError: (error: HttpError) => void
): (() => void) => request<any>({
    path: getApiEndpoint('merchantSettlementStatistics') + '/current-month',
    method: 'GET'
}, (data: any, traceId: string) => onSuccess(toOverview(data), traceId), onError)
export const loadSettlementDashboardCards = (
    onSuccess: (value: SettlementStatisticsDashboard, traceId: string) => void,
    onError: (error: HttpError) => void
): (() => void) => request<any>({
    path: getApiEndpoint('merchantSettlementStatistics') + '/dashboard-cards',
    method: 'GET'
}, (data: any, traceId: string) => onSuccess(toDashboard(data), traceId), onError)
export const exportSettlementDetails = (
    status: string,
    startDate: string,
    endDate: string,
    onSuccess: (path: string) => void,
    onError: (error: HttpError) => void
): (() => void) => {
    if (!isApiConfigured()) {
        onError({ code: 'API_NOT_CONFIGURED', message: '后台服务尚未配置,请联系管理员', statusCode: 0, traceId: '' })
        return () => {}
    }
    const baseUrl = getApiBaseUrl().replace(/\/$/, '')
    const query: Array<string> = []
    if (status != null && status.trim().length > 0) query.push('status=' + encodeURIComponent(status))
    if (startDate != null && startDate.trim().length > 0) query.push('startDate=' + encodeURIComponent(startDate))
    if (endDate != null && endDate.trim().length > 0) query.push('endDate=' + encodeURIComponent(endDate))
    const url = baseUrl + getApiEndpoint('merchantSettlementStatistics') + '/export' + (query.length > 0 ? '?' + query.join('&') : '')
    let currentTask: any = null
    const headers: any = {
        'X-Request-Id': 'merchant-export-' + Date.now(),
        'X-Client-Platform': 'mp-weixin',
        'X-Client-Version': '1.0.0'
    }
    const token = getAccessToken()
    const storeId = getCurrentStoreId()
    if (token.length > 0) headers.Authorization = 'Bearer ' + token
    if (storeId.length > 0) headers['X-Merchant-Store-Id'] = storeId
    const run = (retried: boolean) => {
        currentTask = uni.downloadFile({
            url: url,
            header: headers,
            success: (response: any) => {
                if (response != null && response.statusCode == 401 && !retried) {
                    refreshMerchantSession((success: boolean) => {
                        if (!success) {
                            onError({ code: 'UNAUTHORIZED', message: '登录已失效,请重新登录', statusCode: 401, traceId: '' })
                            return
                        }
                        const refreshedToken = getAccessToken()
                        if (refreshedToken.length > 0) headers.Authorization = 'Bearer ' + refreshedToken
                        run(true)
                    })
                    return
                }
                if (response == null || response.statusCode < 200 || response.statusCode >= 300 || response.tempFilePath == null || response.tempFilePath.length == 0) {
                    onError({ code: 'EXPORT_FAILED', message: '结算明细导出失败', statusCode: response != null ? response.statusCode as number : 0, traceId: '' })
                    return
                }
                onSuccess(response.tempFilePath as string)
            },
            fail: (error: any) => onError({ code: 'NETWORK_ERROR', message: error != null && error.errMsg != null ? error.errMsg as string : '结算明细导出失败', statusCode: 0, traceId: '' })
        })
    }
    run(false)
    return () => {
        if (currentTask != null && currentTask.abort != null) currentTask.abort()
    }
}
talent-card-admin/src/views/AppointmentManagementView.vue
New file
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { Download, Refresh, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { exportServiceApplications, getServiceApplications, updateServiceApplicationStatus } from '../api/admin'
import { saveBlob } from '../utils/excel'
const loading = ref(false)
const processingId = ref<number | string | null>(null)
const rows = ref<Record<string, any>[]>([])
const filters = reactive({ applicationType: '', keyword: '', serviceKeyword: '', cardLevel: '', status: '', submittedTime: [] as string[] })
const pagination = reactive({ page: 1, size: 10, total: 0 })
const appointmentTypes = [
  { label: '体检预约', value: 'MEDICAL_RESERVATION' },
  { label: '医院快捷通道', value: 'FAST_TRACK' },
  { label: '酒店预约', value: 'HOTEL_RESERVATION' },
  { label: '配偶就业', value: 'SPOUSE_EMPLOYMENT' },
  { label: '学生托管', value: 'CHILD_ENTRUSTMENT' },
]
const cardLevels = ['卓越人才卡', '杰出人才卡', '菁英人才卡', '青年人才卡', 'EXCELLENT', 'OUTSTANDING', 'ELITE']
const statusOptions = ['待处理', '已处理', '已驳回', '待审核', '审核通过', '审核不通过', '已取消']
function queryParams(size = pagination.size) {
  return { page: pagination.page, size, applicationType: filters.applicationType || undefined, keyword: filters.keyword || undefined, talentKeyword: filters.keyword || undefined, serviceKeyword: filters.serviceKeyword || undefined, merchantName: filters.serviceKeyword || undefined, hospitalName: filters.serviceKeyword || undefined, cardLevel: filters.cardLevel || undefined, status: filters.status || undefined, submittedStart: filters.submittedTime?.[0], submittedEnd: filters.submittedTime?.[1] }
}
async function loadData() { loading.value = true; try { const result = await getServiceApplications(queryParams() as any); rows.value = (result.records || []) as Record<string, any>[]; pagination.total = result.total || 0 } finally { loading.value = false } }
function search() { pagination.page = 1; loadData() }
function resetFilters() { Object.assign(filters, { applicationType: '', keyword: '', serviceKeyword: '', cardLevel: '', status: '', submittedTime: [] }); search() }
function text(value: unknown) { return value === undefined || value === null || value === '' ? '-' : String(value) }
async function handle(row: Record<string, any>, status = '已处理') { processingId.value = row.id; try { await updateServiceApplicationStatus(row.id, status, '管理员', status === '已驳回' ? '后台驳回' : ''); ElMessage.success('状态已更新'); await loadData() } finally { processingId.value = null } }
async function download() { const blob = await exportServiceApplications(queryParams() as any); saveBlob(blob, '预约申请列表.xlsx') }
onMounted(loadData)
</script>
<template>
  <div class="page-stack prototype-page">
    <div class="toolbar"><div><h2>预约服务管理</h2><p>查看、处理、导出体检、快捷通道、酒店、配偶就业、学生托管预约申请。</p></div><el-button :icon="Refresh" :loading="loading" @click="loadData">刷新</el-button></div>
    <section class="panel">
      <el-form class="filter-form" :model="filters" label-width="84px">
        <el-form-item label="预约类型"><el-select v-model="filters.applicationType" clearable placeholder="全部"><el-option v-for="item in appointmentTypes" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
        <el-form-item label="关键词"><el-input v-model="filters.keyword" clearable placeholder="姓名、手机号、卡号、申请编号" /></el-form-item>
        <el-form-item label="服务机构"><el-input v-model="filters.serviceKeyword" clearable placeholder="医院、酒店或机构名称" /></el-form-item>
        <el-form-item label="卡片等级"><el-select v-model="filters.cardLevel" clearable placeholder="全部"><el-option v-for="level in cardLevels" :key="level" :label="level" :value="level" /></el-select></el-form-item>
        <el-form-item label="状态"><el-select v-model="filters.status" clearable placeholder="全部"><el-option v-for="status in statusOptions" :key="status" :label="status" :value="status" /></el-select></el-form-item>
        <el-form-item label="提交时间"><el-date-picker v-model="filters.submittedTime" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" /></el-form-item>
        <div class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="Refresh" @click="resetFilters">重置</el-button><el-button :icon="Download" @click="download">导出</el-button></div>
      </el-form>
      <el-table v-loading="loading" :data="rows" border empty-text="暂无预约申请数据">
        <el-table-column prop="applicationNo" label="申请单号" min-width="130" />
        <el-table-column label="类型" min-width="120"><template #default="{ row }">{{ text(row.applicationTypeName || row.applicationType) }}</template></el-table-column>
        <el-table-column prop="talentName" label="持卡人" min-width="110" />
        <el-table-column prop="talentMobile" label="手机号" min-width="120" />
        <el-table-column prop="talentLevel" label="等级" min-width="110" />
        <el-table-column label="服务机构" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ text(row.serviceName || row.merchantName || row.hospitalName) }}</template></el-table-column>
        <el-table-column label="预约内容" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ text(row.appointmentContent || row.productName || row.packageName) }}</template></el-table-column>
        <el-table-column label="预约时间" min-width="150"><template #default="{ row }">{{ text(row.appointmentTime || row.expectedTime) }}</template></el-table-column>
        <el-table-column prop="submittedAt" label="提交时间" min-width="170" />
        <el-table-column prop="status" label="状态" min-width="110" />
        <el-table-column label="操作" width="170" fixed="right"><template #default="{ row }"><el-button link type="primary" :loading="processingId === row.id" @click="handle(row)">处理</el-button><el-button link type="danger" :loading="processingId === row.id" @click="handle(row, '已驳回')">驳回</el-button></template></el-table-column>
      </el-table>
      <div class="pagination-row"><el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.size" background layout="total, prev, pager, next" :total="pagination.total" @change="loadData" /></div>
    </section>
  </div>
</template>
talent-card-admin/src/views/BenefitCouponManagementView.vue
New file
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { createBenefitServiceCoupon, getBenefitServiceCoupons, issueBenefitServiceCoupon, updateBenefitServiceCoupon, updateBenefitServiceCouponStatus } from '../api/admin'
const loading = ref(false)
const rows = ref<Record<string, any>[]>([])
const dialogVisible = ref(false)
const issueVisible = ref(false)
const editingId = ref<number | string | null>(null)
const activeCoupon = ref<Record<string, any> | null>(null)
const filters = reactive({ keyword: '', couponType: '', batchNo: '', status: undefined as number | undefined, validOn: '' })
const pagination = reactive({ page: 1, size: 10, total: 0 })
const form = reactive({ merchantId: '', couponName: '', couponType: 'CASH', couponAmount: 0, conditionType: 'NONE', conditionAmount: 0, talentLevels: [] as string[], validStart: '', validEnd: '', batchNo: '', totalQuantity: 100, governmentSubsidy: false, status: 1 })
const issueForm = reactive({ talentId: '', cardId: '', expireTime: '', remark: '' })
const couponTypes = [{ label: '代金券', value: 'CASH' }, { label: '满减券', value: 'FULL_REDUCTION' }, { label: '折扣券', value: 'DISCOUNT' }, { label: '兑换券', value: 'EXCHANGE' }]
const talentLevels = ['EXCELLENT', 'OUTSTANDING', 'ELITE', 'YOUTH']
function text(value: unknown) { return value === undefined || value === null || value === '' ? '-' : String(value) }
async function loadData() { loading.value = true; try { const result = await getBenefitServiceCoupons({ page: pagination.page, size: pagination.size, keyword: filters.keyword || undefined, couponType: filters.couponType || undefined, batchNo: filters.batchNo || undefined, status: filters.status, validOn: filters.validOn || undefined }); rows.value = result.records || []; pagination.total = result.total || 0 } finally { loading.value = false } }
function search() { pagination.page = 1; loadData() }
function resetFilters() { Object.assign(filters, { keyword: '', couponType: '', batchNo: '', status: undefined, validOn: '' }); search() }
function openCreate() { editingId.value = null; Object.assign(form, { merchantId: '', couponName: '', couponType: 'CASH', couponAmount: 0, conditionType: 'NONE', conditionAmount: 0, talentLevels: [], validStart: '', validEnd: '', batchNo: '', totalQuantity: 100, governmentSubsidy: false, status: 1 }); dialogVisible.value = true }
function openEdit(row: Record<string, any>) { editingId.value = row.id; Object.assign(form, { merchantId: row.merchantId || '', couponName: row.couponName || row.name || '', couponType: row.couponType || 'CASH', couponAmount: Number(row.couponAmount || row.amount || 0), conditionType: row.conditionType || 'NONE', conditionAmount: Number(row.conditionAmount || 0), talentLevels: row.talentLevels || [], validStart: row.validStart || '', validEnd: row.validEnd || '', batchNo: row.batchNo || '', totalQuantity: Number(row.totalQuantity ?? 100), governmentSubsidy: Boolean(row.governmentSubsidy), status: Number(row.status ?? 1) }); dialogVisible.value = true }
async function save() { const payload = { ...form, merchantId: Number(form.merchantId), totalQuantity: Number(form.totalQuantity), couponAmount: Number(form.couponAmount), conditionAmount: Number(form.conditionAmount || 0) }; if (editingId.value) await updateBenefitServiceCoupon(editingId.value, payload); else await createBenefitServiceCoupon(payload); ElMessage.success('保存成功'); dialogVisible.value = false; await loadData() }
async function toggleStatus(row: Record<string, any>) { await updateBenefitServiceCouponStatus(row.id, Number(row.status) === 1 ? 0 : 1); ElMessage.success('状态已更新'); await loadData() }
function openIssue(row: Record<string, any>) { activeCoupon.value = row; Object.assign(issueForm, { talentId: '', cardId: '', expireTime: '', remark: '' }); issueVisible.value = true }
async function issue() { if (!activeCoupon.value) return; await issueBenefitServiceCoupon(activeCoupon.value.id, { talentId: Number(issueForm.talentId), cardId: issueForm.cardId ? Number(issueForm.cardId) : undefined, expireTime: issueForm.expireTime || undefined, remark: issueForm.remark }); ElMessage.success('发放成功'); issueVisible.value = false }
onMounted(loadData)
</script>
<template>
  <div class="page-stack"><div class="toolbar"><div><h2>优惠券管理</h2><p>管理优惠券类型、面额、适用商户、有效期、批次和领取发放。</p></div><div class="toolbar-actions"><el-button :icon="Refresh" :loading="loading" @click="loadData">刷新</el-button><el-button type="primary" :icon="Plus" @click="openCreate">新增优惠券</el-button></div></div>
    <section class="panel"><el-form class="filter-form" :model="filters" label-width="84px"><el-form-item label="关键词"><el-input v-model="filters.keyword" clearable placeholder="优惠券或商户名称" /></el-form-item><el-form-item label="券类型"><el-select v-model="filters.couponType" clearable placeholder="全部"><el-option v-for="item in couponTypes" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="批次号"><el-input v-model="filters.batchNo" clearable /></el-form-item><el-form-item label="状态"><el-select v-model="filters.status" clearable placeholder="全部"><el-option label="上架" :value="1" /><el-option label="下架" :value="0" /></el-select></el-form-item><el-form-item label="有效日期"><el-date-picker v-model="filters.validOn" value-format="YYYY-MM-DD" /></el-form-item><div class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="Refresh" @click="resetFilters">重置</el-button></div></el-form>
      <el-table v-loading="loading" :data="rows" border empty-text="暂无优惠券数据"><el-table-column label="优惠券名称" min-width="160"><template #default="{ row }">{{ text(row.couponName || row.name) }}</template></el-table-column><el-table-column prop="merchantName" label="适用商户" min-width="150" show-overflow-tooltip /><el-table-column prop="couponType" label="类型" width="110" /><el-table-column label="面额" width="100"><template #default="{ row }">{{ text(row.couponAmount || row.amount) }}</template></el-table-column><el-table-column prop="batchNo" label="批次号" min-width="130" /><el-table-column label="有效期" min-width="190"><template #default="{ row }">{{ text(row.validStart) }} ~ {{ text(row.validEnd) }}</template></el-table-column><el-table-column label="状态" width="90"><template #default="{ row }">{{ Number(row.status) === 1 ? '上架' : '下架' }}</template></el-table-column><el-table-column label="操作" width="220" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openEdit(row)">编辑</el-button><el-button link type="primary" @click="toggleStatus(row)">{{ Number(row.status) === 1 ? '下架' : '上架' }}</el-button><el-button link type="primary" @click="openIssue(row)">发放</el-button></template></el-table-column></el-table><div class="pagination-row"><el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.size" background layout="total, prev, pager, next" :total="pagination.total" @change="loadData" /></div></section>
    <el-dialog v-model="dialogVisible" :title="editingId ? '编辑优惠券' : '新增优惠券'" width="680px"><el-form :model="form" label-width="110px"><el-form-item label="商户ID" required><el-input v-model="form.merchantId" /></el-form-item><el-form-item label="券名称" required><el-input v-model="form.couponName" /></el-form-item><el-form-item label="券类型"><el-select v-model="form.couponType"><el-option v-for="item in couponTypes" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="面额"><el-input-number v-model="form.couponAmount" :min="0" /></el-form-item><el-form-item label="适用卡等级"><el-select v-model="form.talentLevels" multiple clearable><el-option v-for="level in talentLevels" :key="level" :label="level" :value="level" /></el-select></el-form-item><el-form-item label="有效期"><el-date-picker v-model="form.validStart" value-format="YYYY-MM-DD" placeholder="开始日期" /><el-date-picker v-model="form.validEnd" value-format="YYYY-MM-DD" placeholder="结束日期" /></el-form-item><el-form-item label="批次号"><el-input v-model="form.batchNo" /></el-form-item><el-form-item label="发放总量"><el-input-number v-model="form.totalQuantity" :min="-1" /></el-form-item><el-form-item label="政府补贴"><el-switch v-model="form.governmentSubsidy" /></el-form-item><el-form-item label="状态"><el-switch v-model="form.status" :active-value="1" :inactive-value="0" /></el-form-item></el-form><template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" @click="save">保存</el-button></template></el-dialog>
    <el-dialog v-model="issueVisible" title="发放优惠券" width="520px"><el-form :model="issueForm" label-width="100px"><el-form-item label="持卡人ID" required><el-input v-model="issueForm.talentId" /></el-form-item><el-form-item label="人才卡ID"><el-input v-model="issueForm.cardId" /></el-form-item><el-form-item label="过期时间"><el-date-picker v-model="issueForm.expireTime" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="备注"><el-input v-model="issueForm.remark" type="textarea" /></el-form-item></el-form><template #footer><el-button @click="issueVisible = false">取消</el-button><el-button type="primary" @click="issue">确认发放</el-button></template></el-dialog>
  </div>
</template>
talent-card-admin/src/views/CarWashBenefitView.vue
New file
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { Refresh, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { auditCarWashCredential, getCarWashCredentials, getCarWashJumpConfigs, rollbackCarWashCredential, saveCarWashJumpConfig } from '../api/admin'
const activeTab = ref('configs')
const loading = ref(false)
const rows = ref<Record<string, any>[]>([])
const filters = reactive({ keyword: '', status: undefined as number | undefined, dateRange: [] as string[] })
const pagination = reactive({ page: 1, size: 10, total: 0 })
const configDialogVisible = ref(false)
const configForm = reactive({ merchantId: '', externalLinkType: 'miniProgram', externalMiniProgramAppId: '', externalMiniProgramPath: '', externalWebUrl: '', status: 1 })
const statusOptions = [{ label: '待审核', value: 0 }, { label: '已通过', value: 1 }, { label: '已驳回', value: 2 }, { label: '已回滚', value: 3 }]
async function loadData() { loading.value = true; try { if (activeTab.value === 'configs') { const result = await getCarWashJumpConfigs({ page: pagination.page, size: pagination.size, keyword: filters.keyword || undefined }); rows.value = result.records || []; pagination.total = result.total || 0 } else { const result = await getCarWashCredentials({ page: pagination.page, size: pagination.size, keyword: filters.keyword || undefined, status: filters.status, startDate: filters.dateRange?.[0], endDate: filters.dateRange?.[1] }); rows.value = result.records || []; pagination.total = result.total || 0 } } finally { loading.value = false } }
function search() { pagination.page = 1; loadData() }
function resetFilters() { Object.assign(filters, { keyword: '', status: undefined, dateRange: [] }); search() }
function switchTab() { pagination.page = 1; rows.value = []; loadData() }
function openConfig(row: Record<string, any>) { Object.assign(configForm, { merchantId: row.merchantId || row.id, externalLinkType: row.externalLinkType || 'none', externalMiniProgramAppId: row.externalMiniProgramAppId || '', externalMiniProgramPath: row.externalMiniProgramPath || '', externalWebUrl: row.externalWebUrl || '', status: Number(row.serviceStatus ?? row.status ?? 1) }); configDialogVisible.value = true }
async function saveConfig() { await saveCarWashJumpConfig(configForm.merchantId, { externalLinkType: configForm.externalLinkType, externalMiniProgramAppId: configForm.externalMiniProgramAppId, externalMiniProgramPath: configForm.externalMiniProgramPath, externalWebUrl: configForm.externalWebUrl, status: configForm.status }); ElMessage.success('保存成功'); configDialogVisible.value = false; await loadData() }
async function audit(row: Record<string, any>, status: number) { await auditCarWashCredential(row.id, status, status === 1 ? '后台审核通过' : '后台审核驳回'); ElMessage.success('审核完成'); await loadData() }
async function rollback(row: Record<string, any>) { await rollbackCarWashCredential(row.id, '后台回滚权益次数'); ElMessage.success('已回滚'); await loadData() }
onMounted(loadData)
</script>
<template>
  <div class="page-stack">
    <div class="toolbar"><div><h2>洗车权益管理</h2><p>维护洗车第三方跳转配置,查看凭证并人工审核/回滚权益次数。</p></div><el-button :icon="Refresh" :loading="loading" @click="loadData">刷新</el-button></div>
    <section class="panel">
      <el-tabs v-model="activeTab" @tab-change="switchTab"><el-tab-pane label="跳转配置" name="configs" /><el-tab-pane label="凭证审核" name="credentials" /></el-tabs>
      <el-form class="filter-form" :model="filters" label-width="84px"><el-form-item label="关键词"><el-input v-model="filters.keyword" clearable placeholder="商家、地址、手机号、凭证编号" /></el-form-item><el-form-item v-if="activeTab === 'credentials'" label="状态"><el-select v-model="filters.status" clearable placeholder="全部"><el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item v-if="activeTab === 'credentials'" label="日期"><el-date-picker v-model="filters.dateRange" type="daterange" value-format="YYYY-MM-DD" /></el-form-item><div class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="Refresh" @click="resetFilters">重置</el-button></div></el-form>
      <el-table v-if="activeTab === 'configs'" v-loading="loading" :data="rows" border empty-text="暂无跳转配置"><el-table-column prop="merchantId" label="商户ID" width="100" /><el-table-column prop="merchantName" label="商户名称" min-width="160" /><el-table-column prop="externalLinkType" label="跳转类型" min-width="110" /><el-table-column prop="externalMiniProgramAppId" label="小程序AppId" min-width="160" /><el-table-column prop="externalMiniProgramPath" label="小程序路径" min-width="200" show-overflow-tooltip /><el-table-column prop="externalWebUrl" label="网页地址" min-width="200" show-overflow-tooltip /><el-table-column label="状态" width="90"><template #default="{ row }">{{ Number(row.serviceStatus ?? row.status) === 1 ? '启用' : '停用' }}</template></el-table-column><el-table-column label="操作" width="120" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openConfig(row)">配置</el-button></template></el-table-column></el-table>
      <el-table v-else v-loading="loading" :data="rows" border empty-text="暂无洗车凭证"><el-table-column prop="credentialNo" label="凭证编号" min-width="130" /><el-table-column prop="talentName" label="持卡人" min-width="110" /><el-table-column prop="talentMobile" label="手机号" min-width="120" /><el-table-column prop="merchantName" label="商户" min-width="150" /><el-table-column prop="submitTime" label="提交时间" min-width="170" /><el-table-column prop="statusText" label="状态" min-width="110" /><el-table-column label="操作" width="210" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="audit(row, 1)">通过</el-button><el-button link type="danger" @click="audit(row, 2)">驳回</el-button><el-button link type="warning" @click="rollback(row)">回滚</el-button></template></el-table-column></el-table>
      <div class="pagination-row"><el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.size" background layout="total, prev, pager, next" :total="pagination.total" @change="loadData" /></div>
    </section>
    <el-dialog v-model="configDialogVisible" title="洗车跳转配置" width="620px"><el-form :model="configForm" label-width="130px"><el-form-item label="商户ID"><el-input v-model="configForm.merchantId" disabled /></el-form-item><el-form-item label="跳转类型"><el-select v-model="configForm.externalLinkType"><el-option label="第三方小程序" value="miniProgram" /><el-option label="网页" value="web" /><el-option label="不跳转" value="none" /></el-select></el-form-item><el-form-item label="小程序AppId"><el-input v-model="configForm.externalMiniProgramAppId" /></el-form-item><el-form-item label="小程序路径"><el-input v-model="configForm.externalMiniProgramPath" /></el-form-item><el-form-item label="网页地址"><el-input v-model="configForm.externalWebUrl" /></el-form-item><el-form-item label="状态"><el-switch v-model="configForm.status" :active-value="1" :inactive-value="0" /></el-form-item></el-form><template #footer><el-button @click="configDialogVisible = false">取消</el-button><el-button type="primary" @click="saveConfig">保存</el-button></template></el-dialog>
  </div>
</template>
talent-card-admin/src/views/DisplayServiceManagementView.vue
New file
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { createDisplayService, getDisplayServiceCategories, getDisplayServiceDetail, getDisplayServices, updateDisplayService, updateDisplayServiceSort, updateDisplayServiceStatus } from '../api/admin'
const loading = ref(false)
const rows = ref<Record<string, any>[]>([])
const categories = ref<Record<string, any>[]>([])
const dialogVisible = ref(false)
const editingId = ref<number | string | null>(null)
const filters = reactive({ keyword: '', category: '', status: undefined as number | undefined })
const pagination = reactive({ page: 1, size: 10, total: 0 })
const form = reactive({ merchantName: '', category: '', contactName: '', contactMobile: '', address: '', logoUrl: '', coverUrl: '', serviceLabel: '', rightsText: '', handlingInstructions: '', sortNo: 0, status: 1, recommend: 0, cardPolicies: [] as any[] })
async function loadCategories() { try { categories.value = await getDisplayServiceCategories() } catch { categories.value = [] } }
async function loadData() { loading.value = true; try { const result = await getDisplayServices({ page: pagination.page, size: pagination.size, keyword: filters.keyword || undefined, category: filters.category || undefined, status: filters.status }); rows.value = result.records || []; pagination.total = result.total || 0 } finally { loading.value = false } }
function search() { pagination.page = 1; loadData() }
function resetFilters() { Object.assign(filters, { keyword: '', category: '', status: undefined }); search() }
function resetForm(row?: Record<string, any>) { editingId.value = row?.serviceId || row?.id || null; Object.assign(form, { merchantName: row?.merchantName || '', category: row?.category || '', contactName: row?.contactName || '', contactMobile: row?.contactMobile || '', address: row?.address || '', logoUrl: row?.logoUrl || '', coverUrl: row?.coverUrl || '', serviceLabel: row?.serviceLabel || '', rightsText: row?.rightsText || '', handlingInstructions: row?.handlingInstructions || '', sortNo: Number(row?.sortNo || 0), status: Number(row?.status ?? 1), recommend: Number(row?.recommend ?? 0), cardPolicies: row?.cardPolicies || [] }) }
function openCreate() { resetForm(); dialogVisible.value = true }
async function openEdit(row: Record<string, any>) { const detail = await getDisplayServiceDetail(row.serviceId || row.id); resetForm(detail); dialogVisible.value = true }
async function save() { if (editingId.value) await updateDisplayService(editingId.value, form); else await createDisplayService(form); ElMessage.success('保存成功'); dialogVisible.value = false; await loadData() }
async function toggleStatus(row: Record<string, any>) { await updateDisplayServiceStatus(row.serviceId || row.id, Number(row.status) === 1 ? 0 : 1); ElMessage.success('状态已更新'); await loadData() }
async function changeSort(row: Record<string, any>) { await updateDisplayServiceSort(row.serviceId || row.id, Number(row.sortNo || 0)); ElMessage.success('排序已更新'); await loadData() }
onMounted(async () => { await loadCategories(); await loadData() })
</script>
<template>
  <div class="page-stack">
    <div class="toolbar"><div><h2>展示类服务管理</h2><p>维护停车、体检、购房、购车、家电、公寓租赁等展示服务。</p></div><div class="toolbar-actions"><el-button :icon="Refresh" :loading="loading" @click="loadData">刷新</el-button><el-button type="primary" :icon="Plus" @click="openCreate">新增服务</el-button></div></div>
    <section class="panel">
      <el-form class="filter-form" :model="filters" label-width="84px"><el-form-item label="关键词"><el-input v-model="filters.keyword" clearable placeholder="商户名称、联系人、地址" /></el-form-item><el-form-item label="分类"><el-select v-model="filters.category" clearable placeholder="全部"><el-option label="全部" value="" /><el-option v-for="item in categories" :key="item.key || item.categoryKey" :label="item.name || item.categoryLabel" :value="item.key || item.categoryKey" /></el-select></el-form-item><el-form-item label="状态"><el-select v-model="filters.status" clearable placeholder="全部"><el-option label="上架" :value="1" /><el-option label="下架" :value="0" /></el-select></el-form-item><div class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="Refresh" @click="resetFilters">重置</el-button></div></el-form>
      <el-table v-loading="loading" :data="rows" border empty-text="暂无展示服务数据"><el-table-column prop="merchantName" label="服务名称" min-width="150" /><el-table-column prop="categoryName" label="分类" min-width="120" /><el-table-column prop="contactName" label="联系人" min-width="100" /><el-table-column prop="contactMobile" label="联系电话" min-width="130" /><el-table-column prop="address" label="地址" min-width="170" show-overflow-tooltip /><el-table-column prop="sortNo" label="排序" min-width="80" /><el-table-column label="状态" min-width="90"><template #default="{ row }">{{ Number(row.status) === 1 ? '上架' : '下架' }}</template></el-table-column><el-table-column label="操作" width="240" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openEdit(row)">编辑</el-button><el-button link type="primary" @click="toggleStatus(row)">{{ Number(row.status) === 1 ? '下架' : '上架' }}</el-button><el-button link type="primary" @click="changeSort(row)">更新排序</el-button></template></el-table-column></el-table>
      <div class="pagination-row"><el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.size" background layout="total, prev, pager, next" :total="pagination.total" @change="loadData" /></div>
    </section>
    <el-dialog v-model="dialogVisible" title="展示类服务" width="760px"><el-form :model="form" label-width="110px"><el-form-item label="服务名称"><el-input v-model="form.merchantName" /></el-form-item><el-form-item label="分类"><el-select v-model="form.category"><el-option v-for="item in categories" :key="item.key || item.categoryKey" :label="item.name || item.categoryLabel" :value="item.key || item.categoryKey" /></el-select></el-form-item><el-form-item label="联系人"><el-input v-model="form.contactName" /></el-form-item><el-form-item label="联系电话"><el-input v-model="form.contactMobile" /></el-form-item><el-form-item label="地址"><el-input v-model="form.address" /></el-form-item><el-form-item label="封面图"><el-input v-model="form.coverUrl" /></el-form-item><el-form-item label="服务标签"><el-input v-model="form.serviceLabel" /></el-form-item><el-form-item label="权益说明"><el-input v-model="form.rightsText" type="textarea" :rows="4" /></el-form-item><el-form-item label="办理说明"><el-input v-model="form.handlingInstructions" type="textarea" :rows="4" /></el-form-item><el-form-item label="排序"><el-input-number v-model="form.sortNo" :min="0" /></el-form-item><el-form-item label="推荐"><el-switch v-model="form.recommend" :active-value="1" :inactive-value="0" /></el-form-item><el-form-item label="状态"><el-switch v-model="form.status" :active-value="1" :inactive-value="0" /></el-form-item></el-form><template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" @click="save">保存</el-button></template></el-dialog>
  </div>
</template>