feat:修改数据看板
This commit is contained in:
parent
7e5377861a
commit
d859650136
4
.gitignore
vendored
4
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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 ""
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
@ -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_id(dashboard 顶部下拉)
|
||||
- 验证新数据加载完成后,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 兜底
|
||||
|
||||
### 变更 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 不会被调用。
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 私有 _showTooltip(Vue 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 的 showToolTip,option.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 返回 -1,showToolTip 内部会忽略
|
||||
}
|
||||
}).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 不绘制曲线(曲线透明),仅保留渐变面积填充
|
||||
|
||||
@ -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() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user