diff --git a/hunliji-api/visit/device_names.go b/hunliji-api/visit/device_names.go new file mode 100644 index 0000000..bddb29a --- /dev/null +++ b/hunliji-api/visit/device_names.go @@ -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", +} diff --git a/hunliji-api/visit/visit.go b/hunliji-api/visit/visit.go index 120c118..357f0f4 100644 --- a/hunliji-api/visit/visit.go +++ b/hunliji-api/visit/visit.go @@ -46,6 +46,7 @@ func Register(api *gin.RouterGroup, database *gorm.DB, adminGuard func(*gin.Cont requireAdmin = adminGuard api.POST("/visit", handleTrackVisit) api.GET("/visit/stats", handleVisitStats) + api.GET("/visit/stats/cities", handleVisitCityStats) api.GET("/visit/list", handleVisitList) } @@ -196,7 +197,7 @@ func parseIPGeoJSON(raw []byte) ipGeoResult { city := pick("city", "City") isp := pick("isp", "ISP", "operator", "org") - // result 常为完整文案,如「中国 广东省 深圳市 电信」 + // result 常为完整文案,如「中国|浙江省|杭州市|联通」——运营商单独记 isp,不并入地区 rawLoc := resultStr if rawLoc == "" { parts := []string{} @@ -214,10 +215,24 @@ func parseIPGeoJSON(raw []byte) ipGeoResult { rawLoc = "未知" } - // 若只有完整文案,尽量拆出省用于统计 + tokens := normalizeLocTokens(rawLoc) + if isp == "" { + isp = extractISPFromTokens(tokens) + } + // 归属地展示去掉运营商,避免与 isp 字段重复、也避免被当成地区 + rawLoc = joinLocationWithoutISP(tokens) + if rawLoc == "" { + rawLoc = "未知" + } + + // 省/地区:结构化字段优先,但若整串误带了运营商则清洗;否则从文案推断 + region = sanitizeRegion(region) if region == "" && rawLoc != "" && rawLoc != "未知" && rawLoc != "本地" { region = guessProvince(rawLoc) } + if city == "" { + city = guessCity(tokens, region) + } return ipGeoResult{ Country: country, @@ -228,22 +243,182 @@ func parseIPGeoJSON(raw []byte) ipGeoResult { } } -func guessProvince(raw string) string { - // 粗略从完整归属地文案中取省级片段,供地区分布统计 - tokens := strings.Fields(strings.ReplaceAll(raw, " ", " ")) +// 常见运营商名:不得写入 region / raw_location +var ispNameTokens = []string{ + "联通", "电信", "移动", "铁通", "广电", "教育网", + "长城宽带", "鹏博士", "方正宽带", "宽带通", + "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 { - if strings.Contains(t, "省") || strings.Contains(t, "自治区") || - strings.HasSuffix(t, "市") && (strings.HasPrefix(t, "北京") || strings.HasPrefix(t, "上海") || - strings.HasPrefix(t, "天津") || strings.HasPrefix(t, "重庆")) { + if isISPToken(t) { + continue + } + 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 } } - if len(tokens) >= 2 { - return tokens[1] + for _, t := range tokens { + 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 "" } +// 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 { r := []rune(s) if len(r) <= max { @@ -261,14 +436,20 @@ func parseDeviceFromUA(ua string) string { lower := strings.ToLower(ua) if strings.Contains(ua, "iPhone") { + if ver := extractIOSVersion(ua); ver != "" { + return "iPhone · iOS " + ver + } return "iPhone" } if strings.Contains(ua, "iPad") { + if ver := extractIOSVersion(ua); ver != "" { + return "iPad · iOS " + ver + } return "iPad" } if strings.Contains(lower, "android") { if model := extractAndroidModel(ua); model != "" { - return model + return friendlyAndroidName(model) } return "Android" } @@ -287,6 +468,31 @@ func parseDeviceFromUA(ua string) string { 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 { // 常见形态:Linux; Android 13; SM-S9180 Build/... 或 Android 12; Pixel 7) idx := strings.Index(ua, "Android") @@ -300,17 +506,24 @@ func extractAndroidModel(ua string) string { } rest = strings.TrimSpace(rest[semi+1:]) 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 { end = p } } model := strings.TrimSpace(rest[:end]) model = strings.Trim(model, "; ") + // 部分 UA:HarmonyOS; NOH-AN00 Build if model == "" || strings.EqualFold(model, "wv") || strings.HasPrefix(strings.ToLower(model), "linux") { 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) if len(r) > 40 { model = string(r[:40]) @@ -473,13 +686,31 @@ func handleVisitStats(c *gin.Context) { Name string `json:"name"` Value int64 `json:"value"` } - var regions []regionRow + var rawRegions []regionRow db.Model(&SiteVisit{}). Select("CASE WHEN region = '' OR region IS NULL THEN '未知' ELSE region END as name, COUNT(DISTINCT fingerprint) as value"). Group("name"). - Order("value desc"). - Limit(30). - Scan(®ions) + Scan(&rawRegions) + + // 合并历史脏数据(如「中国|浙江省|杭州市|联通」被整段写入 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 { 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) { if !requireAdmin(c) { return diff --git a/vite-tailwindcss/pnpm-lock.yaml b/vite-tailwindcss/pnpm-lock.yaml index a369658..3b28d6a 100644 --- a/vite-tailwindcss/pnpm-lock.yaml +++ b/vite-tailwindcss/pnpm-lock.yaml @@ -17,12 +17,30 @@ importers: '@fingerprintjs/fingerprintjs': specifier: ^5.2.0 version: 5.2.0 + '@fontsource/cinzel': + specifier: ^5.3.0 + version: 5.3.0 '@fontsource/great-vibes': specifier: ^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': specifier: ^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': specifier: ^6.7.2 version: 6.7.2 @@ -296,12 +314,30 @@ packages: '@fingerprintjs/fingerprintjs@5.2.0': resolution: {integrity: sha512-j+2nInkwCQNTJcNhOjvkGM/nLRTuGJTC6xai4quqvUpjob2ssrGwBZjS7k55nOmKvge7qvJT2nS3i/IRvQSTQA==} + '@fontsource/cinzel@5.3.0': + resolution: {integrity: sha512-KEOYTsrRppW0uRIPWBDPrQgunCS6f8u4nzZxYRBfo9SCknKojLYjK7B0ZoVmJhyVLefz271ZQRF9EOpHZlfhRw==} + '@fontsource/great-vibes@5.3.0': 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': 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': resolution: {integrity: sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==} engines: {node: '>=6'} @@ -1483,10 +1519,22 @@ snapshots: '@fingerprintjs/fingerprintjs@5.2.0': {} + '@fontsource/cinzel@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/pinyon-script@5.3.0': {} + + '@fontsource/playfair-display@5.3.0': {} + + '@fontsource/zhi-mang-xing@5.3.0': {} + '@fortawesome/fontawesome-free@6.7.2': {} '@jridgewell/gen-mapping@0.3.13': diff --git a/vite-tailwindcss/src/api/wedding.js b/vite-tailwindcss/src/api/wedding.js index b886bd8..3d66faa 100644 --- a/vite-tailwindcss/src/api/wedding.js +++ b/vite-tailwindcss/src/api/wedding.js @@ -72,6 +72,10 @@ export function getVisitStats() { return service.get('/visit/stats') } +export function getVisitCityStats(region) { + return service.get('/visit/stats/cities', { params: { region } }) +} + export function getVisitList(params) { return service.get('/visit/list', { params }) } diff --git a/vite-tailwindcss/src/components/PhotoGallery.vue b/vite-tailwindcss/src/components/PhotoGallery.vue index 91194e3..8b09e4b 100644 --- a/vite-tailwindcss/src/components/PhotoGallery.vue +++ b/vite-tailwindcss/src/components/PhotoGallery.vue @@ -197,6 +197,31 @@ + +