497 lines
19 KiB
JavaScript
497 lines
19 KiB
JavaScript
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;
|
||
}
|