2026-09-15 18:32:25 +08:00
|
|
|
|
<!-- 访客统计 · 站点管理 -->
|
|
|
|
|
|
<!-- art-full-height 自动计算剩余高度;art-table-card 模板统一样式并撑满 -->
|
2026-09-15 17:50:36 +08:00
|
|
|
|
<template>
|
2026-09-15 18:32:25 +08:00
|
|
|
|
<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>
|
2026-09-15 17:50:36 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-09-15 18:32:25 +08:00
|
|
|
|
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'
|
2026-09-15 19:58:23 +08:00
|
|
|
|
import { formatDateTime } from '@/utils/tracker'
|
2026-09-15 18:32:25 +08:00
|
|
|
|
|
|
|
|
|
|
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]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 18:57:41 +08:00
|
|
|
|
/** 轻量主机名校验:点分级,每级字母/数字/连字符且不以连字符开头结尾(IPv4/单级内网名天然匹配) */
|
|
|
|
|
|
const HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/
|
|
|
|
|
|
|
2026-09-15 18:32:25 +08:00
|
|
|
|
/** 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('未解析到有效域名,请检查格式'))
|
2026-09-15 18:57:41 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const invalid = parseDomains(value).filter((d) => !HOSTNAME_RE.test(d))
|
|
|
|
|
|
if (invalid.length) {
|
|
|
|
|
|
callback(new Error(`以下内容不是有效主机名:${invalid.join('、')}`))
|
|
|
|
|
|
return
|
2026-09-15 18:32:25 +08:00
|
|
|
|
}
|
2026-09-15 18:57:41 +08:00
|
|
|
|
callback()
|
2026-09-15 18:32:25 +08:00
|
|
|
|
},
|
|
|
|
|
|
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)
|
2026-09-15 18:57:41 +08:00
|
|
|
|
const invalid = normalized.filter((d) => !HOSTNAME_RE.test(d))
|
|
|
|
|
|
if (invalid.length) {
|
|
|
|
|
|
return `以下内容不是有效主机名:${invalid.join('、')}(仅限字母/数字/连字符/点)`
|
|
|
|
|
|
}
|
2026-09-15 18:32:25 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-15 17:50:36 +08:00
|
|
|
|
</script>
|
2026-09-15 18:32:25 +08:00
|
|
|
|
|
|
|
|
|
|
<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>
|