Spaces:
Runtime error
Runtime error
| /* | |
| 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; | |