feat(M08-D): 补支付退款与分账运营
This commit is contained in:
+15
-3
@@ -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 });
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)'
|
||||||
]) {
|
]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user