mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-30 09:15:42 +00:00
Merge pull request #1992 from seefs001/pr-upstream-1981
feat(web): add settings & pages of privacy policy & user agreement
This commit is contained in:
@@ -5,4 +5,5 @@
|
||||
.gitignore
|
||||
Makefile
|
||||
docs
|
||||
.eslintcache
|
||||
.eslintcache
|
||||
.gocache
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,6 +13,7 @@ new-api
|
||||
.DS_Store
|
||||
tiktoken_cache
|
||||
.eslintcache
|
||||
.gocache
|
||||
|
||||
electron/node_modules
|
||||
electron/dist
|
||||
@@ -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()
|
||||
|
||||
@@ -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
setting/system_setting/legal.go
Normal file
21
setting/system_setting/legal.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
web/src/components/common/DocumentRenderer/index.jsx
Normal file
243
web/src/components/common/DocumentRenderer/index.jsx
Normal file
@@ -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 support@quantumnous.com
|
||||
*/
|
||||
|
||||
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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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é"
|
||||
}
|
||||
|
||||
@@ -111,5 +111,27 @@
|
||||
"补单失败": "补单失败",
|
||||
"确认补单": "确认补单",
|
||||
"是否将该订单标记为成功并为用户入账?": "是否将该订单标记为成功并为用户入账?",
|
||||
"操作": "操作"
|
||||
"操作": "操作",
|
||||
"用户协议": "用户协议",
|
||||
"隐私政策": "隐私政策",
|
||||
"在此输入用户协议内容,支持 Markdown & HTML 代码": "在此输入用户协议内容,支持 Markdown & HTML 代码",
|
||||
"在此输入隐私政策内容,支持 Markdown & HTML 代码": "在此输入隐私政策内容,支持 Markdown & HTML 代码",
|
||||
"设置用户协议": "设置用户协议",
|
||||
"设置隐私政策": "设置隐私政策",
|
||||
"用户协议已更新": "用户协议已更新",
|
||||
"隐私政策已更新": "隐私政策已更新",
|
||||
"用户协议更新失败": "用户协议更新失败",
|
||||
"隐私政策更新失败": "隐私政策更新失败",
|
||||
"管理员未设置用户协议内容": "管理员未设置用户协议内容",
|
||||
"管理员未设置隐私政策内容": "管理员未设置隐私政策内容",
|
||||
"加载用户协议内容失败...": "加载用户协议内容失败...",
|
||||
"加载隐私政策内容失败...": "加载隐私政策内容失败...",
|
||||
"我已阅读并同意": "我已阅读并同意",
|
||||
"和": "和",
|
||||
"请先阅读并同意用户协议和隐私政策": "请先阅读并同意用户协议和隐私政策",
|
||||
"填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议": "填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议",
|
||||
"填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策": "填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策",
|
||||
"管理员设置了外部链接,点击下方按钮访问": "管理员设置了外部链接,点击下方按钮访问",
|
||||
"访问用户协议": "访问用户协议",
|
||||
"访问隐私政策": "访问隐私政策"
|
||||
}
|
||||
|
||||
37
web/src/pages/PrivacyPolicy/index.jsx
Normal file
37
web/src/pages/PrivacyPolicy/index.jsx
Normal file
@@ -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 support@quantumnous.com
|
||||
*/
|
||||
|
||||
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
web/src/pages/UserAgreement/index.jsx
Normal file
37
web/src/pages/UserAgreement/index.jsx
Normal file
@@ -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 support@quantumnous.com
|
||||
*/
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user