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

This commit is contained in:
キュビビイ
2025-10-08 10:43:47 +08:00
parent a610ef48e4
commit 6891057647
12 changed files with 786 additions and 3 deletions

View File

@@ -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()

View File

@@ -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

View File

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

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"
}

View File

@@ -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"
}

View File

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

View File

@@ -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 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 (
<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;

View File

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

View File

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