Extracted the User Agreement and Privacy Policy presentation into a reusable DocumentRenderer component (web/src/components/common/DocumentRenderer). Unified rendering logic and i18n source for these documents, removed the legacy contentDetector utility, and updated the related pages to use the new component. Adjusted controller/backend (controller/misc.go) and locale files to support the new rendering approach. This improves reuse, maintainability, and future extensibility.
243 lines
7.2 KiB
JavaScript
243 lines
7.2 KiB
JavaScript
/*
|
||
Copyright (C) 2025 QuantumNous
|
||
|
||
This program is free software: you can redistribute it and/or modify
|
||
it under the terms of the GNU Affero General Public License as
|
||
published by the Free Software Foundation, either version 3 of the
|
||
License, or (at your option) any later version.
|
||
|
||
This program is distributed in the hope that it will be useful,
|
||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
GNU Affero General Public License for more details.
|
||
|
||
You should have received a copy of the GNU Affero General Public License
|
||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
|
||
For commercial licensing, please contact support@quantumnous.com
|
||
*/
|
||
|
||
import React, { useEffect, useState } from 'react';
|
||
import { API, showError } from '../../../helpers';
|
||
import { Empty, Card, Spin, Typography } from '@douyinfe/semi-ui';
|
||
const { Title } = Typography;
|
||
import {
|
||
IllustrationConstruction,
|
||
IllustrationConstructionDark,
|
||
} from '@douyinfe/semi-illustrations';
|
||
import { useTranslation } from 'react-i18next';
|
||
import MarkdownRenderer from '../markdown/MarkdownRenderer';
|
||
|
||
// 检查是否为 URL
|
||
const isUrl = (content) => {
|
||
try {
|
||
new URL(content.trim());
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
};
|
||
|
||
// 检查是否为 HTML 内容
|
||
const isHtmlContent = (content) => {
|
||
if (!content || typeof content !== 'string') return false;
|
||
|
||
// 检查是否包含HTML标签
|
||
const htmlTagRegex = /<\/?[a-z][\s\S]*>/i;
|
||
return htmlTagRegex.test(content);
|
||
};
|
||
|
||
// 安全地渲染HTML内容
|
||
const sanitizeHtml = (html) => {
|
||
// 创建一个临时元素来解析HTML
|
||
const tempDiv = document.createElement('div');
|
||
tempDiv.innerHTML = html;
|
||
|
||
// 提取样式
|
||
const styles = Array.from(tempDiv.querySelectorAll('style'))
|
||
.map(style => style.innerHTML)
|
||
.join('\n');
|
||
|
||
// 提取body内容,如果没有body标签则使用全部内容
|
||
const bodyContent = tempDiv.querySelector('body');
|
||
const content = bodyContent ? bodyContent.innerHTML : html;
|
||
|
||
return { content, styles };
|
||
};
|
||
|
||
/**
|
||
* 通用文档渲染组件
|
||
* @param {string} apiEndpoint - API 接口地址
|
||
* @param {string} title - 文档标题
|
||
* @param {string} cacheKey - 本地存储缓存键
|
||
* @param {string} emptyMessage - 空内容时的提示消息
|
||
*/
|
||
const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||
const { t } = useTranslation();
|
||
const [content, setContent] = useState('');
|
||
const [loading, setLoading] = useState(true);
|
||
const [htmlStyles, setHtmlStyles] = useState('');
|
||
const [processedHtmlContent, setProcessedHtmlContent] = useState('');
|
||
|
||
const loadContent = async () => {
|
||
// 先从缓存中获取
|
||
const cachedContent = localStorage.getItem(cacheKey) || '';
|
||
if (cachedContent) {
|
||
setContent(cachedContent);
|
||
processContent(cachedContent);
|
||
setLoading(false);
|
||
}
|
||
|
||
try {
|
||
const res = await API.get(apiEndpoint);
|
||
const { success, message, data } = res.data;
|
||
if (success && data) {
|
||
setContent(data);
|
||
processContent(data);
|
||
localStorage.setItem(cacheKey, data);
|
||
} else {
|
||
if (!cachedContent) {
|
||
showError(message || emptyMessage);
|
||
setContent('');
|
||
}
|
||
}
|
||
} catch (error) {
|
||
if (!cachedContent) {
|
||
showError(emptyMessage);
|
||
setContent('');
|
||
}
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const processContent = (rawContent) => {
|
||
if (isHtmlContent(rawContent)) {
|
||
const { content: htmlContent, styles } = sanitizeHtml(rawContent);
|
||
setProcessedHtmlContent(htmlContent);
|
||
setHtmlStyles(styles);
|
||
} else {
|
||
setProcessedHtmlContent('');
|
||
setHtmlStyles('');
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadContent();
|
||
}, []);
|
||
|
||
// 处理HTML样式注入
|
||
useEffect(() => {
|
||
const styleId = `document-renderer-styles-${cacheKey}`;
|
||
|
||
if (htmlStyles) {
|
||
let styleEl = document.getElementById(styleId);
|
||
if (!styleEl) {
|
||
styleEl = document.createElement('style');
|
||
styleEl.id = styleId;
|
||
styleEl.type = 'text/css';
|
||
document.head.appendChild(styleEl);
|
||
}
|
||
styleEl.innerHTML = htmlStyles;
|
||
} else {
|
||
const el = document.getElementById(styleId);
|
||
if (el) el.remove();
|
||
}
|
||
|
||
return () => {
|
||
const el = document.getElementById(styleId);
|
||
if (el) el.remove();
|
||
};
|
||
}, [htmlStyles, cacheKey]);
|
||
|
||
// 显示加载状态
|
||
if (loading) {
|
||
return (
|
||
<div className='flex justify-center items-center min-h-screen'>
|
||
<Spin size='large' />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 如果没有内容,显示空状态
|
||
if (!content || content.trim() === '') {
|
||
return (
|
||
<div className='flex justify-center items-center min-h-screen bg-gray-50'>
|
||
<Empty
|
||
title={t('管理员未设置' + title + '内容')}
|
||
image={<IllustrationConstruction style={{ width: 150, height: 150 }} />}
|
||
darkModeImage={<IllustrationConstructionDark style={{ width: 150, height: 150 }} />}
|
||
className='p-8'
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 如果是 URL,显示链接卡片
|
||
if (isUrl(content)) {
|
||
return (
|
||
<div className='flex justify-center items-center min-h-screen bg-gray-50 p-4'>
|
||
<Card className='max-w-md w-full'>
|
||
<div className='text-center'>
|
||
<Title heading={4} className='mb-4'>{title}</Title>
|
||
<p className='text-gray-600 mb-4'>
|
||
{t('管理员设置了外部链接,点击下方按钮访问')}
|
||
</p>
|
||
<a
|
||
href={content.trim()}
|
||
target='_blank'
|
||
rel='noopener noreferrer'
|
||
title={content.trim()}
|
||
aria-label={`${t('访问' + title)}: ${content.trim()}`}
|
||
className='inline-block px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors'
|
||
>
|
||
{t('访问' + title)}
|
||
</a>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 如果是 HTML 内容,直接渲染
|
||
if (isHtmlContent(content)) {
|
||
const { content: htmlContent, styles } = sanitizeHtml(content);
|
||
|
||
// 设置样式(如果有的话)
|
||
useEffect(() => {
|
||
if (styles && styles !== htmlStyles) {
|
||
setHtmlStyles(styles);
|
||
}
|
||
}, [content, styles, htmlStyles]);
|
||
|
||
return (
|
||
<div className='min-h-screen bg-gray-50'>
|
||
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
|
||
<div className='bg-white rounded-lg shadow-sm p-8'>
|
||
<Title heading={2} className='text-center mb-8'>{title}</Title>
|
||
<div
|
||
className='prose prose-lg max-w-none'
|
||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 其他内容统一使用 Markdown 渲染器
|
||
return (
|
||
<div className='min-h-screen bg-gray-50'>
|
||
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
|
||
<div className='bg-white rounded-lg shadow-sm p-8'>
|
||
<Title heading={2} className='text-center mb-8'>{title}</Title>
|
||
<div className='prose prose-lg max-w-none'>
|
||
<MarkdownRenderer content={content} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default DocumentRenderer; |