feat(distribution): 分销站点消息中心与钱包提现多租户独立分支隔离

- 新增 000070 数据库迁移:支持 chat_conversations、wallet_accounts、wallet_ledger、withdrawal_requests 按 distribution_site_id 隔离
- 消息中心:会话列表、未读数统计、站点专属客服及订单群聊与主站实现双向完全隔离
- 资金账户与提现:独立按 (user_id, distribution_site_id) 建账,分销订单自动结算至对应分站钱包,提现与流水相互独立
- 前台界面:Axios 自动透传 X-Distribution-Site-Code,钱包、提现与消息页面呈现分站专属标牌与独立说明
This commit is contained in:
yml2213
2026-09-07 18:11:27 +08:00
parent 72b805c499
commit e024319860
35 changed files with 1109 additions and 298 deletions
+35 -6
View File
@@ -40,6 +40,7 @@ export interface ChatConversation {
last_sender_role?: string
unread_count: number
needs_reply: boolean
distribution_site_id?: number | null
created_at: string
updated_at: string
}
@@ -92,17 +93,34 @@ export interface ChatMessage {
created_at: string
}
export async function fetchChats(page = 1, pageSize = 20) {
export async function fetchChats(page = 1, pageSize = 20, distributionSiteCode?: string) {
const siteCode =
distributionSiteCode ||
(typeof window !== 'undefined' ? localStorage.getItem('hfb_site_code') : '') ||
undefined
const params: Record<string, unknown> = { page, page_size: pageSize }
if (siteCode) {
params.distribution_site_code = siteCode
}
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatConversation>>>('/chats', {
params: { page, page_size: pageSize },
params,
})
return data.data
}
export async function fetchUnreadChatCount() {
export async function fetchUnreadChatCount(distributionSiteCode?: string) {
const siteCode =
distributionSiteCode ||
(typeof window !== 'undefined' ? localStorage.getItem('hfb_site_code') : '') ||
undefined
const params: Record<string, unknown> = {}
if (siteCode) {
params.distribution_site_code = siteCode
}
const { data } = await apiClient.get<ApiResponse<{ unread_count: number }>>(
'/chats/unread-count',
{
params,
silent: true,
}
)
@@ -119,10 +137,21 @@ export async function fetchOrderChat(orderId: number) {
return data.data
}
export async function ensureSupportChat(scene: SupportScene | string = 'general') {
const { data } = await apiClient.post<ApiResponse<ChatConversation>>('/chats/support', {
export async function ensureSupportChat(
scene: SupportScene | string = 'general',
distributionSiteCode?: string
) {
const siteCode =
distributionSiteCode ||
(typeof window !== 'undefined' ? localStorage.getItem('hfb_site_code') : '') ||
undefined
const payload: { scene: string; distribution_site_code?: string } = {
scene: scene || 'general',
})
}
if (siteCode) {
payload.distribution_site_code = siteCode
}
const { data } = await apiClient.post<ApiResponse<ChatConversation>>('/chats/support', payload)
return data.data
}
@@ -18,9 +18,11 @@ import {
useNotificationUnreadCount,
} from '@/features/auth/composables/useNotificationUnreadCount'
import { formatDateMinute, formatDateTime } from '@/shared/utils/time'
import { useDistributionSiteStore } from '@/stores/distributionSite'
const router = useRouter()
const route = useRoute()
const distributionStore = useDistributionSiteStore()
const currentUserId = Number(localStorage.getItem('user_id') || 0)
// ── 聊天状态 ──
@@ -65,7 +67,11 @@ async function loadChats(isRefresh = false, showLoading = true) {
if (loading.value) return
if (showLoading) loading.value = true
try {
const res = await fetchChats(page.value, pageSize)
const res = await fetchChats(
page.value,
pageSize,
distributionStore.isDistribution ? distributionStore.siteCode : undefined
)
conversations.value = isRefresh ? res.items : [...conversations.value, ...res.items]
total.value = res.total
if (conversations.value.length < res.total && res.items.length > 0) {
@@ -180,13 +186,30 @@ function handleTabChange(tab: string) {
<section class="page messages-page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Messages</p>
<h1>消息</h1>
<p class="eyebrow">
Messages
<el-tag
v-if="distributionStore.isDistribution"
type="warning"
effect="light"
size="small"
class="distribution-site-tag"
>
{{ distributionStore.siteName }}专属分站
</el-tag>
</p>
<h1>
{{
distributionStore.isDistribution ? `${distributionStore.siteName} · 消息中心` : '消息'
}}
</h1>
<p>
{{
combinedUnread > 0
? `${combinedUnread} 条未读`
: '查看订单群聊、平台客服消息和系统通知。'
distributionStore.isDistribution
? `当前查看的是【${distributionStore.siteName}】分站专属客服会话与订单群聊。`
: combinedUnread > 0
? `${combinedUnread} 条未读`
: '查看订单群聊、平台客服消息和系统通知。'
}}
</p>
</div>
@@ -352,6 +375,12 @@ function handleTabChange(tab: string) {
align-items: flex-end;
}
.distribution-site-tag {
margin-left: 8px;
vertical-align: middle;
font-weight: 500;
}
.header-actions {
display: flex;
gap: 10px;
@@ -5,8 +5,10 @@ import { showToast } from 'vant'
import MobileBottomNav from '@/components/MobileBottomNav.vue'
import { fetchChats, type ChatConversation } from '@/features/chats/api/chats'
import { formatDateMinute } from '@/shared/utils/time'
import { useDistributionSiteStore } from '@/stores/distributionSite'
const router = useRouter()
const distributionStore = useDistributionSiteStore()
const loading = ref(false)
const refreshing = ref(false)
const finished = ref(false)
@@ -26,7 +28,11 @@ async function loadChats(isRefresh = false) {
}
loading.value = true
try {
const res = await fetchChats(page.value, pageSize)
const res = await fetchChats(
page.value,
pageSize,
distributionStore.isDistribution ? distributionStore.siteCode : undefined
)
conversations.value = isRefresh ? res.items : [...conversations.value, ...res.items]
total.value = res.total
if (conversations.value.length >= res.total || res.items.length === 0) {
@@ -85,7 +91,9 @@ const unreadTotal = computed(() =>
<button class="back-btn" type="button" @click="router.back()">
<van-icon name="arrow-left" :size="20" />
</button>
<h1>消息</h1>
<h1>
{{ distributionStore.isDistribution ? `${distributionStore.siteName} · 消息` : '消息' }}
</h1>
<span class="header-count">{{ unreadTotal > 0 ? `${unreadTotal} 未读` : '' }}</span>
</header>
+22 -4
View File
@@ -8,6 +8,7 @@ export interface WalletAccount {
available_balance_cent: number
frozen_balance_cent: number
status: WalletStatus
distribution_site_id?: number
}
export interface WalletLedger {
@@ -22,19 +23,36 @@ export interface WalletLedger {
biz_type: string
biz_no: string
remark: string
distribution_site_id?: number
created_at: string
}
export async function fetchWalletBalance() {
const { data } = await apiClient.get<ApiResponse<WalletAccount>>('/wallet/balance')
export async function fetchWalletBalance(distributionSiteCode?: string) {
const siteCode =
distributionSiteCode ||
(typeof window !== 'undefined' ? localStorage.getItem('hfb_site_code') : '') ||
undefined
const params: Record<string, unknown> = {}
if (siteCode) {
params.distribution_site_code = siteCode
}
const { data } = await apiClient.get<ApiResponse<WalletAccount>>('/wallet/balance', { params })
return data.data
}
export async function fetchWalletLedger(page = 1, pageSize = 20) {
export async function fetchWalletLedger(page = 1, pageSize = 20, distributionSiteCode?: string) {
const siteCode =
distributionSiteCode ||
(typeof window !== 'undefined' ? localStorage.getItem('hfb_site_code') : '') ||
undefined
const params: Record<string, unknown> = { page, page_size: pageSize }
if (siteCode) {
params.distribution_site_code = siteCode
}
const { data } = await apiClient.get<ApiResponse<PaginatedResult<WalletLedger>>>(
'/wallet/ledger',
{
params: { page, page_size: pageSize },
params,
}
)
return data.data
+26 -4
View File
@@ -55,6 +55,7 @@ export interface WithdrawalRequest {
bank_name: string
status: WithdrawalStatus
review_remark: string
distribution_site_id?: number
created_at: string
updated_at: string
reviewed_at: string | null
@@ -65,6 +66,7 @@ export interface WithdrawalRequest {
export interface CreateWithdrawalRequest {
payment_account_id: number
amount_cent: number
distribution_site_code?: string
}
// ========== 收款账号管理 API ==========
@@ -110,16 +112,36 @@ export async function setDefaultPaymentAccount(id: number) {
// ========== 提现申请 API ==========
export async function createWithdrawal(req: CreateWithdrawalRequest) {
const { data } = await apiClient.post<ApiResponse<WithdrawalRequest>>('/withdrawals', req)
export async function createWithdrawal(
req: CreateWithdrawalRequest,
distributionSiteCode?: string
) {
const siteCode =
distributionSiteCode ||
req.distribution_site_code ||
(typeof window !== 'undefined' ? localStorage.getItem('hfb_site_code') : '') ||
undefined
const payload: CreateWithdrawalRequest = { ...req }
if (siteCode) {
payload.distribution_site_code = siteCode
}
const { data } = await apiClient.post<ApiResponse<WithdrawalRequest>>('/withdrawals', payload)
return data.data
}
export async function fetchWithdrawals(page = 1, pageSize = 20) {
export async function fetchWithdrawals(page = 1, pageSize = 20, distributionSiteCode?: string) {
const siteCode =
distributionSiteCode ||
(typeof window !== 'undefined' ? localStorage.getItem('hfb_site_code') : '') ||
undefined
const params: Record<string, unknown> = { page, page_size: pageSize }
if (siteCode) {
params.distribution_site_code = siteCode
}
const { data } = await apiClient.get<ApiResponse<PaginatedResult<WithdrawalRequest>>>(
'/withdrawals',
{
params: { page, page_size: pageSize },
params,
}
)
return data.data
@@ -52,6 +52,19 @@
box-shadow: 0 14px 28px rgba(255, 106, 0, 0.22);
}
.site-account-tip {
display: inline-block;
align-self: start;
width: fit-content;
padding: 2px 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.25);
color: #fff;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.5px;
}
.balance-panel span,
.balance-panel small {
color: rgba(255, 255, 255, 0.72);
@@ -23,8 +23,10 @@ import { formatCent, formatMoney, yuanToCent } from '@/shared/utils/money'
import { formatDateMinute } from '@/shared/utils/time'
import alipayReceiveQrExample from '@/assets/alipay-receive-qr-example.png'
import wechatReceiveQrExample from '@/assets/wechat-receive-qr-example.png'
import { useDistributionSiteStore } from '@/stores/distributionSite'
const router = useRouter()
const distributionStore = useDistributionSiteStore()
const loading = ref(false)
const submitting = ref(false)
@@ -104,10 +106,11 @@ onMounted(loadData)
async function loadData() {
loading.value = true
try {
const siteCode = distributionStore.isDistribution ? distributionStore.siteCode : undefined
const [balanceData, accountsData, withdrawalsData] = await Promise.all([
fetchWalletBalance(),
fetchWalletBalance(siteCode),
fetchPaymentAccounts(1, 100),
fetchWithdrawals(1, 20),
fetchWithdrawals(1, 20, siteCode),
])
account.value = balanceData
paymentAccounts.value = accountsData.items
@@ -142,10 +145,13 @@ async function handleSubmit() {
submitting.value = true
try {
await createWithdrawal({
payment_account_id: withdrawForm.payment_account_id,
amount_cent: amountCent.value,
})
await createWithdrawal(
{
payment_account_id: withdrawForm.payment_account_id,
amount_cent: amountCent.value,
},
distributionStore.isDistribution ? distributionStore.siteCode : undefined
)
showToast({ message: '提现申请已提交', icon: 'passed' })
withdrawForm.amount = ''
await loadData()
@@ -386,13 +392,18 @@ function statusType(status: string): 'primary' | 'success' | 'danger' | 'warning
<button class="back-btn" type="button" @click="router.back()">
<van-icon name="arrow-left" :size="20" />
</button>
<h1>提现</h1>
<h1>
{{ distributionStore.isDistribution ? `${distributionStore.siteName} · 提现` : '提现' }}
</h1>
<button class="refresh-btn" type="button" @click="loadData">
<van-icon name="replay" :size="18" />
</button>
</header>
<section class="balance-panel">
<div v-if="distributionStore.isDistribution" class="site-account-tip">
{{ distributionStore.siteName }}独立账户
</div>
<span>可用余额</span>
<strong>¥{{ formatCent(availableBalanceCent) }}</strong>
<small>冻结余额 ¥{{ formatCent(account?.frozen_balance_cent) }}</small>
@@ -23,8 +23,10 @@ import {
} from '@/shared/utils/statusLabels'
import { formatDateTime } from '@/shared/utils/time'
import { formatCentWithSymbol } from '@/shared/utils/money'
import { useDistributionSiteStore } from '@/stores/distributionSite'
const router = useRouter()
const distributionStore = useDistributionSiteStore()
const loading = ref(false)
const account = ref<WalletAccount | null>(null)
const ledger = ref<WalletLedger[]>([])
@@ -66,9 +68,10 @@ onMounted(loadWallet)
async function loadWallet() {
loading.value = true
try {
const siteCode = distributionStore.isDistribution ? distributionStore.siteCode : undefined
const [balance, result] = await Promise.all([
fetchWalletBalance(),
fetchWalletLedger(currentPage.value, currentPageSize.value),
fetchWalletBalance(siteCode),
fetchWalletLedger(currentPage.value, currentPageSize.value, siteCode),
])
account.value = balance
ledger.value = result.items
@@ -138,9 +141,32 @@ function amountPrefix(direction: string) {
<section class="page wallet-page" v-loading="loading">
<div class="wallet-hero">
<div class="page-header">
<p class="eyebrow">我的钱包</p>
<h1>资金账户</h1>
<p>查看卖家结算收入、可提现余额和每一笔资金变化。</p>
<p class="eyebrow">
我的钱包
<el-tag
v-if="distributionStore.isDistribution"
type="warning"
effect="light"
size="small"
class="distribution-site-tag"
>
{{ distributionStore.siteName }}专属分站
</el-tag>
</p>
<h1>
{{
distributionStore.isDistribution
? `${distributionStore.siteName} · 资金账户`
: '资金账户'
}}
</h1>
<p>
{{
distributionStore.isDistribution
? `当前查看的是【${distributionStore.siteName}】专属分站钱包与流水明细,资金及流水与主站完全独立。`
: '查看卖家结算收入、可提现余额和每一笔资金变化。'
}}
</p>
</div>
<div class="wallet-hero-action">
<span>当前可用</span>
@@ -258,6 +284,12 @@ function amountPrefix(direction: string) {
max-width: 780px;
}
.distribution-site-tag {
margin-left: 8px;
vertical-align: middle;
font-weight: 500;
}
.wallet-hero-action {
min-width: 220px;
padding: 16px 18px;
@@ -14,8 +14,10 @@ import {
} from '../api/withdrawal'
import { fetchWalletBalance, type WalletAccount } from '../api/wallet'
import { formatMoney, formatMoneyWithSymbol, formatCent, yuanToCent } from '@/shared/utils/money'
import { useDistributionSiteStore } from '@/stores/distributionSite'
const router = useRouter()
const distributionStore = useDistributionSiteStore()
const loading = ref(false)
const submitting = ref(false)
@@ -42,10 +44,11 @@ onMounted(() => {
async function loadData() {
loading.value = true
try {
const siteCode = distributionStore.isDistribution ? distributionStore.siteCode : undefined
const [balanceData, accountsData, withdrawalsData] = await Promise.all([
fetchWalletBalance(),
fetchWalletBalance(siteCode),
fetchPaymentAccounts(1, 100),
fetchWithdrawals(currentPage.value, pageSize.value),
fetchWithdrawals(currentPage.value, pageSize.value, siteCode),
])
account.value = balanceData
paymentAccounts.value = accountsData.items
@@ -95,10 +98,13 @@ async function handleSubmit() {
)
submitting.value = true
await createWithdrawal({
payment_account_id: withdrawForm.value.payment_account_id,
amount_cent: yuanToCent(withdrawForm.value.amount),
})
await createWithdrawal(
{
payment_account_id: withdrawForm.value.payment_account_id,
amount_cent: yuanToCent(withdrawForm.value.amount),
},
distributionStore.isDistribution ? distributionStore.siteCode : undefined
)
ElMessage.success('提现申请已提交')
withdrawForm.value.amount = 0
await loadData()
@@ -167,8 +173,25 @@ function accountTypeLabel(type: string) {
<template>
<div class="withdrawal-page">
<div class="page-header">
<h2>提现</h2>
<p class="page-desc">将钱包余额提现到您的收款账号</p>
<h2>
提现
<el-tag
v-if="distributionStore.isDistribution"
type="warning"
effect="light"
size="small"
class="distribution-site-tag"
>
{{ distributionStore.siteName }}专属分站
</el-tag>
</h2>
<p class="page-desc">
{{
distributionStore.isDistribution
? `将【${distributionStore.siteName}】专属分站钱包余额提现到您的收款账号`
: '将钱包余额提现到您的收款账号'
}}
</p>
</div>
<!-- 余额卡片 -->
@@ -376,6 +399,12 @@ function accountTypeLabel(type: string) {
margin-bottom: 24px;
}
.distribution-site-tag {
margin-left: 8px;
vertical-align: middle;
font-weight: normal;
}
.page-header h2 {
margin: 0 0 8px;
font-size: 24px;