From e830f5a7332d4ef97a9ce7a925fd14bedae309c0 Mon Sep 17 00:00:00 2001 From: zhxiao1124 Date: Fri, 3 Jul 2026 15:01:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=A2=91=E9=81=93?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=95=B4=E4=BD=93UI=E4=B8=8E=E4=BA=A4=E4=BA=92=E4=BD=93?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交完成了以下核心变更: 1. 新增完整的频道管理模块,包括频道增删改查API、页面组件与权限配置 2. 为短剧功能添加频道关联字段,支持将短剧绑定到指定频道 3. 全局UI优化:统一使用Panel组件重构页面布局,统一样式规范与交互细节 4. 表格与日志组件优化:新增请求方法颜色标记、耗时高亮、状态码分级展示 5. 登录页、个人中心、布局等页面的视觉升级与交互优化 6. 完善全局TailwindCSS样式与组件样式自定义 --- src/Channel/index.tsx | 179 ++++++++++++++++ src/Dashboard/index.tsx | 20 +- src/Data/index.tsx | 19 +- src/Drama/components/DramaDetail.tsx | 64 +++--- src/Drama/components/DramaList.tsx | 101 ++++++---- src/Drama/formPayload.ts | 2 + src/Drama/interface.ts | 4 +- src/Log/index.tsx | 10 +- src/Login/components/LoginPanel.tsx | 8 +- src/Login/components/LoginVisual.tsx | 10 +- src/Personnel/index.tsx | 7 +- src/Profile/components/ProfileInfoList.tsx | 2 +- src/Profile/components/ProfileSummary.tsx | 6 +- src/Profile/index.tsx | 27 ++- src/Role/components/PermissionTreePicker.tsx | 1 + src/Role/index.tsx | 9 +- src/User/index.tsx | 14 +- src/api/channels.ts | 18 ++ src/api/dramas.ts | 4 + src/api/index.ts | 1 + src/api/interface.ts | 24 ++- src/components/NoAccessPage/index.tsx | 13 +- src/components/Panel/index.tsx | 9 +- src/components/RequestLogTable/index.tsx | 6 +- src/index.css | 202 ++++++++++++++++++- src/layout/PermissionRoute.tsx | 10 +- src/layout/index.tsx | 48 +++-- src/layout/menu.tsx | 1 + tailwind.config.js | 18 ++ 29 files changed, 667 insertions(+), 170 deletions(-) create mode 100644 src/Channel/index.tsx create mode 100644 src/api/channels.ts diff --git a/src/Channel/index.tsx b/src/Channel/index.tsx new file mode 100644 index 0000000..0e7a08d --- /dev/null +++ b/src/Channel/index.tsx @@ -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 = { + active: "启用", + disabled: "停用", +}; + +type ChannelFormValues = CreateChannelPayload; + +export default function ChannelPage() { + const { data, loading, reload, changePage } = usePageData(channelApi.listChannels, INITIAL_QUERY); + const [createVisible, setCreateVisible] = useState(false); + const [editingChannel, setEditingChannel] = useState(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, initialValues: ChannelFormValues) => ( +
+ + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ ); + + return ( +
+ } onClick={() => setCreateVisible(true)}>新增频道} noPadding> + 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 ? : ), + }, + { + title: "状态", + dataIndex: "status", + width: 100, + render: (status) => {CHANNEL_STATUS_LABEL[String(status)] || String(status)}, + }, + { + title: "操作", + width: 180, + render: (_, record) => ( + + + + + ), + }, + ]} + /> + + + setCreateVisible(false)} footer={null} style={{ width: 640 }}> + {renderForm(createChannel, { sortOrder: 0, status: "active", isDefault: false })} + + + 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, + })} + + + ); +} diff --git a/src/Dashboard/index.tsx b/src/Dashboard/index.tsx index 9f35676..ce5fec8 100644 --- a/src/Dashboard/index.tsx +++ b/src/Dashboard/index.tsx @@ -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 = { function MetricCard({ label, value, icon }: MetricCardProps & { icon: string }) { return ( -
-
-
+
+
+
{METRIC_ICONS[icon]}
{label}
-
{value}
+
{value}
); } @@ -45,20 +45,20 @@ function DashboardPage({ profile }: DashboardPageProps) { const hasAnyDataModule = canReadDramas || canReadUsers || canReadLogs; return ( - <> -
+
+
{canReadDramas && } {canReadUsers && } {canReadLogs && }
{canReadLogs ? ( - + - + ) : null} {!hasAnyDataModule ? : null} - +
); } diff --git a/src/Data/index.tsx b/src/Data/index.tsx index f951ac4..ba6b7cc 100644 --- a/src/Data/index.tsx +++ b/src/Data/index.tsx @@ -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 (
- } loading={syncing} onClick={syncData}>同步 TikTok 数据}> + } loading={syncing} onClick={syncData}>同步 TikTok 数据}>
@@ -51,9 +52,9 @@ function DataPage() { - + - +
`${Math.round(Number(value) * 100)}%` }, { title: '同步时间', dataIndex: 'syncedAt', width: 220 }, ]} /> - + - +
- + - +
- + ); } diff --git a/src/Drama/components/DramaDetail.tsx b/src/Drama/components/DramaDetail.tsx index 160498e..0d34720 100644 --- a/src/Drama/components/DramaDetail.tsx +++ b/src/Drama/components/DramaDetail.tsx @@ -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(null); const [editVisible, setEditVisible] = useState(false); const [updatingDrama, setUpdatingDrama] = useState(false); + const [channels, setChannels] = useState([]); 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 (
- } + noPadding > - + {drama && ( 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, }} > -
- - + + + - + + + + + + @@ -241,17 +260,12 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) { - + - - - - - - + diff --git a/src/Drama/components/DramaList.tsx b/src/Drama/components/DramaList.tsx index bc76e58..d84f4d7 100644 --- a/src/Drama/components/DramaList.tsx +++ b/src/Drama/components/DramaList.tsx @@ -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(undefined); const [filterType, setFilterType] = useState(""); + const [channels, setChannels] = useState([]); + 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 (
- - + } onClick={() => setVisible(true)}>新增短剧}> +
-
短剧标题
- +
短剧标题
+ -
状态
- {STATUS_OPTIONS.map((option) => ( {option.label} @@ -88,16 +97,11 @@ export function DramaList() { -
类型
- +
类型
+ -
 
+
操作
- - +
changePage(page, pageSize), @@ -145,11 +148,16 @@ export function DramaList() { }, { title: "操作", - width: 120, + width: 180, render: (_, record) => ( - + + + + ), }, ]} @@ -157,17 +165,23 @@ export function DramaList() { setVisible(false)} footer={null} style={{ width: 640 }}> -
+ -
- - + + + - + + + + + + diff --git a/src/Drama/formPayload.ts b/src/Drama/formPayload.ts index 6efc7f4..012096c 100644 --- a/src/Drama/formPayload.ts +++ b/src/Drama/formPayload.ts @@ -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), diff --git a/src/Drama/interface.ts b/src/Drama/interface.ts index 3763359..abb75d5 100644 --- a/src/Drama/interface.ts +++ b/src/Drama/interface.ts @@ -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; diff --git a/src/Log/index.tsx b/src/Log/index.tsx index 29aee8b..04e1fc5 100644 --- a/src/Log/index.tsx +++ b/src/Log/index.tsx @@ -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(logApi.listRequestLogs, { page: 1, pageSize: 20 }); - return ; + return ( +
+ + + +
+ ); } export default LogsPage; diff --git a/src/Login/components/LoginPanel.tsx b/src/Login/components/LoginPanel.tsx index 23ae445..477ac88 100644 --- a/src/Login/components/LoginPanel.tsx +++ b/src/Login/components/LoginPanel.tsx @@ -5,9 +5,9 @@ import type { LoginPanelProps } from "../interface"; export const LoginPanel = ({ loading, onSubmit }: LoginPanelProps) => { return (
-
-
-

后台登录

+
+
+

后台登录

使用管理员账号进入控制台

@@ -17,7 +17,7 @@ export const LoginPanel = ({ loading, onSubmit }: LoginPanelProps) => { } placeholder="请输入密码" size="large" /> - diff --git a/src/Login/components/LoginVisual.tsx b/src/Login/components/LoginVisual.tsx index 98efef4..aec97dc 100644 --- a/src/Login/components/LoginVisual.tsx +++ b/src/Login/components/LoginVisual.tsx @@ -2,8 +2,8 @@ export function LoginVisual() { return (
- CT - cth-tk-admin + CT + cth-tk-admin
TikTok 短剧运营后台 @@ -11,15 +11,15 @@ export function LoginVisual() {

集中处理短剧内容、剧集发布、订单支付、播放日志和系统权限,帮助运营人员稳定管理短剧业务。

-
+
内容 短剧与剧集管理
-
+
权限 角色化后台授权
-
+
日志 请求与播放追踪
diff --git a/src/Personnel/index.tsx b/src/Personnel/index.tsx index 05b3db2..b2921f3 100644 --- a/src/Personnel/index.tsx +++ b/src/Personnel/index.tsx @@ -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 ( - } onClick={openCreate}>新增人员}> + } onClick={openCreate}>新增人员} noPadding>
- + ); } diff --git a/src/Profile/components/ProfileInfoList.tsx b/src/Profile/components/ProfileInfoList.tsx index 626dfb7..89f301d 100644 --- a/src/Profile/components/ProfileInfoList.tsx +++ b/src/Profile/components/ProfileInfoList.tsx @@ -13,7 +13,7 @@ export function ProfileInfoList({ profile }: ProfileInfoListProps) { function ProfileInfoItem({ label, value }: ProfileInfoItemProps) { return ( -
+
{label} {value}
diff --git a/src/Profile/components/ProfileSummary.tsx b/src/Profile/components/ProfileSummary.tsx index a414a24..016f58c 100644 --- a/src/Profile/components/ProfileSummary.tsx +++ b/src/Profile/components/ProfileSummary.tsx @@ -3,12 +3,12 @@ import type { ProfileSummaryProps } from "../interface"; export function ProfileSummary({ profile }: ProfileSummaryProps) { return ( -
- +
+ {profile?.username?.slice(0, 1).toUpperCase() || "A"}
-

{profile?.nickname || profile?.username || "管理员"}

+

{profile?.nickname || profile?.username || "管理员"}

{profile?.email || "未设置邮箱"}

diff --git a/src/Profile/index.tsx b/src/Profile/index.tsx index 5513a32..7bfee04 100644 --- a/src/Profile/index.tsx +++ b/src/Profile/index.tsx @@ -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 (
-
- - - - - +
+ + {loading ? ( +
加载中...
+ ) : ( + <> + + + + )} +
+
@@ -81,8 +88,8 @@ function ProfilePage() { - - +
+
@@ -95,7 +102,7 @@ function ProfilePage() { - +
); diff --git a/src/Role/components/PermissionTreePicker.tsx b/src/Role/components/PermissionTreePicker.tsx index 4de7cb5..8fe12a2 100644 --- a/src/Role/components/PermissionTreePicker.tsx +++ b/src/Role/components/PermissionTreePicker.tsx @@ -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: "支付" }, diff --git a/src/Role/index.tsx b/src/Role/index.tsx index 98396f7..e0406c4 100644 --- a/src/Role/index.tsx +++ b/src/Role/index.tsx @@ -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 ( - } onClick={openCreateRole}> 新增角色 } + noPadding >
- + ); } diff --git a/src/User/index.tsx b/src/User/index.tsx index 9150ee6..9e2d053 100644 --- a/src/User/index.tsx +++ b/src/User/index.tsx @@ -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 (
- -
-

小程序用户管理

-
-
- +
{status} }, + { title: '状态', dataIndex: 'status', width: 120, render: (status) => {status === 'active' ? '启用' : '禁用'} }, { title: '操作', width: 160, render: (_, record: AppUser) => }, ]} /> - + ); } diff --git a/src/api/channels.ts b/src/api/channels.ts new file mode 100644 index 0000000..fcc57c8 --- /dev/null +++ b/src/api/channels.ts @@ -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('/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}`); +} diff --git a/src/api/dramas.ts b/src/api/dramas.ts index 1ae2fd8..e1d9d52 100644 --- a/src/api/dramas.ts +++ b/src/api/dramas.ts @@ -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, diff --git a/src/api/index.ts b/src/api/index.ts index 07b6f8e..c7d749a 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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"; diff --git a/src/api/interface.ts b/src/api/interface.ts index 64d0588..04870c3 100644 --- a/src/api/interface.ts +++ b/src/api/interface.ts @@ -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>; +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>; +export type ChannelQuery = PageQuery; + +export type CreateChannelPayload = Partial>; + +export type UpdateChannelPayload = Partial>; + +export type CreateDramaPayload = Partial>; + +export type UpdateDramaPayload = Partial>; export type UpdateEpisodePayload = Partial> & { status?: string; diff --git a/src/components/NoAccessPage/index.tsx b/src/components/NoAccessPage/index.tsx index 4fa1b02..7e9a353 100644 --- a/src/components/NoAccessPage/index.tsx +++ b/src/components/NoAccessPage/index.tsx @@ -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 ( - -
-
+ +
+
- 当前账号没有访问该功能的权限,请联系系统管理员分配角色。 + 当前账号没有访问该功能的权限,请联系系统管理员分配角色。
- +
); } diff --git a/src/components/Panel/index.tsx b/src/components/Panel/index.tsx index 5e80434..e98d998 100644 --- a/src/components/Panel/index.tsx +++ b/src/components/Panel/index.tsx @@ -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 ( -
+
{(title || extra) && ( -
+
{title &&

{title}

} {extra &&
{extra}
}
)} -
{children}
+
{children}
); } diff --git a/src/components/RequestLogTable/index.tsx b/src/components/RequestLogTable/index.tsx index 395afd6..ecc9fa7 100644 --- a/src/components/RequestLogTable/index.tsx +++ b/src/components/RequestLogTable/index.tsx @@ -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) => {value} }, + { title: "方法", dataIndex: "method", width: 100, render: (value) => {value} }, { title: "路径", dataIndex: "path", ellipsis: true }, - { title: "状态", dataIndex: "statusCode", width: 100, render: (value) => {value} }, - { title: "耗时", dataIndex: "costMs", width: 100, render: (value) => String(value) + "ms" }, + { title: "状态", dataIndex: "statusCode", width: 100, render: (value) => {value} }, + { title: "耗时", dataIndex: "costMs", width: 100, render: (value) => 1000 ? 'text-danger font-medium' : ''}>{value}ms }, { title: "消息", dataIndex: "message", width: 180 }, ]} /> diff --git a/src/index.css b/src/index.css index 0382008..647fd04 100644 --- a/src/index.css +++ b/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; } diff --git a/src/layout/PermissionRoute.tsx b/src/layout/PermissionRoute.tsx index 396620b..f1e115f 100644 --- a/src/layout/PermissionRoute.tsx +++ b/src/layout/PermissionRoute.tsx @@ -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 ; + return ( +
+ +
加载中...
+
+
+ ); } if (!hasPermissions(profile, requiredPermissions)) { return ; diff --git a/src/layout/index.tsx b/src/layout/index.tsx index be2343b..a7a19b0 100644 --- a/src/layout/index.tsx +++ b/src/layout/index.tsx @@ -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 ( - -
- TK - {!collapsed && TK短剧管理后台} + +
+ TK + {!collapsed && TK短剧管理后台}
+
-
- +
+
- + } /> } /> + + + + } + /> }, { key: '/dramas', label: '短剧管理', icon: , requiredPermissions: ['drama:read'] }, + { key: '/channels', label: '频道管理', icon: , requiredPermissions: ['channel:read'] }, { key: '/data', label: '数据管理', icon: , requiredPermissions: ['data:read'] }, { key: '/users', label: '用户管理', icon: , requiredPermissions: ['user:read'] }, { diff --git a/tailwind.config.js b/tailwind.config.js index 52d842f..31a13ce 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -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: [],