mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-05 16:56:22 +00:00
⚡️ feat: refactor sorting parameters in post listing API and components
- Renamed `orderedBy` to `orderBy` and `reverse` to `desc` in ListPostsParams interface and related functions. - Updated all usages of the sorting parameters in the post listing logic to reflect the new naming convention. feat: add user-related API functions - Implemented `getLoginUser` and `getUserById` functions in the user API to fetch user details. - Enhanced user model to include `language` property. feat: integrate next-intl for internationalization - Added `next-intl` plugin to Next.js configuration for improved localization support. - Removed previous i18n implementation and replaced it with a new structure using JSON files for translations. - Created locale files for English, Japanese, and Chinese with basic translations. - Implemented a request configuration to handle user locales and messages dynamically. fix: clean up unused imports and code - Removed unused i18n utility functions and language settings from device context. - Cleaned up commented-out code in blog card component and sidebar. chore: update dependencies - Added `deepmerge` for merging locale messages. - Updated package.json and pnpm-lock.yaml to reflect new dependencies.
This commit is contained in:
@ -5,8 +5,8 @@ import axiosClient from "./client";
|
||||
interface ListPostsParams {
|
||||
page?: number;
|
||||
size?: number;
|
||||
orderedBy?: string;
|
||||
reverse?: boolean;
|
||||
orderBy?: string;
|
||||
desc?: boolean;
|
||||
keywords?: string;
|
||||
}
|
||||
|
||||
@ -24,16 +24,16 @@ export async function getPostById(id: string): Promise<Post | null> {
|
||||
export async function listPosts({
|
||||
page = 1,
|
||||
size = 10,
|
||||
orderedBy = 'updated_at',
|
||||
reverse = false,
|
||||
orderBy = 'updated_at',
|
||||
desc = false,
|
||||
keywords = ''
|
||||
}: ListPostsParams = {}): Promise<BaseResponse<Post[]>> {
|
||||
const res = await axiosClient.get<BaseResponse<Post[]>>("/post/list", {
|
||||
params: {
|
||||
page,
|
||||
size,
|
||||
orderedBy,
|
||||
reverse,
|
||||
orderBy,
|
||||
desc,
|
||||
keywords
|
||||
}
|
||||
});
|
||||
|
@ -43,4 +43,18 @@ export async function ListOidcConfigs(): Promise<BaseResponse<OidcConfig[]>> {
|
||||
"/user/oidc/list"
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getLoginUser(token: string = ""): Promise<BaseResponse<User>> {
|
||||
const res = await axiosClient.get<BaseResponse<User>>("/user/me", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getUserById(id: number): Promise<BaseResponse<User>> {
|
||||
const res = await axiosClient.get<BaseResponse<User>>(`/user/u/${id}`);
|
||||
return res.data;
|
||||
}
|
@ -2,6 +2,8 @@ import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { DeviceProvider } from "@/contexts/device-context";
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getLocale } from 'next-intl/server';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@ -29,7 +31,7 @@ export default function RootLayout({
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<DeviceProvider>
|
||||
{children}
|
||||
<NextIntlClientProvider>{children}</NextIntlClientProvider>
|
||||
</DeviceProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -3,7 +3,7 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Calendar, Eye, Heart, MessageCircle, Lock } from "lucide-react";
|
||||
import { Calendar, Eye, Heart, MessageCircle, Lock } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import config from "@/config";
|
||||
|
||||
@ -23,27 +23,7 @@ export function BlogCard({ post, className }: BlogCardProps) {
|
||||
});
|
||||
};
|
||||
|
||||
// 计算阅读时间(估算)
|
||||
// const getReadingTime = (content: string) => {
|
||||
// const wordsPerMinute = 200;
|
||||
// const wordCount = content.length;
|
||||
// const minutes = Math.ceil(wordCount / wordsPerMinute);
|
||||
// return `${minutes} 分钟阅读`;
|
||||
// };
|
||||
|
||||
// // 根据内容类型获取图标
|
||||
// const getContentTypeIcon = (type: Post['type']) => {
|
||||
// switch (type) {
|
||||
// case 'markdown':
|
||||
// return '📝';
|
||||
// case 'html':
|
||||
// return '🌐';
|
||||
// case 'text':
|
||||
// return '📄';
|
||||
// default:
|
||||
// return '📝';
|
||||
// }
|
||||
// };
|
||||
// TODO: 阅读时间估计
|
||||
|
||||
return (
|
||||
<Card className={cn(
|
||||
@ -131,7 +111,7 @@ export function BlogCard({ post, className }: BlogCardProps) {
|
||||
<CardTitle className="line-clamp-2 group-hover:text-primary transition-colors text-lg leading-tight">
|
||||
{post.title}
|
||||
</CardTitle>
|
||||
|
||||
|
||||
</CardHeader>
|
||||
{/* Card Content - 主要内容 */}
|
||||
<CardContent className="flex-1">
|
||||
|
@ -5,8 +5,8 @@ import { Badge } from "@/components/ui/badge";
|
||||
import type { Label } from "@/models/label";
|
||||
import type { Post } from "@/models/post";
|
||||
import type configType from '@/config';
|
||||
import { t } from "i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
// 侧边栏父组件,接收卡片组件列表
|
||||
export default function Sidebar({ cards }: { cards: React.ReactNode[] }) {
|
||||
@ -115,10 +115,11 @@ export function SidebarIframe(props?: { src?: string; scriptSrc?: string; title?
|
||||
title = "External Content",
|
||||
height = "400px",
|
||||
} = props || {};
|
||||
const t = useTranslations('HomePage');
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t(title)}</CardTitle>
|
||||
<CardTitle>{t("title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<iframe
|
||||
|
@ -30,28 +30,28 @@ export default function BlogHome() {
|
||||
const fetchPosts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
let orderedBy: string;
|
||||
let reverse: boolean;
|
||||
let orderBy: string;
|
||||
let desc: boolean;
|
||||
switch (sortType) {
|
||||
case 'latest':
|
||||
orderedBy = 'updated_at';
|
||||
reverse = false;
|
||||
orderBy = 'updated_at';
|
||||
desc = true;
|
||||
break;
|
||||
case 'popular':
|
||||
orderedBy = 'heat';
|
||||
reverse = false;
|
||||
orderBy = 'heat';
|
||||
desc = true;
|
||||
break;
|
||||
default:
|
||||
orderedBy = 'updated_at';
|
||||
reverse = false;
|
||||
orderBy = 'updated_at';
|
||||
desc = true;
|
||||
}
|
||||
// 处理关键词,空格分割转逗号
|
||||
const keywords = debouncedSearch.trim() ? debouncedSearch.trim().split(/\s+/).join(",") : undefined;
|
||||
const data = await listPosts({
|
||||
page: 1,
|
||||
size: 10,
|
||||
orderedBy,
|
||||
reverse,
|
||||
orderBy: orderBy,
|
||||
desc: desc,
|
||||
keywords
|
||||
});
|
||||
setPosts(data.data);
|
||||
|
0
web/src/components/sidebar.tsx
Normal file
0
web/src/components/sidebar.tsx
Normal file
@ -2,8 +2,6 @@
|
||||
|
||||
import React, { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
|
||||
import i18n, { getDefaultLang } from "@/utils/i18n";
|
||||
|
||||
type Mode = "light" | "dark";
|
||||
type Lang = string;
|
||||
|
||||
@ -12,8 +10,6 @@ interface DeviceContextProps {
|
||||
mode: Mode;
|
||||
setMode: (mode: Mode) => void;
|
||||
toggleMode: () => void;
|
||||
lang: Lang;
|
||||
setLang: (lang: Lang) => void;
|
||||
viewport: {
|
||||
width: number;
|
||||
height: number;
|
||||
@ -25,8 +21,6 @@ const DeviceContext = createContext<DeviceContextProps>({
|
||||
mode: "light",
|
||||
setMode: () => {},
|
||||
toggleMode: () => {},
|
||||
lang: "zh-cn",
|
||||
setLang: () => {},
|
||||
viewport: {
|
||||
width: 0,
|
||||
height: 0,
|
||||
@ -36,7 +30,6 @@ const DeviceContext = createContext<DeviceContextProps>({
|
||||
export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [mode, setModeState] = useState<Mode>("light");
|
||||
const [lang, setLangState] = useState<Lang>(getDefaultLang());
|
||||
const [viewport, setViewport] = useState({
|
||||
width: typeof window !== "undefined" ? window.innerWidth : 0,
|
||||
height: typeof window !== "undefined" ? window.innerHeight : 0,
|
||||
@ -92,15 +85,6 @@ export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 初始化语言
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const savedLang = localStorage.getItem("language") || getDefaultLang();
|
||||
setLangState(savedLang);
|
||||
i18n.changeLanguage(savedLang);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setMode = useCallback((newMode: Mode) => {
|
||||
setModeState(newMode);
|
||||
document.documentElement.classList.toggle("dark", newMode === "dark");
|
||||
@ -124,15 +108,9 @@ export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setLang = useCallback((newLang: Lang) => {
|
||||
setLangState(newLang);
|
||||
i18n.changeLanguage(newLang);
|
||||
localStorage.setItem("language", newLang);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DeviceContext.Provider
|
||||
value={{ isMobile, mode, setMode, toggleMode, lang, setLang, viewport }}
|
||||
value={{ isMobile, mode, setMode, toggleMode, viewport }}
|
||||
>
|
||||
{children}
|
||||
</DeviceContext.Provider>
|
||||
|
47
web/src/i18n/request.ts
Normal file
47
web/src/i18n/request.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import deepmerge from 'deepmerge';
|
||||
import { getLoginUser } from '@/api/user';
|
||||
|
||||
export default getRequestConfig(async () => {
|
||||
const locales = await getUserLocales();
|
||||
const messages = await Promise.all(
|
||||
locales.map(async (locale) => {
|
||||
try {
|
||||
return (await import(`@/locales/${locale}.json`)).default;
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
})
|
||||
).then((msgs) => msgs.reduce((acc, msg) => deepmerge(acc, msg), {}));
|
||||
return {
|
||||
locale: locales[0],
|
||||
messages
|
||||
};
|
||||
});
|
||||
|
||||
export async function getUserLocales(): Promise<string[]> {
|
||||
let locales: string[] = ["zh-CN", "zh", "en-US", "en"];
|
||||
const headersList = await headers();
|
||||
const cookieStore = await cookies();
|
||||
try {
|
||||
const token = cookieStore.get('token')?.value || '';
|
||||
const user = (await getLoginUser(token)).data;
|
||||
locales.push(user.language);
|
||||
locales.push(user.language.split('-')[0]);
|
||||
} catch (error) {
|
||||
console.info("获取用户信息失败,使用默认语言", error);
|
||||
}
|
||||
const languageInCookie = cookieStore.get('language')?.value;
|
||||
if (languageInCookie) {
|
||||
locales.push(languageInCookie);
|
||||
locales.push(languageInCookie.split('-')[0]);
|
||||
}
|
||||
const acceptLanguage = headersList.get('accept-language');
|
||||
if (acceptLanguage) {
|
||||
const languages = acceptLanguage.split(',').map(lang => lang.split(';')[0]);
|
||||
const languagesWithoutRegion = languages.map(lang => lang.split('-')[0]);
|
||||
locales = [...new Set([...locales, ...languages, ...languagesWithoutRegion])];
|
||||
}
|
||||
return locales.reverse();
|
||||
}
|
5
web/src/locales/en.json
Normal file
5
web/src/locales/en.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"HomePage": {
|
||||
"title": "Hello world!"
|
||||
}
|
||||
}
|
5
web/src/locales/ja.json
Normal file
5
web/src/locales/ja.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"HomePage": {
|
||||
"title": "Hello world!"
|
||||
}
|
||||
}
|
5
web/src/locales/zh-CN.json
Normal file
5
web/src/locales/zh-CN.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"HomePage": {
|
||||
"title": "Hello world!"
|
||||
}
|
||||
}
|
@ -6,4 +6,5 @@ export interface User {
|
||||
email: string;
|
||||
gender: string;
|
||||
role: string;
|
||||
language: string;
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
import i18n from "i18next";
|
||||
|
||||
import resources from "./locales";
|
||||
|
||||
export const getDefaultLang = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
return (
|
||||
localStorage.getItem("language") ||
|
||||
navigator.language.replace("_", "-") || // 保证格式
|
||||
"zh-CN"
|
||||
);
|
||||
}
|
||||
return "zh-CN";
|
||||
};
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: resources,
|
||||
lng: getDefaultLang(),
|
||||
fallbackLng: "zh-CN",
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
@ -1,30 +0,0 @@
|
||||
const resources = {
|
||||
translation: {
|
||||
name: "English",
|
||||
hello: "Hello",
|
||||
login: {
|
||||
login: "Login",
|
||||
failed: "Login failed",
|
||||
forgotPassword: "Forgot password?",
|
||||
username: "Username",
|
||||
usernameOrEmail: "Username or Email",
|
||||
password: "Password",
|
||||
remember: "Remember this device",
|
||||
captcha: {
|
||||
no: "No captcha required",
|
||||
failed: "Captcha verification failed, please try again",
|
||||
fetchFailed: "Failed to fetch captcha, please try again later",
|
||||
processing: "Waiting for verification...",
|
||||
reCaptchaProcessing: "Processing reCAPTCHA verification, please wait...",
|
||||
reCaptchaFailed: "reCAPTCHA verification failed, please try again",
|
||||
reCaptchaSuccess: "reCAPTCHA verification successful",
|
||||
},
|
||||
oidc: {
|
||||
fetchFailed: "Failed to fetch OIDC providers, please try again later",
|
||||
use: "Login with {{provider}}",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default resources;
|
@ -1,9 +0,0 @@
|
||||
import enUS from "./en-us";
|
||||
import zhCN from "./zh-cn";
|
||||
|
||||
const resources = {
|
||||
"zh-CN": zhCN,
|
||||
"en-US": enUS,
|
||||
};
|
||||
|
||||
export default resources;
|
@ -1,30 +0,0 @@
|
||||
const resources = {
|
||||
translation: {
|
||||
name: "中文",
|
||||
hello: "你好",
|
||||
login: {
|
||||
login: "登录",
|
||||
failed: "登录失败",
|
||||
forgotPassword: "忘了密码?",
|
||||
username: "用户名",
|
||||
usernameOrEmail: "用户名或邮箱",
|
||||
password: "密码",
|
||||
remember: "记住这个设备",
|
||||
captcha: {
|
||||
no: "无需进行机器人挑战",
|
||||
failed: "机器人挑战失败,请重试",
|
||||
fetchFailed: "获取验证码失败,请稍后再试",
|
||||
processing: "等待验证...",
|
||||
reCaptchaProcessing: "正在处理 reCAPTCHA 验证,请稍候...",
|
||||
reCaptchaFailed: "reCAPTCHA 验证失败,请重试",
|
||||
reCaptchaSuccess: "reCAPTCHA 验证成功",
|
||||
},
|
||||
oidc: {
|
||||
fetchFailed: "获取 OIDC 提供商失败,请稍后再试",
|
||||
use: "使用 {{provider}} 登录",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default resources;
|
Reference in New Issue
Block a user