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:
@@ -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('')
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user