fix: remove hardcoded vless uuid placeholder (#582)
This commit is contained in:
parent
cd9b1bf9b8
commit
f5b03bdc77
@ -304,6 +304,22 @@ type UserCenterOverviewTranslation = {
|
||||
description: string
|
||||
action: string
|
||||
}
|
||||
vless: {
|
||||
label: string
|
||||
description: string
|
||||
linkLabel: string
|
||||
copyLink: string
|
||||
copied: string
|
||||
downloadQr: string
|
||||
downloadConfig: string
|
||||
generating: string
|
||||
error: string
|
||||
missingUuid: string
|
||||
warning: string
|
||||
macPath: string
|
||||
linuxPath: string
|
||||
qrAlt: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -772,6 +788,22 @@ export const translations: Record<'en' | 'zh', Translation> = {
|
||||
description: 'Secure the console by pairing an authenticator app.',
|
||||
action: 'Manage MFA',
|
||||
},
|
||||
vless: {
|
||||
label: 'VLESS access',
|
||||
description: 'Scan to connect instantly with the Tokyo Vision node over TLS.',
|
||||
linkLabel: 'VLESS URI',
|
||||
copyLink: 'Copy link',
|
||||
copied: 'Link copied',
|
||||
downloadQr: 'Download QR',
|
||||
downloadConfig: 'Download config',
|
||||
generating: 'Generating QR code…',
|
||||
error: 'We could not generate the QR code. Try again later.',
|
||||
missingUuid: 'We could not locate your UUID. Refresh the page or sign in again.',
|
||||
warning: 'Your UUID is the only credential required to access this node. Keep it private and do not share it.',
|
||||
macPath: 'macOS: /opt/homebrew/etc/config.json',
|
||||
linuxPath: 'Linux: /usr/local/etc/config.json',
|
||||
qrAlt: 'VLESS connection QR code',
|
||||
},
|
||||
},
|
||||
},
|
||||
mfa: {
|
||||
@ -1344,6 +1376,22 @@ export const translations: Record<'en' | 'zh', Translation> = {
|
||||
description: '绑定认证器即可保护控制台访问。',
|
||||
action: '前往设置',
|
||||
},
|
||||
vless: {
|
||||
label: 'VLESS 二维码',
|
||||
description: '扫码即可连接东京 Vision 节点(TLS)。',
|
||||
linkLabel: 'VLESS 链接',
|
||||
copyLink: '复制链接',
|
||||
copied: '链接已复制',
|
||||
downloadQr: '下载二维码',
|
||||
downloadConfig: '下载配置',
|
||||
generating: '二维码生成中…',
|
||||
error: '二维码生成失败,请稍后重试。',
|
||||
missingUuid: '无法获取您的 UUID,请刷新页面或重新登录。',
|
||||
warning: 'UUID 是访问节点的唯一凭证,请谨慎保存,勿随意分发。',
|
||||
macPath: 'macOS:/opt/homebrew/etc/config.json',
|
||||
linuxPath: 'Linux:/usr/local/etc/config.json',
|
||||
qrAlt: 'VLESS 连接二维码',
|
||||
},
|
||||
},
|
||||
},
|
||||
mfa: {
|
||||
|
||||
@ -5,11 +5,13 @@ import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Copy } from 'lucide-react'
|
||||
|
||||
import Card from './Card'
|
||||
import { useLanguage } from '@i18n/LanguageProvider'
|
||||
import { translations } from '@i18n/translations'
|
||||
import { useUser } from '@lib/userStore'
|
||||
|
||||
import Card from './Card'
|
||||
import VlessQrCard from './VlessQrCard'
|
||||
|
||||
function resolveDisplayName(
|
||||
user: {
|
||||
name?: string
|
||||
@ -42,6 +44,7 @@ export default function UserOverview() {
|
||||
|
||||
const displayName = useMemo(() => resolveDisplayName(user), [user])
|
||||
const uuid = user?.uuid ?? user?.id ?? '—'
|
||||
const vlessUuid = user?.uuid ?? user?.id ?? null
|
||||
const username = user?.username ?? '—'
|
||||
const email = user?.email ?? '—'
|
||||
const docsUrl = mfaCopy.actions.docsUrl
|
||||
@ -161,6 +164,8 @@ export default function UserOverview() {
|
||||
<p className="mt-3 text-xs text-[var(--color-text-subtle)]">{copy.cards.uuid.description}</p>
|
||||
</Card>
|
||||
|
||||
<VlessQrCard uuid={vlessUuid} copy={copy.cards.vless} />
|
||||
|
||||
<Card>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--color-primary)]">{copy.cards.username.label}</p>
|
||||
<p className="mt-1 text-base font-medium text-[var(--color-text)]">{username}</p>
|
||||
|
||||
@ -0,0 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { Copy, Download, QrCode } from 'lucide-react'
|
||||
import { toDataURL } from 'qrcode'
|
||||
|
||||
import Card from './Card'
|
||||
import {
|
||||
buildVlessConfig,
|
||||
buildVlessUri,
|
||||
DEFAULT_VLESS_LABEL,
|
||||
serializeConfigForDownload,
|
||||
} from '../lib/vless'
|
||||
|
||||
export type VlessQrCopy = {
|
||||
label: string
|
||||
description: string
|
||||
linkLabel: string
|
||||
copyLink: string
|
||||
copied: string
|
||||
downloadQr: string
|
||||
downloadConfig: string
|
||||
generating: string
|
||||
error: string
|
||||
missingUuid: string
|
||||
warning: string
|
||||
macPath: string
|
||||
linuxPath: string
|
||||
qrAlt: string
|
||||
}
|
||||
|
||||
interface VlessQrCardProps {
|
||||
uuid: string | null | undefined
|
||||
copy: VlessQrCopy
|
||||
}
|
||||
|
||||
export default function VlessQrCard({ uuid, copy }: VlessQrCardProps) {
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [generationError, setGenerationError] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const vlessUri = useMemo(() => buildVlessUri(uuid), [uuid])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
if (!vlessUri) {
|
||||
setQrDataUrl(null)
|
||||
setGenerationError(null)
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
setIsGenerating(true)
|
||||
setGenerationError(null)
|
||||
toDataURL(vlessUri, {
|
||||
errorCorrectionLevel: 'M',
|
||||
margin: 1,
|
||||
scale: 8,
|
||||
})
|
||||
.then((url) => {
|
||||
if (!cancelled) {
|
||||
setQrDataUrl(url)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
console.warn('Failed to generate VLESS QR code', error)
|
||||
setGenerationError(copy.error)
|
||||
setQrDataUrl(null)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [copy.error, vlessUri])
|
||||
|
||||
const handleCopyLink = useCallback(async () => {
|
||||
if (!vlessUri) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard && 'writeText' in navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(vlessUri)
|
||||
} else {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = vlessUri
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.focus()
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (error) {
|
||||
console.warn('Failed to copy VLESS link', error)
|
||||
}
|
||||
}, [vlessUri])
|
||||
|
||||
const handleDownloadQr = useCallback(() => {
|
||||
if (!qrDataUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = qrDataUrl
|
||||
link.download = 'vless-qr.png'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}, [qrDataUrl])
|
||||
|
||||
const handleDownloadConfig = useCallback(() => {
|
||||
const config = buildVlessConfig(uuid)
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
const contents = serializeConfigForDownload(config)
|
||||
const blob = new Blob([contents], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'xray-client-config.json'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
|
||||
URL.revokeObjectURL(url)
|
||||
}, [uuid])
|
||||
|
||||
const isReady = Boolean(vlessUri && qrDataUrl && !generationError)
|
||||
const isDisabled = !vlessUri
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--color-primary)]">{copy.label}</p>
|
||||
<span className="rounded-full bg-[var(--color-primary-muted)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-[var(--color-primary)]">
|
||||
{DEFAULT_VLESS_LABEL}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-[var(--color-text-subtle)]">{copy.description}</p>
|
||||
</div>
|
||||
|
||||
{vlessUri ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 lg:flex-row">
|
||||
<div className="flex h-40 w-40 items-center justify-center overflow-hidden rounded-lg border border-[color:var(--color-surface-border)] bg-[var(--color-surface)]">
|
||||
{isGenerating ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 text-center text-xs text-[var(--color-text-subtle)]">
|
||||
<QrCode className="h-6 w-6 opacity-60" />
|
||||
<span>{copy.generating}</span>
|
||||
</div>
|
||||
) : generationError ? (
|
||||
<div className="px-4 text-center text-xs text-[var(--color-text-subtle)]">{generationError}</div>
|
||||
) : qrDataUrl ? (
|
||||
<Image
|
||||
src={qrDataUrl}
|
||||
alt={copy.qrAlt}
|
||||
width={160}
|
||||
height={160}
|
||||
unoptimized
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-2 text-xs text-[var(--color-text-subtle)]">
|
||||
<div className="rounded-[var(--radius-lg)] border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] p-3 text-[11px] text-[var(--color-text)]">
|
||||
<p className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-[var(--color-text-subtle)]">{copy.linkLabel}</p>
|
||||
<p className="break-all font-mono text-xs">{vlessUri}</p>
|
||||
</div>
|
||||
<p className="flex items-start gap-2 text-[var(--color-warning-foreground)]">
|
||||
<span aria-hidden className="mt-[2px] inline-flex h-2 w-2 rounded-full bg-[var(--color-warning-foreground)]" />
|
||||
<span>{copy.warning}</span>
|
||||
</p>
|
||||
<p>{copy.macPath}</p>
|
||||
<p>{copy.linuxPath}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyLink}
|
||||
disabled={isDisabled}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-[color:var(--color-primary-border)] px-3 py-2 text-xs font-medium text-[var(--color-primary)] transition-colors hover:border-[color:var(--color-primary)] hover:bg-[var(--color-primary-muted)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{copied ? copy.copied : copy.copyLink}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownloadQr}
|
||||
disabled={!isReady}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-[color:var(--color-surface-border)] px-3 py-2 text-xs font-medium text-[var(--color-text)] transition-colors hover:border-[color:var(--color-primary-border)] hover:text-[var(--color-primary)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<QrCode className="h-3.5 w-3.5" />
|
||||
{copy.downloadQr}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownloadConfig}
|
||||
disabled={isDisabled}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-[color:var(--color-surface-border)] px-3 py-2 text-xs font-medium text-[var(--color-text)] transition-colors hover:border-[color:var(--color-primary-border)] hover:text-[var(--color-primary)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{copy.downloadConfig}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--color-text-subtle)]">{copy.missingUuid}</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
161
dashboard/src/extensions/builtin/user-center/lib/vless.ts
Normal file
161
dashboard/src/extensions/builtin/user-center/lib/vless.ts
Normal file
@ -0,0 +1,161 @@
|
||||
export type VlessEndpoint = {
|
||||
host: string
|
||||
port: number
|
||||
type: string
|
||||
security: string
|
||||
flow: string
|
||||
encryption: string
|
||||
serverName: string
|
||||
fingerprint: string
|
||||
allowInsecure: boolean
|
||||
label: string
|
||||
}
|
||||
|
||||
export type VlessTemplate = {
|
||||
endpoint: VlessEndpoint
|
||||
}
|
||||
|
||||
const DEFAULT_VLESS_TEMPLATE: VlessTemplate = {
|
||||
endpoint: {
|
||||
host: 'tky-connector.onwalk.net',
|
||||
port: 1443,
|
||||
type: 'tcp',
|
||||
security: 'tls',
|
||||
flow: 'xtls-rprx-vision',
|
||||
encryption: 'none',
|
||||
serverName: 'tky-connector.onwalk.net',
|
||||
fingerprint: 'chrome',
|
||||
allowInsecure: false,
|
||||
label: 'Tokyo-Node',
|
||||
},
|
||||
}
|
||||
|
||||
const DEFAULT_XRAY_CONFIG = {
|
||||
log: {
|
||||
loglevel: 'info',
|
||||
},
|
||||
routing: {
|
||||
domainStrategy: 'IPIfNonMatch',
|
||||
rules: [
|
||||
{
|
||||
type: 'field',
|
||||
ip: ['geoip:private', 'geoip:cn'],
|
||||
outboundTag: 'direct',
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
domain: ['geosite:cn'],
|
||||
outboundTag: 'direct',
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
network: 'tcp,udp',
|
||||
outboundTag: 'proxy',
|
||||
},
|
||||
],
|
||||
},
|
||||
inbounds: [
|
||||
{
|
||||
listen: '127.0.0.1',
|
||||
port: 1080,
|
||||
protocol: 'socks',
|
||||
settings: {
|
||||
udp: true,
|
||||
},
|
||||
sniffing: {
|
||||
enabled: true,
|
||||
destOverride: ['http', 'tls'],
|
||||
},
|
||||
},
|
||||
{
|
||||
listen: '127.0.0.1',
|
||||
port: 1081,
|
||||
protocol: 'http',
|
||||
sniffing: {
|
||||
enabled: true,
|
||||
destOverride: ['http', 'tls'],
|
||||
},
|
||||
},
|
||||
],
|
||||
outbounds: [
|
||||
{
|
||||
protocol: 'vless',
|
||||
settings: {
|
||||
vnext: [
|
||||
{
|
||||
address: 'tky-connector.onwalk.net',
|
||||
port: 1443,
|
||||
users: [
|
||||
{
|
||||
encryption: 'none',
|
||||
flow: 'xtls-rprx-vision',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
streamSettings: {
|
||||
network: 'tcp',
|
||||
security: 'tls',
|
||||
tlsSettings: {
|
||||
serverName: 'tky-connector.onwalk.net',
|
||||
allowInsecure: false,
|
||||
fingerprint: 'chrome',
|
||||
},
|
||||
},
|
||||
tag: 'proxy',
|
||||
},
|
||||
{
|
||||
protocol: 'freedom',
|
||||
tag: 'direct',
|
||||
},
|
||||
{
|
||||
protocol: 'blackhole',
|
||||
tag: 'block',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export type XrayConfig = typeof DEFAULT_XRAY_CONFIG
|
||||
|
||||
export function buildVlessUri(rawUuid: string | null | undefined): string | null {
|
||||
const uuid = (rawUuid ?? '').trim()
|
||||
if (!uuid) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { endpoint } = DEFAULT_VLESS_TEMPLATE
|
||||
|
||||
const params = new URLSearchParams({
|
||||
type: endpoint.type,
|
||||
security: endpoint.security,
|
||||
flow: endpoint.flow,
|
||||
encryption: endpoint.encryption,
|
||||
sni: endpoint.serverName,
|
||||
fp: endpoint.fingerprint,
|
||||
allowInsecure: endpoint.allowInsecure ? '1' : '0',
|
||||
})
|
||||
|
||||
return `vless://${uuid}@${endpoint.host}:${endpoint.port}?${params.toString()}#${encodeURIComponent(endpoint.label)}`
|
||||
}
|
||||
|
||||
export function buildVlessConfig(rawUuid: string | null | undefined): XrayConfig | null {
|
||||
const uuid = (rawUuid ?? '').trim()
|
||||
if (!uuid) {
|
||||
return null
|
||||
}
|
||||
|
||||
const config = JSON.parse(JSON.stringify(DEFAULT_XRAY_CONFIG)) as XrayConfig
|
||||
const user = config.outbounds?.[0]?.settings?.vnext?.[0]?.users?.[0]
|
||||
if (user) {
|
||||
user.id = uuid
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
export function serializeConfigForDownload(config: XrayConfig): string {
|
||||
return `${JSON.stringify(config, null, 2)}\n`
|
||||
}
|
||||
|
||||
export const DEFAULT_VLESS_LABEL = DEFAULT_VLESS_TEMPLATE.endpoint.label
|
||||
86
docs/vless-qr-plan.md
Normal file
86
docs/vless-qr-plan.md
Normal file
@ -0,0 +1,86 @@
|
||||
# VLESS 二维码功能规划
|
||||
|
||||
## 背景
|
||||
- 用户中心目前仅展示 UUID、用户名、邮箱等信息,无法直接提供 VLESS 客户端配置。
|
||||
- 已知默认节点配置位于 `/opt/homebrew/etc/xray-vpn-node-jp.json`,多数字段在不同用户之间保持固定,只有 UUID 需要按账号动态替换。
|
||||
- 需求是为每位用户生成基于默认配置的 VLESS 链接,并在用户中心渲染对应的二维码(完全在前端本地生成,不依赖外部服务)。
|
||||
- 同步提供内置客户端配置模板(xray JSON),用户可下载后直接使用,仅需确认 UUID 已正确写入。
|
||||
|
||||
## 功能目标
|
||||
1. 在用户中心首页新增 “VLESS 二维码” 卡片:
|
||||
- 展示二维码图像,并提供保存二维码图像的入口。
|
||||
- 提供一键复制 VLESS 链接按钮。
|
||||
- 提示当前节点基础信息(地区、协议等)。
|
||||
2. 所有二维码与链接均基于默认配置生成,且仅 UUID 可根据用户信息动态变化。
|
||||
3. 新增“导出配置”操作,下载包含用户 UUID 的 `xray-client-config.json` 文件,并提示其敏感性。
|
||||
4. 保持与既有界面风格一致,并适配暗色 / 亮色主题。
|
||||
|
||||
## 默认配置映射
|
||||
根据示例配置生成的 VLESS URI 结构如下:
|
||||
```
|
||||
vless://{UUID}@tky-connector.onwalk.net:1443
|
||||
?type=tcp
|
||||
&security=tls
|
||||
&flow=xtls-rprx-vision
|
||||
&encryption=none
|
||||
&sni=tky-connector.onwalk.net
|
||||
&fp=chrome
|
||||
&allowInsecure=0
|
||||
#Tokyo-Node
|
||||
```
|
||||
说明:
|
||||
- `UUID` 取自用户模型中的 `user.uuid`(回退到 `user.id`)。
|
||||
- `allowInsecure=false` 将映射为查询参数 `allowInsecure=0` 方便客户端理解。
|
||||
- 末尾片段(片名)暂定为 `Tokyo-Node`,后续可通过 i18n 字段调整。
|
||||
- 上述 URI 需进行 URL 编码后才能生成二维码文本。
|
||||
- 用于导出的 JSON 模板完全基于 `/opt/homebrew/etc/xray-vpn-node-jp.json`,仅将 `users[0].id` 替换为用户 UUID。
|
||||
|
||||
## 数据来源与状态
|
||||
- `user` 实例来自 `useUser()` hook,已在用户中心页面使用。
|
||||
- 为确保扩展兼容性,若缺失 UUID 则禁用二维码、导出及复制操作并展示占位文案。
|
||||
- 将默认配置常量保存在前端 `lib` 下(例如 `DEFAULT_VLESS_TEMPLATE`),确保仅在生成链接或导出文件时消费。
|
||||
|
||||
## 技术实现
|
||||
1. **工具函数**
|
||||
- 新增 `buildVlessUri(uuid: string): string` 方法(路径建议:`dashboard/src/extensions/builtin/user-center/lib/vless.ts`)。
|
||||
- 负责拼接 URI、执行编码、返回原始字符串供复制或 QR 使用。
|
||||
- 提供 `buildVlessConfig(uuid: string): XrayConfig`,返回替换 `users[0].id` 后的 JSON 对象。
|
||||
- 提供 `serializeConfigForDownload(config: XrayConfig): string`,用于格式化 JSON 字符串。
|
||||
2. **二维码生成**
|
||||
- 复用已有 `qrcode` npm 包(已用于 MFA),通过 `toDataURL` 异步生成 base64 图像。
|
||||
- 在 `useEffect` / `useMemo` 中监听 `uuid` 变化并更新二维码数据。
|
||||
- 支持“下载二维码”按钮,通过创建隐藏 `<a>` 元素并将 dataURL 作为下载链接。
|
||||
- 生成失败时记录 `console.warn` 并展示错误态。
|
||||
3. **React 组件布局**
|
||||
- 在 `dashboard/src/extensions/builtin/user-center/components/` 下新增 `VlessQrCard.tsx`,封装展示逻辑:
|
||||
- 接收 `uuid`、`copyText`、`labels`、`configTemplate` 等属性。
|
||||
- 内部管理二维码状态、复制按钮、下载二维码、导出 JSON、加载 & 错误提示。
|
||||
- 导出 JSON 时调用 `buildVlessConfig` 并生成 `Blob`,文件名采用 `xray-client-config.json`。
|
||||
- 在 `UserOverview.tsx` 中引入该组件,放入栅格布局中(建议与 UUID 卡片同行)。
|
||||
- 确保组件在缺省 UUID 时显示提示并禁用交互。
|
||||
4. **样式与设计**
|
||||
- 继续复用 `Card` 组件,保持圆角、边距一致。
|
||||
- 二维码尺寸建议 `160px`,自适应容器。
|
||||
- 在复制与导出按钮上沿用现有 `Copy`、`Download` 图标,并使用 `Button.Group` 或 `Space` 排列。
|
||||
5. **国际化**
|
||||
- 在 `dashboard/i18n/translations.ts` 中新增文案:标题、描述、按钮(复制链接、下载二维码、下载配置)、状态提示、敏感信息警告等中英文内容。
|
||||
- 警示文案需提醒用户“UUID 是唯一凭证,请妥善保管,勿随意分发”。
|
||||
- 在卡片内展示不同系统的放置路径提示:
|
||||
- `MacOS: /opt/homebrew/etc/config.json`
|
||||
- `Linux: /usr/local/etc/config.json`
|
||||
6. **可测试性**
|
||||
- 单元测试范围有限,重点通过手动验证:
|
||||
- 登录后查看二维码是否正常生成。
|
||||
- 复制按钮是否获取正确的 VLESS 链接。
|
||||
- 二维码下载与配置导出文件内容是否匹配默认模板。
|
||||
- UUID 缺失时的回退 UI 及按钮禁用。
|
||||
|
||||
## 风险与缓解
|
||||
- **浏览器兼容**:`navigator.clipboard` 可能不可用,沿用现有回退写法;文件下载依赖 `URL.createObjectURL`,需在清理阶段释放对象 URL。
|
||||
- **安全性**:VLESS 链接与配置包含敏感信息,仅在用户登录后展示,不做额外缓存;下载前弹出确认或显示警示提醒用户妥善保管。
|
||||
- **扩展性**:若未来存在多节点,可将默认配置抽离为数组并提供选择器,同时更新生成逻辑以支持多模板。
|
||||
|
||||
## 里程碑
|
||||
1. 完成工具函数与数据模板实现,包括 URI 及 JSON 生成。
|
||||
2. 集成二维码卡片、下载操作,并通过 i18n 文案。
|
||||
3. 手动验证二维码显示、复制、下载、导出流程后准备 PR。
|
||||
Loading…
Reference in New Issue
Block a user