Browse Source

Merge pull request #1992 from seefs001/pr-upstream-1981

feat(web): add settings & pages of privacy policy & user agreement
Calcium-Ion 3 months ago
parent
commit
0c181395b4

+ 2 - 1
.dockerignore

@@ -5,4 +5,5 @@
 .gitignore
 Makefile
 docs
-.eslintcache
+.eslintcache
+.gocache

+ 1 - 0
.gitignore

@@ -13,6 +13,7 @@ new-api
 .DS_Store
 tiktoken_cache
 .eslintcache
+.gocache
 
 electron/node_modules
 electron/dist

+ 21 - 0
controller/misc.go

@@ -43,6 +43,7 @@ func GetStatus(c *gin.Context) {
 	defer common.OptionMapRWMutex.RUnlock()
 
 	passkeySetting := system_setting.GetPasskeySettings()
+	legalSetting := system_setting.GetLegalSettings()
 
 	data := gin.H{
 		"version":                     common.Version,
@@ -108,6 +109,8 @@ func GetStatus(c *gin.Context) {
 		"passkey_user_verification":   passkeySetting.UserVerification,
 		"passkey_attachment":          passkeySetting.AttachmentPreference,
 		"setup":                       constant.Setup,
+		"user_agreement_enabled":      legalSetting.UserAgreement != "",
+		"privacy_policy_enabled":      legalSetting.PrivacyPolicy != "",
 	}
 
 	// 根据启用状态注入可选内容
@@ -151,6 +154,24 @@ func GetAbout(c *gin.Context) {
 	return
 }
 
+func GetUserAgreement(c *gin.Context) {
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    system_setting.GetLegalSettings().UserAgreement,
+	})
+	return
+}
+
+func GetPrivacyPolicy(c *gin.Context) {
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    system_setting.GetLegalSettings().PrivacyPolicy,
+	})
+	return
+}
+
 func GetMidjourney(c *gin.Context) {
 	common.OptionMapRWMutex.RLock()
 	defer common.OptionMapRWMutex.RUnlock()

+ 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)

+ 21 - 0
setting/system_setting/legal.go

@@ -0,0 +1,21 @@
+package system_setting
+
+import "one-api/setting/config"
+
+type LegalSettings struct {
+	UserAgreement string `json:"user_agreement"`
+	PrivacyPolicy string `json:"privacy_policy"`
+}
+
+var defaultLegalSettings = LegalSettings{
+	UserAgreement: "",
+	PrivacyPolicy: "",
+}
+
+func init() {
+	config.GlobalConfig.Register("legal", &defaultLegalSettings)
+}
+
+func GetLegalSettings() *LegalSettings {
+	return &defaultLegalSettings
+}

