Files
pangolin/web/usercenter/components/Subscription.tsx
T
wangjia 5b89de656e
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 25s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 7s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 26s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 11s
ci-pangolin / Go — build + test (pull_request) Successful in 15s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 26s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Successful in 10s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Successful in 4m36s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Successful in 20s
ci-pangolin / Lint — shellcheck (pull_request) Failing after 10m3s
Deploy Site / deploy-site (push) Failing after 13m20s
ci-pangolin / Flutter — analyze + test (pull_request) Failing after 14m42s
ci-pangolin / OpenAPI Sync Check (pull_request) Failing after 14m52s
feat(web): 5 项 UI 修复 — 统一浅色/登录回跳/logo locale/用户名下拉/Docs 真页
用户中心:
- 统一浅色:theme.tsx 无偏好时不再跟随系统 prefers-color-scheme:dark(登录页变
  黑真因),恒浅色;保留手动切换。
- 登录回跳:UserCenter 加 safeRedirect 白名单,登录成功按 ?redirect= 回来源页
  (官网带 /,登录后回主页),无则进 overview。
- logo locale:新增 i18n brandName(zh 穿山甲/其余 Pangolin),Login/UserCenter
  /Subscription 三处引用。
- 存 pg_uc_email(getMe 时)/ clearSession 删,供官网读用户名。

官网(全走 CSS class 合 CSP,无内联 style):
- logo locale:i18n nav.brand(zh 穿山甲/其余 Pangolin),Header+Footer。
- 登录态头部显示用户名(读同源 pg_uc_email)+ 下拉菜单(进入用户中心/切换用户/
  退出登录,复用 .langmenu 风格);未登录 Log in 带 ?redirect=/ 回跳。
- Docs 四卡片补真内容页(en+zh:quickstart/faq/protocol/privacy + Doc.astro
  布局),卡片改回 <a href>;Protocol 改正 sing-box+REALITY(去 WireGuard)。

验证:同源闸绿 · 两端 build 过 · redline 0 · Header 零内联 style · 无 WireGuard。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 09:52:04 +08:00

109 lines
6.1 KiB
TypeScript

'use client';
// Subscription.tsx — 订阅导入(链接 + 二维码占位 + 一键导入三方客户端)。承袭 UCSub。
import React, { useEffect, useState } from 'react';
import { Icon } from './icons';
import { card } from './shared';
import type { TFn } from '../lib/i18n';
import { getClient } from '../lib/api/client';
export default function Subscription({ t, mobile }: { t: TFn; mobile: boolean }) {
const api = getClient();
const [url, setUrl] = useState('');
const [copied, setCopied] = useState(false);
const [reset, setReset] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
api.getSubscription().then((s) => setUrl(s.url)).catch(() => {});
}, [api]);
const clients = [
{ name: `${t('brandName')} App`, sub: 'iOS / Android / 桌面', icon: 'shield-check', accent: true },
{ name: 'Shadowrocket', sub: 'iOS', icon: 'external-link' },
{ name: 'Clash Verge', sub: 'Windows / macOS', icon: 'external-link' },
{ name: 'v2rayN', sub: 'Windows', icon: 'external-link' },
{ name: 'sing-box', sub: '全平台', icon: 'external-link' },
];
async function copy() {
try {
await navigator.clipboard?.writeText(url);
} catch {
/* ignore */
}
setCopied(true);
setTimeout(() => setCopied(false), 1800);
}
async function doReset() {
if (busy) return;
setBusy(true);
try {
const s = await api.resetSubscription();
setUrl(s.url);
setReset(true);
} finally {
setBusy(false);
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: 720 }}>
<div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 25, fontWeight: 700, color: 'var(--fg1)' }}>{t('subTitle')}</div>
<div style={{ fontSize: 13, color: 'var(--fg3)', marginTop: 5, lineHeight: 1.6 }}>{t('subDesc')}</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : '1fr auto', gap: 14, alignItems: 'start' }}>
{/* link card */}
<div style={{ ...card, padding: '18px 20px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 9, background: 'var(--bg-subtle)', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', padding: '11px 13px', marginBottom: 12 }}>
<Icon name="link" size={15} color="var(--fg3)" />
<span style={{ flex: 1, fontFamily: 'var(--font-mono)', fontSize: 12.5, color: 'var(--fg1)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{url || t('loading')}</span>
</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<button onClick={copy} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, border: 'none', borderRadius: 'var(--radius-full)', padding: '10px 18px', background: 'var(--accent)', color: '#fff', fontWeight: 700, fontSize: 13.5, cursor: 'pointer' }}>
<Icon name={copied ? 'check' : 'copy'} size={15} color="#fff" />
{copied ? t('subCopied') : t('subCopy')}
</button>
<button onClick={doReset} disabled={busy} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, border: '1.5px solid var(--border-strong)', borderRadius: 'var(--radius-full)', padding: '10px 18px', background: 'transparent', color: 'var(--fg2)', fontWeight: 600, fontSize: 13.5, cursor: busy ? 'wait' : 'pointer' }}>
<Icon name="refresh-cw" size={14} color="var(--fg2)" />
{t('subReset')}
</button>
</div>
<div style={{ fontSize: 11.5, color: reset ? 'var(--success)' : 'var(--fg3)', marginTop: 10, display: 'flex', alignItems: 'center', gap: 6 }}>
{reset && <Icon name="check-circle" size={13} color="var(--success)" />}
{reset ? t('subResetOk') : t('subResetSub')}
</div>
</div>
{/* QR */}
<div style={{ ...card, padding: '16px', width: mobile ? '100%' : 170, boxSizing: 'border-box', textAlign: 'center' }}>
<div style={{ width: 138, height: 138, margin: '0 auto', borderRadius: 'var(--radius-md)', background: 'var(--bg-subtle)', border: '1px dashed var(--border-strong)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
<Icon name="qr-code" size={42} color="var(--fg3)" stroke={1.5} />
<span style={{ fontSize: 10.5, color: 'var(--fg3)', fontWeight: 600 }}>{t('scanTitle')}</span>
</div>
<div style={{ fontSize: 10.5, color: 'var(--fg3)', marginTop: 10, lineHeight: 1.5 }}>{t('scanSub')}</div>
</div>
</div>
{/* one-tap import */}
<div style={{ ...card, padding: '18px 20px' }}>
<div style={{ fontSize: 14.5, fontWeight: 700, color: 'var(--fg1)' }}>{t('importTitle')}</div>
<div style={{ fontSize: 12.5, color: 'var(--fg3)', margin: '4px 0 14px' }}>{t('importSub')}</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(190px,1fr))', gap: 10 }}>
{clients.map((c) => (
<button key={c.name} style={{ display: 'flex', alignItems: 'center', gap: 11, textAlign: 'left', cursor: 'pointer', background: c.accent ? 'var(--accent-subtle)' : 'var(--surface)', border: `1px solid ${c.accent ? 'var(--accent-border)' : 'var(--border)'}`, borderRadius: 'var(--radius-lg)', padding: '12px 14px' }}>
<div style={{ width: 34, height: 34, borderRadius: 'var(--radius-md)', background: c.accent ? 'var(--accent)' : 'var(--bg-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Icon name={c.icon} size={17} color={c.accent ? '#fff' : 'var(--accent)'} />
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--fg1)', whiteSpace: 'nowrap' }}>{c.name}</div>
<div style={{ fontSize: 11, color: 'var(--fg3)' }}>{c.sub}</div>
</div>
</button>
))}
</div>
<div style={{ fontSize: 11.5, color: 'var(--fg3)', marginTop: 12 }}>{t('fmtNote')}</div>
</div>
</div>
);
}