فهرست منبع

feat(web): add settings & pages of privacy policy & user agreement

キュビビイ 2 ماه پیش
والد
کامیت
6891057647

+ 22 - 0
controller/misc.go

@@ -151,6 +151,28 @@ func GetAbout(c *gin.Context) {
 	return
 }
 
+func GetUserAgreement(c *gin.Context) {
+	common.OptionMapRWMutex.RLock()
+	defer common.OptionMapRWMutex.RUnlock()
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    common.OptionMap["UserAgreement"],
+	})
+	return
+}
+
+func GetPrivacyPolicy(c *gin.Context) {
+	common.OptionMapRWMutex.RLock()
+	defer common.OptionMapRWMutex.RUnlock()
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    common.OptionMap["PrivacyPolicy"],
+	})
+	return
+}
+
 func GetMidjourney(c *gin.Context) {
 	common.OptionMapRWMutex.RLock()
 	defer common.OptionMapRWMutex.RUnlock()

+ 2 - 0
model/option.go

@@ -61,6 +61,8 @@ func InitOptionMap() {
 	common.OptionMap["SMTPToken"] = ""
 	common.OptionMap["SMTPSSLEnabled"] = strconv.FormatBool(common.SMTPSSLEnabled)
 	common.OptionMap["Notice"] = ""
+	common.OptionMap["UserAgreement"] = ""
+	common.OptionMap["PrivacyPolicy"] = ""
 	common.OptionMap["About"] = ""
 	common.OptionMap["HomePageContent"] = ""
 	common.OptionMap["Footer"] = common.Footer

+ 2 - 0
router/api-router.go

@@ -20,6 +20,8 @@ func SetApiRouter(router *gin.Engine) {
 		apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels)
 		apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus)
 		apiRouter.GET("/notice", controller.GetNotice)
+		apiRouter.GET("/user-agreement", controller.GetUserAgreement)
+		apiRouter.GET("/privacy-policy", controller.GetPrivacyPolicy)
 		apiRouter.GET("/about", controller.GetAbout)
 		//apiRouter.GET("/midjourney", controller.GetMidjourney)
 		apiRouter.GET("/home_page_content", controller.GetHomePageContent)

+ 18 - 0
web/src/App.jsx

@@ -51,6 +51,8 @@ import SetupCheck from './components/layout/SetupCheck';
 const Home = lazy(() => import('./pages/Home'));
 const Dashboard = lazy(() => import('./pages/Dashboard'));
 const About = lazy(() => import('./pages/About'));
+const UserAgreement = lazy(() => import('./pages/UserAgreement'));
+const PrivacyPolicy = lazy(() => import('./pages/PrivacyPolicy'));
 
 function App() {
   const location = useLocation();
@@ -301,6 +303,22 @@ function App() {
             </Suspense>
           }
         />
+        <Route
+          path='/user-agreement'
+          element={
+            <Suspense fallback={<Loading></Loading>} key={location.pathname}>
+              <UserAgreement />
+            </Suspense>
+          }
+        />
+        <Route
+          path='/privacy-policy'
+          element={
+            <Suspense fallback={<Loading></Loading>} key={location.pathname}>
+              <PrivacyPolicy />
+            </Suspense>
+          }
+        />
         <Route
           path='/console/chat/:id?'
           element={

+ 64 - 0
web/src/components/auth/RegisterForm.jsx

@@ -82,6 +82,9 @@ const RegisterForm = () => {
   const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
   const [disableButton, setDisableButton] = useState(false);
   const [countdown, setCountdown] = useState(30);
+  const [agreedToTerms, setAgreedToTerms] = useState(false);
+  const [hasUserAgreement, setHasUserAgreement] = useState(false);
+  const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false);
 
   const logo = getLogo();
   const systemName = getSystemName();
@@ -106,6 +109,28 @@ const RegisterForm = () => {
       setTurnstileEnabled(true);
       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]);
 
   useEffect(() => {
@@ -505,6 +530,44 @@ const RegisterForm = () => {
                   </>
                 )}
 
+                {(hasUserAgreement || hasPrivacyPolicy) && (
+                  <div className='pt-4'>
+                    <Form.Checkbox
+                      checked={agreedToTerms}
+                      onChange={(checked) => setAgreedToTerms(checked)}
+                    >
+                      <Text size='small' className='text-gray-600'>
+                        {t('我已阅读并同意')}
+                        {hasUserAgreement && (
+                          <>
+                            <a
+                              href='/user-agreement'
+                              target='_blank'
+                              rel='noopener noreferrer'
+                              className='text-blue-600 hover:text-blue-800 mx-1'
+                            >
+                              {t('用户协议')}
+                            </a>
+                          </>
+                        )}
+                        {hasUserAgreement && hasPrivacyPolicy && t('和')}
+                        {hasPrivacyPolicy && (
+                          <>
+                            <a
+                              href='/privacy-policy'
+                              target='_blank'
+                              rel='noopener noreferrer'
+                              className='text-blue-600 hover:text-blue-800 mx-1'
+                            >
+                              {t('隐私政策')}
+                            </a>
+                          </>
+                        )}
+                      </Text>
+                    </Form.Checkbox>
+                  </div>
+                )}
+
                 <div className='space-y-2 pt-2'>
                   <Button
                     theme='solid'
@@ -513,6 +576,7 @@ const RegisterForm = () => {
                     htmlType='submit'
                     onClick={handleSubmit}
                     loading={registerLoading}
+                    disabled={(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms}
                   >
                     {t('注册')}
                   </Button>

+ 58 - 0
web/src/components/settings/OtherSetting.jsx

@@ -38,6 +38,8 @@ const OtherSetting = () => {
   const { t } = useTranslation();
   let [inputs, setInputs] = useState({
     Notice: '',
+    UserAgreement: '',
+    PrivacyPolicy: '',
     SystemName: '',
     Logo: '',
     Footer: '',
@@ -69,6 +71,8 @@ const OtherSetting = () => {
 
   const [loadingInput, setLoadingInput] = useState({
     Notice: false,
+    UserAgreement: false,
+    PrivacyPolicy: false,
     SystemName: false,
     Logo: false,
     HomePageContent: false,
@@ -96,6 +100,32 @@ const OtherSetting = () => {
       setLoadingInput((loadingInput) => ({ ...loadingInput, Notice: false }));
     }
   };
+  // 通用设置 - UserAgreement
+  const submitUserAgreement = async () => {
+    try {
+      setLoadingInput((loadingInput) => ({ ...loadingInput, UserAgreement: true }));
+      await updateOption('UserAgreement', inputs.UserAgreement);
+      showSuccess(t('用户协议已更新'));
+    } catch (error) {
+      console.error(t('用户协议更新失败'), error);
+      showError(t('用户协议更新失败'));
+    } finally {
+      setLoadingInput((loadingInput) => ({ ...loadingInput, UserAgreement: false }));
+    }
+  };
+  // 通用设置 - PrivacyPolicy
+  const submitPrivacyPolicy = async () => {
+    try {
+      setLoadingInput((loadingInput) => ({ ...loadingInput, PrivacyPolicy: true }));
+      await updateOption('PrivacyPolicy', inputs.PrivacyPolicy);
+      showSuccess(t('隐私政策已更新'));
+    } catch (error) {
+      console.error(t('隐私政策更新失败'), error);
+      showError(t('隐私政策更新失败'));
+    } finally {
+      setLoadingInput((loadingInput) => ({ ...loadingInput, PrivacyPolicy: false }));
+    }
+  };
   // 个性化设置
   const formAPIPersonalization = useRef();
   //  个性化设置 - SystemName
@@ -324,6 +354,34 @@ const OtherSetting = () => {
               <Button onClick={submitNotice} loading={loadingInput['Notice']}>
                 {t('设置公告')}
               </Button>
+              <Form.TextArea
+                label={t('用户协议')}
+                placeholder={t(
+                  '在此输入用户协议内容,支持 Markdown & HTML 代码',
+                )}
+                field={'UserAgreement'}
+                onChange={handleInputChange}
+                style={{ fontFamily: 'JetBrains Mono, Consolas' }}
+                autosize={{ minRows: 6, maxRows: 12 }}
+                helpText={t('填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议')}
+              />
+              <Button onClick={submitUserAgreement} loading={loadingInput['UserAgreement']}>
+                {t('设置用户协议')}
+              </Button>
+              <Form.TextArea
+                label={t('隐私政策')}
+                placeholder={t(
+                  '在此输入隐私政策内容,支持 Markdown & HTML 代码',
+                )}
+                field={'PrivacyPolicy'}
+                onChange={handleInputChange}
+                style={{ fontFamily: 'JetBrains Mono, Consolas' }}
+                autosize={{ minRows: 6, maxRows: 12 }}
+                helpText={t('填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策')}
+              />
+              <Button onClick={submitPrivacyPolicy} loading={loadingInput['PrivacyPolicy']}>
+                {t('设置隐私政策')}
+              </Button>
             </Form.Section>
           </Card>
         </Form>

+ 18 - 1
web/src/i18n/locales/en.json

@@ -244,6 +244,8 @@
   "检查更新": "Check for updates",
   "公告": "Announcement",
   "在此输入新的公告内容,支持 Markdown & HTML 代码": "Enter the new announcement content here, supports Markdown & HTML code",
+  "在此输入用户协议内容,支持 Markdown & HTML 代码": "Enter user agreement content here, supports Markdown & HTML code",
+  "在此输入隐私政策内容,支持 Markdown & HTML 代码": "Enter privacy policy content here, supports Markdown & HTML code",
   "保存公告": "Save Announcement",
   "个性化设置": "Personalization Settings",
   "系统名称": "System Name",
@@ -1260,6 +1262,8 @@
   "仅修改展示粒度,统计精确到小时": "Only modify display granularity, statistics accurate to the hour",
   "当运行通道全部测试时,超过此时间将自动禁用通道": "When running all channel tests, the channel will be automatically disabled when this time is exceeded",
   "设置公告": "Set notice",
+  "设置用户协议": "Set user agreement",
+  "设置隐私政策": "Set privacy policy",
   "设置 Logo": "Set Logo",
   "设置首页内容": "Set home page content",
   "设置关于": "Set about",
@@ -2259,5 +2263,18 @@
   "补单成功": "Order completed successfully",
   "补单失败": "Failed to complete order",
   "确认补单": "Confirm Order Completion",
-  "是否将该订单标记为成功并为用户入账?": "Mark this order as successful and credit the user?"
+  "是否将该订单标记为成功并为用户入账?": "Mark this order as successful and credit the user?",
+  "用户协议": "User Agreement",
+  "隐私政策": "Privacy Policy",
+  "用户协议更新失败": "Failed to update user agreement",
+  "隐私政策更新失败": "Failed to update privacy policy",
+  "管理员未设置用户协议内容": "Administrator has not set user agreement content",
+  "管理员未设置隐私政策内容": "Administrator has not set privacy policy content",
+  "加载用户协议内容失败...": "Failed to load user agreement content...",
+  "加载隐私政策内容失败...": "Failed to load privacy policy content...",
+  "我已阅读并同意": "I have read and agree to",
+  "和": " 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"
 }

+ 20 - 1
web/src/i18n/locales/fr.json

@@ -2251,5 +2251,24 @@
   "补单成功": "Commande complétée avec succès",
   "补单失败": "Échec de la complétion de la commande",
   "确认补单": "Confirmer la complétion",
-  "是否将该订单标记为成功并为用户入账?": "Marquer cette commande comme réussie et créditer l'utilisateur ?"
+  "是否将该订单标记为成功并为用户入账?": "Marquer cette commande comme réussie et créditer l'utilisateur ?",
+  "用户协议": "Accord utilisateur",
+  "隐私政策": "Politique de confidentialité",
+  "在此输入用户协议内容,支持 Markdown & HTML 代码": "Saisissez ici le contenu de l'accord utilisateur, prend en charge le code Markdown et HTML",
+  "在此输入隐私政策内容,支持 Markdown & HTML 代码": "Saisissez ici le contenu de la politique de confidentialité, prend en charge le code Markdown et HTML",
+  "设置用户协议": "Définir l'accord utilisateur",
+  "设置隐私政策": "Définir la politique de confidentialité",
+  "用户协议已更新": "L'accord utilisateur a été mis à jour",
+  "隐私政策已更新": "La politique de confidentialité a été mise à jour",
+  "用户协议更新失败": "Échec de la mise à jour de l'accord utilisateur",
+  "隐私政策更新失败": "Échec de la mise à jour de la politique de confidentialité",
+  "管理员未设置用户协议内容": "L'administrateur n'a pas défini le contenu de l'accord utilisateur",
+  "管理员未设置隐私政策内容": "L'administrateur n'a pas défini le contenu de la politique de confidentialité",
+  "加载用户协议内容失败...": "Échec du chargement du contenu de l'accord utilisateur...",
+  "加载隐私政策内容失败...": "Échec du chargement du contenu de la politique de confidentialité...",
+  "我已阅读并同意": "J'ai lu et j'accepte",
+  "和": " 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"
 }

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

@@ -111,5 +111,24 @@
   "补单失败": "补单失败",
   "确认补单": "确认补单",
   "是否将该订单标记为成功并为用户入账?": "是否将该订单标记为成功并为用户入账?",
-  "操作": "操作"
+  "操作": "操作",
+  "用户协议": "用户协议",
+  "隐私政策": "隐私政策",
+  "在此输入用户协议内容,支持 Markdown & HTML 代码": "在此输入用户协议内容,支持 Markdown & HTML 代码",
+  "在此输入隐私政策内容,支持 Markdown & HTML 代码": "在此输入隐私政策内容,支持 Markdown & HTML 代码",
+  "设置用户协议": "设置用户协议",
+  "设置隐私政策": "设置隐私政策",
+  "用户协议已更新": "用户协议已更新",
+  "隐私政策已更新": "隐私政策已更新",
+  "用户协议更新失败": "用户协议更新失败",
+  "隐私政策更新失败": "隐私政策更新失败",
+  "管理员未设置用户协议内容": "管理员未设置用户协议内容",
+  "管理员未设置隐私政策内容": "管理员未设置隐私政策内容",
+  "加载用户协议内容失败...": "加载用户协议内容失败...",
+  "加载隐私政策内容失败...": "加载隐私政策内容失败...",
+  "我已阅读并同意": "我已阅读并同意",
+  "和": "和",
+  "请先阅读并同意用户协议和隐私政策": "请先阅读并同意用户协议和隐私政策",
+  "填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议": "填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议",
+  "填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策": "填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策"
 }

+ 249 - 0
web/src/pages/PrivacyPolicy/index.jsx

@@ -0,0 +1,249 @@
+/*
+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 } from '@douyinfe/semi-ui';
+import {
+  IllustrationConstruction,
+  IllustrationConstructionDark,
+} from '@douyinfe/semi-illustrations';
+import { useTranslation } from 'react-i18next';
+import MarkdownRenderer from '../../components/common/markdown/MarkdownRenderer';
+import { getContentType } from '../../utils/contentDetector';
+
+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()}</>;
+};
+
+export default PrivacyPolicy;

+ 252 - 0
web/src/pages/UserAgreement/index.jsx

@@ -0,0 +1,252 @@
+/*
+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 } from '@douyinfe/semi-ui';
+import {
+  IllustrationConstruction,
+  IllustrationConstructionDark,
+} from '@douyinfe/semi-illustrations';
+import { useTranslation } from 'react-i18next';
+import MarkdownRenderer from '../../components/common/markdown/MarkdownRenderer';
+import { getContentType } from '../../utils/contentDetector';
+
+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()}</>;
+};
+
+export default UserAgreement;

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

@@ -0,0 +1,61 @@
+/**
+ * 检测内容类型并返回相应的渲染信息
+ */
+
+// 检查是否为 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';
+};