修复鉴权:401拦截器加refresh重试 + admin refresh接口 + 路由守卫完善

根因:access_token每2小时过期,前端收到401直接清token跳登录,没有用refresh_token续期

后端修复:
- adminauth模块新增 POST /admin/auth/refresh 接口
- Service 注入 JWTManager,支持 admin refresh token 换新 token pair
- Refresh 方法验证 subjectType=admin + tokenType=refresh

前端修复:
- 401 拦截器核心改造:收到401先调 refresh 接口续期
- 加 isRefreshing 锁 + pendingRequests 队列防止并发刷新
- refresh 用原生 axios.post 避免拦截器递归
- 成功则更新 localStorage + 重试原请求,失败才清 token 跳登录
- 排除 /auth/refresh 自身避免死循环
- 支持 /admin/ 请求独立 token 管理
- auth.ts/adminAuth.ts 新增手动 refreshUserToken/refreshAdminSession
- 路由守卫给所有需登录路由添加 meta.requiresAuth
- 守卫同时支持 PC 端 /login 和移动端 /m/login
This commit is contained in:
yml2213
2026-05-24 07:00:13 +08:00
parent 3631e70321
commit cdee93c7c5
61 changed files with 1735 additions and 722 deletions
+35 -24
View File
@@ -8,22 +8,30 @@ import { formatDateTime } from '@/utils/time'
const loading = ref(false)
const logs = ref<AdminAuditLog[]>([])
const activeLog = ref<AdminAuditLog | null>(null)
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
const filters = reactive({
actor_id: '',
action: '',
biz_type: '',
limit: 200,
})
const highRiskCount = computed(() => logs.value.filter((item) => item.action.includes('freeze') || item.action.includes('update')).length)
const uniqueActors = computed(() => new Set(logs.value.map((item) => item.actor_id)).size)
onMounted(loadLogs)
async function loadLogs() {
loading.value = true
try {
logs.value = await fetchAdminAuditLogs(filters)
const query = {
...filters,
page: currentPage.value,
page_size: currentPageSize.value,
}
const result = await fetchAdminAuditLogs(query)
logs.value = result.items
total.value = result.total
} finally {
loading.value = false
}
@@ -33,10 +41,15 @@ function resetFilters() {
filters.actor_id = ''
filters.action = ''
filters.biz_type = ''
filters.limit = 200
currentPage.value = 1
void loadLogs()
}
function handleSizeChange() {
currentPage.value = 1
loadLogs()
}
function actorName(row: AdminAuditLog) {
return row.actor_nickname || row.actor_username || `${row.actor_type} ${row.actor_id}`
}
@@ -59,7 +72,7 @@ function actionType(action: string) {
<div class="page-header">
<p class="eyebrow">Audit Logs</p>
<h1>审计日志</h1>
<p>查看后台管理员操作业务对象来源 IP 和操作明细用于追踪冻结配置修改等高风险动作</p>
<p>查看后台管理员操作业务对象和操作明细用于追踪冻结配置修改等高风险动作</p>
</div>
<div class="toolbar-actions">
<el-button @click="resetFilters">重置</el-button>
@@ -69,12 +82,8 @@ function actionType(action: string) {
<div class="metric-grid">
<div class="metric-card">
<span>当前结果</span>
<strong>{{ logs.length }} </strong>
</div>
<div class="metric-card">
<span>操作管理员</span>
<strong>{{ uniqueActors }} </strong>
<span>总条数</span>
<strong>{{ total }} </strong>
</div>
<div class="metric-card">
<span>高风险动作</span>
@@ -94,13 +103,8 @@ function actionType(action: string) {
<el-option label="商品标记异常" value="listing.mark_abnormal" />
<el-option label="客服关闭订单" value="order.admin_close" />
<el-option label="订单标记异常" value="order.mark_abnormal" />
<el-option label="号主交接超时" value="order.timeout.owner_submit" />
<el-option label="租客确认超时" value="order.timeout.renter_confirm" />
<el-option label="租客结账逾期" value="order.timeout.return_overdue" />
<el-option label="号主确认结账超时" value="order.timeout.owner_checkout_confirm" />
<el-option label="申诉仲裁" value="dispute.arbitrate" />
<el-option label="更新系统配置" value="system_config.update" />
<el-option label="创建系统配置" value="system_config.create" />
</el-select>
</el-form-item>
<el-form-item label="业务类型">
@@ -112,17 +116,12 @@ function actionType(action: string) {
<el-option label="系统配置" value="system_config" />
</el-select>
</el-form-item>
<el-form-item label="查询条数">
<el-input-number v-model="filters.limit" :min="20" :max="500" :step="20" class="full-control" />
</el-form-item>
</el-form>
<el-table v-loading="loading" class="table-panel" :data="logs">
<el-table-column prop="id" label="ID" width="90" />
<el-table-column label="管理员" min-width="160">
<el-table-column label="操作人" min-width="160">
<template #default="{ row }">
<strong>{{ actorName(row) }}</strong>
<span class="table-subtext">{{ row.actor_type }} ID: {{ row.actor_id }}</span>
</template>
</el-table-column>
<el-table-column label="动作" min-width="180">
@@ -132,8 +131,6 @@ function actionType(action: string) {
</el-table-column>
<el-table-column prop="biz_type" label="业务类型" width="130" />
<el-table-column prop="biz_id" label="业务 ID" width="100" />
<el-table-column prop="ip" label="IP" min-width="130" />
<el-table-column prop="user_agent" label="User-Agent" min-width="240" show-overflow-tooltip />
<el-table-column label="时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
@@ -144,9 +141,23 @@ function actionType(action: string) {
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadLogs"
@size-change="handleSizeChange"
/>
</div>
<el-dialog :model-value="!!activeLog" title="审计明细" width="720px" @update:model-value="activeLog = null">
<div v-if="activeLog" class="dialog-body">
<p><strong>{{ activeLog.action }}</strong> · {{ activeLog.biz_type }} #{{ activeLog.biz_id || '-' }}</p>
<p>操作人{{ actorName(activeLog) }} · IP{{ activeLog.ip }}</p>
<p>User-Agent{{ activeLog.user_agent }}</p>
<div class="code-panel">
<pre>{{ detailText(activeLog) }}</pre>
</div>
+29 -8
View File
@@ -5,6 +5,7 @@ import { onMounted, ref } from 'vue'
import { arbitrateDispute, fetchAdminDisputes, type Dispute } from '@/api/disputes'
import { fetchAdminFileBlob } from '@/api/files'
import { disputeStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
const loading = ref(false)
const submitting = ref(false)
@@ -14,18 +15,28 @@ const evidenceDispute = ref<Dispute | null>(null)
const result = ref('release_deposit')
const remark = ref('')
const amount = ref<number | undefined>()
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
onMounted(loadDisputes)
async function loadDisputes() {
loading.value = true
try {
disputes.value = await fetchAdminDisputes()
const res = await fetchAdminDisputes(currentPage.value, currentPageSize.value)
disputes.value = res.items
total.value = res.total
} finally {
loading.value = false
}
}
function handleSizeChange() {
currentPage.value = 1
loadDisputes()
}
function openArbitration(row: Dispute) {
activeDispute.value = row
result.value = row.arbitration_result || 'release_deposit'
@@ -109,20 +120,30 @@ function readError(error: unknown, fallback: string) {
<el-table-column label="状态" width="110">
<template #default="{ row }">{{ disputeStatusLabel(row.status) }}</template>
</el-table-column>
<el-table-column prop="description" label="说明" min-width="220" show-overflow-tooltip />
<el-table-column prop="arbitration_result" label="结果" width="150" />
<el-table-column label="证据" width="100">
<template #default="{ row }">
<el-button size="small" :disabled="evidenceItems(row).length === 0" @click="evidenceDispute = row">查看</el-button>
</template>
<el-table-column label="创建时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="120">
<el-table-column prop="arbitration_result" label="仲裁结果" width="150" show-overflow-tooltip />
<el-table-column label="操作" width="160">
<template #default="{ row }">
<el-button size="small" :disabled="evidenceItems(row).length === 0" @click="evidenceDispute = row">证据</el-button>
<el-button size="small" :disabled="row.status === 'resolved'" @click="openArbitration(row)">仲裁</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadDisputes"
@size-change="handleSizeChange"
/>
</div>
<el-dialog :model-value="!!activeDispute" title="申诉仲裁" width="560px" @update:model-value="activeDispute = null">
<div v-if="activeDispute" class="dialog-body">
<p><strong>{{ activeDispute.order_no }}</strong> · {{ activeDispute.title }}</p>
@@ -6,13 +6,8 @@ import { fetchAdminFileBlob } from '@/api/files'
import { approveListing, fetchPendingReviewListings, rejectListing, type Listing } from '@/api/listings'
import {
formatMoney,
formatRatio,
getAdminListingPricing,
listingAssetTags,
listingBadgeTags,
listingBuyerPrice,
listingDisplayTitle,
listingResourceTags,
listingSellerPrice,
ownerName,
} from '@/views/admin/listingAdminDisplay'
@@ -24,18 +19,28 @@ const listings = ref<Listing[]>([])
const activeListing = ref<Listing | null>(null)
const evidenceListing = ref<Listing | null>(null)
const rejectReason = ref('')
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
onMounted(loadListings)
async function loadListings() {
loading.value = true
try {
listings.value = await fetchPendingReviewListings()
const result = await fetchPendingReviewListings(currentPage.value, currentPageSize.value)
listings.value = result.items
total.value = result.total
} finally {
loading.value = false
}
}
function handleSizeChange() {
currentPage.value = 1
loadListings()
}
async function handleApprove(row: Listing) {
submitting.value = true
try {
@@ -113,60 +118,23 @@ function readError(error: unknown, fallback: string) {
</div>
<el-table v-loading="loading" class="table-panel" :data="listings">
<el-table-column label="商品" min-width="270" fixed="left">
<el-table-column label="商品" min-width="220" fixed="left">
<template #default="{ row }">
<strong>{{ listingDisplayTitle(row) }}</strong>
<span class="table-subtext">{{ row.server_region }} / {{ row.login_platform }}</span>
<div v-if="listingBadgeTags(row).length" class="admin-tag-row compact">
<el-tag
v-for="tag in listingBadgeTags(row)"
:key="`${row.id}-${tag}`"
size="small"
:type="tag === '特惠' ? 'danger' : 'warning'"
effect="plain"
>
{{ tag }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="号主" min-width="150">
<el-table-column label="号主" min-width="130">
<template #default="{ row }">
<strong>{{ ownerName(row) }}</strong>
<span class="table-subtext">ID: {{ row.owner_id }}</span>
</template>
</el-table-column>
<el-table-column label="资产配置" min-width="260">
<template #default="{ row }">
<div class="admin-tag-row">
<el-tag v-for="tag in listingAssetTags(row).slice(0, 8)" :key="`${row.id}-${tag}`" size="small" effect="plain">
{{ tag }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="资源" min-width="220">
<template #default="{ row }">
<div v-if="listingResourceTags(row).length" class="admin-tag-row">
<el-tag v-for="tag in listingResourceTags(row)" :key="`${row.id}-${tag}`" size="small" type="info" effect="plain">
{{ tag }}
</el-tag>
</div>
<span v-else class="table-subtext">无额外资源</span>
</template>
</el-table-column>
<el-table-column label="价格" min-width="170">
<template #default="{ row }">
<strong>{{ listingBuyerPrice(row) }}</strong>
<span class="table-subtext">卖家 {{ listingSellerPrice(row) }}</span>
<span class="table-subtext">
卖家 {{ formatRatio(getAdminListingPricing(row).sellerRatio) }} / 买家 {{ formatRatio(getAdminListingPricing(row).buyerRatio) }}
</span>
<span class="table-subtext">卖家 {{ listingSellerPrice(row) }} · 押金 {{ formatMoney(row.deposit_amount) }}</span>
</template>
</el-table-column>
<el-table-column label="押金" width="100">
<template #default="{ row }">{{ formatMoney(row.deposit_amount) }}</template>
</el-table-column>
<el-table-column label="截图" width="90">
<template #default="{ row }">
<el-button size="small" :disabled="!row.screenshot_urls?.length" @click="openEvidence(row)">
@@ -185,6 +153,18 @@ function readError(error: unknown, fallback: string) {
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadListings"
@size-change="handleSizeChange"
/>
</div>
<el-dialog :model-value="!!activeListing" title="拒绝发布" width="560px" @update:model-value="activeListing = null">
<div v-if="activeListing" class="dialog-body">
<p><strong>{{ listingDisplayTitle(activeListing) }}</strong></p>
+43 -80
View File
@@ -5,14 +5,8 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { fetchAdminListings, type Listing } from '@/api/listings'
import {
formatMoney,
formatRatio,
getAdminListingPricing,
listingAssetTags,
listingBadgeTags,
listingBuyerPrice,
listingDisplayTitle,
listingResourceTags,
listingRiskTags,
listingSellerPrice,
ownerName,
} from '@/views/admin/listingAdminDisplay'
@@ -21,11 +15,13 @@ import { formatDateTime } from '@/utils/time'
const loading = ref(false)
const listings = ref<Listing[]>([])
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
const filters = reactive({
owner_id: '',
status: '',
review_status: '',
limit: 200,
})
const publishedCount = computed(() => listings.value.filter((item) => item.status === 'published').length)
@@ -37,7 +33,14 @@ onMounted(loadListings)
async function loadListings() {
loading.value = true
try {
listings.value = await fetchAdminListings(filters)
const query = {
...filters,
page: currentPage.value,
page_size: currentPageSize.value,
}
const result = await fetchAdminListings(query)
listings.value = result.items
total.value = result.total
} finally {
loading.value = false
}
@@ -47,10 +50,15 @@ function resetFilters() {
filters.owner_id = ''
filters.status = ''
filters.review_status = ''
filters.limit = 200
currentPage.value = 1
void loadListings()
}
function handleSizeChange() {
currentPage.value = 1
loadListings()
}
function statusType(status: string) {
if (status === 'published') return 'success'
if (status === 'rented') return 'warning'
@@ -72,7 +80,7 @@ function reviewType(status: string) {
<div class="page-header">
<p class="eyebrow">Listings</p>
<h1>商品管理</h1>
<p>查看全部租号商品号主区服平台价格押金上架状态和审核状态</p>
<p>查看全部租号商品号主价格押金上架状态和审核状态</p>
</div>
<div class="toolbar-actions">
<el-button @click="resetFilters">重置</el-button>
@@ -82,8 +90,8 @@ function reviewType(status: string) {
<div class="metric-grid">
<div class="metric-card">
<span>当前结果</span>
<strong>{{ listings.length }} </strong>
<span>总条数</span>
<strong>{{ total }} </strong>
</div>
<div class="metric-card">
<span>已上架</span>
@@ -120,81 +128,21 @@ function reviewType(status: string) {
<el-option label="已拒绝" value="rejected" />
</el-select>
</el-form-item>
<el-form-item label="查询条数">
<el-input-number v-model="filters.limit" :min="20" :max="500" :step="20" class="full-control" />
</el-form-item>
</el-form>
<el-table v-loading="loading" class="table-panel" :data="listings">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="商品" min-width="270" fixed="left">
<el-table-column label="商品" min-width="220" fixed="left">
<template #default="{ row }">
<strong>{{ listingDisplayTitle(row) }}</strong>
<span class="table-subtext">账号 ID: {{ row.account_id }}</span>
<div v-if="listingBadgeTags(row).length" class="admin-tag-row compact">
<el-tag
v-for="tag in listingBadgeTags(row)"
:key="`${row.id}-${tag}`"
size="small"
:type="tag === '特惠' ? 'danger' : 'warning'"
effect="plain"
>
{{ tag }}
</el-tag>
</div>
<span class="table-subtext">{{ row.game_name }}</span>
</template>
</el-table-column>
<el-table-column label="号主" min-width="150">
<el-table-column label="号主" min-width="130">
<template #default="{ row }">
<strong>{{ ownerName(row) }}</strong>
<span class="table-subtext">ID: {{ row.owner_id }}</span>
</template>
</el-table-column>
<el-table-column label="资产配置" min-width="260">
<template #default="{ row }">
<div class="admin-tag-row">
<el-tag v-for="tag in listingAssetTags(row).slice(0, 8)" :key="`${row.id}-${tag}`" size="small" effect="plain">
{{ tag }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="资源" min-width="220">
<template #default="{ row }">
<div v-if="listingResourceTags(row).length" class="admin-tag-row">
<el-tag v-for="tag in listingResourceTags(row)" :key="`${row.id}-${tag}`" size="small" type="info" effect="plain">
{{ tag }}
</el-tag>
</div>
<span v-else class="table-subtext">无额外资源</span>
</template>
</el-table-column>
<el-table-column label="交接风控" min-width="230">
<template #default="{ row }">
<div class="admin-tag-row">
<el-tag v-for="tag in listingRiskTags(row)" :key="`${row.id}-${tag}`" size="small" type="info" effect="plain">
{{ tag }}
</el-tag>
</div>
<span class="table-subtext">{{ row.server_region }} / {{ row.login_platform }}</span>
</template>
</el-table-column>
<el-table-column label="价格" min-width="170">
<template #default="{ row }">
<strong>{{ listingBuyerPrice(row) }}</strong>
<span class="table-subtext">卖家 {{ listingSellerPrice(row) }}</span>
<span class="table-subtext">
卖家 {{ formatRatio(getAdminListingPricing(row).sellerRatio) }} / 买家 {{ formatRatio(getAdminListingPricing(row).buyerRatio) }}
</span>
<span v-if="getAdminListingPricing(row).platformMarkup > 0" class="table-subtext">
平台加价 {{ formatMoney(getAdminListingPricing(row).platformMarkup) }}
</span>
</template>
</el-table-column>
<el-table-column label="押金" width="100">
<template #default="{ row }">{{ formatMoney(row.deposit_amount) }}</template>
</el-table-column>
<el-table-column label="商品状态" width="110">
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="statusType(row.status)">{{ listingStatusLabel(row.status) }}</el-tag>
</template>
@@ -204,11 +152,14 @@ function reviewType(status: string) {
<el-tag :type="reviewType(row.review_status)">{{ listingReviewStatusLabel(row.review_status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="上架时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.published_at) }}</template>
<el-table-column label="价格(租金/押金)" min-width="170">
<template #default="{ row }">
<strong>{{ listingBuyerPrice(row) }}</strong>
<span class="table-subtext">卖家 {{ listingSellerPrice(row) }} · 押金 {{ formatMoney(row.deposit_amount) }}</span>
</template>
</el-table-column>
<el-table-column label="更新时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.updated_at) }}</template>
<el-table-column label="创建时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="110">
<template #default="{ row }">
@@ -218,5 +169,17 @@ function reviewType(status: string) {
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadListings"
@size-change="handleSizeChange"
/>
</div>
</section>
</template>
+46 -14
View File
@@ -1,13 +1,16 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { fetchAdminOrders, type Order } from '@/api/orders'
import { fetchAdminOrders, type OrderListItem } from '@/api/orders'
import { handoffStatusLabel, orderStatusLabel, settlementStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
const loading = ref(false)
const orders = ref<Order[]>([])
const orders = ref<OrderListItem[]>([])
const status = ref('')
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
const filteredOrders = computed(() => {
if (!status.value) return orders.value
@@ -19,11 +22,18 @@ onMounted(loadOrders)
async function loadOrders() {
loading.value = true
try {
orders.value = await fetchAdminOrders()
const result = await fetchAdminOrders(currentPage.value, currentPageSize.value)
orders.value = result.items
total.value = result.total
} finally {
loading.value = false
}
}
function handleSizeChange() {
currentPage.value = 1
loadOrders()
}
</script>
<template>
@@ -55,20 +65,30 @@ async function loadOrders() {
<el-table v-loading="loading" class="table-panel" :data="filteredOrders">
<el-table-column prop="order_no" label="订单号" min-width="230" />
<el-table-column prop="title" label="账号" min-width="170" />
<el-table-column prop="renter_phone" label="租客" min-width="130" />
<el-table-column prop="owner_phone" label="号主" min-width="130" />
<el-table-column label="状态" width="140">
<template #default="{ row }">{{ orderStatusLabel(row.status) }}</template>
<el-table-column prop="listing_title" label="商品名" min-width="170" />
<el-table-column label="租客/号主" min-width="160">
<template #default="{ row }">
<span>{{ row.renter_nickname }}</span>
<span class="table-subtext">{{ row.owner_nickname }}</span>
</template>
</el-table-column>
<el-table-column label="交接" width="180">
<template #default="{ row }">{{ handoffStatusLabel(row.handoff_status) }}</template>
<el-table-column label="状态" min-width="160">
<template #default="{ row }">
<el-tag size="small">{{ orderStatusLabel(row.status) }}</el-tag>
<el-tag size="small" type="warning" v-if="row.handoff_status && row.handoff_status !== 'none'">
{{ handoffStatusLabel(row.handoff_status) }}
</el-tag>
<el-tag size="small" type="info" v-if="row.settlement_status && row.settlement_status !== 'unsettled'">
{{ settlementStatusLabel(row.settlement_status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="结算" width="120">
<template #default="{ row }">{{ settlementStatusLabel(row.settlement_status) }}</template>
<el-table-column label="金额" min-width="130">
<template #default="{ row }">
<span>¥{{ row.rent_amount }}</span>
<span class="table-subtext">押金 ¥{{ row.deposit_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="rent_amount" label="订单金额" width="100" />
<el-table-column prop="deposit_amount" label="押金" width="100" />
<el-table-column label="创建时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
@@ -80,5 +100,17 @@ async function loadOrders() {
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadOrders"
@size-change="handleSizeChange"
/>
</div>
</section>
</template>
+29 -17
View File
@@ -3,7 +3,7 @@ import { ElMessage } from 'element-plus'
import { onMounted, ref } from 'vue'
import { fetchAdminUsers, freezeAdminUser, unfreezeAdminUser, type AdminUserItem } from '@/api/adminUsers'
import { realnameStatusLabel, riskStatusLabel, userStatusLabel } from '@/utils/statusLabels'
import { userStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
const loading = ref(false)
@@ -11,18 +11,28 @@ const submitting = ref(false)
const users = ref<AdminUserItem[]>([])
const activeUser = ref<AdminUserItem | null>(null)
const freezeReason = ref('')
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
onMounted(loadUsers)
async function loadUsers() {
loading.value = true
try {
users.value = await fetchAdminUsers()
const result = await fetchAdminUsers(currentPage.value, currentPageSize.value)
users.value = result.items
total.value = result.total
} finally {
loading.value = false
}
}
function handleSizeChange() {
currentPage.value = 1
loadUsers()
}
function openFreeze(row: AdminUserItem) {
activeUser.value = row
freezeReason.value = ''
@@ -71,30 +81,20 @@ function readError(error: unknown, fallback: string) {
<div class="page-header">
<p class="eyebrow">Users</p>
<h1>用户管理</h1>
<p>查看用户实名风险信用和业务数量处理冻结与解冻</p>
<p>查看用户信息和状态处理冻结与解冻</p>
</div>
<el-button @click="loadUsers">刷新</el-button>
</div>
<el-table v-loading="loading" class="table-panel" :data="users">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="phone" label="手机号" min-width="130" />
<el-table-column prop="id" label="用户ID" width="80" />
<el-table-column prop="nickname" label="昵称" min-width="130" />
<el-table-column label="实名" width="110">
<template #default="{ row }">{{ realnameStatusLabel(row.realname_status) }}</template>
</el-table-column>
<el-table-column label="风险" width="110">
<template #default="{ row }">{{ riskStatusLabel(row.risk_status) }}</template>
</el-table-column>
<el-table-column prop="credit_score" label="信用分" width="100" />
<el-table-column prop="phone" label="手机号" min-width="130" />
<el-table-column label="状态" width="110">
<template #default="{ row }">{{ userStatusLabel(row.status) }}</template>
</el-table-column>
<el-table-column prop="order_count" label="订单" width="90" />
<el-table-column prop="listing_count" label="发布" width="90" />
<el-table-column prop="dispute_count" label="申诉" width="90" />
<el-table-column label="最近登录" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.last_login_at) }}</template>
<el-table-column label="注册时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="150">
<template #default="{ row }">
@@ -106,6 +106,18 @@ function readError(error: unknown, fallback: string) {
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadUsers"
@size-change="handleSizeChange"
/>
</div>
<el-dialog :model-value="!!activeUser" title="冻结用户" width="560px" @update:model-value="activeUser = null">
<div v-if="activeUser" class="dialog-body">
<p><strong>{{ activeUser.phone }}</strong> · {{ activeUser.nickname }}</p>
@@ -8,11 +8,13 @@ import { formatDateTime } from '@/utils/time'
const loading = ref(false)
const ledger = ref<AdminWalletLedger[]>([])
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
const filters = reactive({
user_id: '',
order_id: '',
biz_type: '',
limit: 200,
})
const inAmount = computed(() =>
@@ -21,16 +23,20 @@ const inAmount = computed(() =>
const outAmount = computed(() =>
ledger.value.filter((item) => item.direction === 'out').reduce((sum, item) => sum + Number(item.amount || 0), 0),
)
const frozenAmount = computed(() =>
ledger.value.filter((item) => item.balance_type === 'frozen').reduce((sum, item) => sum + Number(item.amount || 0), 0),
)
onMounted(loadLedger)
async function loadLedger() {
loading.value = true
try {
ledger.value = await fetchAdminWalletLedger(filters)
const query = {
...filters,
page: currentPage.value,
page_size: currentPageSize.value,
}
const result = await fetchAdminWalletLedger(query)
ledger.value = result.items
total.value = result.total
} finally {
loading.value = false
}
@@ -40,10 +46,15 @@ function resetFilters() {
filters.user_id = ''
filters.order_id = ''
filters.biz_type = ''
filters.limit = 200
currentPage.value = 1
void loadLedger()
}
function handleSizeChange() {
currentPage.value = 1
loadLedger()
}
function money(value: number) {
return `¥${value.toFixed(2)}`
}
@@ -65,7 +76,7 @@ function directionLabel(direction: string) {
<div class="page-header">
<p class="eyebrow">Wallet Ledger</p>
<h1>资金流水</h1>
<p>查看订单金额押金冻结解冻退款和结算流水当前为开发态账务记录不代表真实支付余额</p>
<p>查看订单金额押金冻结解冻退款和结算流水</p>
</div>
<div class="toolbar-actions">
<el-button @click="resetFilters">重置</el-button>
@@ -75,8 +86,8 @@ function directionLabel(direction: string) {
<div class="metric-grid">
<div class="metric-card">
<span>当前结果</span>
<strong>{{ ledger.length }} </strong>
<span>总条数</span>
<strong>{{ total }} </strong>
</div>
<div class="metric-card">
<span>入账合计</span>
@@ -86,10 +97,6 @@ function directionLabel(direction: string) {
<span>出账合计</span>
<strong>{{ money(outAmount) }}</strong>
</div>
<div class="metric-card">
<span>冻结相关金额</span>
<strong>{{ money(frozenAmount) }}</strong>
</div>
</div>
<el-form class="filter-panel" label-position="top">
@@ -107,20 +114,16 @@ function directionLabel(direction: string) {
<el-option label="押金退回" value="deposit_refund" />
</el-select>
</el-form-item>
<el-form-item label="查询条数">
<el-input-number v-model="filters.limit" :min="20" :max="500" :step="20" class="full-control" />
</el-form-item>
</el-form>
<el-table v-loading="loading" class="table-panel" :data="ledger">
<el-table-column prop="ledger_no" label="流水号" min-width="230" />
<el-table-column label="用户" min-width="160">
<el-table-column label="用户" min-width="130">
<template #default="{ row }">
<strong>{{ row.user_phone || `用户 ${row.user_id}` }}</strong>
<span class="table-subtext">ID: {{ row.user_id }}</span>
</template>
</el-table-column>
<el-table-column label="订单" min-width="180">
<el-table-column label="订单" min-width="160">
<template #default="{ row }">
<RouterLink v-if="row.order_id" :to="`/admin/orders/${row.order_id}`">{{ row.order_no || row.order_id }}</RouterLink>
<span v-else>-</span>
@@ -141,10 +144,21 @@ function directionLabel(direction: string) {
<el-table-column label="变化后余额" width="130">
<template #default="{ row }">{{ money(Number(row.balance_after)) }}</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="220" />
<el-table-column label="创建时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadLedger"
@size-change="handleSizeChange"
/>
</div>
</section>
</template>