Просмотр исходного кода

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.
キュビビイ 2 месяцев назад
Родитель
Сommit
4d0a9d9494

+ 2 - 0
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"] != "",
 	}
 
 	// 根据启用状态注入可选内容

+ 3 - 21
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(() => {

+ 243 - 0
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 <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;

+ 4 - 1
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"
 }

+ 4 - 1
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é"
 }

+ 4 - 1
web/src/i18n/locales/zh.json

@@ -130,5 +130,8 @@
   "和": "和",
   "请先阅读并同意用户协议和隐私政策": "请先阅读并同意用户协议和隐私政策",
   "填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议": "填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议",
-  "填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策": "填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策"
+  "填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策": "填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策",
+  "管理员设置了外部链接,点击下方按钮访问": "管理员设置了外部链接,点击下方按钮访问",
+  "访问用户协议": "访问用户协议",
+  "访问隐私政策": "访问隐私政策"
 }

+ 10 - 222
web/src/pages/PrivacyPolicy/index.jsx

@@ -17,233 +17,21 @@ 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 } 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 (
-        <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()}</>;
+  return (
+    <DocumentRenderer
+      apiEndpoint="/api/privacy-policy"
+      title={t('隐私政策')}
+      cacheKey="privacy_policy"
+      emptyMessage={t('加载隐私政策内容失败...')}
+    />
+  );
 };
 
 export default PrivacyPolicy;

+ 10 - 225
web/src/pages/UserAgreement/index.jsx

@@ -17,236 +17,21 @@ 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 } 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 UserAgreement = () => {
   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 () => {
-    // 先从缓存中获取
-    const cachedContent = localStorage.getItem('user_agreement') || '';
-    if (cachedContent) {
-      setUserAgreement(cachedContent);
-      const ct = getContentType(cachedContent);
-      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()}</>;
+  return (
+    <DocumentRenderer
+      apiEndpoint="/api/user-agreement"
+      title={t('用户协议')}
+      cacheKey="user_agreement"
+      emptyMessage={t('加载用户协议内容失败...')}
+    />
+  );
 };
 
 export default UserAgreement;

+ 0 - 61
web/src/utils/contentDetector.js

@@ -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';
-};