build: 引入tailwindcss并迁移原有样式到原子类
1. 新增postcss、autoprefixer、tailwindcss依赖并初始化配置 2. 移除全局及页面级CSS文件,将样式替换为tailwind原子类 3. 重构API调用逻辑,拆分接口请求函数 4. 统一项目样式规范,优化布局与组件样式
This commit is contained in:
@@ -1,28 +0,0 @@
|
||||
.page-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.metric-card .label {
|
||||
color: #86909c;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.metric-card .value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.page-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,9 @@ import { usePageData } from '../hooks/usePageData';
|
||||
import { hasPermissions } from '../utils/permission';
|
||||
import { NoAccessPage } from '../components/NoAccessPage';
|
||||
import { RequestLogTable } from '../components/RequestLogTable';
|
||||
import './index.css';
|
||||
|
||||
function MetricCard({ label, value }: { label: string; value: number | string }) {
|
||||
return <Card className="metric-card"><div className="label">{label}</div><div className="value">{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 }) {
|
||||
@@ -21,7 +20,7 @@ function DashboardPage({ profile }: { profile: AdminProfile | null }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-grid">
|
||||
<div className="mb-4 grid grid-cols-1 gap-4 sm:grid-cols-2 min-[901px]:grid-cols-4">
|
||||
{canReadDramas && <MetricCard label="短剧管理" value={dramas.data.pagination.total} />}
|
||||
{canReadUsers && <MetricCard label="用户管理" value={users.data.pagination.total} />}
|
||||
{canReadLogs && <MetricCard label="请求日志" value={logs.data.pagination.total} />}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Card, Grid, Message, Statistic, Table } from '@arco-design/web-react';
|
||||
import { IconRefresh } from '@arco-design/web-react/icon';
|
||||
import type { ApiResponse, DataOverview, DramaMetric, EpisodeMetric, SyncJob } from '../api/types';
|
||||
import { api } from '../api/client';
|
||||
import type { DataOverview, DramaMetric, EpisodeMetric, SyncJob } from '../api/types';
|
||||
import { getDataOverview, syncDataMetrics } from '../api/data';
|
||||
import { usePageData } from '../hooks/usePageData';
|
||||
|
||||
const { Row, Col } = Grid;
|
||||
@@ -22,8 +22,7 @@ function DataPage() {
|
||||
const jobs = usePageData<SyncJob>('/api/admin/v1/data/sync-jobs');
|
||||
|
||||
const loadOverview = async () => {
|
||||
const response = await api.get<ApiResponse<DataOverview>>('/api/admin/v1/data/overview');
|
||||
setOverview(response.data.data);
|
||||
setOverview(await getDataOverview());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,7 +32,7 @@ function DataPage() {
|
||||
const syncData = async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
await api.post('/api/admin/v1/data/sync', {});
|
||||
await syncDataMetrics();
|
||||
Message.success('数据同步完成');
|
||||
await Promise.all([loadOverview(), dramas.reload(), episodes.reload(), jobs.reload()]);
|
||||
} finally {
|
||||
|
||||
@@ -18,8 +18,16 @@ import {
|
||||
Tag,
|
||||
} from "@arco-design/web-react";
|
||||
import { IconArrowLeft, IconPlus, IconRefresh } from "@arco-design/web-react/icon";
|
||||
import type { ApiResponse, Drama, Episode, UploadTask } from "../api/types";
|
||||
import { api } from "../api/client";
|
||||
import 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;
|
||||
@@ -65,7 +73,7 @@ function DramaListPage() {
|
||||
const createDrama = async (values: Partial<Drama>) => {
|
||||
setCreating(true);
|
||||
try {
|
||||
await api.post("/api/admin/v1/dramas", values);
|
||||
await createDramaApi(values);
|
||||
Message.success("短剧创建成功");
|
||||
setVisible(false);
|
||||
await reload();
|
||||
@@ -218,13 +226,10 @@ function DramaDetailPage({ dramaId }: { dramaId: number }) {
|
||||
const loadDetail = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [dramaResponse, episodeResponse] = await Promise.all([
|
||||
api.get<ApiResponse<Drama>>(`/api/admin/v1/dramas/${dramaId}`),
|
||||
api.get<ApiResponse<{ list: Episode[] }>>(`/api/admin/v1/dramas/${dramaId}/episodes?pageSize=200`),
|
||||
]);
|
||||
setDrama(dramaResponse.data.data);
|
||||
setEpisodeCount(dramaResponse.data.data.totalEpisodes || 1);
|
||||
setEpisodes(episodeResponse.data.data.list);
|
||||
const [dramaData, episodeList] = await Promise.all([getDrama(dramaId), getDramaEpisodes(dramaId)]);
|
||||
setDrama(dramaData);
|
||||
setEpisodeCount(dramaData.totalEpisodes || 1);
|
||||
setEpisodes(episodeList);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -235,15 +240,13 @@ function DramaDetailPage({ dramaId }: { dramaId: number }) {
|
||||
}, [dramaId]);
|
||||
|
||||
const configureEpisodes = async () => {
|
||||
await api.post(`/api/admin/v1/dramas/${dramaId}/episodes/batch`, {
|
||||
episodeCount,
|
||||
});
|
||||
await configureDramaEpisodes(dramaId, episodeCount);
|
||||
Message.success("剧集数量已更新");
|
||||
await loadDetail();
|
||||
};
|
||||
|
||||
const updateDramaStatus = async (status: string) => {
|
||||
await api.patch(`/api/admin/v1/dramas/${dramaId}`, { status });
|
||||
await updateDrama(dramaId, { status });
|
||||
Message.success("短剧状态已更新");
|
||||
await loadDetail();
|
||||
};
|
||||
@@ -251,14 +254,14 @@ function DramaDetailPage({ dramaId }: { dramaId: number }) {
|
||||
const uploadEpisodeVideo = async (episode: Episode, file: File) => {
|
||||
setUploadingEpisodeId(episode.id);
|
||||
try {
|
||||
const task = await api.post<ApiResponse<UploadTask>>("/api/admin/v1/media/upload-tasks", {
|
||||
const task = await createUploadTask({
|
||||
episodeId: episode.id,
|
||||
fileName: file.name,
|
||||
contentType: file.type || "video/mp4",
|
||||
fileSize: file.size,
|
||||
});
|
||||
await api.patch(`/api/admin/v1/media/upload-tasks/${task.data.data.jobId}/complete`, {});
|
||||
await api.post("/api/admin/v1/media/review-status/sync", {});
|
||||
await completeUploadTask(task.jobId);
|
||||
await syncReviewStatus();
|
||||
Message.success("视频上传并审核通过");
|
||||
await loadDetail();
|
||||
} finally {
|
||||
@@ -267,7 +270,7 @@ function DramaDetailPage({ dramaId }: { dramaId: number }) {
|
||||
};
|
||||
|
||||
const publishEpisode = async (episode: Episode) => {
|
||||
await api.patch(`/api/admin/v1/episodes/${episode.id}`, {
|
||||
await updateEpisode(episode.id, {
|
||||
publishStatus: "published",
|
||||
status: "published",
|
||||
});
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(520px, 0.95fr) minmax(420px, 1fr);
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.login-visual {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 44px 56px 48px;
|
||||
color: #ffffff;
|
||||
background:
|
||||
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%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.login-visual::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.login-eyebrow {
|
||||
display: inline-flex;
|
||||
margin-bottom: 18px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
border-radius: 999px;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.login-brand h1 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 42px;
|
||||
line-height: 1.16;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.login-brand p {
|
||||
margin: 0;
|
||||
max-width: 560px;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.login-metrics {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.login-metric {
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.login-metric strong {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.login-metric span {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.86), rgba(247, 248, 250, 0.94)),
|
||||
#f7f8fa;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 36px;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.login-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.login-card-mark {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
color: #ffffff;
|
||||
background: #165dff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.login-card h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.login-card .subtitle {
|
||||
margin: 0;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.login-card-footer {
|
||||
margin-top: 22px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid #f2f3f5;
|
||||
color: #86909c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.login-page {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.login-visual {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,8 @@ import { useState } from 'react';
|
||||
import { Button, Form, Input, Message } from '@arco-design/web-react';
|
||||
import { IconLock, IconUser } from '@arco-design/web-react/icon';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { ApiResponse } from '../api/types';
|
||||
import { API_BASE_URL, TOKEN_KEY, api } from '../api/client';
|
||||
import './index.css';
|
||||
import { login } from '../api/auth';
|
||||
import { API_BASE_URL, TOKEN_KEY } from '../api/client';
|
||||
|
||||
function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -13,8 +12,8 @@ function LoginPage() {
|
||||
const onSubmit = async (values: { username: string; password: string }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.post<ApiResponse<{ accessToken: string }>>('/api/admin/v1/auth/login', values);
|
||||
localStorage.setItem(TOKEN_KEY, response.data.data.accessToken);
|
||||
const data = await login(values);
|
||||
localStorage.setItem(TOKEN_KEY, data.accessToken);
|
||||
Message.success('登录成功');
|
||||
navigate('/dashboard', { replace: true });
|
||||
} finally {
|
||||
@@ -23,30 +22,30 @@ function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-visual">
|
||||
<div className="login-logo">
|
||||
<span className="logo-mark">CT</span>
|
||||
<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%)]">
|
||||
<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="login-brand">
|
||||
<span className="login-eyebrow">TikTok 短剧运营后台</span>
|
||||
<h1>CTH TikTok 短剧管理后台</h1>
|
||||
<p>集中处理短剧内容、剧集发布、订单支付、播放日志和系统权限,帮助运营人员稳定管理短剧业务。</p>
|
||||
<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="login-metrics">
|
||||
<div className="login-metric"><strong>内容</strong><span>短剧与剧集管理</span></div>
|
||||
<div className="login-metric"><strong>权限</strong><span>角色化后台授权</span></div>
|
||||
<div className="login-metric"><strong>日志</strong><span>请求与播放追踪</span></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="login-panel">
|
||||
<div className="login-card">
|
||||
<div className="login-card-header">
|
||||
<span className="login-card-mark">CT</span>
|
||||
<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>后台登录</h2>
|
||||
<p className="subtitle">使用管理员账号进入控制台。</p>
|
||||
<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' }}>
|
||||
@@ -58,7 +57,7 @@ function LoginPage() {
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading} long>登录</Button>
|
||||
</Form>
|
||||
<div className="login-card-footer">API 服务:{API_BASE_URL}</div>
|
||||
<div className="mt-[22px] border-t border-canvas pt-[18px] text-xs text-muted">API 服务:{API_BASE_URL}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { Button, Card, Form, Input, Message, Modal, Select, Space, Table, Tag } from '@arco-design/web-react';
|
||||
import { IconPlus } from '@arco-design/web-react/icon';
|
||||
import type { AdminUser, Role } from '../api/types';
|
||||
import { api } from '../api/client';
|
||||
import { createPersonnel as createPersonnelApi, updateAdminUserRoles, updateAdminUserStatus } from '../api/personnel';
|
||||
import { usePageData } from '../hooks/usePageData';
|
||||
|
||||
function PersonnelPage() {
|
||||
@@ -30,10 +30,7 @@ function PersonnelPage() {
|
||||
}) => {
|
||||
setCreating(true);
|
||||
try {
|
||||
await api.post('/api/admin/v1/admin-users', {
|
||||
...values,
|
||||
roleIds: values.roleIds ?? [],
|
||||
});
|
||||
await createPersonnelApi(values);
|
||||
Message.success('人员创建成功');
|
||||
setCreateVisible(false);
|
||||
await reload();
|
||||
@@ -54,9 +51,7 @@ function PersonnelPage() {
|
||||
}
|
||||
setAssigning(true);
|
||||
try {
|
||||
await api.patch(`/api/admin/v1/admin-users/${selectedAdmin.id}/roles`, {
|
||||
roleIds: selectedRoleIds,
|
||||
});
|
||||
await updateAdminUserRoles(selectedAdmin.id, selectedRoleIds);
|
||||
Message.success('角色分配已更新');
|
||||
setAssignVisible(false);
|
||||
await reload();
|
||||
@@ -66,7 +61,7 @@ function PersonnelPage() {
|
||||
};
|
||||
|
||||
const updateStatus = async (id: number, status: 'active' | 'disabled') => {
|
||||
await api.patch(`/api/admin/v1/admin-users/${id}/status`, { status });
|
||||
await updateAdminUserStatus(id, status);
|
||||
Message.success('人员状态已更新');
|
||||
await reload();
|
||||
};
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
.profile-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 420px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.profile-grid .section-card:first-child {
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
.profile-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.profile-summary h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.profile-summary p {
|
||||
margin: 0;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.profile-info-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.profile-info-list div {
|
||||
padding: 14px;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 8px;
|
||||
background: #fbfcfd;
|
||||
}
|
||||
|
||||
.profile-info-list span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #86909c;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.profile-info-list strong {
|
||||
color: #1d2129;
|
||||
font-weight: 500;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.profile-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.profile-info-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Avatar, Button, Card, Form, Input, Message } from '@arco-design/web-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { ApiResponse, AdminProfile } from '../api/types';
|
||||
import { TOKEN_KEY, api } from '../api/client';
|
||||
import './index.css';
|
||||
import type { AdminProfile } from '../api/types';
|
||||
import { changePassword as changePasswordApi, getProfile, updateProfile as updateProfileApi } from '../api/auth';
|
||||
import { TOKEN_KEY } from '../api/client';
|
||||
|
||||
function ProfilePage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -17,11 +17,11 @@ function ProfilePage() {
|
||||
const loadProfile = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get<ApiResponse<AdminProfile>>('/api/admin/v1/auth/profile');
|
||||
setProfile(response.data.data);
|
||||
const data = await getProfile();
|
||||
setProfile(data);
|
||||
form.setFieldsValue({
|
||||
nickname: response.data.data.nickname,
|
||||
email: response.data.data.email,
|
||||
nickname: data.nickname,
|
||||
email: data.email,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -35,8 +35,8 @@ function ProfilePage() {
|
||||
const updateProfile = async (values: { nickname?: string; email?: string }) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await api.patch<ApiResponse<AdminProfile>>('/api/admin/v1/auth/profile', values);
|
||||
setProfile(response.data.data);
|
||||
const data = await updateProfileApi(values);
|
||||
setProfile(data);
|
||||
Message.success('个人信息已更新');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -50,7 +50,7 @@ function ProfilePage() {
|
||||
}
|
||||
setChangingPassword(true);
|
||||
try {
|
||||
await api.patch('/api/admin/v1/auth/password', {
|
||||
await changePasswordApi({
|
||||
oldPassword: values.oldPassword,
|
||||
newPassword: values.newPassword,
|
||||
});
|
||||
@@ -64,20 +64,20 @@ function ProfilePage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="profile-grid">
|
||||
<Card className="section-card" title="个人中心" loading={loading}>
|
||||
<div className="profile-summary">
|
||||
<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}>
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<Avatar size={56}>{profile?.username?.slice(0, 1).toUpperCase() || 'A'}</Avatar>
|
||||
<div>
|
||||
<h2>{profile?.nickname || profile?.username || '管理员'}</h2>
|
||||
<p>{profile?.email || '未设置邮箱'}</p>
|
||||
<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="profile-info-list">
|
||||
<div><span>用户名</span><strong>{profile?.username || '-'}</strong></div>
|
||||
<div><span>状态</span><strong>{profile?.status === 'active' ? '启用' : '禁用'}</strong></div>
|
||||
<div><span>角色</span><strong>{profile?.isSuperAdmin ? '超级管理员' : profile?.roles.map((role) => role.name).join('、') || '-'}</strong></div>
|
||||
<div><span>创建时间</span><strong>{profile?.createdAt || '-'}</strong></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 className="section-card" title="修改个人信息">
|
||||
|
||||
@@ -137,7 +137,7 @@ export function PermissionTreePicker({
|
||||
const checkedKeys = value.map((id) => permissionKey(id));
|
||||
|
||||
return (
|
||||
<div className="permission-tree-panel">
|
||||
<div className="mb-[15px] h-[360px] overflow-auto rounded-md border border-line bg-panel px-4 py-3">
|
||||
<Tree
|
||||
blockNode
|
||||
checkable
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
.form-label {
|
||||
margin: 0 0 8px;
|
||||
color: #4e5969;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
margin: -12px 0 18px;
|
||||
color: #86909c;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.permission-tree-panel {
|
||||
height: 360px;
|
||||
overflow: auto;
|
||||
margin-bottom: 15px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 6px;
|
||||
background: #fbfcfd;
|
||||
}
|
||||
@@ -2,10 +2,9 @@ import { useState } from "react";
|
||||
import { Button, Card, Form, Input, Message, Modal, Space, Table, Tag } from "@arco-design/web-react";
|
||||
import { IconPlus } from "@arco-design/web-react/icon";
|
||||
import type { Permission, Role } from "../api/types";
|
||||
import { api } from "../api/client";
|
||||
import { createRole as createRoleApi, updateRolePermissions } from "../api/roles";
|
||||
import { usePageData } from "../hooks/usePageData";
|
||||
import { PermissionTreePicker } from "./PermissionTreePicker";
|
||||
import "./index.css";
|
||||
|
||||
function RolesPage() {
|
||||
const { data, loading, reload, changePage } = usePageData<Role>("/api/admin/v1/roles");
|
||||
@@ -28,7 +27,7 @@ function RolesPage() {
|
||||
const createRole = async (values: { code: string; name: string; description?: string }) => {
|
||||
setCreatingRole(true);
|
||||
try {
|
||||
await api.post("/api/admin/v1/roles", {
|
||||
await createRoleApi({
|
||||
...values,
|
||||
permissionIds: createPermissionIds,
|
||||
});
|
||||
@@ -52,9 +51,7 @@ function RolesPage() {
|
||||
}
|
||||
setSavingPermissions(true);
|
||||
try {
|
||||
await api.patch(`/api/admin/v1/roles/${selectedRole.id}/permissions`, {
|
||||
permissionIds: selectedPermissionIds,
|
||||
});
|
||||
await updateRolePermissions(selectedRole.id, selectedPermissionIds);
|
||||
Message.success("角色权限已更新");
|
||||
setAssignmentVisible(false);
|
||||
await reload();
|
||||
@@ -122,7 +119,7 @@ function RolesPage() {
|
||||
</Form.Item>
|
||||
<div className="form-label">权限设置</div>
|
||||
<PermissionTreePicker permissions={permissions.data.list} value={createPermissionIds} onChange={setCreatePermissionIds} />
|
||||
<div className="form-help">为角色分配系统权限,决定拥有此角色的用户可以进行的操作</div>
|
||||
<div className="form-help mt-5">为角色分配系统权限,决定拥有此角色的用户可以进行的操作</div>
|
||||
<div className="modal-actions">
|
||||
<Button onClick={() => setCreateVisible(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit" loading={creatingRole}>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Button, Card, Message, Table, Tag } from '@arco-design/web-react';
|
||||
import type { AppUser } from '../api/types';
|
||||
import { api } from '../api/client';
|
||||
import { updateUserStatus } from '../api/users';
|
||||
import { usePageData } from '../hooks/usePageData';
|
||||
|
||||
function UsersPage() {
|
||||
const { data, loading, reload, changePage } = usePageData<AppUser>('/api/admin/v1/users');
|
||||
const update状态 = async (id: number, status: 'active' | 'disabled') => {
|
||||
await api.patch(`/api/admin/v1/users/${id}/status`, { status });
|
||||
await updateUserStatus(id, status);
|
||||
Message.success('用户状态已更新');
|
||||
await reload();
|
||||
};
|
||||
|
||||
44
src/api/auth.ts
Normal file
44
src/api/auth.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { api } from './client';
|
||||
import type { AdminProfile, ApiResponse } from './types';
|
||||
|
||||
export type LoginPayload = {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type UpdateProfilePayload = {
|
||||
nickname?: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export type ChangePasswordPayload = {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
};
|
||||
|
||||
export async function login(payload: LoginPayload) {
|
||||
const response = await api.post<ApiResponse<{ accessToken: string }>>(
|
||||
'/api/admin/v1/auth/login',
|
||||
payload,
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function getProfile() {
|
||||
const response = await api.get<ApiResponse<AdminProfile>>(
|
||||
'/api/admin/v1/auth/profile',
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function updateProfile(payload: UpdateProfilePayload) {
|
||||
const response = await api.patch<ApiResponse<AdminProfile>>(
|
||||
'/api/admin/v1/auth/profile',
|
||||
payload,
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function changePassword(payload: ChangePasswordPayload) {
|
||||
await api.patch('/api/admin/v1/auth/password', payload);
|
||||
}
|
||||
13
src/api/data.ts
Normal file
13
src/api/data.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { api } from './client';
|
||||
import type { ApiResponse, DataOverview } from './types';
|
||||
|
||||
export async function getDataOverview() {
|
||||
const response = await api.get<ApiResponse<DataOverview>>(
|
||||
'/api/admin/v1/data/overview',
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function syncDataMetrics() {
|
||||
await api.post('/api/admin/v1/data/sync', {});
|
||||
}
|
||||
44
src/api/dramas.ts
Normal file
44
src/api/dramas.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { api } from './client';
|
||||
import type { ApiResponse, Drama, Episode } from './types';
|
||||
|
||||
export async function createDrama(payload: Partial<Drama>) {
|
||||
await api.post('/api/admin/v1/dramas', payload);
|
||||
}
|
||||
|
||||
export async function getDrama(dramaId: number) {
|
||||
const response = await api.get<ApiResponse<Drama>>(
|
||||
`/api/admin/v1/dramas/${dramaId}`,
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function getDramaEpisodes(dramaId: number) {
|
||||
const response = await api.get<ApiResponse<{ list: Episode[] }>>(
|
||||
`/api/admin/v1/dramas/${dramaId}/episodes`,
|
||||
{ params: { pageSize: 200 } },
|
||||
);
|
||||
return response.data.data.list;
|
||||
}
|
||||
|
||||
export async function configureDramaEpisodes(
|
||||
dramaId: number,
|
||||
episodeCount: number,
|
||||
) {
|
||||
await api.post(`/api/admin/v1/dramas/${dramaId}/episodes/batch`, {
|
||||
episodeCount,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateDrama(
|
||||
dramaId: number,
|
||||
payload: Partial<Pick<Drama, 'status'>>,
|
||||
) {
|
||||
await api.patch(`/api/admin/v1/dramas/${dramaId}`, payload);
|
||||
}
|
||||
|
||||
export async function updateEpisode(
|
||||
episodeId: number,
|
||||
payload: Partial<Pick<Episode, 'publishStatus' | 'status'>>,
|
||||
) {
|
||||
await api.patch(`/api/admin/v1/episodes/${episodeId}`, payload);
|
||||
}
|
||||
25
src/api/media.ts
Normal file
25
src/api/media.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { api } from './client';
|
||||
import type { ApiResponse, UploadTask } from './types';
|
||||
|
||||
export type CreateUploadTaskPayload = {
|
||||
episodeId: number;
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
fileSize: number;
|
||||
};
|
||||
|
||||
export async function createUploadTask(payload: CreateUploadTaskPayload) {
|
||||
const response = await api.post<ApiResponse<UploadTask>>(
|
||||
'/api/admin/v1/media/upload-tasks',
|
||||
payload,
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function completeUploadTask(jobId: string) {
|
||||
await api.patch(`/api/admin/v1/media/upload-tasks/${jobId}/complete`, {});
|
||||
}
|
||||
|
||||
export async function syncReviewStatus() {
|
||||
await api.post('/api/admin/v1/media/review-status/sync', {});
|
||||
}
|
||||
27
src/api/personnel.ts
Normal file
27
src/api/personnel.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { api } from './client';
|
||||
|
||||
export type CreatePersonnelPayload = {
|
||||
username: string;
|
||||
password: string;
|
||||
nickname?: string;
|
||||
email?: string;
|
||||
roleIds?: number[];
|
||||
};
|
||||
|
||||
export async function createPersonnel(payload: CreatePersonnelPayload) {
|
||||
await api.post('/api/admin/v1/admin-users', {
|
||||
...payload,
|
||||
roleIds: payload.roleIds ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateAdminUserRoles(id: number, roleIds: number[]) {
|
||||
await api.patch(`/api/admin/v1/admin-users/${id}/roles`, { roleIds });
|
||||
}
|
||||
|
||||
export async function updateAdminUserStatus(
|
||||
id: number,
|
||||
status: 'active' | 'disabled',
|
||||
) {
|
||||
await api.patch(`/api/admin/v1/admin-users/${id}/status`, { status });
|
||||
}
|
||||
21
src/api/roles.ts
Normal file
21
src/api/roles.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { api } from './client';
|
||||
|
||||
export type CreateRolePayload = {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
permissionIds: number[];
|
||||
};
|
||||
|
||||
export async function createRole(payload: CreateRolePayload) {
|
||||
await api.post('/api/admin/v1/roles', payload);
|
||||
}
|
||||
|
||||
export async function updateRolePermissions(
|
||||
roleId: number,
|
||||
permissionIds: number[],
|
||||
) {
|
||||
await api.patch(`/api/admin/v1/roles/${roleId}/permissions`, {
|
||||
permissionIds,
|
||||
});
|
||||
}
|
||||
8
src/api/users.ts
Normal file
8
src/api/users.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { api } from './client';
|
||||
|
||||
export async function updateUserStatus(
|
||||
id: number,
|
||||
status: 'active' | 'disabled',
|
||||
) {
|
||||
await api.patch(`/api/admin/v1/users/${id}/status`, { status });
|
||||
}
|
||||
@@ -1,42 +1,48 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply box-border;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
@apply m-0 min-h-full w-full min-w-80;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-canvas font-sans text-ink;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
min-width: 320px;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
@layer components {
|
||||
.section-card {
|
||||
@apply rounded-lg;
|
||||
}
|
||||
|
||||
body {
|
||||
color: #1d2129;
|
||||
background: #f2f3f5;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
.page-stack {
|
||||
@apply flex flex-col gap-4;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
.modal-actions {
|
||||
@apply mt-6 flex justify-end gap-3;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.form-label {
|
||||
@apply mb-2 text-sm font-medium text-[#4e5969];
|
||||
}
|
||||
|
||||
.page-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
.form-help {
|
||||
@apply -mt-3 mb-[18px] text-[13px] leading-[1.6] text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { Avatar, Button, Dropdown, Layout, Menu, Space, Typography } from "@arco-design/web-react";
|
||||
import { IconMenuFold, IconMenuUnfold } from "@arco-design/web-react/icon";
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||
import type { ApiResponse, AdminProfile } from "../api/types";
|
||||
import { TOKEN_KEY, api } from "../api/client";
|
||||
import type { AdminProfile } from "../api/types";
|
||||
import { getProfile } from "../api/auth";
|
||||
import { TOKEN_KEY } from "../api/client";
|
||||
import { filterMenuItems, findCurrentMenu, menuItems, renderMenuItem } from "./menu";
|
||||
import { PermissionRoute } from "./PermissionRoute";
|
||||
import DashboardPage from "../Dashboard";
|
||||
@@ -14,7 +15,6 @@ import RolesPage from "../Role";
|
||||
import PersonnelPage from "../Personnel";
|
||||
import ProfilePage from "../Profile";
|
||||
import LogsPage from "../Log";
|
||||
import "./layout.css";
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
const { Text } = Typography;
|
||||
@@ -29,8 +29,8 @@ export function AdminLayout() {
|
||||
const [openMenuKeys, setOpenMenuKeys] = useState<string[]>(current?.parent ? [current.parent.key] : []);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<ApiResponse<AdminProfile>>("/api/admin/v1/auth/profile")
|
||||
.then((response) => setProfile(response.data.data))
|
||||
getProfile()
|
||||
.then((data) => setProfile(data))
|
||||
.catch(() => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
navigate("/login", { replace: true });
|
||||
@@ -49,10 +49,10 @@ export function AdminLayout() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout className="layout">
|
||||
<Sider className="sidebar" width={220} collapsed={collapsed} collapsible trigger={null}>
|
||||
<div className="logo-row">
|
||||
<span className="logo-mark">TK</span>
|
||||
<Layout className="min-h-screen">
|
||||
<Sider className="shadow-[1px_0_0_#e5e6eb]" width={220} collapsed={collapsed} collapsible trigger={null}>
|
||||
<div className="flex h-14 items-center gap-2.5 border-b border-line px-[18px] font-bold">
|
||||
<span className="grid h-7 w-7 place-items-center rounded-md bg-brand text-sm text-white">TK</span>
|
||||
{!collapsed && <span>TK短剧管理后台</span>}
|
||||
</div>
|
||||
<Menu
|
||||
@@ -65,10 +65,10 @@ export function AdminLayout() {
|
||||
</Menu>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header className="header">
|
||||
<Header className="flex h-14 items-center justify-between border-b border-line bg-white px-5">
|
||||
<Space>
|
||||
<Button icon={collapsed ? <IconMenuUnfold /> : <IconMenuFold />} onClick={() => setCollapsed(!collapsed)} />
|
||||
<div className="header-title">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<strong>{current?.item.label || "工作台"}</strong>
|
||||
</div>
|
||||
</Space>
|
||||
@@ -86,13 +86,13 @@ export function AdminLayout() {
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<button className="user-menu-trigger" type="button">
|
||||
<button className="inline-flex cursor-pointer items-center gap-2 rounded-md border-0 bg-transparent px-2 py-1 text-ink hover:bg-canvas" type="button">
|
||||
<Avatar size={28}>{profile?.username?.slice(0, 1).toUpperCase() || "A"}</Avatar>
|
||||
<Text>{profile?.nickname || profile?.username || "管理员"}</Text>
|
||||
</button>
|
||||
</Dropdown>
|
||||
</Header>
|
||||
<Content className="content">
|
||||
<Content className="p-3 sm:p-5">
|
||||
<Routes>
|
||||
<Route path="/dashboard" element={<DashboardPage profile={profile} />} />
|
||||
<Route
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
.layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
box-shadow: 1px 0 0 #e5e6eb;
|
||||
}
|
||||
|
||||
.logo-row {
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 18px;
|
||||
border-bottom: 1px solid #e5e6eb;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.logo-mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #ffffff;
|
||||
background: #165dff;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 56px;
|
||||
padding: 0 20px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e5e6eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.header-title strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.header-title span {
|
||||
color: #86909c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-menu-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #1d2129;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-menu-trigger:hover {
|
||||
background: #f2f3f5;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.content {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user