1. 迎宾海报
This commit is contained in:
249
hunliji-api/visit/device_names.go
Normal file
249
hunliji-api/visit/device_names.go
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
package visit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// friendlyAndroidName 将 UA 中的型号码映射为可读名称(品牌 + 营销名/型号)
|
||||||
|
func friendlyAndroidName(model string) string {
|
||||||
|
model = strings.TrimSpace(model)
|
||||||
|
if model == "" {
|
||||||
|
return "Android"
|
||||||
|
}
|
||||||
|
key := strings.ToUpper(model)
|
||||||
|
key = strings.ReplaceAll(key, " ", "")
|
||||||
|
|
||||||
|
if name, ok := androidModelNames[key]; ok {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
// 部分型号带前缀品牌词
|
||||||
|
if name, ok := androidModelNames[strings.ToUpper(strings.TrimSpace(model))]; ok {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
brand, rest := detectAndroidBrand(model)
|
||||||
|
if brand != "" {
|
||||||
|
if rest == "" || strings.EqualFold(rest, brand) {
|
||||||
|
return brand
|
||||||
|
}
|
||||||
|
// 已是「Xiaomi 14」这类可读名
|
||||||
|
if looksHumanDeviceName(model) {
|
||||||
|
if !strings.HasPrefix(model, brand) && !strings.Contains(model, brand) {
|
||||||
|
return brand + " " + model
|
||||||
|
}
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
return brand + " " + rest
|
||||||
|
}
|
||||||
|
if looksHumanDeviceName(model) {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksHumanDeviceName(s string) bool {
|
||||||
|
hasLetter := false
|
||||||
|
hasSpaceOrCJK := false
|
||||||
|
for _, r := range s {
|
||||||
|
if unicode.Is(unicode.Han, r) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if r == ' ' {
|
||||||
|
hasSpaceOrCJK = true
|
||||||
|
}
|
||||||
|
if unicode.IsLetter(r) {
|
||||||
|
hasLetter = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pixel 7 / Nexus 5X 等
|
||||||
|
return hasLetter && hasSpaceOrCJK
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectAndroidBrand(model string) (brand, rest string) {
|
||||||
|
m := strings.TrimSpace(model)
|
||||||
|
upper := strings.ToUpper(m)
|
||||||
|
|
||||||
|
// 显式品牌前缀
|
||||||
|
prefixes := []struct {
|
||||||
|
prefix string
|
||||||
|
brand string
|
||||||
|
}{
|
||||||
|
{"HUAWEI ", "华为"},
|
||||||
|
{"HONOR ", "荣耀"},
|
||||||
|
{"XIAOMI ", "小米"},
|
||||||
|
{"REDMI ", "红米"},
|
||||||
|
{"OPPO ", "OPPO"},
|
||||||
|
{"VIVO ", "vivo"},
|
||||||
|
{"SAMSUNG ", "三星"},
|
||||||
|
{"ONEPLUS ", "一加"},
|
||||||
|
{"REALME ", "realme"},
|
||||||
|
{"MEIZU ", "魅族"},
|
||||||
|
{"GOOGLE ", "Google"},
|
||||||
|
{"PIXEL ", "Google"},
|
||||||
|
}
|
||||||
|
for _, p := range prefixes {
|
||||||
|
if strings.HasPrefix(upper, strings.ToUpper(p.prefix)) {
|
||||||
|
return p.brand, strings.TrimSpace(m[len(p.prefix):])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(upper, "SM-"), strings.HasPrefix(upper, "GT-"), strings.HasPrefix(upper, "SCH-"):
|
||||||
|
return "三星", m
|
||||||
|
case strings.HasPrefix(upper, "RMX"):
|
||||||
|
return "realme", m
|
||||||
|
case strings.HasPrefix(upper, "CPH"), strings.HasPrefix(upper, "PGFM"), strings.HasPrefix(upper, "PGBM"),
|
||||||
|
strings.HasPrefix(upper, "PGEM"), strings.HasPrefix(upper, "PHN"), strings.HasPrefix(upper, "PHW"),
|
||||||
|
strings.HasPrefix(upper, "PJU"), strings.HasPrefix(upper, "PJD"):
|
||||||
|
return "OPPO", m
|
||||||
|
case strings.HasPrefix(upper, "V2"), strings.HasPrefix(upper, "V1"), strings.HasPrefix(upper, "V3"):
|
||||||
|
// vivo / iQOO 国内型号多为 VxxxxA
|
||||||
|
if len(upper) >= 5 && unicode.IsDigit(rune(upper[1])) {
|
||||||
|
return "vivo", m
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(upper, "NE2"), strings.HasPrefix(upper, "LE2"), strings.HasPrefix(upper, "KB2"),
|
||||||
|
strings.HasPrefix(upper, "PHB"), strings.HasPrefix(upper, "PHP"), strings.HasPrefix(upper, "CPH24"),
|
||||||
|
strings.HasPrefix(upper, "ONEPLUS"):
|
||||||
|
return "一加", m
|
||||||
|
case strings.HasPrefix(upper, "M20"), strings.HasPrefix(upper, "M21"), strings.HasPrefix(upper, "220"),
|
||||||
|
strings.HasPrefix(upper, "221"), strings.HasPrefix(upper, "222"), strings.HasPrefix(upper, "230"),
|
||||||
|
strings.HasPrefix(upper, "231"), strings.HasPrefix(upper, "240"), strings.HasPrefix(upper, "241"),
|
||||||
|
strings.HasPrefix(upper, "MI "), strings.HasPrefix(upper, "MI-"):
|
||||||
|
return "小米", m
|
||||||
|
case strings.Contains(upper, "REDMI"):
|
||||||
|
return "红米", m
|
||||||
|
case strings.HasSuffix(upper, "-AN00"), strings.HasSuffix(upper, "-AN10"), strings.HasSuffix(upper, "-AL00"),
|
||||||
|
strings.HasSuffix(upper, "-AL10"), strings.HasSuffix(upper, "-TL00"), strings.HasSuffix(upper, "-LND"):
|
||||||
|
// 华为/荣耀鸿蒙型号:XXX-AN00
|
||||||
|
if isHonorModel(upper) {
|
||||||
|
return "荣耀", m
|
||||||
|
}
|
||||||
|
return "华为", m
|
||||||
|
case strings.HasPrefix(upper, "PIXEL"):
|
||||||
|
return "Google", m
|
||||||
|
case strings.HasPrefix(upper, "NEXUS"):
|
||||||
|
return "Google", m
|
||||||
|
}
|
||||||
|
return "", m
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHonorModel(upper string) bool {
|
||||||
|
honor := []string{
|
||||||
|
"ANY-", "REP-", "FNE-", "FRI-", "ANN-", "ALA-", "BRT-", "SDY-", "ELP-",
|
||||||
|
"VER-", "MAA-", "REA-", "NTP-", "GIA-", "CLS-", "DIO-", "LGE-", "CRT-",
|
||||||
|
}
|
||||||
|
for _, p := range honor {
|
||||||
|
if strings.HasPrefix(upper, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 常见国内机型营销名(可持续补充;未命中仍走品牌+型号)
|
||||||
|
var androidModelNames = map[string]string{
|
||||||
|
// 三星
|
||||||
|
"SM-S9180": "三星 Galaxy S23 Ultra",
|
||||||
|
"SM-S9160": "三星 Galaxy S23+",
|
||||||
|
"SM-S9110": "三星 Galaxy S23",
|
||||||
|
"SM-S9280": "三星 Galaxy S24 Ultra",
|
||||||
|
"SM-S9260": "三星 Galaxy S24+",
|
||||||
|
"SM-S9210": "三星 Galaxy S24",
|
||||||
|
"SM-S9380": "三星 Galaxy S25 Ultra",
|
||||||
|
"SM-S9080": "三星 Galaxy S22 Ultra",
|
||||||
|
"SM-S9010": "三星 Galaxy S22",
|
||||||
|
"SM-G9980": "三星 Galaxy S21 Ultra",
|
||||||
|
"SM-G9910": "三星 Galaxy S21",
|
||||||
|
"SM-F9460": "三星 Galaxy Z Fold5",
|
||||||
|
"SM-F7310": "三星 Galaxy Z Flip5",
|
||||||
|
"SM-A5460": "三星 Galaxy A54",
|
||||||
|
"SM-A5560": "三星 Galaxy A55",
|
||||||
|
|
||||||
|
// 小米 / 红米
|
||||||
|
"24031PN0DC": "小米 14",
|
||||||
|
"23127PN0CC": "小米 14",
|
||||||
|
"2211133C": "小米 13",
|
||||||
|
"2210132C": "小米 13 Pro",
|
||||||
|
"2201123C": "小米 12",
|
||||||
|
"2201122C": "小米 12 Pro",
|
||||||
|
"M2011K2C": "小米 11",
|
||||||
|
"M2007J1SC": "小米 10 至尊纪念版",
|
||||||
|
"M2007J17C": "小米 10 青春版",
|
||||||
|
"2311DRK48C": "红米 K70",
|
||||||
|
"23113RKC6C": "红米 K70 Pro",
|
||||||
|
"23013RK75C": "红米 K60",
|
||||||
|
"22041216C": "红米 K50",
|
||||||
|
"24117RK2CC": "红米 K80",
|
||||||
|
"23078RKD5C": "红米 Note 12 Turbo",
|
||||||
|
"2312DRA50C": "红米 Note 13 Pro",
|
||||||
|
|
||||||
|
// 华为
|
||||||
|
"NOH-AN00": "华为 Mate 40 Pro",
|
||||||
|
"NOH-AL10": "华为 Mate 40 Pro",
|
||||||
|
"OCE-AN10": "华为 Mate 40",
|
||||||
|
"ANA-AN00": "华为 P40",
|
||||||
|
"ELS-AN00": "华为 P40 Pro",
|
||||||
|
"BRA-AL00": "华为 P60",
|
||||||
|
"ALN-AL00": "华为 Mate 60",
|
||||||
|
"ALN-AL80": "华为 Mate 60 Pro",
|
||||||
|
"LNA-AL00": "华为 Mate X5",
|
||||||
|
"ALT-AL10": "华为 Mate 70",
|
||||||
|
"PLA-AL10": "华为 Mate 70 Pro",
|
||||||
|
"ADT-AL00": "华为 Pura 70",
|
||||||
|
"ADY-AL00": "华为 Pura 70 Pro",
|
||||||
|
|
||||||
|
// 荣耀
|
||||||
|
"ANY-AN00": "荣耀 Magic5",
|
||||||
|
"REP-AN00": "荣耀 Magic5 Pro",
|
||||||
|
"FNE-AN00": "荣耀 90",
|
||||||
|
"FRI-AN00": "荣耀 Magic6",
|
||||||
|
"BVL-AN16": "荣耀 Magic6 Pro",
|
||||||
|
"MAA-AN10": "荣耀 200",
|
||||||
|
"CLK-AN00": "荣耀 100",
|
||||||
|
|
||||||
|
// OPPO
|
||||||
|
"PGEM10": "OPPO Find X6 Pro",
|
||||||
|
"PGFM10": "OPPO Find X6",
|
||||||
|
"PGBM10": "OPPO Find N2",
|
||||||
|
"PHN110": "OPPO Find X7 Ultra",
|
||||||
|
"PHY110": "OPPO Find X7",
|
||||||
|
"CPH2473": "OPPO Find X5 Pro",
|
||||||
|
"PJU110": "OPPO Reno12",
|
||||||
|
"PJV110": "OPPO Reno12 Pro",
|
||||||
|
|
||||||
|
// vivo / iQOO
|
||||||
|
"V2254A": "vivo X90 Pro+",
|
||||||
|
"V2241A": "vivo X90",
|
||||||
|
"V2227A": "vivo X90 Pro",
|
||||||
|
"V2309A": "vivo X100",
|
||||||
|
"V2324A": "vivo X100 Pro",
|
||||||
|
"V2307A": "iQOO 11",
|
||||||
|
"V2243A": "iQOO 10",
|
||||||
|
"V2329A": "iQOO 12",
|
||||||
|
"V2055A": "vivo S9",
|
||||||
|
"V2183A": "vivo S15",
|
||||||
|
|
||||||
|
// 一加
|
||||||
|
"PHB110": "一加 11",
|
||||||
|
"CPH2449": "一加 11",
|
||||||
|
"PJE110": "一加 12",
|
||||||
|
"PHP110": "一加 Ace 2",
|
||||||
|
"NE2210": "一加 10 Pro",
|
||||||
|
|
||||||
|
// realme
|
||||||
|
"RMX3562": "realme GT Neo3",
|
||||||
|
"RMX3370": "realme GT Neo2",
|
||||||
|
"RMX3706": "realme GT5",
|
||||||
|
"RMX3888": "realme GT5 Pro",
|
||||||
|
|
||||||
|
// Google
|
||||||
|
"PIXEL 9 PRO": "Google Pixel 9 Pro",
|
||||||
|
"PIXEL 9": "Google Pixel 9",
|
||||||
|
"PIXEL 8 PRO": "Google Pixel 8 Pro",
|
||||||
|
"PIXEL 8": "Google Pixel 8",
|
||||||
|
"PIXEL 7 PRO": "Google Pixel 7 Pro",
|
||||||
|
"PIXEL 7": "Google Pixel 7",
|
||||||
|
"PIXEL 6 PRO": "Google Pixel 6 Pro",
|
||||||
|
"PIXEL 6": "Google Pixel 6",
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ func Register(api *gin.RouterGroup, database *gorm.DB, adminGuard func(*gin.Cont
|
|||||||
requireAdmin = adminGuard
|
requireAdmin = adminGuard
|
||||||
api.POST("/visit", handleTrackVisit)
|
api.POST("/visit", handleTrackVisit)
|
||||||
api.GET("/visit/stats", handleVisitStats)
|
api.GET("/visit/stats", handleVisitStats)
|
||||||
|
api.GET("/visit/stats/cities", handleVisitCityStats)
|
||||||
api.GET("/visit/list", handleVisitList)
|
api.GET("/visit/list", handleVisitList)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +197,7 @@ func parseIPGeoJSON(raw []byte) ipGeoResult {
|
|||||||
city := pick("city", "City")
|
city := pick("city", "City")
|
||||||
isp := pick("isp", "ISP", "operator", "org")
|
isp := pick("isp", "ISP", "operator", "org")
|
||||||
|
|
||||||
// result 常为完整文案,如「中国 广东省 深圳市 电信」
|
// result 常为完整文案,如「中国|浙江省|杭州市|联通」——运营商单独记 isp,不并入地区
|
||||||
rawLoc := resultStr
|
rawLoc := resultStr
|
||||||
if rawLoc == "" {
|
if rawLoc == "" {
|
||||||
parts := []string{}
|
parts := []string{}
|
||||||
@@ -214,10 +215,24 @@ func parseIPGeoJSON(raw []byte) ipGeoResult {
|
|||||||
rawLoc = "未知"
|
rawLoc = "未知"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 若只有完整文案,尽量拆出省用于统计
|
tokens := normalizeLocTokens(rawLoc)
|
||||||
|
if isp == "" {
|
||||||
|
isp = extractISPFromTokens(tokens)
|
||||||
|
}
|
||||||
|
// 归属地展示去掉运营商,避免与 isp 字段重复、也避免被当成地区
|
||||||
|
rawLoc = joinLocationWithoutISP(tokens)
|
||||||
|
if rawLoc == "" {
|
||||||
|
rawLoc = "未知"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 省/地区:结构化字段优先,但若整串误带了运营商则清洗;否则从文案推断
|
||||||
|
region = sanitizeRegion(region)
|
||||||
if region == "" && rawLoc != "" && rawLoc != "未知" && rawLoc != "本地" {
|
if region == "" && rawLoc != "" && rawLoc != "未知" && rawLoc != "本地" {
|
||||||
region = guessProvince(rawLoc)
|
region = guessProvince(rawLoc)
|
||||||
}
|
}
|
||||||
|
if city == "" {
|
||||||
|
city = guessCity(tokens, region)
|
||||||
|
}
|
||||||
|
|
||||||
return ipGeoResult{
|
return ipGeoResult{
|
||||||
Country: country,
|
Country: country,
|
||||||
@@ -228,22 +243,182 @@ func parseIPGeoJSON(raw []byte) ipGeoResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func guessProvince(raw string) string {
|
// 常见运营商名:不得写入 region / raw_location
|
||||||
// 粗略从完整归属地文案中取省级片段,供地区分布统计
|
var ispNameTokens = []string{
|
||||||
tokens := strings.Fields(strings.ReplaceAll(raw, " ", " "))
|
"联通", "电信", "移动", "铁通", "广电", "教育网",
|
||||||
|
"长城宽带", "鹏博士", "方正宽带", "宽带通",
|
||||||
|
"China Unicom", "China Telecom", "China Mobile",
|
||||||
|
"CHINANET", "UNICOM", "CMNET", "CRTC",
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLocTokens(raw string) []string {
|
||||||
|
s := strings.TrimSpace(raw)
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
"|", " ", "|", " ", "/", " ", "/", " ",
|
||||||
|
" ", " ", ",", " ", ",", " ",
|
||||||
|
)
|
||||||
|
return strings.Fields(replacer.Replace(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
func isISPToken(t string) bool {
|
||||||
|
t = strings.TrimSpace(t)
|
||||||
|
if t == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(t)
|
||||||
|
for _, name := range ispNameTokens {
|
||||||
|
if t == name || strings.EqualFold(t, name) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.EqualFold(lower, strings.ToLower(name)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 「杭州联通」「浙江电信」等后缀
|
||||||
|
if strings.HasSuffix(t, name) {
|
||||||
|
rest := strings.TrimSuffix(t, name)
|
||||||
|
if rest == "" || len([]rune(rest)) <= 4 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractISPFromTokens(tokens []string) string {
|
||||||
|
for i := len(tokens) - 1; i >= 0; i-- {
|
||||||
|
if isISPToken(tokens[i]) {
|
||||||
|
return tokens[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinLocationWithoutISP(tokens []string) string {
|
||||||
|
parts := make([]string, 0, len(tokens))
|
||||||
for _, t := range tokens {
|
for _, t := range tokens {
|
||||||
if strings.Contains(t, "省") || strings.Contains(t, "自治区") ||
|
if isISPToken(t) {
|
||||||
strings.HasSuffix(t, "市") && (strings.HasPrefix(t, "北京") || strings.HasPrefix(t, "上海") ||
|
continue
|
||||||
strings.HasPrefix(t, "天津") || strings.HasPrefix(t, "重庆")) {
|
}
|
||||||
|
if len(parts) > 0 && parts[len(parts)-1] == t {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts = append(parts, t)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeRegion(region string) string {
|
||||||
|
region = strings.TrimSpace(region)
|
||||||
|
if region == "" || region == "未知" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// 误把整段「中国|浙江省|杭州市|联通」写入 region 时,只保留省级
|
||||||
|
if strings.ContainsAny(region, "||/ ") || isISPToken(region) {
|
||||||
|
return guessProvince(region)
|
||||||
|
}
|
||||||
|
return normalizeMunicipalityName(region)
|
||||||
|
}
|
||||||
|
|
||||||
|
var municipalityRoots = []string{"北京", "上海", "天津", "重庆"}
|
||||||
|
|
||||||
|
func isMunicipalityName(name string) bool {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, m := range municipalityRoots {
|
||||||
|
if name == m || name == m+"市" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeMunicipalityName 统一「北京」「北京市」→「北京市」,避免分布被拆开
|
||||||
|
func normalizeMunicipalityName(name string) string {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
for _, m := range municipalityRoots {
|
||||||
|
if name == m || name == m+"市" {
|
||||||
|
return m + "市"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func guessProvince(raw string) string {
|
||||||
|
// 从完整归属地文案中取省级片段,供地区分布统计(不含运营商)
|
||||||
|
tokens := normalizeLocTokens(raw)
|
||||||
|
for _, t := range tokens {
|
||||||
|
if isISPToken(t) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(t, "省") || strings.Contains(t, "自治区") {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
// 直辖市(含「北京」无「市」后缀)
|
||||||
|
if isMunicipalityName(t) {
|
||||||
|
return normalizeMunicipalityName(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, t := range tokens {
|
||||||
|
if t == "中国" || t == "国内" || isISPToken(t) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return normalizeMunicipalityName(t)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func guessCity(tokens []string, province string) string {
|
||||||
|
province = normalizeMunicipalityName(province)
|
||||||
|
// 优先区/县(直辖市下沉一级)
|
||||||
|
for _, t := range tokens {
|
||||||
|
if isISPToken(t) || t == "中国" || t == "国内" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if t == province || normalizeMunicipalityName(t) == province {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(t, "区") || strings.HasSuffix(t, "县") {
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(tokens) >= 2 {
|
for _, t := range tokens {
|
||||||
return tokens[1]
|
if isISPToken(t) || t == "中国" || t == "国内" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if t == province || normalizeMunicipalityName(t) == province {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(t, "市") || strings.HasSuffix(t, "州") || strings.HasSuffix(t, "盟") {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 直辖市常无独立「市」字段:用直辖市名本身,避免落入「未知」
|
||||||
|
if isMunicipalityName(province) {
|
||||||
|
return normalizeMunicipalityName(province)
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeRegionName 清洗历史脏数据(整串含运营商),用于地区分布聚合
|
||||||
|
func normalizeRegionName(name string) string {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return "未知"
|
||||||
|
}
|
||||||
|
if cleaned := sanitizeRegion(name); cleaned != "" {
|
||||||
|
return normalizeMunicipalityName(cleaned)
|
||||||
|
}
|
||||||
|
if isISPToken(name) {
|
||||||
|
return "未知"
|
||||||
|
}
|
||||||
|
return normalizeMunicipalityName(name)
|
||||||
|
}
|
||||||
|
|
||||||
func truncateRunes(s string, max int) string {
|
func truncateRunes(s string, max int) string {
|
||||||
r := []rune(s)
|
r := []rune(s)
|
||||||
if len(r) <= max {
|
if len(r) <= max {
|
||||||
@@ -261,14 +436,20 @@ func parseDeviceFromUA(ua string) string {
|
|||||||
lower := strings.ToLower(ua)
|
lower := strings.ToLower(ua)
|
||||||
|
|
||||||
if strings.Contains(ua, "iPhone") {
|
if strings.Contains(ua, "iPhone") {
|
||||||
|
if ver := extractIOSVersion(ua); ver != "" {
|
||||||
|
return "iPhone · iOS " + ver
|
||||||
|
}
|
||||||
return "iPhone"
|
return "iPhone"
|
||||||
}
|
}
|
||||||
if strings.Contains(ua, "iPad") {
|
if strings.Contains(ua, "iPad") {
|
||||||
|
if ver := extractIOSVersion(ua); ver != "" {
|
||||||
|
return "iPad · iOS " + ver
|
||||||
|
}
|
||||||
return "iPad"
|
return "iPad"
|
||||||
}
|
}
|
||||||
if strings.Contains(lower, "android") {
|
if strings.Contains(lower, "android") {
|
||||||
if model := extractAndroidModel(ua); model != "" {
|
if model := extractAndroidModel(ua); model != "" {
|
||||||
return model
|
return friendlyAndroidName(model)
|
||||||
}
|
}
|
||||||
return "Android"
|
return "Android"
|
||||||
}
|
}
|
||||||
@@ -287,6 +468,31 @@ func parseDeviceFromUA(ua string) string {
|
|||||||
return "其他"
|
return "其他"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractIOSVersion(ua string) string {
|
||||||
|
// CPU iPhone OS 17_2 like Mac OS X → 17.2
|
||||||
|
for _, key := range []string{"iPhone OS ", "CPU OS "} {
|
||||||
|
idx := strings.Index(ua, key)
|
||||||
|
if idx < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rest := ua[idx+len(key):]
|
||||||
|
end := 0
|
||||||
|
for end < len(rest) {
|
||||||
|
c := rest[end]
|
||||||
|
if (c >= '0' && c <= '9') || c == '_' || c == '.' {
|
||||||
|
end++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if end == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(rest[:end], "_", ".")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func extractAndroidModel(ua string) string {
|
func extractAndroidModel(ua string) string {
|
||||||
// 常见形态:Linux; Android 13; SM-S9180 Build/... 或 Android 12; Pixel 7)
|
// 常见形态:Linux; Android 13; SM-S9180 Build/... 或 Android 12; Pixel 7)
|
||||||
idx := strings.Index(ua, "Android")
|
idx := strings.Index(ua, "Android")
|
||||||
@@ -300,17 +506,24 @@ func extractAndroidModel(ua string) string {
|
|||||||
}
|
}
|
||||||
rest = strings.TrimSpace(rest[semi+1:])
|
rest = strings.TrimSpace(rest[semi+1:])
|
||||||
end := len(rest)
|
end := len(rest)
|
||||||
for _, sep := range []string{" Build", ")", "; HMSCore", "; HarmonyOS"} {
|
for _, sep := range []string{" Build", ")", "; HMSCore", "; HarmonyOS", "; wv"} {
|
||||||
if p := strings.Index(rest, sep); p >= 0 && p < end {
|
if p := strings.Index(rest, sep); p >= 0 && p < end {
|
||||||
end = p
|
end = p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
model := strings.TrimSpace(rest[:end])
|
model := strings.TrimSpace(rest[:end])
|
||||||
model = strings.Trim(model, "; ")
|
model = strings.Trim(model, "; ")
|
||||||
|
// 部分 UA:HarmonyOS; NOH-AN00 Build
|
||||||
if model == "" || strings.EqualFold(model, "wv") || strings.HasPrefix(strings.ToLower(model), "linux") {
|
if model == "" || strings.EqualFold(model, "wv") || strings.HasPrefix(strings.ToLower(model), "linux") {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
// 过长型号截断
|
// 「zh-cn; SM-S9180」或多余语言标签
|
||||||
|
if p := strings.LastIndex(model, ";"); p >= 0 {
|
||||||
|
tail := strings.TrimSpace(model[p+1:])
|
||||||
|
if tail != "" {
|
||||||
|
model = tail
|
||||||
|
}
|
||||||
|
}
|
||||||
r := []rune(model)
|
r := []rune(model)
|
||||||
if len(r) > 40 {
|
if len(r) > 40 {
|
||||||
model = string(r[:40])
|
model = string(r[:40])
|
||||||
@@ -473,13 +686,31 @@ func handleVisitStats(c *gin.Context) {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Value int64 `json:"value"`
|
Value int64 `json:"value"`
|
||||||
}
|
}
|
||||||
var regions []regionRow
|
var rawRegions []regionRow
|
||||||
db.Model(&SiteVisit{}).
|
db.Model(&SiteVisit{}).
|
||||||
Select("CASE WHEN region = '' OR region IS NULL THEN '未知' ELSE region END as name, COUNT(DISTINCT fingerprint) as value").
|
Select("CASE WHEN region = '' OR region IS NULL THEN '未知' ELSE region END as name, COUNT(DISTINCT fingerprint) as value").
|
||||||
Group("name").
|
Group("name").
|
||||||
Order("value desc").
|
Scan(&rawRegions)
|
||||||
Limit(30).
|
|
||||||
Scan(®ions)
|
// 合并历史脏数据(如「中国|浙江省|杭州市|联通」被整段写入 region)
|
||||||
|
merged := map[string]int64{}
|
||||||
|
for _, r := range rawRegions {
|
||||||
|
name := normalizeRegionName(r.Name)
|
||||||
|
merged[name] += r.Value
|
||||||
|
}
|
||||||
|
regions := make([]regionRow, 0, len(merged))
|
||||||
|
for name, value := range merged {
|
||||||
|
regions = append(regions, regionRow{Name: name, Value: value})
|
||||||
|
}
|
||||||
|
sort.Slice(regions, func(i, j int) bool {
|
||||||
|
if regions[i].Value == regions[j].Value {
|
||||||
|
return regions[i].Name < regions[j].Name
|
||||||
|
}
|
||||||
|
return regions[i].Value > regions[j].Value
|
||||||
|
})
|
||||||
|
if len(regions) > 30 {
|
||||||
|
regions = regions[:30]
|
||||||
|
}
|
||||||
if regions == nil {
|
if regions == nil {
|
||||||
regions = []regionRow{}
|
regions = []regionRow{}
|
||||||
}
|
}
|
||||||
@@ -498,6 +729,127 @@ func handleVisitStats(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type namedCount struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value int64 `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleVisitCityStats 某省下的市级分布(按指纹去重)
|
||||||
|
func handleVisitCityStats(c *gin.Context) {
|
||||||
|
if !requireAdmin(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
regionQ := strings.TrimSpace(c.Query("region"))
|
||||||
|
if regionQ == "" {
|
||||||
|
c.JSON(400, gin.H{"error": "缺少 region 参数"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := normalizeRegionName(regionQ)
|
||||||
|
|
||||||
|
type locRow struct {
|
||||||
|
Fingerprint string
|
||||||
|
Region string
|
||||||
|
City string
|
||||||
|
RawLocation string
|
||||||
|
}
|
||||||
|
var rows []locRow
|
||||||
|
q := db.Model(&SiteVisit{}).Select("fingerprint, region, city, raw_location")
|
||||||
|
if target == "未知" {
|
||||||
|
q = q.Where("region = '' OR region IS NULL OR region = ? OR region LIKE ?", "未知", "%未知%")
|
||||||
|
} else if target == "本地" {
|
||||||
|
q = q.Where("region = ? OR raw_location = ?", "本地", "本地")
|
||||||
|
} else {
|
||||||
|
like := "%" + target + "%"
|
||||||
|
q = q.Where("region = ? OR region LIKE ? OR raw_location LIKE ?", target, like, like)
|
||||||
|
}
|
||||||
|
if err := q.Find(&rows).Error; err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": "查询失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// fingerprint → city(同指纹多条时取首次有效市)
|
||||||
|
fpCity := map[string]string{}
|
||||||
|
for _, row := range rows {
|
||||||
|
if !rowBelongsToRegion(row.Region, row.RawLocation, target) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
city := resolveVisitCity(row.City, row.RawLocation, target)
|
||||||
|
if prev, ok := fpCity[row.Fingerprint]; ok {
|
||||||
|
if prev != "未知" || city == "未知" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fpCity[row.Fingerprint] = city
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := map[string]int64{}
|
||||||
|
for _, city := range fpCity {
|
||||||
|
merged[city]++
|
||||||
|
}
|
||||||
|
cities := make([]namedCount, 0, len(merged))
|
||||||
|
for name, value := range merged {
|
||||||
|
cities = append(cities, namedCount{Name: name, Value: value})
|
||||||
|
}
|
||||||
|
sort.Slice(cities, func(i, j int) bool {
|
||||||
|
if cities[i].Value == cities[j].Value {
|
||||||
|
return cities[i].Name < cities[j].Name
|
||||||
|
}
|
||||||
|
return cities[i].Value > cities[j].Value
|
||||||
|
})
|
||||||
|
if len(cities) > 40 {
|
||||||
|
cities = cities[:40]
|
||||||
|
}
|
||||||
|
if cities == nil {
|
||||||
|
cities = []namedCount{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(200, gin.H{
|
||||||
|
"code": 200,
|
||||||
|
"data": gin.H{
|
||||||
|
"region": target,
|
||||||
|
"cities": cities,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func rowBelongsToRegion(region, raw, target string) bool {
|
||||||
|
if target == "未知" {
|
||||||
|
n := normalizeRegionName(region)
|
||||||
|
return n == "未知" || n == ""
|
||||||
|
}
|
||||||
|
if normalizeRegionName(region) == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if guessProvince(raw) == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.Contains(region, target) || strings.Contains(raw, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveVisitCity(city, raw, province string) string {
|
||||||
|
province = normalizeMunicipalityName(strings.TrimSpace(province))
|
||||||
|
city = strings.TrimSpace(city)
|
||||||
|
if city != "" && !isISPToken(city) {
|
||||||
|
// 历史脏数据:city 里也可能是整段归属地
|
||||||
|
if strings.ContainsAny(city, "||/") {
|
||||||
|
if c := guessCity(normalizeLocTokens(city), province); c != "" {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
} else if normalizeMunicipalityName(city) != province {
|
||||||
|
// 普通省:市字段有效;若 city 本身是区县也保留
|
||||||
|
return city
|
||||||
|
}
|
||||||
|
// city 与直辖市同名时,继续从 raw 里找区县,找不到再用直辖市名
|
||||||
|
}
|
||||||
|
if c := guessCity(normalizeLocTokens(raw), province); c != "" {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
if isMunicipalityName(province) {
|
||||||
|
return normalizeMunicipalityName(province)
|
||||||
|
}
|
||||||
|
return "未知"
|
||||||
|
}
|
||||||
|
|
||||||
func handleVisitList(c *gin.Context) {
|
func handleVisitList(c *gin.Context) {
|
||||||
if !requireAdmin(c) {
|
if !requireAdmin(c) {
|
||||||
return
|
return
|
||||||
|
|||||||
48
vite-tailwindcss/pnpm-lock.yaml
generated
48
vite-tailwindcss/pnpm-lock.yaml
generated
@@ -17,12 +17,30 @@ importers:
|
|||||||
'@fingerprintjs/fingerprintjs':
|
'@fingerprintjs/fingerprintjs':
|
||||||
specifier: ^5.2.0
|
specifier: ^5.2.0
|
||||||
version: 5.2.0
|
version: 5.2.0
|
||||||
|
'@fontsource/cinzel':
|
||||||
|
specifier: ^5.3.0
|
||||||
|
version: 5.3.0
|
||||||
'@fontsource/great-vibes':
|
'@fontsource/great-vibes':
|
||||||
specifier: ^5.3.0
|
specifier: ^5.3.0
|
||||||
version: 5.3.0
|
version: 5.3.0
|
||||||
|
'@fontsource/liu-jian-mao-cao':
|
||||||
|
specifier: ^5.3.0
|
||||||
|
version: 5.3.0
|
||||||
|
'@fontsource/long-cang':
|
||||||
|
specifier: ^5.3.0
|
||||||
|
version: 5.3.0
|
||||||
'@fontsource/ma-shan-zheng':
|
'@fontsource/ma-shan-zheng':
|
||||||
specifier: ^5.3.0
|
specifier: ^5.3.0
|
||||||
version: 5.3.0
|
version: 5.3.0
|
||||||
|
'@fontsource/pinyon-script':
|
||||||
|
specifier: ^5.3.0
|
||||||
|
version: 5.3.0
|
||||||
|
'@fontsource/playfair-display':
|
||||||
|
specifier: ^5.3.0
|
||||||
|
version: 5.3.0
|
||||||
|
'@fontsource/zhi-mang-xing':
|
||||||
|
specifier: ^5.3.0
|
||||||
|
version: 5.3.0
|
||||||
'@fortawesome/fontawesome-free':
|
'@fortawesome/fontawesome-free':
|
||||||
specifier: ^6.7.2
|
specifier: ^6.7.2
|
||||||
version: 6.7.2
|
version: 6.7.2
|
||||||
@@ -296,12 +314,30 @@ packages:
|
|||||||
'@fingerprintjs/fingerprintjs@5.2.0':
|
'@fingerprintjs/fingerprintjs@5.2.0':
|
||||||
resolution: {integrity: sha512-j+2nInkwCQNTJcNhOjvkGM/nLRTuGJTC6xai4quqvUpjob2ssrGwBZjS7k55nOmKvge7qvJT2nS3i/IRvQSTQA==}
|
resolution: {integrity: sha512-j+2nInkwCQNTJcNhOjvkGM/nLRTuGJTC6xai4quqvUpjob2ssrGwBZjS7k55nOmKvge7qvJT2nS3i/IRvQSTQA==}
|
||||||
|
|
||||||
|
'@fontsource/cinzel@5.3.0':
|
||||||
|
resolution: {integrity: sha512-KEOYTsrRppW0uRIPWBDPrQgunCS6f8u4nzZxYRBfo9SCknKojLYjK7B0ZoVmJhyVLefz271ZQRF9EOpHZlfhRw==}
|
||||||
|
|
||||||
'@fontsource/great-vibes@5.3.0':
|
'@fontsource/great-vibes@5.3.0':
|
||||||
resolution: {integrity: sha512-1w60cXzXnC9V4y943po8044O1mqRQBiTTK75h7OpZE+1VQtx+KlAAzoIyEE5KNzu05gUpxaCQg7cbL7ITSwegQ==}
|
resolution: {integrity: sha512-1w60cXzXnC9V4y943po8044O1mqRQBiTTK75h7OpZE+1VQtx+KlAAzoIyEE5KNzu05gUpxaCQg7cbL7ITSwegQ==}
|
||||||
|
|
||||||
|
'@fontsource/liu-jian-mao-cao@5.3.0':
|
||||||
|
resolution: {integrity: sha512-OaFm4okm77XoGA9jp1J89tDjdWlzb68yJCUL7LV75QxUWF/qOBRVqmhP7XFFsgcn/PL+nc2dPvRGT/8yit5LZg==}
|
||||||
|
|
||||||
|
'@fontsource/long-cang@5.3.0':
|
||||||
|
resolution: {integrity: sha512-lCcBJb+LWZpYCVtGvQRiZPy5/EaGPuYLycpJXUIOz28EYGGqhTVWBRqY6ucaW8l1F8gvfjutY1PoAPUrFiP0wg==}
|
||||||
|
|
||||||
'@fontsource/ma-shan-zheng@5.3.0':
|
'@fontsource/ma-shan-zheng@5.3.0':
|
||||||
resolution: {integrity: sha512-3p0aUEYOFBa4mvHIRDksZZ9Uf35jQdq5mpwp6NZHiNYt/jDj/g83OwTAWjzy+9ZJAxYC9NLLobIKuW1BEcnv0Q==}
|
resolution: {integrity: sha512-3p0aUEYOFBa4mvHIRDksZZ9Uf35jQdq5mpwp6NZHiNYt/jDj/g83OwTAWjzy+9ZJAxYC9NLLobIKuW1BEcnv0Q==}
|
||||||
|
|
||||||
|
'@fontsource/pinyon-script@5.3.0':
|
||||||
|
resolution: {integrity: sha512-ilhy4l5apzyDkfuKb7PZBUT5Sf6nfxucdXT5TQ0wQDyWtDPlouLy1lQq3lMEPk1lPsHHVrd11EkPnEG8FqDgUA==}
|
||||||
|
|
||||||
|
'@fontsource/playfair-display@5.3.0':
|
||||||
|
resolution: {integrity: sha512-uJSABalvdWc10fEPppQjQocamT21LEyRfzzWfRfqbkPz+sRv+kKpYDKyluBUskvJREkJTos9epj1WjQROGu99Q==}
|
||||||
|
|
||||||
|
'@fontsource/zhi-mang-xing@5.3.0':
|
||||||
|
resolution: {integrity: sha512-V21yCCm5o2PHdXF2P9KKh7uiqL8QFFuII4DRQkpG24tfyDzJPchXKsxkcvKM43xEPvQKdbhHCBbXEFjcm/TUCQ==}
|
||||||
|
|
||||||
'@fortawesome/fontawesome-free@6.7.2':
|
'@fortawesome/fontawesome-free@6.7.2':
|
||||||
resolution: {integrity: sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==}
|
resolution: {integrity: sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1483,10 +1519,22 @@ snapshots:
|
|||||||
|
|
||||||
'@fingerprintjs/fingerprintjs@5.2.0': {}
|
'@fingerprintjs/fingerprintjs@5.2.0': {}
|
||||||
|
|
||||||
|
'@fontsource/cinzel@5.3.0': {}
|
||||||
|
|
||||||
'@fontsource/great-vibes@5.3.0': {}
|
'@fontsource/great-vibes@5.3.0': {}
|
||||||
|
|
||||||
|
'@fontsource/liu-jian-mao-cao@5.3.0': {}
|
||||||
|
|
||||||
|
'@fontsource/long-cang@5.3.0': {}
|
||||||
|
|
||||||
'@fontsource/ma-shan-zheng@5.3.0': {}
|
'@fontsource/ma-shan-zheng@5.3.0': {}
|
||||||
|
|
||||||
|
'@fontsource/pinyon-script@5.3.0': {}
|
||||||
|
|
||||||
|
'@fontsource/playfair-display@5.3.0': {}
|
||||||
|
|
||||||
|
'@fontsource/zhi-mang-xing@5.3.0': {}
|
||||||
|
|
||||||
'@fortawesome/fontawesome-free@6.7.2': {}
|
'@fortawesome/fontawesome-free@6.7.2': {}
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ export function getVisitStats() {
|
|||||||
return service.get('/visit/stats')
|
return service.get('/visit/stats')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getVisitCityStats(region) {
|
||||||
|
return service.get('/visit/stats/cities', { params: { region } })
|
||||||
|
}
|
||||||
|
|
||||||
export function getVisitList(params) {
|
export function getVisitList(params) {
|
||||||
return service.get('/visit/list', { params })
|
return service.get('/visit/list', { params })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,6 +197,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 爱心拼接:绝对定位心形轮廓 + 串行挂载 -->
|
||||||
|
<div v-else-if="page.type === 'heart-collage'" ref="multiRootRef" class="g-heart mt-6">
|
||||||
|
<div
|
||||||
|
v-for="(slot, imgIdx) in HEART_SLOTS"
|
||||||
|
:key="imgIdx"
|
||||||
|
class="g-heart-slot"
|
||||||
|
:class="{ 'g-heart-placeholder': imgIdx >= shownCount }"
|
||||||
|
:style="heartSlotStyle(slot)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="imgIdx < shownCount"
|
||||||
|
v-reveal="{ variant: heartReveal(imgIdx), immediate: true }"
|
||||||
|
class="absolute inset-0 g-heart-cell"
|
||||||
|
:class="frameClass"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="displayAt(imgIdx)"
|
||||||
|
class="preview-img g-heart-img"
|
||||||
|
decoding="async"
|
||||||
|
@click.stop="previewImage(originalAt(imgIdx))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else class="text-center text-[#8A8680] text-[12px] mt-6">未识别的版式:{{ page.type }}</div>
|
<div v-else class="text-center text-[#8A8680] text-[12px] mt-6">未识别的版式:{{ page.type }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -236,8 +261,26 @@ const SEQUENTIAL_SELF_TYPES = new Set([
|
|||||||
'polaroid',
|
'polaroid',
|
||||||
'nine-grid',
|
'nine-grid',
|
||||||
'one-large-five-small',
|
'one-large-five-small',
|
||||||
|
'heart-collage',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
/** 爱心拼接槽位:百分比绝对定位,轮廓接近 ♥(顶开叉、中最宽、底收尖) */
|
||||||
|
const HEART_SLOTS = [
|
||||||
|
{ l: 10, t: 0, w: 32, h: 13 }, // 0 左上叶
|
||||||
|
{ l: 58, t: 0, w: 32, h: 13 }, // 1 右上叶(中间约 16% 开叉)
|
||||||
|
{ l: 0, t: 14, w: 14, h: 15.5 }, // 2 左外上
|
||||||
|
{ l: 0, t: 30.5, w: 14, h: 15.5 }, // 3 左外下
|
||||||
|
{ l: 15, t: 14, w: 26, h: 32 }, // 4 左大图
|
||||||
|
{ l: 42, t: 14, w: 16, h: 32 }, // 5 中轴
|
||||||
|
{ l: 59, t: 14, w: 26, h: 32 }, // 6 右大图
|
||||||
|
{ l: 86, t: 14, w: 14, h: 15.5 }, // 7 右外上
|
||||||
|
{ l: 86, t: 30.5, w: 14, h: 15.5 }, // 8 右外下
|
||||||
|
{ l: 10, t: 47, w: 13, h: 16 }, // 9 左下收腰
|
||||||
|
{ l: 24, t: 47, w: 52, h: 16 }, // 10 中下横
|
||||||
|
{ l: 77, t: 47, w: 13, h: 16 }, // 11 右下收腰
|
||||||
|
{ l: 38, t: 64, w: 24, h: 14 }, // 12 心尖
|
||||||
|
]
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
page: { type: Object, required: true },
|
page: { type: Object, required: true },
|
||||||
/** 九宫格:开启串行 v-reveal;关闭则一次展示 */
|
/** 九宫格:开启串行 v-reveal;关闭则一次展示 */
|
||||||
@@ -447,7 +490,7 @@ const runSequentialByType = async () => {
|
|||||||
await runOlfsSequential()
|
await runOlfsSequential()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (type === 'masonry' || type === 'polaroid') {
|
if (type === 'masonry' || type === 'polaroid' || type === 'heart-collage') {
|
||||||
await runMultiSequential()
|
await runMultiSequential()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -608,6 +651,23 @@ function polaroidTransform(i) {
|
|||||||
return `rotate(${a}deg) translateY(${s}px)`
|
return `rotate(${a}deg) translateY(${s}px)`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 爱心:左右对称入场,中轴与心尖用缩放 */
|
||||||
|
function heartReveal(i) {
|
||||||
|
if (i === 5 || i === 12) return 'scale'
|
||||||
|
if (i === 0 || i === 2 || i === 3 || i === 4 || i === 9) return 'left'
|
||||||
|
if (i === 1 || i === 6 || i === 7 || i === 8 || i === 11) return 'right'
|
||||||
|
return 'up'
|
||||||
|
}
|
||||||
|
|
||||||
|
function heartSlotStyle(slot) {
|
||||||
|
return {
|
||||||
|
left: `${slot.l}%`,
|
||||||
|
top: `${slot.t}%`,
|
||||||
|
width: `${slot.w}%`,
|
||||||
|
height: `${slot.h}%`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -706,6 +766,9 @@ function polaroidTransform(i) {
|
|||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
height: clamp(340px, 48vh, 460px);
|
height: clamp(340px, 48vh, 460px);
|
||||||
}
|
}
|
||||||
|
.g-heart {
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
.g-carousel-slide {
|
.g-carousel-slide {
|
||||||
height: min(52vh, 420px);
|
height: min(52vh, 420px);
|
||||||
}
|
}
|
||||||
@@ -724,6 +787,75 @@ function polaroidTransform(i) {
|
|||||||
.g-dot { width: 7px; height: 7px; border-radius: 9999px; border: none; background: rgba(168,140,107,0.3); padding: 0; cursor: pointer; transition: all 0.3s; }
|
.g-dot { width: 7px; height: 7px; border-radius: 9999px; border: none; background: rgba(168,140,107,0.3); padding: 0; cursor: pointer; transition: all 0.3s; }
|
||||||
.g-dot.active { background: #a88c6b; width: 18px; border-radius: 9999px; }
|
.g-dot.active { background: #a88c6b; width: 18px; border-radius: 9999px; }
|
||||||
|
|
||||||
|
/* 爱心拼接:绝对定位块状心形 + 淡囍底纹 */
|
||||||
|
.g-heart {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
margin-inline: auto;
|
||||||
|
aspect-ratio: 0.8 / 1;
|
||||||
|
padding: 4px 2px 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
.g-heart::before,
|
||||||
|
.g-heart::after {
|
||||||
|
content: '囍';
|
||||||
|
position: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
font-family: var(--font-calligraphy), serif;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #c97878;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.g-heart::before {
|
||||||
|
top: 22%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: clamp(64px, 24vw, 108px);
|
||||||
|
opacity: 0.07;
|
||||||
|
}
|
||||||
|
.g-heart::after {
|
||||||
|
bottom: 10%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: clamp(36px, 12vw, 56px);
|
||||||
|
opacity: 0.05;
|
||||||
|
}
|
||||||
|
.g-heart-slot {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
contain: layout paint;
|
||||||
|
}
|
||||||
|
.g-heart-placeholder {
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
.g-heart-cell {
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.35s ease, box-shadow 0.35s ease;
|
||||||
|
}
|
||||||
|
@media (hover: hover) {
|
||||||
|
.g-heart-cell:hover {
|
||||||
|
transform: scale(1.03);
|
||||||
|
z-index: 3;
|
||||||
|
box-shadow: 0 6px 18px rgba(80, 50, 40, 0.16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.g-heart-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.g-polaroid { display: flex; flex-wrap: wrap; justify-content: center; gap: 14px 10px; padding: 14px 4px 4px; }
|
.g-polaroid { display: flex; flex-wrap: wrap; justify-content: center; gap: 14px 10px; padding: 14px 4px 4px; }
|
||||||
.g-polaroid-slot {
|
.g-polaroid-slot {
|
||||||
width: 42%;
|
width: 42%;
|
||||||
|
|||||||
@@ -238,6 +238,14 @@ export function getSlotResizeSpecs(page) {
|
|||||||
boxForIndex = () => ({ maxWidth: 1200, maxHeight: 780 })
|
boxForIndex = () => ({ maxWidth: 1200, maxHeight: 780 })
|
||||||
} else if (page.type === 'polaroid') {
|
} else if (page.type === 'polaroid') {
|
||||||
boxForIndex = () => ({ maxWidth: 800, maxHeight: 1000 })
|
boxForIndex = () => ({ maxWidth: 800, maxHeight: 1000 })
|
||||||
|
} else if (page.type === 'heart-collage') {
|
||||||
|
// 按槽位比例:大图 / 竖中轴 / 横图 / 小方图
|
||||||
|
boxForIndex = (i) => {
|
||||||
|
if (i === 4 || i === 6) return { maxWidth: 900, maxHeight: 900 }
|
||||||
|
if (i === 5) return { maxWidth: 560, maxHeight: 960 }
|
||||||
|
if (i === 0 || i === 1 || i === 10) return { maxWidth: 900, maxHeight: 680 }
|
||||||
|
return { maxWidth: 520, maxHeight: 520 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return list.map((_, i) => {
|
return list.map((_, i) => {
|
||||||
|
|||||||
@@ -150,8 +150,11 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-5">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-5">
|
||||||
<div class="chart-card">
|
<div class="chart-card">
|
||||||
<div class="text-[12px] text-[#7a6550] mb-2 tracking-wide">地区分布</div>
|
<div class="flex items-baseline justify-between gap-2 mb-2">
|
||||||
<div ref="regionChartRef" class="h-[280px] w-full"></div>
|
<div class="text-[12px] text-[#7a6550] tracking-wide">地区分布</div>
|
||||||
|
<div class="text-[10px] text-[#a89a88]">点击省份查看市级</div>
|
||||||
|
</div>
|
||||||
|
<div ref="regionChartRef" class="h-[280px] w-full cursor-pointer"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-card">
|
<div class="chart-card">
|
||||||
<div class="text-[12px] text-[#7a6550] mb-2 tracking-wide">机型画像</div>
|
<div class="text-[12px] text-[#7a6550] mb-2 tracking-wide">机型画像</div>
|
||||||
@@ -159,6 +162,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedRegion" class="chart-card mb-5">
|
||||||
|
<div class="flex items-center justify-between gap-2 mb-2 flex-wrap">
|
||||||
|
<div class="text-[12px] text-[#7a6550] tracking-wide">
|
||||||
|
{{ selectedRegion }} · 市级分布
|
||||||
|
<span v-if="cityStatsLoading" class="text-[10px] text-[#a89a88] ml-2">加载中…</span>
|
||||||
|
</div>
|
||||||
|
<a-button size="small" type="text" class="!text-[#a88c6b]" @click="clearSelectedRegion">
|
||||||
|
<i class="fa-solid fa-xmark mr-1"></i>关闭
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<div ref="cityChartRef" class="h-[280px] w-full"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-between items-center mb-2 gap-2 flex-wrap">
|
<div class="flex justify-between items-center mb-2 gap-2 flex-wrap">
|
||||||
<div class="text-[12px] text-[#7a6550] tracking-wide">近期访客预览</div>
|
<div class="text-[12px] text-[#7a6550] tracking-wide">近期访客预览</div>
|
||||||
<a-button size="small" type="link" class="!text-[#a88c6b] !px-0" @click="activeTab = 'visits'">
|
<a-button size="small" type="link" class="!text-[#a88c6b] !px-0" @click="activeTab = 'visits'">
|
||||||
@@ -982,6 +998,7 @@
|
|||||||
<a-select-option value="masonry">瀑布流</a-select-option>
|
<a-select-option value="masonry">瀑布流</a-select-option>
|
||||||
<a-select-option value="carousel">轮播滑动</a-select-option>
|
<a-select-option value="carousel">轮播滑动</a-select-option>
|
||||||
<a-select-option value="polaroid">拍立得拼贴</a-select-option>
|
<a-select-option value="polaroid">拍立得拼贴</a-select-option>
|
||||||
|
<a-select-option value="heart-collage">爱心拼接</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="页面高度">
|
<a-form-item label="页面高度">
|
||||||
@@ -1078,9 +1095,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<div v-if="page.type === 'heart-collage'" class="w-full text-[11px] text-[#8A8680] mb-1">
|
||||||
|
共 13 张:按心形槽位排列(上叶 → 中段对称 → 下横 → 心尖)。建议竖图放中轴,横图放上下叶。
|
||||||
|
</div>
|
||||||
<div v-for="(img, iidx) in page.images" :key="iidx" class="relative w-[96px]">
|
<div v-for="(img, iidx) in page.images" :key="iidx" class="relative w-[96px]">
|
||||||
<image-field v-model="page.images[iidx]"
|
<image-field v-model="page.images[iidx]"
|
||||||
:label="`图${iidx+1}`"
|
:label="heartSlotLabel(page.type, iidx)"
|
||||||
@pick="openUpload(u => setOriginalImageAt(page, iidx, u))"
|
@pick="openUpload(u => setOriginalImageAt(page, iidx, u))"
|
||||||
:uploading="uploading" />
|
:uploading="uploading" />
|
||||||
<div v-if="page.imagesResized?.[iidx]" class="text-center mt-1">
|
<div v-if="page.imagesResized?.[iidx]" class="text-center mt-1">
|
||||||
@@ -1436,7 +1456,7 @@ import { TooltipComponent, LegendComponent } from 'echarts/components'
|
|||||||
import { CanvasRenderer } from 'echarts/renderers'
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
import {
|
import {
|
||||||
getConfigSection, saveConfigSection, uploadImage, getRsvpList, deleteRsvp, getUploadConfig, saveUploadConfig,
|
getConfigSection, saveConfigSection, uploadImage, getRsvpList, deleteRsvp, getUploadConfig, saveUploadConfig,
|
||||||
getDanmakuList, updateDanmakuStatus, getVisitStats, getVisitList,
|
getDanmakuList, updateDanmakuStatus, getVisitStats, getVisitCityStats, getVisitList,
|
||||||
getPosterConfig, savePosterConfig, getGiftStats, getGiftList, deleteGift, saveGiftCosts,
|
getPosterConfig, savePosterConfig, getGiftStats, getGiftList, deleteGift, saveGiftCosts,
|
||||||
getCashGiftList, updateCashGift, deleteCashGift, submitCashGift,
|
getCashGiftList, updateCashGift, deleteCashGift, submitCashGift,
|
||||||
getCashGiftNotes, saveCashGiftNotes,
|
getCashGiftNotes, saveCashGiftNotes,
|
||||||
@@ -1649,8 +1669,13 @@ const visitPageSize = ref(20)
|
|||||||
const visitTotal = ref(0)
|
const visitTotal = ref(0)
|
||||||
const regionChartRef = ref(null)
|
const regionChartRef = ref(null)
|
||||||
const deviceChartRef = ref(null)
|
const deviceChartRef = ref(null)
|
||||||
|
const cityChartRef = ref(null)
|
||||||
let regionChart = null
|
let regionChart = null
|
||||||
let deviceChart = null
|
let deviceChart = null
|
||||||
|
let cityChart = null
|
||||||
|
const selectedRegion = ref('')
|
||||||
|
const cityStats = ref([])
|
||||||
|
const cityStatsLoading = ref(false)
|
||||||
|
|
||||||
const cfg = reactive(defaultConfig())
|
const cfg = reactive(defaultConfig())
|
||||||
const uploadCfg = reactive(defaultUploadConfig())
|
const uploadCfg = reactive(defaultUploadConfig())
|
||||||
@@ -1954,12 +1979,12 @@ function ensurePageShape(p) {
|
|||||||
delete p.imagesResized
|
delete p.imagesResized
|
||||||
delete p.imagesResizedMeta
|
delete p.imagesResizedMeta
|
||||||
} else {
|
} else {
|
||||||
// 固定数量版式:一大五小(6) / 九宫格(9);柔性版式:瀑布流/轮播/拍立得,仅首次补齐
|
// 固定数量版式:一大五小(6) / 九宫格(9) / 爱心拼接(13);柔性版式:瀑布流/轮播/拍立得,仅首次补齐
|
||||||
const fixed = { 'one-large-five-small': 6, 'nine-grid': 9, 'masonry': 6, 'carousel': 5, 'polaroid': 6 }
|
const fixed = { 'one-large-five-small': 6, 'nine-grid': 9, 'heart-collage': 13, 'masonry': 6, 'carousel': 5, 'polaroid': 6 }
|
||||||
const need = fixed[p.type] || 6
|
const need = fixed[p.type] || 6
|
||||||
p.images = Array.isArray(p.images) ? p.images : []
|
p.images = Array.isArray(p.images) ? p.images : []
|
||||||
if (p.images.length === 0) while (p.images.length < need) p.images.push('')
|
if (p.images.length === 0) while (p.images.length < need) p.images.push('')
|
||||||
else if (['one-large-five-small', 'nine-grid'].includes(p.type)) {
|
else if (['one-large-five-small', 'nine-grid', 'heart-collage'].includes(p.type)) {
|
||||||
while (p.images.length < need) p.images.push('')
|
while (p.images.length < need) p.images.push('')
|
||||||
if (p.images.length > need) p.images.length = need
|
if (p.images.length > need) p.images.length = need
|
||||||
}
|
}
|
||||||
@@ -1984,6 +2009,15 @@ function ensurePageShape(p) {
|
|||||||
function isFlexible(type) {
|
function isFlexible(type) {
|
||||||
return ['masonry', 'carousel', 'polaroid'].includes(type)
|
return ['masonry', 'carousel', 'polaroid'].includes(type)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HEART_SLOT_LABELS = [
|
||||||
|
'左上叶', '右上叶', '左外上', '左外下', '左大图', '中轴', '右大图',
|
||||||
|
'右外上', '右外下', '左下', '中下横', '右下', '心尖',
|
||||||
|
]
|
||||||
|
function heartSlotLabel(type, i) {
|
||||||
|
if (type === 'heart-collage' && HEART_SLOT_LABELS[i]) return HEART_SLOT_LABELS[i]
|
||||||
|
return `图${i + 1}`
|
||||||
|
}
|
||||||
function addImage(page) {
|
function addImage(page) {
|
||||||
if (!Array.isArray(page.images)) page.images = []
|
if (!Array.isArray(page.images)) page.images = []
|
||||||
page.images.push('')
|
page.images.push('')
|
||||||
@@ -2611,9 +2645,20 @@ function disposeVisitCharts() {
|
|||||||
deviceChart.dispose()
|
deviceChart.dispose()
|
||||||
deviceChart = null
|
deviceChart = null
|
||||||
}
|
}
|
||||||
|
if (cityChart) {
|
||||||
|
cityChart.dispose()
|
||||||
|
cityChart = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPieChart(chartRef, holder, seriesData, emptyName = '暂无数据') {
|
function disposeCityChart() {
|
||||||
|
if (cityChart) {
|
||||||
|
cityChart.dispose()
|
||||||
|
cityChart = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPieChart(chartRef, holder, seriesData, emptyName = '暂无数据', opts = {}) {
|
||||||
if (!chartRef.value) return null
|
if (!chartRef.value) return null
|
||||||
if (holder && holder.getDom() !== chartRef.value) {
|
if (holder && holder.getDom() !== chartRef.value) {
|
||||||
holder.dispose()
|
holder.dispose()
|
||||||
@@ -2621,7 +2666,11 @@ function renderPieChart(chartRef, holder, seriesData, emptyName = '暂无数据'
|
|||||||
}
|
}
|
||||||
if (!holder) holder = echarts.init(chartRef.value)
|
if (!holder) holder = echarts.init(chartRef.value)
|
||||||
const data = seriesData.length
|
const data = seriesData.length
|
||||||
? seriesData.map((r) => ({ name: r.name || '未知', value: Number(r.value) || 0 }))
|
? seriesData.map((r) => ({
|
||||||
|
name: r.name || '未知',
|
||||||
|
value: Number(r.value) || 0,
|
||||||
|
selected: opts.selectedName ? (r.name || '未知') === opts.selectedName : false,
|
||||||
|
}))
|
||||||
: [{ name: emptyName, value: 0 }]
|
: [{ name: emptyName, value: 0 }]
|
||||||
holder.setOption({
|
holder.setOption({
|
||||||
color: PIE_COLORS,
|
color: PIE_COLORS,
|
||||||
@@ -2635,11 +2684,13 @@ function renderPieChart(chartRef, holder, seriesData, emptyName = '暂无数据'
|
|||||||
type: 'pie',
|
type: 'pie',
|
||||||
radius: ['38%', '62%'],
|
radius: ['38%', '62%'],
|
||||||
center: ['50%', '46%'],
|
center: ['50%', '46%'],
|
||||||
|
selectedMode: opts.selectable ? 'single' : false,
|
||||||
|
selectedOffset: 8,
|
||||||
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
|
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
|
||||||
label: { color: '#5c5348', fontSize: 11 },
|
label: { color: '#5c5348', fontSize: 11 },
|
||||||
data,
|
data,
|
||||||
}],
|
}],
|
||||||
})
|
}, { notMerge: true })
|
||||||
return holder
|
return holder
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2648,7 +2699,20 @@ function renderRegionChart() {
|
|||||||
regionChartRef,
|
regionChartRef,
|
||||||
regionChart,
|
regionChart,
|
||||||
Array.isArray(visitStats.regions) ? visitStats.regions : [],
|
Array.isArray(visitStats.regions) ? visitStats.regions : [],
|
||||||
|
'暂无数据',
|
||||||
|
{ selectable: true, selectedName: selectedRegion.value },
|
||||||
)
|
)
|
||||||
|
if (!regionChart) return
|
||||||
|
regionChart.off('click')
|
||||||
|
regionChart.on('click', (params) => {
|
||||||
|
const name = params?.name
|
||||||
|
if (!name || name === '暂无数据') return
|
||||||
|
if (selectedRegion.value === name) {
|
||||||
|
clearSelectedRegion()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onSelectRegion(name)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDeviceChart() {
|
function renderDeviceChart() {
|
||||||
@@ -2659,6 +2723,42 @@ function renderDeviceChart() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderCityChart() {
|
||||||
|
cityChart = renderPieChart(
|
||||||
|
cityChartRef,
|
||||||
|
cityChart,
|
||||||
|
Array.isArray(cityStats.value) ? cityStats.value : [],
|
||||||
|
'暂无市级数据',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelectedRegion(opts = {}) {
|
||||||
|
selectedRegion.value = ''
|
||||||
|
cityStats.value = []
|
||||||
|
disposeCityChart()
|
||||||
|
if (!opts.silent && regionChart) renderRegionChart()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSelectRegion(name) {
|
||||||
|
selectedRegion.value = name
|
||||||
|
cityStatsLoading.value = true
|
||||||
|
cityStats.value = []
|
||||||
|
disposeCityChart()
|
||||||
|
renderRegionChart()
|
||||||
|
try {
|
||||||
|
const res = await getVisitCityStats(name)
|
||||||
|
const d = res.data || {}
|
||||||
|
cityStats.value = Array.isArray(d.cities) ? d.cities : []
|
||||||
|
await nextTick()
|
||||||
|
renderCityChart()
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e?.message || '加载市级分布失败')
|
||||||
|
cityStats.value = []
|
||||||
|
} finally {
|
||||||
|
cityStatsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadGiftStats() {
|
async function loadGiftStats() {
|
||||||
giftStatsLoading.value = true
|
giftStatsLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -2723,6 +2823,9 @@ async function loadVisitStats() {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
renderRegionChart()
|
renderRegionChart()
|
||||||
renderDeviceChart()
|
renderDeviceChart()
|
||||||
|
if (selectedRegion.value) {
|
||||||
|
await onSelectRegion(selectedRegion.value)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e?.message || '加载访客统计失败')
|
message.error(e?.message || '加载访客统计失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2773,7 +2876,10 @@ function onVisitTableChange(pag) {
|
|||||||
|
|
||||||
async function onTabActivate(tab) {
|
async function onTabActivate(tab) {
|
||||||
if (!adminStore.isAdmin) return
|
if (!adminStore.isAdmin) return
|
||||||
if (tab !== 'overview') disposeVisitCharts()
|
if (tab !== 'overview') {
|
||||||
|
clearSelectedRegion({ silent: true })
|
||||||
|
disposeVisitCharts()
|
||||||
|
}
|
||||||
if (tab === 'overview') {
|
if (tab === 'overview') {
|
||||||
await loadOverview()
|
await loadOverview()
|
||||||
return
|
return
|
||||||
@@ -3084,6 +3190,7 @@ watch(activeTab, (tab) => {
|
|||||||
const onResize = () => {
|
const onResize = () => {
|
||||||
regionChart?.resize()
|
regionChart?.resize()
|
||||||
deviceChart?.resize()
|
deviceChart?.resize()
|
||||||
|
cityChart?.resize()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user