feat(M08-D): 补支付退款与分账运营

This commit is contained in:
Codex
2026-08-10 12:00:02 +08:00
parent 041146460d
commit 4eac99dd87
6 changed files with 412 additions and 3 deletions
+15 -3
View File
@@ -50,9 +50,14 @@
<RadioTower :size="18" /> <RadioTower :size="18" />
<span>设备</span> <span>设备</span>
</button> </button>
<button class="nav-item" type="button" disabled> <button
class="nav-item"
:class="{ active: activeModule === 'payments' }"
type="button"
@click="activeModule = 'payments'"
>
<WalletCards :size="18" /> <WalletCards :size="18" />
<span>支付</span> <span>支付分账</span>
</button> </button>
</nav> </nav>
</aside> </aside>
@@ -94,6 +99,11 @@
:session="session" :session="session"
/> />
<PaymentsPanel
v-else-if="activeModule === 'payments'"
:session="session"
/>
<template v-else> <template v-else>
<section class="metric-grid" aria-label="保洁概览"> <section class="metric-grid" aria-label="保洁概览">
<div class="metric"> <div class="metric">
@@ -274,6 +284,7 @@ import CleaningFieldHandoffPanel from './components/CleaningFieldHandoffPanel.vu
import OperationsOverviewPanel from './components/OperationsOverviewPanel.vue'; import OperationsOverviewPanel from './components/OperationsOverviewPanel.vue';
import StoresRoomsPanel from './components/StoresRoomsPanel.vue'; import StoresRoomsPanel from './components/StoresRoomsPanel.vue';
import OrdersPanel from './components/OrdersPanel.vue'; import OrdersPanel from './components/OrdersPanel.vue';
import PaymentsPanel from './components/PaymentsPanel.vue';
import { import {
ApiError, ApiError,
assignCleaningTask, assignCleaningTask,
@@ -310,7 +321,7 @@ import { money } from './format';
const savedToken = ref(localStorage.getItem('qipai.admin.token') || ''); const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
const tokenDraft = ref(savedToken.value); const tokenDraft = ref(savedToken.value);
const activeModule = ref<'overview' | 'stores' | 'orders' | 'cleaning'>('overview'); const activeModule = ref<'overview' | 'stores' | 'orders' | 'payments' | 'cleaning'>('overview');
const activeTab = ref('tasks'); const activeTab = ref('tasks');
const lastError = ref(''); const lastError = ref('');
const lastMessage = ref(''); const lastMessage = ref('');
@@ -323,6 +334,7 @@ const activeModuleMeta = computed(() => ({
overview: { stage: 'M08-D', title: '平台运营总览' }, overview: { stage: 'M08-D', title: '平台运营总览' },
stores: { stage: 'M08-D', title: '门店与房间管理' }, stores: { stage: 'M08-D', title: '门店与房间管理' },
orders: { stage: 'M08-D', title: '订单筛选与运营处置' }, orders: { stage: 'M08-D', title: '订单筛选与运营处置' },
payments: { stage: 'M08-D', title: '支付、退款与分账' },
cleaning: { stage: 'M08-B', title: '保洁任务与结算' } cleaning: { stage: 'M08-B', title: '保洁任务与结算' }
})[activeModule.value]); })[activeModule.value]);
const loading = reactive({ tasks: false, settlements: false, statistics: false, cleaners: false }); const loading = reactive({ tasks: false, settlements: false, statistics: false, cleaners: false });
+59
View File
@@ -14,6 +14,8 @@ import type {
OrderAction, OrderAction,
OrderHistoryItem, OrderHistoryItem,
OrderStatus, OrderStatus,
PaymentAuthorizationStatus,
ProfitSharingSnapshot,
PayoutStateFilter, PayoutStateFilter,
StaffRole, StaffRole,
SettlementStatus, SettlementStatus,
@@ -170,6 +172,63 @@ export function changeManagedOrderRoom(
}); });
} }
export function getProfitSharingSnapshot(session: ApiSession, storeId?: string) {
const query = storeId ? `?${new URLSearchParams({ storeId })}` : '';
return request<ProfitSharingSnapshot>(session, `/pay/profit-shares${query}`);
}
export function saveCollectionAccount(session: ApiSession, input: {
storeId: string | null; merchantId: string; credentialRef: string;
authorizationStatus: PaymentAuthorizationStatus; profitSharingEnabled: boolean; enabled: boolean;
}) {
return request<{ collectionAccountId: string }>(session, '/pay/collection-account', {
method: 'PUT', body: JSON.stringify(input)
});
}
export function saveProfitShareReceiver(session: ApiSession, input: {
collectionAccountId: string; receiverType: 'MERCHANT_ID' | 'PERSONAL_OPENID';
receiverAccount: string; receiverCredentialRef: string; relationType: string; name: string;
authorizationStatus: PaymentAuthorizationStatus; enabled: boolean;
}) {
return request<{ receiverId: string }>(session, '/pay/profit-share-receiver', {
method: 'PUT', body: JSON.stringify(input)
});
}
export function saveProfitSharePolicy(session: ApiSession, input: {
collectionAccountId: string; storeId: string | null; receiverId: string;
percentageBps: number; enabled: boolean;
}) {
return request<{ policyId: string }>(session, '/pay/profit-share-policy', {
method: 'PUT', body: JSON.stringify(input)
});
}
export function executeProfitSharing(
session: ApiSession, input: { paymentId: string; clientRequestId: string; mode: 'API' | 'MOCK' }
) {
return request<{ shares: unknown[]; idempotent: boolean }>(session, '/pay/profit-shares', {
method: 'POST', body: JSON.stringify(input)
});
}
export function createWechatRefund(session: ApiSession, input: {
paymentId: string; amountCents: number; reason: string; clientRequestId: string;
}) {
return request<{ refundId: string; refundNo: string; status: string; refundableCents: number }>(
session, '/pay/refund', { method: 'POST', body: JSON.stringify(input) }
);
}
export function requestWechatReconciliation(session: ApiSession, input: {
storeId: string; billDate: string; billType: 'ALL' | 'SUCCESS' | 'REFUND';
}) {
return request<{ id: string; status: string; downloadUrl: string; idempotent: boolean }>(
session, '/pay/reconciliation', { method: 'POST', body: JSON.stringify(input) }
);
}
export function getBusinessStatistics( export function getBusinessStatistics(
session: ApiSession, session: ApiSession,
input: { storeId: string; from: string; to: string } input: { storeId: string; from: string; to: string }
+155
View File
@@ -0,0 +1,155 @@
<template>
<section class="payments-page">
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
<el-alert v-if="lastResult" :title="lastResult" type="success" show-icon closable @close="lastResult = ''" />
<section class="payment-metrics">
<span><small>收款账户</small><strong>{{ snapshot.accounts.length }}</strong></span>
<span><small>已授权账户</small><strong>{{ authorizedAccounts }}</strong></span>
<span><small>分账接收方</small><strong>{{ snapshot.receivers.length }}</strong></span>
<span><small>近百笔分账金额</small><strong>{{ money(sharedAmountCents) }}</strong></span>
</section>
<section class="panel">
<header class="panel-toolbar payment-toolbar">
<div><p class="section-kicker">M08-D · 资金运营</p><h3>支付退款与分账</h3></div>
<div class="toolbar-actions">
<el-select v-model="storeId" class="filter-select" filterable placeholder="全部账户范围" @change="loadSnapshot">
<el-option label="全部账户范围" value="" />
<el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" />
</el-select>
<el-button :icon="RefreshCw" :loading="loading" @click="loadSnapshot">刷新</el-button>
</div>
</header>
<el-tabs v-model="activeTab" class="payment-tabs">
<el-tab-pane label="分账记录" name="shares">
<el-table v-loading="loading" :data="snapshot.shares" class="data-table" row-key="id">
<el-table-column label="分账单" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ row.shareNo }}</strong><span>支付 #{{ row.paymentId }} · 订单 #{{ row.orderId }}</span></div></template></el-table-column>
<el-table-column label="接收方" min-width="160"><template #default="{ row }"><div class="stack"><strong>{{ row.receiverMasked }}</strong><span>{{ row.receiverType }} · {{ percent(row.percentageBps) }}</span></div></template></el-table-column>
<el-table-column label="金额" width="130"><template #default="{ row }"><strong>{{ money(row.amountCents) }}</strong></template></el-table-column>
<el-table-column label="状态" width="130"><template #default="{ row }"><el-tag :type="shareStatusType(row.status)">{{ row.status }}</el-tag></template></el-table-column>
<el-table-column label="门店 / 时间" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ storeName(row.storeId) }}</strong><span>{{ formatDate(row.createdAt) }}</span><span v-if="row.failureCode">{{ row.failureCode }}</span></div></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="收款账户" name="accounts">
<div class="tab-action-row"><span>凭据只保存环境引用页面不读取或回显私钥</span><el-button type="primary" :icon="Plus" @click="openAccountForm">配置账户</el-button></div>
<el-table :data="snapshot.accounts" class="data-table" row-key="id">
<el-table-column prop="id" label="账户 ID" width="100" />
<el-table-column label="范围" min-width="150"><template #default="{ row }">{{ row.storeId ? storeName(row.storeId) : '租户默认' }}</template></el-table-column>
<el-table-column prop="merchantId" label="商户号" min-width="160" />
<el-table-column label="授权" width="130"><template #default="{ row }"><el-tag :type="authorizationType(row.authorizationStatus)">{{ authorizationLabel(row.authorizationStatus) }}</el-tag></template></el-table-column>
<el-table-column label="分账 / 启用" min-width="150"><template #default="{ row }"><div class="profile-badges"><el-tag :type="truthy(row.profitSharingEnabled) ? 'success' : 'info'" size="small">{{ truthy(row.profitSharingEnabled) ? '分账开启' : '分账关闭' }}</el-tag><el-tag :type="truthy(row.enabled) ? 'success' : 'info'" size="small">{{ truthy(row.enabled) ? '已启用' : '已停用' }}</el-tag></div></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="接收方与策略" name="policies">
<div class="tab-action-row"><span>分账比例由服务端校验总和不得超过 100%</span><div class="row-actions"><el-button :icon="Plus" @click="openReceiverForm">新增接收方</el-button><el-button type="primary" :icon="Plus" @click="openPolicyForm">新增策略</el-button></div></div>
<el-table :data="snapshot.receivers" class="data-table" row-key="id">
<el-table-column prop="name" label="接收方" min-width="150" />
<el-table-column prop="receiverMasked" label="脱敏标识" min-width="170" />
<el-table-column prop="receiverType" label="类型" min-width="150" />
<el-table-column prop="relationType" label="关系" min-width="120" />
<el-table-column label="授权" width="120"><template #default="{ row }"><el-tag :type="authorizationType(row.authorizationStatus)">{{ authorizationLabel(row.authorizationStatus) }}</el-tag></template></el-table-column>
</el-table>
<el-table :data="snapshot.policies" class="data-table policy-table" row-key="id">
<el-table-column prop="id" label="策略 ID" width="100" />
<el-table-column label="账户" min-width="120"><template #default="{ row }">#{{ row.collectionAccountId }}</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 label="接收方" min-width="160"><template #default="{ row }">{{ receiverName(row.receiverId) }}</template></el-table-column>
<el-table-column label="比例" width="120"><template #default="{ row }"><strong>{{ percent(row.percentageBps) }}</strong></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="资金工具" name="tools">
<div class="payment-tools-grid">
<article><header><RotateCcw :size="20" /><div><strong>微信退款</strong><span>金额由操作人明确填写服务端校验可退余额与幂等键</span></div></header><el-button type="primary" plain @click="toolDialog = 'refund'">发起退款</el-button></article>
<article><header><ReceiptText :size="20" /><div><strong>微信对账单</strong><span>按门店账单日期与类型请求官方账单下载地址</span></div></header><el-button type="primary" plain @click="toolDialog = 'reconciliation'">请求对账</el-button></article>
<article><header><Split :size="20" /><div><strong>执行分账</strong><span>生产 API 模式需账户与接收方均授权Mock 仅非生产可用</span></div></header><el-button type="primary" plain @click="toolDialog = 'share'">执行分账</el-button></article>
</div>
</el-tab-pane>
</el-tabs>
</section>
<el-dialog v-model="accountDialog" title="配置收款账户" width="min(620px, 94vw)">
<el-form label-position="top" class="asset-form"><el-form-item label="门店范围"><el-select v-model="accountForm.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-input v-model="accountForm.merchantId" /></el-form-item><el-form-item label="凭据环境引用" class="form-span-2"><el-input v-model="accountForm.credentialRef" placeholder="env:WECHAT_PAY_STORE_001" /></el-form-item><el-form-item label="授权状态"><el-select v-model="accountForm.authorizationStatus"><el-option v-for="item in authorizationOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="开关"><div class="row-actions"><el-switch v-model="accountForm.profitSharingEnabled" active-text="分账" /><el-switch v-model="accountForm.enabled" active-text="启用" /></div></el-form-item></el-form>
<template #footer><el-button @click="accountDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveAccount">保存账户</el-button></template>
</el-dialog>
<el-dialog v-model="receiverDialog" title="新增或更新分账接收方" width="min(620px, 94vw)">
<el-form label-position="top" class="asset-form"><el-form-item label="收款账户"><el-select v-model="receiverForm.collectionAccountId"><el-option v-for="item in snapshot.accounts" :key="item.id" :label="`#${item.id} ${item.merchantId}`" :value="item.id" /></el-select></el-form-item><el-form-item label="接收方类型"><el-select v-model="receiverForm.receiverType"><el-option label="商户号" value="MERCHANT_ID" /><el-option label="个人 OpenID" value="PERSONAL_OPENID" /></el-select></el-form-item><el-form-item label="接收方账户"><el-input v-model="receiverForm.receiverAccount" type="password" show-password /></el-form-item><el-form-item label="接收方凭据引用"><el-input v-model="receiverForm.receiverCredentialRef" /></el-form-item><el-form-item label="名称"><el-input v-model="receiverForm.name" /></el-form-item><el-form-item label="关系类型"><el-input v-model="receiverForm.relationType" placeholder="SERVICE_PROVIDER" /></el-form-item><el-form-item label="授权状态"><el-select v-model="receiverForm.authorizationStatus"><el-option v-for="item in authorizationOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="启用"><el-switch v-model="receiverForm.enabled" /></el-form-item></el-form>
<template #footer><el-button @click="receiverDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveReceiver">保存接收方</el-button></template>
</el-dialog>
<el-dialog v-model="policyDialog" title="新增或更新分账策略" width="min(560px, 94vw)">
<el-form label-position="top"><el-form-item label="收款账户"><el-select v-model="policyForm.collectionAccountId"><el-option v-for="item in snapshot.accounts" :key="item.id" :label="`#${item.id} ${item.merchantId}`" :value="item.id" /></el-select></el-form-item><el-form-item label="门店范围"><el-select v-model="policyForm.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="policyForm.receiverId"><el-option v-for="item in snapshot.receivers" :key="item.id" :label="`${item.name} · ${item.receiverMasked}`" :value="item.id" /></el-select></el-form-item><el-form-item label="分账比例(%"><el-input-number v-model="policyForm.percentage" :min="0.01" :max="100" :precision="2" /></el-form-item><el-form-item label="启用"><el-switch v-model="policyForm.enabled" /></el-form-item></el-form>
<template #footer><el-button @click="policyDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="savePolicy">保存策略</el-button></template>
</el-dialog>
<el-dialog :model-value="toolDialog !== ''" :title="toolTitle" width="min(560px, 94vw)" @close="toolDialog = ''">
<el-form v-if="toolDialog === 'refund'" label-position="top"><el-form-item label="支付 ID"><el-input v-model="refundForm.paymentId" /></el-form-item><el-form-item label="退款金额(元)"><el-input-number v-model="refundForm.amountYuan" :min="0.01" :precision="2" /></el-form-item><el-form-item label="退款原因"><el-input v-model="refundForm.reason" type="textarea" /></el-form-item></el-form>
<el-form v-else-if="toolDialog === 'reconciliation'" label-position="top"><el-form-item label="门店"><el-select v-model="reconciliationForm.storeId"><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-date-picker v-model="reconciliationForm.billDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="账单类型"><el-radio-group v-model="reconciliationForm.billType"><el-radio value="ALL">全部</el-radio><el-radio value="SUCCESS">支付</el-radio><el-radio value="REFUND">退款</el-radio></el-radio-group></el-form-item></el-form>
<el-form v-else label-position="top"><el-form-item label="支付 ID"><el-input v-model="shareForm.paymentId" /></el-form-item><el-form-item label="执行模式"><el-radio-group v-model="shareForm.mode"><el-radio value="API">生产 API</el-radio><el-radio value="MOCK">非生产 Mock</el-radio></el-radio-group></el-form-item></el-form>
<template #footer><el-button @click="toolDialog = ''">取消</el-button><el-button type="primary" :loading="saving" @click="executeTool">确认执行</el-button></template>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { Plus, ReceiptText, RefreshCw, RotateCcw, Split } from '@lucide/vue';
import {
ApiError, createWechatRefund, executeProfitSharing, getProfitSharingSnapshot,
listManagedStores, requestWechatReconciliation, saveCollectionAccount,
saveProfitSharePolicy, saveProfitShareReceiver, type ApiSession
} from '../api';
import { money } from '../format';
import type { ManagedStore, PaymentAuthorizationStatus, ProfitSharingSnapshot } from '../types';
const props = defineProps<{ session: ApiSession }>();
const stores = ref<ManagedStore[]>([]);
const snapshot = reactive<ProfitSharingSnapshot>({ accounts: [], receivers: [], policies: [], shares: [] });
const storeId = ref('');
const activeTab = ref('shares');
const loading = ref(false);
const saving = ref(false);
const lastError = ref('');
const lastResult = ref('');
const accountDialog = ref(false);
const receiverDialog = ref(false);
const policyDialog = ref(false);
const toolDialog = ref<'' | 'refund' | 'reconciliation' | 'share'>('');
const accountForm = reactive({ storeId: '', merchantId: '', credentialRef: '', authorizationStatus: 'UNAUTHORIZED' as PaymentAuthorizationStatus, profitSharingEnabled: false, enabled: true });
const receiverForm = reactive({ collectionAccountId: '', receiverType: 'MERCHANT_ID' as 'MERCHANT_ID' | 'PERSONAL_OPENID', receiverAccount: '', receiverCredentialRef: '', relationType: 'SERVICE_PROVIDER', name: '', authorizationStatus: 'UNAUTHORIZED' as PaymentAuthorizationStatus, enabled: true });
const policyForm = reactive({ collectionAccountId: '', storeId: '', receiverId: '', percentage: 1, enabled: true });
const refundForm = reactive({ paymentId: '', amountYuan: 0, reason: '' });
const reconciliationForm = reactive({ storeId: '', billDate: '', billType: 'ALL' as 'ALL' | 'SUCCESS' | 'REFUND' });
const shareForm = reactive({ paymentId: '', mode: 'API' as 'API' | 'MOCK' });
const authorizationOptions: Array<{ value: PaymentAuthorizationStatus; label: string }> = [{ value: 'UNAUTHORIZED', label: '未授权' }, { value: 'PENDING', label: '授权中' }, { value: 'AUTHORIZED', label: '已授权' }, { value: 'REVOKED', label: '已撤销' }];
const authorizedAccounts = computed(() => snapshot.accounts.filter((item) => item.authorizationStatus === 'AUTHORIZED').length);
const sharedAmountCents = computed(() => snapshot.shares.reduce((sum, item) => sum + Number(item.amountCents || 0), 0));
const toolTitle = computed(() => toolDialog.value === 'refund' ? '发起微信退款' : toolDialog.value === 'reconciliation' ? '请求微信对账单' : '执行微信分账');
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)); await loadSnapshot(); } catch { /* displayed */ } }
async function loadSnapshot() { if (!props.session.token) return; loading.value = true; try { Object.assign(snapshot, await capture(() => getProfitSharingSnapshot(props.session, storeId.value || undefined))); } catch { /* displayed */ } finally { loading.value = false; } }
function openAccountForm() { Object.assign(accountForm, { storeId: storeId.value, merchantId: '', credentialRef: '', authorizationStatus: 'UNAUTHORIZED', profitSharingEnabled: false, enabled: true }); accountDialog.value = true; }
function openReceiverForm() { Object.assign(receiverForm, { collectionAccountId: snapshot.accounts[0]?.id || '', receiverType: 'MERCHANT_ID', receiverAccount: '', receiverCredentialRef: '', relationType: 'SERVICE_PROVIDER', name: '', authorizationStatus: 'UNAUTHORIZED', enabled: true }); receiverDialog.value = true; }
function openPolicyForm() { Object.assign(policyForm, { collectionAccountId: snapshot.accounts[0]?.id || '', storeId: storeId.value, receiverId: snapshot.receivers[0]?.id || '', percentage: 1, enabled: true }); policyDialog.value = true; }
async function saveAccount() { if (!accountForm.merchantId || !accountForm.credentialRef) return ElMessage.warning('请填写商户号和凭据引用'); saving.value = true; try { await capture(() => saveCollectionAccount(props.session, { ...accountForm, storeId: accountForm.storeId || null })); accountDialog.value = false; ElMessage.success('收款账户已保存'); await loadSnapshot(); } catch { /* displayed */ } finally { saving.value = false; } }
async function saveReceiver() { if (!receiverForm.collectionAccountId || !receiverForm.receiverAccount || !receiverForm.receiverCredentialRef || !receiverForm.name) return ElMessage.warning('请完整填写接收方资料'); saving.value = true; try { await capture(() => saveProfitShareReceiver(props.session, receiverForm)); receiverDialog.value = false; receiverForm.receiverAccount = ''; ElMessage.success('分账接收方已保存'); await loadSnapshot(); } catch { /* displayed */ } finally { saving.value = false; } }
async function savePolicy() { if (!policyForm.collectionAccountId || !policyForm.receiverId) return ElMessage.warning('请选择账户和接收方'); saving.value = true; try { await capture(() => saveProfitSharePolicy(props.session, { collectionAccountId: policyForm.collectionAccountId, storeId: policyForm.storeId || null, receiverId: policyForm.receiverId, percentageBps: Math.round(policyForm.percentage * 100), enabled: policyForm.enabled })); policyDialog.value = false; ElMessage.success('分账策略已保存'); await loadSnapshot(); } catch { /* displayed */ } finally { saving.value = false; } }
async function executeTool() { saving.value = true; lastResult.value = ''; try { if (toolDialog.value === 'refund') { if (!refundForm.paymentId || !refundForm.reason || refundForm.amountYuan <= 0) return ElMessage.warning('请完整填写退款信息'); const result = await capture(() => createWechatRefund(props.session, { paymentId: refundForm.paymentId, amountCents: Math.round(refundForm.amountYuan * 100), reason: refundForm.reason, clientRequestId: requestId('refund') })); lastResult.value = `退款 ${result.refundNo} 已提交,状态 ${result.status},剩余可退 ${money(result.refundableCents)}`; } else if (toolDialog.value === 'reconciliation') { if (!reconciliationForm.storeId || !reconciliationForm.billDate) return ElMessage.warning('请选择门店和账单日期'); const result = await capture(() => requestWechatReconciliation(props.session, reconciliationForm)); lastResult.value = `对账请求 #${result.id} 状态 ${result.status}${result.downloadUrl ? ',下载地址已由服务端返回' : ''}`; } else { if (!shareForm.paymentId) return ElMessage.warning('请填写支付 ID'); const result = await capture(() => executeProfitSharing(props.session, { paymentId: shareForm.paymentId, clientRequestId: requestId('share'), mode: shareForm.mode })); lastResult.value = `分账已提交,共 ${result.shares.length}${result.idempotent ? '(幂等复用)' : ''}`; await loadSnapshot(); } toolDialog.value = ''; } catch { /* displayed */ } finally { saving.value = false; } }
function requestId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`; }
function storeName(id: string) { return stores.value.find((item) => item.id === String(id))?.name || `门店 #${id}`; }
function receiverName(id: string) { const item = snapshot.receivers.find((receiver) => receiver.id === String(id)); return item ? `${item.name} · ${item.receiverMasked}` : `接收方 #${id}`; }
function authorizationLabel(value: PaymentAuthorizationStatus) { return authorizationOptions.find((item) => item.value === value)?.label || value; }
function authorizationType(value: PaymentAuthorizationStatus) { return value === 'AUTHORIZED' ? 'success' : value === 'PENDING' ? 'warning' : 'info'; }
function shareStatusType(value: string) { return value === 'SUCCEEDED' ? 'success' : value === 'FAILED' ? 'danger' : 'warning'; }
function truthy(value: boolean | number) { return value === true || Number(value) === 1; }
function percent(bps: number) { return `${(Number(bps || 0) / 100).toFixed(2)}%`; }
function formatDate(value: string) { 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); }
watch(() => props.session.token, (token) => { if (token) void loadWorkspace(); }, { immediate: true });
</script>
+106
View File
@@ -1396,6 +1396,97 @@ textarea {
white-space: nowrap; white-space: nowrap;
} }
.payments-page {
display: grid;
gap: 14px;
}
.payment-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.payment-metrics span {
display: grid;
gap: 6px;
padding: 14px;
background: #fff;
border: 1px solid #d8e0ea;
border-radius: 8px;
}
.payment-metrics small {
color: #69788c;
}
.payment-metrics strong {
color: #172033;
font-size: 22px;
}
.payment-toolbar h3 {
margin: 0;
}
.payment-tabs {
padding: 0 14px 14px;
}
.tab-action-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 54px;
color: #69788c;
font-size: 13px;
}
.policy-table {
margin-top: 16px;
border-top: 1px solid #e6ebf2;
}
.payment-tools-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
padding: 8px 0;
}
.payment-tools-grid article {
display: grid;
gap: 18px;
align-content: space-between;
min-height: 180px;
padding: 16px;
border: 1px solid #dfe6ee;
border-radius: 9px;
background: #f8fafc;
}
.payment-tools-grid header {
display: flex;
gap: 10px;
color: #2878ff;
}
.payment-tools-grid header div {
display: grid;
gap: 6px;
}
.payment-tools-grid header strong {
color: #172033;
}
.payment-tools-grid header span {
color: #69788c;
font-size: 12px;
line-height: 1.6;
}
.el-button { .el-button {
border-radius: 8px; border-radius: 8px;
} }
@@ -1499,6 +1590,11 @@ textarea {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.payment-metrics,
.payment-tools-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.trend-row { .trend-row {
grid-template-columns: 92px minmax(80px, 1fr); grid-template-columns: 92px minmax(80px, 1fr);
} }
@@ -1603,6 +1699,16 @@ textarea {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.payment-metrics,
.payment-tools-grid {
grid-template-columns: 1fr;
}
.tab-action-row {
align-items: stretch;
flex-direction: column;
}
.toolbar-actions { .toolbar-actions {
justify-content: flex-end; justify-content: flex-end;
} }
+55
View File
@@ -151,6 +151,61 @@ export interface OrderHistoryItem {
createdAt: string; createdAt: string;
} }
export type PaymentAuthorizationStatus = 'UNAUTHORIZED' | 'PENDING' | 'AUTHORIZED' | 'REVOKED';
export interface CollectionAccount {
id: string;
platformAppId: string;
storeId: string | null;
provider: string;
merchantId: string;
authorizationStatus: PaymentAuthorizationStatus;
profitSharingEnabled: boolean | number;
enabled: boolean | number;
}
export interface ProfitShareReceiver {
id: string;
collectionAccountId: string;
receiverType: 'MERCHANT_ID' | 'PERSONAL_OPENID';
receiverMasked: string;
relationType: string;
name: string;
authorizationStatus: PaymentAuthorizationStatus;
enabled: boolean | number;
}
export interface ProfitSharePolicy {
id: string;
collectionAccountId: string;
storeId: string | null;
receiverId: string;
percentageBps: number;
enabled: boolean | number;
}
export interface ProfitShareRecord {
id: string;
paymentId: string;
orderId: string;
storeId: string;
shareNo: string;
receiverType: string;
receiverMasked: string;
percentageBps: number;
amountCents: number;
status: string;
failureCode: string;
createdAt: string;
}
export interface ProfitSharingSnapshot {
accounts: CollectionAccount[];
receivers: ProfitShareReceiver[];
policies: ProfitSharePolicy[];
shares: ProfitShareRecord[];
}
export interface BusinessStatistics { export interface BusinessStatistics {
storeId: string; storeId: string;
from: string; from: string;
+22
View File
@@ -12,9 +12,11 @@ for (const pattern of [
"activeModule = 'cleaning'", "activeModule = 'cleaning'",
"activeModule = 'stores'", "activeModule = 'stores'",
"activeModule = 'orders'", "activeModule = 'orders'",
"activeModule = 'payments'",
'平台运营总览', '平台运营总览',
'StoresRoomsPanel', 'StoresRoomsPanel',
'OrdersPanel', 'OrdersPanel',
'PaymentsPanel',
'运营总览', '运营总览',
'savedToken', 'savedToken',
'loadCleaningWorkspace' 'loadCleaningWorkspace'
@@ -100,6 +102,24 @@ for (const pattern of [
assert.match(orders, new RegExp(pattern)); assert.match(orders, new RegExp(pattern));
} }
const payments = read('admin/src/components/PaymentsPanel.vue');
for (const pattern of [
'支付、退款与分账',
'getProfitSharingSnapshot',
'saveCollectionAccount',
'saveProfitShareReceiver',
'saveProfitSharePolicy',
'createWechatRefund',
'requestWechatReconciliation',
'executeProfitSharing',
'凭据只保存环境引用',
'clientRequestId',
'生产 API',
'非生产 Mock'
]) {
assert.match(payments, new RegExp(pattern));
}
const routes = read('backend/src/routes/business-statistics.ts'); const routes = read('backend/src/routes/business-statistics.ts');
assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/); assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/);
@@ -129,6 +149,8 @@ for (const pattern of [
'.hours-editor', '.hours-editor',
'.order-page-summary', '.order-page-summary',
'.history-card', '.history-card',
'.payment-metrics',
'.payment-tools-grid',
'@media (max-width: 980px)', '@media (max-width: 980px)',
'@media (max-width: 560px)' '@media (max-width: 560px)'
]) { ]) {