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:
@@ -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>
|
||||
|
||||
@@ -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="每行一个域名,如: example.com 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>
|
||||
|
||||
Reference in New Issue
Block a user