feat(admin): 用户管理新增用户明细抽屉,聚合资金与交易全链路数据
- 用户管理操作列新增「明细」入口,抽屉按权限提供 5 个 Tab:钱包流水、订单记录、提现记录、邀请返利、支付记录 - 后端:后台订单列表新增 user_id 精确过滤(owner_id / renter_id),邀请关系与返利流水新增 inviter_id / invitee_id 过滤 - 修复:返利流水的「被邀请人手机号」筛选此前后端未实现,一直无效 - 资金流水页、支付记录页支持 ?user_id= 预填,可从明细直达 - 顺带将 invitation/dto.go、repository.go 中两处既有缩进错位按 gofmt 规整
This commit is contained in:
@@ -12,33 +12,33 @@ type InviteCodeInfoDTO struct {
|
|||||||
|
|
||||||
// UserInvitationSummaryDTO 个人中心邀请返利概览
|
// UserInvitationSummaryDTO 个人中心邀请返利概览
|
||||||
type UserInvitationSummaryDTO struct {
|
type UserInvitationSummaryDTO struct {
|
||||||
HasInviteCode bool `json:"has_invite_code"`
|
HasInviteCode bool `json:"has_invite_code"`
|
||||||
InviteCode string `json:"invite_code"`
|
InviteCode string `json:"invite_code"`
|
||||||
InviteLink string `json:"invite_link"`
|
InviteLink string `json:"invite_link"`
|
||||||
IsRealnameVerified bool `json:"is_realname_verified"`
|
IsRealnameVerified bool `json:"is_realname_verified"`
|
||||||
TotalInvitedCount int64 `json:"total_invited_count"`
|
TotalInvitedCount int64 `json:"total_invited_count"`
|
||||||
ActiveInvitedCount int64 `json:"active_invited_count"`
|
ActiveInvitedCount int64 `json:"active_invited_count"`
|
||||||
PendingInvitedCount int64 `json:"pending_invited_count"`
|
PendingInvitedCount int64 `json:"pending_invited_count"`
|
||||||
TotalRebateCent int64 `json:"total_rebate_cent"`
|
TotalRebateCent int64 `json:"total_rebate_cent"`
|
||||||
CurrentMonthEstCent int64 `json:"current_month_est_cent"`
|
CurrentMonthEstCent int64 `json:"current_month_est_cent"`
|
||||||
FirstMonthRate float64 `json:"first_month_rate"`
|
FirstMonthRate float64 `json:"first_month_rate"`
|
||||||
SecondMonthRate float64 `json:"second_month_rate"`
|
SecondMonthRate float64 `json:"second_month_rate"`
|
||||||
SubsequentRate float64 `json:"subsequent_rate"`
|
SubsequentRate float64 `json:"subsequent_rate"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvitedUserRecordDTO 邀请好友记录
|
// InvitedUserRecordDTO 邀请好友记录
|
||||||
type InvitedUserRecordDTO struct {
|
type InvitedUserRecordDTO struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
InviteeID uint64 `json:"invitee_id"`
|
InviteeID uint64 `json:"invitee_id"`
|
||||||
MaskedPhone string `json:"masked_phone"`
|
MaskedPhone string `json:"masked_phone"`
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
AvatarURL string `json:"avatar_url"`
|
AvatarURL string `json:"avatar_url"`
|
||||||
Status string `json:"status"` // pending=待实名, active=已实名生效
|
Status string `json:"status"` // pending=待实名, active=已实名生效
|
||||||
CycleStage string `json:"cycle_stage"` // first_month, second_month, subsequent, unverified
|
CycleStage string `json:"cycle_stage"` // first_month, second_month, subsequent, unverified
|
||||||
DaysSinceVerified int `json:"days_since_verified"`
|
DaysSinceVerified int `json:"days_since_verified"`
|
||||||
VerifiedAt *time.Time `json:"verified_at,omitempty"`
|
VerifiedAt *time.Time `json:"verified_at,omitempty"`
|
||||||
RegisteredAt time.Time `json:"registered_at"`
|
RegisteredAt time.Time `json:"registered_at"`
|
||||||
TotalEarnedFromCent int64 `json:"total_earned_from_cent"`
|
TotalEarnedFromCent int64 `json:"total_earned_from_cent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RebateLedgerDTO 返利明细记录
|
// RebateLedgerDTO 返利明细记录
|
||||||
@@ -76,20 +76,27 @@ type UpdateConfigRequest struct {
|
|||||||
|
|
||||||
// AdminInvitationQuery 后台查询邀请关系
|
// AdminInvitationQuery 后台查询邀请关系
|
||||||
type AdminInvitationQuery struct {
|
type AdminInvitationQuery struct {
|
||||||
Page int `form:"page,default=1"`
|
Page int `form:"page,default=1"`
|
||||||
PageSize int `form:"page_size,default=20"`
|
PageSize int `form:"page_size,default=20"`
|
||||||
InviterPhone string `form:"inviter_phone"`
|
InviterPhone string `form:"inviter_phone"`
|
||||||
InviteePhone string `form:"invitee_phone"`
|
InviteePhone string `form:"invitee_phone"`
|
||||||
Status string `form:"status"`
|
Status string `form:"status"`
|
||||||
DistributionSiteID *uint64 `form:"distribution_site_id"`
|
DistributionSiteID *uint64 `form:"distribution_site_id"`
|
||||||
|
// InviterID / InviteeID 供用户管理「明细」按用户精确筛选。
|
||||||
|
InviterID uint64 `form:"inviter_id"`
|
||||||
|
InviteeID uint64 `form:"invitee_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminRebateLedgerQuery 后台查询返利明细
|
// AdminRebateLedgerQuery 后台查询返利明细
|
||||||
type AdminRebateLedgerQuery struct {
|
type AdminRebateLedgerQuery struct {
|
||||||
Page int `form:"page,default=1"`
|
Page int `form:"page,default=1"`
|
||||||
PageSize int `form:"page_size,default=20"`
|
PageSize int `form:"page_size,default=20"`
|
||||||
OrderNo string `form:"order_no"`
|
OrderNo string `form:"order_no"`
|
||||||
InviterPhone string `form:"inviter_phone"`
|
InviterPhone string `form:"inviter_phone"`
|
||||||
TradeRole string `form:"trade_role"`
|
InviteePhone string `form:"invitee_phone"`
|
||||||
|
TradeRole string `form:"trade_role"`
|
||||||
DistributionSiteID *uint64 `form:"distribution_site_id"`
|
DistributionSiteID *uint64 `form:"distribution_site_id"`
|
||||||
|
// InviterID / InviteeID 供用户管理「明细」按用户精确筛选。
|
||||||
|
InviterID uint64 `form:"inviter_id"`
|
||||||
|
InviteeID uint64 `form:"invitee_id"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -572,18 +572,18 @@ func (r *Repository) GetUserSummary(ctx context.Context, userID uint64, siteID u
|
|||||||
Select("COALESCE(SUM(rebate_amount_cent), 0)").Scan(¤tMonthEstCent).Error
|
Select("COALESCE(SUM(rebate_amount_cent), 0)").Scan(¤tMonthEstCent).Error
|
||||||
|
|
||||||
return &UserInvitationSummaryDTO{
|
return &UserInvitationSummaryDTO{
|
||||||
HasInviteCode: hasCode,
|
HasInviteCode: hasCode,
|
||||||
InviteCode: code,
|
InviteCode: code,
|
||||||
InviteLink: fmt.Sprintf("/i/%s", code),
|
InviteLink: fmt.Sprintf("/i/%s", code),
|
||||||
IsRealnameVerified: user.RealnameStatus == "verified",
|
IsRealnameVerified: user.RealnameStatus == "verified",
|
||||||
TotalInvitedCount: totalCount,
|
TotalInvitedCount: totalCount,
|
||||||
ActiveInvitedCount: activeCount,
|
ActiveInvitedCount: activeCount,
|
||||||
PendingInvitedCount: pendingCount,
|
PendingInvitedCount: pendingCount,
|
||||||
TotalRebateCent: totalRebateCent,
|
TotalRebateCent: totalRebateCent,
|
||||||
CurrentMonthEstCent: currentMonthEstCent,
|
CurrentMonthEstCent: currentMonthEstCent,
|
||||||
FirstMonthRate: firstRate,
|
FirstMonthRate: firstRate,
|
||||||
SecondMonthRate: secondRate,
|
SecondMonthRate: secondRate,
|
||||||
SubsequentRate: subsequentRate,
|
SubsequentRate: subsequentRate,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -734,6 +734,12 @@ func (r *Repository) ListAdminInvitations(ctx context.Context, query AdminInvita
|
|||||||
if query.DistributionSiteID != nil {
|
if query.DistributionSiteID != nil {
|
||||||
db = db.Where("ui.distribution_site_id = ?", *query.DistributionSiteID)
|
db = db.Where("ui.distribution_site_id = ?", *query.DistributionSiteID)
|
||||||
}
|
}
|
||||||
|
if query.InviterID > 0 {
|
||||||
|
db = db.Where("ui.inviter_id = ?", query.InviterID)
|
||||||
|
}
|
||||||
|
if query.InviteeID > 0 {
|
||||||
|
db = db.Where("ui.invitee_id = ?", query.InviteeID)
|
||||||
|
}
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
if err := db.Count(&total).Error; err != nil {
|
if err := db.Count(&total).Error; err != nil {
|
||||||
@@ -804,12 +810,21 @@ func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminReba
|
|||||||
if p := strings.TrimSpace(query.InviterPhone); p != "" {
|
if p := strings.TrimSpace(query.InviterPhone); p != "" {
|
||||||
db = db.Where("inviter.phone = ?", p)
|
db = db.Where("inviter.phone = ?", p)
|
||||||
}
|
}
|
||||||
|
if p := strings.TrimSpace(query.InviteePhone); p != "" {
|
||||||
|
db = db.Where("invitee.phone = ?", p)
|
||||||
|
}
|
||||||
if t := strings.TrimSpace(query.TradeRole); t != "" {
|
if t := strings.TrimSpace(query.TradeRole); t != "" {
|
||||||
db = db.Where("irl.trade_role = ?", t)
|
db = db.Where("irl.trade_role = ?", t)
|
||||||
}
|
}
|
||||||
if query.DistributionSiteID != nil {
|
if query.DistributionSiteID != nil {
|
||||||
db = db.Where("irl.distribution_site_id = ?", *query.DistributionSiteID)
|
db = db.Where("irl.distribution_site_id = ?", *query.DistributionSiteID)
|
||||||
}
|
}
|
||||||
|
if query.InviterID > 0 {
|
||||||
|
db = db.Where("irl.inviter_id = ?", query.InviterID)
|
||||||
|
}
|
||||||
|
if query.InviteeID > 0 {
|
||||||
|
db = db.Where("irl.invitee_id = ?", query.InviteeID)
|
||||||
|
}
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
if err := db.Count(&total).Error; err != nil {
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
|||||||
@@ -217,6 +217,8 @@ type AdminOrderQuery struct {
|
|||||||
HandoffMode string
|
HandoffMode string
|
||||||
SettlementStatus string
|
SettlementStatus string
|
||||||
OfflineSettlementStatus string
|
OfflineSettlementStatus string
|
||||||
|
// UserID 精确匹配该用户作为号主或租客参与的订单(用户管理「明细」入口使用)。
|
||||||
|
UserID uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuditMeta = auditlog.Meta
|
type AuditMeta = auditlog.Meta
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ func parsePagination(c *gin.Context) (int, int) {
|
|||||||
|
|
||||||
func parseAdminOrderQuery(c *gin.Context) AdminOrderQuery {
|
func parseAdminOrderQuery(c *gin.Context) AdminOrderQuery {
|
||||||
page, pageSize := parsePagination(c)
|
page, pageSize := parsePagination(c)
|
||||||
|
userID, _ := strconv.ParseUint(strings.TrimSpace(c.Query("user_id")), 10, 64)
|
||||||
return AdminOrderQuery{
|
return AdminOrderQuery{
|
||||||
Page: page,
|
Page: page,
|
||||||
PageSize: pageSize,
|
PageSize: pageSize,
|
||||||
@@ -63,5 +64,6 @@ func parseAdminOrderQuery(c *gin.Context) AdminOrderQuery {
|
|||||||
HandoffMode: strings.TrimSpace(c.Query("handoff_mode")),
|
HandoffMode: strings.TrimSpace(c.Query("handoff_mode")),
|
||||||
SettlementStatus: strings.TrimSpace(c.Query("settlement_status")),
|
SettlementStatus: strings.TrimSpace(c.Query("settlement_status")),
|
||||||
OfflineSettlementStatus: strings.TrimSpace(c.Query("offline_settlement_status")),
|
OfflineSettlementStatus: strings.TrimSpace(c.Query("offline_settlement_status")),
|
||||||
|
UserID: userID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -310,6 +310,10 @@ func adminListItemNeedsOwnerIncome(row orderRow) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applyAdminOrderFilters(db *gorm.DB, query AdminOrderQuery) *gorm.DB {
|
func applyAdminOrderFilters(db *gorm.DB, query AdminOrderQuery) *gorm.DB {
|
||||||
|
if query.UserID > 0 {
|
||||||
|
// 精确命中该用户作为号主或租客参与的订单,避免与 keyword 的数字 ID 模糊命中混淆。
|
||||||
|
db = db.Where("(o.owner_id = ? OR o.renter_id = ?)", query.UserID, query.UserID)
|
||||||
|
}
|
||||||
if query.Status != "" {
|
if query.Status != "" {
|
||||||
db = db.Where("o.status = ?", query.Status)
|
db = db.Where("o.status = ?", query.Status)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { apiClient } from '@/shared/api/client'
|
||||||
|
|
||||||
|
import type { ApiResponse, PaginatedResult } from '@/shared/types/types'
|
||||||
|
|
||||||
|
/** 后台邀请关系行(/admin/invitations) */
|
||||||
|
export interface AdminInvitationRow {
|
||||||
|
id: number
|
||||||
|
inviter_id: number
|
||||||
|
inviter_phone: string
|
||||||
|
inviter_nickname: string
|
||||||
|
invitee_id: number
|
||||||
|
invitee_phone: string
|
||||||
|
invitee_nickname: string
|
||||||
|
distribution_site_id: number
|
||||||
|
invite_code: string
|
||||||
|
status: string
|
||||||
|
verified_at?: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后台返利结算流水行(/admin/invitations/rebates) */
|
||||||
|
export interface AdminRebateRow {
|
||||||
|
id: number
|
||||||
|
order_id: number
|
||||||
|
order_no: string
|
||||||
|
distribution_site_id: number
|
||||||
|
inviter_id: number
|
||||||
|
inviter_phone: string
|
||||||
|
invitee_id: number
|
||||||
|
invitee_phone: string
|
||||||
|
trade_role: string
|
||||||
|
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 interface AdminInvitationQuery {
|
||||||
|
inviter_id?: number
|
||||||
|
invitee_id?: number
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminInvitations(query: AdminInvitationQuery = {}) {
|
||||||
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminInvitationRow>>>(
|
||||||
|
'/admin/invitations',
|
||||||
|
{ params: query }
|
||||||
|
)
|
||||||
|
const result = data.data
|
||||||
|
return {
|
||||||
|
items: Array.isArray(result?.items) ? result.items : [],
|
||||||
|
total: Number(result?.total ?? 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminRebates(query: AdminInvitationQuery = {}) {
|
||||||
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminRebateRow>>>(
|
||||||
|
'/admin/invitations/rebates',
|
||||||
|
{ params: query }
|
||||||
|
)
|
||||||
|
const result = data.data
|
||||||
|
return {
|
||||||
|
items: Array.isArray(result?.items) ? result.items : [],
|
||||||
|
total: Number(result?.total ?? 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
.user-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-detail__head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-detail__identity {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-detail__identity strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-detail__identity span {
|
||||||
|
color: #475569;
|
||||||
|
font-size: 14px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-detail__identity small {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-detail__metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(96px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-detail__metrics > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-detail__metrics span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-detail__metrics strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 15px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-in {
|
||||||
|
color: #16a34a;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-out {
|
||||||
|
color: #dc2626;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-tag {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-sub {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-toolbar :deep(.el-radio-group) {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,810 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchAdminInvitations,
|
||||||
|
fetchAdminRebates,
|
||||||
|
type AdminInvitationRow,
|
||||||
|
type AdminRebateRow,
|
||||||
|
} from '@/features/admin/api/adminInvitations'
|
||||||
|
import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments'
|
||||||
|
import { fetchAdminWalletLedger, type AdminWalletLedger } from '@/features/admin/api/adminWallet'
|
||||||
|
import { fetchAdminWithdrawals, type WithdrawalDetail } from '@/features/admin/api/adminWithdrawal'
|
||||||
|
import type { AdminUserItem } from '@/features/admin/api/adminUsers'
|
||||||
|
import { fetchAdminOrders, type AdminOrderListItem } from '@/features/orders'
|
||||||
|
import { adminPath } from '@/shared/utils/adminPath'
|
||||||
|
import { readError } from '@/shared/utils/error'
|
||||||
|
import { formatCentWithSymbol } from '@/shared/utils/money'
|
||||||
|
import {
|
||||||
|
balanceTypeLabel,
|
||||||
|
ledgerDirectionLabel,
|
||||||
|
orderStatusLabel,
|
||||||
|
walletBizTypeLabel,
|
||||||
|
} from '@/shared/utils/statusLabels'
|
||||||
|
import { formatDateTime } from '@/shared/utils/time'
|
||||||
|
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||||
|
import AdminTablePagination from './AdminTablePagination.vue'
|
||||||
|
|
||||||
|
type TabKey = 'wallet' | 'orders' | 'withdrawals' | 'invite' | 'payments'
|
||||||
|
type InviteMode = 'rebates' | 'invited' | 'invitedBy'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
user?: AdminUserItem | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', val: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const adminSession = useAdminSessionStore()
|
||||||
|
|
||||||
|
const canViewWallet = computed(() => adminSession.hasPermission('wallet:view'))
|
||||||
|
const canViewOrders = computed(() => adminSession.hasPermission('order:list'))
|
||||||
|
const canViewWithdrawals = computed(() => adminSession.hasPermission('withdrawal:list'))
|
||||||
|
const canViewInviteRelations = computed(() => adminSession.hasPermission('distribution:site_view'))
|
||||||
|
const canViewRebates = computed(() => adminSession.hasPermission('distribution:settlement_view'))
|
||||||
|
const canViewInvite = computed(() => canViewInviteRelations.value || canViewRebates.value)
|
||||||
|
const canViewPayments = computed(() => adminSession.hasPermission('wallet:view'))
|
||||||
|
|
||||||
|
const tabs = computed<{ key: TabKey; label: string }[]>(() => {
|
||||||
|
const list: { key: TabKey; label: string }[] = []
|
||||||
|
if (canViewWallet.value) list.push({ key: 'wallet', label: '钱包流水' })
|
||||||
|
if (canViewOrders.value) list.push({ key: 'orders', label: '订单记录' })
|
||||||
|
if (canViewWithdrawals.value) list.push({ key: 'withdrawals', label: '提现记录' })
|
||||||
|
if (canViewInvite.value) list.push({ key: 'invite', label: '邀请返利' })
|
||||||
|
if (canViewPayments.value) list.push({ key: 'payments', label: '支付记录' })
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
// 邀请返利页内视图,按接口权限收敛(关系查 distribution:site_view,流水查 distribution:settlement_view)。
|
||||||
|
const inviteModes = computed<{ key: InviteMode; label: string }[]>(() => {
|
||||||
|
const list: { key: InviteMode; label: string }[] = []
|
||||||
|
if (canViewRebates.value) list.push({ key: 'rebates', label: '我获得的返利' })
|
||||||
|
if (canViewInviteRelations.value) {
|
||||||
|
list.push({ key: 'invited', label: '我邀请的好友' })
|
||||||
|
list.push({ key: 'invitedBy', label: '我作为被邀请人' })
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeTab = ref<TabKey>('wallet')
|
||||||
|
const inviteMode = ref<InviteMode>('rebates')
|
||||||
|
|
||||||
|
const walletLoading = ref(false)
|
||||||
|
const walletRows = ref<AdminWalletLedger[]>([])
|
||||||
|
const walletTotal = ref(0)
|
||||||
|
const walletPage = ref(1)
|
||||||
|
const walletPageSize = ref(20)
|
||||||
|
|
||||||
|
const orderLoading = ref(false)
|
||||||
|
const orderRows = ref<AdminOrderListItem[]>([])
|
||||||
|
const orderTotal = ref(0)
|
||||||
|
const orderPage = ref(1)
|
||||||
|
const orderPageSize = ref(20)
|
||||||
|
|
||||||
|
const withdrawalLoading = ref(false)
|
||||||
|
const withdrawalRows = ref<WithdrawalDetail[]>([])
|
||||||
|
const withdrawalTotal = ref(0)
|
||||||
|
const withdrawalPage = ref(1)
|
||||||
|
const withdrawalPageSize = ref(20)
|
||||||
|
|
||||||
|
const rebateLoading = ref(false)
|
||||||
|
const rebateRows = ref<AdminRebateRow[]>([])
|
||||||
|
const rebateTotal = ref(0)
|
||||||
|
const rebatePage = ref(1)
|
||||||
|
const rebatePageSize = ref(20)
|
||||||
|
|
||||||
|
const inviteLoading = ref(false)
|
||||||
|
const inviteRows = ref<AdminInvitationRow[]>([])
|
||||||
|
const inviteTotal = ref(0)
|
||||||
|
const invitePage = ref(1)
|
||||||
|
const invitePageSize = ref(20)
|
||||||
|
|
||||||
|
const paymentLoading = ref(false)
|
||||||
|
const paymentRows = ref<AdminPayment[]>([])
|
||||||
|
const paymentTotal = ref(0)
|
||||||
|
const paymentPage = ref(1)
|
||||||
|
const paymentPageSize = ref(20)
|
||||||
|
|
||||||
|
const userId = computed(() => props.user?.id || 0)
|
||||||
|
const userPhone = computed(() => props.user?.phone || '')
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
visible => {
|
||||||
|
if (!visible) return
|
||||||
|
resetState()
|
||||||
|
const first = tabs.value[0]?.key || 'wallet'
|
||||||
|
// 切换标签本身会触发 watch(activeTab) 加载;同标签则显式加载,避免重复请求。
|
||||||
|
if (activeTab.value === first) {
|
||||||
|
void loadActive()
|
||||||
|
} else {
|
||||||
|
activeTab.value = first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(activeTab, () => {
|
||||||
|
if (props.modelValue) void loadActive()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(inviteMode, () => {
|
||||||
|
if (!props.modelValue || activeTab.value !== 'invite') return
|
||||||
|
// 切换数据视图时回到第一页,避免沿用上一个视图的页码导致空列表。
|
||||||
|
rebatePage.value = 1
|
||||||
|
invitePage.value = 1
|
||||||
|
void loadInvite()
|
||||||
|
})
|
||||||
|
|
||||||
|
function resetState() {
|
||||||
|
walletPage.value = 1
|
||||||
|
orderPage.value = 1
|
||||||
|
withdrawalPage.value = 1
|
||||||
|
rebatePage.value = 1
|
||||||
|
invitePage.value = 1
|
||||||
|
paymentPage.value = 1
|
||||||
|
walletRows.value = []
|
||||||
|
orderRows.value = []
|
||||||
|
withdrawalRows.value = []
|
||||||
|
rebateRows.value = []
|
||||||
|
inviteRows.value = []
|
||||||
|
paymentRows.value = []
|
||||||
|
walletTotal.value = 0
|
||||||
|
orderTotal.value = 0
|
||||||
|
withdrawalTotal.value = 0
|
||||||
|
rebateTotal.value = 0
|
||||||
|
inviteTotal.value = 0
|
||||||
|
paymentTotal.value = 0
|
||||||
|
// 权限变化时回落到当前可用的第一个视图,否则保留上次选择。
|
||||||
|
if (!inviteModes.value.some(mode => mode.key === inviteMode.value)) {
|
||||||
|
inviteMode.value = inviteModes.value[0]?.key || 'rebates'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadActive() {
|
||||||
|
if (!props.modelValue || userId.value <= 0) return
|
||||||
|
if (activeTab.value === 'wallet') return loadWallet()
|
||||||
|
if (activeTab.value === 'orders') return loadOrders()
|
||||||
|
if (activeTab.value === 'withdrawals') return loadWithdrawals()
|
||||||
|
if (activeTab.value === 'invite') return loadInvite()
|
||||||
|
return loadPayments()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWallet() {
|
||||||
|
walletLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchAdminWalletLedger({
|
||||||
|
user_id: String(userId.value),
|
||||||
|
page: walletPage.value,
|
||||||
|
page_size: walletPageSize.value,
|
||||||
|
})
|
||||||
|
walletRows.value = Array.isArray(result.items) ? result.items : []
|
||||||
|
walletTotal.value = Number(result.total ?? 0)
|
||||||
|
} catch (error) {
|
||||||
|
walletRows.value = []
|
||||||
|
walletTotal.value = 0
|
||||||
|
ElMessage.error(readError(error, '获取钱包流水失败'))
|
||||||
|
} finally {
|
||||||
|
walletLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOrders() {
|
||||||
|
orderLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchAdminOrders(orderPage.value, orderPageSize.value, {
|
||||||
|
user_id: userId.value,
|
||||||
|
})
|
||||||
|
orderRows.value = Array.isArray(result.items) ? result.items : []
|
||||||
|
orderTotal.value = Number(result.total ?? 0)
|
||||||
|
} catch (error) {
|
||||||
|
orderRows.value = []
|
||||||
|
orderTotal.value = 0
|
||||||
|
ElMessage.error(readError(error, '获取订单记录失败'))
|
||||||
|
} finally {
|
||||||
|
orderLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWithdrawals() {
|
||||||
|
withdrawalLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchAdminWithdrawals({
|
||||||
|
user_id: userId.value,
|
||||||
|
page: withdrawalPage.value,
|
||||||
|
page_size: withdrawalPageSize.value,
|
||||||
|
})
|
||||||
|
withdrawalRows.value = Array.isArray(result.items) ? result.items : []
|
||||||
|
withdrawalTotal.value = Number(result.total ?? 0)
|
||||||
|
} catch (error) {
|
||||||
|
withdrawalRows.value = []
|
||||||
|
withdrawalTotal.value = 0
|
||||||
|
ElMessage.error(readError(error, '获取提现记录失败'))
|
||||||
|
} finally {
|
||||||
|
withdrawalLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInvite() {
|
||||||
|
if (inviteMode.value === 'rebates') return loadRebates()
|
||||||
|
return loadInvitations()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRebates() {
|
||||||
|
rebateLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchAdminRebates({
|
||||||
|
inviter_id: userId.value,
|
||||||
|
page: rebatePage.value,
|
||||||
|
page_size: rebatePageSize.value,
|
||||||
|
})
|
||||||
|
rebateRows.value = result.items
|
||||||
|
rebateTotal.value = result.total
|
||||||
|
} catch (error) {
|
||||||
|
rebateRows.value = []
|
||||||
|
rebateTotal.value = 0
|
||||||
|
ElMessage.error(readError(error, '获取返利流水失败'))
|
||||||
|
} finally {
|
||||||
|
rebateLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInvitations() {
|
||||||
|
inviteLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchAdminInvitations({
|
||||||
|
...(inviteMode.value === 'invitedBy'
|
||||||
|
? { invitee_id: userId.value }
|
||||||
|
: { inviter_id: userId.value }),
|
||||||
|
page: invitePage.value,
|
||||||
|
page_size: invitePageSize.value,
|
||||||
|
})
|
||||||
|
inviteRows.value = result.items
|
||||||
|
inviteTotal.value = result.total
|
||||||
|
} catch (error) {
|
||||||
|
inviteRows.value = []
|
||||||
|
inviteTotal.value = 0
|
||||||
|
ElMessage.error(readError(error, '获取邀请关系失败'))
|
||||||
|
} finally {
|
||||||
|
inviteLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPayments() {
|
||||||
|
paymentLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchAdminPayments({
|
||||||
|
user_id: String(userId.value),
|
||||||
|
page: paymentPage.value,
|
||||||
|
page_size: paymentPageSize.value,
|
||||||
|
})
|
||||||
|
paymentRows.value = Array.isArray(result.items) ? result.items : []
|
||||||
|
paymentTotal.value = Number(result.total ?? 0)
|
||||||
|
} catch (error) {
|
||||||
|
paymentRows.value = []
|
||||||
|
paymentTotal.value = 0
|
||||||
|
ElMessage.error(readError(error, '获取支付记录失败'))
|
||||||
|
} finally {
|
||||||
|
paymentLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 依据手机号判定该用户在本单中的角色(列表接口只回传手机号)。 */
|
||||||
|
function orderRoles(row: AdminOrderListItem): string[] {
|
||||||
|
const phone = userPhone.value
|
||||||
|
if (!phone) return []
|
||||||
|
const roles: string[] = []
|
||||||
|
if (row.renter_phone === phone) roles.push('租客')
|
||||||
|
if (row.owner_phone === phone) roles.push('号主')
|
||||||
|
return roles
|
||||||
|
}
|
||||||
|
|
||||||
|
function walletDirectionType(direction: string): 'success' | 'danger' | 'warning' {
|
||||||
|
if (direction === 'in') return 'success'
|
||||||
|
if (direction === 'out') return 'danger'
|
||||||
|
return 'warning'
|
||||||
|
}
|
||||||
|
|
||||||
|
function withdrawalStatusLabel(status: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
pending: '待审核',
|
||||||
|
processing: '待打款',
|
||||||
|
completed: '已打款',
|
||||||
|
rejected: '已拒绝',
|
||||||
|
cancelled: '已取消',
|
||||||
|
}
|
||||||
|
return labels[status] || status || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function withdrawalStatusType(
|
||||||
|
status: string
|
||||||
|
): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
|
||||||
|
const types: Record<string, 'success' | 'warning' | 'danger' | 'info' | 'primary'> = {
|
||||||
|
pending: 'warning',
|
||||||
|
processing: 'primary',
|
||||||
|
completed: 'success',
|
||||||
|
rejected: 'danger',
|
||||||
|
cancelled: 'info',
|
||||||
|
}
|
||||||
|
return types[status] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
function invitationStatusLabel(status: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
active: '已实名(生效中)',
|
||||||
|
pending: '待实名(未生效)',
|
||||||
|
invalid: '已失效',
|
||||||
|
}
|
||||||
|
return labels[status] || status || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function invitationStatusType(status: string): 'success' | 'warning' | 'info' {
|
||||||
|
if (status === 'active') return 'success'
|
||||||
|
if (status === 'pending') return 'warning'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
function tradeRoleLabel(role: string) {
|
||||||
|
if (role === 'buyer') return '买家端'
|
||||||
|
if (role === 'seller') return '卖家端'
|
||||||
|
return role || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function cycleStageLabel(stage: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
first_month: '首月',
|
||||||
|
second_month: '次月',
|
||||||
|
subsequent: '长期',
|
||||||
|
}
|
||||||
|
return labels[stage] || stage || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function paymentStatusLabel(status: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
created: '已创建',
|
||||||
|
paying: '支付中',
|
||||||
|
paid: '已支付',
|
||||||
|
refunding: '退款中',
|
||||||
|
refunded: '已退款',
|
||||||
|
failed: '失败',
|
||||||
|
closed: '已关闭',
|
||||||
|
}
|
||||||
|
return labels[status] || status || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function paymentStatusType(status: string): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
|
||||||
|
if (['paid', 'refunded'].includes(status)) return 'success'
|
||||||
|
if (status === 'failed') return 'danger'
|
||||||
|
if (['paying', 'refunding'].includes(status)) return 'warning'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
function paymentBizTypeLabel(type: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
order_pay: '订单支付',
|
||||||
|
cancel_refund: '取消退款',
|
||||||
|
admin_close_refund: '客服关闭退款',
|
||||||
|
admin_refund: '人工退款',
|
||||||
|
checkout_refund: '结账退款',
|
||||||
|
deposit_refund: '押金退款',
|
||||||
|
rent_refund: '租金退款',
|
||||||
|
arbitration_refund: '仲裁退款',
|
||||||
|
mohong_pay: '撞车支付',
|
||||||
|
mohong_refund: '撞车退款',
|
||||||
|
}
|
||||||
|
return labels[type] || type || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function goOrderDetail(row: AdminOrderListItem) {
|
||||||
|
close()
|
||||||
|
void router.push(adminPath(`orders/${row.id}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
function goWithdrawals() {
|
||||||
|
close()
|
||||||
|
void router.push({
|
||||||
|
path: adminPath('withdrawals'),
|
||||||
|
query: { withdrawal_user_id: String(userId.value) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function goWalletLedger() {
|
||||||
|
close()
|
||||||
|
void router.push({ path: adminPath('wallet-ledger'), query: { user_id: String(userId.value) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPayments() {
|
||||||
|
close()
|
||||||
|
void router.push({ path: adminPath('payments'), query: { user_id: String(userId.value) } })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-drawer
|
||||||
|
:model-value="modelValue"
|
||||||
|
size="78%"
|
||||||
|
destroy-on-close
|
||||||
|
:title="`用户明细 · ${user?.nickname || user?.phone || userId}`"
|
||||||
|
@update:model-value="(val: boolean) => emit('update:modelValue', val)"
|
||||||
|
>
|
||||||
|
<div v-if="user" class="user-detail">
|
||||||
|
<header class="user-detail__head">
|
||||||
|
<div class="user-detail__identity">
|
||||||
|
<strong>{{ user.nickname || '-' }}</strong>
|
||||||
|
<span>{{ user.phone }}</span>
|
||||||
|
<small>UID: {{ user.id }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="user-detail__metrics">
|
||||||
|
<div>
|
||||||
|
<span>可用余额</span>
|
||||||
|
<strong>{{ formatCentWithSymbol(user.available_balance_cent) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>冻结余额</span>
|
||||||
|
<strong>{{ formatCentWithSymbol(user.frozen_balance_cent) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>成长等级</span>
|
||||||
|
<strong>{{ user.renter_growth_level_name || '普通' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>注册时间</span>
|
||||||
|
<strong>{{ formatDateTime(user.created_at) }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<el-empty v-if="!tabs.length" description="当前账号没有查看资金与订单的权限" />
|
||||||
|
|
||||||
|
<el-tabs v-else v-model="activeTab">
|
||||||
|
<el-tab-pane v-if="canViewWallet" label="钱包流水" name="wallet">
|
||||||
|
<div class="tab-toolbar">
|
||||||
|
<span>共 {{ walletTotal }} 条流水</span>
|
||||||
|
<el-button text type="primary" size="small" @click="goWalletLedger">
|
||||||
|
前往资金流水页
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table
|
||||||
|
v-loading="walletLoading"
|
||||||
|
size="small"
|
||||||
|
border
|
||||||
|
:data="walletRows"
|
||||||
|
empty-text="暂无钱包流水"
|
||||||
|
>
|
||||||
|
<el-table-column label="时间" width="160">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="方向" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="walletDirectionType(row.direction)">
|
||||||
|
{{ ledgerDirectionLabel(row.direction) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="金额" width="120" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :class="row.direction === 'in' ? 'amount-in' : 'amount-out'">
|
||||||
|
{{ row.direction === 'in' ? '+' : '-'
|
||||||
|
}}{{ formatCentWithSymbol(row.amount_cent) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="业务类型" width="130">
|
||||||
|
<template #default="{ row }">{{ walletBizTypeLabel(row.biz_type) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="余额口径" width="100">
|
||||||
|
<template #default="{ row }">{{ balanceTypeLabel(row.balance_type) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="余额快照" width="120" align="right">
|
||||||
|
<template #default="{ row }">{{
|
||||||
|
formatCentWithSymbol(row.balance_after_cent)
|
||||||
|
}}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="order_no" label="关联订单" width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="remark" label="备注" min-width="200" show-overflow-tooltip />
|
||||||
|
</el-table>
|
||||||
|
<AdminTablePagination
|
||||||
|
v-if="walletTotal > 0"
|
||||||
|
v-model:current-page="walletPage"
|
||||||
|
v-model:page-size="walletPageSize"
|
||||||
|
:total="walletTotal"
|
||||||
|
:loading="walletLoading"
|
||||||
|
@page-change="loadWallet"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane v-if="canViewOrders" label="订单记录" name="orders">
|
||||||
|
<div class="tab-toolbar">
|
||||||
|
<span>共 {{ orderTotal }} 笔订单(作为租客或号主)</span>
|
||||||
|
</div>
|
||||||
|
<el-table
|
||||||
|
v-loading="orderLoading"
|
||||||
|
size="small"
|
||||||
|
border
|
||||||
|
:data="orderRows"
|
||||||
|
empty-text="暂无订单记录"
|
||||||
|
>
|
||||||
|
<el-table-column prop="order_no" label="订单号" width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column label="角色" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag
|
||||||
|
v-for="role in orderRoles(row)"
|
||||||
|
:key="role"
|
||||||
|
size="small"
|
||||||
|
effect="plain"
|
||||||
|
class="role-tag"
|
||||||
|
>
|
||||||
|
{{ role }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="title" label="商品" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column label="状态" width="130">
|
||||||
|
<template #default="{ row }">{{ orderStatusLabel(row.status) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="租金" width="110" align="right">
|
||||||
|
<template #default="{ row }">{{
|
||||||
|
formatCentWithSymbol(row.rent_amount_cent)
|
||||||
|
}}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="创建时间" width="160">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="90" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button text type="primary" size="small" @click="goOrderDetail(row)">
|
||||||
|
详情
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<AdminTablePagination
|
||||||
|
v-if="orderTotal > 0"
|
||||||
|
v-model:current-page="orderPage"
|
||||||
|
v-model:page-size="orderPageSize"
|
||||||
|
:total="orderTotal"
|
||||||
|
:loading="orderLoading"
|
||||||
|
@page-change="loadOrders"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane v-if="canViewWithdrawals" label="提现记录" name="withdrawals">
|
||||||
|
<div class="tab-toolbar">
|
||||||
|
<span>共 {{ withdrawalTotal }} 条提现申请</span>
|
||||||
|
<el-button text type="primary" size="small" @click="goWithdrawals">
|
||||||
|
前往提现管理
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table
|
||||||
|
v-loading="withdrawalLoading"
|
||||||
|
size="small"
|
||||||
|
border
|
||||||
|
:data="withdrawalRows"
|
||||||
|
empty-text="暂无提现记录"
|
||||||
|
>
|
||||||
|
<el-table-column
|
||||||
|
prop="withdraw_no"
|
||||||
|
label="提现单号"
|
||||||
|
width="190"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<el-table-column label="申请金额" width="120" align="right">
|
||||||
|
<template #default="{ row }">{{ formatCentWithSymbol(row.amount_cent) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="实际到账" width="120" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatCentWithSymbol(row.actual_amount_cent ?? row.amount_cent) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="收款账户" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.account_name || '-' }} · {{ row.account_no || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="withdrawalStatusType(row.status)">
|
||||||
|
{{ withdrawalStatusLabel(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="申请时间" width="160">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<AdminTablePagination
|
||||||
|
v-if="withdrawalTotal > 0"
|
||||||
|
v-model:current-page="withdrawalPage"
|
||||||
|
v-model:page-size="withdrawalPageSize"
|
||||||
|
:total="withdrawalTotal"
|
||||||
|
:loading="withdrawalLoading"
|
||||||
|
@page-change="loadWithdrawals"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane v-if="canViewInvite" label="邀请返利" name="invite">
|
||||||
|
<div class="tab-toolbar">
|
||||||
|
<el-radio-group v-model="inviteMode" size="small">
|
||||||
|
<el-radio-button v-for="mode in inviteModes" :key="mode.key" :value="mode.key">
|
||||||
|
{{ mode.label }}
|
||||||
|
</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
<span v-if="inviteMode === 'rebates'">共 {{ rebateTotal }} 笔返利</span>
|
||||||
|
<span v-else>共 {{ inviteTotal }} 条邀请关系</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="inviteMode === 'rebates'">
|
||||||
|
<el-table
|
||||||
|
v-loading="rebateLoading"
|
||||||
|
size="small"
|
||||||
|
border
|
||||||
|
:data="rebateRows"
|
||||||
|
empty-text="暂无返利流水"
|
||||||
|
>
|
||||||
|
<el-table-column label="入账时间" width="160">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.settled_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="order_no" label="关联订单" width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column label="交易人(被邀请人)" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<code class="cell-sub">{{ row.invitee_phone || '-' }}</code>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="交易端" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag
|
||||||
|
size="small"
|
||||||
|
:type="row.trade_role === 'buyer' ? 'primary' : 'danger'"
|
||||||
|
effect="plain"
|
||||||
|
>
|
||||||
|
{{ tradeRoleLabel(row.trade_role) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="收益基数" width="110" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatCentWithSymbol(row.single_side_basis_cent) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="提成比例" width="90" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ Math.round((row.rate_applied || 0) * 100) }}%
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="周期" width="80">
|
||||||
|
<template #default="{ row }">{{ cycleStageLabel(row.cycle_stage) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="返利金额" width="110" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="amount-in">+{{ formatCentWithSymbol(row.rebate_amount_cent) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<AdminTablePagination
|
||||||
|
v-if="rebateTotal > 0"
|
||||||
|
v-model:current-page="rebatePage"
|
||||||
|
v-model:page-size="rebatePageSize"
|
||||||
|
:total="rebateTotal"
|
||||||
|
:loading="rebateLoading"
|
||||||
|
@page-change="loadRebates"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<el-table
|
||||||
|
v-loading="inviteLoading"
|
||||||
|
size="small"
|
||||||
|
border
|
||||||
|
:data="inviteRows"
|
||||||
|
empty-text="暂无邀请关系"
|
||||||
|
>
|
||||||
|
<el-table-column label="邀请人" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div>{{ row.inviter_nickname || '-' }}</div>
|
||||||
|
<code class="cell-sub">{{ row.inviter_phone || '-' }}</code>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="被邀请人" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div>{{ row.invitee_nickname || '-' }}</div>
|
||||||
|
<code class="cell-sub">{{ row.invitee_phone || '-' }}</code>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="使用邀请码" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small">{{ row.invite_code || '-' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="生效状态" width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="invitationStatusType(row.status)">
|
||||||
|
{{ invitationStatusLabel(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="实名生效时间" width="160">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.verified_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="绑定时间" width="160">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<AdminTablePagination
|
||||||
|
v-if="inviteTotal > 0"
|
||||||
|
v-model:current-page="invitePage"
|
||||||
|
v-model:page-size="invitePageSize"
|
||||||
|
:total="inviteTotal"
|
||||||
|
:loading="inviteLoading"
|
||||||
|
@page-change="loadInvitations"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane v-if="canViewPayments" label="支付记录" name="payments">
|
||||||
|
<div class="tab-toolbar">
|
||||||
|
<span>共 {{ paymentTotal }} 笔支付/退款单</span>
|
||||||
|
<el-button text type="primary" size="small" @click="goPayments">
|
||||||
|
前往支付记录页
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table
|
||||||
|
v-loading="paymentLoading"
|
||||||
|
size="small"
|
||||||
|
border
|
||||||
|
:data="paymentRows"
|
||||||
|
empty-text="暂无支付记录"
|
||||||
|
>
|
||||||
|
<el-table-column label="创建时间" width="160">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="payment_no" label="支付单号" width="190" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="order_no" label="关联订单" width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column label="业务类型" width="120">
|
||||||
|
<template #default="{ row }">{{ paymentBizTypeLabel(row.biz_type) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="渠道/方式" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.provider || '-' }} · {{ row.pay_way || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="金额" width="110" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :class="row.biz_type.includes('refund') ? 'amount-out' : 'amount-in'">
|
||||||
|
{{ formatCentWithSymbol(row.amount_cent) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" :type="paymentStatusType(row.status)">
|
||||||
|
{{ paymentStatusLabel(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="支付时间" width="160">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.paid_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<AdminTablePagination
|
||||||
|
v-if="paymentTotal > 0"
|
||||||
|
v-model:current-page="paymentPage"
|
||||||
|
v-model:page-size="paymentPageSize"
|
||||||
|
:total="paymentTotal"
|
||||||
|
:loading="paymentLoading"
|
||||||
|
@page-change="loadPayments"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped src="./AdminUserDetailDrawer.css"></style>
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Wallet,
|
Wallet,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments'
|
import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments'
|
||||||
import { adminPath } from '@/shared/utils/adminPath'
|
import { adminPath } from '@/shared/utils/adminPath'
|
||||||
@@ -45,7 +46,14 @@ const refundAmount = computed(() =>
|
|||||||
.reduce((sum, item) => sum + Number(item.amount_cent || 0), 0)
|
.reduce((sum, item) => sum + Number(item.amount_cent || 0), 0)
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(loadPayments)
|
onMounted(() => {
|
||||||
|
// 支持从用户管理「明细」跳转预填用户,直接查看该用户的支付记录。
|
||||||
|
const routeUserID = useRoute().query.user_id
|
||||||
|
if (typeof routeUserID === 'string' && routeUserID.trim()) {
|
||||||
|
filters.user_id = routeUserID.trim()
|
||||||
|
}
|
||||||
|
void loadPayments()
|
||||||
|
})
|
||||||
|
|
||||||
async function loadPayments() {
|
async function loadPayments() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|||||||
@@ -22,13 +22,21 @@ import { realnameStatusLabel, userStatusLabel } from '@/shared/utils/statusLabel
|
|||||||
import { formatDateTime } from '@/shared/utils/time'
|
import { formatDateTime } from '@/shared/utils/time'
|
||||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||||
import AdminTablePagination from '../components/AdminTablePagination.vue'
|
import AdminTablePagination from '../components/AdminTablePagination.vue'
|
||||||
|
import AdminUserDetailDrawer from '../components/AdminUserDetailDrawer.vue'
|
||||||
|
|
||||||
const adminSession = useAdminSessionStore()
|
const adminSession = useAdminSessionStore()
|
||||||
const canAdjustWallet = computed(() => adminSession.hasPermission('user:wallet_adjust'))
|
const canAdjustWallet = computed(() => adminSession.hasPermission('user:wallet_adjust'))
|
||||||
const canAdjustGrowthPoints = computed(() => adminSession.hasPermission('user:growth_points'))
|
const canAdjustGrowthPoints = computed(() => adminSession.hasPermission('user:growth_points'))
|
||||||
|
const canViewUserDetail = computed(
|
||||||
|
() =>
|
||||||
|
adminSession.hasPermission('wallet:view') ||
|
||||||
|
adminSession.hasPermission('order:list') ||
|
||||||
|
adminSession.hasPermission('withdrawal:list')
|
||||||
|
)
|
||||||
|
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const activeUser = ref<AdminUserItem | null>(null)
|
const activeUser = ref<AdminUserItem | null>(null)
|
||||||
|
const detailUser = ref<AdminUserItem | null>(null)
|
||||||
const quotaUser = ref<AdminUserItem | null>(null)
|
const quotaUser = ref<AdminUserItem | null>(null)
|
||||||
const realnameUser = ref<AdminUserItem | null>(null)
|
const realnameUser = ref<AdminUserItem | null>(null)
|
||||||
const manualRealnameUser = ref<AdminUserItem | null>(null)
|
const manualRealnameUser = ref<AdminUserItem | null>(null)
|
||||||
@@ -82,6 +90,10 @@ function resetFilters() {
|
|||||||
queryUsers()
|
queryUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openUserDetail(row: AdminUserItem) {
|
||||||
|
detailUser.value = row
|
||||||
|
}
|
||||||
|
|
||||||
function openFreeze(row: AdminUserItem) {
|
function openFreeze(row: AdminUserItem) {
|
||||||
activeUser.value = row
|
activeUser.value = row
|
||||||
freezeReason.value = ''
|
freezeReason.value = ''
|
||||||
@@ -412,9 +424,18 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
<el-table-column label="注册时间" min-width="160">
|
<el-table-column label="注册时间" min-width="160">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="360" fixed="right" align="right">
|
<el-table-column label="操作" width="400" fixed="right" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
|
<el-button
|
||||||
|
v-if="canViewUserDetail"
|
||||||
|
text
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="openUserDetail(row)"
|
||||||
|
>
|
||||||
|
明细
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canAdjustWallet"
|
v-if="canAdjustWallet"
|
||||||
text
|
text
|
||||||
@@ -752,6 +773,12 @@ function moneyCent(value: number | string | undefined) {
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<AdminUserDetailDrawer
|
||||||
|
:model-value="!!detailUser"
|
||||||
|
:user="detailUser"
|
||||||
|
@update:model-value="(val: boolean) => !val && (detailUser = null)"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Wallet,
|
Wallet,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
import { fetchAdminWalletLedger, type AdminWalletLedger } from '@/features/admin/api/adminWallet'
|
import { fetchAdminWalletLedger, type AdminWalletLedger } from '@/features/admin/api/adminWallet'
|
||||||
import { adminPath } from '@/shared/utils/adminPath'
|
import { adminPath } from '@/shared/utils/adminPath'
|
||||||
@@ -32,6 +33,7 @@ const filters = reactive({
|
|||||||
order_id: '',
|
order_id: '',
|
||||||
biz_type: '',
|
biz_type: '',
|
||||||
})
|
})
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const inAmount = computed(() =>
|
const inAmount = computed(() =>
|
||||||
ledger.value
|
ledger.value
|
||||||
@@ -44,7 +46,14 @@ const outAmount = computed(() =>
|
|||||||
.reduce((sum, item) => sum + Number(item.amount_cent || 0), 0)
|
.reduce((sum, item) => sum + Number(item.amount_cent || 0), 0)
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(loadLedger)
|
onMounted(() => {
|
||||||
|
// 支持从用户管理「明细」跳转预填用户,直接查看该用户的资金流水。
|
||||||
|
const routeUserID = route.query.user_id
|
||||||
|
if (typeof routeUserID === 'string' && routeUserID.trim()) {
|
||||||
|
filters.user_id = routeUserID.trim()
|
||||||
|
}
|
||||||
|
void loadLedger()
|
||||||
|
})
|
||||||
|
|
||||||
async function loadLedger() {
|
async function loadLedger() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|||||||
@@ -277,6 +277,8 @@ export interface AdminOrderQuery {
|
|||||||
handoff_mode?: string
|
handoff_mode?: string
|
||||||
settlement_status?: string
|
settlement_status?: string
|
||||||
offline_settlement_status?: 'pending' | 'settled' | ''
|
offline_settlement_status?: 'pending' | 'settled' | ''
|
||||||
|
// 精确按用户过滤:命中该用户作为号主或租客参与的订单。
|
||||||
|
user_id?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SubmitCheckoutRequest {
|
interface SubmitCheckoutRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user