manual save(2026-01-21 12:14)

This commit is contained in:
SiteAgent Bot
2026-01-21 12:14:11 +08:00
parent a7a56ddd9c
commit dc8a1ddacc
14 changed files with 1460 additions and 509 deletions

View File

@@ -9,6 +9,7 @@ export default defineConfig([
globalIgnores(['dist']), globalIgnores(['dist']),
{ {
files: ['**/*.{ts,tsx}'], files: ['**/*.{ts,tsx}'],
ignores: ['src/clientsdk/**/*.ts'],
extends: [ extends: [
js.configs.recommended, js.configs.recommended,
tseslint.configs.recommended, tseslint.configs.recommended,

View File

@@ -1,10 +1,11 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>react-template</title> <title>PolicyRadar | 全球政策监测与决策支持</title>
<meta name="description" content="面向出海企业的全球热点政策监测与决策支持平台:最新、权威、可解读、可行动。" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,17 +1,35 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom' import { BrowserRouter as Router, Navigate, Route, Routes } from 'react-router-dom'
import { Home } from './pages/Home' import { Home } from './pages/Home'
import { PostDetail } from './pages/PostDetail' import { PolicyHub } from './pages/PolicyHub'
import { CategoriesPage } from './pages/Categories' import { CountryView } from './pages/CountryView'
import { CategoryDetail } from './pages/CategoryDetail' import { PolicyDetail } from './pages/PolicyDetail'
import { PlaceholderPage } from './pages/PlaceholderPage'
function App() { function App() {
return ( return (
<Router> <Router>
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/posts/:slug" element={<PostDetail />} /> <Route path="/policies" element={<PolicyHub />} />
<Route path="/categories" element={<CategoriesPage />} /> <Route path="/countries/:code" element={<CountryView />} />
<Route path="/categories/:slug" element={<CategoryDetail />} /> <Route path="/policies/:slug" element={<PolicyDetail />} />
<Route path="/industries" element={<PlaceholderPage title="行业影响" />} />
<Route path="/alerts" element={<PlaceholderPage title="风险预警" />} />
<Route path="/insights" element={<PlaceholderPage title="深度报告" />} />
<Route path="/subscribe" element={<PlaceholderPage title="订阅" />} />
<Route path="/auth" element={<PlaceholderPage title="登录 / 注册" />} />
<Route path="/about" element={<PlaceholderPage title="关于我们" />} />
<Route path="/contact" element={<PlaceholderPage title="联系方式" />} />
<Route path="/partner" element={<PlaceholderPage title="企业合作" />} />
<Route path="/sources" element={<PlaceholderPage title="数据来源说明" />} />
<Route path="/methodology" element={<PlaceholderPage title="解读方法" />} />
<Route path="/posts/:slug" element={<Navigate to="/policies" replace />} />
<Route path="/categories" element={<Navigate to="/policies" replace />} />
<Route path="/categories/:slug" element={<Navigate to="/policies" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Router> </Router>
) )

View File

@@ -2,12 +2,64 @@ import React from 'react'
export const Footer: React.FC = () => { export const Footer: React.FC = () => {
return ( return (
<footer className="bg-gray-50 border-t border-gray-200 py-8 mt-12"> <footer className="bg-gray-50 border-t border-gray-200 py-10 mt-12">
<div className="container mx-auto px-4 text-center text-gray-600"> <div className="container mx-auto px-4">
<p>Powered by TenantCMS</p> <div className="grid gap-8 md:grid-cols-4">
<p className="text-sm mt-2"> <div>
Using X-Tenant-Slug for multi-tenant authentication <p className="text-lg font-semibold text-gray-900">PolicyRadar</p>
</p> <p className="text-sm text-gray-600 mt-2">MVP</p>
</div>
<div>
<p className="text-sm font-semibold text-gray-900"></p>
<ul className="mt-3 space-y-2 text-sm">
<li>
<a className="text-gray-600 hover:text-gray-900" href="/about">
</a>
</li>
<li>
<a className="text-gray-600 hover:text-gray-900" href="/contact">
</a>
</li>
<li>
<a className="text-gray-600 hover:text-gray-900" href="/partner">
</a>
</li>
</ul>
</div>
<div>
<p className="text-sm font-semibold text-gray-900"></p>
<ul className="mt-3 space-y-2 text-sm">
<li>
<a className="text-gray-600 hover:text-gray-900" href="/sources">
</a>
</li>
<li>
<a className="text-gray-600 hover:text-gray-900" href="/methodology">
</a>
</li>
</ul>
</div>
<div>
<p className="text-sm font-semibold text-gray-900"></p>
<p className="text-sm text-gray-600 mt-3"></p>
<a
href="/subscribe"
className="inline-flex mt-4 px-3 py-2 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700"
>
</a>
</div>
</div>
<div className="mt-10 pt-6 border-t border-gray-200 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<p className="text-xs text-gray-500">© 2026 PolicyRadar. All rights reserved.</p>
<p className="text-xs text-gray-500"></p>
</div>
</div> </div>
</footer> </footer>
) )

View File

@@ -1,16 +1,119 @@
import React from 'react' import React, { useMemo, useState } from 'react'
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
const navItems = [
{ to: '/policies', label: '全球政策' },
{ to: '/industries', label: '行业影响' },
{ to: '/alerts', label: '风险预警' },
{ to: '/insights', label: '深度报告' },
]
function isActive(pathname: string, to: string): boolean {
if (to === '/') return pathname === '/'
return pathname === to || pathname.startsWith(`${to}/`)
}
export const Header: React.FC = () => { export const Header: React.FC = () => {
const location = useLocation()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const qFromUrl = searchParams.get('q') || ''
const [q, setQ] = useState(qFromUrl)
const searchHref = useMemo(() => {
const params = new URLSearchParams()
const trimmed = q.trim()
if (trimmed) params.set('q', trimmed)
const query = params.toString()
return query ? `/policies?${query}` : '/policies'
}, [q])
return ( return (
<header className="bg-white border-b border-gray-200 sticky top-0 z-10"> <header className="bg-white border-b border-gray-200 sticky top-0 z-10">
<div className="container mx-auto px-4 py-4"> <div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<h1 className="text-2xl font-bold text-gray-900"> <div className="flex items-center justify-between gap-6">
TenantCMS <span className="text-blue-600">Demo</span> <Link to="/" className="inline-flex items-baseline gap-2">
</h1> <span className="text-2xl font-bold text-gray-900">PolicyRadar</span>
<nav className="flex items-center gap-4"> <span className="text-sm font-semibold text-blue-700"></span>
<a href="/" className="text-gray-600 hover:text-gray-900"></a> </Link>
<a href="/categories" className="text-gray-600 hover:text-gray-900"></a>
<div className="flex items-center gap-2 lg:hidden">
<a
href={searchHref}
className="px-3 py-2 rounded-md border border-gray-200 text-sm text-gray-700 hover:bg-gray-50"
aria-label="搜索"
>
</a>
<a
href="/subscribe"
className="px-3 py-2 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700"
>
</a>
</div>
</div>
<nav className="flex flex-col gap-3 lg:flex-row lg:items-center lg:gap-6" aria-label="主导航">
<div className="flex flex-wrap items-center gap-4">
<Link
to="/"
className={`text-sm font-medium ${isActive(location.pathname, '/') ? 'text-gray-900' : 'text-gray-600 hover:text-gray-900'}`}
>
</Link>
{navItems.map((item) => (
<Link
key={item.to}
to={item.to}
className={`text-sm font-medium ${isActive(location.pathname, item.to) ? 'text-gray-900' : 'text-gray-600 hover:text-gray-900'}`}
>
{item.label}
</Link>
))}
</div>
<form
className="flex items-center gap-2"
role="search"
onSubmit={(e) => {
e.preventDefault()
navigate(searchHref)
}}
>
<label className="sr-only" htmlFor="site-search">
</label>
<input
id="site-search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索国家、政策、行业"
className="w-full lg:w-64 px-3 py-2 rounded-md border border-gray-200 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 focus:border-blue-300"
/>
<button
type="submit"
className="px-3 py-2 rounded-md border border-gray-200 text-sm text-gray-700 hover:bg-gray-50"
>
</button>
</form>
<div className="hidden lg:flex items-center gap-3">
<a
href="/auth"
className="px-3 py-2 rounded-md border border-gray-200 text-sm text-gray-700 hover:bg-gray-50"
>
/
</a>
<a
href="/subscribe"
className="px-3 py-2 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700"
>
</a>
</div>
</nav> </nav>
</div> </div>
</div> </div>

403
src/data/policies.ts Normal file
View File

@@ -0,0 +1,403 @@
export type PolicyImpactLevel = 'high' | 'medium' | 'low'
export type PolicyType =
| 'trade_tariff'
| 'tax_subsidy'
| 'compliance_regulation'
| 'data_privacy'
| 'labor_visa'
| 'esg_environment'
export type Industry =
| 'cross_border_ecommerce'
| 'manufacturing'
| 'saas_ai'
| 'new_energy'
| 'games_content'
export type CountryCode =
| 'US'
| 'EU'
| 'GB'
| 'DE'
| 'FR'
| 'BR'
| 'MX'
| 'SG'
| 'AE'
| 'IN'
| 'JP'
| 'KR'
export type PolicyTag = 'effective_soon' | 'high_risk' | 'opportunity' | 'data_transfer'
export type Policy = {
id: string
slug: string
title: string
issuer: string
countryCode: CountryCode
countryName: string
policyType: PolicyType
industries: Industry[]
impactLevel: PolicyImpactLevel
publishedAt: string
effectiveAt: string
sourceName: string
sourceUrl: string
summary: string
what: {
officialSummary: string
keyClauses: string[]
}
impact: {
cost: string
compliance: string
opportunity: string
}
action: {
checklist: string[]
applicableFor: string[]
}
tags: PolicyTag[]
}
export const TODAY_ISO = '2026-01-01'
export const policyTypeMeta: Record<PolicyType, { title: string; description: string }> = {
trade_tariff: {
title: '贸易与关税',
description: '关注关税调整、原产地规则、贸易救济与进口限制等变化。',
},
tax_subsidy: {
title: '税收与补贴',
description: '关注税率、抵扣、转移定价、补贴政策与税务合规要求。',
},
compliance_regulation: {
title: '合规与监管',
description: '关注产品合规、行业监管、反垄断与许可制度等要求。',
},
data_privacy: {
title: '数据与隐私',
description: '关注数据跨境、个人信息保护、网络安全与合规审计。',
},
labor_visa: {
title: '用工与签证',
description: '关注用工规范、最低工资、工签流程与派遣合规要求。',
},
esg_environment: {
title: 'ESG / 环保',
description: '关注碳排放、供应链尽调、环保税费与可持续披露要求。',
},
}
export const industryMeta: Record<Industry, string> = {
cross_border_ecommerce: '跨境电商',
manufacturing: '制造业',
saas_ai: 'SaaS / AI',
new_energy: '新能源',
games_content: '游戏 / 内容',
}
export const policies: Policy[] = [
{
id: 'p_001',
slug: 'us-data-transfer-assessment-2026',
title: '美国更新跨境数据传输评估要求2026 版)',
issuer: 'Federal Data Protection Board',
countryCode: 'US',
countryName: '美国',
policyType: 'data_privacy',
industries: ['saas_ai', 'cross_border_ecommerce'],
impactLevel: 'high',
publishedAt: '2026-01-01',
effectiveAt: '2026-01-20',
sourceName: 'Federal Register',
sourceUrl: 'https://example.com/source/us-data-transfer',
summary:
'新增“高敏感数据”传输前置评估要求,明确供应商风险分级,并提高违规罚款上限。',
what: {
officialSummary:
'对跨境数据传输引入统一评估框架,要求企业在向境外处理方共享数据前完成风险评估并保存记录。',
keyClauses: [
'将生物识别、定位与财务数据列为高敏感类别',
'要求每 12 个月复核一次数据传输风险',
'未按要求完成评估的最高罚款提升至年营收 3%',
],
},
impact: {
cost: '需要新增合规评估与供应商审计成本,可能带来法务与安全预算上调。',
compliance:
'SaaS 出海与跨境电商需补充传输评估、合同条款与日志留存,否则面临行政处罚。',
opportunity: '合规能力强的服务商可通过提供标准化评估与托管方案提升市场竞争力。',
},
action: {
checklist: [
'梳理涉及跨境传输的数据类型与流向',
'对境外处理方开展风险分级与合同补充条款',
'建立评估模板与 12 个月复核机制',
],
applicableFor: ['在美国收集用户数据的 SaaS', '涉及支付与物流信息的跨境电商'],
},
tags: ['high_risk', 'effective_soon', 'data_transfer'],
},
{
id: 'p_002',
slug: 'eu-cbam-reporting-clarification-2026q1',
title: '欧盟 CBAM 申报口径澄清与过渡期调整',
issuer: 'European Commission',
countryCode: 'EU',
countryName: '欧盟',
policyType: 'esg_environment',
industries: ['manufacturing', 'new_energy'],
impactLevel: 'high',
publishedAt: '2025-12-28',
effectiveAt: '2026-01-15',
sourceName: 'EUR-Lex',
sourceUrl: 'https://example.com/source/eu-cbam',
summary:
'明确铝、钢、化工产品的嵌入排放核算方法,缩短补报窗口,并新增第三方核验要求。',
what: {
officialSummary:
'对 CBAM 过渡期申报规则做补充说明,统一核算口径并提出核验标准,降低数据不一致风险。',
keyClauses: [
'新增缺失排放数据的默认系数上限',
'补报窗口由 3 个月缩短为 45 天',
'部分行业自 2026 Q2 起需第三方核验报告',
],
},
impact: {
cost: '可能增加碳核算与第三方核验费用,供应链数据采集投入增加。',
compliance: '出口欧盟的制造业需尽快补齐排放数据口径与证明文件,避免清关与罚款风险。',
opportunity: '提前完成核算的企业可通过低碳优势获得订单与议价空间。',
},
action: {
checklist: ['锁定 CBAM 涉及产品清单', '与供应商确认排放数据口径', '准备 45 天内补报流程'],
applicableFor: ['向欧盟出口钢铝化工产品的制造业', '涉及新能源上游材料供应链'],
},
tags: ['high_risk', 'effective_soon'],
},
{
id: 'p_003',
slug: 'sg-digital-services-tax-guidance-2026',
title: '新加坡发布数字服务税征管指引(更新版)',
issuer: 'Inland Revenue Authority of Singapore',
countryCode: 'SG',
countryName: '新加坡',
policyType: 'tax_subsidy',
industries: ['saas_ai'],
impactLevel: 'medium',
publishedAt: '2025-12-20',
effectiveAt: '2026-02-01',
sourceName: 'IRAS',
sourceUrl: 'https://example.com/source/sg-dst',
summary:
'明确跨境订阅服务的纳税地点判断,并提升平台代扣代缴要求,新增电子发票留存规范。',
what: {
officialSummary:
'针对数字服务税适用范围、税基与申报流程进行更新,重点覆盖跨境订阅与平台经济模式。',
keyClauses: ['以用户账单地址与 IP 辅助判断纳税地点', '平台需保存交易记录不少于 5 年', '优化退税与抵扣流程'],
},
impact: {
cost: '可能需要调整计费系统以支持税率计算与发票开具,财税合规成本上升。',
compliance: '在新加坡提供订阅服务的企业需补充税务登记与发票留存,避免补税与罚息。',
opportunity: '合规运营可降低税务不确定性,提升企业在东南亚市场的信任度。',
},
action: {
checklist: ['核对用户所在地判断逻辑', '升级计费与开票流程', '建立交易数据 5 年留存策略'],
applicableFor: ['SaaS 订阅服务出海团队', '提供数字内容的跨境平台'],
},
tags: [],
},
{
id: 'p_004',
slug: 'gb-skilled-visa-salary-threshold-2026',
title: '英国上调技术工签最低薪资门槛',
issuer: 'UK Home Office',
countryCode: 'GB',
countryName: '英国',
policyType: 'labor_visa',
industries: ['saas_ai', 'manufacturing'],
impactLevel: 'medium',
publishedAt: '2025-12-18',
effectiveAt: '2026-01-10',
sourceName: 'GOV.UK',
sourceUrl: 'https://example.com/source/uk-visa',
summary: '技术工签最低年薪标准上调,新增岗位市场薪酬基准核查要求,影响海外团队扩张计划。',
what: {
officialSummary: '调整 Skilled Worker Visa 的薪资门槛与岗位审查要求,强化雇主担保责任。',
keyClauses: ['最低年薪门槛上调约 8%', '部分岗位需提交市场薪酬证明', '雇主合规检查频次提升'],
},
impact: {
cost: '海外招聘薪资成本上升,签证申请材料准备时间增加。',
compliance: '未满足薪资门槛可能导致签证拒签或雇主牌照风险。',
opportunity: '对高端岗位有利于稳定人才供给,降低低薪竞争。',
},
action: {
checklist: ['复核英国岗位薪资结构', '准备市场薪酬证明材料', '评估远程与本地用工组合'],
applicableFor: ['计划在英国设立团队的 SaaS/制造企业'],
},
tags: ['effective_soon'],
},
{
id: 'p_005',
slug: 'br-ecommerce-import-tax-rule-2026',
title: '巴西调整跨境电商进口税征收规则',
issuer: 'Brazil Receita Federal',
countryCode: 'BR',
countryName: '巴西',
policyType: 'trade_tariff',
industries: ['cross_border_ecommerce'],
impactLevel: 'high',
publishedAt: '2025-12-25',
effectiveAt: '2026-01-05',
sourceName: 'Diário Oficial',
sourceUrl: 'https://example.com/source/br-import-tax',
summary: '下调部分低客单商品税率,但强化平台代征代缴与包裹申报一致性检查。',
what: {
officialSummary: '对跨境电商进口税征收做结构性调整,旨在提高申报透明度并提升税收效率。',
keyClauses: ['低于指定金额的包裹适用新税率区间', '平台必须在下单时完成税费预收', '强化申报品类与价格一致性核查'],
},
impact: {
cost: '需要调整税费展示与结算逻辑,可能增加清关与税务接口成本。',
compliance: '申报不一致将导致包裹延误与罚款,平台侧风控要求提高。',
opportunity: '税率结构优化可能提升转化率与客单,利好合规运营的跨境卖家。',
},
action: {
checklist: ['更新巴西税费计算规则', '完善商品申报字段校验', '对高频品类做价格与HS编码抽查'],
applicableFor: ['面向巴西市场的跨境电商卖家与平台'],
},
tags: ['high_risk', 'effective_soon', 'opportunity'],
},
{
id: 'p_006',
slug: 'de-product-compliance-labelling-2026',
title: '德国加强消费品合规标签抽检要求',
issuer: 'Federal Institute for Consumer Protection',
countryCode: 'DE',
countryName: '德国',
policyType: 'compliance_regulation',
industries: ['manufacturing', 'cross_border_ecommerce'],
impactLevel: 'medium',
publishedAt: '2025-12-10',
effectiveAt: '2026-03-01',
sourceName: 'Bundesanzeiger',
sourceUrl: 'https://example.com/source/de-label',
summary: '扩大标签抽检范围并提高不合规处罚,要求线上销售页面同步展示关键安全信息。',
what: {
officialSummary: '针对消费品安全标签与线上信息披露提出强化要求,扩大抽检频次并完善处罚机制。',
keyClauses: ['线上商品页必须展示安全警示与合规声明', '抽检覆盖更多跨境平台仓储渠道', '不合规最高罚款提升至 10 万欧元'],
},
impact: {
cost: '需要补充标签与详情页信息,增加合规审核与翻译成本。',
compliance: '未同步展示信息可能被下架或处罚,影响德国及周边市场销售。',
opportunity: '合规信息完善可降低退货与投诉,提升品牌可信度。',
},
action: {
checklist: ['审计德国在售 SKU 标签与页面信息', '建立多语言合规声明模板', '与仓储/平台确认抽检配合流程'],
applicableFor: ['面向德国销售消费品的制造业与跨境电商'],
},
tags: [],
},
{
id: 'p_007',
slug: 'mx-tax-incentive-nearshoring-2026',
title: '墨西哥推出近岸制造税收激励(试点)',
issuer: 'Secretaría de Hacienda y Crédito Público',
countryCode: 'MX',
countryName: '墨西哥',
policyType: 'tax_subsidy',
industries: ['manufacturing', 'new_energy'],
impactLevel: 'low',
publishedAt: '2025-12-05',
effectiveAt: '2026-01-25',
sourceName: 'DOF',
sourceUrl: 'https://example.com/source/mx-incentive',
summary: '对符合条件的近岸制造项目提供所得税减免与设备加速折旧,鼓励外资产业链落地。',
what: {
officialSummary: '发布近岸制造试点税收优惠,覆盖关键行业投资与就业指标。',
keyClauses: ['满足投资与就业门槛可享所得税优惠', '允许部分设备加速折旧', '要求定期提交运营与合规报告'],
},
impact: {
cost: '需要准备申请材料与持续报告,但可抵消部分投资成本。',
compliance: '不满足运营指标可能导致优惠追回,应提前规划投产与用工。',
opportunity: '有机会降低北美供应链布局成本,提升交付稳定性。',
},
action: {
checklist: ['评估是否满足试点行业与投资门槛', '建立税收优惠申请时间表', '制定运营指标监控机制'],
applicableFor: ['计划在墨西哥设厂的制造与新能源企业'],
},
tags: ['opportunity'],
},
{
id: 'p_008',
slug: 'ae-data-hosting-standard-2026',
title: '阿联酋发布政府数据托管安全标准',
issuer: 'UAE Digital Authority',
countryCode: 'AE',
countryName: '阿联酋',
policyType: 'data_privacy',
industries: ['saas_ai'],
impactLevel: 'medium',
publishedAt: '2025-11-30',
effectiveAt: '2026-02-15',
sourceName: 'UAE Gazette',
sourceUrl: 'https://example.com/source/ae-hosting',
summary: '针对服务政府与关键基础设施客户的云服务商提出安全控制基线与审计要求。',
what: {
officialSummary: '明确政府数据托管的安全控制基线,包括访问控制、加密、日志与审计要求。',
keyClauses: ['关键数据需在本地指定区域存储', '每年进行一次第三方安全审计', '要求 72 小时内报告重大安全事件'],
},
impact: {
cost: '可能需要新增本地部署与审计成本,云资源与合规投入增加。',
compliance: '服务政府客户需满足本地存储与审计要求,否则无法参与招投标。',
opportunity: '提前满足标准可增强中东市场的投标竞争力与客户信任。',
},
action: {
checklist: ['识别涉及政府与关键行业客户的业务', '评估本地存储与加密能力', '准备年度审计与事件响应流程'],
applicableFor: ['提供云服务与数据处理的 SaaS/AI 企业'],
},
tags: ['data_transfer'],
},
]
export function parseDate(iso: string): Date {
return new Date(`${iso}T00:00:00.000Z`)
}
export function getToday(): Date {
return parseDate(TODAY_ISO)
}
export function daysUntil(iso: string, from = getToday()): number {
const target = parseDate(iso)
const deltaMs = target.getTime() - from.getTime()
return Math.ceil(deltaMs / (1000 * 60 * 60 * 24))
}
export function isSameDay(iso: string, day = getToday()): boolean {
const date = parseDate(iso)
return date.toISOString().slice(0, 10) === day.toISOString().slice(0, 10)
}
export function isEffectiveSoon(policy: Policy, withinDays = 30): boolean {
const remaining = daysUntil(policy.effectiveAt)
return remaining >= 0 && remaining <= withinDays
}
export function formatDateCN(iso: string): string {
const date = parseDate(iso)
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
}
export function impactLabel(level: PolicyImpactLevel): string {
if (level === 'high') return '高风险'
if (level === 'medium') return '中等'
return '低'
}
export function impactClass(level: PolicyImpactLevel): string {
if (level === 'high') return 'bg-red-50 text-red-700 border-red-200'
if (level === 'medium') return 'bg-amber-50 text-amber-700 border-amber-200'
return 'bg-emerald-50 text-emerald-700 border-emerald-200'
}

View File

@@ -1,94 +1,6 @@
import React, { useEffect, useState } from 'react' import React from 'react'
import { Header } from '../components/Header' import { Navigate } from 'react-router-dom'
import { Footer } from '../components/Footer'
import { Categories } from '../clientsdk/sdk.gen'
import { createClient } from '../clientsdk/client'
import { customQuerySerializer } from '../clientsdk/querySerializer'
import { TENANT_SLUG, TENANT_API_KEY, API_URL } from '../config'
const client = createClient({
baseUrl: API_URL,
querySerializer: customQuerySerializer,
headers: {
'X-Tenant-Slug': TENANT_SLUG,
'X-API-Key': TENANT_API_KEY,
},
})
export const CategoriesPage: React.FC = () => { export const CategoriesPage: React.FC = () => {
const [categories, setCategories] = useState<any[]>([]) return <Navigate to="/policies" replace />
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchCategories = async () => {
try {
setLoading(true)
setError(null)
const response = await Categories.listCategories({
client,
query: {
limit: 100,
},
})
setCategories((response as any)?.data?.docs || [])
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败')
console.error('获取分类失败:', err)
} finally {
setLoading(false)
}
}
fetchCategories()
}, [])
return (
<div className="min-h-screen bg-gray-50">
<Header />
<main className="container mx-auto px-4 py-8">
<section className="mb-12">
<h2 className="text-3xl font-bold text-gray-900 mb-2">📂 </h2>
<p className="text-gray-600"></p>
</section>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
<strong></strong> {error}
</div>
)}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{loading
? Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="bg-white p-6 rounded-lg shadow-sm animate-pulse">
<div className="h-6 bg-gray-200 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
))
: categories.map((category) => (
<a
key={category.id}
href={`/categories/${category.slug}`}
className="bg-white p-6 rounded-lg shadow-sm hover:shadow-md transition-shadow"
>
<h3 className="text-xl font-semibold text-gray-900 mb-2">{category.title}</h3>
<p className="text-sm text-gray-600"></p>
</a>
))}
</div>
{!loading && categories.length === 0 && !error && (
<div className="text-center py-12">
<p className="text-gray-500 text-lg"></p>
</div>
)}
</main>
<Footer />
</div>
)
} }

View File

@@ -1,148 +1,6 @@
import React, { useEffect, useState } from 'react' import React from 'react'
import { useParams } from 'react-router-dom' import { Navigate } from 'react-router-dom'
import { Header } from '../components/Header'
import { Footer } from '../components/Footer'
import { PostCard } from '../components/PostCard'
import { PostCardSkeleton } from '../components/PostCardSkeleton'
import { Posts, Categories } from '../clientsdk/sdk.gen'
import { createClient } from '../clientsdk/client'
import { customQuerySerializer } from '../clientsdk/querySerializer'
import { TENANT_SLUG, TENANT_API_KEY, API_URL } from '../config'
const client = createClient({
baseUrl: API_URL,
querySerializer: customQuerySerializer,
headers: {
'X-Tenant-Slug': TENANT_SLUG,
'X-API-Key': TENANT_API_KEY,
},
})
export const CategoryDetail: React.FC = () => { export const CategoryDetail: React.FC = () => {
const { slug } = useParams<{ slug: string }>() return <Navigate to="/policies" replace />
const [posts, setPosts] = useState<any[]>([])
const [category, setCategory] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!slug) return
const fetchData = async () => {
try {
setLoading(true)
setError(null)
const [categoriesRes, postsRes] = await Promise.all([
// Use listCategories with where filter since findCategoryById doesn't support slug lookup
Categories.listCategories({
client,
query: {
where: {
slug: {
equals: slug,
},
},
limit: 1,
},
}),
Posts.listPosts({
client,
query: {
limit: 100,
sort: '-createdAt',
},
}),
])
const categoryDocs = (categoriesRes as any)?.data?.docs || []
if (categoryDocs[0]) {
setCategory(categoryDocs[0])
}
const allDocs = (postsRes as any)?.data?.docs || []
// categories is an array, check if any category in the array matches the slug
const categoryPosts = allDocs.filter((post: any) =>
post.categories?.some((cat: any) => cat.slug === slug)
)
setPosts(categoryPosts)
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败')
console.error('获取数据失败:', err)
} finally {
setLoading(false)
}
}
fetchData()
}, [slug])
const stripHtml = (html: string): string => {
const tmp = document.createElement('div')
tmp.innerHTML = html
return tmp.textContent || tmp.innerText || ''
}
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
const getCategoryTitle = (post: any): string | undefined => {
// categories is an array, get the first one
return post.categories?.[0]?.title
}
const handlePostClick = (postSlug: string) => {
window.location.href = `/posts/${postSlug}`
}
return (
<div className="min-h-screen bg-gray-50">
<Header />
<main className="container mx-auto px-4 py-8">
<section className="mb-12">
<h2 className="text-3xl font-bold text-gray-900 mb-2">
📂 {category?.title || '分类'}
</h2>
<p className="text-gray-600"></p>
</section>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
<strong></strong> {error}
</div>
)}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{loading
? Array.from({ length: 6 }).map((_, i) => <PostCardSkeleton key={i} />)
: posts.map((post) => (
<PostCard
key={post.id}
title={post.title}
excerpt={stripHtml(post.content_html || post.content?.root?.children?.[0]?.children?.[0]?.text || post.title)}
category={getCategoryTitle(post)}
date={formatDate(post.createdAt)}
onClick={() => handlePostClick(post.slug)}
/>
))}
</div>
{!loading && posts.length === 0 && !error && (
<div className="text-center py-12">
<p className="text-gray-500 text-lg"></p>
</div>
)}
</main>
<Footer />
</div>
)
} }

191
src/pages/CountryView.tsx Normal file
View File

@@ -0,0 +1,191 @@
import React, { useMemo } from 'react'
import { useParams } from 'react-router-dom'
import { Footer } from '../components/Footer'
import { Header } from '../components/Header'
import {
daysUntil,
formatDateCN,
impactClass,
impactLabel,
industryMeta,
isEffectiveSoon,
policies,
policyTypeMeta,
type CountryCode,
type Policy,
} from '../data/policies'
function getRiskScore(policiesForCountry: Policy[]): number {
const total = policiesForCountry.length
if (total === 0) return 0
const high = policiesForCountry.filter((p) => p.impactLevel === 'high').length
const medium = policiesForCountry.filter((p) => p.impactLevel === 'medium').length
const weighted = high * 3 + medium * 2 + (total - high - medium) * 1
return Math.round((weighted / (total * 3)) * 100)
}
function riskText(score: number): { label: string; className: string } {
if (score >= 70) return { label: '高', className: 'bg-red-600' }
if (score >= 40) return { label: '中', className: 'bg-amber-500' }
return { label: '低', className: 'bg-emerald-500' }
}
function countryBadge(code: string): string {
return code.slice(0, 2).toUpperCase()
}
export const CountryView: React.FC = () => {
const params = useParams<{ code: string }>()
const code = (params.code || '').toUpperCase() as CountryCode
const countryPolicies = useMemo(
() => policies.filter((p) => p.countryCode === code).slice().sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)),
[code],
)
const countryName = countryPolicies[0]?.countryName || code
const score = getRiskScore(countryPolicies)
const risk = riskText(score)
const upcoming = countryPolicies.filter((p) => isEffectiveSoon(p, 30)).slice().sort((a, b) => a.effectiveAt.localeCompare(b.effectiveAt))
const industries = useMemo(() => {
const map = new Map<string, number>()
countryPolicies.forEach((policy) => {
policy.industries.forEach((industry) => {
map.set(industry, (map.get(industry) || 0) + 1)
})
})
return Array.from(map.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([industry, count]) => ({ industry, count }))
}, [countryPolicies])
return (
<div className="min-h-screen bg-gray-50">
<Header />
<main className="container mx-auto px-4 py-8">
<section className="bg-white border border-gray-200 rounded-xl p-6">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<p className="text-sm text-gray-600"> / </p>
<h1 className="text-3xl font-bold text-gray-900 mt-2">
<span
className="mr-2 inline-flex items-center justify-center w-8 h-8 rounded-full bg-gray-100 text-gray-700 text-sm font-semibold"
aria-hidden="true"
>
{countryBadge(code)}
</span>
{countryName}
</h1>
<p className="text-gray-600 mt-2"></p>
</div>
<div className="min-w-[220px]">
<p className="text-sm text-gray-600"></p>
<div className="mt-3 flex items-center gap-3">
<span className={`inline-flex w-10 h-10 rounded-full items-center justify-center text-white text-sm font-semibold ${risk.className}`}>
{risk.label}
</span>
<div className="flex-1">
<div className="h-2 rounded-full bg-gray-100 overflow-hidden">
<div className={`h-full ${risk.className}`} style={{ width: `${score}%` }} />
</div>
<p className="text-xs text-gray-500 mt-2">{score} / 100</p>
</div>
</div>
</div>
</div>
</section>
<div className="grid gap-6 mt-6 lg:grid-cols-[1fr_320px]">
<section className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="text-lg font-semibold text-gray-900"></h2>
<p className="text-sm text-gray-600 mt-2"></p>
{countryPolicies.length === 0 ? (
<div className="mt-6 text-sm text-gray-600"></div>
) : (
<ol className="mt-6 space-y-6">
{countryPolicies.map((policy) => (
<li key={policy.id} className="relative pl-6">
<span className="absolute left-0 top-1.5 w-3 h-3 rounded-full bg-gray-300" aria-hidden="true" />
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-gray-500">{formatDateCN(policy.publishedAt)}</span>
<span className="text-xs text-gray-500">{policyTypeMeta[policy.policyType].title}</span>
<span
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold border ${impactClass(
policy.impactLevel,
)}`}
>
{impactLabel(policy.impactLevel)}
</span>
</div>
<a href={`/policies/${policy.slug}`} className="text-base font-semibold text-gray-900 hover:text-blue-700">
{policy.title}
</a>
<p className="text-sm text-gray-600 line-clamp-2">{policy.summary}</p>
</div>
</li>
))}
</ol>
)}
</section>
<aside className="space-y-6">
<section className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="text-lg font-semibold text-gray-900">30 </h2>
{upcoming.length === 0 ? (
<p className="text-sm text-gray-600 mt-3"></p>
) : (
<ul className="mt-4 space-y-3">
{upcoming.map((policy) => {
const remaining = daysUntil(policy.effectiveAt)
return (
<li key={policy.id} className="border border-gray-200 rounded-lg p-4">
<a
href={`/policies/${policy.slug}`}
className="text-sm font-semibold text-gray-900 hover:text-blue-700"
>
{policy.title}
</a>
<p className="text-xs text-gray-500 mt-2">{remaining} · {formatDateCN(policy.effectiveAt)}</p>
</li>
)
})}
</ul>
)}
</section>
<section className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="text-lg font-semibold text-gray-900"></h2>
{industries.length === 0 ? (
<p className="text-sm text-gray-600 mt-3"></p>
) : (
<ul className="mt-4 space-y-2">
{industries.map(({ industry, count }) => (
<li key={industry} className="flex items-center justify-between text-sm">
<span className="text-gray-700">{industryMeta[industry as keyof typeof industryMeta]}</span>
<span className="text-gray-500">{count}</span>
</li>
))}
</ul>
)}
</section>
<section className="bg-white border border-gray-200 rounded-xl p-6">
<h2 className="text-lg font-semibold text-gray-900"></h2>
<p className="text-sm text-gray-600 mt-3"></p>
</section>
</aside>
</div>
</main>
<Footer />
</div>
)
}

View File

@@ -1,113 +1,201 @@
import React, { useEffect, useState } from 'react' import React, { useMemo, useState } from 'react'
import { Header } from '../components/Header'
import { Footer } from '../components/Footer' import { Footer } from '../components/Footer'
import { PostCard } from '../components/PostCard' import { Header } from '../components/Header'
import { PostCardSkeleton } from '../components/PostCardSkeleton' import {
import { Posts } from '../clientsdk/sdk.gen' daysUntil,
import { createClient } from '../clientsdk/client' formatDateCN,
import { customQuerySerializer } from '../clientsdk/querySerializer' getToday,
import { TENANT_SLUG, TENANT_API_KEY, API_URL } from '../config' impactClass,
impactLabel,
industryMeta,
isEffectiveSoon,
isSameDay,
policies,
policyTypeMeta,
type Policy,
} from '../data/policies'
const client = createClient({ function uniqueCount<T>(items: T[]): number {
baseUrl: API_URL, return new Set(items).size
querySerializer: customQuerySerializer, }
headers: {
'X-Tenant-Slug': TENANT_SLUG, function getPolicyKeyStats(all: Policy[]) {
'X-API-Key': TENANT_API_KEY, const today = getToday()
}, const todayNew = all.filter((p) => isSameDay(p.publishedAt, today)).length
}) const highRisk = all.filter((p) => p.impactLevel === 'high').length
const effectiveSoon = all.filter((p) => isEffectiveSoon(p, 30)).length
const focusCountries = uniqueCount(all.filter((p) => p.impactLevel === 'high').map((p) => p.countryCode))
return { todayNew, highRisk, effectiveSoon, focusCountries }
}
function sortByPublishedDesc(a: Policy, b: Policy): number {
return b.publishedAt.localeCompare(a.publishedAt)
}
export const Home: React.FC = () => { export const Home: React.FC = () => {
const [posts, setPosts] = useState<any[]>([]) const stats = useMemo(() => getPolicyKeyStats(policies), [])
const [loading, setLoading] = useState(true) const [page, setPage] = useState(1)
const [error, setError] = useState<string | null>(null) const pageSize = 5
useEffect(() => { const feed = useMemo(() => policies.slice().sort(sortByPublishedDesc), [])
const fetchPosts = async () => { const paged = useMemo(() => feed.slice(0, page * pageSize), [feed, page])
try { const canLoadMore = paged.length < feed.length
setLoading(true)
setError(null)
const response = await Posts.listPosts({
client,
query: {
limit: 10,
sort: '-createdAt',
},
})
setPosts((response as any)?.data?.docs || [])
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败')
console.error('获取文章失败:', err)
} finally {
setLoading(false)
}
}
fetchPosts()
}, [])
const stripHtml = (html: string): string => {
const tmp = document.createElement('div')
tmp.innerHTML = html
return tmp.textContent || tmp.innerText || ''
}
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
const getCategoryTitle = (post: any): string | undefined => {
// categories is an array, get the first one
return post.categories?.[0]?.title
}
const handlePostClick = (slug: string) => {
window.location.href = `/posts/${slug}`
}
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<Header /> <Header />
<main className="container mx-auto px-4 py-8"> <main>
<section className="mb-12"> <section className="bg-gradient-to-b from-white to-gray-50 border-b border-gray-200">
<h2 className="text-3xl font-bold text-gray-900 mb-2">📚 </h2> <div className="container mx-auto px-4 py-10">
<p className="text-gray-600"></p> <div className="max-w-4xl">
<h1 className="text-3xl md:text-5xl font-bold text-gray-900 leading-tight">
·
</h1>
<p className="text-gray-600 mt-4 text-base md:text-lg">
· ·
</p>
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="bg-white border border-gray-200 rounded-xl p-4">
<p className="text-sm text-gray-600"></p>
<p className="text-2xl font-bold text-gray-900 mt-2">{stats.todayNew}</p>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-4">
<p className="text-sm text-gray-600"></p>
<p className="text-2xl font-bold text-gray-900 mt-2">{stats.highRisk}</p>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-4">
<p className="text-sm text-gray-600"></p>
<p className="text-2xl font-bold text-gray-900 mt-2">{stats.effectiveSoon}</p>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-4">
<p className="text-sm text-gray-600"></p>
<p className="text-2xl font-bold text-gray-900 mt-2">{stats.focusCountries}</p>
</div>
</div>
<div className="mt-8 flex flex-wrap gap-3">
<a
href="/policies"
className="inline-flex px-4 py-2 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700"
>
</a>
<a
href="/subscribe"
className="inline-flex px-4 py-2 rounded-md border border-gray-200 bg-white text-sm text-gray-700 hover:bg-gray-50"
>
</a>
</div>
</div>
</div>
</section> </section>
{error && ( <section className="container mx-auto px-4 py-10">
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6"> <header className="max-w-3xl">
<strong></strong> {error} <h2 className="text-2xl font-bold text-gray-900"> / </h2>
</div> <p className="text-gray-600 mt-2"></p>
)} </header>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <div className="mt-6 space-y-4">
{loading {paged.map((policy) => {
? Array.from({ length: 6 }).map((_, i) => <PostCardSkeleton key={i} />) const remaining = daysUntil(policy.effectiveAt)
: posts.map((post) => ( return (
<PostCard <article
key={post.id} key={policy.id}
title={post.title} className="bg-white border border-gray-200 rounded-xl p-5 hover:shadow-sm transition-shadow"
excerpt={stripHtml(post.content_html || post.content?.root?.children?.[0]?.children?.[0]?.text || post.title)} >
category={getCategoryTitle(post)} <div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
date={formatDate(post.createdAt)} <div>
onClick={() => handlePostClick(post.slug)} <div className="flex flex-wrap items-center gap-2">
/> <a
href={`/countries/${policy.countryCode}`}
className="text-sm font-semibold text-blue-700 hover:text-blue-900"
>
{policy.countryName}
</a>
<span className="text-sm text-gray-500">{policyTypeMeta[policy.policyType].title}</span>
<span
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold border ${impactClass(
policy.impactLevel,
)}`}
>
{impactLabel(policy.impactLevel)}
</span>
</div>
<h3 className="text-lg font-semibold text-gray-900 mt-2">{policy.title}</h3>
<p className="text-sm text-gray-600 mt-2 line-clamp-3">{policy.summary}</p>
<div className="mt-4 flex flex-wrap items-center gap-2">
{policy.industries.slice(0, 3).map((industry) => (
<span
key={industry}
className="inline-flex items-center px-2 py-1 rounded-md bg-gray-100 text-gray-700 text-xs"
>
{industryMeta[industry]}
</span>
))} ))}
{isEffectiveSoon(policy, 30) && (
<span className="inline-flex items-center px-2 py-1 rounded-md bg-amber-50 text-amber-700 text-xs border border-amber-100">
</span>
)}
{policy.impactLevel === 'high' && (
<span className="inline-flex items-center px-2 py-1 rounded-md bg-red-50 text-red-700 text-xs border border-red-100">
</span>
)}
</div>
</div> </div>
{!loading && posts.length === 0 && !error && ( <div className="flex md:flex-col gap-2 md:items-end">
<div className="text-center py-12"> <p className="text-xs text-gray-500">{formatDateCN(policy.publishedAt)}</p>
<p className="text-gray-500 text-lg"></p> <p className="text-xs text-gray-500">{formatDateCN(policy.effectiveAt)}</p>
<p className="text-xs text-gray-500">
{remaining >= 0 ? `${remaining} 天后生效` : '已生效'}
</p>
</div>
</div>
<div className="mt-5 flex items-center gap-3">
<a
href={`/policies/${policy.slug}`}
className="inline-flex px-3 py-2 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700"
>
</a>
<button
type="button"
className="inline-flex px-3 py-2 rounded-md border border-gray-200 text-sm text-gray-700 hover:bg-gray-50"
aria-label="收藏(占位)"
onClick={() => {
window.alert('收藏功能将在后续版本开放。')
}}
>
</button>
</div>
</article>
)
})}
{canLoadMore && (
<div className="flex justify-center pt-2">
<button
type="button"
className="px-4 py-2 rounded-md border border-gray-200 bg-white text-sm text-gray-700 hover:bg-gray-50"
onClick={() => setPage((p) => p + 1)}
>
</button>
</div> </div>
)} )}
</div>
</section>
</main> </main>
<Footer /> <Footer />

View File

@@ -0,0 +1,26 @@
import React from 'react'
import { Header } from '../components/Header'
import { Footer } from '../components/Footer'
export const PlaceholderPage: React.FC<{ title: string }> = ({ title }) => {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<main className="container mx-auto px-4 py-10">
<div className="bg-white border border-gray-200 rounded-xl p-8">
<h1 className="text-2xl font-bold text-gray-900">{title}</h1>
<p className="text-gray-600 mt-3"> MVP </p>
<div className="mt-6">
<a
href="/"
className="inline-flex px-4 py-2 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700"
>
</a>
</div>
</div>
</main>
<Footer />
</div>
)
}

