feat: 添加短剧筛选功能和自定义Panel容器组件
1. 新增DramaQuery类型扩展分页查询参数,支持标题、状态、类型筛选 2. 重构usePageData hook,支持自定义查询类型和筛选状态管理 3. 新建Panel通用容器组件替代原生Card 4. 为短剧管理页面添加搜索筛选UI和逻辑 5. 统一所有页面的分页查询默认参数 6. 调整布局样式优化后台管理界面展示
This commit is contained in:
59
.mimocode/plans/1782986311447-swift-planet.md
Normal file
59
.mimocode/plans/1782986311447-swift-planet.md
Normal 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` 构建成功
|
||||
@@ -39,9 +39,9 @@ function DashboardPage({ profile }: DashboardPageProps) {
|
||||
const canReadDramas = hasPermissions(profile, ['drama:read']);
|
||||
const canReadUsers = hasPermissions(profile, ['user:read']);
|
||||
const canReadLogs = hasPermissions(profile, ['log:read']);
|
||||
const dramas = usePageData<Drama>(dramaApi.listDramas, canReadDramas);
|
||||
const users = usePageData<AppUser>(userApi.listUsers, canReadUsers);
|
||||
const logs = usePageData<RequestLog>(logApi.listRequestLogs, canReadLogs);
|
||||
const dramas = usePageData<Drama>(dramaApi.listDramas, { page: 1, pageSize: 20 }, canReadDramas);
|
||||
const users = usePageData<AppUser>(userApi.listUsers, { page: 1, pageSize: 20 }, canReadUsers);
|
||||
const logs = usePageData<RequestLog>(logApi.listRequestLogs, { page: 1, pageSize: 20 }, canReadLogs);
|
||||
const hasAnyDataModule = canReadDramas || canReadUsers || canReadLogs;
|
||||
|
||||
return (
|
||||
|
||||
@@ -17,9 +17,9 @@ function DataPage() {
|
||||
totalComments: 0,
|
||||
});
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const dramas = usePageData<DramaMetric>(dataApi.listDramaMetrics);
|
||||
const episodes = usePageData<EpisodeMetric>(dataApi.listEpisodeMetrics);
|
||||
const jobs = usePageData<SyncJob>(dataApi.listSyncJobs);
|
||||
const dramas = usePageData<DramaMetric>(dataApi.listDramaMetrics, { page: 1, pageSize: 20 });
|
||||
const episodes = usePageData<EpisodeMetric>(dataApi.listEpisodeMetrics, { page: 1, pageSize: 20 });
|
||||
const jobs = usePageData<SyncJob>(dataApi.listSyncJobs, { page: 1, pageSize: 20 });
|
||||
|
||||
const loadOverview = async () => {
|
||||
setOverview(await dataApi.getDataOverview());
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState } from "react";
|
||||
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 { IconPlus } from "@arco-design/web-react/icon";
|
||||
import { Button, Form, Grid, Input, InputNumber, InputTag, Message, Modal, Select, Space, Switch, Table, Tag } from "@arco-design/web-react";
|
||||
import { IconPlus, IconSearch, IconRefresh } from "@arco-design/web-react/icon";
|
||||
import { dramaApi } from "../../api";
|
||||
import type { DramaQuery } from "../../api/interface";
|
||||
import { usePageData } from "../../hooks/usePageData";
|
||||
import { Panel } from "../../components/Panel";
|
||||
import { toDramaPayload } from "../formPayload";
|
||||
import type { Drama, StatusOption } from "../interface";
|
||||
|
||||
@@ -23,11 +25,27 @@ const PUBLISH_STATUS_LABEL: Record<string, string> = {
|
||||
offline: "已下线",
|
||||
};
|
||||
|
||||
const INITIAL_QUERY: DramaQuery = { page: 1, pageSize: 20 };
|
||||
|
||||
export function DramaList() {
|
||||
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 [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>) => {
|
||||
setCreating(true);
|
||||
@@ -43,15 +61,59 @@ export function DramaList() {
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card
|
||||
className="section-card"
|
||||
title="短剧管理"
|
||||
extra={
|
||||
<Button type="primary" icon={<IconPlus />} onClick={() => setVisible(true)}>
|
||||
<Panel title="短剧管理">
|
||||
<Row gutter={16} className="mb-0">
|
||||
<Col span={6}>
|
||||
<div className="text-[13px] text-muted mb-1.5">短剧标题</div>
|
||||
<Input
|
||||
placeholder="搜索短剧标题"
|
||||
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"> </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
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
@@ -64,11 +126,11 @@ export function DramaList() {
|
||||
}}
|
||||
columns={[
|
||||
{ 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: "region", width: 120 },
|
||||
{ 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: "状态",
|
||||
dataIndex: "status",
|
||||
@@ -92,6 +154,8 @@ export function DramaList() {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<Modal title="创建短剧" visible={visible} onCancel={() => setVisible(false)} footer={null} style={{ width: 640 }}>
|
||||
<Form layout="vertical" onSubmit={createDrama} initialValues={{ status: "draft", isRecommended: false, sortOrder: 0, totalEpisodes: 1 }}>
|
||||
<Form.Item label="标题" field="title" rules={[{ required: true }]}>
|
||||
@@ -166,7 +230,6 @@ export function DramaList() {
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { RequestLogTable } from '../components/RequestLogTable';
|
||||
import type { RequestLog } from './interface';
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import { usePageData } from '../hooks/usePageData';
|
||||
import type { AdminUser, CreatePersonnelFormValues, Role } from './interface';
|
||||
|
||||
function PersonnelPage() {
|
||||
const { data, loading, reload, changePage } = usePageData<AdminUser>(personnelApi.listPersonnel);
|
||||
const roles = usePageData<Role>(roleApi.listRoles);
|
||||
const { data, loading, reload, changePage } = usePageData<AdminUser>(personnelApi.listPersonnel, { page: 1, pageSize: 20 });
|
||||
const roles = usePageData<Role>(roleApi.listRoles, { page: 1, pageSize: 20 });
|
||||
const [createForm] = Form.useForm();
|
||||
const [createVisible, setCreateVisible] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
@@ -7,8 +7,8 @@ import { PermissionTreePicker } from "./components/PermissionTreePicker";
|
||||
import type { CreateRoleFormValues, Permission, Role } from "./interface";
|
||||
|
||||
function RolesPage() {
|
||||
const { data, loading, reload, changePage } = usePageData<Role>(roleApi.listRoles);
|
||||
const permissions = usePageData<Permission>(roleApi.listPermissions);
|
||||
const { data, loading, reload, changePage } = usePageData<Role>(roleApi.listRoles, { page: 1, pageSize: 20 });
|
||||
const permissions = usePageData<Permission>(roleApi.listPermissions, { page: 1, pageSize: 20 });
|
||||
const [createForm] = Form.useForm();
|
||||
const [createVisible, setCreateVisible] = useState(false);
|
||||
const [creatingRole, setCreatingRole] = useState(false);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { usePageData } from '../hooks/usePageData';
|
||||
import type { AppUser } from './interface';
|
||||
|
||||
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') => {
|
||||
await userApi.updateUserStatus(id, status);
|
||||
Message.success('用户状态已更新');
|
||||
@@ -12,7 +12,13 @@ function UsersPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="section-card" title="小程序用户管理">
|
||||
<div className="page-stack">
|
||||
<Card className="section-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="m-0 text-[16px] font-semibold text-ink">小程序用户管理</h3>
|
||||
</div>
|
||||
</Card>
|
||||
<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={[
|
||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||
{ title: 'TikTok Open ID', dataIndex: 'tiktokOpenId' },
|
||||
@@ -21,6 +27,7 @@ function UsersPage() {
|
||||
{ title: '操作', width: 160, render: (_, record: AppUser) => <Button size="small" onClick={() => update状态(record.id, record.status === 'active' ? 'disabled' : 'active')}>{record.status === 'active' ? '禁用' : '启用'}</Button> },
|
||||
]} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,12 @@ export type PageQuery = {
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
export type DramaQuery = PageQuery & {
|
||||
title?: string;
|
||||
status?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export type PageData<T> = {
|
||||
list: T[];
|
||||
pagination: {
|
||||
|
||||
22
src/components/Panel/index.tsx
Normal file
22
src/components/Panel/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,4 +2,4 @@ import type { PageData, PageQuery } from "../api";
|
||||
|
||||
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>>;
|
||||
|
||||
@@ -1,45 +1,53 @@
|
||||
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";
|
||||
|
||||
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 [pageQuery, setPageQuery] = useState<PageQuery>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
const [query, setQuery] = useState<Q>(initialQuery);
|
||||
const [data, setData] = useState<PageData<T>>({
|
||||
list: [],
|
||||
pagination: { page: 1, pageSize: 20, total: 0 },
|
||||
});
|
||||
|
||||
const load = async (query = pageQuery) => {
|
||||
const load = async (q = query) => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetcher(query);
|
||||
setData(resolvePageData(response, query));
|
||||
const response = await fetcher(q);
|
||||
setData(resolvePageData(response, q));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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(() => {
|
||||
if (enabled) {
|
||||
void load(pageQuery);
|
||||
void load(query);
|
||||
}
|
||||
}, [fetcher, enabled, pageQuery.page, pageQuery.pageSize]);
|
||||
}, [fetcher, enabled, query]);
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
reload: () => load(pageQuery),
|
||||
query,
|
||||
reload: () => load(query),
|
||||
changePage,
|
||||
setFilter,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export function AdminLayout() {
|
||||
};
|
||||
|
||||
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}>
|
||||
<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>
|
||||
@@ -64,8 +64,8 @@ export function AdminLayout() {
|
||||
{visibleMenuItems.map((item) => renderMenuItem(item))}
|
||||
</Menu>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header className="flex h-14 items-center justify-between border-b border-line/60 bg-white/80 px-5 backdrop-blur-md">
|
||||
<Layout className="flex flex-col overflow-hidden">
|
||||
<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>
|
||||
<Button
|
||||
type="text"
|
||||
@@ -97,7 +97,7 @@ export function AdminLayout() {
|
||||
</button>
|
||||
</Dropdown>
|
||||
</Header>
|
||||
<Content className="p-4 sm:p-6">
|
||||
<Content className="flex-1 overflow-y-auto p-4 sm:p-6">
|
||||
<Routes>
|
||||
<Route path="/dashboard" element={<DashboardPage profile={profile} />} />
|
||||
<Route
|
||||
|
||||
Reference in New Issue
Block a user