feat: 添加短剧筛选功能和自定义Panel容器组件

1. 新增DramaQuery类型扩展分页查询参数,支持标题、状态、类型筛选
2. 重构usePageData hook,支持自定义查询类型和筛选状态管理
3. 新建Panel通用容器组件替代原生Card
4. 为短剧管理页面添加搜索筛选UI和逻辑
5. 统一所有页面的分页查询默认参数
6. 调整布局样式优化后台管理界面展示
This commit is contained in:
2026-07-02 18:48:51 +08:00
parent 718d1c7aac
commit 2dace1cf8d
14 changed files with 295 additions and 130 deletions

View File

@@ -0,0 +1,59 @@
# DramaList 筛选功能 + 自定义容器组件
## 目标
1. 为短剧管理页面添加搜索筛选功能(标题、状态、类型)
2. 将 Card 容器替换为自定义实现的容器组件
## 分析
### 现有架构
- `PageQuery` 类型仅支持 `{ page, pageSize }`,无筛选字段
- `fetchPage` 直接将 params 传给 axios扩展字段会自动作为 query 参数发送
- `usePageData` hook 固定使用 `PageQuery`,需扩展支持筛选
### 筛选字段(基于 Drama 类型)
- `title` - 短剧标题(模糊搜索)
- `status` - 状态draft/scheduled/published/offline
- `type` - 类型(如复仇、爱情、动作等)
## 实现方案
### 1. 扩展 API 查询类型
**文件**: `src/api/interface.ts`
- 新增 `DramaQuery` 类型,继承 `PageQuery`,添加 `title?``status?``type?` 字段
**文件**: `src/api/dramas.ts`
- `listDramas` 参数类型从 `PageQuery` 改为 `DramaQuery`
### 2. 扩展 usePageData hook
**文件**: `src/hooks/interface.ts`
- `PageFetcher<T>` 泛型扩展,支持自定义查询类型
**文件**: `src/hooks/usePageData.ts`
- 泛型参数增加 `Q extends PageQuery`,支持自定义查询类型
- 新增 `setQuery` 方法用于更新筛选条件
- 筛选条件变化时重置到第 1 页
### 3. 创建自定义容器组件
**文件**: `src/components/Panel/index.tsx`(新建)
- 实现一个轻量级容器组件,替代 Card
- Props: `title``extra`(右侧操作区)、`children``className`
- 样式:圆角、边框、阴影,与现有 `.section-card` 风格一致
### 4. 改造 DramaList 页面
**文件**: `src/Drama/components/DramaList.tsx`
- 使用自定义 Panel 组件替换 Card
- 添加筛选区域:标题输入框 + 状态选择器 + 类型输入框 + 查询/重置按钮
- 管理筛选状态,调用 `setQuery` 更新查询
## 修改文件清单
1. `src/api/interface.ts` - 新增 DramaQuery 类型
2. `src/api/dramas.ts` - 更新 listDramas 参数类型
3. `src/hooks/interface.ts` - 扩展 PageFetcher 泛型
4. `src/hooks/usePageData.ts` - 支持自定义查询类型和 setQuery
5. `src/components/Panel/index.tsx` - 新建自定义容器组件
6. `src/Drama/components/DramaList.tsx` - 添加筛选 UI 和 Panel
## 验证
- `npm run lint` TypeScript 检查通过
- `npm run build` 构建成功

View File