212
src/pages/PolicyDetail.tsx Normal file
View File

@@ -0,0 +1,212 @@
import React, { useMemo } from 'react'
import { useParams } from 'react-router-dom'
import { Footer } from '../components/Footer'
import { Header } from '../components/Header'
import {
daysUntil,
formatDateCN,
getToday,
impactClass,
impactLabel,
industryMeta,
policies,
policyTypeMeta,
type Policy,
} from '../data/policies'
function badgeText(policy: Policy): string {
const type = policyTypeMeta[policy.policyType]?.title
return type ? `${policy.countryName} · ${type}` : policy.countryName
}
export const PolicyDetail: React.FC = () => {
const params = useParams<{ slug: string }>()
const slug = params.slug || ''
const policy = useMemo(() => policies.find((p) => p.slug === slug), [slug])
if (!policy) {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<main className="container mx-auto px-4 py-10 max-w-4xl">
<div className="bg-white border border-gray-200 rounded-xl p-8">
<h1 className="text-xl font-bold text-gray-900"></h1>
<p className="text-gray-600 mt-2"></p>
<a
href="/policies"
className="inline-flex mt-6 px-4 py-2 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700"
>
</a>
</div>
</main>
<Footer />
</div>
)
}
const today = getToday()
const countdown = daysUntil(policy.effectiveAt, today)
return (
<div className="min-h-screen bg-gray-50">
<Header />
<main className="container mx-auto px-4 py-8 max-w-4xl">
<article className="bg-white border border-gray-200 rounded-xl p-6 md:p-8">
<header>
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center px-3 py-1 rounded-full bg-blue-50 text-blue-700 text-xs font-semibold">
{badgeText(policy)}
</span>
<span
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold border ${impactClass(
policy.impactLevel,
)}`}
>
{impactLabel(policy.impactLevel)}
</span>
</div>
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 mt-4">{policy.title}</h1>
<dl className="mt-5 grid gap-3 sm:grid-cols-2">
<div className="text-sm">
<dt className="text-gray-500"></dt>
<dd className="text-gray-900 font-medium mt-1">{policy.issuer}</dd>
</div>
<div className="text-sm">
<dt className="text-gray-500"></dt>
<dd className="text-gray-900 font-medium mt-1">{formatDateCN(policy.publishedAt)}</dd>
</div>
<div className="text-sm">
<dt className="text-gray-500"></dt>
<dd className="text-gray-900 font-medium mt-1">{formatDateCN(policy.effectiveAt)}</dd>
</div>
<div className="text-sm">
<dt className="text-gray-500"></dt>
<dd className="text-gray-900 font-medium mt-1">
{countdown >= 0 ? `${countdown}` : '已生效'}
</dd>
</div>
</dl>
<div className="mt-6 flex flex-wrap items-center gap-2">
{policy.industries.map((industry) => (
<span
key={industry}
className="inline-flex items-center px-2 py-1 rounded-md bg-gray-100 text-gray-700 text-xs"
>
{industryMeta[industry]}
</span>
))}
<a
href={`/countries/${policy.countryCode}`}
className="ml-auto text-sm font-medium text-blue-700 hover:text-blue-900"
>
{policy.countryName}
</a>
</div>
</header>
<section className="mt-10">
<h2 className="text-xl font-bold text-gray-900"></h2>
<p className="text-sm text-gray-600 mt-2">5 </p>
<div className="mt-6 space-y-6">
<section className="border border-gray-200 rounded-xl p-5">
<div className="flex items-baseline justify-between gap-4">
<h3 className="text-lg font-semibold text-gray-900">1. What</h3>
<span className="text-xs text-gray-500">{policy.sourceName}</span>
</div>
<p className="text-sm text-gray-700 mt-3 leading-relaxed">{policy.what.officialSummary}</p>
<h4 className="text-sm font-semibold text-gray-900 mt-5"></h4>
<ul className="mt-3 space-y-2 text-sm text-gray-700 list-disc pl-5">
{policy.what.keyClauses.map((clause) => (
<li key={clause}>{clause}</li>
))}
</ul>
<a
href={policy.sourceUrl}
className="inline-flex mt-5 text-sm font-medium text-blue-700 hover:text-blue-900"
target="_blank"
rel="noreferrer"
>
</a>
</section>
<section className="border border-gray-200 rounded-xl p-5">
<h3 className="text-lg font-semibold text-gray-900">2. Impact</h3>
<dl className="mt-4 grid gap-4">
<div>
<dt className="text-sm font-semibold text-gray-900"></dt>
<dd className="text-sm text-gray-700 mt-2 leading-relaxed">{policy.impact.cost}</dd>
</div>
<div>
<dt className="text-sm font-semibold text-gray-900"></dt>
<dd className="text-sm text-gray-700 mt-2 leading-relaxed">{policy.impact.compliance}</dd>
</div>
<div>
<dt className="text-sm font-semibold text-gray-900"></dt>
<dd className="text-sm text-gray-700 mt-2 leading-relaxed">{policy.impact.opportunity}</dd>
</div>
</dl>
</section>
<section className="border border-gray-200 rounded-xl p-5">
<h3 className="text-lg font-semibold text-gray-900">3. Action</h3>
<div className="mt-4">
<h4 className="text-sm font-semibold text-gray-900"> Checklist</h4>
<ul className="mt-3 space-y-2 text-sm text-gray-700">
{policy.action.checklist.map((item) => (
<li key={item} className="flex items-start gap-2">
<span aria-hidden="true" className="mt-1 w-1.5 h-1.5 rounded-full bg-blue-600" />
<span className="leading-relaxed">{item}</span>
</li>
))}
</ul>
</div>
<div className="mt-6">
<h4 className="text-sm font-semibold text-gray-900"></h4>
<div className="mt-3 flex flex-wrap gap-2">
{policy.action.applicableFor.map((item) => (
<span
key={item}
className="inline-flex items-center px-2 py-1 rounded-md bg-gray-100 text-gray-700 text-xs"
>
{item}
</span>
))}
</div>
</div>
<div className="mt-7 flex flex-wrap gap-3">
<a
href="/subscribe"
className="inline-flex px-4 py-2 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700"
>
</a>
<a
href="/policies"
className="inline-flex px-4 py-2 rounded-md border border-gray-200 text-sm text-gray-700 hover:bg-gray-50"
>
</a>
</div>
</section>
</div>
</section>
</article>
</main>
<Footer />
</div>
)
}

238
src/pages/PolicyHub.tsx Normal file
View File

@@ -0,0 +1,238 @@
import React, { useMemo, useState } from 'react'
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { Footer } from '../components/Footer'
import { Header } from '../components/Header'
import {
formatDateCN,
impactClass,
impactLabel,
industryMeta,
policies,
policyTypeMeta,
type Policy,
type PolicyType,
} from '../data/policies'
const policyTypeOrder: PolicyType[] = [
'trade_tariff',
'tax_subsidy',
'compliance_regulation',
'data_privacy',
'labor_visa',
'esg_environment',
]
function toTitleCase(input: string): string {
return input.replace(/(^|\s)\S/g, (s) => s.toUpperCase())
}
function includesQuery(policy: Policy, q: string): boolean {
if (!q.trim()) return true
const query = q.trim().toLowerCase()
const haystack = [
policy.title,
policy.countryName,
policy.issuer,
policy.summary,
policyTypeMeta[policy.policyType]?.title,
...policy.industries.map((i) => industryMeta[i]),
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return haystack.includes(query)
}
function sortByPublishedDesc(a: Policy, b: Policy): number {
return b.publishedAt.localeCompare(a.publishedAt)
}
export const PolicyHub: React.FC = () => {
const [searchParams] = useSearchParams()
const location = useLocation()
const navigate = useNavigate()
const typeParam = searchParams.get('type') || ''
const q = searchParams.get('q') || ''
const activeType = policyTypeOrder.includes(typeParam as PolicyType)
? (typeParam as PolicyType)
: undefined
const [page, setPage] = useState(1)
const pageSize = 6
const filtered = useMemo(() => {
const base = policies
.filter((p) => (activeType ? p.policyType === activeType : true))
.filter((p) => includesQuery(p, q))
.slice()
.sort(sortByPublishedDesc)
const pinned = base.filter((p) => p.impactLevel === 'high')
const rest = base.filter((p) => p.impactLevel !== 'high')
return [...pinned, ...rest]
}, [activeType, q])
const paged = useMemo(() => filtered.slice(0, page * pageSize), [filtered, page])
const canLoadMore = paged.length < filtered.length
const activeMeta = activeType ? policyTypeMeta[activeType] : undefined
return (
<div className="min-h-screen bg-gray-50">
<Header />
<main className="container mx-auto px-4 py-8">
<div className="grid gap-8 lg:grid-cols-[260px_1fr]">
<aside className="bg-white border border-gray-200 rounded-xl p-5 h-fit">
<h2 className="text-sm font-semibold text-gray-900"></h2>
<p className="text-sm text-gray-600 mt-2"></p>
<nav className="mt-4 space-y-1" aria-label="政策分类">
<button
type="button"
className={`w-full text-left px-3 py-2 rounded-md text-sm font-medium ${
!activeType ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'
}`}
onClick={() => {
const params = new URLSearchParams(location.search)
params.delete('type')
setPage(1)
navigate(`/policies?${params.toString()}`)
}}
>
</button>
{policyTypeOrder.map((type) => {
const meta = policyTypeMeta[type]
const active = activeType === type
return (
<button
key={type}
type="button"
className={`w-full text-left px-3 py-2 rounded-md text-sm font-medium ${
active ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'
}`}
onClick={() => {
const params = new URLSearchParams(location.search)
params.set('type', type)
setPage(1)
navigate(`/policies?${params.toString()}`)
}}
>
{meta.title}
</button>
)
})}
</nav>
</aside>
<section>
<header className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">
{activeMeta ? activeMeta.title : '全球政策'}
</h1>
<p className="text-gray-600 mt-2">{activeMeta ? activeMeta.description : '快速筛选与浏览全球政策变化。'}</p>
{(q || activeType) && (
<p className="text-sm text-gray-500 mt-2">
{activeType ? policyTypeMeta[activeType].title : '全部'}
{q ? ` · 关键词「${q}` : ''}
</p>
)}
</header>
{filtered.length === 0 ? (
<div className="bg-white border border-gray-200 rounded-xl p-8">
<p className="text-gray-700 font-medium"></p>
<p className="text-sm text-gray-600 mt-2"></p>
</div>
) : (
<div className="space-y-4">
{paged.map((policy) => (
<article
key={policy.id}
className="bg-white border border-gray-200 rounded-xl p-5 hover:shadow-sm transition-shadow"
>
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<a
href={`/countries/${policy.countryCode}`}
className="text-sm font-semibold text-blue-700 hover:text-blue-900"
>
{policy.countryName}
</a>
<span className="text-sm text-gray-500">{policyTypeMeta[policy.policyType].title}</span>
<span
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold border ${impactClass(
policy.impactLevel,
)}`}
>
{impactLabel(policy.impactLevel)}
</span>
</div>
<h2 className="text-lg font-semibold text-gray-900 mt-2">{policy.title}</h2>
<p className="text-sm text-gray-600 mt-2 line-clamp-3">{policy.summary}</p>
</div>
<div className="flex md:flex-col gap-2 md:items-end">
<p className="text-xs text-gray-500">{formatDateCN(policy.publishedAt)}</p>
<p className="text-xs text-gray-500">{formatDateCN(policy.effectiveAt)}</p>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
{policy.industries.map((industry) => (
<span
key={industry}
className="inline-flex items-center px-2 py-1 rounded-md bg-gray-100 text-gray-700 text-xs"
>
{toTitleCase(industryMeta[industry])}
</span>
))}
</div>
<div className="mt-4 flex items-center gap-3">
<a
href={`/policies/${policy.slug}`}
className="inline-flex px-3 py-2 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700"
>
</a>
<button
type="button"
className="inline-flex px-3 py-2 rounded-md border border-gray-200 text-sm text-gray-700 hover:bg-gray-50"
aria-label="收藏(占位)"
onClick={() => {
window.alert('收藏功能将在后续版本开放。')
}}
>
</button>
</div>
</article>
))}
{canLoadMore && (
<div className="flex justify-center pt-2">
<button
type="button"
className="px-4 py-2 rounded-md border border-gray-200 bg-white text-sm text-gray-700 hover:bg-gray-50"
onClick={() => setPage((p) => p + 1)}
>
</button>
</div>
)}
</div>
)}
</section>
</div>
</main>
<Footer />
</div>
)
}

