开屏配置
This commit is contained in:
parent
e423adc738
commit
0b3589a701
@ -760,6 +760,70 @@
|
||||
"x-permissions": ["app-config:update"]
|
||||
}
|
||||
},
|
||||
"/admin/app-config/splash-screens": {
|
||||
"get": {
|
||||
"operationId": "listSplashScreens",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "app-config:view",
|
||||
"x-permissions": ["app-config:view"]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "createSplashScreen",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"x-permission": "app-config:update",
|
||||
"x-permissions": ["app-config:update"]
|
||||
}
|
||||
},
|
||||
"/admin/app-config/splash-screens/{splash_id}": {
|
||||
"put": {
|
||||
"operationId": "updateSplashScreen",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "splash_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-permission": "app-config:update",
|
||||
"x-permissions": ["app-config:update"]
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "deleteSplashScreen",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/EmptyResponse"
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "splash_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"x-permission": "app-config:update",
|
||||
"x-permissions": ["app-config:update"]
|
||||
}
|
||||
},
|
||||
"/admin/app-config/h5-links": {
|
||||
"get": {
|
||||
"operationId": "listH5Links",
|
||||
|
||||
@ -146,6 +146,7 @@ export const fallbackNavigation = [
|
||||
children: [
|
||||
routeNavItem("app-config-h5", { icon: SettingsApplicationsOutlined }),
|
||||
routeNavItem("app-config-banners", { icon: ImageOutlined }),
|
||||
routeNavItem("app-config-splash-screens", { icon: ImageOutlined }),
|
||||
routeNavItem("app-config-explore", { icon: MapOutlined }),
|
||||
routeNavItem("payment-recharge-products", { icon: WalletOutlined }),
|
||||
routeNavItem("app-config-versions", { icon: SettingsApplicationsOutlined }),
|
||||
@ -357,7 +358,19 @@ export function mergeNavigationItems(primaryItems = [], fallbackItems = []) {
|
||||
}
|
||||
});
|
||||
|
||||
return merged;
|
||||
return orderNavigationItemsByFallback(merged, fallbackItems);
|
||||
}
|
||||
|
||||
function orderNavigationItemsByFallback(items = [], fallbackItems = []) {
|
||||
const fallbackOrder = new Map(fallbackItems.map((item, index) => [item.code, index]));
|
||||
return items
|
||||
.map((item, index) => ({ index, item }))
|
||||
.sort((left, right) => {
|
||||
const leftOrder = fallbackOrder.get(left.item.code) ?? Number.MAX_SAFE_INTEGER;
|
||||
const rightOrder = fallbackOrder.get(right.item.code) ?? Number.MAX_SAFE_INTEGER;
|
||||
return leftOrder === rightOrder ? left.index - right.index : leftOrder - rightOrder;
|
||||
})
|
||||
.map(({ item }) => item);
|
||||
}
|
||||
|
||||
export function findNavItemByPath(pathname, items = fallbackNavigation) {
|
||||
|
||||
@ -113,6 +113,56 @@ describe("navigation menu helpers", () => {
|
||||
);
|
||||
expect(payment).toBeUndefined();
|
||||
});
|
||||
|
||||
test("places splash screens after banners when backend menu is stale", () => {
|
||||
const backendMenus = [
|
||||
{
|
||||
children: [
|
||||
{ code: "app-config-h5", id: "app-config-h5", label: "H5配置", path: "/app-config/h5" },
|
||||
{
|
||||
code: "app-config-banners",
|
||||
id: "app-config-banners",
|
||||
label: "BANNER配置",
|
||||
path: "/app-config/banners",
|
||||
},
|
||||
{
|
||||
code: "app-config-explore",
|
||||
id: "app-config-explore",
|
||||
label: "Explore配置",
|
||||
path: "/app-config/explore",
|
||||
},
|
||||
{
|
||||
code: "payment-recharge-products",
|
||||
id: "payment-recharge-products",
|
||||
label: "内购配置",
|
||||
path: "/app-config/recharge-products",
|
||||
},
|
||||
{
|
||||
code: "app-config-versions",
|
||||
id: "app-config-versions",
|
||||
label: "版本管理",
|
||||
path: "/app-config/versions",
|
||||
},
|
||||
],
|
||||
code: "app-config",
|
||||
icon: "settings",
|
||||
id: "app-config",
|
||||
label: "APP配置",
|
||||
},
|
||||
];
|
||||
const fallbackMenus = filterNavigationByPermission(fallbackNavigation, (code) => code === "app-config:view");
|
||||
const merged = mergeNavigationItems(mapBackendMenus(backendMenus), fallbackMenus);
|
||||
const appConfig = merged.find((item) => item.code === "app-config");
|
||||
|
||||
expect(appConfig.children.map((item) => item.code)).toEqual([
|
||||
"app-config-h5",
|
||||
"app-config-banners",
|
||||
"app-config-splash-screens",
|
||||
"app-config-explore",
|
||||
"payment-recharge-products",
|
||||
"app-config-versions",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function flattenCodes(items) {
|
||||
|
||||
@ -176,6 +176,7 @@ export const MENU_CODES = {
|
||||
appConfig: "app-config",
|
||||
appConfigH5: "app-config-h5",
|
||||
appConfigBanners: "app-config-banners",
|
||||
appConfigSplashScreens: "app-config-splash-screens",
|
||||
appConfigExplore: "app-config-explore",
|
||||
appConfigVersions: "app-config-versions",
|
||||
resources: "resources",
|
||||
|
||||
@ -4,6 +4,8 @@ import type {
|
||||
ApiList,
|
||||
AppBannerDto,
|
||||
AppBannerPayload,
|
||||
AppSplashScreenDto,
|
||||
AppSplashScreenPayload,
|
||||
AppVersionDto,
|
||||
AppVersionPayload,
|
||||
EntityId,
|
||||
@ -18,23 +20,26 @@ import type {
|
||||
export function listH5Links(): Promise<ApiList<H5LinkConfigDto>> {
|
||||
const endpoint = API_ENDPOINTS.listH5Links;
|
||||
return apiRequest<ApiList<H5LinkConfigDto>>(apiEndpointPath(API_OPERATIONS.listH5Links), {
|
||||
method: endpoint.method
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateH5Links(payload: H5LinkConfigUpdatePayload): Promise<ApiList<H5LinkConfigDto>> {
|
||||
const endpoint = API_ENDPOINTS.updateH5Links;
|
||||
return apiRequest<ApiList<H5LinkConfigDto>, H5LinkConfigUpdatePayload>(apiEndpointPath(API_OPERATIONS.updateH5Links), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
});
|
||||
return apiRequest<ApiList<H5LinkConfigDto>, H5LinkConfigUpdatePayload>(
|
||||
apiEndpointPath(API_OPERATIONS.updateH5Links),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function createH5Link(payload: H5LinkConfigPayload): Promise<H5LinkConfigDto> {
|
||||
const endpoint = API_ENDPOINTS.createH5Link;
|
||||
return apiRequest<H5LinkConfigDto, H5LinkConfigPayload>(apiEndpointPath(API_OPERATIONS.createH5Link), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
@ -42,14 +47,14 @@ export function updateH5Link(key: EntityId, payload: H5LinkConfigPayload): Promi
|
||||
const endpoint = API_ENDPOINTS.updateH5Link;
|
||||
return apiRequest<H5LinkConfigDto, H5LinkConfigPayload>(apiEndpointPath(API_OPERATIONS.updateH5Link, { key }), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteH5Link(key: EntityId): Promise<{ deleted: boolean }> {
|
||||
const endpoint = API_ENDPOINTS.deleteH5Link;
|
||||
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteH5Link, { key }), {
|
||||
method: endpoint.method
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
@ -57,7 +62,7 @@ export function listExploreTabs(query: PageQuery = {}): Promise<ApiList<ExploreT
|
||||
const endpoint = API_ENDPOINTS.listExploreTabs;
|
||||
return apiRequest<ApiList<ExploreTabDto>>(apiEndpointPath(API_OPERATIONS.listExploreTabs), {
|
||||
method: endpoint.method,
|
||||
query
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
@ -65,22 +70,25 @@ export function createExploreTab(payload: ExploreTabPayload): Promise<ExploreTab
|
||||
const endpoint = API_ENDPOINTS.createExploreTab;
|
||||
return apiRequest<ExploreTabDto, ExploreTabPayload>(apiEndpointPath(API_OPERATIONS.createExploreTab), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateExploreTab(tabId: EntityId, payload: ExploreTabPayload): Promise<ExploreTabDto> {
|
||||
const endpoint = API_ENDPOINTS.updateExploreTab;
|
||||
return apiRequest<ExploreTabDto, ExploreTabPayload>(apiEndpointPath(API_OPERATIONS.updateExploreTab, { tab_id: tabId }), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
});
|
||||
return apiRequest<ExploreTabDto, ExploreTabPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.updateExploreTab, { tab_id: tabId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteExploreTab(tabId: EntityId): Promise<{ deleted: boolean }> {
|
||||
const endpoint = API_ENDPOINTS.deleteExploreTab;
|
||||
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteExploreTab, { tab_id: tabId }), {
|
||||
method: endpoint.method
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
@ -88,7 +96,7 @@ export function listBanners(query: PageQuery = {}): Promise<ApiList<AppBannerDto
|
||||
const endpoint = API_ENDPOINTS.listBanners;
|
||||
return apiRequest<ApiList<AppBannerDto>>(apiEndpointPath(API_OPERATIONS.listBanners), {
|
||||
method: endpoint.method,
|
||||
query
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
@ -96,30 +104,70 @@ export function createBanner(payload: AppBannerPayload): Promise<AppBannerDto> {
|
||||
const endpoint = API_ENDPOINTS.createBanner;
|
||||
return apiRequest<AppBannerDto, AppBannerPayload>(apiEndpointPath(API_OPERATIONS.createBanner), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateBanner(bannerId: EntityId, payload: AppBannerPayload): Promise<AppBannerDto> {
|
||||
const endpoint = API_ENDPOINTS.updateBanner;
|
||||
return apiRequest<AppBannerDto, AppBannerPayload>(apiEndpointPath(API_OPERATIONS.updateBanner, { banner_id: bannerId }), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
});
|
||||
return apiRequest<AppBannerDto, AppBannerPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.updateBanner, { banner_id: bannerId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteBanner(bannerId: EntityId): Promise<{ deleted: boolean }> {
|
||||
const endpoint = API_ENDPOINTS.deleteBanner;
|
||||
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteBanner, { banner_id: bannerId }), {
|
||||
method: endpoint.method
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function listSplashScreens(query: PageQuery = {}): Promise<ApiList<AppSplashScreenDto>> {
|
||||
const endpoint = API_ENDPOINTS.listSplashScreens;
|
||||
return apiRequest<ApiList<AppSplashScreenDto>>(apiEndpointPath(API_OPERATIONS.listSplashScreens), {
|
||||
method: endpoint.method,
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
export function createSplashScreen(payload: AppSplashScreenPayload): Promise<AppSplashScreenDto> {
|
||||
const endpoint = API_ENDPOINTS.createSplashScreen;
|
||||
return apiRequest<AppSplashScreenDto, AppSplashScreenPayload>(apiEndpointPath(API_OPERATIONS.createSplashScreen), {
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateSplashScreen(splashId: EntityId, payload: AppSplashScreenPayload): Promise<AppSplashScreenDto> {
|
||||
const endpoint = API_ENDPOINTS.updateSplashScreen;
|
||||
return apiRequest<AppSplashScreenDto, AppSplashScreenPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.updateSplashScreen, { splash_id: splashId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteSplashScreen(splashId: EntityId): Promise<{ deleted: boolean }> {
|
||||
const endpoint = API_ENDPOINTS.deleteSplashScreen;
|
||||
return apiRequest<{ deleted: boolean }>(
|
||||
apiEndpointPath(API_OPERATIONS.deleteSplashScreen, { splash_id: splashId }),
|
||||
{
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function listAppVersions(query: PageQuery = {}): Promise<ApiList<AppVersionDto>> {
|
||||
const endpoint = API_ENDPOINTS.listAppVersions;
|
||||
return apiRequest<ApiList<AppVersionDto>>(apiEndpointPath(API_OPERATIONS.listAppVersions), {
|
||||
method: endpoint.method,
|
||||
query
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
@ -127,7 +175,7 @@ export function createAppVersion(payload: AppVersionPayload): Promise<AppVersion
|
||||
const endpoint = API_ENDPOINTS.createAppVersion;
|
||||
return apiRequest<AppVersionDto, AppVersionPayload>(apiEndpointPath(API_OPERATIONS.createAppVersion), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
method: endpoint.method,
|
||||
});
|
||||
}
|
||||
|
||||
@ -137,14 +185,17 @@ export function updateAppVersion(versionId: EntityId, payload: AppVersionPayload
|
||||
apiEndpointPath(API_OPERATIONS.updateAppVersion, { version_id: versionId }),
|
||||
{
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
}
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteAppVersion(versionId: EntityId): Promise<{ deleted: boolean }> {
|
||||
const endpoint = API_ENDPOINTS.deleteAppVersion;
|
||||
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteAppVersion, { version_id: versionId }), {
|
||||
method: endpoint.method
|
||||
});
|
||||
return apiRequest<{ deleted: boolean }>(
|
||||
apiEndpointPath(API_OPERATIONS.deleteAppVersion, { version_id: versionId }),
|
||||
{
|
||||
method: endpoint.method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -24,11 +24,85 @@
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.splashCover,
|
||||
.splashCoverEmpty {
|
||||
display: inline-flex;
|
||||
width: 54px;
|
||||
height: 96px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-card-strong);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.splashCover {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.formWideField {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.splashFormLayout {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.splashAssetPane,
|
||||
.splashFieldPane {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.splashAssetPane {
|
||||
padding-right: var(--space-1);
|
||||
}
|
||||
|
||||
.splashFieldPane {
|
||||
padding-left: var(--space-1);
|
||||
}
|
||||
|
||||
.splashUploadField {
|
||||
width: min(100%, 260px);
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.splashUploadField .splashUploadPreview {
|
||||
height: auto;
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: min(52vh, 420px);
|
||||
}
|
||||
|
||||
.splashStatusField {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.statusCell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.splashFormLayout {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.splashAssetPane,
|
||||
.splashFieldPane {
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.splashUploadField {
|
||||
justify-self: stretch;
|
||||
width: min(100%, 260px);
|
||||
}
|
||||
}
|
||||
|
||||
204
src/features/app-config/hooks/useSplashScreenConfigPage.js
Normal file
204
src/features/app-config/hooks/useSplashScreenConfigPage.js
Normal file
@ -0,0 +1,204 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
createSplashScreen,
|
||||
deleteSplashScreen,
|
||||
listSplashScreens,
|
||||
updateSplashScreen,
|
||||
} from "@/features/app-config/api";
|
||||
import { useAppConfigAbilities } from "@/features/app-config/permissions.js";
|
||||
import { appSplashScreenSchema } from "@/features/app-config/schema";
|
||||
import { useCountryOptions } from "@/features/host-org/hooks/useCountryOptions.js";
|
||||
import { parseForm } from "@/shared/forms/validation";
|
||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
|
||||
const emptyData = { items: [], total: 0 };
|
||||
|
||||
const emptyForm = () => ({
|
||||
countryCode: "",
|
||||
coverUrl: "",
|
||||
description: "",
|
||||
endsAtMs: "",
|
||||
param: "",
|
||||
platform: "android",
|
||||
regionId: "",
|
||||
sortOrder: "0",
|
||||
splashType: "h5",
|
||||
startsAtMs: "",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
export function useSplashScreenConfigPage() {
|
||||
const abilities = useAppConfigAbilities();
|
||||
const confirm = useConfirm();
|
||||
const { showToast } = useToast();
|
||||
const { countryOptions, loadingCountries } = useCountryOptions();
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [platform, setPlatform] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [countryCode, setCountryCode] = useState("");
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [form, setFormState] = useState(emptyForm);
|
||||
const [loadingAction, setLoadingAction] = useState("");
|
||||
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
countryCode,
|
||||
keyword,
|
||||
platform,
|
||||
regionId,
|
||||
status,
|
||||
}),
|
||||
[countryCode, keyword, platform, regionId, status],
|
||||
);
|
||||
const queryFn = useCallback(() => listSplashScreens(filters), [filters]);
|
||||
const {
|
||||
data = emptyData,
|
||||
error,
|
||||
loading,
|
||||
reload,
|
||||
} = useAdminQuery(queryFn, {
|
||||
errorMessage: "加载开屏配置失败",
|
||||
initialData: emptyData,
|
||||
queryKey: ["app-config", "splash-screens", filters],
|
||||
});
|
||||
|
||||
const setForm = (patch) => {
|
||||
setFormState((current) => ({ ...current, ...patch }));
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingItem(null);
|
||||
setFormState(emptyForm());
|
||||
setActiveAction("create");
|
||||
};
|
||||
|
||||
const openEdit = (item) => {
|
||||
setEditingItem(item);
|
||||
setFormState(formFromSplashScreen(item));
|
||||
setActiveAction("edit");
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setActiveAction("");
|
||||
setEditingItem(null);
|
||||
setFormState(emptyForm());
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setKeyword("");
|
||||
setStatus("");
|
||||
setPlatform("");
|
||||
setRegionId("");
|
||||
setCountryCode("");
|
||||
};
|
||||
|
||||
const submitSplashScreen = async (event) => {
|
||||
event.preventDefault();
|
||||
const payload = parseForm(appSplashScreenSchema, normalizeForm(form));
|
||||
const action = editingItem ? "edit" : "create";
|
||||
setLoadingAction(action);
|
||||
try {
|
||||
if (editingItem) {
|
||||
await updateSplashScreen(editingItem.id, payload);
|
||||
showToast("开屏配置已更新", "success");
|
||||
} else {
|
||||
await createSplashScreen(payload);
|
||||
showToast("开屏配置已新增", "success");
|
||||
}
|
||||
closeDialog();
|
||||
await reload();
|
||||
} catch (err) {
|
||||
showToast(err.message || "操作失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeSplashScreen = async (item) => {
|
||||
const ok = await confirm({
|
||||
confirmText: "删除",
|
||||
message: item.description || item.param || `开屏配置 ${item.id}`,
|
||||
title: "删除开屏配置",
|
||||
tone: "danger",
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
setLoadingAction(`delete:${item.id}`);
|
||||
try {
|
||||
await deleteSplashScreen(item.id);
|
||||
await reload();
|
||||
showToast("开屏配置已删除", "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "删除失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
abilities,
|
||||
activeAction,
|
||||
closeDialog,
|
||||
countryCode,
|
||||
countryOptions,
|
||||
data,
|
||||
editingItem,
|
||||
error,
|
||||
form,
|
||||
keyword,
|
||||
loading,
|
||||
loadingAction,
|
||||
loadingCountries,
|
||||
loadingRegions,
|
||||
openCreate,
|
||||
openEdit,
|
||||
platform,
|
||||
regionId,
|
||||
regionOptions,
|
||||
reload,
|
||||
removeSplashScreen,
|
||||
resetFilters,
|
||||
setCountryCode,
|
||||
setForm,
|
||||
setKeyword,
|
||||
setPlatform,
|
||||
setRegionId,
|
||||
setStatus,
|
||||
status,
|
||||
submitSplashScreen,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeForm(form) {
|
||||
return {
|
||||
...form,
|
||||
countryCode: form.countryCode || "",
|
||||
endsAtMs: form.endsAtMs || 0,
|
||||
regionId: form.regionId || 0,
|
||||
sortOrder: form.sortOrder || 0,
|
||||
startsAtMs: form.startsAtMs || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function formFromSplashScreen(item) {
|
||||
return {
|
||||
countryCode: item.countryCode || "",
|
||||
coverUrl: item.coverUrl || "",
|
||||
description: item.description || "",
|
||||
endsAtMs: item.endsAtMs || "",
|
||||
param: item.param || "",
|
||||
platform: item.platform || "android",
|
||||
regionId: item.regionId ? String(item.regionId) : "",
|
||||
sortOrder: String(item.sortOrder || 0),
|
||||
splashType: item.splashType || "h5",
|
||||
startsAtMs: item.startsAtMs || "",
|
||||
status: item.status || "active",
|
||||
};
|
||||
}
|
||||
425
src/features/app-config/pages/SplashScreenConfigPage.jsx
Normal file
425
src/features/app-config/pages/SplashScreenConfigPage.jsx
Normal file
@ -0,0 +1,425 @@
|
||||
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 MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useMemo } from "react";
|
||||
import { useSplashScreenConfigPage } from "@/features/app-config/hooks/useSplashScreenConfigPage.js";
|
||||
import styles from "@/features/app-config/app-config.module.css";
|
||||
import {
|
||||
AdminFormDialog,
|
||||
AdminFormFieldGrid,
|
||||
AdminFormSection,
|
||||
AdminFormSwitchField,
|
||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminRowActions,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
|
||||
const statusOptions = [
|
||||
["", "全部状态"],
|
||||
["active", "启用"],
|
||||
["disabled", "关闭"],
|
||||
["expired", "过期"],
|
||||
];
|
||||
|
||||
const platformOptions = [
|
||||
["", "全部平台"],
|
||||
["android", "安卓"],
|
||||
["ios", "iOS"],
|
||||
];
|
||||
|
||||
export function SplashScreenConfigPage() {
|
||||
const page = useSplashScreenConfigPage();
|
||||
const items = page.data.items || [];
|
||||
const columns = useMemo(() => splashScreenColumns(page), [page]);
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
page.abilities.canUpdate ? (
|
||||
<AdminActionIconButton label="新增开屏" primary onClick={page.openCreate}>
|
||||
<AddOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
items={items}
|
||||
minWidth="1320px"
|
||||
pagination={{
|
||||
itemCount: items.length,
|
||||
page: 1,
|
||||
pageSize: items.length || 1,
|
||||
total: page.data.total ?? items.length,
|
||||
}}
|
||||
rowKey={(item) => item.id}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
|
||||
<AdminFormDialog
|
||||
loading={page.loadingAction === "create" || page.loadingAction === "edit"}
|
||||
open={Boolean(page.activeAction)}
|
||||
size="large"
|
||||
submitDisabled={
|
||||
!page.abilities.canUpdate || page.loadingAction === "create" || page.loadingAction === "edit"
|
||||
}
|
||||
title={page.editingItem ? "编辑开屏配置" : "新增开屏配置"}
|
||||
onClose={page.closeDialog}
|
||||
onSubmit={page.submitSplashScreen}
|
||||
>
|
||||
<div className={styles.splashFormLayout}>
|
||||
<div className={styles.splashAssetPane}>
|
||||
<AdminFormSection title="素材">
|
||||
<UploadField
|
||||
className={styles.splashUploadField}
|
||||
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
|
||||
label="封面图"
|
||||
previewClassName={styles.splashUploadPreview}
|
||||
value={page.form.coverUrl}
|
||||
onChange={(coverUrl) => page.setForm({ coverUrl })}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
</div>
|
||||
<div className={styles.splashFieldPane}>
|
||||
<AdminFormSection title="跳转信息">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="类型"
|
||||
required
|
||||
select
|
||||
value={page.form.splashType}
|
||||
onChange={(event) => page.setForm({ splashType: event.target.value })}
|
||||
>
|
||||
<MenuItem value="h5">H5</MenuItem>
|
||||
<MenuItem value="app">APP</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="平台"
|
||||
required
|
||||
select
|
||||
value={page.form.platform}
|
||||
onChange={(event) => page.setForm({ platform: event.target.value })}
|
||||
>
|
||||
<MenuItem value="android">安卓</MenuItem>
|
||||
<MenuItem value="ios">iOS</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
className={styles.formWideField}
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label={page.form.splashType === "h5" ? "参数(H5链接)" : "参数"}
|
||||
required={page.form.splashType === "h5"}
|
||||
value={page.form.param}
|
||||
onChange={(event) => page.setForm({ param: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="投放设置">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<RegionSelect
|
||||
disabled={!page.abilities.canUpdate}
|
||||
emptyLabel="全部区域"
|
||||
loading={page.loadingRegions}
|
||||
options={page.regionOptions}
|
||||
value={page.form.regionId}
|
||||
onChange={(regionId) => page.setForm({ regionId })}
|
||||
/>
|
||||
<CountrySelect
|
||||
disabled={!page.abilities.canUpdate || page.loadingCountries}
|
||||
emptyLabel="全部国家"
|
||||
label="国家"
|
||||
options={page.countryOptions}
|
||||
value={page.form.countryCode}
|
||||
onChange={(countryCode) => page.setForm({ countryCode })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="排序"
|
||||
slotProps={{ htmlInput: { step: 1 } }}
|
||||
type="number"
|
||||
value={page.form.sortOrder}
|
||||
onChange={(event) => page.setForm({ sortOrder: event.target.value })}
|
||||
/>
|
||||
<AdminFormSwitchField
|
||||
checked={page.form.status === "active"}
|
||||
checkedLabel="启用"
|
||||
className={styles.splashStatusField}
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="开屏状态"
|
||||
switchProps={{ inputProps: { "aria-label": "开屏启用状态" } }}
|
||||
uncheckedLabel={page.form.status === "expired" ? "过期" : "关闭"}
|
||||
onChange={(checked) => page.setForm({ status: checked ? "active" : "disabled" })}
|
||||
/>
|
||||
<TimeRangeFilter
|
||||
className={styles.formWideField}
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="投放时间区间"
|
||||
value={{ endMs: page.form.endsAtMs, startMs: page.form.startsAtMs }}
|
||||
onChange={(range) =>
|
||||
page.setForm({ endsAtMs: range.endMs, startsAtMs: range.startMs })
|
||||
}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="描述">
|
||||
<TextField
|
||||
disabled={!page.abilities.canUpdate}
|
||||
label="描述"
|
||||
multiline
|
||||
minRows={2}
|
||||
value={page.form.description}
|
||||
onChange={(event) => page.setForm({ description: event.target.value })}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
</div>
|
||||
</div>
|
||||
</AdminFormDialog>
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function splashScreenColumns(page) {
|
||||
const columns = [
|
||||
{
|
||||
key: "cover",
|
||||
label: "封面图",
|
||||
render: (item) => <SplashCover src={item.coverUrl} />,
|
||||
width: "minmax(96px, 0.45fr)",
|
||||
},
|
||||
{
|
||||
key: "target",
|
||||
label: "类型 / 参数",
|
||||
render: (item) => <Stack primary={typeLabel(item.splashType)} secondary={item.param || "-"} />,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索参数、国家、描述",
|
||||
value: page.keyword,
|
||||
onChange: page.setKeyword,
|
||||
}),
|
||||
width: "minmax(260px, 1.3fr)",
|
||||
},
|
||||
{
|
||||
key: "platform",
|
||||
label: "平台",
|
||||
render: (item) => platformLabel(item.platform),
|
||||
filter: createOptionsColumnFilter({
|
||||
options: platformOptions,
|
||||
placeholder: "搜索平台",
|
||||
value: page.platform,
|
||||
onChange: page.setPlatform,
|
||||
}),
|
||||
width: "minmax(110px, 0.6fr)",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
render: (item) => <StatusBadge status={item.status} />,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: statusOptions,
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.setStatus,
|
||||
}),
|
||||
width: "minmax(100px, 0.55fr)",
|
||||
},
|
||||
{
|
||||
key: "region",
|
||||
label: "区域",
|
||||
render: (item) => regionLabel(page.regionOptions, item.regionId),
|
||||
filter: createRegionColumnFilter({
|
||||
loading: page.loadingRegions,
|
||||
options: page.regionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.setRegionId,
|
||||
}),
|
||||
width: "minmax(150px, 0.8fr)",
|
||||
},
|
||||
{
|
||||
key: "country",
|
||||
label: "国家",
|
||||
render: (item) => countryLabel(page.countryOptions, item.countryCode),
|
||||
filter: createOptionsColumnFilter({
|
||||
emptyLabel: "全部国家",
|
||||
loading: page.loadingCountries,
|
||||
options: page.countryOptions,
|
||||
placeholder: "搜索国家",
|
||||
value: page.countryCode,
|
||||
onChange: page.setCountryCode,
|
||||
}),
|
||||
width: "minmax(150px, 0.8fr)",
|
||||
},
|
||||
{
|
||||
key: "sortOrder",
|
||||
label: "排序",
|
||||
width: "minmax(80px, 0.4fr)",
|
||||
},
|
||||
{
|
||||
key: "deliveryTime",
|
||||
label: "投放时间",
|
||||
render: (item) => deliveryTimeLabel(item.startsAtMs, item.endsAtMs),
|
||||
width: "minmax(220px, 1fr)",
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
label: "描述",
|
||||
render: (item) => item.description || "-",
|
||||
width: "minmax(180px, 0.9fr)",
|
||||
},
|
||||
{
|
||||
key: "updatedAtMs",
|
||||
label: "更新时间",
|
||||
render: (item) => formatMillis(item.updatedAtMs),
|
||||
width: "minmax(160px, 0.8fr)",
|
||||
},
|
||||
];
|
||||
|
||||
if (!page.abilities.canUpdate) {
|
||||
return columns;
|
||||
}
|
||||
|
||||
return [
|
||||
...columns,
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
render: (item) => <SplashActions item={item} page={page} />,
|
||||
width: "minmax(96px, 0.45fr)",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function SplashCover({ src }) {
|
||||
if (!src) {
|
||||
return (
|
||||
<span className={styles.splashCoverEmpty}>
|
||||
<ImageOutlined fontSize="small" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <img alt="" className={styles.splashCover} src={src} />;
|
||||
}
|
||||
|
||||
function SplashActions({ item, page }) {
|
||||
const deleting = page.loadingAction === `delete:${item.id}`;
|
||||
return (
|
||||
<AdminRowActions>
|
||||
<AdminActionIconButton
|
||||
disabled={Boolean(page.loadingAction)}
|
||||
label="编辑"
|
||||
onClick={() => page.openEdit(item)}
|
||||
>
|
||||
<EditOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton
|
||||
disabled={deleting || Boolean(page.loadingAction)}
|
||||
label="删除"
|
||||
onClick={() => page.removeSplashScreen(item)}
|
||||
>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</AdminRowActions>
|
||||
);
|
||||
}
|
||||
|
||||
function CountrySelect({ className, disabled, emptyLabel, label = "国家", onChange, options, value }) {
|
||||
return (
|
||||
<TextField
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
label={label}
|
||||
select
|
||||
size="small"
|
||||
value={value || ""}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
<MenuItem value="">{emptyLabel}</MenuItem>
|
||||
{options.map((option) => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
);
|
||||
}
|
||||
|
||||
function Stack({ primary, secondary }) {
|
||||
return (
|
||||
<div className="cell-stack">
|
||||
<span>{primary || "-"}</span>
|
||||
<span className="muted">{secondary || "-"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const tone = status === "active" ? "succeeded" : status === "expired" ? "warning" : "stopped";
|
||||
return (
|
||||
<span className={`status-badge status-badge--${tone}`}>
|
||||
<span className="status-point" />
|
||||
{statusLabel(status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function typeLabel(value) {
|
||||
return value === "app" ? "APP" : "H5";
|
||||
}
|
||||
|
||||
function platformLabel(value) {
|
||||
if (value === "android") {
|
||||
return "安卓";
|
||||
}
|
||||
if (value === "ios") {
|
||||
return "iOS";
|
||||
}
|
||||
return value || "-";
|
||||
}
|
||||
|
||||
function deliveryTimeLabel(startsAtMs, endsAtMs) {
|
||||
const start = startsAtMs ? formatMillis(startsAtMs) : "不限";
|
||||
const end = endsAtMs ? formatMillis(endsAtMs) : "不限";
|
||||
return `${start} - ${end}`;
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
if (status === "active") {
|
||||
return "启用";
|
||||
}
|
||||
if (status === "expired") {
|
||||
return "过期";
|
||||
}
|
||||
return "关闭";
|
||||
}
|
||||
|
||||
function regionLabel(options, value) {
|
||||
if (!value) {
|
||||
return "全部区域";
|
||||
}
|
||||
return options.find((option) => option.value === String(value))?.label || `区域 ${value}`;
|
||||
}
|
||||
|
||||
function countryLabel(options, value) {
|
||||
if (!value) {
|
||||
return "全部国家";
|
||||
}
|
||||
return options.find((option) => option.value === value)?.label || value;
|
||||
}
|
||||
@ -1,45 +1,53 @@
|
||||
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||
|
||||
export const appConfigRoutes = [
|
||||
{
|
||||
label: "H5配置",
|
||||
loader: () => import("./pages/H5ConfigPage.jsx").then((module) => module.H5ConfigPage),
|
||||
menuCode: MENU_CODES.appConfigH5,
|
||||
pageKey: "app-config-h5",
|
||||
path: "/app-config/h5",
|
||||
permission: PERMISSIONS.appConfigView
|
||||
},
|
||||
{
|
||||
label: "BANNER配置",
|
||||
loader: () => import("./pages/BannerConfigPage.jsx").then((module) => module.BannerConfigPage),
|
||||
menuCode: MENU_CODES.appConfigBanners,
|
||||
pageKey: "app-config-banners",
|
||||
path: "/app-config/banners",
|
||||
permission: PERMISSIONS.appConfigView
|
||||
},
|
||||
{
|
||||
label: "Explore配置",
|
||||
loader: () => import("./pages/ExploreConfigPage.jsx").then((module) => module.ExploreConfigPage),
|
||||
menuCode: MENU_CODES.appConfigExplore,
|
||||
pageKey: "app-config-explore",
|
||||
path: "/app-config/explore",
|
||||
permission: PERMISSIONS.appConfigView
|
||||
},
|
||||
{
|
||||
label: "内购配置",
|
||||
loader: () =>
|
||||
import("../payment/pages/RechargeProductConfigPage.jsx").then((module) => module.RechargeProductConfigPage),
|
||||
menuCode: MENU_CODES.paymentRechargeProducts,
|
||||
pageKey: "payment-recharge-products",
|
||||
path: "/app-config/recharge-products",
|
||||
permission: PERMISSIONS.paymentProductView
|
||||
},
|
||||
{
|
||||
label: "版本管理",
|
||||
loader: () => import("./pages/AppVersionManagementPage.jsx").then((module) => module.AppVersionManagementPage),
|
||||
menuCode: MENU_CODES.appConfigVersions,
|
||||
pageKey: "app-config-versions",
|
||||
path: "/app-config/versions",
|
||||
permission: PERMISSIONS.appVersionView
|
||||
}
|
||||
{
|
||||
label: "H5配置",
|
||||
loader: () => import("./pages/H5ConfigPage.jsx").then((module) => module.H5ConfigPage),
|
||||
menuCode: MENU_CODES.appConfigH5,
|
||||
pageKey: "app-config-h5",
|
||||
path: "/app-config/h5",
|
||||
permission: PERMISSIONS.appConfigView,
|
||||
},
|
||||
{
|
||||
label: "BANNER配置",
|
||||
loader: () => import("./pages/BannerConfigPage.jsx").then((module) => module.BannerConfigPage),
|
||||
menuCode: MENU_CODES.appConfigBanners,
|
||||
pageKey: "app-config-banners",
|
||||
path: "/app-config/banners",
|
||||
permission: PERMISSIONS.appConfigView,
|
||||
},
|
||||
{
|
||||
label: "开屏配置",
|
||||
loader: () => import("./pages/SplashScreenConfigPage.jsx").then((module) => module.SplashScreenConfigPage),
|
||||
menuCode: MENU_CODES.appConfigSplashScreens,
|
||||
pageKey: "app-config-splash-screens",
|
||||
path: "/app-config/splash-screens",
|
||||
permission: PERMISSIONS.appConfigView,
|
||||
},
|
||||
{
|
||||
label: "Explore配置",
|
||||
loader: () => import("./pages/ExploreConfigPage.jsx").then((module) => module.ExploreConfigPage),
|
||||
menuCode: MENU_CODES.appConfigExplore,
|
||||
pageKey: "app-config-explore",
|
||||
path: "/app-config/explore",
|
||||
permission: PERMISSIONS.appConfigView,
|
||||
},
|
||||
{
|
||||
label: "内购配置",
|
||||
loader: () =>
|
||||
import("../payment/pages/RechargeProductConfigPage.jsx").then((module) => module.RechargeProductConfigPage),
|
||||
menuCode: MENU_CODES.paymentRechargeProducts,
|
||||
pageKey: "payment-recharge-products",
|
||||
path: "/app-config/recharge-products",
|
||||
permission: PERMISSIONS.paymentProductView,
|
||||
},
|
||||
{
|
||||
label: "版本管理",
|
||||
loader: () => import("./pages/AppVersionManagementPage.jsx").then((module) => module.AppVersionManagementPage),
|
||||
menuCode: MENU_CODES.appConfigVersions,
|
||||
pageKey: "app-config-versions",
|
||||
path: "/app-config/versions",
|
||||
permission: PERMISSIONS.appVersionView,
|
||||
},
|
||||
];
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { appBannerSchema, exploreTabSchema, h5LinkUpdateSchema } from "@/features/app-config/schema";
|
||||
import {
|
||||
appBannerSchema,
|
||||
appSplashScreenSchema,
|
||||
exploreTabSchema,
|
||||
h5LinkUpdateSchema,
|
||||
} from "@/features/app-config/schema";
|
||||
import { FormValidationError, parseForm } from "@/shared/forms/validation";
|
||||
|
||||
const validBannerForm = {
|
||||
@ -19,6 +24,20 @@ const validBannerForm = {
|
||||
status: "active",
|
||||
};
|
||||
|
||||
const validSplashScreenForm = {
|
||||
countryCode: "",
|
||||
coverUrl: "https://media.haiyihy.com/splash/launch.png",
|
||||
description: "",
|
||||
endsAtMs: "1800000000000",
|
||||
param: "https://h5.example.com/splash",
|
||||
platform: "ios",
|
||||
regionId: "",
|
||||
sortOrder: "0",
|
||||
splashType: "h5",
|
||||
startsAtMs: "1700000000000",
|
||||
status: "active",
|
||||
};
|
||||
|
||||
describe("app config form schema", () => {
|
||||
test("validates dynamic h5 config item", () => {
|
||||
const payload = parseForm(h5LinkUpdateSchema, {
|
||||
@ -79,6 +98,34 @@ describe("app config form schema", () => {
|
||||
).toThrow(FormValidationError);
|
||||
});
|
||||
|
||||
test("keeps splash screen delivery and placement fields without display scope", () => {
|
||||
const payload = parseForm(appSplashScreenSchema, validSplashScreenForm);
|
||||
|
||||
expect(payload.splashType).toBe("h5");
|
||||
expect(payload.startsAtMs).toBe(1700000000000);
|
||||
expect(payload.endsAtMs).toBe(1800000000000);
|
||||
expect("displayScopes" in payload).toBe(false);
|
||||
});
|
||||
|
||||
test("requires h5 link for splash screen h5 type", () => {
|
||||
expect(() =>
|
||||
parseForm(appSplashScreenSchema, {
|
||||
...validSplashScreenForm,
|
||||
param: "",
|
||||
}),
|
||||
).toThrow(FormValidationError);
|
||||
});
|
||||
|
||||
test("rejects inverted splash screen delivery time range", () => {
|
||||
expect(() =>
|
||||
parseForm(appSplashScreenSchema, {
|
||||
...validSplashScreenForm,
|
||||
endsAtMs: "1700000000000",
|
||||
startsAtMs: "1800000000000",
|
||||
}),
|
||||
).toThrow(FormValidationError);
|
||||
});
|
||||
|
||||
test("validates explore tab h5 url and sort order", () => {
|
||||
const payload = parseForm(exploreTabSchema, {
|
||||
enabled: true,
|
||||
|
||||
@ -84,6 +84,43 @@ export const appBannerSchema = z
|
||||
|
||||
export type AppBannerForm = z.infer<typeof appBannerSchema>;
|
||||
|
||||
export const appSplashScreenSchema = z
|
||||
.object({
|
||||
countryCode: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => value.toUpperCase())
|
||||
.refine((value) => value === "" || /^[A-Z]{2,3}$/.test(value), "国家编码需为 2-3 位字母"),
|
||||
coverUrl: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "请上传封面图")
|
||||
.max(1024, "封面图地址不能超过 1024 个字符")
|
||||
.refine((value) => !/\s/.test(value), "封面图地址不能包含空白字符"),
|
||||
description: z.string().trim().max(255, "描述不能超过 255 个字符"),
|
||||
endsAtMs: z.coerce.number().int().min(0, "投放结束时间不正确").default(0),
|
||||
param: z.string().trim().max(2048, "参数不能超过 2048 个字符"),
|
||||
platform: z.enum(["android", "ios"]),
|
||||
regionId: z.coerce.number().int().min(0, "区域不正确").default(0),
|
||||
sortOrder: z.coerce.number().int("排序必须是整数").default(0),
|
||||
splashType: z.enum(["h5", "app"]),
|
||||
startsAtMs: z.coerce.number().int().min(0, "投放开始时间不正确").default(0),
|
||||
status: z.enum(["active", "disabled", "expired"]),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.splashType === "h5" && !value.param) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 类型需要填写链接", path: ["param"] });
|
||||
}
|
||||
if (value.splashType === "h5" && /\s/.test(value.param)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "H5 链接不能包含空白字符", path: ["param"] });
|
||||
}
|
||||
if (value.startsAtMs > 0 && value.endsAtMs > 0 && value.startsAtMs >= value.endsAtMs) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "投放结束时间必须晚于开始时间", path: ["endsAtMs"] });
|
||||
}
|
||||
});
|
||||
|
||||
export type AppSplashScreenForm = z.infer<typeof appSplashScreenSchema>;
|
||||
|
||||
export const appVersionSchema = z.object({
|
||||
buildNumber: z.coerce.number().int("编译版本号必须是整数").positive("编译版本号必须大于 0"),
|
||||
description: z.string().trim().max(1000, "更新描述不能超过 1000 个字符").default(""),
|
||||
|
||||
@ -50,6 +50,7 @@ export const API_OPERATIONS = {
|
||||
createResourceGroup: "createResourceGroup",
|
||||
createRole: "createRole",
|
||||
createRoomPin: "createRoomPin",
|
||||
createSplashScreen: "createSplashScreen",
|
||||
createTaskDefinition: "createTaskDefinition",
|
||||
createTeamSalaryPolicy: "createTeamSalaryPolicy",
|
||||
createTier: "createTier",
|
||||
@ -71,6 +72,7 @@ export const API_OPERATIONS = {
|
||||
deleteRechargeProduct: "deleteRechargeProduct",
|
||||
deleteRole: "deleteRole",
|
||||
deleteRoom: "deleteRoom",
|
||||
deleteSplashScreen: "deleteSplashScreen",
|
||||
deleteTeamSalaryPolicy: "deleteTeamSalaryPolicy",
|
||||
disableCountry: "disableCountry",
|
||||
disableGift: "disableGift",
|
||||
@ -155,6 +157,7 @@ export const API_OPERATIONS = {
|
||||
listRooms: "listRooms",
|
||||
listRoomTurnoverRewardSettlements: "listRoomTurnoverRewardSettlements",
|
||||
listSevenDayCheckInClaims: "listSevenDayCheckInClaims",
|
||||
listSplashScreens: "listSplashScreens",
|
||||
listSystemMenus: "listSystemMenus",
|
||||
listTaskDefinitions: "listTaskDefinitions",
|
||||
listTeamSalaryPolicies: "listTeamSalaryPolicies",
|
||||
@ -220,6 +223,7 @@ export const API_OPERATIONS = {
|
||||
updateRoomRocketConfig: "updateRoomRocketConfig",
|
||||
updateRoomTurnoverRewardConfig: "updateRoomTurnoverRewardConfig",
|
||||
updateSevenDayCheckInConfig: "updateSevenDayCheckInConfig",
|
||||
updateSplashScreen: "updateSplashScreen",
|
||||
updateTaskDefinition: "updateTaskDefinition",
|
||||
updateTeamSalaryPolicy: "updateTeamSalaryPolicy",
|
||||
updateTier: "updateTier",
|
||||
@ -494,6 +498,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "room-pin:create",
|
||||
permissions: ["room-pin:create"]
|
||||
},
|
||||
createSplashScreen: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createSplashScreen,
|
||||
path: "/v1/admin/app-config/splash-screens",
|
||||
permission: "app-config:update",
|
||||
permissions: ["app-config:update"]
|
||||
},
|
||||
createTaskDefinition: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.createTaskDefinition,
|
||||
@ -641,6 +652,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "room:delete",
|
||||
permissions: ["room:delete"]
|
||||
},
|
||||
deleteSplashScreen: {
|
||||
method: "DELETE",
|
||||
operationId: API_OPERATIONS.deleteSplashScreen,
|
||||
path: "/v1/admin/app-config/splash-screens/{splash_id}",
|
||||
permission: "app-config:update",
|
||||
permissions: ["app-config:update"]
|
||||
},
|
||||
deleteTeamSalaryPolicy: {
|
||||
method: "DELETE",
|
||||
operationId: API_OPERATIONS.deleteTeamSalaryPolicy,
|
||||
@ -1227,6 +1245,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "seven-day-checkin:view",
|
||||
permissions: ["seven-day-checkin:view"]
|
||||
},
|
||||
listSplashScreens: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listSplashScreens,
|
||||
path: "/v1/admin/app-config/splash-screens",
|
||||
permission: "app-config:view",
|
||||
permissions: ["app-config:view"]
|
||||
},
|
||||
listSystemMenus: {
|
||||
method: "GET",
|
||||
operationId: API_OPERATIONS.listSystemMenus,
|
||||
@ -1670,6 +1695,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
permission: "seven-day-checkin:update",
|
||||
permissions: ["seven-day-checkin:update"]
|
||||
},
|
||||
updateSplashScreen: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateSplashScreen,
|
||||
path: "/v1/admin/app-config/splash-screens/{splash_id}",
|
||||
permission: "app-config:update",
|
||||
permissions: ["app-config:update"]
|
||||
},
|
||||
updateTaskDefinition: {
|
||||
method: "PUT",
|
||||
operationId: API_OPERATIONS.updateTaskDefinition,
|
||||
|
||||
84
src/shared/api/generated/schema.d.ts
vendored
84
src/shared/api/generated/schema.d.ts
vendored
@ -596,6 +596,38 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/app-config/splash-screens": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listSplashScreens"];
|
||||
put?: never;
|
||||
post: operations["createSplashScreen"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/app-config/splash-screens/{splash_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put: operations["updateSplashScreen"];
|
||||
post?: never;
|
||||
delete: operations["deleteSplashScreen"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/app-config/h5-links": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -3574,6 +3606,58 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listSplashScreens: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
createSplashScreen: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
updateSplashScreen: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
splash_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
deleteSplashScreen: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
splash_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listH5Links: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@ -800,6 +800,38 @@ export interface AppBannerPayload {
|
||||
status: "active" | "disabled" | "expired";
|
||||
}
|
||||
|
||||
export interface AppSplashScreenDto {
|
||||
appCode?: string;
|
||||
countryCode?: string;
|
||||
coverUrl: string;
|
||||
createdAtMs?: number;
|
||||
description?: string;
|
||||
endsAtMs?: number;
|
||||
id: number;
|
||||
param?: string;
|
||||
platform: "android" | "ios" | string;
|
||||
regionId?: number;
|
||||
sortOrder?: number;
|
||||
splashType: "h5" | "app" | string;
|
||||
startsAtMs?: number;
|
||||
status: "active" | "disabled" | "expired" | string;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface AppSplashScreenPayload {
|
||||
countryCode?: string;
|
||||
coverUrl: string;
|
||||
description?: string;
|
||||
endsAtMs?: number;
|
||||
param?: string;
|
||||
platform: "android" | "ios";
|
||||
regionId?: number;
|
||||
sortOrder?: number;
|
||||
splashType: "h5" | "app";
|
||||
startsAtMs?: number;
|
||||
status: "active" | "disabled" | "expired";
|
||||
}
|
||||
|
||||
export interface AgencyMembershipDto {
|
||||
agencyId?: number;
|
||||
createdAtMs?: number;
|
||||
|
||||
@ -15,12 +15,15 @@ let pagModulePromise;
|
||||
|
||||
export function UploadField({
|
||||
accept,
|
||||
className,
|
||||
disabled = false,
|
||||
density = "regular",
|
||||
kind = "image",
|
||||
label,
|
||||
onChange,
|
||||
onFileSelected,
|
||||
panelClassName,
|
||||
previewClassName,
|
||||
uploadKind,
|
||||
value = "",
|
||||
}) {
|
||||
@ -90,7 +93,12 @@ export function UploadField({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[styles.root, density === "compact" ? styles.compact : "", disabled ? styles.disabled : ""]
|
||||
className={[
|
||||
styles.root,
|
||||
density === "compact" ? styles.compact : "",
|
||||
disabled ? styles.disabled : "",
|
||||
className || "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
@ -98,10 +106,10 @@ export function UploadField({
|
||||
<span className={styles.label}>{label}</span>
|
||||
<span className={styles.type}>{assetKindLabel(assetKind, isImage)}</span>
|
||||
</div>
|
||||
<div className={styles.panel}>
|
||||
<div className={[styles.panel, panelClassName || ""].filter(Boolean).join(" ")}>
|
||||
<button
|
||||
aria-label={label}
|
||||
className={styles.preview}
|
||||
className={[styles.preview, previewClassName || ""].filter(Boolean).join(" ")}
|
||||
disabled={disabled || uploading}
|
||||
type="button"
|
||||
onClick={openPicker}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user