2026-05-26 05:17:07 +08:00

497 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import AddOutlined from "@mui/icons-material/AddOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import ImageOutlined from "@mui/icons-material/ImageOutlined";
import Checkbox from "@mui/material/Checkbox";
import ListItemText from "@mui/material/ListItemText";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { useMemo } from "react";
import { APP_BANNER_DISPLAY_SCOPE_OPTIONS } from "@/features/app-config/constants";
import { useBannerConfigPage } from "@/features/app-config/hooks/useBannerConfigPage.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 displayScopeFilterOptions = [
["", "全部范围"],
...APP_BANNER_DISPLAY_SCOPE_OPTIONS.map((option) => [option.value, option.label]),
];
const platformOptions = [
["", "全部平台"],
["android", "安卓"],
["ios", "iOS"],
];
export function BannerConfigPage() {
const page = useBannerConfigPage();
const items = page.data.items || [];
const columns = useMemo(() => bannerColumns(page), [page]);
return (
<AdminListPage>
<AdminListToolbar
actions={
page.abilities.canUpdate ? (
<AdminActionIconButton label="新增BANNER" 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="1480px"
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 ? "编辑BANNER" : "新增BANNER"}
onClose={page.closeDialog}
onSubmit={page.submitBanner}
>
<AdminFormSection title="素材">
<AdminFormFieldGrid
columns={hasDisplayScope(page.form.displayScopes, "room") ? "repeat(2, minmax(0, 1fr))" : "1fr"}
>
<UploadField
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
label="封面图"
value={page.form.coverUrl}
onChange={(coverUrl) => page.setForm({ coverUrl })}
/>
{hasDisplayScope(page.form.displayScopes, "room") ? (
<UploadField
disabled={!page.abilities.canUpdate || !page.abilities.canUpload}
label="房间内小屏图"
value={page.form.roomSmallImageUrl}
onChange={(roomSmallImageUrl) => page.setForm({ roomSmallImageUrl })}
/>
) : null}
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="跳转信息">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
disabled={!page.abilities.canUpdate}
label="类型"
required
select
value={page.form.bannerType}
onChange={(event) => page.setForm({ bannerType: 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.bannerType === "h5" ? "参数H5链接" : "参数"}
required={page.form.bannerType === "h5"}
value={page.form.param}
onChange={(event) => page.setForm({ param: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="投放设置">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
disabled={!page.abilities.canUpdate}
label="显示范围"
required
select
slotProps={{
select: {
multiple: true,
renderValue: (selected) => displayScopeListLabel(selected),
},
}}
value={page.form.displayScopes}
onChange={(event) => {
const displayScopes = normalizeSelectedDisplayScopes(event.target.value);
page.setForm({
displayScopes,
roomSmallImageUrl: displayScopes.includes("room")
? page.form.roomSmallImageUrl
: "",
});
}}
>
{APP_BANNER_DISPLAY_SCOPE_OPTIONS.map((option) => (
<MenuItem key={option.value} value={option.value}>
<Checkbox checked={hasDisplayScope(page.form.displayScopes, option.value)} />
<ListItemText primary={option.label} />
</MenuItem>
))}
</TextField>
<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}
inputProps={{ step: 1 }}
label="排序"
type="number"
value={page.form.sortOrder}
onChange={(event) => page.setForm({ sortOrder: event.target.value })}
/>
<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 })}
/>
<AdminFormSwitchField
checked={page.form.status === "active"}
checkedLabel="启用"
disabled={!page.abilities.canUpdate}
label="BANNER 状态"
switchProps={{ inputProps: { "aria-label": "BANNER 启用状态" } }}
uncheckedLabel={page.form.status === "expired" ? "过期" : "关闭"}
onChange={(checked) => page.setForm({ status: checked ? "active" : "disabled" })}
/>
</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>
</AdminFormDialog>
</AdminListPage>
);
}
function bannerColumns(page) {
const columns = [
{
key: "cover",
label: "封面图",
render: (item) => <BannerCover src={item.coverUrl} />,
width: "minmax(112px, 0.55fr)",
},
{
key: "target",
label: "类型 / 参数",
render: (item) => <Stack primary={typeLabel(item.bannerType)} 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: "displayScope",
label: "显示范围",
render: (item) => displayScopeListLabel(item.displayScopes || item.displayScope),
filter: createOptionsColumnFilter({
options: displayScopeFilterOptions,
placeholder: "搜索显示范围",
value: page.displayScope,
onChange: page.setDisplayScope,
}),
width: "minmax(120px, 0.65fr)",
},
{
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) => <BannerActions item={item} page={page} />,
width: "minmax(96px, 0.45fr)",
},
];
}
function BannerCover({ src }) {
if (!src) {
return (
<span className={styles.bannerCoverEmpty}>
<ImageOutlined fontSize="small" />
</span>
);
}
return <img alt="" className={styles.bannerCover} src={src} />;
}
function BannerActions({ 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.removeBanner(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 displayScopeLabel(value) {
return APP_BANNER_DISPLAY_SCOPE_OPTIONS.find((option) => option.value === value)?.label || value || "-";
}
function displayScopeListLabel(value) {
const values = normalizeSelectedDisplayScopes(value);
return values.map((scope) => displayScopeLabel(scope)).join("、") || "-";
}
function normalizeSelectedDisplayScopes(value) {
const values = Array.isArray(value) ? value : String(value || "").split(",");
const allowed = APP_BANNER_DISPLAY_SCOPE_OPTIONS.map((option) => option.value);
const seen = new Set(values.map((item) => String(item || "").trim()).filter(Boolean));
const selected = allowed.filter((scope) => seen.has(scope));
return selected.length ? selected : ["home"];
}
function hasDisplayScope(value, scope) {
return normalizeSelectedDisplayScopes(value).includes(scope);
}
function deliveryTimeLabel(startsAtMs, endsAtMs) {
const start = startsAtMs ? formatMillis(startsAtMs) : "不限";
const end = endsAtMs ? formatMillis(endsAtMs) : "不限";
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;
}