refactor: improve theme settings layout
This commit is contained in:
1 parent
acf3b6b57f
commit
33920265e0
2 files changed
+164
-50
No files matched your search
@@ -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 承担。
|
||||
@@ -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>
|
||||
</Upload>
|
||||
}}
|
||||
>
|
||||
<Button icon={<UploadCloud size={15}/>} loading={operation.isPending}>上传 ZIP</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
</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 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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user