增加道具商店
This commit is contained in:
parent
03869b3a37
commit
31b6a8e88d
@ -1711,6 +1711,72 @@
|
|||||||
"x-permissions": ["resource-grant:create"]
|
"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": {
|
"/admin/resource-groups": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listResourceGroups",
|
"operationId": "listResourceGroups",
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import SendOutlined from "@mui/icons-material/SendOutlined";
|
|||||||
import ShieldOutlined from "@mui/icons-material/ShieldOutlined";
|
import ShieldOutlined from "@mui/icons-material/ShieldOutlined";
|
||||||
import SettingsApplicationsOutlined from "@mui/icons-material/SettingsApplicationsOutlined";
|
import SettingsApplicationsOutlined from "@mui/icons-material/SettingsApplicationsOutlined";
|
||||||
import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined";
|
import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined";
|
||||||
|
import StorefrontOutlined from "@mui/icons-material/StorefrontOutlined";
|
||||||
import TaskAltOutlined from "@mui/icons-material/TaskAltOutlined";
|
import TaskAltOutlined from "@mui/icons-material/TaskAltOutlined";
|
||||||
import WalletOutlined from "@mui/icons-material/WalletOutlined";
|
import WalletOutlined from "@mui/icons-material/WalletOutlined";
|
||||||
import WorkspacePremiumOutlined from "@mui/icons-material/WorkspacePremiumOutlined";
|
import WorkspacePremiumOutlined from "@mui/icons-material/WorkspacePremiumOutlined";
|
||||||
@ -63,6 +64,7 @@ const iconMap = {
|
|||||||
settings: SettingsApplicationsOutlined,
|
settings: SettingsApplicationsOutlined,
|
||||||
shield: ShieldOutlined,
|
shield: ShieldOutlined,
|
||||||
sports_esports: SportsEsportsOutlined,
|
sports_esports: SportsEsportsOutlined,
|
||||||
|
storefront: StorefrontOutlined,
|
||||||
task: TaskAltOutlined,
|
task: TaskAltOutlined,
|
||||||
users: ManageAccountsOutlined,
|
users: ManageAccountsOutlined,
|
||||||
wallet: WalletOutlined,
|
wallet: WalletOutlined,
|
||||||
@ -127,6 +129,7 @@ export const fallbackNavigation = [
|
|||||||
label: "资源管理",
|
label: "资源管理",
|
||||||
children: [
|
children: [
|
||||||
routeNavItem("resource-list", { icon: Inventory2Outlined }),
|
routeNavItem("resource-list", { icon: Inventory2Outlined }),
|
||||||
|
routeNavItem("resource-shop-list", { icon: StorefrontOutlined }),
|
||||||
routeNavItem("resource-group-list", { icon: CategoryOutlined }),
|
routeNavItem("resource-group-list", { icon: CategoryOutlined }),
|
||||||
routeNavItem("gift-list", { icon: CardGiftcardOutlined }),
|
routeNavItem("gift-list", { icon: CardGiftcardOutlined }),
|
||||||
routeNavItem("resource-grant-list", { icon: SendOutlined }),
|
routeNavItem("resource-grant-list", { icon: SendOutlined }),
|
||||||
|
|||||||
@ -83,6 +83,8 @@ export const PERMISSIONS = {
|
|||||||
resourceView: "resource:view",
|
resourceView: "resource:view",
|
||||||
resourceCreate: "resource:create",
|
resourceCreate: "resource:create",
|
||||||
resourceUpdate: "resource:update",
|
resourceUpdate: "resource:update",
|
||||||
|
resourceShopView: "resource-shop:view",
|
||||||
|
resourceShopUpdate: "resource-shop:update",
|
||||||
resourceGroupView: "resource-group:view",
|
resourceGroupView: "resource-group:view",
|
||||||
resourceGroupCreate: "resource-group:create",
|
resourceGroupCreate: "resource-group:create",
|
||||||
resourceGroupUpdate: "resource-group:update",
|
resourceGroupUpdate: "resource-group:update",
|
||||||
@ -150,6 +152,7 @@ export const MENU_CODES = {
|
|||||||
appConfigVersions: "app-config-versions",
|
appConfigVersions: "app-config-versions",
|
||||||
resources: "resources",
|
resources: "resources",
|
||||||
resourceList: "resource-list",
|
resourceList: "resource-list",
|
||||||
|
resourceShopList: "resource-shop-list",
|
||||||
resourceGroupList: "resource-group-list",
|
resourceGroupList: "resource-group-list",
|
||||||
resourceGrantList: "resource-grant-list",
|
resourceGrantList: "resource-grant-list",
|
||||||
giftList: "gift-list",
|
giftList: "gift-list",
|
||||||
|
|||||||
@ -16,7 +16,7 @@ const emptyForm = () => ({
|
|||||||
countryCode: "",
|
countryCode: "",
|
||||||
coverUrl: "",
|
coverUrl: "",
|
||||||
description: "",
|
description: "",
|
||||||
displayScope: "home",
|
displayScopes: ["home"],
|
||||||
endsAtMs: "",
|
endsAtMs: "",
|
||||||
param: "",
|
param: "",
|
||||||
platform: "android",
|
platform: "android",
|
||||||
@ -179,14 +179,15 @@ export function useBannerConfigPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeForm(form) {
|
function normalizeForm(form) {
|
||||||
const displayScope = form.displayScope || "home";
|
const displayScopes = normalizeDisplayScopes(form.displayScopes || form.displayScope);
|
||||||
return {
|
return {
|
||||||
...form,
|
...form,
|
||||||
countryCode: form.countryCode || "",
|
countryCode: form.countryCode || "",
|
||||||
displayScope,
|
displayScope: displayScopes[0] || "home",
|
||||||
|
displayScopes,
|
||||||
endsAtMs: form.endsAtMs || 0,
|
endsAtMs: form.endsAtMs || 0,
|
||||||
regionId: form.regionId || 0,
|
regionId: form.regionId || 0,
|
||||||
roomSmallImageUrl: displayScope === "room" ? form.roomSmallImageUrl || "" : "",
|
roomSmallImageUrl: displayScopes.includes("room") ? form.roomSmallImageUrl || "" : "",
|
||||||
sortOrder: form.sortOrder || 0,
|
sortOrder: form.sortOrder || 0,
|
||||||
startsAtMs: form.startsAtMs || 0,
|
startsAtMs: form.startsAtMs || 0,
|
||||||
};
|
};
|
||||||
@ -198,7 +199,7 @@ function formFromBanner(item) {
|
|||||||
countryCode: item.countryCode || "",
|
countryCode: item.countryCode || "",
|
||||||
coverUrl: item.coverUrl || "",
|
coverUrl: item.coverUrl || "",
|
||||||
description: item.description || "",
|
description: item.description || "",
|
||||||
displayScope: item.displayScope || "home",
|
displayScopes: normalizeDisplayScopes(item.displayScopes || item.displayScope),
|
||||||
endsAtMs: item.endsAtMs || "",
|
endsAtMs: item.endsAtMs || "",
|
||||||
param: item.param || "",
|
param: item.param || "",
|
||||||
platform: item.platform || "android",
|
platform: item.platform || "android",
|
||||||
@ -209,3 +210,11 @@ function formFromBanner(item) {
|
|||||||
status: item.status || "active",
|
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 DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
import ImageOutlined from "@mui/icons-material/ImageOutlined";
|
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 MenuItem from "@mui/material/MenuItem";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
@ -94,7 +96,7 @@ export function BannerConfigPage() {
|
|||||||
>
|
>
|
||||||
<AdminFormSection title="素材">
|
<AdminFormSection title="素材">
|
||||||
<AdminFormFieldGrid
|
<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
|
<UploadField
|
||||||
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
|
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
|
||||||
@ -102,7 +104,7 @@ export function BannerConfigPage() {
|
|||||||
value={page.form.coverUrl}
|
value={page.form.coverUrl}
|
||||||
onChange={(coverUrl) => page.setForm({ coverUrl })}
|
onChange={(coverUrl) => page.setForm({ coverUrl })}
|
||||||
/>
|
/>
|
||||||
{page.form.displayScope === "room" ? (
|
{hasDisplayScope(page.form.displayScopes, "room") ? (
|
||||||
<UploadField
|
<UploadField
|
||||||
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
|
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
|
||||||
label="房间内小屏图"
|
label="房间内小屏图"
|
||||||
@ -153,18 +155,27 @@ export function BannerConfigPage() {
|
|||||||
label="显示范围"
|
label="显示范围"
|
||||||
required
|
required
|
||||||
select
|
select
|
||||||
value={page.form.displayScope}
|
slotProps={{
|
||||||
|
select: {
|
||||||
|
multiple: true,
|
||||||
|
renderValue: (selected) => displayScopeListLabel(selected),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
value={page.form.displayScopes}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const displayScope = event.target.value;
|
const displayScopes = normalizeSelectedDisplayScopes(event.target.value);
|
||||||
page.setForm({
|
page.setForm({
|
||||||
displayScope,
|
displayScopes,
|
||||||
roomSmallImageUrl: displayScope === "room" ? page.form.roomSmallImageUrl : "",
|
roomSmallImageUrl: displayScopes.includes("room")
|
||||||
|
? page.form.roomSmallImageUrl
|
||||||
|
: "",
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{APP_BANNER_DISPLAY_SCOPE_OPTIONS.map((option) => (
|
{APP_BANNER_DISPLAY_SCOPE_OPTIONS.map((option) => (
|
||||||
<MenuItem key={option.value} value={option.value}>
|
<MenuItem key={option.value} value={option.value}>
|
||||||
{option.label}
|
<Checkbox checked={hasDisplayScope(page.form.displayScopes, option.value)} />
|
||||||
|
<ListItemText primary={option.label} />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
@ -259,7 +270,7 @@ function bannerColumns(page) {
|
|||||||
{
|
{
|
||||||
key: "displayScope",
|
key: "displayScope",
|
||||||
label: "显示范围",
|
label: "显示范围",
|
||||||
render: (item) => displayScopeLabel(item.displayScope),
|
render: (item) => displayScopeListLabel(item.displayScopes || item.displayScope),
|
||||||
filter: createOptionsColumnFilter({
|
filter: createOptionsColumnFilter({
|
||||||
options: displayScopeFilterOptions,
|
options: displayScopeFilterOptions,
|
||||||
placeholder: "搜索显示范围",
|
placeholder: "搜索显示范围",
|
||||||
@ -437,6 +448,23 @@ function displayScopeLabel(value) {
|
|||||||
return APP_BANNER_DISPLAY_SCOPE_OPTIONS.find((option) => option.value === value)?.label || 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) {
|
function deliveryTimeLabel(startsAtMs, endsAtMs) {
|
||||||
const start = startsAtMs ? formatMillis(startsAtMs) : "不限";
|
const start = startsAtMs ? formatMillis(startsAtMs) : "不限";
|
||||||
const end = endsAtMs ? formatMillis(endsAtMs) : "不限";
|
const end = endsAtMs ? formatMillis(endsAtMs) : "不限";
|
||||||
|
|||||||
@ -8,6 +8,7 @@ const validBannerForm = {
|
|||||||
coverUrl: "https://media.haiyihy.com/banner/home.png",
|
coverUrl: "https://media.haiyihy.com/banner/home.png",
|
||||||
description: "",
|
description: "",
|
||||||
displayScope: "home",
|
displayScope: "home",
|
||||||
|
displayScopes: ["home"],
|
||||||
endsAtMs: "1800000000000",
|
endsAtMs: "1800000000000",
|
||||||
param: "https://h5.example.com/activity",
|
param: "https://h5.example.com/activity",
|
||||||
platform: "android",
|
platform: "android",
|
||||||
@ -43,16 +44,26 @@ describe("app config form schema", () => {
|
|||||||
test("keeps banner display scope and delivery time fields", () => {
|
test("keeps banner display scope and delivery time fields", () => {
|
||||||
const payload = parseForm(appBannerSchema, validBannerForm);
|
const payload = parseForm(appBannerSchema, validBannerForm);
|
||||||
|
|
||||||
expect(payload.displayScope).toBe("home");
|
expect(payload.displayScopes).toEqual(["home"]);
|
||||||
expect(payload.startsAtMs).toBe(1700000000000);
|
expect(payload.startsAtMs).toBe(1700000000000);
|
||||||
expect(payload.endsAtMs).toBe(1800000000000);
|
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", () => {
|
test("requires room small image for room banner", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
parseForm(appBannerSchema, {
|
parseForm(appBannerSchema, {
|
||||||
...validBannerForm,
|
...validBannerForm,
|
||||||
displayScope: "room",
|
displayScopes: ["home", "room"],
|
||||||
roomSmallImageUrl: "",
|
roomSmallImageUrl: "",
|
||||||
}),
|
}),
|
||||||
).toThrow(FormValidationError);
|
).toThrow(FormValidationError);
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const appBannerDisplayScopes = ["home", "room", "recharge"] as const;
|
||||||
|
|
||||||
const requiredH5LinkURLSchema = z
|
const requiredH5LinkURLSchema = z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
@ -46,7 +48,8 @@ export const appBannerSchema = z
|
|||||||
.max(1024, "封面图地址不能超过 1024 个字符")
|
.max(1024, "封面图地址不能超过 1024 个字符")
|
||||||
.refine((value) => !/\s/.test(value), "封面图地址不能包含空白字符"),
|
.refine((value) => !/\s/.test(value), "封面图地址不能包含空白字符"),
|
||||||
description: z.string().trim().max(255, "描述不能超过 255 个字符"),
|
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),
|
endsAtMs: z.coerce.number().int().min(0, "投放结束时间不正确").default(0),
|
||||||
param: z.string().trim().max(2048, "参数不能超过 2048 个字符"),
|
param: z.string().trim().max(2048, "参数不能超过 2048 个字符"),
|
||||||
platform: z.enum(["android", "ios"]),
|
platform: z.enum(["android", "ios"]),
|
||||||
@ -67,7 +70,7 @@ export const appBannerSchema = z
|
|||||||
if (value.bannerType === "h5" && /\s/.test(value.param)) {
|
if (value.bannerType === "h5" && /\s/.test(value.param)) {
|
||||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 链接不能包含空白字符", path: ["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({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: "房间内显示需要上传房间内小屏图",
|
message: "房间内显示需要上传房间内小屏图",
|
||||||
|
|||||||
@ -5,9 +5,11 @@ import {
|
|||||||
createGift,
|
createGift,
|
||||||
createResource,
|
createResource,
|
||||||
createResourceGroup,
|
createResourceGroup,
|
||||||
|
disableResourceShopItem,
|
||||||
disableGift,
|
disableGift,
|
||||||
disableResource,
|
disableResource,
|
||||||
disableResourceGroup,
|
disableResourceGroup,
|
||||||
|
enableResourceShopItem,
|
||||||
enableGift,
|
enableGift,
|
||||||
enableResource,
|
enableResource,
|
||||||
enableResourceGroup,
|
enableResourceGroup,
|
||||||
@ -18,11 +20,13 @@ import {
|
|||||||
listGiftTypes,
|
listGiftTypes,
|
||||||
listResourceGrants,
|
listResourceGrants,
|
||||||
listGifts,
|
listGifts,
|
||||||
|
listResourceShopItems,
|
||||||
listResources,
|
listResources,
|
||||||
updateGift,
|
updateGift,
|
||||||
updateGiftTypes,
|
updateGiftTypes,
|
||||||
updateResource,
|
updateResource,
|
||||||
updateResourceGroup,
|
updateResourceGroup,
|
||||||
|
upsertResourceShopItems,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
|
||||||
afterEach(() => {
|
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 () => {
|
test("gift type APIs save all tabs with one request", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
|
|||||||
@ -93,6 +93,22 @@ export interface ResourceGroupDto {
|
|||||||
updatedAtMs?: number;
|
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 {
|
export interface GiftDto {
|
||||||
appCode?: string;
|
appCode?: string;
|
||||||
giftId: string;
|
giftId: string;
|
||||||
@ -241,6 +257,20 @@ export interface ResourceGroupPayload {
|
|||||||
items: ResourceGroupItemPayload[];
|
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>> {
|
export function listResources(query: PageQuery = {}): Promise<ApiPage<ResourceDto>> {
|
||||||
const endpoint = API_ENDPOINTS.listResources;
|
const endpoint = API_ENDPOINTS.listResources;
|
||||||
return apiRequest<ApiPage<ResourceDto>>(apiEndpointPath(API_OPERATIONS.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> {
|
export function createResourceGroup(payload: ResourceGroupPayload): Promise<ResourceGroupDto> {
|
||||||
const endpoint = API_ENDPOINTS.createResourceGroup;
|
const endpoint = API_ENDPOINTS.createResourceGroup;
|
||||||
return apiRequest<ResourceGroupDto, ResourceGroupPayload>(apiEndpointPath(API_OPERATIONS.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 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 = [
|
export const resourceGroupAssetOptions = [
|
||||||
["COIN", "金币"],
|
["COIN", "金币"],
|
||||||
["DIAMOND", "钻石"],
|
["DIAMOND", "钻石"],
|
||||||
|
|||||||
@ -8,9 +8,11 @@ import {
|
|||||||
createGift,
|
createGift,
|
||||||
createResource,
|
createResource,
|
||||||
createResourceGroup,
|
createResourceGroup,
|
||||||
|
disableResourceShopItem,
|
||||||
disableGift,
|
disableGift,
|
||||||
disableResource,
|
disableResource,
|
||||||
disableResourceGroup,
|
disableResourceGroup,
|
||||||
|
enableResourceShopItem,
|
||||||
enableGift,
|
enableGift,
|
||||||
enableResource,
|
enableResource,
|
||||||
enableResourceGroup,
|
enableResourceGroup,
|
||||||
@ -22,13 +24,15 @@ import {
|
|||||||
listGifts,
|
listGifts,
|
||||||
listResourceGrants,
|
listResourceGrants,
|
||||||
listResourceGroups,
|
listResourceGroups,
|
||||||
|
listResourceShopItems,
|
||||||
listResources,
|
listResources,
|
||||||
updateGift,
|
updateGift,
|
||||||
updateGiftTypes,
|
updateGiftTypes,
|
||||||
updateResource,
|
updateResource,
|
||||||
updateResourceGroup,
|
updateResourceGroup,
|
||||||
|
upsertResourceShopItems,
|
||||||
} from "@/features/resources/api";
|
} 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 { useResourceAbilities } from "@/features/resources/permissions.js";
|
||||||
import {
|
import {
|
||||||
emojiPackFormSchema,
|
emojiPackFormSchema,
|
||||||
@ -37,6 +41,7 @@ import {
|
|||||||
resourceFormSchema,
|
resourceFormSchema,
|
||||||
resourceGrantFormSchema,
|
resourceGrantFormSchema,
|
||||||
resourceGroupCreateFormSchema,
|
resourceGroupCreateFormSchema,
|
||||||
|
resourceShopItemsFormSchema,
|
||||||
} from "@/features/resources/schema.js";
|
} from "@/features/resources/schema.js";
|
||||||
|
|
||||||
const pageSize = 50;
|
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() {
|
export function useResourceGroupListPage() {
|
||||||
const abilities = useResourceAbilities();
|
const abilities = useResourceAbilities();
|
||||||
const { showToast } = useToast();
|
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) {
|
function uniqueResources(resources) {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
return resources.filter((resource) => {
|
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),
|
canCreateGroup: can(PERMISSIONS.resourceGroupCreate),
|
||||||
canCreateEmojiPack: can(PERMISSIONS.emojiPackCreate),
|
canCreateEmojiPack: can(PERMISSIONS.emojiPackCreate),
|
||||||
canStatusGift: can(PERMISSIONS.giftStatus),
|
canStatusGift: can(PERMISSIONS.giftStatus),
|
||||||
|
canUpdateShop: can(PERMISSIONS.resourceShopUpdate),
|
||||||
canUpdateGroup: can(PERMISSIONS.resourceGroupUpdate),
|
canUpdateGroup: can(PERMISSIONS.resourceGroupUpdate),
|
||||||
canUpdateGift: can(PERMISSIONS.giftUpdate),
|
canUpdateGift: can(PERMISSIONS.giftUpdate),
|
||||||
canUpdate: can(PERMISSIONS.resourceUpdate),
|
canUpdate: can(PERMISSIONS.resourceUpdate),
|
||||||
@ -18,5 +19,6 @@ export function useResourceAbilities() {
|
|||||||
canView: can(PERMISSIONS.resourceView),
|
canView: can(PERMISSIONS.resourceView),
|
||||||
canViewEmojiPack: can(PERMISSIONS.emojiPackView),
|
canViewEmojiPack: can(PERMISSIONS.emojiPackView),
|
||||||
canViewGrant: can(PERMISSIONS.resourceGrantView),
|
canViewGrant: can(PERMISSIONS.resourceGrantView),
|
||||||
|
canViewShop: can(PERMISSIONS.resourceShopView),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -152,3 +152,57 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: var(--space-2);
|
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",
|
path: "/resources",
|
||||||
permission: PERMISSIONS.resourceView,
|
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: "资源组列表",
|
label: "资源组列表",
|
||||||
loader: () => import("./pages/ResourceGroupListPage.jsx").then((module) => module.ResourceGroupListPage),
|
loader: () => import("./pages/ResourceGroupListPage.jsx").then((module) => module.ResourceGroupListPage),
|
||||||
|
|||||||
@ -16,6 +16,7 @@ const resourceTypes = [
|
|||||||
const walletAssetTypes = ["COIN", "DIAMOND"];
|
const walletAssetTypes = ["COIN", "DIAMOND"];
|
||||||
const resourcePriceTypes = ["coin", "free"];
|
const resourcePriceTypes = ["coin", "free"];
|
||||||
const badgeForms = ["strip", "tile"];
|
const badgeForms = ["strip", "tile"];
|
||||||
|
const resourceShopDurations = ["1", "3", "7"];
|
||||||
const optionalWalletAssetTypeSchema = z
|
const optionalWalletAssetTypeSchema = z
|
||||||
.preprocess((value) => {
|
.preprocess((value) => {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
@ -285,6 +286,82 @@ export const giftTypeConfigFormSchema = z.object({
|
|||||||
.min(1, "请至少保留一个礼物类型"),
|
.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) {
|
function datetimeLocalToMs(value) {
|
||||||
const trimmed = String(value || "").trim();
|
const trimmed = String(value || "").trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
resourceFormSchema,
|
resourceFormSchema,
|
||||||
resourceGrantFormSchema,
|
resourceGrantFormSchema,
|
||||||
resourceGroupCreateFormSchema,
|
resourceGroupCreateFormSchema,
|
||||||
|
resourceShopItemsFormSchema,
|
||||||
} from "./schema.js";
|
} from "./schema.js";
|
||||||
|
|
||||||
describe("resource form schema", () => {
|
describe("resource form schema", () => {
|
||||||
@ -147,4 +148,33 @@ describe("resource form schema", () => {
|
|||||||
|
|
||||||
expect(payload.subjectType).toBe("resource");
|
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;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/admin/resource-groups": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -3931,6 +3979,58 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
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: {
|
listResourceGroups: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@ -652,6 +652,7 @@ export interface AppBannerDto {
|
|||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
description?: string;
|
description?: string;
|
||||||
displayScope?: "home" | "room" | "recharge" | string;
|
displayScope?: "home" | "room" | "recharge" | string;
|
||||||
|
displayScopes?: Array<"home" | "room" | "recharge" | string>;
|
||||||
endsAtMs?: number;
|
endsAtMs?: number;
|
||||||
id: number;
|
id: number;
|
||||||
param?: string;
|
param?: string;
|
||||||
@ -670,6 +671,7 @@ export interface AppBannerPayload {
|
|||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
displayScope?: "home" | "room" | "recharge";
|
displayScope?: "home" | "room" | "recharge";
|
||||||
|
displayScopes?: Array<"home" | "room" | "recharge">;
|
||||||
endsAtMs?: number;
|
endsAtMs?: number;
|
||||||
param?: string;
|
param?: string;
|
||||||
platform: "android" | "ios";
|
platform: "android" | "ios";
|
||||||
|
|||||||
@ -14,6 +14,9 @@ import { PaginationBar } from "@/shared/ui/PaginationBar.jsx";
|
|||||||
const DEFAULT_COLUMN_WIDTH = "minmax(120px, 1fr)";
|
const DEFAULT_COLUMN_WIDTH = "minmax(120px, 1fr)";
|
||||||
const DEFAULT_RESIZE_MIN_WIDTH = 72;
|
const DEFAULT_RESIZE_MIN_WIDTH = 72;
|
||||||
const DEFAULT_RESIZE_MAX_WIDTH = 960;
|
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({
|
export function DataTable({
|
||||||
className = "",
|
className = "",
|
||||||
@ -64,11 +67,14 @@ export function DataTable({
|
|||||||
|
|
||||||
const preparedColumns = useMemo(
|
const preparedColumns = useMemo(
|
||||||
() =>
|
() =>
|
||||||
columns.map((column) => ({
|
columns.map((column) => {
|
||||||
...column,
|
const normalizedColumn = normalizeColumn(column);
|
||||||
fixed: resolveColumnFixed(column, fixedEdges && hasHorizontalOverflow),
|
return {
|
||||||
width: columnWidths[column.key] || column.width || DEFAULT_COLUMN_WIDTH
|
...normalizedColumn,
|
||||||
})),
|
fixed: resolveColumnFixed(normalizedColumn, fixedEdges && hasHorizontalOverflow),
|
||||||
|
width: columnWidths[normalizedColumn.key] || normalizedColumn.width || DEFAULT_COLUMN_WIDTH
|
||||||
|
};
|
||||||
|
}),
|
||||||
[columnWidths, columns, fixedEdges, hasHorizontalOverflow]
|
[columnWidths, columns, fixedEdges, hasHorizontalOverflow]
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -483,6 +489,42 @@ function cellClassName(column, extraClassName = "") {
|
|||||||
.join(" ");
|
.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) {
|
function resolveColumnFixed(column, fixedEdges) {
|
||||||
if (fixedEdges && (column.fixed === "left" || column.fixed === "right")) {
|
if (fixedEdges && (column.fixed === "left" || column.fixed === "right")) {
|
||||||
return column.fixed;
|
return column.fixed;
|
||||||
|
|||||||
@ -1178,6 +1178,11 @@
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-cell--actions {
|
||||||
|
justify-content: center;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-cell:first-child {
|
.admin-cell:first-child {
|
||||||
padding-left: var(--admin-table-cell-x);
|
padding-left: var(--admin-table-cell-x);
|
||||||
}
|
}
|
||||||
@ -1385,6 +1390,28 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
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 {
|
.table-frame .admin-row--head {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user