feat: componentize User Agreement and Privacy Policy display
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.
This commit is contained in:
parent
00603520e9
commit
0992f834da
@ -108,6 +108,8 @@ func GetStatus(c *gin.Context) {
|
|||||||
"passkey_user_verification": passkeySetting.UserVerification,
|
"passkey_user_verification": passkeySetting.UserVerification,
|
||||||
"passkey_attachment": passkeySetting.AttachmentPreference,
|
"passkey_attachment": passkeySetting.AttachmentPreference,
|
||||||
"setup": constant.Setup,
|
"setup": constant.Setup,
|
||||||
|
"user_agreement_enabled": common.OptionMap["UserAgreement"] != "",
|
||||||
|
"privacy_policy_enabled": common.OptionMap["PrivacyPolicy"] != "",
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据启用状态注入可选内容
|
// 根据启用状态注入可选内容
|
||||||
|
|||||||
@ -110,27 +110,9 @@ const RegisterForm = () => {
|
|||||||
setTurnstileSiteKey(status.turnstile_site_key);
|
setTurnstileSiteKey(status.turnstile_site_key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查用户协议和隐私政策是否已设置
|
// 从 status 获取用户协议和隐私政策的启用状态
|
||||||
const checkTermsAvailability = async () => {
|
setHasUserAgreement(status.user_agreement_enabled || false);
|
||||||
try {
|
setHasPrivacyPolicy(status.privacy_policy_enabled || false);
|
||||||
const [userAgreementRes, privacyPolicyRes] = await Promise.all([
|
|
||||||
API.get('/api/user-agreement'),
|
|
||||||
API.get('/api/privacy-policy')
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (userAgreementRes.data.success && userAgreementRes.data.data) {
|
|
||||||
setHasUserAgreement(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (privacyPolicyRes.data.success && privacyPolicyRes.data.data) {
|
|
||||||
setHasPrivacyPolicy(true);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('检查用户协议和隐私政策失败:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
checkTermsAvailability();
|
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
243
web/src/components/common/DocumentRenderer/index.jsx
Normal file
243
web/src/components/common/DocumentRenderer/index.jsx
Normal file
@ -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 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;
|
||||||
@ -2276,5 +2276,8 @@
|
|||||||
"和": " and ",
|
"和": " and ",
|
||||||
"请先阅读并同意用户协议和隐私政策": "Please read and agree to the user agreement and privacy policy first",
|
"请先阅读并同意用户协议和隐私政策": "Please read and agree to the user agreement and privacy policy first",
|
||||||
"填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议": "After filling in the user agreement content, users will be required to check that they have read the user agreement when registering",
|
"填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议": "After filling in the user agreement content, users will be required to check that they have read the user agreement when registering",
|
||||||
"填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策": "After filling in the privacy policy content, users will be required to check that they have read the privacy policy when registering"
|
"填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策": "After filling in the privacy policy content, users will be required to check that they have read the privacy policy when registering",
|
||||||
|
"管理员设置了外部链接,点击下方按钮访问": "Administrator has set an external link, click the button below to access",
|
||||||
|
"访问用户协议": "Access User Agreement",
|
||||||
|
"访问隐私政策": "Access Privacy Policy"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2270,5 +2270,8 @@
|
|||||||
"和": " et ",
|
"和": " et ",
|
||||||
"请先阅读并同意用户协议和隐私政策": "Veuillez d'abord lire et accepter l'accord utilisateur et la politique de confidentialité",
|
"请先阅读并同意用户协议和隐私政策": "Veuillez d'abord lire et accepter l'accord utilisateur et la politique de confidentialité",
|
||||||
"填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议": "Après avoir rempli le contenu de l'accord utilisateur, les utilisateurs devront cocher qu'ils ont lu l'accord utilisateur lors de l'inscription",
|
"填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议": "Après avoir rempli le contenu de l'accord utilisateur, les utilisateurs devront cocher qu'ils ont lu l'accord utilisateur lors de l'inscription",
|
||||||
"填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策": "Après avoir rempli le contenu de la politique de confidentialité, les utilisateurs devront cocher qu'ils ont lu la politique de confidentialité lors de l'inscription"
|
"填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策": "Après avoir rempli le contenu de la politique de confidentialité, les utilisateurs devront cocher qu'ils ont lu la politique de confidentialité lors de l'inscription",
|
||||||
|
"管理员设置了外部链接,点击下方按钮访问": "L'administrateur a défini un lien externe, cliquez sur le bouton ci-dessous pour accéder",
|
||||||
|
"访问用户协议": "Accéder à l'accord utilisateur",
|
||||||
|
"访问隐私政策": "Accéder à la politique de confidentialité"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -130,5 +130,8 @@
|
|||||||
"和": "和",
|
"和": "和",
|
||||||
"请先阅读并同意用户协议和隐私政策": "请先阅读并同意用户协议和隐私政策",
|
"请先阅读并同意用户协议和隐私政策": "请先阅读并同意用户协议和隐私政策",
|
||||||
"填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议": "填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议",
|
"填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议": "填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议",
|
||||||
"填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策": "填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策"
|
"填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策": "填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策",
|
||||||
|
"管理员设置了外部链接,点击下方按钮访问": "管理员设置了外部链接,点击下方按钮访问",
|
||||||
|
"访问用户协议": "访问用户协议",
|
||||||
|
"访问隐私政策": "访问隐私政策"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,233 +17,21 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
For commercial licensing, please contact support@quantumnous.com
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React from 'react';
|
||||||
import { API, showError } from '../../helpers';
|
|
||||||
import { Empty } from '@douyinfe/semi-ui';
|
|
||||||
import {
|
|
||||||
IllustrationConstruction,
|
|
||||||
IllustrationConstructionDark,
|
|
||||||
} from '@douyinfe/semi-illustrations';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import MarkdownRenderer from '../../components/common/markdown/MarkdownRenderer';
|
import DocumentRenderer from '../../components/common/DocumentRenderer';
|
||||||
import { getContentType } from '../../utils/contentDetector';
|
|
||||||
|
|
||||||
const PrivacyPolicy = () => {
|
const PrivacyPolicy = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [privacyPolicy, setPrivacyPolicy] = useState('');
|
|
||||||
const [privacyPolicyLoaded, setPrivacyPolicyLoaded] = useState(false);
|
|
||||||
const [contentType, setContentType] = useState('empty');
|
|
||||||
const [htmlBody, setHtmlBody] = useState('');
|
|
||||||
const [htmlStyles, setHtmlStyles] = useState('');
|
|
||||||
const [htmlLinks, setHtmlLinks] = useState([]);
|
|
||||||
// Height of the top navigation/header in pixels. Adjust if your header is a different height.
|
|
||||||
const HEADER_HEIGHT = 64;
|
|
||||||
|
|
||||||
const displayPrivacyPolicy = async () => {
|
return (
|
||||||
// 先从缓存中获取
|
<DocumentRenderer
|
||||||
const cachedContent = localStorage.getItem('privacy_policy') || '';
|
apiEndpoint="/api/privacy-policy"
|
||||||
if (cachedContent) {
|
title={t('隐私政策')}
|
||||||
setPrivacyPolicy(cachedContent);
|
cacheKey="privacy_policy"
|
||||||
const ct = getContentType(cachedContent);
|
emptyMessage={t('加载隐私政策内容失败...')}
|
||||||
setContentType(ct);
|
/>
|
||||||
if (ct === 'html') {
|
);
|
||||||
// parse cached HTML to extract body and inline styles
|
|
||||||
try {
|
|
||||||
const parser = new DOMParser();
|
|
||||||
const doc = parser.parseFromString(cachedContent, 'text/html');
|
|
||||||
setHtmlBody(doc.body ? doc.body.innerHTML : cachedContent);
|
|
||||||
const styles = Array.from(doc.querySelectorAll('style'))
|
|
||||||
.map((s) => s.innerHTML)
|
|
||||||
.join('\n');
|
|
||||||
setHtmlStyles(styles);
|
|
||||||
const links = Array.from(doc.querySelectorAll('link[rel="stylesheet"]'))
|
|
||||||
.map((l) => l.getAttribute('href') || l.href)
|
|
||||||
.filter(Boolean);
|
|
||||||
setHtmlLinks(links);
|
|
||||||
} catch (e) {
|
|
||||||
setHtmlBody(cachedContent);
|
|
||||||
setHtmlStyles('');
|
|
||||||
setHtmlLinks([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await API.get('/api/privacy-policy');
|
|
||||||
const { success, message, data } = res.data;
|
|
||||||
if (success && data) {
|
|
||||||
// 直接使用原始数据,不进行任何预处理
|
|
||||||
setPrivacyPolicy(data);
|
|
||||||
const ct = getContentType(data);
|
|
||||||
setContentType(ct);
|
|
||||||
// 如果是完整 HTML 文档,解析 body 内容并提取内联样式放到 head
|
|
||||||
if (ct === 'html') {
|
|
||||||
try {
|
|
||||||
const parser = new DOMParser();
|
|
||||||
const doc = parser.parseFromString(data, 'text/html');
|
|
||||||
setHtmlBody(doc.body ? doc.body.innerHTML : data);
|
|
||||||
const styles = Array.from(doc.querySelectorAll('style'))
|
|
||||||
.map((s) => s.innerHTML)
|
|
||||||
.join('\n');
|
|
||||||
setHtmlStyles(styles);
|
|
||||||
const links = Array.from(doc.querySelectorAll('link[rel="stylesheet"]'))
|
|
||||||
.map((l) => l.getAttribute('href') || l.href)
|
|
||||||
.filter(Boolean);
|
|
||||||
setHtmlLinks(links);
|
|
||||||
} catch (e) {
|
|
||||||
setHtmlBody(data);
|
|
||||||
setHtmlStyles('');
|
|
||||||
setHtmlLinks([]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setHtmlBody('');
|
|
||||||
setHtmlStyles('');
|
|
||||||
setHtmlLinks([]);
|
|
||||||
}
|
|
||||||
localStorage.setItem('privacy_policy', data);
|
|
||||||
} else {
|
|
||||||
if (!cachedContent) {
|
|
||||||
showError(message || t('加载隐私政策内容失败...'));
|
|
||||||
setPrivacyPolicy('');
|
|
||||||
setContentType('empty');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!cachedContent) {
|
|
||||||
showError(t('加载隐私政策内容失败...'));
|
|
||||||
setPrivacyPolicy('');
|
|
||||||
setContentType('empty');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setPrivacyPolicyLoaded(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
displayPrivacyPolicy();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// inject inline styles for parsed HTML content and cleanup on unmount or styles change
|
|
||||||
useEffect(() => {
|
|
||||||
const styleId = 'privacy-policy-inline-styles';
|
|
||||||
const createdLinkIds = [];
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (htmlLinks && htmlLinks.length) {
|
|
||||||
htmlLinks.forEach((href, idx) => {
|
|
||||||
try {
|
|
||||||
const existing = document.querySelector(`link[rel="stylesheet"][href="${href}"]`);
|
|
||||||
if (existing) return;
|
|
||||||
const linkId = `${styleId}-link-${idx}`;
|
|
||||||
const linkEl = document.createElement('link');
|
|
||||||
linkEl.id = linkId;
|
|
||||||
linkEl.rel = 'stylesheet';
|
|
||||||
linkEl.href = href;
|
|
||||||
document.head.appendChild(linkEl);
|
|
||||||
createdLinkIds.push(linkId);
|
|
||||||
} catch (e) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
const el = document.getElementById(styleId);
|
|
||||||
if (el) el.remove();
|
|
||||||
createdLinkIds.forEach((id) => {
|
|
||||||
const l = document.getElementById(id);
|
|
||||||
if (l) l.remove();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}, [htmlStyles]);
|
|
||||||
|
|
||||||
const renderContent = () => {
|
|
||||||
if (!privacyPolicyLoaded) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '16px', paddingTop: `${HEADER_HEIGHT + 16}px` }}>
|
|
||||||
<MarkdownRenderer content="" loading={true} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contentType === 'empty' || !privacyPolicy) {
|
|
||||||
return (
|
|
||||||
<div style={{ marginTop: HEADER_HEIGHT + 20 }}>
|
|
||||||
<Empty
|
|
||||||
image={
|
|
||||||
<IllustrationConstruction style={{ width: 150, height: 150 }} />
|
|
||||||
}
|
|
||||||
darkModeImage={
|
|
||||||
<IllustrationConstructionDark style={{ width: 150, height: 150 }} />
|
|
||||||
}
|
|
||||||
description={t('管理员未设置隐私政策内容')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contentType === 'url') {
|
|
||||||
return (
|
|
||||||
<iframe
|
|
||||||
src={privacyPolicy}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: `calc(100vh - ${HEADER_HEIGHT}px)`,
|
|
||||||
border: 'none',
|
|
||||||
marginTop: `${HEADER_HEIGHT}px`,
|
|
||||||
}}
|
|
||||||
title={t('隐私政策')}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contentType === 'html') {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '24px',
|
|
||||||
paddingTop: `${HEADER_HEIGHT + 24}px`,
|
|
||||||
maxWidth: '1000px',
|
|
||||||
margin: '0 auto',
|
|
||||||
lineHeight: '1.6',
|
|
||||||
}}
|
|
||||||
dangerouslySetInnerHTML={{ __html: htmlBody || privacyPolicy }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// markdown 或 text 内容
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '24px',
|
|
||||||
paddingTop: `${HEADER_HEIGHT + 24}px`,
|
|
||||||
maxWidth: '1000px',
|
|
||||||
margin: '0 auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MarkdownRenderer
|
|
||||||
content={privacyPolicy}
|
|
||||||
fontSize={16}
|
|
||||||
style={{ lineHeight: '1.8' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return <>{renderContent()}</>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PrivacyPolicy;
|
export default PrivacyPolicy;
|
||||||
@ -17,236 +17,21 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
For commercial licensing, please contact support@quantumnous.com
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React from 'react';
|
||||||
import { API, showError } from '../../helpers';
|
|
||||||
import { Empty } from '@douyinfe/semi-ui';
|
|
||||||
import {
|
|
||||||
IllustrationConstruction,
|
|
||||||
IllustrationConstructionDark,
|
|
||||||
} from '@douyinfe/semi-illustrations';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import MarkdownRenderer from '../../components/common/markdown/MarkdownRenderer';
|
import DocumentRenderer from '../../components/common/DocumentRenderer';
|
||||||
import { getContentType } from '../../utils/contentDetector';
|
|
||||||
|
|
||||||
const UserAgreement = () => {
|
const UserAgreement = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [userAgreement, setUserAgreement] = useState('');
|
|
||||||
const [userAgreementLoaded, setUserAgreementLoaded] = useState(false);
|
|
||||||
const [contentType, setContentType] = useState('empty');
|
|
||||||
const [htmlBody, setHtmlBody] = useState('');
|
|
||||||
const [htmlStyles, setHtmlStyles] = useState('');
|
|
||||||
const [htmlLinks, setHtmlLinks] = useState([]);
|
|
||||||
// Height of the top navigation/header in pixels. Adjust if your header is a different height.
|
|
||||||
const HEADER_HEIGHT = 64;
|
|
||||||
|
|
||||||
const displayUserAgreement = async () => {
|
return (
|
||||||
// 先从缓存中获取
|
<DocumentRenderer
|
||||||
const cachedContent = localStorage.getItem('user_agreement') || '';
|
apiEndpoint="/api/user-agreement"
|
||||||
if (cachedContent) {
|
title={t('用户协议')}
|
||||||
setUserAgreement(cachedContent);
|
cacheKey="user_agreement"
|
||||||
const ct = getContentType(cachedContent);
|
emptyMessage={t('加载用户协议内容失败...')}
|
||||||
setContentType(ct);
|
/>
|
||||||
if (ct === 'html') {
|
);
|
||||||
try {
|
|
||||||
const parser = new DOMParser();
|
|
||||||
const doc = parser.parseFromString(cachedContent, 'text/html');
|
|
||||||
setHtmlBody(doc.body ? doc.body.innerHTML : cachedContent);
|
|
||||||
const styles = Array.from(doc.querySelectorAll('style'))
|
|
||||||
.map((s) => s.innerHTML)
|
|
||||||
.join('\n');
|
|
||||||
setHtmlStyles(styles);
|
|
||||||
const links = Array.from(doc.querySelectorAll('link[rel="stylesheet"]'))
|
|
||||||
.map((l) => l.getAttribute('href') || l.href)
|
|
||||||
.filter(Boolean);
|
|
||||||
setHtmlLinks(links);
|
|
||||||
} catch (e) {
|
|
||||||
setHtmlBody(cachedContent);
|
|
||||||
setHtmlStyles('');
|
|
||||||
setHtmlLinks([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await API.get('/api/user-agreement');
|
|
||||||
const { success, message, data } = res.data;
|
|
||||||
if (success && data) {
|
|
||||||
// 直接使用原始数据,不进行任何预处理
|
|
||||||
setUserAgreement(data);
|
|
||||||
const ct = getContentType(data);
|
|
||||||
setContentType(ct);
|
|
||||||
if (ct === 'html') {
|
|
||||||
try {
|
|
||||||
const parser = new DOMParser();
|
|
||||||
const doc = parser.parseFromString(data, 'text/html');
|
|
||||||
setHtmlBody(doc.body ? doc.body.innerHTML : data);
|
|
||||||
const styles = Array.from(doc.querySelectorAll('style'))
|
|
||||||
.map((s) => s.innerHTML)
|
|
||||||
.join('\n');
|
|
||||||
setHtmlStyles(styles);
|
|
||||||
const links = Array.from(doc.querySelectorAll('link[rel="stylesheet"]'))
|
|
||||||
.map((l) => l.getAttribute('href') || l.href)
|
|
||||||
.filter(Boolean);
|
|
||||||
setHtmlLinks(links);
|
|
||||||
} catch (e) {
|
|
||||||
setHtmlBody(data);
|
|
||||||
setHtmlStyles('');
|
|
||||||
setHtmlLinks([]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setHtmlBody('');
|
|
||||||
setHtmlStyles('');
|
|
||||||
setHtmlLinks([]);
|
|
||||||
}
|
|
||||||
localStorage.setItem('user_agreement', data);
|
|
||||||
} else {
|
|
||||||
if (!cachedContent) {
|
|
||||||
showError(message || t('加载用户协议内容失败...'));
|
|
||||||
setUserAgreement('');
|
|
||||||
setContentType('empty');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!cachedContent) {
|
|
||||||
showError(t('加载用户协议内容失败...'));
|
|
||||||
setUserAgreement('');
|
|
||||||
setContentType('empty');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setUserAgreementLoaded(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
displayUserAgreement();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// inject inline styles for parsed HTML content and cleanup on unmount or styles change
|
|
||||||
useEffect(() => {
|
|
||||||
// if there's nothing to inject, remove any existing injected elements
|
|
||||||
const styleId = 'user-agreement-inline-styles';
|
|
||||||
const createdLinkIds = [];
|
|
||||||
|
|
||||||
// handle style tags
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
// handle external stylesheet links
|
|
||||||
if (htmlLinks && htmlLinks.length) {
|
|
||||||
htmlLinks.forEach((href, idx) => {
|
|
||||||
try {
|
|
||||||
// avoid duplicate injection if a link with same href already exists
|
|
||||||
const existing = document.querySelector(`link[rel="stylesheet"][href="${href}"]`);
|
|
||||||
if (existing) return;
|
|
||||||
const linkId = `${styleId}-link-${idx}`;
|
|
||||||
const linkEl = document.createElement('link');
|
|
||||||
linkEl.id = linkId;
|
|
||||||
linkEl.rel = 'stylesheet';
|
|
||||||
linkEl.href = href;
|
|
||||||
document.head.appendChild(linkEl);
|
|
||||||
createdLinkIds.push(linkId);
|
|
||||||
} catch (e) {
|
|
||||||
// ignore malformed hrefs
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
const el = document.getElementById(styleId);
|
|
||||||
if (el) el.remove();
|
|
||||||
// remove only the links we created
|
|
||||||
createdLinkIds.forEach((id) => {
|
|
||||||
const l = document.getElementById(id);
|
|
||||||
if (l) l.remove();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}, [htmlStyles]);
|
|
||||||
|
|
||||||
const renderContent = () => {
|
|
||||||
if (!userAgreementLoaded) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '16px', paddingTop: `${HEADER_HEIGHT + 16}px` }}>
|
|
||||||
<MarkdownRenderer content="" loading={true} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contentType === 'empty' || !userAgreement) {
|
|
||||||
return (
|
|
||||||
<div style={{ marginTop: HEADER_HEIGHT + 20 }}>
|
|
||||||
<Empty
|
|
||||||
image={
|
|
||||||
<IllustrationConstruction style={{ width: 150, height: 150 }} />
|
|
||||||
}
|
|
||||||
darkModeImage={
|
|
||||||
<IllustrationConstructionDark style={{ width: 150, height: 150 }} />
|
|
||||||
}
|
|
||||||
description={t('管理员未设置用户协议内容')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contentType === 'url') {
|
|
||||||
return (
|
|
||||||
<iframe
|
|
||||||
src={userAgreement}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: `calc(100vh - ${HEADER_HEIGHT}px)`,
|
|
||||||
border: 'none',
|
|
||||||
marginTop: `${HEADER_HEIGHT}px`,
|
|
||||||
}}
|
|
||||||
title={t('用户协议')}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contentType === 'html') {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '24px',
|
|
||||||
paddingTop: `${HEADER_HEIGHT + 24}px`,
|
|
||||||
maxWidth: '1000px',
|
|
||||||
margin: '0 auto',
|
|
||||||
lineHeight: '1.6',
|
|
||||||
}}
|
|
||||||
dangerouslySetInnerHTML={{ __html: htmlBody || userAgreement }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// markdown 或 text 内容
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '24px',
|
|
||||||
paddingTop: `${HEADER_HEIGHT + 24}px`,
|
|
||||||
maxWidth: '1000px',
|
|
||||||
margin: '0 auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MarkdownRenderer
|
|
||||||
content={userAgreement}
|
|
||||||
fontSize={16}
|
|
||||||
style={{ lineHeight: '1.8' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return <>{renderContent()}</>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default UserAgreement;
|
export default UserAgreement;
|
||||||
@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* 检测内容类型并返回相应的渲染信息
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 检查是否为 URL
|
|
||||||
export const isUrl = (content) => {
|
|
||||||
try {
|
|
||||||
new URL(content);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 检查是否为 HTML 内容
|
|
||||||
export const isHtmlContent = (content) => {
|
|
||||||
if (!content || typeof content !== 'string') return false;
|
|
||||||
|
|
||||||
// 检查是否包含HTML标签
|
|
||||||
const htmlTagRegex = /<\/?[a-z][\s\S]*>/i;
|
|
||||||
return htmlTagRegex.test(content);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 检查是否为 Markdown 内容
|
|
||||||
export const isMarkdownContent = (content) => {
|
|
||||||
if (!content || typeof content !== 'string') return false;
|
|
||||||
|
|
||||||
// 如果已经是HTML,则不是原始Markdown
|
|
||||||
if (isHtmlContent(content)) return false;
|
|
||||||
|
|
||||||
// 检查Markdown特征
|
|
||||||
const markdownFeatures = [
|
|
||||||
/^#{1,6}\s+.+$/m, // 标题
|
|
||||||
/^\*\s+.+$/m, // 无序列表
|
|
||||||
/^\d+\.\s+.+$/m, // 有序列表
|
|
||||||
/\*\*.+\*\*/, // 粗体
|
|
||||||
/\*.+\*/, // 斜体
|
|
||||||
/\[.+\]\(.+\)/, // 链接
|
|
||||||
/^>.+$/m, // 引用
|
|
||||||
/^```[\s\S]*?```$/m, // 代码块
|
|
||||||
/`[^`]+`/, // 行内代码
|
|
||||||
/^\|.+\|$/m, // 表格
|
|
||||||
/^---+$/m, // 分割线
|
|
||||||
];
|
|
||||||
|
|
||||||
return markdownFeatures.some(regex => regex.test(content));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取内容类型
|
|
||||||
export const getContentType = (content) => {
|
|
||||||
if (!content) return 'empty';
|
|
||||||
|
|
||||||
const trimmedContent = content.trim();
|
|
||||||
|
|
||||||
if (isUrl(trimmedContent)) return 'url';
|
|
||||||
if (isHtmlContent(trimmedContent)) return 'html';
|
|
||||||
if (isMarkdownContent(trimmedContent)) return 'markdown';
|
|
||||||
|
|
||||||
// 默认当作纯文本处理
|
|
||||||
return 'text';
|
|
||||||
};
|
|
||||||
Loading…
x
Reference in New Issue
Block a user