refactor: improve theme settings layout

This commit is contained in:
dushixiang committed 2026-08-12 20:35:10 +08:00
1 parent acf3b6b57f
commit 33920265e0
2 files changed
+163 -49

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
# Pika 开发约定
## Web 样式
- 不要直接使用 Tailwind CSS 工具类控制 Ant Design 组件的布局、尺寸、间距、溢出、颜色或内部子节点。Ant Design 组件的 `className` 不保证作用在期望的 DOM 节点,且组件样式优先级可能覆盖 Tailwind。
- 需要 Tailwind 布局或视觉样式时,在 Ant Design 组件外层包裹原生 `div`、`span` 等元素,并将 Tailwind `className` 放在这些原生元素上。
- 需要调整 Ant Design 组件本身时,优先使用组件自身的 props、`style`、`styles`、`classNames`、设计 Token,或 Ant Design 的 `Flex`、`Space`、`Row`、`Col` 等布局组件。
- 如果一个样式同时涉及 Ant Design 组件外部布局和组件内部外观,将两者分开:外部布局由包裹元素承担,组件内部外观由 Ant Design API 承担。
+155 -49
View File
@@ -1,18 +1,20 @@
import {useEffect, useState} from 'react';
import {Alert, App, Button, Card, Col, Image, Popconfirm, Row, Space, Spin, Tag, Typography, Upload} from 'antd';
import {Alert, App, Button, Card, Empty, Popconfirm, Space, Spin, Tag, Typography, Upload} from 'antd';
import {Palette, ShieldAlert, Trash2, UploadCloud} from 'lucide-react';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {activateTheme, deleteTheme, listThemes, type ThemeInfo, uploadTheme} from '@/api/theme';
import {getErrorMessage} from '@/lib/utils';
const {Text, Paragraph} = Typography;
const {Text, Paragraph, Title} = Typography;
const ThemePreview = ({theme}: {theme: ThemeInfo}) => {
const [source, setSource] = useState<string>();
useEffect(() => {
const controller = new AbortController();
let objectURL = '';
const token = localStorage.getItem('token');
fetch(theme.previewUrl, {
signal: controller.signal,
headers: token ? {Authorization: 'Bearer ' + token} : {},
@@ -23,36 +25,60 @@ const ThemePreview = ({theme}: {theme: ThemeInfo}) => {
setSource(objectURL);
})
.catch(() => setSource(undefined));
return () => {
controller.abort();
if (objectURL) URL.revokeObjectURL(objectURL);
};
}, [theme.id, theme.previewUrl]);
return source
? <Image preview={false} src={source} className="!h-40 !w-full object-cover" alt={theme.name}/>
: <div className="flex h-40 items-center justify-center bg-slate-100 dark:bg-slate-800"><Palette size={38}/></div>;
return (
<div className="flex aspect-[16/9] w-full items-center justify-center overflow-hidden bg-slate-100 dark:bg-slate-800">
{source ? (
<img
src={source}
alt={`${theme.name} 主题预览`}
className="block h-full w-full object-contain"
/>
) : (
<div className="flex flex-col items-center gap-2 text-slate-400 dark:text-slate-500">
<Palette size={36}/>
<span className="text-sm">暂无预览图</span>
</div>
)}
</div>
);
};
const ThemeSettings = () => {
const {message, modal} = App.useApp();
const queryClient = useQueryClient();
const themesQuery = useQuery({queryKey: ['themes'], queryFn: listThemes});
const themes = themesQuery.data || [];
const refresh = () => queryClient.invalidateQueries({queryKey: ['themes']});
const operation = useMutation({
mutationFn: async (fn: () => Promise<unknown>) => fn(),
onSuccess: async () => { message.success('操作成功'); await refresh(); },
onSuccess: async () => {
message.success('操作成功');
await refresh();
},
onError: (error) => message.error(getErrorMessage(error, '主题操作失败')),
});
const confirmUntrusted = (title: string, action: () => Promise<unknown>) => {
const confirmTrustedTheme = (title: string, action: () => Promise<unknown>) => {
modal.confirm({
title,
icon: <ShieldAlert className="text-amber-500"/>,
icon: <ShieldAlert color="#faad14"/>,
width: 560,
content: (
<Alert className="mt-4" type="warning" showIcon
message="主题是同源可信代码"
description="主题包含可执行 JavaScript,能够访问此站点浏览器存储和公开 API。只安装并启用你完全信任的主题。"/>
<div className="mt-4">
<Alert
type="warning"
showIcon
title="主题是同源可信代码"
description="主题包含可执行 JavaScript,能够访问此站点浏览器存储和公开 API。只安装并启用你完全信任的主题。"
/>
</div>
),
okText: '我信任此主题,继续',
okButtonProps: {danger: true},
@@ -60,51 +86,131 @@ const ThemeSettings = () => {
});
};
const installedCards = (themesQuery.data || []).map((theme) => (
<Col xs={24} md={12} xl={8} key={theme.id}>
<Card className="h-full overflow-hidden" cover={<ThemePreview theme={theme}/>}
actions={[
theme.active ? <Tag color="success">当前主题</Tag> :
<Button type="link" disabled={!theme.compatible}
onClick={() => confirmUntrusted('启用 ' + theme.name, () => activateTheme(theme.id))}>启用</Button>,
theme.official ? <Tag>内置</Tag> :
<Popconfirm title="确认删除主题?" description="主题文件删除后只能通过重新安装恢复。"
disabled={theme.active}
onConfirm={() => operation.mutateAsync(() => deleteTheme(theme.id))}>
<Button type="link" danger disabled={theme.active} icon={<Trash2 size={15}/>}>删除</Button>
</Popconfirm>,
]}>
<Card.Meta
title={<Space>{theme.name}<Tag>{theme.version}</Tag>{theme.official && <Tag color="blue">官方</Tag>}</Space>}
description={<>
<Paragraph ellipsis={{rows: 2}}>{theme.description || '暂无说明'}</Paragraph>
<Text type="secondary">作者:{theme.author}</Text>
{!theme.compatible && <Alert className="mt-3" type="error" showIcon message={theme.compatibilityError}/>}
</>}/>
</Card>
</Col>
));
const renderThemeActions = (theme: ThemeInfo) => [
theme.active ? (
<Tag key="status" color="success">当前主题</Tag>
) : (
<Button
key="activate"
type="link"
disabled={!theme.compatible || operation.isPending}
onClick={() => confirmTrustedTheme('启用 ' + theme.name, () => activateTheme(theme.id))}
>
启用
</Button>
),
theme.official ? (
<Tag key="source">内置</Tag>
) : (
<Popconfirm
key="delete"
title="确认删除主题?"
description="主题文件删除后只能通过重新安装恢复。"
disabled={theme.active || operation.isPending}
onConfirm={() => operation.mutateAsync(() => deleteTheme(theme.id))}
>
<Button
type="link"
danger
disabled={theme.active || operation.isPending}
icon={<Trash2 size={15}/>}
>
删除
</Button>
</Popconfirm>
),
];
return (
<div>
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-xl font-bold">主题管理</h2>
<p className="mt-2 text-gray-500">安装和切换完整的公开页面主题;管理后台始终使用官方界面。</p>
<div className="min-w-0">
<div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<Title level={3} style={{margin: 0}}>主题管理</Title>
<Paragraph type="secondary" style={{margin: '8px 0 0'}}>
安装和切换完整的公开页面主题;管理后台始终使用官方界面。
</Paragraph>
</div>
<Upload accept=".zip,application/zip" showUploadList={false}
<div className="shrink-0">
<Upload
accept=".zip,application/zip"
showUploadList={false}
disabled={operation.isPending}
beforeUpload={(file) => {
confirmUntrusted('上传并安装 ' + file.name, () => uploadTheme(file as File));
confirmTrustedTheme('上传并安装 ' + file.name, () => uploadTheme(file as File));
return false;
}}>
<Button icon={<UploadCloud size={15}/>}>上传 ZIP</Button>
}}
>
<Button icon={<UploadCloud size={15}/>} loading={operation.isPending}>上传 ZIP</Button>
</Upload>
</div>
<Alert className="mb-5" type="warning" showIcon message="安全提示"
description="第三方主题会在 Pika 同一域名下执行 JavaScript。主题不是沙箱,只安装你完全信任的来源。"/>
{themesQuery.isLoading
? <div className="py-16 text-center"><Spin/></div>
: <Row gutter={[16, 16]}>{installedCards}</Row>}
</div>
<div className="mb-6">
<Alert
type="warning"
showIcon
title="只安装你信任的主题:第三方主题会在 Pika 同一域名下执行 JavaScript,安装前请确认来源可信。"
/>
</div>
{themesQuery.isLoading ? (
<div className="flex min-h-72 items-center justify-center">
<Spin size="large" tip="正在加载主题...">
<div className="h-16 w-48"/>
</Spin>
</div>
) : themesQuery.isError ? (
<Alert
type="error"
showIcon
title="主题列表加载失败"
description={getErrorMessage(themesQuery.error, '请稍后重试')}
action={<Button size="small" onClick={() => themesQuery.refetch()}>重试</Button>}
/>
) : themes.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-300 py-14 dark:border-slate-700">
<Empty description="暂无可用主题" image={Empty.PRESENTED_IMAGE_SIMPLE}/>
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 2xl:grid-cols-3">
{themes.map((theme) => (
<div key={theme.id} className="min-w-0">
<Card
hoverable
cover={<ThemePreview theme={theme}/>}
actions={renderThemeActions(theme)}
styles={{
root: {height: '100%', overflow: 'hidden'},
body: {padding: 16},
actions: {marginTop: 'auto'},
cover: {overflow: 'hidden'},
}}
>
<Card.Meta
title={(
<Space size={[6, 6]} wrap>
<Text strong>{theme.name}</Text>
<Tag>{theme.version}</Tag>
{theme.official && <Tag color="blue">官方</Tag>}
</Space>
)}
description={(
<Space orientation="vertical" size={10} style={{display: 'flex'}}>
<Paragraph ellipsis={{rows: 2}} style={{margin: 0}}>
{theme.description || '暂无说明'}
</Paragraph>
<Text type="secondary">作者:{theme.author}</Text>
{!theme.compatible && (
<Alert type="error" showIcon title="主题不兼容" description={theme.compatibilityError}/>
)}
</Space>
)}
/>
</Card>
</div>
))}
</div>
)}
</div>
);
};