From 689105764738b9b2d0cefeb0eca2a333b68e5ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=AD=E3=83=A5=E3=83=93=E3=83=93=E3=82=A4?= Date: Wed, 8 Oct 2025 10:43:47 +0800 Subject: [PATCH 1/4] feat(web): add settings & pages of privacy policy & user agreement --- controller/misc.go | 22 ++ model/option.go | 2 + router/api-router.go | 2 + web/src/App.jsx | 18 ++ web/src/components/auth/RegisterForm.jsx | 64 +++++ web/src/components/settings/OtherSetting.jsx | 58 +++++ web/src/i18n/locales/en.json | 19 +- web/src/i18n/locales/fr.json | 21 +- web/src/i18n/locales/zh.json | 21 +- web/src/pages/PrivacyPolicy/index.jsx | 249 ++++++++++++++++++ web/src/pages/UserAgreement/index.jsx | 252 +++++++++++++++++++ web/src/utils/contentDetector.js | 61 +++++ 12 files changed, 786 insertions(+), 3 deletions(-) create mode 100644 web/src/pages/PrivacyPolicy/index.jsx create mode 100644 web/src/pages/UserAgreement/index.jsx create mode 100644 web/src/utils/contentDetector.js diff --git a/controller/misc.go b/controller/misc.go index a3e017f87..38e98fe9c 100644 --- a/controller/misc.go +++ b/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() diff --git a/model/option.go b/model/option.go index 77525ea25..7f341ff33 100644 --- a/model/option.go +++ b/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 diff --git a/router/api-router.go b/router/api-router.go index 963abd105..7eefa936a 100644 --- a/router/api-router.go +++ b/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) diff --git a/web/src/App.jsx b/web/src/App.jsx index 635742f91..06e364897 100644 --- a/web/src/App.jsx +++ b/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() { } /> + } key={location.pathname}> + + + } + /> + } key={location.pathname}> + + + } + /> { 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) && ( +
+ setAgreedToTerms(checked)} + > + + {t('我已阅读并同意')} + {hasUserAgreement && ( + <> + + {t('用户协议')} + + + )} + {hasUserAgreement && hasPrivacyPolicy && t('和')} + {hasPrivacyPolicy && ( + <> + + {t('隐私政策')} + + + )} + + +
+ )} +
diff --git a/web/src/components/settings/OtherSetting.jsx b/web/src/components/settings/OtherSetting.jsx index 18119d242..6a159e813 100644 --- a/web/src/components/settings/OtherSetting.jsx +++ b/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 = () => { + + + + diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 8b4f1b411..7b0f47932 100644 --- a/web/src/i18n/locales/en.json +++ b/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" } diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index e5db1125b..ee2a3c297 100644 --- a/web/src/i18n/locales/fr.json +++ b/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" } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index dcb693ecd..661cbea92 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -111,5 +111,24 @@ "补单失败": "补单失败", "确认补单": "确认补单", "是否将该订单标记为成功并为用户入账?": "是否将该订单标记为成功并为用户入账?", - "操作": "操作" + "操作": "操作", + "用户协议": "用户协议", + "隐私政策": "隐私政策", + "在此输入用户协议内容,支持 Markdown & HTML 代码": "在此输入用户协议内容,支持 Markdown & HTML 代码", + "在此输入隐私政策内容,支持 Markdown & HTML 代码": "在此输入隐私政策内容,支持 Markdown & HTML 代码", + "设置用户协议": "设置用户协议", + "设置隐私政策": "设置隐私政策", + "用户协议已更新": "用户协议已更新", + "隐私政策已更新": "隐私政策已更新", + "用户协议更新失败": "用户协议更新失败", + "隐私政策更新失败": "隐私政策更新失败", + "管理员未设置用户协议内容": "管理员未设置用户协议内容", + "管理员未设置隐私政策内容": "管理员未设置隐私政策内容", + "加载用户协议内容失败...": "加载用户协议内容失败...", + "加载隐私政策内容失败...": "加载隐私政策内容失败...", + "我已阅读并同意": "我已阅读并同意", + "和": "和", + "请先阅读并同意用户协议和隐私政策": "请先阅读并同意用户协议和隐私政策", + "填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议": "填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议", + "填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策": "填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策" } diff --git a/web/src/pages/PrivacyPolicy/index.jsx b/web/src/pages/PrivacyPolicy/index.jsx new file mode 100644 index 000000000..373b8a282 --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useEffect, useState } from 'react'; +import { API, showError } from '../../helpers'; +import { Empty } from '@douyinfe/semi-ui'; +import { + IllustrationConstruction, + IllustrationConstructionDark, +} from '@douyinfe/semi-illustrations'; +import { 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 ( +
+ +
+ ); + } + + if (contentType === 'empty' || !privacyPolicy) { + return ( +
+ + } + darkModeImage={ + + } + description={t('管理员未设置隐私政策内容')} + /> +
+ ); + } + + if (contentType === 'url') { + return ( +