View File

@@ -1,158 +1,6 @@
import React, { useEffect, useState } from 'react' import React from 'react'
import { useParams } from 'react-router-dom' import { Navigate } from 'react-router-dom'
import { Header } from '../components/Header'
import { Footer } from '../components/Footer'
import { Posts } from '../clientsdk/sdk.gen'
import { createClient } from '../clientsdk/client'
import { customQuerySerializer } from '../clientsdk/querySerializer'
import { TENANT_SLUG, TENANT_API_KEY, API_URL } from '../config'
const client = createClient({
baseUrl: API_URL,
querySerializer: customQuerySerializer,
headers: {
'X-Tenant-Slug': TENANT_SLUG,
'X-API-Key': TENANT_API_KEY,
},
})
export const PostDetail: React.FC = () => { export const PostDetail: React.FC = () => {
const { slug } = useParams<{ slug: string }>() return <Navigate to="/policies" replace />
const [post, setPost] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!slug) return
const fetchPost = async () => {
try {
setLoading(true)
setError(null)
// Use listPosts with where filter since findPostById doesn't support slug lookup
const response = await Posts.listPosts({
client,
query: {
where: {
slug: {
equals: slug,
},
},
limit: 1,
},
})
const docs = (response as any)?.data?.docs || []
setPost(docs[0] || null)
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败')
console.error('获取文章失败:', err)
} finally {
setLoading(false)
}
}
fetchPost()
}, [slug])
const getCategoryTitle = (p: any): string | undefined => {
// categories is an array, get the first one
return p?.categories?.[0]?.title
}
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
if (loading) {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<main className="container mx-auto px-4 py-8">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/2 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-1/4 mb-6"></div>
<div className="space-y-3">
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
</div>
</div>
</main>
<Footer />
</div>
)
}
if (error || !post) {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<main className="container mx-auto px-4 py-8">
<div className="text-center py-12">
<p className="text-red-600 text-lg">{error || '文章不存在'}</p>
<button
onClick={() => window.location.href = '/'}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
</button>
</div>
</main>
<Footer />
</div>
)
}
return (
<div className="min-h-screen bg-gray-50">
<Header />
<main className="container mx-auto px-4 py-8 max-w-4xl">
<article>
<header className="mb-8">
{getCategoryTitle(post) && (
<span className="inline-block px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm mb-4">
{getCategoryTitle(post)}
</span>
)}
<h1 className="text-4xl font-bold text-gray-900 mb-4">{post.title}</h1>
<div className="flex items-center text-gray-600 text-sm">
<span>{formatDate(post.createdAt)}</span>
</div>
</header>
{post.heroImage && (
<img
src={post.heroImage.url}
alt={post.heroImage.alt || post.title}
className="w-full h-auto rounded-lg mb-8"
/>
)}
<div
className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: post.content_html || '' }}
/>
<div className="mt-12 pt-8 border-t border-gray-200">
<button
onClick={() => window.location.href = '/'}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300"
>
</button>
</div>
</article>
</main>
<Footer />
</div>
)
} }