diff --git a/backend/internal/modules/invitation/dto.go b/backend/internal/modules/invitation/dto.go index c7b6829..5d6e18d 100644 --- a/backend/internal/modules/invitation/dto.go +++ b/backend/internal/modules/invitation/dto.go @@ -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"` } diff --git a/backend/internal/modules/invitation/repository.go b/backend/internal/modules/invitation/repository.go index 92de7b9..733dd93 100644 --- a/backend/internal/modules/invitation/repository.go +++ b/backend/internal/modules/invitation/repository.go @@ -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 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 { diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index 9d5b451..a16aaed 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -217,6 +217,8 @@ type AdminOrderQuery struct { HandoffMode string SettlementStatus string OfflineSettlementStatus string + // UserID 精确匹配该用户作为号主或租客参与的订单(用户管理「明细」入口使用)。 + UserID uint64 } type AuditMeta = auditlog.Meta diff --git a/backend/internal/modules/order/handler_parse.go b/backend/internal/modules/order/handler_parse.go index bc54a53..9e6b083 100644 --- a/backend/internal/modules/order/handler_parse.go +++ b/backend/internal/modules/order/handler_parse.go @@ -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, } } diff --git a/backend/internal/modules/order/queries.go b/backend/internal/modules/order/queries.go index 34cca55..e6ba8a9 100644 --- a/backend/internal/modules/order/queries.go +++ b/backend/internal/modules/order/queries.go @@ -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) } diff --git a/frontend/src/features/admin/api/adminInvitations.ts b/frontend/src/features/admin/api/adminInvitations.ts new file mode 100644 index 0000000..e45a06d --- /dev/null +++ b/frontend/src/features/admin/api/adminInvitations.ts @@ -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>>( + '/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>>( + '/admin/invitations/rebates', + { params: query } + ) + const result = data.data + return { + items: Array.isArray(result?.items) ? result.items : [], + total: Number(result?.total ?? 0), + } +} diff --git a/frontend/src/features/admin/components/AdminUserDetailDrawer.css b/frontend/src/features/admin/components/AdminUserDetailDrawer.css new file mode 100644 index 0000000..774c484 --- /dev/null +++ b/frontend/src/features/admin/components/AdminUserDetailDrawer.css @@ -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; +} diff --git a/frontend/src/features/admin/components/AdminUserDetailDrawer.vue b/frontend/src/features/admin/components/AdminUserDetailDrawer.vue new file mode 100644 index 0000000..f10d684 --- /dev/null +++ b/frontend/src/features/admin/components/AdminUserDetailDrawer.vue @@ -0,0 +1,810 @@ + + + + + diff --git a/frontend/src/features/admin/views/AdminPaymentsView.vue b/frontend/src/features/admin/views/AdminPaymentsView.vue index e125c1a..c08392c 100644 --- a/frontend/src/features/admin/views/AdminPaymentsView.vue +++ b/frontend/src/features/admin/views/AdminPaymentsView.vue @@ -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 diff --git a/frontend/src/features/admin/views/AdminUsersView.vue b/frontend/src/features/admin/views/AdminUsersView.vue index c47740d..75bfc62 100644 --- a/frontend/src/features/admin/views/AdminUsersView.vue +++ b/frontend/src/features/admin/views/AdminUsersView.vue @@ -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(null) +const detailUser = ref(null) const quotaUser = ref(null) const realnameUser = ref(null) const manualRealnameUser = ref(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) { - + + + diff --git a/frontend/src/features/admin/views/AdminWalletLedgerView.vue b/frontend/src/features/admin/views/AdminWalletLedgerView.vue index 43de068..154c8ab 100644 --- a/frontend/src/features/admin/views/AdminWalletLedgerView.vue +++ b/frontend/src/features/admin/views/AdminWalletLedgerView.vue @@ -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 diff --git a/frontend/src/features/orders/api/orders.ts b/frontend/src/features/orders/api/orders.ts index 218e253..7c25c43 100644 --- a/frontend/src/features/orders/api/orders.ts +++ b/frontend/src/features/orders/api/orders.ts @@ -277,6 +277,8 @@ export interface AdminOrderQuery { handoff_mode?: string settlement_status?: string offline_settlement_status?: 'pending' | 'settled' | '' + // 精确按用户过滤:命中该用户作为号主或租客参与的订单。 + user_id?: number } interface SubmitCheckoutRequest {