产品体验修复:P0 阻塞 + P1/P2 改善(产品审查 14 项中修复 10 项)

P0 阻塞:
- Pro 加载不再阻塞整个看板:只显示精简顶栏 + 加载状态,不再遮盖整个页面
- Scan 失败加重试按钮:所有用户(含免费)均可手动重试
- Detail Panel 同步超时后显示提示:"同步时间较长,当前展示的数据可能不完整"

P1 体验:
- 登录页增加"忘记密码"链接:调用 Supabase 密码重置流程
- 登录失败/邮箱未验证增加明确提示:"如刚注册,请先点击验证链接"
- 公告横幅增加 ✕ 关闭按钮:点击后永久隐藏

P2 改善:
- entitlement-required 页面重设计:品牌化、增加返回首页和登录按钮
- 订阅帮助页中英双语:所有 FAQ 和 UI 文案支持中英文切换
- 新增产品审查文档 docs/product-review-jun-2026.md

Tested: npx tsc --noEmit
This commit is contained in:
2569718930@qq.com
2026-05-10 16:13:34 +08:00
parent 9635387beb
commit de0f037bf4
8 changed files with 392 additions and 46 deletions
+130
View File
@@ -0,0 +1,130 @@
# PolyWeather 前端产品审查报告
> 审查日期:2026-06 | 视角:产品经理 | 范围:`frontend/` 全部页面、组件、用户流程
## 一、产品概览
PolyWeather 是一个面向天气衍生品交易者的气象情报平台。核心价值主张:**结合多模型气象预报 + AI 机场报文解读 + Polymarket 市场价格,为交易决策提供一站式证据链。**
### 产品分层
| 层级 | 功能 | 门槛 |
|------|------|------|
| 免费 | 交互式全球天气地图 + 城市简报 | 无需登录 |
| Pro 试用 | 3 天全功能 | 注册后自动获得 |
| Pro 订阅 | 城市决策卡(AI 机场报文 + 模型证据 + 市场层)、日内分析、历史对账、未来预报 | 5 USDC/月(积分抵扣最多 3 USDC |
### 页面结构(9 个路由)
| 路由 | 功能 | 是否必需登录 |
|------|------|-------------|
| `/` | 主看板 — AI 天气决策台 | 否 |
| `/account` | 账户中心 — 身份/订阅/钱包/积分/Bot 绑定 | 是 |
| `/auth/login` | 登录页(Google OAuth + 邮箱密码) | 否 |
| `/docs/[...slug]` | 产品文档中心(8 篇双语文档) | 否 |
| `/subscription-help` | 订阅 FAQ(静态) | 否 |
| `/entitlement-required` | 访问被拒页面 | 否 |
| `/ops` | 运营管理后台 | 是(管理员) |
| `/ops/truth-history` | 真值历史查看器 | 是(管理员) |
---
## 二、用户流程分析
### 主看板的两个视图
```
┌─ 分布视图(地图) ──────────────────────────────┐
│ Leaflet 交互式地图,城市彩色气泡 │
│ 点击城市 → 自动添加到决策卡工作区 + 切换到卡片视图 │
│ 免费用户和 Pro 用户均可使用 │
├─ 决策卡(分析) ──────────────────────────────┤
│ 钉选的城市卡片:AI 机场解读 + 模型集群 + 市场层 + 图表 │
│ 需要 Pro 订阅 │
└──────────────────────────────────────────────┘
└── 右侧栏:城市简报面板(始终可见,免费可用)
```
### 关键用户路径
1. **新用户落地** → 看到地图 + 城市列表 → 点击城市 → 看到城市简报 → 想深入分析 → 遇到 Pro 付费墙
2. **Pro 用户打开** → Pro 加载中…… → 等待 → 看到决策卡工作区
3. **Pro 用户工作流** → 地图选城市 → 自动钉选到决策卡 → 展开卡片 → 阅读 AI 报文解读 → 查看市场层 → 判断交易方向
---
## 三、发现的问题(按严重程度排序)
### 🔴 P0 — 阻塞性问题
| # | 问题 | 影响 |
|---|------|------|
| 1 | **Pro 加载阻塞整个看板** — 检查 Pro 状态时(`proAccess.loading`),整个页面显示加载动画,免费用户的地图也被遮住 | 所有用户在加载期间无法使用任何功能 |
| 2 | **Scan 失败无重试** — 免费用户如果 scan terminal 数据拉取失败,只能看到 "Scan failed" 文字,没有重试按钮(重试按钮仅 Pro 用户可见) | 免费用户永久卡在失败状态 |
| 3 | **Detail Panel 同步超时无反馈** — 12 秒后停止显示 "Syncing..." 但不显示错误状态,用户看到空白内容 | 用户以为功能正常但数据缺失 |
### 🟡 P1 — 体验断裂
| # | 问题 | 影响 |
|---|------|------|
| 4 | **无新用户引导** — 没有任何欢迎弹窗、引导提示、或步骤教程。唯一的指引是顶栏内的一行副标题文字 | 新用户不知道从哪开始,可能直接流失 |
| 5 | **付费墙没有预览** — 历史对账和日内分析被硬阻断,非 Pro 用户完全看不到任何预览或示例 | 用户不知道付费后能得到什么,降低付费转化 |
| 6 | **密码重置缺失** — 邮箱注册的用户如果忘记密码,没有任何重置流程 | 用户被永久锁在账户外 |
| 7 | **邮箱验证状态不明** — 注册后提示"请检查邮箱验证",但未验证的用户反复登录失败,没有明确的下一步提示 | 用户反复尝试登录但不知道为什么失败 |
### 🟢 P2 — 改善体验
| # | 问题 | 影响 |
|---|------|------|
| 8 | **公告横幅不可手动关闭** — v1.5.6 升级公告只能通过 localStorage 倒计时 3 天后自动消失,没有 ✕ 关闭按钮 | 占用屏幕空间,用户无法跳过 |
| 9 | **无应用内反馈渠道** — 没有"反馈"/"帮助"/"报 Bug"按钮,用户只能主动去 Telegram 群或发邮件 | 用户反馈成本高,问题发现延迟 |
| 10 | **entitlement-required 页面过于简陋** — 没有品牌标识、没有导航、没有返回首页的链接 | 用户仿佛掉进了一个黑洞页面 |
| 11 | **账户中心缺少个人设置** — 用户无法编辑显示名称/头像、管理活跃会话、删除账户、调整通知偏好 | 产品感觉"半成品" |
| 12 | **Docs 无搜索** — 8 篇文档没有搜索功能,用户必须逐篇浏览找到需要的内容 | 查找效率低 |
| 13 | **订阅帮助仅中文**`/subscription-help` 页面没有英文版 | 英文用户无法自助解决计费问题 |
| 14 | **Ops 面板无审计日志** — 管理员补发积分等操作没有审计记录 | 运营风险 |
---
## 四、做得好的地方
1. **地图 → 决策卡的自动流转设计** — 点击地图城市自动钉选到分析工作区并切换视图,这是很聪明的"零步骤发现"设计
2. **双语覆盖完整** — 所有 UI 文案、文档、AI 解读都有中英文对照,覆盖率接近 100%
3. **数据新鲜度可视化** — DataFreshnessBar 让用户一眼看到 METAR/模型/市场数据的新鲜度(fresh/stale/loading/unavailable),降低误判风险
4. **AI 解读的产品化程度高** — 不只是显示原始 AI 输出,而是分层展示:快速判断 → 完整解读 → 证据链 → 风险提示,每层都可独立查看
5. **免费层有实际价值** — 地图 + 城市简报不是"空壳",用户可以看到真实的气象数据,建立信任后再转化 Pro
6. **支付链路完整** — 从钱包绑定到链上签约到支付恢复,整个流程处理了多种异常情况
7. **空状态有引导文字** — "Click a city on the map" 这样的空状态文案能告诉用户下一步做什么
8. **浅色/深色主题都有** — 两个主题都经过完整设计,不是简单的颜色反转
9. **Ops 面板功能齐全** — 运营人员可以在一个页面看到系统健康、转化漏斗、缓存状态、支付异常、用户管理
---
## 五、建议的优先级路线图
### Phase 1 — 修复阻塞(本周)
1. **Pro 加载不阻塞免费层**`proAccess.loading` 时先显示地图,决策卡区单独显示加载状态
2. **Scan 失败加重试** — 免费用户也能看到重试按钮
3. **Detail Panel 同步超时后显示错误提示** — "数据加载超时,请刷新"
### Phase 2 — 体验堵漏(本月)
4. **新用户引导** — 简单的 3 步 overlay:①点地图选城市 → ②看右侧城市简报 → ③Pro 解锁决策卡分析
5. **付费墙加预览** — 历史对账/日内分析展示一个模糊的示例截图 + "升级 Pro 查看完整分析"
6. **密码重置** — 集成 Supabase 的密码重置 API
7. **公告横幅加关闭按钮** — "知道了"按钮,点击后永久隐藏
### Phase 3 — 产品完善(下月)
8. **应用内反馈入口** — 底部固定"反馈"按钮 → 简单的表单/Telegram 跳转
9. **账户设置补充** — 显示名称编辑、会话管理
10. **Docs 搜索** — 简单的客户端全文搜索
11. **entitlement-required 页面美化** — 加品牌、导航、返回首页链接
### Phase 4 — 增长优化
12. 注册后邮件验证引导流程
13. 订阅帮助页中英双语
14. Ops 操作审计日志
15. 通知偏好设置(Email/Telegram 推送开关)
+63 -16
View File
@@ -1,3 +1,5 @@
import Link from "next/link";
type Props = {
searchParams?: Promise<{ next?: string }>;
};
@@ -21,37 +23,82 @@ export default async function EntitlementRequiredPage({ searchParams }: Props) {
<section
style={{
width: "100%",
maxWidth: 720,
maxWidth: 480,
border: "1px solid rgba(68, 92, 140, 0.45)",
borderRadius: 16,
padding: 24,
background: "rgba(9, 18, 36, 0.88)",
boxShadow: "0 20px 50px rgba(0, 0, 0, 0.35)",
textAlign: "center",
}}
>
<h1 style={{ margin: 0, fontSize: 28, lineHeight: 1.2 }}>
Entitlement Required
<h1
style={{
margin: 0,
fontSize: 22,
lineHeight: 1.3,
fontWeight: 800,
}}
>
访
</h1>
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
This dashboard is protected. If Supabase auth is enabled, please go to{" "}
<a
<br />
Sign in required to access this page.
</p>
<div style={{ marginTop: 20, display: "flex", gap: 12, justifyContent: "center", flexWrap: "wrap" }}>
<Link
href={`/auth/login?next=${encodeURIComponent(nextPath)}`}
style={{
color: "#8fc5ff",
display: "inline-flex",
minHeight: 36,
alignItems: "center",
gap: 6,
minHeight: 40,
padding: "8px 20px",
borderRadius: 12,
background: "linear-gradient(135deg, #2563EB, #4F46E5)",
color: "#fff",
fontWeight: 700,
textDecoration: "none",
fontSize: 14,
}}
>
/auth/login
</a>{" "}
to sign in first.
</p>
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
Legacy mode still supports <code>?access_token=&lt;your-token&gt;</code>.
</p>
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
Requested path: <code>{nextPath}</code>
/ Sign in
</Link>
<Link
href="/"
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
minHeight: 40,
padding: "8px 20px",
borderRadius: 12,
border: "1px solid rgba(68, 92, 140, 0.45)",
background: "rgba(68, 92, 140, 0.2)",
color: "#d6e2ff",
fontWeight: 600,
textDecoration: "none",
fontSize: 14,
}}
>
/ Back to Home
</Link>
</div>
<p
style={{
marginTop: 20,
fontSize: 12,
color: "#7891b5",
lineHeight: 1.5,
}}
>
<code style={{ background: "rgba(255,255,255,0.06)", padding: "2px 6px", borderRadius: 4 }}>?access_token=&lt;your-token&gt;</code>
<br />
<span style={{ marginTop: 4, display: "inline-block" }}>
/ Requested path: <code>{nextPath}</code>
</span>
</p>
</section>
</main>
+53 -29
View File
@@ -1,5 +1,7 @@
import type { Metadata } from "next";
"use client";
import Link from "next/link";
import { useMemo } from "react";
import {
ArrowLeft,
CheckCircle2,
@@ -8,11 +10,7 @@ import {
MessageSquare,
ShieldCheck,
} from "lucide-react";
export const metadata: Metadata = {
title: "PolyWeather | 订阅说明",
description: "PolyWeather Pro 订阅、积分抵扣与支付方式说明。",
};
import { useI18n } from "@/hooks/useI18n";
const TELEGRAM_GROUP_URL = String(
process.env.NEXT_PUBLIC_TELEGRAM_GROUP_URL ||
@@ -21,24 +19,50 @@ const TELEGRAM_GROUP_URL = String(
const FAQ_ITEMS = [
{
q: "Pro 包含哪些功能?",
a: "开通后可解锁:今日日内机场报文规则分析(含高温时段)、历史对账 + 未来日期分析、全平台智能气象推送。",
q_zh: "Pro 包含哪些功能?",
q_en: "What features does Pro include?",
a_zh: "开通后可解锁:今日日内机场报文规则分析(含高温时段)、历史对账 + 未来日期分析、全平台智能气象推送。",
a_en: "Unlocks: intraday METAR rule analysis (including peak window), historical reconciliation + future-date analysis, and cross-platform smart weather push.",
},
{
q: "当前订阅价格是多少?",
a: "目前仅提供月付:5 USDC / 30 天。",
q_zh: "当前订阅价格是多少?",
q_en: "What is the current subscription price?",
a_zh: "目前仅提供月付:5 USDC / 30 天。",
a_en: "Monthly plan only: 5 USDC / 30 days.",
},
{
q: "积分如何抵扣?",
a: "满 500 积分起兑,每 500 积分抵 1U,单次最多抵 3U。",
q_zh: "积分如何抵扣?",
q_en: "How do points work for discounts?",
a_zh: "满 500 积分起兑,每 500 积分抵 1U,单次最多抵 3U。",
a_en: "500 points minimum — every 500 points = 1 USDC off, up to 3 USDC discount per payment.",
},
{
q: "支持哪些钱包和支付方式?",
a: "支持 EVM 浏览器钱包(MetaMask / OKX / Rabby / Bitget 等)及 WalletConnect 扫码钱包(Trust Wallet / Binance Web3 Wallet / TokenPocket 等)。",
q_zh: "支持哪些钱包和支付方式?",
q_en: "Which wallets and payment methods are supported?",
a_zh: "支持 EVM 浏览器钱包(MetaMask / OKX / Rabby / Bitget 等)及 WalletConnect 扫码钱包(Trust Wallet / Binance Web3 Wallet / TokenPocket 等)。",
a_en: "EVM browser wallets (MetaMask, OKX, Rabby, Bitget, etc.) and WalletConnect-compatible wallets (Trust Wallet, Binance Web3 Wallet, TokenPocket, etc.).",
},
];
export default function SubscriptionHelpPage() {
const { locale } = useI18n();
const isEn = locale === "en-US";
const copy = useMemo(() => ({
back: isEn ? "Back to Account" : "返回账户中心",
title: isEn ? "PolyWeather Pro Subscription Guide" : "PolyWeather Pro 订阅说明",
description: isEn
? "Complete subscription rules and payment guide."
: "这里是完整的订阅规则和支付说明。你可以先在页面内绑定钱包,再直接开通 Pro。",
priceLabel: isEn ? "Price" : "订阅价格",
priceText: "5 USDC / 30 " + (isEn ? "Days" : "天"),
discountLabel: isEn ? "Points Discount" : "积分抵扣",
discountText: isEn ? "Up to 3 USDC off" : "最多抵 3U",
communityLabel: isEn ? "Community Points" : "社群积分",
communityLink: isEn ? "Join community to earn" : "加入社群即可赚取积分",
faqTitle: isEn ? "FAQ" : "常见问题",
}), [isEn]);
return (
<main className="min-h-screen bg-[#070d1d] px-4 py-10 text-slate-100">
<div className="mx-auto w-full max-w-4xl">
@@ -47,62 +71,62 @@ export default function SubscriptionHelpPage() {
className="mb-5 inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-slate-300 transition hover:bg-white/10"
>
<ArrowLeft size={15} />
{copy.back}
</Link>
<section className="rounded-3xl border border-blue-400/20 bg-gradient-to-b from-[#162541] to-[#0e1730] p-6 md:p-8">
<div className="mb-5 flex items-center gap-3">
<ShieldCheck className="text-cyan-300" size={22} />
<h1 className="text-2xl font-bold md:text-3xl">PolyWeather Pro </h1>
<h1 className="text-2xl font-bold md:text-3xl">{copy.title}</h1>
</div>
<p className="text-sm text-slate-300 md:text-base">
Pro
</p>
<p className="text-sm text-slate-300 md:text-base">{copy.description}</p>
<div className="mt-6 grid gap-3 md:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<div className="mb-2 flex items-center gap-2 text-cyan-300">
<CreditCard size={16} />
<span className="text-sm font-semibold"></span>
<span className="text-sm font-semibold">{copy.priceLabel}</span>
</div>
<p className="text-xl font-bold">5 USDC / 30 </p>
<p className="text-xl font-bold">{copy.priceText}</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<div className="mb-2 flex items-center gap-2 text-emerald-300">
<Coins size={16} />
<span className="text-sm font-semibold"></span>
<span className="text-sm font-semibold">{copy.discountLabel}</span>
</div>
<p className="text-xl font-bold"> 3U</p>
<p className="text-xl font-bold">{copy.discountText}</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<div className="mb-2 flex items-center gap-2 text-violet-300">
<MessageSquare size={16} />
<span className="text-sm font-semibold"></span>
<span className="text-sm font-semibold">{copy.communityLabel}</span>
</div>
<Link
href={TELEGRAM_GROUP_URL}
target="_blank"
className="inline-flex min-h-9 items-center text-sm font-semibold text-blue-300 underline decoration-blue-500/50 underline-offset-4"
>
{copy.communityLink}
</Link>
</div>
</div>
</section>
<section className="mt-6 rounded-3xl border border-white/10 bg-[#0f162a]/80 p-6 md:p-8">
<h2 className="mb-4 text-lg font-bold"></h2>
<h2 className="mb-4 text-lg font-bold">{copy.faqTitle}</h2>
<div className="space-y-4">
{FAQ_ITEMS.map((item) => (
<article
key={item.q}
key={item.q_en}
className="rounded-2xl border border-white/10 bg-white/[0.03] p-4"
>
<h3 className="mb-2 flex items-center gap-2 text-sm font-semibold text-blue-300">
<CheckCircle2 size={14} />
{item.q}
{isEn ? item.q_en : item.q_zh}
</h3>
<p className="text-sm leading-6 text-slate-300">{item.a}</p>
<p className="text-sm leading-6 text-slate-300">
{isEn ? item.a_en : item.a_zh}
</p>
</article>
))}
</div>
+57
View File
@@ -34,6 +34,7 @@ export function LoginClient({ nextPath }: LoginClientProps) {
const [loading, setLoading] = useState(false);
const [errorText, setErrorText] = useState("");
const [infoText, setInfoText] = useState("");
const [resetSent, setResetSent] = useState(false);
const supabaseReady = hasSupabasePublicEnv();
const isEn = locale === "en-US";
@@ -74,8 +75,45 @@ export function LoginClient({ nextPath }: LoginClientProps) {
trialPromo: isEn
? "New users unlock a free 3-day Pro trial after sign-up."
: "新用户注册后可免费体验 3 天 Pro。",
reset: isEn ? "Forgot password?" : "忘记密码?",
resetSent: isEn
? "Reset link sent. Check your inbox."
: "重置链接已发送,请检查收件箱。",
resetPlaceholder: isEn ? "Enter your email to reset" : "输入邮箱以重置密码",
resendVerify: isEn
? "Didn't receive the verification email? Sign up again with the same email to resend."
: "没收到验证邮件?用同一邮箱重新注册即可重发。",
loginFailedHint: isEn
? "If you just signed up, please verify your email first. Check your inbox or spam folder."
: "如果刚注册,请先点击邮箱中的验证链接。检查收件箱或垃圾邮件。",
} as const;
const onResetPassword = async () => {
setErrorText("");
setInfoText("");
if (!email.trim()) {
setErrorText(copy.resetPlaceholder);
return;
}
if (!supabaseReady) {
setErrorText(copy.supabaseMissing);
return;
}
setLoading(true);
try {
const supabase = getSupabaseBrowserClient();
const { error } = await supabase.auth.resetPasswordForEmail(email.trim());
if (error) {
setErrorText(error.message);
return;
}
setResetSent(true);
setInfoText(copy.resetSent);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!supabaseReady) return;
const run = async () => {
@@ -270,6 +308,19 @@ export function LoginClient({ nextPath }: LoginClientProps) {
/>
</div>
{isLogin && !resetSent ? (
<div className="mt-2 text-right">
<button
type="button"
onClick={() => void onResetPassword()}
disabled={loading}
className="text-xs text-slate-500 underline-offset-2 transition-all hover:text-blue-400 hover:underline disabled:opacity-50"
>
{copy.reset}
</button>
</div>
) : null}
<button
type="submit"
disabled={loading}
@@ -282,6 +333,12 @@ export function LoginClient({ nextPath }: LoginClientProps) {
{errorText ? <p className="mt-4 text-sm text-rose-300">{errorText}</p> : null}
{infoText ? <p className="mt-4 text-sm text-emerald-300">{infoText}</p> : null}
{errorText && isLogin && errorText.includes("Invalid login") ? (
<p className="mt-1 text-xs text-slate-500">{copy.loginFailedHint}</p>
) : null}
{infoText === copy.signupCheckEmail ? (
<p className="mt-1 text-xs text-slate-500">{copy.resendVerify}</p>
) : null}
<div className="mt-8 text-center">
<p className="text-xs text-slate-500">
@@ -277,6 +277,15 @@ export function DetailPanel({
</span>
</div>
)}
{panelSyncTimedOut && (
<div className="panel-loading-hint timed-out" role="alert">
<span>
{locale === "en-US"
? "Sync is taking longer than expected. Data shown may be incomplete."
: "同步时间较长,当前展示的数据可能不完整。"}
</span>
</div>
)}
<div className="panel-meta">
<span className={clsx("risk-badge", panelRiskLevel)}>
{getRiskBadgeLabel(panelRiskLevel, locale)}
@@ -210,3 +210,24 @@
font-size: 13px;
line-height: 1.7;
}
.root :global(.panel-loading-hint) {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
padding: 8px 12px;
border-radius: 10px;
background: rgba(77, 163, 255, 0.06);
border: 1px solid rgba(77, 163, 255, 0.1);
color: var(--color-accent-secondary);
font-size: 12px;
font-weight: 600;
}
.root :global(.panel-loading-hint.timed-out) {
background: rgba(245, 158, 11, 0.06);
border-color: rgba(245, 158, 11, 0.16);
color: var(--color-signal-warning);
font-size: 12px;
}
@@ -353,12 +353,25 @@ function ScanTerminalScreen() {
<div className={scanTerminalRootClassName}>
<div className={clsx("scan-terminal", themeMode === "light" && "light")}>
<main className="scan-data-grid">
<div className="scan-topbar">
<div className="scan-topbar-title">
<strong>{isEn ? "AI Weather Decision Terminal" : "AI 天气交易决策台"}</strong>
<span>
{isEn
? "Start from the map, then open city cards to verify weather evidence"
: "从地图选城市,再打开决策卡验证天气证据"}
</span>
</div>
<div className="scan-topbar-actions">
<span className="scan-topbar-time">{userLocalTime}</span>
</div>
</div>
<div className="scan-loading-state">
<LoadingSignal
title={isEn ? "Preparing decision workspace" : "正在准备决策工作台"}
description={
isEn
? "Checking access, city context and todays tradable weather windows."
? "Checking access, city context and today's tradable weather windows."
: "正在检查权限、城市上下文和今日可交易天气窗口。"
}
/>
@@ -461,6 +474,17 @@ function ScanTerminalScreen() {
<li>{isEn ? "HKO observatory AI read" : "香港天文台观测 AI 解读"}</li>
<li>{isEn ? "Smoother scrolling experience" : "滚动体验优化"}</li>
</ul>
<button
type="button"
className="scan-announcement-dismiss"
aria-label={isEn ? "Dismiss" : "关闭"}
onClick={() => {
localStorage.setItem("polyweather_v156_announcement_seen_at", String(Date.now() + 90 * 24 * 60 * 60 * 1000));
setShowAnnouncement(false);
}}
>
</button>
</section>
) : null}
@@ -534,6 +558,14 @@ function ScanTerminalScreen() {
{isEn ? "Scan failed" : "扫描失败"}
</div>
<div className="scan-empty-copy">{staleReason}</div>
<button
type="button"
className="scan-retry-button"
onClick={() => refreshScanTerminalManually()}
>
<RefreshCw size={13} />
{isEn ? "Retry" : "重试"}
</button>
</div>
) : (
renderMainView()
@@ -148,6 +148,32 @@
padding: 7px 10px;
}
.root :global(.scan-announcement-dismiss) {
position: absolute;
top: 10px;
right: 12px;
width: 28px;
height: 28px;
display: grid;
place-items: center;
border: none;
border-radius: 8px;
background: rgba(148, 163, 184, 0.1);
color: #94a3b8;
font-size: 14px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.root :global(.scan-announcement-dismiss:hover) {
background: rgba(148, 163, 184, 0.22);
color: #e2e8f0;
}
.root :global(.scan-upgrade-announcement) {
position: relative;
}
.root :global(.scan-ghost-button),
.root :global(.scan-cta-ghost),
.root :global(.scan-theme-button),