From a691db573c8472ccd37462ff6cf70c2844f61044 Mon Sep 17 00:00:00 2001 From: zhxiao1124 Date: Wed, 1 Jul 2026 16:35:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2=E4=B8=8E=E7=9F=AD=E5=89=A7?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5=EF=BC=8C=E5=AE=8C=E5=96=84=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E7=B1=BB=E5=9E=8B=E4=B8=8E=E8=8F=9C=E5=8D=95=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增数据管理页面,支持查看数据概览、短剧/剧集指标和同步记录 2. 新增短剧详情页,支持查看短剧信息、管理剧集和上传视频 3. 新增菜单配置项,添加数据管理入口 4. 扩展API类型定义,新增剧集、上传任务、数据统计等类型 5. 修复timestamp类型为数字类型,新增短剧额外字段 6. 为页面布局添加统一的page-stack样式类 7. 新增短剧详情路由,优化短剧列表页跳转逻辑 --- src/Data/index.tsx | 96 +++++++++++++++++++++++ src/Drama/index.tsx | 183 +++++++++++++++++++++++++++++++++++++++++-- src/api/types.ts | 88 ++++++++++++++++++++- src/index.css | 6 ++ src/layout/index.tsx | 3 + src/layout/menu.tsx | 1 + 6 files changed, 371 insertions(+), 6 deletions(-) create mode 100644 src/Data/index.tsx diff --git a/src/Data/index.tsx b/src/Data/index.tsx new file mode 100644 index 0000000..3297ab8 --- /dev/null +++ b/src/Data/index.tsx @@ -0,0 +1,96 @@ +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 { ApiResponse, DataOverview, DramaMetric, EpisodeMetric, SyncJob } from '../api/types'; +import { api } from '../api/client'; +import { usePageData } from '../hooks/usePageData'; + +const { Row, Col } = Grid; + +function DataPage() { + const [overview, setOverview] = useState({ + totalDramas: 0, + totalEpisodes: 0, + totalPlays: 0, + totalLikes: 0, + totalShares: 0, + 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 loadOverview = async () => { + const response = await api.get>('/api/admin/v1/data/overview'); + setOverview(response.data.data); + }; + + useEffect(() => { + void loadOverview(); + }, []); + + const syncData = async () => { + setSyncing(true); + try { + await api.post('/api/admin/v1/data/sync', {}); + Message.success('数据同步完成'); + await Promise.all([loadOverview(), dramas.reload(), episodes.reload(), jobs.reload()]); + } finally { + setSyncing(false); + } + }; + + return ( +
+ } loading={syncing} onClick={syncData}>同步 TikTok 数据}> + + + + + + + + + + + + `${Math.round(Number(value) * 100)}%` }, + { title: '同步时间', dataIndex: 'syncedAt', width: 220 }, + ]} /> + + + +
+ + + +
+ + + ); +} + +export default DataPage; diff --git a/src/Drama/index.tsx b/src/Drama/index.tsx index def565b..8ef16b2 100644 --- a/src/Drama/index.tsx +++ b/src/Drama/index.tsx @@ -1,7 +1,9 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; import { Button, Card, + Descriptions, Form, Grid, Input, @@ -10,12 +12,13 @@ import { Message, Modal, Select, + Space, Switch, Table, Tag, } from '@arco-design/web-react'; -import { IconPlus } from '@arco-design/web-react/icon'; -import type { Drama } from '../api/types'; +import { IconArrowLeft, IconPlus, IconRefresh } from '@arco-design/web-react/icon'; +import type { ApiResponse, Drama, Episode, UploadTask } from '../api/types'; import { api } from '../api/client'; import { usePageData } from '../hooks/usePageData'; @@ -30,7 +33,33 @@ const STATUS_OPTIONS: { value: string; label: string }[] = [ { value: 'offline', label: '已下线' }, ]; +const REVIEW_STATUS_LABEL: Record = { + not_submitted: '未提交', + pending: '待审核', + reviewing: '审核中', + approved: '已通过', + rejected: '已拒绝', +}; + +const PUBLISH_STATUS_LABEL: Record = { + draft: '草稿', + scheduled: '定时发布', + published: '已发布', + offline: '已下线', +}; + function DramasPage() { + const { id } = useParams(); + + if (id) { + return ; + } + + return ; +} + +function DramaListPage() { + const navigate = useNavigate(); const { data, loading, reload } = usePageData('/api/admin/v1/dramas'); const [visible, setVisible] = useState(false); const [creating, setCreating] = useState(false); @@ -52,9 +81,13 @@ function DramasPage() {
{status} }, - { title: '推荐', dataIndex: 'isRecommended', width: 140, render: (value) => value ? : }, + { 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 }}>
(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 [dramaResponse, episodeResponse] = await Promise.all([ + api.get>(`/api/admin/v1/dramas/${dramaId}`), + api.get>(`/api/admin/v1/dramas/${dramaId}/episodes?pageSize=200`), + ]); + setDrama(dramaResponse.data.data); + setEpisodeCount(dramaResponse.data.data.totalEpisodes || 1); + setEpisodes(episodeResponse.data.data.list); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void loadDetail(); + }, [dramaId]); + + const configureEpisodes = async () => { + await api.post(`/api/admin/v1/dramas/${dramaId}/episodes/batch`, { episodeCount }); + Message.success('剧集数量已更新'); + await loadDetail(); + }; + + const updateDramaStatus = async (status: string) => { + await api.patch(`/api/admin/v1/dramas/${dramaId}`, { status }); + Message.success('短剧状态已更新'); + await loadDetail(); + }; + + const uploadEpisodeVideo = async (episode: Episode, file: File) => { + setUploadingEpisodeId(episode.id); + try { + const task = await api.post>('/api/admin/v1/media/upload-tasks', { + episodeId: episode.id, + fileName: file.name, + contentType: file.type || 'video/mp4', + fileSize: file.size, + }); + await api.patch(`/api/admin/v1/media/upload-tasks/${task.data.data.jobId}/complete`, {}); + await api.post('/api/admin/v1/media/review-status/sync', {}); + Message.success('视频上传并审核通过'); + await loadDetail(); + } finally { + setUploadingEpisodeId(null); + } + }; + + const publishEpisode = async (episode: Episode) => { + await api.patch(`/api/admin/v1/episodes/${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) => ( + + + + + ), + }, + ]} /> + + + ); +} + export default DramasPage; diff --git a/src/api/types.ts b/src/api/types.ts index 865878e..cb01155 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -2,7 +2,7 @@ export type ApiResponse = { code: number; data: T; message: string; - timestamp: string; + timestamp: number; requestId: string; cost: string; }; @@ -35,6 +35,8 @@ export type Drama = { status: string; coverUrl: string; isRecommended: boolean; + totalEpisodes?: number; + publishedEpisodes?: number; description?: string; tags?: string[]; region?: string; @@ -44,6 +46,90 @@ export type Drama = { 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; diff --git a/src/index.css b/src/index.css index b36ead9..5baa86e 100644 --- a/src/index.css +++ b/src/index.css @@ -28,6 +28,12 @@ select { border-radius: 8px; } +.page-stack { + display: flex; + flex-direction: column; + gap: 16px; +} + .modal-actions { display: flex; justify-content: flex-end; diff --git a/src/layout/index.tsx b/src/layout/index.tsx index 4c39721..5155edc 100644 --- a/src/layout/index.tsx +++ b/src/layout/index.tsx @@ -16,6 +16,7 @@ import { filterMenuItems, findCurrentMenu, menuItems, renderMenuItem } from './m import { PermissionRoute } from './PermissionRoute'; import DashboardPage from '../Dashboard'; import DramasPage from '../Drama'; +import DataPage from '../Data'; import UsersPage from '../User'; import RolesPage from '../Role'; import PersonnelPage from '../Personnel'; @@ -100,6 +101,8 @@ export function AdminLayout() { } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/layout/menu.tsx b/src/layout/menu.tsx index 6dfeccb..9e0098f 100644 --- a/src/layout/menu.tsx +++ b/src/layout/menu.tsx @@ -24,6 +24,7 @@ export const SYSTEM_MENU_KEY = '/system'; export const menuItems: MenuConfig[] = [ { key: '/dashboard', label: '工作台', icon: }, { key: '/dramas', label: '短剧管理', icon: , requiredPermissions: ['drama:read'] }, + { key: '/data', label: '数据管理', icon: , requiredPermissions: ['data:read'] }, { key: '/users', label: '用户管理', icon: , requiredPermissions: ['user:read'] }, { key: SYSTEM_MENU_KEY,