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>
This commit is contained in:
浅念
2026-09-15 19:58:23 +08:00
parent 3c54ad69c1
commit 99d2a300ba
3 changed files with 353 additions and 6 deletions
+57
View File
@@ -0,0 +1,57 @@
/**
* 访客统计共享格式化工具(列表/详情页复用)
*
* 后端时间戳已统一 timezone.localtime().isoformat()(上海时间)序列化,
* 前端切串直显即可,不做时区换算。
*/
/** 国家 → 国旗 emoji(ip2region 中国 IP 为中文"中国",海外为英文名;未命中默认 🌐) */
const COUNTRY_FLAGS: Record<string, string> = {
中国: '🇨🇳',
'United States': '🇺🇸',
Japan: '🇯🇵',
'South Korea': '🇰🇷',
Singapore: '🇸🇬',
'United Kingdom': '🇬🇧',
Germany: '🇩🇪',
France: '🇫🇷',
Canada: '🇨🇦',
Australia: '🇦🇺',
Russia: '🇷🇺',
India: '🇮🇳',
Brazil: '🇧🇷',
Vietnam: '🇻🇳',
Thailand: '🇹🇭',
Malaysia: '🇲🇾',
Indonesia: '🇮🇩',
Philippines: '🇵🇭',
Netherlands: '🇳🇱',
Italy: '🇮🇹',
Spain: '🇪🇸',
Turkey: '🇹🇷',
Iran: '🇮🇷'
}
/** 国家名 → 国旗 emoji(空/未命中返回 🌐) */
export const countryFlag = (country?: string | null): string =>
(country && COUNTRY_FLAGS[country]) || '🌐'
/** ISO 时间 → YYYY-MM-DD HH:mm(后端已是上海时间;空值显示 —) */
export const formatDateTime = (iso?: string | null): string =>
iso ? iso.replace('T', ' ').slice(0, 16) : '—'
/** 毫秒 → x天x小时x分x秒(智能省略:为 0 的单位不显示;全 0 / 不足 1 秒显示 0秒) */
export const formatStay = (ms?: number | null): string => {
const totalSec = Math.max(0, Math.floor((ms ?? 0) / 1000))
const days = Math.floor(totalSec / 86400)
const hours = Math.floor((totalSec % 86400) / 3600)
const minutes = Math.floor((totalSec % 3600) / 60)
const seconds = totalSec % 60
const parts: string[] = []
if (days) parts.push(`${days}天`)
if (hours) parts.push(`${hours}小时`)
if (minutes) parts.push(`${minutes}分`)
// 秒恒显示(含 0),避免"0秒/不足 1 秒"整行变空
if (seconds || !parts.length) parts.push(`${seconds}秒`)
return parts.join('')
}
+1 -2
View File
@@ -78,6 +78,7 @@
} 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' })
@@ -145,8 +146,6 @@
}
}
const formatDateTime = (iso: string) => (iso ? iso.replace('T', ' ').slice(0, 19) : '—')
const { columns, columnChecks, data, loading, refreshData } = useTable({
core: {
apiFn: getSites,
+295 -4
View File
@@ -1,10 +1,301 @@
<!-- 访客统计 · 访客列表 -->
<!-- 筛选区外置独立卡片(dashboard 同款),避免挤占 ArtTable 的高度计算预算 -->
<template>
<div class="p-5">
<h2 class="mb-3 text-lg font-semibold">访客管理</h2>
<div>Task 20 填充</div>
<div class="visitor-page art-full-height">
<!-- 筛选栏 -->
<div class="art-card flex-c flex-wrap gap-x-4 gap-y-3 mb-3 px-5 py-4">
<ElInput
v-model="filters.search"
class="w-60"
placeholder="访客 ID / IP 前缀"
clearable
@keyup.enter="applyFilters"
@clear="applyFilters"
/>
<ElSelect
v-model="filters.deviceType"
class="w-32"
placeholder="全部设备"
clearable
@change="applyFilters"
>
<ElOption label="PC" value="PC" />
<ElOption label="手机" value="手机" />
<ElOption label="平板" value="平板" />
</ElSelect>
<ElInput
v-model="filters.country"
class="w-44"
placeholder="国家(精确匹配)"
clearable
@keyup.enter="applyFilters"
@clear="applyFilters"
/>
<div class="flex-c gap-1.5">
<span class="text-sm text-g-600">在线</span>
<ElSwitch v-model="filters.online" @change="applyFilters" />
</div>
<ElDatePicker
v-model="filters.dateRange"
type="daterange"
class="w-64"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="最近访问起"
end-placeholder="最近访问止"
unlink-panels
@change="applyFilters"
/>
<ElTooltip
content="默认隐藏爬虫访客(按 UA 识别);打开后列表包含爬虫,供管理员排查"
placement="top"
>
<div class="flex-c gap-1.5">
<span class="text-sm text-g-600">显示爬虫</span>
<ElSwitch v-model="filters.showBot" @change="applyFilters" />
</div>
</ElTooltip>
<div class="ml-auto flex-c gap-2">
<ElButton type="primary" @click="applyFilters">查询</ElButton>
<ElButton @click="resetFilters">重置</ElButton>
</div>
</div>
<!-- 访客表格 -->
<ElCard class="art-table-card">
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData" />
<ArtTable
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
:row-class-name="() => 'visitor-row'"
:pagination-options="{ layout: 'total, prev, pager, next, jumper' }"
empty-text="暂无访客数据"
@row-click="handleRowClick"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
/>
</ElCard>
</div>
</template>
<script setup lang="ts">
// 占位页:Task 20 填充真实内容(访客列表 + 筛选 + 分页)
import { ElButton } from 'element-plus'
import { useTable } from '@/hooks/core/useTable'
import { getVisitors, type VisitorListParams, type VisitorRow } from '@/api/tracker/visitors'
import { countryFlag, formatDateTime, formatStay } from '@/utils/tracker'
defineOptions({ name: 'TrackerVisitor' })
// 复制完整 visitor_id(clipboard API 不可用时走 execCommand 兜底)
const { copy } = useClipboard({ legacy: true })
const copyVisitorId = async (visitorId: string) => {
try {
await copy(visitorId)
ElMessage.success('访客 ID 已复制到剪贴板')
} catch {
ElMessage.error('复制失败,请手动选择复制')
}
}
// 行点击 → 详情页(query 携带 visitor_id;详情页为 Task 21)
const router = useRouter()
const handleRowClick = (row: VisitorRow) => {
router.push({ path: '/tracker/visitor/detail', query: { visitor_id: row.visitor_id } })
}
// ---------- 筛选 ----------
const filters = reactive({
search: '',
deviceType: '',
country: '',
online: false,
showBot: false,
dateRange: null as [string, string] | null
})
/** 组装请求参数:online/show_bot 用后端哨兵 '1'(类型已锁字面量),空值不发送 */
const buildFilterParams = (): VisitorListParams => {
const params: VisitorListParams = {}
const search = filters.search.trim()
if (search) params.search = search
if (filters.deviceType) params.device_type = filters.deviceType
const country = filters.country.trim()
if (country) params.country = country
if (filters.online) params.online = '1'
if (filters.showBot) params.show_bot = '1'
const [dateFrom, dateTo] = filters.dateRange || []
if (dateFrom) params.date_from = dateFrom
if (dateTo) params.date_to = dateTo
return params
}
// ---------- 单元格渲染 ----------
const codeStyle = {
padding: '2px 8px',
borderRadius: '4px',
fontSize: '12px',
background: 'var(--el-fill-color-light)',
color: 'var(--el-text-color-regular)'
} satisfies Record<string, string>
const renderIdCell = (row: VisitorRow) =>
h('div', { class: 'flex-c' }, [
h('code', { style: codeStyle }, row.visitor_id.slice(0, 8)),
h(
ElButton,
{
link: true,
type: 'primary',
size: 'small',
class: 'ml-2',
onClick: (e: MouseEvent) => {
e.stopPropagation()
copyVisitorId(row.visitor_id)
}
},
() => '复制'
)
])
const renderLocation = (row: VisitorRow) => {
const region = [row.province, row.city].filter(Boolean).join(' ')
const text = [row.country, region].filter(Boolean).join(' · ')
return h('span', { class: 'whitespace-nowrap' }, `${countryFlag(row.country)} ${text || '—'}`)
}
const renderDevice = (row: VisitorRow) => {
const env = [row.os, row.browser].filter(Boolean).join(' · ')
return h('div', null, [
h('div', { class: 'whitespace-nowrap' }, row.device_type || '—'),
env ? h('div', { class: 'text-xs text-g-500 whitespace-nowrap mt-0.5' }, env) : null
])
}
const renderOnline = (row: VisitorRow) =>
h('div', { class: 'flex-c gap-1.5 whitespace-nowrap' }, [
h('span', {
style: {
width: '8px',
height: '8px',
borderRadius: '50%',
flexShrink: '0',
background: row.online ? 'var(--el-color-success)' : 'var(--el-text-color-disabled)'
}
}),
h('span', null, row.online ? '在线' : '离线')
])
const renderSource = (val: string) => h('span', { class: 'whitespace-nowrap' }, val || '—')
// ---------- 表格 ----------
/** 后端页大小固定 20(PAGE_SIZE):useTable 侧同步 size=20 才能算对总页数;size 不实际发送 */
const fetchVisitors = (params: VisitorListParams & { size?: number }) => getVisitors(params)
const {
columns,
columnChecks,
data,
loading,
pagination,
getData,
replaceSearchParams,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: fetchVisitors,
apiParams: { size: 20 },
// 后端分页参数是 page(无 size);响应 {list,total,page} 由默认适配器识别
paginationKey: { current: 'page' },
excludeParams: ['size'],
columnsFactory: () => [
{
prop: 'visitor_id',
label: '访客 ID',
minWidth: 170,
fixed: 'left',
formatter: renderIdCell
},
{
prop: 'country',
label: '地理位置',
minWidth: 200,
formatter: renderLocation
},
{
prop: 'device_type',
label: '设备',
minWidth: 130,
formatter: renderDevice
},
{
prop: 'first_seen_at',
label: '首次访问',
width: 140,
formatter: (row: VisitorRow) => formatDateTime(row.first_seen_at)
},
{
prop: 'last_seen_at',
label: '最近访问',
width: 140,
formatter: (row: VisitorRow) => formatDateTime(row.last_seen_at)
},
{ prop: 'visit_count', label: '访问次数', width: 90 },
{ prop: 'active_days', label: '活跃天数', width: 90 },
{
prop: 'total_stay_ms',
label: '总停留',
width: 140,
formatter: (row: VisitorRow) => formatStay(row.total_stay_ms)
},
{
prop: 'first_source',
label: '首触来源',
minWidth: 110,
formatter: (row: VisitorRow) => renderSource(row.first_source)
},
{
prop: 'last_source',
label: '最近来源',
minWidth: 110,
formatter: (row: VisitorRow) => renderSource(row.last_source)
},
{
prop: 'online',
label: '状态',
width: 90,
formatter: renderOnline
}
]
}
})
/** 应用筛选:整组替换查询参数并回到第 1 页(分页切换走 handleCurrentChange 保留筛选) */
const applyFilters = () => {
replaceSearchParams(buildFilterParams())
getData()
}
const resetFilters = () => {
Object.assign(filters, {
search: '',
deviceType: '',
country: '',
online: false,
showBot: false,
dateRange: null
})
applyFilters()
}
</script>
<style lang="scss" scoped>
/* formatter 渲染的单元格样式走内联(不携带本组件 scopeId),行级光标用 :deep 穿透 */
:deep(.visitor-row) {
cursor: pointer;
}
</style>