feat: componentize User Agreement and Privacy Policy display

Extracted the User Agreement and Privacy Policy presentation into a
reusable DocumentRenderer component (web/src/components/common/DocumentRenderer).
Unified rendering logic and i18n source for these documents, removed the
legacy contentDetector utility, and updated the related pages to use the
new component. Adjusted controller/backend (controller/misc.go) and locale
files to support the new rendering approach.

This improves reuse, maintainability, and future extensibility.
This commit is contained in:
キュビビイ
2025-10-08 11:12:49 +08:00
parent 00603520e9
commit 0992f834da
9 changed files with 280 additions and 532 deletions

View File

@@ -17,233 +17,21 @@ 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 React from 'react';
import { useTranslation } from 'react-i18next';
import MarkdownRenderer from '../../components/common/markdown/MarkdownRenderer';
import { getContentType } from '../../utils/contentDetector';
import DocumentRenderer from '../../components/common/DocumentRenderer';
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()}</>;
return (
<DocumentRenderer
apiEndpoint="/api/privacy-policy"
title={t('隐私政策')}
cacheKey="privacy_policy"
emptyMessage={t('加载隐私政策内容失败...')}
/>
);
};
export default PrivacyPolicy;

View File

@@ -17,236 +17,21 @@ 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 React from 'react';
import { useTranslation } from 'react-i18next';
import MarkdownRenderer from '../../components/common/markdown/MarkdownRenderer';
import { getContentType } from '../../utils/contentDetector';
import DocumentRenderer from '../../components/common/DocumentRenderer';
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()}</>;
return (
<DocumentRenderer
apiEndpoint="/api/user-agreement"
title={t('用户协议')}
cacheKey="user_agreement"
emptyMessage={t('加载用户协议内容失败...')}
/>
);
};
export default UserAgreement;