{
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;