feat:修改数据看板

This commit is contained in:
zerosaturation 2026-06-09 00:37:32 +08:00
parent 7e5377861a
commit d859650136
19 changed files with 937 additions and 105 deletions

4
.gitignore vendored
View File

@ -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

View File

@ -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 ""

View File

@ -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)
}

View File

@ -3,12 +3,14 @@
-- 服务于: 点赞收益按等级(累计)
-- 关联: spec §3.4 MV3
-- 关联依赖: public.assets 表
-- 修复: a.level → a.gradeassets 表没有 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);

View File

@ -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);

View File

@ -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 没单独 GetCrystalBalanceFanProfile 含此字段)
// 注意: 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 {

View File

@ -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:// URLpkg/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",

View File

@ -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))
}

View File

@ -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

View File

@ -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 {

View File

@ -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
}

View File

@ -696,6 +696,9 @@ func (s *userService) UpdateCrystalBalance(req *pb.UpdateCrystalBalanceRequest)
zap.Int64("new_balance", newBalance),
)
// 事件埋点crystal.changefire-and-forget
fireCrystalChangeEvent(req.UserId, req.StarId, req.Delta, req.ChangeType)
return &pb.UpdateCrystalBalanceResponse{
Base: &pbCommon.BaseResponse{
Code: pbCommon.StatusCode_STATUS_OK,

View File

@ -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 `<qiun-data-charts ...>` opening tag (line 23), add `ref="chartRef"` as the first attribute:
```vue
<qiun-data-charts
ref="chartRef"
type="area"
:opts="chartOpts"
:chartData="chartData"
:ontouch="true"
:onmovetip="true"
:in-scroll-view="true"
:tooltipShow="true"
:canvas2d="false"
canvasId="incomeCurveCanvas"
:canvasHeight="240"
ontap
@tap="onChartTap"
/>
```
- [ ] **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 `+<income>` (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 `<qiun-data-charts>` 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.

View File

@ -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
<qiun-data-charts
ref="chartRef"
...
@complete="onChartComplete"
@tap="onChartTap"
/>
```
**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
├─ 模板
│ └─ <qiun-data-charts @complete="onChartComplete" />
└─ 脚本
├─ 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_iddashboard 顶部下拉)
- 验证新数据加载完成后tooltip 显示在新数据最后一点
4. **空态/错误态**
- 接口返回空数据 → 不应触发 `_showTooltip`,不应报错
- 接口报错 → 错误态显示正常
#### 跨端验证
- 微信小程序(主战场)
- H5开发预览
- App如果有时间
### 风险与回退
| 风险 | 影响 | 回退方案 |
|------|------|----------|
| **`_showTooltip` 是 qiun-data-charts 私有方法**(下划线开头),未在公共 API 中保证 | default tooltip 失效,但 tap 仍可用 | 已用 try/catch + `?.()` 兜底;最差情况下回退为 `<qiun-data-charts v-if="ready">` 延后首帧渲染(体验略差) |
| `calPoints` 在某些情况下不写入 | default tooltip 不显示 | 已加 `series[len-1]` 守卫;如未触发用户仍可 tap |
| 多次 complete 事件堆积 | 多次绘制 | `lastShownLen` 游标防抖 |
### 改动清单
- **修改**: `frontend/pages/dashboard/components/IncomeCurve.vue`+~15 行 / -~10 行)
- **不动**: `qiun-data-charts` 及其依赖、任何后端代码、其他前端组件
### 实施步骤概要
1. 给 `<qiun-data-charts>``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 兜底
### 变更 2fake 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` 成立。
### 变更 3H5 桌面端鼠标移动不切换 tooltip双层 fallback
**问题 1renderjs `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 立刻退出。
**问题 2uni-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 永远 falseundefined >= 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`**:确保默认 tooltiponChartComplete和鼠标移动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-ophandler 不会被调用。

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -19,21 +19,21 @@
<!-- 图表 -->
<view v-else class="chart-wrap">
<view class="chart-canvas">
<view class="chart-canvas" @mousemove="onCanvasMouseMove">
<qiun-data-charts
ref="chartRef"
type="area"
:opts="chartOpts"
:chartData="chartData"
:ontouch="true"
:ontap="true"
:onmouse="true"
:onmovetip="true"
:in-scroll-view="true"
:tooltipShow="true"
:tooltipFormat="'incomeFormatter'"
:canvas2d="false"
canvasId="incomeCurveCanvas"
:canvasHeight="240"
ontap
@tap="onChartTap"
@complete="onChartComplete"
/>
</view>
@ -42,9 +42,12 @@
</template>
<script setup>
import { computed, nextTick, ref, watch } from "vue";
import { computed, getCurrentInstance, nextTick, ref } from "vue";
// import easycom
import QiunDataCharts from "@/uni_modules/qiun-data-charts/components/qiun-data-charts/qiun-data-charts.vue";
// [] u-charts config cfu.instance[canvasId] showToolTip
// qiun-data-charts _showTooltip Vue ref
import cfu from "@/uni_modules/qiun-data-charts/js_sdk/u-charts/config-ucharts.js";
const props = defineProps({
points: { type: Array, default: () => [] },
@ -53,31 +56,116 @@ const props = defineProps({
});
defineEmits(["retry"]);
// [] qiun-data-charts tooltip
const chartRef = ref(null);
// [] tooltip
const lastShownLen = ref(0);
// [] Task 2
const onChartComplete = (_e) => {
// Task 2 fake event
// [] template <qiun-data-charts> canvasId prop
// cfu.instance uCharts
const CANVAS_ID = "incomeCurveCanvas";
// [] tooltip series.data "+xxx"
// qiun-data-charts tooltipCustom prop Object Function
// {x, y, index, textList}
// formatter cfu.formatter :tooltipFormat="..." chart
// _showTooltip / renderjs showTooltip
// direct showToolTip formatter
// extra.tooltip.showCategory=true opts.categories
const incomeFormatter = (item, _category, _index, _opts) => `+${item.data}`;
cfu.formatter.incomeFormatter = incomeFormatter;
// []
// fake event u-charts showToolTip
// tooltip qiun-data-charts
// _tap _showTooltip
//
//
// 1. calPoints canvas (=CSS × opts.pix)showToolTip
// getTouches opts.pix canvas
// e.detail.x CSS getTouches
// pix canvas
// 2. config-ucharts.js cfu.instance[CANVAS_ID]
// showToolTip qiun-data-charts _showTooltipVue ref
// 3. option.index getCurrentDataIndex
const onChartComplete = async (e) => {
console.log("mouse move", e)
const len = props.points.length;
if (!len) return;
// complete
if (lastShownLen.value === len) return;
await nextTick();
const series = e?.opts?.chartData?.calPoints?.[0];
if (!series || !series[len - 1]) return;
const pix = e?.opts?.pix || 1;
const { x, y } = series[len - 1];
const fakeE = { changedTouches: [{ x: x / pix, y: y / pix }] };
try {
const instance = cfu.instance[CANVAS_ID];
if (!instance) {
console.warn("[IncomeCurve] uCharts instance not found for canvas:", CANVAS_ID);
return;
}
// [H5 fix] qiun-data-charts cfu.option[cid].onmouse HBuilderX H5
// undefined uni_modules mounted() 488-490 inWin
// renderjs.mouseMove `if(onmouse == false) return;`
// `undefined == false` true 退 H5 tooltip
// true H5
if (cfu.option[CANVAS_ID]) {
cfu.option[CANVAS_ID].onmouse = true;
}
// [] onmouse + showToolTip
console.log("[IncomeCurve] onmouse patched to:", cfu.option[CANVAS_ID]?.onmouse, "instance:", !!instance, "len:", len);
// u-charts showToolTipoption.index
// formatter incomeFormatter income "+xxx"
instance.showToolTip(fakeE, { index: len - 1, formatter: incomeFormatter });
} catch (err) {
console.warn("[IncomeCurve] show default tooltip failed:", err);
}
lastShownLen.value = len;
};
// 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;
// [] H5 qiun-data-charts renderjs
// uni-app renderjs mousemove renderjs
// rdcharts.mouseMove H5 tooltip
// IncomeCurve @mousemove cfu.instance
// u-charts showToolTip getCurrentDataIndex
// H5 mousemove handler no-op
// 60ms mousemove canvas
let lastMouseMoveTime = 0;
let mouseMoveCallCount = 0;
const onCanvasMouseMove = (e) => {
mouseMoveCallCount++;
if (mouseMoveCallCount === 1) {
// []
console.log("[IncomeCurve] onCanvasMouseMove FIRST CALL — event reached Vue layer, clientX:", e.clientX, "clientY:", e.clientY);
}
const uchartsInstance = cfu.instance[CANVAS_ID];
if (!uchartsInstance) return;
const now = Date.now();
if (now - lastMouseMoveTime < 60) return; // 60ms
lastMouseMoveTime = now;
// callback e
const clientX = e.clientX;
const clientY = e.clientY;
// qiun-data-charts view id 'UC' + canvasId
const viewSel = "#UC" + CANVAS_ID;
const query = uni.createSelectorQuery();
const inst = getCurrentInstance();
if (inst) query.in(inst.proxy);
query.select(viewSel).boundingClientRect((rect) => {
if (!rect) return;
const localX = clientX - rect.left;
const localY = clientY - rect.top;
//
if (localX < 0 || localX > rect.width || localY < 0 || localY > rect.height) return;
const fakeE = { changedTouches: [{ x: localX, y: localY }] };
try {
// index u-charts getCurrentDataIndex
// formatter incomeFormatter income "+xxx"
uchartsInstance.showToolTip(fakeE, { formatter: incomeFormatter });
} catch (err) {
// getCurrentDataIndex -1showToolTip
}
}).exec();
};
const chartData = computed(() => {
@ -132,28 +220,18 @@ const chartOpts = {
tooltip: {
showBox: true,
showArrow: true,
showCategory: false,
// [] showCategory=true opts.categories tooltip
// qiun-data-charts tooltipCustom prop Object Function
// u-charts showCategory + cfu.formatter
showCategory: true,
bgColor: "#000000",
bgOpacity: 0.6,
fontColor: "#FFFFFF",
// [] fontColor
// series.color #1BAFEE fontColor
fontColor: "#999999",
fontSize: 11,
splitLine: true,
horizentalLine: { type: "dash", width: 1, color: "#FFFFFF" },
// uCharts tooltip index
// index < 0 tooltip
tooltipCustom: (_opts, _categories, index) => {
if (typeof index !== "number" || index < 0) {
return { textList: [] };
}
const point = props.points[index];
if (!point) return { textList: [] };
return {
textList: [
{ text: `+${point.income}`, color: "#1BAFEE" },
{ text: point.date.slice(5), color: "#999999" },
],
};
},
},
// area 线 series.linearType=custom + linearColor
// addLine:false 线线

View File

@ -10,6 +10,9 @@ const API_BASE_URL = String(
const USE_MOCK_API = String(import.meta.env.VITE_USE_MOCK_API || 'false').toLowerCase() === 'true'
// 导出供 composable / 其他模块按需走 mock 短路(避免再读一次 env
export const IS_MOCK_API = USE_MOCK_API
const baseURL = API_BASE_URL
/**
@ -24,7 +27,7 @@ const WS_BASE_URL = (() => {
return baseURL.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
})()
console.log('[API] env:', ENV_NAME, 'baseURL:', baseURL, 'ws:', WS_BASE_URL, 'mock:', USE_MOCK_API)
console.log('[API] env:', 'baseURL:', baseURL, 'ws:', WS_BASE_URL, 'mock:', USE_MOCK_API)
/** 获取 WebSocket 基础地址(环境变量 VITE_WS_BASE_URL未配置时由 API 地址自动推导) */
export async function getWebSocketBaseUrl() {