修改一下礼物相关列显示

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 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 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 listCoinSellers({ page: 1, page_size: 10, region_id: 7, status: "active" });
await createCoinSeller({ commandId: "coin-seller-test", reason: "contact", targetUserId: 1001 }); await createCoinSeller({ commandId: "coin-seller-test", reason: "contact", targetUserId: 1001 });
await setCoinSellerStatus(1001, { commandId: "coin-seller-status-test", reason: "disable", status: "disabled" }); 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(agencyUrl)).toContain("parent_bd_user_id=31");
expect(String(hostUrl)).toContain("/api/v1/admin/hosts?"); expect(String(hostUrl)).toContain("/api/v1/admin/hosts?");
expect(String(hostUrl)).toContain("agency_id=41"); 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("/api/v1/admin/coin-sellers?");
expect(String(coinSellerUrl)).toContain("region_id=7"); expect(String(coinSellerUrl)).toContain("region_id=7");
expect(String(coinSellerUrl)).toContain("status=active"); expect(String(coinSellerUrl)).toContain("status=active");

View File

@ -14,15 +14,18 @@ export function useHostHostsPage() {
const [status, setStatus] = useState(""); const [status, setStatus] = useState("");
const [regionId, setRegionId] = useState(""); const [regionId, setRegionId] = useState("");
const [agencyId, setAgencyId] = useState(""); const [agencyId, setAgencyId] = useState("");
const [diamondSortDirection, setDiamondSortDirection] = useState("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const filters = useMemo( const filters = useMemo(
() => ({ () => ({
agency_id: agencyId, agency_id: agencyId,
keyword: query, keyword: query,
region_id: regionId, region_id: regionId,
sort_by: diamondSortDirection ? "diamond" : "",
sort_direction: diamondSortDirection,
status, status,
}), }),
[agencyId, query, regionId, status], [agencyId, diamondSortDirection, query, regionId, status],
); );
const { const {
data = emptyData, data = emptyData,
@ -58,11 +61,17 @@ export function useHostHostsPage() {
setPage(1); setPage(1);
}; };
const toggleDiamondSort = () => {
setDiamondSortDirection((current) => (current === "desc" ? "asc" : "desc"));
setPage(1);
};
const resetFilters = () => { const resetFilters = () => {
setQuery(""); setQuery("");
setStatus(""); setStatus("");
setRegionId(""); setRegionId("");
setAgencyId(""); setAgencyId("");
setDiamondSortDirection("");
setPage(1); setPage(1);
}; };
@ -74,6 +83,7 @@ export function useHostHostsPage() {
changeRegionId, changeRegionId,
changeStatus, changeStatus,
data, data,
diamondSortDirection,
error, error,
loading, loading,
loadingRegions, loadingRegions,
@ -85,5 +95,6 @@ export function useHostHostsPage() {
resetFilters, resetFilters,
setPage, setPage,
status, 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 { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { formatMillis } from "@/shared/utils/time.js"; import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx"; import { DataState } from "@/shared/ui/DataState.jsx";
import { hostSourceLabels, hostStatusFilters } from "@/features/host-org/constants.js"; 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 { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
import { useHostHostsPage } from "@/features/host-org/hooks/useHostHostsPage.js"; import { useHostHostsPage } from "@/features/host-org/hooks/useHostHostsPage.js";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js"; import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
@ -36,21 +39,8 @@ const hostColumns = [
{ {
key: "currentAgencyId", key: "currentAgencyId",
label: "当前 Agency", label: "当前 Agency",
render: (item) => ( render: (item) => <CurrentAgencyOwner item={item} />,
<HostOrgEntity id={item.currentAgencyId || ""} name={item.currentAgencyName || ""} prefix="Agency " /> width: "minmax(220px, 1.1fr)",
),
width: "minmax(190px, 1fr)",
},
{
key: "currentMembershipId",
label: "当前 Membership",
render: (item) =>
item.currentMembershipId ? (
<HostOrgEntity id={item.currentMembershipId} name="Membership" prefix="" />
) : (
"-"
),
width: "minmax(170px, 0.9fr)",
}, },
{ {
key: "source", 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; return column;
}); });
@ -120,7 +121,7 @@ export function HostHostsPage() {
columns={columns} columns={columns}
context={{ regionOptions: page.regionOptions }} context={{ regionOptions: page.regionOptions }}
items={items} items={items}
minWidth="1350px" minWidth="1240px"
pagination={ pagination={
total > 0 total > 0
? { ? {
@ -144,6 +145,28 @@ function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN"); 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 }) { function HostUser({ item }) {
return ( return (
<HostOrgPerson <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 }) { function HostStatusSwitch({ item }) {
return ( return (
<AdminSwitch <AdminSwitch

View File

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

View File

@ -84,7 +84,6 @@ test("resource APIs use generated admin paths", async () => {
effectiveToMs: 0, effectiveToMs: 0,
effectTypes: ["animation"], effectTypes: ["animation"],
giftId: "rose", giftId: "rose",
giftPointAmount: 1,
giftTypeCode: "normal", giftTypeCode: "normal",
name: "Rose", name: "Rose",
presentationJson: "{}", presentationJson: "{}",
@ -135,7 +134,6 @@ test("resource APIs use generated admin paths", async () => {
effectiveToMs: 0, effectiveToMs: 0,
effectTypes: ["animation"], effectTypes: ["animation"],
giftId: "rose", giftId: "rose",
giftPointAmount: 1,
giftTypeCode: "normal", giftTypeCode: "normal",
name: "Rose", name: "Rose",
presentationJson: "{}", presentationJson: "{}",

View File

@ -15,7 +15,6 @@ export interface ResourceDto {
walletAssetAmount?: number; walletAssetAmount?: number;
priceType?: string; priceType?: string;
coinPrice?: number; coinPrice?: number;
giftPointAmount?: number;
badgeForm?: string; badgeForm?: string;
badgeKind?: string; badgeKind?: string;
levelTrack?: string; levelTrack?: string;
@ -127,7 +126,6 @@ export interface GiftDto {
giftTypeCode?: string; giftTypeCode?: string;
chargeAssetType?: string; chargeAssetType?: string;
coinPrice?: number; coinPrice?: number;
giftPointAmount?: number;
effectiveFromMs?: number; effectiveFromMs?: number;
effectiveToMs?: number; effectiveToMs?: number;
effectTypes?: string[]; effectTypes?: string[];
@ -160,7 +158,6 @@ export interface GiftPayload {
giftTypeCode: string; giftTypeCode: string;
chargeAssetType: string; chargeAssetType: string;
coinPrice: number; coinPrice: number;
giftPointAmount: number;
effectiveAtMs: number; effectiveAtMs: number;
effectiveFromMs: number; effectiveFromMs: number;
effectiveToMs: number; effectiveToMs: number;

View File

@ -111,7 +111,6 @@ const emptyGiftForm = (gift = {}) => ({
effectiveTo: msToDatetimeLocal(gift.effectiveToMs), effectiveTo: msToDatetimeLocal(gift.effectiveToMs),
enabled: gift.status ? gift.status === "active" : true, enabled: gift.status ? gift.status === "active" : true,
giftId: gift.giftId || "", giftId: gift.giftId || "",
giftPointAmount: gift.giftPointAmount === 0 || gift.giftPointAmount ? String(gift.giftPointAmount) : "0",
giftTypeCode: gift.giftTypeCode || "normal", giftTypeCode: gift.giftTypeCode || "normal",
name: gift.name || "", name: gift.name || "",
presentationJson: gift.presentationJson || "{}", presentationJson: gift.presentationJson || "{}",
@ -139,7 +138,6 @@ export function applyGiftPriceDefaults(form, coinPrice) {
return { return {
...form, ...form,
coinPrice: priceValue, coinPrice: priceValue,
giftPointAmount: priceValue,
}; };
} }
@ -737,11 +735,14 @@ export function useGiftListPage() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [status, setStatus] = useState(""); const [status, setStatus] = useState("");
const [regionId, setRegionId] = useState(""); const [regionId, setRegionId] = useState("");
const [giftTypeCode, setGiftTypeCode] = useState("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [activeAction, setActiveAction] = useState(""); const [activeAction, setActiveAction] = useState("");
const [form, setForm] = useState(emptyGiftForm); const [form, setForm] = useState(emptyGiftForm);
const [selectedGift, setSelectedGift] = useState(null); const [selectedGift, setSelectedGift] = useState(null);
const [loadingAction, setLoadingAction] = useState(""); const [loadingAction, setLoadingAction] = useState("");
const [giftSortSavingIds, setGiftSortSavingIds] = useState({});
const [giftRowPatches, setGiftRowPatches] = useState({});
const [giftTypes, setGiftTypes] = useState(defaultGiftTypeOptions); const [giftTypes, setGiftTypes] = useState(defaultGiftTypeOptions);
const [giftTypeForm, setGiftTypeForm] = useState({ items: defaultGiftTypeOptions.map(giftTypeToForm) }); const [giftTypeForm, setGiftTypeForm] = useState({ items: defaultGiftTypeOptions.map(giftTypeToForm) });
const [giftTypeDialogOpen, setGiftTypeDialogOpen] = useState(false); const [giftTypeDialogOpen, setGiftTypeDialogOpen] = useState(false);
@ -751,11 +752,12 @@ export function useGiftListPage() {
const { loadingRegions, regionOptions } = useRegionOptions(); const { loadingRegions, regionOptions } = useRegionOptions();
const filters = useMemo( const filters = useMemo(
() => ({ () => ({
gift_type_code: giftTypeCode,
keyword: query, keyword: query,
region_id: regionId, region_id: regionId,
status, status,
}), }),
[query, regionId, status], [giftTypeCode, query, regionId, status],
); );
const result = usePaginatedQuery({ const result = usePaginatedQuery({
errorMessage: "加载礼物列表失败", errorMessage: "加载礼物列表失败",
@ -765,6 +767,17 @@ export function useGiftListPage() {
pageSize, pageSize,
queryKey: ["gifts", filters, page], 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(() => { useEffect(() => {
let ignore = false; let ignore = false;
@ -956,17 +969,27 @@ export function useGiftListPage() {
if (sortOrder === Number(gift.sortOrder || 0)) { if (sortOrder === Number(gift.sortOrder || 0)) {
return true; return true;
} }
setLoadingAction(`gift-sort-${gift.giftId}`); setGiftSortSavingIds((current) => ({ ...current, [gift.giftId]: true }));
try { try {
await updateGift(gift.giftId, buildGiftInlineSortPayload(gift, sortOrder)); await updateGift(gift.giftId, buildGiftInlineSortPayload(gift, sortOrder));
setGiftRowPatches((current) => ({
...current,
[gift.giftId]: {
...(current[gift.giftId] || {}),
sortOrder,
},
}));
showToast("排序已保存", "success"); showToast("排序已保存", "success");
await result.reload();
return true; return true;
} catch (err) { } catch (err) {
showToast(err.message || "保存排序失败", "error"); showToast(err.message || "保存排序失败", "error");
return false; return false;
} finally { } finally {
setLoadingAction(""); setGiftSortSavingIds((current) => {
const next = { ...current };
delete next[gift.giftId];
return next;
});
} }
}; };
@ -974,19 +997,24 @@ export function useGiftListPage() {
setQuery(""); setQuery("");
setStatus(""); setStatus("");
setRegionId(""); setRegionId("");
setGiftTypeCode("");
setPage(1); setPage(1);
}; };
return { return {
...sharedPageState({ page, query, result, setPage, setQuery }), ...sharedPageState({ page, query, result, setPage, setQuery }),
data: giftData,
abilities, abilities,
activeAction, activeAction,
changeGiftPrice, changeGiftPrice,
changeRegionId: resetSetter(setRegionId, setPage), changeRegionId: resetSetter(setRegionId, setPage),
changeStatus: resetSetter(setStatus, setPage), changeStatus: resetSetter(setStatus, setPage),
changeGiftTypeCode: resetSetter(setGiftTypeCode, setPage),
closeAction, closeAction,
closeGiftTypeDialog, closeGiftTypeDialog,
form, form,
giftSortSavingIds,
giftTypeCode,
giftTypeDialogOpen, giftTypeDialogOpen,
giftTypeForm, giftTypeForm,
giftTypeOptions: giftTypes, giftTypeOptions: giftTypes,
@ -1442,7 +1470,6 @@ function buildGiftPayload(form) {
effectiveToMs: datetimeLocalToMs(form.effectiveTo), effectiveToMs: datetimeLocalToMs(form.effectiveTo),
effectTypes: form.effectTypes || [], effectTypes: form.effectTypes || [],
giftId: form.giftId.trim(), giftId: form.giftId.trim(),
giftPointAmount: Number(form.coinPrice || 0),
giftTypeCode: form.giftTypeCode, giftTypeCode: form.giftTypeCode,
name: form.name.trim(), name: form.name.trim(),
presentationJson: form.presentationJson?.trim() || "{}", presentationJson: form.presentationJson?.trim() || "{}",
@ -1463,7 +1490,6 @@ function buildGiftInlineSortPayload(gift, sortOrder) {
effectiveToMs: Number(gift.effectiveToMs || 0), effectiveToMs: Number(gift.effectiveToMs || 0),
effectTypes: Array.isArray(gift.effectTypes) ? gift.effectTypes : [], effectTypes: Array.isArray(gift.effectTypes) ? gift.effectTypes : [],
giftId: String(gift.giftId || "").trim(), giftId: String(gift.giftId || "").trim(),
giftPointAmount: Number(gift.giftPointAmount ?? gift.coinPrice ?? 0),
giftTypeCode: gift.giftTypeCode || "normal", giftTypeCode: gift.giftTypeCode || "normal",
name: String(gift.name || gift.resource?.name || gift.giftId || "").trim(), name: String(gift.name || gift.resource?.name || gift.giftId || "").trim(),
presentationJson: String(gift.presentationJson || "{}").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 = { const form = {
coinPrice: "", coinPrice: "",
giftPointAmount: "0",
name: "Rose", name: "Rose",
}; };
expect(applyGiftPriceDefaults(form, "100")).toMatchObject({ expect(applyGiftPriceDefaults(form, "100")).toMatchObject({
coinPrice: "100", coinPrice: "100",
giftPointAmount: "100",
}); });
expect(applyGiftPriceDefaults(form, "101")).toMatchObject({ expect(applyGiftPriceDefaults(form, "101")).toMatchObject({
coinPrice: "101", coinPrice: "101",
giftPointAmount: "101",
}); });
}); });

View File

@ -11,7 +11,7 @@ import MenuItem from "@mui/material/MenuItem";
import Popover from "@mui/material/Popover"; import Popover from "@mui/material/Popover";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { import {
AdminFormDialog, AdminFormDialog,
AdminFormFieldGrid, AdminFormFieldGrid,
@ -47,6 +47,12 @@ const effectLabels = Object.fromEntries(effectOptions);
const weekLabels = ["一", "二", "三", "四", "五", "六", "日"]; const weekLabels = ["一", "二", "三", "四", "五", "六", "日"];
const baseColumns = (giftTypeOptions) => [ const baseColumns = (giftTypeOptions) => [
{
key: "id",
label: "ID",
width: "minmax(88px, 0.4fr)",
render: (gift) => gift.giftId || "-",
},
{ {
key: "gift", key: "gift",
label: "礼物", label: "礼物",
@ -54,27 +60,16 @@ const baseColumns = (giftTypeOptions) => [
render: (gift) => <GiftIdentity gift={gift} />, render: (gift) => <GiftIdentity gift={gift} />,
}, },
{ {
key: "resource", key: "type",
label: "资源", label: "类型",
width: "minmax(220px, 1.1fr)", width: "minmax(130px, 0.6fr)",
render: (gift) => <ResourceRef gift={gift} />, render: (gift) => giftTypeLabel(gift.giftTypeCode, giftTypeOptions),
}, },
{ {
key: "price", key: "price",
label: "类型 / 价格", label: "价格",
width: "minmax(150px, 0.75fr)", width: "minmax(130px, 0.6fr)",
render: (gift) => ( render: (gift) => giftPriceLabel(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),
}, },
{ {
key: "regions", key: "regions",
@ -92,8 +87,11 @@ const baseColumns = (giftTypeOptions) => [
width: "minmax(210px, 1.1fr)", width: "minmax(210px, 1.1fr)",
render: (gift) => ( render: (gift) => (
<div className={styles.stack}> <div className={styles.stack}>
<span>{effectiveRangeLabel(gift)}</span> {giftEffectDetails(gift).map((item, index) => (
<span className={styles.meta}>{effectTypesLabel(gift.effectTypes)}</span> <span className={index > 0 ? styles.meta : undefined} key={item}>
{item}
</span>
))}
</div> </div>
), ),
}, },
@ -148,6 +146,22 @@ export function GiftListPage() {
render: (gift) => <GiftStatusSwitch gift={gift} page={page} />, 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") { if (column.key === "sortOrder") {
return { return {
...column, ...column,
@ -196,7 +210,7 @@ export function GiftListPage() {
<DataTable <DataTable
columns={tableColumns} columns={tableColumns}
items={items} items={items}
minWidth="1560px" minWidth="1440px"
pagination={ pagination={
total > 0 total > 0
? { ? {
@ -264,10 +278,11 @@ function GiftStatusSwitch({ gift, page }) {
function GiftSortOrderCell({ gift, page }) { function GiftSortOrderCell({ gift, page }) {
const originalValue = String(gift.sortOrder ?? 0); 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 editable = page.abilities.canUpdateGift && !saving;
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [value, setValue] = useState(originalValue); const [value, setValue] = useState(originalValue);
const skipBlurRef = useRef(false);
useEffect(() => { useEffect(() => {
if (!editing) { if (!editing) {
@ -275,16 +290,14 @@ function GiftSortOrderCell({ gift, page }) {
} }
}, [editing, originalValue]); }, [editing, originalValue]);
const save = async () => { const save = () => {
const nextValue = String(value || "0").trim() || "0"; if (skipBlurRef.current) {
const saved = await page.saveGiftSortOrder(gift, nextValue); skipBlurRef.current = false;
if (saved) {
setEditing(false);
setValue(nextValue);
return; return;
} }
setValue(originalValue); const nextValue = String(value || "0").trim() || "0";
setEditing(false); setEditing(false);
page.saveGiftSortOrder(gift, nextValue);
}; };
if (!editing) { if (!editing) {
@ -324,6 +337,7 @@ function GiftSortOrderCell({ gift, page }) {
} }
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault(); event.preventDefault();
skipBlurRef.current = true;
setValue(originalValue); setValue(originalValue);
setEditing(false); setEditing(false);
} }
@ -394,7 +408,6 @@ function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
<AdminFormSection title="价格与排序"> <AdminFormSection title="价格与排序">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))"> <AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField disabled label="价格" required type="number" value={form.coinPrice} /> <TextField disabled label="价格" required type="number" value={form.coinPrice} />
<TextField disabled label="积分" type="number" value={form.giftPointAmount} />
<TextField <TextField
disabled={disabled} disabled={disabled}
label="排序" label="排序"
@ -651,29 +664,20 @@ function RegionTags({ regionIds = [], regionLabelById }) {
function GiftIdentity({ gift }) { function GiftIdentity({ gift }) {
const preview = imageURL(gift.resource?.previewUrl || gift.resource?.assetUrl); const preview = imageURL(gift.resource?.previewUrl || gift.resource?.assetUrl);
const resourceCode = String(gift.resource?.resourceCode || "").trim();
const title = `${gift.name || "-"}${resourceCode ? `${resourceCode}` : ""}`;
return ( return (
<div className={styles.identity}> <div className={styles.identity}>
<span className={styles.thumb}> <span className={styles.thumb}>
{preview ? <img src={preview} alt="" /> : <CardGiftcardOutlined fontSize="small" />} {preview ? <img src={preview} alt="" /> : <CardGiftcardOutlined fontSize="small" />}
</span> </span>
<div className={styles.identityText}> <div className={styles.identityText}>
<span className={styles.name}>{gift.name || "-"}</span> <span className={styles.name}>{title}</span>
<span className={styles.meta}>{gift.giftId}</span>
</div> </div>
</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) { function imageURL(value) {
const url = String(value || "").trim(); const url = String(value || "").trim();
if (!url) { if (!url) {
@ -691,9 +695,6 @@ function giftTypeLabel(value, options = defaultGiftTypeOptions) {
} }
function giftPriceLabel(gift) { function giftPriceLabel(gift) {
if (Number(gift.coinPrice || 0) === 0) {
return "免费";
}
return `金币 · ${formatNumber(gift.coinPrice)}`; return `金币 · ${formatNumber(gift.coinPrice)}`;
} }
@ -706,6 +707,19 @@ function effectTypesLabel(values) {
return list.length ? list.map(effectTypeLabel).join("、") : "无特效"; 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) { function parseLocalDateTime(value) {
const normalizedValue = String(value || "").trim(); const normalizedValue = String(value || "").trim();
if (!normalizedValue) { if (!normalizedValue) {
@ -763,7 +777,7 @@ function twoDigits(value) {
function effectiveRangeLabel(gift) { function effectiveRangeLabel(gift) {
if (!gift.effectiveFromMs && !gift.effectiveToMs) { if (!gift.effectiveFromMs && !gift.effectiveToMs) {
return "长期有效"; return "";
} }
return `${shortDateTime(gift.effectiveFromMs) || "不限"} - ${shortDateTime(gift.effectiveToMs) || "不限"}`; return `${shortDateTime(gift.effectiveFromMs) || "不限"} - ${shortDateTime(gift.effectiveToMs) || "不限"}`;
} }

View File

@ -175,7 +175,6 @@ export const giftFormSchema = z
effectiveTo: z.string().optional(), effectiveTo: z.string().optional(),
enabled: z.boolean(), enabled: z.boolean(),
giftId: z.string().trim().min(1, "请输入礼物 ID").max(96, "礼物 ID 不能超过 96 个字符"), giftId: z.string().trim().min(1, "请输入礼物 ID").max(96, "礼物 ID 不能超过 96 个字符"),
giftPointAmount: z.union([z.string(), z.number()]).optional(),
giftTypeCode: z giftTypeCode: z
.string() .string()
.trim() .trim()
@ -192,7 +191,6 @@ export const giftFormSchema = z
.superRefine((value, context) => { .superRefine((value, context) => {
const resourceId = Number(value.resourceId); const resourceId = Number(value.resourceId);
const coinPrice = Number(value.coinPrice); const coinPrice = Number(value.coinPrice);
const giftPointAmount = Number(value.giftPointAmount || 0);
const effectiveFromMs = datetimeLocalToMs(value.effectiveFrom); const effectiveFromMs = datetimeLocalToMs(value.effectiveFrom);
const effectiveToMs = datetimeLocalToMs(value.effectiveTo); const effectiveToMs = datetimeLocalToMs(value.effectiveTo);
@ -210,20 +208,6 @@ export const giftFormSchema = z
path: ["coinPrice"], 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) { if (effectiveFromMs < 0) {
context.addIssue({ context.addIssue({
code: "custom", code: "custom",

View File

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

View File

@ -99,7 +99,7 @@ function normalizeConfig(item: RawConfig): RoomTreasureConfigDto {
appCode: stringValue(item.appCode ?? item.app_code), appCode: stringValue(item.appCode ?? item.app_code),
enabled: Boolean(item.enabled), enabled: Boolean(item.enabled),
configVersion: numberValue(item.configVersion ?? item.config_version), 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), openDelayMs: numberValue(item.openDelayMs ?? item.open_delay_ms),
broadcastEnabled: Boolean(item.broadcastEnabled ?? item.broadcast_enabled), broadcastEnabled: Boolean(item.broadcastEnabled ?? item.broadcast_enabled),
broadcastScope: stringValue(item.broadcastScope ?? item.broadcast_scope) || "region", 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[] { function normalizeLevels(levels?: RawLevel[]): RoomTreasureLevelDto[] {
const byLevel = new Map<number, RoomTreasureLevelDto>(); const byLevel = new Map<number, RoomTreasureLevelDto>();
for (const level of levels || []) { 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"; import styles from "@/features/room-treasure/room-treasure.module.css";
const energySourceOptions = [ const energySourceOptions = [
["gift_point_added", "送礼积分"], ["heat_value", "礼物贡献"],
["heat_value", "礼物热度"],
]; ];
const broadcastScopeOptions = [ const broadcastScopeOptions = [

View File

@ -87,7 +87,7 @@ export function emptyRoomTreasureForm() {
broadcastEnabled: true, broadcastEnabled: true,
broadcastScope: "region", broadcastScope: "region",
enabled: false, enabled: false,
energySource: "gift_point_added", energySource: "heat_value",
giftEnergyRules: [], giftEnergyRules: [],
levels: Array.from({ length: 7 }, (_, index) => ({ levels: Array.from({ length: 7 }, (_, index) => ({
animationUrl: "", animationUrl: "",

View File

@ -37,7 +37,7 @@ export const roomTreasureConfigFormSchema = z
broadcastEnabled: z.boolean(), broadcastEnabled: z.boolean(),
broadcastScope: z.enum(["none", "region", "global"]), broadcastScope: z.enum(["none", "region", "global"]),
enabled: z.boolean(), enabled: z.boolean(),
energySource: z.enum(["gift_point_added", "heat_value"]), energySource: z.literal("heat_value"),
giftEnergyRules: z.array(giftEnergyRuleSchema), giftEnergyRules: z.array(giftEnergyRuleSchema),
levels: z.array(levelSchema).length(7, "必须配置 7 级宝箱"), levels: z.array(levelSchema).length(7, "必须配置 7 级宝箱"),
openDelayMs: numberInput, 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[0][1]?.headers).toMatchObject({ Authorization: "Bearer old-token" });
expect(vi.mocked(fetch).mock.calls[1][1]?.headers).toMatchObject({ Authorization: "Bearer new-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 accessToken = window.localStorage.getItem(TOKEN_KEY) || "";
let selectedAppCode = normalizeAppCode(window.localStorage.getItem(APP_CODE_KEY) || "lalu"); let selectedAppCode = normalizeAppCode(window.localStorage.getItem(APP_CODE_KEY) || "lalu");
let refreshHandler: (() => Promise<boolean>) | null = null; let refreshHandler: (() => Promise<boolean>) | null = null;
let refreshInFlight: Promise<boolean> | null = null;
let unauthorizedHandler: (() => void) | null = null; let unauthorizedHandler: (() => void) | null = null;
export type QueryValue = string | number | boolean | null | undefined; 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) { if ((response.status === 401 || response.status === 403) && retry && refreshHandler && !skipAuth) {
const refreshed = await refreshHandler(); const refreshed = await refreshOnce();
if (refreshed) { if (refreshed) {
return apiRequest(path, { ...options, retry: false }); return apiRequest(path, { ...options, retry: false });
} }
@ -134,6 +135,19 @@ export async function apiRequest<TData = unknown, TBody = unknown>(
return payload.data as TData; 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) { function resolveErrorMessage(response: Response, message?: string) {
if (response.status === 404 && (!message || message.includes("404 page not found"))) { if (response.status === 404 && (!message || message.includes("404 page not found"))) {
return "接口不存在或后端服务未更新"; return "接口不存在或后端服务未更新";

View File

@ -422,6 +422,10 @@ export interface HostProfileDto {
createdAtMs?: number; createdAtMs?: number;
currentAgencyId?: number; currentAgencyId?: number;
currentAgencyName?: string; currentAgencyName?: string;
currentAgencyOwnerAvatar?: string;
currentAgencyOwnerDisplayUserId?: string;
currentAgencyOwnerUserId?: number;
currentAgencyOwnerUsername?: string;
currentMembershipId?: number; currentMembershipId?: number;
diamond?: number; diamond?: number;
displayUserId?: string; displayUserId?: string;