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:
1 parent
8df0a3e140
commit
54b5ec751c
5 files changed
+426
-31
No files matched your search
@@ -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::{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user