refactor: 重构项目接口与API调用体系,统一类型管理
1. 合并拆分的API类型定义到统一的api/interface.ts文件 2. 删除旧的api/client.ts与api/types.ts文件 3. 重构所有页面与组件的API调用方式,统一使用api/index.ts导出的接口 4. 调整组件文件结构,将分散的组件按模块整理 5. 统一类型导入路径,移除冗余的类型重复定义
This commit is contained in:
@@ -1,21 +1,22 @@
|
|||||||
import { Card } from '@arco-design/web-react';
|
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 { usePageData } from '../hooks/usePageData';
|
||||||
import { hasPermissions } from '../utils/permission';
|
import { hasPermissions } from '../utils/permission';
|
||||||
import { NoAccessPage } from '../components/NoAccessPage';
|
import { NoAccessPage } from '../components/NoAccessPage';
|
||||||
import { RequestLogTable } from '../components/RequestLogTable';
|
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 <Card><div className="mb-2 text-muted">{label}</div><div className="text-[28px] font-bold">{value}</div></Card>;
|
return <Card><div className="mb-2 text-muted">{label}</div><div className="text-[28px] font-bold">{value}</div></Card>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DashboardPage({ profile }: { profile: AdminProfile | null }) {
|
function DashboardPage({ profile }: DashboardPageProps) {
|
||||||
const canReadDramas = hasPermissions(profile, ['drama:read']);
|
const canReadDramas = hasPermissions(profile, ['drama:read']);
|
||||||
const canReadUsers = hasPermissions(profile, ['user:read']);
|
const canReadUsers = hasPermissions(profile, ['user:read']);
|
||||||
const canReadLogs = hasPermissions(profile, ['log:read']);
|
const canReadLogs = hasPermissions(profile, ['log:read']);
|
||||||
const dramas = usePageData<Drama>('/api/admin/v1/dramas', canReadDramas);
|
const dramas = usePageData<Drama>(dramaApi.listDramas, canReadDramas);
|
||||||
const users = usePageData<AppUser>('/api/admin/v1/users', canReadUsers);
|
const users = usePageData<AppUser>(userApi.listUsers, canReadUsers);
|
||||||
const logs = usePageData<RequestLog>('/api/admin/v1/logs/requests', canReadLogs);
|
const logs = usePageData<RequestLog>(logApi.listRequestLogs, canReadLogs);
|
||||||
const hasAnyDataModule = canReadDramas || canReadUsers || canReadLogs;
|
const hasAnyDataModule = canReadDramas || canReadUsers || canReadLogs;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
12
src/Dashboard/interface.ts
Normal file
12
src/Dashboard/interface.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Button, Card, Grid, Message, Statistic, Table } from '@arco-design/web-react';
|
import { Button, Card, Grid, Message, Statistic, Table } from '@arco-design/web-react';
|
||||||
import { IconRefresh } from '@arco-design/web-react/icon';
|
import { IconRefresh } from '@arco-design/web-react/icon';
|
||||||
import type { DataOverview, DramaMetric, EpisodeMetric, SyncJob } from '../api/types';
|
import { dataApi } from '../api';
|
||||||
import { getDataOverview, syncDataMetrics } from '../api/data';
|
|
||||||
import { usePageData } from '../hooks/usePageData';
|
import { usePageData } from '../hooks/usePageData';
|
||||||
|
import type { DataOverview, DramaMetric, EpisodeMetric, SyncJob } from './interface';
|
||||||
|
|
||||||
const { Row, Col } = Grid;
|
const { Row, Col } = Grid;
|
||||||
|
|
||||||
@@ -17,12 +17,12 @@ function DataPage() {
|
|||||||
totalComments: 0,
|
totalComments: 0,
|
||||||
});
|
});
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
const dramas = usePageData<DramaMetric>('/api/admin/v1/data/dramas');
|
const dramas = usePageData<DramaMetric>(dataApi.listDramaMetrics);
|
||||||
const episodes = usePageData<EpisodeMetric>('/api/admin/v1/data/episodes');
|
const episodes = usePageData<EpisodeMetric>(dataApi.listEpisodeMetrics);
|
||||||
const jobs = usePageData<SyncJob>('/api/admin/v1/data/sync-jobs');
|
const jobs = usePageData<SyncJob>(dataApi.listSyncJobs);
|
||||||
|
|
||||||
const loadOverview = async () => {
|
const loadOverview = async () => {
|
||||||
setOverview(await getDataOverview());
|
setOverview(await dataApi.getDataOverview());
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -32,7 +32,7 @@ function DataPage() {
|
|||||||
const syncData = async () => {
|
const syncData = async () => {
|
||||||
setSyncing(true);
|
setSyncing(true);
|
||||||
try {
|
try {
|
||||||
await syncDataMetrics();
|
await dataApi.syncDataMetrics();
|
||||||
Message.success('数据同步完成');
|
Message.success('数据同步完成');
|
||||||
await Promise.all([loadOverview(), dramas.reload(), episodes.reload(), jobs.reload()]);
|
await Promise.all([loadOverview(), dramas.reload(), episodes.reload(), jobs.reload()]);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
3
src/Data/interface.ts
Normal file
3
src/Data/interface.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type { DataOverview, DramaMetric, EpisodeMetric, SyncJob } from "../api";
|
||||||
|
|
||||||
|
export type { DataOverview, DramaMetric, EpisodeMetric, SyncJob };
|
||||||
142
src/Drama/components/DramaDetail.tsx
Normal file
142
src/Drama/components/DramaDetail.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
draft: "草稿",
|
||||||
|
scheduled: "定时发布",
|
||||||
|
published: "已发布",
|
||||||
|
offline: "已下线",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DramaDetail({ dramaId }: DramaDetailPageProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [drama, setDrama] = useState<Drama | null>(null);
|
||||||
|
const [episodes, setEpisodes] = useState<Episode[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [episodeCount, setEpisodeCount] = useState(1);
|
||||||
|
const [uploadingEpisodeId, setUploadingEpisodeId] = useState<number | null>(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 (
|
||||||
|
<div className="page-stack">
|
||||||
|
<Card
|
||||||
|
className="section-card"
|
||||||
|
loading={loading && !drama}
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<Button icon={<IconArrowLeft />} onClick={() => navigate("/dramas")} />
|
||||||
|
短剧详情
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button icon={<IconRefresh />} onClick={loadDetail}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => updateDramaStatus("draft")}>设为草稿</Button>
|
||||||
|
<Button type="primary" onClick={() => updateDramaStatus("published")}>
|
||||||
|
上架短剧
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{drama && (
|
||||||
|
<Descriptions
|
||||||
|
column={3}
|
||||||
|
data={[
|
||||||
|
{ label: "短剧ID", value: drama.id },
|
||||||
|
{ label: "名称", value: drama.title },
|
||||||
|
{ label: "地区", value: drama.region || "-" },
|
||||||
|
{ label: "类型", value: drama.type },
|
||||||
|
{ label: "总集数", value: drama.totalEpisodes || 0 },
|
||||||
|
{ label: "已发布集数", value: drama.publishedEpisodes || 0 },
|
||||||
|
{ label: "状态", value: PUBLISH_STATUS_LABEL[drama.status] || drama.status },
|
||||||
|
{ label: "封面", value: drama.coverUrl },
|
||||||
|
{ label: "简介", value: drama.description || "-" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className="section-card"
|
||||||
|
title="剧集配置"
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<InputNumber min={1} max={500} value={episodeCount} onChange={(value) => setEpisodeCount(Number(value) || 1)} />
|
||||||
|
<Button type="primary" onClick={configureEpisodes}>
|
||||||
|
保存集数
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<EpisodeTable
|
||||||
|
episodes={episodes}
|
||||||
|
loading={loading}
|
||||||
|
uploadingEpisodeId={uploadingEpisodeId}
|
||||||
|
onUploadVideo={uploadEpisodeVideo}
|
||||||
|
onPublishEpisode={publishEpisode}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
src/Drama/components/DramaList.tsx
Normal file
164
src/Drama/components/DramaList.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
draft: "草稿",
|
||||||
|
scheduled: "定时发布",
|
||||||
|
published: "已发布",
|
||||||
|
offline: "已下线",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DramaList() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data, loading, reload, changePage } = usePageData<Drama>(dramaApi.listDramas);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
const createDrama = async (values: Partial<Drama>) => {
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
await dramaApi.createDrama(values);
|
||||||
|
Message.success("短剧创建成功");
|
||||||
|
setVisible(false);
|
||||||
|
await reload();
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className="section-card"
|
||||||
|
title="短剧管理"
|
||||||
|
extra={
|
||||||
|
<Button type="primary" icon={<IconPlus />} onClick={() => setVisible(true)}>
|
||||||
|
新增短剧
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
data={data.list}
|
||||||
|
pagination={{
|
||||||
|
current: data.pagination.page,
|
||||||
|
total: data.pagination.total,
|
||||||
|
pageSize: data.pagination.pageSize,
|
||||||
|
onChange: (page, pageSize) => 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) => <Tag color={status === "published" ? "green" : "gray"}>{PUBLISH_STATUS_LABEL[String(status)] || String(status)}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "推荐",
|
||||||
|
dataIndex: "isRecommended",
|
||||||
|
width: 100,
|
||||||
|
render: (value) => (value ? <Tag color="arcoblue">是</Tag> : <Tag>否</Tag>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
width: 120,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Button type="text" onClick={() => navigate(`/dramas/${record.id}`)}>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Modal title="创建短剧" visible={visible} onCancel={() => setVisible(false)} footer={null} style={{ width: 640 }}>
|
||||||
|
<Form layout="vertical" onSubmit={createDrama} initialValues={{ status: "draft", isRecommended: false, sortOrder: 0 }}>
|
||||||
|
<Form.Item label="标题" field="title" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="请输入短剧标题" />
|
||||||
|
</Form.Item>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item label="类型" field="type" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="请输入类型,如复仇、爱情、动作等" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item label="状态" field="status">
|
||||||
|
<Select placeholder="请选择状态">
|
||||||
|
{STATUS_OPTIONS.map((option) => (
|
||||||
|
<Select.Option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Form.Item label="封面地址" field="coverUrl" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="请输入封面图片 URL" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="简介" field="description">
|
||||||
|
<Input.TextArea placeholder="请输入短剧简介" autoSize={{ minRows: 2, maxRows: 4 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="标签" field="tags">
|
||||||
|
<InputTag placeholder="输入后回车添加标签" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item label="地区" field="region">
|
||||||
|
<Input placeholder="如 中国" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item label="语言" field="language">
|
||||||
|
<Input placeholder="如 zh-CN" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item label="年份" field="year">
|
||||||
|
<InputNumber placeholder="如 2026" min={1900} style={{ width: "100%" }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item label="排序权重" field="sortOrder">
|
||||||
|
<InputNumber placeholder="数值越大越靠前" style={{ width: "100%" }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item label="推荐" field="isRecommended" triggerPropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<Button onClick={() => setVisible(false)}>取消</Button>
|
||||||
|
<Button type="primary" htmlType="submit" loading={creating}>
|
||||||
|
创建
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/Drama/components/EpisodeTable.tsx
Normal file
89
src/Drama/components/EpisodeTable.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { Button, Space, Table, Tag } from "@arco-design/web-react";
|
||||||
|
import type { EpisodeTableProps } from "../interface";
|
||||||
|
|
||||||
|
const REVIEW_STATUS_LABEL: Record<string, string> = {
|
||||||
|
not_submitted: "未提交",
|
||||||
|
pending: "待审核",
|
||||||
|
reviewing: "审核中",
|
||||||
|
approved: "已通过",
|
||||||
|
rejected: "已拒绝",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PUBLISH_STATUS_LABEL: Record<string, string> = {
|
||||||
|
draft: "草稿",
|
||||||
|
scheduled: "定时发布",
|
||||||
|
published: "已发布",
|
||||||
|
offline: "已下线",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EpisodeTable({ episodes, loading, uploadingEpisodeId, onUploadVideo, onPublishEpisode }: EpisodeTableProps) {
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
data={episodes}
|
||||||
|
pagination={false}
|
||||||
|
columns={[
|
||||||
|
{ title: "集数", dataIndex: "episodeNo", width: 80 },
|
||||||
|
{ title: "标题", dataIndex: "title" },
|
||||||
|
{
|
||||||
|
title: "BytePlus VID",
|
||||||
|
dataIndex: "byteplusVid",
|
||||||
|
width: 180,
|
||||||
|
render: (value) => 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) => <Tag color={status === "approved" ? "green" : "orange"}>{REVIEW_STATUS_LABEL[String(status)] || String(status)}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "发布状态",
|
||||||
|
dataIndex: "publishStatus",
|
||||||
|
width: 120,
|
||||||
|
render: (status) => <Tag color={status === "published" ? "green" : "gray"}>{PUBLISH_STATUS_LABEL[String(status)] || String(status)}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
width: 280,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button loading={uploadingEpisodeId === record.id}>
|
||||||
|
<label>
|
||||||
|
上传视频
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="video/*"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
void onUploadVideo(record, file);
|
||||||
|
}
|
||||||
|
event.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</Button>
|
||||||
|
<Button type="text" disabled={record.reviewStatus !== "approved"} onClick={() => onPublishEpisode(record)}>
|
||||||
|
发布
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,413 +1,15 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useParams } from "react-router-dom";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { DramaDetail } from "./components/DramaDetail";
|
||||||
import {
|
import { DramaList } from "./components/DramaList";
|
||||||
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<string, string> = {
|
|
||||||
not_submitted: "未提交",
|
|
||||||
pending: "待审核",
|
|
||||||
reviewing: "审核中",
|
|
||||||
approved: "已通过",
|
|
||||||
rejected: "已拒绝",
|
|
||||||
};
|
|
||||||
|
|
||||||
const PUBLISH_STATUS_LABEL: Record<string, string> = {
|
|
||||||
draft: "草稿",
|
|
||||||
scheduled: "定时发布",
|
|
||||||
published: "已发布",
|
|
||||||
offline: "已下线",
|
|
||||||
};
|
|
||||||
|
|
||||||
function DramasPage() {
|
function DramasPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
return <DramaDetailPage dramaId={Number(id)} />;
|
return <DramaDetail dramaId={Number(id)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <DramaListPage />;
|
return <DramaList />;
|
||||||
}
|
|
||||||
|
|
||||||
function DramaListPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { data, loading, reload, changePage } = usePageData<Drama>("/api/admin/v1/dramas");
|
|
||||||
const [visible, setVisible] = useState(false);
|
|
||||||
const [creating, setCreating] = useState(false);
|
|
||||||
|
|
||||||
const createDrama = async (values: Partial<Drama>) => {
|
|
||||||
setCreating(true);
|
|
||||||
try {
|
|
||||||
await createDramaApi(values);
|
|
||||||
Message.success("短剧创建成功");
|
|
||||||
setVisible(false);
|
|
||||||
await reload();
|
|
||||||
} finally {
|
|
||||||
setCreating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
className="section-card"
|
|
||||||
title="短剧管理"
|
|
||||||
extra={
|
|
||||||
<Button type="primary" icon={<IconPlus />} onClick={() => setVisible(true)}>
|
|
||||||
新增短剧
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Table
|
|
||||||
rowKey="id"
|
|
||||||
loading={loading}
|
|
||||||
data={data.list}
|
|
||||||
pagination={{
|
|
||||||
current: data.pagination.page,
|
|
||||||
total: data.pagination.total,
|
|
||||||
pageSize: data.pagination.pageSize,
|
|
||||||
onChange: (page, pageSize) => 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) => (
|
|
||||||
<Tag color={status === "published" ? "green" : "gray"}>{PUBLISH_STATUS_LABEL[String(status)] || String(status)}</Tag>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "推荐",
|
|
||||||
dataIndex: "isRecommended",
|
|
||||||
width: 100,
|
|
||||||
render: (value) => (value ? <Tag color="arcoblue">是</Tag> : <Tag>否</Tag>),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "操作",
|
|
||||||
width: 120,
|
|
||||||
render: (_, record) => (
|
|
||||||
<Button type="text" onClick={() => navigate(`/dramas/${record.id}`)}>
|
|
||||||
详情
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Modal title="创建短剧" visible={visible} onCancel={() => setVisible(false)} footer={null} style={{ width: 640 }}>
|
|
||||||
<Form
|
|
||||||
layout="vertical"
|
|
||||||
onSubmit={createDrama}
|
|
||||||
initialValues={{
|
|
||||||
status: "draft",
|
|
||||||
isRecommended: false,
|
|
||||||
sortOrder: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Form.Item label="标题" field="title" rules={[{ required: true }]}>
|
|
||||||
<Input placeholder="请输入短剧标题" />
|
|
||||||
</Form.Item>
|
|
||||||
<Row gutter={16}>
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item label="类型" field="type" rules={[{ required: true }]}>
|
|
||||||
<Input placeholder="请输入类型,如复仇、爱情、动作等" />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item label="状态" field="status">
|
|
||||||
<Select placeholder="请选择状态">
|
|
||||||
{STATUS_OPTIONS.map((option) => (
|
|
||||||
<Select.Option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
<Form.Item label="封面地址" field="coverUrl" rules={[{ required: true }]}>
|
|
||||||
<Input placeholder="请输入封面图片 URL" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="简介" field="description">
|
|
||||||
<Input.TextArea placeholder="请输入短剧简介" autoSize={{ minRows: 2, maxRows: 4 }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="标签" field="tags">
|
|
||||||
<InputTag placeholder="输入后回车添加标签" allowClear />
|
|
||||||
</Form.Item>
|
|
||||||
<Row gutter={16}>
|
|
||||||
<Col span={8}>
|
|
||||||
<Form.Item label="地区" field="region">
|
|
||||||
<Input placeholder="如 中国" />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<Form.Item label="语言" field="language">
|
|
||||||
<Input placeholder="如 zh-CN" />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col span={8}>
|
|
||||||
<Form.Item label="年份" field="year">
|
|
||||||
<InputNumber placeholder="如 2026" min={1900} style={{ width: "100%" }} />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
<Row gutter={16}>
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item label="排序权重" field="sortOrder">
|
|
||||||
<InputNumber placeholder="数值越大越靠前" style={{ width: "100%" }} />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item label="推荐" field="isRecommended" triggerPropName="checked">
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
<div className="modal-actions">
|
|
||||||
<Button onClick={() => setVisible(false)}>取消</Button>
|
|
||||||
<Button type="primary" htmlType="submit" loading={creating}>
|
|
||||||
创建
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DramaDetailPage({ dramaId }: { dramaId: number }) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [drama, setDrama] = useState<Drama | null>(null);
|
|
||||||
const [episodes, setEpisodes] = useState<Episode[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [episodeCount, setEpisodeCount] = useState(1);
|
|
||||||
const [uploadingEpisodeId, setUploadingEpisodeId] = useState<number | null>(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 (
|
|
||||||
<div className="page-stack">
|
|
||||||
<Card
|
|
||||||
className="section-card"
|
|
||||||
loading={loading && !drama}
|
|
||||||
title={
|
|
||||||
<Space>
|
|
||||||
<Button icon={<IconArrowLeft />} onClick={() => navigate("/dramas")} />
|
|
||||||
短剧详情
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
extra={
|
|
||||||
<Space>
|
|
||||||
<Button icon={<IconRefresh />} onClick={loadDetail}>
|
|
||||||
刷新
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => updateDramaStatus("draft")}>设为草稿</Button>
|
|
||||||
<Button type="primary" onClick={() => updateDramaStatus("published")}>
|
|
||||||
上架短剧
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{drama && (
|
|
||||||
<Descriptions
|
|
||||||
column={3}
|
|
||||||
data={[
|
|
||||||
{ label: "短剧ID", value: drama.id },
|
|
||||||
{ label: "名称", value: drama.title },
|
|
||||||
{ label: "地区", value: drama.region || "-" },
|
|
||||||
{ label: "类型", value: drama.type },
|
|
||||||
{ label: "总集数", value: drama.totalEpisodes || 0 },
|
|
||||||
{ label: "已发布集数", value: drama.publishedEpisodes || 0 },
|
|
||||||
{
|
|
||||||
label: "状态",
|
|
||||||
value: PUBLISH_STATUS_LABEL[drama.status] || drama.status,
|
|
||||||
},
|
|
||||||
{ label: "封面", value: drama.coverUrl },
|
|
||||||
{ label: "简介", value: drama.description || "-" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card
|
|
||||||
className="section-card"
|
|
||||||
title="剧集配置"
|
|
||||||
extra={
|
|
||||||
<Space>
|
|
||||||
<InputNumber min={1} max={500} value={episodeCount} onChange={(value) => setEpisodeCount(Number(value) || 1)} />
|
|
||||||
<Button type="primary" onClick={configureEpisodes}>
|
|
||||||
保存集数
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Table
|
|
||||||
rowKey="id"
|
|
||||||
loading={loading}
|
|
||||||
data={episodes}
|
|
||||||
pagination={false}
|
|
||||||
columns={[
|
|
||||||
{ title: "集数", dataIndex: "episodeNo", width: 80 },
|
|
||||||
{ title: "标题", dataIndex: "title" },
|
|
||||||
{
|
|
||||||
title: "BytePlus VID",
|
|
||||||
dataIndex: "byteplusVid",
|
|
||||||
width: 180,
|
|
||||||
render: (value) => 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) => (
|
|
||||||
<Tag color={status === "approved" ? "green" : "orange"}>{REVIEW_STATUS_LABEL[String(status)] || String(status)}</Tag>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "发布状态",
|
|
||||||
dataIndex: "publishStatus",
|
|
||||||
width: 120,
|
|
||||||
render: (status) => (
|
|
||||||
<Tag color={status === "published" ? "green" : "gray"}>{PUBLISH_STATUS_LABEL[String(status)] || String(status)}</Tag>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "操作",
|
|
||||||
width: 280,
|
|
||||||
render: (_, record) => (
|
|
||||||
<Space>
|
|
||||||
<Button loading={uploadingEpisodeId === record.id}>
|
|
||||||
<label>
|
|
||||||
上传视频
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="video/*"
|
|
||||||
style={{ display: "none" }}
|
|
||||||
onChange={(event) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
void uploadEpisodeVideo(record, file);
|
|
||||||
}
|
|
||||||
event.target.value = "";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</Button>
|
|
||||||
<Button type="text" disabled={record.reviewStatus !== "approved"} onClick={() => publishEpisode(record)}>
|
|
||||||
发布
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DramasPage;
|
export default DramasPage;
|
||||||
|
|||||||
20
src/Drama/interface.ts
Normal file
20
src/Drama/interface.ts
Normal file
@@ -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<void>;
|
||||||
|
onPublishEpisode: (episode: Episode) => void | Promise<void>;
|
||||||
|
};
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Card } from '@arco-design/web-react';
|
import { Card } from '@arco-design/web-react';
|
||||||
import type { RequestLog } from '../api/types';
|
import { logApi } from '../api';
|
||||||
import { usePageData } from '../hooks/usePageData';
|
import { usePageData } from '../hooks/usePageData';
|
||||||
import { RequestLogTable } from '../components/RequestLogTable';
|
import { RequestLogTable } from '../components/RequestLogTable';
|
||||||
|
import type { RequestLog } from './interface';
|
||||||
|
|
||||||
function LogsPage() {
|
function LogsPage() {
|
||||||
const { data, loading } = usePageData<RequestLog>('/api/admin/v1/logs/requests');
|
const { data, loading } = usePageData<RequestLog>(logApi.listRequestLogs);
|
||||||
return <Card className="section-card" title="请求日志"><RequestLogTable data={data.list} loading={loading} /></Card>;
|
return <Card className="section-card" title="请求日志"><RequestLogTable data={data.list} loading={loading} /></Card>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
3
src/Log/interface.ts
Normal file
3
src/Log/interface.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type { RequestLog } from "../api";
|
||||||
|
|
||||||
|
export type { RequestLog };
|
||||||
31
src/Login/components/LoginPanel.tsx
Normal file
31
src/Login/components/LoginPanel.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex items-center justify-center bg-[#f7f8fa] bg-[image:linear-gradient(180deg,rgba(255,255,255,0.86),rgba(247,248,250,0.94))] p-12">
|
||||||
|
<div className="w-full max-w-[420px] rounded-lg border border-line bg-white p-9 shadow-login">
|
||||||
|
<div className="mb-[30px] flex items-center gap-3.5">
|
||||||
|
<span className="grid h-11 w-11 place-items-center rounded-lg bg-brand font-bold text-white">CT</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-2 mt-0 text-2xl">后台登录</h2>
|
||||||
|
<p className="m-0 text-muted">使用管理员账号进入控制台。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Form layout="vertical" onSubmit={onSubmit} initialValues={{ username: "admin", password: "admin123" }}>
|
||||||
|
<Form.Item label="用户名" field="username" rules={[{ required: true }]}>
|
||||||
|
<Input prefix={<IconUser />} placeholder="admin" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="密码" field="password" rules={[{ required: true }]}>
|
||||||
|
<Input.Password prefix={<IconLock />} placeholder="admin123" />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={loading} long>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
<div className="mt-[22px] border-t border-canvas pt-[18px] text-xs text-muted">API 服务:{apiBaseUrl}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/Login/components/LoginVisual.tsx
Normal file
29
src/Login/components/LoginVisual.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export function LoginVisual() {
|
||||||
|
return (
|
||||||
|
<div className="relative hidden flex-col justify-between overflow-hidden px-14 pb-12 pt-11 text-white after:pointer-events-none after:absolute after:inset-0 after:bg-[linear-gradient(180deg,rgba(255,255,255,0.08),rgba(255,255,255,0))] min-[901px]:flex bg-[linear-gradient(135deg,rgba(15,23,42,0.98)_0%,rgba(22,93,255,0.94)_54%,rgba(15,118,110,0.96)_100%),radial-gradient(circle_at_18%_18%,rgba(255,255,255,0.18),transparent_28%),radial-gradient(circle_at_82%_72%,rgba(255,255,255,0.12),transparent_32%)]">
|
||||||
|
<div className="relative z-[1] flex items-center gap-2.5 font-bold">
|
||||||
|
<span className="grid h-7 w-7 place-items-center rounded-md bg-brand text-sm text-white">CT</span>
|
||||||
|
<span>cth-tk-admin</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative z-[1] max-w-[640px]">
|
||||||
|
<span className="mb-[18px] inline-flex rounded-full border border-white/20 px-3 py-1.5 text-[13px] text-white/80">TikTok 短剧运营后台</span>
|
||||||
|
<h1 className="mb-4 mt-0 text-[42px] leading-[1.16]">CTH TikTok 短剧管理后台</h1>
|
||||||
|
<p className="m-0 max-w-[560px] text-base leading-[1.8] text-white/75">集中处理短剧内容、剧集发布、订单支付、播放日志和系统权限,帮助运营人员稳定管理短剧业务。</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative z-[1] grid max-w-[640px] grid-cols-3 gap-3">
|
||||||
|
<div className="rounded-lg border border-white/20 bg-white/10 p-4 backdrop-blur-[10px]">
|
||||||
|
<strong className="mb-1.5 block text-xl">内容</strong>
|
||||||
|
<span className="text-white/70">短剧与剧集管理</span>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-white/20 bg-white/10 p-4 backdrop-blur-[10px]">
|
||||||
|
<strong className="mb-1.5 block text-xl">权限</strong>
|
||||||
|
<span className="text-white/70">角色化后台授权</span>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-white/20 bg-white/10 p-4 backdrop-blur-[10px]">
|
||||||
|
<strong className="mb-1.5 block text-xl">日志</strong>
|
||||||
|
<span className="text-white/70">请求与播放追踪</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Form, Input, Message } from '@arco-design/web-react';
|
import { Message } from '@arco-design/web-react';
|
||||||
import { IconLock, IconUser } from '@arco-design/web-react/icon';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { login } from '../api/auth';
|
import { authApi } from '../api';
|
||||||
import { API_BASE_URL, TOKEN_KEY } from '../api/client';
|
import { LoginPanel } from './components/LoginPanel';
|
||||||
|
import { LoginVisual } from './components/LoginVisual';
|
||||||
|
import type { LoginFormValues } from './interface';
|
||||||
|
|
||||||
function LoginPage() {
|
function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const onSubmit = async (values: { username: string; password: string }) => {
|
const onSubmit = async (values: LoginFormValues) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await login(values);
|
const data = await authApi.login(values);
|
||||||
localStorage.setItem(TOKEN_KEY, data.accessToken);
|
authApi.saveAuthToken(data.accessToken);
|
||||||
Message.success('登录成功');
|
Message.success('登录成功');
|
||||||
navigate('/dashboard', { replace: true });
|
navigate('/dashboard', { replace: true });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -23,43 +24,8 @@ function LoginPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-screen grid-cols-1 bg-[#f7f8fa] min-[901px]:grid-cols-[minmax(520px,0.95fr)_minmax(420px,1fr)]">
|
<div className="grid min-h-screen grid-cols-1 bg-[#f7f8fa] min-[901px]:grid-cols-[minmax(520px,0.95fr)_minmax(420px,1fr)]">
|
||||||
<div className="relative hidden flex-col justify-between overflow-hidden px-14 pb-12 pt-11 text-white after:pointer-events-none after:absolute after:inset-0 after:bg-[linear-gradient(180deg,rgba(255,255,255,0.08),rgba(255,255,255,0))] min-[901px]:flex bg-[linear-gradient(135deg,rgba(15,23,42,0.98)_0%,rgba(22,93,255,0.94)_54%,rgba(15,118,110,0.96)_100%),radial-gradient(circle_at_18%_18%,rgba(255,255,255,0.18),transparent_28%),radial-gradient(circle_at_82%_72%,rgba(255,255,255,0.12),transparent_32%)]">
|
<LoginVisual />
|
||||||
<div className="relative z-[1] flex items-center gap-2.5 font-bold">
|
<LoginPanel loading={loading} apiBaseUrl={authApi.getApiBaseUrl()} onSubmit={onSubmit} />
|
||||||
<span className="grid h-7 w-7 place-items-center rounded-md bg-brand text-sm text-white">CT</span>
|
|
||||||
<span>cth-tk-admin</span>
|
|
||||||
</div>
|
|
||||||
<div className="relative z-[1] max-w-[640px]">
|
|
||||||
<span className="mb-[18px] inline-flex rounded-full border border-white/20 px-3 py-1.5 text-[13px] text-white/80">TikTok 短剧运营后台</span>
|
|
||||||
<h1 className="mb-4 mt-0 text-[42px] leading-[1.16]">CTH TikTok 短剧管理后台</h1>
|
|
||||||
<p className="m-0 max-w-[560px] text-base leading-[1.8] text-white/75">集中处理短剧内容、剧集发布、订单支付、播放日志和系统权限,帮助运营人员稳定管理短剧业务。</p>
|
|
||||||
</div>
|
|
||||||
<div className="relative z-[1] grid max-w-[640px] grid-cols-3 gap-3">
|
|
||||||
<div className="rounded-lg border border-white/20 bg-white/10 p-4 backdrop-blur-[10px]"><strong className="mb-1.5 block text-xl">内容</strong><span className="text-white/70">短剧与剧集管理</span></div>
|
|
||||||
<div className="rounded-lg border border-white/20 bg-white/10 p-4 backdrop-blur-[10px]"><strong className="mb-1.5 block text-xl">权限</strong><span className="text-white/70">角色化后台授权</span></div>
|
|
||||||
<div className="rounded-lg border border-white/20 bg-white/10 p-4 backdrop-blur-[10px]"><strong className="mb-1.5 block text-xl">日志</strong><span className="text-white/70">请求与播放追踪</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center bg-[linear-gradient(180deg,rgba(255,255,255,0.86),rgba(247,248,250,0.94)),#f7f8fa] p-12">
|
|
||||||
<div className="w-full max-w-[420px] rounded-lg border border-line bg-white p-9 shadow-login">
|
|
||||||
<div className="mb-[30px] flex items-center gap-3.5">
|
|
||||||
<span className="grid h-11 w-11 place-items-center rounded-lg bg-brand font-bold text-white">CT</span>
|
|
||||||
<div>
|
|
||||||
<h2 className="mb-2 mt-0 text-2xl">后台登录</h2>
|
|
||||||
<p className="m-0 text-muted">使用管理员账号进入控制台。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Form layout="vertical" onSubmit={onSubmit} initialValues={{ username: 'admin', password: 'admin123' }}>
|
|
||||||
<Form.Item label="用户名" field="username" rules={[{ required: true }]}>
|
|
||||||
<Input prefix={<IconUser />} placeholder="admin" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="密码" field="password" rules={[{ required: true }]}>
|
|
||||||
<Input.Password prefix={<IconLock />} placeholder="admin123" />
|
|
||||||
</Form.Item>
|
|
||||||
<Button type="primary" htmlType="submit" loading={loading} long>登录</Button>
|
|
||||||
</Form>
|
|
||||||
<div className="mt-[22px] border-t border-canvas pt-[18px] text-xs text-muted">API 服务:{API_BASE_URL}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
9
src/Login/interface.ts
Normal file
9
src/Login/interface.ts
Normal file
@@ -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<void>;
|
||||||
|
};
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Card, Form, Input, Message, Modal, Select, Space, Table, Tag } from '@arco-design/web-react';
|
import { Button, Card, Form, Input, Message, Modal, Select, Space, Table, Tag } from '@arco-design/web-react';
|
||||||
import { IconPlus } from '@arco-design/web-react/icon';
|
import { IconPlus } from '@arco-design/web-react/icon';
|
||||||
import type { AdminUser, Role } from '../api/types';
|
import { personnelApi, roleApi } from '../api';
|
||||||
import { createPersonnel as createPersonnelApi, updateAdminUserRoles, updateAdminUserStatus } from '../api/personnel';
|
|
||||||
import { usePageData } from '../hooks/usePageData';
|
import { usePageData } from '../hooks/usePageData';
|
||||||
|
import type { AdminUser, CreatePersonnelFormValues, Role } from './interface';
|
||||||
|
|
||||||
function PersonnelPage() {
|
function PersonnelPage() {
|
||||||
const { data, loading, reload, changePage } = usePageData<AdminUser>('/api/admin/v1/admin-users');
|
const { data, loading, reload, changePage } = usePageData<AdminUser>(personnelApi.listPersonnel);
|
||||||
const roles = usePageData<Role>('/api/admin/v1/roles');
|
const roles = usePageData<Role>(roleApi.listRoles);
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
const [createVisible, setCreateVisible] = useState(false);
|
const [createVisible, setCreateVisible] = useState(false);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
@@ -21,16 +21,10 @@ function PersonnelPage() {
|
|||||||
setCreateVisible(true);
|
setCreateVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createPersonnel = async (values: {
|
const createPersonnel = async (values: CreatePersonnelFormValues) => {
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
nickname?: string;
|
|
||||||
email?: string;
|
|
||||||
roleIds?: number[];
|
|
||||||
}) => {
|
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
await createPersonnelApi(values);
|
await personnelApi.createPersonnel(values);
|
||||||
Message.success('人员创建成功');
|
Message.success('人员创建成功');
|
||||||
setCreateVisible(false);
|
setCreateVisible(false);
|
||||||
await reload();
|
await reload();
|
||||||
@@ -51,7 +45,7 @@ function PersonnelPage() {
|
|||||||
}
|
}
|
||||||
setAssigning(true);
|
setAssigning(true);
|
||||||
try {
|
try {
|
||||||
await updateAdminUserRoles(selectedAdmin.id, selectedRoleIds);
|
await personnelApi.updateAdminUserRoles(selectedAdmin.id, selectedRoleIds);
|
||||||
Message.success('角色分配已更新');
|
Message.success('角色分配已更新');
|
||||||
setAssignVisible(false);
|
setAssignVisible(false);
|
||||||
await reload();
|
await reload();
|
||||||
@@ -61,7 +55,7 @@ function PersonnelPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateStatus = async (id: number, status: 'active' | 'disabled') => {
|
const updateStatus = async (id: number, status: 'active' | 'disabled') => {
|
||||||
await updateAdminUserStatus(id, status);
|
await personnelApi.updateAdminUserStatus(id, status);
|
||||||
Message.success('人员状态已更新');
|
Message.success('人员状态已更新');
|
||||||
await reload();
|
await reload();
|
||||||
};
|
};
|
||||||
|
|||||||
5
src/Personnel/interface.ts
Normal file
5
src/Personnel/interface.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { AdminUser, CreatePersonnelPayload, Role } from "../api";
|
||||||
|
|
||||||
|
export type { AdminUser, Role };
|
||||||
|
|
||||||
|
export type CreatePersonnelFormValues = CreatePersonnelPayload;
|
||||||
21
src/Profile/components/ProfileInfoList.tsx
Normal file
21
src/Profile/components/ProfileInfoList.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ProfileInfoItemProps, ProfileInfoListProps } from "../interface";
|
||||||
|
|
||||||
|
export function ProfileInfoList({ profile }: ProfileInfoListProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<ProfileInfoItem label="用户名" value={profile?.username || "-"} />
|
||||||
|
<ProfileInfoItem label="状态" value={profile?.status === "active" ? "启用" : "禁用"} />
|
||||||
|
<ProfileInfoItem label="角色" value={profile?.isSuperAdmin ? "超级管理员" : profile?.roles.map((role) => role.name).join("、") || "-"} />
|
||||||
|
<ProfileInfoItem label="创建时间" value={profile?.createdAt || "-"} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileInfoItem({ label, value }: ProfileInfoItemProps) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-line bg-panel p-3.5">
|
||||||
|
<span className="mb-2 block text-[13px] text-muted">{label}</span>
|
||||||
|
<strong className="break-words font-medium text-ink">{value}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/Profile/components/ProfileSummary.tsx
Normal file
14
src/Profile/components/ProfileSummary.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Avatar } from "@arco-design/web-react";
|
||||||
|
import type { ProfileSummaryProps } from "../interface";
|
||||||
|
|
||||||
|
export function ProfileSummary({ profile }: ProfileSummaryProps) {
|
||||||
|
return (
|
||||||
|
<div className="mb-6 flex items-center gap-4">
|
||||||
|
<Avatar size={56}>{profile?.username?.slice(0, 1).toUpperCase() || "A"}</Avatar>
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-1.5 mt-0 text-[22px]">{profile?.nickname || profile?.username || "管理员"}</h2>
|
||||||
|
<p className="m-0 text-muted">{profile?.email || "未设置邮箱"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 { useNavigate } from 'react-router-dom';
|
||||||
import type { AdminProfile } from '../api/types';
|
import { authApi } from '../api';
|
||||||
import { changePassword as changePasswordApi, getProfile, updateProfile as updateProfileApi } from '../api/auth';
|
import { ProfileInfoList } from './components/ProfileInfoList';
|
||||||
import { TOKEN_KEY } from '../api/client';
|
import { ProfileSummary } from './components/ProfileSummary';
|
||||||
|
import type { AdminProfile, ChangePasswordFormValues, ProfileFormValues } from './interface';
|
||||||
|
|
||||||
function ProfilePage() {
|
function ProfilePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -17,7 +18,7 @@ function ProfilePage() {
|
|||||||
const loadProfile = async () => {
|
const loadProfile = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await getProfile();
|
const data = await authApi.getProfile();
|
||||||
setProfile(data);
|
setProfile(data);
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
nickname: data.nickname,
|
nickname: data.nickname,
|
||||||
@@ -32,10 +33,10 @@ function ProfilePage() {
|
|||||||
void loadProfile();
|
void loadProfile();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateProfile = async (values: { nickname?: string; email?: string }) => {
|
const updateProfile = async (values: ProfileFormValues) => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const data = await updateProfileApi(values);
|
const data = await authApi.updateProfile(values);
|
||||||
setProfile(data);
|
setProfile(data);
|
||||||
Message.success('个人信息已更新');
|
Message.success('个人信息已更新');
|
||||||
} finally {
|
} 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) {
|
if (values.newPassword !== values.confirmPassword) {
|
||||||
Message.error('两次输入的新密码不一致');
|
Message.error('两次输入的新密码不一致');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setChangingPassword(true);
|
setChangingPassword(true);
|
||||||
try {
|
try {
|
||||||
await changePasswordApi({
|
await authApi.changePassword({
|
||||||
oldPassword: values.oldPassword,
|
oldPassword: values.oldPassword,
|
||||||
newPassword: values.newPassword,
|
newPassword: values.newPassword,
|
||||||
});
|
});
|
||||||
Message.success('密码修改成功,请重新登录');
|
Message.success('密码修改成功,请重新登录');
|
||||||
passwordForm.resetFields();
|
passwordForm.resetFields();
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
authApi.clearAuthToken();
|
||||||
navigate('/login', { replace: true });
|
navigate('/login', { replace: true });
|
||||||
} finally {
|
} finally {
|
||||||
setChangingPassword(false);
|
setChangingPassword(false);
|
||||||
@@ -66,19 +67,8 @@ function ProfilePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="grid items-start gap-4 min-[901px]:grid-cols-[minmax(0,1fr)_420px]">
|
<div className="grid items-start gap-4 min-[901px]:grid-cols-[minmax(0,1fr)_420px]">
|
||||||
<Card className="section-card min-[901px]:row-span-2" title="个人中心" loading={loading}>
|
<Card className="section-card min-[901px]:row-span-2" title="个人中心" loading={loading}>
|
||||||
<div className="mb-6 flex items-center gap-4">
|
<ProfileSummary profile={profile} />
|
||||||
<Avatar size={56}>{profile?.username?.slice(0, 1).toUpperCase() || 'A'}</Avatar>
|
<ProfileInfoList profile={profile} />
|
||||||
<div>
|
|
||||||
<h2 className="mb-1.5 mt-0 text-[22px]">{profile?.nickname || profile?.username || '管理员'}</h2>
|
|
||||||
<p className="m-0 text-muted">{profile?.email || '未设置邮箱'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
<div className="rounded-lg border border-line bg-panel p-3.5"><span className="mb-2 block text-[13px] text-muted">用户名</span><strong className="break-words font-medium text-ink">{profile?.username || '-'}</strong></div>
|
|
||||||
<div className="rounded-lg border border-line bg-panel p-3.5"><span className="mb-2 block text-[13px] text-muted">状态</span><strong className="break-words font-medium text-ink">{profile?.status === 'active' ? '启用' : '禁用'}</strong></div>
|
|
||||||
<div className="rounded-lg border border-line bg-panel p-3.5"><span className="mb-2 block text-[13px] text-muted">角色</span><strong className="break-words font-medium text-ink">{profile?.isSuperAdmin ? '超级管理员' : profile?.roles.map((role) => role.name).join('、') || '-'}</strong></div>
|
|
||||||
<div className="rounded-lg border border-line bg-panel p-3.5"><span className="mb-2 block text-[13px] text-muted">创建时间</span><strong className="break-words font-medium text-ink">{profile?.createdAt || '-'}</strong></div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="section-card" title="修改个人信息">
|
<Card className="section-card" title="修改个人信息">
|
||||||
<Form form={form} layout="vertical" onSubmit={updateProfile}>
|
<Form form={form} layout="vertical" onSubmit={updateProfile}>
|
||||||
|
|||||||
22
src/Profile/interface.ts
Normal file
22
src/Profile/interface.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
@@ -1,18 +1,6 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Tree } from "@arco-design/web-react";
|
import { Tree } from "@arco-design/web-react";
|
||||||
import type { Permission } from "../api/types";
|
import type { Permission, PermissionModuleConfig, PermissionTreeNode, PermissionTreePickerProps } from "../interface";
|
||||||
|
|
||||||
type PermissionTreeNode = {
|
|
||||||
key: string;
|
|
||||||
title: string;
|
|
||||||
children?: PermissionTreeNode[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type PermissionModuleConfig = {
|
|
||||||
prefix: string;
|
|
||||||
title: string;
|
|
||||||
subject: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const systemPermissionModules: PermissionModuleConfig[] = [
|
const systemPermissionModules: PermissionModuleConfig[] = [
|
||||||
{ prefix: "permission", title: "权限管理", subject: "权限" },
|
{ prefix: "permission", title: "权限管理", subject: "权限" },
|
||||||
@@ -123,15 +111,7 @@ function collectExpandedKeys(nodes: PermissionTreeNode[]) {
|
|||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PermissionTreePicker({
|
export function PermissionTreePicker({ permissions, value, onChange }: PermissionTreePickerProps) {
|
||||||
permissions,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
permissions: Permission[];
|
|
||||||
value: number[];
|
|
||||||
onChange: (permissionIds: number[]) => void;
|
|
||||||
}) {
|
|
||||||
const treeData = useMemo(() => buildPermissionTree(permissions), [permissions]);
|
const treeData = useMemo(() => buildPermissionTree(permissions), [permissions]);
|
||||||
const expandedKeys = useMemo(() => collectExpandedKeys(treeData), [treeData]);
|
const expandedKeys = useMemo(() => collectExpandedKeys(treeData), [treeData]);
|
||||||
const checkedKeys = value.map((id) => permissionKey(id));
|
const checkedKeys = value.map((id) => permissionKey(id));
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button, Card, Form, Input, Message, Modal, Space, Table, Tag } from "@arco-design/web-react";
|
import { Button, Card, Form, Input, Message, Modal, Space, Table, Tag } from "@arco-design/web-react";
|
||||||
import { IconPlus } from "@arco-design/web-react/icon";
|
import { IconPlus } from "@arco-design/web-react/icon";
|
||||||
import type { Permission, Role } from "../api/types";
|
import { roleApi } from "../api";
|
||||||
import { createRole as createRoleApi, updateRolePermissions } from "../api/roles";
|
|
||||||
import { usePageData } from "../hooks/usePageData";
|
import { usePageData } from "../hooks/usePageData";
|
||||||
import { PermissionTreePicker } from "./PermissionTreePicker";
|
import { PermissionTreePicker } from "./components/PermissionTreePicker";
|
||||||
|
import type { CreateRoleFormValues, Permission, Role } from "./interface";
|
||||||
|
|
||||||
function RolesPage() {
|
function RolesPage() {
|
||||||
const { data, loading, reload, changePage } = usePageData<Role>("/api/admin/v1/roles");
|
const { data, loading, reload, changePage } = usePageData<Role>(roleApi.listRoles);
|
||||||
const permissions = usePageData<Permission>("/api/admin/v1/permissions");
|
const permissions = usePageData<Permission>(roleApi.listPermissions);
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
const [createVisible, setCreateVisible] = useState(false);
|
const [createVisible, setCreateVisible] = useState(false);
|
||||||
const [creatingRole, setCreatingRole] = useState(false);
|
const [creatingRole, setCreatingRole] = useState(false);
|
||||||
@@ -24,10 +24,10 @@ function RolesPage() {
|
|||||||
setCreateVisible(true);
|
setCreateVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createRole = async (values: { code: string; name: string; description?: string }) => {
|
const createRole = async (values: CreateRoleFormValues) => {
|
||||||
setCreatingRole(true);
|
setCreatingRole(true);
|
||||||
try {
|
try {
|
||||||
await createRoleApi({
|
await roleApi.createRole({
|
||||||
...values,
|
...values,
|
||||||
permissionIds: createPermissionIds,
|
permissionIds: createPermissionIds,
|
||||||
});
|
});
|
||||||
@@ -51,7 +51,7 @@ function RolesPage() {
|
|||||||
}
|
}
|
||||||
setSavingPermissions(true);
|
setSavingPermissions(true);
|
||||||
try {
|
try {
|
||||||
await updateRolePermissions(selectedRole.id, selectedPermissionIds);
|
await roleApi.updateRolePermissions(selectedRole.id, selectedPermissionIds);
|
||||||
Message.success("角色权限已更新");
|
Message.success("角色权限已更新");
|
||||||
setAssignmentVisible(false);
|
setAssignmentVisible(false);
|
||||||
await reload();
|
await reload();
|
||||||
|
|||||||
27
src/Role/interface.ts
Normal file
27
src/Role/interface.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Button, Card, Message, Table, Tag } from '@arco-design/web-react';
|
import { Button, Card, Message, Table, Tag } from '@arco-design/web-react';
|
||||||
import type { AppUser } from '../api/types';
|
import { userApi } from '../api';
|
||||||
import { updateUserStatus } from '../api/users';
|
|
||||||
import { usePageData } from '../hooks/usePageData';
|
import { usePageData } from '../hooks/usePageData';
|
||||||
|
import type { AppUser } from './interface';
|
||||||
|
|
||||||
function UsersPage() {
|
function UsersPage() {
|
||||||
const { data, loading, reload, changePage } = usePageData<AppUser>('/api/admin/v1/users');
|
const { data, loading, reload, changePage } = usePageData<AppUser>(userApi.listUsers);
|
||||||
const update状态 = async (id: number, status: 'active' | 'disabled') => {
|
const update状态 = async (id: number, status: 'active' | 'disabled') => {
|
||||||
await updateUserStatus(id, status);
|
await userApi.updateUserStatus(id, status);
|
||||||
Message.success('用户状态已更新');
|
Message.success('用户状态已更新');
|
||||||
await reload();
|
await reload();
|
||||||
};
|
};
|
||||||
|
|||||||
3
src/User/interface.ts
Normal file
3
src/User/interface.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type { AppUser } from "../api";
|
||||||
|
|
||||||
|
export type { AppUser };
|
||||||
@@ -1,44 +1,37 @@
|
|||||||
import { api } from './client';
|
import { API_BASE_URL, TOKEN_KEY, request } from "../plugins/axios";
|
||||||
import type { AdminProfile, ApiResponse } from './types';
|
import type { AdminProfile, ApiResponse, ChangePasswordPayload, LoginPayload, UpdateProfilePayload } from "./interface";
|
||||||
|
|
||||||
export type LoginPayload = {
|
export function getApiBaseUrl() {
|
||||||
username: string;
|
return API_BASE_URL;
|
||||||
password: string;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateProfilePayload = {
|
export function saveAuthToken(token: string) {
|
||||||
nickname?: string;
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
email?: string;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
export type ChangePasswordPayload = {
|
export function clearAuthToken() {
|
||||||
oldPassword: string;
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
newPassword: string;
|
}
|
||||||
};
|
|
||||||
|
export function hasAuthToken() {
|
||||||
|
return Boolean(localStorage.getItem(TOKEN_KEY));
|
||||||
|
}
|
||||||
|
|
||||||
export async function login(payload: LoginPayload) {
|
export async function login(payload: LoginPayload) {
|
||||||
const response = await api.post<ApiResponse<{ accessToken: string }>>(
|
const response = await request.post<ApiResponse<{ accessToken: string }>>("/api/admin/v1/auth/login", payload);
|
||||||
'/api/admin/v1/auth/login',
|
return response.data.data;
|
||||||
payload,
|
|
||||||
);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProfile() {
|
export async function getProfile() {
|
||||||
const response = await api.get<ApiResponse<AdminProfile>>(
|
const response = await request.get<ApiResponse<AdminProfile>>("/api/admin/v1/auth/profile");
|
||||||
'/api/admin/v1/auth/profile',
|
return response.data.data;
|
||||||
);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProfile(payload: UpdateProfilePayload) {
|
export async function updateProfile(payload: UpdateProfilePayload) {
|
||||||
const response = await api.patch<ApiResponse<AdminProfile>>(
|
const response = await request.patch<ApiResponse<AdminProfile>>("/api/admin/v1/auth/profile", payload);
|
||||||
'/api/admin/v1/auth/profile',
|
return response.data.data;
|
||||||
payload,
|
|
||||||
);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function changePassword(payload: ChangePasswordPayload) {
|
export async function changePassword(payload: ChangePasswordPayload) {
|
||||||
await api.patch('/api/admin/v1/auth/password', payload);
|
await request.patch("/api/admin/v1/auth/password", payload);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ApiResponse<unknown>>) => {
|
|
||||||
const message =
|
|
||||||
error.response?.data?.message || error.message || "请求失败";
|
|
||||||
Message.error(message);
|
|
||||||
return Promise.reject(error);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export async function fetchPage<T>(
|
|
||||||
url: string,
|
|
||||||
params?: { page?: number; pageSize?: number },
|
|
||||||
): Promise<PageData<T>> {
|
|
||||||
const response = await api.get<ApiResponse<PageData<T>>>(url, { params });
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,25 @@
|
|||||||
import { api } from './client';
|
import { fetchPage, request } from "../plugins/axios";
|
||||||
import type { ApiResponse, DataOverview } from './types';
|
import type { ApiResponse, DataOverview, DramaMetric, EpisodeMetric, PageQuery, SyncJob } from "./interface";
|
||||||
|
|
||||||
export async function getDataOverview() {
|
export async function getDataOverview() {
|
||||||
const response = await api.get<ApiResponse<DataOverview>>(
|
const response = await request.get<ApiResponse<DataOverview>>(
|
||||||
'/api/admin/v1/data/overview',
|
'/api/admin/v1/data/overview',
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncDataMetrics() {
|
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<DramaMetric>('/api/admin/v1/data/dramas', query);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listEpisodeMetrics(query: PageQuery) {
|
||||||
|
return fetchPage<EpisodeMetric>('/api/admin/v1/data/episodes', query);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSyncJobs(query: PageQuery) {
|
||||||
|
return fetchPage<SyncJob>('/api/admin/v1/data/sync-jobs', query);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
import { api } from './client';
|
import { fetchPage, request } from "../plugins/axios";
|
||||||
import type { ApiResponse, Drama, Episode } from './types';
|
import type { ApiResponse, Drama, Episode, PageQuery } from "./interface";
|
||||||
|
|
||||||
|
export function listDramas(query: PageQuery) {
|
||||||
|
return fetchPage<Drama>('/api/admin/v1/dramas', query);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createDrama(payload: Partial<Drama>) {
|
export async function createDrama(payload: Partial<Drama>) {
|
||||||
await api.post('/api/admin/v1/dramas', payload);
|
await request.post('/api/admin/v1/dramas', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDrama(dramaId: number) {
|
export async function getDrama(dramaId: number) {
|
||||||
const response = await api.get<ApiResponse<Drama>>(
|
const response = await request.get<ApiResponse<Drama>>(
|
||||||
`/api/admin/v1/dramas/${dramaId}`,
|
`/api/admin/v1/dramas/${dramaId}`,
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDramaEpisodes(dramaId: number) {
|
export async function getDramaEpisodes(dramaId: number) {
|
||||||
const response = await api.get<ApiResponse<{ list: Episode[] }>>(
|
const response = await request.get<ApiResponse<{ list: Episode[] }>>(
|
||||||
`/api/admin/v1/dramas/${dramaId}/episodes`,
|
`/api/admin/v1/dramas/${dramaId}/episodes`,
|
||||||
{ params: { pageSize: 200 } },
|
{ params: { pageSize: 200 } },
|
||||||
);
|
);
|
||||||
@@ -24,7 +28,7 @@ export async function configureDramaEpisodes(
|
|||||||
dramaId: number,
|
dramaId: number,
|
||||||
episodeCount: number,
|
episodeCount: number,
|
||||||
) {
|
) {
|
||||||
await api.post(`/api/admin/v1/dramas/${dramaId}/episodes/batch`, {
|
await request.post(`/api/admin/v1/dramas/${dramaId}/episodes/batch`, {
|
||||||
episodeCount,
|
episodeCount,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -33,12 +37,12 @@ export async function updateDrama(
|
|||||||
dramaId: number,
|
dramaId: number,
|
||||||
payload: Partial<Pick<Drama, 'status'>>,
|
payload: Partial<Pick<Drama, 'status'>>,
|
||||||
) {
|
) {
|
||||||
await api.patch(`/api/admin/v1/dramas/${dramaId}`, payload);
|
await request.patch(`/api/admin/v1/dramas/${dramaId}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateEpisode(
|
export async function updateEpisode(
|
||||||
episodeId: number,
|
episodeId: number,
|
||||||
payload: Partial<Pick<Episode, 'publishStatus' | 'status'>>,
|
payload: Partial<Pick<Episode, 'publishStatus'>> & { status?: string },
|
||||||
) {
|
) {
|
||||||
await api.patch(`/api/admin/v1/episodes/${episodeId}`, payload);
|
await request.patch(`/api/admin/v1/episodes/${episodeId}`, payload);
|
||||||
}
|
}
|
||||||
|
|||||||
9
src/api/index.ts
Normal file
9
src/api/index.ts
Normal file
@@ -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";
|
||||||
220
src/api/interface.ts
Normal file
220
src/api/interface.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
export type ApiResponse<T> = {
|
||||||
|
code: number;
|
||||||
|
data: T;
|
||||||
|
message: string;
|
||||||
|
timestamp: number;
|
||||||
|
requestId: string;
|
||||||
|
cost: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PageQuery = {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PageData<T> = {
|
||||||
|
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[];
|
||||||
|
};
|
||||||
6
src/api/logs.ts
Normal file
6
src/api/logs.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { fetchPage } from "../plugins/axios";
|
||||||
|
import type { PageQuery, RequestLog } from "./interface";
|
||||||
|
|
||||||
|
export function listRequestLogs(query: PageQuery) {
|
||||||
|
return fetchPage<RequestLog>('/api/admin/v1/logs/requests', query);
|
||||||
|
}
|
||||||
@@ -1,15 +1,8 @@
|
|||||||
import { api } from './client';
|
import { request } from "../plugins/axios";
|
||||||
import type { ApiResponse, UploadTask } from './types';
|
import type { ApiResponse, CreateUploadTaskPayload, UploadTask } from "./interface";
|
||||||
|
|
||||||
export type CreateUploadTaskPayload = {
|
|
||||||
episodeId: number;
|
|
||||||
fileName: string;
|
|
||||||
contentType: string;
|
|
||||||
fileSize: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function createUploadTask(payload: CreateUploadTaskPayload) {
|
export async function createUploadTask(payload: CreateUploadTaskPayload) {
|
||||||
const response = await api.post<ApiResponse<UploadTask>>(
|
const response = await request.post<ApiResponse<UploadTask>>(
|
||||||
'/api/admin/v1/media/upload-tasks',
|
'/api/admin/v1/media/upload-tasks',
|
||||||
payload,
|
payload,
|
||||||
);
|
);
|
||||||
@@ -17,9 +10,9 @@ export async function createUploadTask(payload: CreateUploadTaskPayload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function completeUploadTask(jobId: string) {
|
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() {
|
export async function syncReviewStatus() {
|
||||||
await api.post('/api/admin/v1/media/review-status/sync', {});
|
await request.post('/api/admin/v1/media/review-status/sync', {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,24 @@
|
|||||||
import { api } from './client';
|
import { fetchPage, request } from "../plugins/axios";
|
||||||
|
import type { AdminUser, CreatePersonnelPayload, PageQuery } from "./interface";
|
||||||
|
|
||||||
export type CreatePersonnelPayload = {
|
export function listPersonnel(query: PageQuery) {
|
||||||
username: string;
|
return fetchPage<AdminUser>('/api/admin/v1/admin-users', query);
|
||||||
password: string;
|
}
|
||||||
nickname?: string;
|
|
||||||
email?: string;
|
|
||||||
roleIds?: number[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function createPersonnel(payload: CreatePersonnelPayload) {
|
export async function createPersonnel(payload: CreatePersonnelPayload) {
|
||||||
await api.post('/api/admin/v1/admin-users', {
|
await request.post('/api/admin/v1/admin-users', {
|
||||||
...payload,
|
...payload,
|
||||||
roleIds: payload.roleIds ?? [],
|
roleIds: payload.roleIds ?? [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAdminUserRoles(id: number, roleIds: number[]) {
|
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(
|
export async function updateAdminUserStatus(
|
||||||
id: number,
|
id: number,
|
||||||
status: 'active' | 'disabled',
|
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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {
|
export function listRoles(query: PageQuery) {
|
||||||
code: string;
|
return fetchPage<Role>('/api/admin/v1/roles', query);
|
||||||
name: string;
|
}
|
||||||
description?: string;
|
|
||||||
permissionIds: number[];
|
export function listPermissions(query: PageQuery) {
|
||||||
};
|
return fetchPage<Permission>('/api/admin/v1/permissions', query);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createRole(payload: CreateRolePayload) {
|
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(
|
export async function updateRolePermissions(
|
||||||
roleId: number,
|
roleId: number,
|
||||||
permissionIds: number[],
|
permissionIds: number[],
|
||||||
) {
|
) {
|
||||||
await api.patch(`/api/admin/v1/roles/${roleId}/permissions`, {
|
await request.patch(`/api/admin/v1/roles/${roleId}/permissions`, {
|
||||||
permissionIds,
|
permissionIds,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
178
src/api/types.ts
178
src/api/types.ts
@@ -1,178 +0,0 @@
|
|||||||
export type ApiResponse<T> = {
|
|
||||||
code: number;
|
|
||||||
data: T;
|
|
||||||
message: string;
|
|
||||||
timestamp: number;
|
|
||||||
requestId: string;
|
|
||||||
cost: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PageData<T> = {
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
@@ -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<AppUser>('/api/admin/v1/users', query);
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateUserStatus(
|
export async function updateUserStatus(
|
||||||
id: number,
|
id: number,
|
||||||
status: 'active' | 'disabled',
|
status: 'active' | 'disabled',
|
||||||
) {
|
) {
|
||||||
await api.patch(`/api/admin/v1/users/${id}/status`, { status });
|
await request.patch(`/api/admin/v1/users/${id}/status`, { status });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { Card, Typography } from '@arco-design/web-react';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
export function NoAccessPage() {
|
|
||||||
return (
|
|
||||||
<Card className="section-card" title="暂无权限">
|
|
||||||
<Text type="secondary">当前账号没有访问该功能的权限,请联系系统管理员分配角色。</Text>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
11
src/components/NoAccessPage/index.tsx
Normal file
11
src/components/NoAccessPage/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Card, Typography } from "@arco-design/web-react";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export function NoAccessPage() {
|
||||||
|
return (
|
||||||
|
<Card className="section-card" title="暂无权限">
|
||||||
|
<Text type="secondary">当前账号没有访问该功能的权限,请联系系统管理员分配角色。</Text>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
|
||||||
<Table rowKey="requestId" loading={loading} data={data} pagination={false} columns={[
|
|
||||||
{ title: '请求 ID', dataIndex: 'requestId', ellipsis: true },
|
|
||||||
{ title: '方法', dataIndex: 'method', width: 100, render: (value) => <Tag>{value}</Tag> },
|
|
||||||
{ title: '路径', dataIndex: 'path', ellipsis: true },
|
|
||||||
{ title: '状态', dataIndex: 'statusCode', width: 100, render: (value) => <Tag color={value < 400 ? 'green' : 'red'}>{value}</Tag> },
|
|
||||||
{ title: '耗时', dataIndex: 'costMs', width: 100, render: (value) => String(value) + 'ms' },
|
|
||||||
{ title: '消息', dataIndex: 'message', width: 180 },
|
|
||||||
]} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
21
src/components/RequestLogTable/index.tsx
Normal file
21
src/components/RequestLogTable/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Table, Tag } from "@arco-design/web-react";
|
||||||
|
import type { RequestLogTableProps } from "./interface";
|
||||||
|
|
||||||
|
export function RequestLogTable({ data, loading }: RequestLogTableProps) {
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
rowKey="requestId"
|
||||||
|
loading={loading}
|
||||||
|
data={data}
|
||||||
|
pagination={false}
|
||||||
|
columns={[
|
||||||
|
{ title: "请求 ID", dataIndex: "requestId", ellipsis: true },
|
||||||
|
{ title: "方法", dataIndex: "method", width: 100, render: (value) => <Tag>{value}</Tag> },
|
||||||
|
{ title: "路径", dataIndex: "path", ellipsis: true },
|
||||||
|
{ title: "状态", dataIndex: "statusCode", width: 100, render: (value) => <Tag color={value < 400 ? "green" : "red"}>{value}</Tag> },
|
||||||
|
{ title: "耗时", dataIndex: "costMs", width: 100, render: (value) => String(value) + "ms" },
|
||||||
|
{ title: "消息", dataIndex: "message", width: 180 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/components/RequestLogTable/interface.ts
Normal file
6
src/components/RequestLogTable/interface.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { RequestLog } from "../../api";
|
||||||
|
|
||||||
|
export type RequestLogTableProps = {
|
||||||
|
data: RequestLog[];
|
||||||
|
loading: boolean;
|
||||||
|
};
|
||||||
5
src/hooks/interface.ts
Normal file
5
src/hooks/interface.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { PageData, PageQuery } from "../api";
|
||||||
|
|
||||||
|
export type { PageData, PageQuery };
|
||||||
|
|
||||||
|
export type PageFetcher<T> = (query: PageQuery) => Promise<PageData<T>>;
|
||||||
@@ -1,9 +1,4 @@
|
|||||||
import type { PageData } from '../api/types';
|
import type { PageData, PageQuery } from "./interface";
|
||||||
|
|
||||||
type PageQuery = {
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function resolvePageData<T>(
|
export function resolvePageData<T>(
|
||||||
response: PageData<T>,
|
response: PageData<T>,
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { fetchPage } from "../api/client";
|
import type { PageData, PageFetcher, PageQuery } from "./interface";
|
||||||
import type { PageData } from "../api/types";
|
|
||||||
import { resolvePageData } from "./pageData";
|
import { resolvePageData } from "./pageData";
|
||||||
|
|
||||||
type PageQuery = {
|
export function usePageData<T>(fetcher: PageFetcher<T>, enabled = true) {
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function usePageData<T>(url: string, enabled = true) {
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [pageQuery, setPageQuery] = useState<PageQuery>({
|
const [pageQuery, setPageQuery] = useState<PageQuery>({
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -25,7 +19,7 @@ export function usePageData<T>(url: string, enabled = true) {
|
|||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetchPage<T>(url, query);
|
const response = await fetcher(query);
|
||||||
setData(resolvePageData(response, query));
|
setData(resolvePageData(response, query));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -40,7 +34,7 @@ export function usePageData<T>(url: string, enabled = true) {
|
|||||||
if (enabled) {
|
if (enabled) {
|
||||||
void load(pageQuery);
|
void load(pageQuery);
|
||||||
}
|
}
|
||||||
}, [url, enabled, pageQuery.page, pageQuery.pageSize]);
|
}, [fetcher, enabled, pageQuery.page, pageQuery.pageSize]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -2,47 +2,24 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@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 {
|
@layer components {
|
||||||
.section-card {
|
.section-card {
|
||||||
@apply rounded-lg;
|
@apply rounded-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-stack {
|
.page-stack {
|
||||||
@apply flex flex-col gap-4;
|
@apply flex flex-col gap-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-actions {
|
.modal-actions {
|
||||||
@apply mt-6 flex justify-end gap-3;
|
@apply mt-6 flex justify-end gap-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-label {
|
.form-label {
|
||||||
@apply mb-2 text-sm font-medium text-[#4e5969];
|
@apply mb-2 text-sm font-medium text-[#4e5969];
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-help {
|
.form-help {
|
||||||
@apply -mt-3 mb-[18px] text-[13px] leading-[1.6] text-muted;
|
@apply -mt-3 mb-[18px] text-[13px] leading-[1.6] text-muted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
import { Card } from '@arco-design/web-react';
|
import { Card } from '@arco-design/web-react';
|
||||||
import type { AdminProfile } from '../api/types';
|
|
||||||
import { hasPermissions } from '../utils/permission';
|
import { hasPermissions } from '../utils/permission';
|
||||||
import { NoAccessPage } from '../components/NoAccessPage';
|
import { NoAccessPage } from '../components/NoAccessPage';
|
||||||
|
import type { PermissionRouteProps } from './interface';
|
||||||
|
|
||||||
export function PermissionRoute({
|
export function PermissionRoute({
|
||||||
profile,
|
profile,
|
||||||
requiredPermissions,
|
requiredPermissions,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: PermissionRouteProps) {
|
||||||
profile: AdminProfile | null;
|
|
||||||
requiredPermissions?: string[];
|
|
||||||
children: ReactElement;
|
|
||||||
}) {
|
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
return <Card className="section-card" loading />;
|
return <Card className="section-card" loading />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { ReactElement } from 'react';
|
|
||||||
import { Navigate } from 'react-router-dom';
|
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 }) {
|
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||||
if (!localStorage.getItem(TOKEN_KEY)) {
|
if (!authApi.hasAuthToken()) {
|
||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
return children;
|
return children;
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { Avatar, Button, Dropdown, Layout, Menu, Space, Typography } from "@arco-design/web-react";
|
import { Avatar, Button, Dropdown, Layout, Menu, Space, Typography } from "@arco-design/web-react";
|
||||||
import { IconMenuFold, IconMenuUnfold } from "@arco-design/web-react/icon";
|
import { IconMenuFold, IconMenuUnfold } from "@arco-design/web-react/icon";
|
||||||
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||||
import type { AdminProfile } from "../api/types";
|
import { authApi } from "../api";
|
||||||
import { getProfile } from "../api/auth";
|
import type { AdminProfile } from "./interface";
|
||||||
import { TOKEN_KEY } from "../api/client";
|
|
||||||
import { filterMenuItems, findCurrentMenu, menuItems, renderMenuItem } from "./menu";
|
import { filterMenuItems, findCurrentMenu, menuItems, renderMenuItem } from "./menu";
|
||||||
import { PermissionRoute } from "./PermissionRoute";
|
import { PermissionRoute } from "./PermissionRoute";
|
||||||
import DashboardPage from "../Dashboard";
|
import DashboardPage from "../Dashboard";
|
||||||
@@ -29,10 +28,10 @@ export function AdminLayout() {
|
|||||||
const [openMenuKeys, setOpenMenuKeys] = useState<string[]>(current?.parent ? [current.parent.key] : []);
|
const [openMenuKeys, setOpenMenuKeys] = useState<string[]>(current?.parent ? [current.parent.key] : []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getProfile()
|
authApi.getProfile()
|
||||||
.then((data) => setProfile(data))
|
.then((data) => setProfile(data))
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
authApi.clearAuthToken();
|
||||||
navigate("/login", { replace: true });
|
navigate("/login", { replace: true });
|
||||||
});
|
});
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
@@ -44,7 +43,7 @@ export function AdminLayout() {
|
|||||||
}, [current, openMenuKeys]);
|
}, [current, openMenuKeys]);
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
authApi.clearAuthToken();
|
||||||
navigate("/login", { replace: true });
|
navigate("/login", { replace: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
22
src/layout/interface.ts
Normal file
22
src/layout/interface.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import { Menu } from '@arco-design/web-react';
|
import { Menu } from '@arco-design/web-react';
|
||||||
import {
|
import {
|
||||||
IconApps,
|
IconApps,
|
||||||
@@ -8,17 +7,9 @@ import {
|
|||||||
IconUser,
|
IconUser,
|
||||||
IconVideoCamera,
|
IconVideoCamera,
|
||||||
} from '@arco-design/web-react/icon';
|
} from '@arco-design/web-react/icon';
|
||||||
import type { AdminProfile } from '../api/types';
|
import type { AdminProfile, MenuConfig } from './interface';
|
||||||
import { hasPermissions } from '../utils/permission';
|
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 SYSTEM_MENU_KEY = '/system';
|
||||||
|
|
||||||
export const menuItems: MenuConfig[] = [
|
export const menuItems: MenuConfig[] = [
|
||||||
|
|||||||
30
src/plugins/axios.ts
Normal file
30
src/plugins/axios.ts
Normal file
@@ -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<ApiResponse<unknown>>) => {
|
||||||
|
const message = error.response?.data?.message || error.message || "请求失败";
|
||||||
|
Message.error(message);
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function fetchPage<T>(url: string, params?: PageQuery): Promise<PageData<T>> {
|
||||||
|
const response = await request.get<ApiResponse<PageData<T>>>(url, { params });
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AdminProfile, Permission } from '../api/types';
|
import type { AdminProfile, Permission } from '../api';
|
||||||
|
|
||||||
export function getPermissionCodes(profile: AdminProfile | null) {
|
export function getPermissionCodes(profile: AdminProfile | null) {
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
|
|||||||
Reference in New Issue
Block a user