feat: 实现文章点赞功能及相关API和UI组件
- 新增点赞功能API接口及数据库表结构 - 添加点赞状态管理及UI交互组件 - 实现点赞统计和列表查询功能 - 优化评论组件布局,将点赞按钮加入头部区域
This commit is contained in:
37
cwd-api/src/api/admin/likeStats.ts
Normal file
37
cwd-api/src/api/admin/likeStats.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Context } from 'hono';
|
||||
import type { Bindings } from '../../bindings';
|
||||
|
||||
type LikeStatsItem = {
|
||||
page_slug: string;
|
||||
page_title: string | null;
|
||||
page_url: string | null;
|
||||
likes: number;
|
||||
};
|
||||
|
||||
export const getLikeStats = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
try {
|
||||
await c.env.CWD_DB.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS Likes (id INTEGER PRIMARY KEY AUTOINCREMENT, page_slug TEXT NOT NULL, user_id TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(page_slug, user_id))'
|
||||
).run();
|
||||
|
||||
await c.env.CWD_DB.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS page_stats (id INTEGER PRIMARY KEY AUTOINCREMENT, post_slug TEXT UNIQUE NOT NULL, post_title TEXT, post_url TEXT, pv INTEGER NOT NULL DEFAULT 0, last_visit_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)'
|
||||
).run();
|
||||
|
||||
const { results } = await c.env.CWD_DB.prepare(
|
||||
'SELECT l.page_slug, COALESCE(p.post_title, NULL) AS page_title, COALESCE(p.post_url, NULL) AS page_url, COUNT(*) AS likes FROM Likes l LEFT JOIN page_stats p ON p.post_slug = l.page_slug GROUP BY l.page_slug, p.post_title, p.post_url ORDER BY likes DESC LIMIT 50'
|
||||
).all<LikeStatsItem>();
|
||||
|
||||
const items = results.map((row) => ({
|
||||
pageSlug: row.page_slug,
|
||||
pageTitle: row.page_title,
|
||||
pageUrl: row.page_url,
|
||||
likes: row.likes
|
||||
}));
|
||||
|
||||
return c.json({ items });
|
||||
} catch (e: any) {
|
||||
return c.json({ message: e.message || '获取点赞统计失败' }, 500);
|
||||
}
|
||||
};
|
||||
|
||||
95
cwd-api/src/api/admin/listLikes.ts
Normal file
95
cwd-api/src/api/admin/listLikes.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { Context } from 'hono';
|
||||
import type { Bindings } from '../../bindings';
|
||||
|
||||
type LikeItem = {
|
||||
id: number;
|
||||
page_slug: string;
|
||||
user_id: string;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
export const listLikes = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
try {
|
||||
const page = parseInt(c.req.query('page') || '1', 10) || 1;
|
||||
const limit = 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const rawPageSlug = c.req.query('page_slug') || c.req.query('pageSlug') || '';
|
||||
const pageSlug = rawPageSlug.trim();
|
||||
|
||||
const rawUserId = c.req.query('user_id') || c.req.query('userId') || '';
|
||||
const userId = rawUserId.trim();
|
||||
|
||||
const rawStart = c.req.query('start') || '';
|
||||
const rawEnd = c.req.query('end') || '';
|
||||
|
||||
const whereSql: string[] = [];
|
||||
const params: (string | number)[] = [];
|
||||
|
||||
if (pageSlug) {
|
||||
whereSql.push('page_slug = ?');
|
||||
params.push(pageSlug);
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
whereSql.push('user_id = ?');
|
||||
params.push(userId);
|
||||
}
|
||||
|
||||
if (rawStart) {
|
||||
const startTs = Number(rawStart);
|
||||
if (Number.isFinite(startTs)) {
|
||||
whereSql.push('created_at >= ?');
|
||||
params.push(startTs);
|
||||
}
|
||||
}
|
||||
|
||||
if (rawEnd) {
|
||||
const endTs = Number(rawEnd);
|
||||
if (Number.isFinite(endTs)) {
|
||||
whereSql.push('created_at <= ?');
|
||||
params.push(endTs);
|
||||
}
|
||||
}
|
||||
|
||||
const whereClause = whereSql.length ? `WHERE ${whereSql.join(' AND ')}` : '';
|
||||
|
||||
await c.env.CWD_DB.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS Likes (id INTEGER PRIMARY KEY AUTOINCREMENT, page_slug TEXT NOT NULL, user_id TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(page_slug, user_id))'
|
||||
).run();
|
||||
|
||||
const totalRow = await c.env.CWD_DB.prepare(
|
||||
`SELECT COUNT(*) AS count FROM Likes ${whereClause}`
|
||||
)
|
||||
.bind(...params)
|
||||
.first<{ count: number }>();
|
||||
|
||||
const { results } = await c.env.CWD_DB.prepare(
|
||||
`SELECT id, page_slug, user_id, created_at FROM Likes ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
||||
)
|
||||
.bind(...params, limit, offset)
|
||||
.all<LikeItem>();
|
||||
|
||||
const data = results.map((row) => ({
|
||||
id: row.id,
|
||||
pageSlug: row.page_slug,
|
||||
userId: row.user_id,
|
||||
createdAt: row.created_at
|
||||
}));
|
||||
|
||||
const totalCount = totalRow?.count || 0;
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / limit));
|
||||
|
||||
return c.json({
|
||||
data,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: totalPages
|
||||
}
|
||||
});
|
||||
} catch (e: any) {
|
||||
return c.json({ message: e.message || '获取点赞记录失败' }, 500);
|
||||
}
|
||||
};
|
||||
|
||||
196
cwd-api/src/api/public/like.ts
Normal file
196
cwd-api/src/api/public/like.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { Context } from 'hono';
|
||||
import type { Bindings } from '../../bindings';
|
||||
|
||||
type LikeStatusResponse = {
|
||||
liked: boolean;
|
||||
alreadyLiked: boolean;
|
||||
totalLikes: number;
|
||||
};
|
||||
|
||||
type LikeRequestBody = {
|
||||
postSlug?: string;
|
||||
postTitle?: string;
|
||||
postUrl?: string;
|
||||
};
|
||||
|
||||
function getUserIdFromRequest(c: Context<{ Bindings: Bindings }>): string {
|
||||
const header =
|
||||
c.req.header('X-CWD-Like-User') ||
|
||||
c.req.header('x-cwd-like-user') ||
|
||||
'';
|
||||
const fromHeader = header.trim();
|
||||
if (fromHeader) {
|
||||
return fromHeader;
|
||||
}
|
||||
const ip = c.req.header('cf-connecting-ip') || '';
|
||||
const trimmedIp = ip.trim();
|
||||
if (trimmedIp) {
|
||||
return `ip:${trimmedIp}`;
|
||||
}
|
||||
return 'anonymous';
|
||||
}
|
||||
|
||||
async function ensureLikesTable(env: Bindings) {
|
||||
await env.CWD_DB.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS Likes (id INTEGER PRIMARY KEY AUTOINCREMENT, page_slug TEXT NOT NULL, user_id TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(page_slug, user_id))'
|
||||
).run();
|
||||
}
|
||||
|
||||
async function ensurePageStatsTable(env: Bindings) {
|
||||
await env.CWD_DB.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS page_stats (id INTEGER PRIMARY KEY AUTOINCREMENT, post_slug TEXT UNIQUE NOT NULL, post_title TEXT, post_url TEXT, pv INTEGER NOT NULL DEFAULT 0, last_visit_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)'
|
||||
).run();
|
||||
}
|
||||
|
||||
export const getLikeStatus = async (
|
||||
c: Context<{ Bindings: Bindings }>
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const rawPostSlug = c.req.query('post_slug') || '';
|
||||
const postSlug = rawPostSlug.trim();
|
||||
|
||||
if (!postSlug) {
|
||||
return c.json({ message: 'post_slug is required' }, 400);
|
||||
}
|
||||
|
||||
const userIdHeader =
|
||||
c.req.header('X-CWD-Like-User') ||
|
||||
c.req.header('x-cwd-like-user') ||
|
||||
'';
|
||||
const userId = userIdHeader.trim();
|
||||
|
||||
await ensureLikesTable(c.env);
|
||||
|
||||
const totalRow = await c.env.CWD_DB.prepare(
|
||||
'SELECT COUNT(*) AS count FROM Likes WHERE page_slug = ?'
|
||||
)
|
||||
.bind(postSlug)
|
||||
.first<{ count: number }>();
|
||||
|
||||
let liked = false;
|
||||
if (userId) {
|
||||
const row = await c.env.CWD_DB.prepare(
|
||||
'SELECT id FROM Likes WHERE page_slug = ? AND user_id = ?'
|
||||
)
|
||||
.bind(postSlug, userId)
|
||||
.first<{ id: number }>();
|
||||
liked = !!row;
|
||||
}
|
||||
|
||||
const totalLikes = totalRow?.count || 0;
|
||||
|
||||
const payload: LikeStatusResponse = {
|
||||
liked,
|
||||
alreadyLiked: false,
|
||||
totalLikes
|
||||
};
|
||||
|
||||
return c.json(payload);
|
||||
} catch (e: any) {
|
||||
return c.json(
|
||||
{ message: e?.message || '获取点赞状态失败' },
|
||||
500
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const likePage = async (
|
||||
c: Context<{ Bindings: Bindings }>
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const body = ((await c.req
|
||||
.json()
|
||||
.catch(() => ({}))) || {}) as LikeRequestBody;
|
||||
|
||||
const rawPostSlug =
|
||||
typeof body.postSlug === 'string' ? body.postSlug.trim() : '';
|
||||
const rawPostTitle =
|
||||
typeof body.postTitle === 'string' ? body.postTitle.trim() : '';
|
||||
const rawPostUrl =
|
||||
typeof body.postUrl === 'string' ? body.postUrl.trim() : '';
|
||||
|
||||
if (!rawPostSlug) {
|
||||
return c.json({ message: 'postSlug is required' }, 400);
|
||||
}
|
||||
|
||||
const userId = getUserIdFromRequest(c);
|
||||
|
||||
await ensureLikesTable(c.env);
|
||||
await ensurePageStatsTable(c.env);
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const existingLike = await c.env.CWD_DB.prepare(
|
||||
'SELECT id FROM Likes WHERE page_slug = ? AND user_id = ?'
|
||||
)
|
||||
.bind(rawPostSlug, userId)
|
||||
.first<{ id: number }>();
|
||||
|
||||
let alreadyLiked = false;
|
||||
|
||||
if (!existingLike) {
|
||||
await c.env.CWD_DB.prepare(
|
||||
'INSERT INTO Likes (page_slug, user_id, created_at) VALUES (?, ?, ?)'
|
||||
)
|
||||
.bind(rawPostSlug, userId, now)
|
||||
.run();
|
||||
} else {
|
||||
alreadyLiked = true;
|
||||
}
|
||||
|
||||
const pageStatsRow = await c.env.CWD_DB.prepare(
|
||||
'SELECT id FROM page_stats WHERE post_slug = ?'
|
||||
)
|
||||
.bind(rawPostSlug)
|
||||
.first<{ id: number }>();
|
||||
|
||||
if (!pageStatsRow) {
|
||||
await c.env.CWD_DB.prepare(
|
||||
'INSERT INTO page_stats (post_slug, post_title, post_url, pv, last_visit_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.bind(
|
||||
rawPostSlug,
|
||||
rawPostTitle || null,
|
||||
rawPostUrl || null,
|
||||
0,
|
||||
now,
|
||||
now,
|
||||
now
|
||||
)
|
||||
.run();
|
||||
} else if (rawPostTitle || rawPostUrl) {
|
||||
await c.env.CWD_DB.prepare(
|
||||
'UPDATE page_stats SET post_title = COALESCE(?, post_title), post_url = COALESCE(?, post_url), updated_at = ? WHERE id = ?'
|
||||
)
|
||||
.bind(
|
||||
rawPostTitle || null,
|
||||
rawPostUrl || null,
|
||||
now,
|
||||
pageStatsRow.id
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
const totalRow = await c.env.CWD_DB.prepare(
|
||||
'SELECT COUNT(*) AS count FROM Likes WHERE page_slug = ?'
|
||||
)
|
||||
.bind(rawPostSlug)
|
||||
.first<{ count: number }>();
|
||||
|
||||
const totalLikes = totalRow?.count || 0;
|
||||
|
||||
const payload: LikeStatusResponse = {
|
||||
liked: true,
|
||||
alreadyLiked,
|
||||
totalLikes
|
||||
};
|
||||
|
||||
return c.json(payload);
|
||||
} catch (e: any) {
|
||||
return c.json(
|
||||
{ message: e?.message || '点赞失败' },
|
||||
500
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user