Add registration flow and improve admin post management
This commit is contained in:
66
README.md
66
README.md
@@ -1,66 +1,42 @@
|
||||
# OPC 信息流平台
|
||||
# OPC Solo Feed 信息流
|
||||
|
||||
面向 OPC(One Person Company,一人公司)的轻量信息流发布站。
|
||||
个人即公司:记录更新、发布进展、同步给关注你的人;简单、克制、能持续。
|
||||
OPC(One Person Company,一人即是公司)信息流项目,用于个人/小团队以“信息流平台”的方式发布动态、记录进展与沉淀内容。
|
||||
|
||||
## 你会得到什么
|
||||
- 一个可公开访问的“个人公司动态墙”
|
||||
- 支持 Markdown 的内容发布
|
||||
- 文章详情页可长期访问(稳定 URL)
|
||||
- RSS 订阅与站点地图,方便搜索与关注
|
||||
- 后台口令发布与统计,维护成本低
|
||||
|
||||
## 典型使用场景
|
||||
- 个人产品更新、周报、交付日志
|
||||
- 公开路线图、里程碑、版本发布
|
||||
- 内容实验、灵感记录、对外同步
|
||||
## 功能概览
|
||||
- 信息流首页:搜索、标签筛选
|
||||
- 内容详情:Markdown 渲染、分享链接/二维码、阅读统计
|
||||
- 后台管理:登录、注册、发布/编辑/删除、统计面板
|
||||
- 登录有效期:24 小时,过期需重新登录
|
||||
|
||||
## 技术栈
|
||||
- Next.js 14(App Router)
|
||||
- MongoDB Atlas(免费云数据库)
|
||||
- MongoDB Atlas
|
||||
- Tailwind CSS
|
||||
- Markdown 渲染
|
||||
- Markdown
|
||||
|
||||
## 快速开始
|
||||
1. 安装依赖
|
||||
## 本地开发
|
||||
1. 安装依赖
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. 新建 `.env.local`
|
||||
2. 配置环境变量:新建 `.env.local`
|
||||
```
|
||||
MONGODB_URI=你的Mongo连接串
|
||||
MONGODB_DB=pushinfo
|
||||
ADMIN_PASS=自定义后台口令
|
||||
SESSION_SECRET=任意长随机字符串
|
||||
NEXT_PUBLIC_SITE_URL=https://你的域名
|
||||
SESSION_SECRET=用于签名的随机长字符串
|
||||
NEXT_PUBLIC_SITE_URL=https://你的站点域名
|
||||
```
|
||||
|
||||
3. 开发运行
|
||||
3. 启动开发服务器
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 功能清单
|
||||
- 首页信息流:最新发布置顶
|
||||
- 文章详情页:Markdown 渲染
|
||||
- 后台:口令登录、发布、编辑/删除、统计
|
||||
- 标签聚合页与标签详情页
|
||||
- 搜索与分页
|
||||
- RSS:`/rss`(标题:不务正业的木子;文案:less is more)
|
||||
- Sitemap:`/sitemap.xml`
|
||||
4. 打开页面并注册账号
|
||||
- 注册:`/register`
|
||||
- 登录:`/login`
|
||||
|
||||
## 部署建议
|
||||
- 前端:Vercel
|
||||
- 数据库:MongoDB Atlas 免费套餐
|
||||
- 图片:图床(如 `https://img.020417.xyz`)
|
||||
|
||||
## 设计原则
|
||||
- 低摩擦发布
|
||||
- 少即是多
|
||||
- 长期可维护
|
||||
|
||||
## TODO
|
||||
- 草稿与置顶
|
||||
- 私密分享链接
|
||||
- 全文索引(MongoDB Text Index)
|
||||
## 说明
|
||||
- 后台入口:`/admin`
|
||||
- 登录后右上角显示当前用户名
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getDb } from "@/lib/mongo";
|
||||
import { notFound } from "next/navigation";
|
||||
import { EditPostForm } from "@/components/EditPostForm";
|
||||
import { Post } from "@/types/post";
|
||||
import { DEFAULT_OPC_SIGNAL } from "@/lib/opc";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -16,7 +17,8 @@ async function fetchPost(slug: string): Promise<Post | null> {
|
||||
markdown: post.markdown ?? "",
|
||||
cover: post.cover,
|
||||
tags: post.tags ?? [],
|
||||
author: post.author ?? "admin",
|
||||
signal: post.signal ?? DEFAULT_OPC_SIGNAL,
|
||||
author: post.author ?? "佚名",
|
||||
createdAt: post.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: post.updatedAt ?? post.createdAt ?? new Date().toISOString(),
|
||||
views: post.views ?? 0
|
||||
|
||||
@@ -4,14 +4,40 @@ import { getDb } from "@/lib/mongo";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const cardClass =
|
||||
"rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100 transition-[transform,box-shadow] duration-300 will-change-transform transform-gpu hover:shadow-lg hover:[transform:perspective(900px)_translateY(-4px)_rotateX(2deg)_rotateY(-2deg)]";
|
||||
|
||||
async function fetchStats() {
|
||||
const db = await getDb();
|
||||
const collection = db.collection("posts");
|
||||
const total = await collection.countDocuments();
|
||||
const latest = await collection.find({}, { projection: { title: 1, createdAt: 1 } }).sort({ createdAt: -1 }).limit(1).toArray();
|
||||
const top = await collection.find({}, { projection: { title: 1, views: 1, slug: 1 } }).sort({ views: -1 }).limit(3).toArray();
|
||||
const latest = await collection
|
||||
.find({}, { projection: { title: 1, createdAt: 1 } })
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(1)
|
||||
.toArray();
|
||||
const top = await collection
|
||||
.find({}, { projection: { title: 1, views: 1, slug: 1 } })
|
||||
.sort({ views: -1 })
|
||||
.limit(3)
|
||||
.toArray();
|
||||
const viewsAgg = await collection
|
||||
.aggregate([{ $group: { _id: null, totalViews: { $sum: { $ifNull: ["$views", 0] } } } }])
|
||||
.toArray();
|
||||
const totalViews = viewsAgg[0]?.totalViews ?? 0;
|
||||
const avgViews = total > 0 ? Math.round(totalViews / total) : 0;
|
||||
const tagCount = (await collection.distinct("tags")).filter(Boolean).length;
|
||||
const authorCount = (await collection.distinct("author")).filter(Boolean).length;
|
||||
|
||||
return { total, latest: latest[0] || null, top };
|
||||
return {
|
||||
total,
|
||||
latest: latest[0] || null,
|
||||
top,
|
||||
totalViews,
|
||||
avgViews,
|
||||
tagCount,
|
||||
authorCount
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchRecentPosts() {
|
||||
@@ -25,6 +51,7 @@ async function fetchRecentPosts() {
|
||||
return posts.map((p: any) => ({
|
||||
...p,
|
||||
_id: p._id?.toString(),
|
||||
author: p.author ?? "佚名",
|
||||
createdAtText: new Date(p.createdAt).toLocaleString("zh-CN", {
|
||||
hour12: false,
|
||||
timeZone: "Asia/Shanghai"
|
||||
@@ -32,19 +59,91 @@ async function fetchRecentPosts() {
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchAllTags() {
|
||||
const db = await getDb();
|
||||
const tags = await db
|
||||
.collection("posts")
|
||||
.aggregate([
|
||||
{ $unwind: "$tags" },
|
||||
{ $group: { _id: "$tags", count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1, _id: 1 } }
|
||||
])
|
||||
.toArray();
|
||||
return tags.map((t: any) => t._id).filter(Boolean);
|
||||
}
|
||||
|
||||
async function fetchTagStats() {
|
||||
const db = await getDb();
|
||||
const tags = await db
|
||||
.collection("posts")
|
||||
.aggregate([
|
||||
{ $unwind: "$tags" },
|
||||
{ $group: { _id: "$tags", count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1, _id: 1 } },
|
||||
{ $limit: 12 }
|
||||
])
|
||||
.toArray();
|
||||
return tags.map((t: any) => ({ tag: t._id, count: t.count }));
|
||||
}
|
||||
|
||||
async function fetchAuthorStats() {
|
||||
const db = await getDb();
|
||||
const authors = await db
|
||||
.collection("posts")
|
||||
.aggregate([
|
||||
{ $group: { _id: { $ifNull: ["$author", "佚名"] }, count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1, _id: 1 } },
|
||||
{ $limit: 10 }
|
||||
])
|
||||
.toArray();
|
||||
return authors.map((a: any) => ({ author: a._id, count: a.count }));
|
||||
}
|
||||
|
||||
async function fetchDailyStats() {
|
||||
const db = await getDb();
|
||||
const now = new Date();
|
||||
const days: { key: string; label: string }[] = [];
|
||||
for (let i = 6; i >= 0; i -= 1) {
|
||||
const d = new Date(now);
|
||||
d.setDate(now.getDate() - i);
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
const label = `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
days.push({ key, label });
|
||||
}
|
||||
const since = `${days[0].key}T00:00:00.000Z`;
|
||||
const raw = await db
|
||||
.collection("posts")
|
||||
.aggregate([
|
||||
{ $match: { createdAt: { $gte: since } } },
|
||||
{ $addFields: { day: { $dateToString: { format: "%Y-%m-%d", date: { $toDate: "$createdAt" } } } } },
|
||||
{ $group: { _id: "$day", count: { $sum: 1 } } }
|
||||
])
|
||||
.toArray();
|
||||
const map = new Map<string, number>();
|
||||
raw.forEach((item: any) => map.set(item._id, item.count));
|
||||
return days.map((d) => ({ label: d.label, count: map.get(d.key) ?? 0 }));
|
||||
}
|
||||
|
||||
export default async function AdminPage() {
|
||||
const stats = await fetchStats();
|
||||
const recentPosts = await fetchRecentPosts();
|
||||
const availableTags = await fetchAllTags();
|
||||
const tagStats = await fetchTagStats();
|
||||
const authorStats = await fetchAuthorStats();
|
||||
const dailyStats = await fetchDailyStats();
|
||||
const tagMax = Math.max(...tagStats.map((t) => t.count), 1);
|
||||
const authorMax = Math.max(...authorStats.map((a) => a.count), 1);
|
||||
const dailyMax = Math.max(...dailyStats.map((d) => d.count), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100">
|
||||
<p className="text-sm text-slate-500">累计发布</p>
|
||||
<div className={cardClass}>
|
||||
<p className="text-sm text-slate-500">总内容数</p>
|
||||
<p className="mt-1 text-3xl font-semibold">{stats.total}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100">
|
||||
<p className="text-sm text-slate-500">最新一条</p>
|
||||
<div className={cardClass}>
|
||||
<p className="text-sm text-slate-500">最新发布</p>
|
||||
<p className="mt-1 text-base font-semibold">{stats.latest?.title ?? "暂无"}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{stats.latest?.createdAt
|
||||
@@ -52,20 +151,112 @@ export default async function AdminPage() {
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100">
|
||||
<p className="text-sm text-slate-500">Top 热度</p>
|
||||
<div className={cardClass}>
|
||||
<p className="text-sm text-slate-500">Top 阅读</p>
|
||||
<ul className="mt-1 space-y-1 text-sm">
|
||||
{stats.top.map((item: any) => (
|
||||
<li key={item.slug} className="flex items-center justify-between">
|
||||
<span className="truncate">{item.title}</span>
|
||||
<span className="text-slate-500">{item.views ?? 0} 次</span>
|
||||
<span className="text-slate-500">{item.views ?? 0} 阅读</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CreatePostForm />
|
||||
<section className="rounded-2xl bg-white/80 p-5 shadow-sm ring-1 ring-slate-100">
|
||||
<h3 className="text-lg font-semibold">统计面板</h3>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className={cardClass}>
|
||||
<p className="text-sm text-slate-500">总阅读</p>
|
||||
<p className="mt-1 text-2xl font-semibold">{stats.totalViews}</p>
|
||||
</div>
|
||||
<div className={cardClass}>
|
||||
<p className="text-sm text-slate-500">平均阅读</p>
|
||||
<p className="mt-1 text-2xl font-semibold">{stats.avgViews}</p>
|
||||
</div>
|
||||
<div className={cardClass}>
|
||||
<p className="text-sm text-slate-500">标签数量</p>
|
||||
<p className="mt-1 text-2xl font-semibold">{stats.tagCount}</p>
|
||||
</div>
|
||||
<div className={cardClass}>
|
||||
<p className="text-sm text-slate-500">作者数量</p>
|
||||
<p className="mt-1 text-2xl font-semibold">{stats.authorCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100">
|
||||
<h4 className="text-sm font-semibold text-slate-700">标签分布</h4>
|
||||
<div className="mt-3 space-y-3">
|
||||
{tagStats.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">暂无标签数据</p>
|
||||
) : (
|
||||
tagStats.map((item) => (
|
||||
<div key={item.tag} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs text-slate-600">
|
||||
<span>#{item.tag}</span>
|
||||
<span>{item.count}</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-slate-100">
|
||||
<div
|
||||
className="h-2 rounded-full bg-brand-500/80"
|
||||
style={{ width: `${(item.count / tagMax) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100">
|
||||
<h4 className="text-sm font-semibold text-slate-700">作者分布</h4>
|
||||
<div className="mt-3 space-y-3">
|
||||
{authorStats.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">暂无作者数据</p>
|
||||
) : (
|
||||
authorStats.map((item) => (
|
||||
<div key={item.author} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs text-slate-600">
|
||||
<span>{item.author}</span>
|
||||
<span>{item.count}</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-slate-100">
|
||||
<div
|
||||
className="h-2 rounded-full bg-indigo-500/80"
|
||||
style={{ width: `${(item.count / authorMax) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100">
|
||||
<h4 className="text-sm font-semibold text-slate-700">近 7 天发布</h4>
|
||||
<div className="mt-3 space-y-3">
|
||||
{dailyStats.map((item) => (
|
||||
<div key={item.label} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs text-slate-600">
|
||||
<span>{item.label}</span>
|
||||
<span>{item.count}</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-slate-100">
|
||||
<div
|
||||
className="h-2 rounded-full bg-emerald-500/80"
|
||||
style={{ width: `${(item.count / dailyMax) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CreatePostForm availableTags={availableTags} />
|
||||
|
||||
<AdminPostList initialPosts={recentPosts} />
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { signSession, cookieName } from "@/lib/auth";
|
||||
import { getDb } from "@/lib/mongo";
|
||||
import { verifyPassword } from "@/lib/password";
|
||||
import { z } from "zod";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const password = body.password as string | undefined;
|
||||
const target = process.env.ADMIN_PASS;
|
||||
const schema = z.object({
|
||||
username: z.string().trim().min(2).max(32),
|
||||
password: z.string().min(6).max(128)
|
||||
});
|
||||
const parsed = schema.safeParse(body);
|
||||
|
||||
if (!target) {
|
||||
return NextResponse.json({ error: "ADMIN_PASS is not set on server" }, { status: 500 });
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "请输入正确的用户名与密码" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!password || password !== target) {
|
||||
return NextResponse.json({ error: "密码错误" }, { status: 401 });
|
||||
const { username, password } = parsed.data;
|
||||
const db = await getDb();
|
||||
const user = await db.collection("users").findOne({ usernameLower: username.toLowerCase() });
|
||||
if (
|
||||
!user ||
|
||||
typeof user.passwordSalt !== "string" ||
|
||||
typeof user.passwordHash !== "string" ||
|
||||
!verifyPassword(password, user.passwordSalt, user.passwordHash)
|
||||
) {
|
||||
return NextResponse.json({ error: "用户名或密码错误" }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = await signSession({ role: "admin", iat: Date.now() });
|
||||
const res = NextResponse.json({ ok: true });
|
||||
const name = user.displayName || user.username || username;
|
||||
const exp = Date.now() + 24 * 60 * 60 * 1000;
|
||||
const token = await signSession({
|
||||
role: "admin",
|
||||
iat: Date.now(),
|
||||
exp,
|
||||
uid: user._id?.toString(),
|
||||
name
|
||||
});
|
||||
const res = NextResponse.json({ ok: true, name });
|
||||
res.cookies.set(cookieName, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: true,
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 24,
|
||||
path: "/"
|
||||
});
|
||||
return res;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDb } from "@/lib/mongo";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { z } from "zod";
|
||||
import { OPC_SIGNAL_VALUES } from "@/lib/opc";
|
||||
|
||||
export async function GET(_: NextRequest, { params }: { params: { slug: string } }) {
|
||||
const db = await getDb();
|
||||
@@ -17,6 +18,7 @@ export async function GET(_: NextRequest, { params }: { params: { slug: string }
|
||||
|
||||
return NextResponse.json({
|
||||
...post.value,
|
||||
author: post.value.author ?? "佚名",
|
||||
_id: (post.value._id as ObjectId)?.toString()
|
||||
});
|
||||
}
|
||||
@@ -28,7 +30,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { slug: stri
|
||||
markdown: z.string().min(5).optional(),
|
||||
cover: z.string().url().nullable().optional(),
|
||||
tags: z.array(z.string().trim()).optional(),
|
||||
author: z.string().optional()
|
||||
signal: z.enum(OPC_SIGNAL_VALUES).optional()
|
||||
});
|
||||
const parsed = schema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
@@ -49,7 +51,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { slug: stri
|
||||
set.cover = data.cover;
|
||||
}
|
||||
if (Array.isArray(data.tags)) set.tags = data.tags;
|
||||
if (typeof data.author === "string") set.author = data.author;
|
||||
if (typeof data.signal === "string") set.signal = data.signal;
|
||||
|
||||
const update: Record<string, unknown> = { $set: set };
|
||||
if (Object.keys(unset).length > 0) update.$unset = unset;
|
||||
|
||||
@@ -2,13 +2,15 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDb } from "@/lib/mongo";
|
||||
import { z } from "zod";
|
||||
import { generateSlug } from "@/lib/slug";
|
||||
import { DEFAULT_OPC_SIGNAL, OPC_SIGNAL_VALUES } from "@/lib/opc";
|
||||
import { cookieName, verifySession } from "@/lib/auth";
|
||||
|
||||
const postSchema = z.object({
|
||||
title: z.string().min(2).max(80),
|
||||
markdown: z.string().min(5),
|
||||
cover: z.string().url().optional(),
|
||||
tags: z.array(z.string().trim()).optional(),
|
||||
author: z.string().default("admin")
|
||||
signal: z.enum(OPC_SIGNAL_VALUES).default(DEFAULT_OPC_SIGNAL)
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
@@ -23,6 +25,7 @@ export async function GET() {
|
||||
return NextResponse.json(
|
||||
posts.map((p) => ({
|
||||
...p,
|
||||
author: p.author ?? "佚名",
|
||||
_id: p._id?.toString()
|
||||
}))
|
||||
);
|
||||
@@ -37,6 +40,12 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
const token = req.cookies.get(cookieName)?.value;
|
||||
const session = await verifySession(token);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
const author = session.name ?? "佚名";
|
||||
const now = new Date().toISOString();
|
||||
let slug = generateSlug();
|
||||
const db = await getDb();
|
||||
@@ -53,6 +62,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const doc = {
|
||||
...data,
|
||||
author,
|
||||
slug,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
||||
59
app/api/register/route.ts
Normal file
59
app/api/register/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDb } from "@/lib/mongo";
|
||||
import { hashPassword } from "@/lib/password";
|
||||
import { signSession, cookieName } from "@/lib/auth";
|
||||
import { z } from "zod";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const schema = z.object({
|
||||
username: z.string().trim().min(2).max(32),
|
||||
password: z.string().min(6).max(128),
|
||||
displayName: z.string().trim().min(2).max(32).optional()
|
||||
});
|
||||
const parsed = schema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "请填写有效的用户名与密码" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { username, password, displayName } = parsed.data;
|
||||
const usernameLower = username.toLowerCase();
|
||||
const db = await getDb();
|
||||
|
||||
const exists = await db.collection("users").findOne({ usernameLower });
|
||||
if (exists) {
|
||||
return NextResponse.json({ error: "用户名已存在" }, { status: 409 });
|
||||
}
|
||||
|
||||
const { hash, salt } = hashPassword(password);
|
||||
const now = new Date().toISOString();
|
||||
const doc = {
|
||||
username,
|
||||
usernameLower,
|
||||
displayName: displayName || username,
|
||||
passwordHash: hash,
|
||||
passwordSalt: salt,
|
||||
createdAt: now
|
||||
};
|
||||
|
||||
const result = await db.collection("users").insertOne(doc);
|
||||
|
||||
const name = doc.displayName;
|
||||
const exp = Date.now() + 24 * 60 * 60 * 1000;
|
||||
const token = await signSession({
|
||||
role: "admin",
|
||||
iat: Date.now(),
|
||||
exp,
|
||||
uid: result.insertedId?.toString(),
|
||||
name
|
||||
});
|
||||
const res = NextResponse.json({ ok: true, name });
|
||||
res.cookies.set(cookieName, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 24,
|
||||
path: "/"
|
||||
});
|
||||
return res;
|
||||
}
|
||||
@@ -2,13 +2,19 @@ import "./globals.css";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ReactNode } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import { cookieName, getAdminName, verifySession } from "@/lib/auth";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Push Info",
|
||||
title: "OPC Solo Feed",
|
||||
description: "轻量信息流发布平台,支持 Markdown 与多端浏览。"
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||
const token = cookies().get(cookieName)?.value;
|
||||
const session = await verifySession(token);
|
||||
const userName = session?.name ?? getAdminName();
|
||||
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className="text-slate-900 antialiased">
|
||||
@@ -16,26 +22,40 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
<header className="mb-8 flex flex-wrap items-center justify-between gap-3 rounded-2xl bg-white/70 p-4 shadow-sm ring-1 ring-slate-100 backdrop-blur">
|
||||
<Link href="/" className="flex items-center gap-2 font-semibold text-slate-900">
|
||||
<span className="rounded-xl bg-brand-100 px-2 py-1 text-xs font-bold uppercase text-brand-700">
|
||||
Push
|
||||
Solo
|
||||
</span>
|
||||
<span>信息流</span>
|
||||
<span>solo-feed</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-3 text-sm text-slate-600">
|
||||
<Link href="/" className="hover:text-brand-600">
|
||||
首页
|
||||
</Link>
|
||||
<Link href="/tags" className="hover:text-brand-600">
|
||||
标签
|
||||
</Link>
|
||||
<Link href="/admin" className="hover:text-brand-600">
|
||||
管理
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="flex items-center gap-3 text-sm text-slate-600">
|
||||
<nav className="flex items-center gap-3">
|
||||
<Link href="/" className="hover:text-brand-600">
|
||||
首页
|
||||
</Link>
|
||||
<Link href="/tags" className="hover:text-brand-600">
|
||||
标签
|
||||
</Link>
|
||||
<Link href="/admin" className="hover:text-brand-600">
|
||||
后台
|
||||
</Link>
|
||||
</nav>
|
||||
{session ? (
|
||||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700">
|
||||
{userName}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
href="/login"
|
||||
className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700 hover:text-brand-600"
|
||||
>
|
||||
登录
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1">{children}</main>
|
||||
<footer className="mt-10 flex items-center justify-between border-t border-slate-200 pt-6 text-xs text-slate-500">
|
||||
<span>云图床:img.020417.xyz</span>
|
||||
<span>Made for friends · {new Date().getFullYear()}</span>
|
||||
<span>素材来自 img.020417.xyz</span>
|
||||
<span>为朋友制作 · {new Date().getFullYear()}</span>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function LoginForm() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -18,14 +18,13 @@ export default function LoginForm() {
|
||||
const res = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password })
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data.error || "登录失败");
|
||||
} else {
|
||||
const next = params.get("next") || "/admin";
|
||||
router.push(next);
|
||||
router.replace("/");
|
||||
router.refresh();
|
||||
}
|
||||
} finally {
|
||||
@@ -36,14 +35,25 @@ export default function LoginForm() {
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<label className="block space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">后台口令</span>
|
||||
<span className="text-sm font-medium text-slate-700">用户名</span>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner focus:border-brand-500 focus:outline-none"
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">密码</span>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner focus:border-brand-500 focus:outline-none"
|
||||
placeholder="输入 ADMIN_PASS"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</label>
|
||||
{error ? <p className="text-sm text-red-500">{error}</p> : null}
|
||||
@@ -52,7 +62,7 @@ export default function LoginForm() {
|
||||
disabled={loading}
|
||||
className="w-full rounded-full bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? "登录中…" : "登录"}
|
||||
{loading ? "登录中..." : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import LoginForm from "./login-form";
|
||||
import { Suspense } from "react";
|
||||
|
||||
export const metadata = {
|
||||
title: "登录 - Push Info"
|
||||
title: "登录 - OPC Solo Feed"
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-md rounded-2xl bg-white/90 p-8 shadow-sm ring-1 ring-slate-100">
|
||||
<h1 className="text-2xl font-semibold text-slate-900">管理员登录</h1>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
输入后台口令即可发布/统计。口令保存在服务器的环境变量 ADMIN_PASS。
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-slate-600">使用已注册账号登录。</p>
|
||||
<div className="mt-6">
|
||||
<Suspense fallback={null}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
<LoginForm />
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-slate-500">
|
||||
还没有账号?{" "}
|
||||
<a href="/register" className="text-brand-600 hover:text-brand-700">
|
||||
去注册
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { normalizeImageUrl } from "@/lib/normalize";
|
||||
import { Post } from "@/types/post";
|
||||
import { SharePanel } from "@/components/SharePanel";
|
||||
import { getSiteUrl } from "@/lib/site";
|
||||
import { DEFAULT_OPC_SIGNAL } from "@/lib/opc";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -25,7 +26,8 @@ async function fetchPost(slug: string): Promise<Post | null> {
|
||||
markdown: doc.markdown ?? "",
|
||||
cover: doc.cover,
|
||||
tags: doc.tags ?? [],
|
||||
author: doc.author ?? "admin",
|
||||
signal: doc.signal ?? DEFAULT_OPC_SIGNAL,
|
||||
author: doc.author ?? "佚名",
|
||||
createdAt: doc.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: doc.updatedAt ?? doc.createdAt ?? new Date().toISOString(),
|
||||
views: doc.views ?? 0
|
||||
@@ -49,7 +51,7 @@ export default async function PostPage({ params }: Props) {
|
||||
<SharePanel url={shareUrl} />
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
{new Date(post.createdAt).toLocaleString("zh-CN", { hour12: false })}
|
||||
{post.author || "佚名"} · {new Date(post.createdAt).toLocaleString("zh-CN", { hour12: false })}
|
||||
</p>
|
||||
{coverUrl ? (
|
||||
<img
|
||||
|
||||
40
app/page.tsx
40
app/page.tsx
@@ -35,6 +35,7 @@ async function fetchPosts(params: {
|
||||
return {
|
||||
posts: docs.map((d: any) => ({
|
||||
...d,
|
||||
author: d.author ?? "佚名",
|
||||
_id: d._id?.toString()
|
||||
})),
|
||||
total,
|
||||
@@ -62,32 +63,50 @@ export default async function HomePage({
|
||||
return qs ? `/?${qs}` : "/";
|
||||
};
|
||||
|
||||
const clearSearchHref = (() => {
|
||||
const params = new URLSearchParams();
|
||||
if (tag) params.set("tag", tag);
|
||||
const qs = params.toString();
|
||||
return qs ? `/?${qs}` : "/";
|
||||
})();
|
||||
|
||||
const clearTagHref = (() => {
|
||||
const params = new URLSearchParams();
|
||||
if (q) params.set("q", q);
|
||||
const qs = params.toString();
|
||||
return qs ? `/?${qs}` : "/";
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl bg-gradient-to-r from-brand-500 to-brand-700 p-6 text-white shadow-lg">
|
||||
<h1 className="text-2xl font-semibold">朋友圈 · 信息流</h1>
|
||||
<h1 className="text-2xl font-semibold">OPC 信息流</h1>
|
||||
<p className="mt-2 text-sm text-white/80">
|
||||
Markdown 排版 · 云图床 · 响应式 · 免费 MongoDB Atlas 存储。
|
||||
用 Markdown 记录进展、发布动态,让一人公司也有自己的信息流平台。
|
||||
</p>
|
||||
{tag ? (
|
||||
<div className="mt-3 inline-flex items-center gap-2 rounded-full bg-white/15 px-3 py-1 text-xs font-medium">
|
||||
<span>筛选标签:#{tag}</span>
|
||||
<span>当前标签 #{tag}</span>
|
||||
<a
|
||||
href="/"
|
||||
href={clearTagHref}
|
||||
className="rounded-full bg-white/20 px-2 py-1 text-white/90 hover:bg-white/30"
|
||||
>
|
||||
清除
|
||||
清除标签
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<form action="/" method="get" className="flex flex-wrap items-center gap-3 rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100">
|
||||
<form
|
||||
action="/"
|
||||
method="get"
|
||||
className="flex flex-wrap items-center gap-3 rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100"
|
||||
>
|
||||
{tag ? <input type="hidden" name="tag" value={tag} /> : null}
|
||||
<input
|
||||
name="q"
|
||||
defaultValue={q || ""}
|
||||
placeholder="搜索标题或正文"
|
||||
placeholder="搜索标题或内容"
|
||||
className="min-w-[200px] flex-1 rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner focus:border-brand-500 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
@@ -97,10 +116,7 @@ export default async function HomePage({
|
||||
搜索
|
||||
</button>
|
||||
{q ? (
|
||||
<a
|
||||
href={tag ? `/?tag=${encodeURIComponent(tag)}` : "/"}
|
||||
className="text-sm text-slate-500 hover:text-brand-600"
|
||||
>
|
||||
<a href={clearSearchHref} className="text-sm text-slate-500 hover:text-brand-600">
|
||||
清除搜索
|
||||
</a>
|
||||
) : null}
|
||||
@@ -109,7 +125,7 @@ export default async function HomePage({
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<p className="rounded-xl bg-white/70 p-4 text-sm text-slate-500 ring-1 ring-slate-100">
|
||||
暂无匹配内容。
|
||||
暂无内容,去后台发布吧。
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
|
||||
23
app/register/page.tsx
Normal file
23
app/register/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import RegisterForm from "./register-form";
|
||||
|
||||
export const metadata = {
|
||||
title: "注册 - OPC Solo Feed"
|
||||
};
|
||||
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-md rounded-2xl bg-white/90 p-8 shadow-sm ring-1 ring-slate-100">
|
||||
<h1 className="text-2xl font-semibold text-slate-900">注册账号</h1>
|
||||
<p className="mt-2 text-sm text-slate-600">注册后 24 小时内有效,过期需重新登录。</p>
|
||||
<div className="mt-6">
|
||||
<RegisterForm />
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-slate-500">
|
||||
已有账号?{" "}
|
||||
<a href="/login" className="text-brand-600 hover:text-brand-700">
|
||||
去登录
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
app/register/register-form.tsx
Normal file
88
app/register/register-form.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function RegisterForm() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch("/api/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
displayName: displayName.trim() || undefined
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data.error || "注册失败");
|
||||
} else {
|
||||
router.replace("/");
|
||||
router.refresh();
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<label className="block space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">用户名</span>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner focus:border-brand-500 focus:outline-none"
|
||||
placeholder="2-32 个字符"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">显示名(可选)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner focus:border-brand-500 focus:outline-none"
|
||||
placeholder="不填则使用用户名"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">密码</span>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner focus:border-brand-500 focus:outline-none"
|
||||
placeholder="至少 6 位"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? <p className="text-sm text-red-500">{error}</p> : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-full bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? "注册中..." : "注册"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ async function fetchTagPosts(params: {
|
||||
return {
|
||||
posts: docs.map((d: any) => ({
|
||||
...d,
|
||||
author: d.author ?? "佚名",
|
||||
_id: d._id?.toString()
|
||||
})),
|
||||
total,
|
||||
@@ -63,8 +64,8 @@ export default async function TagDetailPage({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl bg-white/80 p-6 shadow-sm ring-1 ring-slate-100">
|
||||
<h1 className="text-2xl font-semibold">标签:#{tag}</h1>
|
||||
<p className="mt-2 text-sm text-slate-500">共 {total} 条内容。</p>
|
||||
<h1 className="text-2xl font-semibold">标签 · {tag}</h1>
|
||||
<p className="mt-2 text-sm text-slate-500">共 {total} 条内容</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
@@ -75,7 +76,7 @@ export default async function TagDetailPage({
|
||||
<input
|
||||
name="q"
|
||||
defaultValue={q || ""}
|
||||
placeholder="在该标签下搜索"
|
||||
placeholder="在当前标签内搜索"
|
||||
className="min-w-[200px] flex-1 rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner focus:border-brand-500 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
@@ -96,7 +97,7 @@ export default async function TagDetailPage({
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<p className="rounded-xl bg-white/70 p-4 text-sm text-slate-500 ring-1 ring-slate-100">
|
||||
暂无匹配内容。
|
||||
暂无内容。
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
|
||||
@@ -18,13 +18,13 @@ export default async function TagsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl bg-white/80 p-6 shadow-sm ring-1 ring-slate-100">
|
||||
<h1 className="text-2xl font-semibold">标签聚合</h1>
|
||||
<p className="mt-2 text-sm text-slate-500">按标签浏览所有文章。</p>
|
||||
<h1 className="text-2xl font-semibold">标签</h1>
|
||||
<p className="mt-2 text-sm text-slate-500">按标签浏览信息流内容。</p>
|
||||
</div>
|
||||
|
||||
{tags.length === 0 ? (
|
||||
<p className="rounded-xl bg-white/70 p-4 text-sm text-slate-500 ring-1 ring-slate-100">
|
||||
暂无标签内容。
|
||||
暂无标签。
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Post } from "@/types/post";
|
||||
|
||||
type AdminPost = Post & { createdAtText?: string };
|
||||
|
||||
export function AdminPostList({ initialPosts }: { initialPosts: AdminPost[] }) {
|
||||
const [posts, setPosts] = useState<AdminPost[]>(initialPosts);
|
||||
const [tagQuery, setTagQuery] = useState("");
|
||||
|
||||
const visiblePosts = useMemo(() => {
|
||||
const q = tagQuery.trim().toLowerCase();
|
||||
if (!q) return posts;
|
||||
return posts.filter((post) => post.tags?.some((tag) => tag.toLowerCase().includes(q)));
|
||||
}, [posts, tagQuery]);
|
||||
|
||||
async function handleDelete(slug: string) {
|
||||
if (!window.confirm("确定要删除这篇文章吗?此操作不可恢复。")) return;
|
||||
if (!window.confirm("确定要删除这条内容吗?此操作不可恢复。")) return;
|
||||
const res = await fetch(`/api/posts/${slug}`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
@@ -23,19 +30,41 @@ export function AdminPostList({ initialPosts }: { initialPosts: AdminPost[] }) {
|
||||
if (posts.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-white/80 p-4 text-sm text-slate-500 shadow-sm ring-1 ring-slate-100">
|
||||
暂无文章。
|
||||
暂无内容。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const summary = tagQuery ? `匹配 ${visiblePosts.length} / 总 ${posts.length}` : `共 ${posts.length} 条`;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">最近发布</h3>
|
||||
<span className="text-xs text-slate-400">共 {posts.length} 条</span>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">最近内容</h3>
|
||||
<p className="text-xs text-slate-400">{summary}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={tagQuery}
|
||||
onChange={(e) => setTagQuery(e.target.value)}
|
||||
placeholder="按标签搜索"
|
||||
className="w-40 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs shadow-inner focus:border-brand-500 focus:outline-none"
|
||||
/>
|
||||
{tagQuery ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTagQuery("")}
|
||||
className="rounded-full bg-slate-100 px-3 py-1 text-xs text-slate-600 ring-1 ring-slate-200 hover:text-brand-600"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{posts.map((post) => (
|
||||
{visiblePosts.map((post) => (
|
||||
<div
|
||||
key={post.slug}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-100 bg-white/70 p-3"
|
||||
@@ -45,19 +74,18 @@ export function AdminPostList({ initialPosts }: { initialPosts: AdminPost[] }) {
|
||||
{post.title}
|
||||
</Link>
|
||||
<p className="text-xs text-slate-500">
|
||||
{post.createdAtText ||
|
||||
new Date(post.createdAt).toLocaleString("zh-CN", {
|
||||
hour12: false,
|
||||
timeZone: "Asia/Shanghai"
|
||||
})}
|
||||
{(post.author || "佚名") +
|
||||
" · " +
|
||||
(post.createdAtText ||
|
||||
new Date(post.createdAt).toLocaleString("zh-CN", {
|
||||
hour12: false,
|
||||
timeZone: "Asia/Shanghai"
|
||||
}))}
|
||||
</p>
|
||||
{post.tags && post.tags.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-slate-100 px-2 py-1 text-xs text-slate-600"
|
||||
>
|
||||
<span key={tag} className="rounded-full bg-slate-100 px-2 py-1 text-xs text-slate-600">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -3,21 +3,37 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { normalizeImageUrl } from "@/lib/normalize";
|
||||
import { MarkdownPreview } from "@/components/MarkdownPreview";
|
||||
|
||||
const defaultIntro = `## 新帖内容
|
||||
const defaultIntro = `## solo-feed 记录模板
|
||||
|
||||
- 这里支持 **Markdown**
|
||||
- 图片请上传到 img.020417.xyz 后填入链接
|
||||
- 今日目标:
|
||||
- 产品/交付:
|
||||
- 客户/收入:
|
||||
- 增长/运营:
|
||||
- 学习/复盘:
|
||||
`;
|
||||
|
||||
export function CreatePostForm() {
|
||||
export function CreatePostForm({ availableTags = [] }: { availableTags?: string[] }) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [cover, setCover] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [markdown, setMarkdown] = useState(defaultIntro);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [preview, setPreview] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const addTag = (tag: string) => {
|
||||
const current = new Set(
|
||||
tags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
current.add(tag);
|
||||
setTags(Array.from(current).join(", "));
|
||||
};
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
@@ -56,18 +72,27 @@ export function CreatePostForm() {
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 rounded-2xl bg-white/80 p-5 shadow-sm ring-1 ring-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">新建一条信息</h3>
|
||||
<p className="text-sm text-slate-500">填写后提交,立刻出现在首页最上方。</p>
|
||||
<h3 className="text-lg font-semibold">发布新内容</h3>
|
||||
<p className="text-sm text-slate-500">以信息流的方式记录进展、沉淀经验。</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreview((prev) => !prev)}
|
||||
className="rounded-full bg-slate-100 px-4 py-2 text-sm font-medium text-slate-700 ring-1 ring-slate-200 hover:text-brand-600"
|
||||
>
|
||||
{preview ? "编辑" : "预览"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded-full bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? "发布中..." : "发布"}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded-full bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? "发布中…" : "发布"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-1">
|
||||
@@ -77,12 +102,12 @@ export function CreatePostForm() {
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner focus:border-brand-500 focus:outline-none"
|
||||
placeholder="比如:周末聚会安排"
|
||||
placeholder="例如:本周交付进展"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">封面图(可选)</span>
|
||||
<span className="text-sm font-medium text-slate-700">封面图片 URL(可选)</span>
|
||||
<input
|
||||
value={cover}
|
||||
onChange={(e) => setCover(e.target.value)}
|
||||
@@ -92,25 +117,45 @@ export function CreatePostForm() {
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">标签(用逗号分隔,可选)</span>
|
||||
<span className="text-sm font-medium text-slate-700">标签(逗号分隔)</span>
|
||||
<input
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner focus:border-brand-500 focus:outline-none"
|
||||
placeholder="朋友, 聚会, 通知"
|
||||
placeholder="产品, 交付, 复盘"
|
||||
/>
|
||||
{availableTags.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{availableTags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onClick={() => addTag(tag)}
|
||||
className="rounded-full bg-slate-100 px-2 py-1 text-xs text-slate-600 ring-1 ring-slate-200 hover:text-brand-600"
|
||||
>
|
||||
#{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">正文(Markdown)</span>
|
||||
<textarea
|
||||
required
|
||||
value={markdown}
|
||||
onChange={(e) => setMarkdown(e.target.value)}
|
||||
rows={12}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner focus:border-brand-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">Markdown 内容</span>
|
||||
{!preview ? (
|
||||
<textarea
|
||||
required
|
||||
value={markdown}
|
||||
onChange={(e) => setMarkdown(e.target.value)}
|
||||
rows={12}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner focus:border-brand-500 focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<MarkdownPreview markdown={markdown || "(暂无内容)"} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { normalizeImageUrl } from "@/lib/normalize";
|
||||
import { Post } from "@/types/post";
|
||||
import { MarkdownPreview } from "@/components/MarkdownPreview";
|
||||
|
||||
export function EditPostForm({ post }: { post: Post }) {
|
||||
const router = useRouter();
|
||||
@@ -12,6 +13,7 @@ export function EditPostForm({ post }: { post: Post }) {
|
||||
const [tags, setTags] = useState(post.tags ? post.tags.join(", ") : "");
|
||||
const [markdown, setMarkdown] = useState(post.markdown);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [preview, setPreview] = useState(false);
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -48,7 +50,7 @@ export function EditPostForm({ post }: { post: Post }) {
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!window.confirm("确定要删除这篇文章吗?此操作不可恢复。")) return;
|
||||
if (!window.confirm("确定要删除这条内容吗?此操作不可恢复。")) return;
|
||||
const res = await fetch(`/api/posts/${post.slug}`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
@@ -63,8 +65,8 @@ export function EditPostForm({ post }: { post: Post }) {
|
||||
<form onSubmit={handleSave} className="space-y-4 rounded-2xl bg-white/80 p-5 shadow-sm ring-1 ring-slate-100">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">编辑文章</h3>
|
||||
<p className="text-sm text-slate-500">保存后会立即更新到前台。</p>
|
||||
<h3 className="text-lg font-semibold">编辑内容</h3>
|
||||
<p className="text-sm text-slate-500">更新信息流内容与标签。</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -79,7 +81,7 @@ export function EditPostForm({ post }: { post: Post }) {
|
||||
disabled={loading}
|
||||
className="rounded-full bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? "保存中…" : "保存"}
|
||||
{loading ? "保存中..." : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,7 +97,7 @@ export function EditPostForm({ post }: { post: Post }) {
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">封面图(可选)</span>
|
||||
<span className="text-sm font-medium text-slate-700">封面图片 URL(可选)</span>
|
||||
<input
|
||||
value={cover}
|
||||
onChange={(e) => setCover(e.target.value)}
|
||||
@@ -105,25 +107,40 @@ export function EditPostForm({ post }: { post: Post }) {
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">标签(用逗号分隔,可选)</span>
|
||||
<span className="text-sm font-medium text-slate-700">标签(逗号分隔)</span>
|
||||
<input
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner focus:border-brand-500 focus:outline-none"
|
||||
placeholder="朋友, 聚会, 通知"
|
||||
placeholder="产品, 交付, 复盘"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">正文(Markdown)</span>
|
||||
<textarea
|
||||
required
|
||||
value={markdown}
|
||||
onChange={(e) => setMarkdown(e.target.value)}
|
||||
rows={12}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner focus:border-brand-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-700">Markdown 内容</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreview((prev) => !prev)}
|
||||
className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700 ring-1 ring-slate-200 hover:text-brand-600"
|
||||
>
|
||||
{preview ? "编辑" : "预览"}
|
||||
</button>
|
||||
</div>
|
||||
{!preview ? (
|
||||
<textarea
|
||||
required
|
||||
value={markdown}
|
||||
onChange={(e) => setMarkdown(e.target.value)}
|
||||
rows={12}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner focus:border-brand-500 focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<MarkdownPreview markdown={markdown || "(暂无内容)"} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
12
components/MarkdownPreview.tsx
Normal file
12
components/MarkdownPreview.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
export function MarkdownPreview({ markdown }: { markdown: string }) {
|
||||
return (
|
||||
<article className="prose prose-slate max-w-none prose-a:text-brand-600 prose-img:rounded-xl">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{markdown}</ReactMarkdown>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,9 @@ type Props = {
|
||||
|
||||
export function PostCard({ post }: Props) {
|
||||
const coverUrl = normalizeImageUrl(post.cover);
|
||||
const author = post.author || "佚名";
|
||||
return (
|
||||
<article className="group rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100 transition hover:-translate-y-1 hover:shadow-md">
|
||||
<article className="group rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100 transition-[transform,box-shadow] duration-300 will-change-transform transform-gpu hover:shadow-lg hover:[transform:perspective(900px)_translateY(-4px)_rotateX(2deg)_rotateY(-2deg)]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<Link
|
||||
@@ -19,7 +20,7 @@ export function PostCard({ post }: Props) {
|
||||
{post.title}
|
||||
</Link>
|
||||
<p className="text-sm text-slate-500">
|
||||
{new Date(post.createdAt).toLocaleString("zh-CN", { hour12: false })}
|
||||
{author} · {new Date(post.createdAt).toLocaleString("zh-CN", { hour12: false })}
|
||||
</p>
|
||||
</div>
|
||||
{coverUrl ? (
|
||||
|
||||
@@ -93,7 +93,7 @@ export function SharePanel({ url }: { url: string }) {
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">扫码查看本篇内容</p>
|
||||
<p className="text-xs text-slate-500">使用手机扫码打开链接</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
33
lib/auth.ts
33
lib/auth.ts
@@ -3,6 +3,8 @@ import { NextRequest } from "next/server";
|
||||
|
||||
const COOKIE_NAME = "admin_session";
|
||||
const encoder = new TextEncoder();
|
||||
let cachedKey: CryptoKey | null = null;
|
||||
let cachedSecret: string | null = null;
|
||||
|
||||
function getSecret() {
|
||||
const secret = process.env.SESSION_SECRET;
|
||||
@@ -15,16 +17,32 @@ function getSecret() {
|
||||
export type SessionPayload = {
|
||||
role: "admin";
|
||||
iat: number;
|
||||
exp?: number;
|
||||
uid?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
async function hmacSha256(data: string, secret: string): Promise<string> {
|
||||
const key = await crypto.subtle.importKey(
|
||||
export function getAdminName() {
|
||||
return process.env.ADMIN_NAME?.trim() || "Admin";
|
||||
}
|
||||
|
||||
async function getHmacKey(secret: string) {
|
||||
if (cachedKey && cachedSecret === secret) {
|
||||
return cachedKey;
|
||||
}
|
||||
cachedSecret = secret;
|
||||
cachedKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"]
|
||||
);
|
||||
return cachedKey;
|
||||
}
|
||||
|
||||
async function hmacSha256(data: string, secret: string): Promise<string> {
|
||||
const key = await getHmacKey(secret);
|
||||
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
|
||||
return Buffer.from(sig).toString("base64url");
|
||||
}
|
||||
@@ -45,6 +63,12 @@ export async function verifySession(token?: string): Promise<SessionPayload | nu
|
||||
if (check !== sig) return null;
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(base, "base64url").toString());
|
||||
if (typeof payload?.exp !== "number") {
|
||||
return null;
|
||||
}
|
||||
if (Date.now() > payload.exp) {
|
||||
return null;
|
||||
}
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -57,11 +81,12 @@ export async function requireAdminFromRequest(req: NextRequest): Promise<boolean
|
||||
}
|
||||
|
||||
export function setAdminCookie(token: string) {
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
cookies().set(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: true,
|
||||
maxAge: 60 * 60 * 24 * 30
|
||||
secure: isProd,
|
||||
maxAge: 60 * 60 * 24
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
23
lib/opc.ts
Normal file
23
lib/opc.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export const OPC_SIGNAL_VALUES = ["solo-feed"] as const;
|
||||
|
||||
export type OpcSignalValue = (typeof OPC_SIGNAL_VALUES)[number];
|
||||
|
||||
export const OPC_SIGNAL_LABELS: Record<OpcSignalValue, string> = {
|
||||
"solo-feed": "solo-feed"
|
||||
};
|
||||
|
||||
export const DEFAULT_OPC_SIGNAL: OpcSignalValue = "solo-feed";
|
||||
|
||||
const OPC_SIGNAL_SET = new Set<string>(OPC_SIGNAL_VALUES);
|
||||
|
||||
export function isOpcSignal(value?: string | null): value is OpcSignalValue {
|
||||
if (!value) return false;
|
||||
return OPC_SIGNAL_SET.has(value);
|
||||
}
|
||||
|
||||
export function getOpcSignalLabel(value?: string | null): string {
|
||||
if (value && OPC_SIGNAL_SET.has(value)) {
|
||||
return OPC_SIGNAL_LABELS[value as OpcSignalValue];
|
||||
}
|
||||
return OPC_SIGNAL_LABELS[DEFAULT_OPC_SIGNAL];
|
||||
}
|
||||
20
lib/password.ts
Normal file
20
lib/password.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
const ITERATIONS = 100_000;
|
||||
const KEY_LENGTH = 32;
|
||||
const DIGEST = "sha256";
|
||||
const SALT_BYTES = 16;
|
||||
|
||||
export function hashPassword(password: string, salt?: string) {
|
||||
const realSalt = salt ?? crypto.randomBytes(SALT_BYTES).toString("hex");
|
||||
const hash = crypto.pbkdf2Sync(password, realSalt, ITERATIONS, KEY_LENGTH, DIGEST).toString("hex");
|
||||
return { salt: realSalt, hash };
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, salt: string, hash: string) {
|
||||
const next = hashPassword(password, salt).hash;
|
||||
const a = Buffer.from(next, "hex");
|
||||
const b = Buffer.from(hash, "hex");
|
||||
if (a.length !== b.length) return false;
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -23,6 +23,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.28",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.2.63",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"eslint": "^8.57.0",
|
||||
@@ -634,6 +635,16 @@
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmmirror.com/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.28",
|
||||
"resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.28.tgz",
|
||||
|
||||
1
test_cn.txt
Normal file
1
test_cn.txt
Normal file
@@ -0,0 +1 @@
|
||||
中文
|
||||
@@ -5,6 +5,7 @@ export type Post = {
|
||||
markdown: string;
|
||||
cover?: string;
|
||||
tags?: string[];
|
||||
signal?: string;
|
||||
author: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
Reference in New Issue
Block a user