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,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