99d2a300ba
- 访客管理页:useTable 分页表(后端固定 20/页,paginationKey 映射 page, size 不发送),列含 截断 ID+复制 / 国旗 emoji+国家·省市 / 设备主行+os·browser 小字 / 首访·最近访问(共享 formatDateTime,上海时间直显)/ 访问次数 / 活跃天数 / 总停留 formatStay / 首触·最近来源 / 在线绿点;行点击进详情路由 - 筛选栏:关键词(visitor_id/IP 前缀)回车或查询触发、设备下拉、国家精确、 在线开关(online='1' 哨兵)、日期范围 date_from/date_to、显示爬虫开关 (默认排除);筛选变化重置 page=1,分页切换保留筛选 - 共享工具提取 admin/src/utils/tracker:formatDateTime/formatStay/countryFlag, site 页改引用;formatStay 智能省略(0 单位不显示,全 0 显示 0秒) - 红线:无 v-html,访客数据不绑 href/src;加载/空态齐全(错误态走请求层统一 toast) Co-Authored-By: Claude Code <noreply@anthropic.com>
342 lines
11 KiB
Vue
342 lines
11 KiB
Vue
<!-- 访客统计 · 站点管理 -->
|
||
<!-- art-full-height 自动计算剩余高度;art-table-card 模板统一样式并撑满 -->
|
||
<template>
|
||
<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">
|
||
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'
|
||
import { formatDateTime } from '@/utils/tracker'
|
||
|
||
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]
|
||
}
|
||
|
||
/** 轻量主机名校验:点分级,每级字母/数字/连字符且不以连字符开头结尾(IPv4/单级内网名天然匹配) */
|
||
const HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/
|
||
|
||
/** 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 { 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('未解析到有效域名,请检查格式'))
|
||
return
|
||
}
|
||
const invalid = parseDomains(value).filter((d) => !HOSTNAME_RE.test(d))
|
||
if (invalid.length) {
|
||
callback(new Error(`以下内容不是有效主机名:${invalid.join('、')}`))
|
||
return
|
||
}
|
||
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 invalid = normalized.filter((d) => !HOSTNAME_RE.test(d))
|
||
if (invalid.length) {
|
||
return `以下内容不是有效主机名:${invalid.join('、')}(仅限字母/数字/连字符/点)`
|
||
}
|
||
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>
|