feat(admin): 添加域名列表获取功能用于筛选
新增获取域名列表的API端点,用于统一管理评论和访问统计中的域名筛选功能。修改了前端多个视图的域名获取逻辑,从原来的分散获取改为统一调用新接口,提高代码复用性和维护性。 docs: 添加反馈页面并更新侧边栏
This commit is contained in:
@@ -107,6 +107,10 @@ export type VisitPagesResponse = {
|
|||||||
items: VisitPageItem[];
|
items: VisitPageItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DomainListResponse = {
|
||||||
|
domains: string[];
|
||||||
|
};
|
||||||
|
|
||||||
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;
|
||||||
@@ -265,3 +269,7 @@ export function fetchVisitPages(domain?: string, order?: 'pv' | 'latest'): Promi
|
|||||||
const url = query ? `/admin/analytics/pages?${query}` : '/admin/analytics/pages';
|
const url = query ? `/admin/analytics/pages?${query}` : '/admin/analytics/pages';
|
||||||
return get<VisitPagesResponse>(url);
|
return get<VisitPagesResponse>(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchDomainList(): Promise<DomainListResponse> {
|
||||||
|
return get<DomainListResponse>('/admin/stats/domains');
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
<h3 class="card-title">最近 30 天访问趋势</h3>
|
<h3 class="card-title">最近 30 天访问趋势</h3>
|
||||||
<div v-if="loading" class="page-hint">加载中...</div>
|
<div v-if="loading" class="page-hint">加载中...</div>
|
||||||
<div v-else-if="error" class="page-error">{{ error }}</div>
|
<div v-else-if="error" class="page-error">{{ error }}</div>
|
||||||
<div v-else class="chart-wrapper">
|
<div class="chart-wrapper">
|
||||||
<div ref="chartEl" class="chart"></div>
|
<div ref="chartEl" class="chart"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,6 +127,7 @@ import * as echarts from "echarts";
|
|||||||
import {
|
import {
|
||||||
fetchVisitOverview,
|
fetchVisitOverview,
|
||||||
fetchVisitPages,
|
fetchVisitPages,
|
||||||
|
fetchDomainList,
|
||||||
type VisitOverviewResponse,
|
type VisitOverviewResponse,
|
||||||
type VisitPageItem,
|
type VisitPageItem,
|
||||||
} from "../api/admin";
|
} from "../api/admin";
|
||||||
@@ -252,20 +253,6 @@ async function loadData() {
|
|||||||
last30Days.value = Array.isArray(overviewRes.last30Days)
|
last30Days.value = Array.isArray(overviewRes.last30Days)
|
||||||
? overviewRes.last30Days
|
? overviewRes.last30Days
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const domains = new Set<string>();
|
|
||||||
for (const item of items.value) {
|
|
||||||
const domainFromUrl =
|
|
||||||
extractDomain(item.postUrl) || extractDomain(item.postSlug);
|
|
||||||
if (domainFromUrl) {
|
|
||||||
domains.add(domainFromUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const options = Array.from(domains);
|
|
||||||
if (domainFilter.value && !options.includes(domainFilter.value)) {
|
|
||||||
options.unshift(domainFilter.value);
|
|
||||||
}
|
|
||||||
domainOptions.value = options;
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const msg = e.message || "加载访问统计数据失败";
|
const msg = e.message || "加载访问统计数据失败";
|
||||||
error.value = msg;
|
error.value = msg;
|
||||||
@@ -274,14 +261,27 @@ async function loadData() {
|
|||||||
loading.value = false;
|
loading.value = false;
|
||||||
listLoading.value = false;
|
listLoading.value = false;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
if (!error.value && last30Days.value.length > 0) {
|
if (!error.value) {
|
||||||
renderChart();
|
renderChart();
|
||||||
} else if (chartInstance) {
|
|
||||||
chartInstance.clear();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadDomains() {
|
||||||
|
try {
|
||||||
|
const res = await fetchDomainList();
|
||||||
|
const domains = Array.isArray(res.domains) ? res.domains : [];
|
||||||
|
const set = new Set(domains);
|
||||||
|
if (domainFilter.value && !set.has(domainFilter.value)) {
|
||||||
|
set.add(domainFilter.value);
|
||||||
|
}
|
||||||
|
domainOptions.value = Array.from(set);
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e.message || "加载域名列表失败";
|
||||||
|
showToast(msg, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadVisitPagesOnly() {
|
async function loadVisitPagesOnly() {
|
||||||
listLoading.value = true;
|
listLoading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
@@ -370,6 +370,7 @@ function handleResize() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadData();
|
loadData();
|
||||||
|
loadDomains();
|
||||||
window.addEventListener("resize", handleResize);
|
window.addEventListener("resize", handleResize);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -257,12 +257,12 @@ import {
|
|||||||
CommentItem,
|
CommentItem,
|
||||||
CommentListResponse,
|
CommentListResponse,
|
||||||
fetchComments,
|
fetchComments,
|
||||||
fetchCommentStats,
|
|
||||||
deleteComment,
|
deleteComment,
|
||||||
updateCommentStatus,
|
updateCommentStatus,
|
||||||
updateComment,
|
updateComment,
|
||||||
blockIp,
|
blockIp,
|
||||||
blockEmail,
|
blockEmail,
|
||||||
|
fetchDomainList,
|
||||||
} from "../api/admin";
|
} from "../api/admin";
|
||||||
|
|
||||||
const DOMAIN_STORAGE_KEY = "cwd_admin_domain_filter";
|
const DOMAIN_STORAGE_KEY = "cwd_admin_domain_filter";
|
||||||
@@ -558,11 +558,9 @@ onMounted(() => {
|
|||||||
domainFilter.value = d.trim();
|
domainFilter.value = d.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchCommentStats()
|
fetchDomainList()
|
||||||
.then((res: CommentListResponse | any) => {
|
.then((res) => {
|
||||||
const domains = Array.isArray(res.domains)
|
const domains = Array.isArray(res.domains) ? res.domains : [];
|
||||||
? res.domains.map((item: any) => item.domain)
|
|
||||||
: [];
|
|
||||||
domainOptions.value = Array.from(new Set(domains));
|
domainOptions.value = Array.from(new Set(domains));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
<h3 class="card-title">最近 30 天评论数趋势</h3>
|
<h3 class="card-title">最近 30 天评论数趋势</h3>
|
||||||
<div v-if="statsLoading" class="page-hint">加载中...</div>
|
<div v-if="statsLoading" class="page-hint">加载中...</div>
|
||||||
<div v-else-if="statsError" class="page-error">{{ statsError }}</div>
|
<div v-else-if="statsError" class="page-error">{{ statsError }}</div>
|
||||||
<div v-else class="chart-wrapper">
|
<div class="chart-wrapper">
|
||||||
<div ref="chartEl" class="chart"></div>
|
<div ref="chartEl" class="chart"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onBeforeUnmount, ref, nextTick, watch } from "vue";
|
import { onMounted, onBeforeUnmount, ref, nextTick, watch } from "vue";
|
||||||
import * as echarts from "echarts";
|
import * as echarts from "echarts";
|
||||||
import { fetchCommentStats } from "../api/admin";
|
import { fetchCommentStats, fetchDomainList } from "../api/admin";
|
||||||
|
|
||||||
type DomainStat = {
|
type DomainStat = {
|
||||||
domain: string;
|
domain: string;
|
||||||
@@ -152,14 +152,6 @@ async function loadStats() {
|
|||||||
rejected: res.summary.rejected,
|
rejected: res.summary.rejected,
|
||||||
};
|
};
|
||||||
domainStats.value = res.domains;
|
domainStats.value = res.domains;
|
||||||
const domains = Array.isArray(res.domains)
|
|
||||||
? res.domains.map((item) => item.domain)
|
|
||||||
: [];
|
|
||||||
const set = new Set(domains);
|
|
||||||
if (domainFilter.value && !set.has(domainFilter.value)) {
|
|
||||||
set.add(domainFilter.value);
|
|
||||||
}
|
|
||||||
domainOptions.value = Array.from(set);
|
|
||||||
last7Days.value = Array.isArray(res.last7Days) ? res.last7Days : [];
|
last7Days.value = Array.isArray(res.last7Days) ? res.last7Days : [];
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const msg = e.message || "加载统计数据失败";
|
const msg = e.message || "加载统计数据失败";
|
||||||
@@ -168,14 +160,27 @@ async function loadStats() {
|
|||||||
} finally {
|
} finally {
|
||||||
statsLoading.value = false;
|
statsLoading.value = false;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
if (!statsError.value && last7Days.value.length > 0) {
|
if (!statsError.value) {
|
||||||
renderChart();
|
renderChart();
|
||||||
} else if (chartInstance) {
|
|
||||||
chartInstance.clear();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadDomains() {
|
||||||
|
try {
|
||||||
|
const res = await fetchDomainList();
|
||||||
|
const domains = Array.isArray(res.domains) ? res.domains : [];
|
||||||
|
const set = new Set(domains);
|
||||||
|
if (domainFilter.value && !set.has(domainFilter.value)) {
|
||||||
|
set.add(domainFilter.value);
|
||||||
|
}
|
||||||
|
domainOptions.value = Array.from(set);
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e.message || "加载域名列表失败";
|
||||||
|
showToast(msg, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderChart() {
|
function renderChart() {
|
||||||
const el = chartEl.value;
|
const el = chartEl.value;
|
||||||
if (!el) {
|
if (!el) {
|
||||||
@@ -239,6 +244,7 @@ function handleResize() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadStats();
|
loadStats();
|
||||||
|
loadDomains();
|
||||||
window.addEventListener("resize", handleResize);
|
window.addEventListener("resize", handleResize);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
74
cwd-api/src/api/admin/getDomains.ts
Normal file
74
cwd-api/src/api/admin/getDomains.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type { Context } from 'hono';
|
||||||
|
import type { Bindings } from '../../bindings';
|
||||||
|
|
||||||
|
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 getDomains = async (c: Context<{ Bindings: Bindings }>) => {
|
||||||
|
try {
|
||||||
|
const domains = new Set<string>();
|
||||||
|
|
||||||
|
const { results: commentRows } = await c.env.CWD_DB.prepare(
|
||||||
|
'SELECT post_slug, url FROM Comment'
|
||||||
|
).all<{
|
||||||
|
post_slug: string;
|
||||||
|
url: string | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
for (const row of commentRows) {
|
||||||
|
const domain =
|
||||||
|
extractDomain(row.post_slug) || extractDomain(row.url);
|
||||||
|
if (domain) {
|
||||||
|
domains.add(domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await c.env.CWD_DB.prepare(
|
||||||
|
'CREATE TABLE IF NOT EXISTS page_stats (id INTEGER PRIMARY KEY AUTOINCREMENT, post_slug TEXT UNIQUE NOT NULL, post_title TEXT, post_url TEXT, pv INTEGER NOT NULL DEFAULT 0, last_visit_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)'
|
||||||
|
).run();
|
||||||
|
|
||||||
|
const { results: pageRows } = await c.env.CWD_DB.prepare(
|
||||||
|
'SELECT post_slug, post_url FROM page_stats'
|
||||||
|
).all<{
|
||||||
|
post_slug: string;
|
||||||
|
post_url: string | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
for (const row of pageRows) {
|
||||||
|
const domain =
|
||||||
|
extractDomain(row.post_url) || extractDomain(row.post_slug);
|
||||||
|
if (domain) {
|
||||||
|
domains.add(domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = Array.from(domains);
|
||||||
|
list.sort();
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
domains: list
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
return c.json(
|
||||||
|
{ message: e.message || '获取域名列表失败' },
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -23,6 +23,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 { 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';
|
||||||
|
|
||||||
@@ -243,6 +244,7 @@ app.post('/admin/comments/import', importComments);
|
|||||||
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/analytics/overview', getVisitOverview);
|
app.get('/admin/analytics/overview', getVisitOverview);
|
||||||
app.get('/admin/analytics/pages', getVisitPages);
|
app.get('/admin/analytics/pages', getVisitPages);
|
||||||
app.get('/admin/settings/email', getAdminEmail);
|
app.get('/admin/settings/email', getAdminEmail);
|
||||||
|
|||||||
@@ -25,4 +25,5 @@ export default [
|
|||||||
{ text: '管理员 API', link: '/api/admin' },
|
{ text: '管理员 API', link: '/api/admin' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{ text: '反馈', link: '/feedback' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1161,6 +1161,53 @@ GET /admin/stats/comments
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 6.2 获取域名列表
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/stats/domains
|
||||||
|
```
|
||||||
|
|
||||||
|
用于管理后台获取所有已产生评论或访问记录的域名列表,用于域名筛选下拉框等场景。
|
||||||
|
|
||||||
|
- 方法:`GET`
|
||||||
|
- 路径:`/admin/stats/domains`
|
||||||
|
- 鉴权:需要(Bearer Token)
|
||||||
|
|
||||||
|
**成功响应**
|
||||||
|
|
||||||
|
- 状态码:`200`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"domains": [
|
||||||
|
"blog.example.com",
|
||||||
|
"example.com",
|
||||||
|
"www.example.com"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
| ---------- | --------------- | ---------------------- |
|
||||||
|
| `domains` | Array\<string\> | 域名列表,按字母排序 |
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 域名从评论数据和访问统计数据中提取
|
||||||
|
- 域名自动去重并按字母顺序排序
|
||||||
|
|
||||||
|
**错误响应**
|
||||||
|
|
||||||
|
- 状态码:`500`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "获取域名列表失败"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## 7. 访问统计相关
|
## 7. 访问统计相关
|
||||||
|
|
||||||
### 7.1 获取访问统计概览
|
### 7.1 获取访问统计概览
|
||||||
|
|||||||
8
docs/feedback.md
Normal file
8
docs/feedback.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# 反馈
|
||||||
|
|
||||||
|
在使用中遇到问题或有建议吗?请通过以下方式联系我们:
|
||||||
|
|
||||||
|
- [Github Isseus](https://github.com/anghunk/cwd/issues)
|
||||||
|
- 涉及到文档的问题,请直接在对应页面下进行评论留言!
|
||||||
|
|
||||||
|
我们会尽快回复您的反馈。感谢您的参与!
|
||||||
Reference in New Issue
Block a user