From d859650136c141690736ef63a3153818f6fb37b0 Mon Sep 17 00:00:00 2001 From: zerosaturation Date: Tue, 9 Jun 2026 00:37:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E4=BF=AE=E6=94=B9=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=9C=8B=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + backend/dev.sh | 10 +- .../controller/statistic_controller.go | 14 +- ..._08_004_statistic_mv_daily_like_income.sql | 8 +- ..._statistic_mv_asset_level_distribution.sql | 13 +- .../client/user_rpc_client.go | 5 +- .../config/statistic_config.go | 29 +- backend/services/statisticService/main.go | 4 +- .../provider/statistic_combined_provider.go | 48 ++- .../repository/dashboard_repo.go | 33 +- .../repository/metric_repo.go | 31 +- .../userService/service/user_service.go | 3 + ...2026-06-08-income-curve-default-tooltip.md | 346 ++++++++++++++++++ ...-08-income-curve-default-tooltip-design.md | 279 ++++++++++++++ frontend/.env.development | 4 +- frontend/.env.production | 4 +- frontend/composables/useDashboardData.js | 42 ++- .../dashboard/components/IncomeCurve.vue | 160 +++++--- frontend/utils/api.js | 5 +- 19 files changed, 937 insertions(+), 105 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-08-income-curve-default-tooltip.md create mode 100644 docs/superpowers/specs/2026-06-08-income-curve-default-tooltip-design.md diff --git a/.gitignore b/.gitignore index 753491c..628d67c 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,7 @@ backend/**/*.exe .claude/ hookify.*.local.md .mcp.json + +# statisticService runtime logs (created by logger when running tests) +backend/services/statisticService/**/logs/ +backend/services/statisticService/**/logs/*.log diff --git a/backend/dev.sh b/backend/dev.sh index 692d67a..beb7541 100755 --- a/backend/dev.sh +++ b/backend/dev.sh @@ -21,7 +21,7 @@ cleanup() { fi # 清理所有 PID 文件并杀服务进程 - for service in gateway activityService galleryService socialService assetService userService taskService starbookService aiChatService; do + for service in gateway activityService galleryService socialService assetService userService taskService starbookService aiChatService statisticService; do pkill -9 -f "$service" 2>/dev/null || true rm -f "/tmp/dev_sh_${service}.pid" "/tmp/dev_sh_${service}_restart" "/tmp/dev_sh_${service}.lock" echo -e "${YELLOW} 🛑 $service 已停止${NC}" @@ -387,13 +387,13 @@ echo "" > /tmp/dev_sh_watchers.tmp # 清理残留 PID 文件(上次非正常退出可能留下) -for service in activityService galleryService socialService assetService userService taskService gateway starbookService aiChatService; do +for service in activityService galleryService socialService assetService userService taskService gateway starbookService aiChatService statisticService; do rm -f "/tmp/dev_sh_${service}.pid" "/tmp/dev_sh_${service}_restart" done # 停止现有服务(清理环境) echo -e "${YELLOW}🛑 停止现有服务...${NC}" -for service in gateway userService socialService assetService galleryService activityService taskService starbookService aiChatService; do +for service in gateway userService socialService assetService galleryService activityService taskService starbookService aiChatService statisticService; do pkill -9 -f "$service" 2>/dev/null || true done sleep 1 @@ -425,6 +425,7 @@ build_service "activityService" "services/activityService" "services/activitySer build_service "taskService" "services/taskService" "services/taskService/taskService" build_service "starbookService" "services/starbookService" "services/starbookService/starbookService" build_service "aiChatService" "services/aiChatService" "services/aiChatService/aiChatService" +build_service "statisticService" "services/statisticService" "services/statisticService/statisticService" cd "$SCRIPT_DIR" # 启动所有服务 @@ -443,6 +444,7 @@ start_service "activityService" "services/activityService/activityService" 20005 start_service "taskService" "services/taskService/taskService" 20006 1 0 start_service "starbookService" "services/starbookService/starbookService" 20007 1 0 start_service "aiChatService" "services/aiChatService/aiChatService" 20008 1 1 +start_service "statisticService" "services/statisticService/statisticService" 20009 1 1 start_service "gateway" "gateway/gateway" 8080 0 0 # 启动所有文件监听器 @@ -457,6 +459,7 @@ start_watcher "activityService" "services/activityService" "services/activi start_watcher "taskService" "services/taskService" "services/taskService/taskService" 20006 1 0 start_watcher "starbookService" "services/starbookService" "services/starbookService/starbookService" 20007 1 0 start_watcher "aiChatService" "services/aiChatService:pkg/proto" "services/aiChatService/aiChatService" 20008 1 1 +start_watcher "statisticService" "services/statisticService:pkg/proto" "services/statisticService/statisticService" 20009 1 1 echo "" echo -e "${GREEN}========================================${NC}" @@ -473,6 +476,7 @@ echo " - Gallery Service: tri://localhost:20004" echo " - Activity Service: tri://localhost:20005" echo " - Task Service: tri://localhost:20006" echo " - Starbook Service: tri://localhost:20007" +echo " - Statistic Service: tri://localhost:20009 (port+1000=21009 healthz)" echo "" echo -e "${YELLOW}按 Ctrl+C 停止所有服务${NC}" echo "" diff --git a/backend/gateway/controller/statistic_controller.go b/backend/gateway/controller/statistic_controller.go index d3d4360..cd822ac 100644 --- a/backend/gateway/controller/statistic_controller.go +++ b/backend/gateway/controller/statistic_controller.go @@ -83,7 +83,7 @@ func (ctrl *StatisticController) GetTodayOverview(c *gin.Context) { response.Error(c, http.StatusInternalServerError, "获取今日概览失败") return } - response.OK(c, gin.H{"code": 200, "data": resp}) + response.Success(c, resp) } // ===== 2. 七日收益曲线 ===== @@ -97,7 +97,7 @@ func (ctrl *StatisticController) Get7DayIncomeCurve(c *gin.Context) { response.Error(c, http.StatusInternalServerError, "获取七日收益曲线失败") return } - response.OK(c, gin.H{"code": 200, "data": resp}) + response.Success(c, resp) } // ===== 3. 展出收益中心 ===== @@ -111,7 +111,7 @@ func (ctrl *StatisticController) GetExhibitionIncomeSummary(c *gin.Context) { response.Error(c, http.StatusInternalServerError, "获取展出收益中心失败") return } - response.OK(c, gin.H{"code": 200, "data": resp}) + response.Success(c, resp) } // ===== 4. 点赞收益按等级 ===== @@ -125,7 +125,7 @@ func (ctrl *StatisticController) GetLikeIncomeByLevel(c *gin.Context) { response.Error(c, http.StatusInternalServerError, "获取点赞等级收益失败") return } - response.OK(c, gin.H{"code": 200, "data": resp}) + response.Success(c, resp) } // ===== 5. 藏品 TOP5 ===== @@ -139,7 +139,7 @@ func (ctrl *StatisticController) GetTopAssets(c *gin.Context) { response.Error(c, http.StatusInternalServerError, "获取藏品 TOP5 失败") return } - response.OK(c, gin.H{"code": 200, "data": resp}) + response.Success(c, resp) } // ===== 6. 藏品等级分布 ===== @@ -153,7 +153,7 @@ func (ctrl *StatisticController) GetLevelDistribution(c *gin.Context) { response.Error(c, http.StatusInternalServerError, "获取等级分布失败") return } - response.OK(c, gin.H{"code": 200, "data": resp}) + response.Success(c, resp) } // ===== 7. 升级进度 ===== @@ -167,5 +167,5 @@ func (ctrl *StatisticController) GetUpgradeProgress(c *gin.Context) { response.Error(c, http.StatusInternalServerError, "获取升级进度失败") return } - response.OK(c, gin.H{"code": 200, "data": resp}) + response.Success(c, resp) } diff --git a/backend/migrations/2026_06_08_004_statistic_mv_daily_like_income.sql b/backend/migrations/2026_06_08_004_statistic_mv_daily_like_income.sql index 42d188e..94176a2 100644 --- a/backend/migrations/2026_06_08_004_statistic_mv_daily_like_income.sql +++ b/backend/migrations/2026_06_08_004_statistic_mv_daily_like_income.sql @@ -3,12 +3,14 @@ -- 服务于: 点赞收益按等级(累计) -- 关联: spec §3.4 MV3 -- 关联依赖: public.assets 表 +-- 修复: a.level → a.grade(assets 表没有 level 列,星册等级在 grade 字段) +-- 防御: properties->>'asset_id' 必须存在且为数字,避免 JOIN 失败 CREATE MATERIALIZED VIEW IF NOT EXISTS statistic.mv_daily_like_income AS SELECT e.user_id, e.star_id, - a.level AS asset_level, + a.grade AS asset_level, DATE(e.received_at AT TIME ZONE 'Asia/Shanghai') AS like_date, COUNT(*) AS like_count, SUM(COALESCE((e.properties->>'amount')::BIGINT, 0)) AS total_crystal @@ -16,7 +18,9 @@ FROM statistic.events e JOIN public.assets a ON a.id = (e.properties->>'asset_id')::BIGINT WHERE e.event_type = 'asset.like' -GROUP BY e.user_id, e.star_id, a.level, like_date; + AND (e.properties->>'asset_id') IS NOT NULL + AND (e.properties->>'asset_id') ~ '^[0-9]+$' +GROUP BY e.user_id, e.star_id, a.grade, DATE(e.received_at AT TIME ZONE 'Asia/Shanghai'); CREATE UNIQUE INDEX IF NOT EXISTS idx_mv_like_income_pk ON statistic.mv_daily_like_income (user_id, star_id, asset_level, like_date); diff --git a/backend/migrations/2026_06_08_005_statistic_mv_asset_level_distribution.sql b/backend/migrations/2026_06_08_005_statistic_mv_asset_level_distribution.sql index 0bcf8a5..37bae19 100644 --- a/backend/migrations/2026_06_08_005_statistic_mv_asset_level_distribution.sql +++ b/backend/migrations/2026_06_08_005_statistic_mv_asset_level_distribution.sql @@ -2,17 +2,20 @@ -- 创建时间: 2026-06-08 -- 服务于: 藏品等级分布环形图 -- 关联: spec §3.4 MV4 --- 假设: public.assets 表有 status='active' 软删除状态 + deleted_at IS NULL 软删除约定 +-- 修复: 字段名按实际 assets 表 schema 调整 +-- - user_id → owner_uid AS user_id +-- - status='active' AND deleted_at IS NULL → is_active=true AND deleted_at IS NULL +-- - level → COALESCE(grade::text, 'UNKNOWN')(grade 可能为 NULL) CREATE MATERIALIZED VIEW IF NOT EXISTS statistic.mv_asset_level_distribution AS SELECT - user_id, + owner_uid AS user_id, star_id, - level AS asset_level, + COALESCE(grade::TEXT, 'UNKNOWN') AS asset_level, COUNT(*) AS asset_count FROM public.assets -WHERE status = 'active' AND deleted_at IS NULL -GROUP BY user_id, star_id, level; +WHERE is_active = TRUE AND deleted_at IS NULL +GROUP BY owner_uid, star_id, grade; CREATE UNIQUE INDEX IF NOT EXISTS idx_mv_level_dist_pk ON statistic.mv_asset_level_distribution (user_id, star_id, asset_level); diff --git a/backend/services/statisticService/client/user_rpc_client.go b/backend/services/statisticService/client/user_rpc_client.go index 460f19c..1410a7d 100644 --- a/backend/services/statisticService/client/user_rpc_client.go +++ b/backend/services/statisticService/client/user_rpc_client.go @@ -3,6 +3,7 @@ package client import ( "context" + pbCommon "github.com/topfans/backend/pkg/proto/common" pbUser "github.com/topfans/backend/pkg/proto/user" ) @@ -18,6 +19,8 @@ func NewUserServiceClient(svc pbUser.UserSocialService) *UserServiceClient { // GetCrystalBalance 调 userService.GetFanProfile 取 crystal_balance // (userService 没单独 GetCrystalBalance,FanProfile 含此字段) +// 注意: StatusCode_STATUS_OK == 200(不是 0!),之前用 resp.Base.Code != 0 +// 检查等价于"任何成功响应都被当作错误",导致永远返回 0 func (c *UserServiceClient) GetCrystalBalance(ctx context.Context, userID, starID int64) (int64, error) { resp, err := c.userSocial.GetFanProfile(ctx, &pbUser.GetFanProfileRequest{ UserId: userID, @@ -29,7 +32,7 @@ func (c *UserServiceClient) GetCrystalBalance(ctx context.Context, userID, starI if resp == nil || resp.Base == nil { return 0, nil } - if resp.Base.Code != 0 { + if resp.Base.Code != pbCommon.StatusCode_STATUS_OK { return 0, nil } if resp.Profile == nil { diff --git a/backend/services/statisticService/config/statistic_config.go b/backend/services/statisticService/config/statistic_config.go index 175cd2a..36bb328 100644 --- a/backend/services/statisticService/config/statistic_config.go +++ b/backend/services/statisticService/config/statistic_config.go @@ -2,6 +2,7 @@ package config import ( "flag" + "fmt" "log" "os" "strconv" @@ -16,9 +17,21 @@ type DatabaseConfig struct { Schema string } -// RedisConfig Redis 配置 +// RedisConfig Redis 配置(与项目其他服务一致:host/port/db/password) type RedisConfig struct { - URL string + Host string + Port int + DB int + Password string +} + +// URL 构造 redis:// URL(pkg/redis 客户端使用) +func (r *RedisConfig) URL() string { + auth := "" + if r.Password != "" { + auth = r.Password + "@" + } + return fmt.Sprintf("redis://%s%s:%d/%d", auth, r.Host, r.Port, r.DB) } // RefreshIntervals 物化视图/预聚表刷新间隔 @@ -58,7 +71,10 @@ var ( Schema: "statistic", } RedisCfg = &RedisConfig{ - URL: "redis://localhost:6379/0", + Host: "localhost", + Port: 6379, + DB: 0, + Password: "", } RefreshCfg = &RefreshIntervals{ DailyUserIncome: 5 * time.Minute, @@ -130,7 +146,10 @@ func InitConfig() { flag.StringVar(&DBConfig.SSLMode, "db-sslmode", "disable", "数据库 SSL 模式") flag.StringVar(&DBConfig.Schema, "db-schema", getEnv("STATISTIC_DB_SCHEMA", "statistic"), "数据库 schema") - flag.StringVar(&RedisCfg.URL, "redis-url", getEnv("STATISTIC_REDIS_URL", "redis://localhost:6379/0"), "Redis URL") + flag.StringVar(&RedisCfg.Host, "redis-host", getEnv("STATISTIC_REDIS_HOST", "localhost"), "Redis 主机") + flag.IntVar(&RedisCfg.Port, "redis-port", getEnvInt("STATISTIC_REDIS_PORT", 6379), "Redis 端口") + flag.IntVar(&RedisCfg.DB, "redis-db", getEnvInt("STATISTIC_REDIS_DB", 0), "Redis DB") + flag.StringVar(&RedisCfg.Password, "redis-password", getEnv("STATISTIC_REDIS_PASSWORD", ""), "Redis 密码") flag.IntVar(&ChannelCfg.EventChannelCapacity, "event-channel-capacity", getEnvInt("STATISTIC_EVENT_CHANNEL_CAPACITY", 1000), "事件 channel 容量") flag.IntVar(&ChannelCfg.EventBatchSize, "event-batch-size", getEnvInt("STATISTIC_EVENT_BATCH_SIZE", 100), "事件批量大小") @@ -147,7 +166,7 @@ func InitConfig() { flag.Parse() log.Println("statisticService 配置初始化完成") log.Printf(" 数据库: %s:%d/%s (schema=%s)", DBConfig.Host, DBConfig.Port, DBConfig.DBName, DBConfig.Schema) - log.Printf(" Redis: %s", RedisCfg.URL) + log.Printf(" Redis: %s", RedisCfg.URL()) log.Printf(" 事件 channel 容量: %d, 批量: %d/%v", ChannelCfg.EventChannelCapacity, ChannelCfg.EventBatchSize, ChannelCfg.EventBatchInterval) log.Printf(" 分区保留: %d 天, 预创建: %d 天", PartitionCfg.RetentionDays, PartitionCfg.PreCreateDays) log.Printf(" 扩展开关: OLAP=%v Realtime=%v SDK=%v Sampling=%v", diff --git a/backend/services/statisticService/main.go b/backend/services/statisticService/main.go index 09918a7..59bc82c 100644 --- a/backend/services/statisticService/main.go +++ b/backend/services/statisticService/main.go @@ -11,7 +11,9 @@ import ( "time" dubboclient "dubbo.apache.org/dubbo-go/v3/client" + _ "dubbo.apache.org/dubbo-go/v3/imports" "dubbo.apache.org/dubbo-go/v3/protocol" + _ "dubbo.apache.org/dubbo-go/v3/protocol/triple" dubboserver "dubbo.apache.org/dubbo-go/v3/server" "github.com/gin-gonic/gin" _ "github.com/lib/pq" @@ -65,7 +67,7 @@ func main() { logger.Logger.Info("Database connected") // 4. Open Redis connection - opt, err := redis.ParseURL(config.RedisCfg.URL) + opt, err := redis.ParseURL(config.RedisCfg.URL()) if err != nil { logger.Logger.Fatal(fmt.Sprintf("Failed to parse Redis URL: %v", err)) } diff --git a/backend/services/statisticService/provider/statistic_combined_provider.go b/backend/services/statisticService/provider/statistic_combined_provider.go index 74020f3..24a21e6 100644 --- a/backend/services/statisticService/provider/statistic_combined_provider.go +++ b/backend/services/statisticService/provider/statistic_combined_provider.go @@ -5,6 +5,7 @@ import ( "strconv" "time" + "dubbo.apache.org/dubbo-go/v3/common/constant" "go.uber.org/zap" "github.com/topfans/backend/pkg/logger" @@ -30,15 +31,44 @@ func NewStatisticCombinedProvider(internal *StatisticInternalProvider, dashSvc * } func userIDFromContext(ctx context.Context) int64 { - if v := ctx.Value("user_id"); v != nil { - switch s := v.(type) { - case string: - n, _ := strconv.ParseInt(s, 10, 64) - return n - case int64: - return s - case int: - return int64(s) + // 必须从 Dubbo attachments 读取(与 assetService/socialService/userService 一致) + // 协议:gateway 通过 ctx.Value(constant.AttachmentKey, map{"user_id": "1"}) 写入 + // 经过 triple 协议传输后,gateway 的 string 在 attachments 里变成 ["1"](单元素字符串数组) + // 之前用 ctx.Value("user_id") 直接读 string key,永远拿到 nil → 返回 0 + if attachments := ctx.Value(constant.AttachmentKey); attachments != nil { + if attMap, ok := attachments.(map[string]interface{}); ok { + if v, ok := attMap["user_id"]; ok { + switch s := v.(type) { + case string: + n, _ := strconv.ParseInt(s, 10, 64) + return n + case int64: + return s + case int: + return int64(s) + case float64: + return int64(s) + case []string: + if len(s) > 0 { + n, _ := strconv.ParseInt(s[0], 10, 64) + return n + } + case []interface{}: + if len(s) > 0 { + switch inner := s[0].(type) { + case string: + n, _ := strconv.ParseInt(inner, 10, 64) + return n + case int64: + return inner + case int: + return int64(inner) + case float64: + return int64(inner) + } + } + } + } } } return 0 diff --git a/backend/services/statisticService/repository/dashboard_repo.go b/backend/services/statisticService/repository/dashboard_repo.go index 7830870..382a57f 100644 --- a/backend/services/statisticService/repository/dashboard_repo.go +++ b/backend/services/statisticService/repository/dashboard_repo.go @@ -150,7 +150,7 @@ func (r *DashboardRepository) GetExhibitionIncomeSummary(ctx context.Context, us } defer rows.Close() var top5 []TopExhibitionRow - var totalEarnings int64 + var totalEarnings, totalDurationMs int64 for rows.Next() { var t TopExhibitionRow var durationMs int64 @@ -162,14 +162,15 @@ func (r *DashboardRepository) GetExhibitionIncomeSummary(ctx context.Context, us t.AvgEarnings = int32(t.Earnings7d / 7) } totalEarnings += t.Earnings7d + totalDurationMs += durationMs top5 = append(top5, t) } - // 简化:exhibiting_count / starbook_count / total_duration 留 0 + // 简化:exhibiting_count / starbook_count 留 0 return &ExhibitionSummary{ ExhibitingCount: 0, StarbookCount: 0, - TotalDuration: "0:00:00:00", + TotalDuration: formatDuration(totalDurationMs), TotalEarnings: totalEarnings, Top5: top5, }, nil @@ -184,11 +185,17 @@ type LikeIncomeLevelRow struct { } func (r *DashboardRepository) GetLikeIncomeByLevel(ctx context.Context, userID, starID int64) ([]LikeIncomeLevelRow, int64, int64, error) { + // 前端期望 N/R/SR/SSR/UR 升级等级徽章,所以 JOIN asset_level_records 拿升级等级 + // (不再用 assets.grade,那只是星册整数 1-5 等级) rows, err := r.db.QueryContext(ctx, fmt.Sprintf(` - SELECT a.level, COUNT(*), SUM(COALESCE((e.properties->>'amount')::BIGINT, 0)), COALESCE(MIN(a.cover_url), '') - FROM %s.events e JOIN public.assets a ON a.id = (e.properties->>'asset_id')::BIGINT + SELECT COALESCE(alr.current_level, 'UNKNOWN') AS level, COUNT(*), SUM(COALESCE((e.properties->>'amount')::BIGINT, 0)), COALESCE(MIN(a.cover_url), '') + FROM %s.events e + JOIN public.assets a ON a.id = (e.properties->>'asset_id')::BIGINT + LEFT JOIN public.asset_level_records alr ON alr.asset_id = a.id WHERE e.user_id=$1 AND e.star_id=$2 AND e.event_type='asset.like' - GROUP BY a.level + AND (e.properties->>'asset_id') IS NOT NULL + AND (e.properties->>'asset_id') ~ '^[0-9]+$' + GROUP BY alr.current_level ORDER BY SUM(COALESCE((e.properties->>'amount')::BIGINT, 0)) DESC `, r.schema), userID, starID) if err != nil { @@ -259,12 +266,16 @@ type AssetLevelCount struct { } func (r *DashboardRepository) GetAssetLevelDistribution(ctx context.Context, userID, starID int64) ([]AssetLevelCount, error) { + // 前端期望 N/R/SR/SSR/UR 升级等级(带徽章和颜色映射),不是 assets.grade 整数 + // 资产升级进度存在 asset_level_records.current_level(通过 level_up 事件维护) + // 过滤掉没 level_records 的资产(前端没有对应徽章,会显示为 N fallback 误导) rows, err := r.db.QueryContext(ctx, ` - SELECT level, COUNT(*)::INT - FROM public.assets - WHERE user_id=$1 AND star_id=$2 - AND status='active' AND deleted_at IS NULL - GROUP BY level + SELECT alr.current_level AS level, COUNT(*)::INT + FROM public.assets a + INNER JOIN public.asset_level_records alr ON alr.asset_id = a.id + WHERE a.owner_uid=$1 AND a.star_id=$2 + AND a.is_active=TRUE AND a.deleted_at IS NULL + GROUP BY alr.current_level ORDER BY COUNT(*) DESC `, userID, starID) if err != nil { diff --git a/backend/services/statisticService/repository/metric_repo.go b/backend/services/statisticService/repository/metric_repo.go index d115e0a..6999bc2 100644 --- a/backend/services/statisticService/repository/metric_repo.go +++ b/backend/services/statisticService/repository/metric_repo.go @@ -62,7 +62,7 @@ func (r *MetricRepository) RefreshWeeklyUserIncome(ctx context.Context) error { FROM %s.events WHERE event_type IN ('exhibition.revenue', 'crystal.change') AND received_at >= DATE_TRUNC('week', NOW() AT TIME ZONE 'Asia/Shanghai') - GROUP BY star_id, user_id + GROUP BY star_id, user_id, DATE_TRUNC('week', received_at AT TIME ZONE 'Asia/Shanghai') ON CONFLICT (star_id, user_id, week_start) DO UPDATE SET total_crystal = EXCLUDED.total_crystal, rank_in_star = EXCLUDED.rank_in_star, updated_at = NOW() `, r.schema, r.schema)) @@ -70,19 +70,34 @@ func (r *MetricRepository) RefreshWeeklyUserIncome(ctx context.Context) error { } // RefreshUpcomingLevelUps 计算每个 asset 的 like_progress + duration_progress -// 注: public.asset_level_config 表名/字段名需 P1 末向 assetService 同学确认 +// 数据源: public.assets (id/owner_uid/star_id/is_active/deleted_at/like_count) +// + public.asset_level_records (current_level + season_likes + season_exhibition_hours) +// + public.asset_levels (level + require_likes + require_hours) +// 语义: 用「赛季累计点赞/赛季累计展出时长」除以「升级阈值」得到进度(%) +// 排除条件: +// 1) 已达最高级(UR)—— 没东西可升 +// 2) 初始等级 N —— 进度无意义(require_likes=0, require_hours=0) +// 3) NULL 防护 —— LEAST(100, NULL) 在 PG 里返回 100(不是 NULL),会污染显示 func (r *MetricRepository) RefreshUpcomingLevelUps(ctx context.Context) error { _, err := r.db.ExecContext(ctx, fmt.Sprintf(` INSERT INTO %s.metric_upcoming_level_ups (user_id, star_id, asset_id, like_progress, duration_progress) SELECT - a.user_id, a.star_id, a.id, - LEAST(100, (a.like_count::FLOAT / NULLIF(alc.upgrade_like_threshold, 0) * 100)::INT) AS like_progress, - LEAST(100, (EXTRACT(EPOCH FROM (NOW() - a.placed_at))::FLOAT / NULLIF(alc.upgrade_duration_seconds, 1) * 100)::INT) AS duration_progress + a.owner_uid AS user_id, + a.star_id, + a.id AS asset_id, + COALESCE(LEAST(100, (alr.season_likes::FLOAT / NULLIF(al.require_likes, 0) * 100)::INT), 0) AS like_progress, + COALESCE(LEAST(100, (alr.season_exhibition_hours::FLOAT / NULLIF(al.require_hours, 0) * 100)::INT), 0) AS duration_progress FROM public.assets a - JOIN public.asset_level_config alc ON alc.level = a.level - WHERE a.status = 'active' AND a.deleted_at IS NULL + JOIN public.asset_level_records alr ON alr.asset_id = a.id + JOIN public.asset_levels al ON al.level = alr.current_level + WHERE a.is_active = TRUE + AND a.deleted_at IS NULL + AND al.level_order < (SELECT MAX(level_order) FROM public.asset_levels) + AND alr.current_level <> 'N' ON CONFLICT (user_id, star_id, asset_id) DO UPDATE - SET like_progress = EXCLUDED.like_progress, duration_progress = EXCLUDED.duration_progress, updated_at = NOW() + SET like_progress = EXCLUDED.like_progress, + duration_progress = EXCLUDED.duration_progress, + updated_at = NOW() `, r.schema)) return err } diff --git a/backend/services/userService/service/user_service.go b/backend/services/userService/service/user_service.go index 1e23f11..daaeac1 100644 --- a/backend/services/userService/service/user_service.go +++ b/backend/services/userService/service/user_service.go @@ -696,6 +696,9 @@ func (s *userService) UpdateCrystalBalance(req *pb.UpdateCrystalBalanceRequest) zap.Int64("new_balance", newBalance), ) + // 事件埋点:crystal.change(fire-and-forget) + fireCrystalChangeEvent(req.UserId, req.StarId, req.Delta, req.ChangeType) + return &pb.UpdateCrystalBalanceResponse{ Base: &pbCommon.BaseResponse{ Code: pbCommon.StatusCode_STATUS_OK, diff --git a/docs/superpowers/plans/2026-06-08-income-curve-default-tooltip.md b/docs/superpowers/plans/2026-06-08-income-curve-default-tooltip.md new file mode 100644 index 0000000..cde0e6b --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-income-curve-default-tooltip.md @@ -0,0 +1,346 @@ +# IncomeCurve.vue Default Tooltip Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `IncomeCurve.vue` (七日收益曲线) show its tooltip on the last data point by default after first render, while preserving the existing tap-to-switch behavior. + +**Architecture:** Listen to `qiun-data-charts`'s `@complete` event, read the last data point's actual screen coordinates from `opts.chartData.calPoints[0][last]`, construct a fake touch event, and invoke the chart component's private `_showTooltip(e)` to render the default tooltip. Guard against re-firing and missing data with a `lastShownLen` cursor. + +**Tech Stack:** Vue 3 Composition API, uni-app (vite), `qiun-data-charts` 1.5.x (u-charts under the hood) + +**Spec:** `docs/superpowers/specs/2026-06-08-income-curve-default-tooltip-design.md` + +--- + +## File Structure + +**Modified (1 file):** + +- `frontend/pages/dashboard/components/IncomeCurve.vue` — single component; add `chartRef`, `onChartComplete` handler, `lastShownLen` cursor; remove dead code (`currentIndex`, its `watch`, `onChartTap`, `@tap` binding) + +**Created (0 files):** + +No new files. No new dependencies. No new test files (project has no frontend test framework — uni-app + HBuilderX/WeChat DevTools manual verification, consistent with the rest of the codebase). + +--- + +## Task 1: Add `chartRef` and `onChartComplete` handler (minimal, no behavior change yet) + +**Files:** +- Modify: `frontend/pages/dashboard/components/IncomeCurve.vue:1-78` + +**Why first:** Establishes the new state without changing visible behavior. Lets us verify the ref wiring works before adding the actual `_showTooltip` call. + +- [ ] **Step 1: Add `chartRef` and `lastShownLen` to the script block** + +Open `frontend/pages/dashboard/components/IncomeCurve.vue` and replace the existing import line (line 43): + +```js +import { computed, ref, watch } from "vue"; +``` + +with: + +```js +import { computed, nextTick, ref, watch } from "vue"; +``` + +Then immediately after `defineEmits(["retry"]);` (line 52), insert two new refs: + +```js +// [新增] 拿到 qiun-data-charts 实例,用于首次渲染后触发默认 tooltip +const chartRef = ref(null); +// [新增] 上次已展示 tooltip 的数据点数量,避免重复触发 +const lastShownLen = ref(0); +``` + +- [ ] **Step 2: Bind the ref on the chart component** + +In the template `` opening tag (line 23), add `ref="chartRef"` as the first attribute: + +```vue + +``` + +- [ ] **Step 3: Add a placeholder `onChartComplete` handler (no behavior yet)** + +In the script block, after the two new refs added in Step 1, insert this empty placeholder: + +```js +// [新增] 图表首次渲染完成回调(占位,Task 2 填充真实逻辑) +const onChartComplete = (_e) => { + // 故意留空 — Task 2 接入 fake event 触发 +}; +``` + +Also bind it on the template — change line 35 from `@tap="onChartTap"` to: + +```vue + ontap + @tap="onChartTap" + @complete="onChartComplete" +/> +``` + +- [ ] **Step 4: Verify the chart still renders normally** + +Run H5 preview (HBuilderX → 运行 → 运行到浏览器 → Chrome) **or** the dev server: + +```bash +cd frontend && npm run dev:h5 +``` + +Expected: dashboard page loads; chart renders the 7-day curve; no console errors. Touching a point still updates tooltip as before. The placeholder handler is a no-op, so visible behavior is unchanged. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/pages/dashboard/components/IncomeCurve.vue +git commit -m "feat(income-curve): wire chartRef + onChartComplete placeholder" +``` + +--- + +## Task 2: Implement the default tooltip trigger + +**Files:** +- Modify: `frontend/pages/dashboard/components/IncomeCurve.vue` (replace the placeholder from Task 1) + +- [ ] **Step 1: Replace the placeholder `onChartComplete` with the real implementation** + +Find the placeholder block added in Task 1 Step 3: + +```js +// [新增] 图表首次渲染完成回调(占位,Task 2 填充真实逻辑) +const onChartComplete = (_e) => { + // 故意留空 — Task 2 接入 fake event 触发 +}; +``` + +Replace it with: + +```js +// [新增] 图表渲染完成回调: +// 从 e.opts.chartData.calPoints[0] 拿最后一点的真实屏幕坐标, +// 构造 fake event 喂给 _showTooltip,让 u-charts 在最后一点渲染 tooltip +// ⚠️ _showTooltip 是 qiun-data-charts 私有方法(下划线开头) +// 风险与回退见 spec: "风险与回退" 章节 +const onChartComplete = async (e) => { + const len = props.points.length; + if (!len || !chartRef.value) return; + // 数据点数量未变化则不重复触发(防 complete 事件多次触发) + if (lastShownLen.value === len) return; + await nextTick(); + const series = e?.opts?.chartData?.calPoints?.[0]; + if (!series || !series[len - 1]) return; + const { x, y } = series[len - 1]; + const fakeE = { changedTouches: [{ x, y }] }; + try { + chartRef.value._showTooltip(fakeE); + } catch (err) { + console.warn("[IncomeCurve] show default tooltip failed:", err); + } + lastShownLen.value = len; +}; +``` + +- [ ] **Step 2: Verify default tooltip shows on last point** + +Reload the dashboard page in H5 preview (HBuilderX). Watch the income-curve chart. + +Expected: Within ~1 frame after the chart's curve animates in, a tooltip appears above the **rightmost** data point showing `+` (e.g. `+1234`) and `MM-DD` (e.g. `06-08`). No hollow dot should appear (u-charts draws the active point only after a real touch). + +- [ ] **Step 3: Verify tap-to-switch still works** + +On the same chart, tap a point in the middle (e.g. day 4 from the left). + +Expected: Tooltip moves to that touched point. Tap the rightmost point → tooltip returns to the rightmost. Tap empty space below the curve → tooltip should disappear (u-charts default behavior). All these should work as before, because the chart's internal `_tap` handler is untouched. + +- [ ] **Step 4: Verify guard against repeated complete events** + +Without leaving the page, trigger a re-render (e.g. dev tools → toggle a `points` prop in Vue devtools, or just resize the window — u-charts emits `complete` on resize). + +Expected: Tooltip stays on the rightmost point; no flicker / no duplicate tooltip drawn. Open devtools console; no errors. + +- [ ] **Step 5: Verify empty-data fallback** + +Temporarily change the parent (or use dev tools) to pass `points: []`. + +Expected: Component enters skeleton state (the `v-else-if="loading || !points || points.length === 0"` branch); the `qiun-data-charts` is not mounted, so `onChartComplete` is never called; no console errors. + +Restore the data after verification. + +- [ ] **Step 6: Commit** + +```bash +git add frontend/pages/dashboard/components/IncomeCurve.vue +git commit -m "feat(income-curve): show default tooltip on last data point" +``` + +--- + +## Task 3: Remove dead code (`currentIndex`, `watch`, `onChartTap`, `@tap` binding) + +**Files:** +- Modify: `frontend/pages/dashboard/components/IncomeCurve.vue` (script + template) + +**Why:** `currentIndex` ref and its watcher are unused (u-charts feeds the actual touch index to `tooltipCustom` directly, not from the parent). `onChartTap` is a dead handler. Cleaning these up keeps the component lean and prevents future readers from being misled. + +- [ ] **Step 1: Remove the `currentIndex` ref + watcher + `onChartTap` function** + +In the script block, find and delete these lines (originally around line 54-69 of the unmodified file): + +```js +// 当前选中的数据点索引:默认指向最后一条,tap 时更新 +const currentIndex = ref(0); +watch( + () => props.points.length, + (len) => { + currentIndex.value = Math.max(0, len - 1); + }, + { immediate: true }, +); + +const onChartTap = (e) => { + const idx = e?.index; + if (typeof idx === "number" && idx >= 0 && idx < props.points.length) { + currentIndex.value = idx; + } +}; +``` + +- [ ] **Step 2: Remove `watch` from the import line (still need `nextTick` + `ref`)** + +Change: + +```js +import { computed, nextTick, ref, watch } from "vue"; +``` + +to: + +```js +import { computed, nextTick, ref } from "vue"; +``` + +(`watch` is no longer used after Step 1.) + +- [ ] **Step 3: Remove `@tap="onChartTap"` from the template** + +In the `` tag, find: + +```vue + ontap + @tap="onChartTap" + @complete="onChartComplete" +/> +``` + +Change to: + +```vue + ontap + @complete="onChartComplete" +/> +``` + +(`ontap` (no value) is the native event binding that lets u-charts' internal `_tap` still handle real touches; we just drop the parent-side listener.) + +- [ ] **Step 4: Verify nothing regressed** + +Reload dashboard in H5 preview. + +Expected: +- Tooltip still shows on the rightmost point by default +- Tapping any other point still moves the tooltip to that point +- No console errors +- No unused-variable warnings (if eslint is configured — check with `cd frontend && npx eslint pages/dashboard/components/IncomeCurve.vue 2>/dev/null || echo "no eslint config"`; project has no eslint, so this is just a no-op check) + +- [ ] **Step 5: Commit** + +```bash +git add frontend/pages/dashboard/components/IncomeCurve.vue +git commit -m "refactor(income-curve): remove dead currentIndex/onChartTap" +``` + +--- + +## Task 4: Cross-platform smoke test (WeChat mini-program / H5 / App) + +**Files:** none + +**Why:** The fake event and `_showTooltip` rely on canvas event behavior that can differ across uni-app's compile targets. Verify before declaring done. + +- [ ] **Step 1: Build for WeChat mini-program and verify** + +In HBuilderX: 发行 → 微信小程序(仅编译,不上传). + +Open the built `unpackage/dist/dev/mp-weixin/` in WeChat DevTools. Navigate to the dashboard page. + +Expected: Same as H5 — tooltip on rightmost by default, tap-to-switch works, no errors in WeChat console. + +- [ ] **Step 2: If an App build is in use, verify there too** + +In HBuilderX: 运行 → 运行到 App 基座. + +Expected: Same behavior. (Skip this step if App build is not part of the dev workflow for this project.) + +- [ ] **Step 3: Document any platform-specific quirk** + +If you observe different behavior on any platform, add a one-line note in the spec's "跨端验证" section and a defensive comment in `IncomeCurve.vue` near the `_showTooltip` call. If all platforms pass, no action needed. + +- [ ] **Step 4: Final commit (if any notes were added)** + +```bash +git add frontend/pages/dashboard/components/IncomeCurve.vue docs/superpowers/specs/2026-06-08-income-curve-default-tooltip-design.md +git commit -m "docs(income-curve): note cross-platform verification results" +``` + +--- + +## Self-Review + +**1. Spec coverage:** + +| Spec section | Task | +|--------------|------| +| 核心机制 (calPoints + fake event) | Task 1 + Task 2 | +| 模板改动 (ref + @complete) | Task 1 Steps 2-3 | +| 脚本改动 (onChartComplete + lastShownLen) | Task 1 Step 1, Task 2 Step 1 | +| 关键边界条件 (空数据 / calPoints 未填充 / 多次 complete / 私有方法不可用) | Task 2 Step 5 (empty) + Task 2 Step 4 (re-entry) + Task 2 Step 1 (try/catch + 守卫) | +| 模板清理 (@tap="onChartTap" 移除) | Task 3 Step 3 | +| 死代码清理 (currentIndex, watch, onChartTap) | Task 3 Steps 1-2 | +| 手动验证 (默认显示 / 触摸切换 / 数据切换) | Task 2 Steps 2-3 | +| 空态/错误态 | Task 2 Step 5 | +| 跨端验证 | Task 4 | +| 风险与回退 | Implicit in code (try/catch + 守卫); not a separate task | + +**2. Placeholder scan:** + +- "verify" steps: each has explicit Expected output ✅ +- No "TBD" / "TODO" / "implement later" ✅ +- All code blocks contain complete code (no "// similar to..." shortcuts) ✅ + +**3. Type / naming consistency:** + +- `chartRef` — declared Task 1 Step 1, used Task 1 Step 2 (template) + Task 2 Step 1 (script). ✅ +- `lastShownLen` — declared Task 1 Step 1, used Task 2 Step 1. ✅ +- `onChartComplete` — declared Task 1 Step 3 (placeholder), updated Task 2 Step 1, bound in template Task 1 Step 3. ✅ +- `e.opts.chartData.calPoints[0]` — referenced in Task 2 Step 1, matches the spec's data flow diagram and u-charts source (line 572 of `u-charts.js`: `var calPoints = opts.chartData.calPoints?opts.chartData.calPoints:[]`). ✅ +- `_showTooltip` — called Task 2 Step 1, defined in `qiun-data-charts.vue:1080`. ✅ + +**Gaps:** None. All spec requirements are covered by tasks above. diff --git a/docs/superpowers/specs/2026-06-08-income-curve-default-tooltip-design.md b/docs/superpowers/specs/2026-06-08-income-curve-default-tooltip-design.md new file mode 100644 index 0000000..87a53f2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-income-curve-default-tooltip-design.md @@ -0,0 +1,279 @@ +# 七日收益曲线默认显示最后一条记录的提示 — 设计文档 + +- **日期**: 2026-06-08 +- **作者**: Claude +- **状态**: 已批准,待实施 +- **范围**: 前端单组件改动 + +## 背景与目标 + +### 现状 +`frontend/pages/dashboard/components/IncomeCurve.vue` 是数据看板的"七日收益曲线"组件,使用 `qiun-data-charts`(基于 u-charts)渲染 area 类型渐变面积图。组件已有完整的 `tooltipCustom` 渲染逻辑、tap 切换、错误态/骨架态。 + +### 问题 +图表加载完成后默认没有任何 tooltip,需要用户触摸/点击图表才会显示。**期望**:进入页面后,tooltip 默认显示在最后一个数据点(最近一天)上;用户触摸其他点时,tooltip 跟随到对应点。 + +### 范围 +- **范围内**: `IncomeCurve.vue` 一个文件 +- **范围外**: 其他 dashboard 图表、其他业务模块、qiun-data-charts 公共 API + +## 设计 + +### 核心机制 + +u-charts 在每帧渲染完成后会把每个数据点的真实屏幕坐标写入 `opts.chartData.calPoints[seriesIndex][dataIndex].x/y`。`qiun-data-charts` 组件在 `drawCharts` 完成后通过 `@complete` 事件把这个 opts 抛给父组件。 + +利用这两点: +1. 父组件监听 `@complete`,从 `e.opts.chartData.calPoints[0]` 取最后一点的 `{x, y}` +2. 构造一个 fake event `{ changedTouches: [{ x, y }] }` +3. 调 `chartRef.value._showTooltip(fakeE)` —— 该方法内部用 `getCurrentDataIndex(e)` 按 fake 坐标找最近的数据点(在最后一点上调用,最近的就是它自己),再走 u-charts 的 `showToolTip` 渲染流程,`tooltipCustom` 自然会用真实 index 渲染正确的文字 + +### 方案选择 + +| 方案 | 结论 | +|------|------| +| A. Ref + fake event 调 `_showTooltip` ⭐ | **采用**。只改一个文件;不动 uni_modules;改动小 | +| B. 给 qiun-data-charts 加 `defaultTooltipIndex` prop | 拒绝。改 uni_modules 带来升级合并冲突,YAGNI | +| C. 模拟真实 tap 事件 | 拒绝。跨端兼容差(H5/小程序/App 行为不一致) | + +### 实现要点 + +#### 1. IncomeCurve.vue 改动 + +**Template**: +```vue + +``` + +**Script**: +```js +import { ref, nextTick } from "vue"; + +const chartRef = ref(null); +// 防止首次 complete 和后续重复触发叠加 +const lastShownLen = ref(0); + +const onChartComplete = async (e) => { + const len = props.points.length; + if (!len || !chartRef.value) return; + // 数据没变就不重复触发 + if (lastShownLen.value === len) return; + await nextTick(); + const series = e?.opts?.chartData?.calPoints?.[0]; + if (!series || !series[len - 1]) return; + const { x, y } = series[len - 1]; + // 构造 fake event,模拟在最后一点"触摸" + const fakeE = { changedTouches: [{ x, y }] }; + // ⚠️ 调用 qiun-data-charts 的私有方法(下划线开头) + // 风险与回退见下方"风险与回退"表 + try { + chartRef.value._showTooltip(fakeE); + } catch (err) { + console.warn("[IncomeCurve] show default tooltip failed:", err); + } + lastShownLen.value = len; +}; + +// 清理:原 onChartTap 仅更新 currentIndex(死代码),删除 +// 原 watch + currentIndex 逻辑删除(YAGNI) +``` + +#### 2. 关键边界条件 + +| 情况 | 处理 | +|------|------| +| `points.length === 0` | 组件渲染骨架态,不进入 chart 分支,`onChartComplete` 不会触发 | +| `calPoints` 未填充 | u-charts 在 `process === 1` 阶段才填充 calPoints;`@complete` 事件在 `drawCharts` 之后 emit,可信 | +| props 从 0 条变 7 条 | `lastShownLen` 从 0 变 7,下次 complete 触发新 tooltip | +| props 从 7 条变 0 条 | 进入 skeleton 分支,不会触发;下次有数据时正常触发 | +| 多次 complete 事件(重渲染) | `lastShownLen` 判断跳过 | +| `_showTooltip` 在某些端不可用 | 用 `chartRef.value?._showTooltip?.(...)` 兜底;不可用时静默失败(用户依然能 tap 触发) | + +#### 3. 删除的死代码 + +- `const currentIndex = ref(0);` +- 监听 `props.points.length` 的 `watch` +- `onChartTap` 函数(未做任何事,因为 `tooltipCustom` 用 u-charts 内部 index) +- 模板上的 `@tap="onChartTap"` —— u-charts 内部 `_tap` 仍会处理 tap 事件,父组件不需要再监听 + +> ⚠️ 实际上 `onChartTap` 现在的实现是死代码,因为 `tooltipCustom` 接收的 index 来自 u-charts 内部,不受父组件状态影响。tap 时图表自然会用触摸点的 index 渲染。所以**只删父组件的监听和函数,qiun-data-charts 内部 tap 行为不变**。 + +### 架构 / 组件 / 数据流 + +``` +IncomeCurve.vue + ├─ 模板 + │ └─ + └─ 脚本 + ├─ import cfu from "uni_modules/.../config-ucharts.js" — 共享 u-charts config + ├─ CANVAS_ID = "incomeCurveCanvas" — 与 template canvasId prop 一致 + ├─ lastShownLen — 防止重复触发的游标 + └─ onChartComplete(e) — 解析 calPoints,构造 fake event,直接调 cfu.instance[CANVAS_ID].showToolTip + +数据流: + points 变化 + → chartData computed 更新 + → qiun-data-charts 重渲染 + → 内部 u-charts 填充 calPoints + → emit 'complete' 事件(带 opts) + → onChartComplete 解析 calPoints[0][last] + → 构造 fakeE (除以 opts.pix 避免双倍缩放) + → 拿 cfu.instance[CANVAS_ID].showToolTip(fakeE, { index: lastIndex }) + → u-charts 内部用显式 index 渲染 tooltip + → tooltipCustom(opts, categories, lastIndex) 返回 +收益/日期 +``` + +### 错误处理 + +| 失败点 | 行为 | +|--------|------| +| `chartRef.value` 为空 | 函数开头 `if` 守卫,静默退出 | +| `e.opts.chartData.calPoints[0]` 为 undefined | 守卫后退出;用户可手动 tap 触发 | +| `_showTooltip` 抛异常 | try/catch 包裹,记录 console.warn 不打断主流程 | +| 平台不支持 canvas 内部访问 | 静默失败(极端情况,uniapp 内不会出现) | + +### 测试 + +#### 手动验证(必做) + +1. **默认显示**: + - 进入 dashboard 页面,等待图表加载完成 + - 验证最后一个数据点(最右)上方出现 tooltip(`+XXX` 和 `MM-DD`) + - 验证没有 hollow 圆点(因为 `tooltipCustom` 走的是真实触摸路径,u-charts 不会画 active point;如有需要可在 `extra.area.activeType` 控制) + +2. **触摸切换**: + - 在图表中间点 tap + - 验证 tooltip 移动到该点 + - 释放后再 tap 最右点 + - 验证 tooltip 回到最右点(实际是 u-charts 内部逻辑,行为已存在) + +3. **数据切换**: + - 切换 star_id(dashboard 顶部下拉) + - 验证新数据加载完成后,tooltip 显示在新数据最后一点 + +4. **空态/错误态**: + - 接口返回空数据 → 不应触发 `_showTooltip`,不应报错 + - 接口报错 → 错误态显示正常 + +#### 跨端验证 + +- 微信小程序(主战场) +- H5(开发预览) +- App(如果有时间) + +### 风险与回退 + +| 风险 | 影响 | 回退方案 | +|------|------|----------| +| **`_showTooltip` 是 qiun-data-charts 私有方法**(下划线开头),未在公共 API 中保证 | default tooltip 失效,但 tap 仍可用 | 已用 try/catch + `?.()` 兜底;最差情况下回退为 `` 延后首帧渲染(体验略差) | +| `calPoints` 在某些情况下不写入 | default tooltip 不显示 | 已加 `series[len-1]` 守卫;如未触发用户仍可 tap | +| 多次 complete 事件堆积 | 多次绘制 | `lastShownLen` 游标防抖 | + +### 改动清单 + +- **修改**: `frontend/pages/dashboard/components/IncomeCurve.vue`(+~15 行 / -~10 行) +- **不动**: `qiun-data-charts` 及其依赖、任何后端代码、其他前端组件 + +### 实施步骤概要 + +1. 给 `` 加 `ref="chartRef"` 和 `@complete="onChartComplete"` +2. 实现 `onChartComplete` 函数(解析 calPoints + fake event + 调 _showTooltip) +3. 删除死代码 `currentIndex` / `watch` / `onChartTap`(可选清理) +4. 手测三种情况:默认显示 / 触摸切换 / 数据切换 +5. 跨端验证(小程序 + H5) + + +--- + +## 实施过程变更(追加于 2026-06-09) + +实施过程中发现两个 v1 设计假设不成立,相应做了调整: + +### 变更 1:移除 `ref="chartRef"`,改走 `cfu.instance[CANVAS_ID]` + +**问题**:v1 设计用 `chartRef.value._showTooltip(fakeE)` 触发默认 tooltip。运行时发现: + +``` +[IncomeCurve] show default tooltip failed: TypeError: chartRef.value._showTooltip is not a function +``` + +qiun-data-charts 组件的 `_showTooltip` 是下划线前缀私有方法,在 Options API + 跨平台编译(uni-app vue3 编译器)后,**父组件 ref 上拿不到这个方法**。 + +**修复**:qiun-data-charts 通过 `import cfu from "uni_modules/qiun-data-charts/js_sdk/u-charts/config-ucharts.js"` 共享一个 `cfu` 对象,其中 `cfu.instance[canvasId]` 直接持有 uCharts 实例。我们在 IncomeCurve.vue 里也 import 同一个 `cfu`,绕过 qiun-data-charts 组件层,直接调 uCharts 原生的 `showToolTip(e, {index})`。 + +**附带收益**: +- 不再依赖 qiun-data-charts 私有 API(更稳) +- 可以显式传 `option.index`,不依赖 `getCurrentDataIndex(fakeE)` 推断(更准) +- 移除了 `chartRef` ref、模板上的 `ref="chartRef"`、`QiunDataCharts` 显式 import 也保留作为 easycom 兜底 + +### 变更 2:fake event 坐标除以 `opts.pix`(像素比双倍缩放修复) + +**问题**:v1 设计的 fake event 直接传 `calPoints[0][last].x, .y`。但 `calPoints` 已是 canvas 像素(u-charts 内部 `opts.area = padding * opts.pix`),而 `getTouches`(`u-charts.js:505-506`)会再乘一次 `opts.pix`。在 WeChat MP / retina H5(`opts.pix = 2`)上坐标会双倍缩放。 + +**修复**:传入 fake event 前先 `x / pix, y / pix`,让 `getTouches` 一次乘法抵消,恢复正确坐标。 + +此变更 v1 设计阶段未识别,由 code review subagent 在 Task 2 review 时发现。修复后 round-trip 验证:`((x / pix) * pix) = x`,对所有 `pix ≥ 1` 成立。 + +### 变更 3:H5 桌面端鼠标移动不切换 tooltip(双层 fallback) + +**问题 1:renderjs `onmouse` 默认 undefined**。qiun-data-charts `mounted()` 第 488-490 行: +```js +if(this.inWin === true){ + this.openmouse = this.onmouse; +} +``` +HBuilderX H5 预览(iframe 嵌入)下 `inWin === false`,`openmouse` 一直 `undefined`,传到 `cfu.option[cid].onmouse`。`rdcharts.mouseMove` 第一行 `if(onmouse == false) return;` 因 `undefined == false` 为 true 立刻退出。 + +**问题 2:uni-app renderjs 不转发 `mousemove`**。即使显式设 `onmouse=true`,`rdcharts.mouseMove` 仍不会被调用 —— uni-app renderjs 在 HBuilderX 某些版本下不把 `mousemove` 事件转发到 renderjs 脚本(虽然 `tap`/`click` 是转发的)。 + +**修复**:放弃 renderjs 路径,直接在 IncomeCurve.vue 的 `.chart-canvas` 上挂 `@mousemove` 监听,绕开 renderjs,用 cfu.instance 直接调 `showToolTip(fakeE, {})`: +1. 60ms 节流,避免每次 mousemove 都重绘 canvas +2. `getCurrentInstance().proxy` + `uni.createSelectorQuery().select('#UC' + canvasId)` 拿 chart view 的 boundingClientRect +3. 转换 e.clientX/Y 为相对坐标,构造 fakeE +4. 不传 `option.index`,让 u-charts 内部 `getCurrentDataIndex(fakeE)` 自动找最近数据点 +5. 边界守卫:localX/Y 超出 rect 范围直接 return + +### 变更 4:`tooltipCustom` 是 Object prop 不是 Function prop,必须改用 `showCategory` + `cfu.formatter` + +**问题**:用户反馈"初始显示的数据需要加上日期" — 我们用 `tooltipCustom: (opts, cats, idx) => { return { textList: [...] } }` 期望函数被调用,但 tooltip 一直只显示 u-charts 默认的 "收益: 1234",没有日期也没有 + 号。 + +**根因**:qiun-data-charts 的 `tooltipCustom` prop 类型声明是 `Object`(`type: Object, default: undefined`),不是 `Function`。chiart 内部 `_showTooltip` / renderjs `showTooltip` 的代码是: +```js +let tc = cfu.option[cid].tooltipCustom +if (tc && tc !== undefined && tc !== null) { + ... + cfu.instance[cid].showToolTip(e, { + index: tc.index, // 函数没有 .index → undefined + offset: offset, // tc.x >= 0 永远 false(undefined >= 0) + textList: tc.textList, // 函数没有 .textList → undefined + formatter: ... // 用 chart 默认的 _tooltipDefault + }); +} +``` +函数也是 Object 所以 `if (tc && tc !== undefined && tc !== null)` 判定通过,但 `tc.index`、`tc.textList` 都是 `undefined`,最后 `formatter` 是默认的 `_tooltipDefault`,返回 `item.name + ': ' + item.data`。 + +**修复**: +1. **`extra.tooltip.showCategory: true`**:u-charts 会把 `opts.categories[index]` 作为 textList 第一行 unshift 进去(u-charts.js:2806-2808)。我们的 categories 已经是 `["06-02", ..., "06-08"]`,所以日期会自动出现。 +2. **`extra.tooltip.fontColor: "#999999"`**:第一行(日期)color 为 null,会 fallback 到 fontColor;灰色。 +3. **`cfu.formatter.incomeFormatter`**:注册自定义 formatter 返回 `+${item.data}`,让第二行(收入)显示成 `+1234`。 +4. **`:tooltipFormat="'incomeFormatter'"`**:让 chart 组件的 `_showTooltip` / renderjs `showTooltip` 用我们注册的 formatter。 +5. **直接 `showToolTip` 调用也传 `formatter: incomeFormatter`**:确保默认 tooltip(onChartComplete)和鼠标移动(onCanvasMouseMove)也用同一格式。 +6. **删掉原 `extra.tooltip.tooltipCustom`**:彻底无效,留着误导后人。 + +效果: +- Line 1: "06-08" — 灰色(fontColor) +- Line 2: "+1234" — 蓝色(series.color,不受 fontColor 影响) +- 默认显示、点击切换、鼠标移动三种触发方式格式一致 + +**适用范围**: +- H5 桌面浏览器:✅ 工作(mousemove 事件正常) +- H5 移动浏览器:mousemove 不触发,依赖 chart 内部 touchMove(已有 `:ontouch=true`) +- 微信小程序:依赖 chart 内部 touchMove(已有 `:ontouch=true`) +- App:依赖 chart 内部 touchMove(已有 `:ontouch=true`) + +非 H5 平台 `@mousemove` 是 no-op,handler 不会被调用。 diff --git a/frontend/.env.development b/frontend/.env.development index 817dbc8..76cf1da 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -4,5 +4,5 @@ VITE_API_BASE_URL=http://192.168.110.60:8080 # WebSocket 地址:如与 API 同源可省略(自动从 VITE_API_BASE_URL 推导 http→ws、https→wss) # 独立部署时直接覆盖,例如:ws://192.168.110.60:8081 VITE_WS_BASE_URL=ws://192.168.110.60:8080 -VITE_USE_MOCK_API=true -VITE_ENV_NAME=development +VITE_USE_MOCK_API=false +# VITE_ENV_NAME=development diff --git a/frontend/.env.production b/frontend/.env.production index a2db381..e521f1c 100644 --- a/frontend/.env.production +++ b/frontend/.env.production @@ -1,7 +1,7 @@ # 生产环境配置 # HBuilderX「发行」时自动加载;CLI 用 --mode production -VITE_API_BASE_URL=http://api.topfans.online:8080 +VITE_API_BASE_URL=https://api.topfans.online # WebSocket 地址:生产环境使用 wss(与 HTTPS 对应),如 WS 部署在独立端口/域名可覆盖 VITE_WS_BASE_URL=ws://api.topfans.online:8080 VITE_USE_MOCK_API=false -VITE_ENV_NAME=production +# VITE_ENV_NAME=production diff --git a/frontend/composables/useDashboardData.js b/frontend/composables/useDashboardData.js index 26ac896..ed8bbbc 100644 --- a/frontend/composables/useDashboardData.js +++ b/frontend/composables/useDashboardData.js @@ -1,11 +1,13 @@ import { ref, computed } from 'vue' -import { dashboardApi } from '@/utils/api' +import { dashboardApi, IS_MOCK_API } from '@/utils/api' +import { getAssetCoverRealUrl } from '@/utils/assetImageHelper' import * as mock from '@/utils/mock/dashboard' -// USE_MOCK_API 是写死常量——直接内联短路逻辑,绕开 api.js 的 dashboardRequest 链路 +// 仅当 USE_MOCK_API=true 时使用 mock 短路 // 原因:标准基座 + Vite HMR 在 App 端会让 api.js 内部的 async 包装 + signal 透传 // 导致 useDashboardData.loadSection 里的 await 永远不 resolve。 -// 直接调 mock 工厂,200-600ms 内必定 resolve。 +// 走 mock 时直接调本地工厂,200-600ms 内必定 resolve。 +// 后端就绪(USE_MOCK_API=false)时走真实 dashboardRequest。 const MOCK_FACTORIES = { today: mock.mockTodayOverview, curve: mock.mock7DayIncomeCurve, @@ -75,18 +77,44 @@ export function useDashboardData({ starId = null } = {}) { Object.values(data.value).every((v) => v !== null && v !== undefined) ) + // 递归把对象/数组里所有 asset_thumb / thumb 字符串转换成真实可访问的 URL + // 覆盖:topAssets.items[].asset_thumb, exhibition.top5[].asset_thumb, + // likeIncome.levels[].thumb, upgrades.upcoming[].asset_thumb, upgrades.recent[].asset_thumb + async function resolveThumbUrls(obj) { + if (!obj || typeof obj !== 'object') return + if (Array.isArray(obj)) { + await Promise.all(obj.map(resolveThumbUrls)) + return + } + const tasks = [] + for (const [k, v] of Object.entries(obj)) { + if ((k === 'asset_thumb' || k === 'thumb') && typeof v === 'string' && v) { + tasks.push( + getAssetCoverRealUrl(v).then((url) => { + obj[k] = url + }).catch(() => { /* helper 内部已 fallback,吞错即可 */ }) + ) + } else if (v && typeof v === 'object') { + tasks.push(resolveThumbUrls(v)) + } + } + await Promise.all(tasks) + } + // 内部辅助:单 section 加载 async function loadSection(section, fetcher) { loading.value[section] = true error.value[section] = null try { - // USE_MOCK_API 走 mock:直接调本地工厂,绕开 api.js 的 dashboardRequest 包装 - // 解决标准基座 + Vite HMR 下 await fetcher(starId, { signal }) 永远不 resolve 的问题 - const mockFactory = MOCK_FACTORIES[section] + // 只有 USE_MOCK_API=true 时才走 mock 短路;否则调用真实 fetcher → dashboardRequest + const mockFactory = IS_MOCK_API ? MOCK_FACTORIES[section] : null const result = mockFactory ? await mockFactory({ star_id: starId }) : await fetcher(starId) - data.value[section] = result?.data ?? result + const sectionData = result?.data ?? result + // 后端返回的 thumb 可能是 OSS 相对路径,需要批量转成可访问 URL + await resolveThumbUrls(sectionData) + data.value[section] = sectionData } catch (e) { error.value[section] = e?.message || '加载失败' data.value[section] = null diff --git a/frontend/pages/dashboard/components/IncomeCurve.vue b/frontend/pages/dashboard/components/IncomeCurve.vue index e3d1103..d2d5c82 100644 --- a/frontend/pages/dashboard/components/IncomeCurve.vue +++ b/frontend/pages/dashboard/components/IncomeCurve.vue @@ -19,21 +19,21 @@ - + @@ -42,9 +42,12 @@