From 0b3589a70192244271f3af998a688475eaa9f759 Mon Sep 17 00:00:00 2001 From: zhx Date: Tue, 9 Jun 2026 22:08:57 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=80=E5=B1=8F=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/admin-openapi.json | 64 +++ src/app/navigation/menu.js | 15 +- src/app/navigation/menu.test.jsx | 50 +++ src/app/permissions.ts | 1 + src/features/app-config/api.ts | 109 +++-- src/features/app-config/app-config.module.css | 74 +++ .../hooks/useSplashScreenConfigPage.js | 204 +++++++++ .../pages/SplashScreenConfigPage.jsx | 425 ++++++++++++++++++ src/features/app-config/routes.js | 90 ++-- src/features/app-config/schema.test.ts | 49 +- src/features/app-config/schema.ts | 37 ++ src/shared/api/generated/endpoints.ts | 32 ++ src/shared/api/generated/schema.d.ts | 84 ++++ src/shared/api/types.ts | 32 ++ src/shared/ui/UploadField.jsx | 14 +- 15 files changed, 1205 insertions(+), 75 deletions(-) create mode 100644 src/features/app-config/hooks/useSplashScreenConfigPage.js create mode 100644 src/features/app-config/pages/SplashScreenConfigPage.jsx diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index ada3104..63ff7fe 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -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", diff --git a/src/app/navigation/menu.js b/src/app/navigation/menu.js index aa91287..2638869 100644 --- a/src/app/navigation/menu.js +++ b/src/app/navigation/menu.js @@ -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) { diff --git a/src/app/navigation/menu.test.jsx b/src/app/navigation/menu.test.jsx index b93435a..39a1d6e 100644 --- a/src/app/navigation/menu.test.jsx +++ b/src/app/navigation/menu.test.jsx @@ -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) { diff --git a/src/app/permissions.ts b/src/app/permissions.ts index 4237f34..2c4cfce 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -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", diff --git a/src/features/app-config/api.ts b/src/features/app-config/api.ts index 2969804..c38e88d 100644 --- a/src/features/app-config/api.ts +++ b/src/features/app-config/api.ts @@ -4,6 +4,8 @@ import type { ApiList, AppBannerDto, AppBannerPayload, + AppSplashScreenDto, + AppSplashScreenPayload, AppVersionDto, AppVersionPayload, EntityId, @@ -18,23 +20,26 @@ import type { export function listH5Links(): Promise> { const endpoint = API_ENDPOINTS.listH5Links; return apiRequest>(apiEndpointPath(API_OPERATIONS.listH5Links), { - method: endpoint.method + method: endpoint.method, }); } export function updateH5Links(payload: H5LinkConfigUpdatePayload): Promise> { const endpoint = API_ENDPOINTS.updateH5Links; - return apiRequest, H5LinkConfigUpdatePayload>(apiEndpointPath(API_OPERATIONS.updateH5Links), { - body: payload, - method: endpoint.method - }); + return apiRequest, H5LinkConfigUpdatePayload>( + apiEndpointPath(API_OPERATIONS.updateH5Links), + { + body: payload, + method: endpoint.method, + }, + ); } export function createH5Link(payload: H5LinkConfigPayload): Promise { const endpoint = API_ENDPOINTS.createH5Link; return apiRequest(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(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>(apiEndpointPath(API_OPERATIONS.listExploreTabs), { method: endpoint.method, - query + query, }); } @@ -65,22 +70,25 @@ export function createExploreTab(payload: ExploreTabPayload): Promise(apiEndpointPath(API_OPERATIONS.createExploreTab), { body: payload, - method: endpoint.method + method: endpoint.method, }); } export function updateExploreTab(tabId: EntityId, payload: ExploreTabPayload): Promise { const endpoint = API_ENDPOINTS.updateExploreTab; - return apiRequest(apiEndpointPath(API_OPERATIONS.updateExploreTab, { tab_id: tabId }), { - body: payload, - method: endpoint.method - }); + return apiRequest( + 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>(apiEndpointPath(API_OPERATIONS.listBanners), { method: endpoint.method, - query + query, }); } @@ -96,30 +104,70 @@ export function createBanner(payload: AppBannerPayload): Promise { const endpoint = API_ENDPOINTS.createBanner; return apiRequest(apiEndpointPath(API_OPERATIONS.createBanner), { body: payload, - method: endpoint.method + method: endpoint.method, }); } export function updateBanner(bannerId: EntityId, payload: AppBannerPayload): Promise { const endpoint = API_ENDPOINTS.updateBanner; - return apiRequest(apiEndpointPath(API_OPERATIONS.updateBanner, { banner_id: bannerId }), { - body: payload, - method: endpoint.method - }); + return apiRequest( + 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> { + const endpoint = API_ENDPOINTS.listSplashScreens; + return apiRequest>(apiEndpointPath(API_OPERATIONS.listSplashScreens), { + method: endpoint.method, + query, + }); +} + +export function createSplashScreen(payload: AppSplashScreenPayload): Promise { + const endpoint = API_ENDPOINTS.createSplashScreen; + return apiRequest(apiEndpointPath(API_OPERATIONS.createSplashScreen), { + body: payload, + method: endpoint.method, + }); +} + +export function updateSplashScreen(splashId: EntityId, payload: AppSplashScreenPayload): Promise { + const endpoint = API_ENDPOINTS.updateSplashScreen; + return apiRequest( + 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> { const endpoint = API_ENDPOINTS.listAppVersions; return apiRequest>(apiEndpointPath(API_OPERATIONS.listAppVersions), { method: endpoint.method, - query + query, }); } @@ -127,7 +175,7 @@ export function createAppVersion(payload: AppVersionPayload): Promise(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, + }, + ); } diff --git a/src/features/app-config/app-config.module.css b/src/features/app-config/app-config.module.css index 32ff7bb..ffe65dd 100644 --- a/src/features/app-config/app-config.module.css +++ b/src/features/app-config/app-config.module.css @@ -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); + } +} diff --git a/src/features/app-config/hooks/useSplashScreenConfigPage.js b/src/features/app-config/hooks/useSplashScreenConfigPage.js new file mode 100644 index 0000000..d0e697a --- /dev/null +++ b/src/features/app-config/hooks/useSplashScreenConfigPage.js @@ -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", + }; +} diff --git a/src/features/app-config/pages/SplashScreenConfigPage.jsx b/src/features/app-config/pages/SplashScreenConfigPage.jsx new file mode 100644 index 0000000..495591f --- /dev/null +++ b/src/features/app-config/pages/SplashScreenConfigPage.jsx @@ -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 ( + + + + + ) : null + } + /> + + + item.id} + /> + + + + +
+
+ + page.setForm({ coverUrl })} + /> + +
+
+ + + page.setForm({ splashType: event.target.value })} + > + H5 + APP + + page.setForm({ platform: event.target.value })} + > + 安卓 + iOS + + page.setForm({ param: event.target.value })} + /> + + + + + page.setForm({ regionId })} + /> + page.setForm({ countryCode })} + /> + page.setForm({ sortOrder: event.target.value })} + /> + page.setForm({ status: checked ? "active" : "disabled" })} + /> + + page.setForm({ endsAtMs: range.endMs, startsAtMs: range.startMs }) + } + /> + + + + page.setForm({ description: event.target.value })} + /> + +
+
+
+
+ ); +} + +function splashScreenColumns(page) { + const columns = [ + { + key: "cover", + label: "封面图", + render: (item) => , + width: "minmax(96px, 0.45fr)", + }, + { + key: "target", + label: "类型 / 参数", + render: (item) => , + 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) => , + 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) => , + width: "minmax(96px, 0.45fr)", + }, + ]; +} + +function SplashCover({ src }) { + if (!src) { + return ( + + + + ); + } + return ; +} + +function SplashActions({ item, page }) { + const deleting = page.loadingAction === `delete:${item.id}`; + return ( + + page.openEdit(item)} + > + + + page.removeSplashScreen(item)} + > + + + + ); +} + +function CountrySelect({ className, disabled, emptyLabel, label = "国家", onChange, options, value }) { + return ( + onChange(event.target.value)} + > + {emptyLabel} + {options.map((option) => ( + + {option.label} + + ))} + + ); +} + +function Stack({ primary, secondary }) { + return ( +
+ {primary || "-"} + {secondary || "-"} +
+ ); +} + +function StatusBadge({ status }) { + const tone = status === "active" ? "succeeded" : status === "expired" ? "warning" : "stopped"; + return ( + + + {statusLabel(status)} + + ); +} + +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; +} diff --git a/src/features/app-config/routes.js b/src/features/app-config/routes.js index 5c00b5f..1401aa9 100644 --- a/src/features/app-config/routes.js +++ b/src/features/app-config/routes.js @@ -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, + }, ]; diff --git a/src/features/app-config/schema.test.ts b/src/features/app-config/schema.test.ts index f220dac..9ddafdf 100644 --- a/src/features/app-config/schema.test.ts +++ b/src/features/app-config/schema.test.ts @@ -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, diff --git a/src/features/app-config/schema.ts b/src/features/app-config/schema.ts index 7044acf..6f6280a 100644 --- a/src/features/app-config/schema.ts +++ b/src/features/app-config/schema.ts @@ -84,6 +84,43 @@ export const appBannerSchema = z export type AppBannerForm = z.infer; +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; + export const appVersionSchema = z.object({ buildNumber: z.coerce.number().int("编译版本号必须是整数").positive("编译版本号必须大于 0"), description: z.string().trim().max(1000, "更新描述不能超过 1000 个字符").default(""), diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index da98de0..91f9810 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -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 = { 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 = { 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 = { 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 = { 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, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index da780c8..dff9a0d 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -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; diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index 52aa7c2..e324c8e 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -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; diff --git a/src/shared/ui/UploadField.jsx b/src/shared/ui/UploadField.jsx index 0d2fb7f..b165aac 100644 --- a/src/shared/ui/UploadField.jsx +++ b/src/shared/ui/UploadField.jsx @@ -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 (
@@ -98,10 +106,10 @@ export function UploadField({ {label} {assetKindLabel(assetKind, isImage)}
-
+