feat: marketplace 按钮移到 refresh 左 + list cache 秒开 + GitHub OAuth Device Flow Phase 1

Style.tsx:
- 「风格市场」按钮从 PageHeader.titleRight 移到 .right 槽,放在「刷新」按钮左边(按用户需求)。
- 删除 titleRight 那块独立的 pill 按钮,与其他动作按钮统一为 Btn variant=ghost icon=cloud。

ipc.ts:
- 加 sessionStorage 缓存:readMarketplaceListCache / writeMarketplaceListCache,5 分钟 TTL。
- 加 GitHub OAuth Device Flow IPC wrappers:githubDeviceFlowStart / githubDeviceFlowPoll + 类型定义。

commands.rs:
- 新增 github_device_flow_start:调 GitHub /login/device/code,返回 user_code/device_code/verification_uri。
- 新增 github_device_flow_poll:轮询 /login/oauth/access_token,拿到 token 后调 /user,返回 login。
- GITHUB_OAUTH_CLIENT_ID 常量:留空 + 详细错误指引(去 https://github.com/settings/applications/new 注册);
  支持环境变量 GITHUB_OAUTH_CLIENT_ID 覆盖(dev 方便)。

lib.rs:
- 注册 github_device_flow_start / github_device_flow_poll 到 invoke_handler。

Marketplace.tsx:
- items state 用 readMarketplaceListCache() 做 lazy init —— 重开「风格市场」秒看到上次默认视图。
- refresh() 拿到默认视图(popular + 空 query)的列表后写缓存。
- 加载 UI 改成 (loading && items.length === 0) —— 有缓存时不显示「加载中」遮挡数据。
- OAuth 状态机:idle → starting → pending (展示 user_code) → success / error。
- 「我的发布」弹框里的 @login chip 点击 → 触发 GitHub OAuth Device Flow;浏览器自动拉起 verification_uri;
  轮询直到用户授权完成;拿到 login 后自动 updatePrefs(marketplaceDevLogin)。
- 新 OAuth 弹框 UI:大字号 user_code + 一键复制 + 打开浏览器 + 取消 + waiting pulse。

Phase 2 留给后续:marketplace backend 验证 GitHub JWT 防止 X-Dev-User 伪造。
This commit is contained in:
baiqing committed 2026-05-16 17:01:54 +08:00
1 parent 8df0a3e140
commit 54b5ec751c
5 files changed
+426 -31

No files matched your search

+147
View File
@@ -2677,6 +2677,153 @@ pub async fn marketplace_my_packs(
.map_err(|e| format!("parse my-packs failed: {e}"))
}
// ─────────────────────── GitHub OAuth Device Flow (Phase 1) ───────────────────────
//
// 客户端直连 GitHub 拿 access_token + login,前端自动把 login 写进
// prefs.marketplaceDevLogin。marketplace backend 完全不动(依然 X-Dev-User)。
// Phase 2 才会让 backend 验证 GitHub identity(JWT 签发 + 防伪造)。
//
// 配置 client_id 的两种方式(OAuth App client_id 非敏感,可硬编码):
// 1. 在下方 GITHUB_OAUTH_CLIENT_ID 常量填值(生产推荐 — 直接 bake 进二进制)
// 2. 启动前设置环境变量 GITHUB_OAUTH_CLIENT_ID=<your_client_id>(dev 方便)
//
// 注册 OAuth App:
// https://github.com/settings/applications/new
// - Application name: OpenLess (or your fork)
// - Homepage URL: https://openless.top (or任意)
// - Authorization callback URL: https://openless.top (Device Flow 不真用,但表单要求填)
// - 创建后在 General 页面勾选 "Enable Device Flow"
// - 抄 client_id 填到本常量
const GITHUB_OAUTH_CLIENT_ID: &str = "";
fn get_github_oauth_client_id() -> Result<String, String> {
if let Ok(env_id) = std::env::var("GITHUB_OAUTH_CLIENT_ID") {
let trimmed = env_id.trim();
if !trimmed.is_empty() {
return Ok(trimmed.to_string());
}
}
if !GITHUB_OAUTH_CLIENT_ID.is_empty() {
return Ok(GITHUB_OAUTH_CLIENT_ID.to_string());
}
Err("GitHub OAuth 未配置。请去 https://github.com/settings/applications/new 注册一个 OAuth App\
(必须勾 Enable Device Flow),把 client_id 填到 \
openless-all/app/src-tauri/src/commands.rs 的 GITHUB_OAUTH_CLIENT_ID 常量,\
或在启动前设置环境变量 GITHUB_OAUTH_CLIENT_ID=<your_client_id>。"
.to_string())
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GithubDeviceStartResponse {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub interval: u32,
pub expires_in: u32,
}
#[tauri::command]
pub async fn github_device_flow_start() -> Result<GithubDeviceStartResponse, String> {
let client_id = get_github_oauth_client_id()?;
let client = reqwest::Client::new();
let resp = client
.post("https://github.com/login/device/code")
.header("Accept", "application/json")
.header("User-Agent", "OpenLess")
.form(&[("client_id", client_id.as_str()), ("scope", "read:user")])
.send()
.await
.map_err(|e| format!("调用 GitHub /login/device/code 失败:{e}"))?;
let status = resp.status();
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("解析 device/code 响应失败:{e}"))?;
if !status.is_success() {
let err = body["error"].as_str().unwrap_or("unknown_error");
let desc = body["error_description"].as_str().unwrap_or("");
return Err(format!("GitHub device/code {status} {err}: {desc}"));
}
Ok(GithubDeviceStartResponse {
device_code: body["device_code"].as_str().unwrap_or("").to_string(),
user_code: body["user_code"].as_str().unwrap_or("").to_string(),
verification_uri: body["verification_uri"]
.as_str()
.unwrap_or("https://github.com/login/device")
.to_string(),
interval: body["interval"].as_u64().unwrap_or(5) as u32,
expires_in: body["expires_in"].as_u64().unwrap_or(900) as u32,
})
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum GithubDevicePollResult {
Authorized { login: String },
Pending,
SlowDown,
Error { message: String },
}
#[tauri::command]
pub async fn github_device_flow_poll(
device_code: String,
) -> Result<GithubDevicePollResult, String> {
let client_id = get_github_oauth_client_id()?;
let client = reqwest::Client::new();
let token_resp = client
.post("https://github.com/login/oauth/access_token")
.header("Accept", "application/json")
.header("User-Agent", "OpenLess")
.form(&[
("client_id", client_id.as_str()),
("device_code", device_code.as_str()),
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
])
.send()
.await
.map_err(|e| format!("调用 GitHub /login/oauth/access_token 失败:{e}"))?;
let body: serde_json::Value = token_resp
.json()
.await
.map_err(|e| format!("解析 access_token 响应失败:{e}"))?;
if let Some(token) = body["access_token"].as_str() {
let user_resp = client
.get("https://api.github.com/user")
.header("User-Agent", "OpenLess")
.header("Accept", "application/vnd.github+json")
.bearer_auth(token)
.send()
.await
.map_err(|e| format!("调用 GitHub /user 失败:{e}"))?;
let user_body: serde_json::Value = user_resp
.json()
.await
.map_err(|e| format!("解析 /user 响应失败:{e}"))?;
let login = user_body["login"].as_str().unwrap_or("").to_string();
if login.is_empty() {
return Ok(GithubDevicePollResult::Error {
message: "GitHub /user 返回空 login".to_string(),
});
}
return Ok(GithubDevicePollResult::Authorized { login });
}
let err = body["error"].as_str().unwrap_or("");
let msg = match err {
"authorization_pending" => return Ok(GithubDevicePollResult::Pending),
"slow_down" => return Ok(GithubDevicePollResult::SlowDown),
"expired_token" => "OAuth 设备码已过期,请重新发起登录".to_string(),
"access_denied" => "你在 GitHub 上拒绝了授权".to_string(),
other if !other.is_empty() => format!("OAuth 错误:{other}"),
_ => "未知 OAuth 错误(access_token 缺失)".to_string(),
};
Ok(GithubDevicePollResult::Error { message: msg })
}
#[cfg(test)]
mod tests {
use super::{
+2
View File
@@ -292,6 +292,8 @@ pub fn run() {
commands::marketplace_my_likes,
commands::marketplace_my_packs,
commands::marketplace_delete,
commands::github_device_flow_start,
commands::github_device_flow_poll,
commands::list_vocab,
commands::add_vocab,
commands::remove_vocab,
+66
View File
@@ -1031,3 +1031,69 @@ export function marketplaceMyPacks(): Promise<MarketplaceMyPackItem[]> {
export function marketplaceDelete(packId: string): Promise<void> {
return invokeOrMock<void>('marketplace_delete', { packId }, () => undefined);
}
// ─────────────────────── GitHub OAuth Device Flow (Phase 1) ───────────────
// 客户端直连 GitHub OAuth Device Flow 拿 login,自动写进 prefs.marketplaceDevLogin。
// marketplace backend 不动(继续走 X-Dev-User header;Phase 2 才接 JWT 验证)。
//
// 后端 Rust 实现:commands.rs:github_device_flow_start / github_device_flow_poll
// 需要预先配置 GITHUB_OAUTH_CLIENT_ID(OAuth App client_id,非敏感,可硬编码)。
export interface GithubDeviceStartResponse {
deviceCode: string;
userCode: string;
verificationUri: string;
interval: number;
expiresIn: number;
}
export type GithubDevicePollResult =
| { kind: 'authorized'; login: string }
| { kind: 'pending' }
| { kind: 'slowDown' }
| { kind: 'error'; message: string };
export function githubDeviceFlowStart(): Promise<GithubDeviceStartResponse> {
return invokeOrMock<GithubDeviceStartResponse>('github_device_flow_start', undefined, () => ({
deviceCode: 'mock-device-code-xxxxxxxx',
userCode: 'MOCK-CODE',
verificationUri: 'https://github.com/login/device',
interval: 5,
expiresIn: 900,
}));
}
export function githubDeviceFlowPoll(deviceCode: string): Promise<GithubDevicePollResult> {
return invokeOrMock<GithubDevicePollResult>('github_device_flow_poll', { deviceCode }, () => ({
kind: 'authorized' as const,
login: 'mock-user',
}));
}
// ─────────────────────── Marketplace list 缓存 (sessionStorage) ─────────────
// 让用户重开「风格市场」时秒看到上次列表,再后台 refresh 校准。
// 只缓存「默认视图」(query='' + sort='popular'),避免缓存 query/sort 组合爆炸。
const MARKETPLACE_LIST_CACHE_KEY = 'ol-marketplace-list-cache-v1';
const MARKETPLACE_LIST_CACHE_TTL_MS = 5 * 60 * 1000; // 5 分钟内信任缓存
export function readMarketplaceListCache(): MarketplaceListItem[] | null {
try {
const raw = sessionStorage.getItem(MARKETPLACE_LIST_CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as { items: MarketplaceListItem[]; ts: number };
if (!parsed || !Array.isArray(parsed.items)) return null;
if (Date.now() - parsed.ts > MARKETPLACE_LIST_CACHE_TTL_MS) return null;
return parsed.items;
} catch {
return null;
}
}
export function writeMarketplaceListCache(items: MarketplaceListItem[]): void {
try {
sessionStorage.setItem(MARKETPLACE_LIST_CACHE_KEY, JSON.stringify({ items, ts: Date.now() }));
} catch {
// quota exceeded / disabled — silent
}
}
+207 -11
View File
@@ -16,6 +16,8 @@ import { useTranslation } from 'react-i18next';
import { Icon } from '../components/Icon';
import {
fetchMarketplaceDetail,
githubDeviceFlowPoll,
githubDeviceFlowStart,
installMarketplacePack,
likeMarketplacePack,
listMarketplace,
@@ -23,7 +25,10 @@ import {
marketplaceDelete,
marketplaceMyLikes,
marketplaceMyPacks,
openExternal,
readMarketplaceListCache,
uploadMarketplacePack,
writeMarketplaceListCache,
} from '../lib/ipc';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
import type { MarketplaceDetail, MarketplaceListItem, MarketplaceMyPackItem, StylePack } from '../lib/types';
@@ -33,9 +38,10 @@ type SortMode = 'popular' | 'new' | 'liked';
export function Marketplace() {
const { t } = useTranslation();
const { prefs } = useHotkeySettings();
const { prefs, updatePrefs } = useHotkeySettings();
const [items, setItems] = useState<MarketplaceListItem[]>([]);
// 启动时尝试读缓存:上次默认视图(popular + 空 query)的列表,秒呈现。后台 refresh 校准。
const [items, setItems] = useState<MarketplaceListItem[]>(() => readMarketplaceListCache() ?? []);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [query, setQuery] = useState('');
@@ -77,6 +83,15 @@ export function Marketplace() {
const [myPacksQuery, setMyPacksQuery] = useState('');
// 弹框内已下架包 5 分钟自动消失:tick 每 30s 一次,让 visibleMyPacks 重新计算。
const [nowTick, setNowTick] = useState(() => Date.now());
// GitHub OAuth Device Flow 状态。点登录 chip → 'starting' → 'pending'(展示 user_code 等待
// 用户在浏览器授权)→ 'success'(自动保存 marketplaceDevLogin)/ 'error'。
type OAuthPhase =
| { phase: 'idle' }
| { phase: 'starting' }
| { phase: 'pending'; userCode: string; verificationUri: string; deviceCode: string }
| { phase: 'success'; login: string }
| { phase: 'error'; message: string };
const [oauth, setOauth] = useState<OAuthPhase>({ phase: 'idle' });
// 当前用户赞过的 pack id 集合 —— 用于红心渲染 + 「我赞过的」过滤。
// 进入 marketplace 时拉一次;点星后本地 mutate。
const [likedIds, setLikedIds] = useState<Set<string>>(new Set());
@@ -107,6 +122,10 @@ export function Marketplace() {
const list = await listMarketplace({ query: debouncedQuery, sort: serverSort, limit: 50 });
if (seq !== reqSeqRef.current) return; // stale response
setItems(list);
// 只缓存「默认视图」(popular + 空 query),重开时秒出。
if (serverSort === 'popular' && debouncedQuery.trim() === '') {
writeMarketplaceListCache(list);
}
} catch (error) {
if (seq !== reqSeqRef.current) return;
console.error('[marketplace] list failed', error);
@@ -366,6 +385,68 @@ export function Marketplace() {
}
};
// GitHub OAuth Device Flow 入口:点登录 chip 触发。
const beginGithubLogin = useCallback(async () => {
setOauth({ phase: 'starting' });
try {
const start = await githubDeviceFlowStart();
setOauth({
phase: 'pending',
userCode: start.userCode,
verificationUri: start.verificationUri,
deviceCode: start.deviceCode,
});
// 自动拉起浏览器到 verification_uri;失败不致命,用户可以手动复制点击
try { await openExternal(start.verificationUri); } catch { /* user can copy manually */ }
} catch (error) {
setOauth({ phase: 'error', message: errorMessage(error) });
}
}, []);
// OAuth 轮询:phase==='pending' 时每 interval 秒打 backend → GitHub 一次。
useEffect(() => {
if (oauth.phase !== 'pending') return;
let cancelled = false;
let timer: number | null = null;
let interval = 5_000;
const pendingDeviceCode = oauth.deviceCode;
const tick = async () => {
if (cancelled) return;
try {
const res = await githubDeviceFlowPoll(pendingDeviceCode);
if (cancelled) return;
if (res.kind === 'authorized') {
setOauth({ phase: 'success', login: res.login });
// 写入 prefs.marketplaceDevLogin,让后续 X-Dev-User 走真实 GitHub login。
try {
await updatePrefs(current => ({ ...current, marketplaceDevLogin: res.login }));
} catch (e) {
console.warn('[oauth] save login to prefs failed', e);
}
setActionMsg({ kind: 'ok', text: `已登录为 @${res.login}` });
window.setTimeout(() => {
if (!cancelled) setOauth({ phase: 'idle' });
}, 1500);
} else if (res.kind === 'slowDown') {
interval = Math.min(interval + 5_000, 30_000);
timer = window.setTimeout(tick, interval);
} else if (res.kind === 'pending') {
timer = window.setTimeout(tick, interval);
} else {
setOauth({ phase: 'error', message: res.message });
}
} catch (error) {
if (cancelled) return;
setOauth({ phase: 'error', message: errorMessage(error) });
}
};
timer = window.setTimeout(tick, interval);
return () => {
cancelled = true;
if (timer != null) window.clearTimeout(timer);
};
}, [oauth, updatePrefs]);
const sortPills = useMemo<Array<{ id: SortMode; label: string }>>(
() => [
{ id: 'popular', label: t('marketplace.sortPopular') },
@@ -531,7 +612,8 @@ export function Marketplace() {
{/* 卡片列表 / 我的发布 */}
<div style={{ flex: 1, overflow: 'auto' }} className="ol-thinscroll">
{loading ? (
{loading && items.length === 0 ? (
// 只在没有缓存数据时才显示 loading;有缓存就直接渲染缓存数据,后台 refresh 校准
<div style={{ padding: 32, textAlign: 'center', color: 'var(--ol-ink-4)', fontSize: 13 }}>
{t('common.loading')}
</div>
@@ -775,15 +857,13 @@ export function Marketplace() {
}}
/>
</div>
{/* 用户名 + 登录 chip。点击 → 触发 OAuth Device Flow(上线后接入);
现阶段提示用户去 Settings 填发布身份。 */}
{/* 用户名 + 登录 chip。点击 → 触发 GitHub OAuth Device Flow。
已登录时再点会重新走一次(切账号)。 */}
<button
type="button"
title={currentLogin ? '已登录身份 · GitHub OAuth 上线后可切换' : '点击去 Settings 填写身份'}
onClick={() => setActionMsg({
kind: 'ok',
text: currentLogin ? `当前身份 @${currentLogin}` : 'GitHub 登录开发中,请先在 Settings → 风格市场 填写身份',
})}
title={currentLogin ? `点击重新登录 / 切换账号(当前 @${currentLogin})` : '点击用 GitHub 登录'}
onClick={() => void beginGithubLogin()}
disabled={oauth.phase === 'starting' || oauth.phase === 'pending'}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '5px 10px', borderRadius: 9,
@@ -791,8 +871,9 @@ export function Marketplace() {
background: currentLogin ? 'var(--ol-blue-soft)' : 'var(--ol-surface)',
color: currentLogin ? 'var(--ol-blue)' : 'var(--ol-ink-3)',
fontSize: 12, fontWeight: 650,
cursor: 'pointer',
cursor: (oauth.phase === 'starting' || oauth.phase === 'pending') ? 'wait' : 'pointer',
whiteSpace: 'nowrap',
opacity: (oauth.phase === 'starting' || oauth.phase === 'pending') ? 0.6 : 1,
}}
>
<span style={{
@@ -907,6 +988,121 @@ export function Marketplace() {
)}
</Modal>
)}
{/* GitHub OAuth Device Flow 弹框(叠在「我的发布」之上)*/}
{oauth.phase !== 'idle' && (
<Modal onClose={() => {
// 关闭即放弃;正在 pending 也允许取消
setOauth({ phase: 'idle' });
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 12 }}>
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 650 }}>用 GitHub 登录</h2>
<button
type="button"
aria-label="关闭"
title="关闭"
onClick={() => setOauth({ phase: 'idle' })}
style={{
width: 28, height: 28, borderRadius: 8,
display: 'inline-grid', placeItems: 'center',
border: '0.5px solid var(--ol-line-strong)',
background: 'var(--ol-surface)',
color: 'var(--ol-ink-2)',
cursor: 'pointer',
fontSize: 16, lineHeight: 1,
}}
>×</button>
</div>
{oauth.phase === 'starting' && (
<div style={{ padding: '24px 8px', textAlign: 'center', color: 'var(--ol-ink-3)', fontSize: 13 }}>
正在生成设备验证码…
</div>
)}
{oauth.phase === 'pending' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ fontSize: 13, color: 'var(--ol-ink-2)', lineHeight: 1.6 }}>
在浏览器中打开 <span style={{ fontFamily: 'var(--ol-font-mono)', fontSize: 12, padding: '1px 5px', background: 'var(--ol-surface-2)', borderRadius: 4 }}>{oauth.verificationUri}</span> 并输入下方代码:
</div>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12,
padding: 18, borderRadius: 12,
border: '0.5px solid var(--ol-line-strong)',
background: 'var(--ol-surface-2)',
}}>
<span style={{
fontFamily: 'var(--ol-font-mono)',
fontSize: 22, fontWeight: 700,
letterSpacing: 2,
color: 'var(--ol-blue)',
}}>{oauth.userCode}</span>
<Btn
variant="ghost"
size="sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(oauth.userCode);
setActionMsg({ kind: 'ok', text: '已复制设备码' });
} catch (e) {
setActionMsg({ kind: 'err', text: `复制失败:${errorMessage(e)}` });
}
}}
>
复制
</Btn>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
<Btn variant="ghost" size="sm" onClick={() => void openExternal(oauth.verificationUri)}>
打开浏览器
</Btn>
<Btn variant="ghost" size="sm" onClick={() => setOauth({ phase: 'idle' })}>
取消
</Btn>
</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', textAlign: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<span style={{
display: 'inline-block', width: 8, height: 8, borderRadius: 999,
background: 'var(--ol-blue)', animation: 'ol-pulse 1.4s ease-in-out infinite',
}} />
等待你在浏览器中授权…
</div>
<style>{`
@keyframes ol-pulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
`}</style>
</div>
)}
{oauth.phase === 'success' && (
<div style={{ padding: '24px 8px', textAlign: 'center' }}>
<div style={{ fontSize: 24, color: 'var(--ol-blue)', marginBottom: 8 }}>✓</div>
<div style={{ fontSize: 14, fontWeight: 650, color: 'var(--ol-ink)' }}>已登录为 @{oauth.login}</div>
</div>
)}
{oauth.phase === 'error' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{
padding: 12, borderRadius: 10,
border: '0.5px solid rgba(239,68,68,0.3)',
background: 'rgba(239,68,68,0.06)',
color: '#b91c1c',
fontSize: 12, lineHeight: 1.6,
whiteSpace: 'pre-wrap',
}}>
{oauth.message}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Btn variant="ghost" size="sm" onClick={() => setOauth({ phase: 'idle' })}>关闭</Btn>
<Btn variant="blue" size="sm" onClick={() => void beginGithubLogin()}>重试</Btn>
</div>
</div>
)}
</Modal>
)}
</div>
);
}
+4 -20
View File
@@ -643,28 +643,12 @@ export function Style() {
kicker={copy.kicker}
title={copy.title}
desc={copy.desc}
titleRight={(
<button
type="button"
onClick={() => setMarketplaceOpen(true)}
title={isEnglish ? 'Open Style Marketplace' : '打开风格市场'}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '6px 12px', borderRadius: 999,
border: '0.5px solid var(--ol-line)',
background: 'var(--ol-surface)',
color: 'var(--ol-blue)',
fontSize: 12, fontWeight: 500,
cursor: 'pointer',
transition: 'background 0.16s var(--ol-motion-quick)',
}}
>
<Icon name="cloud" size={13} />
<span>{isEnglish ? 'Marketplace' : '风格市场'}</span>
</button>
)}
right={(
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end', marginTop: 40 }}>
{/* 风格市场入口:放在 刷新 左边(按用户需求)。点击 → 全屏弹框承载 <Marketplace />。*/}
<Btn variant="ghost" icon="cloud" onClick={() => setMarketplaceOpen(true)}>
{isEnglish ? 'Marketplace' : '风格市场'}
</Btn>
<Btn variant="ghost" icon="refresh" onClick={() => void loadPacks(selectedId)} disabled={busy === 'loading'}>
{t('common.refresh')}
</Btn>