mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
fix: dns cname
- Enhanced AGENTS.md to provide a more detailed description of the dashboard's design aesthetic, emphasizing a linear, data-first approach. - Updated UI design guidelines in the dashboard to reflect a clearer description of the design principles, focusing on simplicity and clarity. - Improved domain verification instructions in the CustomDomainCard and DomainListItem components, ensuring better user guidance for DNS setup. - Added new localization keys for improved clarity in domain verification steps and UI elements. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -76,7 +76,7 @@ The project is divided into four main applications:
|
||||
* **SSR for Shared Pages**: Server-renders specific pages to provide fast initial load times.
|
||||
|
||||
- **`be/apps/core`**: The complete backend server (Hono) for real-time data. For a detailed breakdown of its architecture, see `be/apps/core/AGENTS.md`.
|
||||
- **`be/apps/dashboard`**: The administration panel for the backend. See `be/apps/dashboard/AGENTS.md` for UI guidelines.
|
||||
- **`be/apps/dashboard`**: The administration panel for the backend, using a linear, data-first admin aesthetic (crisp frames, subtle gradients). See `be/apps/dashboard/AGENTS.md` for full UI guidelines.
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
@@ -85,7 +85,7 @@ This is a pnpm workspace with multiple applications and packages:
|
||||
- `apps/web/` - Main frontend React application (Vite + React 19 SPA).
|
||||
- `apps/ssr/` - Next.js 15 application serving as an SPA host and dynamic SEO/OG generator.
|
||||
- `be/apps/core/` - The complete backend server (Hono) for real-time data.
|
||||
- `be/apps/dashboard/` - The administration panel for the backend.
|
||||
- `be/apps/dashboard/` - The administration panel for the backend (linear, data-first admin look).
|
||||
- `packages/builder/` - Photo processing and manifest generation tool.
|
||||
- `packages/webgl-viewer/` - High-performance WebGL-based photo viewer component.
|
||||
- `packages/data/` - Shared data access layer and PhotoLoader singleton.
|
||||
@@ -216,8 +216,8 @@ class PhotoLoader {
|
||||
This project contains multiple web applications with distinct design systems. For specific UI and design guidelines, please refer to the `AGENTS.md` file within each application's directory:
|
||||
|
||||
- **`apps/web`**: Contains the "Glassmorphic Depth Design System" for the main user-facing photo gallery. See `apps/web/AGENTS.md` for details.
|
||||
- **`be/apps/dashboard`**: Contains guidelines for the functional, data-driven UI of the administration panel. See `be/apps/dashboard/AGENTS.md` for details.
|
||||
- **`be/apps/dashboard`**: Contains guidelines for the functional, data-driven UI of the administration panel (linear, data-first aesthetic). See `be/apps/dashboard/AGENTS.md` for details.
|
||||
|
||||
## IMPORTANT
|
||||
|
||||
Avoid feature gates/flags and any backwards compability changes - since our app is still unreleased" is really helpful.
|
||||
Avoid feature gates/flags and any backwards compability changes - since our app is still unreleased" is really helpful.
|
||||
|
||||
@@ -15,6 +15,7 @@ import { TenantDomainRepository } from './tenant-domain.repository'
|
||||
@injectable()
|
||||
export class TenantDomainService {
|
||||
private readonly log = logger.extend('TenantDomainService')
|
||||
private readonly verificationTxtLabel = '_afilmory-verification'
|
||||
|
||||
constructor(
|
||||
private readonly repository: TenantDomainRepository,
|
||||
@@ -143,43 +144,125 @@ export class TenantDomainService {
|
||||
|
||||
private async performDnsVerification(domain: TenantDomainRecord): Promise<{ ok: boolean; reason?: string }> {
|
||||
const baseDomain = await this.getBaseDomain()
|
||||
|
||||
const [cnameTargets, txtRecords] = await Promise.all([
|
||||
this.resolveCname(domain.domain),
|
||||
this.resolveTxt(domain.domain),
|
||||
])
|
||||
|
||||
const normalizedBase = baseDomain.toLowerCase()
|
||||
const cnameMatches = cnameTargets.some((target) => this.matchesBaseDomain(target, normalizedBase))
|
||||
const txtMatches =
|
||||
domain.verificationToken?.length > 0 && txtRecords.some((entries) => entries.includes(domain.verificationToken))
|
||||
const token = domain.verificationToken ?? ''
|
||||
|
||||
if (cnameMatches || txtMatches) {
|
||||
const txtHosts = [domain.domain, `${this.verificationTxtLabel}.${domain.domain}`]
|
||||
this.log.verbose('Starting DNS verification', {
|
||||
domainId: domain.id,
|
||||
domain: domain.domain,
|
||||
tokenPresent: token.length > 0,
|
||||
txtHosts,
|
||||
})
|
||||
const txtRecordsPerHost = await Promise.all(txtHosts.map((host) => this.resolveTxt(host)))
|
||||
const txtMatches = token.length > 0 && this.txtContainsToken(txtRecordsPerHost.flat(), token)
|
||||
|
||||
if (txtMatches) {
|
||||
this.log.info('DNS verification via TXT succeeded', {
|
||||
domainId: domain.id,
|
||||
domain: domain.domain,
|
||||
txtHosts,
|
||||
txtRecords: txtRecordsPerHost,
|
||||
})
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const cnameChain = await this.resolveCnameChain(domain.domain)
|
||||
const cnameTerminal = cnameChain.at(-1)
|
||||
const pointsToBase = cnameTerminal ? this.matchesBaseDomain(cnameTerminal, normalizedBase) : false
|
||||
const reasonDetails = pointsToBase
|
||||
? '已检测到 CNAME 指向基础域名,但缺少 TXT 验证记录'
|
||||
: cnameChain.length > 0
|
||||
? `当前 CNAME 链终点为 ${cnameTerminal ?? cnameChain.at(-1)}`
|
||||
: '未检测到 CNAME 记录'
|
||||
|
||||
this.log.warn('DNS verification failed', {
|
||||
domainId: domain.id,
|
||||
domain: domain.domain,
|
||||
txtHosts,
|
||||
txtMatches,
|
||||
txtRecords: txtRecordsPerHost,
|
||||
cnameChain,
|
||||
pointsToBase,
|
||||
})
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
reason: `未检测到指向 ${normalizedBase} 的 CNAME 或包含验证 token 的 TXT 记录`,
|
||||
reason: `未找到包含验证 token 的 TXT 记录;${reasonDetails}`,
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveCname(domain: string): Promise<string[]> {
|
||||
try {
|
||||
return await dns.resolveCname(domain)
|
||||
} catch (error) {
|
||||
this.log.debug(`resolveCname failed for ${domain}`, error)
|
||||
return []
|
||||
private async resolveCnameChain(domain: string): Promise<string[]> {
|
||||
const resolvers = await this.getResolvers(domain)
|
||||
for (const resolver of resolvers) {
|
||||
try {
|
||||
const chain: string[] = []
|
||||
const visited = new Set<string>()
|
||||
let current = domain
|
||||
|
||||
while (!visited.has(current) && chain.length < 10) {
|
||||
visited.add(current)
|
||||
const records = await resolver.resolveCname(current)
|
||||
if (!records?.length) break
|
||||
const target = records[0].replace(/\.$/, '').toLowerCase()
|
||||
chain.push(target)
|
||||
current = target
|
||||
}
|
||||
|
||||
return chain
|
||||
} catch (error) {
|
||||
this.log.debug(`resolveCname failed for ${domain} via resolver`, error)
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private async resolveTxt(domain: string): Promise<string[][]> {
|
||||
try {
|
||||
return await dns.resolveTxt(domain)
|
||||
} catch (error) {
|
||||
this.log.debug(`resolveTxt failed for ${domain}`, error)
|
||||
return []
|
||||
const resolvers = await this.getResolvers(domain)
|
||||
for (const resolver of resolvers) {
|
||||
try {
|
||||
return await resolver.resolveTxt(domain)
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException)?.code
|
||||
if (code === 'ENOTFOUND' || code === 'ENODATA' || code === 'NXDOMAIN') {
|
||||
this.log.debug(`resolveTxt no data for ${domain} via resolver`, error)
|
||||
return []
|
||||
}
|
||||
this.log.debug(`resolveTxt failed for ${domain} via resolver`, error)
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private async getResolvers(domain: string): Promise<Array<typeof dns | dns.Resolver>> {
|
||||
const resolvers: Array<typeof dns | dns.Resolver> = []
|
||||
|
||||
const authoritative = await this.createAuthoritativeResolver(domain)
|
||||
if (authoritative) {
|
||||
resolvers.push(authoritative)
|
||||
}
|
||||
|
||||
resolvers.push(dns)
|
||||
return resolvers
|
||||
}
|
||||
|
||||
private async createAuthoritativeResolver(domain: string): Promise<dns.Resolver | null> {
|
||||
try {
|
||||
const nameServers = await dns.resolveNs(domain)
|
||||
if (nameServers.length === 0) {
|
||||
return null
|
||||
}
|
||||
const resolver = new dns.Resolver()
|
||||
resolver.setServers(nameServers)
|
||||
return resolver
|
||||
} catch (error) {
|
||||
this.log.debug(`resolveNs failed for ${domain}`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private txtContainsToken(records: string[][], token: string): boolean {
|
||||
return records.some((entries) => entries.some((txt) => txt.includes(token)))
|
||||
}
|
||||
|
||||
private matchesBaseDomain(target: string, baseDomain: string): boolean {
|
||||
|
||||
@@ -194,7 +194,7 @@ export const Component = () => {
|
||||
|
||||
UI Design Guidelines:
|
||||
|
||||
This dashboard follows a **linear design language** with clean lines and subtle gradients. The design emphasizes simplicity and clarity without rounded corners or heavy visual effects.
|
||||
This dashboard uses a **linear, data-first admin aesthetic**: crisp container edges, subtle gradient dividers, neutral backgrounds, and minimal ornamentation. Keep page frames sharp while allowing gentle rounding on interactive elements; avoid glassmorphism, blobs, and heavy shadows.
|
||||
|
||||
Core Design Principles:
|
||||
|
||||
|
||||
@@ -27,15 +27,15 @@ function normalizeHostname(): string {
|
||||
|
||||
function buildVerificationInstructions(normalizedBase = 'your-domain.com') {
|
||||
return [
|
||||
{
|
||||
titleKey: 'settings.domain.steps.txt.title',
|
||||
descriptionKey: 'settings.domain.steps.txt.desc',
|
||||
},
|
||||
{
|
||||
titleKey: 'settings.domain.steps.cname.title',
|
||||
descriptionKey: 'settings.domain.steps.cname.desc',
|
||||
meta: normalizedBase,
|
||||
},
|
||||
{
|
||||
titleKey: 'settings.domain.steps.txt.title',
|
||||
descriptionKey: 'settings.domain.steps.txt.desc',
|
||||
},
|
||||
{
|
||||
titleKey: 'settings.domain.steps.verify.title',
|
||||
descriptionKey: 'settings.domain.steps.verify.desc',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { LinearBorderPanel } from '~/components/common/LinearBorderPanel'
|
||||
import { resolveBaseDomain } from '~/modules/auth/utils/domain'
|
||||
|
||||
import type { TenantDomain } from '../types'
|
||||
import { DomainBadge } from './DomainBadge'
|
||||
@@ -17,6 +18,9 @@ interface DomainListItemProps {
|
||||
|
||||
export function DomainListItem({ domain, onVerify, onDelete, isVerifying, isDeleting }: DomainListItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const baseDomain = resolveBaseDomain(typeof window !== 'undefined' ? window.location.host : '')
|
||||
const txtName = `_afilmory-verification.${domain.domain}`
|
||||
const verificationToken = domain.verificationToken ?? '—'
|
||||
|
||||
return (
|
||||
<LinearBorderPanel className="bg-background p-4 transition-all duration-200 hover:bg-fill/30">
|
||||
@@ -49,16 +53,37 @@ export function DomainListItem({ domain, onVerify, onDelete, isVerifying, isDele
|
||||
</div>
|
||||
{domain.status === 'pending' ? (
|
||||
<LinearBorderPanel className="bg-fill/50 p-3">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-text-secondary">
|
||||
{t('settings.domain.token.label')}
|
||||
{t('settings.domain.dns.txt.title')}
|
||||
</p>
|
||||
<code className="block w-full rounded-lg bg-background border border-fill-tertiary px-3 py-2 text-xs font-mono text-text break-all">
|
||||
{domain.verificationToken}
|
||||
</code>
|
||||
<div className="space-y-2 rounded-lg border border-fill bg-background p-3">
|
||||
<KeyValueRow label={t('settings.domain.dns.type')} value="TXT" />
|
||||
<KeyValueRow label={t('settings.domain.dns.name')} value={txtName} copyable monospace />
|
||||
<KeyValueRow
|
||||
label={t('settings.domain.dns.value')}
|
||||
value={verificationToken}
|
||||
monospace
|
||||
copyable
|
||||
copyLabel={t('settings.domain.actions.copy')}
|
||||
/>
|
||||
<KeyValueRow label={t('settings.domain.dns.ttl')} value={t('settings.domain.dns.hint.ttl')} />
|
||||
</div>
|
||||
<FormHelperText className="text-xs text-text-tertiary">
|
||||
{t('settings.domain.token.helper')}
|
||||
</FormHelperText>
|
||||
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-text-secondary">
|
||||
{t('settings.domain.dns.cname.title')}
|
||||
</p>
|
||||
<div className="space-y-2 rounded-lg border border-fill bg-background p-3">
|
||||
<KeyValueRow label={t('settings.domain.dns.type')} value="CNAME" />
|
||||
<KeyValueRow label={t('settings.domain.dns.name')} value={domain.domain} copyable monospace />
|
||||
<KeyValueRow label={t('settings.domain.dns.value')} value={baseDomain} copyable monospace />
|
||||
<FormHelperText className="text-xs text-text-tertiary">
|
||||
{t('settings.domain.dns.cname.helper')}
|
||||
</FormHelperText>
|
||||
</div>
|
||||
</div>
|
||||
</LinearBorderPanel>
|
||||
) : null}
|
||||
@@ -66,3 +91,44 @@ export function DomainListItem({ domain, onVerify, onDelete, isVerifying, isDele
|
||||
</LinearBorderPanel>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyValueRow({
|
||||
label,
|
||||
value,
|
||||
monospace,
|
||||
copyable,
|
||||
copyLabel,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
monospace?: boolean
|
||||
copyable?: boolean
|
||||
copyLabel?: string
|
||||
}) {
|
||||
const common = 'text-sm text-text'
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-28 shrink-0 text-xs uppercase tracking-wide text-text-tertiary">{label}</span>
|
||||
<div className="flex-1 truncate">
|
||||
<span className={monospace ? `${common} font-mono break-all` : common}>{value}</span>
|
||||
</div>
|
||||
{copyable ? <CopyButton value={value} label={copyLabel} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CopyButton({ value, label = 'Copy' }: { value: string; label?: string }) {
|
||||
return (
|
||||
<Button
|
||||
variant="text"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||
navigator.clipboard.writeText(value)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -505,11 +505,20 @@
|
||||
"settings.account.title": "Account & Login",
|
||||
"settings.data.description": "Run database maintenance tasks to keep photo data consistent with object storage.",
|
||||
"settings.data.title": "Data Management",
|
||||
"settings.domain.actions.copy": "Copy",
|
||||
"settings.domain.actions.verify": "Verify",
|
||||
"settings.domain.banner.pending": "Pending verification for {{domain}}. DNS changes may take a few minutes to propagate.",
|
||||
"settings.domain.bound-list.empty": "No custom domains yet. Add one on the left to start verification.",
|
||||
"settings.domain.bound-list.title": "Bound domains",
|
||||
"settings.domain.description": "Bind your own domain to serve the gallery under a branded URL. We support CNAME or TXT verification.",
|
||||
"settings.domain.description": "Bind your own domain to serve the gallery under a branded URL. Verification requires the TXT token; after it passes, point a CNAME to {{base}} to route traffic.",
|
||||
"settings.domain.dns.cname.helper": "Once TXT passes, point your custom domain to this target.",
|
||||
"settings.domain.dns.cname.title": "CNAME target (after TXT verified)",
|
||||
"settings.domain.dns.hint.ttl": "300s or provider default",
|
||||
"settings.domain.dns.name": "Name / Host",
|
||||
"settings.domain.dns.ttl": "TTL",
|
||||
"settings.domain.dns.txt.title": "TXT record (required)",
|
||||
"settings.domain.dns.type": "Type",
|
||||
"settings.domain.dns.value": "Value",
|
||||
"settings.domain.input.cta": "Bind domain",
|
||||
"settings.domain.input.helper": "Use the root domain or a subdomain. Avoid the platform base domain {{base}} itself.",
|
||||
"settings.domain.input.label": "Custom domain",
|
||||
@@ -517,12 +526,12 @@
|
||||
"settings.domain.status.disabled": "Disabled",
|
||||
"settings.domain.status.pending": "Pending DNS",
|
||||
"settings.domain.status.verified": "Active",
|
||||
"settings.domain.steps.cname.desc": "Create a CNAME record pointing to your workspace entry. This is the recommended approach.",
|
||||
"settings.domain.steps.cname.title": "Add a CNAME record",
|
||||
"settings.domain.steps.cname.desc": "After TXT verification succeeds, point a CNAME to {{base}} so the domain serves your site.",
|
||||
"settings.domain.steps.cname.title": "Point CNAME to workspace",
|
||||
"settings.domain.steps.title": "Verification steps",
|
||||
"settings.domain.steps.txt.desc": "Alternatively, add a TXT record with the verification token below if CNAME is not available.",
|
||||
"settings.domain.steps.txt.title": "Optional TXT verification",
|
||||
"settings.domain.steps.verify.desc": "After DNS propagates, click Verify. We will also accept the TXT token if present.",
|
||||
"settings.domain.steps.txt.desc": "Create a TXT record (preferred at _afilmory-verification.<your-domain>) containing the verification token.",
|
||||
"settings.domain.steps.txt.title": "Add TXT verification (required)",
|
||||
"settings.domain.steps.verify.desc": "When the TXT record is live, click Verify to activate the domain.",
|
||||
"settings.domain.steps.verify.title": "Verify and publish",
|
||||
"settings.domain.title": "Custom domain",
|
||||
"settings.domain.toast.delete-failed": "Failed to remove domain",
|
||||
@@ -532,7 +541,7 @@
|
||||
"settings.domain.toast.request-success": "Domain added. Please complete DNS and verify.",
|
||||
"settings.domain.toast.verify-failed": "Verification failed. Please verify DNS and try again.",
|
||||
"settings.domain.toast.verify-success": "Domain verified and activated",
|
||||
"settings.domain.token.helper": "Place this token in a TXT record if you cannot point a CNAME.",
|
||||
"settings.domain.token.helper": "Publish this token in a TXT record (ideally _afilmory-verification.<your-domain>).",
|
||||
"settings.domain.token.label": "Verification token",
|
||||
"settings.nav.account": "Account & Login",
|
||||
"settings.nav.data": "Data Management",
|
||||
|
||||
@@ -504,11 +504,20 @@
|
||||
"settings.account.title": "账号与登录",
|
||||
"settings.data.description": "执行数据库级别的维护操作,以保持照片数据与对象存储一致。",
|
||||
"settings.data.title": "数据管理",
|
||||
"settings.domain.actions.copy": "复制",
|
||||
"settings.domain.actions.verify": "验证",
|
||||
"settings.domain.banner.pending": "{{domain}} 正在验证中,DNS 生效可能需要几分钟。",
|
||||
"settings.domain.bound-list.empty": "还没有绑定域名,请在左侧输入后开始验证。",
|
||||
"settings.domain.bound-list.title": "已绑定域名",
|
||||
"settings.domain.description": "绑定自己的域名,让相册以品牌化地址访问。支持 CNAME 或 TXT 验证。",
|
||||
"settings.domain.description": "绑定自己的域名,用品牌地址访问。验证必须通过 TXT Token;完成后再将 CNAME 指向 {{base}} 以正式接入。",
|
||||
"settings.domain.dns.cname.helper": "TXT 验证通过后,将自定义域名指向此目标。",
|
||||
"settings.domain.dns.cname.title": "CNAME 目标(TXT 通过后)",
|
||||
"settings.domain.dns.hint.ttl": "300 秒或使用默认值",
|
||||
"settings.domain.dns.name": "主机名",
|
||||
"settings.domain.dns.ttl": "TTL",
|
||||
"settings.domain.dns.txt.title": "TXT 记录(必填)",
|
||||
"settings.domain.dns.type": "记录类型",
|
||||
"settings.domain.dns.value": "记录值",
|
||||
"settings.domain.input.cta": "绑定域名",
|
||||
"settings.domain.input.helper": "可以使用根域或子域,避免直接填写平台基础域名 {{base}}。",
|
||||
"settings.domain.input.label": "自定义域名",
|
||||
@@ -516,12 +525,12 @@
|
||||
"settings.domain.status.disabled": "已停用",
|
||||
"settings.domain.status.pending": "等待 DNS",
|
||||
"settings.domain.status.verified": "已生效",
|
||||
"settings.domain.steps.cname.desc": "创建指向工作空间入口的 CNAME 记录,推荐优先使用。",
|
||||
"settings.domain.steps.cname.title": "添加 CNAME 记录",
|
||||
"settings.domain.steps.cname.desc": "TXT 验证通过后,再把 CNAME 指向 {{base}},让流量正式路由到站点。",
|
||||
"settings.domain.steps.cname.title": "将 CNAME 指向工作空间",
|
||||
"settings.domain.steps.title": "验证步骤",
|
||||
"settings.domain.steps.txt.desc": "若无法使用 CNAME,可添加 TXT 记录并填写下方验证 Token。",
|
||||
"settings.domain.steps.txt.title": "可选 TXT 验证",
|
||||
"settings.domain.steps.verify.desc": "DNS 生效后点击验证。如果存在 TXT Token 也会一并校验。",
|
||||
"settings.domain.steps.txt.desc": "创建 TXT 记录(推荐 _afilmory-verification.<你的域名>)并填写下方验证 Token。",
|
||||
"settings.domain.steps.txt.title": "添加 TXT 验证(必需)",
|
||||
"settings.domain.steps.verify.desc": "TXT 生效后点击“验证”即可激活域名。",
|
||||
"settings.domain.steps.verify.title": "完成验证并生效",
|
||||
"settings.domain.title": "自定义域名",
|
||||
"settings.domain.toast.delete-failed": "移除域名失败",
|
||||
@@ -531,7 +540,7 @@
|
||||
"settings.domain.toast.request-success": "域名已添加,请完成 DNS 配置后再验证。",
|
||||
"settings.domain.toast.verify-failed": "验证失败,请检查 DNS 后重试。",
|
||||
"settings.domain.toast.verify-success": "域名已验证并启用",
|
||||
"settings.domain.token.helper": "无法配置 CNAME 时,可将此 Token 写入 TXT 记录完成验证。",
|
||||
"settings.domain.token.helper": "将此 Token 写入 TXT 记录(推荐 _afilmory-verification.<你的域名>)。",
|
||||
"settings.domain.token.label": "验证 Token",
|
||||
"settings.nav.account": "账号与登录",
|
||||
"settings.nav.data": "数据管理",
|
||||
|
||||
Reference in New Issue
Block a user