修改一下礼物相关列显示
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 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");
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]),
|
||||
|
||||
@ -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: "{}",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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() || "{}",
|
||||
|
||||
@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@ -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) || "不限"}`;
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -128,7 +128,6 @@ describe("resource form schema", () => {
|
||||
effectiveTo: "",
|
||||
enabled: true,
|
||||
giftId: "rose",
|
||||
giftPointAmount: "10",
|
||||
giftTypeCode: "normal",
|
||||
name: "Rose",
|
||||
presentationJson: "{}",
|
||||
|
||||
@ -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 || []) {
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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: "",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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 "接口不存在或后端服务未更新";
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user