manual save(2026-01-22 17:13)

This commit is contained in:
SiteAgent Bot
2026-01-22 17:13:59 +08:00
parent 8b7de36cdc
commit ca2732a97c
4 changed files with 375 additions and 21 deletions

View File

@@ -8,6 +8,7 @@ import { Contact } from './pages/Contact'
import { Cases } from './pages/Cases' import { Cases } from './pages/Cases'
import { Learning } from './pages/Learning' import { Learning } from './pages/Learning'
import { Assistant } from './pages/Assistant' import { Assistant } from './pages/Assistant'
import { Privacy } from './pages/Privacy'
// 页面切换动画配置 // 页面切换动画配置
const pageVariants = { const pageVariants = {
@@ -74,6 +75,7 @@ function App() {
<Route path="/news" element={<PageWrapper><News /></PageWrapper>} /> <Route path="/news" element={<PageWrapper><News /></PageWrapper>} />
<Route path="/learning" element={<PageWrapper><Learning /></PageWrapper>} /> <Route path="/learning" element={<PageWrapper><Learning /></PageWrapper>} />
<Route path="/assistant" element={<PageWrapper><Assistant /></PageWrapper>} /> <Route path="/assistant" element={<PageWrapper><Assistant /></PageWrapper>} />
<Route path="/privacy" element={<PageWrapper><Privacy /></PageWrapper>} />
<Route path="/contact" element={<PageWrapper><Contact /></PageWrapper>} /> <Route path="/contact" element={<PageWrapper><Contact /></PageWrapper>} />
{/* English routes */} {/* English routes */}
@@ -84,6 +86,7 @@ function App() {
<Route path="/en/news" element={<PageWrapper><News /></PageWrapper>} /> <Route path="/en/news" element={<PageWrapper><News /></PageWrapper>} />
<Route path="/en/learning" element={<PageWrapper><Learning /></PageWrapper>} /> <Route path="/en/learning" element={<PageWrapper><Learning /></PageWrapper>} />
<Route path="/en/assistant" element={<PageWrapper><Assistant /></PageWrapper>} /> <Route path="/en/assistant" element={<PageWrapper><Assistant /></PageWrapper>} />
<Route path="/en/privacy" element={<PageWrapper><Privacy /></PageWrapper>} />
<Route path="/en/contact" element={<PageWrapper><Contact /></PageWrapper>} /> <Route path="/en/contact" element={<PageWrapper><Contact /></PageWrapper>} />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />

View File

@@ -0,0 +1,127 @@
import React, { useId, useMemo, useState } from 'react'
import { X } from 'lucide-react'
import { Link } from 'react-router-dom'
import { getLocaleFromPathname, withLocalePath } from '../lib/i18n'
type PrivacyConsentModalProps = {
open: boolean
onClose: () => void
onConfirm: (payload: { consentedAt: string; version: string }) => void
locale: 'zh' | 'en'
}
const CONSENT_VERSION = '2026-01-01'
export const PrivacyConsentModal: React.FC<PrivacyConsentModalProps> = ({ open, onClose, onConfirm, locale }) => {
const checkboxId = useId()
const [agreed, setAgreed] = useState(false)
const privacyPath = useMemo(() => withLocalePath(locale, '/privacy'), [locale])
if (!open) return null
return (
<div className="fixed inset-0 z-[100]">
<div className="absolute inset-0 bg-black/40" aria-hidden="true" onClick={onClose} />
<div className="absolute inset-0 flex items-center justify-center p-4">
<div
role="dialog"
aria-modal="true"
aria-label={locale === 'en' ? 'Privacy consent' : '个人信息处理告知与同意'}
className="w-full max-w-lg rounded-2xl bg-white shadow-xl border border-gray-100 overflow-hidden"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<div>
<div className="text-base font-semibold text-primary-dark">
{locale === 'en' ? 'Privacy notice & consent' : '个人信息处理告知与同意'}
</div>
<div className="text-xs text-gray-500 mt-1">
{locale === 'en' ? `Version: ${CONSENT_VERSION}` : `版本:${CONSENT_VERSION}`}
</div>
</div>
<button
type="button"
onClick={onClose}
className="w-9 h-9 inline-flex items-center justify-center rounded-lg text-gray-600 hover:bg-gray-50"
aria-label={locale === 'en' ? 'Close' : '关闭'}
>
<X size={18} />
</button>
</div>
<div className="px-6 py-5 space-y-4 text-sm text-gray-700 leading-relaxed">
<p>
{locale === 'en'
? 'To arrange a callback, we need to process your phone number and related inquiry information.'
: '为安排专员回电沟通,我们需要处理你的手机号及与本次咨询相关的信息。'}
</p>
<div className="rounded-xl bg-background border border-gray-100 p-4 space-y-2">
<div className="font-medium text-primary-dark">{locale === 'en' ? 'What we collect' : '收集信息范围'}</div>
<ul className="list-disc pl-5 space-y-1 text-gray-700">
<li>{locale === 'en' ? 'Phone number (required)' : '手机号(必填)'}</li>
<li>{locale === 'en' ? 'Inquiry description (required)' : '咨询需求描述(必填)'}</li>
<li>{locale === 'en' ? 'Name/company (optional)' : '姓名/公司(选填)'}</li>
</ul>
</div>
<div className="rounded-xl bg-background border border-gray-100 p-4 space-y-2">
<div className="font-medium text-primary-dark">{locale === 'en' ? 'Purpose & retention' : '使用目的与保存期限'}</div>
<p className="text-gray-700">
{locale === 'en'
? 'Used only for contact and follow-up regarding this inquiry. Retention period follows necessary business processing requirements and applicable laws.'
: '仅用于与本次咨询相关的联系、答复与后续服务;保存期限以完成处理所必需的期限及法律法规要求为准。'}
</p>
</div>
<p className="text-xs text-gray-500">
{locale === 'en' ? 'For details, see ' : '更多信息请查看'}
<Link to={privacyPath} className="text-primary hover:text-primary-light underline underline-offset-2">
{locale === 'en' ? 'Privacy Policy' : '《隐私政策》'}
</Link>
{locale === 'en' ? '.' : '。'}
</p>
<label className="flex items-start gap-3 text-sm">
<input
id={checkboxId}
type="checkbox"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
className="mt-1 h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<span>
{locale === 'en'
? 'I have read and agree to the privacy notice above and the Privacy Policy.'
: '我已阅读并同意以上告知内容及《隐私政策》。'}
</span>
</label>
</div>
<div className="px-6 py-4 border-t border-gray-100 flex items-center justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-xl text-sm font-medium text-gray-700 hover:bg-gray-50"
>
{locale === 'en' ? 'Cancel' : '取消'}
</button>
<button
type="button"
onClick={() => onConfirm({ consentedAt: new Date().toISOString(), version: CONSENT_VERSION })}
disabled={!agreed}
className={`px-4 py-2 rounded-xl text-sm font-medium text-white ${
agreed ? 'bg-primary hover:bg-primary-light' : 'bg-gray-300 cursor-not-allowed'
}`}
>
{locale === 'en' ? 'Agree & continue' : '同意并继续'}
</button>
</div>
</div>
</div>
</div>
)
}
export default PrivacyConsentModal

View File

@@ -14,6 +14,7 @@ import {
import { Header } from '../components/Header' import { Header } from '../components/Header'
import { Footer } from '../components/Footer' import { Footer } from '../components/Footer'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { PrivacyConsentModal } from '../components/PrivacyConsentModal'
import { FormSubmissions, Forms, SearchResults } from '../clientsdk/sdk.gen' import { FormSubmissions, Forms, SearchResults } from '../clientsdk/sdk.gen'
import type { Post, SearchResult, SearchResultQueryOperations } from '../clientsdk/types.gen' import type { Post, SearchResult, SearchResultQueryOperations } from '../clientsdk/types.gen'
import { createClient } from '../clientsdk/client' import { createClient } from '../clientsdk/client'
@@ -182,8 +183,26 @@ export const Assistant: React.FC = () => {
const [leadSubmitted, setLeadSubmitted] = useState(false) const [leadSubmitted, setLeadSubmitted] = useState(false)
const [leadSubmitError, setLeadSubmitError] = useState<string | null>(null) const [leadSubmitError, setLeadSubmitError] = useState<string | null>(null)
const [privacyOpen, setPrivacyOpen] = useState(false)
const [pendingSubmitEvent, setPendingSubmitEvent] = useState<React.FormEvent | null>(null)
const [privacyConsent, setPrivacyConsent] = useState<{ consentedAt: string; version: string } | null>(null)
const listRef = useRef<HTMLDivElement | null>(null) const listRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
try {
const cached = localStorage.getItem('privacyConsent.callback')
if (cached) {
const parsed = JSON.parse(cached) as { consentedAt?: string; version?: string }
if (parsed.consentedAt && parsed.version) {
setPrivacyConsent({ consentedAt: parsed.consentedAt, version: parsed.version })
}
}
} catch {
// ignore
}
}, [])
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
try { try {
@@ -293,21 +312,25 @@ export const Assistant: React.FC = () => {
return Object.keys(next).length === 0 return Object.keys(next).length === 0
} }
const submitLead = async (e: React.FormEvent) => { const doSubmitLead = async () => {
e.preventDefault()
setLeadSubmitError(null) setLeadSubmitError(null)
if (!validateLead()) return if (!privacyConsent) {
setLeadSubmitError(locale === 'en' ? 'Please review and agree to the privacy notice first.' : '请先阅读并同意个人信息处理告知。')
return
}
setLeadSubmitting(true) setLeadSubmitting(true)
try { try {
if (!leadFormId) { if (!leadFormId) {
const cacheKey = 'chengyu_leads_local' const cacheKey = 'chengyu_callback_requests_local'
const record = { const record = {
...lead, ...lead,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
source: 'assistant', source: 'assistant',
privacyConsentedAt: privacyConsent.consentedAt,
privacyVersion: privacyConsent.version,
} }
const existing = JSON.parse(localStorage.getItem(cacheKey) || '[]') const existing = JSON.parse(localStorage.getItem(cacheKey) || '[]')
@@ -329,6 +352,8 @@ export const Assistant: React.FC = () => {
{ field: 'company', value: lead.company.trim() }, { field: 'company', value: lead.company.trim() },
{ field: 'demand', value: lead.demand.trim() }, { field: 'demand', value: lead.demand.trim() },
{ field: 'source', value: 'AI智能助手' }, { field: 'source', value: 'AI智能助手' },
{ field: 'privacyConsentedAt', value: privacyConsent.consentedAt },
{ field: 'privacyVersion', value: privacyConsent.version },
].filter((item) => item.value), ].filter((item) => item.value),
}, },
}) })
@@ -343,6 +368,16 @@ export const Assistant: React.FC = () => {
} }
} }
const submitLead = async (e: React.FormEvent) => {
e.preventDefault()
setLeadSubmitError(null)
if (!validateLead()) return
setPendingSubmitEvent(e)
setPrivacyOpen(true)
}
return ( return (
<motion.div className="min-h-screen bg-background" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.4 }}> <motion.div className="min-h-screen bg-background" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.4 }}>
<Header /> <Header />
@@ -513,8 +548,8 @@ export const Assistant: React.FC = () => {
</h2> </h2>
<p className="mt-1 text-sm text-gray-600 leading-relaxed"> <p className="mt-1 text-sm text-gray-600 leading-relaxed">
{locale === 'en' {locale === 'en'
? 'Phone number is required. The ops team can process submissions in the backend.' ? 'Phone number is required for a callback. We will ask for your consent before submission.'
: '手机号为必填项;提交后,后台运营人员可及时查看并处理(可在运营平台表单里配置邮件通知)。'} : '手机号为预约回电所必需;提交前会弹出“个人信息处理告知”,经你同意后才会提交。'}
</p> </p>
</div> </div>
</div> </div>
@@ -543,7 +578,7 @@ export const Assistant: React.FC = () => {
</div> </div>
)} )}
<form onSubmit={submitLead} className="mt-6 space-y-5"> <form onSubmit={submitLead} className="mt-6 space-y-5" aria-label={locale === 'en' ? 'Callback request form' : '预约回电表单'}>
<div className="grid sm:grid-cols-2 gap-4"> <div className="grid sm:grid-cols-2 gap-4">
<div> <div>
<label htmlFor="leadName" className="block text-sm font-medium text-gray-700 mb-2"> <label htmlFor="leadName" className="block text-sm font-medium text-gray-700 mb-2">
@@ -644,6 +679,25 @@ export const Assistant: React.FC = () => {
</button> </button>
<div className="text-xs text-gray-500 leading-relaxed"> <div className="text-xs text-gray-500 leading-relaxed">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<button
type="button"
onClick={() => setPrivacyOpen(true)}
className="text-primary hover:text-primary-light underline underline-offset-2"
>
{locale === 'en' ? 'Privacy notice' : '个人信息处理告知'}
</button>
<span className="text-gray-300" aria-hidden="true">
|
</span>
<a
href={locale === 'en' ? '/en/privacy' : '/privacy'}
className="text-primary hover:text-primary-light underline underline-offset-2"
>
{locale === 'en' ? 'Privacy policy' : '隐私政策'}
</a>
</div>
<div className="mt-2">
{leadFormLoading ? ( {leadFormLoading ? (
<span>{locale === 'en' ? 'Loading backend form configuration…' : '正在加载后台表单配置…'}</span> <span>{locale === 'en' ? 'Loading backend form configuration…' : '正在加载后台表单配置…'}</span>
) : leadFormId ? ( ) : leadFormId ? (
@@ -660,6 +714,7 @@ export const Assistant: React.FC = () => {
</span> </span>
)} )}
</div> </div>
</div>
</form> </form>
</div> </div>
)} )}
@@ -716,6 +771,27 @@ export const Assistant: React.FC = () => {
</section> </section>
</main> </main>
<PrivacyConsentModal
open={privacyOpen}
locale={locale}
onClose={() => {
setPrivacyOpen(false)
setPendingSubmitEvent(null)
}}
onConfirm={(payload) => {
try {
localStorage.setItem('privacyConsent.callback', JSON.stringify(payload))
} catch {
// ignore
}
setPrivacyConsent(payload)
setPrivacyOpen(false)
setPendingSubmitEvent(null)
void doSubmitLead()
}}
/>
<Footer /> <Footer />
</motion.div> </motion.div>
) )

