fix: remove hardcoded vless uuid placeholder (#582)

This commit is contained in:
shenlan 2025-10-26 20:52:29 +08:00 committed by GitHub
parent cd9b1bf9b8
commit f5b03bdc77
5 changed files with 536 additions and 1 deletions

View File

@ -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: {

View File

@ -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>

View File

@ -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>
)
}

View 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
View 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。