feat(invitation): 支持分销站点自主配置返利比例与权限隔离

- 后端新增分站专属配置及审计接口(/api/distribution/invitation-config 等),基于分站管理员严格鉴权隔离
- 分销独立后台新增「邀请返利设置」页面(Invitations.tsx),支持分站管理员自主开启/调整返利比例并查看本站流水
- 主站后台邀请接口路径补齐 /admin 前缀,解决 404/401 鉴权作用域问题
- 验证方式:backend 单元测试全数通过,distribution-admin 类型与构建检查通过
This commit is contained in:
yml2213
2026-10-10 13:47:53 +08:00
parent 984433f866
commit 7cddbe9463
8 changed files with 799 additions and 7 deletions
+139 -1
View File
@@ -1,6 +1,7 @@
package invitation
import (
"context"
"errors"
"net/http"
"strconv"
@@ -13,14 +14,23 @@ import (
"github.com/gin-gonic/gin"
)
type SiteScoper interface {
ScopeSiteID(ctx context.Context, adminID, requested uint64) (uint64, error)
}
type Handler struct {
service *Service
service *Service
siteScoper SiteScoper
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
func (h *Handler) SetSiteScoper(scoper SiteScoper) {
h.siteScoper = scoper
}
// GetInviteInfo 公开接口:根据邀请码查询邀请人简要信息(用于落地注册页)
func (h *Handler) GetInviteInfo(c *gin.Context) {
code := strings.TrimSpace(c.Param("code"))
@@ -225,3 +235,131 @@ func writeInvitationError(c *gin.Context, err error) {
response.Error(c, http.StatusInternalServerError, "invitation_error", err.Error())
}
}
// DistributionGetConfig 分销端获取本站邀请返利比例配置
func (h *Handler) DistributionGetConfig(c *gin.Context) {
siteID, err := h.resolveDistributionSiteID(c)
if err != nil {
response.Forbidden(c, "无权访问该分销站点")
return
}
cfg, err := h.service.GetRebateConfig(c.Request.Context(), siteID)
if err != nil {
writeInvitationError(c, err)
return
}
response.OK(c, cfg)
}
// DistributionUpdateConfig 分销端更新本站邀请返利比例配置
func (h *Handler) DistributionUpdateConfig(c *gin.Context) {
siteID, err := h.resolveDistributionSiteID(c)
if err != nil {
response.Forbidden(c, "无权操作该分销站点")
return
}
if siteID == 0 {
response.BadRequest(c, "未指定有效分销站点")
return
}
var req UpdateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "比例参数格式不正确,必须在0到1之间")
return
}
cfg, err := h.service.UpdateRebateConfig(c.Request.Context(), siteID, req)
if err != nil {
writeInvitationError(c, err)
return
}
response.OK(c, cfg)
}
// DistributionListInvitations 分销端查看本站邀请关系
func (h *Handler) DistributionListInvitations(c *gin.Context) {
siteID, err := h.resolveDistributionSiteID(c)
if err != nil {
response.Forbidden(c, "无权访问该分销站点")
return
}
var query AdminInvitationQuery
if err := c.ShouldBindQuery(&query); err != nil {
response.BadRequest(c, "查询参数不正确")
return
}
query.DistributionSiteID = &siteID
items, total, err := h.service.ListAdminInvitations(c.Request.Context(), query)
if err != nil {
writeInvitationError(c, err)
return
}
response.OK(c, gin.H{
"items": items,
"total": total,
"page": query.Page,
"page_size": query.PageSize,
})
}
// DistributionListRebates 分销端查看本站返利支出流水
func (h *Handler) DistributionListRebates(c *gin.Context) {
siteID, err := h.resolveDistributionSiteID(c)
if err != nil {
response.Forbidden(c, "无权访问该分销站点")
return
}
var query AdminRebateLedgerQuery
if err := c.ShouldBindQuery(&query); err != nil {
response.BadRequest(c, "查询参数不正确")
return
}
query.DistributionSiteID = &siteID
items, total, err := h.service.ListAdminRebateLedgers(c.Request.Context(), query)
if err != nil {
writeInvitationError(c, err)
return
}
response.OK(c, gin.H{
"items": items,
"total": total,
"page": query.Page,
"page_size": query.PageSize,
})
}
func (h *Handler) resolveDistributionSiteID(c *gin.Context) (uint64, error) {
adminID, _ := currentAdminID(c)
var requested uint64
if raw := c.Param("id"); raw != "" {
if id, err := strconv.ParseUint(raw, 10, 64); err == nil && id > 0 {
requested = id
}
}
if requested == 0 {
if raw := c.Query("site_id"); raw != "" {
if id, err := strconv.ParseUint(raw, 10, 64); err == nil && id > 0 {
requested = id
}
}
}
if requested == 0 {
if raw := c.Query("distribution_site_id"); raw != "" {
if id, err := strconv.ParseUint(raw, 10, 64); err == nil && id > 0 {
requested = id
}
}
}
if h.siteScoper != nil {
return h.siteScoper.ScopeSiteID(c.Request.Context(), adminID, requested)
}
return requested, nil
}
func currentAdminID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextAdminID)
if !ok {
return 0, false
}
adminID, ok := value.(uint64)
return adminID, ok
}
+13
View File
@@ -352,6 +352,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
}
}
distributionHandler := distribution.NewHandler(distributionService)
if invitationHandler != nil && distributionService != nil {
invitationHandler.SetSiteScoper(distributionService)
}
backupMonitorHandler := backupmonitor.NewHandler(cfg.BackupStatusFile)
var adminRoleRepo *adminrole.Repository
if deps.DB != nil {
@@ -684,6 +687,16 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
distributionProtectedRoutes.PUT("/accounts/:id/markup", distributionHandler.UpdateAccountMarkup)
distributionProtectedRoutes.POST("/accounts/batch-markup", distributionHandler.BatchUpdateAccountMarkup)
distributionProtectedRoutes.POST("/accounts/batch-display", distributionHandler.BatchUpdateAccountDisplay)
if invitationHandler != nil {
distributionProtectedRoutes.GET("/sites/:id/invitation-config", invitationHandler.DistributionGetConfig)
distributionProtectedRoutes.PUT("/sites/:id/invitation-config", invitationHandler.DistributionUpdateConfig)
distributionProtectedRoutes.GET("/sites/:id/invitations", invitationHandler.DistributionListInvitations)
distributionProtectedRoutes.GET("/sites/:id/invitation-rebates", invitationHandler.DistributionListRebates)
distributionProtectedRoutes.GET("/invitation-config", invitationHandler.DistributionGetConfig)
distributionProtectedRoutes.PUT("/invitation-config", invitationHandler.DistributionUpdateConfig)
distributionProtectedRoutes.GET("/invitations", invitationHandler.DistributionListInvitations)
distributionProtectedRoutes.GET("/invitation-rebates", invitationHandler.DistributionListRebates)
}
}
adminRoutes := api.Group("/admin", requireAdmin, middleware.RequireAdminPasswordChanged())
+4
View File
@@ -71,6 +71,10 @@ func BadRequest(c *gin.Context, message string) {
Error(c, http.StatusBadRequest, "bad_request", message)
}
func Forbidden(c *gin.Context, message string) {
Error(c, http.StatusForbidden, "forbidden", message)
}
func Unauthorized(c *gin.Context, message string) {
Error(c, http.StatusUnauthorized, "unauthorized", message)
}
+8
View File
@@ -16,6 +16,7 @@ import {
SkinOutlined,
UserOutlined,
WalletOutlined,
GiftOutlined,
} from '@ant-design/icons'
import { getMe, logout, type CurrentMember, getStoredToken } from './api'
import { MemberContext } from './MemberContext'
@@ -29,6 +30,7 @@ import Orders from './pages/Orders'
import Wallet from './pages/Wallet'
import Announcements from './pages/Announcements'
import SiteSettings from './pages/SiteSettings'
import Invitations from './pages/Invitations'
import Login from './pages/Login'
import Storefront from './pages/Storefront'
import './style.css'
@@ -62,6 +64,11 @@ function MainLayout({ member, onLogout }: { member: CurrentMember; onLogout: ()
icon: <SettingOutlined />,
label: <Link to="/rules">{member.is_superadmin ? '比例与税费规则' : '合作政策与权益'}</Link>,
},
{
key: '/invitations',
icon: <GiftOutlined />,
label: <Link to="/invitations">邀请返利设置</Link>,
},
]
const selectedKey = menuItems.some((item) => item && 'key' in item && item.key === location.pathname)
@@ -162,6 +169,7 @@ function MainLayout({ member, onLogout }: { member: CurrentMember; onLogout: ()
<Route path="/sold" element={<Orders kind="sold" />} />
<Route path="/receive" element={<Orders kind="receive" />} />
<Route path="/orders" element={<Orders kind="all" />} />
<Route path="/invitations" element={<Invitations />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout.Content>
+82
View File
@@ -880,3 +880,85 @@ export async function testSiteSMSConfig(siteID: number, payload: TestSiteSMSConf
const resp = await api.post<ApiResponse<{ message: string }>>(`/admin/distribution/sites/${siteID}/sms-config/test`, payload)
return extract(resp)
}
export interface InvitationRebateConfig {
distribution_site_id: number
first_month_rate: number
second_month_rate: number
subsequent_rate: number
enabled: boolean
updated_at?: string
}
export interface InvitedUserItem {
id: number
inviter_id: number
inviter_phone?: string
inviter_nickname?: string
invitee_id: number
invitee_phone?: string
invitee_nickname?: string
invite_code: string
status: string
verified_at?: string
created_at: string
}
export interface RebateLedgerItem {
id: number
order_id: number
order_no: string
inviter_id: number
inviter_phone?: string
inviter_nickname?: string
invitee_id: number
invitee_phone?: string
invitee_nickname?: string
trade_role: 'buyer' | 'seller'
order_profit_basis_cent: number
single_side_basis_cent: number
rate_applied: number
days_since_verified: number
cycle_stage: string
rebate_amount_cent: number
status: string
settled_at: string
}
export async function fetchSiteInvitationConfig(siteID?: number): Promise<InvitationRebateConfig> {
const url = siteID ? `/distribution/sites/${siteID}/invitation-config` : '/distribution/invitation-config'
const resp = await api.get<ApiResponse<InvitationRebateConfig>>(url)
return extract(resp)
}
export async function updateSiteInvitationConfig(
siteID: number,
payload: {
first_month_rate: number
second_month_rate: number
subsequent_rate: number
enabled: boolean
}
): Promise<InvitationRebateConfig> {
const url = siteID ? `/distribution/sites/${siteID}/invitation-config` : '/distribution/invitation-config'
const resp = await api.put<ApiResponse<InvitationRebateConfig>>(url, payload)
return extract(resp)
}
export async function fetchSiteInvitations(
siteID: number,
params: { page: number; page_size: number; phone?: string; invite_code?: string }
): Promise<{ items: InvitedUserItem[]; total: number; page: number; page_size: number }> {
const url = siteID ? `/distribution/sites/${siteID}/invitations` : '/distribution/invitations'
const resp = await api.get<ApiResponse<{ items: InvitedUserItem[]; total: number; page: number; page_size: number }>>(url, { params })
return extract(resp)
}
export async function fetchSiteRebates(
siteID: number,
params: { page: number; page_size: number; order_no?: string; inviter_phone?: string; invitee_phone?: string }
): Promise<{ items: RebateLedgerItem[]; total: number; page: number; page_size: number }> {
const url = siteID ? `/distribution/sites/${siteID}/invitation-rebates` : '/distribution/invitation-rebates'
const resp = await api.get<ApiResponse<{ items: RebateLedgerItem[]; total: number; page: number; page_size: number }>>(url, { params })
return extract(resp)
}
@@ -0,0 +1,529 @@
import { useEffect, useState, useCallback } from 'react'
import {
Card,
Typography,
Space,
Select,
message,
Button,
Form,
InputNumber,
Switch,
Alert,
Tabs,
Table,
Tag,
Input,
Row,
Col,
} from 'antd'
import {
SettingOutlined,
UserOutlined,
DollarCircleOutlined,
SaveOutlined,
ReloadOutlined,
SearchOutlined,
InfoCircleOutlined,
} from '@ant-design/icons'
import {
fetchSiteInvitationConfig,
updateSiteInvitationConfig,
fetchSiteInvitations,
fetchSiteRebates,
listSites,
formatMoney,
type Site,
type InvitedUserItem,
type RebateLedgerItem,
} from '../api'
import { useMember } from '../MemberContext'
export default function Invitations() {
const member = useMember()
const isSuperadmin = Boolean(member?.is_superadmin)
const [sites, setSites] = useState<Site[]>([])
const [selectedSiteID, setSelectedSiteID] = useState<number>(member?.site_id || 0)
const [activeTab, setActiveTab] = useState<string>('config')
// 配置表单状态
const [loadingConfig, setLoadingConfig] = useState(false)
const [savingConfig, setSavingConfig] = useState(false)
const [form] = Form.useForm()
// 邀请关系状态
const [loadingInvitations, setLoadingInvitations] = useState(false)
const [invitations, setInvitations] = useState<InvitedUserItem[]>([])
const [invitationsTotal, setInvitationsTotal] = useState(0)
const [invitationPage, setInvitationPage] = useState(1)
const [invitationPhone, setInvitationPhone] = useState('')
const [invitationCode, setInvitationCode] = useState('')
// 返利流水状态
const [loadingRebates, setLoadingRebates] = useState(false)
const [rebates, setRebates] = useState<RebateLedgerItem[]>([])
const [rebatesTotal, setRebatesTotal] = useState(0)
const [rebatePage, setRebatePage] = useState(1)
const [rebateOrderNo, setRebateOrderNo] = useState('')
const [rebateInviterPhone, setRebateInviterPhone] = useState('')
// 加载站点列表(仅超管可选站点)
useEffect(() => {
if (isSuperadmin) {
listSites()
.then((data) => {
setSites(data)
if (!selectedSiteID && data.length > 0) {
setSelectedSiteID(data[0].id)
}
})
.catch(() => {
// ignore
})
} else if (member?.site_id) {
setSelectedSiteID(member.site_id)
}
}, [isSuperadmin, member?.site_id, selectedSiteID])
// 加载配置
const loadConfig = useCallback(async (siteID: number) => {
if (!siteID) return
setLoadingConfig(true)
try {
const cfg = await fetchSiteInvitationConfig(siteID)
form.setFieldsValue({
enabled: cfg.enabled !== false,
firstMonthRate: Math.round((cfg.first_month_rate || 0.6) * 100),
secondMonthRate: Math.round((cfg.second_month_rate || 0.3) * 100),
subsequentRate: Math.round((cfg.subsequent_rate || 0.15) * 100),
})
} catch {
message.error('获取本站邀请返利配置失败')
} finally {
setLoadingConfig(false)
}
}, [form])
// 加载邀请关系
const loadInvitations = useCallback(async (siteID: number, page = 1) => {
if (!siteID) return
setLoadingInvitations(true)
try {
const res = await fetchSiteInvitations(siteID, {
page,
page_size: 15,
phone: invitationPhone.trim() || undefined,
invite_code: invitationCode.trim() || undefined,
})
setInvitations(res.items || [])
setInvitationsTotal(res.total || 0)
setInvitationPage(page)
} catch {
message.error('获取邀请关系列表失败')
} finally {
setLoadingInvitations(false)
}
}, [invitationPhone, invitationCode])
// 加载返利流水
const loadRebates = useCallback(async (siteID: number, page = 1) => {
if (!siteID) return
setLoadingRebates(true)
try {
const res = await fetchSiteRebates(siteID, {
page,
page_size: 15,
order_no: rebateOrderNo.trim() || undefined,
inviter_phone: rebateInviterPhone.trim() || undefined,
})
setRebates(res.items || [])
setRebatesTotal(res.total || 0)
setRebatePage(page)
} catch {
message.error('获取返利流水列表失败')
} finally {
setLoadingRebates(false)
}
}, [rebateOrderNo, rebateInviterPhone])
useEffect(() => {
if (selectedSiteID) {
if (activeTab === 'config') {
void loadConfig(selectedSiteID)
} else if (activeTab === 'invitations') {
void loadInvitations(selectedSiteID, 1)
} else if (activeTab === 'rebates') {
void loadRebates(selectedSiteID, 1)
}
}
}, [selectedSiteID, activeTab, loadConfig, loadInvitations, loadRebates])
// 保存配置
const handleSaveConfig = async () => {
try {
const values = await form.validateFields()
setSavingConfig(true)
await updateSiteInvitationConfig(selectedSiteID, {
enabled: Boolean(values.enabled),
first_month_rate: Number((values.firstMonthRate / 100).toFixed(4)),
second_month_rate: Number((values.secondMonthRate / 100).toFixed(4)),
subsequent_rate: Number((values.subsequentRate / 100).toFixed(4)),
})
message.success('分站邀请返利比例配置已保存')
} catch {
message.error('保存返利比例配置失败,请检查输入')
} finally {
setSavingConfig(false)
}
}
const tabItems = [
{
key: 'config',
label: (
<span>
<SettingOutlined /> 本站返利比例自主设置
</span>
),
children: (
<Card loading={loadingConfig} style={{ marginTop: 8 }}>
<Alert
message="分站自主返利机制说明"
description="本站邀请返利支出由本站自身产生的利润出资(收号产生的卖家端收益或本站售出产生的买家端收益)。分站站长可根据本站经营策略自由调整分成比例,独立核算,不影响其他站点。"
type="info"
showIcon
icon={<InfoCircleOutlined />}
style={{ marginBottom: 24 }}
/>
<Form
form={form}
layout="vertical"
style={{ maxWidth: 580 }}
initialValues={{
enabled: true,
firstMonthRate: 60,
secondMonthRate: 30,
subsequentRate: 15,
}}
>
<Form.Item
name="enabled"
label="本站返利总开关"
valuePropName="checked"
extra="关闭后,属于本站收益承担的订单将不再向邀请人计算并发放返利"
>
<Switch checkedChildren="已启用本站返利" unCheckedChildren="暂停本站返利" />
</Form.Item>
<Form.Item
name="firstMonthRate"
label="首月分成比例(被邀请人实名生效 1 ~ 30 天)"
rules={[{ required: true, message: '请输入首月比例' }]}
extra="默认 60%,按本站单边毛利基准计提返利奖励"
>
<InputNumber min={0} max={100} step={5} addonAfter="%" style={{ width: 160 }} />
</Form.Item>
<Form.Item
name="secondMonthRate"
label="次月分成比例(被邀请人实名生效 31 ~ 60 天)"
rules={[{ required: true, message: '请输入次月比例' }]}
extra="默认 30%"
>
<InputNumber min={0} max={100} step={5} addonAfter="%" style={{ width: 160 }} />
</Form.Item>
<Form.Item
name="subsequentRate"
label="往后长期分成比例(实名生效 60 天后永久有效)"
rules={[{ required: true, message: '请输入往后长期比例' }]}
extra="默认 15%,终身持续绑定产生收益"
>
<InputNumber min={0} max={100} step={5} addonAfter="%" style={{ width: 160 }} />
</Form.Item>
<Form.Item style={{ marginTop: 24 }}>
<Button
type="primary"
icon={<SaveOutlined />}
loading={savingConfig}
onClick={handleSaveConfig}
size="large"
>
保存本站比例配置
</Button>
</Form.Item>
</Form>
</Card>
),
},
{
key: 'invitations',
label: (
<span>
<UserOutlined /> 本站邀请关系 ({invitationsTotal})
</span>
),
children: (
<Card style={{ marginTop: 8 }}>
<Space direction="horizontal" style={{ marginBottom: 16 }} wrap>
<Input
placeholder="搜索手机号"
value={invitationPhone}
onChange={(e) => setInvitationPhone(e.target.value)}
style={{ width: 180 }}
allowClear
/>
<Input
placeholder="搜索邀请码"
value={invitationCode}
onChange={(e) => setInvitationCode(e.target.value)}
style={{ width: 160 }}
allowClear
/>
<Button
type="primary"
icon={<SearchOutlined />}
onClick={() => loadInvitations(selectedSiteID, 1)}
>
查询
</Button>
<Button
icon={<ReloadOutlined />}
onClick={() => {
setInvitationPhone('')
setInvitationCode('')
void loadInvitations(selectedSiteID, 1)
}}
>
重置
</Button>
</Space>
<Table<InvitedUserItem>
loading={loadingInvitations}
dataSource={invitations}
rowKey="id"
pagination={{
current: invitationPage,
pageSize: 15,
total: invitationsTotal,
onChange: (p) => loadInvitations(selectedSiteID, p),
showTotal: (total) => `共 ${total} 条邀请关系`,
}}
columns={[
{
title: '邀请人',
key: 'inviter',
render: (_, r) => (
<div>
<div>{r.inviter_nickname || '用户' + r.inviter_id}</div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{r.inviter_phone || '--'}
</Typography.Text>
</div>
),
},
{
title: '邀请码',
dataIndex: 'invite_code',
render: (code) => <Tag color="blue">{code}</Tag>,
},
{
title: '被邀请人',
key: 'invitee',
render: (_, r) => (
<div>
<div>{r.invitee_nickname || '用户' + r.invitee_id}</div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{r.invitee_phone || '--'}
</Typography.Text>
</div>
),
},
{
title: '实名状态',
dataIndex: 'status',
render: (status) =>
status === 'active' ? (
<Tag color="green">已实名生效</Tag>
) : (
<Tag color="orange">注册待实名</Tag>
),
},
{
title: '注册时间',
dataIndex: 'created_at',
},
{
title: '实名通过时间',
dataIndex: 'verified_at',
render: (val) => val || '--',
},
]}
/>
</Card>
),
},
{
key: 'rebates',
label: (
<span>
<DollarCircleOutlined /> 本站返利流水 ({rebatesTotal})
</span>
),
children: (
<Card style={{ marginTop: 8 }}>
<Space direction="horizontal" style={{ marginBottom: 16 }} wrap>
<Input
placeholder="订单号"
value={rebateOrderNo}
onChange={(e) => setRebateOrderNo(e.target.value)}
style={{ width: 180 }}
allowClear
/>
<Input
placeholder="邀请人手机号"
value={rebateInviterPhone}
onChange={(e) => setRebateInviterPhone(e.target.value)}
style={{ width: 160 }}
allowClear
/>
<Button
type="primary"
icon={<SearchOutlined />}
onClick={() => loadRebates(selectedSiteID, 1)}
>
查询
</Button>
<Button
icon={<ReloadOutlined />}
onClick={() => {
setRebateOrderNo('')
setRebateInviterPhone('')
void loadRebates(selectedSiteID, 1)
}}
>
重置
</Button>
</Space>
<Table<RebateLedgerItem>
loading={loadingRebates}
dataSource={rebates}
rowKey="id"
pagination={{
current: rebatePage,
pageSize: 15,
total: rebatesTotal,
onChange: (p) => loadRebates(selectedSiteID, p),
showTotal: (total) => `共 ${total} 条返利流水`,
}}
columns={[
{
title: '订单号',
dataIndex: 'order_no',
render: (no) => <Typography.Text copyable>{no}</Typography.Text>,
},
{
title: '分成端',
dataIndex: 'trade_role',
render: (role) =>
role === 'buyer' ? (
<Tag color="blue">买家端 (本站售出)</Tag>
) : (
<Tag color="green">卖家端 (本站收号)</Tag>
),
},
{
title: '收益基准',
dataIndex: 'single_side_basis_cent',
render: (cent) => formatMoney(cent),
},
{
title: '分成比例',
dataIndex: 'rate_applied',
render: (r, row) => (
<div>
<Tag color="purple">{(r * 100).toFixed(1)}%</Tag>
<span style={{ fontSize: 12, color: '#888' }}>
{row.cycle_stage === 'first_month'
? '首月'
: row.cycle_stage === 'second_month'
? '次月'
: '长期'}
</span>
</div>
),
},
{
title: '返利发放金额',
dataIndex: 'rebate_amount_cent',
render: (cent) => (
<span style={{ color: '#52c41a', fontWeight: 'bold' }}>
+{formatMoney(cent)}
</span>
),
},
{
title: '获益邀请人',
key: 'inviter',
render: (_, r) => (
<div>
<div>{r.inviter_nickname || '用户' + r.inviter_id}</div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{r.inviter_phone || '--'}
</Typography.Text>
</div>
),
},
{
title: '结算时间',
dataIndex: 'settled_at',
},
]}
/>
</Card>
),
},
]
return (
<Space direction="vertical" className="page" style={{ width: '100%' }} size="middle">
<Row justify="space-between" align="middle">
<Col>
<Typography.Title level={3} style={{ marginBottom: 4 }}>
本站邀请返利自主设置
</Typography.Title>
<Typography.Text type="secondary">
分销站点可根据自身经营策略,自由调整本站邀请返利比例并监控本站返利支出与用户绑定流水。
</Typography.Text>
</Col>
<Col>
{isSuperadmin && sites.length > 0 ? (
<Space>
<span>切换分销网站:</span>
<Select
style={{ width: 220 }}
value={selectedSiteID}
onChange={(val) => setSelectedSiteID(val)}
options={sites.map((s) => ({
value: s.id,
label: `${s.site_name} (${s.site_code})`,
}))}
/>
</Space>
) : (
<Tag color="blue" style={{ fontSize: 13, padding: '4px 10px' }}>
当前站点: {member?.site_name} ({member?.site_code})
</Tag>
)}
</Col>
</Row>
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
</Space>
)
}
@@ -126,7 +126,7 @@ async function loadInvitations() {
params.distribution_site_id = invitationQuery.distribution_site_id
}
const { data } = await apiClient.get('/invitations', { params })
const { data } = await apiClient.get('/admin/invitations', { params })
invitations.value = data.data.items || []
invitationsTotal.value = data.data.total || 0
} catch (err) {
@@ -150,7 +150,7 @@ async function loadRebates() {
params.distribution_site_id = rebateQuery.distribution_site_id
}
const { data } = await apiClient.get('/invitations/rebates', { params })
const { data } = await apiClient.get('/admin/invitations/rebates', { params })
rebates.value = data.data.items || []
rebatesTotal.value = data.data.total || 0
} catch (err) {
@@ -231,6 +231,14 @@ function cycleLabel(cycle: string) {
</el-select>
<el-button :icon="Refresh" circle @click="loadConfig" />
</div>
<div v-if="selectedSiteId === 0" class="site-desc-hint">
💡
说明:当前配置为【主站平台订单默认分成比例】(分站未设置专属比例时作为全局兜底)。各加盟分销站可在其专属【分销后台】中自主调整属于该分站的返利比例,分成支出由分站自身收益承担。
</div>
<div v-else class="site-desc-hint">
💡
说明:当前正在为指定分销站维护专属返利比例(该分站管理员亦可在其独立分销后台中自主设置,分成支出由该分站自身收益承担)。
</div>
</div>
<div v-loading="loadingConfig" class="config-form-body">
@@ -507,9 +515,19 @@ function cycleLabel(cycle: string) {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px solid #f1f5f9;
margin-bottom: 20px;
margin-bottom: 14px;
}
.site-desc-hint {
font-size: 13px;
color: #475569;
background: #f8fafc;
border-left: 3px solid #3b82f6;
padding: 8px 12px;
border-radius: 4px;
margin-bottom: 18px;
}
.rate-input-box {
@@ -110,7 +110,7 @@ export async function fetchMyRebates(
/** 管理端获取返利比例配置(siteId 为 0 时为主站) */
export async function fetchAdminRebateConfig(siteId = 0): Promise<InvitationRebateConfig> {
const { data } = await apiClient.get<ApiResponse<InvitationRebateConfig>>(
'/invitations/configs',
'/admin/invitations/configs',
{
params: { distribution_site_id: siteId },
}
@@ -129,7 +129,7 @@ export async function updateAdminRebateConfig(
}
): Promise<InvitationRebateConfig> {
const { data } = await apiClient.put<ApiResponse<InvitationRebateConfig>>(
'/invitations/configs',
'/admin/invitations/configs',
config,
{
params: { distribution_site_id: siteId },