feat(评论): 新增功能开关管理及评论点赞功能优化
- 在管理后台添加功能开关页面,支持控制评论点赞和文章点赞功能的开启/关闭 - 优化评论点赞功能,支持取消点赞和本地存储点赞状态 - 更新点赞UI样式,提升用户体验 - 添加相关API接口和文档说明
This commit is contained in:
@@ -123,6 +123,11 @@ export type LikeStatsResponse = {
|
|||||||
items: LikeStatsItem[];
|
items: LikeStatsItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FeatureSettingsResponse = {
|
||||||
|
enableCommentLike: boolean;
|
||||||
|
enableArticleLike: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export async function loginAdmin(name: string, password: string): Promise<string> {
|
export async function loginAdmin(name: string, password: string): Promise<string> {
|
||||||
const res = await post<AdminLoginResponse>('/admin/login', { name, password });
|
const res = await post<AdminLoginResponse>('/admin/login', { name, password });
|
||||||
const key = res.data.key;
|
const key = res.data.key;
|
||||||
@@ -289,3 +294,14 @@ export function fetchDomainList(): Promise<DomainListResponse> {
|
|||||||
export function fetchLikeStats(): Promise<LikeStatsResponse> {
|
export function fetchLikeStats(): Promise<LikeStatsResponse> {
|
||||||
return get<LikeStatsResponse>('/admin/likes/stats');
|
return get<LikeStatsResponse>('/admin/likes/stats');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchFeatureSettings(): Promise<FeatureSettingsResponse> {
|
||||||
|
return get<FeatureSettingsResponse>('/admin/settings/features');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveFeatureSettings(data: {
|
||||||
|
enableCommentLike?: boolean;
|
||||||
|
enableArticleLike?: boolean;
|
||||||
|
}): Promise<{ message: string }> {
|
||||||
|
return put<{ message: string }>('/admin/settings/features', data);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import LoginView from '../views/LoginView.vue';
|
|||||||
import LayoutView from '../views/LayoutView.vue';
|
import LayoutView from '../views/LayoutView.vue';
|
||||||
import CommentsView from '../views/CommentsView.vue';
|
import CommentsView from '../views/CommentsView.vue';
|
||||||
import SettingsView from '../views/SettingsView.vue';
|
import SettingsView from '../views/SettingsView.vue';
|
||||||
|
import FeatureSettingsView from '../views/FeatureSettingsView.vue';
|
||||||
import DataView from '../views/DataView.vue';
|
import DataView from '../views/DataView.vue';
|
||||||
import StatsView from '../views/StatsView.vue';
|
import StatsView from '../views/StatsView.vue';
|
||||||
import AnalyticsVisitView from '../views/AnalyticsVisitView.vue';
|
import AnalyticsVisitView from '../views/AnalyticsVisitView.vue';
|
||||||
@@ -41,6 +42,11 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'settings',
|
name: 'settings',
|
||||||
component: SettingsView
|
component: SettingsView
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/features',
|
||||||
|
name: 'feature-settings',
|
||||||
|
component: FeatureSettingsView
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'data',
|
path: 'data',
|
||||||
name: 'data',
|
name: 'data',
|
||||||
|
|||||||
262
cwd-admin/src/views/FeatureSettingsView.vue
Normal file
262
cwd-admin/src/views/FeatureSettingsView.vue
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<h2 class="page-title">评论功能开关</h2>
|
||||||
|
<div
|
||||||
|
v-if="toastVisible"
|
||||||
|
class="toast"
|
||||||
|
:class="toastType === 'error' ? 'toast-error' : 'toast-success'"
|
||||||
|
>
|
||||||
|
{{ toastMessage }}
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="page-hint">加载中...</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="card">
|
||||||
|
<h3 class="card-title">显示功能设置</h3>
|
||||||
|
|
||||||
|
<div class="form-item">
|
||||||
|
<label class="form-label">开启文章点赞功能</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input v-model="enableArticleLike" type="checkbox" />
|
||||||
|
<span class="slider" />
|
||||||
|
</label>
|
||||||
|
<div class="form-hint">开启后,评论区顶部会显示文章点赞(喜欢)按钮。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-item">
|
||||||
|
<label class="form-label">开启评论点赞功能</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input v-model="enableCommentLike" type="checkbox" />
|
||||||
|
<span class="slider" />
|
||||||
|
</label>
|
||||||
|
<div class="form-hint">开启后,评论列表中的每条评论都会显示点赞按钮。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="message && messageType === 'error'"
|
||||||
|
class="form-message form-message-error"
|
||||||
|
>
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="card-button" :disabled="saving" @click="save">
|
||||||
|
<span v-if="saving">保存中...</span>
|
||||||
|
<span v-else>保存</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { fetchFeatureSettings, saveFeatureSettings } from "../api/admin";
|
||||||
|
|
||||||
|
const enableCommentLike = ref(true);
|
||||||
|
const enableArticleLike = ref(true);
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const message = ref("");
|
||||||
|
const messageType = ref<"success" | "error">("success");
|
||||||
|
const toastMessage = ref("");
|
||||||
|
const toastType = ref<"success" | "error">("success");
|
||||||
|
const toastVisible = ref(false);
|
||||||
|
|
||||||
|
function showToast(msg: string, type: "success" | "error" = "success") {
|
||||||
|
toastMessage.value = msg;
|
||||||
|
toastType.value = type;
|
||||||
|
toastVisible.value = true;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
toastVisible.value = false;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await fetchFeatureSettings();
|
||||||
|
enableCommentLike.value = res.enableCommentLike;
|
||||||
|
enableArticleLike.value = res.enableArticleLike;
|
||||||
|
} catch (e: any) {
|
||||||
|
message.value = e.message || "加载失败";
|
||||||
|
messageType.value = "error";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true;
|
||||||
|
message.value = "";
|
||||||
|
try {
|
||||||
|
const res = await saveFeatureSettings({
|
||||||
|
enableCommentLike: enableCommentLike.value,
|
||||||
|
enableArticleLike: enableArticleLike.value,
|
||||||
|
});
|
||||||
|
showToast(res.message || "保存成功", "success");
|
||||||
|
} catch (e: any) {
|
||||||
|
message.value = e.message || "保存失败";
|
||||||
|
messageType.value = "error";
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 620px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #24292f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #d0d7de;
|
||||||
|
padding: 16px 18px;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #555555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
box-shadow: 0 8px 24px rgba(140, 149, 159, 0.2);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-success {
|
||||||
|
background-color: #1a7f37;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-error {
|
||||||
|
background-color: #d1242f;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: #d0d7de;
|
||||||
|
transition: 0.2s;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
left: 3px;
|
||||||
|
top: 3px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
transition: 0.2s;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 1px 2px rgba(27, 31, 36, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input:checked + .slider {
|
||||||
|
background-color: #0969da;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input:checked + .slider::before {
|
||||||
|
transform: translateX(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-button {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: none;
|
||||||
|
background-color: #0969da;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-button:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-message {
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-message-error {
|
||||||
|
color: #d1242f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-hint {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #57606a;
|
||||||
|
}
|
||||||
|
.form-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #57606a;
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -97,6 +97,13 @@
|
|||||||
>
|
>
|
||||||
网站设置
|
网站设置
|
||||||
</li>
|
</li>
|
||||||
|
<li
|
||||||
|
class="menu-item"
|
||||||
|
:class="{ active: isRouteActive('feature-settings') }"
|
||||||
|
@click="goFeatureSettings"
|
||||||
|
>
|
||||||
|
功能开关
|
||||||
|
</li>
|
||||||
<li
|
<li
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
:class="{ active: isRouteActive('data') }"
|
:class="{ active: isRouteActive('data') }"
|
||||||
@@ -205,6 +212,11 @@ function goSettings() {
|
|||||||
closeSider();
|
closeSider();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goFeatureSettings() {
|
||||||
|
router.push({ name: "feature-settings" });
|
||||||
|
closeSider();
|
||||||
|
}
|
||||||
|
|
||||||
function openDocs() {
|
function openDocs() {
|
||||||
window.open("https://cwd.js.org", "_blank");
|
window.open("https://cwd.js.org", "_blank");
|
||||||
closeActions();
|
closeActions();
|
||||||
|
|||||||
38
cwd-api/src/api/admin/featureSettings.ts
Normal file
38
cwd-api/src/api/admin/featureSettings.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Context } from 'hono';
|
||||||
|
import { Bindings } from '../../bindings';
|
||||||
|
import {
|
||||||
|
loadFeatureSettings,
|
||||||
|
saveFeatureSettings
|
||||||
|
} from '../../utils/featureSettings';
|
||||||
|
|
||||||
|
export const getFeatureSettings = async (c: Context<{ Bindings: Bindings }>) => {
|
||||||
|
try {
|
||||||
|
const settings = await loadFeatureSettings(c.env);
|
||||||
|
return c.json(settings);
|
||||||
|
} catch (e: any) {
|
||||||
|
return c.json({ message: e.message || 'Failed to load feature settings' }, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateFeatureSettings = async (c: Context<{ Bindings: Bindings }>) => {
|
||||||
|
try {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const enableCommentLike =
|
||||||
|
typeof body.enableCommentLike === 'boolean'
|
||||||
|
? body.enableCommentLike
|
||||||
|
: undefined;
|
||||||
|
const enableArticleLike =
|
||||||
|
typeof body.enableArticleLike === 'boolean'
|
||||||
|
? body.enableArticleLike
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await saveFeatureSettings(c.env, {
|
||||||
|
enableCommentLike,
|
||||||
|
enableArticleLike
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ message: 'Saved successfully' });
|
||||||
|
} catch (e: any) {
|
||||||
|
return c.json({ message: e.message || 'Failed to save feature settings' }, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
loadEmailNotificationSettings,
|
loadEmailNotificationSettings,
|
||||||
saveEmailNotificationSettings
|
saveEmailNotificationSettings
|
||||||
} from './utils/email';
|
} from './utils/email';
|
||||||
|
import { loadFeatureSettings } from './utils/featureSettings';
|
||||||
import packageJson from '../package.json';
|
import packageJson from '../package.json';
|
||||||
|
|
||||||
import { getComments } from './api/public/getComments';
|
import { getComments } from './api/public/getComments';
|
||||||
@@ -30,6 +31,10 @@ import { getLikeStatus, likePage } from './api/public/like';
|
|||||||
import { likeComment } from './api/public/likeComment';
|
import { likeComment } from './api/public/likeComment';
|
||||||
import { listLikes } from './api/admin/listLikes';
|
import { listLikes } from './api/admin/listLikes';
|
||||||
import { getLikeStats } from './api/admin/likeStats';
|
import { getLikeStats } from './api/admin/likeStats';
|
||||||
|
import {
|
||||||
|
getFeatureSettings,
|
||||||
|
updateFeatureSettings
|
||||||
|
} from './api/admin/featureSettings';
|
||||||
|
|
||||||
const app = new Hono<{ Bindings: Bindings }>();
|
const app = new Hono<{ Bindings: Bindings }>();
|
||||||
const VERSION = `v${packageJson.version}`;
|
const VERSION = `v${packageJson.version}`;
|
||||||
@@ -228,6 +233,7 @@ app.post('/api/comments/like', likeComment);
|
|||||||
app.get('/api/config/comments', async (c) => {
|
app.get('/api/config/comments', async (c) => {
|
||||||
try {
|
try {
|
||||||
const settings = await loadCommentSettings(c.env);
|
const settings = await loadCommentSettings(c.env);
|
||||||
|
const featureSettings = await loadFeatureSettings(c.env);
|
||||||
const {
|
const {
|
||||||
adminKey,
|
adminKey,
|
||||||
adminKeySet,
|
adminKeySet,
|
||||||
@@ -236,7 +242,7 @@ app.get('/api/config/comments', async (c) => {
|
|||||||
...publicSettings
|
...publicSettings
|
||||||
} = settings as any;
|
} = settings as any;
|
||||||
|
|
||||||
return c.json(publicSettings);
|
return c.json({ ...publicSettings, ...featureSettings });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return c.json({ message: e.message || '加载评论配置失败' }, 500);
|
return c.json({ message: e.message || '加载评论配置失败' }, 500);
|
||||||
}
|
}
|
||||||
@@ -256,6 +262,8 @@ app.get('/admin/analytics/overview', getVisitOverview);
|
|||||||
app.get('/admin/analytics/pages', getVisitPages);
|
app.get('/admin/analytics/pages', getVisitPages);
|
||||||
app.get('/admin/likes/list', listLikes);
|
app.get('/admin/likes/list', listLikes);
|
||||||
app.get('/admin/likes/stats', getLikeStats);
|
app.get('/admin/likes/stats', getLikeStats);
|
||||||
|
app.get('/admin/settings/features', getFeatureSettings);
|
||||||
|
app.put('/admin/settings/features', updateFeatureSettings);
|
||||||
app.get('/admin/settings/email', getAdminEmail);
|
app.get('/admin/settings/email', getAdminEmail);
|
||||||
app.put('/admin/settings/email', setAdminEmail);
|
app.put('/admin/settings/email', setAdminEmail);
|
||||||
app.get('/admin/settings/email-notify', async (c) => {
|
app.get('/admin/settings/email-notify', async (c) => {
|
||||||
|
|||||||
92
cwd-api/src/utils/featureSettings.ts
Normal file
92
cwd-api/src/utils/featureSettings.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { Bindings } from '../bindings';
|
||||||
|
|
||||||
|
export const FEATURE_COMMENT_LIKE_KEY = 'comment_feature_comment_like';
|
||||||
|
export const FEATURE_ARTICLE_LIKE_KEY = 'comment_feature_article_like';
|
||||||
|
|
||||||
|
export type FeatureSettings = {
|
||||||
|
enableCommentLike: boolean;
|
||||||
|
enableArticleLike: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadFeatureSettings(env: Bindings): Promise<FeatureSettings> {
|
||||||
|
await env.CWD_DB.prepare(
|
||||||
|
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
|
||||||
|
).run();
|
||||||
|
|
||||||
|
const keys = [FEATURE_COMMENT_LIKE_KEY, FEATURE_ARTICLE_LIKE_KEY];
|
||||||
|
const { results } = await env.CWD_DB.prepare(
|
||||||
|
'SELECT key, value FROM Settings WHERE key IN (?, ?)'
|
||||||
|
)
|
||||||
|
.bind(...keys)
|
||||||
|
.all<{ key: string; value: string }>();
|
||||||
|
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const row of results) {
|
||||||
|
if (row && row.key) {
|
||||||
|
map.set(row.key, row.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to true if not set, or false?
|
||||||
|
// Usually features might be enabled by default.
|
||||||
|
// But let's check the user requirement. "New settings... whether to enable..."
|
||||||
|
// If I default to false, existing users might lose features if they were implicit.
|
||||||
|
// But these are "new" settings.
|
||||||
|
// "comment likes" and "article likes" existed before?
|
||||||
|
// The code shows `like.ts` and `likeComment.ts`.
|
||||||
|
// `likePage` handler in `index.ts`.
|
||||||
|
// So the features exist. To avoid breaking changes, I should probably default to TRUE.
|
||||||
|
// But wait, if I default to true, then the user has to manually turn them off.
|
||||||
|
// If I default to false, they disappear.
|
||||||
|
// Given "whether to enable" implies they might be optional now.
|
||||||
|
// I'll default to TRUE to maintain backward compatibility (features visible by default).
|
||||||
|
|
||||||
|
const enableCommentLikeRaw = map.get(FEATURE_COMMENT_LIKE_KEY);
|
||||||
|
const enableCommentLike = enableCommentLikeRaw !== '0'; // Default to true if missing or '1'
|
||||||
|
|
||||||
|
const enableArticleLikeRaw = map.get(FEATURE_ARTICLE_LIKE_KEY);
|
||||||
|
const enableArticleLike = enableArticleLikeRaw !== '0'; // Default to true if missing or '1'
|
||||||
|
|
||||||
|
return {
|
||||||
|
enableCommentLike,
|
||||||
|
enableArticleLike
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveFeatureSettings(
|
||||||
|
env: Bindings,
|
||||||
|
settings: Partial<FeatureSettings>
|
||||||
|
) {
|
||||||
|
await env.CWD_DB.prepare(
|
||||||
|
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
|
||||||
|
).run();
|
||||||
|
|
||||||
|
const entries: { key: string; value: string | undefined }[] = [
|
||||||
|
{
|
||||||
|
key: FEATURE_COMMENT_LIKE_KEY,
|
||||||
|
value:
|
||||||
|
typeof settings.enableCommentLike === 'boolean'
|
||||||
|
? settings.enableCommentLike
|
||||||
|
? '1'
|
||||||
|
: '0'
|
||||||
|
: undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: FEATURE_ARTICLE_LIKE_KEY,
|
||||||
|
value:
|
||||||
|
typeof settings.enableArticleLike === 'boolean'
|
||||||
|
? settings.enableArticleLike
|
||||||
|
? '1'
|
||||||
|
: '0'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.value !== undefined) {
|
||||||
|
await env.CWD_DB.prepare('REPLACE INTO Settings (key, value) VALUES (?, ?)')
|
||||||
|
.bind(entry.key, entry.value)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,7 @@ GET /api/comments
|
|||||||
- 当 `nested=true`(默认)时,接口返回的是"根评论列表",每条根评论包含其 `replies`。
|
- 当 `nested=true`(默认)时,接口返回的是"根评论列表",每条根评论包含其 `replies`。
|
||||||
- 当 `nested=false` 时,接口返回扁平列表,所有评论都在 `data` 中,`replies` 为空。
|
- 当 `nested=false` 时,接口返回扁平列表,所有评论都在 `data` 中,`replies` 为空。
|
||||||
- `priority` 字段:评论的置顶权重,数值越大排序越靠前。
|
- `priority` 字段:评论的置顶权重,数值越大排序越靠前。
|
||||||
|
- `likes` 字段:评论的点赞数,默认为 0。
|
||||||
|
|
||||||
**错误响应**
|
**错误响应**
|
||||||
|
|
||||||
@@ -439,6 +440,124 @@ POST /api/like
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 1.5 评论点赞
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/comments/like
|
||||||
|
```
|
||||||
|
|
||||||
|
对指定评论进行点赞操作。
|
||||||
|
|
||||||
|
- 方法:`POST`
|
||||||
|
- 路径:`/api/comments/like`
|
||||||
|
- 鉴权:不需要
|
||||||
|
|
||||||
|
**请求头**
|
||||||
|
|
||||||
|
| 名称 | 必填 | 示例 |
|
||||||
|
| -------------- | ---- | ------------------ |
|
||||||
|
| `Content-Type` | 是 | `application/json` |
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 123
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 必填 | 说明 |
|
||||||
|
| ------ | ------ | ---- | ---------- |
|
||||||
|
| `id` | number | 是 | 评论 ID |
|
||||||
|
|
||||||
|
**成功响应**
|
||||||
|
|
||||||
|
- 状态码:`200`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "点赞成功",
|
||||||
|
"likes": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
| ------- | ------ | -------------- |
|
||||||
|
| `likes` | number | 更新后的点赞数 |
|
||||||
|
|
||||||
|
**错误响应**
|
||||||
|
|
||||||
|
- 状态码:`400`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "评论不存在"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.6 取消评论点赞
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/comments/like
|
||||||
|
```
|
||||||
|
|
||||||
|
取消对指定评论的点赞。
|
||||||
|
|
||||||
|
- 方法:`DELETE`
|
||||||
|
- 路径:`/api/comments/like`
|
||||||
|
- 鉴权:不需要
|
||||||
|
|
||||||
|
**请求头**
|
||||||
|
|
||||||
|
| 名称 | 必填 | 示例 |
|
||||||
|
| -------------- | ---- | ------------------ |
|
||||||
|
| `Content-Type` | 是 | `application/json` |
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 123
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 必填 | 说明 |
|
||||||
|
| ------ | ------ | ---- | ---------- |
|
||||||
|
| `id` | number | 是 | 评论 ID |
|
||||||
|
|
||||||
|
**成功响应**
|
||||||
|
|
||||||
|
- 状态码:`200`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "取消点赞成功",
|
||||||
|
"likes": 4
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
| ------- | ------ | -------------- |
|
||||||
|
| `likes` | number | 更新后的点赞数 |
|
||||||
|
|
||||||
|
**错误响应**
|
||||||
|
|
||||||
|
- 状态码:`400`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "评论不存在"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## 2. 配置相关
|
## 2. 配置相关
|
||||||
|
|
||||||
### 2.1 获取评论相关的公开配置
|
### 2.1 获取评论相关的公开配置
|
||||||
@@ -470,14 +589,16 @@ GET /api/config/comments
|
|||||||
|
|
||||||
字段说明:
|
字段说明:
|
||||||
|
|
||||||
| 字段名 | 类型 | 说明 |
|
| 字段名 | 类型 | 说明 |
|
||||||
| -------------- | ------- | -------------------------------------------------------------------- |
|
| ------------------ | ------- | -------------------------------------------------------------------- |
|
||||||
| `adminEmail` | string | 博主邮箱地址,用于在前端展示"博主"标识,并触发管理员身份验证流程 |
|
| `adminEmail` | string | 博主邮箱地址,用于在前端展示"博主"标识,并触发管理员身份验证流程 |
|
||||||
| `adminBadge` | string | 博主标识文字,例如 `"博主"` |
|
| `adminBadge` | string | 博主标识文字,例如 `"博主"` |
|
||||||
| `avatarPrefix` | string | 头像地址前缀,如 Gravatar 或 Cravatar 镜像地址 |
|
| `avatarPrefix` | string | 头像地址前缀,如 Gravatar 或 Cravatar 镜像地址 |
|
||||||
| `adminEnabled` | boolean | 是否启用博主标识相关展示(关闭时不显示徽标,但仍可作为管理员邮箱) |
|
| `adminEnabled` | boolean | 是否启用博主标识相关展示(关闭时不显示徽标,但仍可作为管理员邮箱) |
|
||||||
| `allowedDomains` | Array\<string\> | 允许调用组件的域名列表,留空则不限制 |
|
| `allowedDomains` | Array\<string\> | 允许调用组件的域名列表,留空则不限制 |
|
||||||
| `requireReview` | boolean | 是否开启新评论先审核再显示(true 表示新评论默认为待审核) |
|
| `requireReview` | boolean | 是否开启新评论先审核再显示(true 表示新评论默认为待审核) |
|
||||||
|
| `enableCommentLike` | boolean | 是否启用评论点赞功能(默认 true) |
|
||||||
|
| `enableArticleLike` | boolean | 是否启用文章点赞功能(默认 true) |
|
||||||
|
|
||||||
**错误响应**
|
**错误响应**
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import { ReplyEditor } from './ReplyEditor.js';
|
|||||||
import { formatRelativeTime } from '@/utils/date.js';
|
import { formatRelativeTime } from '@/utils/date.js';
|
||||||
|
|
||||||
export class CommentItem extends Component {
|
export class CommentItem extends Component {
|
||||||
|
// 防抖缓存,防止连续点击
|
||||||
|
static _likeDebounce = new Map();
|
||||||
|
// 用户标识缓存(单例,确保一致性)
|
||||||
|
static _userId = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {HTMLElement|string} container - 容器元素或选择器
|
* @param {HTMLElement|string} container - 容器元素或选择器
|
||||||
* @param {Object} props - 组件属性
|
* @param {Object} props - 组件属性
|
||||||
@@ -105,49 +110,58 @@ export class CommentItem extends Component {
|
|||||||
},
|
},
|
||||||
text: '回复'
|
text: '回复'
|
||||||
}),
|
}),
|
||||||
this.createElement('div', {
|
...(this.props.enableCommentLike !== false ? [
|
||||||
className: 'cwd-comment-like',
|
this.createElement('div', {
|
||||||
children: [
|
className: 'cwd-comment-like',
|
||||||
this.createElement('button', {
|
children: [
|
||||||
className: 'cwd-comment-like-button',
|
this.createElement('button', {
|
||||||
attributes: {
|
className: `cwd-comment-like-button${this.hasLiked(comment.id) ? ' cwd-comment-like-button-liked' : ''}`,
|
||||||
type: 'button',
|
attributes: {
|
||||||
'aria-label': '点赞',
|
type: 'button',
|
||||||
onClick: () => this.handleLikeComment()
|
'aria-label': this.hasLiked(comment.id) ? '取消点赞' : '点赞',
|
||||||
},
|
onClick: () => this.handleLikeComment()
|
||||||
children: [
|
},
|
||||||
this.createElement('span', {
|
children: [
|
||||||
className: 'cwd-comment-like-icon-wrapper',
|
this.createElement('span', {
|
||||||
children: [
|
className: 'cwd-comment-like-icon-wrapper',
|
||||||
this.createElement('svg', {
|
children: [
|
||||||
className: 'cwd-comment-like-icon',
|
this.createElement('svg', {
|
||||||
attributes: {
|
className: 'cwd-comment-like-icon',
|
||||||
viewBox: '0 0 24 24',
|
attributes: {
|
||||||
'aria-hidden': 'true'
|
viewBox: '0 0 24 24',
|
||||||
},
|
'aria-hidden': 'true',
|
||||||
children: [
|
fill: this.hasLiked(comment.id) ? 'currentColor' : 'none'
|
||||||
this.createElement('path', {
|
},
|
||||||
attributes: {
|
children: [
|
||||||
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'
|
this.createElement('path', {
|
||||||
}
|
attributes: {
|
||||||
})
|
d: 'M2 21h4V9H2v12zm20-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L13 1 7.59 6.41C7.22 6.78 7 7.3 7 7.83V19c0 1.1.9 2 2 2h8c.78 0 1.48-.45 1.82-1.11l3.02-7.05c.11-.23.16-.48.16-.74v-2z'
|
||||||
]
|
}
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
}),
|
})
|
||||||
this.createTextElement(
|
]
|
||||||
'span',
|
}),
|
||||||
String(
|
...(() => {
|
||||||
typeof comment.likes === 'number' && Number.isFinite(comment.likes) && comment.likes >= 0
|
const likeCount =
|
||||||
? comment.likes
|
typeof comment.likes === 'number' && Number.isFinite(comment.likes) && comment.likes >= 0
|
||||||
: 0
|
? comment.likes
|
||||||
),
|
: 0;
|
||||||
'cwd-comment-like-count'
|
return likeCount >= 1
|
||||||
)
|
? [
|
||||||
]
|
this.createTextElement(
|
||||||
})
|
'span',
|
||||||
]
|
String(likeCount),
|
||||||
}),
|
'cwd-comment-like-count'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
})()
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
] : []),
|
||||||
this.createTextElement('span', formatRelativeTime(comment.created), 'cwd-comment-time')
|
this.createTextElement('span', formatRelativeTime(comment.created), 'cwd-comment-time')
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -303,9 +317,99 @@ export class CommentItem extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleLikeComment() {
|
handleLikeComment() {
|
||||||
if (this.props.onLikeComment) {
|
if (!this.props.onLikeComment) {
|
||||||
this.props.onLikeComment(this.props.comment.id);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const commentId = String(this.props.comment.id);
|
||||||
|
|
||||||
|
// 防抖检查:1秒内同一评论只能操作一次
|
||||||
|
const now = Date.now();
|
||||||
|
const debounceKey = `${this.getUserId()}_${commentId}`;
|
||||||
|
const lastClick = CommentItem._likeDebounce.get(debounceKey);
|
||||||
|
if (lastClick && now - lastClick < 1000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CommentItem._likeDebounce.set(debounceKey, now);
|
||||||
|
|
||||||
|
// 获取当前点赞状态
|
||||||
|
const likedComments = this.getLikedComments();
|
||||||
|
const hasLiked = likedComments.has(commentId);
|
||||||
|
|
||||||
|
if (!hasLiked) {
|
||||||
|
// 未点赞,执行点赞
|
||||||
|
likedComments.add(commentId);
|
||||||
|
this.saveLikedComments(likedComments);
|
||||||
|
this.props.onLikeComment(commentId, true);
|
||||||
|
}
|
||||||
|
// 已点赞则不做任何操作
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户唯一标识
|
||||||
|
* 使用静态缓存确保一致性
|
||||||
|
* @returns {string} 用户ID
|
||||||
|
*/
|
||||||
|
getUserId() {
|
||||||
|
if (CommentItem._userId) {
|
||||||
|
return CommentItem._userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'cwd_comment_user_id';
|
||||||
|
let userId = localStorage.getItem(STORAGE_KEY);
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
// 生成简单的用户ID
|
||||||
|
userId = 'u_' + Date.now() + '_' + Math.random().toString(36).substring(2, 12);
|
||||||
|
localStorage.setItem(STORAGE_KEY, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
CommentItem._userId = userId;
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取已点赞的评论ID集合
|
||||||
|
* @returns {Set<string>} 已点赞的评论ID集合
|
||||||
|
*/
|
||||||
|
getLikedComments() {
|
||||||
|
const userId = this.getUserId();
|
||||||
|
const key = `cwd_comment_liked_${userId}`;
|
||||||
|
const data = localStorage.getItem(key);
|
||||||
|
const likedSet = new Set();
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
parsed.forEach(id => likedSet.add(String(id)));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 解析失败,返回空集合
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return likedSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存点赞记录到 localStorage
|
||||||
|
* @param {Set} likedSet - 点赞集合
|
||||||
|
*/
|
||||||
|
saveLikedComments(likedSet) {
|
||||||
|
const userId = this.getUserId();
|
||||||
|
const key = `cwd_comment_liked_${userId}`;
|
||||||
|
localStorage.setItem(key, JSON.stringify(Array.from(likedSet)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否已点赞
|
||||||
|
* @param {string|number} commentId - 评论ID
|
||||||
|
* @returns {boolean} 是否已点赞
|
||||||
|
*/
|
||||||
|
hasLiked(commentId) {
|
||||||
|
const likedComments = this.getLikedComments();
|
||||||
|
return likedComments.has(String(commentId));
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSubmitReply() {
|
handleSubmitReply() {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export class CommentList extends Component {
|
|||||||
* @param {Function} props.onGoToPage - 跳转页码回调
|
* @param {Function} props.onGoToPage - 跳转页码回调
|
||||||
* @param {string} props.adminEmail - 博主邮箱(可选)
|
* @param {string} props.adminEmail - 博主邮箱(可选)
|
||||||
* @param {string} props.adminBadge - 博主标识文字(可选)
|
* @param {string} props.adminBadge - 博主标识文字(可选)
|
||||||
|
* @param {boolean} props.enableCommentLike - 是否开启评论点赞
|
||||||
*/
|
*/
|
||||||
constructor(container, props = {}) {
|
constructor(container, props = {}) {
|
||||||
super(container, props);
|
super(container, props);
|
||||||
@@ -96,6 +97,7 @@ export class CommentList extends Component {
|
|||||||
submitting: this.props.submitting,
|
submitting: this.props.submitting,
|
||||||
adminEmail: this.props.adminEmail,
|
adminEmail: this.props.adminEmail,
|
||||||
adminBadge: this.props.adminBadge,
|
adminBadge: this.props.adminBadge,
|
||||||
|
enableCommentLike: this.props.enableCommentLike,
|
||||||
onReply: (commentId) => this.handleReply(commentId),
|
onReply: (commentId) => this.handleReply(commentId),
|
||||||
onSubmitReply: (commentId) => this.handleSubmitReply(commentId),
|
onSubmitReply: (commentId) => this.handleSubmitReply(commentId),
|
||||||
onCancelReply: () => this.handleCancelReply(),
|
onCancelReply: () => this.handleCancelReply(),
|
||||||
|
|||||||
@@ -97,10 +97,41 @@ export class Component {
|
|||||||
* @returns {HTMLElement}
|
* @returns {HTMLElement}
|
||||||
*/
|
*/
|
||||||
createElement(tag, options = {}) {
|
createElement(tag, options = {}) {
|
||||||
const el = document.createElement(tag);
|
const svgTags = new Set([
|
||||||
|
'svg',
|
||||||
|
'path',
|
||||||
|
'circle',
|
||||||
|
'rect',
|
||||||
|
'line',
|
||||||
|
'polyline',
|
||||||
|
'polygon',
|
||||||
|
'ellipse',
|
||||||
|
'g',
|
||||||
|
'defs',
|
||||||
|
'clipPath',
|
||||||
|
'mask',
|
||||||
|
'pattern',
|
||||||
|
'text',
|
||||||
|
'tspan',
|
||||||
|
'use',
|
||||||
|
'symbol',
|
||||||
|
'linearGradient',
|
||||||
|
'radialGradient',
|
||||||
|
'stop',
|
||||||
|
'filter'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isSvgTag = svgTags.has(String(tag).toLowerCase());
|
||||||
|
const el = isSvgTag
|
||||||
|
? document.createElementNS('http://www.w3.org/2000/svg', tag)
|
||||||
|
: document.createElement(tag);
|
||||||
|
|
||||||
if (options.className) {
|
if (options.className) {
|
||||||
el.className = options.className;
|
if (isSvgTag) {
|
||||||
|
el.setAttribute('class', options.className);
|
||||||
|
} else {
|
||||||
|
el.className = options.className;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.attributes) {
|
if (options.attributes) {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export class CWDComments {
|
|||||||
this.likeState = {
|
this.likeState = {
|
||||||
count: 0,
|
count: 0,
|
||||||
liked: false,
|
liked: false,
|
||||||
loading: false
|
loading: false,
|
||||||
};
|
};
|
||||||
this._likeButtonEl = null;
|
this._likeButtonEl = null;
|
||||||
this._likeCountEl = null;
|
this._likeCountEl = null;
|
||||||
@@ -99,6 +99,8 @@ export class CWDComments {
|
|||||||
adminEnabled: !!data.adminEnabled,
|
adminEnabled: !!data.adminEnabled,
|
||||||
avatarPrefix: data.avatarPrefix || '',
|
avatarPrefix: data.avatarPrefix || '',
|
||||||
allowedDomains: Array.isArray(data.allowedDomains) ? data.allowedDomains : [],
|
allowedDomains: Array.isArray(data.allowedDomains) ? data.allowedDomains : [],
|
||||||
|
enableCommentLike: typeof data.enableCommentLike === 'boolean' ? data.enableCommentLike : true,
|
||||||
|
enableArticleLike: typeof data.enableArticleLike === 'boolean' ? data.enableArticleLike : true,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {};
|
return {};
|
||||||
@@ -137,11 +139,7 @@ export class CWDComments {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查域名限制
|
// 检查域名限制
|
||||||
if (
|
if (serverConfig.allowedDomains && serverConfig.allowedDomains.length > 0 && typeof window !== 'undefined') {
|
||||||
serverConfig.allowedDomains &&
|
|
||||||
serverConfig.allowedDomains.length > 0 &&
|
|
||||||
typeof window !== 'undefined'
|
|
||||||
) {
|
|
||||||
const currentHostname = window.location.hostname;
|
const currentHostname = window.location.hostname;
|
||||||
const isAllowed = serverConfig.allowedDomains.some((domain) => {
|
const isAllowed = serverConfig.allowedDomains.some((domain) => {
|
||||||
return currentHostname === domain || currentHostname.endsWith('.' + domain);
|
return currentHostname === domain || currentHostname.endsWith('.' + domain);
|
||||||
@@ -169,6 +167,8 @@ export class CWDComments {
|
|||||||
this.config.adminBadge = serverConfig.adminBadge;
|
this.config.adminBadge = serverConfig.adminBadge;
|
||||||
}
|
}
|
||||||
this.config.requireReview = !!serverConfig.requireReview;
|
this.config.requireReview = !!serverConfig.requireReview;
|
||||||
|
this.config.enableCommentLike = serverConfig.enableCommentLike;
|
||||||
|
this.config.enableArticleLike = serverConfig.enableArticleLike;
|
||||||
|
|
||||||
const api = createApiClient(this.config);
|
const api = createApiClient(this.config);
|
||||||
this.api = api;
|
this.api = api;
|
||||||
@@ -177,7 +177,7 @@ export class CWDComments {
|
|||||||
this.config,
|
this.config,
|
||||||
api.fetchComments.bind(api),
|
api.fetchComments.bind(api),
|
||||||
api.submitComment.bind(api),
|
api.submitComment.bind(api),
|
||||||
typeof api.likeComment === 'function' ? api.likeComment.bind(api) : undefined
|
typeof api.likeComment === 'function' ? api.likeComment.bind(api) : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.unsubscribe = this.store.store.subscribe((state) => {
|
this.unsubscribe = this.store.store.subscribe((state) => {
|
||||||
@@ -195,10 +195,7 @@ export class CWDComments {
|
|||||||
if (this.api && typeof this.api.getLikeStatus === 'function') {
|
if (this.api && typeof this.api.getLikeStatus === 'function') {
|
||||||
try {
|
try {
|
||||||
const likeResult = await this.api.getLikeStatus();
|
const likeResult = await this.api.getLikeStatus();
|
||||||
const count =
|
const count = likeResult && typeof likeResult.totalLikes === 'number' ? likeResult.totalLikes : 0;
|
||||||
likeResult && typeof likeResult.totalLikes === 'number'
|
|
||||||
? likeResult.totalLikes
|
|
||||||
: 0;
|
|
||||||
const liked = !!(likeResult && likeResult.liked);
|
const liked = !!(likeResult && likeResult.liked);
|
||||||
this.likeState.count = count;
|
this.likeState.count = count;
|
||||||
this.likeState.liked = liked;
|
this.likeState.liked = liked;
|
||||||
@@ -206,8 +203,7 @@ export class CWDComments {
|
|||||||
this.store.setLikeState(count, liked);
|
this.store.setLikeState(count, liked);
|
||||||
}
|
}
|
||||||
this._updateLikeButton();
|
this._updateLikeButton();
|
||||||
} catch (e) {
|
} catch (e) {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -263,7 +259,7 @@ export class CWDComments {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const state = this.store.store.getState();
|
const state = this.store.store.getState();
|
||||||
|
|
||||||
// 创建错误提示
|
// 创建错误提示
|
||||||
const existingError = this.mountPoint.querySelector('.cwd-error-inline');
|
const existingError = this.mountPoint.querySelector('.cwd-error-inline');
|
||||||
if (state.error) {
|
if (state.error) {
|
||||||
@@ -311,20 +307,21 @@ export class CWDComments {
|
|||||||
if (!header) {
|
if (!header) {
|
||||||
header = document.createElement('div');
|
header = document.createElement('div');
|
||||||
header.className = 'cwd-comments-header';
|
header.className = 'cwd-comments-header';
|
||||||
|
const showArticleLike = this.config.enableArticleLike !== false;
|
||||||
header.innerHTML = `
|
header.innerHTML = `
|
||||||
<h3 class="cwd-comments-count">
|
<div class="cwd-like" ${showArticleLike ? '' : 'style="display: none;"'}>
|
||||||
共 <span class="cwd-comments-count-number">0</span> 条评论
|
|
||||||
</h3>
|
|
||||||
<div class="cwd-like">
|
|
||||||
<button type="button" class="cwd-like-button" data-liked="false">
|
<button type="button" class="cwd-like-button" data-liked="false">
|
||||||
<span class="cwd-like-icon-wrapper">
|
<span class="cwd-like-icon-wrapper">
|
||||||
<svg class="cwd-like-icon" viewBox="0 0 24 24" aria-hidden="true">
|
<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>
|
<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>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<span class="cwd-like-count">0</span>
|
<div>已有 <span class="cwd-like-count">0</span>人喜欢~ </div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<h3 class="cwd-comments-count">
|
||||||
|
共 <span class="cwd-comments-count-number">0</span> 条评论
|
||||||
|
</h3>
|
||||||
`;
|
`;
|
||||||
this.mountPoint.appendChild(header);
|
this.mountPoint.appendChild(header);
|
||||||
}
|
}
|
||||||
@@ -349,7 +346,7 @@ export class CWDComments {
|
|||||||
onSubmit: () => this._handleSubmit(),
|
onSubmit: () => this._handleSubmit(),
|
||||||
onFieldChange: (field, value) => this.store.updateFormField(field, value),
|
onFieldChange: (field, value) => this.store.updateFormField(field, value),
|
||||||
adminEmail: this.config.adminEmail,
|
adminEmail: this.config.adminEmail,
|
||||||
onVerifyAdmin: (key) => this.api.verifyAdminKey(key)
|
onVerifyAdmin: (key) => this.api.verifyAdminKey(key),
|
||||||
});
|
});
|
||||||
this.commentForm.render();
|
this.commentForm.render();
|
||||||
}
|
}
|
||||||
@@ -371,6 +368,7 @@ export class CWDComments {
|
|||||||
submitting: state.submitting,
|
submitting: state.submitting,
|
||||||
adminEmail: this.config.adminEmail,
|
adminEmail: this.config.adminEmail,
|
||||||
adminBadge: this.config.adminBadge,
|
adminBadge: this.config.adminBadge,
|
||||||
|
enableCommentLike: this.config.enableCommentLike !== false,
|
||||||
onRetry: () => this.store.loadComments(),
|
onRetry: () => this.store.loadComments(),
|
||||||
onReply: (commentId) => this.store.startReply(commentId),
|
onReply: (commentId) => this.store.startReply(commentId),
|
||||||
onSubmitReply: (commentId) => this.store.submitReply(commentId),
|
onSubmitReply: (commentId) => this.store.submitReply(commentId),
|
||||||
@@ -380,9 +378,9 @@ export class CWDComments {
|
|||||||
onPrevPage: () => this.store.goToPage(state.pagination.page - 1),
|
onPrevPage: () => this.store.goToPage(state.pagination.page - 1),
|
||||||
onNextPage: () => this.store.goToPage(state.pagination.page + 1),
|
onNextPage: () => this.store.goToPage(state.pagination.page + 1),
|
||||||
onGoToPage: (page) => this.store.goToPage(page),
|
onGoToPage: (page) => this.store.goToPage(page),
|
||||||
onLikeComment: (commentId) => {
|
onLikeComment: (commentId, isLike) => {
|
||||||
if (this.store && typeof this.store.likeComment === 'function') {
|
if (this.store && typeof this.store.likeComment === 'function') {
|
||||||
this.store.likeComment(commentId);
|
this.store.likeComment(commentId, isLike);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -415,7 +413,7 @@ export class CWDComments {
|
|||||||
form: state.form,
|
form: state.form,
|
||||||
formErrors: state.formErrors,
|
formErrors: state.formErrors,
|
||||||
submitting: state.submitting,
|
submitting: state.submitting,
|
||||||
adminEmail: this.config.adminEmail
|
adminEmail: this.config.adminEmail,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,10 +562,7 @@ export class CWDComments {
|
|||||||
if (!this.shadowRoot) {
|
if (!this.shadowRoot) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rawUrl =
|
const rawUrl = this.config && typeof this.config.customCssUrl === 'string' ? this.config.customCssUrl : '';
|
||||||
this.config && typeof this.config.customCssUrl === 'string'
|
|
||||||
? this.config.customCssUrl
|
|
||||||
: '';
|
|
||||||
const url = rawUrl.trim();
|
const url = rawUrl.trim();
|
||||||
if (!url) {
|
if (!url) {
|
||||||
if (this.customStyleElement && this.customStyleElement.parentNode) {
|
if (this.customStyleElement && this.customStyleElement.parentNode) {
|
||||||
@@ -624,10 +619,7 @@ export class CWDComments {
|
|||||||
}
|
}
|
||||||
const state = this.store?.store?.getState();
|
const state = this.store?.store?.getState();
|
||||||
const liked = state ? !!state.liked : this.likeState.liked;
|
const liked = state ? !!state.liked : this.likeState.liked;
|
||||||
const count =
|
const count = state && typeof state.likeCount === 'number' ? state.likeCount : this.likeState.count;
|
||||||
state && typeof state.likeCount === 'number'
|
|
||||||
? state.likeCount
|
|
||||||
: this.likeState.count;
|
|
||||||
this.likeState.count = count;
|
this.likeState.count = count;
|
||||||
this.likeState.liked = liked;
|
this.likeState.liked = liked;
|
||||||
this._likeButtonEl.dataset.liked = liked ? 'true' : 'false';
|
this._likeButtonEl.dataset.liked = liked ? 'true' : 'false';
|
||||||
@@ -650,10 +642,7 @@ export class CWDComments {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const currentState = this.store?.store?.getState();
|
const currentState = this.store?.store?.getState();
|
||||||
const currentCount =
|
const currentCount = currentState && typeof currentState.likeCount === 'number' ? currentState.likeCount : this.likeState.count;
|
||||||
currentState && typeof currentState.likeCount === 'number'
|
|
||||||
? currentState.likeCount
|
|
||||||
: this.likeState.count;
|
|
||||||
const wasLiked = currentState ? !!currentState.liked : this.likeState.liked;
|
const wasLiked = currentState ? !!currentState.liked : this.likeState.liked;
|
||||||
if (wasLiked) {
|
if (wasLiked) {
|
||||||
return;
|
return;
|
||||||
@@ -669,10 +658,7 @@ export class CWDComments {
|
|||||||
this.api
|
this.api
|
||||||
.likePage()
|
.likePage()
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
const total =
|
const total = result && typeof result.totalLikes === 'number' ? result.totalLikes : nextCount;
|
||||||
result && typeof result.totalLikes === 'number'
|
|
||||||
? result.totalLikes
|
|
||||||
: nextCount;
|
|
||||||
const liked = !!(result && result.liked);
|
const liked = !!(result && result.liked);
|
||||||
this.likeState.count = total;
|
this.likeState.count = total;
|
||||||
this.likeState.liked = liked;
|
this.likeState.liked = liked;
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ export function createApiClient(config) {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function likeComment(commentId) {
|
async function likeComment(commentId, isLike = true) {
|
||||||
const id =
|
const id =
|
||||||
typeof commentId === 'number'
|
typeof commentId === 'number'
|
||||||
? commentId
|
? commentId
|
||||||
@@ -188,8 +188,9 @@ export function createApiClient(config) {
|
|||||||
if (!Number.isFinite(id) || id <= 0) {
|
if (!Number.isFinite(id) || id <= 0) {
|
||||||
throw new Error('Invalid comment id');
|
throw new Error('Invalid comment id');
|
||||||
}
|
}
|
||||||
|
const method = isLike ? 'POST' : 'DELETE';
|
||||||
const response = await fetch(`${baseUrl}/api/comments/like`, {
|
const response = await fetch(`${baseUrl}/api/comments/like`, {
|
||||||
method: 'POST',
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function likeComment(commentId) {
|
async function likeComment(commentId, isLike = true) {
|
||||||
const state = store.getState();
|
const state = store.getState();
|
||||||
if (!likeCommentFn || state.commentLikeLoadingId === commentId) {
|
if (!likeCommentFn || state.commentLikeLoadingId === commentId) {
|
||||||
return;
|
return;
|
||||||
@@ -196,6 +196,7 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const safeComments = Array.isArray(state.comments) ? state.comments : [];
|
const safeComments = Array.isArray(state.comments) ? state.comments : [];
|
||||||
|
const delta = isLike ? 1 : -1;
|
||||||
const nextComments = safeComments.map((item) => {
|
const nextComments = safeComments.map((item) => {
|
||||||
if (!item || typeof item.id !== 'number') {
|
if (!item || typeof item.id !== 'number') {
|
||||||
return item;
|
return item;
|
||||||
@@ -205,9 +206,10 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom
|
|||||||
typeof item.likes === 'number' && Number.isFinite(item.likes) && item.likes >= 0
|
typeof item.likes === 'number' && Number.isFinite(item.likes) && item.likes >= 0
|
||||||
? item.likes
|
? item.likes
|
||||||
: 0;
|
: 0;
|
||||||
|
const nextLikes = Math.max(0, current + delta);
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
likes: current + 1,
|
likes: nextLikes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (Array.isArray(item.replies) && item.replies.length > 0) {
|
if (Array.isArray(item.replies) && item.replies.length > 0) {
|
||||||
@@ -220,9 +222,10 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom
|
|||||||
typeof reply.likes === 'number' && Number.isFinite(reply.likes) && reply.likes >= 0
|
typeof reply.likes === 'number' && Number.isFinite(reply.likes) && reply.likes >= 0
|
||||||
? reply.likes
|
? reply.likes
|
||||||
: 0;
|
: 0;
|
||||||
|
const nextLikes = Math.max(0, current + delta);
|
||||||
return {
|
return {
|
||||||
...reply,
|
...reply,
|
||||||
likes: current + 1,
|
likes: nextLikes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return reply;
|
return reply;
|
||||||
@@ -237,7 +240,7 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom
|
|||||||
store.setState({
|
store.setState({
|
||||||
comments: nextComments,
|
comments: nextComments,
|
||||||
});
|
});
|
||||||
await likeCommentFn(id);
|
await likeCommentFn(id, isLike);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
} finally {
|
} finally {
|
||||||
const latest = store.getState();
|
const latest = store.getState();
|
||||||
|
|||||||
@@ -40,11 +40,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cwd-comments-header {
|
.cwd-comments-header {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 16px 0;
|
padding: 16px 0;
|
||||||
margin-bottom: 16px;
|
|
||||||
border-bottom: 1px solid var(--cwd-border);
|
border-bottom: 1px solid var(--cwd-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,17 +513,23 @@
|
|||||||
.cwd-like {
|
.cwd-like {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: center;
|
||||||
|
padding: 30px 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cwd-like-button {
|
.cwd-like-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid var(--cwd-border-light, #eaeef2);
|
border: 1px solid var(--cwd-border-light, #eaeef2);
|
||||||
|
border: none;
|
||||||
background: var(--cwd-bg-secondary, #f6f8fa);
|
background: var(--cwd-bg-secondary, #f6f8fa);
|
||||||
|
background: none;
|
||||||
color: var(--cwd-text-secondary, #6e7781);
|
color: var(--cwd-text-secondary, #6e7781);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -536,8 +538,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cwd-like-button[data-liked='true'] {
|
.cwd-like-button[data-liked='true'] {
|
||||||
background: rgba(9, 105, 218, 0.08);
|
/* background: rgba(9, 105, 218, 0.08); */
|
||||||
border-color: var(--cwd-primary, #0969da);
|
/* border-color: var(--cwd-primary, #0969da); */
|
||||||
color: var(--cwd-primary, #0969da);
|
color: var(--cwd-primary, #0969da);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,14 +556,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cwd-like-icon {
|
.cwd-like-icon {
|
||||||
width: 18px;
|
width: 32px;
|
||||||
height: 18px;
|
height: 32px;
|
||||||
fill: currentColor;
|
fill: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cwd-like-count {
|
.cwd-like-count {
|
||||||
min-width: 1.5em;
|
text-align: center;
|
||||||
text-align: right;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cwd-like-animate .cwd-like-icon {
|
.cwd-like-animate .cwd-like-icon {
|
||||||
@@ -589,18 +591,18 @@
|
|||||||
.cwd-comment-like {
|
.cwd-comment-like {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cwd-comment-like-button {
|
.cwd-comment-like-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
/* gap: 4px; */
|
||||||
padding: 2px 8px;
|
/* padding: 2px 8px; */
|
||||||
border-radius: 999px;
|
/* border-radius: 999px; */
|
||||||
border: 1px solid var(--cwd-border-light, #eaeef2);
|
border: 1px solid var(--cwd-border-light, #eaeef2);
|
||||||
|
border: 1px solid transparent;
|
||||||
background: var(--cwd-bg-secondary, #f6f8fa);
|
background: var(--cwd-bg-secondary, #f6f8fa);
|
||||||
|
background: none;
|
||||||
color: var(--cwd-text-secondary, #6e7781);
|
color: var(--cwd-text-secondary, #6e7781);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -609,8 +611,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cwd-comment-like-button:hover {
|
.cwd-comment-like-button:hover {
|
||||||
background: rgba(9, 105, 218, 0.08);
|
/* background: rgba(9, 105, 218, 0.08);
|
||||||
border-color: var(--cwd-primary, #0969da);
|
border-color: var(--cwd-primary, #0969da); */
|
||||||
color: var(--cwd-primary, #0969da);
|
color: var(--cwd-primary, #0969da);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,7 +629,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cwd-comment-like-count {
|
.cwd-comment-like-count {
|
||||||
min-width: 1.5em;
|
/* min-width: 1.5em; */
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-left: 5px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user