增加道具商店

This commit is contained in:
zhx 2026-05-26 05:17:07 +08:00
parent 03869b3a37
commit 31b6a8e88d
22 changed files with 2725 additions and 1494 deletions

View File

@ -1711,6 +1711,72 @@
"x-permissions": ["resource-grant:create"]
}
},
"/admin/resource-shop/items": {
"get": {
"operationId": "listResourceShopItems",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "resource-shop:view",
"x-permissions": ["resource-shop:view"]
},
"post": {
"operationId": "upsertResourceShopItems",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "resource-shop:update",
"x-permissions": ["resource-shop:update"]
}
},
"/admin/resource-shop/items/{shop_item_id}/disable": {
"post": {
"operationId": "disableResourceShopItem",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "shop_item_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "resource-shop:update",
"x-permissions": ["resource-shop:update"]
}
},
"/admin/resource-shop/items/{shop_item_id}/enable": {
"post": {
"operationId": "enableResourceShopItem",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "shop_item_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "resource-shop:update",
"x-permissions": ["resource-shop:update"]
}
},
"/admin/resource-groups": {
"get": {
"operationId": "listResourceGroups",

View File

@ -24,6 +24,7 @@ import SendOutlined from "@mui/icons-material/SendOutlined";
import ShieldOutlined from "@mui/icons-material/ShieldOutlined";
import SettingsApplicationsOutlined from "@mui/icons-material/SettingsApplicationsOutlined";
import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined";
import StorefrontOutlined from "@mui/icons-material/StorefrontOutlined";
import TaskAltOutlined from "@mui/icons-material/TaskAltOutlined";
import WalletOutlined from "@mui/icons-material/WalletOutlined";
import WorkspacePremiumOutlined from "@mui/icons-material/WorkspacePremiumOutlined";
@ -63,6 +64,7 @@ const iconMap = {
settings: SettingsApplicationsOutlined,
shield: ShieldOutlined,
sports_esports: SportsEsportsOutlined,
storefront: StorefrontOutlined,
task: TaskAltOutlined,
users: ManageAccountsOutlined,
wallet: WalletOutlined,
@ -127,6 +129,7 @@ export const fallbackNavigation = [
label: "资源管理",
children: [
routeNavItem("resource-list", { icon: Inventory2Outlined }),
routeNavItem("resource-shop-list", { icon: StorefrontOutlined }),
routeNavItem("resource-group-list", { icon: CategoryOutlined }),
routeNavItem("gift-list", { icon: CardGiftcardOutlined }),
routeNavItem("resource-grant-list", { icon: SendOutlined }),

View File

@ -83,6 +83,8 @@ export const PERMISSIONS = {
resourceView: "resource:view",
resourceCreate: "resource:create",
resourceUpdate: "resource:update",
resourceShopView: "resource-shop:view",
resourceShopUpdate: "resource-shop:update",
resourceGroupView: "resource-group:view",
resourceGroupCreate: "resource-group:create",
resourceGroupUpdate: "resource-group:update",
@ -150,6 +152,7 @@ export const MENU_CODES = {
appConfigVersions: "app-config-versions",
resources: "resources",
resourceList: "resource-list",
resourceShopList: "resource-shop-list",
resourceGroupList: "resource-group-list",
resourceGrantList: "resource-grant-list",
giftList: "gift-list",

View File

@ -16,7 +16,7 @@ const emptyForm = () => ({
countryCode: "",
coverUrl: "",
description: "",
displayScope: "home",
displayScopes: ["home"],
endsAtMs: "",
param: "",
platform: "android",
@ -179,14 +179,15 @@ export function useBannerConfigPage() {
}
function normalizeForm(form) {
const displayScope = form.displayScope || "home";
const displayScopes = normalizeDisplayScopes(form.displayScopes || form.displayScope);
return {
...form,
countryCode: form.countryCode || "",
displayScope,
displayScope: displayScopes[0] || "home",
displayScopes,
endsAtMs: form.endsAtMs || 0,
regionId: form.regionId || 0,
roomSmallImageUrl: displayScope === "room" ? form.roomSmallImageUrl || "" : "",
roomSmallImageUrl: displayScopes.includes("room") ? form.roomSmallImageUrl || "" : "",
sortOrder: form.sortOrder || 0,
startsAtMs: form.startsAtMs || 0,
};
@ -198,7 +199,7 @@ function formFromBanner(item) {
countryCode: item.countryCode || "",
coverUrl: item.coverUrl || "",
description: item.description || "",
displayScope: item.displayScope || "home",
displayScopes: normalizeDisplayScopes(item.displayScopes || item.displayScope),
endsAtMs: item.endsAtMs || "",
param: item.param || "",
platform: item.platform || "android",
@ -209,3 +210,11 @@ function formFromBanner(item) {
status: item.status || "active",
};
}
function normalizeDisplayScopes(value) {
const values = Array.isArray(value) ? value : String(value || "").split(",");
const known = ["home", "room", "recharge"];
const seen = new Set(values.map((item) => String(item || "").trim()).filter(Boolean));
const normalized = known.filter((scope) => seen.has(scope));
return normalized.length ? normalized : ["home"];
}

View File

@ -2,6 +2,8 @@ import AddOutlined from "@mui/icons-material/AddOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import ImageOutlined from "@mui/icons-material/ImageOutlined";
import Checkbox from "@mui/material/Checkbox";
import ListItemText from "@mui/material/ListItemText";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { useMemo } from "react";
@ -94,7 +96,7 @@ export function BannerConfigPage() {
>
<AdminFormSection title="素材">
<AdminFormFieldGrid
columns={page.form.displayScope === "room" ? "repeat(2, minmax(0, 1fr))" : "1fr"}
columns={hasDisplayScope(page.form.displayScopes, "room") ? "repeat(2, minmax(0, 1fr))" : "1fr"}
>
<UploadField
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
@ -102,7 +104,7 @@ export function BannerConfigPage() {
value={page.form.coverUrl}
onChange={(coverUrl) => page.setForm({ coverUrl })}
/>
{page.form.displayScope === "room" ? (
{hasDisplayScope(page.form.displayScopes, "room") ? (
<UploadField
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
label="房间内小屏图"
@ -153,18 +155,27 @@ export function BannerConfigPage() {
label="显示范围"
required
select
value={page.form.displayScope}
slotProps={{
select: {
multiple: true,
renderValue: (selected) => displayScopeListLabel(selected),
},
}}
value={page.form.displayScopes}
onChange={(event) => {
const displayScope = event.target.value;
const displayScopes = normalizeSelectedDisplayScopes(event.target.value);
page.setForm({
displayScope,
roomSmallImageUrl: displayScope === "room" ? page.form.roomSmallImageUrl : "",
displayScopes,
roomSmallImageUrl: displayScopes.includes("room")
? page.form.roomSmallImageUrl
: "",
});
}}
>
{APP_BANNER_DISPLAY_SCOPE_OPTIONS.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
<Checkbox checked={hasDisplayScope(page.form.displayScopes, option.value)} />
<ListItemText primary={option.label} />
</MenuItem>
))}
</TextField>
@ -259,7 +270,7 @@ function bannerColumns(page) {
{
key: "displayScope",
label: "显示范围",
render: (item) => displayScopeLabel(item.displayScope),
render: (item) => displayScopeListLabel(item.displayScopes || item.displayScope),
filter: createOptionsColumnFilter({
options: displayScopeFilterOptions,
placeholder: "搜索显示范围",
@ -437,6 +448,23 @@ function displayScopeLabel(value) {
return APP_BANNER_DISPLAY_SCOPE_OPTIONS.find((option) => option.value === value)?.label || value || "-";
}
function displayScopeListLabel(value) {
const values = normalizeSelectedDisplayScopes(value);
return values.map((scope) => displayScopeLabel(scope)).join("、") || "-";
}
function normalizeSelectedDisplayScopes(value) {
const values = Array.isArray(value) ? value : String(value || "").split(",");
const allowed = APP_BANNER_DISPLAY_SCOPE_OPTIONS.map((option) => option.value);
const seen = new Set(values.map((item) => String(item || "").trim()).filter(Boolean));
const selected = allowed.filter((scope) => seen.has(scope));
return selected.length ? selected : ["home"];
}
function hasDisplayScope(value, scope) {
return normalizeSelectedDisplayScopes(value).includes(scope);
}
function deliveryTimeLabel(startsAtMs, endsAtMs) {
const start = startsAtMs ? formatMillis(startsAtMs) : "不限";
const end = endsAtMs ? formatMillis(endsAtMs) : "不限";

View File

@ -8,6 +8,7 @@ const validBannerForm = {
coverUrl: "https://media.haiyihy.com/banner/home.png",
description: "",
displayScope: "home",
displayScopes: ["home"],
endsAtMs: "1800000000000",
param: "https://h5.example.com/activity",
platform: "android",
@ -43,16 +44,26 @@ describe("app config form schema", () => {
test("keeps banner display scope and delivery time fields", () => {
const payload = parseForm(appBannerSchema, validBannerForm);
expect(payload.displayScope).toBe("home");
expect(payload.displayScopes).toEqual(["home"]);
expect(payload.startsAtMs).toBe(1700000000000);
expect(payload.endsAtMs).toBe(1800000000000);
});
test("accepts multiple banner display scopes", () => {
const payload = parseForm(appBannerSchema, {
...validBannerForm,
displayScopes: ["home", "room", "recharge"],
roomSmallImageUrl: "https://media.haiyihy.com/banner/room-small.png",
});
expect(payload.displayScopes).toEqual(["home", "room", "recharge"]);
});
test("requires room small image for room banner", () => {
expect(() =>
parseForm(appBannerSchema, {
...validBannerForm,
displayScope: "room",
displayScopes: ["home", "room"],
roomSmallImageUrl: "",
}),
).toThrow(FormValidationError);

View File

@ -1,5 +1,7 @@
import { z } from "zod";
const appBannerDisplayScopes = ["home", "room", "recharge"] as const;
const requiredH5LinkURLSchema = z
.string()
.trim()
@ -46,7 +48,8 @@ export const appBannerSchema = z
.max(1024, "封面图地址不能超过 1024 个字符")
.refine((value) => !/\s/.test(value), "封面图地址不能包含空白字符"),
description: z.string().trim().max(255, "描述不能超过 255 个字符"),
displayScope: z.enum(["home", "room", "recharge"]),
displayScope: z.enum(appBannerDisplayScopes).optional(),
displayScopes: z.array(z.enum(appBannerDisplayScopes)).min(1, "请选择显示范围"),
endsAtMs: z.coerce.number().int().min(0, "投放结束时间不正确").default(0),
param: z.string().trim().max(2048, "参数不能超过 2048 个字符"),
platform: z.enum(["android", "ios"]),
@ -67,7 +70,7 @@ export const appBannerSchema = z
if (value.bannerType === "h5" && /\s/.test(value.param)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 链接不能包含空白字符", path: ["param"] });
}
if (value.displayScope === "room" && !value.roomSmallImageUrl) {
if (value.displayScopes.includes("room") && !value.roomSmallImageUrl) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "房间内显示需要上传房间内小屏图",

View File

@ -5,9 +5,11 @@ import {
createGift,
createResource,
createResourceGroup,
disableResourceShopItem,
disableGift,
disableResource,
disableResourceGroup,
enableResourceShopItem,
enableGift,
enableResource,
enableResourceGroup,
@ -18,11 +20,13 @@ import {
listGiftTypes,
listResourceGrants,
listGifts,
listResourceShopItems,
listResources,
updateGift,
updateGiftTypes,
updateResource,
updateResourceGroup,
upsertResourceShopItems,
} from "./api";
afterEach(() => {
@ -266,6 +270,50 @@ test("emoji pack APIs use generated admin paths", async () => {
});
});
test("resource shop APIs use generated admin paths", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 10, total: 0 } })),
),
);
await listResourceShopItems({ page: 1, page_size: 10, resource_type: "avatar_frame", status: "active" });
await upsertResourceShopItems({
items: [
{
durationDays: 3,
effectiveFromMs: 0,
effectiveToMs: 0,
resourceId: 11,
sortOrder: 0,
status: "active",
},
],
});
await enableResourceShopItem(99);
await disableResourceShopItem(99);
const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0];
const [upsertUrl, upsertInit] = vi.mocked(fetch).mock.calls[1];
const [enableUrl, enableInit] = vi.mocked(fetch).mock.calls[2];
const [disableUrl, disableInit] = vi.mocked(fetch).mock.calls[3];
expect(String(listUrl)).toContain("/api/v1/admin/resource-shop/items?");
expect(String(listUrl)).toContain("resource_type=avatar_frame");
expect(String(listUrl)).toContain("status=active");
expect(listInit?.method).toBe("GET");
expect(String(upsertUrl)).toContain("/api/v1/admin/resource-shop/items");
expect(upsertInit?.method).toBe("POST");
expect(JSON.parse(String(upsertInit?.body))).toMatchObject({
items: [{ durationDays: 3, resourceId: 11, status: "active" }],
});
expect(String(enableUrl)).toContain("/api/v1/admin/resource-shop/items/99/enable");
expect(enableInit?.method).toBe("POST");
expect(String(disableUrl)).toContain("/api/v1/admin/resource-shop/items/99/disable");
expect(disableInit?.method).toBe("POST");
});
test("gift type APIs save all tabs with one request", async () => {
vi.stubGlobal(
"fetch",

View File

@ -93,6 +93,22 @@ export interface ResourceGroupDto {
updatedAtMs?: number;
}
export interface ResourceShopItemDto {
appCode?: string;
shopItemId: number;
resourceId: number;
resource?: ResourceDto;
status?: string;
durationDays?: number;
priceType?: string;
coinPrice?: number;
effectiveFromMs?: number;
effectiveToMs?: number;
sortOrder?: number;
createdAtMs?: number;
updatedAtMs?: number;
}
export interface GiftDto {
appCode?: string;
giftId: string;
@ -241,6 +257,20 @@ export interface ResourceGroupPayload {
items: ResourceGroupItemPayload[];
}
export interface ResourceShopItemPayload {
durationDays: number;
effectiveFromMs: number;
effectiveToMs: number;
resourceId: number;
shopItemId?: number;
sortOrder: number;
status: string;
}
export interface ResourceShopItemsPayload {
items: ResourceShopItemPayload[];
}
export function listResources(query: PageQuery = {}): Promise<ApiPage<ResourceDto>> {
const endpoint = API_ENDPOINTS.listResources;
return apiRequest<ApiPage<ResourceDto>>(apiEndpointPath(API_OPERATIONS.listResources), {
@ -290,6 +320,45 @@ export function listResourceGroups(query: PageQuery = {}): Promise<ApiPage<Resou
});
}
export function listResourceShopItems(query: PageQuery = {}): Promise<ApiPage<ResourceShopItemDto>> {
const endpoint = API_ENDPOINTS.listResourceShopItems;
return apiRequest<ApiPage<ResourceShopItemDto>>(apiEndpointPath(API_OPERATIONS.listResourceShopItems), {
method: endpoint.method,
query,
});
}
export function upsertResourceShopItems(payload: ResourceShopItemsPayload): Promise<ResourceShopItemDto[]> {
const endpoint = API_ENDPOINTS.upsertResourceShopItems;
return apiRequest<ResourceShopItemDto[], ResourceShopItemsPayload>(
apiEndpointPath(API_OPERATIONS.upsertResourceShopItems),
{
body: payload,
method: endpoint.method,
},
);
}
export function enableResourceShopItem(shopItemId: EntityId): Promise<ResourceShopItemDto> {
const endpoint = API_ENDPOINTS.enableResourceShopItem;
return apiRequest<ResourceShopItemDto>(
apiEndpointPath(API_OPERATIONS.enableResourceShopItem, { shop_item_id: shopItemId }),
{
method: endpoint.method,
},
);
}
export function disableResourceShopItem(shopItemId: EntityId): Promise<ResourceShopItemDto> {
const endpoint = API_ENDPOINTS.disableResourceShopItem;
return apiRequest<ResourceShopItemDto>(
apiEndpointPath(API_OPERATIONS.disableResourceShopItem, { shop_item_id: shopItemId }),
{
method: endpoint.method,
},
);
}
export function createResourceGroup(payload: ResourceGroupPayload): Promise<ResourceGroupDto> {
const endpoint = API_ENDPOINTS.createResourceGroup;
return apiRequest<ResourceGroupDto, ResourceGroupPayload>(apiEndpointPath(API_OPERATIONS.createResourceGroup), {

View File

@ -72,6 +72,27 @@ export const resourceTypeFilters = [
export const resourceTypeLabels = Object.fromEntries(resourceTypeFilters.filter(([value]) => value));
export const resourceShopDurationOptions = [
["1", "1天"],
["3", "3天"],
["7", "7天"],
];
export const resourceShopSellableTypes = [
"avatar_frame",
"profile_card",
"vehicle",
"chat_bubble",
"badge",
"mic_seat_icon",
"mic_seat_animation",
];
export const resourceShopTypeFilters = [
["", "全部类型"],
...resourceTypeFilters.filter(([value]) => resourceShopSellableTypes.includes(value)),
];
export const resourceGroupAssetOptions = [
["COIN", "金币"],
["DIAMOND", "钻石"],

View File

@ -8,9 +8,11 @@ import {
createGift,
createResource,
createResourceGroup,
disableResourceShopItem,
disableGift,
disableResource,
disableResourceGroup,
enableResourceShopItem,
enableGift,
enableResource,
enableResourceGroup,
@ -22,13 +24,15 @@ import {
listGifts,
listResourceGrants,
listResourceGroups,
listResourceShopItems,
listResources,
updateGift,
updateGiftTypes,
updateResource,
updateResourceGroup,
upsertResourceShopItems,
} from "@/features/resources/api";
import { defaultGiftTypeOptions } from "@/features/resources/constants.js";
import { defaultGiftTypeOptions, resourceShopSellableTypes } from "@/features/resources/constants.js";
import { useResourceAbilities } from "@/features/resources/permissions.js";
import {
emojiPackFormSchema,
@ -37,6 +41,7 @@ import {
resourceFormSchema,
resourceGrantFormSchema,
resourceGroupCreateFormSchema,
resourceShopItemsFormSchema,
} from "@/features/resources/schema.js";
const pageSize = 50;
@ -308,6 +313,243 @@ export function useResourceListPage() {
};
}
export function useResourceShopPage() {
const abilities = useResourceAbilities();
const { showToast } = useToast();
const [query, setQuery] = useState("");
const [status, setStatus] = useState("");
const [resourceType, setResourceType] = useState("");
const [page, setPage] = useState(1);
const [drawerOpen, setDrawerOpen] = useState(false);
const [drawerQuery, setDrawerQuery] = useState("");
const [drawerResourceType, setDrawerResourceType] = useState("");
const [batchDurationDays, setBatchDurationDays] = useState("1");
const [resourceOptions, setResourceOptions] = useState([]);
const [resourceOptionsLoading, setResourceOptionsLoading] = useState(false);
const [draftByResourceId, setDraftByResourceId] = useState({});
const [loadingAction, setLoadingAction] = useState("");
const filters = useMemo(
() => ({
keyword: query,
resource_type: resourceType,
status,
}),
[query, resourceType, status],
);
const result = usePaginatedQuery({
errorMessage: "加载道具商店失败",
fetcher: listResourceShopItems,
filters,
page,
pageSize,
queryKey: ["resource-shop", filters, page],
});
const shopItemsByResourceId = useMemo(() => {
const items = new Map();
(result.data?.items || []).forEach((item) => {
if (item.resourceId) {
items.set(Number(item.resourceId), item);
}
});
return items;
}, [result.data?.items]);
const selectedDrafts = useMemo(
() =>
Object.values(draftByResourceId)
.map((draft) => ({
...draft,
resource: resourceOptions.find(
(resource) => String(resource.resourceId) === String(draft.resourceId),
),
}))
.sort((left, right) => Number(left.sortOrder || 0) - Number(right.sortOrder || 0)),
[draftByResourceId, resourceOptions],
);
const filteredResourceOptions = useMemo(() => {
const keyword = drawerQuery.trim().toLowerCase();
return resourceOptions.filter((resource) => {
if (drawerResourceType && resource.resourceType !== drawerResourceType) {
return false;
}
if (!keyword) {
return true;
}
return `${resource.name || ""} ${resource.resourceCode || ""}`.toLowerCase().includes(keyword);
});
}, [drawerQuery, drawerResourceType, resourceOptions]);
useEffect(() => {
if (!drawerOpen) {
return undefined;
}
let ignore = false;
setResourceOptionsLoading(true);
listResources({ page: 1, page_size: 100, status: "active" })
.then((data) => {
if (!ignore) {
setResourceOptions(toShopResourceOptions(data.items || []));
}
})
.catch((err) => {
if (!ignore) {
showToast(err.message || "加载售卖资源失败", "error");
}
})
.finally(() => {
if (!ignore) {
setResourceOptionsLoading(false);
}
});
return () => {
ignore = true;
};
}, [drawerOpen, showToast]);
const openShopDrawer = () => {
setDraftByResourceId({});
setDrawerQuery("");
setDrawerResourceType("");
setBatchDurationDays("1");
setDrawerOpen(true);
};
const closeShopDrawer = () => {
setDrawerOpen(false);
setDraftByResourceId({});
};
const toggleDraftResource = (resource) => {
if (!resource?.resourceId) {
return;
}
const resourceId = String(resource.resourceId);
setDraftByResourceId((current) => {
if (current[resourceId]) {
const next = { ...current };
delete next[resourceId];
return next;
}
const existing = shopItemsByResourceId.get(Number(resource.resourceId));
return {
...current,
[resourceId]: {
durationDays: String(existing?.durationDays || batchDurationDays),
effectiveFrom: msToDatetimeLocal(existing?.effectiveFromMs),
effectiveTo: msToDatetimeLocal(existing?.effectiveToMs),
resourceId,
sortOrder: String(existing?.sortOrder ?? Object.keys(current).length),
},
};
});
};
const updateDraftItem = (resourceId, patch) => {
setDraftByResourceId((current) => ({
...current,
[resourceId]: {
...current[resourceId],
...patch,
},
}));
};
const applyBatchDuration = (durationDays) => {
setBatchDurationDays(durationDays);
setDraftByResourceId((current) =>
Object.fromEntries(
Object.entries(current).map(([resourceId, draft]) => [resourceId, { ...draft, durationDays }]),
),
);
};
const submitShopItems = async (event) => {
event.preventDefault();
if (!abilities.canUpdateShop) {
return;
}
setLoadingAction("resource-shop-upsert");
try {
const parsed = parseForm(resourceShopItemsFormSchema, { items: selectedDrafts });
await upsertResourceShopItems({
items: parsed.items.map((item) => ({
durationDays: Number(item.durationDays),
effectiveFromMs: datetimeLocalToMs(item.effectiveFrom),
effectiveToMs: datetimeLocalToMs(item.effectiveTo),
resourceId: Number(item.resourceId),
shopItemId: shopItemsByResourceId.get(Number(item.resourceId))?.shopItemId,
sortOrder: Number(item.sortOrder || 0),
status: "active",
})),
});
showToast("售卖资源已保存", "success");
closeShopDrawer();
await result.reload();
} catch (err) {
showToast(err.message || "保存售卖资源失败", "error");
} finally {
setLoadingAction("");
}
};
const toggleShopItem = async (item, nextEnabled = item.status !== "active") => {
if (!abilities.canUpdateShop || !item?.shopItemId) {
return;
}
if ((item.status === "active") === nextEnabled) {
return;
}
setLoadingAction(`resource-shop-status-${item.shopItemId}`);
try {
if (nextEnabled) {
await enableResourceShopItem(item.shopItemId);
} else {
await disableResourceShopItem(item.shopItemId);
}
showToast(nextEnabled ? "售卖资源已启用" : "售卖资源已禁用", "success");
await result.reload();
} catch (err) {
showToast(err.message || "操作失败", "error");
} finally {
setLoadingAction("");
}
};
const resetFilters = () => {
setQuery("");
setStatus("");
setResourceType("");
setPage(1);
};
return {
...sharedPageState({ page, query, result, setPage, setQuery }),
abilities,
applyBatchDuration,
batchDurationDays,
changeDrawerQuery: setDrawerQuery,
changeDrawerResourceType: setDrawerResourceType,
changeResourceType: resetSetter(setResourceType, setPage),
changeStatus: resetSetter(setStatus, setPage),
closeShopDrawer,
drawerOpen,
drawerQuery,
drawerResourceType,
filteredResourceOptions,
loadingAction,
openShopDrawer,
resourceOptionsLoading,
resourceType,
resetFilters,
selectedDrafts,
selectedResourceIds: new Set(Object.keys(draftByResourceId)),
status,
submitShopItems,
toggleDraftResource,
toggleShopItem,
updateDraftItem,
};
}
export function useResourceGroupListPage() {
const abilities = useResourceAbilities();
const { showToast } = useToast();
@ -1245,6 +1487,16 @@ function toGiftResourceOptions(items, selectedGift) {
);
}
function toShopResourceOptions(items) {
return uniqueResources(items).filter(
(resource) =>
resourceShopSellableTypes.includes(resource.resourceType) &&
resource.status === "active" &&
resource.priceType === "coin" &&
Number(resource.coinPrice || 0) > 0,
);
}
function uniqueResources(resources) {
const seen = new Set();
return resources.filter((resource) => {

View File

@ -0,0 +1,344 @@
import Add from "@mui/icons-material/Add";
import StorefrontOutlined from "@mui/icons-material/StorefrontOutlined";
import Checkbox from "@mui/material/Checkbox";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
AdminActionIconButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import {
resourcePriceTypeLabels,
resourceShopDurationOptions,
resourceShopTypeFilters,
resourceStatusFilters,
resourceTypeLabels,
} from "@/features/resources/constants.js";
import { useResourceShopPage } from "@/features/resources/hooks/useResourcePages.js";
import styles from "@/features/resources/resources.module.css";
const shopColumns = [
{
key: "resource",
label: "售卖资源",
width: "minmax(270px, 1.4fr)",
render: (item) => <ResourceIdentity resource={item.resource} />,
},
{
key: "type",
label: "类型",
width: "minmax(120px, 0.65fr)",
render: (item) => resourceTypeLabels[item.resource?.resourceType] || item.resource?.resourceType || "-",
},
{
key: "sale",
label: "售卖规格",
width: "minmax(150px, 0.8fr)",
render: (item) => <SaleSpec item={item} />,
},
{
key: "effective",
label: "生效时间",
width: "minmax(220px, 1fr)",
render: (item) => <EffectiveTime item={item} />,
},
{
key: "sort",
label: "排序",
width: "minmax(80px, 0.4fr)",
render: (item) => item.sortOrder ?? 0,
},
{
key: "status",
label: "状态",
width: "minmax(92px, 0.5fr)",
},
{
key: "time",
label: "更新时间",
width: "minmax(170px, 0.8fr)",
render: (item) => formatMillis(item.updatedAtMs || item.createdAtMs),
},
];
export function ResourceShopPage() {
const page = useResourceShopPage();
const items = page.data.items || [];
const total = page.data.total || 0;
const columns = shopColumns.map((column) => {
if (column.key === "resource") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索资源名称、资源编码",
value: page.query,
onChange: page.changeQuery,
}),
};
}
if (column.key === "type") {
return {
...column,
filter: createOptionsColumnFilter({
options: resourceShopTypeFilters,
placeholder: "搜索类型",
value: page.resourceType,
onChange: page.changeResourceType,
}),
};
}
if (column.key === "status") {
return {
...column,
filter: createOptionsColumnFilter({
options: resourceStatusFilters,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
render: (item) => <ResourceShopStatusSwitch item={item} page={page} />,
};
}
return column;
});
return (
<AdminListPage>
<AdminListToolbar
actions={
page.abilities.canUpdateShop ? (
<AdminActionIconButton label="添加售卖资源" primary onClick={page.openShopDrawer}>
<Add fontSize="small" />
</AdminActionIconButton>
) : null
}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable
columns={columns}
items={items}
minWidth="1120px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.data.pageSize || 50,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(item) => item.shopItemId}
/>
</AdminListBody>
</DataState>
<ResourceShopDrawer page={page} />
</AdminListPage>
);
}
function ResourceShopDrawer({ page }) {
const saving = page.loadingAction === "resource-shop-upsert";
const disabled = !page.abilities.canUpdateShop || saving;
return (
<SideDrawer
actions={
<>
<Button disabled={saving} onClick={page.closeShopDrawer}>
取消
</Button>
<Button
disabled={disabled || page.resourceOptionsLoading || page.selectedDrafts.length === 0}
type="submit"
variant="primary"
>
保存
</Button>
</>
}
as="form"
open={page.drawerOpen}
title="添加售卖资源"
width="wide"
onClose={saving ? undefined : page.closeShopDrawer}
onSubmit={page.submitShopItems}
>
<div className={styles.shopDrawerTools}>
<TextField
label="搜索资源"
size="small"
value={page.drawerQuery}
onChange={(event) => page.changeDrawerQuery(event.target.value)}
/>
<TextField
label="资源类型"
select
size="small"
value={page.drawerResourceType}
onChange={(event) => page.changeDrawerResourceType(event.target.value)}
>
{resourceShopTypeFilters.map(([value, label]) => (
<MenuItem key={value || "all"} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
label="批量售卖天数"
select
size="small"
value={page.batchDurationDays}
onChange={(event) => page.applyBatchDuration(event.target.value)}
>
{resourceShopDurationOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
</div>
<DataState error={null} loading={page.resourceOptionsLoading} onRetry={page.openShopDrawer}>
{page.filteredResourceOptions.length > 0 ? (
<div className={styles.shopSelectionList}>
{page.filteredResourceOptions.map((resource) => (
<ResourceSelectionRow
disabled={disabled}
key={resource.resourceId}
page={page}
resource={resource}
/>
))}
</div>
) : (
<div className={styles.shopEmpty}>暂无可售卖资源</div>
)}
</DataState>
</SideDrawer>
);
}
function ResourceSelectionRow({ disabled, page, resource }) {
const resourceId = String(resource.resourceId);
const selected = page.selectedResourceIds.has(resourceId);
const draft = selected ? page.selectedDrafts.find((item) => String(item.resourceId) === resourceId) : null;
return (
<div className={styles.shopSelectionRow}>
<Checkbox
checked={selected}
disabled={disabled}
inputProps={{ "aria-label": selected ? "取消选择资源" : "选择资源" }}
onChange={() => page.toggleDraftResource(resource)}
/>
<ResourceIdentity resource={resource} />
<span className={styles.shopBasePrice}>{resourcePriceLabel(resource)}</span>
{draft ? (
<div className={styles.shopSelectionControls}>
<TextField
disabled={disabled}
label="售卖天数"
select
size="small"
value={draft.durationDays}
onChange={(event) => page.updateDraftItem(resourceId, { durationDays: event.target.value })}
>
{resourceShopDurationOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
InputLabelProps={{ shrink: true }}
disabled={disabled}
label="开始时间"
size="small"
type="datetime-local"
value={draft.effectiveFrom}
onChange={(event) => page.updateDraftItem(resourceId, { effectiveFrom: event.target.value })}
/>
<TextField
InputLabelProps={{ shrink: true }}
disabled={disabled}
label="结束时间"
size="small"
type="datetime-local"
value={draft.effectiveTo}
onChange={(event) => page.updateDraftItem(resourceId, { effectiveTo: event.target.value })}
/>
<TextField
disabled={disabled}
label="排序"
size="small"
type="number"
value={draft.sortOrder}
onChange={(event) => page.updateDraftItem(resourceId, { sortOrder: event.target.value })}
/>
</div>
) : null}
</div>
);
}
function ResourceShopStatusSwitch({ item, page }) {
const checked = item.status === "active";
return (
<AdminSwitch
checked={checked}
disabled={!page.abilities.canUpdateShop || page.loadingAction === `resource-shop-status-${item.shopItemId}`}
inputProps={{ "aria-label": checked ? "禁用售卖资源" : "启用售卖资源" }}
onChange={(event) => page.toggleShopItem(item, event.target.checked)}
/>
);
}
function ResourceIdentity({ resource = {} }) {
const imageUrl = resource.previewUrl || resource.assetUrl || resource.animationUrl;
return (
<div className={styles.identity}>
<span className={styles.thumb}>
{imageUrl ? <img alt="" src={imageUrl} /> : <StorefrontOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.name}>{resource.name || "-"}</span>
<span className={styles.meta}>{resource.resourceCode || resource.resourceId || "-"}</span>
</span>
</div>
);
}
function SaleSpec({ item }) {
return (
<div className={styles.stack}>
<span>{item.durationDays || 1}</span>
<span className={styles.meta}>{resourcePriceLabel(item)}</span>
</div>
);
}
function EffectiveTime({ item }) {
const from = item.effectiveFromMs ? formatMillis(item.effectiveFromMs) : "立即生效";
const to = item.effectiveToMs ? formatMillis(item.effectiveToMs) : "长期";
return (
<div className={styles.stack}>
<span>{from}</span>
<span className={styles.meta}>{to}</span>
</div>
);
}
function resourcePriceLabel(item = {}) {
if (item.priceType === "coin" || item.coinPrice > 0) {
return `${item.coinPrice || 0}金币`;
}
return resourcePriceTypeLabels[item.priceType] || "-";
}

View File

@ -11,6 +11,7 @@ export function useResourceAbilities() {
canCreateGroup: can(PERMISSIONS.resourceGroupCreate),
canCreateEmojiPack: can(PERMISSIONS.emojiPackCreate),
canStatusGift: can(PERMISSIONS.giftStatus),
canUpdateShop: can(PERMISSIONS.resourceShopUpdate),
canUpdateGroup: can(PERMISSIONS.resourceGroupUpdate),
canUpdateGift: can(PERMISSIONS.giftUpdate),
canUpdate: can(PERMISSIONS.resourceUpdate),
@ -18,5 +19,6 @@ export function useResourceAbilities() {
canView: can(PERMISSIONS.resourceView),
canViewEmojiPack: can(PERMISSIONS.emojiPackView),
canViewGrant: can(PERMISSIONS.resourceGrantView),
canViewShop: can(PERMISSIONS.resourceShopView),
};
}

View File

@ -152,3 +152,57 @@
justify-content: flex-end;
gap: var(--space-2);
}
.shopDrawerTools {
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(160px, 0.7fr) minmax(150px, 0.55fr);
gap: var(--space-3);
}
.shopSelectionList {
display: grid;
gap: var(--space-2);
}
.shopSelectionRow {
display: grid;
min-height: 56px;
align-items: center;
gap: var(--space-3);
grid-template-columns: 40px minmax(220px, 1fr) minmax(92px, 0.35fr) minmax(0, 2fr);
padding: var(--space-2) 0;
border-bottom: 1px solid var(--border-muted);
}
.shopSelectionControls {
display: grid;
min-width: 0;
gap: var(--space-2);
grid-template-columns: minmax(112px, 0.7fr) minmax(170px, 1fr) minmax(170px, 1fr) minmax(84px, 0.45fr);
}
.shopBasePrice {
color: var(--text-secondary);
font-weight: 650;
white-space: nowrap;
}
.shopEmpty {
display: flex;
min-height: 180px;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
}
@media (max-width: 860px) {
.shopDrawerTools,
.shopSelectionRow,
.shopSelectionControls {
grid-template-columns: 1fr;
}
.shopSelectionRow {
align-items: stretch;
}
}

View File

@ -9,6 +9,14 @@ export const resourceRoutes = [
path: "/resources",
permission: PERMISSIONS.resourceView,
},
{
label: "道具商店",
loader: () => import("./pages/ResourceShopPage.jsx").then((module) => module.ResourceShopPage),
menuCode: MENU_CODES.resourceShopList,
pageKey: "resource-shop-list",
path: "/resource-shop",
permission: PERMISSIONS.resourceShopView,
},
{
label: "资源组列表",
loader: () => import("./pages/ResourceGroupListPage.jsx").then((module) => module.ResourceGroupListPage),

View File

@ -16,6 +16,7 @@ const resourceTypes = [
const walletAssetTypes = ["COIN", "DIAMOND"];
const resourcePriceTypes = ["coin", "free"];
const badgeForms = ["strip", "tile"];
const resourceShopDurations = ["1", "3", "7"];
const optionalWalletAssetTypeSchema = z
.preprocess((value) => {
if (typeof value !== "string") {
@ -285,6 +286,82 @@ export const giftTypeConfigFormSchema = z.object({
.min(1, "请至少保留一个礼物类型"),
});
export const resourceShopItemsFormSchema = z
.object({
items: z
.array(
z.object({
durationDays: z.union([z.string(), z.number()]),
effectiveFrom: z.string().optional(),
effectiveTo: z.string().optional(),
resourceId: z.union([z.string(), z.number()]),
sortOrder: z.union([z.string(), z.number()]).optional(),
}),
)
.min(1, "请至少选择一个资源"),
})
.superRefine((value, context) => {
const resourceIds = new Set();
value.items.forEach((item, index) => {
const resourceId = Number(item.resourceId);
const durationDays = String(item.durationDays || "").trim();
const effectiveFromMs = datetimeLocalToMs(item.effectiveFrom);
const effectiveToMs = datetimeLocalToMs(item.effectiveTo);
const sortOrder = Number(item.sortOrder || 0);
if (!Number.isInteger(resourceId) || resourceId <= 0) {
context.addIssue({
code: "custom",
message: "请选择资源",
path: ["items", index, "resourceId"],
});
}
if (resourceIds.has(resourceId)) {
context.addIssue({
code: "custom",
message: "同一个资源不能重复添加",
path: ["items", index, "resourceId"],
});
}
resourceIds.add(resourceId);
if (!resourceShopDurations.includes(durationDays)) {
context.addIssue({
code: "custom",
message: "售卖天数只能选择 1、3、7 天",
path: ["items", index, "durationDays"],
});
}
if (effectiveFromMs < 0) {
context.addIssue({
code: "custom",
message: "请选择生效开始时间",
path: ["items", index, "effectiveFrom"],
});
}
if (effectiveToMs < 0) {
context.addIssue({
code: "custom",
message: "请选择生效结束时间",
path: ["items", index, "effectiveTo"],
});
}
if (effectiveFromMs > 0 && effectiveToMs > 0 && effectiveToMs <= effectiveFromMs) {
context.addIssue({
code: "custom",
message: "生效结束时间必须晚于开始时间",
path: ["items", index, "effectiveTo"],
});
}
if (!Number.isInteger(sortOrder) || sortOrder < 0) {
context.addIssue({
code: "custom",
message: "排序不能小于 0",
path: ["items", index, "sortOrder"],
});
}
});
});
function datetimeLocalToMs(value) {
const trimmed = String(value || "").trim();
if (!trimmed) {

View File

@ -6,6 +6,7 @@ import {
resourceFormSchema,
resourceGrantFormSchema,
resourceGroupCreateFormSchema,
resourceShopItemsFormSchema,
} from "./schema.js";
describe("resource form schema", () => {
@ -147,4 +148,33 @@ describe("resource form schema", () => {
expect(payload.subjectType).toBe("resource");
});
test("validates resource shop durations", () => {
const payload = parseForm(resourceShopItemsFormSchema, {
items: [
{
durationDays: "3",
effectiveFrom: "",
effectiveTo: "",
resourceId: "11",
sortOrder: "0",
},
],
});
expect(payload.items[0].durationDays).toBe("3");
expect(() =>
parseForm(resourceShopItemsFormSchema, {
items: [
{
durationDays: "2",
effectiveFrom: "",
effectiveTo: "",
resourceId: "11",
sortOrder: "0",
},
],
}),
).toThrow(FormValidationError);
});
});

File diff suppressed because it is too large Load Diff

View File

@ -1172,6 +1172,54 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/resource-shop/items": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listResourceShopItems"];
put?: never;
post: operations["upsertResourceShopItems"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/resource-shop/items/{shop_item_id}/disable": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["disableResourceShopItem"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/resource-shop/items/{shop_item_id}/enable": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["enableResourceShopItem"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/resource-groups": {
parameters: {
query?: never;
@ -3931,6 +3979,58 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
listResourceShopItems: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
upsertResourceShopItems: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
disableResourceShopItem: {
parameters: {
query?: never;
header?: never;
path: {
shop_item_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
enableResourceShopItem: {
parameters: {
query?: never;
header?: never;
path: {
shop_item_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listResourceGroups: {
parameters: {
query?: never;

View File

@ -652,6 +652,7 @@ export interface AppBannerDto {
createdAtMs?: number;
description?: string;
displayScope?: "home" | "room" | "recharge" | string;
displayScopes?: Array<"home" | "room" | "recharge" | string>;
endsAtMs?: number;
id: number;
param?: string;
@ -670,6 +671,7 @@ export interface AppBannerPayload {
coverUrl: string;
description?: string;
displayScope?: "home" | "room" | "recharge";
displayScopes?: Array<"home" | "room" | "recharge">;
endsAtMs?: number;
param?: string;
platform: "android" | "ios";

View File

@ -14,6 +14,9 @@ import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
const DEFAULT_COLUMN_WIDTH = "minmax(120px, 1fr)";
const DEFAULT_RESIZE_MIN_WIDTH = 72;
const DEFAULT_RESIZE_MAX_WIDTH = 960;
const ACTION_COLUMN_WIDTH = "112px";
const DEFAULT_COMPACT_MIN_WIDTH = 220;
const LOW_PRIORITY_COMPACT_MIN_WIDTH = 180;
export function DataTable({
className = "",
@ -64,11 +67,14 @@ export function DataTable({
const preparedColumns = useMemo(
() =>
columns.map((column) => ({
...column,
fixed: resolveColumnFixed(column, fixedEdges && hasHorizontalOverflow),
width: columnWidths[column.key] || column.width || DEFAULT_COLUMN_WIDTH
})),
columns.map((column) => {
const normalizedColumn = normalizeColumn(column);
return {
...normalizedColumn,
fixed: resolveColumnFixed(normalizedColumn, fixedEdges && hasHorizontalOverflow),
width: columnWidths[normalizedColumn.key] || normalizedColumn.width || DEFAULT_COLUMN_WIDTH
};
}),
[columnWidths, columns, fixedEdges, hasHorizontalOverflow]
);
@ -483,6 +489,42 @@ function cellClassName(column, extraClassName = "") {
.join(" ");
}
function normalizeColumn(column) {
const actionColumn = isActionColumn(column);
const className = [column.className || "", actionColumn ? "admin-cell--actions" : ""].filter(Boolean).join(" ");
return {
...column,
className,
resizable: actionColumn ? false : column.resizable,
width: actionColumn ? ACTION_COLUMN_WIDTH : normalizeColumnWidth(column)
};
}
function normalizeColumnWidth(column) {
if (!column.width || typeof column.width !== "string") {
return column.width;
}
const minmaxMatch = column.width.match(/^minmax\(\s*(\d+(?:\.\d+)?)px\s*,\s*(.+)\)$/i);
if (!minmaxMatch) {
return column.width;
}
const minWidth = Number(minmaxMatch[1]);
const flexibleWidth = minmaxMatch[2];
const maxCompactWidth = isLowPriorityColumn(column) ? LOW_PRIORITY_COMPACT_MIN_WIDTH : DEFAULT_COMPACT_MIN_WIDTH;
const compactMinWidth = Math.min(minWidth, maxCompactWidth);
return `minmax(${compactMinWidth}px, ${flexibleWidth})`;
}
function isActionColumn(column) {
return column.key === "actions" || column.label === "操作";
}
function isLowPriorityColumn(column) {
return /description|detail|remark|param|url|metadata|context|reason/i.test(String(column.key || ""));
}
function resolveColumnFixed(column, fixedEdges) {
if (fixedEdges && (column.fixed === "left" || column.fixed === "right")) {
return column.fixed;

View File

@ -1178,6 +1178,11 @@
overflow-wrap: anywhere;
}
.admin-cell--actions {
justify-content: center;
overflow: visible;
}
.admin-cell:first-child {
padding-left: var(--admin-table-cell-x);
}
@ -1385,6 +1390,28 @@
flex: 1;
min-height: 0;
overflow: auto;
scrollbar-color: var(--border-strong) var(--bg-subtle);
scrollbar-gutter: stable;
scrollbar-width: thin;
}
.table-frame .table-scroll::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.table-frame .table-scroll::-webkit-scrollbar-track {
background: var(--bg-subtle);
}
.table-frame .table-scroll::-webkit-scrollbar-thumb {
border: 2px solid var(--bg-subtle);
border-radius: 999px;
background: var(--border-strong);
}
.table-frame .table-scroll::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
.table-frame .admin-row--head {