feat(distribution): 独立分销后台账号体系、多租户隔离与主站后台分销管理GUI

- 独立账号鉴权:分销后台支持独立登录页面、Bearer+Cookie 双模鉴权与租户资料接口
- 多租户隔离:分销商成员强制锁定自身站点数据范围,防止横向越权;平台超管享有全局视界
- 一键开通分销商:平台端支持一键录入站点、管理员账号、分配角色并原子化初始化佣金规则
- 主后台分销分组:在主站后台增加「分销管理」导航分组,提供站点管理、规则配置、订单监控与结算管理 GUI
- 门禁与联调:补充完整单测,全量代码门禁 check.sh 100% 通过,dev.sh 支持前后端与分销后台一键同启
This commit is contained in:
yml2213
2026-09-07 15:45:53 +08:00
parent 0c37c7e659
commit 550b42eb22
28 changed files with 3192 additions and 117 deletions
@@ -0,0 +1,246 @@
import { apiClient } from '@/shared/api/client'
import type { ApiResponse } from '@/shared/types/types'
export interface DistributionSite {
id: number
site_code: string
site_name: string
domain: string
status: string
display_account_enabled: boolean
tax_enabled: boolean
tax_rate_bps: number
created_at: string
}
export interface DistributionRule {
id: number
site_id?: number
mode: string
version: number
status: string
platform_receive_ratio: number
platform_sell_ratio: number
distributor_receive_ratio: number
distributor_sell_ratio: number
distributor_a_ratio: number
distributor_b_ratio: number
service_fee_value: number
service_fee_unit: string
payment_fee_bps: number
tax_enabled: boolean
tax_rate_bps: number
effective_from: string
effective_to?: string
}
export interface DistributionOrder {
order_id: number
order_no: string
site_id: number
status: string
refund_status: string
settlement_status: string
distribution_mode: string
net_paid_amount_cent: number
payment_fee_cent: number
service_fee_cent: number
markup_amount_cent: number
tax_amount_cent: number
split_base_amount_cent: number
rounding_adjustment_cent: number
created_at: string
}
export interface DistributionOrderPage {
items: DistributionOrder[]
total: number
page: number
page_size: number
}
export interface DistributionSettlementBatch {
id: number
site_id: number
batch_no: string
period_start: string
period_end: string
total_amount_cent: number
status: string
}
export interface DistributionSettlementItem {
id: number
order_id: number
party_role: string
amount_cent: number
status: string
}
export interface OnboardDistributionSitePayload {
site_code: string
site_name: string
domain: string
admin_username: string
admin_password: string
admin_nickname?: string
member_type?: string
display_account_enabled?: boolean
tax_enabled?: boolean
tax_rate_bps?: number
distributor_receive_ratio?: number
platform_sell_ratio?: number
service_fee_value?: number
}
export interface OnboardDistributionSiteResult {
site: DistributionSite
rule: DistributionRule
member: {
admin_id: number
username: string
nickname: string
is_superadmin: boolean
site_id?: number
site_code?: string
site_name?: string
domain?: string
member_type?: string
}
}
export async function fetchDistributionSites() {
const { data } = await apiClient.get<ApiResponse<{ items: DistributionSite[] }>>(
'/admin/distribution/sites'
)
return data.data.items ?? []
}
export async function onboardDistributionSite(payload: OnboardDistributionSitePayload) {
const { data } = await apiClient.post<ApiResponse<OnboardDistributionSiteResult>>(
'/admin/distribution/sites/onboard',
payload
)
return data.data
}
export async function updateDistributionSite(
id: number,
payload: Partial<
Pick<
DistributionSite,
'site_name' | 'domain' | 'status' | 'display_account_enabled' | 'tax_enabled' | 'tax_rate_bps'
>
>
) {
const { data } = await apiClient.put<ApiResponse<DistributionSite>>(
`/admin/distribution/sites/${id}`,
payload
)
return data.data
}
export async function fetchDistributionRules(siteId?: number, mode?: string) {
const { data } = await apiClient.get<ApiResponse<{ items: DistributionRule[] }>>(
'/admin/distribution/rules',
{
params: { site_id: siteId || undefined, mode: mode || undefined },
}
)
return data.data.items ?? []
}
export interface CreateRulePayload {
site_id?: number
mode: string
distributor_receive_ratio?: number
platform_sell_ratio?: number
distributor_sell_ratio?: number
platform_receive_ratio?: number
distributor_a_ratio?: number
distributor_b_ratio?: number
service_fee_value: number
service_fee_unit: string
payment_fee_bps: number
tax_enabled: boolean
tax_rate_bps: number
effective_from?: string
}
export async function createDistributionRule(payload: CreateRulePayload) {
const { data } = await apiClient.post<ApiResponse<DistributionRule>>(
'/admin/distribution/rules',
payload
)
return data.data
}
export async function updateDistributionRuleStatus(id: number, status: string) {
const { data } = await apiClient.put<ApiResponse<DistributionRule>>(
`/admin/distribution/rules/${id}/status`,
{ status }
)
return data.data
}
export async function fetchDistributionOrders(params: {
kind?: string
status?: string
siteID?: number
page?: number
pageSize?: number
}) {
const { data } = await apiClient.get<ApiResponse<DistributionOrderPage>>(
'/admin/distribution/orders',
{
params: {
kind: params.kind || undefined,
status: params.status || undefined,
site_id: params.siteID || undefined,
page: params.page || 1,
page_size: params.pageSize || 20,
},
}
)
return data.data
}
export async function fetchDistributionSettlementBatches(siteId: number) {
const { data } = await apiClient.get<ApiResponse<{ items: DistributionSettlementBatch[] }>>(
'/admin/distribution/settlement-batches',
{
params: { site_id: siteId },
}
)
return data.data.items ?? []
}
export async function createDistributionSettlementBatch(
siteId: number,
periodStart: string,
periodEnd: string
) {
const { data } = await apiClient.post<ApiResponse<DistributionSettlementBatch>>(
'/admin/distribution/settlement-batches',
{
site_id: siteId,
period_start: periodStart,
period_end: periodEnd,
}
)
return data.data
}
export async function settleDistributionBatch(id: number) {
const { data } = await apiClient.post<ApiResponse<DistributionSettlementBatch>>(
`/admin/distribution/settlement-batches/${id}/settle`
)
return data.data
}
export async function fetchDistributionSettlementItems(batchId: number) {
const { data } = await apiClient.get<ApiResponse<{ items: DistributionSettlementItem[] }>>(
`/admin/distribution/settlement-batches/${batchId}/items`
)
return data.data.items ?? []
}
+1
View File
@@ -7,6 +7,7 @@ export * from './api/adminPayments'
export * from './api/adminFinance'
export * from './api/adminAudit'
export * from './api/adminNotifications'
export * from './api/adminDistribution'
export * from './api/systemConfigs'
export * from './api/supportGroups'
export * from './composables/useAdminTable'
@@ -0,0 +1,229 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import {
fetchDistributionOrders,
fetchDistributionSites,
type DistributionOrder,
type DistributionSite,
} from '@/features/admin/api/adminDistribution'
import { formatCent } from '@/shared/utils/money'
import { formatDateTime } from '@/shared/utils/time'
import { readError } from '@/shared/utils/error'
const loading = ref(false)
const sites = ref<DistributionSite[]>([])
const orders = ref<DistributionOrder[]>([])
const total = ref(0)
const query = reactive({
siteID: undefined as number | undefined,
kind: '' as string,
status: '' as string,
page: 1,
pageSize: 20,
})
const modeMap: Record<string, string> = {
two_party_distributor_receive: '收号',
two_party_platform_receive: '售出',
three_party: '三方',
}
async function loadSites() {
try {
sites.value = await fetchDistributionSites()
} catch (err) {
ElMessage.error(readError(err, '获取分销站点失败'))
}
}
async function loadOrders() {
loading.value = true
try {
const res = await fetchDistributionOrders({
siteID: query.siteID,
kind: query.kind || undefined,
status: query.status || undefined,
page: query.page,
pageSize: query.pageSize,
})
orders.value = res.items ?? []
total.value = res.total ?? 0
} catch (err) {
ElMessage.error(readError(err, '获取分销订单失败'))
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
loadOrders()
}
function handleReset() {
query.siteID = undefined
query.kind = ''
query.status = ''
query.page = 1
loadOrders()
}
function getSiteName(siteId: number) {
const site = sites.value.find(s => s.id === siteId)
return site ? `${site.site_name}` : `站点 #${siteId}`
}
onMounted(async () => {
await loadSites()
await loadOrders()
})
</script>
<template>
<div class="distribution-orders-view">
<div class="page-header">
<div>
<h2 class="page-title">分销订单监控</h2>
<p class="page-subtitle">
全览各合作分销站点的收号与售出订单明细、渠道手续费、单笔服务费及利润分成流水。
</p>
</div>
</div>
<el-card shadow="never" class="filter-card">
<el-form :inline="true" :model="query">
<el-form-item label="分销站点">
<el-select v-model="query.siteID" clearable placeholder="全部分销站" style="width: 180px">
<el-option
v-for="site in sites"
:key="site.id"
:label="site.site_name"
:value="site.id"
/>
</el-select>
</el-form-item>
<el-form-item label="交易类型">
<el-select v-model="query.kind" clearable placeholder="全部模式" style="width: 140px">
<el-option label="分销收号" value="receive" />
<el-option label="分销售出" value="sold" />
<el-option label="全部" value="all" />
</el-select>
</el-form-item>
<el-form-item label="结算状态">
<el-select v-model="query.status" clearable placeholder="全部状态" style="width: 140px">
<el-option label="待结算" value="pending" />
<el-option label="已结算" value="settled" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" style="margin-top: 16px">
<el-table v-loading="loading" :data="orders" stripe style="width: 100%">
<el-table-column label="订单号" prop="order_no" min-width="160" />
<el-table-column label="分销站点" min-width="140">
<template #default="{ row }">
<el-tag size="small">{{ getSiteName(row.site_id) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="模式" width="90" align="center">
<template #default="{ row }">
<el-tag type="info" size="small">
{{ modeMap[row.distribution_mode] || row.distribution_mode }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="订单实付" min-width="110" align="right">
<template #default="{ row }">
<strong>{{ formatCent(row.net_paid_amount_cent) }}</strong>
</template>
</el-table-column>
<el-table-column label="渠道手续费" min-width="100" align="right">
<template #default="{ row }">
{{ formatCent(row.payment_fee_cent) }}
</template>
</el-table-column>
<el-table-column label="固定服务费" min-width="100" align="right">
<template #default="{ row }">
{{ formatCent(row.service_fee_cent) }}
</template>
</el-table-column>
<el-table-column label="税费" min-width="90" align="right">
<template #default="{ row }">
{{ formatCent(row.tax_amount_cent) }}
</template>
</el-table-column>
<el-table-column label="订单状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'completed' ? 'success' : 'warning'" size="small">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="结算状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.settlement_status === 'settled' ? 'success' : 'info'" size="small">
{{ row.settlement_status === 'settled' ? '已结算' : '待结算' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" min-width="160">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadOrders"
@size-change="loadOrders"
/>
</div>
</el-card>
</div>
</template>
<style scoped>
.distribution-orders-view {
padding: 20px;
}
.page-header {
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
font-weight: 600;
margin: 0 0 6px 0;
color: #1f2937;
}
.page-subtitle {
font-size: 13px;
color: #6b7280;
margin: 0;
}
.filter-card {
padding-bottom: 0;
}
.pagination-bar {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import {
fetchDistributionRules,
fetchDistributionSites,
updateDistributionRuleStatus,
type DistributionRule,
type DistributionSite,
} from '@/features/admin/api/adminDistribution'
import { formatDateTime } from '@/shared/utils/time'
import { readError } from '@/shared/utils/error'
const loading = ref(false)
const sites = ref<DistributionSite[]>([])
const selectedSiteId = ref<number | undefined>(undefined)
const rules = ref<DistributionRule[]>([])
const modeMap: Record<string, string> = {
two_party_distributor_receive: '两方-分销收号',
two_party_platform_receive: '两方-分销卖出',
three_party: '三方分销',
}
async function loadSites() {
try {
sites.value = await fetchDistributionSites()
} catch (err) {
ElMessage.error(readError(err, '获取分销站点失败'))
}
}
async function loadRules() {
loading.value = true
try {
rules.value = await fetchDistributionRules(selectedSiteId.value)
} catch (err) {
ElMessage.error(readError(err, '获取分销规则列表失败'))
} finally {
loading.value = false
}
}
async function handleToggleStatus(row: DistributionRule) {
const next = row.status === 'active' ? 'disabled' : 'active'
try {
await updateDistributionRuleStatus(row.id, next)
ElMessage.success(`规则已${next === 'active' ? '启用' : '停用'}`)
await loadRules()
} catch (err) {
ElMessage.error(readError(err, '更新规则状态失败'))
}
}
function getSiteName(siteId?: number) {
if (!siteId) return '全局默认'
const site = sites.value.find(s => s.id === siteId)
return site ? `${site.site_name} (${site.site_code})` : `站点 #${siteId}`
}
onMounted(async () => {
await loadSites()
await loadRules()
})
</script>
<template>
<div class="distribution-rules-view">
<div class="page-header">
<div>
<h2 class="page-title">分销规则配置</h2>
<p class="page-subtitle">
配置与维护各分销站点的佣金分成比例、单笔服务费、支付通道手续费与税费规则。
</p>
</div>
<div class="header-filters">
<el-select
v-model="selectedSiteId"
clearable
placeholder="按分销站点筛选"
style="width: 240px"
@change="loadRules"
>
<el-option
v-for="site in sites"
:key="site.id"
:label="`${site.site_name} (${site.site_code})`"
:value="site.id"
/>
</el-select>
</div>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="rules" stripe style="width: 100%">
<el-table-column label="适用站点" min-width="160">
<template #default="{ row }">
<el-tag :type="row.site_id ? 'primary' : 'info'" size="small">
{{ getSiteName(row.site_id) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="分销模式" prop="mode" min-width="140">
<template #default="{ row }">
{{ modeMap[row.mode] || row.mode }}
</template>
</el-table-column>
<el-table-column label="版本号" prop="version" width="80" align="center">
<template #default="{ row }"> v{{ row.version }} </template>
</el-table-column>
<el-table-column label="分销商收号分成" min-width="130" align="right">
<template #default="{ row }"> {{ row.distributor_receive_ratio }}% </template>
</el-table-column>
<el-table-column label="平台卖号分成" min-width="120" align="right">
<template #default="{ row }"> {{ row.platform_sell_ratio }}% </template>
</el-table-column>
<el-table-column label="固定服务费" min-width="110" align="right">
<template #default="{ row }"> ¥{{ row.service_fee_value }} </template>
</el-table-column>
<el-table-column label="支付手续费" min-width="110" align="right">
<template #default="{ row }"> {{ row.payment_fee_bps / 100 }}% </template>
</el-table-column>
<el-table-column label="税费率" min-width="100" align="center">
<template #default="{ row }">
<el-tag v-if="row.tax_enabled" type="warning" size="small">
{{ row.tax_rate_bps / 100 }}%
</el-tag>
<el-tag v-else type="info" size="small">免税</el-tag>
</template>
</el-table-column>
<el-table-column label="生效时间" min-width="160">
<template #default="{ row }">
{{ formatDateTime(row.effective_from) }}
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
{{ row.status === 'active' ? '生效中' : '已停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button
link
:type="row.status === 'active' ? 'danger' : 'success'"
@click="handleToggleStatus(row)"
>
{{ row.status === 'active' ? '停用' : '启用' }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<style scoped>
.distribution-rules-view {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
font-weight: 600;
margin: 0 0 6px 0;
color: #1f2937;
}
.page-subtitle {
font-size: 13px;
color: #6b7280;
margin: 0;
}
</style>
@@ -0,0 +1,285 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import {
fetchDistributionSettlementBatches,
fetchDistributionSettlementItems,
createDistributionSettlementBatch,
settleDistributionBatch,
fetchDistributionSites,
type DistributionSettlementBatch,
type DistributionSettlementItem,
type DistributionSite,
} from '@/features/admin/api/adminDistribution'
import { formatCent } from '@/shared/utils/money'
import { formatDateTime } from '@/shared/utils/time'
import { readError } from '@/shared/utils/error'
const loading = ref(false)
const sites = ref<DistributionSite[]>([])
const selectedSiteId = ref<number>()
const batches = ref<DistributionSettlementBatch[]>([])
const createDialogVisible = ref(false)
const creating = ref(false)
const createForm = reactive({
site_id: undefined as number | undefined,
date_range: [] as string[],
})
const itemsDialogVisible = ref(false)
const itemsLoading = ref(false)
const currentBatch = ref<DistributionSettlementBatch | null>(null)
const items = ref<DistributionSettlementItem[]>([])
async function loadSites() {
try {
const list = await fetchDistributionSites()
sites.value = list
if (list.length > 0 && list[0]) {
selectedSiteId.value = list[0].id
await loadBatches()
}
} catch (err) {
ElMessage.error(readError(err, '获取分销站点失败'))
}
}
async function loadBatches() {
if (!selectedSiteId.value) return
loading.value = true
try {
batches.value = await fetchDistributionSettlementBatches(selectedSiteId.value)
} catch (err) {
ElMessage.error(readError(err, '获取结算批次失败'))
} finally {
loading.value = false
}
}
function handleOpenCreate() {
createForm.site_id = selectedSiteId.value
createForm.date_range = []
createDialogVisible.value = true
}
async function handleCreateBatch() {
const [start, end] = createForm.date_range
if (!createForm.site_id || !start || !end) {
ElMessage.warning('请选择站点与结算周期起止时间')
return
}
creating.value = true
try {
await createDistributionSettlementBatch(createForm.site_id, start, end)
ElMessage.success('结算批次生成成功')
createDialogVisible.value = false
await loadBatches()
} catch (err) {
ElMessage.error(readError(err, '生成结算批次失败'))
} finally {
creating.value = false
}
}
async function handleSettle(row: DistributionSettlementBatch) {
try {
await ElMessageBox.confirm(
`确定将结算批次 ${row.batch_no} 标记为已打款结算吗?结算总额为 ${formatCent(row.total_amount_cent)}。`,
'确认结算',
{ type: 'warning', confirmButtonText: '确认标记已结算', cancelButtonText: '取消' }
)
await settleDistributionBatch(row.id)
ElMessage.success('批次已成功标记为已结算')
await loadBatches()
} catch {
// cancelled or error handled
}
}
async function handleViewItems(row: DistributionSettlementBatch) {
currentBatch.value = row
itemsDialogVisible.value = true
itemsLoading.value = true
try {
items.value = await fetchDistributionSettlementItems(row.id)
} catch (err) {
ElMessage.error(readError(err, '获取结算明细失败'))
} finally {
itemsLoading.value = false
}
}
onMounted(() => {
loadSites()
})
</script>
<template>
<div class="distribution-settlements-view">
<div class="page-header">
<div>
<h2 class="page-title">分销结算管理</h2>
<p class="page-subtitle">
按周期汇总各合作分销站点的佣金分成批次,并执行最终结算出款确认。
</p>
</div>
<div class="header-actions">
<el-select
v-model="selectedSiteId"
placeholder="选择分销站点"
style="width: 220px"
@change="loadBatches"
>
<el-option
v-for="site in sites"
:key="site.id"
:label="`${site.site_name} (${site.site_code})`"
:value="site.id"
/>
</el-select>
<el-button type="primary" :icon="Plus" @click="handleOpenCreate"> 生成结算批次 </el-button>
</div>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="batches" stripe style="width: 100%">
<el-table-column label="结算批次号" prop="batch_no" min-width="180" />
<el-table-column label="周期开始时间" min-width="160">
<template #default="{ row }">
{{ formatDateTime(row.period_start) }}
</template>
</el-table-column>
<el-table-column label="周期结束时间" min-width="160">
<template #default="{ row }">
{{ formatDateTime(row.period_end) }}
</template>
</el-table-column>
<el-table-column label="应结总金额" min-width="140" align="right">
<template #default="{ row }">
<strong style="color: #67c23a; font-size: 15px">
{{ formatCent(row.total_amount_cent) }}
</strong>
</template>
</el-table-column>
<el-table-column label="批次状态" width="120" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'settled' ? 'success' : 'warning'" size="small">
{{ row.status === 'settled' ? '已打款' : '待结算' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleViewItems(row)">查看明细</el-button>
<el-button
v-if="row.status !== 'settled'"
link
type="success"
@click="handleSettle(row)"
>
打款结算
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 生成结算批次对话框 -->
<el-dialog
v-model="createDialogVisible"
title="生成分销结算批次"
width="500px"
destroy-on-close
>
<el-form label-width="100px">
<el-form-item label="分销站点">
<el-select v-model="createForm.site_id" style="width: 100%">
<el-option
v-for="site in sites"
:key="site.id"
:label="`${site.site_name} (${site.site_code})`"
:value="site.id"
/>
</el-select>
</el-form-item>
<el-form-item label="结算周期">
<el-date-picker
v-model="createForm.date_range"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="handleCreateBatch">
立即结算汇总
</el-button>
</template>
</el-dialog>
<!-- 结算条目明细对话框 -->
<el-dialog
v-model="itemsDialogVisible"
:title="`结算批次明细 (${currentBatch?.batch_no})`"
width="650px"
>
<el-table v-loading="itemsLoading" :data="items" size="small" border>
<el-table-column label="订单 ID" prop="order_id" width="100" />
<el-table-column label="分成角色" prop="party_role" min-width="150" />
<el-table-column label="应分金额" min-width="120" align="right">
<template #default="{ row }">
<strong>{{ formatCent(row.amount_cent) }}</strong>
</template>
</el-table-column>
<el-table-column label="状态" prop="status" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'settled' ? 'success' : 'info'" size="small">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="itemsDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.distribution-settlements-view {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
font-weight: 600;
margin: 0 0 6px 0;
color: #1f2937;
}
.page-subtitle {
font-size: 13px;
color: #6b7280;
margin: 0;
}
.header-actions {
display: flex;
gap: 12px;
}
</style>
@@ -0,0 +1,406 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Plus, Shop, Link, DocumentCopy } from '@element-plus/icons-vue'
import {
fetchDistributionSites,
onboardDistributionSite,
type DistributionSite,
type OnboardDistributionSitePayload,
type OnboardDistributionSiteResult,
} from '@/features/admin/api/adminDistribution'
import { formatDateTime } from '@/shared/utils/time'
import { readError } from '@/shared/utils/error'
const loading = ref(false)
const sites = ref<DistributionSite[]>([])
const onboardDialogVisible = ref(false)
const submitting = ref(false)
const onboardFormRef = ref<FormInstance>()
const successDialogVisible = ref(false)
const onboardResult = ref<OnboardDistributionSiteResult | null>(null)
const onboardForm = reactive({
site_name: '',
site_code: '',
domain: '',
admin_username: '',
admin_password: '',
admin_nickname: '',
member_type: 'operator',
distributor_receive_ratio: 3.5,
platform_sell_ratio: 4.0,
service_fee_value: 2.5,
display_account_enabled: true,
tax_enabled: false,
tax_rate_bps: 100,
})
const onboardRules: FormRules = {
site_name: [{ required: true, message: '请输入分销站点名称', trigger: 'blur' }],
site_code: [
{ required: true, message: '请输入站点唯一标识', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_-]+$/, message: '仅支持英文字母、数字及下划线', trigger: 'blur' },
],
domain: [{ required: true, message: '请输入分销专属域名', trigger: 'blur' }],
admin_username: [{ required: true, message: '请输入分销管理员账号', trigger: 'blur' }],
admin_password: [
{ required: true, message: '请输入初始密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' },
],
}
async function loadSites() {
loading.value = true
try {
sites.value = await fetchDistributionSites()
} catch (err) {
ElMessage.error(readError(err, '获取分销站点列表失败'))
} finally {
loading.value = false
}
}
function handleOpenOnboard() {
onboardForm.site_name = ''
onboardForm.site_code = ''
onboardForm.domain = ''
onboardForm.admin_username = ''
onboardForm.admin_password = ''
onboardForm.admin_nickname = ''
onboardForm.member_type = 'operator'
onboardForm.distributor_receive_ratio = 3.5
onboardForm.platform_sell_ratio = 4.0
onboardForm.service_fee_value = 2.5
onboardForm.display_account_enabled = true
onboardForm.tax_enabled = false
onboardForm.tax_rate_bps = 100
onboardDialogVisible.value = true
}
async function handleOnboardSubmit() {
if (!onboardFormRef.value) return
await onboardFormRef.value.validate(async valid => {
if (!valid) return
submitting.value = true
try {
const payload: OnboardDistributionSitePayload = {
site_name: onboardForm.site_name.trim(),
site_code: onboardForm.site_code.trim(),
domain: onboardForm.domain.trim(),
admin_username: onboardForm.admin_username.trim(),
admin_password: onboardForm.admin_password,
admin_nickname: onboardForm.admin_nickname.trim() || undefined,
member_type: onboardForm.member_type,
distributor_receive_ratio: Number(onboardForm.distributor_receive_ratio),
platform_sell_ratio: Number(onboardForm.platform_sell_ratio),
service_fee_value: Number(onboardForm.service_fee_value),
display_account_enabled: onboardForm.display_account_enabled,
tax_enabled: onboardForm.tax_enabled,
tax_rate_bps: onboardForm.tax_enabled ? onboardForm.tax_rate_bps : 0,
}
const res = await onboardDistributionSite(payload)
ElMessage.success('分销站点与管理员账号已成功开通!')
onboardDialogVisible.value = false
onboardResult.value = res
successDialogVisible.value = true
await loadSites()
} catch (err) {
ElMessage.error(readError(err, '开通分销商失败'))
} finally {
submitting.value = false
}
})
}
function handleCopyInfo() {
if (!onboardResult.value) return
const text = `分销站点:${onboardResult.value.site.site_name} (${onboardResult.value.site.site_code})\n分销域名:${onboardResult.value.site.domain}\n后台登录地址:http://${window.location.hostname}:5190/login\n管理员账号:${onboardResult.value.member.username}`
navigator.clipboard?.writeText(text)
ElMessage.success('已复制分销商开通凭证到剪贴板')
}
function openDistributionAdmin() {
window.open(`http://${window.location.hostname}:5190`, '_blank')
}
onMounted(() => {
loadSites()
})
</script>
<template>
<div class="distribution-sites-view">
<div class="page-header">
<div>
<h2 class="page-title">分销站点管理</h2>
<p class="page-subtitle">
统一管理全平台合作分销站点,支持一键开通分销商、初始化管理员账号及专属佣金费率。
</p>
</div>
<div class="header-actions">
<el-button :icon="Link" @click="openDistributionAdmin"> 打开独立分销后台 </el-button>
<el-button type="primary" :icon="Plus" @click="handleOpenOnboard">
一键开通分销商
</el-button>
</div>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="sites" stripe style="width: 100%">
<el-table-column label="站点名称" prop="site_name" min-width="160">
<template #default="{ row }">
<div class="site-name-cell">
<el-icon class="site-icon"><Shop /></el-icon>
<strong>{{ row.site_name }}</strong>
</div>
</template>
</el-table-column>
<el-table-column label="站点标识" prop="site_code" min-width="120">
<template #default="{ row }">
<el-tag size="small">{{ row.site_code }}</el-tag>
</template>
</el-table-column>
<el-table-column label="绑定域名" prop="domain" min-width="180" />
<el-table-column label="运行状态" prop="status" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">
{{ row.status === 'active' ? '正常' : '已停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="展示主站账号" prop="display_account_enabled" width="130">
<template #default="{ row }">
<el-tag :type="row.display_account_enabled ? 'success' : 'info'" size="small">
{{ row.display_account_enabled ? '已开启' : '关闭' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="税费设定" width="120">
<template #default="{ row }">
<el-tag v-if="row.tax_enabled" type="warning" size="small">
{{ (row.tax_rate_bps / 100).toFixed(1) }}%
</el-tag>
<el-tag v-else type="info" size="small">免税</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" prop="created_at" min-width="160">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 一键开通分销商对话框 -->
<el-dialog v-model="onboardDialogVisible" title="一键开通分销商" width="680px" destroy-on-close>
<el-form ref="onboardFormRef" :model="onboardForm" :rules="onboardRules" label-width="140px">
<div class="form-section-title">1. 分销站点基础信息</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="站点名称" prop="site_name">
<el-input v-model="onboardForm.site_name" placeholder="例如:广州游戏分销站" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="站点唯一标识" prop="site_code">
<el-input v-model="onboardForm.site_code" placeholder="例如:gz_dist" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="分销专属域名" prop="domain">
<el-input v-model="onboardForm.domain" placeholder="例如:gz.distribution.hao858.com" />
</el-form-item>
<div class="form-section-title">2. 分销商管理员账号</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="登录用户名" prop="admin_username">
<el-input v-model="onboardForm.admin_username" placeholder="例如:gz_admin" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="初始登录密码" prop="admin_password">
<el-input
v-model="onboardForm.admin_password"
type="password"
show-password
placeholder="至少6位"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="站长姓名/昵称" prop="admin_nickname">
<el-input v-model="onboardForm.admin_nickname" placeholder="例如:张站长" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="初始角色权限" prop="member_type">
<el-select v-model="onboardForm.member_type" style="width: 100%">
<el-option label="分销运营 (operator)" value="operator" />
<el-option label="分销财务 (finance)" value="finance" />
<el-option label="客服人员 (customer_service)" value="customer_service" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<div class="form-section-title">3. 佣金比例与费率初始化</div>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="收号分成比例 (%)">
<el-input-number
v-model="onboardForm.distributor_receive_ratio"
:min="0"
:max="100"
:step="0.1"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="平台卖号分成 (%)">
<el-input-number
v-model="onboardForm.platform_sell_ratio"
:min="0"
:max="100"
:step="0.1"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="单笔服务费 (元)">
<el-input-number
v-model="onboardForm.service_fee_value"
:min="0"
:step="0.5"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="展示主站账号">
<el-switch v-model="onboardForm.display_account_enabled" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="是否征税">
<el-switch v-model="onboardForm.tax_enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="onboardDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleOnboardSubmit">
立即开通并生成分销商
</el-button>
</template>
</el-dialog>
<!-- 开通成功凭证对话框 -->
<el-dialog v-model="successDialogVisible" title="分销商开通成功" width="560px">
<div v-if="onboardResult">
<el-alert
type="success"
:closable="false"
show-icon
title="分销站点与管理员账号已成功初始化,请妥善保管交付凭证:"
style="margin-bottom: 16px"
/>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="分销站点">
<strong>{{ onboardResult.site.site_name }}</strong> ({{ onboardResult.site.site_code }})
</el-descriptions-item>
<el-descriptions-item label="分销专属域名">
{{ onboardResult.site.domain }}
</el-descriptions-item>
<el-descriptions-item label="分销后台登录账号">
<el-tag type="success">{{ onboardResult.member.username }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="分销后台地址">
<code>http://{{ windowHost }}:5190/login</code>
</el-descriptions-item>
<el-descriptions-item label="初始规则">
收号分成: {{ onboardResult.rule.distributor_receive_ratio }}% · 卖号分成:
{{ onboardResult.rule.platform_sell_ratio }}% · 服务费: ¥{{
onboardResult.rule.service_fee_value
}}
</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button :icon="DocumentCopy" type="primary" @click="handleCopyInfo">
一键复制交付信息
</el-button>
<el-button @click="successDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts">
export default {
data() {
return {
windowHost: typeof window !== 'undefined' ? window.location.hostname : 'localhost',
}
},
}
</script>
<style scoped>
.distribution-sites-view {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
font-weight: 600;
margin: 0 0 6px 0;
color: #1f2937;
}
.page-subtitle {
font-size: 13px;
color: #6b7280;
margin: 0;
}
.header-actions {
display: flex;
gap: 12px;
}
.site-name-cell {
display: flex;
align-items: center;
gap: 8px;
}
.site-icon {
color: #409eff;
font-size: 16px;
}
.form-section-title {
font-size: 14px;
font-weight: 600;
color: #374151;
margin: 16px 0 12px 0;
padding-bottom: 6px;
border-bottom: 1px dashed #e5e7eb;
}
</style>
@@ -164,7 +164,6 @@ function userText(value: string | number | undefined) {
function isPlatformManaged(row: AdminOrderListItem) {
return row.handoff_mode === 'platform' || row.settlement_mode === 'platform_managed'
}
</script>
<template>