feat: 新增频道管理功能,优化整体UI与交互体验
本次提交完成了以下核心变更: 1. 新增完整的频道管理模块,包括频道增删改查API、页面组件与权限配置 2. 为短剧功能添加频道关联字段,支持将短剧绑定到指定频道 3. 全局UI优化:统一使用Panel组件重构页面布局,统一样式规范与交互细节 4. 表格与日志组件优化:新增请求方法颜色标记、耗时高亮、状态码分级展示 5. 登录页、个人中心、布局等页面的视觉升级与交互优化 6. 完善全局TailwindCSS样式与组件样式自定义
This commit is contained in:
179
src/Channel/index.tsx
Normal file
179
src/Channel/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Card } from '@arco-design/web-react';
|
||||
import { IconPlayCircle, IconUser, IconFile, IconCheckCircle } from '@arco-design/web-react/icon';
|
||||
import { dramaApi, logApi, userApi } from '../api';
|
||||
import { usePageData } from '../hooks/usePageData';
|
||||
import { hasPermissions } from '../utils/permission';
|
||||
import { NoAccessPage } from '../components/NoAccessPage';
|
||||
import { Panel } from '../components/Panel';
|
||||
import { RequestLogTable } from '../components/RequestLogTable';
|
||||
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 }) {
|
||||
return (
|
||||
<div className={`stat-card bg-gradient-to-br ${METRIC_COLORS[icon]}`}>
|
||||
<div className="mb-3 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={`stat-card animate-slide-up bg-gradient-to-br ${METRIC_COLORS[icon]}`}>
|
||||
<div className="mb-3.5 flex items-center gap-3">
|
||||
<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]}
|
||||
</div>
|
||||
<span className="text-[13px] font-medium text-muted">{label}</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -45,20 +45,20 @@ function DashboardPage({ profile }: DashboardPageProps) {
|
||||
const hasAnyDataModule = canReadDramas || canReadUsers || canReadLogs;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5 grid grid-cols-1 gap-4 sm:grid-cols-2 min-[901px]:grid-cols-4">
|
||||
<div className="page-stack">
|
||||
<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" />}
|
||||
{canReadUsers && <MetricCard label="用户管理" value={users.data.pagination.total} icon="users" />}
|
||||
{canReadLogs && <MetricCard label="请求日志" value={logs.data.pagination.total} icon="logs" />}
|
||||
<MetricCard label="接口状态" value="运行中" icon="status" />
|
||||
</div>
|
||||
{canReadLogs ? (
|
||||
<Card className="section-card" title="最近请求">
|
||||
<Panel title="最近请求" noPadding>
|
||||
<RequestLogTable data={logs.data.list.slice(0, 8)} loading={logs.loading} />
|
||||
</Card>
|
||||
</Panel>
|
||||
) : null}
|
||||
{!hasAnyDataModule ? <NoAccessPage /> : null}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { dataApi } from '../api';
|
||||
import { usePageData } from '../hooks/usePageData';
|
||||
import { Panel } from '../components/Panel';
|
||||
import type { DataOverview, DramaMetric, EpisodeMetric, SyncJob } from './interface';
|
||||
|
||||
const { Row, Col } = Grid;
|
||||
@@ -42,7 +43,7 @@ function DataPage() {
|
||||
|
||||
return (
|
||||
<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}>
|
||||
<Col span={4}><Statistic title="短剧数" value={overview.totalDramas} /></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.totalComments} /></Col>
|
||||
</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={[
|
||||
{ title: '短剧ID', dataIndex: 'dramaId', width: 100 },
|
||||
{ title: '短剧名称', dataIndex: 'title' },
|
||||
@@ -64,9 +65,9 @@ function DataPage() {
|
||||
{ title: '完播率', dataIndex: 'completionRate', width: 120, render: (value) => `${Math.round(Number(value) * 100)}%` },
|
||||
{ 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={[
|
||||
{ title: '剧集ID', dataIndex: 'episodeId', width: 100 },
|
||||
{ title: '短剧ID', dataIndex: 'dramaId', width: 100 },
|
||||
@@ -77,9 +78,9 @@ function DataPage() {
|
||||
{ title: '平均观看秒数', dataIndex: 'averageWatchSeconds', width: 150 },
|
||||
{ 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={[
|
||||
{ title: '任务ID', dataIndex: 'jobId' },
|
||||
{ title: '状态', dataIndex: 'status', width: 120 },
|
||||
@@ -87,7 +88,7 @@ function DataPage() {
|
||||
{ title: '剧集数', dataIndex: 'episodeCount', width: 120 },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 220 },
|
||||
]} />
|
||||
</Card>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
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 { dramaApi, mediaApi } from "../../api";
|
||||
import { channelApi, dramaApi, mediaApi } from "../../api";
|
||||
import { Panel } from "../../components/Panel";
|
||||
import { toDramaPayload, toEpisodePayload } from "../formPayload";
|
||||
import type { Drama, DramaDetailPageProps, Episode } from "../interface";
|
||||
import type { Channel, Drama, DramaDetailPageProps, Episode } from "../interface";
|
||||
import { EpisodeTable } from "./EpisodeTable";
|
||||
|
||||
const { Row, Col } = Grid;
|
||||
@@ -32,14 +33,20 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
|
||||
const [uploadingEpisodeId, setUploadingEpisodeId] = useState<number | null>(null);
|
||||
const [editVisible, setEditVisible] = useState(false);
|
||||
const [updatingDrama, setUpdatingDrama] = useState(false);
|
||||
const [channels, setChannels] = useState<Channel[]>([]);
|
||||
|
||||
const loadDetail = async () => {
|
||||
setLoading(true);
|
||||
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);
|
||||
setEpisodeCount(dramaData.totalEpisodes || 1);
|
||||
setEpisodes(episodeList);
|
||||
setChannels(channelPage.list);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -108,9 +115,7 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card
|
||||
className="section-card"
|
||||
loading={loading && !drama}
|
||||
<Panel
|
||||
title={
|
||||
<Space>
|
||||
<Button icon={<IconArrowLeft />} onClick={() => navigate("/dramas")} />
|
||||
@@ -132,12 +137,15 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{drama && (
|
||||
{loading && !drama ? (
|
||||
<div className="py-12 text-center text-muted">加载中...</div>
|
||||
) : drama ? (
|
||||
<Descriptions
|
||||
column={3}
|
||||
data={[
|
||||
{ label: "短剧ID", value: drama.id },
|
||||
{ label: "名称", value: drama.title },
|
||||
{ label: "频道", value: drama.channelName || "-" },
|
||||
{ label: "地区", value: drama.region || "-" },
|
||||
{ label: "类型", value: drama.type },
|
||||
{ label: "总集数", value: drama.totalEpisodes || 0 },
|
||||
@@ -147,11 +155,10 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
|
||||
{ label: "简介", value: drama.description || "-" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
) : null}
|
||||
</Panel>
|
||||
|
||||
<Card
|
||||
className="section-card"
|
||||
<Panel
|
||||
title="剧集配置"
|
||||
extra={
|
||||
<Space>
|
||||
@@ -161,6 +168,7 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
noPadding
|
||||
>
|
||||
<EpisodeTable
|
||||
episodes={episodes}
|
||||
@@ -170,7 +178,7 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
|
||||
onPublishEpisode={publishEpisode}
|
||||
onUpdateEpisode={updateEpisode}
|
||||
/>
|
||||
</Card>
|
||||
</Panel>
|
||||
|
||||
{drama && (
|
||||
<Modal title="编辑短剧" visible={editVisible} onCancel={() => setEditVisible(false)} footer={null} style={{ width: 720 }}>
|
||||
@@ -190,19 +198,25 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
|
||||
year: drama.year,
|
||||
sortOrder: drama.sortOrder,
|
||||
isRecommended: drama.isRecommended,
|
||||
totalEpisodes: drama.totalEpisodes || 1,
|
||||
channelId: drama.channelId,
|
||||
}}
|
||||
>
|
||||
<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="请输入类型" />
|
||||
<Col span={8}>
|
||||
<Form.Item label="频道" field="channelId">
|
||||
<Select placeholder="默认频道" allowClear>
|
||||
{channels.map((channel) => (
|
||||
<Select.Option key={channel.id} value={channel.id}>
|
||||
{channel.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Col span={8}>
|
||||
<Form.Item label="状态" field="status">
|
||||
<Select>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
@@ -213,6 +227,11 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="类型" field="type">
|
||||
<Input placeholder="请输入类型" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item label="封面地址" field="coverUrl" rules={[{ required: true }]}>
|
||||
<Input placeholder="请输入封面图片 URL" />
|
||||
@@ -241,17 +260,12 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="排序权重" field="sortOrder">
|
||||
<InputNumber style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="剧集总数" field="totalEpisodes" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={500} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="推荐" field="isRecommended" triggerPropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
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 { IconPlus, IconSearch, IconRefresh } from "@arco-design/web-react/icon";
|
||||
import { dramaApi } from "../../api";
|
||||
import { IconDelete, IconPlus, IconSearch, IconRefresh } from "@arco-design/web-react/icon";
|
||||
import { channelApi, 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";
|
||||
import type { Channel, Drama, StatusOption } from "../interface";
|
||||
|
||||
const { Row, Col } = Grid;
|
||||
|
||||
@@ -35,6 +35,12 @@ export function DramaList() {
|
||||
const [filterTitle, setFilterTitle] = useState("");
|
||||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||||
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 = () => {
|
||||
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 (
|
||||
<div className="page-stack">
|
||||
<Panel title="短剧管理">
|
||||
<Row gutter={16} className="mb-0">
|
||||
<Panel title="短剧管理" extra={<Button type="primary" status="success" icon={<IconPlus />} onClick={() => setVisible(true)}>新增短剧</Button>}>
|
||||
<Row gutter={16} align="center">
|
||||
<Col span={6}>
|
||||
<div className="text-[13px] text-muted mb-1.5">短剧标题</div>
|
||||
<Input
|
||||
placeholder="搜索短剧标题"
|
||||
value={filterTitle}
|
||||
onChange={setFilterTitle}
|
||||
allowClear
|
||||
/>
|
||||
<div className="form-label">短剧标题</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
|
||||
>
|
||||
<div className="form-label">状态</div>
|
||||
<Select placeholder="全部状态" value={filterStatus} onChange={setFilterStatus} allowClear>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<Select.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
@@ -88,16 +97,11 @@ export function DramaList() {
|
||||
</Select>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<div className="text-[13px] text-muted mb-1.5">类型</div>
|
||||
<Input
|
||||
placeholder="搜索类型"
|
||||
value={filterType}
|
||||
onChange={setFilterType}
|
||||
allowClear
|
||||
/>
|
||||
<div className="form-label">类型</div>
|
||||
<Input placeholder="搜索类型" value={filterType} onChange={setFilterType} allowClear />
|
||||
</Col>
|
||||
<Col span={10}>
|
||||
<div className="text-[13px] mb-1.5"> </div>
|
||||
<div className="form-label invisible">操作</div>
|
||||
<Space>
|
||||
<Button type="primary" icon={<IconSearch />} onClick={handleSearch}>
|
||||
查询
|
||||
@@ -105,21 +109,20 @@ export function DramaList() {
|
||||
<Button icon={<IconRefresh />} onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
<Button type="primary" status="success" icon={<IconPlus />} onClick={() => setVisible(true)}>
|
||||
新增短剧
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Panel>
|
||||
|
||||
<Panel>
|
||||
<Panel noPadding>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
data={data.list}
|
||||
pagination={{
|
||||
current: data.pagination.page,
|
||||
sizeCanChange: true,
|
||||
showTotal: true,
|
||||
total: data.pagination.total,
|
||||
pageSize: data.pagination.pageSize,
|
||||
onChange: (page, pageSize) => changePage(page, pageSize),
|
||||
@@ -145,11 +148,16 @@ export function DramaList() {
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 120,
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Button type="text" onClick={() => navigate(`/dramas/${record.id}`)}>
|
||||
详情
|
||||
</Button>
|
||||
<Space>
|
||||
<Button type="text" onClick={() => navigate(`/dramas/${record.id}`)}>
|
||||
详情
|
||||
</Button>
|
||||
<Button type="text" status="danger" icon={<IconDelete />} onClick={() => deleteDrama(record)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
@@ -157,17 +165,23 @@ 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 layout="vertical" onSubmit={createDrama} initialValues={{ status: "draft", isRecommended: false, sortOrder: 0, totalEpisodes: 1, channelId: defaultChannel?.id }}>
|
||||
<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="请输入类型,如复仇、爱情、动作等" />
|
||||
<Col span={8}>
|
||||
<Form.Item label="频道" field="channelId">
|
||||
<Select placeholder="默认频道" allowClear>
|
||||
{channels.map((channel) => (
|
||||
<Select.Option key={channel.id} value={channel.id}>
|
||||
{channel.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Col span={8}>
|
||||
<Form.Item label="状态" field="status">
|
||||
<Select placeholder="请选择状态">
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
@@ -178,6 +192,11 @@ export function DramaList() {
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="类型" field="type">
|
||||
<Input placeholder="请输入类型,如复仇、爱情、动作等" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item label="封面地址" field="coverUrl" rules={[{ required: true }]}>
|
||||
<Input placeholder="请输入封面图片 URL" />
|
||||
|
||||
@@ -7,6 +7,7 @@ type DramaFormValues = Partial<
|
||||
| "type"
|
||||
| "status"
|
||||
| "coverUrl"
|
||||
| "channelId"
|
||||
| "description"
|
||||
| "tags"
|
||||
| "region"
|
||||
@@ -55,6 +56,7 @@ export function toDramaPayload(values: DramaFormValues): DramaFormValues {
|
||||
type: normalizeOptionalString(values.type),
|
||||
status: values.status,
|
||||
coverUrl: normalizeOptionalString(values.coverUrl),
|
||||
channelId: normalizePositiveInteger(values.channelId),
|
||||
description: normalizeOptionalString(values.description),
|
||||
tags: values.tags?.filter(Boolean),
|
||||
region: normalizeOptionalString(values.region),
|
||||
|
||||
@@ -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 = {
|
||||
value: string;
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { Card } from '@arco-design/web-react';
|
||||
import { logApi } from '../api';
|
||||
import { usePageData } from '../hooks/usePageData';
|
||||
import { Panel } from '../components/Panel';
|
||||
import { RequestLogTable } from '../components/RequestLogTable';
|
||||
import type { RequestLog } from './interface';
|
||||
|
||||
function LogsPage() {
|
||||
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;
|
||||
|
||||
@@ -5,9 +5,9 @@ import type { LoginPanelProps } from "../interface";
|
||||
export const LoginPanel = ({ loading, onSubmit }: LoginPanelProps) => {
|
||||
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="w-full max-w-[420px] rounded-2xl border border-line/60 bg-white p-9 shadow-login">
|
||||
<div className="mb-8 flex items-center flex-col text-center gap-3.5">
|
||||
<h2 className="mb-1 w-full mt-0 text-2xl font-semibold text-ink">后台登录</h2>
|
||||
<div className="w-full max-w-[420px] rounded-2xl border border-line/40 bg-white p-10 shadow-login">
|
||||
<div className="mb-8 flex flex-col items-center text-center">
|
||||
<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>
|
||||
</div>
|
||||
<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 }]}>
|
||||
<Input.Password prefix={<IconLock className="text-muted" />} placeholder="请输入密码" size="large" />
|
||||
</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>
|
||||
</Form>
|
||||
|
||||
@@ -2,8 +2,8 @@ export function LoginVisual() {
|
||||
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 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="text-white/90">cth-tk-admin</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 text-[15px]">cth-tk-admin</span>
|
||||
</div>
|
||||
<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>
|
||||
@@ -11,15 +11,15 @@ export function LoginVisual() {
|
||||
<p className="m-0 max-w-[560px] text-base leading-[1.8] text-white/70">集中处理短剧内容、剧集发布、订单支付、播放日志和系统权限,帮助运营人员稳定管理短剧业务。</p>
|
||||
</div>
|
||||
<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>
|
||||
<span className="text-[13px] text-white/65">短剧与剧集管理</span>
|
||||
</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>
|
||||
<span className="text-[13px] text-white/65">角色化后台授权</span>
|
||||
</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>
|
||||
<span className="text-[13px] text-white/65">请求与播放追踪</span>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { personnelApi, roleApi } from '../api';
|
||||
import { usePageData } from '../hooks/usePageData';
|
||||
import { Panel } from '../components/Panel';
|
||||
import type { AdminUser, CreatePersonnelFormValues, Role } from './interface';
|
||||
|
||||
function PersonnelPage() {
|
||||
@@ -65,7 +66,7 @@ function PersonnelPage() {
|
||||
));
|
||||
|
||||
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={[
|
||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||
{ title: '用户名', dataIndex: 'username' },
|
||||
@@ -129,7 +130,7 @@ function PersonnelPage() {
|
||||
{roleOptions}
|
||||
</Select>
|
||||
</Modal>
|
||||
</Card>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export function ProfileInfoList({ profile }: ProfileInfoListProps) {
|
||||
|
||||
function ProfileInfoItem({ label, value }: ProfileInfoItemProps) {
|
||||
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>
|
||||
<strong className="break-words text-[15px] font-semibold text-ink">{value}</strong>
|
||||
</div>
|
||||
|
||||
@@ -3,12 +3,12 @@ import type { ProfileSummaryProps } from "../interface";
|
||||
|
||||
export function ProfileSummary({ profile }: ProfileSummaryProps) {
|
||||
return (
|
||||
<div className="mb-6 flex items-center gap-4 rounded-xl bg-gradient-to-br from-brand/5 to-brand/10 p-5">
|
||||
<Avatar size={60} className="!bg-brand !text-white !text-xl !font-bold !shadow-lg !shadow-brand/30">
|
||||
<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={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"}
|
||||
</Avatar>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 { authApi } from '../api';
|
||||
import { Panel } from '../components/Panel';
|
||||
import { ProfileInfoList } from './components/ProfileInfoList';
|
||||
import { ProfileSummary } from './components/ProfileSummary';
|
||||
import type { AdminProfile, ChangePasswordFormValues, ProfileFormValues } from './interface';
|
||||
@@ -66,12 +67,18 @@ function ProfilePage() {
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<div className="grid items-start gap-4 min-[901px]:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<Card className="section-card min-[901px]:row-span-2" title="个人中心" loading={loading}>
|
||||
<ProfileSummary profile={profile} />
|
||||
<ProfileInfoList profile={profile} />
|
||||
</Card>
|
||||
<Card className="section-card" title="修改个人信息">
|
||||
<div className="grid items-start gap-5 min-[901px]:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<Panel title="个人中心" className="min-[901px]:row-span-2">
|
||||
{loading ? (
|
||||
<div className="py-12 text-center text-muted">加载中...</div>
|
||||
) : (
|
||||
<>
|
||||
<ProfileSummary profile={profile} />
|
||||
<ProfileInfoList profile={profile} />
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
<Panel title="修改个人信息">
|
||||
<Form form={form} layout="vertical" onSubmit={updateProfile}>
|
||||
<Form.Item label="姓名" field="nickname">
|
||||
<Input placeholder="请输入姓名" />
|
||||
@@ -81,8 +88,8 @@ function ProfilePage() {
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={saving}>保存修改</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
<Card className="section-card" title="修改密码">
|
||||
</Panel>
|
||||
<Panel title="修改密码">
|
||||
<Form form={passwordForm} layout="vertical" onSubmit={changePassword}>
|
||||
<Form.Item label="旧密码" field="oldPassword" rules={[{ required: true }]}>
|
||||
<Input.Password placeholder="请输入旧密码" />
|
||||
@@ -95,7 +102,7 @@ function ProfilePage() {
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={changingPassword}>确认修改</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ const systemPermissionModules: PermissionModuleConfig[] = [
|
||||
|
||||
const businessPermissionModules: PermissionModuleConfig[] = [
|
||||
{ prefix: "drama", title: "短剧管理", subject: "短剧" },
|
||||
{ prefix: "channel", title: "频道管理", subject: "频道" },
|
||||
{ prefix: "episode", title: "剧集管理", subject: "剧集" },
|
||||
{ prefix: "media", title: "媒体管理", subject: "媒体" },
|
||||
{ prefix: "payment", title: "支付管理", subject: "支付" },
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { roleApi } from "../api";
|
||||
import { usePageData } from "../hooks/usePageData";
|
||||
import { Panel } from "../components/Panel";
|
||||
import { PermissionTreePicker } from "./components/PermissionTreePicker";
|
||||
import type { CreateRoleFormValues, Permission, Role } from "./interface";
|
||||
|
||||
@@ -61,14 +62,14 @@ function RolesPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="section-card"
|
||||
<Panel
|
||||
title="角色管理"
|
||||
extra={
|
||||
<Button type="primary" icon={<IconPlus />} onClick={openCreateRole}>
|
||||
新增角色
|
||||
</Button>
|
||||
}
|
||||
noPadding
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
@@ -137,7 +138,7 @@ function RolesPage() {
|
||||
>
|
||||
<PermissionTreePicker permissions={permissions.data.list} value={selectedPermissionIds} onChange={setSelectedPermissionIds} />
|
||||
</Modal>
|
||||
</Card>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { usePageData } from '../hooks/usePageData';
|
||||
import { Panel } from '../components/Panel';
|
||||
import type { AppUser } from './interface';
|
||||
|
||||
function UsersPage() {
|
||||
@@ -13,20 +14,15 @@ function UsersPage() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Panel title="小程序用户管理" 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={[
|
||||
{ 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: '状态', 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> },
|
||||
]} />
|
||||
</Card>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
18
src/api/channels.ts
Normal file
18
src/api/channels.ts
Normal 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}`);
|
||||
}
|
||||
@@ -40,6 +40,10 @@ export async function updateDrama(
|
||||
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(
|
||||
episodeId: number,
|
||||
payload: UpdateEpisodePayload,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as authApi from "./auth";
|
||||
export * as dataApi from "./data";
|
||||
export * as dramaApi from "./dramas";
|
||||
export * as channelApi from "./channels";
|
||||
export * as logApi from "./logs";
|
||||
export * as mediaApi from "./media";
|
||||
export * as personnelApi from "./personnel";
|
||||
|
||||
@@ -45,6 +45,8 @@ export type Drama = {
|
||||
type: string;
|
||||
status: string;
|
||||
coverUrl: string;
|
||||
channelId?: number;
|
||||
channelName?: string;
|
||||
isRecommended: boolean;
|
||||
totalEpisodes?: number;
|
||||
publishedEpisodes?: number;
|
||||
@@ -80,9 +82,27 @@ export type Episode = {
|
||||
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">> & {
|
||||
status?: string;
|
||||
|
||||
@@ -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 { Panel } from "../Panel";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export function NoAccessPage() {
|
||||
return (
|
||||
<Card className="section-card" title="暂无权限">
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-warning/10">
|
||||
<Panel title="暂无权限">
|
||||
<div className="flex flex-col items-center py-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" />
|
||||
</div>
|
||||
<Text type="secondary" className="text-center">当前账号没有访问该功能的权限,请联系系统管理员分配角色。</Text>
|
||||
<Text type="secondary" className="text-center text-[14px]">当前账号没有访问该功能的权限,请联系系统管理员分配角色。</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,18 +5,19 @@ type PanelProps = {
|
||||
extra?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
noPadding?: boolean;
|
||||
};
|
||||
|
||||
export function Panel({ title, extra, children, className = "" }: PanelProps) {
|
||||
export function Panel({ title, extra, children, className = "", noPadding = false }: PanelProps) {
|
||||
return (
|
||||
<div className={`rounded-xl border border-line/60 bg-white shadow-sm ${className}`}>
|
||||
<div className={`section-card ${className}`}>
|
||||
{(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>}
|
||||
{extra && <div>{extra}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-5">{children}</div>
|
||||
<div className={noPadding ? "" : "p-6"}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@ export function RequestLogTable({ data, loading }: RequestLogTableProps) {
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ 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: "statusCode", width: 100, render: (value) => <Tag color={value < 400 ? "green" : "red"}>{value}</Tag> },
|
||||
{ title: "耗时", dataIndex: "costMs", width: 100, render: (value) => String(value) + "ms" },
|
||||
{ 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) => <span className={Number(value) > 1000 ? 'text-danger font-medium' : ''}>{value}ms</span> },
|
||||
{ title: "消息", dataIndex: "message", width: 180 },
|
||||
]}
|
||||
/>
|
||||
|
||||
202
src/index.css
202
src/index.css
@@ -3,10 +3,6 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply transition-colors duration-200;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@apply w-1.5 h-1.5;
|
||||
}
|
||||
@@ -18,15 +14,23 @@
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply rounded-full bg-line hover:bg-muted;
|
||||
}
|
||||
|
||||
::selection {
|
||||
@apply bg-brand/15 text-brand;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.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 {
|
||||
@apply flex flex-col gap-4;
|
||||
@apply flex flex-col gap-5;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
@@ -34,7 +38,7 @@
|
||||
}
|
||||
|
||||
.form-label {
|
||||
@apply mb-2 text-sm font-medium text-[#4e5969];
|
||||
@apply mb-2 text-sm font-medium text-ink;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
@@ -42,10 +46,11 @@
|
||||
}
|
||||
|
||||
.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 {
|
||||
transition: width 0.2s cubic-bezier(0.2, 0, 0, 1) !important;
|
||||
}
|
||||
@@ -54,12 +59,56 @@
|
||||
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 {
|
||||
border-radius: 0.75rem !important;
|
||||
border: 1px solid rgba(229, 230, 235, 0.6) !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 {
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
@@ -69,20 +118,76 @@
|
||||
background-color: #fafbfc !important;
|
||||
font-weight: 600 !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 {
|
||||
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 {
|
||||
box-shadow: 0 2px 6px rgba(22, 93, 251, 0.3) !important;
|
||||
}
|
||||
|
||||
.arco-btn-primary:hover {
|
||||
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 {
|
||||
border-radius: 0.75rem !important;
|
||||
overflow: hidden;
|
||||
@@ -93,13 +198,24 @@
|
||||
padding: 16px 24px !important;
|
||||
}
|
||||
|
||||
.arco-modal-title {
|
||||
font-weight: 600 !important;
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.arco-modal-body {
|
||||
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 {
|
||||
border-radius: 0.5rem !important;
|
||||
transition: all 0.2s !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
|
||||
.arco-input-wrapper:hover {
|
||||
@@ -113,9 +229,77 @@
|
||||
|
||||
.arco-select-view {
|
||||
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 {
|
||||
font-weight: 500 !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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Card } from '@arco-design/web-react';
|
||||
import { Panel } from '../components/Panel';
|
||||
import { hasPermissions } from '../utils/permission';
|
||||
import { NoAccessPage } from '../components/NoAccessPage';
|
||||
import type { PermissionRouteProps } from './interface';
|
||||
@@ -9,7 +9,13 @@ export function PermissionRoute({
|
||||
children,
|
||||
}: PermissionRouteProps) {
|
||||
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)) {
|
||||
return <NoAccessPage />;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { filterMenuItems, findCurrentMenu, menuItems, renderMenuItem } from "./m
|
||||
import { PermissionRoute } from "./PermissionRoute";
|
||||
import DashboardPage from "../Dashboard";
|
||||
import DramasPage from "../Drama";
|
||||
import ChannelPage from "../Channel";
|
||||
import DataPage from "../Data";
|
||||
import UsersPage from "../User";
|
||||
import RolesPage from "../Role";
|
||||
@@ -49,11 +50,18 @@ export function AdminLayout() {
|
||||
|
||||
return (
|
||||
<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>
|
||||
{!collapsed && <span className="font-semibold text-ink">TK短剧管理后台</span>}
|
||||
<Sider
|
||||
className="border-r border-line/40 bg-gradient-to-b from-white to-panel"
|
||||
width={224}
|
||||
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 className="mx-3 mb-2 h-px bg-line/50" />
|
||||
<Menu
|
||||
selectedKeys={[current?.item.key || "/dashboard"]}
|
||||
openKeys={openMenuKeys}
|
||||
@@ -65,39 +73,39 @@ export function AdminLayout() {
|
||||
</Menu>
|
||||
</Sider>
|
||||
<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>
|
||||
<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 size={12}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? <IconMenuUnfold /> : <IconMenuFold />}
|
||||
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">
|
||||
<strong className="text-[15px] text-ink">{current?.item.label || "工作台"}</strong>
|
||||
<div className="flex items-center gap-2">
|
||||
<strong className="text-[15px] font-semibold text-ink">{current?.item.label || "工作台"}</strong>
|
||||
</div>
|
||||
</Space>
|
||||
<Dropdown
|
||||
position="br"
|
||||
trigger="click"
|
||||
droplist={
|
||||
<Menu className="!rounded-lg !border-line/60 !shadow-lg">
|
||||
<Menu.Item key="profile" onClick={() => navigate("/profile")} className="!rounded-md">
|
||||
<Menu className="!rounded-xl !border-line/40 !shadow-card-lg !p-1.5">
|
||||
<Menu.Item key="profile" onClick={() => navigate("/profile")} className="!rounded-lg !h-9">
|
||||
个人中心
|
||||
</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>
|
||||
}
|
||||
>
|
||||
<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">
|
||||
<Avatar size={30} className="!bg-brand !text-white">{profile?.username?.slice(0, 1).toUpperCase() || "A"}</Avatar>
|
||||
<Text className="!text-[13px]">{profile?.nickname || profile?.username || "管理员"}</Text>
|
||||
<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={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] !font-medium">{profile?.nickname || profile?.username || "管理员"}</Text>
|
||||
</button>
|
||||
</Dropdown>
|
||||
</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>
|
||||
<Route path="/dashboard" element={<DashboardPage profile={profile} />} />
|
||||
<Route
|
||||
@@ -116,6 +124,14 @@ export function AdminLayout() {
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/channels"
|
||||
element={
|
||||
<PermissionRoute profile={profile} requiredPermissions={["channel:read"]}>
|
||||
<ChannelPage />
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/data"
|
||||
element={
|
||||
|
||||
@@ -15,6 +15,7 @@ export const SYSTEM_MENU_KEY = '/system';
|
||||
export const menuItems: MenuConfig[] = [
|
||||
{ key: '/dashboard', label: '工作台', icon: <IconDashboard /> },
|
||||
{ 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: '/users', label: '用户管理', icon: <IconUser />, requiredPermissions: ['user:read'] },
|
||||
{
|
||||
|
||||
@@ -16,8 +16,11 @@ export default {
|
||||
brand: '#165dff',
|
||||
'brand-light': '#e8f3ff',
|
||||
success: '#00b42a',
|
||||
'success-light': '#e8ffea',
|
||||
warning: '#ff7d00',
|
||||
'warning-light': '#fff7e8',
|
||||
danger: '#f53f3f',
|
||||
'danger-light': '#fff1f0',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
@@ -34,10 +37,25 @@ export default {
|
||||
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-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: {
|
||||
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: [],
|
||||
|
||||
Reference in New Issue
Block a user