mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 16:20:25 +00:00
Refactor font handling and theme config, switch to CodeMirror editor
Replaces Monaco editor with CodeMirror in the frontend, removing related dependencies and configuration. Refactors font management to support multiple formats (woff, woff2, ttf, otf) and dynamic font switching, including backend API and frontend theme config UI. Adds gzip compression middleware to backend. Updates theme config to allow font selection and custom font upload, and improves theme preview and color customization UI. Cleans up unused code and improves sidebar and terminal font sizing responsiveness.
This commit is contained in:
@@ -1,28 +1,34 @@
|
||||
import { Accordion, AccordionItem } from '@heroui/accordion';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card';
|
||||
import { Select, SelectItem } from '@heroui/select';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { useRequest } from 'ahooks';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
|
||||
import { Controller, useForm, useWatch } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
import { FaUserAstronaut } from 'react-icons/fa';
|
||||
import { FaFont, FaUserAstronaut, FaCheck } from 'react-icons/fa';
|
||||
import { FaPaintbrush } from 'react-icons/fa6';
|
||||
import { IoIosColorPalette } from 'react-icons/io';
|
||||
import { MdDarkMode, MdLightMode } from 'react-icons/md';
|
||||
import { IoMdRefresh } from 'react-icons/io';
|
||||
|
||||
import themes from '@/const/themes';
|
||||
|
||||
import ColorPicker from '@/components/ColorPicker';
|
||||
import SaveButtons from '@/components/button/save_buttons';
|
||||
import FileInput from '@/components/input/file_input';
|
||||
import PageLoading from '@/components/page_loading';
|
||||
|
||||
import { colorKeys, generateTheme, loadTheme } from '@/utils/theme';
|
||||
import FileManager from '@/controllers/file_manager';
|
||||
import { applyFont, colorKeys, generateTheme, loadTheme, updateFontCache } from '@/utils/theme';
|
||||
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
|
||||
export type PreviewThemeCardProps = {
|
||||
theme: ThemeInfo;
|
||||
onPreview: () => void;
|
||||
isSelected?: boolean;
|
||||
};
|
||||
|
||||
const values = [
|
||||
@@ -47,7 +53,7 @@ const colors = [
|
||||
'default',
|
||||
];
|
||||
|
||||
function PreviewThemeCard ({ theme, onPreview }: PreviewThemeCardProps) {
|
||||
function PreviewThemeCard ({ theme, onPreview, isSelected }: PreviewThemeCardProps) {
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = generateTheme(theme.theme, theme.name);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
@@ -64,8 +70,19 @@ function PreviewThemeCard ({ theme, onPreview }: PreviewThemeCardProps) {
|
||||
radius='sm'
|
||||
isPressable
|
||||
onPress={onPreview}
|
||||
className={clsx('text-primary bg-primary-50', theme.name)}
|
||||
className={clsx(
|
||||
'text-primary bg-primary-50 relative transition-all',
|
||||
theme.name,
|
||||
isSelected && 'ring-2 ring-primary ring-offset-2'
|
||||
)}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="absolute top-1 right-1 z-10">
|
||||
<Chip size="sm" color="primary" variant="solid">
|
||||
<FaCheck size={10} />
|
||||
</Chip>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader className='pb-0 flex flex-col items-start gap-1'>
|
||||
<div className='px-1 rounded-md bg-primary text-primary-foreground'>
|
||||
{theme.name}
|
||||
@@ -100,6 +117,29 @@ function PreviewThemeCard ({ theme, onPreview }: PreviewThemeCardProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// 比较两个主题配置是否相同(不比较 fontMode)
|
||||
const isThemeColorsEqual = (a: ThemeConfig, b: ThemeConfig): boolean => {
|
||||
if (!a || !b) return false;
|
||||
const aKeys = [...Object.keys(a.light || {}), ...Object.keys(a.dark || {})];
|
||||
const bKeys = [...Object.keys(b.light || {}), ...Object.keys(b.dark || {})];
|
||||
if (aKeys.length !== bKeys.length) return false;
|
||||
|
||||
for (const key of Object.keys(a.light || {})) {
|
||||
if (a.light?.[key as keyof ThemeConfigItem] !== b.light?.[key as keyof ThemeConfigItem]) return false;
|
||||
}
|
||||
for (const key of Object.keys(a.dark || {})) {
|
||||
if (a.dark?.[key as keyof ThemeConfigItem] !== b.dark?.[key as keyof ThemeConfigItem]) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 字体模式显示名称映射
|
||||
const fontModeNames: Record<string, string> = {
|
||||
'aacute': 'Aa 偷吃可爱长大的',
|
||||
'system': '系统默认',
|
||||
'custom': '自定义字体',
|
||||
};
|
||||
|
||||
const ThemeConfigCard = () => {
|
||||
const { data, loading, error, refreshAsync } = useRequest(
|
||||
WebUIManager.getThemeConfig
|
||||
@@ -116,12 +156,17 @@ const ThemeConfigCard = () => {
|
||||
theme: {
|
||||
dark: {},
|
||||
light: {},
|
||||
fontMode: 'aacute',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [dataLoaded, setDataLoaded] = useState(false);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
// 使用 useRef 存储 style 标签引用
|
||||
const styleTagRef = useRef<HTMLStyleElement | null>(null);
|
||||
const originalDataRef = useRef<ThemeConfig | null>(null);
|
||||
|
||||
// 在组件挂载时创建 style 标签,并在卸载时清理
|
||||
useEffect(() => {
|
||||
@@ -137,13 +182,45 @@ const ThemeConfigCard = () => {
|
||||
|
||||
const theme = useWatch({ control, name: 'theme' });
|
||||
|
||||
const reset = () => {
|
||||
if (data) setOnebotValue('theme', data);
|
||||
};
|
||||
// 检测是否有未保存的更改
|
||||
useEffect(() => {
|
||||
if (originalDataRef.current && dataLoaded) {
|
||||
const colorsChanged = !isThemeColorsEqual(theme, originalDataRef.current);
|
||||
const fontChanged = theme.fontMode !== originalDataRef.current.fontMode;
|
||||
setHasUnsavedChanges(colorsChanged || fontChanged);
|
||||
}
|
||||
}, [theme, dataLoaded]);
|
||||
|
||||
const onSubmit = handleOnebotSubmit(async (data) => {
|
||||
const reset = useCallback(() => {
|
||||
if (data) {
|
||||
setOnebotValue('theme', data);
|
||||
originalDataRef.current = data;
|
||||
// 应用已保存的字体设置
|
||||
if (data.fontMode) {
|
||||
applyFont(data.fontMode);
|
||||
}
|
||||
}
|
||||
setDataLoaded(true);
|
||||
setHasUnsavedChanges(false);
|
||||
}, [data, setOnebotValue]);
|
||||
|
||||
// 实时应用字体预设(预览)
|
||||
useEffect(() => {
|
||||
if (dataLoaded && theme.fontMode) {
|
||||
applyFont(theme.fontMode);
|
||||
}
|
||||
}, [theme.fontMode, dataLoaded]);
|
||||
|
||||
const onSubmit = handleOnebotSubmit(async (formData) => {
|
||||
try {
|
||||
await WebUIManager.setThemeConfig(data.theme);
|
||||
await WebUIManager.setThemeConfig(formData.theme);
|
||||
// 更新原始数据引用
|
||||
originalDataRef.current = formData.theme;
|
||||
// 更新字体缓存
|
||||
if (formData.theme.fontMode) {
|
||||
updateFontCache(formData.theme.fontMode);
|
||||
}
|
||||
setHasUnsavedChanges(false);
|
||||
toast.success('保存成功');
|
||||
loadTheme();
|
||||
} catch (error) {
|
||||
@@ -164,7 +241,7 @@ const ThemeConfigCard = () => {
|
||||
|
||||
useEffect(() => {
|
||||
reset();
|
||||
}, [data]);
|
||||
}, [data, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme && styleTagRef.current) {
|
||||
@@ -173,6 +250,25 @@ const ThemeConfigCard = () => {
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
// 找到当前选中的主题(预览中的)
|
||||
const selectedThemeName = useMemo(() => {
|
||||
return themes.find(t => isThemeColorsEqual(t.theme, theme))?.name;
|
||||
}, [theme]);
|
||||
|
||||
// 找到已保存的主题名称
|
||||
const savedThemeName = useMemo(() => {
|
||||
if (!originalDataRef.current) return null;
|
||||
return themes.find(t => isThemeColorsEqual(t.theme, originalDataRef.current!))?.name || '自定义';
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dataLoaded, hasUnsavedChanges]);
|
||||
|
||||
// 已保存的字体模式显示名称
|
||||
const savedFontModeDisplayName = useMemo(() => {
|
||||
const mode = originalDataRef.current?.fontMode || 'aacute';
|
||||
return fontModeNames[mode] || mode;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dataLoaded, hasUnsavedChanges]);
|
||||
|
||||
if (loading) return <PageLoading loading />;
|
||||
|
||||
if (error) {
|
||||
@@ -185,96 +281,209 @@ const ThemeConfigCard = () => {
|
||||
<>
|
||||
<title>主题配置 - NapCat WebUI</title>
|
||||
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={reset}
|
||||
isSubmitting={isSubmitting}
|
||||
refresh={onRefresh}
|
||||
className='items-end w-full p-4'
|
||||
/>
|
||||
<div className='px-4 text-sm text-default-600'>实时预览,记得保存!</div>
|
||||
<Accordion variant='splitted' defaultExpandedKeys={['select']}>
|
||||
<AccordionItem
|
||||
key='select'
|
||||
aria-label='Pick Color'
|
||||
title='选择主题'
|
||||
subtitle='可以切换夜间/白昼模式查看对应颜色'
|
||||
className='shadow-small'
|
||||
startContent={<IoIosColorPalette />}
|
||||
>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{themes.map((theme) => (
|
||||
<PreviewThemeCard
|
||||
key={theme.name}
|
||||
theme={theme}
|
||||
onPreview={() => {
|
||||
setOnebotValue('theme', theme.theme);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* 顶部操作栏 */}
|
||||
<div className="sticky top-0 z-20 bg-background/80 backdrop-blur-md border-b border-divider">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-default-400">当前主题:</span>
|
||||
<Chip size="sm" color="primary" variant="flat">
|
||||
{savedThemeName || '加载中...'}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-default-400">字体:</span>
|
||||
<Chip size="sm" color="secondary" variant="flat">
|
||||
{savedFontModeDisplayName}
|
||||
</Chip>
|
||||
</div>
|
||||
{hasUnsavedChanges && (
|
||||
<Chip size="sm" color="warning" variant="solid">
|
||||
有未保存的更改
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</AccordionItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
radius="full"
|
||||
variant="flat"
|
||||
className="font-medium bg-default-100 text-default-600 dark:bg-default-50/50"
|
||||
onPress={() => {
|
||||
reset();
|
||||
toast.success('已重置');
|
||||
}}
|
||||
isDisabled={!hasUnsavedChanges}
|
||||
>
|
||||
取消更改
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color='primary'
|
||||
radius="full"
|
||||
className="font-medium shadow-md shadow-primary/20"
|
||||
isLoading={isSubmitting}
|
||||
onPress={() => onSubmit()}
|
||||
isDisabled={!hasUnsavedChanges}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
isIconOnly
|
||||
radius='full'
|
||||
variant='flat'
|
||||
className="text-default-500 bg-default-100 dark:bg-default-50/50"
|
||||
onPress={onRefresh}
|
||||
>
|
||||
<IoMdRefresh size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AccordionItem
|
||||
key='pick'
|
||||
aria-label='Pick Color'
|
||||
title='自定义配色'
|
||||
className='shadow-small'
|
||||
startContent={<FaPaintbrush />}
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
{(['dark', 'light'] as const).map((mode) => (
|
||||
<div
|
||||
key={mode}
|
||||
className={clsx(
|
||||
'p-2 rounded-md',
|
||||
mode === 'dark' ? 'text-white' : 'text-black',
|
||||
mode === 'dark'
|
||||
? 'bg-content1-foreground dark:bg-content1'
|
||||
: 'bg-content1 dark:bg-content1-foreground'
|
||||
)}
|
||||
>
|
||||
<h3 className='text-center p-2 rounded-md bg-content2 mb-2 text-default-800 flex items-center justify-center'>
|
||||
{mode === 'dark'
|
||||
? (
|
||||
<MdDarkMode size={24} />
|
||||
)
|
||||
: (
|
||||
<MdLightMode size={24} />
|
||||
)}
|
||||
{mode === 'dark' ? '夜间模式主题' : '白昼模式主题'}
|
||||
</h3>
|
||||
{colorKeys.map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
className='grid grid-cols-2 items-center mb-2 gap-2'
|
||||
<div className="p-4">
|
||||
<Accordion variant='splitted' defaultExpandedKeys={['font', 'select']}>
|
||||
<AccordionItem
|
||||
key='font'
|
||||
aria-label='Font Settings'
|
||||
title='字体设置'
|
||||
subtitle='自定义WebUI显示的字体'
|
||||
className='shadow-small'
|
||||
startContent={<FaFont />}
|
||||
>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<Controller
|
||||
control={control}
|
||||
name="theme.fontMode"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
label="字体预设"
|
||||
selectedKeys={field.value ? [field.value] : ['aacute']}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
className="max-w-xs"
|
||||
disallowEmptySelection
|
||||
>
|
||||
<label className='text-right'>{key}</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`theme.${mode}.${key}`}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const hslArray = value?.split(' ') ?? [0, 0, 0];
|
||||
const color = `hsl(${hslArray[0]}, ${hslArray[1]}, ${hslArray[2]})`;
|
||||
return (
|
||||
<ColorPicker
|
||||
color={color}
|
||||
onChange={(result) => {
|
||||
onChange(
|
||||
`${result.hsl.h} ${result.hsl.s * 100}% ${result.hsl.l * 100}%`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<SelectItem key="aacute">Aa 偷吃可爱长大的</SelectItem>
|
||||
<SelectItem key="system">系统默认</SelectItem>
|
||||
<SelectItem key="custom">自定义字体</SelectItem>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<div className='p-3 rounded-lg bg-default-100 dark:bg-default-50/30'>
|
||||
<div className='text-sm text-default-500 mb-2'>
|
||||
上传自定义字体(仅在选择"自定义字体"时生效)
|
||||
</div>
|
||||
<FileInput
|
||||
label='上传字体文件'
|
||||
placeholder='选择字体文件 (.woff/.woff2/.ttf/.otf)'
|
||||
accept='.ttf,.otf,.woff,.woff2'
|
||||
onChange={async (file) => {
|
||||
try {
|
||||
await FileManager.uploadWebUIFont(file);
|
||||
toast.success('上传成功,即将刷新页面');
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
toast.error('上传失败: ' + (error as Error).message);
|
||||
}
|
||||
}}
|
||||
onDelete={async () => {
|
||||
try {
|
||||
await FileManager.deleteWebUIFont();
|
||||
toast.success('删除成功,即将刷新页面');
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
toast.error('删除失败: ' + (error as Error).message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
key='select'
|
||||
aria-label='Pick Color'
|
||||
title='选择主题'
|
||||
subtitle='点击主题卡片即可预览,记得保存'
|
||||
className='shadow-small'
|
||||
startContent={<IoIosColorPalette />}
|
||||
>
|
||||
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3'>
|
||||
{themes.map((t) => (
|
||||
<PreviewThemeCard
|
||||
key={t.name}
|
||||
theme={t}
|
||||
isSelected={selectedThemeName === t.name}
|
||||
onPreview={() => {
|
||||
setOnebotValue('theme', { ...t.theme, fontMode: theme.fontMode });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
key='pick'
|
||||
aria-label='Pick Color'
|
||||
title='自定义配色'
|
||||
subtitle='精细调整每个颜色变量'
|
||||
className='shadow-small'
|
||||
startContent={<FaPaintbrush />}
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
{(['light', 'dark'] as const).map((mode) => (
|
||||
<div
|
||||
key={mode}
|
||||
className={clsx(
|
||||
'p-4 rounded-lg',
|
||||
mode === 'dark' ? 'bg-zinc-900 text-white' : 'bg-zinc-100 text-black'
|
||||
)}
|
||||
>
|
||||
<h3 className='flex items-center justify-center gap-2 p-2 rounded-md bg-opacity-20 mb-4 font-medium'>
|
||||
{mode === 'dark' ? <MdDarkMode size={20} /> : <MdLightMode size={20} />}
|
||||
{mode === 'dark' ? '深色模式' : '浅色模式'}
|
||||
</h3>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'>
|
||||
{colorKeys.map((colorKey) => (
|
||||
<div
|
||||
key={colorKey}
|
||||
className='flex items-center gap-2 p-2 rounded bg-black/5 dark:bg-white/5'
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`theme.${mode}.${colorKey}`}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const hslArray = value?.split(' ') ?? [0, 0, 0];
|
||||
const color = `hsl(${hslArray[0]}, ${hslArray[1]}, ${hslArray[2]})`;
|
||||
return (
|
||||
<ColorPicker
|
||||
color={color}
|
||||
onChange={(result) => {
|
||||
onChange(
|
||||
`${result.hsl.h} ${result.hsl.s * 100}% ${result.hsl.l * 100}%`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className='text-xs font-mono truncate flex-1' title={colorKey}>
|
||||
{colorKey.replace('--heroui-', '')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,11 +7,9 @@ import toast from 'react-hot-toast';
|
||||
import key from '@/const/key';
|
||||
|
||||
import SaveButtons from '@/components/button/save_buttons';
|
||||
import FileInput from '@/components/input/file_input';
|
||||
import ImageInput from '@/components/input/image_input';
|
||||
|
||||
import { siteConfig } from '@/config/site';
|
||||
import FileManager from '@/controllers/file_manager';
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
|
||||
// Base64URL to Uint8Array converter
|
||||
@@ -37,10 +35,10 @@ const WebUIConfigCard = () => {
|
||||
handleSubmit: handleWebuiSubmit,
|
||||
formState: { isSubmitting },
|
||||
setValue: setWebuiValue,
|
||||
} = useForm<IConfig['webui']>({
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
background: '',
|
||||
customIcons: {},
|
||||
customIcons: {} as Record<string, string>,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -92,39 +90,6 @@ const WebUIConfigCard = () => {
|
||||
return (
|
||||
<>
|
||||
<title>WebUI配置 - NapCat WebUI</title>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex-shrink-0 w-full font-bold text-default-600 dark:text-default-400 px-1'>WebUI字体</div>
|
||||
<div className='text-sm text-default-400'>
|
||||
此项不需要手动保存,上传成功后需清空网页缓存并刷新
|
||||
<FileInput
|
||||
label='中文字体'
|
||||
placeholder='选择字体文件'
|
||||
accept='.ttf,.otf,.woff,.woff2'
|
||||
onChange={async (file) => {
|
||||
try {
|
||||
await FileManager.uploadWebUIFont(file);
|
||||
toast.success('上传成功');
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
toast.error('上传失败: ' + (error as Error).message);
|
||||
}
|
||||
}}
|
||||
onDelete={async () => {
|
||||
try {
|
||||
await FileManager.deleteWebUIFont();
|
||||
toast.success('删除成功');
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
toast.error('删除失败: ' + (error as Error).message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex-shrink-0 w-full font-bold text-default-600 dark:text-default-400 px-1'>背景图</div>
|
||||
<Controller
|
||||
|
||||
Reference in New Issue
Block a user