feat(M08-D): 补团购与第三方平台运营

This commit is contained in:
Codex
2026-08-10 12:10:15 +08:00
parent e8bd176914
commit 7b978089d5
9 changed files with 493 additions and 50 deletions
+18 -1
View File
@@ -59,6 +59,15 @@
<WalletCards :size="18" />
<span>支付分账</span>
</button>
<button
class="nav-item"
:class="{ active: activeModule === 'thirdParty' }"
type="button"
@click="activeModule = 'thirdParty'"
>
<TicketCheck :size="18" />
<span>团购平台</span>
</button>
</nav>
</aside>
@@ -104,6 +113,11 @@
:session="session"
/>
<ThirdPartyPanel
v-else-if="activeModule === 'thirdParty'"
:session="session"
/>
<template v-else>
<section class="metric-grid" aria-label="保洁概览">
<div class="metric">
@@ -274,6 +288,7 @@ import {
Send,
Sparkles,
Store,
TicketCheck,
WalletCards
} from '@lucide/vue';
import CleaningTasksPanel from './components/CleaningTasksPanel.vue';
@@ -285,6 +300,7 @@ import OperationsOverviewPanel from './components/OperationsOverviewPanel.vue';
import StoresRoomsPanel from './components/StoresRoomsPanel.vue';
import OrdersPanel from './components/OrdersPanel.vue';
import PaymentsPanel from './components/PaymentsPanel.vue';
import ThirdPartyPanel from './components/ThirdPartyPanel.vue';
import {
ApiError,
assignCleaningTask,
@@ -321,7 +337,7 @@ import { money } from './format';
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
const tokenDraft = ref(savedToken.value);
const activeModule = ref<'overview' | 'stores' | 'orders' | 'payments' | 'cleaning'>('overview');
const activeModule = ref<'overview' | 'stores' | 'orders' | 'payments' | 'thirdParty' | 'cleaning'>('overview');
const activeTab = ref('tasks');
const lastError = ref('');
const lastMessage = ref('');
@@ -335,6 +351,7 @@ const activeModuleMeta = computed(() => ({
stores: { stage: 'M08-D', title: '门店与房间管理' },
orders: { stage: 'M08-D', title: '订单筛选与运营处置' },
payments: { stage: 'M08-D', title: '支付、退款与分账' },
thirdParty: { stage: 'M08-D', title: '团购与第三方平台运营' },
cleaning: { stage: 'M08-B', title: '保洁任务与结算' }
})[activeModule.value]);
const loading = reactive({ tasks: false, settlements: false, statistics: false, cleaners: false });
+55
View File
@@ -25,6 +25,10 @@ import type {
RoomInput,
RoomOperationalStatus,
StoreInput,
ThirdPartyMode,
ThirdPartyProvider,
ThirdPartyRecords,
ThirdPartySetup,
UserStatus,
WechatTransferPreflight
} from './types';
@@ -229,6 +233,57 @@ export function requestWechatReconciliation(session: ApiSession, input: {
);
}
export function listThirdPartyRecords(session: ApiSession, input: {
provider?: ThirdPartyProvider; status?: string; storeId?: string;
}) {
const params = new URLSearchParams();
if (input.provider) params.set('provider', input.provider);
if (input.status) params.set('status', input.status);
if (input.storeId) params.set('storeId', input.storeId);
const query = params.toString();
return request<ThirdPartyRecords>(session, `/third-party/records${query ? `?${query}` : ''}`);
}
export function getThirdPartySetup(session: ApiSession, provider?: ThirdPartyProvider) {
const query = provider ? `?${new URLSearchParams({ provider })}` : '';
return request<ThirdPartySetup>(session, `/third-party/setup${query}`);
}
export function saveThirdPartyConfig(session: ApiSession, input: {
provider: ThirdPartyProvider; storeId: string | null; mode: ThirdPartyMode;
enabled: boolean; credentialRef: string; settings: Record<string, unknown>;
}) {
return request<{ configId: string; created: boolean }>(session, '/third-party/config', {
method: 'PUT', body: JSON.stringify(input)
});
}
export function saveThirdPartyMapping(session: ApiSession, input: {
provider: ThirdPartyProvider; resourceType: 'STORE' | 'ROOM';
externalRef: string; localResourceId: string;
}) {
return request<{ mapped: boolean }>(session, '/third-party/mappings', {
method: 'PUT', body: JSON.stringify(input)
});
}
export function redeemGroupVoucher(session: ApiSession, input: {
provider: ThirdPartyProvider; voucherCode: string; orderId: string; clientRequestId: string;
}) {
return request<{ redemptionId: string; status: string; voucherMasked: string }>(
session, '/group-vouchers/redeem', { method: 'POST', body: JSON.stringify(input) }
);
}
export function redeemGroupVoucherManually(session: ApiSession, input: {
provider: ThirdPartyProvider; voucherCode: string; orderId: string;
amountCents: number; note: string; clientRequestId: string;
}) {
return request<{ redemptionId: string; status: string; voucherMasked: string }>(
session, '/group-vouchers/redeem-manual', { method: 'POST', body: JSON.stringify(input) }
);
}
export function getBusinessStatistics(
session: ApiSession,
input: { storeId: string; from: string; to: string }
+139
View File
@@ -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>
+49
View File
@@ -1487,6 +1487,53 @@ textarea {
line-height: 1.6;
}
.third-party-page {
display: grid;
gap: 14px;
}
.third-party-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.third-party-metrics span {
display: grid;
gap: 6px;
padding: 14px;
background: #fff;
border: 1px solid #d8e0ea;
border-radius: 8px;
}
.third-party-metrics small {
color: #69788c;
}
.third-party-metrics strong {
color: #172033;
font-size: 22px;
}
.third-party-toolbar h3 {
margin: 0;
}
.third-party-tabs {
padding: 0 14px 14px;
}
.settings-preview {
display: block;
max-width: 320px;
overflow: hidden;
color: #526177;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.el-button {
border-radius: 8px;
}
@@ -1591,6 +1638,7 @@ textarea {
}
.payment-metrics,
.third-party-metrics,
.payment-tools-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -1700,6 +1748,7 @@ textarea {
}
.payment-metrics,
.third-party-metrics,
.payment-tools-grid {
grid-template-columns: 1fr;
}
+62
View File
@@ -206,6 +206,68 @@ export interface ProfitSharingSnapshot {
shares: ProfitShareRecord[];
}
export type ThirdPartyProvider = 'MEITUAN' | 'DIANPING' | 'DOUYIN' | 'KUAISHOU';
export type ThirdPartyMode = 'MANUAL' | 'MOCK' | 'API';
export interface DirectBookingRecord {
id: string;
provider: ThirdPartyProvider;
externalBookingNo: string;
storeId: string | null;
roomId: string | null;
startsAt: string;
endsAt: string;
amountCents: number;
status: string;
orderId: string | null;
failureCode: string;
createdAt: string;
}
export interface GroupRedemptionRecord {
id: string;
provider: ThirdPartyProvider;
voucherMasked: string;
orderId: string;
storeId: string;
mode: ThirdPartyMode;
actorId: string | null;
status: string;
failureCode: string;
createdAt: string;
completedAt: string | null;
}
export interface ThirdPartyRecords {
bookings: DirectBookingRecord[];
redemptions: GroupRedemptionRecord[];
}
export interface ThirdPartyConfigSummary {
id: string;
provider: ThirdPartyProvider;
storeId: string | null;
mode: ThirdPartyMode;
enabled: boolean;
credentialRef: string;
credentialConfigured: boolean;
settings: Record<string, unknown>;
updatedAt: string;
}
export interface ThirdPartyMappingSummary {
id: string;
provider: ThirdPartyProvider;
resourceType: 'STORE' | 'ROOM';
externalRef: string;
localResourceId: string;
}
export interface ThirdPartySetup {
configs: ThirdPartyConfigSummary[];
mappings: ThirdPartyMappingSummary[];
}
export interface BusinessStatistics {
storeId: string;
from: string;
+29 -19
View File
@@ -39,6 +39,7 @@ const recordsQuery = z.object({
status: z.string().min(1).max(32).optional(),
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
});
const setupQuery = z.object({ provider: providerSchema.optional() });
const configSchema = z.object({
provider: providerSchema,
storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().default(null),
@@ -83,24 +84,11 @@ export async function registerThirdPartyRoutes(
}));
});
app.post('/admin-api/group-vouchers/redeem-manual', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, true);
const body = manualSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.redeemVoucherManually({
tenantId: auth.tenantId,
actorId: auth.userId,
access: auth.access!,
...body.data
}),
traceId: request.traceId
}));
});
app.post('/app-api/management/group-vouchers/redeem', async (request, reply) => {
for (const path of [
'/admin-api/group-vouchers/redeem',
'/app-api/management/group-vouchers/redeem'
]) {
app.post(path, async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, true);
const body = redeemSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
@@ -116,8 +104,13 @@ export async function registerThirdPartyRoutes(
traceId: request.traceId
}));
});
}
app.post('/app-api/management/group-vouchers/redeem-manual', async (request, reply) => {
for (const path of [
'/admin-api/group-vouchers/redeem-manual',
'/app-api/management/group-vouchers/redeem-manual'
]) {
app.post(path, async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, true);
const body = manualSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
@@ -133,6 +126,7 @@ export async function registerThirdPartyRoutes(
traceId: request.traceId
}));
});
}
app.post(
'/app-api/third-party/:provider/tenants/:tenantId/bookings/notify',
@@ -192,6 +186,22 @@ export async function registerThirdPartyRoutes(
}));
});
app.get('/admin-api/third-party/setup', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, true);
const query = setupQuery.safeParse(request.query);
if (!auth) return unauthorized(reply, request.traceId);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.listSetup({
tenantId: auth.tenantId,
access: auth.access!,
provider: query.data.provider as ThirdPartyProvider | undefined
}),
traceId: request.traceId
}));
});
app.get('/app-api/management/third-party/records', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, true);
const query = recordsQuery.safeParse(request.query);
+60
View File
@@ -345,6 +345,55 @@ export class ThirdPartyService {
return { bookings, redemptions };
}
async listSetup(input: {
tenantId: string;
access: AccessProfile;
provider?: ThirdPartyProvider;
}) {
if (!input.access.capabilities.includes('tenant.manage')
&& !input.access.roles.includes('PLATFORM_ADMIN')) {
throw new ThirdPartyError('THIRD_PARTY_CONFIG_FORBIDDEN');
}
const params = input.provider ? [input.tenantId, input.provider] : [input.tenantId];
const providerFilter = input.provider ? 'AND provider = ?' : '';
const [configs] = await this.pool.execute<RowDataPacket[]>(
`SELECT id, provider, store_id AS storeId, mode, enabled,
credential_ref AS credentialRef, settings, updated_at AS updatedAt
FROM qipai_third_party_configs
WHERE tenant_id = ? ${providerFilter}
ORDER BY provider, store_id IS NULL DESC, store_id, id`,
params
);
const [mappings] = await this.pool.execute<RowDataPacket[]>(
`SELECT id, provider, resource_type AS resourceType,
external_ref AS externalRef, local_resource_id AS localResourceId
FROM qipai_third_party_mappings
WHERE tenant_id = ? ${providerFilter}
ORDER BY provider, resource_type, id`,
params
);
return {
configs: configs.map((row) => ({
id: String(row.id),
provider: row.provider,
storeId: row.storeId === null ? null : String(row.storeId),
mode: row.mode,
enabled: Boolean(row.enabled),
credentialRef: String(row.credentialRef || ''),
credentialConfigured: Boolean(row.credentialRef),
settings: sanitizeSettings(row.settings),
updatedAt: row.updatedAt
})),
mappings: mappings.map((row) => ({
id: String(row.id),
provider: row.provider,
resourceType: row.resourceType,
externalRef: row.externalRef,
localResourceId: String(row.localResourceId)
}))
};
}
async saveConfig(input: {
tenantId: string;
actorId: string;
@@ -724,6 +773,17 @@ function sanitizePayload(value: Record<string, unknown>) {
return copy;
}
function sanitizeSettings(value: unknown): Record<string, unknown> {
let source: unknown = value;
if (typeof source === 'string') {
try { source = JSON.parse(source); } catch { return {}; }
}
if (!source || typeof source !== 'object' || Array.isArray(source)) return {};
return Object.fromEntries(Object.entries(source as Record<string, unknown>)
.filter(([key]) => !/(secret|token|password|private|api.?key|credential)/i.test(key))
.map(([key, item]) => [key, item]));
}
function normalizeBooking(row: BookingRow) {
return {
bookingId: String(row.id),
+29
View File
@@ -66,6 +66,7 @@ let managerRedeemInput;
let manualRedeemInput;
let recordsInput;
let notifyInput;
let setupInput;
const routeApp = await buildApp({
thirdParty: {
jwtSecret: secret,
@@ -116,6 +117,13 @@ const routeApp = await buildApp({
recordsInput = input;
return { bookings: [], redemptions: [] };
},
async listSetup(input) {
setupInput = input;
return {
configs: [{ id: '81', provider: 'MEITUAN', credentialConfigured: true, settings: {} }],
mappings: [{ id: '91', provider: 'MEITUAN', resourceType: 'STORE' }]
};
},
async saveConfig() {
return { configId: '81', created: true };
},
@@ -153,6 +161,18 @@ assert.equal(managerRedeemed.statusCode, 200);
assert.equal(managerRedeemInput.actorId, '21');
assert.equal(managerRedeemInput.provider, 'DOUYIN');
const adminRedeemed = await routeApp.inject({
method: 'POST',
url: '/admin-api/group-vouchers/redeem',
headers: { authorization: `Bearer ${token}` },
payload: {
provider: 'MEITUAN', voucherCode: '1122334455', orderId: '31',
clientRequestId: 'admin-redeem-request-001'
}
});
assert.equal(adminRedeemed.statusCode, 200);
assert.equal(managerRedeemInput.provider, 'MEITUAN');
const manualRedeemed = await routeApp.inject({
method: 'POST',
url: '/app-api/management/group-vouchers/redeem-manual',
@@ -177,6 +197,15 @@ assert.equal(recordsInput.storeId, '11');
assert.equal(recordsInput.provider, 'MEITUAN');
assert.equal(recordsInput.status, 'SUCCEEDED');
const setup = await routeApp.inject({
method: 'GET',
url: '/admin-api/third-party/setup?provider=MEITUAN',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(setup.statusCode, 200);
assert.equal(setup.json().data.configs[0].credentialConfigured, true);
assert.equal(setupInput.provider, 'MEITUAN');
const bookingPayload = {
eventId: 'event-001',
externalBookingNo: 'booking-001',
+22
View File
@@ -13,10 +13,12 @@ for (const pattern of [
"activeModule = 'stores'",
"activeModule = 'orders'",
"activeModule = 'payments'",
"activeModule = 'thirdParty'",
'平台运营总览',
'StoresRoomsPanel',
'OrdersPanel',
'PaymentsPanel',
'ThirdPartyPanel',
'运营总览',
'savedToken',
'loadCleaningWorkspace'
@@ -120,6 +122,24 @@ for (const pattern of [
assert.match(payments, new RegExp(pattern));
}
const thirdParty = read('admin/src/components/ThirdPartyPanel.vue');
for (const pattern of [
'listThirdPartyRecords',
'getThirdPartySetup',
'redeemGroupVoucher',
'redeemGroupVoucherManually',
'saveThirdPartyConfig',
'saveThirdPartyMapping',
'voucherCode',
'PENDING_MAPPING'
]) {
assert.match(thirdParty, new RegExp(pattern));
}
const thirdPartyRoutes = read('backend/src/routes/third-party.ts');
assert.match(thirdPartyRoutes, /'\/admin-api\/third-party\/setup'/);
assert.match(thirdPartyRoutes, /'\/admin-api\/group-vouchers\/redeem'/);
const routes = read('backend/src/routes/business-statistics.ts');
assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/);
@@ -151,6 +171,8 @@ for (const pattern of [
'.history-card',
'.payment-metrics',
'.payment-tools-grid',
'.third-party-metrics',
'.settings-preview',
'@media (max-width: 980px)',
'@media (max-width: 560px)'
]) {