修改一下礼物相关列显示
This commit is contained in:
parent
39d7e527cb
commit
dcf5ea2045
@ -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");
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"]),
|
||||||
|
|||||||
@ -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: "{}",
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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() || "{}",
|
||||||
|
|||||||
@ -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",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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) || "不限"}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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: "{}",
|
||||||
|
|||||||
@ -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 || []) {
|
||||||
|
|||||||
@ -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 = [
|
||||||
|
|||||||
@ -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: "",
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@ -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 "接口不存在或后端服务未更新";
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user