feat: 实现多站点数据隔离和管理功能
- 新增站点管理功能,替换原有的域名管理 - 添加数据库迁移工具,为现有表增加 site_id 字段 - 创建 useSite 组合式 API 统一管理站点状态 - 修改前后端 API 以支持站点筛选参数 - 更新管理界面,将域名筛选改为站点筛选 - 优化数据导出功能,支持按站点筛选导出 - 更新文档,调整站点 ID 默认值为空字符串 - 升级 wrangler 依赖至最新版本
This commit is contained in:
@@ -23,6 +23,7 @@ export type CommentItem = {
|
|||||||
likes?: number;
|
likes?: number;
|
||||||
ua?: string | null;
|
ua?: string | null;
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
|
siteId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CommentListResponse = {
|
export type CommentListResponse = {
|
||||||
@@ -115,8 +116,8 @@ export type VisitPagesResponse = {
|
|||||||
itemsByLatest?: VisitPageItem[];
|
itemsByLatest?: VisitPageItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DomainListResponse = {
|
export type SiteListResponse = {
|
||||||
domains: string[];
|
sites: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LikeStatsItem = {
|
export type LikeStatsItem = {
|
||||||
@@ -153,11 +154,11 @@ export function logoutAdmin(): void {
|
|||||||
localStorage.removeItem('cwd_admin_token');
|
localStorage.removeItem('cwd_admin_token');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchComments(page: number, domain?: string): Promise<CommentListResponse> {
|
export function fetchComments(page: number, siteId?: string): Promise<CommentListResponse> {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
searchParams.set('page', String(page));
|
searchParams.set('page', String(page));
|
||||||
if (domain) {
|
if (siteId && siteId !== 'default') {
|
||||||
searchParams.set('domain', domain);
|
searchParams.set('siteId', siteId);
|
||||||
}
|
}
|
||||||
return get<CommentListResponse>(`/admin/comments/list?${searchParams.toString()}`);
|
return get<CommentListResponse>(`/admin/comments/list?${searchParams.toString()}`);
|
||||||
}
|
}
|
||||||
@@ -262,8 +263,13 @@ export function blockEmail(email: string): Promise<{ message: string }> {
|
|||||||
return post<{ message: string }>('/admin/comments/block-email', { email });
|
return post<{ message: string }>('/admin/comments/block-email', { email });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportComments(): Promise<any[]> {
|
export function exportComments(siteId?: string): Promise<any[]> {
|
||||||
return get<any[]>('/admin/comments/export');
|
const searchParams = new URLSearchParams();
|
||||||
|
if (siteId && siteId !== 'default') {
|
||||||
|
searchParams.set('siteId', siteId);
|
||||||
|
}
|
||||||
|
const query = searchParams.toString();
|
||||||
|
return get<any[]>(query ? `/admin/comments/export?${query}` : '/admin/comments/export');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function importComments(data: any[]): Promise<{ message: string }> {
|
export function importComments(data: any[]): Promise<{ message: string }> {
|
||||||
@@ -278,8 +284,13 @@ export function importConfig(data: any[]): Promise<{ message: string }> {
|
|||||||
return post<{ message: string }>('/admin/import/config', data);
|
return post<{ message: string }>('/admin/import/config', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportStats(): Promise<any> {
|
export function exportStats(siteId?: string): Promise<any> {
|
||||||
return get<any>('/admin/export/stats');
|
const searchParams = new URLSearchParams();
|
||||||
|
if (siteId && siteId !== 'default') {
|
||||||
|
searchParams.set('siteId', siteId);
|
||||||
|
}
|
||||||
|
const query = searchParams.toString();
|
||||||
|
return get<any>(query ? `/admin/export/stats?${query}` : '/admin/export/stats');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function importStats(data: any): Promise<{ message: string }> {
|
export function importStats(data: any): Promise<{ message: string }> {
|
||||||
@@ -294,30 +305,30 @@ export function importBackup(data: any): Promise<{ message: string }> {
|
|||||||
return post<{ message: string }>('/admin/import/backup', data);
|
return post<{ message: string }>('/admin/import/backup', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchCommentStats(domain?: string): Promise<CommentStatsResponse> {
|
export function fetchCommentStats(siteId?: string): Promise<CommentStatsResponse> {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (domain) {
|
if (siteId && siteId !== 'default') {
|
||||||
searchParams.set('domain', domain);
|
searchParams.set('siteId', siteId);
|
||||||
}
|
}
|
||||||
const query = searchParams.toString();
|
const query = searchParams.toString();
|
||||||
const url = query ? `/admin/stats/comments?${query}` : '/admin/stats/comments';
|
const url = query ? `/admin/stats/comments?${query}` : '/admin/stats/comments';
|
||||||
return get<CommentStatsResponse>(url);
|
return get<CommentStatsResponse>(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchVisitOverview(domain?: string): Promise<VisitOverviewResponse> {
|
export function fetchVisitOverview(siteId?: string): Promise<VisitOverviewResponse> {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (domain) {
|
if (siteId && siteId !== 'default') {
|
||||||
searchParams.set('domain', domain);
|
searchParams.set('siteId', siteId);
|
||||||
}
|
}
|
||||||
const query = searchParams.toString();
|
const query = searchParams.toString();
|
||||||
const url = query ? `/admin/analytics/overview?${query}` : '/admin/analytics/overview';
|
const url = query ? `/admin/analytics/overview?${query}` : '/admin/analytics/overview';
|
||||||
return get<VisitOverviewResponse>(url);
|
return get<VisitOverviewResponse>(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchVisitPages(domain?: string, order?: 'pv' | 'latest'): Promise<VisitPagesResponse> {
|
export function fetchVisitPages(siteId?: string, order?: 'pv' | 'latest'): Promise<VisitPagesResponse> {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (domain) {
|
if (siteId && siteId !== 'default') {
|
||||||
searchParams.set('domain', domain);
|
searchParams.set('siteId', siteId);
|
||||||
}
|
}
|
||||||
if (order) {
|
if (order) {
|
||||||
searchParams.set('order', order);
|
searchParams.set('order', order);
|
||||||
@@ -327,8 +338,8 @@ export function fetchVisitPages(domain?: string, order?: 'pv' | 'latest'): Promi
|
|||||||
return get<VisitPagesResponse>(url);
|
return get<VisitPagesResponse>(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchDomainList(): Promise<DomainListResponse> {
|
export function fetchSiteList(): Promise<SiteListResponse> {
|
||||||
return get<DomainListResponse>('/admin/stats/domains');
|
return get<SiteListResponse>('/admin/stats/sites');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchLikeStats(): Promise<LikeStatsResponse> {
|
export function fetchLikeStats(): Promise<LikeStatsResponse> {
|
||||||
|
|||||||
17
cwd-admin/src/composables/useSite.ts
Normal file
17
cwd-admin/src/composables/useSite.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const currentSiteId = ref(localStorage.getItem('cwd_admin_site_id') || 'default');
|
||||||
|
|
||||||
|
watch(currentSiteId, (val) => {
|
||||||
|
if (val) {
|
||||||
|
localStorage.setItem('cwd_admin_site_id', val);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('cwd_admin_site_id');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useSite() {
|
||||||
|
return {
|
||||||
|
currentSiteId
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -224,6 +224,7 @@ import {
|
|||||||
fetchLikeStats,
|
fetchLikeStats,
|
||||||
type LikeStatsItem,
|
type LikeStatsItem,
|
||||||
} from "../../api/admin";
|
} from "../../api/admin";
|
||||||
|
import { useSite } from "../../composables/useSite";
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const listLoading = ref(false);
|
const listLoading = ref(false);
|
||||||
@@ -240,6 +241,8 @@ const overview = ref<VisitOverviewResponse>({
|
|||||||
last30Days: [],
|
last30Days: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { currentSiteId } = useSite();
|
||||||
|
|
||||||
function calculateChange(current: number, previous: number) {
|
function calculateChange(current: number, previous: number) {
|
||||||
if (previous === 0) {
|
if (previous === 0) {
|
||||||
return current > 0 ? 100 : 0;
|
return current > 0 ? 100 : 0;
|
||||||
@@ -271,8 +274,6 @@ const visitTab = ref<"pv" | "latest">("pv");
|
|||||||
const visitTabStorageKey = "cwd-analytics-visit-tab";
|
const visitTabStorageKey = "cwd-analytics-visit-tab";
|
||||||
const chartRangeStorageKey = "cwd-analytics-visit-chart-range";
|
const chartRangeStorageKey = "cwd-analytics-visit-chart-range";
|
||||||
|
|
||||||
const injectedDomainFilter = inject<Ref<string> | null>("domainFilter", null);
|
|
||||||
const domainFilter = injectedDomainFilter ?? ref("");
|
|
||||||
const last30Days = ref<{ date: string; total: number }[]>([]);
|
const last30Days = ref<{ date: string; total: number }[]>([]);
|
||||||
const likeStatsItems = ref<LikeStatsItem[]>([]);
|
const likeStatsItems = ref<LikeStatsItem[]>([]);
|
||||||
const chartRange = ref<"7" | "30">("7");
|
const chartRange = ref<"7" | "30">("7");
|
||||||
@@ -424,7 +425,7 @@ async function loadData() {
|
|||||||
listLoading.value = true;
|
listLoading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
try {
|
try {
|
||||||
const domain = domainFilter.value || undefined;
|
const domain = currentSiteId.value;
|
||||||
const order = getVisitOrderParam();
|
const order = getVisitOrderParam();
|
||||||
const [overviewRes, pagesRes, likeStatsRes] = await Promise.all([
|
const [overviewRes, pagesRes, likeStatsRes] = await Promise.all([
|
||||||
fetchVisitOverview(domain),
|
fetchVisitOverview(domain),
|
||||||
@@ -589,7 +590,7 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(domainFilter, () => {
|
watch(currentSiteId, () => {
|
||||||
loadData();
|
loadData();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -240,9 +240,9 @@ import {
|
|||||||
updateComment,
|
updateComment,
|
||||||
blockIp,
|
blockIp,
|
||||||
blockEmail,
|
blockEmail,
|
||||||
fetchDomainList,
|
|
||||||
} from "../../api/admin";
|
} from "../../api/admin";
|
||||||
import ModalEdit from "./components/ModalEdit.vue";
|
import ModalEdit from "./components/ModalEdit.vue";
|
||||||
|
import { useSite } from "../../composables/useSite";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -252,8 +252,7 @@ const pagination = ref<{ page: number; total: number }>({ page: 1, total: 1 });
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
const statusFilter = ref("");
|
const statusFilter = ref("");
|
||||||
const injectedDomainFilter = inject<Ref<string> | null>("domainFilter", null);
|
const { currentSiteId } = useSite();
|
||||||
const domainFilter = injectedDomainFilter ?? ref("");
|
|
||||||
const jumpPageInput = ref("");
|
const jumpPageInput = ref("");
|
||||||
const editVisible = ref(false);
|
const editVisible = ref(false);
|
||||||
const editSaving = ref(false);
|
const editSaving = ref(false);
|
||||||
@@ -325,7 +324,7 @@ async function loadComments(page?: number) {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
try {
|
try {
|
||||||
const res = await fetchComments(targetPage, domainFilter.value || undefined);
|
const res = await fetchComments(targetPage, currentSiteId.value);
|
||||||
comments.value = res.data;
|
comments.value = res.data;
|
||||||
pagination.value = { page: res.pagination.page, total: res.pagination.total };
|
pagination.value = { page: res.pagination.page, total: res.pagination.total };
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -342,11 +341,9 @@ function updateRoutePage(page: number) {
|
|||||||
} else {
|
} else {
|
||||||
query.p = String(page);
|
query.p = String(page);
|
||||||
}
|
}
|
||||||
if (domainFilter.value) {
|
// Removed domain from query as it's now global state
|
||||||
query.domain = domainFilter.value;
|
delete query.domain;
|
||||||
} else {
|
|
||||||
delete query.domain;
|
|
||||||
}
|
|
||||||
router.push({ query });
|
router.push({ query });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,15 +527,11 @@ onMounted(() => {
|
|||||||
initialPage = Math.floor(value);
|
initialPage = Math.floor(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const d = route.query.domain;
|
|
||||||
if (typeof d === "string" && d.trim()) {
|
|
||||||
domainFilter.value = d.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
loadComments(initialPage);
|
loadComments(initialPage);
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(domainFilter, () => {
|
watch(currentSiteId, () => {
|
||||||
updateRoutePage(1);
|
updateRoutePage(1);
|
||||||
loadComments(1);
|
loadComments(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ import {
|
|||||||
exportStats, importStats,
|
exportStats, importStats,
|
||||||
exportBackup, importBackup
|
exportBackup, importBackup
|
||||||
} from "../../api/admin";
|
} from "../../api/admin";
|
||||||
|
import { useSite } from "../../composables/useSite";
|
||||||
|
|
||||||
const exporting = ref(false);
|
const exporting = ref(false);
|
||||||
const importing = ref(false);
|
const importing = ref(false);
|
||||||
@@ -130,6 +131,7 @@ const toastMessage = ref("");
|
|||||||
const toastType = ref<"success" | "error">("success");
|
const toastType = ref<"success" | "error">("success");
|
||||||
const toastVisible = ref(false);
|
const toastVisible = ref(false);
|
||||||
const importLogs = ref<string[]>([]);
|
const importLogs = ref<string[]>([]);
|
||||||
|
const { currentSiteId } = useSite();
|
||||||
|
|
||||||
// 当前导入模式: comments | config | stats | backup
|
// 当前导入模式: comments | config | stats | backup
|
||||||
const currentImportMode = ref<string>('comments');
|
const currentImportMode = ref<string>('comments');
|
||||||
@@ -178,9 +180,9 @@ async function executeExport(apiFunc: () => Promise<any>, fileNamePrefix: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 导出处理
|
// 导出处理
|
||||||
const handleExportComments = () => executeExport(exportComments, 'comments-export');
|
const handleExportComments = () => executeExport(() => exportComments(currentSiteId.value), 'comments-export');
|
||||||
const handleExportConfig = () => executeExport(exportConfig, 'cwd-config');
|
const handleExportConfig = () => executeExport(exportConfig, 'cwd-config');
|
||||||
const handleExportStats = () => executeExport(exportStats, 'cwd-stats');
|
const handleExportStats = () => executeExport(() => exportStats(currentSiteId.value), 'cwd-stats');
|
||||||
const handleExportBackup = () => executeExport(exportBackup, 'cwd-full-backup');
|
const handleExportBackup = () => executeExport(exportBackup, 'cwd-full-backup');
|
||||||
|
|
||||||
// 触发文件选择
|
// 触发文件选择
|
||||||
|
|||||||
@@ -12,10 +12,10 @@
|
|||||||
<div class="layout-title">{{ layoutTitle }}</div>
|
<div class="layout-title">{{ layoutTitle }}</div>
|
||||||
<div class="layout-actions-wrapper">
|
<div class="layout-actions-wrapper">
|
||||||
<div class="layout-domain-filter layout-domain-filter-header">
|
<div class="layout-domain-filter layout-domain-filter-header">
|
||||||
<select v-model="domainFilter" class="layout-domain-select">
|
<select v-model="currentSiteId" class="layout-domain-select">
|
||||||
<option value="">全部域名</option>
|
<option value="default">所有站点</option>
|
||||||
<option v-for="item in domainOptions" :key="item" :value="item">
|
<option v-for="item in siteOptions" :key="item.value" :value="item.value">
|
||||||
{{ item }}
|
{{ item.label }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,10 +70,10 @@
|
|||||||
:class="{ 'layout-sider-mobile-open': isMobileSiderOpen }"
|
:class="{ 'layout-sider-mobile-open': isMobileSiderOpen }"
|
||||||
>
|
>
|
||||||
<div class="layout-sider-domain-filter">
|
<div class="layout-sider-domain-filter">
|
||||||
<select v-model="domainFilter" class="layout-domain-select">
|
<select v-model="currentSiteId" class="layout-domain-select">
|
||||||
<option value="">全部域名</option>
|
<option value="default">所有站点</option>
|
||||||
<option v-for="item in domainOptions" :key="item" :value="item">
|
<option v-for="item in siteOptions" :key="item.value" :value="item.value">
|
||||||
{{ item }}
|
{{ item.label }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,19 +171,20 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch, provide, computed } from "vue";
|
import { ref, onMounted, provide, computed } from "vue";
|
||||||
import { useRouter, useRoute } from "vue-router";
|
import { useRouter, useRoute } from "vue-router";
|
||||||
import { logoutAdmin, fetchDomainList, fetchAdminDisplaySettings, fetchFeatureSettings } from "../../api/admin";
|
import { logoutAdmin, fetchSiteList, fetchAdminDisplaySettings } from "../../api/admin";
|
||||||
import { useTheme } from "../../composables/useTheme";
|
import { useTheme } from "../../composables/useTheme";
|
||||||
|
import { useSite } from "../../composables/useSite";
|
||||||
import packageJson from "../../../package.json";
|
import packageJson from "../../../package.json";
|
||||||
|
|
||||||
const DOMAIN_STORAGE_KEY = "cwd_admin_domain_filter";
|
|
||||||
const API_BASE_URL_KEY = "cwd_admin_api_base_url";
|
const API_BASE_URL_KEY = "cwd_admin_api_base_url";
|
||||||
const SITE_TITLE_KEY = "cwd_admin_site_title";
|
const SITE_TITLE_KEY = "cwd_admin_site_title";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
const { currentSiteId } = useSite();
|
||||||
|
|
||||||
const isMobileSiderOpen = ref(false);
|
const isMobileSiderOpen = ref(false);
|
||||||
const isActionsOpen = ref(false);
|
const isActionsOpen = ref(false);
|
||||||
@@ -206,34 +207,20 @@ function cycleTheme() {
|
|||||||
else setTheme("system");
|
else setTheme("system");
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedDomain =
|
type SiteOption = { label: string; value: string };
|
||||||
typeof window !== "undefined"
|
const siteOptions = ref<SiteOption[]>([]);
|
||||||
? window.localStorage.getItem(DOMAIN_STORAGE_KEY) || ""
|
|
||||||
: "";
|
|
||||||
const domainFilter = ref(storedDomain);
|
|
||||||
const domainOptions = ref<string[]>([]);
|
|
||||||
|
|
||||||
async function loadDomains() {
|
async function loadSites() {
|
||||||
try {
|
try {
|
||||||
const [domainRes, settingsRes] = await Promise.all([
|
const res = await fetchSiteList();
|
||||||
fetchDomainList(),
|
const sites = Array.isArray(res.sites) ? res.sites : [];
|
||||||
fetchFeatureSettings().catch(() => ({ visibleDomains: undefined }))
|
|
||||||
]);
|
|
||||||
|
|
||||||
let domains = Array.isArray(domainRes.domains) ? domainRes.domains : [];
|
siteOptions.value = sites.map(s => ({
|
||||||
|
label: s === '' ? '默认站点 (Default)' : s,
|
||||||
// 如果配置了显示域名,则仅显示配置的域名
|
value: s
|
||||||
if (settingsRes.visibleDomains && Array.isArray(settingsRes.visibleDomains) && settingsRes.visibleDomains.length > 0) {
|
}));
|
||||||
domains = settingsRes.visibleDomains;
|
|
||||||
}
|
|
||||||
|
|
||||||
const set = new Set(domains);
|
|
||||||
if (domainFilter.value && !set.has(domainFilter.value)) {
|
|
||||||
set.add(domainFilter.value);
|
|
||||||
}
|
|
||||||
domainOptions.value = Array.from(set);
|
|
||||||
} catch {
|
} catch {
|
||||||
domainOptions.value = [];
|
siteOptions.value = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,21 +274,14 @@ async function loadDisplaySettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
provide("domainFilter", domainFilter);
|
|
||||||
provide("updateSiteTitle", updateTitle);
|
provide("updateSiteTitle", updateTitle);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadDomains();
|
loadSites();
|
||||||
loadVersion();
|
loadVersion();
|
||||||
loadDisplaySettings();
|
loadDisplaySettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(domainFilter, (value) => {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
window.localStorage.setItem(DOMAIN_STORAGE_KEY, value || "");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function isRouteActive(name: string) {
|
function isRouteActive(name: string) {
|
||||||
return route.name === name;
|
return route.name === name;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="domain-settings">
|
<div class="domain-settings">
|
||||||
<div class="domain-settings-desc">
|
<div class="domain-settings-desc">
|
||||||
配置后台可见的域名。左侧为后台下拉框中显示的域名,右侧为数据库中发现的所有域名。支持拖拽或点击按钮移动。
|
配置后台可见的站点。左侧为后台下拉框中显示的站点,右侧为数据库中发现的所有站点。支持拖拽或点击按钮移动。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="domain-transfer">
|
<div class="domain-transfer">
|
||||||
<!-- Visible Domains (Left) -->
|
<!-- Visible Domains (Left) -->
|
||||||
<div class="transfer-panel">
|
<div class="transfer-panel">
|
||||||
<div class="transfer-header">
|
<div class="transfer-header">
|
||||||
后台显示域名 ({{ visibleList.length }})
|
后台显示站点 ({{ visibleList.length }})
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="transfer-body"
|
class="transfer-body"
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
v-if="visibleList.length === 0"
|
v-if="visibleList.length === 0"
|
||||||
class="transfer-empty"
|
class="transfer-empty"
|
||||||
>
|
>
|
||||||
无域名 (默认显示全部)
|
无站点 (默认显示全部)
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="domain in visibleList"
|
v-for="domain in visibleList"
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
<!-- Hidden/All Domains (Right) -->
|
<!-- Hidden/All Domains (Right) -->
|
||||||
<div class="transfer-panel">
|
<div class="transfer-panel">
|
||||||
<div class="transfer-header">
|
<div class="transfer-header">
|
||||||
其他域名 ({{ hiddenList.length }})
|
其他站点 ({{ hiddenList.length }})
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="transfer-body"
|
class="transfer-body"
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
v-if="hiddenList.length === 0"
|
v-if="hiddenList.length === 0"
|
||||||
class="transfer-empty"
|
class="transfer-empty"
|
||||||
>
|
>
|
||||||
无更多域名
|
无更多站点
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="domain in hiddenList"
|
v-for="domain in hiddenList"
|
||||||
@@ -338,4 +338,4 @@ onMounted(() => {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -38,10 +38,10 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="settings-tab"
|
class="settings-tab"
|
||||||
:class="{ 'settings-tab-active': activeTab === 'domain' }"
|
:class="{ 'settings-tab-active': activeTab === 'site' }"
|
||||||
@click="activeTab = 'domain'"
|
@click="activeTab = 'site'"
|
||||||
>
|
>
|
||||||
域名管理
|
站点管理
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -238,13 +238,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="activeTab === 'domain'">
|
<template v-else-if="activeTab === 'site'">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-title">域名选择管理</div>
|
<div class="card-title">站点列表管理</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<DomainSettings />
|
<SiteManager />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -497,7 +497,7 @@ import {
|
|||||||
sendTelegramTestMessage,
|
sendTelegramTestMessage,
|
||||||
} from "../../api/admin";
|
} from "../../api/admin";
|
||||||
|
|
||||||
import DomainSettings from "./components/DomainSettings.vue";
|
import SiteManager from "./components/SiteManager.vue";
|
||||||
import TagInput from "../../components/TagInput.vue";
|
import TagInput from "../../components/TagInput.vue";
|
||||||
|
|
||||||
const DEFAULT_REPLY_TEMPLATE = `<div style="background-color:#f4f4f5;padding:24px 0;">
|
const DEFAULT_REPLY_TEMPLATE = `<div style="background-color:#f4f4f5;padding:24px 0;">
|
||||||
@@ -607,14 +607,14 @@ type TabKey =
|
|||||||
| "display"
|
| "display"
|
||||||
| "emailNotify"
|
| "emailNotify"
|
||||||
| "telegramNotify"
|
| "telegramNotify"
|
||||||
| "domain";
|
| "site";
|
||||||
const validTabs: TabKey[] = [
|
const validTabs: TabKey[] = [
|
||||||
"comment",
|
"comment",
|
||||||
"feature",
|
"feature",
|
||||||
"display",
|
"display",
|
||||||
"emailNotify",
|
"emailNotify",
|
||||||
"telegramNotify",
|
"telegramNotify",
|
||||||
"domain",
|
"site",
|
||||||
];
|
];
|
||||||
|
|
||||||
const activeTab = ref<TabKey>(
|
const activeTab = ref<TabKey>(
|
||||||
@@ -661,11 +661,12 @@ function loadCardsExpanded() {
|
|||||||
feature: parsed.feature ?? false,
|
feature: parsed.feature ?? false,
|
||||||
email: parsed.email ?? false,
|
email: parsed.email ?? false,
|
||||||
telegram: parsed.telegram ?? false,
|
telegram: parsed.telegram ?? false,
|
||||||
|
site: parsed.site ?? false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
return { comment: true, display: false, feature: false, email: false, telegram: false };
|
return { comment: true, display: false, feature: false, email: false, telegram: false, site: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardsExpanded = ref(loadCardsExpanded());
|
const cardsExpanded = ref(loadCardsExpanded());
|
||||||
@@ -822,7 +823,7 @@ async function testEmail() {
|
|||||||
message.value = "请先在上方“评论显示配置”中设置管理员邮箱";
|
message.value = "请先在上方“评论显示配置”中设置管理员邮箱";
|
||||||
messageType.value = "error";
|
messageType.value = "error";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!smtpUser.value || !smtpPass.value) {
|
if (!smtpUser.value || !smtpPass.value) {
|
||||||
message.value = "请先填写 SMTP 账号和密码";
|
message.value = "请先填写 SMTP 账号和密码";
|
||||||
messageType.value = "error";
|
messageType.value = "error";
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ import { onMounted, onBeforeUnmount, ref, nextTick, watch, inject } from "vue";
|
|||||||
import type { Ref } from "vue";
|
import type { Ref } from "vue";
|
||||||
import * as echarts from "echarts";
|
import * as echarts from "echarts";
|
||||||
import { fetchCommentStats } from "../../api/admin";
|
import { fetchCommentStats } from "../../api/admin";
|
||||||
|
import { useSite } from "../../composables/useSite";
|
||||||
|
|
||||||
type DomainStat = {
|
type DomainStat = {
|
||||||
domain: string;
|
domain: string;
|
||||||
@@ -137,8 +138,7 @@ const last7Days = ref<{ date: string; total: number }[]>([]);
|
|||||||
const chartRange = ref<"7" | "30">("7");
|
const chartRange = ref<"7" | "30">("7");
|
||||||
const chartRangeStorageKey = "cwd-stats-chart-range";
|
const chartRangeStorageKey = "cwd-stats-chart-range";
|
||||||
|
|
||||||
const injectedDomainFilter = inject<Ref<string> | null>("domainFilter", null);
|
const { currentSiteId } = useSite();
|
||||||
const domainFilter = injectedDomainFilter ?? ref("");
|
|
||||||
|
|
||||||
const toastMessage = ref("");
|
const toastMessage = ref("");
|
||||||
const toastType = ref<"success" | "error">("success");
|
const toastType = ref<"success" | "error">("success");
|
||||||
@@ -185,7 +185,7 @@ async function loadStats() {
|
|||||||
statsLoading.value = true;
|
statsLoading.value = true;
|
||||||
statsError.value = "";
|
statsError.value = "";
|
||||||
try {
|
try {
|
||||||
const res = await fetchCommentStats(domainFilter.value || undefined);
|
const res = await fetchCommentStats(currentSiteId.value);
|
||||||
statsSummary.value = {
|
statsSummary.value = {
|
||||||
total: res.summary.total,
|
total: res.summary.total,
|
||||||
approved: res.summary.approved,
|
approved: res.summary.approved,
|
||||||
@@ -330,7 +330,7 @@ onMounted(() => {
|
|||||||
window.addEventListener("resize", handleResize);
|
window.addEventListener("resize", handleResize);
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(domainFilter, () => {
|
watch(currentSiteId, () => {
|
||||||
loadStats();
|
loadStats();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"@types/ua-parser-js": "^0.7.39",
|
"@types/ua-parser-js": "^0.7.39",
|
||||||
"typescript": "^5.5.2",
|
"typescript": "^5.5.2",
|
||||||
"vitest": "~3.2.0",
|
"vitest": "~3.2.0",
|
||||||
"wrangler": "^4.58.0"
|
"wrangler": "^4.63.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"hono": "^4.11.3",
|
"hono": "^4.11.3",
|
||||||
|
|||||||
@@ -3,9 +3,18 @@ import { Bindings } from '../../bindings';
|
|||||||
|
|
||||||
export const exportComments = async (c: Context<{ Bindings: Bindings }>) => {
|
export const exportComments = async (c: Context<{ Bindings: Bindings }>) => {
|
||||||
try {
|
try {
|
||||||
const { results } = await c.env.CWD_DB.prepare(
|
const siteId = c.req.query('siteId');
|
||||||
'SELECT * FROM Comment ORDER BY priority DESC, created DESC'
|
let query = 'SELECT * FROM Comment';
|
||||||
).all();
|
const params: any[] = [];
|
||||||
|
|
||||||
|
if (siteId) {
|
||||||
|
query += ' WHERE site_id = ?';
|
||||||
|
params.push(siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY priority DESC, created DESC';
|
||||||
|
|
||||||
|
const { results } = await c.env.CWD_DB.prepare(query).bind(...params).all();
|
||||||
|
|
||||||
return c.json(results);
|
return c.json(results);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -1,26 +1,24 @@
|
|||||||
import { Context } from 'hono';
|
import { Context } from 'hono';
|
||||||
import { Bindings } from '../../bindings';
|
import { Bindings } from '../../bindings';
|
||||||
|
|
||||||
async function ensureStatsTables(env: Bindings) {
|
export const getStatsData = async (env: Bindings, siteId?: string) => {
|
||||||
await env.CWD_DB.prepare(
|
// Tables are ensured by dbMigration on startup
|
||||||
'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();
|
|
||||||
|
|
||||||
await env.CWD_DB.prepare(
|
let statsQuery = 'SELECT * FROM page_stats';
|
||||||
'CREATE TABLE IF NOT EXISTS page_visit_daily (id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL, domain TEXT, count INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)'
|
let dailyQuery = 'SELECT * FROM page_visit_daily';
|
||||||
).run();
|
let likesQuery = 'SELECT * FROM Likes';
|
||||||
|
const params: any[] = [];
|
||||||
|
|
||||||
await env.CWD_DB.prepare(
|
if (siteId) {
|
||||||
'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))'
|
statsQuery += ' WHERE site_id = ?';
|
||||||
).run();
|
dailyQuery += ' WHERE site_id = ?';
|
||||||
}
|
likesQuery += ' WHERE site_id = ?';
|
||||||
|
params.push(siteId);
|
||||||
|
}
|
||||||
|
|
||||||
export const getStatsData = async (env: Bindings) => {
|
const { results: pageStats } = await env.CWD_DB.prepare(statsQuery).bind(...params).all();
|
||||||
await ensureStatsTables(env);
|
const { results: dailyVisits } = await env.CWD_DB.prepare(dailyQuery).bind(...params).all();
|
||||||
|
const { results: likes } = await env.CWD_DB.prepare(likesQuery).bind(...params).all();
|
||||||
const { results: pageStats } = await env.CWD_DB.prepare('SELECT * FROM page_stats').all();
|
|
||||||
const { results: dailyVisits } = await env.CWD_DB.prepare('SELECT * FROM page_visit_daily').all();
|
|
||||||
const { results: likes } = await env.CWD_DB.prepare('SELECT * FROM Likes').all();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
page_stats: pageStats,
|
page_stats: pageStats,
|
||||||
@@ -31,7 +29,8 @@ export const getStatsData = async (env: Bindings) => {
|
|||||||
|
|
||||||
export const exportStats = async (c: Context<{ Bindings: Bindings }>) => {
|
export const exportStats = async (c: Context<{ Bindings: Bindings }>) => {
|
||||||
try {
|
try {
|
||||||
const data = await getStatsData(c.env);
|
const siteId = c.req.query('siteId');
|
||||||
|
const data = await getStatsData(c.env, siteId);
|
||||||
return c.json(data);
|
return c.json(data);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return c.json({ message: e.message || '导出统计数据失败' }, 500);
|
return c.json({ message: e.message || '导出统计数据失败' }, 500);
|
||||||
|
|||||||
@@ -1,74 +1,53 @@
|
|||||||
import type { Context } from 'hono';
|
import type { Context } from 'hono';
|
||||||
import type { Bindings } from '../../bindings';
|
import type { Bindings } from '../../bindings';
|
||||||
|
|
||||||
function extractDomain(source: string | null | undefined): string | null {
|
export const getSites = async (c: Context<{ Bindings: Bindings }>) => {
|
||||||
if (!source) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const value = source.trim();
|
|
||||||
if (!value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!/^https?:\/\//i.test(value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(value);
|
const sites = new Set<string>();
|
||||||
return url.hostname.toLowerCase();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getDomains = async (c: Context<{ Bindings: Bindings }>) => {
|
|
||||||
try {
|
|
||||||
const domains = new Set<string>();
|
|
||||||
|
|
||||||
|
// 1. Get sites from Comment
|
||||||
const { results: commentRows } = await c.env.CWD_DB.prepare(
|
const { results: commentRows } = await c.env.CWD_DB.prepare(
|
||||||
'SELECT post_slug, post_url FROM Comment'
|
'SELECT DISTINCT site_id FROM Comment'
|
||||||
).all<{
|
).all<{ site_id: string }>();
|
||||||
post_slug: string;
|
|
||||||
post_url: string | null;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
for (const row of commentRows) {
|
for (const row of commentRows) {
|
||||||
const domain =
|
if (row.site_id !== undefined && row.site_id !== null) {
|
||||||
extractDomain(row.post_url) || extractDomain(row.post_slug);
|
sites.add(row.site_id);
|
||||||
if (domain) {
|
|
||||||
domains.add(domain);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await c.env.CWD_DB.prepare(
|
// 2. Get sites from page_stats
|
||||||
'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: pageRows } = await c.env.CWD_DB.prepare(
|
const { results: pageRows } = await c.env.CWD_DB.prepare(
|
||||||
'SELECT post_slug, post_url FROM page_stats'
|
'SELECT DISTINCT site_id FROM page_stats'
|
||||||
).all<{
|
).all<{ site_id: string }>();
|
||||||
post_slug: string;
|
|
||||||
post_url: string | null;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
for (const row of pageRows) {
|
for (const row of pageRows) {
|
||||||
const domain =
|
if (row.site_id !== undefined && row.site_id !== null) {
|
||||||
extractDomain(row.post_url) || extractDomain(row.post_slug);
|
sites.add(row.site_id);
|
||||||
if (domain) {
|
|
||||||
domains.add(domain);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = Array.from(domains);
|
// 3. Get sites from page_visit_daily
|
||||||
|
const { results: dailyRows } = await c.env.CWD_DB.prepare(
|
||||||
|
'SELECT DISTINCT site_id FROM page_visit_daily'
|
||||||
|
).all<{ site_id: string }>();
|
||||||
|
|
||||||
|
for (const row of dailyRows) {
|
||||||
|
if (row.site_id !== undefined && row.site_id !== null) {
|
||||||
|
sites.add(row.site_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = Array.from(sites);
|
||||||
list.sort();
|
list.sort();
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
domains: list
|
sites: list
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{ message: e.message || '获取域名列表失败' },
|
{ message: e.message || '获取站点列表失败' },
|
||||||
500
|
500
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -33,26 +33,25 @@ function extractDomain(source: string | null | undefined): string | null {
|
|||||||
|
|
||||||
export const getStats = async (c: Context<{ Bindings: Bindings }>) => {
|
export const getStats = async (c: Context<{ Bindings: Bindings }>) => {
|
||||||
try {
|
try {
|
||||||
const rawDomain = c.req.query('domain') || '';
|
const rawSiteId = c.req.query('siteId');
|
||||||
const domainFilter = rawDomain.trim().toLowerCase();
|
const siteId = rawSiteId && rawSiteId !== 'default' ? rawSiteId : null;
|
||||||
|
|
||||||
const { results } = await c.env.CWD_DB.prepare(
|
let sql = 'SELECT created, post_slug, post_url, status FROM Comment';
|
||||||
'SELECT created, post_slug, post_url, status FROM Comment'
|
const params: any[] = [];
|
||||||
).all<{
|
|
||||||
|
if (siteId) {
|
||||||
|
sql += ' WHERE site_id = ?';
|
||||||
|
params.push(siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { results } = await c.env.CWD_DB.prepare(sql).bind(...params).all<{
|
||||||
created: number;
|
created: number;
|
||||||
post_slug: string;
|
post_slug: string;
|
||||||
post_url: string | null;
|
post_url: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const summaryAll: StatusCounts = {
|
const summary: StatusCounts = {
|
||||||
total: 0,
|
|
||||||
approved: 0,
|
|
||||||
pending: 0,
|
|
||||||
rejected: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
const summaryFiltered: StatusCounts = {
|
|
||||||
total: 0,
|
total: 0,
|
||||||
approved: 0,
|
approved: 0,
|
||||||
pending: 0,
|
pending: 0,
|
||||||
@@ -60,9 +59,7 @@ export const getStats = async (c: Context<{ Bindings: Bindings }>) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const domainMap = new Map<string, StatusCounts>();
|
const domainMap = new Map<string, StatusCounts>();
|
||||||
|
const dailyMap = new Map<string, number>();
|
||||||
const dailyMapAll = new Map<string, number>();
|
|
||||||
const dailyMapFiltered = new Map<string, number>();
|
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const thirtyDaysAgo = now - 29 * 24 * 60 * 60 * 1000;
|
const thirtyDaysAgo = now - 29 * 24 * 60 * 60 * 1000;
|
||||||
@@ -90,26 +87,13 @@ export const getStats = async (c: Context<{ Bindings: Bindings }>) => {
|
|||||||
counts.rejected += 1;
|
counts.rejected += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
summaryAll.total += 1;
|
summary.total += 1;
|
||||||
if (row.status === 'approved') {
|
if (row.status === 'approved') {
|
||||||
summaryAll.approved += 1;
|
summary.approved += 1;
|
||||||
} else if (row.status === 'pending') {
|
} else if (row.status === 'pending') {
|
||||||
summaryAll.pending += 1;
|
summary.pending += 1;
|
||||||
} else if (row.status === 'rejected') {
|
} else if (row.status === 'rejected') {
|
||||||
summaryAll.rejected += 1;
|
summary.rejected += 1;
|
||||||
}
|
|
||||||
|
|
||||||
const matchesFilter = domainFilter && domain === domainFilter;
|
|
||||||
|
|
||||||
if (matchesFilter) {
|
|
||||||
summaryFiltered.total += 1;
|
|
||||||
if (row.status === 'approved') {
|
|
||||||
summaryFiltered.approved += 1;
|
|
||||||
} else if (row.status === 'pending') {
|
|
||||||
summaryFiltered.pending += 1;
|
|
||||||
} else if (row.status === 'rejected') {
|
|
||||||
summaryFiltered.rejected += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (row.created >= thirtyDaysAgo) {
|
if (row.created >= thirtyDaysAgo) {
|
||||||
@@ -119,14 +103,7 @@ export const getStats = async (c: Context<{ Bindings: Bindings }>) => {
|
|||||||
const day = String(d.getUTCDate()).padStart(2, '0');
|
const day = String(d.getUTCDate()).padStart(2, '0');
|
||||||
const key = `${year}-${month}-${day}`;
|
const key = `${year}-${month}-${day}`;
|
||||||
|
|
||||||
dailyMapAll.set(key, (dailyMapAll.get(key) || 0) + 1);
|
dailyMap.set(key, (dailyMap.get(key) || 0) + 1);
|
||||||
|
|
||||||
if (matchesFilter) {
|
|
||||||
dailyMapFiltered.set(
|
|
||||||
key,
|
|
||||||
(dailyMapFiltered.get(key) || 0) + 1
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,8 +117,6 @@ export const getStats = async (c: Context<{ Bindings: Bindings }>) => {
|
|||||||
}))
|
}))
|
||||||
.sort((a, b) => b.total - a.total);
|
.sort((a, b) => b.total - a.total);
|
||||||
|
|
||||||
const dailyMap = domainFilter ? dailyMapFiltered : dailyMapAll;
|
|
||||||
|
|
||||||
const last7Days: { date: string; total: number }[] = [];
|
const last7Days: { date: string; total: number }[] = [];
|
||||||
for (let i = 29; i >= 0; i--) {
|
for (let i = 29; i >= 0; i--) {
|
||||||
const d = new Date(now - i * 24 * 60 * 60 * 1000);
|
const d = new Date(now - i * 24 * 60 * 60 * 1000);
|
||||||
@@ -155,8 +130,6 @@ export const getStats = async (c: Context<{ Bindings: Bindings }>) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const summary = domainFilter ? summaryFiltered : summaryAll;
|
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
summary,
|
summary,
|
||||||
domains,
|
domains,
|
||||||
|
|||||||
@@ -10,17 +10,24 @@ type LikeStatsItem = {
|
|||||||
|
|
||||||
export const getLikeStats = async (c: Context<{ Bindings: Bindings }>) => {
|
export const getLikeStats = async (c: Context<{ Bindings: Bindings }>) => {
|
||||||
try {
|
try {
|
||||||
await c.env.CWD_DB.prepare(
|
const siteId = c.req.query('siteId');
|
||||||
'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(
|
let query = `
|
||||||
'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)'
|
SELECT l.page_slug, COALESCE(p.post_title, NULL) AS page_title, COALESCE(p.post_url, NULL) AS page_url, COUNT(*) AS likes
|
||||||
).run();
|
FROM Likes l
|
||||||
|
LEFT JOIN page_stats p ON p.post_slug = l.page_slug AND p.site_id = l.site_id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: any[] = [];
|
||||||
|
|
||||||
const { results } = await c.env.CWD_DB.prepare(
|
if (siteId) {
|
||||||
'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'
|
query += ' WHERE l.site_id = ?';
|
||||||
).all<LikeStatsItem>();
|
params.push(siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' GROUP BY l.page_slug, l.site_id, p.post_title, p.post_url ORDER BY likes DESC LIMIT 50';
|
||||||
|
|
||||||
|
const { results } = await c.env.CWD_DB.prepare(query).bind(...params).all<LikeStatsItem>();
|
||||||
|
|
||||||
const items = results.map((row) => ({
|
const items = results.map((row) => ({
|
||||||
pageSlug: row.page_slug,
|
pageSlug: row.page_slug,
|
||||||
|
|||||||
@@ -10,25 +10,14 @@ export const listComments = async (c: Context<{ Bindings: Bindings }>) => {
|
|||||||
const limit = 10;
|
const limit = 10;
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
const rawDomain = c.req.query('domain') || '';
|
const rawSiteId = c.req.query('siteId'); // Changed from site_id to siteId to be consistent
|
||||||
const domain = rawDomain.trim();
|
const siteId = rawSiteId && rawSiteId !== 'default' ? rawSiteId : null;
|
||||||
const rawSiteId = c.req.query('site_id') || '';
|
|
||||||
const siteId = rawSiteId.trim();
|
|
||||||
|
|
||||||
let whereSql = '';
|
let whereSql = '';
|
||||||
const params: (string | number)[] = [];
|
const params: (string | number)[] = [];
|
||||||
if (domain) {
|
|
||||||
const pattern = `%://${domain}/%`;
|
|
||||||
whereSql = 'WHERE post_slug LIKE ? OR post_url LIKE ?';
|
|
||||||
params.push(pattern, pattern);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (siteId) {
|
if (siteId) {
|
||||||
if (whereSql) {
|
whereSql = 'WHERE site_id = ?';
|
||||||
whereSql += ' AND site_id = ?';
|
|
||||||
} else {
|
|
||||||
whereSql = 'WHERE site_id = ?';
|
|
||||||
}
|
|
||||||
params.push(siteId);
|
params.push(siteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,46 +21,25 @@ type VisitPageItem = {
|
|||||||
postTitle: string | null;
|
postTitle: string | null;
|
||||||
postUrl: string | null;
|
postUrl: string | null;
|
||||||
pv: number;
|
pv: number;
|
||||||
lastVisitAt: string | null;
|
lastVisitAt: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function extractDomain(source: string | null | undefined): string | null {
|
|
||||||
if (!source) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const value = source.trim();
|
|
||||||
if (!value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!/^https?:\/\//i.test(value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const url = new URL(value);
|
|
||||||
return url.hostname.toLowerCase();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getVisitOverview = async (
|
export const getVisitOverview = async (
|
||||||
c: Context<{ Bindings: Bindings }>
|
c: Context<{ Bindings: Bindings }>
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const rawDomain = c.req.query('domain') || '';
|
const rawSiteId = c.req.query('siteId');
|
||||||
const domainFilter = rawDomain.trim().toLowerCase();
|
const siteId = rawSiteId && rawSiteId !== 'default' ? rawSiteId : null;
|
||||||
|
|
||||||
await c.env.CWD_DB.prepare(
|
let statsSql = 'SELECT post_slug, post_title, post_url, pv, last_visit_at FROM page_stats';
|
||||||
'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)'
|
const statsParams: any[] = [];
|
||||||
).run();
|
|
||||||
|
|
||||||
await c.env.CWD_DB.prepare(
|
if (siteId) {
|
||||||
'CREATE TABLE IF NOT EXISTS page_visit_daily (id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL, domain TEXT, count INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)'
|
statsSql += ' WHERE site_id = ?';
|
||||||
).run();
|
statsParams.push(siteId);
|
||||||
|
}
|
||||||
|
|
||||||
const { results } = await c.env.CWD_DB.prepare(
|
const { results } = await c.env.CWD_DB.prepare(statsSql).bind(...statsParams).all<{
|
||||||
'SELECT post_slug, post_title, post_url, pv, last_visit_at FROM page_stats'
|
|
||||||
).all<{
|
|
||||||
post_slug: string;
|
post_slug: string;
|
||||||
post_title: string | null;
|
post_title: string | null;
|
||||||
post_url: string | null;
|
post_url: string | null;
|
||||||
@@ -72,15 +51,6 @@ export const getVisitOverview = async (
|
|||||||
let totalPages = 0;
|
let totalPages = 0;
|
||||||
|
|
||||||
for (const row of results) {
|
for (const row of results) {
|
||||||
const domain =
|
|
||||||
extractDomain(row.post_url) ||
|
|
||||||
extractDomain(row.post_slug) ||
|
|
||||||
null;
|
|
||||||
|
|
||||||
if (domainFilter && domain !== domainFilter) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
totalPv += row.pv || 0;
|
totalPv += row.pv || 0;
|
||||||
totalPages += 1;
|
totalPages += 1;
|
||||||
}
|
}
|
||||||
@@ -108,9 +78,6 @@ export const getVisitOverview = async (
|
|||||||
const lastMonthStartDate = new Date(Date.UTC(year, month - 1, 1));
|
const lastMonthStartDate = new Date(Date.UTC(year, month - 1, 1));
|
||||||
const lastMonthEndDate = new Date(monthStartDate.getTime() - 24 * 60 * 60 * 1000);
|
const lastMonthEndDate = new Date(monthStartDate.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
// For last week, we need the start date of last week.
|
|
||||||
// weekStartDate is the start of current week.
|
|
||||||
// So lastWeekStartDate is weekStartDate - 7 days.
|
|
||||||
const weekStartDate = (() => {
|
const weekStartDate = (() => {
|
||||||
const d = new Date(Date.UTC(year, month, day));
|
const d = new Date(Date.UTC(year, month, day));
|
||||||
const weekday = d.getUTCDay();
|
const weekday = d.getUTCDay();
|
||||||
@@ -121,8 +88,6 @@ export const getVisitOverview = async (
|
|||||||
const lastWeekStartDate = new Date(weekStartDate.getTime() - 7 * 24 * 60 * 60 * 1000);
|
const lastWeekStartDate = new Date(weekStartDate.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
const lastWeekEndDate = new Date(weekStartDate.getTime() - 24 * 60 * 60 * 1000);
|
const lastWeekEndDate = new Date(weekStartDate.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
// We need to fetch enough history for last month.
|
|
||||||
// The earliest date could be either startDate30 or lastMonthStartDate.
|
|
||||||
let earliestDate = startDate30;
|
let earliestDate = startDate30;
|
||||||
if (toKey(lastMonthStartDate) < earliestDate) {
|
if (toKey(lastMonthStartDate) < earliestDate) {
|
||||||
earliestDate = toKey(lastMonthStartDate);
|
earliestDate = toKey(lastMonthStartDate);
|
||||||
@@ -132,19 +97,18 @@ export const getVisitOverview = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
let dailySql =
|
let dailySql =
|
||||||
'SELECT date, domain, count FROM page_visit_daily WHERE date >= ?';
|
'SELECT date, count FROM page_visit_daily WHERE date >= ?';
|
||||||
const params: string[] = [earliestDate];
|
const params: string[] = [earliestDate];
|
||||||
|
|
||||||
if (domainFilter) {
|
if (siteId) {
|
||||||
dailySql += ' AND domain = ?';
|
dailySql += ' AND site_id = ?';
|
||||||
params.push(domainFilter);
|
params.push(siteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { results: dailyRows } = await c.env.CWD_DB.prepare(dailySql)
|
const { results: dailyRows } = await c.env.CWD_DB.prepare(dailySql)
|
||||||
.bind(...params)
|
.bind(...params)
|
||||||
.all<{
|
.all<{
|
||||||
date: string;
|
date: string;
|
||||||
domain: string | null;
|
|
||||||
count: number;
|
count: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -159,6 +123,7 @@ export const getVisitOverview = async (
|
|||||||
dailyMap.set(key, (dailyMap.get(key) || 0) + value);
|
dailyMap.set(key, (dailyMap.get(key) || 0) + value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback if no daily data but totalPv exists (rare edge case or initial migration)
|
||||||
if (dailyMap.size === 0 && totalPv > 0) {
|
if (dailyMap.size === 0 && totalPv > 0) {
|
||||||
const fallbackDate = now.toISOString().slice(0, 10);
|
const fallbackDate = now.toISOString().slice(0, 10);
|
||||||
dailyMap.set(fallbackDate, totalPv);
|
dailyMap.set(fallbackDate, totalPv);
|
||||||
@@ -167,9 +132,6 @@ export const getVisitOverview = async (
|
|||||||
const todayKey = toKey(now);
|
const todayKey = toKey(now);
|
||||||
const yesterdayKey = toKey(new Date(now.getTime() - 24 * 60 * 60 * 1000));
|
const yesterdayKey = toKey(new Date(now.getTime() - 24 * 60 * 60 * 1000));
|
||||||
|
|
||||||
// weekStartDate is already calculated above
|
|
||||||
const weekStartKey = toKey(weekStartDate);
|
|
||||||
|
|
||||||
let todayPv = dailyMap.get(todayKey) || 0;
|
let todayPv = dailyMap.get(todayKey) || 0;
|
||||||
let yesterdayPv = dailyMap.get(yesterdayKey) || 0;
|
let yesterdayPv = dailyMap.get(yesterdayKey) || 0;
|
||||||
let weekPv = 0;
|
let weekPv = 0;
|
||||||
@@ -260,18 +222,21 @@ export const getVisitOverview = async (
|
|||||||
|
|
||||||
export const getVisitPages = async (c: Context<{ Bindings: Bindings }>) => {
|
export const getVisitPages = async (c: Context<{ Bindings: Bindings }>) => {
|
||||||
try {
|
try {
|
||||||
const rawDomain = c.req.query('domain') || '';
|
const rawSiteId = c.req.query('siteId');
|
||||||
const domainFilter = rawDomain.trim().toLowerCase();
|
const siteId = rawSiteId && rawSiteId !== 'default' ? rawSiteId : null;
|
||||||
|
|
||||||
const rawOrder = c.req.query('order') || '';
|
const rawOrder = c.req.query('order') || '';
|
||||||
const order = rawOrder.trim().toLowerCase() === 'latest' ? 'latest' : 'pv';
|
const order = rawOrder.trim().toLowerCase() === 'latest' ? 'latest' : 'pv';
|
||||||
|
|
||||||
await c.env.CWD_DB.prepare(
|
let sql = 'SELECT post_slug, post_title, post_url, pv, last_visit_at FROM page_stats';
|
||||||
'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)'
|
const params: any[] = [];
|
||||||
).run();
|
|
||||||
|
|
||||||
const { results } = await c.env.CWD_DB.prepare(
|
if (siteId) {
|
||||||
'SELECT post_slug, post_title, post_url, pv, last_visit_at FROM page_stats'
|
sql += ' WHERE site_id = ?';
|
||||||
).all<{
|
params.push(siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { results } = await c.env.CWD_DB.prepare(sql).bind(...params).all<{
|
||||||
post_slug: string;
|
post_slug: string;
|
||||||
post_title: string | null;
|
post_title: string | null;
|
||||||
post_url: string | null;
|
post_url: string | null;
|
||||||
@@ -282,15 +247,6 @@ export const getVisitPages = async (c: Context<{ Bindings: Bindings }>) => {
|
|||||||
let items: VisitPageItem[] = [];
|
let items: VisitPageItem[] = [];
|
||||||
|
|
||||||
for (const row of results) {
|
for (const row of results) {
|
||||||
const domain =
|
|
||||||
extractDomain(row.post_url) ||
|
|
||||||
extractDomain(row.post_slug) ||
|
|
||||||
null;
|
|
||||||
|
|
||||||
if (domainFilter && domain !== domainFilter) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
postSlug: row.post_slug,
|
postSlug: row.post_slug,
|
||||||
postTitle: row.post_title,
|
postTitle: row.post_title,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type LikeRequestBody = {
|
|||||||
postSlug?: string;
|
postSlug?: string;
|
||||||
postTitle?: string;
|
postTitle?: string;
|
||||||
postUrl?: string;
|
postUrl?: string;
|
||||||
|
siteId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getUserIdFromRequest(c: Context<{ Bindings: Bindings }>): string {
|
function getUserIdFromRequest(c: Context<{ Bindings: Bindings }>): string {
|
||||||
@@ -30,24 +31,13 @@ function getUserIdFromRequest(c: Context<{ Bindings: Bindings }>): string {
|
|||||||
return 'anonymous';
|
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 (
|
export const getLikeStatus = async (
|
||||||
c: Context<{ Bindings: Bindings }>
|
c: Context<{ Bindings: Bindings }>
|
||||||
): Promise<Response> => {
|
): Promise<Response> => {
|
||||||
try {
|
try {
|
||||||
const rawPostSlug = c.req.query('post_slug') || '';
|
const rawPostSlug = c.req.query('post_slug') || '';
|
||||||
const postSlug = rawPostSlug.trim();
|
const postSlug = rawPostSlug.trim();
|
||||||
|
const siteId = c.req.query('siteId') || '';
|
||||||
|
|
||||||
if (!postSlug) {
|
if (!postSlug) {
|
||||||
return c.json({ message: 'post_slug is required' }, 400);
|
return c.json({ message: 'post_slug is required' }, 400);
|
||||||
@@ -59,20 +49,18 @@ export const getLikeStatus = async (
|
|||||||
'';
|
'';
|
||||||
const userId = userIdHeader.trim();
|
const userId = userIdHeader.trim();
|
||||||
|
|
||||||
await ensureLikesTable(c.env);
|
|
||||||
|
|
||||||
const totalRow = await c.env.CWD_DB.prepare(
|
const totalRow = await c.env.CWD_DB.prepare(
|
||||||
'SELECT COUNT(*) AS count FROM Likes WHERE page_slug = ?'
|
'SELECT COUNT(*) AS count FROM Likes WHERE page_slug = ? AND site_id = ?'
|
||||||
)
|
)
|
||||||
.bind(postSlug)
|
.bind(postSlug, siteId)
|
||||||
.first<{ count: number }>();
|
.first<{ count: number }>();
|
||||||
|
|
||||||
let liked = false;
|
let liked = false;
|
||||||
if (userId) {
|
if (userId) {
|
||||||
const row = await c.env.CWD_DB.prepare(
|
const row = await c.env.CWD_DB.prepare(
|
||||||
'SELECT id FROM Likes WHERE page_slug = ? AND user_id = ?'
|
'SELECT id FROM Likes WHERE page_slug = ? AND user_id = ? AND site_id = ?'
|
||||||
)
|
)
|
||||||
.bind(postSlug, userId)
|
.bind(postSlug, userId, siteId)
|
||||||
.first<{ id: number }>();
|
.first<{ id: number }>();
|
||||||
liked = !!row;
|
liked = !!row;
|
||||||
}
|
}
|
||||||
@@ -108,6 +96,7 @@ export const likePage = async (
|
|||||||
typeof body.postTitle === 'string' ? body.postTitle.trim() : '';
|
typeof body.postTitle === 'string' ? body.postTitle.trim() : '';
|
||||||
const rawPostUrl =
|
const rawPostUrl =
|
||||||
typeof body.postUrl === 'string' ? body.postUrl.trim() : '';
|
typeof body.postUrl === 'string' ? body.postUrl.trim() : '';
|
||||||
|
const siteId = typeof body.siteId === 'string' ? body.siteId.trim() : '';
|
||||||
|
|
||||||
if (!rawPostSlug) {
|
if (!rawPostSlug) {
|
||||||
return c.json({ message: 'postSlug is required' }, 400);
|
return c.json({ message: 'postSlug is required' }, 400);
|
||||||
@@ -115,38 +104,35 @@ export const likePage = async (
|
|||||||
|
|
||||||
const userId = getUserIdFromRequest(c);
|
const userId = getUserIdFromRequest(c);
|
||||||
|
|
||||||
await ensureLikesTable(c.env);
|
|
||||||
await ensurePageStatsTable(c.env);
|
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const existingLike = await c.env.CWD_DB.prepare(
|
const existingLike = await c.env.CWD_DB.prepare(
|
||||||
'SELECT id FROM Likes WHERE page_slug = ? AND user_id = ?'
|
'SELECT id FROM Likes WHERE page_slug = ? AND user_id = ? AND site_id = ?'
|
||||||
)
|
)
|
||||||
.bind(rawPostSlug, userId)
|
.bind(rawPostSlug, userId, siteId)
|
||||||
.first<{ id: number }>();
|
.first<{ id: number }>();
|
||||||
|
|
||||||
let alreadyLiked = false;
|
let alreadyLiked = false;
|
||||||
|
|
||||||
if (!existingLike) {
|
if (!existingLike) {
|
||||||
await c.env.CWD_DB.prepare(
|
await c.env.CWD_DB.prepare(
|
||||||
'INSERT INTO Likes (page_slug, user_id, created_at) VALUES (?, ?, ?)'
|
'INSERT INTO Likes (page_slug, user_id, created_at, site_id) VALUES (?, ?, ?, ?)'
|
||||||
)
|
)
|
||||||
.bind(rawPostSlug, userId, now)
|
.bind(rawPostSlug, userId, now, siteId)
|
||||||
.run();
|
.run();
|
||||||
} else {
|
} else {
|
||||||
alreadyLiked = true;
|
alreadyLiked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageStatsRow = await c.env.CWD_DB.prepare(
|
const pageStatsRow = await c.env.CWD_DB.prepare(
|
||||||
'SELECT id FROM page_stats WHERE post_slug = ?'
|
'SELECT id FROM page_stats WHERE post_slug = ? AND site_id = ?'
|
||||||
)
|
)
|
||||||
.bind(rawPostSlug)
|
.bind(rawPostSlug, siteId)
|
||||||
.first<{ id: number }>();
|
.first<{ id: number }>();
|
||||||
|
|
||||||
if (!pageStatsRow) {
|
if (!pageStatsRow) {
|
||||||
await c.env.CWD_DB.prepare(
|
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 (?, ?, ?, ?, ?, ?, ?)'
|
'INSERT INTO page_stats (post_slug, post_title, post_url, pv, last_visit_at, created_at, updated_at, site_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
rawPostSlug,
|
rawPostSlug,
|
||||||
@@ -155,7 +141,8 @@ export const likePage = async (
|
|||||||
0,
|
0,
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
now
|
now,
|
||||||
|
siteId
|
||||||
)
|
)
|
||||||
.run();
|
.run();
|
||||||
} else if (rawPostTitle || rawPostUrl) {
|
} else if (rawPostTitle || rawPostUrl) {
|
||||||
@@ -172,9 +159,9 @@ export const likePage = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const totalRow = await c.env.CWD_DB.prepare(
|
const totalRow = await c.env.CWD_DB.prepare(
|
||||||
'SELECT COUNT(*) AS count FROM Likes WHERE page_slug = ?'
|
'SELECT COUNT(*) AS count FROM Likes WHERE page_slug = ? AND site_id = ?'
|
||||||
)
|
)
|
||||||
.bind(rawPostSlug)
|
.bind(rawPostSlug, siteId)
|
||||||
.first<{ count: number }>();
|
.first<{ count: number }>();
|
||||||
|
|
||||||
const totalLikes = totalRow?.count || 0;
|
const totalLikes = totalRow?.count || 0;
|
||||||
@@ -193,4 +180,3 @@ export const likePage = async (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ type TrackVisitBody = {
|
|||||||
postSlug?: string;
|
postSlug?: string;
|
||||||
postTitle?: string;
|
postTitle?: string;
|
||||||
postUrl?: string;
|
postUrl?: string;
|
||||||
|
siteId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function extractDomain(source: string | null | undefined): string | null {
|
function extractDomain(source: string | null | undefined): string | null {
|
||||||
@@ -33,19 +34,12 @@ export const trackVisit = async (c: Context<{ Bindings: Bindings }>) => {
|
|||||||
const rawPostSlug = typeof body.postSlug === 'string' ? body.postSlug.trim() : '';
|
const rawPostSlug = typeof body.postSlug === 'string' ? body.postSlug.trim() : '';
|
||||||
const rawPostTitle = typeof body.postTitle === 'string' ? body.postTitle.trim() : '';
|
const rawPostTitle = typeof body.postTitle === 'string' ? body.postTitle.trim() : '';
|
||||||
const rawPostUrl = typeof body.postUrl === 'string' ? body.postUrl.trim() : '';
|
const rawPostUrl = typeof body.postUrl === 'string' ? body.postUrl.trim() : '';
|
||||||
|
const rawSiteId = typeof body.siteId === 'string' ? body.siteId.trim() : '';
|
||||||
|
|
||||||
if (!rawPostSlug) {
|
if (!rawPostSlug) {
|
||||||
return c.json({ message: 'postSlug is required' }, 400);
|
return c.json({ message: 'postSlug is required' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
await c.env.CWD_DB.prepare(
|
|
||||||
'CREATE TABLE IF NOT EXISTS page_visit_daily (id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL, domain TEXT, count INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)'
|
|
||||||
).run();
|
|
||||||
|
|
||||||
const nowDate = new Date();
|
const nowDate = new Date();
|
||||||
const nowTs = nowDate.getTime();
|
const nowTs = nowDate.getTime();
|
||||||
const nowIso = nowDate.toISOString();
|
const nowIso = nowDate.toISOString();
|
||||||
@@ -56,68 +50,51 @@ export const trackVisit = async (c: Context<{ Bindings: Bindings }>) => {
|
|||||||
extractDomain(rawPostSlug) ||
|
extractDomain(rawPostSlug) ||
|
||||||
null;
|
null;
|
||||||
|
|
||||||
const existing = await c.env.CWD_DB.prepare(
|
// Upsert page_stats using ON CONFLICT (site_id, post_slug)
|
||||||
'SELECT id, pv FROM page_stats WHERE post_slug = ?'
|
await c.env.CWD_DB.prepare(
|
||||||
|
`INSERT INTO page_stats (site_id, post_slug, post_title, post_url, pv, last_visit_at, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, 1, ?, ?, ?)
|
||||||
|
ON CONFLICT(site_id, post_slug) DO UPDATE SET
|
||||||
|
pv = pv + 1,
|
||||||
|
last_visit_at = ?,
|
||||||
|
updated_at = ?,
|
||||||
|
post_title = excluded.post_title,
|
||||||
|
post_url = excluded.post_url`
|
||||||
)
|
)
|
||||||
.bind(rawPostSlug)
|
.bind(
|
||||||
.first<{ id: number; pv: number }>();
|
rawSiteId,
|
||||||
|
rawPostSlug,
|
||||||
if (!existing) {
|
rawPostTitle || null,
|
||||||
await c.env.CWD_DB.prepare(
|
rawPostUrl || null,
|
||||||
'INSERT INTO page_stats (post_slug, post_title, post_url, pv, last_visit_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
nowTs,
|
||||||
|
nowTs,
|
||||||
|
nowTs,
|
||||||
|
nowTs,
|
||||||
|
nowTs
|
||||||
)
|
)
|
||||||
.bind(
|
.run();
|
||||||
rawPostSlug,
|
|
||||||
rawPostTitle || null,
|
|
||||||
rawPostUrl || null,
|
|
||||||
1,
|
|
||||||
nowTs,
|
|
||||||
nowTs,
|
|
||||||
nowTs
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
} else {
|
|
||||||
const newPv = (existing.pv || 0) + 1;
|
|
||||||
await c.env.CWD_DB.prepare(
|
|
||||||
'UPDATE page_stats SET post_title = ?, post_url = ?, pv = ?, last_visit_at = ?, updated_at = ? WHERE id = ?'
|
|
||||||
)
|
|
||||||
.bind(
|
|
||||||
rawPostTitle || null,
|
|
||||||
rawPostUrl || null,
|
|
||||||
newPv,
|
|
||||||
nowTs,
|
|
||||||
nowTs,
|
|
||||||
existing.id
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
|
|
||||||
let dailyRow:
|
// Update page_visit_daily
|
||||||
| {
|
// We try to find a row for (date, site_id, domain)
|
||||||
id: number;
|
let dailySql = 'SELECT id, count FROM page_visit_daily WHERE date = ? AND site_id = ?';
|
||||||
count: number;
|
const params: any[] = [today, rawSiteId];
|
||||||
}
|
|
||||||
| null = null;
|
|
||||||
|
|
||||||
if (domain) {
|
if (domain) {
|
||||||
dailyRow = await c.env.CWD_DB.prepare(
|
dailySql += ' AND domain = ?';
|
||||||
'SELECT id, count FROM page_visit_daily WHERE date = ? AND domain = ?'
|
params.push(domain);
|
||||||
)
|
|
||||||
.bind(today, domain)
|
|
||||||
.first<{ id: number; count: number }>();
|
|
||||||
} else {
|
} else {
|
||||||
dailyRow = await c.env.CWD_DB.prepare(
|
dailySql += ' AND domain IS NULL';
|
||||||
'SELECT id, count FROM page_visit_daily WHERE date = ? AND domain IS NULL'
|
|
||||||
)
|
|
||||||
.bind(today)
|
|
||||||
.first<{ id: number; count: number }>();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dailyRow = await c.env.CWD_DB.prepare(dailySql)
|
||||||
|
.bind(...params)
|
||||||
|
.first<{ id: number; count: number }>();
|
||||||
|
|
||||||
if (!dailyRow) {
|
if (!dailyRow) {
|
||||||
await c.env.CWD_DB.prepare(
|
await c.env.CWD_DB.prepare(
|
||||||
'INSERT INTO page_visit_daily (date, domain, count, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
|
'INSERT INTO page_visit_daily (date, site_id, domain, count, created_at, updated_at) VALUES (?, ?, ?, 1, ?, ?)'
|
||||||
)
|
)
|
||||||
.bind(today, domain, 1, nowTs, nowTs)
|
.bind(today, rawSiteId, domain, nowTs, nowTs)
|
||||||
.run();
|
.run();
|
||||||
} else {
|
} else {
|
||||||
const newCount = (dailyRow.count || 0) + 1;
|
const newCount = (dailyRow.count || 0) + 1;
|
||||||
@@ -133,4 +110,3 @@ export const trackVisit = async (c: Context<{ Bindings: Bindings }>) => {
|
|||||||
return c.json({ message: e.message || '记录访问数据失败' }, 500);
|
return c.json({ message: e.message || '记录访问数据失败' }, 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { getAdminEmail } from './api/admin/getAdminEmail';
|
|||||||
import { setAdminEmail } from './api/admin/setAdminEmail';
|
import { setAdminEmail } from './api/admin/setAdminEmail';
|
||||||
import { testEmail } from './api/admin/testEmail';
|
import { testEmail } from './api/admin/testEmail';
|
||||||
import { getStats } from './api/admin/getStats';
|
import { getStats } from './api/admin/getStats';
|
||||||
import { getDomains } from './api/admin/getDomains';
|
import { getSites } from './api/admin/getDomains';
|
||||||
import { trackVisit } from './api/public/trackVisit';
|
import { trackVisit } from './api/public/trackVisit';
|
||||||
import { getVisitOverview, getVisitPages } from './api/admin/visitAnalytics';
|
import { getVisitOverview, getVisitPages } from './api/admin/visitAnalytics';
|
||||||
import { getLikeStatus, likePage } from './api/public/like';
|
import { getLikeStatus, likePage } from './api/public/like';
|
||||||
@@ -36,10 +36,13 @@ import { getLikeStats } from './api/admin/likeStats';
|
|||||||
import { getFeatureSettings, updateFeatureSettings } from './api/admin/featureSettings';
|
import { getFeatureSettings, updateFeatureSettings } from './api/admin/featureSettings';
|
||||||
import { getTelegramSettings, updateTelegramSettings, setupTelegramWebhook, testTelegramMessage } from './api/admin/telegramSettings';
|
import { getTelegramSettings, updateTelegramSettings, setupTelegramWebhook, testTelegramMessage } from './api/admin/telegramSettings';
|
||||||
import { telegramWebhook } from './api/telegram/webhook';
|
import { telegramWebhook } from './api/telegram/webhook';
|
||||||
|
import { ensureSchema } from './utils/dbMigration';
|
||||||
|
|
||||||
const app = new Hono<{ Bindings: Bindings }>();
|
const app = new Hono<{ Bindings: Bindings }>();
|
||||||
const VERSION = `${packageJson.version}`;
|
const VERSION = `${packageJson.version}`;
|
||||||
|
|
||||||
|
let MIGRATION_CHECKED = false;
|
||||||
|
|
||||||
const COMMENT_ADMIN_EMAIL_KEY = 'comment_admin_email';
|
const COMMENT_ADMIN_EMAIL_KEY = 'comment_admin_email';
|
||||||
const COMMENT_ADMIN_BADGE_KEY = 'comment_admin_badge';
|
const COMMENT_ADMIN_BADGE_KEY = 'comment_admin_badge';
|
||||||
const COMMENT_AVATAR_PREFIX_KEY = 'comment_avatar_prefix';
|
const COMMENT_AVATAR_PREFIX_KEY = 'comment_avatar_prefix';
|
||||||
@@ -226,6 +229,10 @@ async function saveCommentSettings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.use('*', async (c, next) => {
|
app.use('*', async (c, next) => {
|
||||||
|
if (!MIGRATION_CHECKED) {
|
||||||
|
await ensureSchema(c.env);
|
||||||
|
MIGRATION_CHECKED = true;
|
||||||
|
}
|
||||||
console.log('Request:start', {
|
console.log('Request:start', {
|
||||||
method: c.req.method,
|
method: c.req.method,
|
||||||
path: c.req.path,
|
path: c.req.path,
|
||||||
@@ -290,7 +297,7 @@ app.post('/admin/import/backup', importBackup);
|
|||||||
app.put('/admin/comments/status', updateStatus);
|
app.put('/admin/comments/status', updateStatus);
|
||||||
app.put('/admin/comments/update', updateComment);
|
app.put('/admin/comments/update', updateComment);
|
||||||
app.get('/admin/stats/comments', getStats);
|
app.get('/admin/stats/comments', getStats);
|
||||||
app.get('/admin/stats/domains', getDomains);
|
app.get('/admin/stats/sites', getSites);
|
||||||
app.get('/admin/analytics/overview', getVisitOverview);
|
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);
|
||||||
|
|||||||
133
cwd-api/src/utils/dbMigration.ts
Normal file
133
cwd-api/src/utils/dbMigration.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { Bindings } from '../bindings';
|
||||||
|
|
||||||
|
export async function ensureSchema(env: Bindings) {
|
||||||
|
try {
|
||||||
|
// 1. Check and migrate page_stats
|
||||||
|
const statsInfo = await env.CWD_DB.prepare('PRAGMA table_info(page_stats)').all();
|
||||||
|
const statsColumns = (statsInfo.results || []) as any[];
|
||||||
|
const hasStatsSiteId = statsColumns.some((col) => col.name === 'site_id');
|
||||||
|
|
||||||
|
if (!hasStatsSiteId && statsColumns.length > 0) {
|
||||||
|
console.log('Migrating page_stats table...');
|
||||||
|
// Create new table with site_id and composite unique constraint
|
||||||
|
await env.CWD_DB.prepare(
|
||||||
|
`CREATE TABLE page_stats_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
site_id TEXT NOT NULL DEFAULT '',
|
||||||
|
post_slug TEXT 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,
|
||||||
|
UNIQUE(site_id, post_slug)
|
||||||
|
)`
|
||||||
|
).run();
|
||||||
|
|
||||||
|
// Copy data
|
||||||
|
await env.CWD_DB.prepare(
|
||||||
|
`INSERT INTO page_stats_new (post_slug, post_title, post_url, pv, last_visit_at, created_at, updated_at)
|
||||||
|
SELECT post_slug, post_title, post_url, pv, last_visit_at, created_at, updated_at FROM page_stats`
|
||||||
|
).run();
|
||||||
|
|
||||||
|
// Drop old table
|
||||||
|
await env.CWD_DB.prepare('DROP TABLE page_stats').run();
|
||||||
|
|
||||||
|
// Rename new table
|
||||||
|
await env.CWD_DB.prepare('ALTER TABLE page_stats_new RENAME TO page_stats').run();
|
||||||
|
console.log('Migrated page_stats table successfully.');
|
||||||
|
} else if (statsColumns.length === 0) {
|
||||||
|
// Table doesn't exist, create it directly
|
||||||
|
await env.CWD_DB.prepare(
|
||||||
|
`CREATE TABLE IF NOT EXISTS page_stats (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
site_id TEXT NOT NULL DEFAULT '',
|
||||||
|
post_slug TEXT 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,
|
||||||
|
UNIQUE(site_id, post_slug)
|
||||||
|
)`
|
||||||
|
).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check and migrate page_visit_daily
|
||||||
|
const dailyInfo = await env.CWD_DB.prepare('PRAGMA table_info(page_visit_daily)').all();
|
||||||
|
const dailyColumns = (dailyInfo.results || []) as any[];
|
||||||
|
const hasDailySiteId = dailyColumns.some((col) => col.name === 'site_id');
|
||||||
|
|
||||||
|
if (!hasDailySiteId && dailyColumns.length > 0) {
|
||||||
|
console.log('Migrating page_visit_daily table...');
|
||||||
|
await env.CWD_DB.prepare('ALTER TABLE page_visit_daily ADD COLUMN site_id TEXT NOT NULL DEFAULT ""').run();
|
||||||
|
console.log('Migrated page_visit_daily table successfully.');
|
||||||
|
} else if (dailyColumns.length === 0) {
|
||||||
|
await env.CWD_DB.prepare(
|
||||||
|
`CREATE TABLE IF NOT EXISTS page_visit_daily (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
domain TEXT,
|
||||||
|
count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
site_id TEXT NOT NULL DEFAULT ''
|
||||||
|
)`
|
||||||
|
).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check and migrate Likes
|
||||||
|
const likesInfo = await env.CWD_DB.prepare('PRAGMA table_info(Likes)').all();
|
||||||
|
const likesColumns = (likesInfo.results || []) as any[];
|
||||||
|
const hasLikesSiteId = likesColumns.some((col) => col.name === 'site_id');
|
||||||
|
|
||||||
|
if (!hasLikesSiteId && likesColumns.length > 0) {
|
||||||
|
console.log('Migrating Likes table...');
|
||||||
|
await env.CWD_DB.prepare(
|
||||||
|
`CREATE TABLE Likes_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
site_id TEXT NOT NULL DEFAULT '',
|
||||||
|
page_slug TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
UNIQUE(site_id, page_slug, user_id)
|
||||||
|
)`
|
||||||
|
).run();
|
||||||
|
|
||||||
|
await env.CWD_DB.prepare(
|
||||||
|
`INSERT INTO Likes_new (page_slug, user_id, created_at)
|
||||||
|
SELECT page_slug, user_id, created_at FROM Likes`
|
||||||
|
).run();
|
||||||
|
|
||||||
|
await env.CWD_DB.prepare('DROP TABLE Likes').run();
|
||||||
|
await env.CWD_DB.prepare('ALTER TABLE Likes_new RENAME TO Likes').run();
|
||||||
|
console.log('Migrated Likes table successfully.');
|
||||||
|
} else if (likesColumns.length === 0) {
|
||||||
|
await env.CWD_DB.prepare(
|
||||||
|
`CREATE TABLE IF NOT EXISTS Likes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
site_id TEXT NOT NULL DEFAULT '',
|
||||||
|
page_slug TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
UNIQUE(site_id, page_slug, user_id)
|
||||||
|
)`
|
||||||
|
).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create Indexes
|
||||||
|
await env.CWD_DB.prepare('CREATE INDEX IF NOT EXISTS idx_page_stats_site_id ON page_stats(site_id)').run();
|
||||||
|
await env.CWD_DB.prepare('CREATE INDEX IF NOT EXISTS idx_page_visit_daily_site_id ON page_visit_daily(site_id)').run();
|
||||||
|
await env.CWD_DB.prepare('CREATE INDEX IF NOT EXISTS idx_likes_site_id ON Likes(site_id)').run();
|
||||||
|
// Also ensure Comment index exists (just in case)
|
||||||
|
await env.CWD_DB.prepare('CREATE INDEX IF NOT EXISTS idx_site_id ON Comment(site_id)').run();
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Database migration failed:', e);
|
||||||
|
// Don't throw, to allow app to start, but log error.
|
||||||
|
// Or maybe we should throw? If schema is wrong, queries will fail anyway.
|
||||||
|
// For now, log error.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,14 +48,14 @@ https://cwd.js.org/cwd.js
|
|||||||
|
|
||||||
### 参数说明
|
### 参数说明
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||||
| -------------- | ----------------------- | ---- | --------- | ---------------------------------------- |
|
| -------------- | ----------------------- | ---- | ------ | ---------------------------------------- |
|
||||||
| `el` | `string \| HTMLElement` | 是 | - | 挂载元素选择器或 DOM 元素 |
|
| `el` | `string \| HTMLElement` | 是 | - | 挂载元素选择器或 DOM 元素 |
|
||||||
| `apiBaseUrl` | `string` | 是 | - | API 基础地址 |
|
| `apiBaseUrl` | `string` | 是 | - | API 基础地址 |
|
||||||
| `siteId` | `string` | 否 | `default` | 站点 ID,用于多站点数据隔离 |
|
| `siteId` | `string` | 否 | `''` | 站点 ID,用于多站点数据隔离 |
|
||||||
| `theme` | `'light' \| 'dark'` | 否 | `'light'` | 主题模式 |
|
| `theme` | `'light' \| 'dark'` | 否 | `'light'` | 主题模式 |
|
||||||
| `pageSize` | `number` | 否 | `20` | 每页显示评论数 |
|
| `pageSize` | `number` | 否 | `20` | 每页显示评论数 |
|
||||||
| `customCssUrl` | `string` | 否 | - | 自定义样式表 URL,追加到 Shadow DOM 底部 |
|
| `customCssUrl` | `string` | 否 | - | 自定义样式表 URL,追加到 Shadow DOM 底部 |
|
||||||
|
|
||||||
头像前缀、博主邮箱和标识等信息由后端接口 `/api/config/comments` 提供,无需在前端进行配置。
|
头像前缀、博主邮箱和标识等信息由后端接口 `/api/config/comments` 提供,无需在前端进行配置。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user