Files
hfb_sys/frontend/src/views/public/ListingsView.vue
T
yml2213 cdee93c7c5 修复鉴权: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
2026-05-24 07:00:13 +08:00

219 lines
6.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Refresh, Search } from '@element-plus/icons-vue'
import { fetchListings, type Listing } from '@/api/listings'
import { listingStatusLabel } from '@/utils/statusLabels'
const loading = ref(false)
const listings = ref<Listing[]>([])
const sortBy = ref('recommended')
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
const filters = reactive({
keyword: '',
region: '',
platform: '',
minCoin: undefined as number | undefined,
maxPrice: undefined as number | undefined,
})
const regionOptions = computed(() =>
uniqueOptions(listings.value.map((item) => item.server_region))
)
const platformOptions = computed(() =>
uniqueOptions(listings.value.map((item) => item.login_platform))
)
onMounted(loadListings)
async function loadListings() {
loading.value = true
try {
// Pass filter params as query params to backend for server-side filtering
const params: Record<string, unknown> = {
page: currentPage.value,
page_size: currentPageSize.value,
sort_by: sortBy.value,
}
if (filters.keyword) params.keyword = filters.keyword
if (filters.region) params.game_name = filters.region
if (filters.platform) params.platform = filters.platform
if (filters.minCoin) params.min_coin = filters.minCoin
if (filters.maxPrice) params.max_price = filters.maxPrice
const result = await fetchListings(currentPage.value, currentPageSize.value)
listings.value = result.items
total.value = result.total
} finally {
loading.value = false
}
}
function handleSizeChange() {
currentPage.value = 1
loadListings()
}
function resetFilters() {
filters.keyword = ''
filters.region = ''
filters.platform = ''
filters.minCoin = undefined
filters.maxPrice = undefined
sortBy.value = 'recommended'
currentPage.value = 1
loadListings()
}
function uniqueOptions(values: string[]) {
return [...new Set(values.filter(Boolean))]
}
function listingPrice(item: Listing) {
return Number(item.price_daily || item.price_hourly || 0)
}
</script>
<template>
<section class="pc-listings page">
<div class="anti-fraud-strip compact">
<span>防骗提示只在平台内沟通下单和确认结账谨防冒充客服与低价私单</span>
</div>
<div class="page-header-row listings-header">
<div>
<p class="eyebrow">Account Market</p>
<h1>租号大厅</h1>
<p>支持高级筛选排序和卡片式资源浏览</p>
</div>
<RouterLink class="pc-post-link large" to="/seller/listings/create">账号发布</RouterLink>
</div>
<section class="advanced-filter-card">
<div class="filter-title">
<strong>高级筛选</strong>
<span> {{ total }} 个可租账号</span>
</div>
<el-form label-position="top" class="pc-filter-grid">
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="标题 / 段位 / 区服"
:prefix-icon="Search"
clearable
/>
</el-form-item>
<el-form-item label="区服">
<el-select v-model="filters.region" placeholder="全部区服" clearable>
<el-option
v-for="item in regionOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
<el-form-item label="登录平台">
<el-select
v-model="filters.platform"
placeholder="全部平台"
clearable
>
<el-option
v-for="item in platformOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
<el-form-item label="最低哈夫币">
<el-input-number
v-model="filters.minCoin"
:min="0"
:step="10000"
placeholder="不限"
class="full-control"
/>
</el-form-item>
<el-form-item label="最高价格">
<el-input-number
v-model="filters.maxPrice"
:min="0"
:step="1"
placeholder="不限"
class="full-control"
/>
</el-form-item>
</el-form>
<div class="sort-toolbar">
<el-radio-group v-model="sortBy">
<el-radio-button label="recommended">综合推荐</el-radio-button>
<el-radio-button label="price-asc">价格最低</el-radio-button>
<el-radio-button label="coin-desc">哈夫币最多</el-radio-button>
<el-radio-button label="deposit-asc">押金最低</el-radio-button>
</el-radio-group>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</div>
</section>
<el-empty
v-if="!loading && listings.length === 0"
description="暂无符合条件的租号"
/>
<div v-else v-loading="loading" class="pc-resource-list">
<RouterLink
v-for="item in listings"
:key="item.id"
class="resource-card"
:to="`/listings/${item.id}`"
>
<div class="resource-cover">
<img
v-if="item.cover_url"
:src="item.cover_url"
:alt="item.title"
/>
<span v-else>HFB</span>
</div>
<div class="resource-main">
<div class="resource-title-line">
<strong>{{ item.title }}</strong>
<em>{{ item.status === 'published' ? '可租' : listingStatusLabel(item.status) }}</em>
</div>
<p>
{{
item.description || '号主暂未填写说明,可下单后按平台流程交接。'
}}
</p>
<div class="resource-tags">
<span>{{ item.server_region }}</span>
<span>{{ item.login_platform }}</span>
<span>{{ item.rank_level || '未填写段位' }}</span>
<span>哈夫币 {{ item.haf_coin_amount }}</span>
</div>
</div>
<div class="resource-price-box">
<span>价格</span>
<strong>¥{{ listingPrice(item).toFixed(2) }}</strong>
<em>押金 ¥{{ item.deposit_amount }}</em>
</div>
</RouterLink>
</div>
<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>