修改一下礼物相关列显示

This commit is contained in:
zhx 2026-06-05 14:57:44 +08:00
parent 39d7e527cb
commit dcf5ea2045
18 changed files with 230 additions and 108 deletions

View File

@ -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");

View File

@ -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,
};
}

View File

@ -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) => (
<HostOrgEntity id={item.currentAgencyId || ""} name={item.currentAgencyName || ""} prefix="Agency " />
),
width: "minmax(190px, 1fr)",
},
{
key: "currentMembershipId",
label: "当前 Membership",
render: (item) =>
item.currentMembershipId ? (
<HostOrgEntity id={item.currentMembershipId} name="Membership" prefix="" />
) : (
"-"
),
width: "minmax(170px, 0.9fr)",
render: (item) => <CurrentAgencyOwner item={item} />,
width: "minmax(220px, 1.1fr)",
},
{
key: "source",
@ -108,6 +98,17 @@ export function HostHostsPage() {
}),
};
}
if (column.key === "diamond") {
return {
...column,
header: (
<DiamondSortHeader
direction={page.diamondSortDirection}
onToggle={page.toggleDiamondSort}
/>
),
};
}
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 (
<button
aria-label={`按钻石${nextDirectionLabel}排序`}
className={["admin-cell__head-trigger", direction ? "admin-cell__head-trigger--active" : ""]
.filter(Boolean)
.join(" ")}
type="button"
onClick={onToggle}
>
<SortIcon className="admin-cell__head-icon" fontSize="inherit" />
<span className="admin-cell__head-label">
钻石{direction ? <span className="admin-cell__head-filter">({directionLabel})</span> : null}
</span>
</button>
);
}
function HostUser({ item }) {
return (
<HostOrgPerson
@ -155,6 +178,25 @@ function HostUser({ item }) {
);
}
function CurrentAgencyOwner({ item }) {
if (
!item.currentAgencyId &&
!item.currentAgencyOwnerUserId &&
!item.currentAgencyOwnerDisplayUserId &&
!item.currentAgencyOwnerUsername
) {
return "-";
}
return (
<HostOrgPerson
avatar={item.currentAgencyOwnerAvatar}
displayUserId={item.currentAgencyOwnerDisplayUserId}
fallbackId={item.currentAgencyOwnerUserId}
username={item.currentAgencyOwnerUsername || item.currentAgencyName}
/>
);
}
function HostStatusSwitch({ item }) {
return (
<AdminSwitch

View File

@ -73,7 +73,6 @@ const metadataLabels = {
gift_count: "礼物数量",
gift_id: "礼物 ID",
gift_name: "礼物名称",
gift_point_added: "礼物积分",
heat_value: "热度",
operator_user_id: "操作人",
op_type: "操作类型",
@ -335,7 +334,6 @@ function businessContextRows(entry, metadata) {
metadataRow("赠送人", metadata, ["sender_user_id", "senderUserId"], entry.userId),
metadataRow("房间 ID", metadata, ["room_id", "roomId"], entry.roomId),
metadataRow("消耗金币", metadata, ["coin_spent", "coinSpent", "charge_amount", "chargeAmount"]),
metadataRow("礼物积分", metadata, ["gift_point_added", "giftPointAdded"]),
metadataRow("热度", metadata, ["heat_value", "heatValue"]),
metadataRow("账单 ID", metadata, ["billing_receipt_id", "billingReceiptId"]),
metadataRow("价格版本", metadata, ["price_version", "priceVersion"]),

View File

@ -84,7 +84,6 @@ test("resource APIs use generated admin paths", async () => {
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: "{}",

View File

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

View File

@ -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() || "{}",

View File

@ -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",
});
});

View File

@ -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) => <GiftIdentity gift={gift} />,
},
{
key: "resource",
label: "资源",
width: "minmax(220px, 1.1fr)",
render: (gift) => <ResourceRef gift={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) => (
<div className={styles.stack}>
<span>{giftTypeLabel(gift.giftTypeCode, giftTypeOptions)}</span>
<span className={styles.meta}>{giftPriceLabel(gift)}</span>
</div>
),
},
{
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) => (
<div className={styles.stack}>
<span>{effectiveRangeLabel(gift)}</span>
<span className={styles.meta}>{effectTypesLabel(gift.effectTypes)}</span>
{giftEffectDetails(gift).map((item, index) => (
<span className={index > 0 ? styles.meta : undefined} key={item}>
{item}
</span>
))}
</div>
),
},
@ -148,6 +146,22 @@ export function GiftListPage() {
render: (gift) => <GiftStatusSwitch gift={gift} page={page} />,
};
}
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() {
<DataTable
columns={tableColumns}
items={items}
minWidth="1560px"
minWidth="1440px"
pagination={
total > 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
<AdminFormSection title="价格与排序">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField disabled label="价格" required type="number" value={form.coinPrice} />
<TextField disabled label="积分" type="number" value={form.giftPointAmount} />
<TextField
disabled={disabled}
label="排序"
@ -651,29 +664,20 @@ function RegionTags({ regionIds = [], regionLabelById }) {
function GiftIdentity({ gift }) {
const preview = imageURL(gift.resource?.previewUrl || gift.resource?.assetUrl);
const resourceCode = String(gift.resource?.resourceCode || "").trim();
const title = `${gift.name || "-"}${resourceCode ? `${resourceCode}` : ""}`;
return (
<div className={styles.identity}>
<span className={styles.thumb}>
{preview ? <img src={preview} alt="" /> : <CardGiftcardOutlined fontSize="small" />}
</span>
<div className={styles.identityText}>
<span className={styles.name}>{gift.name || "-"}</span>
<span className={styles.meta}>{gift.giftId}</span>
<span className={styles.name}>{title}</span>
</div>
</div>
);
}
function ResourceRef({ gift }) {
const resource = gift.resource || {};
return (
<div className={styles.stack}>
<span>{resource.name || "-"}</span>
<span className={styles.meta}>{resource.resourceCode || gift.resourceId || "-"}</span>
</div>
);
}
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) || "不限"}`;
}

View File

@ -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",

View File

@ -128,7 +128,6 @@ describe("resource form schema", () => {
effectiveTo: "",
enabled: true,
giftId: "rose",
giftPointAmount: "10",
giftTypeCode: "normal",
name: "Rose",
presentationJson: "{}",

View File

@ -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<number, RoomTreasureLevelDto>();
for (const level of levels || []) {

View File

@ -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 = [

View File

@ -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: "",

View File

@ -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,

View File

@ -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<string, string> | 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);
});

View File

@ -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<boolean>) | null = null;
let refreshInFlight: Promise<boolean> | null = null;
let unauthorizedHandler: (() => void) | null = null;
export type QueryValue = string | number | boolean | null | undefined;
@ -109,7 +110,7 @@ export async function apiRequest<TData = unknown, TBody = unknown>(
});
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<TData = unknown, TBody = unknown>(
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 "接口不存在或后端服务未更新";

View File

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