+ 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={

+ 113 - 1
web/src/components/auth/LoginForm.jsx

@@ -37,7 +37,7 @@ import {
   isPasskeySupported,
 } from '../../helpers';
 import Turnstile from 'react-turnstile';
-import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
+import { Button, Card, Checkbox, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
 import Title from '@douyinfe/semi-ui/lib/es/typography/title';
 import Text from '@douyinfe/semi-ui/lib/es/typography/text';
 import TelegramLoginButton from 'react-telegram-login';
@@ -84,6 +84,9 @@ const LoginForm = () => {
   const [showTwoFA, setShowTwoFA] = useState(false);
   const [passkeySupported, setPasskeySupported] = useState(false);
   const [passkeyLoading, setPasskeyLoading] = useState(false);
+  const [agreedToTerms, setAgreedToTerms] = useState(false);
+  const [hasUserAgreement, setHasUserAgreement] = useState(false);
+  const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false);
 
   const logo = getLogo();
   const systemName = getSystemName();
@@ -103,6 +106,10 @@ const LoginForm = () => {
       setTurnstileEnabled(true);
       setTurnstileSiteKey(status.turnstile_site_key);
     }
+    
+    // 从 status 获取用户协议和隐私政策的启用状态
+    setHasUserAgreement(status.user_agreement_enabled || false);
+    setHasPrivacyPolicy(status.privacy_policy_enabled || false);
   }, [status]);
 
   useEffect(() => {
@@ -118,6 +125,10 @@ const LoginForm = () => {
   }, []);
 
   const onWeChatLoginClicked = () => {
+    if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
+      showInfo(t('请先阅读并同意用户协议和隐私政策'));
+      return;
+    }
     setWechatLoading(true);
     setShowWeChatLoginModal(true);
     setWechatLoading(false);
@@ -157,6 +168,10 @@ const LoginForm = () => {
   }
 
   async function handleSubmit(e) {
+    if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
+      showInfo(t('请先阅读并同意用户协议和隐私政策'));
+      return;
+    }
     if (turnstileEnabled && turnstileToken === '') {
       showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
       return;
@@ -208,6 +223,10 @@ const LoginForm = () => {
 
   // 添加Telegram登录处理函数
   const onTelegramLoginClicked = async (response) => {
+    if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
+      showInfo(t('请先阅读并同意用户协议和隐私政策'));
+      return;
+    }
     const fields = [
       'id',
       'first_name',
@@ -244,6 +263,10 @@ const LoginForm = () => {
 
   // 包装的GitHub登录点击处理
   const handleGitHubClick = () => {
+    if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
+      showInfo(t('请先阅读并同意用户协议和隐私政策'));
+      return;
+    }
     setGithubLoading(true);
     try {
       onGitHubOAuthClicked(status.github_client_id);
@@ -255,6 +278,10 @@ const LoginForm = () => {
 
   // 包装的OIDC登录点击处理
   const handleOIDCClick = () => {
+    if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
+      showInfo(t('请先阅读并同意用户协议和隐私政策'));
+      return;
+    }
     setOidcLoading(true);
     try {
       onOIDCClicked(status.oidc_authorization_endpoint, status.oidc_client_id);
@@ -266,6 +293,10 @@ const LoginForm = () => {
 
   // 包装的LinuxDO登录点击处理
   const handleLinuxDOClick = () => {
+    if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
+      showInfo(t('请先阅读并同意用户协议和隐私政策'));
+      return;
+    }
     setLinuxdoLoading(true);
     try {
       onLinuxDOOAuthClicked(status.linuxdo_client_id);
@@ -283,6 +314,10 @@ const LoginForm = () => {
   };
 
   const handlePasskeyLogin = async () => {
+    if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
+      showInfo(t('请先阅读并同意用户协议和隐私政策'));
+      return;
+    }
     if (!passkeySupported) {
       showInfo('当前环境无法使用 Passkey 登录');
       return;
@@ -486,6 +521,44 @@ const LoginForm = () => {
                 </Button>
               </div>
 
+              {(hasUserAgreement || hasPrivacyPolicy) && (
+                <div className='mt-6'>
+                  <Checkbox
+                    checked={agreedToTerms}
+                    onChange={(e) => setAgreedToTerms(e.target.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>
+                    </Checkbox>
+                  </div>
+                )}
+
               {!status.self_use_mode_enabled && (
                 <div className='mt-6 text-center text-sm'>
                   <Text>
@@ -554,6 +627,44 @@ const LoginForm = () => {
                   prefix={<IconLock />}
                 />
 
+                {(hasUserAgreement || hasPrivacyPolicy) && (
+                  <div className='pt-4'>
+                    <Checkbox
+                      checked={agreedToTerms}
+                      onChange={(e) => setAgreedToTerms(e.target.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>
+                    </Checkbox>
+                  </div>
+                )}
+
                 <div className='space-y-2 pt-2'>
                   <Button
                     theme='solid'
@@ -562,6 +673,7 @@ const LoginForm = () => {
                     htmlType='submit'
                     onClick={handleSubmit}
                     loading={loginLoading}
+                    disabled={(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms}
                   >
                     {t('继续')}
                   </Button>

+ 47 - 1
web/src/components/auth/RegisterForm.jsx

@@ -30,7 +30,7 @@ import {
   setUserData,
 } from '../../helpers';
 import Turnstile from 'react-turnstile';
-import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
+import { Button, Card, Checkbox, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
 import Title from '@douyinfe/semi-ui/lib/es/typography/title';
 import Text from '@douyinfe/semi-ui/lib/es/typography/text';
 import {
@@ -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,10 @@ const RegisterForm = () => {
       setTurnstileEnabled(true);
       setTurnstileSiteKey(status.turnstile_site_key);
     }
+    
+    // 从 status 获取用户协议和隐私政策的启用状态
+    setHasUserAgreement(status.user_agreement_enabled || false);
+    setHasPrivacyPolicy(status.privacy_policy_enabled || false);
   }, [status]);
 
   useEffect(() => {
@@ -505,6 +512,44 @@ const RegisterForm = () => {
                   </>
                 )}
 
+                {(hasUserAgreement || hasPrivacyPolicy) && (
+                  <div className='pt-4'>
+                    <Checkbox
+                      checked={agreedToTerms}
+                      onChange={(e) => setAgreedToTerms(e.target.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>
+                    </Checkbox>
+                  </div>
+                )}
+
                 <div className='space-y-2 pt-2'>
                   <Button
                     theme='solid'
@@ -513,6 +558,7 @@ const RegisterForm = () => {
                     htmlType='submit'
                     onClick={handleSubmit}
                     loading={registerLoading}
+                    disabled={(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms}
                   >
                     {t('注册')}
                   </Button>

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

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

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

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

@@ -245,6 +245,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",
@@ -1261,6 +1263,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",
@@ -2260,5 +2264,21 @@
   "补单成功": "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",
+  "管理员设置了外部链接,点击下方按钮访问": "Administrator has set an external link, click the button below to access",
+  "访问用户协议": "Access User Agreement",
+  "访问隐私政策": "Access Privacy Policy"
 }

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

@@ -2252,5 +2252,27 @@
   "补单成功": "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",
+  "管理员设置了外部链接,点击下方按钮访问": "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é"
 }

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

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

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

@@ -0,0 +1,37 @@
+/*
+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 from 'react';
+import { useTranslation } from 'react-i18next';
+import DocumentRenderer from '../../components/common/DocumentRenderer';
+
+const PrivacyPolicy = () => {
+  const { t } = useTranslation();
+
+  return (
+    <DocumentRenderer
+      apiEndpoint="/api/privacy-policy"
+      title={t('隐私政策')}
+      cacheKey="privacy_policy"
+      emptyMessage={t('加载隐私政策内容失败...')}
+    />
+  );
+};
+
+export default PrivacyPolicy;

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

@@ -0,0 +1,37 @@
+/*
+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 from 'react';
+import { useTranslation } from 'react-i18next';
+import DocumentRenderer from '../../components/common/DocumentRenderer';
+
+const UserAgreement = () => {
+  const { t } = useTranslation();
+
+  return (
+    <DocumentRenderer
+      apiEndpoint="/api/user-agreement"
+      title={t('用户协议')}
+      cacheKey="user_agreement"
+      emptyMessage={t('加载用户协议内容失败...')}
+    />
+  );
+};
+
+export default UserAgreement;