提交 811c01b6 authored 作者: 王鹏飞's avatar 王鹏飞

refactor(admin): complete advert AppList migration

上级 a3f9b528
# Advert Migration Report
## Status
- The active `/advertisement/advert` module route now renders `AdvertListView`, whose main list shell is the controlled shared `AppList`.
- The proxy to the legacy page was removed; the legacy page remains available for deprecated route/config compatibility.
- The advertisement-position request remains sourced from `pages/advertisement/adsense/request.js`; that dependency was not modified.
## Preserved list behavior
- Columns: ID, advertisement position, three-line advertisement name, 80×80 image with fallback, linked/truncated URL with popover, status, validity start/end, and operations.
- Filters: advertisement position, advertisement name, and validity range; validity is sent as local start-of-day/end-of-day Unix seconds.
- Pagination remains controlled with page-size choices 10, 20, 30, and 40. Page and page-size changes use the current draft filters.
- Search, reset, refresh, retry, and immediate reload when clearing an active filter are wired through `useAdvertList`.
- Successful add, edit, delete, and status changes preserve the legacy unfiltered-current-page refresh behavior.
## Preserved permissions and operations
- `/advertisement/advert/changeStatus`: status switch.
- `/advertisement/advert/getInfoById`: edit action and detail request.
- `/advertisement/advert/del`: delete action and confirmation.
- `/advertisement/advert/add`: add action.
- Local typed request wrappers preserve the six `/ad/ad/*` endpoints, form encoding, server success/error messages, and `数据获取失败!` fallback.
## Private UI and upload
- `AdvertFormDrawer`, `AdvertImageUpload`, and `DeleteAdvertModal` are private to the advert module.
- The add/edit drawer preserves advertisement-position, name, required image, validity, and URL fields with the legacy validation and copy.
- Editing loads the existing detail and maps its validity values into the date range.
- Image upload preserves Ali OSS STS initialization/refresh, the `zxts-ad-file` bucket, dated randomized object paths, JPG/PNG validation, spinner, uploaded-image preview, and upload error handling.
- Delete confirmation preserves the original wording and successful mutation refresh.
## Verification
- `pnpm exec oxlint src/modules/advertisements/advert --deny-warnings`: passed.
- `pnpm exec oxfmt --check src/modules/advertisements/advert`: passed.
- `pnpm exec tsc --noEmit`: no diagnostics under `src/modules/advertisements/advert`; the workspace still has unrelated existing diagnostics.
- `pnpm test`: passed, 11 tests.
- `pnpm build`: passed; only the existing formula-editor and large-chunk warnings were emitted.
## Concerns
- Per task direction, no API/query/Hook tests were added.
- Deprecated root route/config metadata still references the legacy advertisement page, so that old compatibility implementation and its request file remain in place.
- The unchanged legacy advertisement-position request remains a deliberate dependency of this module.
export * from "@/pages/advertisement/advert/request";
import { message } from "antd";
// @ts-expect-error The legacy workspace does not install the qs declaration package.
import qs from "qs";
import { getPositionList as getLegacyPositionList } from "@/pages/advertisement/adsense/request.js";
import request from "@/utils/request.js";
import type { AdvertQuery } from "./query";
export interface AdvertRow {
id: number;
position_id?: number | string;
position_name?: string;
name?: string;
pic?: string;
url?: string;
status?: number | string;
start_validity?: string;
end_validity?: string;
}
export interface AdvertPosition {
id: number;
position_name?: string;
}
export interface AdvertListResult {
rows: AdvertRow[];
total: number;
}
type ApiParams = Record<string, unknown>;
type ApiBody<T> = { code?: number; data?: T; message?: string };
async function getPayload<T>(url: string, params: ApiParams): Promise<T | false> {
try {
const response = await request.post(url, qs.stringify(params));
const body = response.data as ApiBody<T>;
const text = body?.message ?? "数据获取失败!";
if (body?.code === 200) return (body.data ?? []) as T;
message.error(text);
return false;
} catch {
return false;
}
}
async function mutate(url: string, params: ApiParams) {
try {
const response = await request.post(url, qs.stringify(params));
const body = response.data as ApiBody<unknown>;
const text = body?.message ?? "数据获取失败!";
if (body?.code === 200) {
message.success(text);
return true;
}
message.error(text);
return false;
} catch {
return false;
}
}
export async function getAdvertList(query: AdvertQuery): Promise<AdvertListResult | false> {
const data = await getPayload<{ list?: AdvertRow[]; total?: number }>("/ad/ad/getList", {
...query,
});
if (data === false) return false;
return { rows: data.list ?? [], total: data.total ?? 0 };
}
export function getAdvertInfo(id: number) {
return getPayload<AdvertRow>("/ad/ad/getInfoById", { id });
}
export function addAdvert(params: ApiParams) {
return mutate("/ad/ad/add", params);
}
export function editAdvert(params: ApiParams) {
return mutate("/ad/ad/edit", params);
}
export function deleteAdvert(id: number) {
return mutate("/ad/ad/del", { id });
}
export function changeAdvertStatus(id: number, status: "0" | "1") {
return mutate("/ad/ad/changeStatus", { id, status });
}
export async function getAdvertPositions(): Promise<AdvertPosition[] | false> {
const result = await getLegacyPositionList();
if (!result) return false;
return (result.list ?? []) as AdvertPosition[];
}
import { Button, DatePicker, Drawer, Form, Input, Select, Space, Spin } from "antd";
import type { Dayjs } from "dayjs";
import { useEffect, useState } from "react";
import type { AdvertPosition } from "../api";
import AdvertImageUpload from "./AdvertImageUpload";
const { RangePicker } = DatePicker;
export interface AdvertFormValues {
position_id?: number | string | null;
name?: string;
pic?: string;
validity?: [Dayjs | null, Dayjs | null] | null;
url?: string;
}
interface AdvertFormDrawerProps {
open: boolean;
positions: AdvertPosition[];
initialValues: AdvertFormValues | null;
onClose: () => void;
onSubmit: (values: AdvertFormValues) => void | Promise<void>;
}
const EMPTY_VALUES: AdvertFormValues = {
position_id: null,
name: "",
pic: "",
validity: null,
url: "",
};
export default function AdvertFormDrawer({
open,
positions,
initialValues,
onClose,
onSubmit,
}: AdvertFormDrawerProps) {
const [form] = Form.useForm<AdvertFormValues>();
const [uploading, setUploading] = useState(false);
useEffect(() => {
if (!open) {
form.resetFields();
form.setFieldsValue(EMPTY_VALUES as Parameters<typeof form.setFieldsValue>[0]);
return;
}
form.setFieldsValue({
...EMPTY_VALUES,
...initialValues,
} as Parameters<typeof form.setFieldsValue>[0]);
}, [form, initialValues, open]);
return (
<Drawer placement="right" onClose={onClose} open={open} mask={false}>
<Spin spinning={uploading}>
<Form form={form} onFinish={onSubmit} labelCol={{ span: 7 }}>
<Form.Item
label="所属广告位"
rules={[{ required: true, message: "请选择所属广告位" }]}
name="position_id"
>
<Select
placeholder="请选择所属广告位"
options={positions.map((position) => ({
value: position.id,
label: position.position_name,
}))}
/>
</Form.Item>
<Form.Item
label="广告名称"
rules={[
{ required: true, message: "请输入广告名称" },
{ max: 80, message: "最大80个字符" },
]}
name="name"
>
<Input autoComplete="off" placeholder="请输入广告名称" />
</Form.Item>
<Form.Item
name="pic"
label="广告图片"
rules={[{ required: true, message: "请选择广告图片" }]}
>
<AdvertImageUpload onUploadingChange={setUploading} />
</Form.Item>
<Form.Item
name="validity"
label="广告有效期"
rules={[
{ required: true, message: "请选择广告有效期" },
{
validator(_, value) {
if (!value || value.length !== 2) return Promise.reject("");
const today = new Date();
const startDate = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate(),
);
const endDate = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate(),
23,
59,
);
if (endDate.getFullYear() > 2101) {
return Promise.reject("有效期结束时间最大限制2100-12-31");
}
if (startDate <= endDate) return Promise.resolve();
return Promise.reject("开始时间要小于结束时间");
},
},
]}
>
<RangePicker />
</Form.Item>
<Form.Item name="url" label="广告链接地址" rules={[{ max: 500, message: "最大长度500" }]}>
<Input autoComplete="off" placeholder="请输入广告链接地址" />
</Form.Item>
<Form.Item wrapperCol={{ offset: 11, span: 16 }}>
<Space size={20}>
<Button onClick={onClose}>取消</Button>
<Button type="primary" htmlType="submit">
提交
</Button>
</Space>
</Form.Item>
</Form>
</Spin>
</Drawer>
);
}
// @ts-expect-error The legacy workspace does not install the ali-oss declaration package.
import AliOSS from "ali-oss";
import { Button, Image, message, Upload } from "antd";
import type { UploadProps } from "antd";
import dayjs from "dayjs";
import { useEffect, useState } from "react";
import { getAliOSSSTSToken } from "@/pages/setting/help/addedit/requet";
interface OssClient {
put: (path: string, file: File) => Promise<{ url: string }>;
}
interface AdvertImageUploadProps {
value?: string;
onChange?: (value: string) => void;
onUploadingChange: (uploading: boolean) => void;
}
export default function AdvertImageUpload({
value,
onChange,
onUploadingChange,
}: AdvertImageUploadProps) {
const [ossClient, setOssClient] = useState<OssClient | null>(null);
useEffect(() => {
let active = true;
void getAliOSSSTSToken().then((data: any) => {
if (!active || !data) return;
window.sessionStorage.setItem("sts", JSON.stringify(data));
const client = new AliOSS({
accessKeyId: data.AccessKeyId,
accessKeySecret: data.AccessKeySecret,
stsToken: data.SecurityToken,
endpoint: data.endpoint,
bucket: "zxts-ad-file",
timeout: 180000,
refreshSTSToken: async () => {
const info = await getAliOSSSTSToken();
return {
AccessKeyId: info.AccessKeyId,
AccessKeySecret: info.AccessKeySecret,
SecurityToken: info.SecurityToken,
};
},
refreshSTSTokenInterval: 14 * 60 * 1000,
}) as OssClient;
setOssClient(client);
});
return () => {
active = false;
};
}, []);
const beforeUpload: UploadProps["beforeUpload"] = (file) => {
const suffixes = ["jpg", "png"];
const suffix = file.name.substring(file.name.lastIndexOf(".") + 1);
if (!suffixes.includes(suffix)) {
message.error(`请上传以${suffixes.join("、")}格式的书籍`);
return false;
}
return true;
};
const customRequest: UploadProps["customRequest"] = async (options) => {
if (!ossClient) {
message.error("ossClient还未初始化");
options.onError?.(new Error("ossClient还未初始化"));
return;
}
onUploadingChange(true);
try {
const uploadFile = options.file as File;
const fileExt = uploadFile.name.substring(uploadFile.name.lastIndexOf("."));
const fileName = `ad-${dayjs().valueOf()}-${Math.random().toString(36).substring(2)}${fileExt}`;
const filePath = `${dayjs().format("YYYY/MM/DD")}/${fileName}`;
const result = await ossClient.put(filePath, uploadFile);
onChange?.(result.url);
options.onSuccess?.(result);
} catch (reason) {
console.error("上传文件失败:", reason);
message.error("上传文件失败,请重试");
options.onError?.(reason instanceof Error ? reason : new Error("上传文件失败"));
} finally {
onUploadingChange(false);
}
};
return (
<div>
<Upload
name="file"
accept=".jpg,.png"
showUploadList={false}
beforeUpload={beforeUpload}
customRequest={customRequest}
>
<Button ghost type="primary">
点击上传
</Button>
</Upload>
{value ? (
<div className="img" style={{ marginTop: 10 }}>
<Image src={value} width={80} height={80} />
</div>
) : null}
</div>
);
}
import { Modal, Row, Space } from "antd";
interface DeleteAdvertModalProps {
open: boolean;
onCancel: () => void;
onConfirm: () => void | Promise<void>;
}
export default function DeleteAdvertModal({ open, onCancel, onConfirm }: DeleteAdvertModalProps) {
return (
<Modal
mask={false}
centered
open={open}
onOk={onConfirm}
onCancel={onCancel}
footer={(_, { OkBtn, CancelBtn }) => (
<Row justify="center" className="delModal" style={{ marginBottom: "30px" }}>
<Space size={20}>
<CancelBtn />
<OkBtn />
</Space>
</Row>
)}
okText="确认"
>
<p style={{ textAlign: "center", padding: "30px 0 18px", fontSize: 16 }}>
广告将永久删除,是否继续?
</p>
</Modal>
);
}
export const buildAdvertQuery = (page: number, pageSize: number, filters: Record<string, unknown> = {}) => ({ page, page_size: pageSize, ...filters });
import type { Dayjs } from "dayjs";
export interface AdvertFilters {
position_id?: number | string | null;
name?: string;
time?: [Dayjs | null, Dayjs | null] | null;
}
export interface AdvertQuery {
page: number;
page_size: number;
position_id?: number | string;
name?: string;
start_time?: number;
end_time?: number;
}
interface BuildAdvertQueryInput {
page: number;
pageSize: number;
filters: AdvertFilters;
}
export function buildAdvertQuery({ page, pageSize, filters }: BuildAdvertQueryInput): AdvertQuery {
const query: AdvertQuery = { page, page_size: pageSize };
if (filters.position_id) query.position_id = filters.position_id;
if (filters.name) query.name = filters.name;
if (filters.time?.[0] && filters.time[1]) {
query.start_time = filters.time[0].startOf("day").unix();
query.end_time = filters.time[1].endOf("day").unix();
}
return query;
}
import { Navigate, useRoutes } from "react-router";
import AdvertView from "./views/AdvertView";
export default function AdvertRoutes() { return useRoutes([{ index: true, Component: AdvertView }, { path: "*", element: <Navigate to="." replace /> }]); }
import AdvertListView from "./views/AdvertListView";
export default function AdvertRoutes() {
return useRoutes([
{ index: true, Component: AdvertListView },
{ path: "*", element: <Navigate to="." replace /> },
]);
}
import { useState } from "react";
export function useAdvertList() { const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); return { page, pageSize, setPage, setPageSize }; }
import { useCallback, useEffect, useRef, useState } from "react";
import { getAdvertList, type AdvertListResult, type AdvertRow } from "./api";
import { buildAdvertQuery, type AdvertFilters, type AdvertQuery } from "./query";
const INITIAL_PAGE = 1;
const INITIAL_PAGE_SIZE = 10;
function isCleared(previous: AdvertFilters, next: AdvertFilters) {
const keys = new Set([...Object.keys(previous), ...Object.keys(next)]) as Set<
keyof AdvertFilters
>;
return [...keys].some((key) => previous[key] !== next[key] && !next[key]);
}
export function useAdvertList() {
const [filters, setFilterState] = useState<AdvertFilters>({});
const [page, setPage] = useState(INITIAL_PAGE);
const [pageSize, setPageSize] = useState(INITIAL_PAGE_SIZE);
const [rows, setRows] = useState<AdvertRow[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const requestId = useRef(0);
const lastQuery = useRef<AdvertQuery>(
buildAdvertQuery({ page: INITIAL_PAGE, pageSize: INITIAL_PAGE_SIZE, filters: {} }),
);
const load = useCallback(async (query: AdvertQuery) => {
const currentRequest = ++requestId.current;
lastQuery.current = query;
setLoading(true);
setError(null);
try {
const result: AdvertListResult | false = await getAdvertList(query);
if (currentRequest !== requestId.current) return;
if (result === false) {
setError(new Error("数据获取失败!"));
return;
}
setRows(result.rows);
setTotal(result.total);
} catch (reason) {
if (currentRequest === requestId.current) {
setError(reason instanceof Error ? reason : new Error("数据获取失败!"));
}
} finally {
if (currentRequest === requestId.current) setLoading(false);
}
}, []);
const loadFor = useCallback(
(nextPage: number, nextPageSize: number, nextFilters: AdvertFilters) =>
load(buildAdvertQuery({ page: nextPage, pageSize: nextPageSize, filters: nextFilters })),
[load],
);
useEffect(() => {
void load(lastQuery.current);
return () => {
requestId.current += 1;
};
}, [load]);
const setFilters = useCallback(
(nextFilters: AdvertFilters) => {
const shouldReload = isCleared(filters, nextFilters);
setFilterState(nextFilters);
if (shouldReload) void loadFor(page, pageSize, nextFilters);
},
[filters, loadFor, page, pageSize],
);
const search = useCallback(() => {
setPage(INITIAL_PAGE);
void loadFor(INITIAL_PAGE, pageSize, filters);
}, [filters, loadFor, pageSize]);
const reset = useCallback(() => {
const emptyFilters: AdvertFilters = {};
setFilterState(emptyFilters);
setPage(INITIAL_PAGE);
void loadFor(INITIAL_PAGE, pageSize, emptyFilters);
}, [loadFor, pageSize]);
const refresh = useCallback(() => {
void loadFor(page, pageSize, filters);
}, [filters, loadFor, page, pageSize]);
const refreshUnfiltered = useCallback(() => {
void loadFor(page, pageSize, {});
}, [loadFor, page, pageSize]);
const changePage = useCallback(
(nextPage: number) => {
setPage(nextPage);
void loadFor(nextPage, pageSize, filters);
},
[filters, loadFor, pageSize],
);
const changePageSize = useCallback(
(nextPageSize: number) => {
setPage(INITIAL_PAGE);
setPageSize(nextPageSize);
void loadFor(INITIAL_PAGE, nextPageSize, filters);
},
[filters, loadFor],
);
const retry = useCallback(() => void load(lastQuery.current), [load]);
return {
filters,
setFilters,
page,
pageSize,
rows,
total,
loading,
error,
search,
reset,
refresh,
refreshUnfiltered,
changePage,
changePageSize,
retry,
};
}
import { Button, DatePicker, Image, Input, Popover, Select, Space, Switch } from "antd";
import type { ColumnsType } from "antd/es/table";
import dayjs from "dayjs";
import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { AppList, type AppListFilterItem } from "@/shared/components/AppList";
import {
addAdvert,
changeAdvertStatus,
deleteAdvert,
editAdvert,
getAdvertInfo,
getAdvertPositions,
type AdvertPosition,
type AdvertRow,
} from "../api";
import AdvertFormDrawer, { type AdvertFormValues } from "../components/AdvertFormDrawer";
import DeleteAdvertModal from "../components/DeleteAdvertModal";
import type { AdvertFilters } from "../query";
import { useAdvertList } from "../useAdvertList";
interface RootState {
user: {
operationPermissionsList?: string[];
};
}
const { RangePicker } = DatePicker;
const IMAGE_FALLBACK = "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";
export default function AdvertListView() {
const list = useAdvertList();
const permissions = useSelector((state: RootState) => state.user.operationPermissionsList ?? []);
const [positions, setPositions] = useState<AdvertPosition[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [isAdd, setIsAdd] = useState(false);
const [formOpen, setFormOpen] = useState(false);
const [formValues, setFormValues] = useState<AdvertFormValues | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
useEffect(() => {
let active = true;
void getAdvertPositions().then((result) => {
if (active && result !== false) setPositions(result);
});
return () => {
active = false;
};
}, []);
const handleStatus = async (enabled: boolean, id: number) => {
const success = await changeAdvertStatus(id, enabled ? "1" : "0");
if (success) list.refreshUnfiltered();
};
const handleEdit = async (id: number) => {
const advert = await getAdvertInfo(id);
if (advert === false) return;
setSelectedId(advert.id);
setIsAdd(false);
setFormValues({
position_id: advert.position_id,
name: advert.name,
pic: advert.pic,
validity: [dayjs(advert.start_validity), dayjs(advert.end_validity)],
url: advert.url,
});
setFormOpen(true);
};
const handleSubmit = async (values: AdvertFormValues) => {
if (!values.validity?.[0] || !values.validity[1]) return;
const { validity, ...payload } = values;
const [startDate, endDate] = validity;
if (!startDate || !endDate) return;
const startValidity = startDate.startOf("day").unix();
const endValidity = endDate.endOf("day").unix();
const success = isAdd
? await addAdvert({
...payload,
start_validity: startValidity,
end_validity: endValidity,
})
: selectedId !== null &&
(await editAdvert({
...payload,
id: selectedId,
start_validity: startValidity,
end_validity: endValidity,
}));
if (!success) return;
list.refreshUnfiltered();
setFormOpen(false);
};
const handleDelete = async () => {
if (selectedId === null) return;
setDeleteOpen(false);
const success = await deleteAdvert(selectedId);
if (success) list.refreshUnfiltered();
};
const columns: ColumnsType<AdvertRow> = [
{ title: "ID", key: "id", dataIndex: "id" },
{
title: "所属广告位",
key: "position_name",
align: "center",
dataIndex: "position_name",
},
{
title: "广告名称",
key: "name",
dataIndex: "name",
width: 200,
align: "center",
render: (text: string) => (
<Popover content={text} overlayStyle={{ maxWidth: "400px" }} open={false}>
<div
style={{
display: "-webkit-box",
WebkitLineClamp: "3",
WebkitBoxOrient: "vertical",
overflow: "hidden",
textOverflow: "ellipsis",
textAlign: "center",
}}
>
{text}
</div>
</Popover>
),
},
{
title: "广告图片",
key: "pic",
dataIndex: "pic",
align: "center",
render: (_, record) => (
<Image width={80} height={80} src={record.pic} fallback={IMAGE_FALLBACK} />
),
},
{
title: "广告链接地址",
key: "url",
dataIndex: "url",
align: "center",
width: 200,
render: (value?: string) => {
const text = value ?? "";
return (
<Popover
content={text}
overlayStyle={{
maxWidth: "400px",
whiteSpace: "pre-wrap",
wordBreak: "breal-all" as "break-all",
wordWrap: "break-word",
}}
>
<div style={{ textAlign: "left", maxWidth: "300px" }}>
<a href={text} target="_blank" rel="noreferrer">
{text.length > 80 ? `${text.substring(0, 80)}...` : text}
</a>
</div>
</Popover>
);
},
},
{
title: "状态",
key: "status",
align: "center",
dataIndex: "status",
render: (_, record) => (
<Switch
checked={String(record.status) === "1"}
onChange={(enabled) => void handleStatus(enabled, record.id)}
disabled={!permissions.includes("/advertisement/advert/changeStatus")}
/>
),
},
{
title: "有效期开始时间",
key: "start_validity",
dataIndex: "start_validity",
align: "center",
},
{
title: "有效期结束时间",
key: "end_validity",
dataIndex: "end_validity",
align: "center",
},
{
title: "操作",
key: "handle",
dataIndex: "handle",
align: "center",
render: (_, record) => (
<Space>
{permissions.includes("/advertisement/advert/getInfoById") ? (
<Button onClick={() => void handleEdit(record.id)}>编辑</Button>
) : null}
{permissions.includes("/advertisement/advert/del") ? (
<Button
onClick={() => {
setSelectedId(record.id);
setDeleteOpen(true);
}}
>
删除
</Button>
) : null}
</Space>
),
},
];
const filterItems: AppListFilterItem<AdvertFilters>[] = [
{
name: "position_id",
label: "所属广告位",
render: (field) => (
<Select
style={{ width: 160 }}
value={field.value}
allowClear
id="position_id"
placeholder="请选择所属广告位"
onChange={(value) => field.onChange(value)}
options={positions.map((position) => ({
value: position.id,
label: position.position_name,
}))}
/>
),
},
{
name: "name",
label: "广告名称",
render: (field) => (
<Input
autoComplete="off"
allowClear
value={field.value ?? ""}
onChange={(event) => field.onChange(event.target.value)}
placeholder="请输入广告名称"
id="name"
/>
),
},
{
name: "time",
label: "有效期",
render: (field) => (
<RangePicker
value={field.value}
onChange={(value) => field.onChange(value)}
id="createTime"
/>
),
},
];
const toolbar = permissions.includes("/advertisement/advert/add") ? (
<Button
type="primary"
ghost
onClick={() => {
setSelectedId(null);
setIsAdd(true);
setFormValues(null);
setFormOpen(true);
}}
>
添加
</Button>
) : undefined;
return (
<>
<AppList<AdvertRow, AdvertFilters>
rowKey="id"
columns={columns}
data={{
rows: list.rows,
total: list.total,
loading: list.loading,
error: list.error,
}}
filters={{
value: list.filters,
items: filterItems,
onChange: list.setFilters,
onSearch: list.search,
onReset: list.reset,
onRefresh: list.refresh,
}}
pagination={{
page: list.page,
pageSize: list.pageSize,
pageSizeOptions: [10, 20, 30, 40],
onChange: list.changePage,
onPageSizeChange: list.changePageSize,
}}
toolbar={toolbar}
onRetry={list.retry}
/>
<DeleteAdvertModal
open={deleteOpen}
onCancel={() => setDeleteOpen(false)}
onConfirm={handleDelete}
/>
<AdvertFormDrawer
open={formOpen}
positions={positions}
initialValues={formValues}
onClose={() => setFormOpen(false)}
onSubmit={handleSubmit}
/>
</>
);
}
export { default } from "@/pages/advertisement/advert";
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论