@@ -0,0 +1,139 @@
< template >
< section class = "third-party-page" >
< el-alert v-if = "lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" / >
< el-alert v-if = "lastMessage" :title="lastMessage" type="success" show-icon closable @close="lastMessage = ''" / >
< section class = "third-party-metrics" >
< span > < small > 直订记录 < / small > < strong > { { records . bookings . length } } < / strong > < / span >
< span > < small > 待映射直订 < / small > < strong > { { pendingMappingCount } } < / strong > < / span >
< span > < small > 核销记录 < / small > < strong > { { records . redemptions . length } } < / strong > < / span >
< span > < small > 失败 / 待处理 < / small > < strong > { { attentionCount } } < / strong > < / span >
< / section >
< section class = "panel" >
< header class = "panel-toolbar third-party-toolbar" >
< div > < p class = "section-kicker" > M08 - D · 渠道运营 < / p > < h3 > 团购与第三方平台 < / h3 > < / div >
< div class = "toolbar-actions" >
< el-select v-model = "provider" class="filter-select" placeholder="全部平台" @change="applyFilters"><el-option label="全部平台" value="" /><el-option v-for="item in providerOptions" :key="item.value" :label="item.label" :value="item.value" / > < / el-select >
< el-select v-model = "storeId" class="filter-select" filterable placeholder="全部授权门店" @change="applyFilters"><el-option label="全部授权门店" value="" /><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" / > < / el-select >
< el-input v-model = "status" class="search-input" clearable placeholder="状态精确筛选" @keyup.enter="applyFilters" / >
< el-button :icon = "RefreshCw" :loading = "loading" @click ="loadData" > 刷新 < / el -button >
< / div >
< / header >
< el-tabs v-model = "activeTab" class="third-party-tabs" >
< el -tab -pane label = "直订记录" name = "bookings" >
< el-table v-loading = "loading" :data="records.bookings" class="data-table" row-key="id" >
< el -table -column label = "平台订单" min -width = " 190 " > < template # default = "{ row }" > < div class = "stack" > < strong > { { row . externalBookingNo } } < / strong > < span > { { providerLabel ( row . provider ) } } · # { { row . id } } < / span > < / div > < / template > < / el-table-column >
< el-table-column label = "门店 / 房间" min -width = " 180 " > < template # default = "{ row }" > < div class = "stack" > < strong > { { row . storeId ? storeName ( row . storeId ) : '待映射门店' } } < / strong > < span > { { row . roomId ? roomName ( row . roomId ) : '待映射房间' } } < / span > < / div > < / template > < / el-table-column >
< el-table-column label = "使用时段" min -width = " 200 " > < template # default = "{ row }" > < div class = "stack" > < strong > { { formatDate ( row . startsAt ) } } < / strong > < span > 至 { { formatDate ( row . endsAt ) } } < / span > < / div > < / template > < / el-table-column >
< el-table-column label = "金额" width = "120" > < template # default = "{ row }" > { { money ( row . amountCents ) } } < / template > < / el-table-column >
< el-table-column label = "状态" width = "150" > < template # default = "{ row }" > < el-tag :type = "recordStatusType(row.status)" > { { row . status } } < / el-tag > < / template > < / el-table-column >
< el-table-column label = "本地订单 / 异常" min -width = " 180 " > < template # default = "{ row }" > < div class = "stack" > < strong > { { row . orderId ? ` # ${ row . orderId } ` : '-' } } < / strong > < span > { { row . failureCode || formatDate ( row . createdAt ) } } < / span > < / div > < / template > < / el-table-column >
< / el-table >
< / el-tab-pane >
< el-tab-pane label = "核销记录" name = "redemptions" >
< div class = "tab-action-row" > < span > 券码只显示脱敏结果 , 真实券码不会进入列表或日志 。 < / span > < el-button type = "primary" :icon = "ScanLine" @click ="openRedeem" > 验券 < / el -button > < / div >
< el-table v-loading = "loading" :data="records.redemptions" class="data-table" row-key="id" >
< el -table -column label = "券码" min -width = " 170 " > < template # default = "{ row }" > < div class = "stack" > < strong > { { row . voucherMasked } } < / strong > < span > { { providerLabel ( row . provider ) } } · { { row . mode } } < / span > < / div > < / template > < / el-table-column >
< el-table-column label = "订单 / 门店" min -width = " 180 " > < template # default = "{ row }" > < div class = "stack" > < strong > 订单 # { { row . orderId } } < / strong > < span > { { storeName ( row . storeId ) } } < / span > < / div > < / template > < / el-table-column >
< el-table-column label = "状态" width = "150" > < template # default = "{ row }" > < el-tag :type = "recordStatusType(row.status)" > { { row . status } } < / el-tag > < / template > < / el-table-column >
< el-table-column label = "操作人" width = "120" > < template # default = "{ row }" > { { row . actorId ? ` # ${ row . actorId } ` : '-' } } < / template > < / el-table-column >
< el-table-column label = "完成时间 / 异常" min -width = " 190 " > < template # default = "{ row }" > < div class = "stack" > < strong > { { formatDate ( row . completedAt || row . createdAt ) } } < / strong > < span > { { row . failureCode || '-' } } < / span > < / div > < / template > < / el-table-column >
< / el-table >
< / el-tab-pane >
< el-tab-pane label = "平台配置" name = "configs" >
< div class = "tab-action-row" > < span > 只登记环境凭据引用 ; 敏感设置键由服务端过滤 , 不在读取接口返回 。 < / span > < el-button type = "primary" :icon = "Plus" @click ="openConfig" > 配置平台 < / el -button > < / div >
< el-table :data = "setup.configs" class = "data-table" row -key = " id " >
< el-table-column label = "平台" min -width = " 130 " > < template # default = "{ row }" > < strong > { { providerLabel ( row . provider ) } } < / strong > < / template > < / el-table-column >
< el-table-column label = "范围" min -width = " 160 " > < template # default = "{ row }" > { { row . storeId ? storeName ( row . storeId ) : '租户默认' } } < / template > < / el-table-column >
< el-table-column prop = "mode" label = "模式" width = "110" / >
< el-table-column label = "凭据" min -width = " 180 " > < template # default = "{ row }" > < div class = "stack" > < strong > { { row . credentialConfigured ? '已配置引用' : '未配置' } } < / strong > < span > { { row . credentialRef || '-' } } < / span > < / div > < / template > < / el-table-column >
< el-table-column label = "安全设置" min -width = " 240 " > < template # default = "{ row }" > < code class = "settings-preview" > { { JSON . stringify ( row . settings ) } } < / code > < / template > < / el-table-column >
< el-table-column label = "启用" width = "90" > < template # default = "{ row }" > < el-tag : type = "row.enabled ? 'success' : 'info'" > { { row . enabled ? '是' : '否' } } < / el-tag > < / template > < / el-table-column >
< / el-table >
< / el-tab-pane >
< el-tab-pane label = "资源映射" name = "mappings" >
< div class = "tab-action-row" > < span > 外部门店与房间必须映射到同一租户下的真实资源 。 < / span > < el-button type = "primary" :icon = "Link2" @click ="openMapping" > 新增映射 < / el -button > < / div >
< el-table :data = "setup.mappings" class = "data-table" row -key = " id " >
< el-table-column label = "平台" min -width = " 130 " > < template # default = "{ row }" > { { providerLabel ( row . provider ) } } < / template > < / el-table-column >
< el-table-column prop = "resourceType" label = "资源类型" width = "120" / >
< el-table-column prop = "externalRef" label = "外部标识" min -width = " 220 " / >
< el-table-column label = "本地资源" min -width = " 220 " > < template # default = "{ row }" > { { mappingResourceName ( row . resourceType , row . localResourceId ) } } < / template > < / el-table-column >
< / el-table >
< / el-tab-pane >
< / el-tabs >
< / section >
< el-dialog v-model = "redeemDialog" title="团购验券" width="min(580px, 94vw)" >
< el -form label -position = " top " > < el-form-item label = "平台" > < el-select v-model = "redeemForm.provider"><el-option v-for="item in providerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="订单 ID"><el-input v-model="redeemForm.orderId" /></el-form-item><el-form-item label="券码"><el-input v-model="redeemForm.voucherCode" type="password" show-password autocomplete="off" /></el-form-item><el-form-item label="核销方式"><el-radio-group v-model="redeemForm.mode"><el-radio value="API">平台 API/Mock</el-radio><el-radio value="MANUAL">人工确认</el-radio></el-radio-group></el-form-item><template v-if="redeemForm.mode === 'MANUAL'"><el-form-item label="确认金额(元)"><el-input-number v-model="redeemForm.amountYuan" :min="0.01" :precision="2" /></el-form-item><el-form-item label="人工确认备注"><el-input v-model="redeemForm.note" / > < / el-form-item > < / template > < / el-form >
< template # footer > < el-button @click ="redeemDialog = false" > 取消 < / el -button > < el-button type = "primary" :loading = "saving" @click ="saveRedemption" > 确认验券 < / el -button > < / template >
< / el-dialog >
< el-dialog v-model = "configDialog" title="配置第三方平台" width="min(650px, 94vw)" >
< el -form label -position = " top " class = "asset-form" > < el-form-item label = "平台" > < el-select v-model = "configForm.provider"><el-option v-for="item in providerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="门店范围"><el-select v-model="configForm.storeId" clearable><el-option label="租户默认" value="" /><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select></el-form-item><el-form-item label="模式"><el-select v-model="configForm.mode"><el-option label="人工" value="MANUAL" /><el-option label="非生产 Mock" value="MOCK" /><el-option label="平台 API" value="API" /></el-select></el-form-item><el-form-item label="启用"><el-switch v-model="configForm.enabled" /></el-form-item><el-form-item label="凭据环境引用" class="form-span-2"><el-input v-model="configForm.credentialRef" placeholder="env:MEITUAN_STORE_001" /></el-form-item><el-form-item label="非敏感设置 JSON" class="form-span-2"><el-input v-model="configForm.settingsText" type="textarea" :rows="5" placeholder = '{"redeemEndpoint":"https://..."}' / > < / el-form-item > < / el-form >
< template # footer > < el-button @click ="configDialog = false" > 取消 < / el -button > < el-button type = "primary" :loading = "saving" @click ="saveConfig" > 保存配置 < / el -button > < / template >
< / el-dialog >
< el-dialog v-model = "mappingDialog" title="新增或更新资源映射" width="min(620px, 94vw)" >
< el -form label -position = " top " > < el-form-item label = "平台" > < el-select v-model = "mappingForm.provider"><el-option v-for="item in providerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="资源类型"><el-radio-group v-model="mappingForm.resourceType" @change="mappingForm.localResourceId = ''"><el-radio value="STORE">门店</el-radio><el-radio value="ROOM">房间</el-radio></el-radio-group></el-form-item><el-form-item label="外部资源标识"><el-input v-model="mappingForm.externalRef" /></el-form-item><el-form-item label="本地门店"><el-select v-model="mappingForm.storeId" filterable @change="loadMappingRooms"><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select></el-form-item><el-form-item v-if="mappingForm.resourceType === 'ROOM'" label="本地房间"><el-select v-model="mappingForm.localResourceId" filterable><el-option v-for="room in mappingRooms" :key="room.id" :label="`${room.roomNo} · ${room.name}`" :value="room.id" / > < / el-select > < / el-form-item > < / el-form >
< template # footer > < el-button @click ="mappingDialog = false" > 取消 < / el -button > < el-button type = "primary" :loading = "saving" @click ="saveMapping" > 保存映射 < / el -button > < / template >
< / el-dialog >
< / section >
< / template >
< script setup lang = "ts" >
import { computed , reactive , ref , watch } from 'vue' ;
import { ElMessage } from 'element-plus' ;
import { Link2 , Plus , RefreshCw , ScanLine } from '@lucide/vue' ;
import { ApiError , getThirdPartySetup , listManagedRooms , listManagedStores , listThirdPartyRecords , redeemGroupVoucher , redeemGroupVoucherManually , saveThirdPartyConfig , saveThirdPartyMapping , type ApiSession } from '../api' ;
import { money } from '../format' ;
import type { ManagedRoom , ManagedStore , ThirdPartyMode , ThirdPartyProvider , ThirdPartyRecords , ThirdPartySetup } from '../types' ;
const props = defineProps < { session : ApiSession } > ( ) ;
const stores = ref < ManagedStore [ ] > ( [ ] ) ;
const allRooms = ref < ManagedRoom [ ] > ( [ ] ) ;
const mappingRooms = ref < ManagedRoom [ ] > ( [ ] ) ;
const records = reactive < ThirdPartyRecords > ( { bookings : [ ] , redemptions : [ ] } ) ;
const setup = reactive < ThirdPartySetup > ( { configs : [ ] , mappings : [ ] } ) ;
const provider = ref < ThirdPartyProvider | ' ' > ( '' ) ;
const storeId = ref ( '' ) ;
const status = ref ( '' ) ;
const activeTab = ref ( 'bookings' ) ;
const loading = ref ( false ) ;
const saving = ref ( false ) ;
const lastError = ref ( '' ) ;
const lastMessage = ref ( '' ) ;
const redeemDialog = ref ( false ) ;
const configDialog = ref ( false ) ;
const mappingDialog = ref ( false ) ;
const providerOptions : Array < { value : ThirdPartyProvider ; label : string } > = [ { value : 'MEITUAN' , label : '美团' } , { value : 'DIANPING' , label : '大众点评' } , { value : 'DOUYIN' , label : '抖音' } , { value : 'KUAISHOU' , label : '快手' } ] ;
const redeemForm = reactive ( { provider : 'MEITUAN' as ThirdPartyProvider , orderId : '' , voucherCode : '' , mode : 'API' as 'API' | 'MANUAL' , amountYuan : 0 , note : '' } ) ;
const configForm = reactive ( { provider : 'MEITUAN' as ThirdPartyProvider , storeId : '' , mode : 'MANUAL' as ThirdPartyMode , enabled : true , credentialRef : '' , settingsText : '{}' } ) ;
const mappingForm = reactive ( { provider : 'MEITUAN' as ThirdPartyProvider , resourceType : 'STORE' as 'STORE' | 'ROOM' , externalRef : '' , storeId : '' , localResourceId : '' } ) ;
const pendingMappingCount = computed ( ( ) => records . bookings . filter ( ( item ) => item . status === 'PENDING_MAPPING' || ! item . storeId || ! item . roomId ) . length ) ;
const attentionCount = computed ( ( ) => [ ... records . bookings , ... records . redemptions ] . filter ( ( item ) => [ 'FAILED' , 'PENDING' , 'PENDING_MAPPING' , 'MANUAL_REVIEW' ] . includes ( item . status ) ) . length ) ;
async function capture < T > ( work : ( ) => Promise < T > ) { lastError . value = '' ; try { return await work ( ) ; } catch ( error ) { lastError . value = error instanceof ApiError ? ` ${ error . code } ${ error . traceId ? ` · ${ error . traceId } ` : '' } ` : error instanceof Error ? error . message : '操作失败' ; throw error ; } }
async function loadWorkspace ( ) { try { stores . value = await capture ( ( ) => listManagedStores ( props . session ) ) ; const roomLists = await Promise . all ( stores . value . map ( ( store ) => listManagedRooms ( props . session , store . id ) ) ) ; allRooms . value = roomLists . flat ( ) ; await loadData ( ) ; } catch { /* displayed */ } }
async function loadData ( ) { if ( ! props . session . token ) return ; loading . value = true ; try { const [ nextRecords , nextSetup ] = await Promise . all ( [ capture ( ( ) => listThirdPartyRecords ( props . session , { provider : provider . value || undefined , status : status . value . trim ( ) || undefined , storeId : storeId . value || undefined } ) ) , capture ( ( ) => getThirdPartySetup ( props . session , provider . value || undefined ) ) ] ) ; Object . assign ( records , nextRecords ) ; Object . assign ( setup , nextSetup ) ; } catch { /* displayed */ } finally { loading . value = false ; } }
function applyFilters ( ) { void loadData ( ) ; }
function openRedeem ( ) { Object . assign ( redeemForm , { provider : provider . value || 'MEITUAN' , orderId : '' , voucherCode : '' , mode : 'API' , amountYuan : 0 , note : '' } ) ; redeemDialog . value = true ; }
async function saveRedemption ( ) { if ( ! redeemForm . orderId || redeemForm . voucherCode . length < 4 ) return ElMessage . warning ( '请填写订单 ID 和券码' ) ; saving . value = true ; try { const clientRequestId = requestId ( 'redeem' ) ; const result = redeemForm . mode === 'MANUAL' ? await capture ( ( ) => redeemGroupVoucherManually ( props . session , { provider : redeemForm . provider , orderId : redeemForm . orderId , voucherCode : redeemForm . voucherCode , amountCents : Math . round ( redeemForm . amountYuan * 100 ) , note : redeemForm . note , clientRequestId } ) ) : await capture ( ( ) => redeemGroupVoucher ( props . session , { provider : redeemForm . provider , orderId : redeemForm . orderId , voucherCode : redeemForm . voucherCode , clientRequestId } ) ) ; redeemForm . voucherCode = '' ; redeemDialog . value = false ; lastMessage . value = ` 核销 # ${ result . redemptionId } 状态 ${ result . status } ,券码 ${ result . voucherMasked || '已脱敏' } ` ; await loadData ( ) ; } catch { /* displayed */ } finally { saving . value = false ; } }
function openConfig ( ) { Object . assign ( configForm , { provider : provider . value || 'MEITUAN' , storeId : storeId . value , mode : 'MANUAL' , enabled : true , credentialRef : '' , settingsText : '{}' } ) ; configDialog . value = true ; }
async function saveConfig ( ) { let settings : Record < string , unknown > ; try { const parsed : unknown = JSON . parse ( configForm . settingsText || '{}' ) ; if ( ! parsed || typeof parsed !== 'object' || Array . isArray ( parsed ) ) throw new Error ( ) ; settings = parsed as Record < string , unknown > ; } catch { return ElMessage . warning ( '设置必须是 JSON 对象' ) ; } saving . value = true ; try { await capture ( ( ) => saveThirdPartyConfig ( props . session , { provider : configForm . provider , storeId : configForm . storeId || null , mode : configForm . mode , enabled : configForm . enabled , credentialRef : configForm . credentialRef . trim ( ) , settings } ) ) ; configDialog . value = false ; ElMessage . success ( '第三方配置已保存' ) ; await loadData ( ) ; } catch { /* displayed */ } finally { saving . value = false ; } }
function openMapping ( ) { Object . assign ( mappingForm , { provider : provider . value || 'MEITUAN' , resourceType : 'STORE' , externalRef : '' , storeId : storeId . value || stores . value [ 0 ] ? . id || '' , localResourceId : '' } ) ; mappingDialog . value = true ; void loadMappingRooms ( ) ; }
async function loadMappingRooms ( ) { mappingRooms . value = allRooms . value . filter ( ( room ) => room . storeId === mappingForm . storeId ) ; if ( mappingForm . resourceType === 'STORE' ) mappingForm . localResourceId = mappingForm . storeId ; }
async function saveMapping ( ) { const localResourceId = mappingForm . resourceType === 'STORE' ? mappingForm . storeId : mappingForm . localResourceId ; if ( ! mappingForm . externalRef . trim ( ) || ! localResourceId ) return ElMessage . warning ( '请完整填写外部标识和本地资源' ) ; saving . value = true ; try { await capture ( ( ) => saveThirdPartyMapping ( props . session , { provider : mappingForm . provider , resourceType : mappingForm . resourceType , externalRef : mappingForm . externalRef . trim ( ) , localResourceId } ) ) ; mappingDialog . value = false ; ElMessage . success ( '资源映射已保存' ) ; await loadData ( ) ; } catch { /* displayed */ } finally { saving . value = false ; } }
function providerLabel ( value : ThirdPartyProvider ) { return providerOptions . find ( ( item ) => item . value === value ) ? . label || value ; }
function storeName ( id : string ) { return stores . value . find ( ( item ) => item . id === String ( id ) ) ? . name || ` 门店 # ${ id } ` ; }
function roomName ( id : string ) { const room = allRooms . value . find ( ( item ) => item . id === String ( id ) ) ; return room ? ` ${ room . roomNo } · ${ room . name } ` : ` 房间 # ${ id } ` ; }
function mappingResourceName ( type : 'STORE' | 'ROOM' , id : string ) { return type === 'STORE' ? storeName ( id ) : roomName ( id ) ; }
function recordStatusType ( value : string ) { return [ 'SUCCEEDED' , 'READY_TO_CLAIM' , 'CLAIMED' ] . includes ( value ) ? 'success' : [ 'FAILED' , 'REJECTED' ] . includes ( value ) ? 'danger' : 'warning' ; }
function formatDate ( value : string | null ) { if ( ! value ) return '-' ; const date = new Date ( value ) ; return Number . isNaN ( date . getTime ( ) ) ? value : new Intl . DateTimeFormat ( 'zh-CN' , { month : '2-digit' , day : '2-digit' , hour : '2-digit' , minute : '2-digit' , hour12 : false } ) . format ( date ) ; }
function requestId ( prefix : string ) { return ` ${ prefix } - ${ Date . now ( ) } - ${ Math . random ( ) . toString ( 16 ) . slice ( 2 , 10 ) } ` ; }
watch ( ( ) => props . session . token , ( token ) => { if ( token ) void loadWorkspace ( ) ; } , { immediate : true } ) ;
< / script >