@lobehub/chat 1.82.3 → 1.82.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,64 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ### [Version 1.82.5](https://github.com/lobehub/lobe-chat/compare/v1.82.4...v1.82.5)
6
+
7
+ <sup>Released on **2025-04-24**</sup>
8
+
9
+ #### 🐛 Bug Fixes
10
+
11
+ - **misc**: Countries-and-timezones return invalid timezone.
12
+
13
+ #### 💄 Styles
14
+
15
+ - **misc**: Add avatar for server database upload to S3, removing SSO dependency for avatar management.
16
+
17
+ <br/>
18
+
19
+ <details>
20
+ <summary><kbd>Improvements and Fixes</kbd></summary>
21
+
22
+ #### What's fixed
23
+
24
+ - **misc**: Countries-and-timezones return invalid timezone, closes [#7539](https://github.com/lobehub/lobe-chat/issues/7539) [#7518](https://github.com/lobehub/lobe-chat/issues/7518) [#7542](https://github.com/lobehub/lobe-chat/issues/7542) ([bdb44a8](https://github.com/lobehub/lobe-chat/commit/bdb44a8))
25
+
26
+ #### Styles
27
+
28
+ - **misc**: Add avatar for server database upload to S3, removing SSO dependency for avatar management, closes [#7152](https://github.com/lobehub/lobe-chat/issues/7152) ([f15200d](https://github.com/lobehub/lobe-chat/commit/f15200d))
29
+
30
+ </details>
31
+
32
+ <div align="right">
33
+
34
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
35
+
36
+ </div>
37
+
38
+ ### [Version 1.82.4](https://github.com/lobehub/lobe-chat/compare/v1.82.3...v1.82.4)
39
+
40
+ <sup>Released on **2025-04-24**</sup>
41
+
42
+ #### 🐛 Bug Fixes
43
+
44
+ - **misc**: Fix hydration error.
45
+
46
+ <br/>
47
+
48
+ <details>
49
+ <summary><kbd>Improvements and Fixes</kbd></summary>
50
+
51
+ #### What's fixed
52
+
53
+ - **misc**: Fix hydration error, closes [#7535](https://github.com/lobehub/lobe-chat/issues/7535) ([e130855](https://github.com/lobehub/lobe-chat/commit/e130855))
54
+
55
+ </details>
56
+
57
+ <div align="right">
58
+
59
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
60
+
61
+ </div>
62
+
5
63
  ### [Version 1.82.3](https://github.com/lobehub/lobe-chat/compare/v1.82.2...v1.82.3)
6
64
 
7
65
  <sup>Released on **2025-04-24**</sup>
package/changelog/v1.json CHANGED
@@ -1,4 +1,25 @@
1
1
  [
2
+ {
3
+ "children": {
4
+ "fixes": [
5
+ "Countries-and-timezones return invalid timezone."
6
+ ],
7
+ "improvements": [
8
+ "Add avatar for server database upload to S3, removing SSO dependency for avatar management."
9
+ ]
10
+ },
11
+ "date": "2025-04-24",
12
+ "version": "1.82.5"
13
+ },
14
+ {
15
+ "children": {
16
+ "fixes": [
17
+ "Fix hydration error."
18
+ ]
19
+ },
20
+ "date": "2025-04-24",
21
+ "version": "1.82.4"
22
+ },
2
23
  {
3
24
  "children": {
4
25
  "fixes": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/chat",
3
- "version": "1.82.3",
3
+ "version": "1.82.5",
4
4
  "description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
5
5
  "keywords": [
6
6
  "framework",
@@ -195,7 +195,7 @@
195
195
  "langfuse": "^3.37.1",
196
196
  "langfuse-core": "^3.37.1",
197
197
  "lodash-es": "^4.17.21",
198
- "lucide-react": "^0.487.0",
198
+ "lucide-react": "^0.503.0",
199
199
  "mammoth": "^1.9.0",
200
200
  "mdast-util-to-markdown": "^2.1.2",
201
201
  "modern-screenshot": "^4.6.0",
@@ -0,0 +1,56 @@
1
+ import { UserService } from '@/server/services/user';
2
+
3
+ export const runtime = 'nodejs';
4
+
5
+ type Params = Promise<{ id: string , image: string }>;
6
+
7
+ // 扩展名到内容类型的映射
8
+ const CONTENT_TYPE_MAP: Record<string, string> = {
9
+ avif: 'image/avif',
10
+ bmp: 'image/bmp',
11
+ gif: 'image/gif',
12
+ heic: 'image/heic',
13
+ heif: 'image/heif',
14
+ ico: 'image/x-icon',
15
+ jpeg: 'image/jpeg',
16
+ jpg: 'image/jpg',
17
+ png: 'image/png',
18
+ svg: 'image/svg+xml',
19
+ tif: 'image/tiff',
20
+ tiff: 'image/tiff',
21
+ webp: 'image/webp',
22
+ };
23
+
24
+ // 根据文件扩展名确定内容类型
25
+ function getContentType(filename: string): string {
26
+ const extension = filename.split('.').pop()?.toLowerCase() || '';
27
+ return CONTENT_TYPE_MAP[extension] || 'application/octet-stream';
28
+ }
29
+
30
+ export const GET = async (req: Request, segmentData: { params: Params }) => {
31
+ try {
32
+ const params = await segmentData.params;
33
+ const type = getContentType(params.image);
34
+ const userService = new UserService();
35
+
36
+ const userAvatar = await userService.getUserAvatar(params.id, params.image);
37
+ if (!userAvatar) {
38
+ return new Response('Avatar not found', {
39
+ status: 404,
40
+ });
41
+ }
42
+
43
+ return new Response(userAvatar, {
44
+ headers: {
45
+ 'Cache-Control': 'public, max-age=31536000, immutable',
46
+ 'Content-Type': type,
47
+ },
48
+ status: 200,
49
+ });
50
+ } catch (error) {
51
+ console.error('Error fetching user avatar:', error);
52
+ return new Response('Internal server error', {
53
+ status: 500,
54
+ });
55
+ }
56
+ };
@@ -16,7 +16,10 @@ import SSOProvidersList from './features/SSOProvidersList';
16
16
  type SettingItemGroup = ItemGroup;
17
17
 
18
18
  const Client = memo<{ mobile?: boolean }>(() => {
19
- const [isLoginWithNextAuth] = useUserStore((s) => [authSelectors.isLoginWithNextAuth(s)]);
19
+ const [isLoginWithNextAuth, isLogin] = useUserStore((s) => [
20
+ authSelectors.isLoginWithNextAuth(s),
21
+ authSelectors.isLogin(s),
22
+ ]);
20
23
  const [nickname, username, userProfile] = useUserStore((s) => [
21
24
  userProfileSelectors.nickName(s),
22
25
  userProfileSelectors.username(s),
@@ -29,7 +32,7 @@ const Client = memo<{ mobile?: boolean }>(() => {
29
32
  const profile: SettingItemGroup = {
30
33
  children: [
31
34
  {
32
- children: enableAuth && isLoginWithNextAuth ? <UserAvatar /> : <AvatarWithUpload />,
35
+ children: enableAuth && !isLogin ? <UserAvatar /> : <AvatarWithUpload />,
33
36
  label: t('profile.avatar'),
34
37
  minWidth: undefined,
35
38
  },
@@ -264,13 +264,13 @@ class _MessageModel extends BaseModel {
264
264
  translate,
265
265
  tts,
266
266
  ...item
267
- }: DBModel<DB_Message>): ChatMessage => {
267
+ }: DBModel<DB_Message>) => {
268
268
  return {
269
269
  ...item,
270
270
  extra: { fromModel, fromProvider, translate, tts },
271
271
  meta: {},
272
272
  topicId: item.topicId ?? undefined,
273
- };
273
+ } as ChatMessage;
274
274
  };
275
275
  }
276
276
 
@@ -11,7 +11,7 @@ const PluginSchema = z.object({
11
11
  identifier: z.string(),
12
12
  arguments: z.string(),
13
13
  apiName: z.string(),
14
- type: z.enum(['default', 'markdown', 'standalone', 'builtin']).default('default'),
14
+ type: z.enum(['default', 'markdown', 'standalone', 'builtin', 'mcp']).default('default'),
15
15
  });
16
16
 
17
17
  const ToolCallSchema = PluginSchema.extend({
@@ -1,8 +1,10 @@
1
1
  'use client';
2
2
 
3
- import { Upload } from 'antd';
4
- import { memo, useCallback } from 'react';
3
+ import { LoadingOutlined } from '@ant-design/icons';
4
+ import { Spin, Upload } from 'antd';
5
+ import React, { memo, useCallback } from 'react';
5
6
 
7
+ import { fetchErrorNotification } from '@/components/Error/fetchErrorNotification';
6
8
  import { useUserStore } from '@/store/user';
7
9
  import { imageToBase64 } from '@/utils/imageToBase64';
8
10
  import { createUploadImageHandler } from '@/utils/uploadFIle';
@@ -15,24 +17,49 @@ interface AvatarWithUploadProps extends UserAvatarProps {
15
17
 
16
18
  const AvatarWithUpload = memo<AvatarWithUploadProps>(
17
19
  ({ size = 40, compressSize = 256, ...rest }) => {
18
- const updateAvatar = useUserStore((s) => s.updateAvatar);
20
+ const updateAvatar = useUserStore((state) => state.updateAvatar);
21
+ const [uploading, setUploading] = React.useState<boolean>(false);
19
22
 
20
23
  const handleUploadAvatar = useCallback(
21
- createUploadImageHandler((avatar) => {
22
- const img = new Image();
23
- img.src = avatar;
24
- img.addEventListener('load', () => {
24
+ createUploadImageHandler(async (avatar) => {
25
+ try {
26
+ setUploading(true);
27
+ // 准备图像
28
+ const img = new Image();
29
+ img.src = avatar;
30
+
31
+ // 使用 Promise 等待图片加载
32
+ await new Promise((resolve, reject) => {
33
+ img.addEventListener('load', resolve);
34
+ img.addEventListener('error', reject);
35
+ });
36
+
37
+ // 压缩图像
25
38
  const webpBase64 = imageToBase64({ img, size: compressSize });
26
- updateAvatar(webpBase64);
27
- });
39
+
40
+ // 上传头像
41
+ await updateAvatar(webpBase64);
42
+
43
+ setUploading(false);
44
+ } catch (error) {
45
+ console.error('Failed to upload avatar:', error);
46
+ setUploading(false);
47
+
48
+ fetchErrorNotification.error({
49
+ errorMessage: error instanceof Error ? error.message : String(error),
50
+ status: 500,
51
+ });
52
+ }
28
53
  }),
29
- [],
54
+ [compressSize, updateAvatar],
30
55
  );
31
56
 
32
57
  return (
33
- <Upload beforeUpload={handleUploadAvatar} itemRender={() => void 0} maxCount={1}>
34
- <UserAvatar clickable size={size} {...rest} />
35
- </Upload>
58
+ <Spin indicator={<LoadingOutlined spin />} spinning={uploading}>
59
+ <Upload beforeUpload={handleUploadAvatar} itemRender={() => void 0} maxCount={1}>
60
+ <UserAvatar clickable size={size} {...rest} />
61
+ </Upload>
62
+ </Spin>
36
63
  );
37
64
  },
38
65
  );
@@ -119,6 +119,19 @@ export class S3 {
119
119
  });
120
120
  }
121
121
 
122
+ // 添加一个新方法用于上传二进制内容
123
+ public async uploadBuffer(path: string, buffer: Buffer, contentType?: string) {
124
+ const command = new PutObjectCommand({
125
+ ACL: this.setAcl ? 'public-read' : undefined,
126
+ Body: buffer,
127
+ Bucket: this.bucket,
128
+ ContentType: contentType,
129
+ Key: path,
130
+ });
131
+
132
+ return this.client.send(command);
133
+ }
134
+
122
135
  public async uploadContent(path: string, content: string) {
123
136
  const command = new PutObjectCommand({
124
137
  ACL: this.setAcl ? 'public-read' : undefined,
@@ -26,6 +26,7 @@ vi.mock('@/database/models/session');
26
26
  vi.mock('@/database/models/user');
27
27
  vi.mock('@/libs/next-auth/adapter');
28
28
  vi.mock('@/server/modules/KeyVaultsEncrypt');
29
+ vi.mock('@/server/modules/S3');
29
30
  vi.mock('@/server/services/user');
30
31
  vi.mock('@/const/auth', () => ({
31
32
  enableClerk: true,
@@ -1,5 +1,6 @@
1
1
  import { UserJSON } from '@clerk/backend';
2
2
  import { z } from 'zod';
3
+ import { v4 as uuidv4 } from 'uuid'; // 需要添加此导入
3
4
 
4
5
  import { enableClerk } from '@/const/auth';
5
6
  import { MessageModel } from '@/database/models/message';
@@ -10,6 +11,8 @@ import { LobeNextAuthDbAdapter } from '@/libs/next-auth/adapter';
10
11
  import { authedProcedure, router } from '@/libs/trpc/lambda';
11
12
  import { serverDatabase } from '@/libs/trpc/lambda/middleware';
12
13
  import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
14
+ import { S3 } from '@/server/modules/S3';
15
+ import { FileService } from '@/server/services/file';
13
16
  import { UserService } from '@/server/services/user';
14
17
  import {
15
18
  NextAuthAccountSchame,
@@ -23,6 +26,7 @@ const userProcedure = authedProcedure.use(serverDatabase).use(async ({ ctx, next
23
26
  return next({
24
27
  ctx: {
25
28
  clerkAuth: new ClerkAuth(),
29
+ fileService: new FileService(),
26
30
  nextAuthDbAdapter: LobeNextAuthDbAdapter(ctx.serverDB),
27
31
  userModel: new UserModel(ctx.serverDB, ctx.userId),
28
32
  },
@@ -130,6 +134,61 @@ export const userRouter = router({
130
134
  }
131
135
  }),
132
136
 
137
+ // 服务端上传头像
138
+ updateAvatar: userProcedure.input(z.string()).mutation(async ({ ctx, input }) => {
139
+ // 如果是 Base64 数据,需要上传到 S3
140
+ if (input.startsWith('data:image')) {
141
+ try {
142
+ // 提取 mimeType,例如 "image/png"
143
+ const prefix = 'data:';
144
+ const semicolonIndex = input.indexOf(';');
145
+ const mimeType =
146
+ semicolonIndex !== -1 ? input.slice(prefix.length, semicolonIndex) : 'image/png';
147
+ const fileType = mimeType.split('/')[1];
148
+
149
+ // 分割字符串,获取 Base64 部分
150
+ const commaIndex = input.indexOf(',');
151
+ if (commaIndex === -1) {
152
+ throw new Error('Invalid Base64 data');
153
+ }
154
+ const base64Data = input.slice(commaIndex + 1);
155
+
156
+ // 创建 S3 客户端
157
+ const s3 = new S3();
158
+
159
+ // 使用 UUID 生成唯一文件名,防止缓存问题
160
+ // 获取旧头像 URL, 后面删除该头像
161
+ const userState = await ctx.userModel.getUserState(KeyVaultsGateKeeper.getUserKeyVaults);
162
+ const oldAvatarUrl = userState.avatar;
163
+
164
+ const fileName = `${uuidv4()}.${fileType}`;
165
+ const filePath = `user/avatar/${ctx.userId}/${fileName}`;
166
+
167
+ // 将 Base64 数据转换为 Buffer 再上传到 S3
168
+ const buffer = Buffer.from(base64Data, 'base64');
169
+
170
+ await s3.uploadBuffer(filePath, buffer, mimeType);
171
+
172
+ // 删除旧头像
173
+ if (oldAvatarUrl && oldAvatarUrl.startsWith('/webapi/')) {
174
+ const oldFilePath = oldAvatarUrl.replace('/webapi/', '');
175
+ await s3.deleteFile(oldFilePath);
176
+ }
177
+
178
+ const avatarUrl = '/webapi/' + filePath;
179
+
180
+ return ctx.userModel.updateUser({ avatar: avatarUrl });
181
+ } catch (error) {
182
+ throw new Error(
183
+ 'Error uploading avatar: ' + (error instanceof Error ? error.message : String(error)),
184
+ );
185
+ }
186
+ }
187
+
188
+ // 如果不是 Base64 数据,直接使用 URL 更新用户头像
189
+ return ctx.userModel.updateUser({ avatar: input });
190
+ }),
191
+
133
192
  updateGuide: userProcedure.input(UserGuideSchema).mutation(async ({ ctx, input }) => {
134
193
  return ctx.userModel.updateGuide(input);
135
194
  }),
@@ -4,6 +4,7 @@ import { UserModel } from '@/database/models/user';
4
4
  import { serverDB } from '@/database/server';
5
5
  import { pino } from '@/libs/logger';
6
6
  import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
7
+ import { S3 } from '@/server/modules/S3';
7
8
  import { AgentService } from '@/server/services/agent';
8
9
 
9
10
  export class UserService {
@@ -94,4 +95,21 @@ export class UserService {
94
95
  getUserApiKeys = async (id: string) => {
95
96
  return UserModel.getUserApiKeys(serverDB, id, KeyVaultsGateKeeper.getUserKeyVaults);
96
97
  };
98
+
99
+ getUserAvatar = async (id: string, image: string) => {
100
+ const s3 = new S3();
101
+ const s3FileUrl = `user/avatar/${id}/${image}`;
102
+
103
+ try{
104
+ const file = await s3.getFileByteArray(s3FileUrl);
105
+ if (!file) {
106
+ return null;
107
+ }
108
+ const fileBuffer = Buffer.from(file);
109
+ return fileBuffer;
110
+ }
111
+ catch (error) {
112
+ pino.error('Failed to get user avatar:', error);
113
+ }
114
+ }
97
115
  }
@@ -25,6 +25,10 @@ export class ServerService implements IUserService {
25
25
  return lambdaClient.user.makeUserOnboarded.mutate();
26
26
  };
27
27
 
28
+ updateAvatar: IUserService['updateAvatar'] = async (avatar) => {
29
+ return lambdaClient.user.updateAvatar.mutate(avatar);
30
+ };
31
+
28
32
  updatePreference: IUserService['updatePreference'] = async (preference) => {
29
33
  return lambdaClient.user.updatePreference.mutate(preference);
30
34
  };
@@ -14,6 +14,7 @@ export interface IUserService {
14
14
  getUserState: () => Promise<UserInitializationState>;
15
15
  resetUserSettings: () => Promise<any>;
16
16
  unlinkSSOProvider: (provider: string, providerAccountId: string) => Promise<any>;
17
+ updateAvatar: (avatar: string) => Promise<any>;
17
18
  updateGuide: (guide: Partial<UserGuide>) => Promise<any>;
18
19
  updatePreference: (preference: Partial<UserPreference>) => Promise<any>;
19
20
  updateUserSettings: (value: DeepPartial<UserSettings>, signal?: AbortSignal) => Promise<any>;
@@ -44,9 +44,9 @@ export const createCommonSlice: StateCreator<
44
44
  await mutate(GET_USER_STATE_KEY);
45
45
  },
46
46
  updateAvatar: async (avatar) => {
47
- const { userClientService } = await import('@/services/user');
47
+ // 1. 更新服务端/数据库中的头像
48
+ await userService.updateAvatar(avatar);
48
49
 
49
- await userClientService.updateAvatar(avatar);
50
50
  await get().refreshUserState();
51
51
  },
52
52
 
@@ -0,0 +1,113 @@
1
+ import { geolocation } from '@vercel/functions';
2
+ import { getCountry } from 'countries-and-timezones';
3
+ import { NextRequest } from 'next/server';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+
6
+ import { parseDefaultThemeFromCountry } from '../geo';
7
+
8
+ vi.mock('@vercel/functions', () => ({
9
+ geolocation: vi.fn(),
10
+ }));
11
+
12
+ vi.mock('countries-and-timezones', () => ({
13
+ getCountry: vi.fn(),
14
+ }));
15
+
16
+ describe('parseDefaultThemeFromCountry', () => {
17
+ const mockRequest = (headers: Record<string, string> = {}) => {
18
+ return {
19
+ headers: {
20
+ get: (key: string) => headers[key],
21
+ },
22
+ } as NextRequest;
23
+ };
24
+
25
+ it('should return light theme when no country code is found', () => {
26
+ vi.mocked(geolocation).mockReturnValue({});
27
+ const request = mockRequest();
28
+ expect(parseDefaultThemeFromCountry(request)).toBe('light');
29
+ });
30
+
31
+ it('should return light theme when country has no timezone', () => {
32
+ vi.mocked(geolocation).mockReturnValue({ country: 'US' });
33
+ vi.mocked(getCountry).mockReturnValue({
34
+ id: 'US',
35
+ name: 'United States',
36
+ timezones: [],
37
+ });
38
+ const request = mockRequest();
39
+ expect(parseDefaultThemeFromCountry(request)).toBe('light');
40
+ });
41
+
42
+ it('should return light theme when country has invalid timezone', () => {
43
+ vi.mocked(geolocation).mockReturnValue({ country: 'US' });
44
+ vi.mocked(getCountry).mockReturnValue({
45
+ id: 'US',
46
+ name: 'United States',
47
+ // @ts-ignore
48
+ timezones: ['America/Invalid'],
49
+ });
50
+
51
+ const mockDate = new Date('2025-04-01T12:00:00');
52
+ vi.setSystemTime(mockDate);
53
+
54
+ const request = mockRequest();
55
+ expect(parseDefaultThemeFromCountry(request)).toBe('light');
56
+ });
57
+
58
+ it('should return light theme during daytime hours', () => {
59
+ vi.mocked(geolocation).mockReturnValue({ country: 'US' });
60
+ vi.mocked(getCountry).mockReturnValue({
61
+ id: 'US',
62
+ name: 'United States',
63
+ timezones: ['America/New_York'],
64
+ });
65
+
66
+ const mockDate = new Date('2025-04-01T12:00:00');
67
+ vi.setSystemTime(mockDate);
68
+
69
+ const request = mockRequest();
70
+ expect(parseDefaultThemeFromCountry(request)).toBe('light');
71
+ });
72
+
73
+ it('should return dark theme during night hours', () => {
74
+ vi.mocked(geolocation).mockReturnValue({ country: 'US' });
75
+ vi.mocked(getCountry).mockReturnValue({
76
+ id: 'US',
77
+ name: 'United States',
78
+ timezones: ['America/New_York'],
79
+ });
80
+
81
+ const mockDate = new Date('2025-04-01T22:00:00');
82
+ vi.setSystemTime(mockDate);
83
+
84
+ const request = mockRequest();
85
+ expect(parseDefaultThemeFromCountry(request)).toBe('dark');
86
+ });
87
+
88
+ it('should try different header sources for country code', () => {
89
+ vi.mocked(geolocation).mockReturnValue({});
90
+ vi.mocked(getCountry).mockReturnValue({
91
+ id: 'US',
92
+ name: 'United States',
93
+ timezones: ['America/New_York'],
94
+ });
95
+
96
+ const headers = {
97
+ 'x-vercel-ip-country': 'US',
98
+ 'cf-ipcountry': 'CA',
99
+ 'x-zeabur-ip-country': 'UK',
100
+ 'x-country-code': 'FR',
101
+ };
102
+
103
+ const request = mockRequest(headers);
104
+ parseDefaultThemeFromCountry(request);
105
+
106
+ expect(getCountry).toHaveBeenCalledWith('US');
107
+ });
108
+
109
+ afterEach(() => {
110
+ vi.useRealTimers();
111
+ vi.clearAllMocks();
112
+ });
113
+ });
@@ -2,6 +2,28 @@ import { geolocation } from '@vercel/functions';
2
2
  import { getCountry } from 'countries-and-timezones';
3
3
  import { NextRequest } from 'next/server';
4
4
 
5
+ const getLocalTime = (timeZone: string) => {
6
+ return new Date().toLocaleString('en-US', {
7
+ hour: 'numeric',
8
+ hour12: false,
9
+ timeZone,
10
+ });
11
+ };
12
+
13
+ const isValidTimeZone = (timeZone: string) => {
14
+ try {
15
+ getLocalTime(timeZone);
16
+ return true; // 如果没抛异常,说明时区有效
17
+ } catch (e) {
18
+ // 捕获到 RangeError,说明时区无效
19
+ if (e instanceof RangeError) {
20
+ return false;
21
+ }
22
+ // 如果是其他错误,最好重新抛出
23
+ throw e;
24
+ }
25
+ };
26
+
5
27
  export const parseDefaultThemeFromCountry = (request: NextRequest) => {
6
28
  // 1. 从请求头中获取国家代码
7
29
  const geo = geolocation(request);
@@ -22,12 +44,11 @@ export const parseDefaultThemeFromCountry = (request: NextRequest) => {
22
44
  // 如果找不到国家信息或该国家没有时区信息,返回 light 主题
23
45
  if (!country?.timezones?.length) return 'light';
24
46
 
47
+ const timeZone = country.timezones.find((tz) => isValidTimeZone(tz));
48
+ if (!timeZone) return 'light';
49
+
25
50
  // 3. 获取该国家的第一个 时区下的当前时间
26
- const localTime = new Date().toLocaleString('en-US', {
27
- hour: 'numeric',
28
- hour12: false,
29
- timeZone: country.timezones[0],
30
- });
51
+ const localTime = getLocalTime(timeZone);
31
52
 
32
53
  // 4. 解析小时数并确定主题
33
54
  const localHour = parseInt(localTime);
@@ -1,11 +0,0 @@
1
- import { PropsWithChildren } from 'react';
2
-
3
- const Layout = ({ children }: PropsWithChildren) => {
4
- return (
5
- <html>
6
- <body>{children}</body>
7
- </html>
8
- );
9
- };
10
-
11
- export default Layout;
@@ -1 +0,0 @@
1
- export { default } from '@/components/404';