feat: 新增频道管理功能,优化整体UI与交互体验

本次提交完成了以下核心变更:
1.  新增完整的频道管理模块,包括频道增删改查API、页面组件与权限配置
2.  为短剧功能添加频道关联字段,支持将短剧绑定到指定频道
3.  全局UI优化:统一使用Panel组件重构页面布局,统一样式规范与交互细节
4.  表格与日志组件优化:新增请求方法颜色标记、耗时高亮、状态码分级展示
5.  登录页、个人中心、布局等页面的视觉升级与交互优化
6.  完善全局TailwindCSS样式与组件样式自定义
This commit is contained in:
2026-07-03 15:01:27 +08:00
parent 2dace1cf8d
commit e830f5a733
29 changed files with 667 additions and 170 deletions

179
src/Channel/index.tsx Normal file
View File

@@ -0,0 +1,179 @@
import { useState } from "react";
import { Button, Form, Input, InputNumber, Message, Modal, Select, Space, Switch, Table, Tag } from "@arco-design/web-react";
import { IconDelete, IconEdit, IconPlus } from "@arco-design/web-react/icon";
import { channelApi } from "../api";
import type { Channel, ChannelQuery, CreateChannelPayload } from "../api";
import { Panel } from "../components/Panel";
import { usePageData } from "../hooks/usePageData";
const INITIAL_QUERY: ChannelQuery = { page: 1, pageSize: 20 };
const CHANNEL_STATUS_LABEL: Record<string, string> = {
active: "启用",
disabled: "停用",
};
type ChannelFormValues = CreateChannelPayload;
export default function ChannelPage() {
const { data, loading, reload, changePage } = usePageData<Channel, ChannelQuery>(channelApi.listChannels, INITIAL_QUERY);
const [createVisible, setCreateVisible] = useState(false);
const [editingChannel, setEditingChannel] = useState<Channel | null>(null);
const [saving, setSaving] = useState(false);
const createChannel = async (values: ChannelFormValues) => {
setSaving(true);
try {
await channelApi.createChannel({
...values,
sortOrder: Number(values.sortOrder) || 0,
isDefault: Boolean(values.isDefault),
status: values.status || "active",
});
Message.success("频道创建成功");
setCreateVisible(false);
await reload();
} finally {
setSaving(false);
}
};
const updateChannel = async (values: ChannelFormValues) => {
if (!editingChannel) {
return;
}
setSaving(true);
try {
await channelApi.updateChannel(editingChannel.id, {
...values,
sortOrder: Number(values.sortOrder) || 0,
isDefault: Boolean(values.isDefault),
status: values.status || "active",
});
Message.success("频道已更新");
setEditingChannel(null);
await reload();
} finally {
setSaving(false);
}
};
const deleteChannel = (channel: Channel) => {
Modal.confirm({
title: "删除频道",
content: `确认删除频道「${channel.name}」?`,
okButtonProps: { status: "danger" },
onOk: async () => {
await channelApi.deleteChannel(channel.id);
Message.success("频道已删除");
await reload();
},
});
};
const renderForm = (onSubmit: (values: ChannelFormValues) => Promise<void>, initialValues: ChannelFormValues) => (
<Form layout="vertical" initialValues={initialValues} onSubmit={onSubmit}>
<Form.Item label="频道名称" field="name" rules={[{ required: true }]}>
<Input placeholder="请输入频道名称" />
</Form.Item>
<Form.Item label="频道编码" field="code" rules={[{ required: true }]}>
<Input placeholder="请输入频道编码,如 default" disabled={Boolean(editingChannel?.isDefault)} />
</Form.Item>
<Form.Item label="描述" field="description">
<Input.TextArea placeholder="请输入频道描述" autoSize={{ minRows: 2, maxRows: 4 }} />
</Form.Item>
<Space size="large" align="start">
<Form.Item label="排序权重" field="sortOrder">
<InputNumber min={0} style={{ width: 180 }} />
</Form.Item>
<Form.Item label="状态" field="status">
<Select style={{ width: 180 }} disabled={Boolean(editingChannel?.isDefault)}>
<Select.Option value="active"></Select.Option>
<Select.Option value="disabled"></Select.Option>
</Select>
</Form.Item>
<Form.Item label="默认频道" field="isDefault" triggerPropName="checked">
<Switch disabled={Boolean(editingChannel?.isDefault)} />
</Form.Item>
</Space>
<div className="modal-actions">
<Button onClick={() => {
setCreateVisible(false);
setEditingChannel(null);
}}>
</Button>
<Button type="primary" htmlType="submit" loading={saving}>
</Button>
</div>
</Form>
);
return (
<div className="page-stack">
<Panel title="频道管理" extra={<Button type="primary" icon={<IconPlus />} onClick={() => setCreateVisible(true)}></Button>} noPadding>
<Table
rowKey="id"
loading={loading}
data={data.list}
pagination={{
current: data.pagination.page,
pageSize: data.pagination.pageSize,
total: data.pagination.total,
sizeCanChange: true,
showTotal: true,
onChange: (page, pageSize) => changePage(page, pageSize),
}}
columns={[
{ title: "ID", dataIndex: "id", width: 80 },
{ title: "名称", dataIndex: "name" },
{ title: "编码", dataIndex: "code", width: 180 },
{ title: "排序", dataIndex: "sortOrder", width: 100 },
{
title: "默认",
dataIndex: "isDefault",
width: 100,
render: (value) => (value ? <Tag color="arcoblue"></Tag> : <Tag></Tag>),
},
{
title: "状态",
dataIndex: "status",
width: 100,
render: (status) => <Tag color={status === "active" ? "green" : "gray"}>{CHANNEL_STATUS_LABEL[String(status)] || String(status)}</Tag>,
},
{
title: "操作",
width: 180,
render: (_, record) => (
<Space>
<Button type="text" icon={<IconEdit />} onClick={() => setEditingChannel(record)}>
</Button>
<Button type="text" status="danger" icon={<IconDelete />} disabled={record.isDefault} onClick={() => deleteChannel(record)}>
</Button>
</Space>
),
},
]}
/>
</Panel>
<Modal title="新增频道" visible={createVisible} onCancel={() => setCreateVisible(false)} footer={null} style={{ width: 640 }}>
{renderForm(createChannel, { sortOrder: 0, status: "active", isDefault: false })}
</Modal>
<Modal title="编辑频道" visible={Boolean(editingChannel)} onCancel={() => setEditingChannel(null)} footer={null} style={{ width: 640 }}>
{editingChannel && renderForm(updateChannel, {
name: editingChannel.name,
code: editingChannel.code,
description: editingChannel.description,
sortOrder: editingChannel.sortOrder,
status: editingChannel.status,
isDefault: editingChannel.isDefault,
})}
</Modal>
</div>
);
}

View File

