1. 海报
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -251,6 +252,117 @@ func truncateRunes(s string, max int) string {
|
||||
return string(r[:max])
|
||||
}
|
||||
|
||||
// parseDeviceFromUA 从 UA 解析机型/平台,用于用户画像统计
|
||||
func parseDeviceFromUA(ua string) string {
|
||||
ua = strings.TrimSpace(ua)
|
||||
if ua == "" {
|
||||
return "未知"
|
||||
}
|
||||
lower := strings.ToLower(ua)
|
||||
|
||||
if strings.Contains(ua, "iPhone") {
|
||||
return "iPhone"
|
||||
}
|
||||
if strings.Contains(ua, "iPad") {
|
||||
return "iPad"
|
||||
}
|
||||
if strings.Contains(lower, "android") {
|
||||
if model := extractAndroidModel(ua); model != "" {
|
||||
return model
|
||||
}
|
||||
return "Android"
|
||||
}
|
||||
if strings.Contains(lower, "windows phone") {
|
||||
return "Windows Phone"
|
||||
}
|
||||
if strings.Contains(lower, "windows") {
|
||||
return "Windows"
|
||||
}
|
||||
if strings.Contains(ua, "Macintosh") || strings.Contains(lower, "mac os") {
|
||||
return "Mac"
|
||||
}
|
||||
if strings.Contains(lower, "linux") {
|
||||
return "Linux"
|
||||
}
|
||||
return "其他"
|
||||
}
|
||||
|
||||
func extractAndroidModel(ua string) string {
|
||||
// 常见形态:Linux; Android 13; SM-S9180 Build/... 或 Android 12; Pixel 7)
|
||||
idx := strings.Index(ua, "Android")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := ua[idx:]
|
||||
semi := strings.Index(rest, ";")
|
||||
if semi < 0 {
|
||||
return ""
|
||||
}
|
||||
rest = strings.TrimSpace(rest[semi+1:])
|
||||
end := len(rest)
|
||||
for _, sep := range []string{" Build", ")", "; HMSCore", "; HarmonyOS"} {
|
||||
if p := strings.Index(rest, sep); p >= 0 && p < end {
|
||||
end = p
|
||||
}
|
||||
}
|
||||
model := strings.TrimSpace(rest[:end])
|
||||
model = strings.Trim(model, "; ")
|
||||
if model == "" || strings.EqualFold(model, "wv") || strings.HasPrefix(strings.ToLower(model), "linux") {
|
||||
return ""
|
||||
}
|
||||
// 过长型号截断
|
||||
r := []rune(model)
|
||||
if len(r) > 40 {
|
||||
model = string(r[:40])
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
func aggregateDevices() []map[string]interface{} {
|
||||
type uaRow struct {
|
||||
Fingerprint string
|
||||
UserAgent string
|
||||
LastSeenAt time.Time
|
||||
}
|
||||
var rows []uaRow
|
||||
db.Model(&SiteVisit{}).
|
||||
Select("fingerprint, user_agent, last_seen_at").
|
||||
Order("last_seen_at desc").
|
||||
Find(&rows)
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
counts := map[string]int64{}
|
||||
for _, r := range rows {
|
||||
if _, ok := seen[r.Fingerprint]; ok {
|
||||
continue
|
||||
}
|
||||
seen[r.Fingerprint] = struct{}{}
|
||||
name := parseDeviceFromUA(r.UserAgent)
|
||||
counts[name]++
|
||||
}
|
||||
|
||||
type kv struct {
|
||||
Name string
|
||||
Value int64
|
||||
}
|
||||
list := make([]kv, 0, len(counts))
|
||||
for name, value := range counts {
|
||||
list = append(list, kv{Name: name, Value: value})
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].Value > list[j].Value })
|
||||
if len(list) > 30 {
|
||||
list = list[:30]
|
||||
}
|
||||
out := make([]map[string]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
out = append(out, map[string]interface{}{
|
||||
"name": item.Name,
|
||||
"value": item.Value,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func handleTrackVisit(c *gin.Context) {
|
||||
var body struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
@@ -372,6 +484,8 @@ func handleVisitStats(c *gin.Context) {
|
||||
regions = []regionRow{}
|
||||
}
|
||||
|
||||
devices := aggregateDevices()
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"code": 200,
|
||||
"data": gin.H{
|
||||
@@ -379,6 +493,7 @@ func handleVisitStats(c *gin.Context) {
|
||||
"pv": pv,
|
||||
"today_uv": todayUV,
|
||||
"regions": regions,
|
||||
"devices": devices,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -408,10 +523,31 @@ func handleVisitList(c *gin.Context) {
|
||||
list = []SiteVisit{}
|
||||
}
|
||||
|
||||
enriched := make([]gin.H, 0, len(list))
|
||||
for _, row := range list {
|
||||
enriched = append(enriched, gin.H{
|
||||
"id": row.ID,
|
||||
"fingerprint": row.Fingerprint,
|
||||
"client_id": row.ClientID,
|
||||
"ip": row.IP,
|
||||
"country": row.Country,
|
||||
"region": row.Region,
|
||||
"city": row.City,
|
||||
"isp": row.ISP,
|
||||
"raw_location": row.RawLocation,
|
||||
"user_agent": row.UserAgent,
|
||||
"device": parseDeviceFromUA(row.UserAgent),
|
||||
"referer": row.Referer,
|
||||
"visit_count": row.VisitCount,
|
||||
"first_seen_at": row.FirstSeenAt,
|
||||
"last_seen_at": row.LastSeenAt,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"code": 200,
|
||||
"data": gin.H{
|
||||
"list": list,
|
||||
"list": enriched,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
|
||||
@@ -54,9 +54,9 @@
|
||||
<div class="flex justify-between items-center mb-4 gap-3 flex-wrap">
|
||||
<div>
|
||||
<div class="text-sm text-[#2c2c2c]">访客概览</div>
|
||||
<div class="text-[11px] text-[#8A8680] mt-0.5">指纹去重访客与地区分布</div>
|
||||
<div class="text-[11px] text-[#8A8680] mt-0.5">指纹去重访客 · 地区 / 机型画像</div>
|
||||
</div>
|
||||
<a-button size="small" :loading="visitLoading" @click="loadOverview">
|
||||
<a-button size="small" :loading="visitStatsLoading" @click="loadVisitStats">
|
||||
<i class="fa-solid fa-rotate-right mr-1"></i>刷新
|
||||
</a-button>
|
||||
</div>
|
||||
@@ -82,41 +82,90 @@
|
||||
<div ref="regionChartRef" class="h-[280px] w-full"></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="text-[12px] text-[#7a6550] mb-2 tracking-wide">说明</div>
|
||||
<ul class="text-[12px] text-[#8A8680] leading-relaxed space-y-2 m-0 pl-4">
|
||||
<li>进入请柬页会采集浏览器指纹与 IP,服务端解析归属地。</li>
|
||||
<li>同一指纹 24 小时内重复访问会累加次数,不新增访客。</li>
|
||||
<li>切到本页会重新拉取最新统计与列表。</li>
|
||||
</ul>
|
||||
<div class="text-[12px] text-[#7a6550] mb-2 tracking-wide">机型画像</div>
|
||||
<div ref="deviceChartRef" class="h-[280px] w-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-[12px] text-[#7a6550] mb-2 tracking-wide">近期访客</div>
|
||||
<a-table
|
||||
class="admin-table"
|
||||
:columns="visitColumns"
|
||||
:data-source="visitList"
|
||||
:loading="visitLoading"
|
||||
row-key="id"
|
||||
size="middle"
|
||||
:pagination="visitPagination"
|
||||
@change="onVisitTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'fingerprint'">
|
||||
<span class="font-mono text-[11px]" :title="record.fingerprint">{{ shortFp(record.fingerprint) }}</span>
|
||||
<div class="flex justify-between items-center mb-2 gap-2 flex-wrap">
|
||||
<div class="text-[12px] text-[#7a6550] tracking-wide">近期访客预览</div>
|
||||
<a-button size="small" type="link" class="!text-[#a88c6b] !px-0" @click="activeTab = 'visits'">
|
||||
查看全部访客记录 <i class="fa-solid fa-arrow-right ml-1 text-[10px]"></i>
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="visit-preview-wrap">
|
||||
<a-table
|
||||
class="admin-table"
|
||||
:columns="visitPreviewColumns"
|
||||
:data-source="visitPreviewList"
|
||||
:loading="visitListLoading"
|
||||
row-key="id"
|
||||
size="small"
|
||||
:pagination="false"
|
||||
:scroll="{ y: 260 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'fingerprint'">
|
||||
<span class="font-mono text-[11px]" :title="record.fingerprint">{{ shortFp(record.fingerprint) }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'location'">
|
||||
<span>{{ record.raw_location || [record.region, record.city].filter(Boolean).join(' ') || '未知' }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'device'">
|
||||
<span class="text-[12px]">{{ record.device || '未知' }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'last_seen_at'">
|
||||
<span class="text-[12px] text-[#8A8680]">{{ formatTime(record.last_seen_at) }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'location'">
|
||||
<span>{{ record.raw_location || [record.region, record.city].filter(Boolean).join(' ') || '未知' }}</span>
|
||||
</a-table>
|
||||
</div>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 访客记录 -->
|
||||
<a-tab-pane key="visits" tab="访客记录">
|
||||
<div v-if="activeTab === 'visits'" class="admin-panel" :class="{ 'opacity-60 pointer-events-none': tabLoading }">
|
||||
<div class="flex justify-between items-center mb-4 gap-3 flex-wrap">
|
||||
<div>
|
||||
<div class="text-sm text-[#2c2c2c]">访客记录</div>
|
||||
<div class="text-[11px] text-[#8A8680] mt-0.5">共 {{ visitTotal }} 条,分页浏览</div>
|
||||
</div>
|
||||
<a-button size="small" :loading="visitListLoading" @click="loadVisitList">
|
||||
<i class="fa-solid fa-rotate-right mr-1"></i>刷新
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="visit-list-wrap">
|
||||
<a-table
|
||||
class="admin-table"
|
||||
:columns="visitColumns"
|
||||
:data-source="visitList"
|
||||
:loading="visitListLoading"
|
||||
row-key="id"
|
||||
size="middle"
|
||||
:pagination="visitPagination"
|
||||
:scroll="{ y: 'calc(100dvh - 280px)', x: 960 }"
|
||||
@change="onVisitTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'fingerprint'">
|
||||
<span class="font-mono text-[11px]" :title="record.fingerprint">{{ shortFp(record.fingerprint) }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'location'">
|
||||
<span>{{ record.raw_location || [record.region, record.city].filter(Boolean).join(' ') || '未知' }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'device'">
|
||||
<span class="text-[12px]" :title="record.user_agent">{{ record.device || '未知' }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'user_agent'">
|
||||
<span class="text-[11px] text-[#8A8680]" :title="record.user_agent">{{ shortUa(record.user_agent) }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'last_seen_at'">
|
||||
<span class="text-[12px] text-[#8A8680]">{{ formatTime(record.last_seen_at) }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'user_agent'">
|
||||
<span class="text-[11px] text-[#8A8680]" :title="record.user_agent">{{ shortUa(record.user_agent) }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'last_seen_at'">
|
||||
<span class="text-[12px] text-[#8A8680]">{{ formatTime(record.last_seen_at) }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-table>
|
||||
</div>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
@@ -937,7 +986,7 @@ const adminStore = useAdminStore()
|
||||
|
||||
const TAB_KEY = 'wedding_admin_active_tab'
|
||||
const CONFIG_SECTIONS = ['basic', 'copy', 'visual', 'music', 'photos', 'schedule']
|
||||
const VALID_TABS = ['overview', ...CONFIG_SECTIONS, 'upload', 'poster', 'rsvp', 'danmaku']
|
||||
const VALID_TABS = ['overview', 'visits', ...CONFIG_SECTIONS, 'upload', 'poster', 'rsvp', 'danmaku']
|
||||
|
||||
const SECTION_KEYS = {
|
||||
basic: ['groom', 'bride', 'date', 'lunar', 'calendarDate', 'hotel', 'address', 'heroImg', 'endImg'],
|
||||
@@ -975,13 +1024,18 @@ const danmakuLoading = ref(false)
|
||||
const danmakuList = ref([])
|
||||
const danmakuFilter = ref('pending')
|
||||
|
||||
const visitLoading = ref(false)
|
||||
const visitStats = reactive({ uv: 0, pv: 0, today_uv: 0, regions: [] })
|
||||
const visitStatsLoading = ref(false)
|
||||
const visitListLoading = ref(false)
|
||||
const visitStats = reactive({ uv: 0, pv: 0, today_uv: 0, regions: [], devices: [] })
|
||||
const visitList = ref([])
|
||||
const visitPreviewList = ref([])
|
||||
const visitPage = ref(1)
|
||||
const visitPageSize = ref(20)
|
||||
const visitTotal = ref(0)
|
||||
const regionChartRef = ref(null)
|
||||
const deviceChartRef = ref(null)
|
||||
let regionChart = null
|
||||
let deviceChart = null
|
||||
|
||||
const cfg = reactive(defaultConfig())
|
||||
const uploadCfg = reactive(defaultUploadConfig())
|
||||
@@ -1026,10 +1080,11 @@ const currentPhotoIndex = computed(() => Math.min(Number(activePhotoTab.value) |
|
||||
const isConfigTab = computed(() => CONFIG_SECTIONS.includes(activeTab.value))
|
||||
const visitPagination = computed(() => ({
|
||||
current: visitPage.value,
|
||||
pageSize: 20,
|
||||
pageSize: visitPageSize.value,
|
||||
total: visitTotal.value,
|
||||
hideOnSinglePage: true,
|
||||
showSizeChanger: false,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}))
|
||||
|
||||
function defaultConfig() {
|
||||
@@ -1554,10 +1609,19 @@ async function onSave() {
|
||||
}
|
||||
}
|
||||
|
||||
const visitPreviewColumns = [
|
||||
{ title: '指纹', key: 'fingerprint', width: 110 },
|
||||
{ title: '归属地', key: 'location', ellipsis: true },
|
||||
{ title: '机型', key: 'device', width: 120, ellipsis: true },
|
||||
{ title: '次数', dataIndex: 'visit_count', key: 'visit_count', width: 64 },
|
||||
{ title: '最近访问', key: 'last_seen_at', width: 160 },
|
||||
]
|
||||
|
||||
const visitColumns = [
|
||||
{ title: '指纹', key: 'fingerprint', width: 120 },
|
||||
{ title: 'IP', dataIndex: 'ip', key: 'ip', width: 130 },
|
||||
{ title: '归属地', key: 'location', ellipsis: true },
|
||||
{ title: '机型', key: 'device', width: 140, ellipsis: true },
|
||||
{ title: '次数', dataIndex: 'visit_count', key: 'visit_count', width: 70 },
|
||||
{ title: 'UA', key: 'user_agent', ellipsis: true },
|
||||
{ title: '最近访问', key: 'last_seen_at', width: 170 },
|
||||
@@ -1572,25 +1636,31 @@ function shortUa(ua) {
|
||||
return ua.length > 42 ? `${ua.slice(0, 42)}…` : ua
|
||||
}
|
||||
|
||||
function disposeRegionChart() {
|
||||
const PIE_COLORS = ['#a88c6b', '#c4a484', '#e8b4b8', '#7c9eb2', '#b5a4c9', '#d4a574', '#9b7e9a', '#64748b']
|
||||
|
||||
function disposeVisitCharts() {
|
||||
if (regionChart) {
|
||||
regionChart.dispose()
|
||||
regionChart = null
|
||||
}
|
||||
if (deviceChart) {
|
||||
deviceChart.dispose()
|
||||
deviceChart = null
|
||||
}
|
||||
}
|
||||
|
||||
function renderRegionChart() {
|
||||
if (!regionChartRef.value) return
|
||||
if (regionChart && regionChart.getDom() !== regionChartRef.value) {
|
||||
disposeRegionChart()
|
||||
function renderPieChart(chartRef, holder, seriesData, emptyName = '暂无数据') {
|
||||
if (!chartRef.value) return null
|
||||
if (holder && holder.getDom() !== chartRef.value) {
|
||||
holder.dispose()
|
||||
holder = null
|
||||
}
|
||||
if (!regionChart) regionChart = echarts.init(regionChartRef.value)
|
||||
const regions = Array.isArray(visitStats.regions) ? visitStats.regions : []
|
||||
const data = regions.length
|
||||
? regions.map((r) => ({ name: r.name || '未知', value: Number(r.value) || 0 }))
|
||||
: [{ name: '暂无数据', value: 0 }]
|
||||
regionChart.setOption({
|
||||
color: ['#a88c6b', '#c4a484', '#e8b4b8', '#7c9eb2', '#b5a4c9', '#d4a574', '#9b7e9a', '#64748b'],
|
||||
if (!holder) holder = echarts.init(chartRef.value)
|
||||
const data = seriesData.length
|
||||
? seriesData.map((r) => ({ name: r.name || '未知', value: Number(r.value) || 0 }))
|
||||
: [{ name: emptyName, value: 0 }]
|
||||
holder.setOption({
|
||||
color: PIE_COLORS,
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: {
|
||||
type: 'scroll',
|
||||
@@ -1606,45 +1676,97 @@ function renderRegionChart() {
|
||||
data,
|
||||
}],
|
||||
})
|
||||
return holder
|
||||
}
|
||||
|
||||
async function loadOverview() {
|
||||
visitLoading.value = true
|
||||
function renderRegionChart() {
|
||||
regionChart = renderPieChart(
|
||||
regionChartRef,
|
||||
regionChart,
|
||||
Array.isArray(visitStats.regions) ? visitStats.regions : [],
|
||||
)
|
||||
}
|
||||
|
||||
function renderDeviceChart() {
|
||||
deviceChart = renderPieChart(
|
||||
deviceChartRef,
|
||||
deviceChart,
|
||||
Array.isArray(visitStats.devices) ? visitStats.devices : [],
|
||||
)
|
||||
}
|
||||
|
||||
async function loadVisitStats() {
|
||||
visitStatsLoading.value = true
|
||||
try {
|
||||
const [statsRes, listRes] = await Promise.all([
|
||||
getVisitStats(),
|
||||
getVisitList({ page: visitPage.value, pageSize: 20 }),
|
||||
])
|
||||
const statsRes = await getVisitStats()
|
||||
const s = statsRes.data || {}
|
||||
visitStats.uv = Number(s.uv) || 0
|
||||
visitStats.pv = Number(s.pv) || 0
|
||||
visitStats.today_uv = Number(s.today_uv) || 0
|
||||
visitStats.regions = Array.isArray(s.regions) ? s.regions : []
|
||||
visitStats.devices = Array.isArray(s.devices) ? s.devices : []
|
||||
await nextTick()
|
||||
renderRegionChart()
|
||||
renderDeviceChart()
|
||||
} catch (e) {
|
||||
message.error(e?.message || '加载访客统计失败')
|
||||
} finally {
|
||||
visitStatsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVisitPreview() {
|
||||
visitListLoading.value = true
|
||||
try {
|
||||
const listRes = await getVisitList({ page: 1, pageSize: 10 })
|
||||
const listData = listRes.data || {}
|
||||
visitPreviewList.value = listData.list || []
|
||||
visitTotal.value = Number(listData.total) || 0
|
||||
} catch (e) {
|
||||
message.error(e?.message || '加载访客预览失败')
|
||||
} finally {
|
||||
visitListLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVisitList() {
|
||||
visitListLoading.value = true
|
||||
try {
|
||||
const listRes = await getVisitList({
|
||||
page: visitPage.value,
|
||||
pageSize: visitPageSize.value,
|
||||
})
|
||||
const listData = listRes.data || {}
|
||||
visitList.value = listData.list || []
|
||||
visitTotal.value = Number(listData.total) || 0
|
||||
await nextTick()
|
||||
renderRegionChart()
|
||||
} catch (e) {
|
||||
message.error(e?.message || '加载访客数据失败')
|
||||
message.error(e?.message || '加载访客记录失败')
|
||||
} finally {
|
||||
visitLoading.value = false
|
||||
visitListLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOverview() {
|
||||
await Promise.all([loadVisitStats(), loadVisitPreview()])
|
||||
}
|
||||
|
||||
function onVisitTableChange(pag) {
|
||||
visitPage.value = pag?.current || 1
|
||||
loadOverview()
|
||||
visitPageSize.value = pag?.pageSize || visitPageSize.value
|
||||
loadVisitList()
|
||||
}
|
||||
|
||||
async function onTabActivate(tab) {
|
||||
if (!adminStore.isAdmin) return
|
||||
if (tab !== 'overview') disposeRegionChart()
|
||||
if (tab !== 'overview') disposeVisitCharts()
|
||||
if (tab === 'overview') {
|
||||
visitPage.value = 1
|
||||
await loadOverview()
|
||||
return
|
||||
}
|
||||
if (tab === 'visits') {
|
||||
await loadVisitList()
|
||||
return
|
||||
}
|
||||
if (CONFIG_SECTIONS.includes(tab)) {
|
||||
await loadConfigSection(tab)
|
||||
return
|
||||
@@ -1753,7 +1875,10 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
const onResize = () => { regionChart?.resize() }
|
||||
const onResize = () => {
|
||||
regionChart?.resize()
|
||||
deviceChart?.resize()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onResize)
|
||||
@@ -1761,7 +1886,7 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
disposeRegionChart()
|
||||
disposeVisitCharts()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1804,6 +1929,17 @@ onUnmounted(() => {
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.visit-preview-wrap,
|
||||
.visit-list-wrap {
|
||||
border: 1px solid rgba(168, 140, 107, 0.12);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #fffdf9;
|
||||
}
|
||||
.visit-preview-wrap :deep(.ant-table-body),
|
||||
.visit-list-wrap :deep(.ant-table-body) {
|
||||
max-height: inherit;
|
||||
}
|
||||
.chart-card {
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
|
||||
Reference in New Issue
Block a user