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:
yml2213
2026-10-13 14:49:36 +08:00
parent 4a9e5f9d66
commit 0d6a7786a4
12 changed files with 1100 additions and 48 deletions
+40 -33
View File
@@ -12,33 +12,33 @@ type InviteCodeInfoDTO struct {
// UserInvitationSummaryDTO 个人中心邀请返利概览
type UserInvitationSummaryDTO struct {
HasInviteCode bool `json:"has_invite_code"`
InviteCode string `json:"invite_code"`
InviteLink string `json:"invite_link"`
IsRealnameVerified bool `json:"is_realname_verified"`
TotalInvitedCount int64 `json:"total_invited_count"`
ActiveInvitedCount int64 `json:"active_invited_count"`
PendingInvitedCount int64 `json:"pending_invited_count"`
TotalRebateCent int64 `json:"total_rebate_cent"`
CurrentMonthEstCent int64 `json:"current_month_est_cent"`
FirstMonthRate float64 `json:"first_month_rate"`
SecondMonthRate float64 `json:"second_month_rate"`
SubsequentRate float64 `json:"subsequent_rate"`
HasInviteCode bool `json:"has_invite_code"`
InviteCode string `json:"invite_code"`
InviteLink string `json:"invite_link"`
IsRealnameVerified bool `json:"is_realname_verified"`
TotalInvitedCount int64 `json:"total_invited_count"`
ActiveInvitedCount int64 `json:"active_invited_count"`
PendingInvitedCount int64 `json:"pending_invited_count"`
TotalRebateCent int64 `json:"total_rebate_cent"`
CurrentMonthEstCent int64 `json:"current_month_est_cent"`
FirstMonthRate float64 `json:"first_month_rate"`
SecondMonthRate float64 `json:"second_month_rate"`
SubsequentRate float64 `json:"subsequent_rate"`
}
// InvitedUserRecordDTO 邀请好友记录
type InvitedUserRecordDTO struct {
ID uint64 `json:"id"`
InviteeID uint64 `json:"invitee_id"`
MaskedPhone string `json:"masked_phone"`
Nickname string `json:"nickname"`
AvatarURL string `json:"avatar_url"`
Status string `json:"status"` // pending=待实名, active=已实名生效
CycleStage string `json:"cycle_stage"` // first_month, second_month, subsequent, unverified
DaysSinceVerified int `json:"days_since_verified"`
VerifiedAt *time.Time `json:"verified_at,omitempty"`
RegisteredAt time.Time `json:"registered_at"`
TotalEarnedFromCent int64 `json:"total_earned_from_cent"`
ID uint64 `json:"id"`
InviteeID uint64 `json:"invitee_id"`
MaskedPhone string `json:"masked_phone"`
Nickname string `json:"nickname"`
AvatarURL string `json:"avatar_url"`
Status string `json:"status"` // pending=待实名, active=已实名生效
CycleStage string `json:"cycle_stage"` // first_month, second_month, subsequent, unverified
DaysSinceVerified int `json:"days_since_verified"`
VerifiedAt *time.Time `json:"verified_at,omitempty"`
RegisteredAt time.Time `json:"registered_at"`
TotalEarnedFromCent int64 `json:"total_earned_from_cent"`
}
// RebateLedgerDTO 返利明细记录
@@ -76,20 +76,27 @@ type UpdateConfigRequest struct {
// AdminInvitationQuery 后台查询邀请关系
type AdminInvitationQuery struct {
Page int `form:"page,default=1"`
PageSize int `form:"page_size,default=20"`
InviterPhone string `form:"inviter_phone"`
InviteePhone string `form:"invitee_phone"`
Status string `form:"status"`
Page int `form:"page,default=1"`
PageSize int `form:"page_size,default=20"`
InviterPhone string `form:"inviter_phone"`
InviteePhone string `form:"invitee_phone"`
Status string `form:"status"`
DistributionSiteID *uint64 `form:"distribution_site_id"`
// InviterID / InviteeID 供用户管理「明细」按用户精确筛选。
InviterID uint64 `form:"inviter_id"`
InviteeID uint64 `form:"invitee_id"`
}
// AdminRebateLedgerQuery 后台查询返利明细
type AdminRebateLedgerQuery struct {
Page int `form:"page,default=1"`
PageSize int `form:"page_size,default=20"`
OrderNo string `form:"order_no"`
InviterPhone string `form:"inviter_phone"`
TradeRole string `form:"trade_role"`
Page int `form:"page,default=1"`
PageSize int `form:"page_size,default=20"`
OrderNo string `form:"order_no"`
InviterPhone string `form:"inviter_phone"`
InviteePhone string `form:"invitee_phone"`
TradeRole string `form:"trade_role"`
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(&currentMonthEstCent).Error
return &UserInvitationSummaryDTO{
HasInviteCode: hasCode,
InviteCode: code,
InviteLink: fmt.Sprintf("/i/%s", code),
IsRealnameVerified: user.RealnameStatus == "verified",
TotalInvitedCount: totalCount,
ActiveInvitedCount: activeCount,
PendingInvitedCount: pendingCount,
TotalRebateCent: totalRebateCent,
CurrentMonthEstCent: currentMonthEstCent,
FirstMonthRate: firstRate,
SecondMonthRate: secondRate,
SubsequentRate: subsequentRate,
HasInviteCode: hasCode,
InviteCode: code,
InviteLink: fmt.Sprintf("/i/%s", code),
IsRealnameVerified: user.RealnameStatus == "verified",
TotalInvitedCount: totalCount,
ActiveInvitedCount: activeCount,
PendingInvitedCount: pendingCount,
TotalRebateCent: totalRebateCent,
CurrentMonthEstCent: currentMonthEstCent,
FirstMonthRate: firstRate,
SecondMonthRate: secondRate,
SubsequentRate: subsequentRate,
}, nil
}
@@ -734,6 +734,12 @@ func (r *Repository) ListAdminInvitations(ctx context.Context, query AdminInvita
if query.DistributionSiteID != nil {
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
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 != "" {
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 != "" {
db = db.Where("irl.trade_role = ?", t)
}
if query.DistributionSiteID != nil {
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
if err := db.Count(&total).Error; err != nil {
+2
View File
@@ -217,6 +217,8 @@ type AdminOrderQuery struct {
HandoffMode string
SettlementStatus string
OfflineSettlementStatus string
// UserID 精确匹配该用户作为号主或租客参与的订单(用户管理「明细」入口使用)。
UserID uint64
}
type AuditMeta = auditlog.Meta
@@ -54,6 +54,7 @@ func parsePagination(c *gin.Context) (int, int) {
func parseAdminOrderQuery(c *gin.Context) AdminOrderQuery {
page, pageSize := parsePagination(c)
userID, _ := strconv.ParseUint(strings.TrimSpace(c.Query("user_id")), 10, 64)
return AdminOrderQuery{
Page: page,
PageSize: pageSize,
@@ -63,5 +64,6 @@ func parseAdminOrderQuery(c *gin.Context) AdminOrderQuery {
HandoffMode: strings.TrimSpace(c.Query("handoff_mode")),
SettlementStatus: strings.TrimSpace(c.Query("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 {
if query.UserID > 0 {
// 精确命中该用户作为号主或租客参与的订单,避免与 keyword 的数字 ID 模糊命中混淆。
db = db.Where("(o.owner_id = ? OR o.renter_id = ?)", query.UserID, query.UserID)
}
if 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,
} from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments'
import { adminPath } from '@/shared/utils/adminPath'
@@ -45,7 +46,14 @@ const refundAmount = computed(() =>
.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() {
loading.value = true
@@ -22,13 +22,21 @@ import { realnameStatusLabel, userStatusLabel } from '@/shared/utils/statusLabel
import { formatDateTime } from '@/shared/utils/time'
import { useAdminSessionStore } from '@/stores/adminSession'
import AdminTablePagination from '../components/AdminTablePagination.vue'
import AdminUserDetailDrawer from '../components/AdminUserDetailDrawer.vue'
const adminSession = useAdminSessionStore()
const canAdjustWallet = computed(() => adminSession.hasPermission('user:wallet_adjust'))
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 activeUser = ref<AdminUserItem | null>(null)
const detailUser = ref<AdminUserItem | null>(null)
const quotaUser = ref<AdminUserItem | null>(null)
const realnameUser = ref<AdminUserItem | null>(null)
const manualRealnameUser = ref<AdminUserItem | null>(null)
@@ -82,6 +90,10 @@ function resetFilters() {
queryUsers()
}
function openUserDetail(row: AdminUserItem) {
detailUser.value = row
}
function openFreeze(row: AdminUserItem) {
activeUser.value = row
freezeReason.value = ''
@@ -412,9 +424,18 @@ function moneyCent(value: number | string | undefined) {
<el-table-column label="注册时间" min-width="160">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</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 }">
<div class="table-actions">
<el-button
v-if="canViewUserDetail"
text
type="primary"
size="small"
@click="openUserDetail(row)"
>
明细
</el-button>
<el-button
v-if="canAdjustWallet"
text
@@ -752,6 +773,12 @@ function moneyCent(value: number | string | undefined) {
</el-button>
</template>
</el-dialog>
<AdminUserDetailDrawer
:model-value="!!detailUser"
:user="detailUser"
@update:model-value="(val: boolean) => !val && (detailUser = null)"
/>
</section>
</template>
@@ -9,6 +9,7 @@ import {
Wallet,
} from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchAdminWalletLedger, type AdminWalletLedger } from '@/features/admin/api/adminWallet'
import { adminPath } from '@/shared/utils/adminPath'
@@ -32,6 +33,7 @@ const filters = reactive({
order_id: '',
biz_type: '',
})
const route = useRoute()
const inAmount = computed(() =>
ledger.value
@@ -44,7 +46,14 @@ const outAmount = computed(() =>
.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() {
loading.value = true
@@ -277,6 +277,8 @@ export interface AdminOrderQuery {
handoff_mode?: string
settlement_status?: string
offline_settlement_status?: 'pending' | 'settled' | ''
// 精确按用户过滤:命中该用户作为号主或租客参与的订单。
user_id?: number
}
interface SubmitCheckoutRequest {