@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 +58 -0
- package/changelog/v1.json +21 -0
- package/package.json +2 -2
- package/src/app/(backend)/webapi/user/avatar/[id]/[image]/route.ts +56 -0
- package/src/app/[variants]/(main)/profile/(home)/Client.tsx +5 -2
- package/src/database/_deprecated/models/message.ts +2 -2
- package/src/database/_deprecated/schemas/message.ts +1 -1
- package/src/features/AvatarWithUpload/index.tsx +40 -13
- package/src/server/modules/S3/index.ts +13 -0
- package/src/server/routers/lambda/user.test.ts +1 -0
- package/src/server/routers/lambda/user.ts +59 -0
- package/src/server/services/user/index.ts +18 -0
- package/src/services/user/server.ts +4 -0
- package/src/services/user/type.ts +1 -0
- package/src/store/user/slices/common/action.ts +2 -2
- package/src/utils/server/__tests__/geo.test.ts +113 -0
- package/src/utils/server/geo.ts +26 -5
- package/src/app/layout.tsx +0 -11
- package/src/app/not-found.tsx +0 -1
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
|
+
[](#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
|
+
[](#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
|
+
"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.
|
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) => [
|
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 &&
|
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>)
|
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 {
|
4
|
-
import {
|
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((
|
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
|
-
|
23
|
-
|
24
|
-
|
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
|
-
|
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
|
-
<
|
34
|
-
<
|
35
|
-
|
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
|
-
|
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
|
+
});
|
package/src/utils/server/geo.ts
CHANGED
@@ -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 =
|
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);
|
package/src/app/layout.tsx
DELETED
package/src/app/not-found.tsx
DELETED
@@ -1 +0,0 @@
|
|
1
|
-
export { default } from '@/components/404';
|