Merge pull request #1952 from kyubibii/main

fix(topup): add currency symbol to amounts in RechargeCard
This commit is contained in:
Calcium-Ion
2025-10-03 21:39:02 +08:00
committed by GitHub
13 changed files with 10203 additions and 2 deletions

View File

@@ -147,6 +147,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)

9403
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

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

@@ -3,7 +3,20 @@ 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
published by th async function handleSubmit(e) {
if (password.length < 8) {
showError(t('密码长度不能少于 8 位!'));
return;
}
if (password !== password2) {
showError(t('两次输入的密码不一致'));
return;
}
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
showError(t('请先阅读并同意用户协议和隐私政策'));
return;
}
if (username && password) {tware 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,
@@ -82,6 +95,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 +122,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 +543,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 +589,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,10 +1262,14 @@
"仅修改展示粒度,统计精确到小时": "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",
"公告已更新": "Notice updated",
"用户协议已更新": "User agreement updated",
"隐私政策已更新": "Privacy policy updated",
"系统名称已更新": "System name updated",
"Logo 已更新": "Logo updated",
"首页内容已更新": "Home page content updated",
@@ -2243,6 +2249,19 @@
"追加模式:将新密钥添加到现有密钥列表末尾": "Append mode: add new keys to the end of the existing key list",
"覆盖模式:将完全替换现有的所有密钥": "Overwrite mode: completely replace all existing keys",
"轮询模式必须搭配Redis和内存缓存功能使用否则性能将大幅降低并且无法实现轮询功能": "Polling mode must be used with Redis and memory cache functions, otherwise the performance will be significantly reduced and the polling function will not be implemented",
"用户协议": "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",
"common": {
"changeLanguage": "Change Language"
}

View File

@@ -2173,6 +2173,25 @@
"service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用": "Le champ service_tier est utilisé pour spécifier le niveau de service. Permettre le passage peut entraîner une facturation plus élevée que prévu. Désactivé par défaut pour éviter des frais supplémentaires",
"store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "Le champ store autorise OpenAI à stocker les données de requête pour l'évaluation et l'optimisation du produit. Désactivé par défaut. L'activation peut causer un dysfonctionnement de Codex",
"safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私": "Le champ safety_identifier aide OpenAI à identifier les utilisateurs d'applications susceptibles de violer les politiques d'utilisation. Désactivé par défaut pour protéger la confidentialité des utilisateurs",
"用户协议": "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",
"common": {
"changeLanguage": "Changer de langue"
},

View File

@@ -94,5 +94,24 @@
"允许通过 Passkey 登录 & 认证": "允许通过 Passkey 登录 & 认证",
"确认解绑 Passkey": "确认解绑 Passkey",
"解绑后将无法使用 Passkey 登录,确定要继续吗?": "解绑后将无法使用 Passkey 登录,确定要继续吗?",
"确认解绑": "确认解绑"
"确认解绑": "确认解绑",
"用户协议": "用户协议",
"隐私政策": "隐私政策",
"在此输入用户协议内容,支持 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';
};