148
src/pages/Privacy.tsx Normal file
View File

@@ -0,0 +1,148 @@
import React, { useMemo } from 'react'
import { motion } from 'framer-motion'
import { Header } from '../components/Header'
import { Footer } from '../components/Footer'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { usePageMeta } from '../hooks/usePageMeta'
import { getLocaleFromPathname } from '../lib/i18n'
export const Privacy: React.FC = () => {
const locale = useMemo(() => getLocaleFromPathname(window.location.pathname), [])
usePageMeta({
title: locale === 'en' ? 'Privacy Policy' : '隐私政策',
description:
locale === 'en'
? 'Privacy policy and personal information processing notice.'
: '个人信息处理规则与隐私政策说明。',
locale,
})
return (
<motion.div className="min-h-screen bg-background" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.4 }}>
<Header />
<main>
<section className="pt-28 pb-14 bg-gradient-to-br from-primary to-primary-light">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-white">
<Breadcrumbs items={[{ label: locale === 'en' ? 'Privacy Policy' : '隐私政策', href: '/privacy' }]} className="mb-5" />
<h1 className="text-4xl md:text-5xl font-bold tracking-tight">{locale === 'en' ? 'Privacy Policy' : '隐私政策'}</h1>
<p className="mt-4 text-lg text-white/85 max-w-3xl">
{locale === 'en'
? 'This policy describes how we process personal information in connection with website inquiries and callback requests.'
: '本政策用于说明我们在网站咨询与“预约回电”等场景下对个人信息的处理规则。'}
</p>
<p className="mt-2 text-xs text-white/70">{locale === 'en' ? 'Effective date: 2026-01-01' : '生效日期2026-01-01'}</p>
</div>
</div>
</section>
<section className="py-14 -mt-8">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-8 space-y-8 text-sm leading-relaxed text-gray-700">
<div>
<h2 className="text-lg font-semibold text-primary-dark">{locale === 'en' ? '1. Scope' : '一、适用范围'}</h2>
<p className="mt-3">
{locale === 'en'
? 'This policy applies to personal information processing when you use our website features such as inquiries, AI assistant, and callback requests.'
: '本政策适用于你使用本网站提供的咨询、AI智能助手、预约回电等功能时我们对个人信息的处理活动。'}
</p>
</div>
<div>
<h2 className="text-lg font-semibold text-primary-dark">{locale === 'en' ? '2. What we collect' : '二、我们收集哪些信息'}</h2>
<ul className="mt-3 list-disc pl-5 space-y-2">
<li>{locale === 'en' ? 'Phone number (required for callback requests).' : '手机号(用于预约回电时必填)。'}</li>
<li>{locale === 'en' ? 'Inquiry description and related information you provide.' : '你主动填写的咨询内容及相关信息。'}</li>
<li>{locale === 'en' ? 'Optional name/company information.' : '姓名、公司等选填信息(如你提供)。'}</li>
<li>{locale === 'en' ? 'Basic device/browser information for security and performance.' : '为保障安全与服务稳定性所需的基础设备/浏览器信息(通常由浏览器自动提供)。'}</li>
</ul>
</div>
<div>
<h2 className="text-lg font-semibold text-primary-dark">{locale === 'en' ? '3. Purpose and legal basis' : '三、使用目的与处理依据'}</h2>
<ul className="mt-3 list-disc pl-5 space-y-2">
<li>
{locale === 'en'
? 'To contact you and respond to your inquiry / arrange a callback.'
: '用于联系你、答复你的咨询、安排专员回电沟通。'}
</li>
<li>
{locale === 'en'
? 'To ensure service security, prevent abuse, and improve performance.'
: '用于保障服务安全、防范滥用与提升服务稳定性。'}
</li>
</ul>
<p className="mt-3 text-gray-700">
{locale === 'en'
? 'For scenarios requiring consent (e.g., collecting your phone number for callback), we will obtain your explicit consent before submission.'
: '对于需要取得同意的场景(例如:收集手机号用于回电沟通),我们会在你提交前通过弹框获得你的明确同意。'}
</p>
</div>
<div>
<h2 className="text-lg font-semibold text-primary-dark">{locale === 'en' ? '4. Retention' : '四、保存期限'}</h2>
<p className="mt-3">
{locale === 'en'
? 'We retain your personal information for the minimum period necessary to complete the processing of your inquiry and comply with applicable laws.'
: '我们仅在实现本政策所述目的所必需的最短期限内保存个人信息,并在法律法规要求的范围内留存必要记录。'}
</p>
</div>
<div>
<h2 className="text-lg font-semibold text-primary-dark">{locale === 'en' ? '5. Sharing and disclosure' : '五、共享、转让与公开披露'}</h2>
<p className="mt-3">
{locale === 'en'
? 'We do not sell personal information. We may share information with authorized personnel and service providers only as needed for inquiry processing, and under appropriate confidentiality and security measures.'
: '我们不会出售个人信息。仅在处理咨询所必需的范围内,与经授权的工作人员及必要的服务提供方共享,并采取相应的保密与安全措施。'}
</p>
</div>
<div>
<h2 className="text-lg font-semibold text-primary-dark">{locale === 'en' ? '6. Security measures' : '六、安全保护措施'}</h2>
<ul className="mt-3 list-disc pl-5 space-y-2">
<li>{locale === 'en' ? 'Access control and least-privilege principle.' : '权限控制与最小必要原则。'}</li>
<li>{locale === 'en' ? 'Audit and monitoring for abnormal access.' : '对异常访问进行审计与监测。'}</li>
<li>{locale === 'en' ? 'Secure storage and transmission where applicable.' : '在适用情况下采取安全存储与传输措施。'}</li>
</ul>
</div>
<div>
<h2 className="text-lg font-semibold text-primary-dark">{locale === 'en' ? '7. Your rights' : '七、你的权利'}</h2>
<ul className="mt-3 list-disc pl-5 space-y-2">
<li>{locale === 'en' ? 'Access, correction, and deletion as permitted by law.' : '在法律允许范围内的查阅、更正、删除等权利。'}</li>
<li>{locale === 'en' ? 'Withdraw consent (will not affect processing already performed).' : '撤回同意(不影响撤回前已进行的处理活动的效力)。'}</li>
</ul>
<p className="mt-3 text-gray-700">
{locale === 'en'
? 'You can stop providing information at any time by not submitting the form.'
: '你可以随时选择不再提交信息(例如:不提交“预约回电”表单),以停止进一步提供个人信息。'}
</p>
</div>
<div>
<h2 className="text-lg font-semibold text-primary-dark">{locale === 'en' ? '8. Contact' : '八、联系我们'}</h2>
<p className="mt-3">
{locale === 'en'
? 'If you have any questions about this policy, please contact us via the Contact page.'
: '如你对本政策有任何疑问,可通过“联系我们”页面与我们取得联系。'}
</p>
</div>
<p className="text-xs text-gray-500">
{locale === 'en'
? 'Note: This is a template policy. Please have legal/compliance review and update fields such as entity name, contact, retention and security details.'
: '提示:本页面为模板化隐私政策内容。正式上线前建议由法务/合规审核,并补齐主体名称、联系方式、保存期限与安全措施等细节。'}
</p>
</div>
</div>
</section>
</main>
<Footer />
</motion.div>
)
}
export default Privacy