增加道具商店
This commit is contained in:
parent
03869b3a37
commit
31b6a8e88d
@ -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",
|
||||
|
||||
@ -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 }),
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"];
|
||||
}
|
||||
|
||||
@ -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) : "不限";
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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: "房间内显示需要上传房间内小屏图",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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), {
|
||||
|
||||
@ -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", "钻石"],
|
||||
|
||||
@ -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) => {
|
||||
|
||||
344
src/features/resources/pages/ResourceShopPage.jsx
Normal file
344
src/features/resources/pages/ResourceShopPage.jsx
Normal 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] || "-";
|
||||
}
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
100
src/shared/api/generated/schema.d.ts
vendored
100
src/shared/api/generated/schema.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user