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

@@ -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 (

View File

@@ -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());

View File

@@ -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)}>
</Button>
}
>
<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">&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
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,81 +154,82 @@ export function DramaList() {
},
]}
/>
<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 }]}>
<Input placeholder="请输入短剧标题" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="类型" field="type" rules={[{ required: true }]}>
<Input placeholder="请输入类型,如复仇、爱情、动作等" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="状态" field="status">
<Select placeholder="请选择状态">
{STATUS_OPTIONS.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item label="封面地址" field="coverUrl" rules={[{ required: true }]}>
<Input placeholder="请输入封面图片 URL" />
</Form.Item>
<Form.Item label="简介" field="description">
<Input.TextArea placeholder="请输入短剧简介" autoSize={{ minRows: 2, maxRows: 4 }} />
</Form.Item>
<Form.Item label="标签" field="tags">
<InputTag placeholder="输入后回车添加标签" allowClear />
</Form.Item>
<Row gutter={16}>
<Col span={8}>
<Form.Item label="地区" field="region">
<Input placeholder="如 中国" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="语言" field="language">
<Input placeholder="如 zh-CN" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="年份" field="year">
<InputNumber placeholder="如 2026" min={1900} style={{ width: "100%" }} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item label="排序权重" field="sortOrder">
<InputNumber placeholder="数值越大越靠前" style={{ width: "100%" }} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="剧集总数" field="totalEpisodes" rules={[{ required: true }]}>
<InputNumber min={1} max={500} placeholder="创建剧集占位" style={{ width: "100%" }} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="推荐" field="isRecommended" triggerPropName="checked">
<Switch />
</Form.Item>
</Col>
</Row>
<div className="modal-actions">
<Button onClick={() => setVisible(false)}></Button>
<Button type="primary" htmlType="submit" loading={creating}>
</Button>
</div>
</Form>
</Modal>
</Card>
</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 }]}>
<Input placeholder="请输入短剧标题" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="类型" field="type" rules={[{ required: true }]}>
<Input placeholder="请输入类型,如复仇、爱情、动作等" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="状态" field="status">
<Select placeholder="请选择状态">
{STATUS_OPTIONS.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item label="封面地址" field="coverUrl" rules={[{ required: true }]}>
<Input placeholder="请输入封面图片 URL" />
</Form.Item>
<Form.Item label="简介" field="description">
<Input.TextArea placeholder="请输入短剧简介" autoSize={{ minRows: 2, maxRows: 4 }} />
</Form.Item>
<Form.Item label="标签" field="tags">
<InputTag placeholder="输入后回车添加标签" allowClear />
</Form.Item>
<Row gutter={16}>
<Col span={8}>
<Form.Item label="地区" field="region">
<Input placeholder="如 中国" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="语言" field="language">
<Input placeholder="如 zh-CN" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="年份" field="year">
<InputNumber placeholder="如 2026" min={1900} style={{ width: "100%" }} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item label="排序权重" field="sortOrder">
<InputNumber placeholder="数值越大越靠前" style={{ width: "100%" }} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="剧集总数" field="totalEpisodes" rules={[{ required: true }]}>
<InputNumber min={1} max={500} placeholder="创建剧集占位" style={{ width: "100%" }} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="推荐" field="isRecommended" triggerPropName="checked">
<Switch />
</Form.Item>
</Col>
</Row>
<div className="modal-actions">
<Button onClick={() => setVisible(false)}></Button>
<Button type="primary" htmlType="submit" loading={creating}>
</Button>
</div>
</Form>
</Modal>
</div>
);
}

View File

@@ -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>;
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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,15 +12,22 @@ function UsersPage() {
};
return (
<Card className="section-card" title="小程序用户管理">
<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' },
{ 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 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' },
{ 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 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);
}

View File

@@ -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: {

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 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 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,
};
}

View File

@@ -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