postlist
|
@ -1,7 +1,7 @@
|
||||||
import type { AppConfig } from "@/libs/types";
|
import type { AppConfig } from "@/libs/types";
|
||||||
|
|
||||||
export const appConfig: AppConfig = {
|
export const appConfig: AppConfig = {
|
||||||
baseUrl: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
|
baseUrl: process.env.NEXT_PUBLIC_APP_URL || "http://127.0.0.1:3001/",
|
||||||
apiUrl:
|
apiUrl:
|
||||||
process.env.NEXT_PUBLIC_APi_URL || `${process.env.NEXT_PUBLIC_APP_URL}/api`,
|
process.env.NEXT_PUBLIC_APi_URL || `${process.env.NEXT_PUBLIC_APP_URL}/api`,
|
||||||
};
|
};
|
||||||
|
|
|
@ -4,11 +4,11 @@ import { app } from "./server/main";
|
||||||
serve(
|
serve(
|
||||||
{
|
{
|
||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
port: 4000,
|
port: 3001,
|
||||||
hostname: "localhost",
|
hostname: "localhost",
|
||||||
},
|
},
|
||||||
|
|
||||||
async () => {
|
async () => {
|
||||||
console.log("Server started on http://localhost:4000/api/");
|
console.log("Server started on http://localhost:3001/api/");
|
||||||
},
|
},
|
||||||
).listen();
|
).listen();
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { zValidator } from "@/middleware/zValidator.middleware";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { zValidator } from "@/middleware/zValidator.middleware";
|
||||||
|
|
||||||
import { createErrorResult, createHonoApp } from "../common/utils";
|
import { createErrorResult, createHonoApp } from "../common/utils";
|
||||||
import { createCategorySchema } from "./schema";
|
import { createCategorySchema } from "./schema";
|
||||||
import {
|
import {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import db from "@/libs/db/prismaClient";
|
|
||||||
import { isNil } from "lodash";
|
import { isNil } from "lodash";
|
||||||
|
|
||||||
|
import db from "@/libs/db/prismaClient";
|
||||||
|
|
||||||
import type { CategoryItem, CreateCategoryParams } from "./type";
|
import type { CategoryItem, CreateCategoryParams } from "./type";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { getCookie } from "hono/cookie";
|
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
|
|
||||||
import { authApi, categoryApi, postApi, tagApi } from ".";
|
import { authApi, categoryApi, postApi, tagApi } from ".";
|
||||||
|
@ -12,11 +11,6 @@ const routes = app
|
||||||
.route("/auth", authApi)
|
.route("/auth", authApi)
|
||||||
.route("/categories", categoryApi);
|
.route("/categories", categoryApi);
|
||||||
app.use("*", cors());
|
app.use("*", cors());
|
||||||
app.use("*", async (c, next) => {
|
|
||||||
const allCookies = getCookie(c);
|
|
||||||
console.log(allCookies, "cookies111");
|
|
||||||
await next();
|
|
||||||
});
|
|
||||||
|
|
||||||
type AppType = typeof routes;
|
type AppType = typeof routes;
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { isNil } from "lodash";
|
import { isNil } from "lodash";
|
||||||
|
|
||||||
import db from "@/libs/db/prismaClient";
|
import db from "@/libs/db/prismaClient";
|
||||||
import { getUserMiddleware } from "@/middleware/user.middleware";
|
|
||||||
import { zValidator } from "@/middleware/zValidator.middleware";
|
import { zValidator } from "@/middleware/zValidator.middleware";
|
||||||
|
|
||||||
import { createErrorResult, createHonoApp } from "../common/utils";
|
import { createErrorResult, createHonoApp } from "../common/utils";
|
||||||
|
@ -12,16 +11,11 @@ import {
|
||||||
postPagesNumberRequestQuerySchema,
|
postPagesNumberRequestQuerySchema,
|
||||||
postPaginateRequestQuerySchema,
|
postPaginateRequestQuerySchema,
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
import {
|
import { createPostItem, queryPostPaginate } from "./service";
|
||||||
createPostItem,
|
|
||||||
getPostByTag,
|
|
||||||
isSlugUnique,
|
|
||||||
queryPostPaginate,
|
|
||||||
} from "./service";
|
|
||||||
|
|
||||||
const app = createHonoApp();
|
const app = createHonoApp();
|
||||||
export const postApi = app
|
export const postApi = app
|
||||||
.use("*", getUserMiddleware)
|
|
||||||
.get("/", async (c) => {
|
.get("/", async (c) => {
|
||||||
const q = c.req.query();
|
const q = c.req.query();
|
||||||
const validated = postPaginateRequestQuerySchema.safeParse(q);
|
const validated = postPaginateRequestQuerySchema.safeParse(q);
|
||||||
|
@ -137,20 +131,9 @@ export const postApi = app
|
||||||
)
|
)
|
||||||
: c.json(createErrorResult("参数错误"), 400);
|
: c.json(createErrorResult("参数错误"), 400);
|
||||||
})
|
})
|
||||||
.post(
|
.post("/", zValidator("json", getPostItemRequestSchema), async (c) => {
|
||||||
"/",
|
const res = c.req.valid("json");
|
||||||
zValidator("json", getPostItemRequestSchema(isSlugUnique())),
|
console.log(res, "res");
|
||||||
async (c) => {
|
const post = await createPostItem(res);
|
||||||
const res = c.req.valid("json");
|
|
||||||
return createPostItem(res)
|
|
||||||
.then((result) => c.json(result, 200))
|
|
||||||
.catch((error) =>
|
|
||||||
c.json(createErrorResult("服务器内部错误", error), 500),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.get("/test/:tag", async (c) => {
|
|
||||||
const { tag } = c.req.param();
|
|
||||||
const post = await getPostByTag(tag);
|
|
||||||
return c.json(post, 200);
|
return c.json(post, 200);
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { isNil } from "lodash";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { tagListResponesSchema } from "../tag/schema";
|
import { tagListResponesSchema } from "../tag/schema";
|
||||||
|
@ -105,49 +104,39 @@ export const postIdParamsSchema = z.object({
|
||||||
* @param slugUniqueValidator Slug唯一性验证结构生成器
|
* @param slugUniqueValidator Slug唯一性验证结构生成器
|
||||||
* @param id
|
* @param id
|
||||||
*/
|
*/
|
||||||
export const getPostItemRequestSchema = (
|
export const getPostItemRequestSchema = z
|
||||||
slugUniqueValidator?: (val?: string | null) => Promise<boolean>,
|
.object({
|
||||||
) => {
|
title: z
|
||||||
let slug = z
|
.string()
|
||||||
.string()
|
.min(10, {
|
||||||
.max(250, {
|
message: "标题不得少于10个字符",
|
||||||
message: "slug不得超过250个字符",
|
})
|
||||||
})
|
.max(200, {
|
||||||
|
message: "标题不得超过200个字符",
|
||||||
|
}),
|
||||||
|
summary: z.string().max(300, {
|
||||||
|
message: "摘要不得超过300个字符",
|
||||||
|
}),
|
||||||
|
body: z.string().min(1, {
|
||||||
|
message: "标题不得少于1个字符",
|
||||||
|
}),
|
||||||
|
slug: z
|
||||||
|
.string()
|
||||||
|
.max(250, {
|
||||||
|
message: "slug不得超过250个字符",
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
|
||||||
.optional();
|
thumb: z.string().url(),
|
||||||
if (!isNil(slugUniqueValidator)) {
|
keywords: z.string().max(200, {
|
||||||
slug = slug.refine(slugUniqueValidator, {
|
message: "描述不得超过200个字符",
|
||||||
message: "slug必须是唯一的,请重新设置",
|
}),
|
||||||
}) as any;
|
|
||||||
}
|
|
||||||
return z
|
|
||||||
.object({
|
|
||||||
title: z
|
|
||||||
.string()
|
|
||||||
.min(10, {
|
|
||||||
message: "标题不得少于10个字符",
|
|
||||||
})
|
|
||||||
.max(200, {
|
|
||||||
message: "标题不得超过200个字符",
|
|
||||||
}),
|
|
||||||
summary: z.string().max(300, {
|
|
||||||
message: "摘要不得超过300个字符",
|
|
||||||
}),
|
|
||||||
body: z.string().min(1, {
|
|
||||||
message: "标题不得少于1个字符",
|
|
||||||
}),
|
|
||||||
slug,
|
|
||||||
thumb: z.string().url(),
|
|
||||||
keywords: z.string().max(200, {
|
|
||||||
message: "描述不得超过200个字符",
|
|
||||||
}),
|
|
||||||
|
|
||||||
description: z.string().max(300, {
|
description: z.string().max(300, {
|
||||||
message: "描述不得超过300个字符",
|
message: "描述不得超过300个字符",
|
||||||
}),
|
}),
|
||||||
authorId: z.string().uuid({ message: "not uuid" }),
|
authorId: z.string().uuid({ message: "not uuid" }),
|
||||||
tags: tagListResponesSchema.optional(),
|
tags: tagListResponesSchema.optional(),
|
||||||
categoryId: z.string().uuid({ message: "not uuid" }),
|
categoryId: z.string().uuid({ message: "not uuid" }),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
};
|
|
||||||
|
|
|
@ -12,14 +12,6 @@ import type { PostCreateOrUpdateData } from "./type";
|
||||||
* 通过ID验证slug是否唯一 存在则返回false
|
* 通过ID验证slug是否唯一 存在则返回false
|
||||||
* @param id
|
* @param id
|
||||||
*/
|
*/
|
||||||
export const isSlugUnique = (id?: string) => async (val?: string | null) => {
|
|
||||||
if (isNil(val)) return true;
|
|
||||||
console.log("isSlugUnique", val, id);
|
|
||||||
|
|
||||||
return db.post
|
|
||||||
.findFirst({ where: { slug: val } })
|
|
||||||
.then((res) => (isNil(res) ? true : res.id === id));
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询分页文章列表信息
|
* 查询分页文章列表信息
|
||||||
|
@ -36,6 +28,10 @@ export const queryPostPaginate = async (
|
||||||
orderBy: {
|
orderBy: {
|
||||||
id: orderBy,
|
id: orderBy,
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
tags: true,
|
||||||
|
author: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const totalPages =
|
const totalPages =
|
||||||
totalItems % limit === 0
|
totalItems % limit === 0
|
||||||
|
@ -113,7 +109,7 @@ export const createPostItem = async (
|
||||||
data: PostCreateOrUpdateData,
|
data: PostCreateOrUpdateData,
|
||||||
): Promise<Post> => {
|
): Promise<Post> => {
|
||||||
const tagPromises = data.tags?.map(async (tag) => {
|
const tagPromises = data.tags?.map(async (tag) => {
|
||||||
const tagItem = await db.tag.findUnique({ where: { text: tag } });
|
const tagItem = await db.tag.findUnique({ where: { text: tag.text } });
|
||||||
return tagItem;
|
return tagItem;
|
||||||
});
|
});
|
||||||
const resolvedTags = tagPromises ? await Promise.all(tagPromises) : [];
|
const resolvedTags = tagPromises ? await Promise.all(tagPromises) : [];
|
||||||
|
|
|
@ -23,6 +23,4 @@ export type PostPageNumbers = z.infer<typeof postPageNumbersResponseSchema>;
|
||||||
/**
|
/**
|
||||||
* 文章操作(建或更新文章)时的请求数据类型
|
* 文章操作(建或更新文章)时的请求数据类型
|
||||||
*/
|
*/
|
||||||
export type PostCreateOrUpdateData = z.infer<
|
export type PostCreateOrUpdateData = z.infer<typeof getPostItemRequestSchema>;
|
||||||
ReturnType<typeof getPostItemRequestSchema>
|
|
||||||
>;
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { zValidator } from "@/middleware/zValidator.middleware";
|
|
||||||
import { isNil } from "lodash";
|
import { isNil } from "lodash";
|
||||||
|
|
||||||
|
import { zValidator } from "@/middleware/zValidator.middleware";
|
||||||
|
|
||||||
import { createErrorResult, createHonoApp } from "../common/utils";
|
import { createErrorResult, createHonoApp } from "../common/utils";
|
||||||
import { createTagRequestSchema, tagItemRequestParamsSchema } from "./schema";
|
import { createTagRequestSchema, tagItemRequestParamsSchema } from "./schema";
|
||||||
// import { tagItemRequestParamsSchema } from './schema';
|
// import { tagItemRequestParamsSchema } from './schema';
|
||||||
|
|
After Width: | Height: | Size: 86 KiB |
After Width: | Height: | Size: 86 KiB |
After Width: | Height: | Size: 87 KiB |
After Width: | Height: | Size: 1.0 MiB |
After Width: | Height: | Size: 1.0 MiB |
After Width: | Height: | Size: 374 KiB |
After Width: | Height: | Size: 29 KiB |
After Width: | Height: | Size: 86 KiB |
After Width: | Height: | Size: 87 KiB |
After Width: | Height: | Size: 1.1 MiB |
After Width: | Height: | Size: 1.0 MiB |
After Width: | Height: | Size: 1.0 MiB |
After Width: | Height: | Size: 1.0 MiB |
After Width: | Height: | Size: 1.0 MiB |
After Width: | Height: | Size: 374 KiB |
After Width: | Height: | Size: 374 KiB |
After Width: | Height: | Size: 29 KiB |
After Width: | Height: | Size: 86 KiB |
After Width: | Height: | Size: 86 KiB |
|
@ -13,6 +13,7 @@ async function bootstrap() {
|
||||||
const BootModule = AppModule.forRoot(configure);
|
const BootModule = AppModule.forRoot(configure);
|
||||||
const app = await NestFactory.create(BootModule, {
|
const app = await NestFactory.create(BootModule, {
|
||||||
logger: WinstonModule.createLogger({ instance }),
|
logger: WinstonModule.createLogger({ instance }),
|
||||||
|
cors: true,
|
||||||
});
|
});
|
||||||
// 为class-validator添加容器以便在自定义约束中可以注入dataSource等依赖
|
// 为class-validator添加容器以便在自定义约束中可以注入dataSource等依赖
|
||||||
useContainer(app.select(BootModule), { fallbackOnErrors: true });
|
useContainer(app.select(BootModule), { fallbackOnErrors: true });
|
||||||
|
@ -26,6 +27,7 @@ async function bootstrap() {
|
||||||
defaultVersion: ["1", "2"],
|
defaultVersion: ["1", "2"],
|
||||||
type: VersioningType.URI,
|
type: VersioningType.URI,
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|
|
@ -26,7 +26,10 @@ export class FileController {
|
||||||
filename: (req, file, cb) => {
|
filename: (req, file, cb) => {
|
||||||
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
|
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
|
||||||
const ext = extname(file.originalname);
|
const ext = extname(file.originalname);
|
||||||
const filename = `${file.originalname.split(ext)[0]}-${uniqueSuffix}${ext}`;
|
const baseName = decodeURIComponent(file.originalname.split(ext)[0]);
|
||||||
|
console.log(baseName);
|
||||||
|
file.originalname = baseName + ext;
|
||||||
|
const filename = `${baseName}-${uniqueSuffix}${ext}`;
|
||||||
return cb(null, filename);
|
return cb(null, filename);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
###
|
###
|
||||||
@baseurl=http://127.0.0.1:3000/v1
|
@baseurl=http://127.0.0.1:4000/v1
|
||||||
|
|
||||||
###
|
###
|
||||||
POST {{baseurl}}/auth/register HTTP/1.1
|
POST {{baseurl}}/auth/register HTTP/1.1
|
||||||
|
|
|
@ -9,6 +9,16 @@ const nextConfig: NextConfig = {
|
||||||
reactStrictMode: true, // 开启react严格模式
|
reactStrictMode: true, // 开启react严格模式
|
||||||
serverExternalPackages: externals,
|
serverExternalPackages: externals,
|
||||||
transpilePackages: ["@repo/api", "@repo/ui"],
|
transpilePackages: ["@repo/api", "@repo/ui"],
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "http",
|
||||||
|
hostname: "192.168.31.43",
|
||||||
|
port: "4000",
|
||||||
|
pathname: "/v1/file/**",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
|
@ -1,22 +1,18 @@
|
||||||
|
import { PostList } from "@/app/_components/paginate/simple";
|
||||||
import { toast } from "@/app/_components/ui/sonner";
|
import { toast } from "@/app/_components/ui/sonner";
|
||||||
import { fetchApi } from "@/lib/api";
|
import { fetchApi } from "@/lib/api";
|
||||||
|
|
||||||
export default async function PostPage() {
|
export default async function PostPage() {
|
||||||
const result = await fetchApi((c) => c.api.posts.$get());
|
const result = await fetchApi((c) =>
|
||||||
|
c.api.posts.$get({ query: { limit: 50, page: 1, orderBy: "asc" } }),
|
||||||
|
);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
toast.error((await result.json()).message);
|
toast.error((await result.json()).message);
|
||||||
return <div></div>;
|
return <div />;
|
||||||
} else {
|
} else {
|
||||||
const data = await result.json();
|
const data = await result.json();
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="tw-container">
|
||||||
<p>总数:{data.meta.itemCount}</p>
|
<PostList posts={data.items}></PostList>
|
||||||
{data.items.map((item) => (
|
|
||||||
<div key={item.id}>
|
|
||||||
<h2>{item.title}</h2>
|
|
||||||
<p>{item.description}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
export default function TestPage() {
|
||||||
|
return <div></div>;
|
||||||
|
}
|
|
@ -18,7 +18,6 @@ import {
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/app/_components/ui/form";
|
} from "@/app/_components/ui/form";
|
||||||
import { Input } from "@/app/_components/ui/input";
|
import { Input } from "@/app/_components/ui/input";
|
||||||
import { toast } from "@/app/_components/ui/sonner";
|
|
||||||
import { setAccessToken } from "@/lib/token";
|
import { setAccessToken } from "@/lib/token";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
@ -41,21 +40,17 @@ export function LoginForm({
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const setauth = useAuthStore((state) => state.setAuth);
|
const setauth = useAuthStore((state) => state.setAuth);
|
||||||
const useloginHandler = async (data: AuthLoginRequest) => {
|
const useloginHandler = async (data: AuthLoginRequest) => {
|
||||||
try {
|
const { token } = await loginApi(data);
|
||||||
const { token } = await loginApi(data);
|
setAccessToken(token);
|
||||||
setAccessToken(token);
|
const user = await getUser();
|
||||||
const user = await getUser();
|
console.log(user);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
setauth(user);
|
setauth(user);
|
||||||
}
|
|
||||||
router.push("/");
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err);
|
|
||||||
|
|
||||||
toast.error("Invalid username or password.");
|
|
||||||
}
|
}
|
||||||
|
router.push("/");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("tw-flex tw-flex-col tw-gap-6", className)} {...props}>
|
<div className={cn("tw-flex tw-flex-col tw-gap-6", className)} {...props}>
|
||||||
<Card className="tw-overflow-hidden">
|
<Card className="tw-overflow-hidden">
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import type { AuthItem } from "@repo/api/types";
|
import type { AuthItem } from "@repo/api/types";
|
||||||
|
|
||||||
import { createStore } from "@/lib/store";
|
import { createPersistStore } from "@/lib/store";
|
||||||
|
|
||||||
interface AuthAction {
|
interface AuthAction {
|
||||||
setAuth: (auth: Date2String<AuthItem>) => void;
|
setAuth: (auth: Date2String<AuthItem>) => void;
|
||||||
|
@ -15,8 +15,13 @@ export type Date2String<T> = {
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
auth: Date2String<AuthItem> | null;
|
auth: Date2String<AuthItem> | null;
|
||||||
}
|
}
|
||||||
export const useAuthStore = createStore<AuthState & AuthAction>((set) => ({
|
export const useAuthStore = createPersistStore<AuthState & AuthAction>(
|
||||||
auth: null,
|
(set) => ({
|
||||||
setAuth: (auth: Date2String<AuthItem>) => set(() => ({ auth })),
|
auth: null,
|
||||||
removeAuth: () => set(() => ({ auth: null })),
|
setAuth: (auth: Date2String<AuthItem>) => set(() => ({ auth })),
|
||||||
}));
|
removeAuth: () => set(() => ({ auth: null })),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "auth",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
POST http://127.0.0.1:3001/api/posts/ HTTP/1.1
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "slug": "useFormls11111111111111",
|
||||||
|
"title": "useFormls1111111111111",
|
||||||
|
"summary": "useFormlsuseFormlsuseFormls",
|
||||||
|
"body": "useFormlsuseFormlsuseFormlsuseFormls",
|
||||||
|
"authorId": "0e5d2a48-c480-44a5-a22b-2e3201d47e5e",
|
||||||
|
"keywords": "useFormlsuseFormlsuseFormls",
|
||||||
|
"description": "useFormlsuseFormlsuseFormls",
|
||||||
|
"categoryId": "a6a5caac-e283-47be-9028-018a8df173dd",
|
||||||
|
"thumb": "http://192.168.31.43:4000/v1/file/fcbcc067-e812-43cf-8390-0ce361278290",
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"id": "3d9c01bf-eaec-4512-b3d0-bda0b3abf799",
|
||||||
|
"text": "测试2"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -9,6 +9,9 @@ import type { DeepNonNullable } from "utility-types";
|
||||||
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
import type { Option } from "@/app/_components/ui/multiple-selector/multiple-selector";
|
||||||
|
|
||||||
|
import { useAuthStore } from "@/app/_components/auth/store";
|
||||||
import { Details } from "@/app/_components/collapsible/details";
|
import { Details } from "@/app/_components/collapsible/details";
|
||||||
import { MdxEditor } from "@/app/_components/mdx/editor";
|
import { MdxEditor } from "@/app/_components/mdx/editor";
|
||||||
import { Button } from "@/app/_components/ui/button";
|
import { Button } from "@/app/_components/ui/button";
|
||||||
|
@ -24,6 +27,7 @@ import {
|
||||||
import { Input } from "@/app/_components/ui/input";
|
import { Input } from "@/app/_components/ui/input";
|
||||||
import MultipleSelector from "@/app/_components/ui/multiple-selector/multiple-selector";
|
import MultipleSelector from "@/app/_components/ui/multiple-selector/multiple-selector";
|
||||||
import { Textarea } from "@/app/_components/ui/textarea";
|
import { Textarea } from "@/app/_components/ui/textarea";
|
||||||
|
import InputFile from "@/app/_components/upload/upload";
|
||||||
|
|
||||||
import { CategorySelect } from "./category-select";
|
import { CategorySelect } from "./category-select";
|
||||||
|
|
||||||
|
@ -32,16 +36,33 @@ export const PostActionForm: React.FC<{
|
||||||
categoryList: CategoryList;
|
categoryList: CategoryList;
|
||||||
tagList: TagListResponse;
|
tagList: TagListResponse;
|
||||||
}> = ({ item, categoryList, tagList }) => {
|
}> = ({ item, categoryList, tagList }) => {
|
||||||
const form = useForm<DeepNonNullable<PostCreateOrUpdateData>>({
|
const auth = useAuthStore((state) => state.auth);
|
||||||
defaultValues: item,
|
const useFormls = (auth: string) =>
|
||||||
});
|
useForm<DeepNonNullable<PostCreateOrUpdateData>>({
|
||||||
|
defaultValues: { ...item, authorId: auth },
|
||||||
|
});
|
||||||
|
const form = useFormls(auth?.id || "");
|
||||||
return (
|
return (
|
||||||
<div className="tw-w-2/3 tw-mx-auto tw-pt-6">
|
<div className="tw-w-2/3 tw-mx-auto tw-pt-6">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
className=" tw-space-y-4"
|
className=" tw-space-y-4"
|
||||||
onSubmit={form.handleSubmit((data) => {
|
onSubmit={form.handleSubmit(async (data) => {
|
||||||
console.log(data);
|
const tags = data.tags as unknown as Option[];
|
||||||
|
const newtags = tags.map((tag) => ({
|
||||||
|
id: tag.value,
|
||||||
|
text: tag.label,
|
||||||
|
}));
|
||||||
|
const newdata = { ...data, tags: newtags };
|
||||||
|
console.log(newdata, "newdata");
|
||||||
|
const res = await fetch("http://127.0.0.1:3001/api/posts/", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(newdata),
|
||||||
|
});
|
||||||
|
console.log(await res.json(), "res");
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
|
@ -63,26 +84,25 @@ export const PostActionForm: React.FC<{
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Details summary="可选字段">
|
<FormField
|
||||||
<FormField
|
control={form.control}
|
||||||
control={form.control}
|
name="slug"
|
||||||
name="summary"
|
render={({ field }) => {
|
||||||
render={({ field }) => (
|
return (
|
||||||
<FormItem className="tw-mt-2 tw-border-b tw-border-dashed tw-pb-1">
|
<FormItem>
|
||||||
<FormLabel>摘要简述</FormLabel>
|
<FormLabel>bieming</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
placeholder="请输入文章摘要"
|
placeholder="请输入标题"
|
||||||
disabled={form.formState.isSubmitting}
|
disabled={form.formState.isSubmitting}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>摘要会显示在文章列表页</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
);
|
||||||
/>
|
}}
|
||||||
</Details>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="categoryId"
|
name="categoryId"
|
||||||
|
@ -134,7 +154,85 @@ export const PostActionForm: React.FC<{
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="thumb"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="tw-mt-2 tw-border-b tw-border-dashed tw-pb-1">
|
||||||
|
<FormLabel>封面图</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<InputFile
|
||||||
|
imageUrl={field.value}
|
||||||
|
setImageUrl={field.onChange}
|
||||||
|
></InputFile>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>文章展示封面图</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
></FormField>
|
||||||
|
|
||||||
|
<Details summary="SEO相关字段" defaultOpen={false}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="summary"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="tw-mt-2 tw-border-b tw-border-dashed tw-pb-1">
|
||||||
|
<FormLabel>摘要简述</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
{...field}
|
||||||
|
placeholder="请输入文章摘要"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>摘要会显示在文章列表页</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="keywords"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="tw-mt-2 tw-border-b tw-border-dashed tw-pb-1">
|
||||||
|
<FormLabel>关键字</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="请输入关键字,用逗号分割(关键字是可选的)"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
关键字不会显示,仅在SEO时发挥作用.每个关键字之间请用英文逗号(,)分割
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="tw-mt-2 tw-border-b tw-border-dashed tw-pb-1">
|
||||||
|
<FormLabel>文章描述</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
{...field}
|
||||||
|
placeholder="请输入文章描述"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
文章描述不会显示,仅在SEO时发挥作用
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Details>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="body"
|
name="body"
|
||||||
|
|
|
@ -16,10 +16,5 @@ export const slugUniqueValidator = async (slug: string): Promise<boolean> => {
|
||||||
};
|
};
|
||||||
export const createPostApi = async (data: PostCreateOrUpdateData) => {
|
export const createPostApi = async (data: PostCreateOrUpdateData) => {
|
||||||
const result = await fetchApi((c) => c.api.posts.$post({ json: data }));
|
const result = await fetchApi((c) => c.api.posts.$post({ json: data }));
|
||||||
switch (result.status) {
|
return result;
|
||||||
case 200:
|
|
||||||
return await result.json();
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -32,7 +32,6 @@ import {
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/app/_components/ui/tooltip";
|
} from "@/app/_components/ui/tooltip";
|
||||||
import { deleteCookie } from "@/lib/cookies";
|
import { deleteCookie } from "@/lib/cookies";
|
||||||
import { ACCESS_TOKEN_COOKIE_NAME } from "@/lib/token";
|
|
||||||
|
|
||||||
import { useAuthStore } from "../../auth/store";
|
import { useAuthStore } from "../../auth/store";
|
||||||
|
|
||||||
|
@ -58,7 +57,7 @@ const UserAction = ({ value }: { value: Date2String<AuthItem> }) => {
|
||||||
const removeAuth = useAuthStore((state) => state.removeAuth);
|
const removeAuth = useAuthStore((state) => state.removeAuth);
|
||||||
const loginout = () => {
|
const loginout = () => {
|
||||||
removeAuth();
|
removeAuth();
|
||||||
deleteCookie(ACCESS_TOKEN_COOKIE_NAME);
|
deleteCookie("auth_token");
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
|
|
|
@ -1,78 +1,55 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
import type { PostItem } from "@repo/api/types";
|
||||||
|
|
||||||
import type { FC } from "react";
|
import Image from "next/image";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import clsx from "clsx";
|
export function PostList({ posts }: { posts: PostItem[] }) {
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
const [displayCount, setDisplayCount] = useState(5);
|
||||||
import { useCallback, useEffect } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Pagination,
|
|
||||||
PaginationContent,
|
|
||||||
PaginationItem,
|
|
||||||
PaginationNext,
|
|
||||||
PaginationPrevious,
|
|
||||||
} from "@/app/_components/ui/pagination";
|
|
||||||
|
|
||||||
const SimplePaginate: FC<{ totalPages: number; currentPage: number }> = ({
|
|
||||||
totalPages,
|
|
||||||
currentPage,
|
|
||||||
}) => {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const router = useRouter();
|
|
||||||
const pathname = usePathname();
|
|
||||||
const getPageUrl = useCallback(
|
|
||||||
(page: number) => {
|
|
||||||
const parems = new URLSearchParams(searchParams);
|
|
||||||
page <= 1 ? parems.delete("page") : parems.set("page", page.toString());
|
|
||||||
return pathname + (parems.toString() ? `?${parems.toString()}` : "");
|
|
||||||
},
|
|
||||||
[searchParams],
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 在当前页面小于等于1时,删除URL中的页面查询参数
|
const handleScroll = () => {
|
||||||
const params = new URLSearchParams(searchParams);
|
const distanceFromBottom =
|
||||||
if (currentPage <= 1) params.delete("page");
|
document.documentElement.scrollHeight -
|
||||||
router.replace(
|
(window.innerHeight + document.documentElement.scrollTop);
|
||||||
pathname + (params.toString() ? `?${params.toString()}` : ""),
|
if (distanceFromBottom < 500 && displayCount < posts.length) {
|
||||||
);
|
// 100px 是阈值
|
||||||
}, [currentPage]);
|
setDisplayCount((prevCount) => prevCount + 1);
|
||||||
if (currentPage < 1) return null;
|
}
|
||||||
|
setDisplayCount((prevCount) => prevCount + 1);
|
||||||
|
};
|
||||||
|
window.addEventListener("scroll", handleScroll);
|
||||||
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
|
}, [displayCount]);
|
||||||
return (
|
return (
|
||||||
<Pagination>
|
<div className=" tw-border-2 tw-flex tw-items-center tw-flex-col">
|
||||||
<PaginationContent>
|
{posts.slice(0, displayCount).map((item) => (
|
||||||
<PaginationItem>
|
<div
|
||||||
<PaginationPrevious
|
className="tw-flex tw-justify-between tw-w-4/5 tw-h-full tw-my-5 tw-border tw-p-2"
|
||||||
size={"default"}
|
key={item.id}
|
||||||
className={clsx(
|
>
|
||||||
"tw-rounded-sm,",
|
<div>
|
||||||
currentPage <= 1
|
<Image src={item.thumb} alt={item.title} width={500} height={500} />
|
||||||
? " tw-shadow-gray-50 tw-bg-slate-50/70"
|
</div>
|
||||||
: " tw-bg-white/90 hover:tw-shadow-nylg",
|
<div className="tw-flex-1 tw-flex tw-flex-col tw-items-start ">
|
||||||
)}
|
<h1 className="tw-pt-6">{item.title}</h1>
|
||||||
href={getPageUrl(currentPage - 1)}
|
|
||||||
isActive={currentPage <= 1}
|
<p className="tw-flex-1 tw-mt-5">
|
||||||
aria-label="访问上一页"
|
<span className="tw-whitespace-normal tw-break-words ">
|
||||||
title="上一页"
|
{(item.description || "")?.length < 200
|
||||||
/>
|
? item.description
|
||||||
</PaginationItem>
|
: `${item.description?.slice(0, 200)}...`}
|
||||||
<PaginationItem>
|
</span>
|
||||||
<PaginationNext
|
</p>
|
||||||
size={"default"}
|
<p>{(item.tags || []).map((tag: any) => `#${tag.text} `)}</p>
|
||||||
className={clsx(
|
<p>
|
||||||
"tw-rounded-sm,",
|
日期:{" "}
|
||||||
currentPage >= totalPages
|
<time dateTime={item.createdAt}>
|
||||||
? " tw-shadow-gray-50 tw-bg-slate-50/70"
|
{new Date(item.createdAt).toDateString()}
|
||||||
: " tw-bg-white/90 hover:tw-shadow-nylg",
|
</time>
|
||||||
)}
|
</p>
|
||||||
href={getPageUrl(currentPage + 1)}
|
</div>
|
||||||
isActive={totalPages <= currentPage}
|
</div>
|
||||||
aria-label="访问下一页"
|
))}
|
||||||
title="下一页"
|
</div>
|
||||||
/>
|
|
||||||
</PaginationItem>
|
|
||||||
</PaginationContent>
|
|
||||||
</Pagination>
|
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
export default SimplePaginate;
|
|
||||||
|
|
|
@ -133,7 +133,7 @@ const SelectItem = React.forwardRef<
|
||||||
</SelectPrimitive.ItemIndicator>
|
</SelectPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
<SelectPrimitive.ItemText style={{ color: "red" }}>
|
<SelectPrimitive.ItemText style={{ color: "red" }}>
|
||||||
{children}122
|
{children}
|
||||||
</SelectPrimitive.ItemText>
|
</SelectPrimitive.ItemText>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
));
|
));
|
||||||
|
|
|
@ -1,47 +1,81 @@
|
||||||
"use client";
|
"use client";
|
||||||
import React from "react";
|
|
||||||
import { useDropzone } from "react-dropzone";
|
|
||||||
|
|
||||||
import { getCookie } from "@/lib/cookies";
|
import type { ChangeEvent } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export function UploadPage({
|
import Image from "next/image";
|
||||||
url,
|
import React, { useState } from "react";
|
||||||
className,
|
|
||||||
}: {
|
import { Button } from "../ui/button";
|
||||||
url: string;
|
import { Input } from "../ui/input";
|
||||||
className?: string;
|
|
||||||
}) {
|
interface InputFileProps {
|
||||||
const onDrop = (acceptedFiles: Blob[]) => {
|
imageUrl: string;
|
||||||
acceptedFiles.forEach((file: Blob) => {
|
setImageUrl: (url: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InputFile(props: InputFileProps) {
|
||||||
|
const { imageUrl, setImageUrl } = props;
|
||||||
|
const [selectedFile, setSelectedFile] = useState<string | undefined>(
|
||||||
|
imageUrl,
|
||||||
|
);
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const fileObj = e.target.files?.[0];
|
||||||
|
|
||||||
|
if (fileObj) {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.readAsArrayBuffer(file);
|
reader.onloadend = () => {
|
||||||
reader.onabort = () => console.log("file reading was aborted");
|
setSelectedFile(reader.result as string);
|
||||||
reader.onerror = () => console.log("file reading has failed");
|
setFile(fileObj);
|
||||||
reader.onload = async () => {
|
|
||||||
const data = new FormData();
|
|
||||||
data.append("file", file);
|
|
||||||
fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
body: data,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${await getCookie("auth_token")}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
});
|
reader.readAsDataURL(fileObj);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const { getRootProps, getInputProps } = useDropzone({ onDrop });
|
|
||||||
|
const handleRemoveClick = () => {
|
||||||
|
setSelectedFile(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (!file) return;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file, encodeURIComponent(file.name)); // 直接用 File 对象
|
||||||
|
|
||||||
|
const res = await fetch("http://192.168.31.43:4000/v1/file/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
// 处理响应
|
||||||
|
const data = await res.json();
|
||||||
|
setImageUrl(`http://192.168.31.43:4000/v1/file/${data.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="tw-grid tw-w-full tw-max-w-sm tw-items-center tw-gap-1.5">
|
||||||
{...getRootProps({
|
<Input id="picture" type="file" onChange={handleFileChange} />
|
||||||
className: cn(
|
{selectedFile && (
|
||||||
"tw-size-12 tw-pt-96 tw-border tw-border-red-100",
|
<div className="tw-mt-2 tw-relative">
|
||||||
className,
|
<Image src={selectedFile} alt="Preview" width={400} height={400} />
|
||||||
),
|
<button
|
||||||
})}
|
type="button"
|
||||||
>
|
onClick={handleRemoveClick}
|
||||||
<input {...getInputProps()} />
|
className="tw-absolute tw-top-0 tw-right-0 tw-bg-red-500 tw-text-white tw-py-1 tw-px-2"
|
||||||
|
aria-label="Remove image"
|
||||||
|
>
|
||||||
|
X
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="tw-w-24"
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={handleUpload}
|
||||||
|
>
|
||||||
|
上传封面
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,6 +59,8 @@ const getAccessTokenOptions = (token: string): AccessTokenCookieOptions => {
|
||||||
*/
|
*/
|
||||||
const setAccessToken = (token: string) => {
|
const setAccessToken = (token: string) => {
|
||||||
const options = getAccessTokenOptions(token);
|
const options = getAccessTokenOptions(token);
|
||||||
|
console.log("set access token", options);
|
||||||
|
|
||||||
setCookie(
|
setCookie(
|
||||||
options.name,
|
options.name,
|
||||||
token,
|
token,
|
||||||
|
|