feat(admin): 站点管理与总览页

- 站点管理:useTable+ArtTable 列表(名称/Site ID 复制/域名白名单标签/状态/创建时间),
  新建/编辑复用弹窗(域名按行归一预览,与后端 _norm_domains 同口径),
  停用二次确认(软删语义,文案用停用)/启用切换
- 数据总览:今日五指标卡(PV/UV/会话/新访客/在线)、近30天趋势折线、
  来源构成环图、热门页面表格;站点筛选下拉(哨兵值全部站点)联动刷新
- 图表用模板 useChartComponent 管理实例(自动销毁/空态/主题色)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-15 18:32:25 +08:00
parent 0a80b43804
commit 78ed08d217
2 changed files with 519 additions and 8 deletions
+195 -4
View File
@@ -1,10 +1,201 @@
<!-- 访客统计 · 数据总览 -->
<template>
<div class="p-5">
<h2 class="mb-3 text-lg font-semibold">数据总览</h2>
<div>Task 19 填充</div>
<div class="tracker-dashboard-page">
<!-- 筛选栏:站点切换 + 刷新 -->
<div class="art-card mb-4 flex-c flex-wrap gap-3 px-5 py-4">
<ArtSvgIcon icon="ri:bar-chart-box-line" class="text-lg text-theme" />
<h2 class="m-0 text-base font-semibold">数据总览</h2>
<span class="text-xs text-g-500">今日指标为上海自然日口径,已排除爬虫</span>
<div class="ml-auto flex-c gap-2">
<span class="text-sm text-g-600">站点</span>
<ElSelect
v-model="selectedSiteId"
class="w-64"
:loading="sitesLoading"
@change="loadOverview"
>
<ElOption label="全部站点" value="ALL_SITES" />
<ElOption
v-for="site in sites"
:key="site.id"
:label="`${site.name}(${site.site_id})`"
:value="site.site_id"
/>
</ElSelect>
<ElTooltip content="刷新数据" placement="top">
<ElButton circle :icon="RefreshIcon" :loading="overviewLoading" @click="refreshAll" />
</ElTooltip>
</div>
</div>
<!-- 今日五指标 -->
<div
v-loading="overviewLoading"
class="grid grid-cols-2 gap-4 mb-4 md:grid-cols-3 xl:grid-cols-5"
>
<div
v-for="card in statCards"
:key="card.label"
class="art-card relative h-30 flex flex-col justify-center px-5"
>
<span class="text-sm text-g-600">{{ card.label }}</span>
<ArtCountTo class="mt-1.5 text-[26px] font-medium" :target="card.value" :duration="1300" />
<div class="absolute top-0 bottom-0 right-5 m-auto size-11 rounded-lg flex-cc bg-theme/10">
<ArtSvgIcon :icon="card.icon" class="text-lg text-theme" />
</div>
</div>
</div>
<!-- 近 30 天趋势 -->
<div class="art-card mb-4 px-5 py-4">
<div class="flex-c flex-wrap gap-2 mb-2">
<h3 class="m-0 text-sm font-semibold">近 30 天趋势</h3>
<span class="text-xs text-g-500">今日为实时值,历史为每日聚合</span>
</div>
<ArtLineChart
:data="trendSeries"
:x-axis-data="trendDates"
:colors="lineColors"
:loading="overviewLoading"
height="22rem"
show-legend
legend-position="top"
/>
</div>
<!-- 来源构成 + 热门页面 -->
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
<div class="art-card px-5 py-4">
<div class="flex-c flex-wrap gap-2 mb-2">
<h3 class="m-0 text-sm font-semibold">来源构成(今日 TOP8)</h3>
<span class="text-xs text-g-500">按会话数统计,无referrer 归为"直接访问"</span>
</div>
<ArtRingChart
:data="sourceData"
:colors="pieColors"
:loading="overviewLoading"
height="20rem"
show-legend
legend-position="right"
/>
</div>
<div class="art-card px-5 py-4" v-loading="overviewLoading">
<div class="flex-c flex-wrap gap-2 mb-2">
<h3 class="m-0 text-sm font-semibold">热门页面(今日 TOP8)</h3>
<span class="text-xs text-g-500">按 PV 统计</span>
</div>
<ElTable :data="topPages" height="20rem">
<ElTableColumn type="index" label="#" width="50" />
<ElTableColumn prop="path" label="页面路径" show-overflow-tooltip min-width="200">
<template #default="{ row }">
<span class="font-mono text-xs">{{ row.path }}</span>
</template>
</ElTableColumn>
<ElTableColumn prop="pv" label="PV" width="80" align="right" sortable />
</ElTable>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// 占位页:Task 19 填充真实内容(今日指标 / 趋势 / 来源 / 热门页面)
import { Refresh as RefreshIcon } from '@element-plus/icons-vue'
import { useChartOps } from '@/hooks/core/useChart'
import { getSites, type SiteRow } from '@/api/tracker/sites'
import { getOverview, type OverviewResp } from '@/api/tracker/dashboard'
defineOptions({ name: 'TrackerDashboard' })
/** 图表色板:模板主题色为主,剔除过浅色与重复色(useChartOps 默认第 3 位 #EDF2FF 在浅色底不可见) */
const themeColor = useChartOps().themeColor
const lineColors = [themeColor, '#4ABEFF', '#14DEBA', '#FFAF20']
const pieColors = [
themeColor,
'#4ABEFF',
'#14DEBA',
'#FFAF20',
'#FA8A6C',
'#67C23A',
'#E6A23C',
'#9AA5CE'
]
// 站点筛选(哨兵值 ALL_SITES = 全部站点;空串会被 ElSelect 当空值显示 placeholder)
const ALL_SITES = 'ALL_SITES'
const sites = ref<SiteRow[]>([])
const sitesLoading = ref(false)
const selectedSiteId = ref(ALL_SITES)
// 总览数据
const overview = ref<OverviewResp | null>(null)
const overviewLoading = ref(false)
const loadSites = async () => {
sitesLoading.value = true
try {
sites.value = await getSites()
} catch {
// 失败已由请求层统一 ElMessage 提示,下拉保持"全部站点"
} finally {
sitesLoading.value = false
}
}
const loadOverview = async () => {
overviewLoading.value = true
try {
overview.value = await getOverview(
selectedSiteId.value === ALL_SITES ? undefined : selectedSiteId.value
)
} catch {
// 失败已由请求层统一 ElMessage 提示;overview 置空让图表/表格走空态
overview.value = null
} finally {
overviewLoading.value = false
}
}
const refreshAll = () => {
loadSites()
loadOverview()
}
onMounted(() => {
loadSites()
loadOverview()
})
// 今日五指标卡
const statCards = computed(() => [
{ label: '今日 PV', value: overview.value?.today.pv ?? 0, icon: 'ri:eye-line' },
{ label: '今日 UV', value: overview.value?.today.uv ?? 0, icon: 'ri:user-line' },
{ label: '今日会话', value: overview.value?.today.sessions ?? 0, icon: 'ri:door-open-line' },
{
label: '今日新访客',
value: overview.value?.today.new_visitors ?? 0,
icon: 'ri:user-add-line'
},
{ label: '在线访客', value: overview.value?.today.active_now ?? 0, icon: 'ri:pulse-line' }
])
// 趋势折线:x 轴日期取 MM-DD
const trendDates = computed(() => (overview.value?.trend ?? []).map((p) => p.date.slice(5)))
const trendSeries = computed(() => {
const trend = overview.value?.trend ?? []
return [
{ name: '浏览量', data: trend.map((p) => p.pv) },
{ name: '访客数', data: trend.map((p) => p.uv) },
{ name: '会话数', data: trend.map((p) => p.sessions) },
{ name: '新访客', data: trend.map((p) => p.new_visitors) }
]
})
// 来源饼图("直接访问"由后端天然归并)
const sourceData = computed(() =>
(overview.value?.sources ?? []).map((s) => ({ name: s.source, value: s.sessions }))
)
// 热门页面
const topPages = computed(() => overview.value?.top_pages ?? [])
</script>
+324 -4
View File
@@ -1,10 +1,330 @@
<!-- 访客统计 · 站点管理 -->
<!-- art-full-height 自动计算剩余高度;art-table-card 模板统一样式并撑满 -->
<template>
<div class="p-5">
<h2 class="mb-3 text-lg font-semibold">站点管理</h2>
<div>Task 21 填充</div>
<div class="site-page art-full-height">
<ElCard class="art-table-card">
<!-- 表格头部:列配置 + 刷新 + 新建 -->
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
<template #left>
<ElSpace wrap>
<ElButton type="primary" @click="openDialog('add')" v-ripple>新建站点</ElButton>
</ElSpace>
</template>
</ArtTableHeader>
<!-- 站点表格 -->
<ArtTable :loading="loading" :data="data" :columns="columns"> </ArtTable>
<!-- 新建/编辑弹窗(复用) -->
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新建站点' : '编辑站点'"
width="540px"
destroy-on-close
:close-on-click-modal="false"
>
<ElForm ref="formRef" :model="formData" :rules="formRules" label-width="110px">
<ElFormItem label="站点名称" prop="name">
<ElInput
v-model.trim="formData.name"
maxlength="100"
show-word-limit
placeholder="站点名称,如:官网"
/>
</ElFormItem>
<ElFormItem label="域名白名单" prop="domainsText">
<div class="w-full">
<ElInput
v-model="formData.domainsText"
type="textarea"
:rows="4"
placeholder="每行一个域名,如:&#10;example.com&#10;blog.example.com"
/>
<p v-if="normalizeHint" class="mt-1 mb-0 text-xs text-warning">
{{ normalizeHint }}
</p>
<p class="mt-1 mb-0 text-xs text-g-500">
上报时校验 Origin 域名;自动去除协议/端口/路径并转小写、去重
</p>
</div>
</ElFormItem>
<ElFormItem label="无 Origin 上报">
<div class="flex-c">
<ElSwitch v-model="formData.allow_no_origin" />
<span class="ml-2 text-xs text-g-500">调试用,允许无 Origin 上报</span>
</div>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleSubmit">
{{ dialogType === 'add' ? '创建' : '保存' }}
</ElButton>
</template>
</ElDialog>
</ElCard>
</div>
</template>
<script setup lang="ts">
// 占位页:Task 21 填充真实内容(站点列表 + 创建 / 编辑 / 停用)
import ArtButtonTable from '@/components/core/forms/art-button-table/index.vue'
import { useTable } from '@/hooks/core/useTable'
import {
getSites,
createSite,
updateSite,
deactivateSite,
type SiteRow
} from '@/api/tracker/sites'
import { ElTag, ElButton, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
defineOptions({ name: 'TrackerSite' })
type DialogType = 'add' | 'edit'
/** 域名归一(与后端 _norm_domains 同口径:去协议/端口/路径、转小写) */
const normalizeDomain = (raw: string): string => {
let s = raw.trim().toLowerCase()
if (s.startsWith('https://')) s = s.slice(8)
else if (s.startsWith('http://')) s = s.slice(7)
return s.split('/')[0].split(':')[0]
}
/** textarea 按行解析 → 归一 → 去空去重 */
const parseDomains = (text: string): string[] => {
const out: string[] = []
for (const line of (text || '').split(/\r?\n/)) {
const d = normalizeDomain(line)
if (d && !out.includes(d)) out.push(d)
}
return out
}
// 复制 site_id(clipboard API 不可用时走 execCommand 兜底)
const { copy } = useClipboard({ legacy: true })
const copySiteId = async (siteId: string) => {
try {
await copy(siteId)
ElMessage.success('Site ID 已复制到剪贴板')
} catch {
ElMessage.error('复制失败,请手动选择复制')
}
}
/** 停用/启用(停用是后端软删语义,文案统一用"停用") */
const handleToggle = (row: SiteRow) => {
if (row.is_active) {
ElMessageBox.confirm('停用后该站点上报将被拒绝,确定停用吗?', '停用站点', {
confirmButtonText: '停用',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
await deactivateSite(row.id)
ElMessage.success('站点已停用')
refreshData()
})
.catch(() => {})
} else {
ElMessageBox.confirm('启用后该站点将恢复接收上报,确定启用吗?', '启用站点', {
confirmButtonText: '启用',
cancelButtonText: '取消',
type: 'info'
})
.then(async () => {
await updateSite(row.id, { is_active: true })
ElMessage.success('站点已启用')
refreshData()
})
.catch(() => {})
}
}
const formatDateTime = (iso: string) => (iso ? iso.replace('T', ' ').slice(0, 19) : '—')
const { columns, columnChecks, data, loading, refreshData } = useTable({
core: {
apiFn: getSites,
columnsFactory: () => [
{ prop: 'name', label: '站点名称', minWidth: 140 },
{
prop: 'site_id',
label: 'Site ID',
minWidth: 230,
formatter: (row: SiteRow) =>
h('div', { class: 'flex-c' }, [
h('code', { class: 'site-id-code' }, row.site_id),
h(
ElButton,
{
link: true,
type: 'primary',
size: 'small',
class: 'ml-2',
onClick: () => copySiteId(row.site_id)
},
() => '复制'
)
])
},
{
prop: 'domains',
label: '域名白名单',
minWidth: 260,
formatter: (row: SiteRow) =>
row.domains?.length
? h(
'div',
{ class: 'flex flex-wrap gap-1' },
row.domains.map((d) =>
h(ElTag, { key: d, type: 'info', effect: 'plain' }, () => d)
)
)
: h('span', { class: 'text-g-500' }, '—')
},
{
prop: 'is_active',
label: '状态',
width: 90,
formatter: (row: SiteRow) =>
h(ElTag, { type: row.is_active ? 'success' : 'info' }, () =>
row.is_active ? '启用中' : '已停用'
)
},
{
prop: 'created_at',
label: '创建时间',
width: 170,
sortable: true,
formatter: (row: SiteRow) => formatDateTime(row.created_at)
},
{
prop: 'operation',
label: '操作',
width: 130,
fixed: 'right',
formatter: (row: SiteRow) =>
h('div', { class: 'flex' }, [
h(ArtButtonTable, { type: 'edit', onClick: () => openDialog('edit', row) }),
row.is_active
? h(ArtButtonTable, {
icon: 'ri:stop-circle-line',
iconClass: 'bg-error/12 text-error',
onClick: () => handleToggle(row)
})
: h(ArtButtonTable, {
icon: 'ri:play-circle-line',
iconClass: 'bg-success/12 text-success',
onClick: () => handleToggle(row)
})
])
}
]
}
})
// 弹窗表单
const formRef = ref<FormInstance>()
const dialogVisible = ref(false)
const dialogType = ref<DialogType>('add')
const editingRow = ref<SiteRow | null>(null)
const submitting = ref(false)
const formData = ref({
name: '',
domainsText: '',
allow_no_origin: false
})
const formRules: FormRules = {
name: [{ required: true, message: '请输入站点名称', trigger: 'blur' }],
domainsText: [
{ required: true, message: '请填写域名白名单(每行一个)', trigger: 'blur' },
{
validator: (_rule, value: string, callback) => {
if (value && !parseDomains(value).length) {
callback(new Error('未解析到有效域名,请检查格式'))
} else {
callback()
}
},
trigger: 'blur'
}
]
}
/** 归一预校验提示:输入与归一结果不一致时提示将归一成什么 */
const normalizeHint = computed(() => {
const text = formData.value.domainsText
if (!text.trim()) return ''
const rawLines = text
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean)
const normalized = parseDomains(text)
const changed =
normalized.join('\n') !== rawLines.join('\n') || rawLines.length !== normalized.length
if (!changed) return ''
return `提交后将自动归一为:${normalized.join('、')}(去协议/端口/路径、转小写、去重)`
})
const openDialog = (type: DialogType, row?: SiteRow) => {
dialogType.value = type
editingRow.value = row ?? null
formData.value = {
name: row?.name ?? '',
domainsText: row?.domains?.join('\n') ?? '',
allow_no_origin: row?.allow_no_origin ?? false
}
dialogVisible.value = true
}
const handleSubmit = async () => {
try {
await formRef.value?.validate()
} catch {
return
}
const domains = parseDomains(formData.value.domainsText)
if (!domains.length) {
ElMessage.warning('请至少填写一个有效域名')
return
}
submitting.value = true
try {
if (dialogType.value === 'add') {
await createSite({
name: formData.value.name,
domains,
allow_no_origin: formData.value.allow_no_origin
})
ElMessage.success('站点创建成功')
} else if (editingRow.value) {
await updateSite(editingRow.value.id, {
name: formData.value.name,
domains,
allow_no_origin: formData.value.allow_no_origin
})
ElMessage.success('站点已保存')
}
dialogVisible.value = false
refreshData()
} catch {
// 失败已由请求层统一 ElMessage 提示,弹窗保留输入
} finally {
submitting.value = false
}
}
</script>
<style lang="scss" scoped>
.site-id-code {
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
background: var(--el-fill-color-light);
color: var(--el-text-color-regular);
}
</style>