feat(invitation): 支持分销站点自主配置返利比例与权限隔离
- 后端新增分站专属配置及审计接口(/api/distribution/invitation-config 等),基于分站管理员严格鉴权隔离 - 分销独立后台新增「邀请返利设置」页面(Invitations.tsx),支持分站管理员自主开启/调整返利比例并查看本站流水 - 主站后台邀请接口路径补齐 /admin 前缀,解决 404/401 鉴权作用域问题 - 验证方式:backend 单元测试全数通过,distribution-admin 类型与构建检查通过
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user