diff --git a/src/Dashboard/index.tsx b/src/Dashboard/index.tsx index 27a78ef..b410967 100644 --- a/src/Dashboard/index.tsx +++ b/src/Dashboard/index.tsx @@ -1,21 +1,22 @@ import { Card } from '@arco-design/web-react'; -import type { AdminProfile, AppUser, Drama, RequestLog } from '../api/types'; +import { dramaApi, logApi, userApi } from '../api'; import { usePageData } from '../hooks/usePageData'; import { hasPermissions } from '../utils/permission'; import { NoAccessPage } from '../components/NoAccessPage'; import { RequestLogTable } from '../components/RequestLogTable'; +import type { AppUser, DashboardPageProps, Drama, MetricCardProps, RequestLog } from './interface'; -function MetricCard({ label, value }: { label: string; value: number | string }) { +function MetricCard({ label, value }: MetricCardProps) { return
{label}
{value}
; } -function DashboardPage({ profile }: { profile: AdminProfile | null }) { +function DashboardPage({ profile }: DashboardPageProps) { const canReadDramas = hasPermissions(profile, ['drama:read']); const canReadUsers = hasPermissions(profile, ['user:read']); const canReadLogs = hasPermissions(profile, ['log:read']); - const dramas = usePageData('/api/admin/v1/dramas', canReadDramas); - const users = usePageData('/api/admin/v1/users', canReadUsers); - const logs = usePageData('/api/admin/v1/logs/requests', canReadLogs); + const dramas = usePageData(dramaApi.listDramas, canReadDramas); + const users = usePageData(userApi.listUsers, canReadUsers); + const logs = usePageData(logApi.listRequestLogs, canReadLogs); const hasAnyDataModule = canReadDramas || canReadUsers || canReadLogs; return ( diff --git a/src/Dashboard/interface.ts b/src/Dashboard/interface.ts new file mode 100644 index 0000000..e4f7a93 --- /dev/null +++ b/src/Dashboard/interface.ts @@ -0,0 +1,12 @@ +import type { AdminProfile, AppUser, Drama, RequestLog } from "../api"; + +export type { AdminProfile, AppUser, Drama, RequestLog }; + +export type DashboardPageProps = { + profile: AdminProfile | null; +}; + +export type MetricCardProps = { + label: string; + value: number | string; +}; diff --git a/src/Data/index.tsx b/src/Data/index.tsx index dd2c9b3..71138b0 100644 --- a/src/Data/index.tsx +++ b/src/Data/index.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from 'react'; import { Button, Card, Grid, Message, Statistic, Table } from '@arco-design/web-react'; import { IconRefresh } from '@arco-design/web-react/icon'; -import type { DataOverview, DramaMetric, EpisodeMetric, SyncJob } from '../api/types'; -import { getDataOverview, syncDataMetrics } from '../api/data'; +import { dataApi } from '../api'; import { usePageData } from '../hooks/usePageData'; +import type { DataOverview, DramaMetric, EpisodeMetric, SyncJob } from './interface'; const { Row, Col } = Grid; @@ -17,12 +17,12 @@ function DataPage() { totalComments: 0, }); const [syncing, setSyncing] = useState(false); - const dramas = usePageData('/api/admin/v1/data/dramas'); - const episodes = usePageData('/api/admin/v1/data/episodes'); - const jobs = usePageData('/api/admin/v1/data/sync-jobs'); + const dramas = usePageData(dataApi.listDramaMetrics); + const episodes = usePageData(dataApi.listEpisodeMetrics); + const jobs = usePageData(dataApi.listSyncJobs); const loadOverview = async () => { - setOverview(await getDataOverview()); + setOverview(await dataApi.getDataOverview()); }; useEffect(() => { @@ -32,7 +32,7 @@ function DataPage() { const syncData = async () => { setSyncing(true); try { - await syncDataMetrics(); + await dataApi.syncDataMetrics(); Message.success('数据同步完成'); await Promise.all([loadOverview(), dramas.reload(), episodes.reload(), jobs.reload()]); } finally { diff --git a/src/Data/interface.ts b/src/Data/interface.ts new file mode 100644 index 0000000..89c0a9d --- /dev/null +++ b/src/Data/interface.ts @@ -0,0 +1,3 @@ +import type { DataOverview, DramaMetric, EpisodeMetric, SyncJob } from "../api"; + +export type { DataOverview, DramaMetric, EpisodeMetric, SyncJob }; diff --git a/src/Drama/components/DramaDetail.tsx b/src/Drama/components/DramaDetail.tsx new file mode 100644 index 0000000..4f76680 --- /dev/null +++ b/src/Drama/components/DramaDetail.tsx @@ -0,0 +1,142 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Button, Card, Descriptions, InputNumber, Message, Space } from "@arco-design/web-react"; +import { IconArrowLeft, IconRefresh } from "@arco-design/web-react/icon"; +import { dramaApi, mediaApi } from "../../api"; +import type { Drama, DramaDetailPageProps, Episode } from "../interface"; +import { EpisodeTable } from "./EpisodeTable"; + +const PUBLISH_STATUS_LABEL: Record = { + draft: "草稿", + scheduled: "定时发布", + published: "已发布", + offline: "已下线", +}; + +export function DramaDetail({ dramaId }: DramaDetailPageProps) { + const navigate = useNavigate(); + const [drama, setDrama] = useState(null); + const [episodes, setEpisodes] = useState([]); + const [loading, setLoading] = useState(false); + const [episodeCount, setEpisodeCount] = useState(1); + const [uploadingEpisodeId, setUploadingEpisodeId] = useState(null); + + const loadDetail = async () => { + setLoading(true); + try { + const [dramaData, episodeList] = await Promise.all([dramaApi.getDrama(dramaId), dramaApi.getDramaEpisodes(dramaId)]); + setDrama(dramaData); + setEpisodeCount(dramaData.totalEpisodes || 1); + setEpisodes(episodeList); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void loadDetail(); + }, [dramaId]); + + const configureEpisodes = async () => { + await dramaApi.configureDramaEpisodes(dramaId, episodeCount); + Message.success("剧集数量已更新"); + await loadDetail(); + }; + + const updateDramaStatus = async (status: string) => { + await dramaApi.updateDrama(dramaId, { status }); + Message.success("短剧状态已更新"); + await loadDetail(); + }; + + const uploadEpisodeVideo = async (episode: Episode, file: File) => { + setUploadingEpisodeId(episode.id); + try { + const task = await mediaApi.createUploadTask({ + episodeId: episode.id, + fileName: file.name, + contentType: file.type || "video/mp4", + fileSize: file.size, + }); + await mediaApi.completeUploadTask(task.jobId); + await mediaApi.syncReviewStatus(); + Message.success("视频上传并审核通过"); + await loadDetail(); + } finally { + setUploadingEpisodeId(null); + } + }; + + const publishEpisode = async (episode: Episode) => { + await dramaApi.updateEpisode(episode.id, { + publishStatus: "published", + status: "published", + }); + Message.success("剧集已发布"); + await loadDetail(); + }; + + return ( +
+ + + + + + } + > + {drama && ( + + )} + + + + setEpisodeCount(Number(value) || 1)} /> + + + } + > + + +
+ ); +} diff --git a/src/Drama/components/DramaList.tsx b/src/Drama/components/DramaList.tsx new file mode 100644 index 0000000..492dc17 --- /dev/null +++ b/src/Drama/components/DramaList.tsx @@ -0,0 +1,164 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Button, Card, Form, Grid, Input, InputNumber, InputTag, Message, Modal, Select, Switch, Table, Tag } from "@arco-design/web-react"; +import { IconPlus } from "@arco-design/web-react/icon"; +import { dramaApi } from "../../api"; +import { usePageData } from "../../hooks/usePageData"; +import type { Drama, StatusOption } from "../interface"; + +const { Row, Col } = Grid; + +const STATUS_OPTIONS: StatusOption[] = [ + { value: "draft", label: "草稿" }, + { value: "scheduled", label: "定时发布" }, + { value: "published", label: "已发布" }, + { value: "offline", label: "已下线" }, +]; + +const PUBLISH_STATUS_LABEL: Record = { + draft: "草稿", + scheduled: "定时发布", + published: "已发布", + offline: "已下线", +}; + +export function DramaList() { + const navigate = useNavigate(); + const { data, loading, reload, changePage } = usePageData(dramaApi.listDramas); + const [visible, setVisible] = useState(false); + const [creating, setCreating] = useState(false); + + const createDrama = async (values: Partial) => { + setCreating(true); + try { + await dramaApi.createDrama(values); + Message.success("短剧创建成功"); + setVisible(false); + await reload(); + } finally { + setCreating(false); + } + }; + + return ( + } onClick={() => setVisible(true)}> + 新增短剧 + + } + > + changePage(page, pageSize), + }} + columns={[ + { title: "ID", dataIndex: "id", width: 80 }, + { title: "标题", dataIndex: "title" }, + { title: "地区", dataIndex: "region", width: 120 }, + { title: "类型", dataIndex: "type", width: 140 }, + { title: "总集数", dataIndex: "totalEpisodes", width: 100 }, + { title: "已发布", dataIndex: "publishedEpisodes", width: 100 }, + { + title: "状态", + dataIndex: "status", + width: 120, + render: (status) => {PUBLISH_STATUS_LABEL[String(status)] || String(status)}, + }, + { + title: "推荐", + dataIndex: "isRecommended", + width: 100, + render: (value) => (value ? : ), + }, + { + title: "操作", + width: 120, + render: (_, record) => ( + + ), + }, + ]} + /> + setVisible(false)} footer={null} style={{ width: 640 }}> +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + + + ); +} diff --git a/src/Drama/components/EpisodeTable.tsx b/src/Drama/components/EpisodeTable.tsx new file mode 100644 index 0000000..c2f9bba --- /dev/null +++ b/src/Drama/components/EpisodeTable.tsx @@ -0,0 +1,89 @@ +import { Button, Space, Table, Tag } from "@arco-design/web-react"; +import type { EpisodeTableProps } from "../interface"; + +const REVIEW_STATUS_LABEL: Record = { + not_submitted: "未提交", + pending: "待审核", + reviewing: "审核中", + approved: "已通过", + rejected: "已拒绝", +}; + +const PUBLISH_STATUS_LABEL: Record = { + draft: "草稿", + scheduled: "定时发布", + published: "已发布", + offline: "已下线", +}; + +export function EpisodeTable({ episodes, loading, uploadingEpisodeId, onUploadVideo, onPublishEpisode }: EpisodeTableProps) { + return ( +
value || "-", + }, + { + title: "尺寸", + width: 120, + render: (_, record) => (record.width && record.height ? `${record.width}x${record.height}` : "-"), + }, + { + title: "时长", + dataIndex: "durationSeconds", + width: 100, + render: (value) => (value ? `${value}s` : "-"), + }, + { + title: "审核状态", + dataIndex: "reviewStatus", + width: 120, + render: (status) => {REVIEW_STATUS_LABEL[String(status)] || String(status)}, + }, + { + title: "发布状态", + dataIndex: "publishStatus", + width: 120, + render: (status) => {PUBLISH_STATUS_LABEL[String(status)] || String(status)}, + }, + { + title: "操作", + width: 280, + render: (_, record) => ( + + + + + ), + }, + ]} + /> + ); +} diff --git a/src/Drama/index.tsx b/src/Drama/index.tsx index 49ea771..656786a 100644 --- a/src/Drama/index.tsx +++ b/src/Drama/index.tsx @@ -1,413 +1,15 @@ -import { useEffect, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; -import { - Button, - Card, - Descriptions, - Form, - Grid, - Input, - InputNumber, - InputTag, - Message, - Modal, - Select, - Space, - Switch, - Table, - Tag, -} from "@arco-design/web-react"; -import { IconArrowLeft, IconPlus, IconRefresh } from "@arco-design/web-react/icon"; -import type { Drama, Episode } from "../api/types"; -import { - configureDramaEpisodes, - createDrama as createDramaApi, - getDrama, - getDramaEpisodes, - updateDrama, - updateEpisode, -} from "../api/dramas"; -import { completeUploadTask, createUploadTask, syncReviewStatus } from "../api/media"; -import { usePageData } from "../hooks/usePageData"; - -const { Row, Col } = Grid; - -const STATUS_OPTIONS: { value: string; label: string }[] = [ - { value: "draft", label: "草稿" }, - { value: "scheduled", label: "定时发布" }, - { value: "published", label: "已发布" }, - { value: "offline", label: "已下线" }, -]; - -const REVIEW_STATUS_LABEL: Record = { - not_submitted: "未提交", - pending: "待审核", - reviewing: "审核中", - approved: "已通过", - rejected: "已拒绝", -}; - -const PUBLISH_STATUS_LABEL: Record = { - draft: "草稿", - scheduled: "定时发布", - published: "已发布", - offline: "已下线", -}; +import { useParams } from "react-router-dom"; +import { DramaDetail } from "./components/DramaDetail"; +import { DramaList } from "./components/DramaList"; function DramasPage() { const { id } = useParams(); if (id) { - return ; + return ; } - return ; -} - -function DramaListPage() { - const navigate = useNavigate(); - const { data, loading, reload, changePage } = usePageData("/api/admin/v1/dramas"); - const [visible, setVisible] = useState(false); - const [creating, setCreating] = useState(false); - - const createDrama = async (values: Partial) => { - setCreating(true); - try { - await createDramaApi(values); - Message.success("短剧创建成功"); - setVisible(false); - await reload(); - } finally { - setCreating(false); - } - }; - - return ( - } onClick={() => setVisible(true)}> - 新增短剧 - - } - > -
changePage(page, pageSize), - }} - columns={[ - { title: "ID", dataIndex: "id", width: 80 }, - { title: "标题", dataIndex: "title" }, - { title: "地区", dataIndex: "region", width: 120 }, - { title: "类型", dataIndex: "type", width: 140 }, - { title: "总集数", dataIndex: "totalEpisodes", width: 100 }, - { title: "已发布", dataIndex: "publishedEpisodes", width: 100 }, - { - title: "状态", - dataIndex: "status", - width: 120, - render: (status) => ( - {PUBLISH_STATUS_LABEL[String(status)] || String(status)} - ), - }, - { - title: "推荐", - dataIndex: "isRecommended", - width: 100, - render: (value) => (value ? : ), - }, - { - title: "操作", - width: 120, - render: (_, record) => ( - - ), - }, - ]} - /> - setVisible(false)} footer={null} style={{ width: 640 }}> -
- - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - -
- - - - ); -} - -function DramaDetailPage({ dramaId }: { dramaId: number }) { - const navigate = useNavigate(); - const [drama, setDrama] = useState(null); - const [episodes, setEpisodes] = useState([]); - const [loading, setLoading] = useState(false); - const [episodeCount, setEpisodeCount] = useState(1); - const [uploadingEpisodeId, setUploadingEpisodeId] = useState(null); - - const loadDetail = async () => { - setLoading(true); - try { - const [dramaData, episodeList] = await Promise.all([getDrama(dramaId), getDramaEpisodes(dramaId)]); - setDrama(dramaData); - setEpisodeCount(dramaData.totalEpisodes || 1); - setEpisodes(episodeList); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - void loadDetail(); - }, [dramaId]); - - const configureEpisodes = async () => { - await configureDramaEpisodes(dramaId, episodeCount); - Message.success("剧集数量已更新"); - await loadDetail(); - }; - - const updateDramaStatus = async (status: string) => { - await updateDrama(dramaId, { status }); - Message.success("短剧状态已更新"); - await loadDetail(); - }; - - const uploadEpisodeVideo = async (episode: Episode, file: File) => { - setUploadingEpisodeId(episode.id); - try { - const task = await createUploadTask({ - episodeId: episode.id, - fileName: file.name, - contentType: file.type || "video/mp4", - fileSize: file.size, - }); - await completeUploadTask(task.jobId); - await syncReviewStatus(); - Message.success("视频上传并审核通过"); - await loadDetail(); - } finally { - setUploadingEpisodeId(null); - } - }; - - const publishEpisode = async (episode: Episode) => { - await updateEpisode(episode.id, { - publishStatus: "published", - status: "published", - }); - Message.success("剧集已发布"); - await loadDetail(); - }; - - return ( -
- - - - - - } - > - {drama && ( - - )} - - - - setEpisodeCount(Number(value) || 1)} /> - - - } - > -
value || "-", - }, - { - title: "尺寸", - width: 120, - render: (_, record) => (record.width && record.height ? `${record.width}x${record.height}` : "-"), - }, - { - title: "时长", - dataIndex: "durationSeconds", - width: 100, - render: (value) => (value ? `${value}s` : "-"), - }, - { - title: "审核状态", - dataIndex: "reviewStatus", - width: 120, - render: (status) => ( - {REVIEW_STATUS_LABEL[String(status)] || String(status)} - ), - }, - { - title: "发布状态", - dataIndex: "publishStatus", - width: 120, - render: (status) => ( - {PUBLISH_STATUS_LABEL[String(status)] || String(status)} - ), - }, - { - title: "操作", - width: 280, - render: (_, record) => ( - - - - - ), - }, - ]} - /> - - - ); + return ; } export default DramasPage; diff --git a/src/Drama/interface.ts b/src/Drama/interface.ts new file mode 100644 index 0000000..0a614c7 --- /dev/null +++ b/src/Drama/interface.ts @@ -0,0 +1,20 @@ +import type { Drama, Episode } from "../api"; + +export type { Drama, Episode }; + +export type StatusOption = { + value: string; + label: string; +}; + +export type DramaDetailPageProps = { + dramaId: number; +}; + +export type EpisodeTableProps = { + episodes: Episode[]; + loading: boolean; + uploadingEpisodeId: number | null; + onUploadVideo: (episode: Episode, file: File) => void | Promise; + onPublishEpisode: (episode: Episode) => void | Promise; +}; diff --git a/src/Log/index.tsx b/src/Log/index.tsx index 8d027eb..dbdb362 100644 --- a/src/Log/index.tsx +++ b/src/Log/index.tsx @@ -1,10 +1,11 @@ import { Card } from '@arco-design/web-react'; -import type { RequestLog } from '../api/types'; +import { logApi } from '../api'; import { usePageData } from '../hooks/usePageData'; import { RequestLogTable } from '../components/RequestLogTable'; +import type { RequestLog } from './interface'; function LogsPage() { - const { data, loading } = usePageData('/api/admin/v1/logs/requests'); + const { data, loading } = usePageData(logApi.listRequestLogs); return ; } diff --git a/src/Log/interface.ts b/src/Log/interface.ts new file mode 100644 index 0000000..3126939 --- /dev/null +++ b/src/Log/interface.ts @@ -0,0 +1,3 @@ +import type { RequestLog } from "../api"; + +export type { RequestLog }; diff --git a/src/Login/components/LoginPanel.tsx b/src/Login/components/LoginPanel.tsx new file mode 100644 index 0000000..f156719 --- /dev/null +++ b/src/Login/components/LoginPanel.tsx @@ -0,0 +1,31 @@ +import { Button, Form, Input } from "@arco-design/web-react"; +import { IconLock, IconUser } from "@arco-design/web-react/icon"; +import type { LoginPanelProps } from "../interface"; + +export function LoginPanel({ loading, apiBaseUrl, onSubmit }: LoginPanelProps) { + return ( +
+
+
+ CT +
+

后台登录

+

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

+
+
+
+ + } placeholder="admin" /> + + + } placeholder="admin123" /> + + + +
API 服务:{apiBaseUrl}
+
+
+ ); +} diff --git a/src/Login/components/LoginVisual.tsx b/src/Login/components/LoginVisual.tsx new file mode 100644 index 0000000..3a56d93 --- /dev/null +++ b/src/Login/components/LoginVisual.tsx @@ -0,0 +1,29 @@ +export function LoginVisual() { + return ( +
+
+ CT + cth-tk-admin +
+
+ TikTok 短剧运营后台 +

CTH TikTok 短剧管理后台

+

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

+
+
+
+ 内容 + 短剧与剧集管理 +
+
+ 权限 + 角色化后台授权 +
+
+ 日志 + 请求与播放追踪 +
+
+
+ ); +} diff --git a/src/Login/index.tsx b/src/Login/index.tsx index 1f90f62..3bfeed2 100644 --- a/src/Login/index.tsx +++ b/src/Login/index.tsx @@ -1,19 +1,20 @@ import { useState } from 'react'; -import { Button, Form, Input, Message } from '@arco-design/web-react'; -import { IconLock, IconUser } from '@arco-design/web-react/icon'; +import { Message } from '@arco-design/web-react'; import { useNavigate } from 'react-router-dom'; -import { login } from '../api/auth'; -import { API_BASE_URL, TOKEN_KEY } from '../api/client'; +import { authApi } from '../api'; +import { LoginPanel } from './components/LoginPanel'; +import { LoginVisual } from './components/LoginVisual'; +import type { LoginFormValues } from './interface'; function LoginPage() { const navigate = useNavigate(); const [loading, setLoading] = useState(false); - const onSubmit = async (values: { username: string; password: string }) => { + const onSubmit = async (values: LoginFormValues) => { setLoading(true); try { - const data = await login(values); - localStorage.setItem(TOKEN_KEY, data.accessToken); + const data = await authApi.login(values); + authApi.saveAuthToken(data.accessToken); Message.success('登录成功'); navigate('/dashboard', { replace: true }); } finally { @@ -23,43 +24,8 @@ function LoginPage() { return (
-
-
- CT - cth-tk-admin -
-
- TikTok 短剧运营后台 -

CTH TikTok 短剧管理后台

-

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

-
-
-
内容短剧与剧集管理
-
权限角色化后台授权
-
日志请求与播放追踪
-
-
-
-
-
- CT -
-

后台登录

-

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

-
-
-
- - } placeholder="admin" /> - - - } placeholder="admin123" /> - - - -
API 服务:{API_BASE_URL}
-
-
+ +
); } diff --git a/src/Login/interface.ts b/src/Login/interface.ts new file mode 100644 index 0000000..0382854 --- /dev/null +++ b/src/Login/interface.ts @@ -0,0 +1,9 @@ +import type { LoginPayload } from "../api"; + +export type LoginFormValues = LoginPayload; + +export type LoginPanelProps = { + loading: boolean; + apiBaseUrl: string; + onSubmit: (values: LoginFormValues) => void | Promise; +}; diff --git a/src/Personnel/index.tsx b/src/Personnel/index.tsx index 41ddee3..ebf5a0c 100644 --- a/src/Personnel/index.tsx +++ b/src/Personnel/index.tsx @@ -1,13 +1,13 @@ import { useState } from 'react'; import { Button, Card, Form, Input, Message, Modal, Select, Space, Table, Tag } from '@arco-design/web-react'; import { IconPlus } from '@arco-design/web-react/icon'; -import type { AdminUser, Role } from '../api/types'; -import { createPersonnel as createPersonnelApi, updateAdminUserRoles, updateAdminUserStatus } from '../api/personnel'; +import { personnelApi, roleApi } from '../api'; import { usePageData } from '../hooks/usePageData'; +import type { AdminUser, CreatePersonnelFormValues, Role } from './interface'; function PersonnelPage() { - const { data, loading, reload, changePage } = usePageData('/api/admin/v1/admin-users'); - const roles = usePageData('/api/admin/v1/roles'); + const { data, loading, reload, changePage } = usePageData(personnelApi.listPersonnel); + const roles = usePageData(roleApi.listRoles); const [createForm] = Form.useForm(); const [createVisible, setCreateVisible] = useState(false); const [creating, setCreating] = useState(false); @@ -21,16 +21,10 @@ function PersonnelPage() { setCreateVisible(true); }; - const createPersonnel = async (values: { - username: string; - password: string; - nickname?: string; - email?: string; - roleIds?: number[]; - }) => { + const createPersonnel = async (values: CreatePersonnelFormValues) => { setCreating(true); try { - await createPersonnelApi(values); + await personnelApi.createPersonnel(values); Message.success('人员创建成功'); setCreateVisible(false); await reload(); @@ -51,7 +45,7 @@ function PersonnelPage() { } setAssigning(true); try { - await updateAdminUserRoles(selectedAdmin.id, selectedRoleIds); + await personnelApi.updateAdminUserRoles(selectedAdmin.id, selectedRoleIds); Message.success('角色分配已更新'); setAssignVisible(false); await reload(); @@ -61,7 +55,7 @@ function PersonnelPage() { }; const updateStatus = async (id: number, status: 'active' | 'disabled') => { - await updateAdminUserStatus(id, status); + await personnelApi.updateAdminUserStatus(id, status); Message.success('人员状态已更新'); await reload(); }; diff --git a/src/Personnel/interface.ts b/src/Personnel/interface.ts new file mode 100644 index 0000000..bf9cb2a --- /dev/null +++ b/src/Personnel/interface.ts @@ -0,0 +1,5 @@ +import type { AdminUser, CreatePersonnelPayload, Role } from "../api"; + +export type { AdminUser, Role }; + +export type CreatePersonnelFormValues = CreatePersonnelPayload; diff --git a/src/Profile/components/ProfileInfoList.tsx b/src/Profile/components/ProfileInfoList.tsx new file mode 100644 index 0000000..c541b9c --- /dev/null +++ b/src/Profile/components/ProfileInfoList.tsx @@ -0,0 +1,21 @@ +import type { ProfileInfoItemProps, ProfileInfoListProps } from "../interface"; + +export function ProfileInfoList({ profile }: ProfileInfoListProps) { + return ( +
+ + + role.name).join("、") || "-"} /> + +
+ ); +} + +function ProfileInfoItem({ label, value }: ProfileInfoItemProps) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/src/Profile/components/ProfileSummary.tsx b/src/Profile/components/ProfileSummary.tsx new file mode 100644 index 0000000..bc714fb --- /dev/null +++ b/src/Profile/components/ProfileSummary.tsx @@ -0,0 +1,14 @@ +import { Avatar } from "@arco-design/web-react"; +import type { ProfileSummaryProps } from "../interface"; + +export function ProfileSummary({ profile }: ProfileSummaryProps) { + return ( +
+ {profile?.username?.slice(0, 1).toUpperCase() || "A"} +
+

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

+

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

+
+
+ ); +} diff --git a/src/Profile/index.tsx b/src/Profile/index.tsx index b3f3b20..bcecd53 100644 --- a/src/Profile/index.tsx +++ b/src/Profile/index.tsx @@ -1,9 +1,10 @@ import { useEffect, useState } from 'react'; -import { Avatar, Button, Card, Form, Input, Message } from '@arco-design/web-react'; +import { Button, Card, Form, Input, Message } from '@arco-design/web-react'; import { useNavigate } from 'react-router-dom'; -import type { AdminProfile } from '../api/types'; -import { changePassword as changePasswordApi, getProfile, updateProfile as updateProfileApi } from '../api/auth'; -import { TOKEN_KEY } from '../api/client'; +import { authApi } from '../api'; +import { ProfileInfoList } from './components/ProfileInfoList'; +import { ProfileSummary } from './components/ProfileSummary'; +import type { AdminProfile, ChangePasswordFormValues, ProfileFormValues } from './interface'; function ProfilePage() { const navigate = useNavigate(); @@ -17,7 +18,7 @@ function ProfilePage() { const loadProfile = async () => { setLoading(true); try { - const data = await getProfile(); + const data = await authApi.getProfile(); setProfile(data); form.setFieldsValue({ nickname: data.nickname, @@ -32,10 +33,10 @@ function ProfilePage() { void loadProfile(); }, []); - const updateProfile = async (values: { nickname?: string; email?: string }) => { + const updateProfile = async (values: ProfileFormValues) => { setSaving(true); try { - const data = await updateProfileApi(values); + const data = await authApi.updateProfile(values); setProfile(data); Message.success('个人信息已更新'); } finally { @@ -43,20 +44,20 @@ function ProfilePage() { } }; - const changePassword = async (values: { oldPassword: string; newPassword: string; confirmPassword: string }) => { + const changePassword = async (values: ChangePasswordFormValues) => { if (values.newPassword !== values.confirmPassword) { Message.error('两次输入的新密码不一致'); return; } setChangingPassword(true); try { - await changePasswordApi({ + await authApi.changePassword({ oldPassword: values.oldPassword, newPassword: values.newPassword, }); Message.success('密码修改成功,请重新登录'); passwordForm.resetFields(); - localStorage.removeItem(TOKEN_KEY); + authApi.clearAuthToken(); navigate('/login', { replace: true }); } finally { setChangingPassword(false); @@ -66,19 +67,8 @@ function ProfilePage() { return (
-
- {profile?.username?.slice(0, 1).toUpperCase() || 'A'} -
-

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

-

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

-
-
-
-
用户名{profile?.username || '-'}
-
状态{profile?.status === 'active' ? '启用' : '禁用'}
-
角色{profile?.isSuperAdmin ? '超级管理员' : profile?.roles.map((role) => role.name).join('、') || '-'}
-
创建时间{profile?.createdAt || '-'}
-
+ +
diff --git a/src/Profile/interface.ts b/src/Profile/interface.ts new file mode 100644 index 0000000..71aeab7 --- /dev/null +++ b/src/Profile/interface.ts @@ -0,0 +1,22 @@ +import type { AdminProfile, ChangePasswordPayload, UpdateProfilePayload } from "../api"; + +export type { AdminProfile }; + +export type ProfileFormValues = UpdateProfilePayload; + +export type ChangePasswordFormValues = ChangePasswordPayload & { + confirmPassword: string; +}; + +export type ProfileSummaryProps = { + profile: AdminProfile | null; +}; + +export type ProfileInfoListProps = { + profile: AdminProfile | null; +}; + +export type ProfileInfoItemProps = { + label: string; + value: string; +}; diff --git a/src/Role/PermissionTreePicker.tsx b/src/Role/components/PermissionTreePicker.tsx similarity index 91% rename from src/Role/PermissionTreePicker.tsx rename to src/Role/components/PermissionTreePicker.tsx index 331ef69..18bf0fd 100644 --- a/src/Role/PermissionTreePicker.tsx +++ b/src/Role/components/PermissionTreePicker.tsx @@ -1,18 +1,6 @@ import { useMemo } from "react"; import { Tree } from "@arco-design/web-react"; -import type { Permission } from "../api/types"; - -type PermissionTreeNode = { - key: string; - title: string; - children?: PermissionTreeNode[]; -}; - -type PermissionModuleConfig = { - prefix: string; - title: string; - subject: string; -}; +import type { Permission, PermissionModuleConfig, PermissionTreeNode, PermissionTreePickerProps } from "../interface"; const systemPermissionModules: PermissionModuleConfig[] = [ { prefix: "permission", title: "权限管理", subject: "权限" }, @@ -123,15 +111,7 @@ function collectExpandedKeys(nodes: PermissionTreeNode[]) { return keys; } -export function PermissionTreePicker({ - permissions, - value, - onChange, -}: { - permissions: Permission[]; - value: number[]; - onChange: (permissionIds: number[]) => void; -}) { +export function PermissionTreePicker({ permissions, value, onChange }: PermissionTreePickerProps) { const treeData = useMemo(() => buildPermissionTree(permissions), [permissions]); const expandedKeys = useMemo(() => collectExpandedKeys(treeData), [treeData]); const checkedKeys = value.map((id) => permissionKey(id)); diff --git a/src/Role/index.tsx b/src/Role/index.tsx index 6b28075..ef4a13f 100644 --- a/src/Role/index.tsx +++ b/src/Role/index.tsx @@ -1,14 +1,14 @@ import { useState } from "react"; import { Button, Card, Form, Input, Message, Modal, Space, Table, Tag } from "@arco-design/web-react"; import { IconPlus } from "@arco-design/web-react/icon"; -import type { Permission, Role } from "../api/types"; -import { createRole as createRoleApi, updateRolePermissions } from "../api/roles"; +import { roleApi } from "../api"; import { usePageData } from "../hooks/usePageData"; -import { PermissionTreePicker } from "./PermissionTreePicker"; +import { PermissionTreePicker } from "./components/PermissionTreePicker"; +import type { CreateRoleFormValues, Permission, Role } from "./interface"; function RolesPage() { - const { data, loading, reload, changePage } = usePageData("/api/admin/v1/roles"); - const permissions = usePageData("/api/admin/v1/permissions"); + const { data, loading, reload, changePage } = usePageData(roleApi.listRoles); + const permissions = usePageData(roleApi.listPermissions); const [createForm] = Form.useForm(); const [createVisible, setCreateVisible] = useState(false); const [creatingRole, setCreatingRole] = useState(false); @@ -24,10 +24,10 @@ function RolesPage() { setCreateVisible(true); }; - const createRole = async (values: { code: string; name: string; description?: string }) => { + const createRole = async (values: CreateRoleFormValues) => { setCreatingRole(true); try { - await createRoleApi({ + await roleApi.createRole({ ...values, permissionIds: createPermissionIds, }); @@ -51,7 +51,7 @@ function RolesPage() { } setSavingPermissions(true); try { - await updateRolePermissions(selectedRole.id, selectedPermissionIds); + await roleApi.updateRolePermissions(selectedRole.id, selectedPermissionIds); Message.success("角色权限已更新"); setAssignmentVisible(false); await reload(); diff --git a/src/Role/interface.ts b/src/Role/interface.ts new file mode 100644 index 0000000..876683f --- /dev/null +++ b/src/Role/interface.ts @@ -0,0 +1,27 @@ +import type { Permission, Role } from "../api"; + +export type { Permission, Role }; + +export type CreateRoleFormValues = { + code: string; + name: string; + description?: string; +}; + +export type PermissionTreeNode = { + key: string; + title: string; + children?: PermissionTreeNode[]; +}; + +export type PermissionModuleConfig = { + prefix: string; + title: string; + subject: string; +}; + +export type PermissionTreePickerProps = { + permissions: Permission[]; + value: number[]; + onChange: (permissionIds: number[]) => void; +}; diff --git a/src/User/index.tsx b/src/User/index.tsx index 94e67f7..ed1b163 100644 --- a/src/User/index.tsx +++ b/src/User/index.tsx @@ -1,12 +1,12 @@ import { Button, Card, Message, Table, Tag } from '@arco-design/web-react'; -import type { AppUser } from '../api/types'; -import { updateUserStatus } from '../api/users'; +import { userApi } from '../api'; import { usePageData } from '../hooks/usePageData'; +import type { AppUser } from './interface'; function UsersPage() { - const { data, loading, reload, changePage } = usePageData('/api/admin/v1/users'); + const { data, loading, reload, changePage } = usePageData(userApi.listUsers); const update状态 = async (id: number, status: 'active' | 'disabled') => { - await updateUserStatus(id, status); + await userApi.updateUserStatus(id, status); Message.success('用户状态已更新'); await reload(); }; diff --git a/src/User/interface.ts b/src/User/interface.ts new file mode 100644 index 0000000..3fd8122 --- /dev/null +++ b/src/User/interface.ts @@ -0,0 +1,3 @@ +import type { AppUser } from "../api"; + +export type { AppUser }; diff --git a/src/api/auth.ts b/src/api/auth.ts index 3fd13eb..069dfaf 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -1,44 +1,37 @@ -import { api } from './client'; -import type { AdminProfile, ApiResponse } from './types'; +import { API_BASE_URL, TOKEN_KEY, request } from "../plugins/axios"; +import type { AdminProfile, ApiResponse, ChangePasswordPayload, LoginPayload, UpdateProfilePayload } from "./interface"; -export type LoginPayload = { - username: string; - password: string; -}; +export function getApiBaseUrl() { + return API_BASE_URL; +} -export type UpdateProfilePayload = { - nickname?: string; - email?: string; -}; +export function saveAuthToken(token: string) { + localStorage.setItem(TOKEN_KEY, token); +} -export type ChangePasswordPayload = { - oldPassword: string; - newPassword: string; -}; +export function clearAuthToken() { + localStorage.removeItem(TOKEN_KEY); +} + +export function hasAuthToken() { + return Boolean(localStorage.getItem(TOKEN_KEY)); +} export async function login(payload: LoginPayload) { - const response = await api.post>( - '/api/admin/v1/auth/login', - payload, - ); - return response.data.data; + const response = await request.post>("/api/admin/v1/auth/login", payload); + return response.data.data; } export async function getProfile() { - const response = await api.get>( - '/api/admin/v1/auth/profile', - ); - return response.data.data; + const response = await request.get>("/api/admin/v1/auth/profile"); + return response.data.data; } export async function updateProfile(payload: UpdateProfilePayload) { - const response = await api.patch>( - '/api/admin/v1/auth/profile', - payload, - ); - return response.data.data; + const response = await request.patch>("/api/admin/v1/auth/profile", payload); + return response.data.data; } export async function changePassword(payload: ChangePasswordPayload) { - await api.patch('/api/admin/v1/auth/password', payload); + await request.patch("/api/admin/v1/auth/password", payload); } diff --git a/src/api/client.ts b/src/api/client.ts deleted file mode 100644 index 1b64267..0000000 --- a/src/api/client.ts +++ /dev/null @@ -1,35 +0,0 @@ -import axios, { AxiosError } from "axios"; -import { Message } from "@arco-design/web-react"; -import type { ApiResponse, PageData } from "./types"; - -export const API_BASE_URL = - import.meta.env.VITE_API_BASE_URL || "http://localhost:3000"; -export const TOKEN_KEY = "cth_tk_admin_token"; - -export const api = axios.create({ baseURL: API_BASE_URL }); - -api.interceptors.request.use((config) => { - const token = localStorage.getItem(TOKEN_KEY); - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; -}); - -api.interceptors.response.use( - (response) => response, - (error: AxiosError>) => { - const message = - error.response?.data?.message || error.message || "请求失败"; - Message.error(message); - return Promise.reject(error); - }, -); - -export async function fetchPage( - url: string, - params?: { page?: number; pageSize?: number }, -): Promise> { - const response = await api.get>>(url, { params }); - return response.data.data; -} diff --git a/src/api/data.ts b/src/api/data.ts index 0dbc098..8a78351 100644 --- a/src/api/data.ts +++ b/src/api/data.ts @@ -1,13 +1,25 @@ -import { api } from './client'; -import type { ApiResponse, DataOverview } from './types'; +import { fetchPage, request } from "../plugins/axios"; +import type { ApiResponse, DataOverview, DramaMetric, EpisodeMetric, PageQuery, SyncJob } from "./interface"; export async function getDataOverview() { - const response = await api.get>( + const response = await request.get>( '/api/admin/v1/data/overview', ); return response.data.data; } export async function syncDataMetrics() { - await api.post('/api/admin/v1/data/sync', {}); + await request.post('/api/admin/v1/data/sync', {}); +} + +export function listDramaMetrics(query: PageQuery) { + return fetchPage('/api/admin/v1/data/dramas', query); +} + +export function listEpisodeMetrics(query: PageQuery) { + return fetchPage('/api/admin/v1/data/episodes', query); +} + +export function listSyncJobs(query: PageQuery) { + return fetchPage('/api/admin/v1/data/sync-jobs', query); } diff --git a/src/api/dramas.ts b/src/api/dramas.ts index cde9c4d..f0c2ee9 100644 --- a/src/api/dramas.ts +++ b/src/api/dramas.ts @@ -1,19 +1,23 @@ -import { api } from './client'; -import type { ApiResponse, Drama, Episode } from './types'; +import { fetchPage, request } from "../plugins/axios"; +import type { ApiResponse, Drama, Episode, PageQuery } from "./interface"; + +export function listDramas(query: PageQuery) { + return fetchPage('/api/admin/v1/dramas', query); +} export async function createDrama(payload: Partial) { - await api.post('/api/admin/v1/dramas', payload); + await request.post('/api/admin/v1/dramas', payload); } export async function getDrama(dramaId: number) { - const response = await api.get>( + const response = await request.get>( `/api/admin/v1/dramas/${dramaId}`, ); return response.data.data; } export async function getDramaEpisodes(dramaId: number) { - const response = await api.get>( + const response = await request.get>( `/api/admin/v1/dramas/${dramaId}/episodes`, { params: { pageSize: 200 } }, ); @@ -24,7 +28,7 @@ export async function configureDramaEpisodes( dramaId: number, episodeCount: number, ) { - await api.post(`/api/admin/v1/dramas/${dramaId}/episodes/batch`, { + await request.post(`/api/admin/v1/dramas/${dramaId}/episodes/batch`, { episodeCount, }); } @@ -33,12 +37,12 @@ export async function updateDrama( dramaId: number, payload: Partial>, ) { - await api.patch(`/api/admin/v1/dramas/${dramaId}`, payload); + await request.patch(`/api/admin/v1/dramas/${dramaId}`, payload); } export async function updateEpisode( episodeId: number, - payload: Partial>, + payload: Partial> & { status?: string }, ) { - await api.patch(`/api/admin/v1/episodes/${episodeId}`, payload); + await request.patch(`/api/admin/v1/episodes/${episodeId}`, payload); } diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..07b6f8e --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,9 @@ +export * as authApi from "./auth"; +export * as dataApi from "./data"; +export * as dramaApi from "./dramas"; +export * as logApi from "./logs"; +export * as mediaApi from "./media"; +export * as personnelApi from "./personnel"; +export * as roleApi from "./roles"; +export * as userApi from "./users"; +export * from "./interface"; diff --git a/src/api/interface.ts b/src/api/interface.ts new file mode 100644 index 0000000..e5182a0 --- /dev/null +++ b/src/api/interface.ts @@ -0,0 +1,220 @@ +export type ApiResponse = { + code: number; + data: T; + message: string; + timestamp: number; + requestId: string; + cost: string; +}; + +export type PageQuery = { + page: number; + pageSize: number; +}; + +export type PageData = { + list: T[]; + pagination: { + page: number; + pageSize: number; + total: number; + }; +}; + +export type AdminProfile = { + id: number; + username: string; + nickname?: string; + email?: string; + status: string; + isSuperAdmin: boolean; + roles: Role[]; + permissions: Permission[] | ["*"]; + createdAt: string; +}; + +export type Drama = { + id: number; + title: string; + type: string; + status: string; + coverUrl: string; + isRecommended: boolean; + totalEpisodes?: number; + publishedEpisodes?: number; + description?: string; + tags?: string[]; + region?: string; + language?: string; + year?: number; + sortOrder?: number; + createdAt: string; +}; + +export type Episode = { + id: number; + dramaId: number; + albumId: number; + episodeNo: number; + seq: number; + title: string; + coverUrl: string; + videoUrl?: string; + byteplusVid?: string; + freeType: "free" | "paid"; + price: number; + durationSeconds: number; + width?: number; + height?: number; + reviewStatus: string; + reviewMessage?: string; + publishStatus: string; + nextReleaseAt?: string; + createdAt: string; +}; + +export type UploadTask = { + id: number; + jobId: string; + episodeId?: number; + status: string; + uploadUrl?: string; + bucket?: string; + objectKey?: string; + byteplusVid?: string; + width?: number; + height?: number; + durationSeconds?: number; + reviewStatus?: string; + reviewMessage?: string; +}; + +export type DataOverview = { + totalDramas: number; + totalEpisodes: number; + totalPlays: number; + totalLikes: number; + totalShares: number; + totalComments: number; + latestSyncedAt?: string; +}; + +export type DramaMetric = { + dramaId: number; + title: string; + plays: number; + likes: number; + shares: number; + comments: number; + completionRate: number; + averageWatchSeconds: number; + syncedAt: string; +}; + +export type EpisodeMetric = { + dramaId: number; + episodeId: number; + title: string; + episodeNo: number; + plays: number; + likes: number; + shares: number; + comments: number; + completionRate: number; + averageWatchSeconds: number; + syncedAt: string; +}; + +export type SyncJob = { + id: number; + jobId: string; + status: string; + dramaId?: number; + dramaCount: number; + episodeCount: number; + createdAt: string; +}; + +export type AppUser = { + id: number; + tiktokOpenId: string; + nickname?: string; + status: string; + createdAt: string; +}; + +export type Permission = { + id: number; + code: string; + name: string; + description?: string; +}; + +export type Role = { + id: number; + code: string; + name: string; + description?: string; + permissionIds?: number[]; + permissions: Permission[]; +}; + +export type AdminUser = { + id: number; + username: string; + nickname?: string; + email?: string; + status: string; + isSuperAdmin: boolean; + roles: Role[]; + createdAt: string; +}; + +export type RequestLog = { + id: number; + requestId: string; + method: string; + path: string; + statusCode: number; + responseCode: number; + message: string; + costMs: number; + createdAt: string; +}; + +export type LoginPayload = { + username: string; + password: string; +}; + +export type UpdateProfilePayload = { + nickname?: string; + email?: string; +}; + +export type ChangePasswordPayload = { + oldPassword: string; + newPassword: string; +}; + +export type CreateUploadTaskPayload = { + episodeId: number; + fileName: string; + contentType: string; + fileSize: number; +}; + +export type CreatePersonnelPayload = { + username: string; + password: string; + nickname?: string; + email?: string; + roleIds?: number[]; +}; + +export type CreateRolePayload = { + code: string; + name: string; + description?: string; + permissionIds: number[]; +}; diff --git a/src/api/logs.ts b/src/api/logs.ts new file mode 100644 index 0000000..f4e86df --- /dev/null +++ b/src/api/logs.ts @@ -0,0 +1,6 @@ +import { fetchPage } from "../plugins/axios"; +import type { PageQuery, RequestLog } from "./interface"; + +export function listRequestLogs(query: PageQuery) { + return fetchPage('/api/admin/v1/logs/requests', query); +} diff --git a/src/api/media.ts b/src/api/media.ts index b47a5d7..2829c7e 100644 --- a/src/api/media.ts +++ b/src/api/media.ts @@ -1,15 +1,8 @@ -import { api } from './client'; -import type { ApiResponse, UploadTask } from './types'; - -export type CreateUploadTaskPayload = { - episodeId: number; - fileName: string; - contentType: string; - fileSize: number; -}; +import { request } from "../plugins/axios"; +import type { ApiResponse, CreateUploadTaskPayload, UploadTask } from "./interface"; export async function createUploadTask(payload: CreateUploadTaskPayload) { - const response = await api.post>( + const response = await request.post>( '/api/admin/v1/media/upload-tasks', payload, ); @@ -17,9 +10,9 @@ export async function createUploadTask(payload: CreateUploadTaskPayload) { } export async function completeUploadTask(jobId: string) { - await api.patch(`/api/admin/v1/media/upload-tasks/${jobId}/complete`, {}); + await request.patch(`/api/admin/v1/media/upload-tasks/${jobId}/complete`, {}); } export async function syncReviewStatus() { - await api.post('/api/admin/v1/media/review-status/sync', {}); + await request.post('/api/admin/v1/media/review-status/sync', {}); } diff --git a/src/api/personnel.ts b/src/api/personnel.ts index 3359943..c093bf2 100644 --- a/src/api/personnel.ts +++ b/src/api/personnel.ts @@ -1,27 +1,24 @@ -import { api } from './client'; +import { fetchPage, request } from "../plugins/axios"; +import type { AdminUser, CreatePersonnelPayload, PageQuery } from "./interface"; -export type CreatePersonnelPayload = { - username: string; - password: string; - nickname?: string; - email?: string; - roleIds?: number[]; -}; +export function listPersonnel(query: PageQuery) { + return fetchPage('/api/admin/v1/admin-users', query); +} export async function createPersonnel(payload: CreatePersonnelPayload) { - await api.post('/api/admin/v1/admin-users', { + await request.post('/api/admin/v1/admin-users', { ...payload, roleIds: payload.roleIds ?? [], }); } export async function updateAdminUserRoles(id: number, roleIds: number[]) { - await api.patch(`/api/admin/v1/admin-users/${id}/roles`, { roleIds }); + await request.patch(`/api/admin/v1/admin-users/${id}/roles`, { roleIds }); } export async function updateAdminUserStatus( id: number, status: 'active' | 'disabled', ) { - await api.patch(`/api/admin/v1/admin-users/${id}/status`, { status }); + await request.patch(`/api/admin/v1/admin-users/${id}/status`, { status }); } diff --git a/src/api/roles.ts b/src/api/roles.ts index 30d751e..1f0d66c 100644 --- a/src/api/roles.ts +++ b/src/api/roles.ts @@ -1,21 +1,23 @@ -import { api } from './client'; +import { fetchPage, request } from "../plugins/axios"; +import type { CreateRolePayload, PageQuery, Permission, Role } from "./interface"; -export type CreateRolePayload = { - code: string; - name: string; - description?: string; - permissionIds: number[]; -}; +export function listRoles(query: PageQuery) { + return fetchPage('/api/admin/v1/roles', query); +} + +export function listPermissions(query: PageQuery) { + return fetchPage('/api/admin/v1/permissions', query); +} export async function createRole(payload: CreateRolePayload) { - await api.post('/api/admin/v1/roles', payload); + await request.post('/api/admin/v1/roles', payload); } export async function updateRolePermissions( roleId: number, permissionIds: number[], ) { - await api.patch(`/api/admin/v1/roles/${roleId}/permissions`, { + await request.patch(`/api/admin/v1/roles/${roleId}/permissions`, { permissionIds, }); } diff --git a/src/api/types.ts b/src/api/types.ts deleted file mode 100644 index cb01155..0000000 --- a/src/api/types.ts +++ /dev/null @@ -1,178 +0,0 @@ -export type ApiResponse = { - code: number; - data: T; - message: string; - timestamp: number; - requestId: string; - cost: string; -}; - -export type PageData = { - list: T[]; - pagination: { - page: number; - pageSize: number; - total: number; - }; -}; - -export type AdminProfile = { - id: number; - username: string; - nickname?: string; - email?: string; - status: string; - isSuperAdmin: boolean; - roles: Role[]; - permissions: Permission[] | ['*']; - createdAt: string; -}; - -export type Drama = { - id: number; - title: string; - type: string; - status: string; - coverUrl: string; - isRecommended: boolean; - totalEpisodes?: number; - publishedEpisodes?: number; - description?: string; - tags?: string[]; - region?: string; - language?: string; - year?: number; - sortOrder?: number; - createdAt: string; -}; - -export type Episode = { - id: number; - dramaId: number; - albumId: number; - episodeNo: number; - seq: number; - title: string; - coverUrl: string; - videoUrl?: string; - byteplusVid?: string; - freeType: 'free' | 'paid'; - price: number; - durationSeconds: number; - width?: number; - height?: number; - reviewStatus: string; - reviewMessage?: string; - publishStatus: string; - nextReleaseAt?: string; - createdAt: string; -}; - -export type UploadTask = { - id: number; - jobId: string; - episodeId?: number; - status: string; - uploadUrl?: string; - bucket?: string; - objectKey?: string; - byteplusVid?: string; - width?: number; - height?: number; - durationSeconds?: number; - reviewStatus?: string; - reviewMessage?: string; -}; - -export type DataOverview = { - totalDramas: number; - totalEpisodes: number; - totalPlays: number; - totalLikes: number; - totalShares: number; - totalComments: number; - latestSyncedAt?: string; -}; - -export type DramaMetric = { - dramaId: number; - title: string; - plays: number; - likes: number; - shares: number; - comments: number; - completionRate: number; - averageWatchSeconds: number; - syncedAt: string; -}; - -export type EpisodeMetric = { - dramaId: number; - episodeId: number; - title: string; - episodeNo: number; - plays: number; - likes: number; - shares: number; - comments: number; - completionRate: number; - averageWatchSeconds: number; - syncedAt: string; -}; - -export type SyncJob = { - id: number; - jobId: string; - status: string; - dramaId?: number; - dramaCount: number; - episodeCount: number; - createdAt: string; -}; - -export type AppUser = { - id: number; - tiktokOpenId: string; - nickname?: string; - status: string; - createdAt: string; -}; - -export type Permission = { - id: number; - code: string; - name: string; - description?: string; -}; - -export type Role = { - id: number; - code: string; - name: string; - description?: string; - permissionIds?: number[]; - permissions: Permission[]; -}; - -export type AdminUser = { - id: number; - username: string; - nickname?: string; - email?: string; - status: string; - isSuperAdmin: boolean; - roles: Role[]; - createdAt: string; -}; - -export type RequestLog = { - id: number; - requestId: string; - method: string; - path: string; - statusCode: number; - responseCode: number; - message: string; - costMs: number; - createdAt: string; -}; diff --git a/src/api/users.ts b/src/api/users.ts index c1cc775..2ce4824 100644 --- a/src/api/users.ts +++ b/src/api/users.ts @@ -1,8 +1,13 @@ -import { api } from './client'; +import { fetchPage, request } from "../plugins/axios"; +import type { AppUser, PageQuery } from "./interface"; + +export function listUsers(query: PageQuery) { + return fetchPage('/api/admin/v1/users', query); +} export async function updateUserStatus( id: number, status: 'active' | 'disabled', ) { - await api.patch(`/api/admin/v1/users/${id}/status`, { status }); + await request.patch(`/api/admin/v1/users/${id}/status`, { status }); } diff --git a/src/components/NoAccessPage.tsx b/src/components/NoAccessPage.tsx deleted file mode 100644 index 0bd9489..0000000 --- a/src/components/NoAccessPage.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Card, Typography } from '@arco-design/web-react'; - -const { Text } = Typography; - -export function NoAccessPage() { - return ( - - 当前账号没有访问该功能的权限,请联系系统管理员分配角色。 - - ); -} diff --git a/src/components/NoAccessPage/index.tsx b/src/components/NoAccessPage/index.tsx new file mode 100644 index 0000000..90bd25c --- /dev/null +++ b/src/components/NoAccessPage/index.tsx @@ -0,0 +1,11 @@ +import { Card, Typography } from "@arco-design/web-react"; + +const { Text } = Typography; + +export function NoAccessPage() { + return ( + + 当前账号没有访问该功能的权限,请联系系统管理员分配角色。 + + ); +} diff --git a/src/components/RequestLogTable.tsx b/src/components/RequestLogTable.tsx deleted file mode 100644 index 7fd7fea..0000000 --- a/src/components/RequestLogTable.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Table, Tag } from '@arco-design/web-react'; -import type { RequestLog } from '../api/types'; - -export function RequestLogTable({ data, loading }: { data: RequestLog[]; loading: boolean }) { - return ( -
{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: 'message', width: 180 }, - ]} /> - ); -} diff --git a/src/components/RequestLogTable/index.tsx b/src/components/RequestLogTable/index.tsx new file mode 100644 index 0000000..395afd6 --- /dev/null +++ b/src/components/RequestLogTable/index.tsx @@ -0,0 +1,21 @@ +import { Table, Tag } from "@arco-design/web-react"; +import type { RequestLogTableProps } from "./interface"; + +export function RequestLogTable({ data, loading }: RequestLogTableProps) { + return ( +
{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: "message", width: 180 }, + ]} + /> + ); +} diff --git a/src/components/RequestLogTable/interface.ts b/src/components/RequestLogTable/interface.ts new file mode 100644 index 0000000..575205d --- /dev/null +++ b/src/components/RequestLogTable/interface.ts @@ -0,0 +1,6 @@ +import type { RequestLog } from "../../api"; + +export type RequestLogTableProps = { + data: RequestLog[]; + loading: boolean; +}; diff --git a/src/hooks/interface.ts b/src/hooks/interface.ts new file mode 100644 index 0000000..d2ef804 --- /dev/null +++ b/src/hooks/interface.ts @@ -0,0 +1,5 @@ +import type { PageData, PageQuery } from "../api"; + +export type { PageData, PageQuery }; + +export type PageFetcher = (query: PageQuery) => Promise>; diff --git a/src/hooks/pageData.ts b/src/hooks/pageData.ts index 2ea44c7..2217721 100644 --- a/src/hooks/pageData.ts +++ b/src/hooks/pageData.ts @@ -1,9 +1,4 @@ -import type { PageData } from '../api/types'; - -type PageQuery = { - page: number; - pageSize: number; -}; +import type { PageData, PageQuery } from "./interface"; export function resolvePageData( response: PageData, diff --git a/src/hooks/usePageData.ts b/src/hooks/usePageData.ts index 1c6cf21..9078fd1 100644 --- a/src/hooks/usePageData.ts +++ b/src/hooks/usePageData.ts @@ -1,14 +1,8 @@ import { useEffect, useState } from "react"; -import { fetchPage } from "../api/client"; -import type { PageData } from "../api/types"; +import type { PageData, PageFetcher, PageQuery } from "./interface"; import { resolvePageData } from "./pageData"; -type PageQuery = { - page: number; - pageSize: number; -}; - -export function usePageData(url: string, enabled = true) { +export function usePageData(fetcher: PageFetcher, enabled = true) { const [loading, setLoading] = useState(false); const [pageQuery, setPageQuery] = useState({ page: 1, @@ -25,7 +19,7 @@ export function usePageData(url: string, enabled = true) { } setLoading(true); try { - const response = await fetchPage(url, query); + const response = await fetcher(query); setData(resolvePageData(response, query)); } finally { setLoading(false); @@ -40,7 +34,7 @@ export function usePageData(url: string, enabled = true) { if (enabled) { void load(pageQuery); } - }, [url, enabled, pageQuery.page, pageQuery.pageSize]); + }, [fetcher, enabled, pageQuery.page, pageQuery.pageSize]); return { data, diff --git a/src/index.css b/src/index.css index 70ffe97..4a774fa 100644 --- a/src/index.css +++ b/src/index.css @@ -2,47 +2,24 @@ @tailwind components; @tailwind utilities; -@layer base { - * { - @apply box-border; - } - - html, - body, - #root { - @apply m-0 min-h-full w-full min-w-80; - } - - body { - @apply bg-canvas font-sans text-ink; - } - - button, - input, - textarea, - select { - font: inherit; - } -} - @layer components { - .section-card { - @apply rounded-lg; - } + .section-card { + @apply rounded-lg; + } - .page-stack { - @apply flex flex-col gap-4; - } + .page-stack { + @apply flex flex-col gap-4; + } - .modal-actions { - @apply mt-6 flex justify-end gap-3; - } + .modal-actions { + @apply mt-6 flex justify-end gap-3; + } - .form-label { - @apply mb-2 text-sm font-medium text-[#4e5969]; - } + .form-label { + @apply mb-2 text-sm font-medium text-[#4e5969]; + } - .form-help { - @apply -mt-3 mb-[18px] text-[13px] leading-[1.6] text-muted; - } + .form-help { + @apply -mt-3 mb-[18px] text-[13px] leading-[1.6] text-muted; + } } diff --git a/src/layout/PermissionRoute.tsx b/src/layout/PermissionRoute.tsx index 305cdfe..396620b 100644 --- a/src/layout/PermissionRoute.tsx +++ b/src/layout/PermissionRoute.tsx @@ -1,18 +1,13 @@ -import type { ReactElement } from 'react'; import { Card } from '@arco-design/web-react'; -import type { AdminProfile } from '../api/types'; import { hasPermissions } from '../utils/permission'; import { NoAccessPage } from '../components/NoAccessPage'; +import type { PermissionRouteProps } from './interface'; export function PermissionRoute({ profile, requiredPermissions, children, -}: { - profile: AdminProfile | null; - requiredPermissions?: string[]; - children: ReactElement; -}) { +}: PermissionRouteProps) { if (!profile) { return ; } diff --git a/src/layout/ProtectedRoute.tsx b/src/layout/ProtectedRoute.tsx index 400a702..3267c88 100644 --- a/src/layout/ProtectedRoute.tsx +++ b/src/layout/ProtectedRoute.tsx @@ -1,9 +1,9 @@ -import type { ReactElement } from 'react'; import { Navigate } from 'react-router-dom'; -import { TOKEN_KEY } from '../api/client'; +import { authApi } from '../api'; +import type { ProtectedRouteProps } from './interface'; -export function ProtectedRoute({ children }: { children: ReactElement }) { - if (!localStorage.getItem(TOKEN_KEY)) { +export function ProtectedRoute({ children }: ProtectedRouteProps) { + if (!authApi.hasAuthToken()) { return ; } return children; diff --git a/src/layout/index.tsx b/src/layout/index.tsx index 9217a00..245cda4 100644 --- a/src/layout/index.tsx +++ b/src/layout/index.tsx @@ -2,9 +2,8 @@ import { useEffect, useMemo, useState } from "react"; import { Avatar, Button, Dropdown, Layout, Menu, Space, Typography } from "@arco-design/web-react"; import { IconMenuFold, IconMenuUnfold } from "@arco-design/web-react/icon"; import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom"; -import type { AdminProfile } from "../api/types"; -import { getProfile } from "../api/auth"; -import { TOKEN_KEY } from "../api/client"; +import { authApi } from "../api"; +import type { AdminProfile } from "./interface"; import { filterMenuItems, findCurrentMenu, menuItems, renderMenuItem } from "./menu"; import { PermissionRoute } from "./PermissionRoute"; import DashboardPage from "../Dashboard"; @@ -29,10 +28,10 @@ export function AdminLayout() { const [openMenuKeys, setOpenMenuKeys] = useState(current?.parent ? [current.parent.key] : []); useEffect(() => { - getProfile() + authApi.getProfile() .then((data) => setProfile(data)) .catch(() => { - localStorage.removeItem(TOKEN_KEY); + authApi.clearAuthToken(); navigate("/login", { replace: true }); }); }, [navigate]); @@ -44,7 +43,7 @@ export function AdminLayout() { }, [current, openMenuKeys]); const logout = () => { - localStorage.removeItem(TOKEN_KEY); + authApi.clearAuthToken(); navigate("/login", { replace: true }); }; diff --git a/src/layout/interface.ts b/src/layout/interface.ts new file mode 100644 index 0000000..427fb87 --- /dev/null +++ b/src/layout/interface.ts @@ -0,0 +1,22 @@ +import type { ReactElement, ReactNode } from "react"; +import type { AdminProfile } from "../api"; + +export type { AdminProfile }; + +export type MenuConfig = { + key: string; + label: string; + icon?: ReactNode; + requiredPermissions?: string[]; + children?: MenuConfig[]; +}; + +export type PermissionRouteProps = { + profile: AdminProfile | null; + requiredPermissions?: string[]; + children: ReactElement; +}; + +export type ProtectedRouteProps = { + children: ReactElement; +}; diff --git a/src/layout/menu.tsx b/src/layout/menu.tsx index 9e0098f..2311009 100644 --- a/src/layout/menu.tsx +++ b/src/layout/menu.tsx @@ -1,4 +1,3 @@ -import type { ReactNode } from 'react'; import { Menu } from '@arco-design/web-react'; import { IconApps, @@ -8,17 +7,9 @@ import { IconUser, IconVideoCamera, } from '@arco-design/web-react/icon'; -import type { AdminProfile } from '../api/types'; +import type { AdminProfile, MenuConfig } from './interface'; import { hasPermissions } from '../utils/permission'; -export type MenuConfig = { - key: string; - label: string; - icon?: ReactNode; - requiredPermissions?: string[]; - children?: MenuConfig[]; -}; - export const SYSTEM_MENU_KEY = '/system'; export const menuItems: MenuConfig[] = [ diff --git a/src/plugins/axios.ts b/src/plugins/axios.ts new file mode 100644 index 0000000..ddbe5fc --- /dev/null +++ b/src/plugins/axios.ts @@ -0,0 +1,30 @@ +import axios, { AxiosError } from "axios"; +import { Message } from "@arco-design/web-react"; +import type { ApiResponse, PageData, PageQuery } from "../api/interface"; + +export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:3000"; +export const TOKEN_KEY = "cth_tk_admin_token"; + +export const request = axios.create({ baseURL: API_BASE_URL }); + +request.interceptors.request.use((config) => { + const token = localStorage.getItem(TOKEN_KEY); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +request.interceptors.response.use( + (response) => response, + (error: AxiosError>) => { + const message = error.response?.data?.message || error.message || "请求失败"; + Message.error(message); + return Promise.reject(error); + }, +); + +export async function fetchPage(url: string, params?: PageQuery): Promise> { + const response = await request.get>>(url, { params }); + return response.data.data; +} diff --git a/src/utils/permission.ts b/src/utils/permission.ts index 7d4c84f..d3b8a6e 100644 --- a/src/utils/permission.ts +++ b/src/utils/permission.ts @@ -1,4 +1,4 @@ -import type { AdminProfile, Permission } from '../api/types'; +import type { AdminProfile, Permission } from '../api'; export function getPermissionCodes(profile: AdminProfile | null) { if (!profile) {