diff --git a/controller/misc.go b/controller/misc.go index 38e98fe9c..c6c54b6d1 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -108,6 +108,8 @@ func GetStatus(c *gin.Context) { "passkey_user_verification": passkeySetting.UserVerification, "passkey_attachment": passkeySetting.AttachmentPreference, "setup": constant.Setup, + "user_agreement_enabled": common.OptionMap["UserAgreement"] != "", + "privacy_policy_enabled": common.OptionMap["PrivacyPolicy"] != "", } // 根据启用状态注入可选内容 diff --git a/web/src/components/auth/RegisterForm.jsx b/web/src/components/auth/RegisterForm.jsx index 6c76b20d3..5bf9b670a 100644 --- a/web/src/components/auth/RegisterForm.jsx +++ b/web/src/components/auth/RegisterForm.jsx @@ -110,27 +110,9 @@ const RegisterForm = () => { setTurnstileSiteKey(status.turnstile_site_key); } - // 检查用户协议和隐私政策是否已设置 - const checkTermsAvailability = async () => { - try { - 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 获取用户协议和隐私政策的启用状态 + setHasUserAgreement(status.user_agreement_enabled || false); + setHasPrivacyPolicy(status.privacy_policy_enabled || false); }, [status]); useEffect(() => { diff --git a/web/src/components/common/DocumentRenderer/index.jsx b/web/src/components/common/DocumentRenderer/index.jsx new file mode 100644 index 000000000..383afc11d --- /dev/null +++ b/web/src/components/common/DocumentRenderer/index.jsx @@ -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 . + +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 ( +
+ +
+ ); + } + + // 如果没有内容,显示空状态 + if (!content || content.trim() === '') { + return ( +
+ } + darkModeImage={} + className='p-8' + /> +
+ ); + } + + // 如果是 URL,显示链接卡片 + if (isUrl(content)) { + return ( +
+ +
+ {title} +

+ {t('管理员设置了外部链接,点击下方按钮访问')} +

+ + {t('访问' + title)} + +
+
+
+ ); + } + + // 如果是 HTML 内容,直接渲染 + if (isHtmlContent(content)) { + const { content: htmlContent, styles } = sanitizeHtml(content); + + // 设置样式(如果有的话) + useEffect(() => { + if (styles && styles !== htmlStyles) { + setHtmlStyles(styles); + } + }, [content, styles, htmlStyles]); + + return ( +
+
+
+ {title} +
+
+
+
+ ); + } + + // 其他内容统一使用 Markdown 渲染器 + return ( +
+
+
+ {title} +
+ +
+
+
+
+ ); +}; + +export default DocumentRenderer; \ No newline at end of file diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 7b0f47932..48d5a3ff2 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2276,5 +2276,8 @@ "和": " and ", "请先阅读并同意用户协议和隐私政策": "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 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" } diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index ee2a3c297..ac70a2bde 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -2270,5 +2270,8 @@ "和": " et ", "请先阅读并同意用户协议和隐私政策": "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 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é" } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 661cbea92..8db3e215f 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -130,5 +130,8 @@ "和": "和", "请先阅读并同意用户协议和隐私政策": "请先阅读并同意用户协议和隐私政策", "填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议": "填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议", - "填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策": "填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策" + "填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策": "填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策", + "管理员设置了外部链接,点击下方按钮访问": "管理员设置了外部链接,点击下方按钮访问", + "访问用户协议": "访问用户协议", + "访问隐私政策": "访问隐私政策" } diff --git a/web/src/pages/PrivacyPolicy/index.jsx b/web/src/pages/PrivacyPolicy/index.jsx index 373b8a282..026290b18 100644 --- a/web/src/pages/PrivacyPolicy/index.jsx +++ b/web/src/pages/PrivacyPolicy/index.jsx @@ -17,233 +17,21 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useEffect, useState } from 'react'; -import { API, showError } from '../../helpers'; -import { Empty } from '@douyinfe/semi-ui'; -import { - IllustrationConstruction, - IllustrationConstructionDark, -} from '@douyinfe/semi-illustrations'; +import React from 'react'; import { useTranslation } from 'react-i18next'; -import MarkdownRenderer from '../../components/common/markdown/MarkdownRenderer'; -import { getContentType } from '../../utils/contentDetector'; +import DocumentRenderer from '../../components/common/DocumentRenderer'; const PrivacyPolicy = () => { 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 () => { - // 先从缓存中获取 - const cachedContent = localStorage.getItem('privacy_policy') || ''; - if (cachedContent) { - setPrivacyPolicy(cachedContent); - const ct = getContentType(cachedContent); - 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 ( -
- -
- ); - } - - if (contentType === 'empty' || !privacyPolicy) { - return ( -
- - } - darkModeImage={ - - } - description={t('管理员未设置隐私政策内容')} - /> -
- ); - } - - if (contentType === 'url') { - return ( -