From 7b225eb954269a2cf6dfece8317fb4b5fb83f1b3 Mon Sep 17 00:00:00 2001 From: Joey Zhong Date: Tue, 7 Apr 2026 12:07:05 +0100 Subject: [PATCH 1/3] feat: add google auth --- .../@buildingai/constants/src/shared/auth.ts | 2 + .../db/src/entities/user.entity.ts | 6 + .../@buildingai/db/src/seeds/data/menu.json | 12 ++ packages/@buildingai/utils/src/is.ts | 6 + .../web/services/src/console/user.ts | 31 +++ .../auth/services/google-oauth.service.ts | 182 ++++++++++++++++++ .../src/modules/ai/agents/agents.module.ts | 2 +- .../api/src/modules/ai/chat/ai-chat.module.ts | 4 +- packages/api/src/modules/app.module.ts | 6 +- packages/api/src/modules/auth/auth.module.ts | 6 + .../auth/controller/web/auth.controller.ts | 63 ++++-- .../api/src/modules/channel/channel.module.ts | 8 +- .../console/google-config.controller.ts | 38 ++++ .../modules/channel/dto/google-config.dto.ts | 15 ++ .../channel/services/google-config.service.ts | 74 +++++++ .../src/modules/extension/extension.module.ts | 4 +- .../api/src/modules/system/system.module.ts | 2 +- .../api/src/modules/upload/upload.module.ts | 4 +- .../src/modules/user/services/user.service.ts | 35 ++++ packages/api/src/modules/user/user.module.ts | 2 +- packages/client/src/layouts/console/index.tsx | 5 + .../pages/console/channel/google/index.tsx | 173 +++++++++++++++++ .../console/system/login-config/index.tsx | 5 + .../pages/login/_components/login-form.tsx | 13 ++ .../client/src/pages/login/oauth-callback.tsx | 11 +- packages/client/vite.config.ts | 14 +- 26 files changed, 686 insertions(+), 37 deletions(-) create mode 100644 packages/api/src/common/modules/auth/services/google-oauth.service.ts create mode 100644 packages/api/src/modules/channel/controller/console/google-config.controller.ts create mode 100644 packages/api/src/modules/channel/dto/google-config.dto.ts create mode 100644 packages/api/src/modules/channel/services/google-config.service.ts create mode 100644 packages/client/src/pages/console/channel/google/index.tsx diff --git a/packages/@buildingai/constants/src/shared/auth.ts b/packages/@buildingai/constants/src/shared/auth.ts index 3ec7103c7..5a55aa235 100644 --- a/packages/@buildingai/constants/src/shared/auth.ts +++ b/packages/@buildingai/constants/src/shared/auth.ts @@ -13,5 +13,7 @@ export const LOGIN_TYPE = { PHONE: 2, /** 微信登录 */ WECHAT: 3, + /** Google登录 */ + GOOGLE: 5, } as const; export type LoginType = (typeof LOGIN_TYPE)[keyof typeof LOGIN_TYPE]; diff --git a/packages/@buildingai/db/src/entities/user.entity.ts b/packages/@buildingai/db/src/entities/user.entity.ts index 36fc676c6..27da8fb0c 100644 --- a/packages/@buildingai/db/src/entities/user.entity.ts +++ b/packages/@buildingai/db/src/entities/user.entity.ts @@ -37,6 +37,12 @@ export class User extends SoftDeleteBaseEntity { @Column({ nullable: true, unique: true, comment: "用户unionid" }) unionid: string; + /** + * Google OAuth openid + */ + @Column({ nullable: true, comment: "Google OAuth openid" }) + googleOpenid: string; + @Column({ nullable: true }) userNo: string; /** diff --git a/packages/@buildingai/db/src/seeds/data/menu.json b/packages/@buildingai/db/src/seeds/data/menu.json index 9d85f4580..96de2c652 100644 --- a/packages/@buildingai/db/src/seeds/data/menu.json +++ b/packages/@buildingai/db/src/seeds/data/menu.json @@ -386,6 +386,18 @@ "isHidden": 0, "type": 2, "sourceType": 1 + }, + { + "name": "Google登录", + "code": "channel-google", + "path": "google", + "icon": "google", + "component": "/console/channel/google/index", + "permissionCode": "google-config:get-config", + "sort": 1, + "isHidden": 0, + "type": 2, + "sourceType": 1 } ] }, diff --git a/packages/@buildingai/utils/src/is.ts b/packages/@buildingai/utils/src/is.ts index 85db84824..18e1e1787 100644 --- a/packages/@buildingai/utils/src/is.ts +++ b/packages/@buildingai/utils/src/is.ts @@ -110,3 +110,9 @@ export function isAsyncGenerator( ): value is AsyncGenerator { return value != null && typeof value === "object" && Symbol.asyncIterator in value; } + +export function getFrontendBaseUrl() { + return isDevelopment() + ? (process.env.CLIENT_URL || "http://localhost:4091").replace(/\/$/, "") + : (process.env.APP_DOMAIN || "http://localhost:4090").replace(/\/$/, ""); +} diff --git a/packages/@buildingai/web/services/src/console/user.ts b/packages/@buildingai/web/services/src/console/user.ts index 149cd98cb..e65a76f17 100644 --- a/packages/@buildingai/web/services/src/console/user.ts +++ b/packages/@buildingai/web/services/src/console/user.ts @@ -369,6 +369,37 @@ export function useUpdateWxOaConfigMutation( }); } +/** Google登录配置(接口返回) */ +export type GoogleConfigResponse = { + clientId: string; + clientSecret: string; + enabled: boolean; +}; + +/** 更新Google登录配置 DTO */ +export type UpdateGoogleConfigDto = { + clientId?: string; + clientSecret?: string; + enabled?: boolean; +}; + +export function useGoogleConfigQuery(options?: QueryOptionsUtil) { + return useQuery({ + queryKey: ["channel", "google-config"], + queryFn: () => consoleHttpClient.get("/google-config"), + ...options, + }); +} + +export function useUpdateGoogleConfigMutation( + options?: MutationOptionsUtil<{ success: boolean }, UpdateGoogleConfigDto>, +) { + return useMutation<{ success: boolean }, Error, UpdateGoogleConfigDto>({ + mutationFn: (dto) => consoleHttpClient.patch<{ success: boolean }>("/google-config", dto), + ...options, + }); +} + // User subscription types export type UserSubscriptionLevel = { id: string; diff --git a/packages/api/src/common/modules/auth/services/google-oauth.service.ts b/packages/api/src/common/modules/auth/services/google-oauth.service.ts new file mode 100644 index 000000000..631367e63 --- /dev/null +++ b/packages/api/src/common/modules/auth/services/google-oauth.service.ts @@ -0,0 +1,182 @@ +import { BooleanNumber, UserTerminal, UserTerminalType } from "@buildingai/constants"; +import { checkUserLoginPlayground, LoginUserPlayground } from "@buildingai/db"; +import { HttpErrorFactory } from "@buildingai/errors"; +import { Injectable } from "@nestjs/common"; +import { getFrontendBaseUrl } from "@buildingai/utils"; +import { GoogleConfigService } from "../../../../modules/channel/services/google-config.service"; +import { UserService } from "../../../../modules/user/services/user.service"; +import { UserTokenService } from "./user-token.service"; + +/** + * Google OAuth 2.0 Service + * + * Implements the OAuth 2.0 authorization code flow for Google authentication: + * 1. Build authorization URL + * 2. Exchange authorization code for access token + * 3. Fetch user info from Google + * 4. Find or create user and return app token + */ +@Injectable() +export class GoogleOAuthService { + private readonly TOKEN_URL = "https://oauth2.googleapis.com/token"; + private readonly USER_INFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"; + private readonly AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; + private readonly SCOPES = "openid email profile"; + + constructor( + private readonly configService: GoogleConfigService, + private readonly userService: UserService, + private readonly userTokenService: UserTokenService, + ) { } + + /** + * Step 1: Build Google OAuth authorization URL + * + * @param state Random state string for CSRF protection + * @returns Authorization URL to redirect user to Google + * @throws Error if Google OAuth is not properly configured + */ + async getAuthorizationUrl(state: string): Promise { + const config = await this.configService.getConfig(); + + if (!config.clientId || !config.clientSecret) { + throw HttpErrorFactory.unauthorized("Google OAuth is not configured. Please configure clientId and clientSecret."); + } + + const redirectUri = `${getFrontendBaseUrl()}/api/auth/google-callback`; + const params = new URLSearchParams({ + client_id: config.clientId, + redirect_uri: redirectUri, + response_type: "code", + scope: this.SCOPES, + state, + }); + + return `${this.AUTH_URL}?${params}`; + } + + /** + * Step 2: Exchange authorization code for access token + * + * @param code Authorization code from Google callback + * @param redirectUri Redirect URI used in the authorization request + * @returns Access token response + * @throws Error if token exchange fails + */ + private async exchangeCodeForToken( + code: string, + redirectUri: string, + ): Promise<{ access_token: string }> { + const config = await this.configService.getConfig(); + + const response = await fetch(this.TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + code, + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uri: redirectUri, + grant_type: "authorization_code", + }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + throw HttpErrorFactory.unauthorized(`Google token exchange failed: ${response.status} - ${errorText}`); + } + + return response.json() as Promise<{ access_token: string }>; + } + + /** + * Step 3: Fetch user info from Google + * + * @param accessToken Google access token + * @returns Google user info + * @throws Error if user info fetch fails + */ + private async fetchUserInfo(accessToken: string): Promise<{ + id: string; + email?: string; + name?: string; + picture?: string; + }> { + const response = await fetch(this.USER_INFO_URL, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + throw HttpErrorFactory.unauthorized(`Google user info fetch failed: ${response.status} - ${errorText}`); + } + + return response.json() as Promise<{ id: string; email?: string; name?: string; picture?: string }>; + } + + /** + * Step 4: Handle callback - find or create user, return token + * + * @param code Authorization code from Google callback + * @param redirectUri Redirect URI used in the authorization request + * @param terminal Login terminal type + * @param ipAddress Client IP address (optional) + * @param userAgent Client user agent (optional) + * @returns Token result with user info + */ + async handleCallback( + code: string, + state: string, + redirectUri: string, + terminal: UserTerminalType = UserTerminal.PC, + ipAddress?: string, + userAgent?: string, + ) { + // Validate state parameter for CSRF protection + if (!state || state.trim() === "") { + throw HttpErrorFactory.unauthorized("Invalid state parameter: CSRF validation failed"); + } + + // Exchange code for access token + const { access_token } = await this.exchangeCodeForToken(code, redirectUri); + + // Fetch user info from Google + const userInfo = await this.fetchUserInfo(access_token); + const googleOpenid = userInfo.id; + + // Find or create user by Google OpenID + let user = await this.userService.findByGoogleOpenid(googleOpenid); + + if (!user) { + user = await this.userService.createByGoogle({ + googleOpenid, + email: userInfo.email, + nickname: userInfo.name, + avatar: userInfo.picture, + }); + } + + // Build login playground payload + const payload: LoginUserPlayground = checkUserLoginPlayground({ + id: user.id, + username: user.username, + isRoot: user.isRoot ?? BooleanNumber.NO, + terminal, + }); + + // Create app token + const tokenResult = await this.userTokenService.createToken( + user.id, + payload, + terminal, + ipAddress, + userAgent, + ); + + return { + token: tokenResult.token, + expiresAt: tokenResult.expiresAt, + user, + }; + } +} diff --git a/packages/api/src/modules/ai/agents/agents.module.ts b/packages/api/src/modules/ai/agents/agents.module.ts index ae0ac0912..33d00f799 100644 --- a/packages/api/src/modules/ai/agents/agents.module.ts +++ b/packages/api/src/modules/ai/agents/agents.module.ts @@ -78,7 +78,7 @@ import { AgentsService } from "./services/agents.service"; forwardRef(() => AiDatasetsModule), AiMemoryModule, ConfigModule, - UserModule, + forwardRef(() => UserModule), ], controllers: [ AgentsConsoleController, diff --git a/packages/api/src/modules/ai/chat/ai-chat.module.ts b/packages/api/src/modules/ai/chat/ai-chat.module.ts index 340ee0216..7c9f3d56d 100644 --- a/packages/api/src/modules/ai/chat/ai-chat.module.ts +++ b/packages/api/src/modules/ai/chat/ai-chat.module.ts @@ -19,7 +19,7 @@ import { Secret, UserSubscription, } from "@buildingai/db/entities"; -import { Module } from "@nestjs/common"; +import { forwardRef, Module } from "@nestjs/common"; import { UserModule } from "../../user/user.module"; import { AiMcpServerService } from "../mcp/services/ai-mcp-server.service"; @@ -51,7 +51,7 @@ import { ChatConfigService } from "./services/chat-config.service"; @Module({ imports: [ AiMemoryModule, - UserModule, + forwardRef(() => UserModule), TypeOrmModule.forFeature([ AiModel, AiProvider, diff --git a/packages/api/src/modules/app.module.ts b/packages/api/src/modules/app.module.ts index 89e89253e..b505fbff6 100644 --- a/packages/api/src/modules/app.module.ts +++ b/packages/api/src/modules/app.module.ts @@ -30,7 +30,7 @@ import { ExtensionCoreModule } from "@modules/extension/extension.module"; import { HealthModule } from "@modules/health/health.module"; import { MembershipModule } from "@modules/membership/membership.module"; import { NotificationModule } from "@modules/notification/notification.module"; -import { DynamicModule, Module } from "@nestjs/common"; +import { DynamicModule, forwardRef, Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; import { APP_GUARD } from "@nestjs/core"; import { ServeStaticModule } from "@nestjs/serve-static"; @@ -97,7 +97,7 @@ export class AppModule { DatabaseModule, GuardsModule, BillingModule, - AuthModule, + forwardRef(() => AuthModule), CDKModule, // ChannelModule, AiModule, @@ -118,7 +118,7 @@ export class AppModule { UploadModule, AnalyseModule, SecretModule, - UserModule, + forwardRef(() => UserModule), CloudStorageModule, ScheduleModule, SmsModule, diff --git a/packages/api/src/modules/auth/auth.module.ts b/packages/api/src/modules/auth/auth.module.ts index 84fbf8d19..13e527753 100644 --- a/packages/api/src/modules/auth/auth.module.ts +++ b/packages/api/src/modules/auth/auth.module.ts @@ -14,14 +14,17 @@ import { import { AuthService } from "@common/modules/auth/services/auth.service"; import { ExtensionFeatureService } from "@common/modules/auth/services/extension-feature.service"; import { ExtensionFeatureScanService } from "@common/modules/auth/services/extension-feature-scan.service"; +import { GoogleOAuthService } from "@common/modules/auth/services/google-oauth.service"; import { RolePermissionService } from "@common/modules/auth/services/role-permission.service"; import { UserTokenService } from "@common/modules/auth/services/user-token.service"; import { SmsModule } from "@common/modules/sms/sms.module"; import { WechatOaService } from "@common/modules/wechat/services/wechatoa.service"; import { ChannelModule } from "@modules/channel/channel.module"; +import { UserModule } from "@modules/user/user.module"; import { Module } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { JwtModule } from "@nestjs/jwt"; +import { forwardRef } from "@nestjs/common"; import type { StringValue } from "ms"; import { AuthWebController } from "./controller/web/auth.controller"; @@ -47,6 +50,7 @@ import { AuthWebController } from "./controller/web/auth.controller"; DepartmentUserIndex, ]), ChannelModule, + forwardRef(() => UserModule), JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], @@ -63,6 +67,7 @@ import { AuthWebController } from "./controller/web/auth.controller"; AuthService, ExtensionFeatureScanService, ExtensionFeatureService, + GoogleOAuthService, RolePermissionService, UserTokenService, WechatOaService, @@ -71,6 +76,7 @@ import { AuthWebController } from "./controller/web/auth.controller"; AuthService, ExtensionFeatureScanService, ExtensionFeatureService, + GoogleOAuthService, JwtModule, RolePermissionService, UserTokenService, diff --git a/packages/api/src/modules/auth/controller/web/auth.controller.ts b/packages/api/src/modules/auth/controller/web/auth.controller.ts index 879c25dcb..112424a75 100644 --- a/packages/api/src/modules/auth/controller/web/auth.controller.ts +++ b/packages/api/src/modules/auth/controller/web/auth.controller.ts @@ -10,18 +10,20 @@ import { Playground } from "@buildingai/decorators/playground.decorator"; import { Public } from "@buildingai/decorators/public.decorator"; import { DictService } from "@buildingai/dict"; import { HttpErrorFactory } from "@buildingai/errors"; -import { isDevelopment } from "@buildingai/utils"; +import { getFrontendBaseUrl } from "@buildingai/utils"; import { WebController } from "@common/decorators"; import { ChangePasswordDto } from "@common/modules/auth/dto/change-password.dto"; import { LoginDto } from "@common/modules/auth/dto/login.dto"; import { RegisterDto } from "@common/modules/auth/dto/register.dto"; import { SendSmsCodeDto, SmsLoginDto } from "@common/modules/auth/dto/sms.dto"; import { AuthService } from "@common/modules/auth/services/auth.service"; +import { GoogleOAuthService } from "@common/modules/auth/services/google-oauth.service"; import { SmsService } from "@common/modules/sms/services/sms.service"; import { WechatOaService } from "@common/modules/wechat/services/wechatoa.service"; import { type LoginSettingsConfig } from "@modules/user/dto/login-settings.dto"; import { Body, Get, Headers, Param, Post, Query, Req, Res } from "@nestjs/common"; import type { Request, Response } from "express"; +import crypto from "node:crypto"; const OAUTH_SESSION_PREFIX = "oauth:session:"; @@ -38,6 +40,7 @@ export class AuthWebController extends BaseController { private smsService: SmsService, private dictService: DictService, private cacheService: CacheService, + private googleOAuthService: GoogleOAuthService, ) { super(); } @@ -338,22 +341,6 @@ export class AuthWebController extends BaseController { return { token: data.token, user: data.user }; } - private getFrontendBaseUrl() { - return isDevelopment() - ? "http://localhost:4091" - : (process.env.APP_DOMAIN || "http://localhost:4090").replace(/\/$/, ""); - } - - private redirectOAuthCallback(res: Response, error?: string, redirectState?: string) { - const baseUrl = this.getFrontendBaseUrl(); - if (error) { - const params = new URLSearchParams({ error }); - if (redirectState) params.set("redirect", redirectState); - return res.redirect(`${baseUrl}/login?${params.toString()}`); - } - return res.redirect(`${baseUrl}/login`); - } - private async getLoginSettings() { return await this.dictService.get( "login_settings", @@ -439,4 +426,46 @@ export class AuthWebController extends BaseController { res.setHeader("Content-Type", "text/html; charset=utf-8"); return res.send(html); } + + /** + * Redirect user to Google consent screen + * + * @param res Express response object + * @returns Redirects to Google OAuth authorization URL + */ + @Public() + @Get("google") + async googleAuth(@Res() res: Response) { + const state = crypto.randomUUID(); + const url = await this.googleOAuthService.getAuthorizationUrl(state); + return res.redirect(url); + } + + /** + * Google OAuth callback - Google redirects here with code + * + * @param code Authorization code from Google + * @param state State parameter for CSRF validation + * @param error Error parameter if user denied consent + * @param res Express response object + * @returns Redirects to frontend with token + */ + @Public() + @Get("google-callback") + async googleCallback( + @Query("code") code: string, + @Query("state") state: string, + @Query("error") error: string, + @Res() res: Response, + ) { + const redirectUri = `${getFrontendBaseUrl()}/api/auth/google-callback`; + + if (error || !code) { + return res.redirect(`${getFrontendBaseUrl()}/login?error=${error || "missing_code"}`); + } + + const { token } = await this.googleOAuthService.handleCallback(code, state, redirectUri); + console.log("Google OAuth callback - token:", token); + return res.redirect(`${getFrontendBaseUrl()}/login/oauth-callback?provider=google&token=${token}`); + } } diff --git a/packages/api/src/modules/channel/channel.module.ts b/packages/api/src/modules/channel/channel.module.ts index 26dee6677..d66cdd41a 100644 --- a/packages/api/src/modules/channel/channel.module.ts +++ b/packages/api/src/modules/channel/channel.module.ts @@ -2,14 +2,16 @@ import { TypeOrmModule } from "@buildingai/db/@nestjs/typeorm"; import { Dict } from "@buildingai/db/entities"; import { DictCacheService } from "@buildingai/dict"; import { DictService } from "@buildingai/dict"; +import { GoogleConfigConsoleController } from "@modules/channel/controller/console/google-config.controller"; +import { GoogleConfigService } from "@modules/channel/services/google-config.service"; import { WxOaConfigConsoleController } from "@modules/channel/controller/console/wxoaconfig.controller"; import { WxOaConfigService } from "@modules/channel/services/wxoaconfig.service"; import { Module } from "@nestjs/common"; @Module({ imports: [TypeOrmModule.forFeature([Dict])], - controllers: [WxOaConfigConsoleController], - providers: [WxOaConfigService, DictService, DictCacheService], - exports: [WxOaConfigService], + controllers: [WxOaConfigConsoleController, GoogleConfigConsoleController], + providers: [WxOaConfigService, GoogleConfigService, DictService, DictCacheService], + exports: [WxOaConfigService, GoogleConfigService], }) export class ChannelModule {} diff --git a/packages/api/src/modules/channel/controller/console/google-config.controller.ts b/packages/api/src/modules/channel/controller/console/google-config.controller.ts new file mode 100644 index 000000000..eb5d869fb --- /dev/null +++ b/packages/api/src/modules/channel/controller/console/google-config.controller.ts @@ -0,0 +1,38 @@ +import { BaseController } from "@buildingai/base"; +import { Permissions } from "@common/decorators"; +import { ConsoleController } from "@common/decorators/controller.decorator"; +import { UpdateGoogleConfigDto } from "@modules/channel/dto/google-config.dto"; +import { GoogleConfigService } from "@modules/channel/services/google-config.service"; +import { Body, Get, Patch } from "@nestjs/common"; + +@ConsoleController("google-config", "Google登录配置") +export class GoogleConfigConsoleController extends BaseController { + constructor(private readonly googleConfigService: GoogleConfigService) { + super(); + } + /** + * 获取Google登录配置 + * @returns Google登录配置 + */ + @Get() + @Permissions({ + code: "google-config:get-config", + name: "获取Google登录配置", + }) + getConfig() { + return this.googleConfigService.getConfig(); + } + /** + * 更新Google登录配置 + * @param updateGoogleConfigDto 更新配置DTO + * @returns 更新后的配置 + */ + @Patch() + @Permissions({ + code: "google-config:update-config", + name: "更新Google登录配置", + }) + update(@Body() updateGoogleConfigDto: UpdateGoogleConfigDto) { + return this.googleConfigService.updateConfig(updateGoogleConfigDto); + } +} diff --git a/packages/api/src/modules/channel/dto/google-config.dto.ts b/packages/api/src/modules/channel/dto/google-config.dto.ts new file mode 100644 index 000000000..36af27487 --- /dev/null +++ b/packages/api/src/modules/channel/dto/google-config.dto.ts @@ -0,0 +1,15 @@ +import { IsString, IsOptional, IsBoolean } from 'class-validator'; + +export class UpdateGoogleConfigDto { + @IsString() + @IsOptional() + clientId?: string; + + @IsString() + @IsOptional() + clientSecret?: string; + + @IsBoolean() + @IsOptional() + enabled?: boolean; +} diff --git a/packages/api/src/modules/channel/services/google-config.service.ts b/packages/api/src/modules/channel/services/google-config.service.ts new file mode 100644 index 000000000..64c977115 --- /dev/null +++ b/packages/api/src/modules/channel/services/google-config.service.ts @@ -0,0 +1,74 @@ +import { BaseService } from "@buildingai/base"; +import { InjectRepository } from "@buildingai/db/@nestjs/typeorm"; +import { Dict } from "@buildingai/db/entities"; +import { Repository } from "@buildingai/db/typeorm"; +import { DictService } from "@buildingai/dict"; +import { UpdateGoogleConfigDto } from "@modules/channel/dto/google-config.dto"; +import { Injectable } from "@nestjs/common"; +import { EventEmitter2 } from "@nestjs/event-emitter"; + +export const GOOGLE_EVENTS = { + REFRESH: "google.access_token.refresh", +} as const; + +export type GoogleConfig = { + clientId: string; + clientSecret: string; + enabled: boolean; +}; + +@Injectable() +export class GoogleConfigService extends BaseService { + private readonly GROUP = "googleConfig"; + + constructor( + private readonly dictService: DictService, + @InjectRepository(Dict) repository: Repository, + private readonly eventEmitter: EventEmitter2, + ) { + super(repository); + } + + async getConfig(): Promise { + const defaultConfig: GoogleConfig = { + clientId: "", + clientSecret: "", + enabled: false, + }; + + try { + const items = await this.dictService.findAll({ + where: { group: this.GROUP }, + }); + + if (items.length === 0) { + return defaultConfig; + } + + return { + clientId: items.find((i) => i.key === "clientId")?.value || "", + clientSecret: items.find((i) => i.key === "clientSecret")?.value || "", + enabled: items.find((i) => i.key === "enabled")?.value === "true", + }; + } catch (error) { + this.logger.error(`获取 ${this.GROUP} 配置失败: ${error.message}`); + return defaultConfig; + } + } + + async updateConfig(dto: UpdateGoogleConfigDto): Promise { + try { + const entries = Object.entries(dto).filter(([, v]) => v !== undefined); + for (const [key, value] of entries) { + await this.dictService.set(key, String(value), { + group: this.GROUP, + description: `配置 - ${key}`, + }); + } + this.eventEmitter.emit(GOOGLE_EVENTS.REFRESH); + } catch (error) { + this.logger.error(`更新 ${this.GROUP} 配置失败: ${error.message}`); + throw error; + } + } +} diff --git a/packages/api/src/modules/extension/extension.module.ts b/packages/api/src/modules/extension/extension.module.ts index b23dc5970..7b0773515 100644 --- a/packages/api/src/modules/extension/extension.module.ts +++ b/packages/api/src/modules/extension/extension.module.ts @@ -10,7 +10,7 @@ import { Extension } from "@buildingai/db/entities"; import { DataSource } from "@buildingai/db/typeorm"; import { TerminalLogger } from "@buildingai/logger"; import { ExtensionFeatureScanService } from "@common/modules/auth/services/extension-feature-scan.service"; -import { DynamicModule, Logger, Module, OnModuleInit } from "@nestjs/common"; +import { DynamicModule, forwardRef, Logger, Module, OnModuleInit } from "@nestjs/common"; import { ModuleRef } from "@nestjs/core"; import { AuthModule } from "../auth/auth.module"; @@ -51,7 +51,7 @@ export class ExtensionCoreModule implements OnModuleInit { return { module: ExtensionCoreModule, imports: [ - AuthModule, + forwardRef(() => AuthModule), Pm2Module, UploadModule, TypeOrmModule.forFeature([Extension]), diff --git a/packages/api/src/modules/system/system.module.ts b/packages/api/src/modules/system/system.module.ts index 80644ab97..701fc67d5 100644 --- a/packages/api/src/modules/system/system.module.ts +++ b/packages/api/src/modules/system/system.module.ts @@ -30,7 +30,7 @@ import { WebsiteService } from "./services/website.service"; */ @Module({ imports: [ - AuthModule, + forwardRef(() => AuthModule), CloudStorageModule, TypeOrmModule.forFeature([ Dict, diff --git a/packages/api/src/modules/upload/upload.module.ts b/packages/api/src/modules/upload/upload.module.ts index ae1e67fd9..8e6ad44a6 100644 --- a/packages/api/src/modules/upload/upload.module.ts +++ b/packages/api/src/modules/upload/upload.module.ts @@ -4,7 +4,7 @@ import { TypeOrmModule } from "@buildingai/db/@nestjs/typeorm"; import { File } from "@buildingai/db/entities"; import { SystemModule } from "@modules/system/system.module"; import { UserModule } from "@modules/user/user.module"; -import { Module } from "@nestjs/common"; +import { forwardRef, Module } from "@nestjs/common"; import { MulterModule } from "@nestjs/platform-express"; import { memoryStorage } from "multer"; @@ -26,7 +26,7 @@ import { UploadService } from "./services/upload.service"; storage: memoryStorage(), }), RedisModule, - UserModule, + forwardRef(() => UserModule), ], controllers: [UploadController], providers: [UploadService], diff --git a/packages/api/src/modules/user/services/user.service.ts b/packages/api/src/modules/user/services/user.service.ts index 57f340f8c..389b20861 100644 --- a/packages/api/src/modules/user/services/user.service.ts +++ b/packages/api/src/modules/user/services/user.service.ts @@ -868,6 +868,41 @@ export class UserService extends BaseService { return result; } + /** + * 根据 Google OpenID 查找用户 + * + * @param googleOpenid Google OpenID + * @returns 用户或 null + */ + async findByGoogleOpenid(googleOpenid: string): Promise { + return this.userRepository.findOne({ where: { googleOpenid } }); + } + + /** + * 通过 Google 创建用户 + * + * @param data 用户数据 + * @returns 创建的用户 + */ + async createByGoogle(data: { + googleOpenid: string; + email?: string; + nickname?: string; + avatar?: string; + }): Promise { + const user = this.userRepository.create({ + googleOpenid: data.googleOpenid, + email: data.email, + nickname: data.nickname || data.email?.split("@")[0] || "Google User", + username: `google_${data.googleOpenid.slice(0, 8)}`, + avatar: data.avatar, + password: "GOOGLE_AUTH_CREDENTIALS", + source: UserCreateSource.GOOGLE, + status: 1, + }); + return await this.userRepository.save(user); + } + /** * 获取用户当前最高会员等级ID * diff --git a/packages/api/src/modules/user/user.module.ts b/packages/api/src/modules/user/user.module.ts index 7fbcbf577..ce53166b7 100644 --- a/packages/api/src/modules/user/user.module.ts +++ b/packages/api/src/modules/user/user.module.ts @@ -43,7 +43,7 @@ import { UserCapacityService } from "./services/user-capacity.service"; Department, DepartmentUserIndex, ]), - AuthModule, + forwardRef(() => AuthModule), SmsModule, MenuModule, RoleModule, diff --git a/packages/client/src/layouts/console/index.tsx b/packages/client/src/layouts/console/index.tsx index 0454e228f..312eff076 100644 --- a/packages/client/src/layouts/console/index.tsx +++ b/packages/client/src/layouts/console/index.tsx @@ -18,6 +18,7 @@ import DatasetsIndexPage from "@/pages/console/ai/datasets/list"; import AiMcpIndexPage from "@/pages/console/ai/mcp"; import AiProviderIndexPage from "@/pages/console/ai/provider"; import AiSecretIndexPage from "@/pages/console/ai/secret"; +import ChannelGoogleIndexPage from "@/pages/console/channel/google"; import ChannelWechatOaIndexPage from "@/pages/console/channel/wechat-oa"; import ChatConfigIndexPage from "@/pages/console/chat/config"; import ChatRecordIndexPage from "@/pages/console/chat/record"; @@ -201,6 +202,10 @@ function ConsoleRoutes() { path: "wechat-oa", element: , }, + { + path: "google", + element: , + }, ], }, { diff --git a/packages/client/src/pages/console/channel/google/index.tsx b/packages/client/src/pages/console/channel/google/index.tsx new file mode 100644 index 000000000..5551ffc4b --- /dev/null +++ b/packages/client/src/pages/console/channel/google/index.tsx @@ -0,0 +1,173 @@ +import { + useUpdateGoogleConfigMutation, + useGoogleConfigQuery, + type GoogleConfigResponse, +} from "@buildingai/services/console"; +import { PermissionGuard } from "@buildingai/ui/components/auth/permission-guard"; +import { Alert, AlertTitle } from "@buildingai/ui/components/ui/alert"; +import { Button } from "@buildingai/ui/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@buildingai/ui/components/ui/card"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@buildingai/ui/components/ui/field"; +import { Input } from "@buildingai/ui/components/ui/input"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@buildingai/ui/components/ui/input-group"; +import { Copy, ShieldCheck } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { toast } from "sonner"; + +import { PageContainer } from "@/layouts/console/_components/page-container"; + +const GOOGLE_CALLBACK_URL = `${window.location.origin}/api/auth/google-callback`; + +const GoogleIndexPage = () => { + const { data, isLoading } = useGoogleConfigQuery(); + const config = data as GoogleConfigResponse | undefined; + const updateMutation = useUpdateGoogleConfigMutation({ + onSuccess: () => toast.success("保存成功"), + onError: (e) => toast.error(`保存失败: ${e.message}`), + }); + + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [enabled, setEnabled] = useState(false); + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text).then(() => toast.success("已复制")); + }; + + useEffect(() => { + if (!config) return; + setClientId(config.clientId ?? ""); + setClientSecret(config.clientSecret ?? ""); + setEnabled(config.enabled ?? false); + }, [config]); + + const handleSave = () => { + if (!clientId.trim()) { + toast.error("请填写 Client ID"); + return; + } + if (!clientSecret.trim()) { + toast.error("请填写 Client Secret"); + return; + } + updateMutation.mutate({ + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + enabled, + }); + }; + + return ( + +
+
+

Google登录配置

+ + + +
+ + + + +
请先前往 Google Cloud Console 申请 OAuth 2.0 客户端ID
+
+ + 前往 Google Cloud Console + + +
+
+
+ + +
+ + + Google OAuth 配置 + + 登录 Google Cloud Console,点击 APIs & Services > Credentials,创建 OAuth 2.0 Client ID + + + + + 回调地址 + + 登录 Google Cloud Console,在 OAuth 2.0 Client ID 的已授权重定向 URI 中添加以下地址 + + + + + copyToClipboard(GOOGLE_CALLBACK_URL)} + > + + + + + + + + * Client ID + + setClientId(e.target.value)} + placeholder="粘贴 Google Client ID" + disabled={isLoading} + /> + + + + * Client Secret + + setClientSecret(e.target.value)} + placeholder="粘贴 Google Client Secret" + disabled={isLoading} + /> + + + +
+
+
+
+ ); +}; + +export default GoogleIndexPage; diff --git a/packages/client/src/pages/console/system/login-config/index.tsx b/packages/client/src/pages/console/system/login-config/index.tsx index 9a2f26b07..843e36908 100644 --- a/packages/client/src/pages/console/system/login-config/index.tsx +++ b/packages/client/src/pages/console/system/login-config/index.tsx @@ -23,6 +23,7 @@ const LOGIN_TYPE_OPTIONS: { value: LoginType; label: string }[] = [ { value: LOGIN_TYPE.ACCOUNT as LoginType, label: "账号" }, { value: LOGIN_TYPE.WECHAT as LoginType, label: "微信" }, { value: LOGIN_TYPE.PHONE as LoginType, label: "手机号" }, + { value: LOGIN_TYPE.GOOGLE as LoginType, label: "Google" }, ]; const defaultConfig = { @@ -190,6 +191,10 @@ const SystemLoginConfigIndexPage = () => { 渠道 - 微信公众号配置 {" "} + 中设置,Google 登录凭证请在{" "} + + 渠道 - Google登录配置 + {" "} 中设置 diff --git a/packages/client/src/pages/login/_components/login-form.tsx b/packages/client/src/pages/login/_components/login-form.tsx index 9a90e9cbc..b887c0dca 100644 --- a/packages/client/src/pages/login/_components/login-form.tsx +++ b/packages/client/src/pages/login/_components/login-form.tsx @@ -171,6 +171,7 @@ export function LoginForm({ className, ...props }: React.ComponentProps<"div">) loginSettings?.allowedLoginMethods?.includes(LOGIN_TYPE.ACCOUNT) ?? true; const allowPhoneLogin = loginSettings?.allowedLoginMethods?.includes(LOGIN_TYPE.PHONE) ?? false; const allowWechatLogin = loginSettings?.allowedLoginMethods?.includes(LOGIN_TYPE.WECHAT) ?? true; + const allowGoogleLogin = loginSettings?.allowedLoginMethods?.includes(LOGIN_TYPE.GOOGLE) ?? true; const allowAccountRegister = loginSettings?.allowedRegisterMethods?.includes(LOGIN_TYPE.ACCOUNT) ?? true; const allowPhoneRegister = @@ -609,6 +610,18 @@ export function LoginForm({ className, ...props }: React.ComponentProps<"div">) )} )} + {allowGoogleLogin && ( + + + + )} {allowWechatLogin && canUseAccountInput && ( 或使用账号登录 diff --git a/packages/client/src/pages/login/oauth-callback.tsx b/packages/client/src/pages/login/oauth-callback.tsx index 21297f019..9d3a9dda5 100644 --- a/packages/client/src/pages/login/oauth-callback.tsx +++ b/packages/client/src/pages/login/oauth-callback.tsx @@ -11,9 +11,18 @@ const OAuthCallbackPage = () => { const [error, setError] = useState(null); const code = useMemo(() => searchParams.get("code"), [searchParams]); + const provider = useMemo(() => searchParams.get("provider"), [searchParams]); + const token = useMemo(() => searchParams.get("token"), [searchParams]); const redirect = useMemo(() => searchParams.get("redirect") || "/", [searchParams]); useEffect(() => { + if (provider === "google" && token) { + setToken(token); + window.history.replaceState(null, "", window.location.pathname); + navigate(redirect, { replace: true }); + return; + } + if (!code) { setError("missing_code"); return; @@ -35,7 +44,7 @@ const OAuthCallbackPage = () => { return () => { cancelled = true; }; - }, [code, redirect, setToken, navigate]); + }, [code, provider, token, redirect, setToken, navigate]); if (error) { return ( diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts index 1b0b72d83..129ac7f08 100644 --- a/packages/client/vite.config.ts +++ b/packages/client/vite.config.ts @@ -32,14 +32,20 @@ export default defineConfig({ strictPort: true, hmr: host ? { - protocol: "ws", - host, - port: 1421, - } + protocol: "ws", + host, + port: 1421, + } : undefined, watch: { ignored: ["**/src-tauri/**"], }, + proxy: { + "/api": { + target: "http://localhost:4090", + changeOrigin: true, + }, + }, }, build: { sourcemap: false, From 00e9d5e702084d936874807ebe0fe37b71ebd5fe Mon Sep 17 00:00:00 2001 From: Joey Zhong Date: Tue, 7 Apr 2026 12:17:49 +0100 Subject: [PATCH 2/3] feat: improve i18n to support english --- .../console/_components/app-navbar.tsx | 4 +- .../console/_components/console-logo.tsx | 4 +- .../console/_components/nav-user.tsx | 16 +- .../default/_components/default-nav-main.tsx | 24 +- .../default/_components/default-sidebar.tsx | 6 +- .../src/components/agreement-dialog.tsx | 6 +- .../src/components/ask-assistant-ui/chat.tsx | 17 +- .../components/input/prompt-input.tsx | 14 +- .../components/input/voice-input.tsx | 10 +- .../components/mcp-selector.tsx | 20 +- .../components/message/file-parse-queue.tsx | 6 +- .../components/message/message-actions.tsx | 16 +- .../components/message/message-context.tsx | 18 +- .../components/message/message-feedback.tsx | 66 +- .../components/message/message-usage.tsx | 28 +- .../message/user-message-actions.tsx | 10 +- .../components/model-selector.tsx | 21 +- .../tools/image-generation-tool.tsx | 16 +- .../components/tools/knowledge-references.tsx | 6 +- .../components/tools/plan-tool.tsx | 10 +- .../components/tools/weather-tool.tsx | 8 +- .../components/tools/weather.tsx | 4 +- .../src/components/right-floating-panel.tsx | 5 +- .../settings-dialog-provider.tsx | 8 +- .../settings-items/profile-setting.tsx | 122 +-- .../settings-items/recharge-detail-dialog.tsx | 56 +- .../settings-items/subscribe-setting.tsx | 46 +- .../_components/mcp-form-dialog.tsx | 15 +- .../settings-items/wallet-setting.tsx | 16 +- .../components/tags/manage-tags-dialog.tsx | 20 +- .../client/src/components/tags/tag-create.tsx | 12 +- .../client/src/components/tags/tag-select.tsx | 17 +- .../console/_components/app-navbar.tsx | 19 +- .../layouts/console/_components/nav-main.tsx | 23 +- .../layouts/console/_components/nav-user.tsx | 22 +- packages/client/src/layouts/console/index.tsx | 7 +- packages/client/src/locales/en-US/access.ts | 156 ++++ packages/client/src/locales/en-US/agent.ts | 591 ++++++++++++++ packages/client/src/locales/en-US/ai.ts | 727 ++++++++++++++++++ packages/client/src/locales/en-US/channel.ts | 102 +++ packages/client/src/locales/en-US/chat.ts | 159 ++++ packages/client/src/locales/en-US/common.ts | 160 ++++ .../client/src/locales/en-US/dashboard.ts | 81 ++ packages/client/src/locales/en-US/dataset.ts | 404 ++++++++++ packages/client/src/locales/en-US/decorate.ts | 170 ++++ .../client/src/locales/en-US/extension.ts | 146 ++++ .../client/src/locales/en-US/financial.ts | 155 ++++ packages/client/src/locales/en-US/index.ts | 249 ++++++ packages/client/src/locales/en-US/mcp.ts | 126 +++ packages/client/src/locales/en-US/notice.ts | 90 +++ .../client/src/locales/en-US/operation.ts | 354 +++++++++ packages/client/src/locales/en-US/provider.ts | 58 ++ packages/client/src/locales/en-US/settings.ts | 52 ++ packages/client/src/locales/en-US/sidebar.ts | 85 ++ packages/client/src/locales/en-US/system.ts | 256 ++++++ packages/client/src/locales/en-US/user.ts | 242 ++++++ packages/client/src/locales/zh-CN/access.ts | 123 +++ packages/client/src/locales/zh-CN/agent.ts | 544 +++++++++++++ packages/client/src/locales/zh-CN/ai.ts | 680 ++++++++++++++++ packages/client/src/locales/zh-CN/channel.ts | 94 +++ packages/client/src/locales/zh-CN/chat.ts | 144 ++++ packages/client/src/locales/zh-CN/common.ts | 157 ++++ .../client/src/locales/zh-CN/dashboard.ts | 81 ++ packages/client/src/locales/zh-CN/dataset.ts | 293 +++++++ packages/client/src/locales/zh-CN/decorate.ts | 265 +++++++ .../client/src/locales/zh-CN/extension.ts | 146 ++++ .../client/src/locales/zh-CN/financial.ts | 153 ++++ packages/client/src/locales/zh-CN/index.ts | 247 ++++++ packages/client/src/locales/zh-CN/mcp.ts | 126 +++ packages/client/src/locales/zh-CN/notice.ts | 89 +++ .../client/src/locales/zh-CN/operation.ts | 313 ++++++++ packages/client/src/locales/zh-CN/provider.ts | 56 ++ packages/client/src/locales/zh-CN/settings.ts | 52 ++ packages/client/src/locales/zh-CN/sidebar.ts | 85 ++ packages/client/src/locales/zh-CN/system.ts | 255 ++++++ packages/client/src/locales/zh-CN/user.ts | 200 +++++ .../pages/agents/_components/agent-modal.tsx | 46 +- .../configuration/debugging/index.tsx | 73 +- .../configuration/function/form-variables.tsx | 83 +- .../configuration/function/knowledge-base.tsx | 56 +- .../_components/configuration/index.tsx | 36 +- .../interface/auto-follow-up.tsx | 28 +- .../configuration/interface/chat-avatar.tsx | 16 +- .../interface/quick-commands.tsx | 48 +- .../interface/starter-questions.tsx | 8 +- .../configuration/model/model-selector.tsx | 114 +-- .../model/voice-config-selector.tsx | 34 +- .../configuration/publish-dialog.tsx | 54 +- .../detail/_components/monitoring/index.tsx | 108 ++- .../publish/api-publish-dialog.tsx | 23 +- .../publish/embed-publish-dialog.tsx | 61 +- .../detail/_components/publish/index.tsx | 45 +- .../detail/_hooks/use-agent-feedback.ts | 12 +- .../pages/agents/detail/_layouts/sidebar.tsx | 22 +- .../src/pages/agents/detail/chat/index.tsx | 66 +- packages/client/src/pages/agents/index.tsx | 31 +- .../_hooks/use-public-agent-assistant.ts | 4 +- .../src/pages/agents/site-chat/index.tsx | 41 +- .../services/public-conversations.ts | 5 +- .../client/src/pages/agents/workspace.tsx | 56 +- packages/client/src/pages/apps/index.tsx | 29 +- packages/client/src/pages/chat/index.tsx | 20 +- .../src/pages/console/access/menu/index.tsx | 130 ++-- .../pages/console/access/permission/index.tsx | 14 +- .../_components/assign-permissions-dialog.tsx | 38 +- .../role/_components/edit-role-dialog.tsx | 33 +- .../src/pages/console/access/role/index.tsx | 48 +- .../pages/console/ai/agent/config/index.tsx | 46 +- .../_components/agent-dashboard-panel.tsx | 104 ++- .../list/_components/dashboard-dialog.tsx | 10 +- .../agent/list/_components/review-dialog.tsx | 59 +- .../src/pages/console/ai/agent/list/index.tsx | 139 ++-- .../console/ai/datasets/config/index.tsx | 38 +- .../config/retrieval-config/index.tsx | 53 +- .../retrieval-config/retrieval-params.tsx | 84 +- .../list/_components/member-dialog.tsx | 70 +- .../list/_components/review-dialog.tsx | 26 +- .../list/_components/vector-config-dialog.tsx | 18 +- .../pages/console/ai/datasets/list/index.tsx | 131 ++-- .../ai/mcp/_components/mcp-form-dialog.tsx | 83 +- .../ai/mcp/_components/mcp-import-dialog.tsx | 26 +- .../ai/mcp/_components/mcp-tools-dialog.tsx | 8 +- .../client/src/pages/console/ai/mcp/index.tsx | 77 +- .../_components/model-form-dialog.tsx | 22 +- .../_components/provider-form-dialog.tsx | 86 ++- .../src/pages/console/ai/provider/index.tsx | 105 ++- .../secret-template-form-dialog.tsx | 95 ++- .../_components/secret-template-manage.tsx | 150 ++-- .../pages/console/channel/google/index.tsx | 54 +- .../pages/console/channel/wechat-oa/index.tsx | 106 +-- .../src/pages/console/chat/config/index.tsx | 112 +-- .../record/conversation-messages-drawer.tsx | 34 +- .../src/pages/console/chat/record/index.tsx | 61 +- .../dashboard/_components/line-chart.tsx | 23 +- .../src/pages/console/dashboard/index.tsx | 138 ++-- .../agent/_components/add-tag-dialog.tsx | 22 +- .../_components/decorate-settings-dialog.tsx | 38 +- .../pages/console/decorate/agent/index.tsx | 48 +- .../apps/_components/add-tag-dialog.tsx | 22 +- .../apps/_components/app-item-edit-dialog.tsx | 36 +- .../_components/decorate-settings-dialog.tsx | 38 +- .../src/pages/console/decorate/apps/index.tsx | 39 +- .../_components/decorate-layout-sidebar.tsx | 124 +-- .../_components/activation-install-dialog.tsx | 47 +- .../_components/extension-detail-sheet.tsx | 88 ++- .../_components/extension-form-dialog.tsx | 174 +++-- .../src/pages/console/extension/index.tsx | 114 +-- .../console/financial/analysis/index.tsx | 118 ++- .../financial/balance-details/index.tsx | 30 +- .../notice/notification-settings/index.tsx | 70 +- .../notice/sms/_components/aliyunSms.tsx | 50 +- .../notice/sms/_components/tencentSms.tsx | 59 +- .../src/pages/console/notice/sms/index.tsx | 8 +- .../operation/_config/sidebar-config.ts | 34 +- .../console/operation/_layouts/sidebar.tsx | 10 +- .../_components/batch-detail-dialog.tsx | 95 ++- .../_components/create-batch-dialog.tsx | 120 +-- .../operation/cdk/management/index.tsx | 68 +- .../console/operation/cdk/records/index.tsx | 38 +- .../console/operation/cdk/settings/index.tsx | 28 +- .../src/pages/console/operation/index.tsx | 8 +- .../level/_components/level-form-dialog.tsx | 89 ++- .../operation/membership/level/index.tsx | 71 +- .../plan/_components/plan-form-dialog.tsx | 222 +++--- .../operation/membership/plan/index.tsx | 124 +-- .../console/operation/recharge/index.tsx | 60 +- .../_components/order-detail-dialog.tsx | 69 +- .../pages/console/order/membership/index.tsx | 120 +-- .../_components/order-detail-dialog.tsx | 67 +- .../pages/console/order/recharge/index.tsx | 127 +-- .../pages/console/system/agreement/index.tsx | 28 +- .../console/system/login-config/index.tsx | 62 +- .../_components/pay-config-form-dialog.tsx | 182 +++-- .../pages/console/system/pay-config/index.tsx | 58 +- .../storage-config/_components/local.tsx | 26 +- .../system/storage-config/_components/oss.tsx | 72 +- .../console/system/storage-config/index.tsx | 12 +- .../website-config/_components/copyright.tsx | 49 +- .../_components/information.tsx | 62 +- .../website-config/_components/statistics.tsx | 19 +- .../console/system/website-config/index.tsx | 10 +- .../_components/balance-adjustment-dialog.tsx | 56 +- .../membership-adjustment-dialog.tsx | 73 +- .../_components/reset-password-dialog.tsx | 58 +- .../subscription-records-dialog.tsx | 30 +- .../list/_components/user-form-dialog.tsx | 112 ++- .../src/pages/console/user/list/index.tsx | 70 +- .../src/pages/datasets/_layouts/sidebar.tsx | 44 +- .../_components/chat/chat-container.tsx | 24 +- .../detail/_components/chat/chat-history.tsx | 12 +- .../detail/_components/chat/chat-input.tsx | 4 +- .../detail/_components/chat/chat-welcome.tsx | 12 +- .../detail/_components/dataset-actions.tsx | 41 +- .../detail/_components/dataset-info.tsx | 12 +- .../dialogs/dataset-edit-dialog.tsx | 33 +- .../_components/dialogs/edit-tags-dialog.tsx | 21 +- .../_components/dialogs/member-dialog.tsx | 69 +- .../_components/dialogs/publish-dialog.tsx | 51 +- .../_components/dialogs/transfer-dialog.tsx | 55 +- .../_components/dialogs/upload-dialog.tsx | 16 +- .../_components/document-batch-actions.tsx | 13 +- .../detail/_components/document-drop-zone.tsx | 10 +- .../detail/_components/document-empty.tsx | 4 +- .../detail/_components/document-list.tsx | 26 +- .../detail/_components/document-preview.tsx | 4 +- .../detail/_components/document-table.tsx | 42 +- .../detail/_components/document-tabs.tsx | 14 +- .../datasets/detail/_layouts/page-layout.tsx | 6 +- .../detail/hooks/use-document-upload.ts | 6 +- .../src/pages/datasets/detail/index.tsx | 33 +- packages/client/src/pages/datasets/index.tsx | 29 +- .../_components/admiin-account-form.tsx | 71 +- .../install/_components/initial-success.tsx | 10 +- .../_components/website-setting-form.tsx | 32 +- .../install/_components/welcome-animate.tsx | 20 +- packages/client/src/pages/install/index.tsx | 10 +- .../pages/login/_components/login-form.tsx | 244 +++--- .../client/src/pages/login/oauth-callback.tsx | 8 +- .../src/pages/payment/alipay-return/index.tsx | 22 +- 219 files changed, 13540 insertions(+), 3436 deletions(-) create mode 100644 packages/client/src/locales/en-US/access.ts create mode 100644 packages/client/src/locales/en-US/agent.ts create mode 100644 packages/client/src/locales/en-US/ai.ts create mode 100644 packages/client/src/locales/en-US/channel.ts create mode 100644 packages/client/src/locales/en-US/chat.ts create mode 100644 packages/client/src/locales/en-US/dashboard.ts create mode 100644 packages/client/src/locales/en-US/dataset.ts create mode 100644 packages/client/src/locales/en-US/decorate.ts create mode 100644 packages/client/src/locales/en-US/extension.ts create mode 100644 packages/client/src/locales/en-US/financial.ts create mode 100644 packages/client/src/locales/en-US/mcp.ts create mode 100644 packages/client/src/locales/en-US/notice.ts create mode 100644 packages/client/src/locales/en-US/operation.ts create mode 100644 packages/client/src/locales/en-US/provider.ts create mode 100644 packages/client/src/locales/en-US/sidebar.ts create mode 100644 packages/client/src/locales/en-US/system.ts create mode 100644 packages/client/src/locales/en-US/user.ts create mode 100644 packages/client/src/locales/zh-CN/access.ts create mode 100644 packages/client/src/locales/zh-CN/agent.ts create mode 100644 packages/client/src/locales/zh-CN/ai.ts create mode 100644 packages/client/src/locales/zh-CN/channel.ts create mode 100644 packages/client/src/locales/zh-CN/chat.ts create mode 100644 packages/client/src/locales/zh-CN/dashboard.ts create mode 100644 packages/client/src/locales/zh-CN/dataset.ts create mode 100644 packages/client/src/locales/zh-CN/decorate.ts create mode 100644 packages/client/src/locales/zh-CN/extension.ts create mode 100644 packages/client/src/locales/zh-CN/financial.ts create mode 100644 packages/client/src/locales/zh-CN/mcp.ts create mode 100644 packages/client/src/locales/zh-CN/notice.ts create mode 100644 packages/client/src/locales/zh-CN/operation.ts create mode 100644 packages/client/src/locales/zh-CN/provider.ts create mode 100644 packages/client/src/locales/zh-CN/sidebar.ts create mode 100644 packages/client/src/locales/zh-CN/system.ts create mode 100644 packages/client/src/locales/zh-CN/user.ts diff --git a/packages/@buildingai/web/ui/src/layouts/extension/console/_components/app-navbar.tsx b/packages/@buildingai/web/ui/src/layouts/extension/console/_components/app-navbar.tsx index 52e303f59..75fd0d966 100644 --- a/packages/@buildingai/web/ui/src/layouts/extension/console/_components/app-navbar.tsx +++ b/packages/@buildingai/web/ui/src/layouts/extension/console/_components/app-navbar.tsx @@ -1,4 +1,5 @@ import { ReloadWindow } from "@buildingai/ui/components/reload-windows"; +import { useI18n } from "@buildingai/i18n"; import { Breadcrumb, BreadcrumbItem, @@ -61,6 +62,7 @@ function findBreadcrumbTrail( } const AppNavbar = ({ menus }: { menus: ExtensionMenuItem[] }) => { + const { t } = useI18n(); const { state } = useSidebar(); const location = useLocation(); const navigate = useNavigate(); @@ -78,7 +80,7 @@ const AppNavbar = ({ menus }: { menus: ExtensionMenuItem[] }) => { -

{state === "expanded" ? "收起侧边栏" : "展开侧边栏"}

+

{state === "expanded" ? t("common.collapseSidebar") : t("common.expandSidebar")}

diff --git a/packages/@buildingai/web/ui/src/layouts/extension/console/_components/console-logo.tsx b/packages/@buildingai/web/ui/src/layouts/extension/console/_components/console-logo.tsx index 85b94042b..0d5b6cec2 100644 --- a/packages/@buildingai/web/ui/src/layouts/extension/console/_components/console-logo.tsx +++ b/packages/@buildingai/web/ui/src/layouts/extension/console/_components/console-logo.tsx @@ -1,4 +1,5 @@ import { useExtensionDetailQuery } from "@buildingai/services/console"; +import { useI18n } from "@buildingai/i18n"; import { useConfigStore } from "@buildingai/stores"; import { Avatar, AvatarFallback, AvatarImage } from "@buildingai/ui/components/ui/avatar"; import { @@ -15,6 +16,7 @@ export interface ConsoleLogoProps { } export function ConsoleLogo({ identifier }: ConsoleLogoProps) { + const { t } = useI18n(); const { websiteConfig } = useConfigStore((state) => state.config); const { data: extension, isLoading } = useExtensionDetailQuery(identifier || "", { enabled: !!identifier, @@ -54,7 +56,7 @@ export function ConsoleLogo({ identifier }: ConsoleLogoProps) { {extension?.name || websiteConfig?.webinfo.name} - {extension?.description || "插件管理 · 工作台"} + {extension?.description || t("extension.pluginManagementWorkspace")} diff --git a/packages/@buildingai/web/ui/src/layouts/extension/console/_components/nav-user.tsx b/packages/@buildingai/web/ui/src/layouts/extension/console/_components/nav-user.tsx index 0b377c02d..8e29538b3 100644 --- a/packages/@buildingai/web/ui/src/layouts/extension/console/_components/nav-user.tsx +++ b/packages/@buildingai/web/ui/src/layouts/extension/console/_components/nav-user.tsx @@ -1,4 +1,5 @@ import { useAuthStore } from "@buildingai/stores"; +import { useI18n } from "@buildingai/i18n"; import { Avatar, AvatarFallback, AvatarImage } from "@buildingai/ui/components/ui/avatar"; import { DropdownMenu, @@ -18,6 +19,7 @@ import { ChevronsUpDown, LogOut, User } from "lucide-react"; import { useLocation, useNavigate } from "react-router-dom"; export function NavUser() { + const { t } = useI18n(); const { isMobile } = useSidebar(); const { userInfo } = useAuthStore((state) => state.auth); const { logout, isLogin } = useAuthStore((state) => state.authActions); @@ -48,13 +50,13 @@ export function NavUser() {
- {userInfo?.nickname || "未登录"} + {userInfo?.nickname || t("common.notLoggedIn")} {isLogin() ? isEnabled(userInfo?.isRoot) - ? "超级管理员" - : userInfo?.role?.name || "未设置角色" - : "请先登录后使用"} + ? t("common.superAdmin") + : userInfo?.role?.name || t("common.noRole") + : t("common.pleaseLoginFirst")}
@@ -69,8 +71,8 @@ export function NavUser() { { await confirm({ - title: "退出确认", - description: "确定要退出登录吗?", + title: t("common.logoutConfirm"), + description: t("common.logoutConfirmDesc"), }); await logout(); const redirect = encodeURIComponent(location.pathname + location.search); @@ -81,7 +83,7 @@ export function NavUser() { }} > - 退出登录 + {t("common.logout")} diff --git a/packages/@buildingai/web/ui/src/layouts/styles/default/_components/default-nav-main.tsx b/packages/@buildingai/web/ui/src/layouts/styles/default/_components/default-nav-main.tsx index de5943f8e..96dcf6bfa 100644 --- a/packages/@buildingai/web/ui/src/layouts/styles/default/_components/default-nav-main.tsx +++ b/packages/@buildingai/web/ui/src/layouts/styles/default/_components/default-nav-main.tsx @@ -1,5 +1,6 @@ "use client"; +import { useI18n } from "@buildingai/i18n"; import { type ConversationRecord, useConversationsQuery, @@ -444,6 +445,7 @@ function ChatHistoryMenuItem({ isActive: boolean; onOpenDialog: () => void; }) { + const { t } = useI18n(); const { pathname } = useLocation(); const { state } = useSidebar(); const { isLogin } = useAuthStore((state) => state.authActions); @@ -452,7 +454,7 @@ function ChatHistoryMenuItem({ return ( - {item.title} + {t(`sidebar.${item.id}`) || item.title} @@ -509,9 +511,9 @@ function ChatHistoryMenuItem({ - 查看全部 + {t("chat.viewAll")} - 查看全部 + {t("chat.viewAll")} path === pathname; + const { t } = useI18n(); return ( - + {item.icon && } - {item.title} + {t(`sidebar.${item.id}`)} @@ -570,11 +573,12 @@ function CollapsibleMenuItem({ item, isActive }: { item: NavItem; isActive: bool */ function LinkMenuItem({ item, isActive }: { item: NavItem; isActive: boolean }) { const isExternal = item.target === "_blank"; + const { t } = useI18n(); const content = ( <> {item.icon && } - {item.title} + {t(`sidebar.${item.id}`) || item.title} {item.action} {isExternal && ( @@ -585,7 +589,7 @@ function LinkMenuItem({ item, isActive }: { item: NavItem; isActive: boolean }) return ( state.authActions); @@ -712,7 +717,6 @@ export function DefaultNavMain({ const renderMenuItem = (item: NavItem) => { const hasItems = item.items && item.items.length > 0; const isChatHistory = item.id === "menu_history_fixed"; - if (isChatHistory) { return ( + {conversations.map((conversation) => ( ) { + const { t } = useI18n(); const navigate = useNavigate(); const { userInfo } = useAuthStore((state) => state.auth); const { data: menuConfig, isLoading: isMenuLoading } = useDecorateMenuQuery(); @@ -115,7 +117,7 @@ export function DefaultAppSidebar({ ...props }: React.ComponentProps conversationsData?.items?.map((conversation) => ({ id: `conversation-${conversation.id}`, - title: conversation.title || "新对话", + title: conversation.title || t("chat.newConversation"), path: `/c/${conversation.id}`, })) || [], [conversationsData], @@ -182,7 +184,7 @@ export function DefaultAppSidebar({ ...props }: React.ComponentProps - 工作台 + {t("common.workspace")}
diff --git a/packages/client/src/components/agreement-dialog.tsx b/packages/client/src/components/agreement-dialog.tsx index 4db512221..8c64467b6 100644 --- a/packages/client/src/components/agreement-dialog.tsx +++ b/packages/client/src/components/agreement-dialog.tsx @@ -1,4 +1,5 @@ import { useAgreementConfigQuery } from "@buildingai/services/web"; +import { useI18n } from "@buildingai/i18n"; import { EditorContentRenderer } from "@buildingai/ui/components/editor"; import { Dialog, @@ -17,6 +18,7 @@ type AgreementDialogProps = { }; const AgreementDialog = ({ open, onOpenChange, type }: AgreementDialogProps) => { + const { t } = useI18n(); const { data, isLoading } = useAgreementConfigQuery(); const agreement = data?.agreement; @@ -38,7 +40,7 @@ const AgreementDialog = ({ open, onOpenChange, type }: AgreementDialogProps) =>
{isLoading && ( -

正在加载协议内容…

+

{t("agreement.loadingContent")}

)}

{currentTitle}

@@ -48,7 +50,7 @@ const AgreementDialog = ({ open, onOpenChange, type }: AgreementDialogProps) => {!isLoading && !currentContent && (

- 暂未配置{isService ? "用户协议" : "隐私政策"}内容,请联系管理员在网站配置中补充。 + {isService ? t("agreement.notConfiguredService") : t("agreement.notConfiguredPrivacy")}

)}
diff --git a/packages/client/src/components/ask-assistant-ui/chat.tsx b/packages/client/src/components/ask-assistant-ui/chat.tsx index c3db70146..4328c634b 100644 --- a/packages/client/src/components/ask-assistant-ui/chat.tsx +++ b/packages/client/src/components/ask-assistant-ui/chat.tsx @@ -5,6 +5,7 @@ import { InfiniteScrollTopScrollButton, useInfiniteScrollTopContext, } from "@buildingai/ui/components/infinite-scroll-top"; +import { useI18n } from "@buildingai/i18n"; import { Button } from "@buildingai/ui/components/ui/button"; import { SidebarTrigger } from "@buildingai/ui/components/ui/sidebar"; import { cn } from "@buildingai/ui/lib/utils"; @@ -44,6 +45,7 @@ const ChatHeader = memo(function ChatHeader({ onSelectModel: (id: string) => void; onShare?: () => void; }) { + const { t } = useI18n(); const [modelSelectorOpen, setModelSelectorOpen] = useState(false); return ( @@ -70,7 +72,7 @@ const ChatHeader = memo(function ChatHeader({ {onShare && ( )} @@ -101,6 +103,7 @@ const WelcomeMessage = memo(function WelcomeMessage({ welcomeTitle?: string; welcomeDescription?: string; }) { + const { t } = useI18n(); const descContent = useMemo( () => parseWelcomeDescription(welcomeDescription), [welcomeDescription], @@ -109,7 +112,7 @@ const WelcomeMessage = memo(function WelcomeMessage({ return (
-

{welcomeTitle || "欢迎使用 AI 助手"}

+

{welcomeTitle || t("common.askAssistant.welcomeToAIAssistant")}

{descContent?.type === "rich" ? (
{descContent.value}

) : ( -

开始对话,或者选择一个推荐问题

+

{t("common.askAssistant.startConversationOrChoose")}

)}
@@ -127,10 +130,11 @@ const WelcomeMessage = memo(function WelcomeMessage({ }); const LoadingIndicator = memo(function LoadingIndicator() { + const { t } = useI18n(); return (
-

加载中...

+

{t("common.askAssistant.loading")}

); @@ -178,6 +182,7 @@ const InputArea = memo(function InputArea({ hasMessages: boolean; footerText?: string; }) { + const { t } = useI18n(); const { suggestions, status, textareaRef, isLoading, onSend, onStop, selectedModelId } = useAssistantContext(); const { id } = useParams<{ id: string }>(); @@ -186,7 +191,7 @@ const InputArea = memo(function InputArea({ const handleSubmit = useCallback( async (message: PromptInputMessage, _event: FormEvent) => { if (!selectedModelId) { - toast.warning("请先选择模型"); + toast.warning(t("common.askAssistant.pleaseSelectModelFirst")); throw new Error("No model selected"); } const text = message.text?.trim(); @@ -227,7 +232,7 @@ const InputArea = memo(function InputArea({ />
- {footerText || "内容由 AI 生成,请仔细甄别"} + {footerText || t("common.askAssistant.contentGeneratedByAI")}
); diff --git a/packages/client/src/components/ask-assistant-ui/components/input/prompt-input.tsx b/packages/client/src/components/ask-assistant-ui/components/input/prompt-input.tsx index 6eb1cd64e..c7c6d406d 100644 --- a/packages/client/src/components/ask-assistant-ui/components/input/prompt-input.tsx +++ b/packages/client/src/components/ask-assistant-ui/components/input/prompt-input.tsx @@ -1,3 +1,4 @@ +import { useI18n } from "@buildingai/i18n"; import { useMcpServerQuickMenuQuery, useMcpServersAllQuery } from "@buildingai/services/web"; import { PromptInputAttachment as AIPromptInputAttachment, @@ -153,6 +154,7 @@ const PromptInputInner = memo( children, }: PromptInputProps) => { const context = useContext(AssistantContext); + const { t } = useI18n(); const models = modelsProp ?? context?.models ?? []; const selectedModelId = selectedModelIdProp ?? context?.selectedModelId ?? ""; const selectedMcpServerIds = selectedMcpServerIdsProp ?? context?.selectedMcpServerIds ?? []; @@ -223,7 +225,7 @@ const PromptInputInner = memo( if (invalidFiles.length > 0) { const typeText = unsupportedTypeLabels.join("、"); - toast.error(`当前模型不支持${typeText}类型`); + toast.error(t("ai:askAssistant.promptInput.modelNotSupportType", { type: typeText })); } // Always prevent default to take full control of file handling @@ -257,7 +259,7 @@ const PromptInputInner = memo( items.unshift({ id: "thinking", icon: , - label: "思考", + label: t("ai:askAssistant.promptInput.thinking"), featureKey: "thinking", }); } @@ -345,14 +347,14 @@ const PromptInputInner = memo( -

更多操作

+

{t("ai:askAssistant.promptInput.moreActions")}

{showFile && ( - {hasImageSupport ? "选择照片和文件" : "选择文件"} + {hasImageSupport ? t("ai:askAssistant.promptInput.selectPhotosAndFiles") : t("ai:askAssistant.promptInput.selectFiles")} )} {showFeatureItems.map((item) => ( @@ -367,7 +369,7 @@ const PromptInputInner = memo( {showExploreApps && ( - 全部应用 + {t("ai:askAssistant.promptInput.allApps")} )} @@ -420,7 +422,7 @@ const PromptInputInner = memo(
-

语音输入

+

{t("ai:askAssistant.promptInput.voiceInput")}

) : null} diff --git a/packages/client/src/components/ask-assistant-ui/components/input/voice-input.tsx b/packages/client/src/components/ask-assistant-ui/components/input/voice-input.tsx index d9da9122a..4725ae8cd 100644 --- a/packages/client/src/components/ask-assistant-ui/components/input/voice-input.tsx +++ b/packages/client/src/components/ask-assistant-ui/components/input/voice-input.tsx @@ -1,5 +1,6 @@ "use client"; +import { useI18n } from "@buildingai/i18n"; import { Button } from "@buildingai/ui/components/ui/button"; import { Spinner } from "@buildingai/ui/components/ui/spinner"; import { cn } from "@buildingai/ui/lib/utils"; @@ -212,6 +213,7 @@ const VoiceInputInner = memo(function VoiceInputInner({ onTranscriptReceived, className, }: VoiceInputProps) { + const { t } = useI18n(); const [isRecording, setIsRecording] = useState(false); const [isProcessing, setIsProcessing] = useState(false); const [stream, setStream] = useState(null); @@ -352,7 +354,7 @@ const VoiceInputInner = memo(function VoiceInputInner({ style={{ width: "100%", height: "100%", imageRendering: "crisp-edges" }} /> ) : ( - 连接中… + {t("ai:askAssistant.voiceInput.connecting")} )} @@ -371,7 +373,7 @@ const VoiceInputInner = memo(function VoiceInputInner({ className="size-7 rounded-full" onClick={handleConfirm} disabled={isProcessing} - aria-label="确认" + aria-label={t("ai:askAssistant.voiceInput.confirm")} loading={isProcessing} > @@ -389,7 +391,7 @@ const VoiceInputInner = memo(function VoiceInputInner({ disabled={disabled} onClick={startRecording} loading={isProcessing} - aria-label="语音输入" + aria-label={t("ai:askAssistant.voiceInput.voiceInput")} > diff --git a/packages/client/src/components/ask-assistant-ui/components/mcp-selector.tsx b/packages/client/src/components/ask-assistant-ui/components/mcp-selector.tsx index 2e4a5220b..ac36683aa 100644 --- a/packages/client/src/components/ask-assistant-ui/components/mcp-selector.tsx +++ b/packages/client/src/components/ask-assistant-ui/components/mcp-selector.tsx @@ -1,4 +1,5 @@ import { useSettingsDialog } from "@buildingai/hooks"; +import { useI18n } from "@buildingai/i18n"; import type { McpServer, McpServerType } from "@buildingai/services/web"; import { PromptInputButton as AIPromptInputButton } from "@buildingai/ui/components/ai-elements/prompt-input"; import SvgIcons from "@buildingai/ui/components/svg-icons"; @@ -33,6 +34,7 @@ export interface McpSelectorProps { export const McpSelector = memo( ({ mcpServers, selectedMcpServerIds, onSelectionChange }: McpSelectorProps) => { + const { t } = useI18n(); const [open, setOpen] = useState(false); const [filterType, setFilterType] = useState("all"); @@ -88,7 +90,7 @@ export const McpSelector = memo( )} ) : ( - 工具 + {t("common.askAssistant.tools")} )} @@ -97,7 +99,7 @@ export const McpSelector = memo(
- +
- 全部({mcpServers.length}) + {t("common.askAssistant.all")}({mcpServers.length}) - 系统({systemServers.length}) + {t("common.askAssistant.system")}({systemServers.length}) - 我的({userServers.length}) + {t("common.askAssistant.mine")}({userServers.length})
- 未找到 MCP 服务 + {t("common.askAssistant.noMcpServicesFound")} {(filterType === "all" || filterType === "system") && systemServers.length > 0 && ( <> {systemServers.map((server) => { const isSelected = selectedMcpServerIds.includes(server.id); @@ -167,7 +169,7 @@ export const McpSelector = memo( {server.tools && server.tools.length > 0 && ( - {server.tools.length} 工具 + {t("common.askAssistant.toolsCount", { count: server.tools.length })} )} @@ -179,7 +181,7 @@ export const McpSelector = memo( )} {(filterType === "all" || filterType === "user") && userServers.length > 0 && ( {userServers.map((server) => { const isSelected = selectedMcpServerIds.includes(server.id); diff --git a/packages/client/src/components/ask-assistant-ui/components/message/file-parse-queue.tsx b/packages/client/src/components/ask-assistant-ui/components/message/file-parse-queue.tsx index e5d0e61a2..120636883 100644 --- a/packages/client/src/components/ask-assistant-ui/components/message/file-parse-queue.tsx +++ b/packages/client/src/components/ask-assistant-ui/components/message/file-parse-queue.tsx @@ -8,6 +8,7 @@ import { TaskTrigger, } from "@buildingai/ui/components/ai-elements/task"; import { cn } from "@buildingai/ui/lib/utils"; +import { useI18n } from "@buildingai/i18n"; import type { UIMessage } from "ai"; import { ChevronDownIcon, FileSearchCornerIcon, FileTextIcon, LoaderIcon } from "lucide-react"; @@ -81,6 +82,7 @@ const toMetadataParts = (parts?: UIMessage["parts"]): FileParseMetadataPart[] => (parts ?? []).filter(isFileParseMetadataPart); export const FileParseQueue = ({ messageId, parts, isStreaming }: FileParseQueueProps) => { + const { t } = useI18n(); const progressParts = toProgressParts(parts); const metadataParts = toMetadataParts(parts); const latestMetadata = metadataParts.at(-1)?.data; @@ -106,7 +108,7 @@ export const FileParseQueue = ({ messageId, parts, isStreaming }: FileParseQueue }; const progressPercentage = getProgressPercentage(latestProgress); - const title = progressPercentage !== undefined ? `文件解析完成` : "文件解析中"; + const title = progressPercentage !== undefined ? t("action.fileParsingComplete") : t("action.fileParsing"); const Icon = hasCompleted ? FileSearchCornerIcon : LoaderIcon; return ( @@ -145,7 +147,7 @@ export const FileParseQueue = ({ messageId, parts, isStreaming }: FileParseQueue {progressParts.length > 0 && progressParts.map((part, index) => { const progress = part.data; - const message = progress?.message?.trim() || progress?.stage || "处理中"; + const message = progress?.message?.trim() || progress?.stage || t("action.processing"); const percentage = getProgressPercentage(progress); // Remove percentage from message if it's already in the format diff --git a/packages/client/src/components/ask-assistant-ui/components/message/message-actions.tsx b/packages/client/src/components/ask-assistant-ui/components/message/message-actions.tsx index 7f641ed02..47df9cada 100644 --- a/packages/client/src/components/ask-assistant-ui/components/message/message-actions.tsx +++ b/packages/client/src/components/ask-assistant-ui/components/message/message-actions.tsx @@ -14,6 +14,7 @@ import { } from "lucide-react"; import { memo, type ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import { useI18n } from "@buildingai/i18n"; import { MessageContext } from "./message-context"; import { MessageUsage, type MessageUsageProps } from "./message-usage"; @@ -55,6 +56,7 @@ export const MessageActions = memo(function MessageActions({ onSpeak, extraActions, }: MessageActionsProps) { + const { t } = useI18n(); const [isCopied, setIsCopied] = useState(false); const [isTtsLoading, setIsTtsLoading] = useState(false); const [isTtsPlaying, setIsTtsPlaying] = useState(false); @@ -120,18 +122,18 @@ export const MessageActions = memo(function MessageActions({ return ( {onRetry && ( - + )} {hasValidMessageId && onLikeChange && !disliked && ( - + )} {hasValidMessageId && onDislikeChange && !liked && ( { if (disliked) { await handleDislike(); @@ -140,19 +142,19 @@ export const MessageActions = memo(function MessageActions({ onShowFeedbackCard?.(true); } }} - tooltip="不喜欢" + tooltip={t("action.dislike")} > )} - + {isCopied ? : } {onSpeak && content.trim() && ( {isTtsLoading ? ( diff --git a/packages/client/src/components/ask-assistant-ui/components/message/message-context.tsx b/packages/client/src/components/ask-assistant-ui/components/message/message-context.tsx index d2ff2abd1..c6b7456ed 100644 --- a/packages/client/src/components/ask-assistant-ui/components/message/message-context.tsx +++ b/packages/client/src/components/ask-assistant-ui/components/message/message-context.tsx @@ -6,6 +6,7 @@ import { } from "@buildingai/ui/components/ui/dialog"; import { ScrollArea } from "@buildingai/ui/components/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipTrigger } from "@buildingai/ui/components/ui/tooltip"; +import { useI18n } from "@buildingai/i18n"; import { ListChecks } from "lucide-react"; import { memo, useState } from "react"; @@ -14,12 +15,13 @@ export interface MessageContextProps { } const roleLabel: Record = { - system: "系统", - user: "用户", - assistant: "助手", + system: "roles.system", + user: "roles.user", + assistant: "roles.assistant", }; export const MessageContext = memo(function MessageContext({ messages }: MessageContextProps) { + const { t } = useI18n(); const [open, setOpen] = useState(false); if (!messages?.length) return null; @@ -37,26 +39,26 @@ export const MessageContext = memo(function MessageContext({ messages }: Message -

查看对话上下文

+

{t("action.viewConversationContext")}

- 对话上下文 + {t("message.conversationContext")}

- 发送给模型的完整上下文(含 system、截断历史与当前轮) + {t("message.fullContextSentToModel")}

{messages.map((msg, i) => (
- {roleLabel[msg.role] ?? msg.role} + {t(`message.${roleLabel[msg.role]}`) ?? msg.role}
-                    {msg.content || "(无文本)"}
+                    {msg.content || t("action.noContent")}
                   
))} diff --git a/packages/client/src/components/ask-assistant-ui/components/message/message-feedback.tsx b/packages/client/src/components/ask-assistant-ui/components/message/message-feedback.tsx index d5c70277a..702a1c406 100644 --- a/packages/client/src/components/ask-assistant-ui/components/message/message-feedback.tsx +++ b/packages/client/src/components/ask-assistant-ui/components/message/message-feedback.tsx @@ -10,31 +10,32 @@ import { } from "@buildingai/ui/components/ui/dialog"; import { Textarea } from "@buildingai/ui/components/ui/textarea"; import { cn } from "@buildingai/ui/lib/utils"; +import { useI18n } from "@buildingai/i18n"; import { XIcon } from "lucide-react"; import { memo, useState } from "react"; const DISLIKE_REASONS = [ - "回答不准确", - "回答不完整", - "回答不相关", - "回答有偏见", - "回答格式不佳", - "代码不正确", - "不应该使用记忆", - "不喜欢此人物", - "不喜欢这种风格", - "与事实不符", - "未完全遵循指令", - "其他", + "feedbackReasonInaccurate", + "feedbackReasonIncomplete", + "feedbackReasonIrrelevant", + "feedbackReasonBiased", + "feedbackReasonPoorFormat", + "feedbackReasonCodeIncorrect", + "feedbackReasonShouldNotUseMemory", + "feedbackReasonDislikePersona", + "feedbackReasonDislikeStyle", + "feedbackReasonFactuallyIncorrect", + "feedbackReasonInstructionNotFollowed", + "feedbackReasonOther", ]; const FEEDBACK_CARD_REASONS = [ - "代码不正确", - "与事实不符", - "回答不准确", - "回答不完整", - "回答不相关", - "未完全遵循指令", + "feedbackReasonCodeIncorrect", + "feedbackReasonFactuallyIncorrect", + "feedbackReasonInaccurate", + "feedbackReasonIncomplete", + "feedbackReasonIrrelevant", + "feedbackReasonInstructionNotFollowed", ]; export interface FeedbackCardProps { @@ -48,6 +49,7 @@ export const FeedbackCard = memo(function FeedbackCard({ onMore, onClose, }: FeedbackCardProps) { + const { t } = useI18n(); return (
-

请与我们分享更多信息:

+

{t("message.feedbackTitle")}

@@ -86,7 +88,7 @@ export const FeedbackCard = memo(function FeedbackCard({ className="h-auto px-3 py-1.5 text-xs" onClick={() => onSelectReason(reason)} > - {reason} + {t(`message.${reason}`)} ))}
@@ -117,6 +119,7 @@ export const MessageFeedback = memo(function MessageFeedback({ onSubmit, onCancel, }: MessageFeedbackProps) { + const { t } = useI18n(); const [selectedReasons, setSelectedReasons] = useState([]); const [customReason, setCustomReason] = useState(""); @@ -130,12 +133,13 @@ export const MessageFeedback = memo(function MessageFeedback({ }; const handleSubmit = async () => { - const reasons = selectedReasons.filter((r) => r !== "其他"); + const reasons = selectedReasons.filter((r) => r !== "message.feedbackReasonOther"); + const translatedReasons = reasons.map((r) => t(r)); let finalReason: string | undefined; - if (reasons.length > 0 && customReason.trim()) { - finalReason = `${reasons.join("、")};${customReason}`; - } else if (reasons.length > 0) { - finalReason = reasons.join("、"); + if (translatedReasons.length > 0 && customReason.trim()) { + finalReason = `${translatedReasons.join("、")};${customReason}`; + } else if (translatedReasons.length > 0) { + finalReason = translatedReasons.join("、"); } else if (customReason.trim()) { finalReason = customReason.trim(); } @@ -164,8 +168,8 @@ export const MessageFeedback = memo(function MessageFeedback({ - 分享反馈 - 请选择您不喜欢这条消息的原因(可选) + {t("message.feedbackTitle")} + {t("message.feedbackDescription")}
@@ -182,14 +186,14 @@ export const MessageFeedback = memo(function MessageFeedback({ )} onClick={() => handleReasonToggle(reason)} > - {reason} + {t(`message.${reason}`)} ); })}