|
|
@@ -0,0 +1,243 @@
|
|
|
+/*
|
|
|
+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 [email protected]
|
|
|
+*/
|
|
|
+
|
|
|
+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;
|