@@ -1,9 +1,9 @@
import { Card } from '@arco-design/web-react';
import { IconPlayCircle, IconUser, IconFile, IconCheckCircle } from '@arco-design/web-react/icon'; import { IconPlayCircle, IconUser, IconFile, IconCheckCircle } from '@arco-design/web-react/icon';
import { dramaApi, logApi, userApi } from '../api'; import { dramaApi, logApi, userApi } from '../api';
import { usePageData } from '../hooks/usePageData'; import { usePageData } from '../hooks/usePageData';
import { hasPermissions } from '../utils/permission'; import { hasPermissions } from '../utils/permission';
import { NoAccessPage } from '../components/NoAccessPage'; import { NoAccessPage } from '../components/NoAccessPage';
import { Panel } from '../components/Panel';
import { RequestLogTable } from '../components/RequestLogTable'; import { RequestLogTable } from '../components/RequestLogTable';
import type { AppUser, DashboardPageProps, Drama, MetricCardProps, RequestLog } from './interface'; import type { AppUser, DashboardPageProps, Drama, MetricCardProps, RequestLog } from './interface';
@@ -23,14 +23,14 @@ const METRIC_COLORS: Record<string, string> = {
function MetricCard({ label, value, icon }: MetricCardProps & { icon: string }) { function MetricCard({ label, value, icon }: MetricCardProps & { icon: string }) {
return ( return (
<div className={`stat-card bg-gradient-to-br ${METRIC_COLORS[icon]}`}> <div className={`stat-card animate-slide-up bg-gradient-to-br ${METRIC_COLORS[icon]}`}>
<div className="mb-3 flex items-center gap-3"> <div className="mb-3.5 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/80 shadow-sm"> <div className="flex h-11 w-11 items-center justify-center rounded-xl bg-white/80 shadow-sm ring-1 ring-black/[0.03]">
{METRIC_ICONS[icon]} {METRIC_ICONS[icon]}
</div> </div>
<span className="text-[13px] font-medium text-muted">{label}</span> <span className="text-[13px] font-medium text-muted">{label}</span>
</div> </div>
<div className="text-[28px] font-bold tracking-tight text-ink">{value}</div> <div className="text-[30px] font-bold tracking-tight text-ink">{value}</div>
</div> </div>
); );
} }
@@ -45,20 +45,20 @@ function DashboardPage({ profile }: DashboardPageProps) {
const hasAnyDataModule = canReadDramas || canReadUsers || canReadLogs; const hasAnyDataModule = canReadDramas || canReadUsers || canReadLogs;
return ( return (
<> <div className="page-stack">
<div className="mb-5 grid grid-cols-1 gap-4 sm:grid-cols-2 min-[901px]:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 min-[901px]:grid-cols-4">
{canReadDramas && <MetricCard label="短剧管理" value={dramas.data.pagination.total} icon="dramas" />} {canReadDramas && <MetricCard label="短剧管理" value={dramas.data.pagination.total} icon="dramas" />}
{canReadUsers && <MetricCard label="用户管理" value={users.data.pagination.total} icon="users" />} {canReadUsers && <MetricCard label="用户管理" value={users.data.pagination.total} icon="users" />}
{canReadLogs && <MetricCard label="请求日志" value={logs.data.pagination.total} icon="logs" />} {canReadLogs && <MetricCard label="请求日志" value={logs.data.pagination.total} icon="logs" />}
<MetricCard label="接口状态" value="运行中" icon="status" /> <MetricCard label="接口状态" value="运行中" icon="status" />
</div> </div>
{canReadLogs ? ( {canReadLogs ? (
<Card className="section-card" title="最近请求"> <Panel title="最近请求" noPadding>
<RequestLogTable data={logs.data.list.slice(0, 8)} loading={logs.loading} /> <RequestLogTable data={logs.data.list.slice(0, 8)} loading={logs.loading} />
</Card> </Panel>
) : null} ) : null}
{!hasAnyDataModule ? <NoAccessPage /> : null} {!hasAnyDataModule ? <NoAccessPage /> : null}
</> </div>
); );
} }

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Button, Card, Grid, Message, Statistic, Table } from '@arco-design/web-react'; import { Button, Grid, Message, Statistic, Table } from '@arco-design/web-react';
import { IconRefresh } from '@arco-design/web-react/icon'; import { IconRefresh } from '@arco-design/web-react/icon';
import { dataApi } from '../api'; import { dataApi } from '../api';
import { usePageData } from '../hooks/usePageData'; import { usePageData } from '../hooks/usePageData';
import { Panel } from '../components/Panel';
import type { DataOverview, DramaMetric, EpisodeMetric, SyncJob } from './interface'; import type { DataOverview, DramaMetric, EpisodeMetric, SyncJob } from './interface';
const { Row, Col } = Grid; const { Row, Col } = Grid;
@@ -42,7 +43,7 @@ function DataPage() {
return ( return (
<div className="page-stack"> <div className="page-stack">
<Card className="section-card" title="数据管理" extra={<Button type="primary" icon={<IconRefresh />} loading={syncing} onClick={syncData}> TikTok </Button>}> <Panel title="数据管理" extra={<Button type="primary" icon={<IconRefresh />} loading={syncing} onClick={syncData}> TikTok </Button>}>
<Row gutter={16}> <Row gutter={16}>
<Col span={4}><Statistic title="短剧数" value={overview.totalDramas} /></Col> <Col span={4}><Statistic title="短剧数" value={overview.totalDramas} /></Col>
<Col span={4}><Statistic title="剧集数" value={overview.totalEpisodes} /></Col> <Col span={4}><Statistic title="剧集数" value={overview.totalEpisodes} /></Col>
@@ -51,9 +52,9 @@ function DataPage() {
<Col span={4}><Statistic title="分享" value={overview.totalShares} /></Col> <Col span={4}><Statistic title="分享" value={overview.totalShares} /></Col>
<Col span={4}><Statistic title="评论" value={overview.totalComments} /></Col> <Col span={4}><Statistic title="评论" value={overview.totalComments} /></Col>
</Row> </Row>
</Card> </Panel>
<Card className="section-card" title="短剧数据"> <Panel title="短剧数据" noPadding>
<Table rowKey="dramaId" loading={dramas.loading} data={dramas.data.list} pagination={{ current: dramas.data.pagination.page, total: dramas.data.pagination.total, pageSize: dramas.data.pagination.pageSize, onChange: dramas.changePage }} columns={[ <Table rowKey="dramaId" loading={dramas.loading} data={dramas.data.list} pagination={{ current: dramas.data.pagination.page, total: dramas.data.pagination.total, pageSize: dramas.data.pagination.pageSize, onChange: dramas.changePage }} columns={[
{ title: '短剧ID', dataIndex: 'dramaId', width: 100 }, { title: '短剧ID', dataIndex: 'dramaId', width: 100 },
{ title: '短剧名称', dataIndex: 'title' }, { title: '短剧名称', dataIndex: 'title' },
@@ -64,9 +65,9 @@ function DataPage() {
{ title: '完播率', dataIndex: 'completionRate', width: 120, render: (value) => `${Math.round(Number(value) * 100)}%` }, { title: '完播率', dataIndex: 'completionRate', width: 120, render: (value) => `${Math.round(Number(value) * 100)}%` },
{ title: '同步时间', dataIndex: 'syncedAt', width: 220 }, { title: '同步时间', dataIndex: 'syncedAt', width: 220 },
]} /> ]} />
</Card> </Panel>
<Card className="section-card" title="剧集数据"> <Panel title="剧集数据" noPadding>
<Table rowKey="episodeId" loading={episodes.loading} data={episodes.data.list} pagination={{ current: episodes.data.pagination.page, total: episodes.data.pagination.total, pageSize: episodes.data.pagination.pageSize, onChange: episodes.changePage }} columns={[ <Table rowKey="episodeId" loading={episodes.loading} data={episodes.data.list} pagination={{ current: episodes.data.pagination.page, total: episodes.data.pagination.total, pageSize: episodes.data.pagination.pageSize, onChange: episodes.changePage }} columns={[
{ title: '剧集ID', dataIndex: 'episodeId', width: 100 }, { title: '剧集ID', dataIndex: 'episodeId', width: 100 },
{ title: '短剧ID', dataIndex: 'dramaId', width: 100 }, { title: '短剧ID', dataIndex: 'dramaId', width: 100 },
@@ -77,9 +78,9 @@ function DataPage() {
{ title: '平均观看秒数', dataIndex: 'averageWatchSeconds', width: 150 }, { title: '平均观看秒数', dataIndex: 'averageWatchSeconds', width: 150 },
{ title: '同步时间', dataIndex: 'syncedAt', width: 220 }, { title: '同步时间', dataIndex: 'syncedAt', width: 220 },
]} /> ]} />
</Card> </Panel>
<Card className="section-card" title="同步记录"> <Panel title="同步记录" noPadding>
<Table rowKey="id" loading={jobs.loading} data={jobs.data.list} pagination={{ current: jobs.data.pagination.page, total: jobs.data.pagination.total, pageSize: jobs.data.pagination.pageSize, onChange: jobs.changePage }} columns={[ <Table rowKey="id" loading={jobs.loading} data={jobs.data.list} pagination={{ current: jobs.data.pagination.page, total: jobs.data.pagination.total, pageSize: jobs.data.pagination.pageSize, onChange: jobs.changePage }} columns={[
{ title: '任务ID', dataIndex: 'jobId' }, { title: '任务ID', dataIndex: 'jobId' },
{ title: '状态', dataIndex: 'status', width: 120 }, { title: '状态', dataIndex: 'status', width: 120 },
@@ -87,7 +88,7 @@ function DataPage() {
{ title: '剧集数', dataIndex: 'episodeCount', width: 120 }, { title: '剧集数', dataIndex: 'episodeCount', width: 120 },
{ title: '创建时间', dataIndex: 'createdAt', width: 220 }, { title: '创建时间', dataIndex: 'createdAt', width: 220 },
]} /> ]} />
</Card> </Panel>
</div> </div>
); );
} }

View File

@@ -1,10 +1,11 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Button, Card, Descriptions, Form, Grid, Input, InputNumber, InputTag, Message, Modal, Select, Space, Switch } from "@arco-design/web-react"; import { Button, Descriptions, Form, Grid, Input, InputNumber, InputTag, Message, Modal, Select, Space, Switch } from "@arco-design/web-react";
import { IconArrowLeft, IconRefresh } from "@arco-design/web-react/icon"; import { IconArrowLeft, IconRefresh } from "@arco-design/web-react/icon";
import { dramaApi, mediaApi } from "../../api"; import { channelApi, dramaApi, mediaApi } from "../../api";
import { Panel } from "../../components/Panel";
import { toDramaPayload, toEpisodePayload } from "../formPayload"; import { toDramaPayload, toEpisodePayload } from "../formPayload";
import type { Drama, DramaDetailPageProps, Episode } from "../interface"; import type { Channel, Drama, DramaDetailPageProps, Episode } from "../interface";
import { EpisodeTable } from "./EpisodeTable"; import { EpisodeTable } from "./EpisodeTable";
const { Row, Col } = Grid; const { Row, Col } = Grid;
@@ -32,14 +33,20 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
const [uploadingEpisodeId, setUploadingEpisodeId] = useState<number | null>(null); const [uploadingEpisodeId, setUploadingEpisodeId] = useState<number | null>(null);
const [editVisible, setEditVisible] = useState(false); const [editVisible, setEditVisible] = useState(false);
const [updatingDrama, setUpdatingDrama] = useState(false); const [updatingDrama, setUpdatingDrama] = useState(false);
const [channels, setChannels] = useState<Channel[]>([]);
const loadDetail = async () => { const loadDetail = async () => {
setLoading(true); setLoading(true);
try { try {
const [dramaData, episodeList] = await Promise.all([dramaApi.getDrama(dramaId), dramaApi.getDramaEpisodes(dramaId)]); const [dramaData, episodeList, channelPage] = await Promise.all([
dramaApi.getDrama(dramaId),
dramaApi.getDramaEpisodes(dramaId),
channelApi.listChannels({ page: 1, pageSize: 100 }).catch(() => ({ list: [] })),
]);
setDrama(dramaData); setDrama(dramaData);
setEpisodeCount(dramaData.totalEpisodes || 1); setEpisodeCount(dramaData.totalEpisodes || 1);
setEpisodes(episodeList); setEpisodes(episodeList);
setChannels(channelPage.list);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -108,9 +115,7 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
return ( return (
<div className="page-stack"> <div className="page-stack">
<Card <Panel
className="section-card"
loading={loading && !drama}
title={ title={
<Space> <Space>
<Button icon={<IconArrowLeft />} onClick={() => navigate("/dramas")} /> <Button icon={<IconArrowLeft />} onClick={() => navigate("/dramas")} />
@@ -132,12 +137,15 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
</Space> </Space>
} }
> >
{drama && ( {loading && !drama ? (
<div className="py-12 text-center text-muted">...</div>
) : drama ? (
<Descriptions <Descriptions
column={3} column={3}
data={[ data={[
{ label: "短剧ID", value: drama.id }, { label: "短剧ID", value: drama.id },
{ label: "名称", value: drama.title }, { label: "名称", value: drama.title },
{ label: "频道", value: drama.channelName || "-" },
{ label: "地区", value: drama.region || "-" }, { label: "地区", value: drama.region || "-" },
{ label: "类型", value: drama.type }, { label: "类型", value: drama.type },
{ label: "总集数", value: drama.totalEpisodes || 0 }, { label: "总集数", value: drama.totalEpisodes || 0 },
@@ -147,11 +155,10 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
{ label: "简介", value: drama.description || "-" }, { label: "简介", value: drama.description || "-" },
]} ]}
/> />
)} ) : null}
</Card> </Panel>
<Card <Panel
className="section-card"
title="剧集配置" title="剧集配置"
extra={ extra={
<Space> <Space>
@@ -161,6 +168,7 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
</Button> </Button>
</Space> </Space>
} }
noPadding
> >
<EpisodeTable <EpisodeTable
episodes={episodes} episodes={episodes}
@@ -170,7 +178,7 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
onPublishEpisode={publishEpisode} onPublishEpisode={publishEpisode}
onUpdateEpisode={updateEpisode} onUpdateEpisode={updateEpisode}
/> />
</Card> </Panel>
{drama && ( {drama && (
<Modal title="编辑短剧" visible={editVisible} onCancel={() => setEditVisible(false)} footer={null} style={{ width: 720 }}> <Modal title="编辑短剧" visible={editVisible} onCancel={() => setEditVisible(false)} footer={null} style={{ width: 720 }}>
@@ -190,19 +198,25 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
year: drama.year, year: drama.year,
sortOrder: drama.sortOrder, sortOrder: drama.sortOrder,
isRecommended: drama.isRecommended, isRecommended: drama.isRecommended,
totalEpisodes: drama.totalEpisodes || 1, channelId: drama.channelId,
}} }}
> >
<Form.Item label="标题" field="title" rules={[{ required: true }]}> <Form.Item label="标题" field="title" rules={[{ required: true }]}>
<Input placeholder="请输入短剧标题" /> <Input placeholder="请输入短剧标题" />
</Form.Item> </Form.Item>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={8}>
<Form.Item label="类型" field="type" rules={[{ required: true }]}> <Form.Item label="频道" field="channelId">
<Input placeholder="请输入类型" /> <Select placeholder="默认频道" allowClear>
{channels.map((channel) => (
<Select.Option key={channel.id} value={channel.id}>
{channel.name}
</Select.Option>
))}
</Select>
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={8}>
<Form.Item label="状态" field="status"> <Form.Item label="状态" field="status">
<Select> <Select>
{STATUS_OPTIONS.map((option) => ( {STATUS_OPTIONS.map((option) => (
@@ -213,6 +227,11 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
</Select> </Select>
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={8}>
<Form.Item label="类型" field="type">
<Input placeholder="请输入类型" />
</Form.Item>
</Col>
</Row> </Row>
<Form.Item label="封面地址" field="coverUrl" rules={[{ required: true }]}> <Form.Item label="封面地址" field="coverUrl" rules={[{ required: true }]}>
<Input placeholder="请输入封面图片 URL" /> <Input placeholder="请输入封面图片 URL" />
@@ -241,17 +260,12 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
</Col> </Col>
</Row> </Row>
<Row gutter={16}> <Row gutter={16}>
<Col span={8}> <Col span={12}>
<Form.Item label="排序权重" field="sortOrder"> <Form.Item label="排序权重" field="sortOrder">
<InputNumber style={{ width: "100%" }} /> <InputNumber style={{ width: "100%" }} />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={8}> <Col span={12}>
<Form.Item label="剧集总数" field="totalEpisodes" rules={[{ required: true }]}>
<InputNumber min={1} max={500} style={{ width: "100%" }} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="推荐" field="isRecommended" triggerPropName="checked"> <Form.Item label="推荐" field="isRecommended" triggerPropName="checked">
<Switch /> <Switch />
</Form.Item> </Form.Item>

View File

@@ -1,13 +1,13 @@
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Button, Form, Grid, Input, InputNumber, InputTag, Message, Modal, Select, Space, 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, IconSearch, IconRefresh } from "@arco-design/web-react/icon"; import { IconDelete, IconPlus, IconSearch, IconRefresh } from "@arco-design/web-react/icon";
import { dramaApi } from "../../api"; import { channelApi, dramaApi } from "../../api";
import type { DramaQuery } from "../../api/interface"; import type { DramaQuery } from "../../api/interface";
import { usePageData } from "../../hooks/usePageData"; import { usePageData } from "../../hooks/usePageData";
import { Panel } from "../../components/Panel"; import { Panel } from "../../components/Panel";
import { toDramaPayload } from "../formPayload"; import { toDramaPayload } from "../formPayload";
import type { Drama, StatusOption } from "../interface"; import type { Channel, Drama, StatusOption } from "../interface";
const { Row, Col } = Grid; const { Row, Col } = Grid;
@@ -35,6 +35,12 @@ export function DramaList() {
const [filterTitle, setFilterTitle] = useState(""); const [filterTitle, setFilterTitle] = useState("");
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined); const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [filterType, setFilterType] = useState(""); const [filterType, setFilterType] = useState("");
const [channels, setChannels] = useState<Channel[]>([]);
const defaultChannel = useMemo(() => channels.find((channel) => channel.isDefault) ?? channels[0], [channels]);
useEffect(() => {
channelApi.listChannels({ page: 1, pageSize: 100 }).then((page) => setChannels(page.list)).catch(() => setChannels([]));
}, []);
const handleSearch = () => { const handleSearch = () => {
setFilter({ title: filterTitle || undefined, status: filterStatus, type: filterType || undefined }); setFilter({ title: filterTitle || undefined, status: filterStatus, type: filterType || undefined });
@@ -59,27 +65,30 @@ export function DramaList() {
} }
}; };
const deleteDrama = (drama: Drama) => {
Modal.confirm({
title: "删除短剧",
content: `确认删除短剧「${drama.title}」?删除后该短剧下的剧集也会同步删除。`,
okButtonProps: { status: "danger" },
onOk: async () => {
await dramaApi.deleteDrama(drama.id);
Message.success("短剧已删除");
await reload();
},
});
};
return ( return (
<div className="page-stack"> <div className="page-stack">
<Panel title="短剧管理"> <Panel title="短剧管理" extra={<Button type="primary" status="success" icon={<IconPlus />} onClick={() => setVisible(true)}></Button>}>
<Row gutter={16} className="mb-0"> <Row gutter={16} align="center">
<Col span={6}> <Col span={6}>
<div className="text-[13px] text-muted mb-1.5"></div> <div className="form-label"></div>
<Input <Input placeholder="搜索短剧标题" value={filterTitle} onChange={setFilterTitle} allowClear />
placeholder="搜索短剧标题"
value={filterTitle}
onChange={setFilterTitle}
allowClear
/>
</Col> </Col>
<Col span={4}> <Col span={4}>
<div className="text-[13px] text-muted mb-1.5"></div> <div className="form-label"></div>
<Select <Select placeholder="全部状态" value={filterStatus} onChange={setFilterStatus} allowClear>
placeholder="全部状态"
value={filterStatus}
onChange={setFilterStatus}
allowClear
>
{STATUS_OPTIONS.map((option) => ( {STATUS_OPTIONS.map((option) => (
<Select.Option key={option.value} value={option.value}> <Select.Option key={option.value} value={option.value}>
{option.label} {option.label}
@@ -88,16 +97,11 @@ export function DramaList() {
</Select> </Select>
</Col> </Col>
<Col span={4}> <Col span={4}>
<div className="text-[13px] text-muted mb-1.5"></div> <div className="form-label"></div>
<Input <Input placeholder="搜索类型" value={filterType} onChange={setFilterType} allowClear />
placeholder="搜索类型"
value={filterType}
onChange={setFilterType}
allowClear
/>
</Col> </Col>
<Col span={10}> <Col span={10}>
<div className="text-[13px] mb-1.5">&nbsp;</div> <div className="form-label invisible"></div>
<Space> <Space>
<Button type="primary" icon={<IconSearch />} onClick={handleSearch}> <Button type="primary" icon={<IconSearch />} onClick={handleSearch}>
@@ -105,21 +109,20 @@ export function DramaList() {
<Button icon={<IconRefresh />} onClick={handleReset}> <Button icon={<IconRefresh />} onClick={handleReset}>
</Button> </Button>
<Button type="primary" status="success" icon={<IconPlus />} onClick={() => setVisible(true)}>
</Button>
</Space> </Space>
</Col> </Col>
</Row> </Row>
</Panel> </Panel>
<Panel> <Panel noPadding>
<Table <Table
rowKey="id" rowKey="id"
loading={loading} loading={loading}
data={data.list} data={data.list}
pagination={{ pagination={{
current: data.pagination.page, current: data.pagination.page,
sizeCanChange: true,
showTotal: true,
total: data.pagination.total, total: data.pagination.total,
pageSize: data.pagination.pageSize, pageSize: data.pagination.pageSize,
onChange: (page, pageSize) => changePage(page, pageSize), onChange: (page, pageSize) => changePage(page, pageSize),
@@ -145,11 +148,16 @@ export function DramaList() {
}, },
{ {
title: "操作", title: "操作",
width: 120, width: 180,
render: (_, record) => ( render: (_, record) => (
<Space>
<Button type="text" onClick={() => navigate(`/dramas/${record.id}`)}> <Button type="text" onClick={() => navigate(`/dramas/${record.id}`)}>
</Button> </Button>
<Button type="text" status="danger" icon={<IconDelete />} onClick={() => deleteDrama(record)}>
</Button>
</Space>
), ),
}, },
]} ]}
@@ -157,17 +165,23 @@ export function DramaList() {
</Panel> </Panel>
<Modal title="创建短剧" visible={visible} onCancel={() => setVisible(false)} footer={null} style={{ width: 640 }}> <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 layout="vertical" onSubmit={createDrama} initialValues={{ status: "draft", isRecommended: false, sortOrder: 0, totalEpisodes: 1, channelId: defaultChannel?.id }}>
<Form.Item label="标题" field="title" rules={[{ required: true }]}> <Form.Item label="标题" field="title" rules={[{ required: true }]}>
<Input placeholder="请输入短剧标题" /> <Input placeholder="请输入短剧标题" />
</Form.Item> </Form.Item>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={8}>
<Form.Item label="类型" field="type" rules={[{ required: true }]}> <Form.Item label="频道" field="channelId">
<Input placeholder="请输入类型,如复仇、爱情、动作等" /> <Select placeholder="默认频道" allowClear>
{channels.map((channel) => (
<Select.Option key={channel.id} value={channel.id}>
{channel.name}
</Select.Option>
))}
</Select>
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={8}>
<Form.Item label="状态" field="status"> <Form.Item label="状态" field="status">
<Select placeholder="请选择状态"> <Select placeholder="请选择状态">
{STATUS_OPTIONS.map((option) => ( {STATUS_OPTIONS.map((option) => (
@@ -178,6 +192,11 @@ export function DramaList() {
</Select> </Select>
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={8}>
<Form.Item label="类型" field="type">
<Input placeholder="请输入类型,如复仇、爱情、动作等" />
</Form.Item>
</Col>
</Row> </Row>
<Form.Item label="封面地址" field="coverUrl" rules={[{ required: true }]}> <Form.Item label="封面地址" field="coverUrl" rules={[{ required: true }]}>
<Input placeholder="请输入封面图片 URL" /> <Input placeholder="请输入封面图片 URL" />

View File

@@ -7,6 +7,7 @@ type DramaFormValues = Partial<
| "type" | "type"
| "status" | "status"
| "coverUrl" | "coverUrl"
| "channelId"
| "description" | "description"
| "tags" | "tags"
| "region" | "region"
@@ -55,6 +56,7 @@ export function toDramaPayload(values: DramaFormValues): DramaFormValues {
type: normalizeOptionalString(values.type), type: normalizeOptionalString(values.type),
status: values.status, status: values.status,
coverUrl: normalizeOptionalString(values.coverUrl), coverUrl: normalizeOptionalString(values.coverUrl),
channelId: normalizePositiveInteger(values.channelId),
description: normalizeOptionalString(values.description), description: normalizeOptionalString(values.description),
tags: values.tags?.filter(Boolean), tags: values.tags?.filter(Boolean),
region: normalizeOptionalString(values.region), region: normalizeOptionalString(values.region),

View File

@@ -1,6 +1,6 @@
import type { Drama, Episode } from "../api"; import type { Channel, Drama, Episode } from "../api";
export type { Drama, Episode }; export type { Channel, Drama, Episode };
export type StatusOption = { export type StatusOption = {
value: string; value: string;

View File

@@ -1,12 +1,18 @@
import { Card } from '@arco-design/web-react';
import { logApi } from '../api'; import { logApi } from '../api';
import { usePageData } from '../hooks/usePageData'; import { usePageData } from '../hooks/usePageData';
import { Panel } from '../components/Panel';
import { RequestLogTable } from '../components/RequestLogTable'; 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, { page: 1, pageSize: 20 }); 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 (
<div className="page-stack">
<Panel title="请求日志" noPadding>
<RequestLogTable data={data.list} loading={loading} />
</Panel>
</div>
);
} }
export default LogsPage; export default LogsPage;

View File

@@ -5,9 +5,9 @@ import type { LoginPanelProps } from "../interface";
export const LoginPanel = ({ loading, onSubmit }: LoginPanelProps) => { export const LoginPanel = ({ loading, onSubmit }: LoginPanelProps) => {
return ( return (
<div className="flex items-center justify-center bg-[#f7f8fa] bg-[image:linear-gradient(180deg,rgba(255,255,255,0.86),rgba(247,248,250,0.94))] p-12"> <div className="flex items-center justify-center bg-[#f7f8fa] bg-[image:linear-gradient(180deg,rgba(255,255,255,0.86),rgba(247,248,250,0.94))] p-12">
<div className="w-full max-w-[420px] rounded-2xl border border-line/60 bg-white p-9 shadow-login"> <div className="w-full max-w-[420px] rounded-2xl border border-line/40 bg-white p-10 shadow-login">
<div className="mb-8 flex items-center flex-col text-center gap-3.5"> <div className="mb-8 flex flex-col items-center text-center">
<h2 className="mb-1 w-full mt-0 text-2xl font-semibold text-ink"></h2> <h2 className="mb-2 w-full mt-0 text-[22px] font-bold text-ink"></h2>
<p className="m-0 w-full text-[13px] text-muted">使</p> <p className="m-0 w-full text-[13px] text-muted">使</p>
</div> </div>
<Form layout="vertical" onSubmit={onSubmit} initialValues={{ username: "admin", password: "admin123" }}> <Form layout="vertical" onSubmit={onSubmit} initialValues={{ username: "admin", password: "admin123" }}>
@@ -17,7 +17,7 @@ export const LoginPanel = ({ loading, onSubmit }: LoginPanelProps) => {
<Form.Item label="密码" field="password" rules={[{ required: true }]}> <Form.Item label="密码" field="password" rules={[{ required: true }]}>
<Input.Password prefix={<IconLock className="text-muted" />} placeholder="请输入密码" size="large" /> <Input.Password prefix={<IconLock className="text-muted" />} placeholder="请输入密码" size="large" />
</Form.Item> </Form.Item>
<Button type="primary" htmlType="submit" loading={loading} long size="large" className="!h-11 mt-6 !rounded-lg !text-[15px] !font-medium"> <Button type="primary" htmlType="submit" loading={loading} long size="large" className="!h-11 mt-6 !rounded-xl !text-[15px] !font-semibold !shadow-brand/30">
</Button> </Button>
</Form> </Form>

View File

@@ -2,8 +2,8 @@ export function LoginVisual() {
return ( return (
<div className="relative hidden flex-col justify-between overflow-hidden px-14 pb-12 pt-11 text-white after:pointer-events-none after:absolute after:inset-0 after:bg-[linear-gradient(180deg,rgba(255,255,255,0.08),rgba(255,255,255,0))] min-[901px]:flex bg-[linear-gradient(135deg,rgba(15,23,42,0.98)_0%,rgba(22,93,255,0.94)_54%,rgba(15,118,110,0.96)_100%),radial-gradient(circle_at_18%_18%,rgba(255,255,255,0.18),transparent_28%),radial-gradient(circle_at_82%_72%,rgba(255,255,255,0.12),transparent_32%)]"> <div className="relative hidden flex-col justify-between overflow-hidden px-14 pb-12 pt-11 text-white after:pointer-events-none after:absolute after:inset-0 after:bg-[linear-gradient(180deg,rgba(255,255,255,0.08),rgba(255,255,255,0))] min-[901px]:flex bg-[linear-gradient(135deg,rgba(15,23,42,0.98)_0%,rgba(22,93,255,0.94)_54%,rgba(15,118,110,0.96)_100%),radial-gradient(circle_at_18%_18%,rgba(255,255,255,0.18),transparent_28%),radial-gradient(circle_at_82%_72%,rgba(255,255,255,0.12),transparent_32%)]">
<div className="relative z-[1] flex items-center gap-2.5 font-bold"> <div className="relative z-[1] flex items-center gap-2.5 font-bold">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-white/20 text-sm backdrop-blur-sm">CT</span> <span className="grid h-9 w-9 place-items-center rounded-xl bg-white/20 text-sm font-bold backdrop-blur-sm">CT</span>
<span className="text-white/90">cth-tk-admin</span> <span className="text-white/90 text-[15px]">cth-tk-admin</span>
</div> </div>
<div className="relative z-[1] max-w-[640px]"> <div className="relative z-[1] max-w-[640px]">
<span className="mb-5 inline-flex rounded-full border border-white/20 bg-white/10 px-4 py-2 text-[13px] text-white/80 backdrop-blur-sm">TikTok </span> <span className="mb-5 inline-flex rounded-full border border-white/20 bg-white/10 px-4 py-2 text-[13px] text-white/80 backdrop-blur-sm">TikTok </span>
@@ -11,15 +11,15 @@ export function LoginVisual() {
<p className="m-0 max-w-[560px] text-base leading-[1.8] text-white/70"></p> <p className="m-0 max-w-[560px] text-base leading-[1.8] text-white/70"></p>
</div> </div>
<div className="relative z-[1] grid max-w-[640px] grid-cols-3 gap-4"> <div className="relative z-[1] grid max-w-[640px] grid-cols-3 gap-4">
<div className="rounded-xl border border-white/15 bg-white/10 p-5 backdrop-blur-md transition-all duration-300 hover:bg-white/15"> <div className="rounded-xl border border-white/15 bg-white/10 p-5 backdrop-blur-md transition-all duration-300 hover:bg-white/15 hover:border-white/25">
<strong className="mb-2 block text-xl font-semibold"></strong> <strong className="mb-2 block text-xl font-semibold"></strong>
<span className="text-[13px] text-white/65"></span> <span className="text-[13px] text-white/65"></span>
</div> </div>
<div className="rounded-xl border border-white/15 bg-white/10 p-5 backdrop-blur-md transition-all duration-300 hover:bg-white/15"> <div className="rounded-xl border border-white/15 bg-white/10 p-5 backdrop-blur-md transition-all duration-300 hover:bg-white/15 hover:border-white/25">
<strong className="mb-2 block text-xl font-semibold"></strong> <strong className="mb-2 block text-xl font-semibold"></strong>
<span className="text-[13px] text-white/65"></span> <span className="text-[13px] text-white/65"></span>
</div> </div>
<div className="rounded-xl border border-white/15 bg-white/10 p-5 backdrop-blur-md transition-all duration-300 hover:bg-white/15"> <div className="rounded-xl border border-white/15 bg-white/10 p-5 backdrop-blur-md transition-all duration-300 hover:bg-white/15 hover:border-white/25">
<strong className="mb-2 block text-xl font-semibold"></strong> <strong className="mb-2 block text-xl font-semibold"></strong>
<span className="text-[13px] text-white/65"></span> <span className="text-[13px] text-white/65"></span>
</div> </div>

View File

@@ -1,8 +1,9 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button, Card, Form, Input, Message, Modal, Select, Space, Table, Tag } from '@arco-design/web-react'; import { Button, Form, Input, Message, Modal, Select, Space, Table, Tag } from '@arco-design/web-react';
import { IconPlus } from '@arco-design/web-react/icon'; import { IconPlus } from '@arco-design/web-react/icon';
import { personnelApi, roleApi } from '../api'; import { personnelApi, roleApi } from '../api';
import { usePageData } from '../hooks/usePageData'; import { usePageData } from '../hooks/usePageData';
import { Panel } from '../components/Panel';
import type { AdminUser, CreatePersonnelFormValues, Role } from './interface'; import type { AdminUser, CreatePersonnelFormValues, Role } from './interface';
function PersonnelPage() { function PersonnelPage() {
@@ -65,7 +66,7 @@ function PersonnelPage() {
)); ));
return ( return (
<Card className="section-card" title="人员管理" extra={<Button type="primary" icon={<IconPlus />} onClick={openCreate}></Button>}> <Panel title="人员管理" extra={<Button type="primary" icon={<IconPlus />} onClick={openCreate}></Button>} noPadding>
<Table rowKey="id" loading={loading} data={data.list} pagination={{ current: data.pagination.page, total: data.pagination.total, pageSize: data.pagination.pageSize, onChange: changePage }} columns={[ <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: 'ID', dataIndex: 'id', width: 80 },
{ title: '用户名', dataIndex: 'username' }, { title: '用户名', dataIndex: 'username' },
@@ -129,7 +130,7 @@ function PersonnelPage() {
{roleOptions} {roleOptions}
</Select> </Select>
</Modal> </Modal>
</Card> </Panel>
); );
} }

View File

@@ -13,7 +13,7 @@ export function ProfileInfoList({ profile }: ProfileInfoListProps) {
function ProfileInfoItem({ label, value }: ProfileInfoItemProps) { function ProfileInfoItem({ label, value }: ProfileInfoItemProps) {
return ( return (
<div className="rounded-xl border border-line/60 bg-canvas/50 p-4 transition-all duration-200 hover:bg-canvas"> <div className="rounded-xl border border-line/50 bg-canvas/50 p-4 transition-all duration-200 hover:border-line hover:bg-canvas">
<span className="mb-2 block text-[12px] font-medium uppercase tracking-wider text-muted">{label}</span> <span className="mb-2 block text-[12px] font-medium uppercase tracking-wider text-muted">{label}</span>
<strong className="break-words text-[15px] font-semibold text-ink">{value}</strong> <strong className="break-words text-[15px] font-semibold text-ink">{value}</strong>
</div> </div>

View File

@@ -3,12 +3,12 @@ import type { ProfileSummaryProps } from "../interface";
export function ProfileSummary({ profile }: ProfileSummaryProps) { export function ProfileSummary({ profile }: ProfileSummaryProps) {
return ( return (
<div className="mb-6 flex items-center gap-4 rounded-xl bg-gradient-to-br from-brand/5 to-brand/10 p-5"> <div className="mb-6 flex items-center gap-5 rounded-2xl bg-gradient-to-br from-brand/5 to-brand/10 p-6 ring-1 ring-brand/10">
<Avatar size={60} className="!bg-brand !text-white !text-xl !font-bold !shadow-lg !shadow-brand/30"> <Avatar size={64} className="!bg-gradient-to-br !from-brand !to-blue-600 !text-white !text-xl !font-bold !shadow-lg !shadow-brand/30">
{profile?.username?.slice(0, 1).toUpperCase() || "A"} {profile?.username?.slice(0, 1).toUpperCase() || "A"}
</Avatar> </Avatar>
<div> <div>
<h2 className="mb-1 mt-0 text-xl font-semibold text-ink">{profile?.nickname || profile?.username || "管理员"}</h2> <h2 className="mb-1 mt-0 text-xl font-bold text-ink">{profile?.nickname || profile?.username || "管理员"}</h2>
<p className="m-0 text-[13px] text-muted">{profile?.email || "未设置邮箱"}</p> <p className="m-0 text-[13px] text-muted">{profile?.email || "未设置邮箱"}</p>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Button, Card, Form, Input, Message } from '@arco-design/web-react'; import { Button, Form, Input, Message } from '@arco-design/web-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { authApi } from '../api'; import { authApi } from '../api';
import { Panel } from '../components/Panel';
import { ProfileInfoList } from './components/ProfileInfoList'; import { ProfileInfoList } from './components/ProfileInfoList';
import { ProfileSummary } from './components/ProfileSummary'; import { ProfileSummary } from './components/ProfileSummary';
import type { AdminProfile, ChangePasswordFormValues, ProfileFormValues } from './interface'; import type { AdminProfile, ChangePasswordFormValues, ProfileFormValues } from './interface';
@@ -66,12 +67,18 @@ function ProfilePage() {
return ( return (
<div className="page-stack"> <div className="page-stack">
<div className="grid items-start gap-4 min-[901px]:grid-cols-[minmax(0,1fr)_420px]"> <div className="grid items-start gap-5 min-[901px]:grid-cols-[minmax(0,1fr)_420px]">
<Card className="section-card min-[901px]:row-span-2" title="个人中心" loading={loading}> <Panel title="个人中心" className="min-[901px]:row-span-2">
{loading ? (
<div className="py-12 text-center text-muted">...</div>
) : (
<>
<ProfileSummary profile={profile} /> <ProfileSummary profile={profile} />
<ProfileInfoList profile={profile} /> <ProfileInfoList profile={profile} />
</Card> </>
<Card className="section-card" title="修改个人信息"> )}
</Panel>
<Panel title="修改个人信息">
<Form form={form} layout="vertical" onSubmit={updateProfile}> <Form form={form} layout="vertical" onSubmit={updateProfile}>
<Form.Item label="姓名" field="nickname"> <Form.Item label="姓名" field="nickname">
<Input placeholder="请输入姓名" /> <Input placeholder="请输入姓名" />
@@ -81,8 +88,8 @@ function ProfilePage() {
</Form.Item> </Form.Item>
<Button type="primary" htmlType="submit" loading={saving}></Button> <Button type="primary" htmlType="submit" loading={saving}></Button>
</Form> </Form>
</Card> </Panel>
<Card className="section-card" title="修改密码"> <Panel title="修改密码">
<Form form={passwordForm} layout="vertical" onSubmit={changePassword}> <Form form={passwordForm} layout="vertical" onSubmit={changePassword}>
<Form.Item label="旧密码" field="oldPassword" rules={[{ required: true }]}> <Form.Item label="旧密码" field="oldPassword" rules={[{ required: true }]}>
<Input.Password placeholder="请输入旧密码" /> <Input.Password placeholder="请输入旧密码" />
@@ -95,7 +102,7 @@ function ProfilePage() {
</Form.Item> </Form.Item>
<Button type="primary" htmlType="submit" loading={changingPassword}></Button> <Button type="primary" htmlType="submit" loading={changingPassword}></Button>
</Form> </Form>
</Card> </Panel>
</div> </div>
</div> </div>
); );

View File

@@ -12,6 +12,7 @@ const systemPermissionModules: PermissionModuleConfig[] = [
const businessPermissionModules: PermissionModuleConfig[] = [ const businessPermissionModules: PermissionModuleConfig[] = [
{ prefix: "drama", title: "短剧管理", subject: "短剧" }, { prefix: "drama", title: "短剧管理", subject: "短剧" },
{ prefix: "channel", title: "频道管理", subject: "频道" },
{ prefix: "episode", title: "剧集管理", subject: "剧集" }, { prefix: "episode", title: "剧集管理", subject: "剧集" },
{ prefix: "media", title: "媒体管理", subject: "媒体" }, { prefix: "media", title: "媒体管理", subject: "媒体" },
{ prefix: "payment", title: "支付管理", subject: "支付" }, { prefix: "payment", title: "支付管理", subject: "支付" },

View File

@@ -1,8 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import { Button, Card, Form, Input, Message, Modal, Space, Table, Tag } from "@arco-design/web-react"; import { Button, Form, Input, Message, Modal, Space, Table, Tag } from "@arco-design/web-react";
import { IconPlus } from "@arco-design/web-react/icon"; import { IconPlus } from "@arco-design/web-react/icon";
import { roleApi } from "../api"; import { roleApi } from "../api";
import { usePageData } from "../hooks/usePageData"; import { usePageData } from "../hooks/usePageData";
import { Panel } from "../components/Panel";
import { PermissionTreePicker } from "./components/PermissionTreePicker"; import { PermissionTreePicker } from "./components/PermissionTreePicker";
import type { CreateRoleFormValues, Permission, Role } from "./interface"; import type { CreateRoleFormValues, Permission, Role } from "./interface";
@@ -61,14 +62,14 @@ function RolesPage() {
}; };
return ( return (
<Card <Panel
className="section-card"
title="角色管理" title="角色管理"
extra={ extra={
<Button type="primary" icon={<IconPlus />} onClick={openCreateRole}> <Button type="primary" icon={<IconPlus />} onClick={openCreateRole}>
</Button> </Button>
} }
noPadding
> >
<Table <Table
rowKey="id" rowKey="id"
@@ -137,7 +138,7 @@ function RolesPage() {
> >
<PermissionTreePicker permissions={permissions.data.list} value={selectedPermissionIds} onChange={setSelectedPermissionIds} /> <PermissionTreePicker permissions={permissions.data.list} value={selectedPermissionIds} onChange={setSelectedPermissionIds} />
</Modal> </Modal>
</Card> </Panel>
); );
} }

View File

@@ -1,6 +1,7 @@
import { Button, Card, Message, Table, Tag } from '@arco-design/web-react'; import { Button, Message, Table, Tag } from '@arco-design/web-react';
import { userApi } from '../api'; import { userApi } from '../api';
import { usePageData } from '../hooks/usePageData'; import { usePageData } from '../hooks/usePageData';
import { Panel } from '../components/Panel';
import type { AppUser } from './interface'; import type { AppUser } from './interface';
function UsersPage() { function UsersPage() {
@@ -13,20 +14,15 @@ function UsersPage() {
return ( return (
<div className="page-stack"> <div className="page-stack">
<Card className="section-card"> <Panel title="小程序用户管理" noPadding>
<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={[ <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: 'ID', dataIndex: 'id', width: 80 },
{ title: 'TikTok Open ID', dataIndex: 'tiktokOpenId' }, { title: 'TikTok Open ID', dataIndex: 'tiktokOpenId' },
{ title: '昵称', dataIndex: 'nickname' }, { title: '昵称', dataIndex: 'nickname' },
{ title: '状态', dataIndex: 'status', width: 120, render: (status) => <Tag color={status === 'active' ? 'green' : 'red'}>{status}</Tag> }, { title: '状态', dataIndex: 'status', width: 120, render: (status) => <Tag color={status === 'active' ? 'green' : 'red'}>{status === 'active' ? '启用' : '禁用'}</Tag> },
{ title: '操作', width: 160, render: (_, record: AppUser) => <Button size="small" onClick={() => update状态(record.id, record.status === 'active' ? 'disabled' : 'active')}>{record.status === 'active' ? '禁用' : '启用'}</Button> }, { title: '操作', width: 160, render: (_, record: AppUser) => <Button size="small" onClick={() => update状态(record.id, record.status === 'active' ? 'disabled' : 'active')}>{record.status === 'active' ? '禁用' : '启用'}</Button> },
]} /> ]} />
</Card> </Panel>
</div> </div>
); );
} }

18
src/api/channels.ts Normal file
View File

@@ -0,0 +1,18 @@
import { fetchPage, request } from "../plugins/axios";
import type { Channel, ChannelQuery, CreateChannelPayload, UpdateChannelPayload } from "./interface";
export function listChannels(query: ChannelQuery) {
return fetchPage<Channel>('/api/admin/v1/channels', query);
}
export async function createChannel(payload: CreateChannelPayload) {
await request.post('/api/admin/v1/channels', payload);
}
export async function updateChannel(channelId: number, payload: UpdateChannelPayload) {
await request.patch(`/api/admin/v1/channels/${channelId}`, payload);
}
export async function deleteChannel(channelId: number) {
await request.delete(`/api/admin/v1/channels/${channelId}`);
}

View File

@@ -40,6 +40,10 @@ export async function updateDrama(
await request.patch(`/api/admin/v1/dramas/${dramaId}`, payload); await request.patch(`/api/admin/v1/dramas/${dramaId}`, payload);
} }
export async function deleteDrama(dramaId: number) {
await request.delete(`/api/admin/v1/dramas/${dramaId}`);
}
export async function updateEpisode( export async function updateEpisode(
episodeId: number, episodeId: number,
payload: UpdateEpisodePayload, payload: UpdateEpisodePayload,

View File

@@ -1,6 +1,7 @@
export * as authApi from "./auth"; export * as authApi from "./auth";
export * as dataApi from "./data"; export * as dataApi from "./data";
export * as dramaApi from "./dramas"; export * as dramaApi from "./dramas";
export * as channelApi from "./channels";
export * as logApi from "./logs"; export * as logApi from "./logs";
export * as mediaApi from "./media"; export * as mediaApi from "./media";
export * as personnelApi from "./personnel"; export * as personnelApi from "./personnel";

View File

@@ -45,6 +45,8 @@ export type Drama = {
type: string; type: string;
status: string; status: string;
coverUrl: string; coverUrl: string;
channelId?: number;
channelName?: string;
isRecommended: boolean; isRecommended: boolean;
totalEpisodes?: number; totalEpisodes?: number;
publishedEpisodes?: number; publishedEpisodes?: number;
@@ -80,9 +82,27 @@ export type Episode = {
createdAt: string; createdAt: string;
}; };
export type CreateDramaPayload = Partial<Omit<Drama, "id" | "publishedEpisodes" | "createdAt">>; export type Channel = {
id: number;
name: string;
code: string;
description?: string;
sortOrder: number;
isDefault: boolean;
status: "active" | "disabled";
createdAt: string;
updatedAt: string;
};
export type UpdateDramaPayload = Partial<Omit<Drama, "id" | "publishedEpisodes" | "createdAt">>; export type ChannelQuery = PageQuery;
export type CreateChannelPayload = Partial<Omit<Channel, "id" | "createdAt" | "updatedAt">>;
export type UpdateChannelPayload = Partial<Omit<Channel, "id" | "createdAt" | "updatedAt">>;
export type CreateDramaPayload = Partial<Omit<Drama, "id" | "publishedEpisodes" | "channelName" | "createdAt">>;
export type UpdateDramaPayload = Partial<Omit<Drama, "id" | "publishedEpisodes" | "channelName" | "createdAt">>;
export type UpdateEpisodePayload = Partial<Omit<Episode, "id" | "dramaId" | "albumId" | "episodeNo" | "seq" | "createdAt">> & { export type UpdateEpisodePayload = Partial<Omit<Episode, "id" | "dramaId" | "albumId" | "episodeNo" | "seq" | "createdAt">> & {
status?: string; status?: string;

View File

@@ -1,17 +1,18 @@
import { Card, Typography } from "@arco-design/web-react"; import { Typography } from "@arco-design/web-react";
import { IconLock } from "@arco-design/web-react/icon"; import { IconLock } from "@arco-design/web-react/icon";
import { Panel } from "../Panel";
const { Text } = Typography; const { Text } = Typography;
export function NoAccessPage() { export function NoAccessPage() {
return ( return (
<Card className="section-card" title="暂无权限"> <Panel title="暂无权限">
<div className="flex flex-col items-center py-8"> <div className="flex flex-col items-center py-10">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-warning/10"> <div className="mb-5 flex h-16 w-16 items-center justify-center rounded-2xl bg-warning-light">
<IconLock className="text-3xl text-warning" /> <IconLock className="text-3xl text-warning" />
</div> </div>
<Text type="secondary" className="text-center">访</Text> <Text type="secondary" className="text-center text-[14px]">访</Text>
</div> </div>
</Card> </Panel>
); );
} }

View File

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

View File

@@ -10,10 +10,10 @@ export function RequestLogTable({ data, loading }: RequestLogTableProps) {
pagination={false} pagination={false}
columns={[ columns={[
{ title: "请求 ID", dataIndex: "requestId", ellipsis: true }, { title: "请求 ID", dataIndex: "requestId", ellipsis: true },
{ title: "方法", dataIndex: "method", width: 100, render: (value) => <Tag>{value}</Tag> }, { title: "方法", dataIndex: "method", width: 100, render: (value) => <Tag color={value === 'GET' ? 'blue' : value === 'POST' ? 'green' : 'orange'}>{value}</Tag> },
{ title: "路径", dataIndex: "path", ellipsis: true }, { title: "路径", dataIndex: "path", ellipsis: true },
{ title: "状态", dataIndex: "statusCode", width: 100, render: (value) => <Tag color={value < 400 ? "green" : "red"}>{value}</Tag> }, { title: "状态", dataIndex: "statusCode", width: 100, render: (value) => <Tag color={value < 400 ? "green" : value < 500 ? "orange" : "red"}>{value}</Tag> },
{ title: "耗时", dataIndex: "costMs", width: 100, render: (value) => String(value) + "ms" }, { title: "耗时", dataIndex: "costMs", width: 100, render: (value) => <span className={Number(value) > 1000 ? 'text-danger font-medium' : ''}>{value}ms</span> },
{ title: "消息", dataIndex: "message", width: 180 }, { title: "消息", dataIndex: "message", width: 180 },
]} ]}
/> />

View File

@@ -3,10 +3,6 @@
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
* {
@apply transition-colors duration-200;
}
::-webkit-scrollbar { ::-webkit-scrollbar {
@apply w-1.5 h-1.5; @apply w-1.5 h-1.5;
} }
@@ -18,15 +14,23 @@
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
@apply rounded-full bg-line hover:bg-muted; @apply rounded-full bg-line hover:bg-muted;
} }
::selection {
@apply bg-brand/15 text-brand;
}
} }
@layer components { @layer components {
.section-card { .section-card {
@apply rounded-xl border border-line/60 bg-white shadow-sm; @apply rounded-xl border border-line/60 bg-white shadow-sm transition-shadow duration-300;
}
.section-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
} }
.page-stack { .page-stack {
@apply flex flex-col gap-4; @apply flex flex-col gap-5;
} }
.modal-actions { .modal-actions {
@@ -34,7 +38,7 @@
} }
.form-label { .form-label {
@apply mb-2 text-sm font-medium text-[#4e5969]; @apply mb-2 text-sm font-medium text-ink;
} }
.form-help { .form-help {
@@ -42,10 +46,11 @@
} }
.stat-card { .stat-card {
@apply rounded-xl border border-line/60 bg-white p-5 shadow-sm transition-all duration-300 hover:shadow-md hover:-translate-y-0.5; @apply rounded-xl border border-line/60 bg-white p-5 shadow-sm transition-all duration-300 hover:shadow-lg hover:-translate-y-0.5 cursor-default;
} }
} }
/* ── Layout Sider ───────────────────────────────────── */
.arco-layout-sider { .arco-layout-sider {
transition: width 0.2s cubic-bezier(0.2, 0, 0, 1) !important; transition: width 0.2s cubic-bezier(0.2, 0, 0, 1) !important;
} }
@@ -54,12 +59,56 @@
border-right: none !important; border-right: none !important;
} }
/* ── Menu ───────────────────────────────────────────── */
.arco-menu-item {
border-radius: 0.5rem !important;
margin: 2px 8px !important;
transition: all 0.2s ease !important;
}
.arco-menu-item:hover {
background-color: #f2f3f5 !important;
}
.arco-menu-item.arco-menu-selected {
background-color: #e8f3ff !important;
color: #165dff !important;
font-weight: 600 !important;
}
.arco-menu-inline-header {
border-radius: 0.5rem !important;
margin: 2px 8px !important;
}
.arco-menu-inline-header:hover {
background-color: #f2f3f5 !important;
}
/* ── Card ───────────────────────────────────────────── */
.arco-card { .arco-card {
border-radius: 0.75rem !important; border-radius: 0.75rem !important;
border: 1px solid rgba(229, 230, 235, 0.6) !important; border: 1px solid rgba(229, 230, 235, 0.6) !important;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.04) !important; box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.04) !important;
transition: box-shadow 0.3s ease !important;
} }
.arco-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06) !important;
}
.arco-card-header {
border-bottom: 1px solid rgba(229, 230, 235, 0.5) !important;
padding: 14px 20px !important;
}
.arco-card-header-title {
font-weight: 600 !important;
font-size: 15px !important;
color: #1d2129 !important;
}
/* ── Table ──────────────────────────────────────────── */
.arco-table-container { .arco-table-container {
border-radius: 0.5rem; border-radius: 0.5rem;
overflow: hidden; overflow: hidden;
@@ -69,20 +118,76 @@
background-color: #fafbfc !important; background-color: #fafbfc !important;
font-weight: 600 !important; font-weight: 600 !important;
color: #4e5969 !important; color: #4e5969 !important;
font-size: 13px !important;
letter-spacing: 0.01em;
border-bottom: 1px solid rgba(229, 230, 235, 0.8) !important;
}
.arco-table-td {
transition: background-color 0.15s ease !important;
} }
.arco-table-tr:hover .arco-table-td { .arco-table-tr:hover .arco-table-td {
background-color: #f7f8fa !important; background-color: #f7f8fa !important;
} }
.arco-table-sticky-header .arco-table-container {
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
}
/* ── Pagination ─────────────────────────────────────── */
.arco-pagination {
margin-top: 16px !important;
}
.arco-pagination-item {
border-radius: 0.375rem !important;
transition: all 0.2s ease !important;
}
.arco-pagination-item:hover {
background-color: #e8f3ff !important;
color: #165dff !important;
border-color: #bedaff !important;
}
.arco-pagination-item-active {
background-color: #165dff !important;
border-color: #165dff !important;
color: #fff !important;
box-shadow: 0 2px 6px rgba(22, 93, 251, 0.3) !important;
}
/* ── Button ─────────────────────────────────────────── */
.arco-btn {
transition: all 0.2s ease !important;
font-weight: 500 !important;
}
.arco-btn-primary { .arco-btn-primary {
box-shadow: 0 2px 6px rgba(22, 93, 251, 0.3) !important; box-shadow: 0 2px 6px rgba(22, 93, 251, 0.3) !important;
} }
.arco-btn-primary:hover { .arco-btn-primary:hover {
box-shadow: 0 4px 12px rgba(22, 93, 251, 0.4) !important; box-shadow: 0 4px 12px rgba(22, 93, 251, 0.4) !important;
transform: translateY(-1px);
} }
.arco-btn-secondary:not(.arco-btn-disabled):hover {
background-color: #f2f3f5 !important;
border-color: #c9cdd4 !important;
}
.arco-btn-text:not(.arco-btn-disabled):hover {
background-color: #f2f3f5 !important;
}
.arco-btn-text-danger:not(.arco-btn-disabled):hover {
background-color: #fff1f0 !important;
}
/* ── Modal ──────────────────────────────────────────── */
.arco-modal { .arco-modal {
border-radius: 0.75rem !important; border-radius: 0.75rem !important;
overflow: hidden; overflow: hidden;
@@ -93,13 +198,24 @@
padding: 16px 24px !important; padding: 16px 24px !important;
} }
.arco-modal-title {
font-weight: 600 !important;
font-size: 16px !important;
}
.arco-modal-body { .arco-modal-body {
padding: 24px !important; padding: 24px !important;
} }
.arco-modal-footer {
border-top: 1px solid rgba(229, 230, 235, 0.6) !important;
padding: 12px 24px !important;
}
/* ── Input & Select ─────────────────────────────────── */
.arco-input-wrapper { .arco-input-wrapper {
border-radius: 0.5rem !important; border-radius: 0.5rem !important;
transition: all 0.2s !important; transition: all 0.2s ease !important;
} }
.arco-input-wrapper:hover { .arco-input-wrapper:hover {
@@ -113,9 +229,77 @@
.arco-select-view { .arco-select-view {
border-radius: 0.5rem !important; border-radius: 0.5rem !important;
transition: all 0.2s ease !important;
} }
.arco-select-view:hover {
border-color: #c9cdd4 !important;
}
.arco-select-view.arco-select-view-focus {
border-color: #165dff !important;
box-shadow: 0 0 0 2px rgba(22, 93, 251, 0.1) !important;
}
.arco-textarea-wrapper {
border-radius: 0.5rem !important;
transition: all 0.2s ease !important;
}
.arco-textarea-wrapper:hover {
border-color: #c9cdd4 !important;
}
.arco-textarea-focus {
border-color: #165dff !important;
box-shadow: 0 0 0 2px rgba(22, 93, 251, 0.1) !important;
}
/* ── Form ───────────────────────────────────────────── */
.arco-form-item-label { .arco-form-item-label {
font-weight: 500 !important; font-weight: 500 !important;
color: #4e5969 !important; color: #4e5969 !important;
font-size: 13px !important;
}
/* ── Tag ────────────────────────────────────────────── */
.arco-tag {
border-radius: 0.375rem !important;
font-weight: 500 !important;
letter-spacing: 0.01em;
}
/* ── Descriptions ───────────────────────────────────── */
.arco-descriptions-item-label {
font-weight: 500 !important;
color: #86909c !important;
background-color: #fafbfc !important;
}
.arco-descriptions-item-value {
color: #1d2129 !important;
}
/* ── Dropdown ───────────────────────────────────────── */
.arco-dropdown-menu {
border-radius: 0.5rem !important;
padding: 4px !important;
}
.arco-dropdown-menu-item {
border-radius: 0.375rem !important;
transition: all 0.15s ease !important;
}
.arco-dropdown-menu-item:hover {
background-color: #f2f3f5 !important;
}
/* ── Switch ─────────────────────────────────────────── */
.arco-switch {
transition: all 0.2s ease !important;
}
.arco-switch-checked {
box-shadow: 0 2px 4px rgba(22, 93, 251, 0.3) !important;
} }

View File

@@ -1,4 +1,4 @@
import { Card } from '@arco-design/web-react'; import { Panel } from '../components/Panel';
import { hasPermissions } from '../utils/permission'; import { hasPermissions } from '../utils/permission';
import { NoAccessPage } from '../components/NoAccessPage'; import { NoAccessPage } from '../components/NoAccessPage';
import type { PermissionRouteProps } from './interface'; import type { PermissionRouteProps } from './interface';
@@ -9,7 +9,13 @@ export function PermissionRoute({
children, children,
}: PermissionRouteProps) { }: PermissionRouteProps) {
if (!profile) { if (!profile) {
return <Card className="section-card" loading />; return (
<div className="page-stack">
<Panel>
<div className="py-16 text-center text-muted">...</div>
</Panel>
</div>
);
} }
if (!hasPermissions(profile, requiredPermissions)) { if (!hasPermissions(profile, requiredPermissions)) {
return <NoAccessPage />; return <NoAccessPage />;

View File

@@ -8,6 +8,7 @@ import { filterMenuItems, findCurrentMenu, menuItems, renderMenuItem } from "./m
import { PermissionRoute } from "./PermissionRoute"; import { PermissionRoute } from "./PermissionRoute";
import DashboardPage from "../Dashboard"; import DashboardPage from "../Dashboard";
import DramasPage from "../Drama"; import DramasPage from "../Drama";
import ChannelPage from "../Channel";
import DataPage from "../Data"; import DataPage from "../Data";
import UsersPage from "../User"; import UsersPage from "../User";
import RolesPage from "../Role"; import RolesPage from "../Role";
@@ -49,11 +50,18 @@ export function AdminLayout() {
return ( return (
<Layout className="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
<div className="flex h-14 items-center gap-2.5 border-b border-line/60 px-[18px]"> className="border-r border-line/40 bg-gradient-to-b from-white to-panel"
<span className="grid h-8 w-8 place-items-center rounded-lg bg-brand text-sm font-bold text-white shadow-sm">TK</span> width={224}
{!collapsed && <span className="font-semibold text-ink">TK短剧管理后台</span>} collapsed={collapsed}
collapsible
trigger={null}
>
<div className="flex h-[60px] items-center gap-2.5 px-5">
<span className="grid h-9 w-9 place-items-center rounded-xl bg-gradient-to-br from-brand to-blue-600 text-sm font-bold text-white shadow-md shadow-brand/25">TK</span>
{!collapsed && <span className="text-[15px] font-bold tracking-tight text-ink">TK短剧管理后台</span>}
</div> </div>
<div className="mx-3 mb-2 h-px bg-line/50" />
<Menu <Menu
selectedKeys={[current?.item.key || "/dashboard"]} selectedKeys={[current?.item.key || "/dashboard"]}
openKeys={openMenuKeys} openKeys={openMenuKeys}
@@ -65,39 +73,39 @@ export function AdminLayout() {
</Menu> </Menu>
</Sider> </Sider>
<Layout className="flex flex-col overflow-hidden"> <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"> <Header className="flex h-[60px] shrink-0 items-center justify-between border-b border-line/40 bg-white/90 px-6 backdrop-blur-lg">
<Space> <Space size={12}>
<Button <Button
type="text" type="text"
icon={collapsed ? <IconMenuUnfold /> : <IconMenuFold />} icon={collapsed ? <IconMenuUnfold /> : <IconMenuFold />}
onClick={() => setCollapsed(!collapsed)} onClick={() => setCollapsed(!collapsed)}
className="!text-muted hover:!text-ink" className="!text-muted hover:!text-ink !rounded-lg"
/> />
<div className="flex flex-col gap-0.5"> <div className="flex items-center gap-2">
<strong className="text-[15px] text-ink">{current?.item.label || "工作台"}</strong> <strong className="text-[15px] font-semibold text-ink">{current?.item.label || "工作台"}</strong>
</div> </div>
</Space> </Space>
<Dropdown <Dropdown
position="br" position="br"
trigger="click" trigger="click"
droplist={ droplist={
<Menu className="!rounded-lg !border-line/60 !shadow-lg"> <Menu className="!rounded-xl !border-line/40 !shadow-card-lg !p-1.5">
<Menu.Item key="profile" onClick={() => navigate("/profile")} className="!rounded-md"> <Menu.Item key="profile" onClick={() => navigate("/profile")} className="!rounded-lg !h-9">
</Menu.Item> </Menu.Item>
<Menu.Item key="logout" onClick={logout} className="!rounded-md"> <Menu.Item key="logout" onClick={logout} className="!rounded-lg !h-9">
退 退
</Menu.Item> </Menu.Item>
</Menu> </Menu>
} }
> >
<button className="inline-flex cursor-pointer items-center gap-2 rounded-lg border-0 bg-transparent px-2.5 py-1.5 text-ink transition-all duration-200 hover:bg-canvas" type="button"> <button className="inline-flex cursor-pointer items-center gap-2.5 rounded-xl border border-transparent bg-transparent px-3 py-2 text-ink transition-all duration-200 hover:border-line/60 hover:bg-canvas/80" type="button">
<Avatar size={30} className="!bg-brand !text-white">{profile?.username?.slice(0, 1).toUpperCase() || "A"}</Avatar> <Avatar size={32} className="!bg-gradient-to-br !from-brand !to-blue-600 !text-white !text-[13px] !font-semibold !shadow-sm !shadow-brand/20">{profile?.username?.slice(0, 1).toUpperCase() || "A"}</Avatar>
<Text className="!text-[13px]">{profile?.nickname || profile?.username || "管理员"}</Text> <Text className="!text-[13px] !font-medium">{profile?.nickname || profile?.username || "管理员"}</Text>
</button> </button>
</Dropdown> </Dropdown>
</Header> </Header>
<Content className="flex-1 overflow-y-auto p-4 sm:p-6"> <Content className="flex-1 overflow-y-auto p-5 sm:p-6">
<Routes> <Routes>
<Route path="/dashboard" element={<DashboardPage profile={profile} />} /> <Route path="/dashboard" element={<DashboardPage profile={profile} />} />
<Route <Route
@@ -116,6 +124,14 @@ export function AdminLayout() {
</PermissionRoute> </PermissionRoute>
} }
/> />
<Route
path="/channels"
element={
<PermissionRoute profile={profile} requiredPermissions={["channel:read"]}>
<ChannelPage />
</PermissionRoute>
}
/>
<Route <Route
path="/data" path="/data"
element={ element={

View File

@@ -15,6 +15,7 @@ export const SYSTEM_MENU_KEY = '/system';
export const menuItems: MenuConfig[] = [ export const menuItems: MenuConfig[] = [
{ key: '/dashboard', label: '工作台', icon: <IconDashboard /> }, { key: '/dashboard', label: '工作台', icon: <IconDashboard /> },
{ key: '/dramas', label: '短剧管理', icon: <IconVideoCamera />, requiredPermissions: ['drama:read'] }, { key: '/dramas', label: '短剧管理', icon: <IconVideoCamera />, requiredPermissions: ['drama:read'] },
{ key: '/channels', label: '频道管理', icon: <IconApps />, requiredPermissions: ['channel:read'] },
{ key: '/data', label: '数据管理', icon: <IconApps />, requiredPermissions: ['data:read'] }, { key: '/data', label: '数据管理', icon: <IconApps />, requiredPermissions: ['data:read'] },
{ key: '/users', label: '用户管理', icon: <IconUser />, requiredPermissions: ['user:read'] }, { key: '/users', label: '用户管理', icon: <IconUser />, requiredPermissions: ['user:read'] },
{ {

View File

@@ -16,8 +16,11 @@ export default {
brand: '#165dff', brand: '#165dff',
'brand-light': '#e8f3ff', 'brand-light': '#e8f3ff',
success: '#00b42a', success: '#00b42a',
'success-light': '#e8ffea',
warning: '#ff7d00', warning: '#ff7d00',
'warning-light': '#fff7e8',
danger: '#f53f3f', danger: '#f53f3f',
'danger-light': '#fff1f0',
}, },
fontFamily: { fontFamily: {
sans: [ sans: [
@@ -34,10 +37,25 @@ export default {
login: '0 18px 48px rgba(15, 23, 42, 0.08)', login: '0 18px 48px rgba(15, 23, 42, 0.08)',
card: '0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.02)', card: '0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.02)',
'card-hover': '0 4px 12px rgba(0,0,0,0.08)', 'card-hover': '0 4px 12px rgba(0,0,0,0.08)',
'card-lg': '0 4px 16px rgba(0,0,0,0.06), 0 1px 4px rgba(0,0,0,0.04)',
}, },
borderRadius: { borderRadius: {
xl: '0.75rem', xl: '0.75rem',
}, },
animation: {
'fade-in': 'fadeIn 0.3s ease-out',
'slide-up': 'slideUp 0.3s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { opacity: '0', transform: 'translateY(8px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
},
}, },
}, },
plugins: [], plugins: [],