@@ -39,9 +39,9 @@ function DashboardPage({ profile }: DashboardPageProps) {
const canReadDramas = hasPermissions(profile, ['drama:read']); const canReadDramas = hasPermissions(profile, ['drama:read']);
const canReadUsers = hasPermissions(profile, ['user:read']); const canReadUsers = hasPermissions(profile, ['user:read']);
const canReadLogs = hasPermissions(profile, ['log:read']); const canReadLogs = hasPermissions(profile, ['log:read']);
const dramas = usePageData<Drama>(dramaApi.listDramas, canReadDramas); const dramas = usePageData<Drama>(dramaApi.listDramas, { page: 1, pageSize: 20 }, canReadDramas);
const users = usePageData<AppUser>(userApi.listUsers, canReadUsers); const users = usePageData<AppUser>(userApi.listUsers, { page: 1, pageSize: 20 }, canReadUsers);
const logs = usePageData<RequestLog>(logApi.listRequestLogs, canReadLogs); const logs = usePageData<RequestLog>(logApi.listRequestLogs, { page: 1, pageSize: 20 }, canReadLogs);
const hasAnyDataModule = canReadDramas || canReadUsers || canReadLogs; const hasAnyDataModule = canReadDramas || canReadUsers || canReadLogs;
return ( return (

View File

@@ -17,9 +17,9 @@ function DataPage() {
totalComments: 0, totalComments: 0,
}); });
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const dramas = usePageData<DramaMetric>(dataApi.listDramaMetrics); const dramas = usePageData<DramaMetric>(dataApi.listDramaMetrics, { page: 1, pageSize: 20 });
const episodes = usePageData<EpisodeMetric>(dataApi.listEpisodeMetrics); const episodes = usePageData<EpisodeMetric>(dataApi.listEpisodeMetrics, { page: 1, pageSize: 20 });
const jobs = usePageData<SyncJob>(dataApi.listSyncJobs); const jobs = usePageData<SyncJob>(dataApi.listSyncJobs, { page: 1, pageSize: 20 });
const loadOverview = async () => { const loadOverview = async () => {
setOverview(await dataApi.getDataOverview()); setOverview(await dataApi.getDataOverview());

View File

@@ -1,9 +1,11 @@
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Button, Card, Form, Grid, Input, InputNumber, InputTag, Message, Modal, Select, Switch, Table, Tag } from "@arco-design/web-react"; import { Button, Form, Grid, Input, InputNumber, InputTag, Message, Modal, Select, Space, Switch, Table, Tag } from "@arco-design/web-react";
import { IconPlus } from "@arco-design/web-react/icon"; import { IconPlus, IconSearch, IconRefresh } from "@arco-design/web-react/icon";
import { dramaApi } from "../../api"; import { dramaApi } from "../../api";
import type { DramaQuery } from "../../api/interface";
import { usePageData } from "../../hooks/usePageData"; import { usePageData } from "../../hooks/usePageData";
import { Panel } from "../../components/Panel";
import { toDramaPayload } from "../formPayload"; import { toDramaPayload } from "../formPayload";
import type { Drama, StatusOption } from "../interface"; import type { Drama, StatusOption } from "../interface";
@@ -23,11 +25,27 @@ const PUBLISH_STATUS_LABEL: Record<string, string> = {
offline: "已下线", offline: "已下线",
}; };
const INITIAL_QUERY: DramaQuery = { page: 1, pageSize: 20 };
export function DramaList() { export function DramaList() {
const navigate = useNavigate(); const navigate = useNavigate();
const { data, loading, reload, changePage } = usePageData<Drama>(dramaApi.listDramas); const { data, loading, reload, changePage, setFilter } = usePageData<Drama, DramaQuery>(dramaApi.listDramas, INITIAL_QUERY);
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [filterTitle, setFilterTitle] = useState("");
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [filterType, setFilterType] = useState("");
const handleSearch = () => {
setFilter({ title: filterTitle || undefined, status: filterStatus, type: filterType || undefined });
};
const handleReset = () => {
setFilterTitle("");
setFilterStatus(undefined);
setFilterType("");
setFilter({ title: undefined, status: undefined, type: undefined });
};
const createDrama = async (values: Partial<Drama>) => { const createDrama = async (values: Partial<Drama>) => {
setCreating(true); setCreating(true);
@@ -43,15 +61,59 @@ export function DramaList() {
return ( return (
<div className="page-stack"> <div className="page-stack">
<Card <Panel title="短剧管理">
className="section-card" <Row gutter={16} className="mb-0">
title="短剧管理" <Col span={6}>
extra={ <div className="text-[13px] text-muted mb-1.5"></div>
<Button type="primary" icon={<IconPlus />} onClick={() => setVisible(true)}> <Input
placeholder="搜索短剧标题"
</Button> value={filterTitle}
} onChange={setFilterTitle}
> allowClear
/>
</Col>
<Col span={4}>
<div className="text-[13px] text-muted mb-1.5"></div>
<Select
placeholder="全部状态"
value={filterStatus}
onChange={setFilterStatus}
allowClear
>
{STATUS_OPTIONS.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
</Col>
<Col span={4}>
<div className="text-[13px] text-muted mb-1.5"></div>
<Input
placeholder="搜索类型"
value={filterType}
onChange={setFilterType}
allowClear
/>
</Col>
<Col span={10}>
<div className="text-[13px] mb-1.5">&nbsp;</div>
<Space>
<Button type="primary" icon={<IconSearch />} onClick={handleSearch}>
</Button>
<Button icon={<IconRefresh />} onClick={handleReset}>
</Button>
<Button type="primary" status="success" icon={<IconPlus />} onClick={() => setVisible(true)}>
</Button>
</Space>
</Col>
</Row>
</Panel>
<Panel>
<Table <Table
rowKey="id" rowKey="id"
loading={loading} loading={loading}
@@ -64,11 +126,11 @@ export function DramaList() {
}} }}
columns={[ columns={[
{ title: "ID", dataIndex: "id", width: 80 }, { title: "ID", dataIndex: "id", width: 80 },
{ title: "封面图", dataIndex: "coverUrl", width: 120, render: (value) => (value ? <img src={value} alt="封面图" style={{ width: 100, height: 100, objectFit: "cover" }} /> : null) }, { title: "封面图", dataIndex: "coverUrl", width: 120, render: (value) => (value ? <img src={value} alt="封面图" className="rounded-lg object-cover" style={{ width: 80, height: 80 }} /> : null) },
{ title: "标题", dataIndex: "title" }, { title: "标题", dataIndex: "title" },
{ title: "地区", dataIndex: "region", width: 120 }, { title: "地区", dataIndex: "region", width: 120 },
{ title: "类型", dataIndex: "type", width: 140 }, { title: "类型", dataIndex: "type", width: 140 },
{ title: "已发布 / 总集数", dataIndex: "totalEpisodes", width: 180, render: (_, record) => `${record.publishedEpisodes}(已发布)/共${record.totalEpisodes}` }, { title: "已发布 / 总集数", dataIndex: "totalEpisodes", width: 180, render: (_, record) => `${record.publishedEpisodes || 0}(已发布)/共${record.totalEpisodes || 0}` },
{ {
title: "状态", title: "状态",
dataIndex: "status", dataIndex: "status",
@@ -92,81 +154,82 @@ export function DramaList() {
}, },
]} ]}
/> />
<Modal title="创建短剧" visible={visible} onCancel={() => setVisible(false)} footer={null} style={{ width: 640 }}> </Panel>
<Form layout="vertical" onSubmit={createDrama} initialValues={{ status: "draft", isRecommended: false, sortOrder: 0, totalEpisodes: 1 }}>
<Form.Item label="标题" field="title" rules={[{ required: true }]}> <Modal title="创建短剧" visible={visible} onCancel={() => setVisible(false)} footer={null} style={{ width: 640 }}>
<Input placeholder="请输入短剧标题" /> <Form layout="vertical" onSubmit={createDrama} initialValues={{ status: "draft", isRecommended: false, sortOrder: 0, totalEpisodes: 1 }}>
</Form.Item> <Form.Item label="标题" field="title" rules={[{ required: true }]}>
<Row gutter={16}> <Input placeholder="请输入短剧标题" />
<Col span={12}> </Form.Item>
<Form.Item label="类型" field="type" rules={[{ required: true }]}> <Row gutter={16}>
<Input placeholder="请输入类型,如复仇、爱情、动作等" /> <Col span={12}>
</Form.Item> <Form.Item label="类型" field="type" rules={[{ required: true }]}>
</Col> <Input placeholder="请输入类型,如复仇、爱情、动作等" />
<Col span={12}> </Form.Item>
<Form.Item label="状态" field="status"> </Col>
<Select placeholder="请选择状态"> <Col span={12}>
{STATUS_OPTIONS.map((option) => ( <Form.Item label="状态" field="status">
<Select.Option key={option.value} value={option.value}> <Select placeholder="请选择状态">
{option.label} {STATUS_OPTIONS.map((option) => (
</Select.Option> <Select.Option key={option.value} value={option.value}>
))} {option.label}
</Select> </Select.Option>
</Form.Item> ))}
</Col> </Select>
</Row> </Form.Item>
<Form.Item label="封面地址" field="coverUrl" rules={[{ required: true }]}> </Col>
<Input placeholder="请输入封面图片 URL" /> </Row>
</Form.Item> <Form.Item label="封面地址" field="coverUrl" rules={[{ required: true }]}>
<Form.Item label="简介" field="description"> <Input placeholder="请输入封面图片 URL" />
<Input.TextArea placeholder="请输入短剧简介" autoSize={{ minRows: 2, maxRows: 4 }} /> </Form.Item>
</Form.Item> <Form.Item label="简介" field="description">
<Form.Item label="标签" field="tags"> <Input.TextArea placeholder="请输入短剧简介" autoSize={{ minRows: 2, maxRows: 4 }} />
<InputTag placeholder="输入后回车添加标签" allowClear /> </Form.Item>
</Form.Item> <Form.Item label="标签" field="tags">
<Row gutter={16}> <InputTag placeholder="输入后回车添加标签" allowClear />
<Col span={8}> </Form.Item>
<Form.Item label="地区" field="region"> <Row gutter={16}>
<Input placeholder="如 中国" /> <Col span={8}>
</Form.Item> <Form.Item label="地区" field="region">
</Col> <Input placeholder="如 中国" />
<Col span={8}> </Form.Item>
<Form.Item label="语言" field="language"> </Col>
<Input placeholder="如 zh-CN" /> <Col span={8}>
</Form.Item> <Form.Item label="语言" field="language">
</Col> <Input placeholder="如 zh-CN" />
<Col span={8}> </Form.Item>
<Form.Item label="年份" field="year"> </Col>
<InputNumber placeholder="如 2026" min={1900} style={{ width: "100%" }} /> <Col span={8}>
</Form.Item> <Form.Item label="年份" field="year">
</Col> <InputNumber placeholder="如 2026" min={1900} style={{ width: "100%" }} />
</Row> </Form.Item>
<Row gutter={16}> </Col>
<Col span={8}> </Row>
<Form.Item label="排序权重" field="sortOrder"> <Row gutter={16}>
<InputNumber placeholder="数值越大越靠前" style={{ width: "100%" }} /> <Col span={8}>
</Form.Item> <Form.Item label="排序权重" field="sortOrder">
</Col> <InputNumber placeholder="数值越大越靠前" style={{ width: "100%" }} />
<Col span={8}> </Form.Item>
<Form.Item label="剧集总数" field="totalEpisodes" rules={[{ required: true }]}> </Col>
<InputNumber min={1} max={500} placeholder="创建剧集占位" style={{ width: "100%" }} /> <Col span={8}>
</Form.Item> <Form.Item label="剧集总数" field="totalEpisodes" rules={[{ required: true }]}>
</Col> <InputNumber min={1} max={500} placeholder="创建剧集占位" style={{ width: "100%" }} />
<Col span={8}> </Form.Item>
<Form.Item label="推荐" field="isRecommended" triggerPropName="checked"> </Col>
<Switch /> <Col span={8}>
</Form.Item> <Form.Item label="推荐" field="isRecommended" triggerPropName="checked">
</Col> <Switch />
</Row> </Form.Item>
<div className="modal-actions"> </Col>
<Button onClick={() => setVisible(false)}></Button> </Row>
<Button type="primary" htmlType="submit" loading={creating}> <div className="modal-actions">
<Button onClick={() => setVisible(false)}></Button>
</Button> <Button type="primary" htmlType="submit" loading={creating}>
</div>
</Form> </Button>
</Modal> </div>
</Card> </Form>
</Modal>
</div> </div>
); );
} }

View File

@@ -5,7 +5,7 @@ import { RequestLogTable } from '../components/RequestLogTable';
import type { RequestLog } from './interface'; import type { RequestLog } from './interface';
function LogsPage() { function LogsPage() {
const { data, loading } = usePageData<RequestLog>(logApi.listRequestLogs); const { data, loading } = usePageData<RequestLog>(logApi.listRequestLogs, { page: 1, pageSize: 20 });
return <Card className="section-card" title="请求日志"><RequestLogTable data={data.list} loading={loading} /></Card>; return <Card className="section-card" title="请求日志"><RequestLogTable data={data.list} loading={loading} /></Card>;
} }

View File

@@ -6,8 +6,8 @@ import { usePageData } from '../hooks/usePageData';
import type { AdminUser, CreatePersonnelFormValues, Role } from './interface'; import type { AdminUser, CreatePersonnelFormValues, Role } from './interface';
function PersonnelPage() { function PersonnelPage() {
const { data, loading, reload, changePage } = usePageData<AdminUser>(personnelApi.listPersonnel); const { data, loading, reload, changePage } = usePageData<AdminUser>(personnelApi.listPersonnel, { page: 1, pageSize: 20 });
const roles = usePageData<Role>(roleApi.listRoles); const roles = usePageData<Role>(roleApi.listRoles, { page: 1, pageSize: 20 });
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
const [createVisible, setCreateVisible] = useState(false); const [createVisible, setCreateVisible] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);

View File

@@ -7,8 +7,8 @@ import { PermissionTreePicker } from "./components/PermissionTreePicker";
import type { CreateRoleFormValues, Permission, Role } from "./interface"; import type { CreateRoleFormValues, Permission, Role } from "./interface";
function RolesPage() { function RolesPage() {
const { data, loading, reload, changePage } = usePageData<Role>(roleApi.listRoles); const { data, loading, reload, changePage } = usePageData<Role>(roleApi.listRoles, { page: 1, pageSize: 20 });
const permissions = usePageData<Permission>(roleApi.listPermissions); const permissions = usePageData<Permission>(roleApi.listPermissions, { page: 1, pageSize: 20 });
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
const [createVisible, setCreateVisible] = useState(false); const [createVisible, setCreateVisible] = useState(false);
const [creatingRole, setCreatingRole] = useState(false); const [creatingRole, setCreatingRole] = useState(false);

View File

@@ -4,7 +4,7 @@ import { usePageData } from '../hooks/usePageData';
import type { AppUser } from './interface'; import type { AppUser } from './interface';
function UsersPage() { function UsersPage() {
const { data, loading, reload, changePage } = usePageData<AppUser>(userApi.listUsers); const { data, loading, reload, changePage } = usePageData<AppUser>(userApi.listUsers, { page: 1, pageSize: 20 });
const update状态 = async (id: number, status: 'active' | 'disabled') => { const update状态 = async (id: number, status: 'active' | 'disabled') => {
await userApi.updateUserStatus(id, status); await userApi.updateUserStatus(id, status);
Message.success('用户状态已更新'); Message.success('用户状态已更新');
@@ -12,15 +12,22 @@ function UsersPage() {
}; };
return ( return (
<Card className="section-card" title="小程序用户管理"> <div className="page-stack">
<Table rowKey="id" loading={loading} data={data.list} pagination={{ current: data.pagination.page, total: data.pagination.total, pageSize: data.pagination.pageSize, onChange: changePage }} columns={[ <Card className="section-card">
{ title: 'ID', dataIndex: 'id', width: 80 }, <div className="flex items-center justify-between">
{ title: 'TikTok Open ID', dataIndex: 'tiktokOpenId' }, <h3 className="m-0 text-[16px] font-semibold text-ink"></h3>
{ title: '昵称', dataIndex: 'nickname' }, </div>
{ title: '状态', dataIndex: 'status', width: 120, render: (status) => <Tag color={status === 'active' ? 'green' : 'red'}>{status}</Tag> }, </Card>
{ title: '操作', width: 160, render: (_, record: AppUser) => <Button size="small" onClick={() => update状态(record.id, record.status === 'active' ? 'disabled' : 'active')}>{record.status === 'active' ? '禁用' : '启用'}</Button> }, <Card className="section-card">
]} /> <Table rowKey="id" loading={loading} data={data.list} pagination={{ current: data.pagination.page, total: data.pagination.total, pageSize: data.pagination.pageSize, onChange: changePage }} columns={[
</Card> { title: 'ID', dataIndex: 'id', width: 80 },
{ title: 'TikTok Open ID', dataIndex: 'tiktokOpenId' },
{ title: '昵称', dataIndex: 'nickname' },
{ title: '状态', dataIndex: 'status', width: 120, render: (status) => <Tag color={status === 'active' ? 'green' : 'red'}>{status}</Tag> },
{ title: '操作', width: 160, render: (_, record: AppUser) => <Button size="small" onClick={() => update状态(record.id, record.status === 'active' ? 'disabled' : 'active')}>{record.status === 'active' ? '禁用' : '启用'}</Button> },
]} />
</Card>
</div>
); );
} }

View File

@@ -1,7 +1,7 @@
import { fetchPage, request } from "../plugins/axios"; import { fetchPage, request } from "../plugins/axios";
import type { ApiResponse, CreateDramaPayload, Drama, Episode, PageQuery, UpdateDramaPayload, UpdateEpisodePayload } from "./interface"; import type { ApiResponse, CreateDramaPayload, Drama, DramaQuery, Episode, UpdateDramaPayload, UpdateEpisodePayload } from "./interface";
export function listDramas(query: PageQuery) { export function listDramas(query: DramaQuery) {
return fetchPage<Drama>('/api/admin/v1/dramas', query); return fetchPage<Drama>('/api/admin/v1/dramas', query);
} }

View File

@@ -12,6 +12,12 @@ export type PageQuery = {
pageSize: number; pageSize: number;
}; };
export type DramaQuery = PageQuery & {
title?: string;
status?: string;
type?: string;
};
export type PageData<T> = { export type PageData<T> = {
list: T[]; list: T[];
pagination: { pagination: {

View File

@@ -0,0 +1,22 @@
import type { ReactNode } from "react";
type PanelProps = {
title?: ReactNode;
extra?: ReactNode;
children: ReactNode;
className?: string;
};
export function Panel({ title, extra, children, className = "" }: PanelProps) {
return (
<div className={`rounded-xl border border-line/60 bg-white shadow-sm ${className}`}>
{(title || extra) && (
<div className="flex items-center justify-between border-b border-line/60 px-5 py-4">
{title && <h3 className="m-0 text-[15px] font-semibold text-ink">{title}</h3>}
{extra && <div>{extra}</div>}
</div>
)}
<div className="p-5">{children}</div>
</div>
);
}

View File

@@ -2,4 +2,4 @@ import type { PageData, PageQuery } from "../api";
export type { PageData, PageQuery }; export type { PageData, PageQuery };
export type PageFetcher<T> = (query: PageQuery) => Promise<PageData<T>>; export type PageFetcher<T, Q extends PageQuery = PageQuery> = (query: Q) => Promise<PageData<T>>;

View File

@@ -1,45 +1,53 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { PageData, PageFetcher, PageQuery } from "./interface"; import type { PageData, PageQuery } from "../api";
import type { PageFetcher } from "./interface";
import { resolvePageData } from "./pageData"; import { resolvePageData } from "./pageData";
export function usePageData<T>(fetcher: PageFetcher<T>, enabled = true) { export function usePageData<T, Q extends PageQuery = PageQuery>(
fetcher: PageFetcher<T, Q>,
initialQuery: Q,
enabled = true,
) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [pageQuery, setPageQuery] = useState<PageQuery>({ const [query, setQuery] = useState<Q>(initialQuery);
page: 1,
pageSize: 20,
});
const [data, setData] = useState<PageData<T>>({ const [data, setData] = useState<PageData<T>>({
list: [], list: [],
pagination: { page: 1, pageSize: 20, total: 0 }, pagination: { page: 1, pageSize: 20, total: 0 },
}); });
const load = async (query = pageQuery) => { const load = async (q = query) => {
if (!enabled) { if (!enabled) {
return; return;
} }
setLoading(true); setLoading(true);
try { try {
const response = await fetcher(query); const response = await fetcher(q);
setData(resolvePageData(response, query)); setData(resolvePageData(response, q));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const changePage = (page: number, pageSize: number) => { const changePage = (page: number, pageSize: number) => {
setPageQuery({ page, pageSize }); setQuery((prev) => ({ ...prev, page, pageSize } as Q));
};
const setFilter = (patch: Partial<Q>) => {
setQuery((prev) => ({ ...prev, ...patch, page: 1 } as Q));
}; };
useEffect(() => { useEffect(() => {
if (enabled) { if (enabled) {
void load(pageQuery); void load(query);
} }
}, [fetcher, enabled, pageQuery.page, pageQuery.pageSize]); }, [fetcher, enabled, query]);
return { return {
data, data,
loading, loading,
reload: () => load(pageQuery), query,
reload: () => load(query),
changePage, changePage,
setFilter,
}; };
} }

View File

@@ -48,7 +48,7 @@ export function AdminLayout() {
}; };
return ( return (
<Layout className="min-h-screen bg-canvas"> <Layout className="h-screen bg-canvas">
<Sider className="shadow-[1px_0_0_#e5e6eb] bg-white" width={220} collapsed={collapsed} collapsible trigger={null}> <Sider className="shadow-[1px_0_0_#e5e6eb] bg-white" width={220} collapsed={collapsed} collapsible trigger={null}>
<div className="flex h-14 items-center gap-2.5 border-b border-line/60 px-[18px]"> <div className="flex h-14 items-center gap-2.5 border-b border-line/60 px-[18px]">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-brand text-sm font-bold text-white shadow-sm">TK</span> <span className="grid h-8 w-8 place-items-center rounded-lg bg-brand text-sm font-bold text-white shadow-sm">TK</span>
@@ -64,8 +64,8 @@ export function AdminLayout() {
{visibleMenuItems.map((item) => renderMenuItem(item))} {visibleMenuItems.map((item) => renderMenuItem(item))}
</Menu> </Menu>
</Sider> </Sider>
<Layout> <Layout className="flex flex-col overflow-hidden">
<Header className="flex h-14 items-center justify-between border-b border-line/60 bg-white/80 px-5 backdrop-blur-md"> <Header className="flex h-14 shrink-0 items-center justify-between border-b border-line/60 bg-white/80 px-5 backdrop-blur-md">
<Space> <Space>
<Button <Button
type="text" type="text"
@@ -97,7 +97,7 @@ export function AdminLayout() {
</button> </button>
</Dropdown> </Dropdown>
</Header> </Header>
<Content className="p-4 sm:p-6"> <Content className="flex-1 overflow-y-auto p-4 sm:p-6">
<Routes> <Routes>
<Route path="/dashboard" element={<DashboardPage profile={profile} />} /> <Route path="/dashboard" element={<DashboardPage profile={profile} />} />
<Route <Route