manual save(2026-01-21 12:14)
This commit is contained in:
@@ -9,6 +9,7 @@ export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
ignores: ['src/clientsdk/**/*.ts'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>react-template</title>
|
||||
<title>PolicyRadar | 全球政策监测与决策支持</title>
|
||||
<meta name="description" content="面向出海企业的全球热点政策监测与决策支持平台:最新、权威、可解读、可行动。" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
32
src/App.tsx
32
src/App.tsx
@@ -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 { PostDetail } from './pages/PostDetail'
|
||||
import { CategoriesPage } from './pages/Categories'
|
||||
import { CategoryDetail } from './pages/CategoryDetail'
|
||||
import { PolicyHub } from './pages/PolicyHub'
|
||||
import { CountryView } from './pages/CountryView'
|
||||
import { PolicyDetail } from './pages/PolicyDetail'
|
||||
import { PlaceholderPage } from './pages/PlaceholderPage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/posts/:slug" element={<PostDetail />} />
|
||||
<Route path="/categories" element={<CategoriesPage />} />
|
||||
<Route path="/categories/:slug" element={<CategoryDetail />} />
|
||||
<Route path="/policies" element={<PolicyHub />} />
|
||||
<Route path="/countries/:code" element={<CountryView />} />
|
||||
<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>
|
||||
</Router>
|
||||
)
|
||||
|
||||
@@ -2,12 +2,64 @@ import React from 'react'
|
||||
|
||||
export const Footer: React.FC = () => {
|
||||
return (
|
||||
<footer className="bg-gray-50 border-t border-gray-200 py-8 mt-12">
|
||||
<div className="container mx-auto px-4 text-center text-gray-600">
|
||||
<p>Powered by TenantCMS</p>
|
||||
<p className="text-sm mt-2">
|
||||
Using X-Tenant-Slug for multi-tenant authentication
|
||||
</p>
|
||||
<footer className="bg-gray-50 border-t border-gray-200 py-10 mt-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="grid gap-8 md:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-gray-900">PolicyRadar</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>
|
||||
</footer>
|
||||
)
|
||||
|
||||
@@ -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 = () => {
|
||||
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 (
|
||||
<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="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
TenantCMS <span className="text-blue-600">Demo</span>
|
||||
</h1>
|
||||
<nav className="flex items-center gap-4">
|
||||
<a href="/" className="text-gray-600 hover:text-gray-900">首页</a>
|
||||
<a href="/categories" className="text-gray-600 hover:text-gray-900">分类</a>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<Link to="/" className="inline-flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold text-gray-900">PolicyRadar</span>
|
||||
<span className="text-sm font-semibold text-blue-700">出海政策</span>
|
||||
</Link>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
403
src/data/policies.ts
Normal file
403
src/data/policies.ts
Normal 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'
|
||||
}
|
||||
@@ -1,94 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Header } from '../components/Header'
|
||||
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,
|
||||
},
|
||||
})
|
||||
import React from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
|
||||
export const CategoriesPage: React.FC = () => {
|
||||
const [categories, setCategories] = useState<any[]>([])
|
||||
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>
|
||||
)
|
||||
return <Navigate to="/policies" replace />
|
||||
}
|
||||
|
||||
@@ -1,148 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useParams } 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,
|
||||
},
|
||||
})
|
||||
import React from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
|
||||
export const CategoryDetail: React.FC = () => {
|
||||
const { slug } = useParams<{ slug: string }>()
|
||||
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>
|
||||
)
|
||||
return <Navigate to="/policies" replace />
|
||||
}
|
||||
|
||||
191
src/pages/CountryView.tsx
Normal file
191
src/pages/CountryView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,113 +1,201 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Header } from '../components/Header'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { Footer } from '../components/Footer'
|
||||
import { PostCard } from '../components/PostCard'
|
||||
import { PostCardSkeleton } from '../components/PostCardSkeleton'
|
||||
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'
|
||||
import { Header } from '../components/Header'
|
||||
import {
|
||||
daysUntil,
|
||||
formatDateCN,
|
||||
getToday,
|
||||
impactClass,
|
||||
impactLabel,
|
||||
industryMeta,
|
||||
isEffectiveSoon,
|
||||
isSameDay,
|
||||
policies,
|
||||
policyTypeMeta,
|
||||
type Policy,
|
||||
} from '../data/policies'
|
||||
|
||||
const client = createClient({
|
||||
baseUrl: API_URL,
|
||||
querySerializer: customQuerySerializer,
|
||||
headers: {
|
||||
'X-Tenant-Slug': TENANT_SLUG,
|
||||
'X-API-Key': TENANT_API_KEY,
|
||||
},
|
||||
})
|
||||
function uniqueCount<T>(items: T[]): number {
|
||||
return new Set(items).size
|
||||
}
|
||||
|
||||
function getPolicyKeyStats(all: Policy[]) {
|
||||
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 = () => {
|
||||
const [posts, setPosts] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const stats = useMemo(() => getPolicyKeyStats(policies), [])
|
||||
const [page, setPage] = useState(1)
|
||||
const pageSize = 5
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPosts = async () => {
|
||||
try {
|
||||
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}`
|
||||
}
|
||||
const feed = useMemo(() => policies.slice().sort(sortByPublishedDesc), [])
|
||||
const paged = useMemo(() => feed.slice(0, page * pageSize), [feed, page])
|
||||
const canLoadMore = paged.length < feed.length
|
||||
|
||||
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>
|
||||
<main>
|
||||
<section className="bg-gradient-to-b from-white to-gray-50 border-b border-gray-200">
|
||||
<div className="container mx-auto px-4 py-10">
|
||||
<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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
<section className="container mx-auto px-4 py-10">
|
||||
<header className="max-w-3xl">
|
||||
<h2 className="text-2xl font-bold text-gray-900">今日 / 本周政策快讯</h2>
|
||||
<p className="text-gray-600 mt-2">快速浏览影响等级与摘要,进入详情查看三段式解读。</p>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
<div className="mt-6 space-y-4">
|
||||
{paged.map((policy) => {
|
||||
const remaining = daysUntil(policy.effectiveAt)
|
||||
return (
|
||||
<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>
|
||||
|
||||
{!loading && posts.length === 0 && !error && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 text-lg">暂无文章</p>
|
||||
<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 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>
|
||||
<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>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
|
||||
26
src/pages/PlaceholderPage.tsx
Normal file
26
src/pages/PlaceholderPage.tsx
Normal 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
212
src/pages/PolicyDetail.tsx
Normal 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
238
src/pages/PolicyHub.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,158 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useParams } 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,
|
||||
},
|
||||
})
|
||||
import React from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
|
||||
export const PostDetail: React.FC = () => {
|
||||
const { slug } = useParams<{ slug: string }>()
|
||||
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>
|
||||
)
|
||||
return <Navigate to="/policies" replace />
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user