feat: 添加swarms团队编排功能并优化agent委派系统
- 引入AgentTeamOrchestrator支持多agent协同任务执行 - 增加第三方swarms库依赖并配置git协议替换以改善包管理 - 扩展DelegationManager支持团队任务调度和进度跟踪 - 实现中文bigram分词算法提升中文任务检索准确性 - 调整A2AClient和DelegationManager超时时间从30秒增至600秒 - 优化AgentRunResult状态判断逻辑增加有意义摘要检测 - 修改Dockerfile配置npm仓库镜像地址和git协议映射 - 更新CLI命令行接口支持网关端口配置传递 - 调整提供者超时配置机制增强请求稳定性 - 移除过时的support_group字段简化agent描述符结构 - 增强错误处理和进度事件报告机制改进用户体验
This commit is contained in:
@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import type { TokenResponse } from '@/types/auth';
|
||||
import { normalizePortalLocale, pickPortalText } from '@/lib/i18n/core';
|
||||
import { HttpError, callDeployControl, callInstanceApi, normalizeTokenResponse } from '@/lib/runtime-control';
|
||||
|
||||
function errorStatus(error: unknown): number {
|
||||
@ -18,6 +19,11 @@ function errorDetail(error: unknown): string {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const locale = normalizePortalLocale(
|
||||
request.cookies.get('nanobot_locale')?.value ||
|
||||
request.headers.get('accept-language')
|
||||
);
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as {
|
||||
username?: string;
|
||||
@ -27,7 +33,9 @@ export async function POST(request: NextRequest) {
|
||||
const password = body.password || '';
|
||||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ detail: 'username and password are required' }, { status: 400 });
|
||||
return NextResponse.json({
|
||||
detail: pickPortalText(locale, '用户名和密码不能为空', 'Username and password are required'),
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const routing = await callDeployControl<{
|
||||
@ -44,7 +52,9 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(normalizeTokenResponse(response, routing));
|
||||
} catch (error) {
|
||||
const status = errorStatus(error);
|
||||
const detail = status === 404 || status === 401 ? '用户名或密码错误' : errorDetail(error);
|
||||
const detail = status === 404 || status === 401
|
||||
? pickPortalText(locale, '用户名或密码错误', 'Incorrect username or password')
|
||||
: errorDetail(error);
|
||||
return NextResponse.json({ detail }, { status: status === 404 ? 401 : status });
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import type { TokenResponse } from '@/types/auth';
|
||||
import { normalizePortalLocale, pickPortalText } from '@/lib/i18n/core';
|
||||
import { HttpError, REGISTER_REQUEST_TIMEOUT_MS, callAuthzService } from '@/lib/runtime-control';
|
||||
|
||||
function errorStatus(error: unknown): number {
|
||||
@ -18,6 +19,11 @@ function errorDetail(error: unknown): string {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const locale = normalizePortalLocale(
|
||||
request.cookies.get('nanobot_locale')?.value ||
|
||||
request.headers.get('accept-language')
|
||||
);
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as {
|
||||
username?: string;
|
||||
@ -29,7 +35,9 @@ export async function POST(request: NextRequest) {
|
||||
const password = body.password || '';
|
||||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ detail: 'username and password are required' }, { status: 400 });
|
||||
return NextResponse.json({
|
||||
detail: pickPortalText(locale, '用户名和密码不能为空', 'Username and password are required'),
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const response = await callAuthzService<TokenResponse>('/portal/register', {
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import './globals.css';
|
||||
import type { Metadata } from 'next';
|
||||
import { PortalI18nProvider } from '@/lib/i18n/provider';
|
||||
import { getServerPortalLocale } from '@/lib/i18n/server';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Boardware Agent Sandbox Auth Portal',
|
||||
description: 'Dedicated login and registration portal for Boardware Genius containers.',
|
||||
description: 'Boardware Agent Sandbox Auth Portal',
|
||||
icons: {
|
||||
icon: '/boardware-logo.jpg',
|
||||
},
|
||||
@ -14,9 +16,13 @@ export default function RootLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const locale = getServerPortalLocale();
|
||||
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>{children}</body>
|
||||
<html lang={locale}>
|
||||
<body>
|
||||
<PortalI18nProvider initialLocale={locale}>{children}</PortalI18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,9 +5,13 @@ import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
||||
import { buildFrontendHandoffUrl, login, withNext } from '@/lib/auth-client';
|
||||
import { pickPortalText } from '@/lib/i18n/core';
|
||||
import { usePortalI18n } from '@/lib/i18n/provider';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { locale } = usePortalI18n();
|
||||
const searchParams = useSearchParams();
|
||||
const nextPath = searchParams?.get('next') || '/';
|
||||
|
||||
@ -25,7 +29,7 @@ export default function LoginPage() {
|
||||
const response = await login(username, password);
|
||||
window.location.replace(buildFrontendHandoffUrl(response, nextPath));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败,请稍后重试');
|
||||
setError(err instanceof Error ? err.message : pickPortalText(locale, '登录失败,请稍后重试', 'Sign-in failed. Please try again.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -33,6 +37,9 @@ export default function LoginPage() {
|
||||
|
||||
return (
|
||||
<main className="portal-page">
|
||||
<div className="absolute right-5 top-5 z-10">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<section className="portal-shell">
|
||||
<div className="portal-brand">
|
||||
<div className="portal-logo-lockup">
|
||||
@ -47,47 +54,55 @@ export default function LoginPage() {
|
||||
<div className="portal-kicker">Auth Portal</div>
|
||||
<h1 className="portal-title">Boardware Agent Sandbox</h1>
|
||||
<p className="portal-copy">
|
||||
这个入口只负责鉴权。成功后会把你直接送到为你分配的专属实例 URL,后续前后端请求都留在那套容器里。
|
||||
{pickPortalText(
|
||||
locale,
|
||||
'这个入口只负责鉴权。成功后会把你直接送到为你分配的专属实例 URL,后续前后端请求都留在那套容器里。',
|
||||
'This portal only handles authentication. After sign-in, you are redirected to your dedicated runtime URL and all later requests stay inside that container.'
|
||||
)}
|
||||
</p>
|
||||
<div className="portal-notes">
|
||||
<div className="portal-note">
|
||||
<strong>容器边界</strong>
|
||||
登录注册先经过独立 auth portal,再跳到专属实例。一用户一套前后端容器不变。
|
||||
<strong>{pickPortalText(locale, '容器边界', 'Container boundary')}</strong>
|
||||
{pickPortalText(
|
||||
locale,
|
||||
'登录注册先经过独立 auth portal,再跳到专属实例。一用户一套前后端容器不变。',
|
||||
'Authentication happens in this standalone portal first, then the browser jumps into the dedicated runtime. Each user keeps an isolated frontend/backend container pair.'
|
||||
)}
|
||||
</div>
|
||||
<div className="portal-note">
|
||||
<strong>目标页面</strong>
|
||||
当前登录完成后将回到:<code>{nextPath}</code>
|
||||
<strong>{pickPortalText(locale, '目标页面', 'Target page')}</strong>
|
||||
{pickPortalText(locale, '当前登录完成后将回到:', 'After sign-in you will return to:')} <code>{nextPath}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="portal-panel">
|
||||
<div className="auth-card">
|
||||
<h1>登录</h1>
|
||||
<p>输入已有账号,认证完成后直接进入目标容器前端。</p>
|
||||
<h1>{pickPortalText(locale, '登录', 'Sign In')}</h1>
|
||||
<p>{pickPortalText(locale, '输入已有账号,认证完成后直接进入目标容器前端。', 'Use an existing account and continue straight into the target runtime UI.')}</p>
|
||||
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<div className="field">
|
||||
<label htmlFor="username">用户名</label>
|
||||
<label htmlFor="username">{pickPortalText(locale, '用户名', 'Username')}</label>
|
||||
<input
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
placeholder="例如:bwgdi"
|
||||
placeholder={pickPortalText(locale, '例如:bwgdi', 'Example: bwgdi')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="password">密码</label>
|
||||
<label htmlFor="password">{pickPortalText(locale, '密码', 'Password')}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
placeholder="输入密码"
|
||||
placeholder={pickPortalText(locale, '输入密码', 'Enter password')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@ -95,12 +110,14 @@ export default function LoginPage() {
|
||||
<div className="error-text">{error}</div>
|
||||
|
||||
<button className="primary-button" type="submit" disabled={loading}>
|
||||
{loading ? '登录中...' : '登录并进入容器'}
|
||||
{loading
|
||||
? pickPortalText(locale, '登录中...', 'Signing in...')
|
||||
: pickPortalText(locale, '登录并进入容器', 'Sign in and continue')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="auth-footer">
|
||||
还没有账号? <Link href={withNext('/register', nextPath)}>去注册</Link>
|
||||
{pickPortalText(locale, '还没有账号?', "Don't have an account yet?")} <Link href={withNext('/register', nextPath)}>{pickPortalText(locale, '去注册', 'Create one')}</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -5,9 +5,13 @@ import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
||||
import { buildFrontendHandoffUrl, register, withNext } from '@/lib/auth-client';
|
||||
import { pickPortalText } from '@/lib/i18n/core';
|
||||
import { usePortalI18n } from '@/lib/i18n/provider';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { locale } = usePortalI18n();
|
||||
const searchParams = useSearchParams();
|
||||
const nextPath = searchParams?.get('next') || '/mcp';
|
||||
|
||||
@ -25,12 +29,12 @@ export default function RegisterPage() {
|
||||
|
||||
try {
|
||||
if (password !== confirmPassword) {
|
||||
throw new Error('两次输入的密码不一致');
|
||||
throw new Error(pickPortalText(locale, '两次输入的密码不一致', 'Passwords do not match'));
|
||||
}
|
||||
const response = await register(username, email, password);
|
||||
window.location.replace(buildFrontendHandoffUrl(response, nextPath));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '注册失败,请稍后重试');
|
||||
setError(err instanceof Error ? err.message : pickPortalText(locale, '注册失败,请稍后重试', 'Sign-up failed. Please try again.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -38,6 +42,9 @@ export default function RegisterPage() {
|
||||
|
||||
return (
|
||||
<main className="portal-page">
|
||||
<div className="absolute right-5 top-5 z-10">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<section className="portal-shell">
|
||||
<div className="portal-brand">
|
||||
<div className="portal-logo-lockup">
|
||||
@ -52,72 +59,80 @@ export default function RegisterPage() {
|
||||
<div className="portal-kicker">Auth Portal</div>
|
||||
<h1 className="portal-title">Create Runtime</h1>
|
||||
<p className="portal-copy">
|
||||
注册不仅建立登录账号,还会触发专属实例创建和 backend 身份分配。认证完成后会直接进入你的专属 URL。
|
||||
{pickPortalText(
|
||||
locale,
|
||||
'注册不仅建立登录账号,还会触发专属实例创建和 backend 身份分配。认证完成后会直接进入你的专属 URL。',
|
||||
'Sign-up not only creates a login account, it also provisions your dedicated runtime and backend identity. After authentication, you go straight into your own URL.'
|
||||
)}
|
||||
</p>
|
||||
<div className="portal-notes">
|
||||
<div className="portal-note">
|
||||
<strong>注册结果</strong>
|
||||
AuthZ 会编排 deploy-control 创建实例,并完成 backend 身份初始化,auth portal 最后把你转交到该实例前端。
|
||||
<strong>{pickPortalText(locale, '注册结果', 'Provisioning result')}</strong>
|
||||
{pickPortalText(
|
||||
locale,
|
||||
'AuthZ 会编排 deploy-control 创建实例,并完成 backend 身份初始化,auth portal 最后把你转交到该实例前端。',
|
||||
'AuthZ coordinates deploy-control to create the runtime, initialize backend identity, and then the portal hands the browser over to that frontend.'
|
||||
)}
|
||||
</div>
|
||||
<div className="portal-note">
|
||||
<strong>目标页面</strong>
|
||||
当前注册完成后将回到:<code>{nextPath}</code>
|
||||
<strong>{pickPortalText(locale, '目标页面', 'Target page')}</strong>
|
||||
{pickPortalText(locale, '当前注册完成后将回到:', 'After sign-up you will return to:')} <code>{nextPath}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="portal-panel">
|
||||
<div className="auth-card">
|
||||
<h1>注册</h1>
|
||||
<p>为当前容器创建登录账号,并完成 backend 身份初始化。</p>
|
||||
<h1>{pickPortalText(locale, '注册', 'Sign Up')}</h1>
|
||||
<p>{pickPortalText(locale, '为当前容器创建登录账号,并完成 backend 身份初始化。', 'Create a login account for this runtime and initialize backend identity.')}</p>
|
||||
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<div className="field">
|
||||
<label htmlFor="username">用户名</label>
|
||||
<label htmlFor="username">{pickPortalText(locale, '用户名', 'Username')}</label>
|
||||
<input
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
placeholder="例如:bwgdi"
|
||||
placeholder={pickPortalText(locale, '例如:bwgdi', 'Example: bwgdi')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="email">邮箱</label>
|
||||
<label htmlFor="email">{pickPortalText(locale, '邮箱', 'Email')}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
autoComplete="email"
|
||||
placeholder="例如:steven@example.com"
|
||||
placeholder={pickPortalText(locale, '例如:steven@example.com', 'Example: steven@example.com')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="password">密码</label>
|
||||
<label htmlFor="password">{pickPortalText(locale, '密码', 'Password')}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
placeholder="设置密码"
|
||||
placeholder={pickPortalText(locale, '设置密码', 'Set a password')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="confirmPassword">确认密码</label>
|
||||
<label htmlFor="confirmPassword">{pickPortalText(locale, '确认密码', 'Confirm password')}</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
placeholder="再次输入密码"
|
||||
placeholder={pickPortalText(locale, '再次输入密码', 'Enter the password again')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@ -125,16 +140,18 @@ export default function RegisterPage() {
|
||||
<div className="error-text">{error}</div>
|
||||
|
||||
<button className="primary-button" type="submit" disabled={loading}>
|
||||
{loading ? '注册中...' : '注册并进入容器'}
|
||||
{loading
|
||||
? pickPortalText(locale, '注册中...', 'Creating account...')
|
||||
: pickPortalText(locale, '注册并进入容器', 'Create account and continue')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="auth-footer">
|
||||
已有账号? <Link href={withNext('/login', nextPath)}>去登录</Link>
|
||||
{pickPortalText(locale, '已有账号?', 'Already have an account?')} <Link href={withNext('/login', nextPath)}>{pickPortalText(locale, '去登录', 'Sign in')}</Link>
|
||||
</div>
|
||||
|
||||
<div className="status-panel">
|
||||
Portal 会先调用部署机接口创建实例,再把浏览器跳到实例自己的 URL。
|
||||
{pickPortalText(locale, 'Portal 会先调用部署机接口创建实例,再把浏览器跳到实例自己的 URL。', 'The portal first calls the deployment controller to create the runtime, then redirects the browser into the instance URL.')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
32
auth-portal/src/components/LanguageSwitcher.tsx
Normal file
32
auth-portal/src/components/LanguageSwitcher.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { usePortalI18n } from '@/lib/i18n/provider';
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 'zh-CN', label: 'ZH' },
|
||||
{ value: 'en-US', label: 'EN' },
|
||||
] as const;
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const { locale, setLocale } = usePortalI18n();
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1 rounded-full border border-white/15 bg-black/25 p-1 backdrop-blur">
|
||||
<span className="ml-1 text-[11px] font-medium uppercase tracking-[0.14em] text-white/70">Lang</span>
|
||||
{OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setLocale(option.value)}
|
||||
className={`rounded-full px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
locale === option.value
|
||||
? 'bg-white text-slate-900'
|
||||
: 'text-white/75 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { TokenResponse } from '@/types/auth';
|
||||
import { getCurrentPortalLocale, pickPortalText } from '@/lib/i18n/core';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 8000;
|
||||
const REGISTER_REQUEST_TIMEOUT_MS = 90000;
|
||||
@ -28,6 +29,7 @@ function buildApiUrl(path: string): string {
|
||||
}
|
||||
|
||||
async function fetchJSON<T>(path: string, options?: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> {
|
||||
const locale = getCurrentPortalLocale();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
@ -52,13 +54,13 @@ async function fetchJSON<T>(path: string, options?: RequestInit, timeoutMs = REQ
|
||||
} catch {
|
||||
// keep raw text
|
||||
}
|
||||
throw new Error(`接口错误 ${response.status}: ${detail}`);
|
||||
throw new Error(`${pickPortalText(locale, '接口错误', 'API error')} ${response.status}: ${detail}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new Error('请求超时');
|
||||
throw new Error(pickPortalText(locale, '请求超时', 'Request timed out'));
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
@ -81,13 +83,14 @@ export async function register(username: string, email: string, password: string
|
||||
}
|
||||
|
||||
export function buildFrontendHandoffUrl(response: TokenResponse, nextPath: string): string {
|
||||
const locale = getCurrentPortalLocale();
|
||||
const frontendBaseUrl = getFrontendBaseUrl(response);
|
||||
if (!frontendBaseUrl) {
|
||||
throw new Error('后端未返回目标前端地址');
|
||||
throw new Error(pickPortalText(locale, '后端未返回目标前端地址', 'Backend did not return a target frontend URL'));
|
||||
}
|
||||
const handoffCode = response.handoff_code?.trim();
|
||||
if (!handoffCode) {
|
||||
throw new Error('后端未返回 handoff code');
|
||||
throw new Error(pickPortalText(locale, '后端未返回 handoff code', 'Backend did not return a handoff code'));
|
||||
}
|
||||
|
||||
const url = new URL('/handoff', frontendBaseUrl);
|
||||
|
||||
72
auth-portal/src/lib/i18n/core.ts
Normal file
72
auth-portal/src/lib/i18n/core.ts
Normal file
@ -0,0 +1,72 @@
|
||||
export const PORTAL_LOCALE_COOKIE = 'nanobot_locale';
|
||||
export const PORTAL_LOCALE_STORAGE_KEY = 'nanobot_locale';
|
||||
|
||||
export const PORTAL_LOCALES = ['zh-CN', 'en-US'] as const;
|
||||
|
||||
export type PortalLocale = (typeof PORTAL_LOCALES)[number];
|
||||
|
||||
export function normalizePortalLocale(value?: string | null): PortalLocale {
|
||||
const probe = value?.trim().toLowerCase() || '';
|
||||
if (probe.startsWith('en')) {
|
||||
return 'en-US';
|
||||
}
|
||||
return 'zh-CN';
|
||||
}
|
||||
|
||||
function readCookieLocale(): string | null {
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = document.cookie
|
||||
.split('; ')
|
||||
.find((item) => item.startsWith(`${PORTAL_LOCALE_COOKIE}=`));
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return decodeURIComponent(match.slice(PORTAL_LOCALE_COOKIE.length + 1));
|
||||
}
|
||||
|
||||
export function readBrowserPortalLocale(): PortalLocale {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'zh-CN';
|
||||
}
|
||||
|
||||
const fromDocument = document.documentElement.lang;
|
||||
if (fromDocument) {
|
||||
return normalizePortalLocale(fromDocument);
|
||||
}
|
||||
|
||||
const fromStorage = window.localStorage.getItem(PORTAL_LOCALE_STORAGE_KEY);
|
||||
if (fromStorage) {
|
||||
return normalizePortalLocale(fromStorage);
|
||||
}
|
||||
|
||||
const fromCookie = readCookieLocale();
|
||||
if (fromCookie) {
|
||||
return normalizePortalLocale(fromCookie);
|
||||
}
|
||||
|
||||
return normalizePortalLocale(window.navigator.language);
|
||||
}
|
||||
|
||||
export function persistPortalLocale(locale: PortalLocale): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.lang = locale;
|
||||
window.localStorage.setItem(PORTAL_LOCALE_STORAGE_KEY, locale);
|
||||
document.cookie = `${PORTAL_LOCALE_COOKIE}=${encodeURIComponent(locale)}; path=/; max-age=31536000; samesite=lax`;
|
||||
}
|
||||
|
||||
export function getCurrentPortalLocale(): PortalLocale {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'zh-CN';
|
||||
}
|
||||
return readBrowserPortalLocale();
|
||||
}
|
||||
|
||||
export function pickPortalText<T>(locale: PortalLocale, zhValue: T, enValue: T): T {
|
||||
return locale === 'en-US' ? enValue : zhValue;
|
||||
}
|
||||
57
auth-portal/src/lib/i18n/provider.tsx
Normal file
57
auth-portal/src/lib/i18n/provider.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
type PortalLocale,
|
||||
persistPortalLocale,
|
||||
readBrowserPortalLocale,
|
||||
} from '@/lib/i18n/core';
|
||||
|
||||
type PortalI18nContextValue = {
|
||||
locale: PortalLocale;
|
||||
setLocale: (locale: PortalLocale) => void;
|
||||
};
|
||||
|
||||
const PortalI18nContext = React.createContext<PortalI18nContextValue | null>(null);
|
||||
|
||||
export function PortalI18nProvider({
|
||||
initialLocale,
|
||||
children,
|
||||
}: {
|
||||
initialLocale: PortalLocale;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [locale, setLocaleState] = React.useState<PortalLocale>(initialLocale);
|
||||
|
||||
React.useEffect(() => {
|
||||
const browserLocale = readBrowserPortalLocale();
|
||||
if (browserLocale !== locale) {
|
||||
setLocaleState(browserLocale);
|
||||
return;
|
||||
}
|
||||
persistPortalLocale(locale);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
persistPortalLocale(locale);
|
||||
}, [locale]);
|
||||
|
||||
const value = React.useMemo<PortalI18nContextValue>(
|
||||
() => ({
|
||||
locale,
|
||||
setLocale: setLocaleState,
|
||||
}),
|
||||
[locale]
|
||||
);
|
||||
|
||||
return <PortalI18nContext.Provider value={value}>{children}</PortalI18nContext.Provider>;
|
||||
}
|
||||
|
||||
export function usePortalI18n(): PortalI18nContextValue {
|
||||
const value = React.useContext(PortalI18nContext);
|
||||
if (!value) {
|
||||
throw new Error('usePortalI18n must be used within PortalI18nProvider');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
17
auth-portal/src/lib/i18n/server.ts
Normal file
17
auth-portal/src/lib/i18n/server.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { cookies, headers } from 'next/headers';
|
||||
|
||||
import { PORTAL_LOCALE_COOKIE, normalizePortalLocale, type PortalLocale } from '@/lib/i18n/core';
|
||||
|
||||
export function getServerPortalLocale(): PortalLocale {
|
||||
const cookieLocale = cookies().get(PORTAL_LOCALE_COOKIE)?.value;
|
||||
if (cookieLocale) {
|
||||
return normalizePortalLocale(cookieLocale);
|
||||
}
|
||||
|
||||
const acceptLanguage = headers().get('accept-language');
|
||||
if (acceptLanguage) {
|
||||
return normalizePortalLocale(acceptLanguage);
|
||||
}
|
||||
|
||||
return 'zh-CN';
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user