Files
fangke/admin/src/views/tracker/site/index.vue
T
浅念 99d2a300ba feat(admin): 访客列表页
- 访客管理页: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>
2026-09-15 19:58:23 +08:00

342 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 访客统计 · 站点管理 -->
<!-- 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="每行一个域名,如:&#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">
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>