开屏配置

This commit is contained in:
zhx 2026-06-09 22:08:57 +08:00
parent e423adc738
commit 0b3589a701
15 changed files with 1205 additions and 75 deletions

View File

@ -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",

View File

@ -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) {

View File

@ -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) {

View File

@ -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",

View File

@ -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,
},
);
}

View File

@ -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);
}
}

View 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",
};
}

View 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;
}

View File

@ -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,
},
];

View File

@ -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,

View File

@ -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(""),

View File

@ -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,

View File

@ -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;

View File

@ -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;

View File

@ -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}