From dcf5ea204597accf1a78342aad6d5302c856fe61 Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 5 Jun 2026 14:57:44 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=B8=80=E4=B8=8B=E7=A4=BC?= =?UTF-8?q?=E7=89=A9=E7=9B=B8=E5=85=B3=E5=88=97=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/host-org/api.test.ts | 4 +- .../host-org/hooks/useHostHostsPage.js | 13 ++- src/features/host-org/pages/HostHostsPage.jsx | 76 +++++++++--- .../operations/pages/CoinLedgerPage.jsx | 2 - src/features/resources/api.test.ts | 2 - src/features/resources/api.ts | 3 - .../resources/hooks/useResourcePages.js | 42 +++++-- .../resources/hooks/useResourcePages.test.js | 5 +- src/features/resources/pages/GiftListPage.jsx | 108 ++++++++++-------- src/features/resources/schema.js | 16 --- src/features/resources/schema.test.ts | 1 - src/features/room-treasure/api.ts | 10 +- .../components/RoomTreasureConfigEditor.jsx | 3 +- .../hooks/useRoomTreasurePage.js | 2 +- src/features/room-treasure/schema.ts | 2 +- src/shared/api/request.test.ts | 29 +++++ src/shared/api/request.ts | 16 ++- src/shared/api/types.ts | 4 + 18 files changed, 230 insertions(+), 108 deletions(-) diff --git a/src/features/host-org/api.test.ts b/src/features/host-org/api.test.ts index 5194bf2..b3066a3 100644 --- a/src/features/host-org/api.test.ts +++ b/src/features/host-org/api.test.ts @@ -32,7 +32,7 @@ test("host org list APIs use generated admin paths and filters", async () => { await listBDs({ page: 1, page_size: 10, parent_leader_user_id: 21, region_id: 7, status: "active" }); await listAgencies({ keyword: "agency", page: 2, page_size: 10, parent_bd_user_id: 31 }); - await listHosts({ agency_id: 41, page: 1, page_size: 10 }); + await listHosts({ agency_id: 41, page: 1, page_size: 10, sort_by: "diamond", sort_direction: "desc" }); await listCoinSellers({ page: 1, page_size: 10, region_id: 7, status: "active" }); await createCoinSeller({ commandId: "coin-seller-test", reason: "contact", targetUserId: 1001 }); await setCoinSellerStatus(1001, { commandId: "coin-seller-status-test", reason: "disable", status: "disabled" }); @@ -60,6 +60,8 @@ test("host org list APIs use generated admin paths and filters", async () => { expect(String(agencyUrl)).toContain("parent_bd_user_id=31"); expect(String(hostUrl)).toContain("/api/v1/admin/hosts?"); expect(String(hostUrl)).toContain("agency_id=41"); + expect(String(hostUrl)).toContain("sort_by=diamond"); + expect(String(hostUrl)).toContain("sort_direction=desc"); expect(String(coinSellerUrl)).toContain("/api/v1/admin/coin-sellers?"); expect(String(coinSellerUrl)).toContain("region_id=7"); expect(String(coinSellerUrl)).toContain("status=active"); diff --git a/src/features/host-org/hooks/useHostHostsPage.js b/src/features/host-org/hooks/useHostHostsPage.js index 555442a..5e1f405 100644 --- a/src/features/host-org/hooks/useHostHostsPage.js +++ b/src/features/host-org/hooks/useHostHostsPage.js @@ -14,15 +14,18 @@ export function useHostHostsPage() { const [status, setStatus] = useState(""); const [regionId, setRegionId] = useState(""); const [agencyId, setAgencyId] = useState(""); + const [diamondSortDirection, setDiamondSortDirection] = useState(""); const [page, setPage] = useState(1); const filters = useMemo( () => ({ agency_id: agencyId, keyword: query, region_id: regionId, + sort_by: diamondSortDirection ? "diamond" : "", + sort_direction: diamondSortDirection, status, }), - [agencyId, query, regionId, status], + [agencyId, diamondSortDirection, query, regionId, status], ); const { data = emptyData, @@ -58,11 +61,17 @@ export function useHostHostsPage() { setPage(1); }; + const toggleDiamondSort = () => { + setDiamondSortDirection((current) => (current === "desc" ? "asc" : "desc")); + setPage(1); + }; + const resetFilters = () => { setQuery(""); setStatus(""); setRegionId(""); setAgencyId(""); + setDiamondSortDirection(""); setPage(1); }; @@ -74,6 +83,7 @@ export function useHostHostsPage() { changeRegionId, changeStatus, data, + diamondSortDirection, error, loading, loadingRegions, @@ -85,5 +95,6 @@ export function useHostHostsPage() { resetFilters, setPage, status, + toggleDiamondSort, }; } diff --git a/src/features/host-org/pages/HostHostsPage.jsx b/src/features/host-org/pages/HostHostsPage.jsx index 0c88132..5d3630b 100644 --- a/src/features/host-org/pages/HostHostsPage.jsx +++ b/src/features/host-org/pages/HostHostsPage.jsx @@ -1,8 +1,11 @@ +import ArrowDownwardOutlined from "@mui/icons-material/ArrowDownwardOutlined"; +import ArrowUpwardOutlined from "@mui/icons-material/ArrowUpwardOutlined"; +import SwapVertOutlined from "@mui/icons-material/SwapVertOutlined"; import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import { formatMillis } from "@/shared/utils/time.js"; import { DataState } from "@/shared/ui/DataState.jsx"; import { hostSourceLabels, hostStatusFilters } from "@/features/host-org/constants.js"; -import { HostOrgEntity, HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx"; +import { HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx"; import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx"; import { useHostHostsPage } from "@/features/host-org/hooks/useHostHostsPage.js"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; @@ -36,21 +39,8 @@ const hostColumns = [ { key: "currentAgencyId", label: "当前 Agency", - render: (item) => ( - - ), - width: "minmax(190px, 1fr)", - }, - { - key: "currentMembershipId", - label: "当前 Membership", - render: (item) => - item.currentMembershipId ? ( - - ) : ( - "-" - ), - width: "minmax(170px, 0.9fr)", + render: (item) => , + width: "minmax(220px, 1.1fr)", }, { key: "source", @@ -108,6 +98,17 @@ export function HostHostsPage() { }), }; } + if (column.key === "diamond") { + return { + ...column, + header: ( + + ), + }; + } return column; }); @@ -120,7 +121,7 @@ export function HostHostsPage() { columns={columns} context={{ regionOptions: page.regionOptions }} items={items} - minWidth="1350px" + minWidth="1240px" pagination={ total > 0 ? { @@ -144,6 +145,28 @@ function formatNumber(value) { return Number(value || 0).toLocaleString("zh-CN"); } +function DiamondSortHeader({ direction, onToggle }) { + const SortIcon = + direction === "asc" ? ArrowUpwardOutlined : direction === "desc" ? ArrowDownwardOutlined : SwapVertOutlined; + const directionLabel = direction === "asc" ? "升序" : "降序"; + const nextDirectionLabel = direction === "desc" ? "升序" : "降序"; + return ( + + ); +} + function HostUser({ item }) { return ( + ); +} + function HostStatusSwitch({ item }) { return ( { effectiveToMs: 0, effectTypes: ["animation"], giftId: "rose", - giftPointAmount: 1, giftTypeCode: "normal", name: "Rose", presentationJson: "{}", @@ -135,7 +134,6 @@ test("resource APIs use generated admin paths", async () => { effectiveToMs: 0, effectTypes: ["animation"], giftId: "rose", - giftPointAmount: 1, giftTypeCode: "normal", name: "Rose", presentationJson: "{}", diff --git a/src/features/resources/api.ts b/src/features/resources/api.ts index 8ccd369..c4d14eb 100644 --- a/src/features/resources/api.ts +++ b/src/features/resources/api.ts @@ -15,7 +15,6 @@ export interface ResourceDto { walletAssetAmount?: number; priceType?: string; coinPrice?: number; - giftPointAmount?: number; badgeForm?: string; badgeKind?: string; levelTrack?: string; @@ -127,7 +126,6 @@ export interface GiftDto { giftTypeCode?: string; chargeAssetType?: string; coinPrice?: number; - giftPointAmount?: number; effectiveFromMs?: number; effectiveToMs?: number; effectTypes?: string[]; @@ -160,7 +158,6 @@ export interface GiftPayload { giftTypeCode: string; chargeAssetType: string; coinPrice: number; - giftPointAmount: number; effectiveAtMs: number; effectiveFromMs: number; effectiveToMs: number; diff --git a/src/features/resources/hooks/useResourcePages.js b/src/features/resources/hooks/useResourcePages.js index 88d8013..8918935 100644 --- a/src/features/resources/hooks/useResourcePages.js +++ b/src/features/resources/hooks/useResourcePages.js @@ -111,7 +111,6 @@ const emptyGiftForm = (gift = {}) => ({ effectiveTo: msToDatetimeLocal(gift.effectiveToMs), enabled: gift.status ? gift.status === "active" : true, giftId: gift.giftId || "", - giftPointAmount: gift.giftPointAmount === 0 || gift.giftPointAmount ? String(gift.giftPointAmount) : "0", giftTypeCode: gift.giftTypeCode || "normal", name: gift.name || "", presentationJson: gift.presentationJson || "{}", @@ -139,7 +138,6 @@ export function applyGiftPriceDefaults(form, coinPrice) { return { ...form, coinPrice: priceValue, - giftPointAmount: priceValue, }; } @@ -737,11 +735,14 @@ export function useGiftListPage() { const [query, setQuery] = useState(""); const [status, setStatus] = useState(""); const [regionId, setRegionId] = useState(""); + const [giftTypeCode, setGiftTypeCode] = useState(""); const [page, setPage] = useState(1); const [activeAction, setActiveAction] = useState(""); const [form, setForm] = useState(emptyGiftForm); const [selectedGift, setSelectedGift] = useState(null); const [loadingAction, setLoadingAction] = useState(""); + const [giftSortSavingIds, setGiftSortSavingIds] = useState({}); + const [giftRowPatches, setGiftRowPatches] = useState({}); const [giftTypes, setGiftTypes] = useState(defaultGiftTypeOptions); const [giftTypeForm, setGiftTypeForm] = useState({ items: defaultGiftTypeOptions.map(giftTypeToForm) }); const [giftTypeDialogOpen, setGiftTypeDialogOpen] = useState(false); @@ -751,11 +752,12 @@ export function useGiftListPage() { const { loadingRegions, regionOptions } = useRegionOptions(); const filters = useMemo( () => ({ + gift_type_code: giftTypeCode, keyword: query, region_id: regionId, status, }), - [query, regionId, status], + [giftTypeCode, query, regionId, status], ); const result = usePaginatedQuery({ errorMessage: "加载礼物列表失败", @@ -765,6 +767,17 @@ export function useGiftListPage() { pageSize, queryKey: ["gifts", filters, page], }); + const giftData = useMemo(() => { + const source = result.data || emptyData; + const sourceItems = Array.isArray(source.items) ? source.items : []; + return { + ...source, + items: sourceItems.map((item) => ({ + ...item, + ...(giftRowPatches[item.giftId] || {}), + })), + }; + }, [giftRowPatches, result.data]); useEffect(() => { let ignore = false; @@ -956,17 +969,27 @@ export function useGiftListPage() { if (sortOrder === Number(gift.sortOrder || 0)) { return true; } - setLoadingAction(`gift-sort-${gift.giftId}`); + setGiftSortSavingIds((current) => ({ ...current, [gift.giftId]: true })); try { await updateGift(gift.giftId, buildGiftInlineSortPayload(gift, sortOrder)); + setGiftRowPatches((current) => ({ + ...current, + [gift.giftId]: { + ...(current[gift.giftId] || {}), + sortOrder, + }, + })); showToast("排序已保存", "success"); - await result.reload(); return true; } catch (err) { showToast(err.message || "保存排序失败", "error"); return false; } finally { - setLoadingAction(""); + setGiftSortSavingIds((current) => { + const next = { ...current }; + delete next[gift.giftId]; + return next; + }); } }; @@ -974,19 +997,24 @@ export function useGiftListPage() { setQuery(""); setStatus(""); setRegionId(""); + setGiftTypeCode(""); setPage(1); }; return { ...sharedPageState({ page, query, result, setPage, setQuery }), + data: giftData, abilities, activeAction, changeGiftPrice, changeRegionId: resetSetter(setRegionId, setPage), changeStatus: resetSetter(setStatus, setPage), + changeGiftTypeCode: resetSetter(setGiftTypeCode, setPage), closeAction, closeGiftTypeDialog, form, + giftSortSavingIds, + giftTypeCode, giftTypeDialogOpen, giftTypeForm, giftTypeOptions: giftTypes, @@ -1442,7 +1470,6 @@ function buildGiftPayload(form) { effectiveToMs: datetimeLocalToMs(form.effectiveTo), effectTypes: form.effectTypes || [], giftId: form.giftId.trim(), - giftPointAmount: Number(form.coinPrice || 0), giftTypeCode: form.giftTypeCode, name: form.name.trim(), presentationJson: form.presentationJson?.trim() || "{}", @@ -1463,7 +1490,6 @@ function buildGiftInlineSortPayload(gift, sortOrder) { effectiveToMs: Number(gift.effectiveToMs || 0), effectTypes: Array.isArray(gift.effectTypes) ? gift.effectTypes : [], giftId: String(gift.giftId || "").trim(), - giftPointAmount: Number(gift.giftPointAmount ?? gift.coinPrice ?? 0), giftTypeCode: gift.giftTypeCode || "normal", name: String(gift.name || gift.resource?.name || gift.giftId || "").trim(), presentationJson: String(gift.presentationJson || "{}").trim() || "{}", diff --git a/src/features/resources/hooks/useResourcePages.test.js b/src/features/resources/hooks/useResourcePages.test.js index ca8c335..0fef6a8 100644 --- a/src/features/resources/hooks/useResourcePages.test.js +++ b/src/features/resources/hooks/useResourcePages.test.js @@ -25,19 +25,16 @@ test("fills gift name and id from selected gift resource", () => { }); }); -test("fills gift points from gift price", () => { +test("fills gift price without gift points", () => { const form = { coinPrice: "", - giftPointAmount: "0", name: "Rose", }; expect(applyGiftPriceDefaults(form, "100")).toMatchObject({ coinPrice: "100", - giftPointAmount: "100", }); expect(applyGiftPriceDefaults(form, "101")).toMatchObject({ coinPrice: "101", - giftPointAmount: "101", }); }); diff --git a/src/features/resources/pages/GiftListPage.jsx b/src/features/resources/pages/GiftListPage.jsx index ec70bea..d8cae4f 100644 --- a/src/features/resources/pages/GiftListPage.jsx +++ b/src/features/resources/pages/GiftListPage.jsx @@ -11,7 +11,7 @@ import MenuItem from "@mui/material/MenuItem"; import Popover from "@mui/material/Popover"; import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import TextField from "@mui/material/TextField"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { AdminFormDialog, AdminFormFieldGrid, @@ -47,6 +47,12 @@ const effectLabels = Object.fromEntries(effectOptions); const weekLabels = ["一", "二", "三", "四", "五", "六", "日"]; const baseColumns = (giftTypeOptions) => [ + { + key: "id", + label: "ID", + width: "minmax(88px, 0.4fr)", + render: (gift) => gift.giftId || "-", + }, { key: "gift", label: "礼物", @@ -54,27 +60,16 @@ const baseColumns = (giftTypeOptions) => [ render: (gift) => , }, { - key: "resource", - label: "资源", - width: "minmax(220px, 1.1fr)", - render: (gift) => , + key: "type", + label: "类型", + width: "minmax(130px, 0.6fr)", + render: (gift) => giftTypeLabel(gift.giftTypeCode, giftTypeOptions), }, { key: "price", - label: "类型 / 价格", - width: "minmax(150px, 0.75fr)", - render: (gift) => ( -
- {giftTypeLabel(gift.giftTypeCode, giftTypeOptions)} - {giftPriceLabel(gift)} -
- ), - }, - { - key: "income", - label: "积分", - width: "minmax(140px, 0.8fr)", - render: (gift) => formatNumber(gift.giftPointAmount), + label: "价格", + width: "minmax(130px, 0.6fr)", + render: (gift) => giftPriceLabel(gift), }, { key: "regions", @@ -92,8 +87,11 @@ const baseColumns = (giftTypeOptions) => [ width: "minmax(210px, 1.1fr)", render: (gift) => (
- {effectiveRangeLabel(gift)} - {effectTypesLabel(gift.effectTypes)} + {giftEffectDetails(gift).map((item, index) => ( + 0 ? styles.meta : undefined} key={item}> + {item} + + ))}
), }, @@ -148,6 +146,22 @@ export function GiftListPage() { render: (gift) => , }; } + if (column.key === "type") { + return { + ...column, + filter: createOptionsColumnFilter({ + emptyLabel: "全部类型", + loading: page.giftTypesLoading, + options: giftTypeOptions.map((item) => ({ + label: item.displayName || item.tabName || item.tabKey, + value: item.tabKey, + })), + placeholder: "搜索类型", + value: page.giftTypeCode, + onChange: page.changeGiftTypeCode, + }), + }; + } if (column.key === "sortOrder") { return { ...column, @@ -196,7 +210,7 @@ export function GiftListPage() { 0 ? { @@ -264,10 +278,11 @@ function GiftStatusSwitch({ gift, page }) { function GiftSortOrderCell({ gift, page }) { const originalValue = String(gift.sortOrder ?? 0); - const saving = page.loadingAction === `gift-sort-${gift.giftId}`; + const saving = Boolean(page.giftSortSavingIds?.[gift.giftId]); const editable = page.abilities.canUpdateGift && !saving; const [editing, setEditing] = useState(false); const [value, setValue] = useState(originalValue); + const skipBlurRef = useRef(false); useEffect(() => { if (!editing) { @@ -275,16 +290,14 @@ function GiftSortOrderCell({ gift, page }) { } }, [editing, originalValue]); - const save = async () => { - const nextValue = String(value || "0").trim() || "0"; - const saved = await page.saveGiftSortOrder(gift, nextValue); - if (saved) { - setEditing(false); - setValue(nextValue); + const save = () => { + if (skipBlurRef.current) { + skipBlurRef.current = false; return; } - setValue(originalValue); + const nextValue = String(value || "0").trim() || "0"; setEditing(false); + page.saveGiftSortOrder(gift, nextValue); }; if (!editing) { @@ -324,6 +337,7 @@ function GiftSortOrderCell({ gift, page }) { } if (event.key === "Escape") { event.preventDefault(); + skipBlurRef.current = true; setValue(originalValue); setEditing(false); } @@ -394,7 +408,6 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open - {preview ? : }
- {gift.name || "-"} - {gift.giftId} + {title}
); } -function ResourceRef({ gift }) { - const resource = gift.resource || {}; - return ( -
- {resource.name || "-"} - {resource.resourceCode || gift.resourceId || "-"} -
- ); -} - function imageURL(value) { const url = String(value || "").trim(); if (!url) { @@ -691,9 +695,6 @@ function giftTypeLabel(value, options = defaultGiftTypeOptions) { } function giftPriceLabel(gift) { - if (Number(gift.coinPrice || 0) === 0) { - return "免费"; - } return `金币 · ${formatNumber(gift.coinPrice)}`; } @@ -706,6 +707,19 @@ function effectTypesLabel(values) { return list.length ? list.map(effectTypeLabel).join("、") : "无特效"; } +function giftEffectDetails(gift) { + const details = []; + const effective = effectiveRangeLabel(gift); + if (effective) { + details.push(effective); + } + const effects = effectTypesLabel(gift.effectTypes); + if (effects !== "无特效") { + details.push(effects); + } + return details.length ? details : ["-"]; +} + function parseLocalDateTime(value) { const normalizedValue = String(value || "").trim(); if (!normalizedValue) { @@ -763,7 +777,7 @@ function twoDigits(value) { function effectiveRangeLabel(gift) { if (!gift.effectiveFromMs && !gift.effectiveToMs) { - return "长期有效"; + return ""; } return `${shortDateTime(gift.effectiveFromMs) || "不限"} - ${shortDateTime(gift.effectiveToMs) || "不限"}`; } diff --git a/src/features/resources/schema.js b/src/features/resources/schema.js index f184f25..28ab24a 100644 --- a/src/features/resources/schema.js +++ b/src/features/resources/schema.js @@ -175,7 +175,6 @@ export const giftFormSchema = z effectiveTo: z.string().optional(), enabled: z.boolean(), giftId: z.string().trim().min(1, "请输入礼物 ID").max(96, "礼物 ID 不能超过 96 个字符"), - giftPointAmount: z.union([z.string(), z.number()]).optional(), giftTypeCode: z .string() .trim() @@ -192,7 +191,6 @@ export const giftFormSchema = z .superRefine((value, context) => { const resourceId = Number(value.resourceId); const coinPrice = Number(value.coinPrice); - const giftPointAmount = Number(value.giftPointAmount || 0); const effectiveFromMs = datetimeLocalToMs(value.effectiveFrom); const effectiveToMs = datetimeLocalToMs(value.effectiveTo); @@ -210,20 +208,6 @@ export const giftFormSchema = z path: ["coinPrice"], }); } - if (!Number.isInteger(giftPointAmount) || giftPointAmount < 0) { - context.addIssue({ - code: "custom", - message: "请输入有效积分", - path: ["giftPointAmount"], - }); - } - if (Number.isInteger(coinPrice) && Number.isInteger(giftPointAmount) && giftPointAmount !== coinPrice) { - context.addIssue({ - code: "custom", - message: "积分必须同步价格", - path: ["giftPointAmount"], - }); - } if (effectiveFromMs < 0) { context.addIssue({ code: "custom", diff --git a/src/features/resources/schema.test.ts b/src/features/resources/schema.test.ts index 424b9d8..bf2dc18 100644 --- a/src/features/resources/schema.test.ts +++ b/src/features/resources/schema.test.ts @@ -128,7 +128,6 @@ describe("resource form schema", () => { effectiveTo: "", enabled: true, giftId: "rose", - giftPointAmount: "10", giftTypeCode: "normal", name: "Rose", presentationJson: "{}", diff --git a/src/features/room-treasure/api.ts b/src/features/room-treasure/api.ts index 2fe55e7..2348798 100644 --- a/src/features/room-treasure/api.ts +++ b/src/features/room-treasure/api.ts @@ -99,7 +99,7 @@ function normalizeConfig(item: RawConfig): RoomTreasureConfigDto { appCode: stringValue(item.appCode ?? item.app_code), enabled: Boolean(item.enabled), configVersion: numberValue(item.configVersion ?? item.config_version), - energySource: stringValue(item.energySource ?? item.energy_source) || "gift_point_added", + energySource: normalizeEnergySource(item.energySource ?? item.energy_source), openDelayMs: numberValue(item.openDelayMs ?? item.open_delay_ms), broadcastEnabled: Boolean(item.broadcastEnabled ?? item.broadcast_enabled), broadcastScope: stringValue(item.broadcastScope ?? item.broadcast_scope) || "region", @@ -113,6 +113,14 @@ function normalizeConfig(item: RawConfig): RoomTreasureConfigDto { }; } +function normalizeEnergySource(value: unknown) { + const source = stringValue(value); + if (!source || source === "gift_point_added") { + return "heat_value"; + } + return source; +} + function normalizeLevels(levels?: RawLevel[]): RoomTreasureLevelDto[] { const byLevel = new Map(); for (const level of levels || []) { diff --git a/src/features/room-treasure/components/RoomTreasureConfigEditor.jsx b/src/features/room-treasure/components/RoomTreasureConfigEditor.jsx index b1719ad..0661c92 100644 --- a/src/features/room-treasure/components/RoomTreasureConfigEditor.jsx +++ b/src/features/room-treasure/components/RoomTreasureConfigEditor.jsx @@ -16,8 +16,7 @@ import { UploadField } from "@/shared/ui/UploadField.jsx"; import styles from "@/features/room-treasure/room-treasure.module.css"; const energySourceOptions = [ - ["gift_point_added", "送礼积分"], - ["heat_value", "礼物热度"], + ["heat_value", "礼物贡献"], ]; const broadcastScopeOptions = [ diff --git a/src/features/room-treasure/hooks/useRoomTreasurePage.js b/src/features/room-treasure/hooks/useRoomTreasurePage.js index aa8abc0..454475e 100644 --- a/src/features/room-treasure/hooks/useRoomTreasurePage.js +++ b/src/features/room-treasure/hooks/useRoomTreasurePage.js @@ -87,7 +87,7 @@ export function emptyRoomTreasureForm() { broadcastEnabled: true, broadcastScope: "region", enabled: false, - energySource: "gift_point_added", + energySource: "heat_value", giftEnergyRules: [], levels: Array.from({ length: 7 }, (_, index) => ({ animationUrl: "", diff --git a/src/features/room-treasure/schema.ts b/src/features/room-treasure/schema.ts index d8cbfe5..693501d 100644 --- a/src/features/room-treasure/schema.ts +++ b/src/features/room-treasure/schema.ts @@ -37,7 +37,7 @@ export const roomTreasureConfigFormSchema = z broadcastEnabled: z.boolean(), broadcastScope: z.enum(["none", "region", "global"]), enabled: z.boolean(), - energySource: z.enum(["gift_point_added", "heat_value"]), + energySource: z.literal("heat_value"), giftEnergyRules: z.array(giftEnergyRuleSchema), levels: z.array(levelSchema).length(7, "必须配置 7 级宝箱"), openDelayMs: numberInput, diff --git a/src/shared/api/request.test.ts b/src/shared/api/request.test.ts index e4b74ee..ca38592 100644 --- a/src/shared/api/request.test.ts +++ b/src/shared/api/request.test.ts @@ -30,3 +30,32 @@ test("retries once after refreshing a stale permission token on 403", async () = expect(vi.mocked(fetch).mock.calls[0][1]?.headers).toMatchObject({ Authorization: "Bearer old-token" }); expect(vi.mocked(fetch).mock.calls[1][1]?.headers).toMatchObject({ Authorization: "Bearer new-token" }); }); + +test("shares one refresh request across concurrent stale access token responses", async () => { + setAccessToken("old-token"); + let refreshCount = 0; + setRefreshHandler(async () => { + refreshCount += 1; + await new Promise((resolve) => window.setTimeout(resolve, 0)); + setAccessToken("new-token"); + return true; + }); + vi.stubGlobal( + "fetch", + vi.fn(async (_url, init) => { + const auth = (init?.headers as Record | undefined)?.Authorization; + if (auth === "Bearer old-token") { + return new Response(JSON.stringify({ code: 40100, message: "访问凭证已失效" }), { status: 401 }); + } + return new Response(JSON.stringify({ code: 0, data: { ok: true } })); + }) + ); + + await expect(Promise.all([apiRequest("/v1/admin/countries"), apiRequest("/v1/admin/regions")])).resolves.toEqual([ + { ok: true }, + { ok: true } + ]); + + expect(refreshCount).toBe(1); + expect(fetch).toHaveBeenCalledTimes(4); +}); diff --git a/src/shared/api/request.ts b/src/shared/api/request.ts index 187f98e..d2e9140 100644 --- a/src/shared/api/request.ts +++ b/src/shared/api/request.ts @@ -7,6 +7,7 @@ const APP_CODE_HEADER = "X-App-Code"; let accessToken = window.localStorage.getItem(TOKEN_KEY) || ""; let selectedAppCode = normalizeAppCode(window.localStorage.getItem(APP_CODE_KEY) || "lalu"); let refreshHandler: (() => Promise) | null = null; +let refreshInFlight: Promise | null = null; let unauthorizedHandler: (() => void) | null = null; export type QueryValue = string | number | boolean | null | undefined; @@ -109,7 +110,7 @@ export async function apiRequest( }); if ((response.status === 401 || response.status === 403) && retry && refreshHandler && !skipAuth) { - const refreshed = await refreshHandler(); + const refreshed = await refreshOnce(); if (refreshed) { return apiRequest(path, { ...options, retry: false }); } @@ -134,6 +135,19 @@ export async function apiRequest( return payload.data as TData; } +function refreshOnce() { + if (!refreshHandler) { + return Promise.resolve(false); + } + if (!refreshInFlight) { + const handler = refreshHandler; + refreshInFlight = handler().finally(() => { + refreshInFlight = null; + }); + } + return refreshInFlight; +} + function resolveErrorMessage(response: Response, message?: string) { if (response.status === 404 && (!message || message.includes("404 page not found"))) { return "接口不存在或后端服务未更新"; diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index 387a8f0..e6de788 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -422,6 +422,10 @@ export interface HostProfileDto { createdAtMs?: number; currentAgencyId?: number; currentAgencyName?: string; + currentAgencyOwnerAvatar?: string; + currentAgencyOwnerDisplayUserId?: string; + currentAgencyOwnerUserId?: number; + currentAgencyOwnerUsername?: string; currentMembershipId?: number; diamond?: number; displayUserId?: string;