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
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -26,6 +26,9 @@ import { getStats } from './api/admin/getStats';
|
||||
import { getDomains } from './api/admin/getDomains';
|
||||
import { trackVisit } from './api/public/trackVisit';
|
||||
import { getVisitOverview, getVisitPages } from './api/admin/visitAnalytics';
|
||||
import { getLikeStatus, likePage } from './api/public/like';
|
||||
import { listLikes } from './api/admin/listLikes';
|
||||
import { getLikeStats } from './api/admin/likeStats';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
const VERSION = `v${packageJson.version}`;
|
||||
@@ -218,6 +221,8 @@ app.get('/api/comments', getComments);
|
||||
app.post('/api/comments', postComment);
|
||||
app.post('/api/verify-admin', verifyAdminKey);
|
||||
app.post('/api/analytics/visit', trackVisit);
|
||||
app.get('/api/like', getLikeStatus);
|
||||
app.post('/api/like', likePage);
|
||||
app.get('/api/config/comments', async (c) => {
|
||||
try {
|
||||
const settings = await loadCommentSettings(c.env);
|
||||
@@ -247,6 +252,8 @@ app.get('/admin/stats/comments', getStats);
|
||||
app.get('/admin/stats/domains', getDomains);
|
||||
app.get('/admin/analytics/overview', getVisitOverview);
|
||||
app.get('/admin/analytics/pages', getVisitPages);
|
||||
app.get('/admin/likes/list', listLikes);
|
||||
app.get('/admin/likes/stats', getLikeStats);
|
||||
app.get('/admin/settings/email', getAdminEmail);
|
||||
app.put('/admin/settings/email', setAdminEmail);
|
||||
app.get('/admin/settings/email-notify', async (c) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ export const customCors = () => {
|
||||
return cors({
|
||||
origin: '*',
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
allowHeaders: ['Content-Type', 'Authorization', 'X-CWD-Like-User'],
|
||||
exposeHeaders: ['Content-Length'],
|
||||
maxAge: 600,
|
||||
credentials: false,
|
||||
|
||||
@@ -42,10 +42,17 @@ export class CWDComments {
|
||||
this.mountPoint = null;
|
||||
this.commentForm = null;
|
||||
this.commentList = null;
|
||||
this.formContainer = null;
|
||||
this.store = null;
|
||||
this.unsubscribe = null;
|
||||
this.likeState = {
|
||||
count: 0,
|
||||
liked: false,
|
||||
loading: false
|
||||
};
|
||||
this._likeButtonEl = null;
|
||||
this._likeCountEl = null;
|
||||
|
||||
// 初始加载标志
|
||||
this._mounted = false;
|
||||
}
|
||||
|
||||
@@ -181,6 +188,24 @@ export class CWDComments {
|
||||
if (this.api && typeof this.api.trackVisit === 'function') {
|
||||
this.api.trackVisit();
|
||||
}
|
||||
|
||||
if (this.api && typeof this.api.getLikeStatus === 'function') {
|
||||
try {
|
||||
const likeResult = await this.api.getLikeStatus();
|
||||
const count =
|
||||
likeResult && typeof likeResult.totalLikes === 'number'
|
||||
? likeResult.totalLikes
|
||||
: 0;
|
||||
const liked = !!(likeResult && likeResult.liked);
|
||||
this.likeState.count = count;
|
||||
this.likeState.liked = liked;
|
||||
if (this.store && typeof this.store.setLikeState === 'function') {
|
||||
this.store.setLikeState(count, liked);
|
||||
}
|
||||
this._updateLikeButton();
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
this._mounted = true;
|
||||
@@ -235,21 +260,7 @@ export class CWDComments {
|
||||
}
|
||||
|
||||
const state = this.store.store.getState();
|
||||
|
||||
// 创建评论表单
|
||||
if (!this.commentForm) {
|
||||
this.commentForm = new CommentForm(this.mountPoint, {
|
||||
form: state.form,
|
||||
formErrors: state.formErrors,
|
||||
submitting: state.submitting,
|
||||
onSubmit: () => this._handleSubmit(),
|
||||
onFieldChange: (field, value) => this.store.updateFormField(field, value),
|
||||
adminEmail: this.config.adminEmail,
|
||||
onVerifyAdmin: (key) => this.api.verifyAdminKey(key)
|
||||
});
|
||||
this.commentForm.render();
|
||||
}
|
||||
|
||||
|
||||
// 创建错误提示
|
||||
const existingError = this.mountPoint.querySelector('.cwd-error-inline');
|
||||
if (state.error) {
|
||||
@@ -301,6 +312,16 @@ export class CWDComments {
|
||||
<h3 class="cwd-comments-count">
|
||||
共 <span class="cwd-comments-count-number">0</span> 条评论
|
||||
</h3>
|
||||
<div class="cwd-like">
|
||||
<button type="button" class="cwd-like-button" data-liked="false">
|
||||
<span class="cwd-like-icon-wrapper">
|
||||
<svg class="cwd-like-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 21c-.4 0-.8-.1-1.1-.4L4.5 15C3 13.6 2 11.7 2 9.6 2 6.5 4.5 4 7.6 4c1.7 0 3.3.8 4.4 2.1C13.1 4.8 14.7 4 16.4 4 19.5 4 22 6.5 22 9.6c0 2.1-1 4-2.5 5.4l-6.4 5.6c-.3.3-.7.4-1.1.4z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="cwd-like-count">0</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
this.mountPoint.appendChild(header);
|
||||
}
|
||||
@@ -309,6 +330,27 @@ export class CWDComments {
|
||||
countEl.textContent = state.pagination.totalCount;
|
||||
}
|
||||
|
||||
this._initLikeButton(header);
|
||||
|
||||
if (!this.formContainer) {
|
||||
this.formContainer = document.createElement('div');
|
||||
this.mountPoint.appendChild(this.formContainer);
|
||||
}
|
||||
|
||||
// 创建评论表单(放在点赞区域下方,使用单独容器以保证顺序)
|
||||
if (!this.commentForm) {
|
||||
this.commentForm = new CommentForm(this.formContainer, {
|
||||
form: state.form,
|
||||
formErrors: state.formErrors,
|
||||
submitting: state.submitting,
|
||||
onSubmit: () => this._handleSubmit(),
|
||||
onFieldChange: (field, value) => this.store.updateFormField(field, value),
|
||||
adminEmail: this.config.adminEmail,
|
||||
onVerifyAdmin: (key) => this.api.verifyAdminKey(key)
|
||||
});
|
||||
this.commentForm.render();
|
||||
}
|
||||
|
||||
// 创建评论列表
|
||||
if (!this.commentList) {
|
||||
const listContainer = document.createElement('div');
|
||||
@@ -418,6 +460,21 @@ export class CWDComments {
|
||||
countEl.textContent = state.pagination.totalCount;
|
||||
}
|
||||
|
||||
if (typeof state.likeCount === 'number' || typeof state.liked === 'boolean') {
|
||||
if (typeof state.likeCount === 'number') {
|
||||
this.likeState.count = state.likeCount;
|
||||
}
|
||||
if (typeof state.liked === 'boolean') {
|
||||
this.likeState.liked = state.liked;
|
||||
}
|
||||
if (header) {
|
||||
if (!this._likeButtonEl || !this._likeCountEl) {
|
||||
this._initLikeButton(header);
|
||||
}
|
||||
this._updateLikeButton();
|
||||
}
|
||||
}
|
||||
|
||||
// 更新评论列表
|
||||
if (this.commentList) {
|
||||
this.commentList.setProps({
|
||||
@@ -494,6 +551,111 @@ export class CWDComments {
|
||||
}
|
||||
}
|
||||
|
||||
_initLikeButton(header) {
|
||||
if (!header) {
|
||||
return;
|
||||
}
|
||||
if (!this._likeButtonEl) {
|
||||
this._likeButtonEl = header.querySelector('.cwd-like-button');
|
||||
if (this._likeButtonEl) {
|
||||
this._likeButtonEl.addEventListener('click', () => {
|
||||
this._handleLikeClick();
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!this._likeCountEl) {
|
||||
this._likeCountEl = header.querySelector('.cwd-like-count');
|
||||
}
|
||||
this._updateLikeButton();
|
||||
}
|
||||
|
||||
_updateLikeButton(animate = false) {
|
||||
if (!this._likeButtonEl) {
|
||||
if (!this.mountPoint) {
|
||||
return;
|
||||
}
|
||||
const header = this.mountPoint.querySelector('.cwd-comments-header');
|
||||
if (!header) {
|
||||
return;
|
||||
}
|
||||
this._initLikeButton(header);
|
||||
}
|
||||
if (!this._likeButtonEl) {
|
||||
return;
|
||||
}
|
||||
const state = this.store?.store?.getState();
|
||||
const liked = state ? !!state.liked : this.likeState.liked;
|
||||
const count =
|
||||
state && typeof state.likeCount === 'number'
|
||||
? state.likeCount
|
||||
: this.likeState.count;
|
||||
this.likeState.count = count;
|
||||
this.likeState.liked = liked;
|
||||
this._likeButtonEl.dataset.liked = liked ? 'true' : 'false';
|
||||
this._likeButtonEl.dataset.loading = this.likeState.loading ? 'true' : 'false';
|
||||
if (this._likeCountEl) {
|
||||
this._likeCountEl.textContent = String(count);
|
||||
}
|
||||
if (animate && this._likeButtonEl) {
|
||||
this._likeButtonEl.classList.remove('cwd-like-animate');
|
||||
void this._likeButtonEl.offsetWidth;
|
||||
this._likeButtonEl.classList.add('cwd-like-animate');
|
||||
}
|
||||
}
|
||||
|
||||
_handleLikeClick() {
|
||||
if (!this.api || typeof this.api.likePage !== 'function') {
|
||||
return;
|
||||
}
|
||||
if (this.likeState.loading) {
|
||||
return;
|
||||
}
|
||||
const currentState = this.store?.store?.getState();
|
||||
const currentCount =
|
||||
currentState && typeof currentState.likeCount === 'number'
|
||||
? currentState.likeCount
|
||||
: this.likeState.count;
|
||||
const wasLiked = currentState ? !!currentState.liked : this.likeState.liked;
|
||||
if (wasLiked) {
|
||||
return;
|
||||
}
|
||||
const nextCount = currentCount + 1;
|
||||
this.likeState.loading = true;
|
||||
this.likeState.count = nextCount;
|
||||
this.likeState.liked = true;
|
||||
if (this.store && typeof this.store.setLikeState === 'function') {
|
||||
this.store.setLikeState(nextCount, true);
|
||||
}
|
||||
this._updateLikeButton(true);
|
||||
this.api
|
||||
.likePage()
|
||||
.then((result) => {
|
||||
const total =
|
||||
result && typeof result.totalLikes === 'number'
|
||||
? result.totalLikes
|
||||
: nextCount;
|
||||
const liked = !!(result && result.liked);
|
||||
this.likeState.count = total;
|
||||
this.likeState.liked = liked;
|
||||
if (this.store && typeof this.store.setLikeState === 'function') {
|
||||
this.store.setLikeState(total, liked);
|
||||
}
|
||||
this._updateLikeButton();
|
||||
})
|
||||
.catch(() => {
|
||||
this.likeState.count = currentCount;
|
||||
this.likeState.liked = wasLiked;
|
||||
if (this.store && typeof this.store.setLikeState === 'function') {
|
||||
this.store.setLikeState(currentCount, wasLiked);
|
||||
}
|
||||
this._updateLikeButton();
|
||||
})
|
||||
.finally(() => {
|
||||
this.likeState.loading = false;
|
||||
this._updateLikeButton();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前配置
|
||||
* @returns {Object}
|
||||
|
||||
@@ -11,9 +11,27 @@
|
||||
* @param {string} config.postUrl - 文章 URL(可选)
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function createApiClient(config) {
|
||||
export function createApiClient(config) {
|
||||
const baseUrl = config.apiBaseUrl.replace(/\/$/, '');
|
||||
|
||||
function getLikeUserId() {
|
||||
try {
|
||||
const storageKey = 'cwd_like_uid';
|
||||
let token = localStorage.getItem(storageKey);
|
||||
if (!token) {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
token = crypto.randomUUID();
|
||||
} else {
|
||||
token = `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
localStorage.setItem(storageKey, token);
|
||||
}
|
||||
return token;
|
||||
} catch (e) {
|
||||
return 'anonymous';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取评论列表
|
||||
* @param {number} page - 页码
|
||||
@@ -114,10 +132,58 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function getLikeStatus() {
|
||||
const params = new URLSearchParams({
|
||||
post_slug: config.postSlug
|
||||
});
|
||||
const headers = {
|
||||
'X-CWD-Like-User': getLikeUserId()
|
||||
};
|
||||
const response = await fetch(`${baseUrl}/api/like?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
if (!response.ok) {
|
||||
return {
|
||||
liked: false,
|
||||
alreadyLiked: false,
|
||||
totalLikes: 0
|
||||
};
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function likePage() {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CWD-Like-User': getLikeUserId()
|
||||
};
|
||||
const response = await fetch(`${baseUrl}/api/like`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
postSlug: config.postSlug,
|
||||
postTitle: config.postTitle,
|
||||
postUrl: config.postUrl
|
||||
})
|
||||
});
|
||||
if (!response.ok) {
|
||||
let msg = response.statusText;
|
||||
try {
|
||||
const json = await response.json();
|
||||
if (json.message) msg = json.message;
|
||||
} catch (e) {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
return {
|
||||
fetchComments,
|
||||
submitComment,
|
||||
verifyAdminKey,
|
||||
trackVisit
|
||||
trackVisit,
|
||||
getLikeStatus,
|
||||
likePage
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,6 +126,8 @@ export function createCommentStore(config, fetchComments, submitComment) {
|
||||
replyingTo: null,
|
||||
replyContent: '',
|
||||
replyError: null,
|
||||
likeCount: 0,
|
||||
liked: false,
|
||||
});
|
||||
|
||||
// 监听用户信息变化,自动保存到 localStorage
|
||||
@@ -165,6 +167,14 @@ export function createCommentStore(config, fetchComments, submitComment) {
|
||||
}
|
||||
}
|
||||
|
||||
function setLikeState(likeCount, liked) {
|
||||
const safeCount = typeof likeCount === 'number' && Number.isFinite(likeCount) && likeCount >= 0 ? likeCount : 0;
|
||||
store.setState({
|
||||
likeCount: safeCount,
|
||||
liked: !!liked,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交评论
|
||||
*/
|
||||
@@ -362,7 +372,7 @@ export function createCommentStore(config, fetchComments, submitComment) {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
return {
|
||||
// Store 实例
|
||||
store,
|
||||
|
||||
@@ -384,5 +394,6 @@ export function createCommentStore(config, fetchComments, submitComment) {
|
||||
clearError,
|
||||
clearSuccess,
|
||||
goToPage,
|
||||
setLikeState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,6 +39,21 @@
|
||||
color: var(--cwd-text);
|
||||
}
|
||||
|
||||
.cwd-comments-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--cwd-border);
|
||||
}
|
||||
|
||||
.cwd-comments-count {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--cwd-text);
|
||||
}
|
||||
|
||||
/* ========== Loading 组件 ========== */
|
||||
.cwd-loading {
|
||||
display: flex;
|
||||
@@ -499,6 +514,72 @@
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.cwd-like {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cwd-like-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--cwd-border-light, #eaeef2);
|
||||
background: var(--cwd-bg-secondary, #f6f8fa);
|
||||
color: var(--cwd-text-secondary, #6e7781);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.cwd-like-button[data-liked='true'] {
|
||||
background: rgba(9, 105, 218, 0.08);
|
||||
border-color: var(--cwd-primary, #0969da);
|
||||
color: var(--cwd-primary, #0969da);
|
||||
}
|
||||
|
||||
.cwd-like-button[data-loading='true'] {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cwd-like-icon-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cwd-like-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.cwd-like-count {
|
||||
min-width: 1.5em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.cwd-like-animate .cwd-like-icon {
|
||||
animation: cwd-like-bounce 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes cwd-like-bounce {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.cwd-comment-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user