diff --git a/frontend/src/features/admin/composables/useAdminListState.ts b/frontend/src/features/admin/composables/useAdminListState.ts new file mode 100644 index 0000000..b646ba1 --- /dev/null +++ b/frontend/src/features/admin/composables/useAdminListState.ts @@ -0,0 +1,127 @@ +import { onActivated, onBeforeUnmount, onDeactivated, onMounted, watch, type Ref } from 'vue' +import { onBeforeRouteLeave } from 'vue-router' + +interface AdminListStateOptions { + key: string + filters: TFilters + currentPage: Ref + pageSize: Ref + scrollSelector?: string +} + +interface StoredAdminListState { + filters?: TFilters + currentPage?: number + pageSize?: number + scrollTop?: number +} + +/** + * 在同一后台标签页内保留列表筛选、分页与滚动位置。 + * 数据列表本身仍由页面按当前筛选重新请求,避免展示过期数据。 + */ +export function useAdminListState( + options: AdminListStateOptions +) { + const storageKey = `hfb.admin.list-state.${options.key}` + const scrollSelector = options.scrollSelector || '.admin-main' + let stateSaveTimer: number | undefined + let scrollSaveTimer: number | undefined + + function readState() { + if (typeof window === 'undefined') return null + try { + const raw = window.sessionStorage.getItem(storageKey) + if (!raw) return null + const state = JSON.parse(raw) as StoredAdminListState + return state && typeof state === 'object' ? state : null + } catch { + return null + } + } + + function scrollContainer() { + return document.querySelector(scrollSelector) + } + + function saveState() { + if (typeof window === 'undefined') return + try { + const state: StoredAdminListState = { + filters: { ...options.filters }, + currentPage: options.currentPage.value, + pageSize: options.pageSize.value, + scrollTop: scrollContainer()?.scrollTop || 0, + } + window.sessionStorage.setItem(storageKey, JSON.stringify(state)) + } catch { + // sessionStorage 不可用时仅不恢复状态,不影响列表使用。 + } + } + + function restoreState() { + const state = readState() + if (!state) return + if (state.filters && typeof state.filters === 'object') { + Object.assign(options.filters, state.filters) + } + if (typeof state.currentPage === 'number' && state.currentPage > 0) { + options.currentPage.value = state.currentPage + } + if (typeof state.pageSize === 'number' && state.pageSize > 0) { + options.pageSize.value = state.pageSize + } + } + + function restoreScroll() { + const scrollTop = Number(readState()?.scrollTop || 0) + if (!Number.isFinite(scrollTop) || scrollTop <= 0) return + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const container = scrollContainer() + if (container) container.scrollTop = scrollTop + }) + }) + } + + function scheduleSave() { + if (stateSaveTimer !== undefined) window.clearTimeout(stateSaveTimer) + stateSaveTimer = window.setTimeout(saveState, 200) + } + + function handleScroll() { + if (scrollSaveTimer !== undefined) window.clearTimeout(scrollSaveTimer) + scrollSaveTimer = window.setTimeout(saveState, 300) + } + + // 在页面首次请求前恢复筛选和分页。 + restoreState() + + watch([options.filters, options.currentPage, options.pageSize], scheduleSave, { deep: true }) + + onMounted(() => { + scrollContainer()?.addEventListener('scroll', handleScroll, { passive: true }) + }) + + onActivated(() => { + scrollContainer()?.addEventListener('scroll', handleScroll, { passive: true }) + }) + + onDeactivated(() => { + saveState() + scrollContainer()?.removeEventListener('scroll', handleScroll) + }) + + onBeforeRouteLeave(() => { + saveState() + }) + + onBeforeUnmount(() => { + saveState() + scrollContainer()?.removeEventListener('scroll', handleScroll) + if (stateSaveTimer !== undefined) window.clearTimeout(stateSaveTimer) + if (scrollSaveTimer !== undefined) window.clearTimeout(scrollSaveTimer) + }) + + return { restoreScroll, saveState } +} diff --git a/frontend/src/features/admin/views/AdminOrderDetailView.vue b/frontend/src/features/admin/views/AdminOrderDetailView.vue index 9c0a5ce..145ca73 100644 --- a/frontend/src/features/admin/views/AdminOrderDetailView.vue +++ b/frontend/src/features/admin/views/AdminOrderDetailView.vue @@ -2,7 +2,7 @@ import { readError } from '@/shared/utils/error' import { ElMessage, ElMessageBox } from 'element-plus' import { computed, onMounted, ref } from 'vue' -import { useRoute } from 'vue-router' +import { useRoute, useRouter } from 'vue-router' import { adminCloseOrder, @@ -33,7 +33,7 @@ import { readSnapshot, readSnapshotResources, } from '@/features/orders/composables/useOrderSnapshot' -import { adminPath } from '@/shared/utils/adminPath' +import { adminPath, isAdminPath } from '@/shared/utils/adminPath' import { centToYuan, formatCentWithSymbol } from '@/shared/utils/money' import { disputeStatusLabel, @@ -46,6 +46,7 @@ import { formatDateTime } from '@/shared/utils/time' import { formatGameName, formatListingNo } from '@/shared/utils/listingDisplay' const route = useRoute() +const router = useRouter() const loading = ref(false) const submitting = ref(false) const order = ref(null) @@ -122,6 +123,15 @@ const returnLabel = computed(() => ? '返回会话' : '返回列表' ) + +function goBackToList() { + const back = (window.history.state as { back?: string | null } | null)?.back + if (back && isAdminPath(back)) { + router.back() + return + } + void router.push(returnTarget.value) +} const refunding = ref(false) const snapshotText = computed(() => JSON.stringify(order.value?.account_snapshot || {}, null, 2)) @@ -910,9 +920,7 @@ function paymentPaidAt(record: AdminPayment) {

- - {{ returnLabel }} - + {{ returnLabel }} {{ resetActionLabel }} diff --git a/frontend/src/features/admin/views/AdminOrdersView.vue b/frontend/src/features/admin/views/AdminOrdersView.vue index 2fe16d7..f9f48b2 100644 --- a/frontend/src/features/admin/views/AdminOrdersView.vue +++ b/frontend/src/features/admin/views/AdminOrdersView.vue @@ -13,6 +13,7 @@ import { formatDateTime } from '@/shared/utils/time' import { centToYuan, formatMoney } from '@/shared/utils/money' import { formatListingNo } from '@/shared/utils/listingDisplay' import { adminPath } from '@/shared/utils/adminPath' +import { useAdminListState } from '@/features/admin/composables/useAdminListState' import AdminTablePagination from '../components/AdminTablePagination.vue' const filters = reactive({ @@ -31,6 +32,12 @@ const currentPageSize = ref(20) const route = useRoute() const router = useRouter() const canReturnToWithdrawals = computed(() => route.query.from === 'withdrawals') +const { restoreScroll } = useAdminListState({ + key: 'orders', + filters, + currentPage, + pageSize: currentPageSize, +}) const orderStatusOptions = [ { label: '待支付', value: 'pending_payment' }, @@ -77,13 +84,14 @@ const settlementStatusOptions = [ { label: '已仲裁', value: 'arbitrated' }, ] as const -onMounted(() => { +onMounted(async () => { // 支持从提现审核等页面跳转预填号主/租客搜索 const keyword = route.query.keyword if (typeof keyword === 'string' && keyword.trim()) { filters.keyword = keyword.trim() } - void loadOrders() + await loadOrders() + restoreScroll() }) async function loadOrders() { @@ -325,7 +333,9 @@ function isPlatformManaged(row: Order) {