From 96348ce39b760a898da3158bf58af15110b55706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Fri, 14 Aug 2026 13:34:21 +0800 Subject: [PATCH] 1 --- .gitignore | 1 - CLAUDE.md | 1 + biz/controller/admin_user.go | 29 - biz/controller/badge.go | 44 - biz/controller/chapter_review.go | 31 - biz/controller/element.go | 34 - biz/controller/life_task.go | 45 - biz/controller/node_option.go | 48 - biz/controller/prize.go | 47 - biz/controller/redemption.go | 32 - biz/controller/scene_node.go | 53 - biz/controller/user_badge.go | 29 - biz/controller/user_collection.go | 29 - biz/controller/user_progress.go | 45 - biz/controller/user_route_log.go | 32 - biz/model/dto/admin_user.go | 14 - biz/model/dto/badge.go | 23 - biz/model/dto/chapter_review.go | 16 - biz/model/dto/element.go | 19 - biz/model/dto/life_task.go | 24 - biz/model/dto/node_option.go | 30 - biz/model/dto/prize.go | 26 - biz/model/dto/redemption.go | 17 - biz/model/dto/scene_node.go | 35 - biz/model/dto/user_badge.go | 14 - biz/model/dto/user_collection.go | 14 - biz/model/dto/user_progress.go | 28 - biz/model/dto/user_route_log.go | 18 - biz/service/admin_user.go | 13 - biz/service/chapter_review.go | 6 - common/auth/auth.go | 8 - common/base_dao.go | 5 - docs/superpowers/plans/2026-08-13-m1-core.md | 786 ---- .../plans/2026-08-13-m1_5-interactions.md | 3372 ----------------- .../plans/2026-08-13-story-theater-v2.md | 1588 -------- .../plans/2026-08-13-story-theater.md | 765 ---- .../specs/2026-08-13-story-theater-design.md | 256 -- main.go | 10 +- {biz/service/seed => seed}/annotate.go | 2 +- {biz/service/seed => seed}/interaction.go | 8 +- .../service/seed => seed}/interaction_test.go | 0 {biz/service/seed => seed}/seed.go | 4 +- .../seed => seed}/seed_36_ji/group1.json | 0 .../seed => seed}/seed_36_ji/group2.json | 0 .../seed => seed}/seed_36_ji/group3.json | 0 .../seed => seed}/seed_36_ji/group4.json | 0 .../seed => seed}/seed_36_ji/group5.json | 0 .../seed => seed}/seed_36_ji/group6.json | 0 .../seed => seed}/seed_36_ji/seed_meta.json | 0 ui-src/src/components/StoryPlayer.vue | 5 +- ui-src/src/utils/speech.js | 15 + workspace/.gitkeep | 0 技术设计.md | 8 +- 53 files changed, 43 insertions(+), 7586 deletions(-) delete mode 100644 docs/superpowers/plans/2026-08-13-m1-core.md delete mode 100644 docs/superpowers/plans/2026-08-13-m1_5-interactions.md delete mode 100644 docs/superpowers/plans/2026-08-13-story-theater-v2.md delete mode 100644 docs/superpowers/plans/2026-08-13-story-theater.md delete mode 100644 docs/superpowers/specs/2026-08-13-story-theater-design.md rename {biz/service/seed => seed}/annotate.go (98%) rename {biz/service/seed => seed}/interaction.go (97%) rename {biz/service/seed => seed}/interaction_test.go (100%) rename {biz/service/seed => seed}/seed.go (98%) rename {biz/service/seed => seed}/seed_36_ji/group1.json (100%) rename {biz/service/seed => seed}/seed_36_ji/group2.json (100%) rename {biz/service/seed => seed}/seed_36_ji/group3.json (100%) rename {biz/service/seed => seed}/seed_36_ji/group4.json (100%) rename {biz/service/seed => seed}/seed_36_ji/group5.json (100%) rename {biz/service/seed => seed}/seed_36_ji/group6.json (100%) rename {biz/service/seed => seed}/seed_36_ji/seed_meta.json (100%) delete mode 100644 workspace/.gitkeep diff --git a/.gitignore b/.gitignore index a919e8e..267ee43 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # 运行时数据与本地环境 workspace/* -!workspace/.gitkeep data/* !data/.gitkeep .idea/ diff --git a/CLAUDE.md b/CLAUDE.md index 65b5e7f..bc88789 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ | biz/dao/ | 单表数据访问,每表一个文件 | 无业务逻辑;查询经 base_dao 缓存 | | biz/service/ | 业务逻辑:规则校验、文件读写、事务、跨表组装、调用 dao、LLM 编排 | 不直接写 HTTP 响应(例外见下);并行任务走 common 协程池 | | biz/controller/ | 接口层:接收参数、调用 service、组装返回值 | 见「分层职责规范」;禁止调用 dao | +| seed/ | 内置内容种子包(根级):`go:embed` JSON + 启动导入(跨表插入/互动编排/拼音补标),main.go 启动时 `EnsureSeeded` 幂等调用 | 跨表启动导入不属任何业务表,禁止放进 `biz/service/` 等业务分层;同 `cmd/` 属非表分层目录 | | 前端目录 | 前台 `ui-src/` 为 uni-app (Vue 3) 多端工程(Android/iOS/平板/微信小程序/H5);后台 `admin-src/` 为 Vue 3 + Element Plus Web 工程 | 前台/后台开发用 vite 代理,生产构建产物 `dist` 由后端托管;多端工程差异见技术设计.md | | 运行时数据目录 | SQLite 库、上传/解析文件(本项目 `data/` `workspace/`) | 不提交 git;删除即丢失数据,改动前先确认 | diff --git a/biz/controller/admin_user.go b/biz/controller/admin_user.go index 2e49d99..27d1376 100644 --- a/biz/controller/admin_user.go +++ b/biz/controller/admin_user.go @@ -1,34 +1,5 @@ package controller -import ( - "context" - - "github.com/gogf/gf/v2/database/gdb" - - "36wisdom/biz/model/dto" - "36wisdom/biz/service" -) - type adminUser struct{} var AdminUser = &adminUser{} - -func (c *adminUser) Get(ctx context.Context, req *dto.AdminUserGetReq) (*dto.AdminUserGetRes, error) { - rec, err := service.AdminUser.GetByUsername(ctx, req.Username) - if err != nil { - return nil, err - } - res := &dto.AdminUserGetRes{} - if !rec.IsEmpty() { - res.AdminUser = adminUserItem(rec) - } - return res, nil -} - -func adminUserItem(r gdb.Record) dto.AdminUserItem { - return dto.AdminUserItem{ - Id: r["id"].Int64(), - Username: r["username"].String(), - Status: r["status"].Int(), - } -} diff --git a/biz/controller/badge.go b/biz/controller/badge.go index cd0a0e2..6fb185c 100644 --- a/biz/controller/badge.go +++ b/biz/controller/badge.go @@ -1,49 +1,5 @@ package controller -import ( - "context" - - "github.com/gogf/gf/v2/database/gdb" - - "36wisdom/biz/model/dto" - "36wisdom/biz/service" -) - type badge struct{} var Badge = &badge{} - -func (c *badge) Get(ctx context.Context, req *dto.BadgeGetReq) (*dto.BadgeGetRes, error) { - rec, err := service.Badge.GetByPk(ctx, req.Id) - if err != nil { - return nil, err - } - res := &dto.BadgeGetRes{} - if !rec.IsEmpty() { - res.Badge = badgeItem(rec) - } - return res, nil -} - -func (c *badge) List(ctx context.Context, req *dto.BadgeListReq) (*dto.BadgeListRes, error) { - recs, err := service.Badge.ListEnabled(ctx) - if err != nil { - return nil, err - } - res := &dto.BadgeListRes{List: make([]dto.BadgeItem, 0, len(recs))} - for _, r := range recs { - res.List = append(res.List, badgeItem(r)) - } - return res, nil -} - -func badgeItem(r gdb.Record) dto.BadgeItem { - return dto.BadgeItem{ - Id: r["id"].Int64(), - Name: r["name"].String(), - Icon: r["icon"].String(), - CondType: r["cond_type"].Int(), - CondValue: r["cond_value"].Int(), - Status: r["status"].Int(), - } -} diff --git a/biz/controller/chapter_review.go b/biz/controller/chapter_review.go index d578660..65b9c07 100644 --- a/biz/controller/chapter_review.go +++ b/biz/controller/chapter_review.go @@ -1,36 +1,5 @@ package controller -import ( - "context" - - "github.com/gogf/gf/v2/database/gdb" - - "36wisdom/biz/model/dto" - "36wisdom/biz/service" -) - type chapterReview struct{} var ChapterReview = &chapterReview{} - -func (c *chapterReview) ListByChild(ctx context.Context, req *dto.ChapterReviewListReq) (*dto.ChapterReviewListRes, error) { - recs, err := service.ChapterReview.ListByChild(ctx, req.ChildId) - if err != nil { - return nil, err - } - res := &dto.ChapterReviewListRes{List: make([]dto.ChapterReviewItem, 0, len(recs))} - for _, r := range recs { - res.List = append(res.List, chapterReviewItem(r)) - } - return res, nil -} - -func chapterReviewItem(r gdb.Record) dto.ChapterReviewItem { - return dto.ChapterReviewItem{ - Id: r["id"].Int64(), - ChildId: r["child_id"].Int64(), - StrategyId: r["strategy_id"].Int64(), - LevelIds: r["level_ids"].String(), - Status: r["status"].Int(), - } -} diff --git a/biz/controller/element.go b/biz/controller/element.go index 71c1e92..b8b89ee 100644 --- a/biz/controller/element.go +++ b/biz/controller/element.go @@ -1,39 +1,5 @@ package controller -import ( - "context" - - "github.com/gogf/gf/v2/database/gdb" - - "36wisdom/biz/model/dto" - "36wisdom/biz/service" -) - type element struct{} var Element = &element{} - -func (c *element) ListByIds(ctx context.Context, req *dto.ElementListReq) (*dto.ElementListRes, error) { - recs, err := service.Element.ListEnabledByIds(ctx, req.Ids) - if err != nil { - return nil, err - } - res := &dto.ElementListRes{List: make([]dto.ElementItem, 0, len(recs))} - for _, r := range recs { - res.List = append(res.List, elementItem(r)) - } - return res, nil -} - -func elementItem(r gdb.Record) dto.ElementItem { - return dto.ElementItem{ - Id: r["id"].Int64(), - EType: r["e_type"].Int(), - Name: r["name"].String(), - Image: r["image"].String(), - Audio: r["audio"].String(), - Description: r["description"].String(), - Status: r["status"].Int(), - SortOrder: r["sort_order"].Int(), - } -} diff --git a/biz/controller/life_task.go b/biz/controller/life_task.go index e60fef7..241aea4 100644 --- a/biz/controller/life_task.go +++ b/biz/controller/life_task.go @@ -1,50 +1,5 @@ package controller -import ( - "context" - - "github.com/gogf/gf/v2/database/gdb" - - "36wisdom/biz/model/dto" - "36wisdom/biz/service" -) - type lifeTask struct{} var LifeTask = &lifeTask{} - -func (c *lifeTask) Get(ctx context.Context, req *dto.LifeTaskGetReq) (*dto.LifeTaskGetRes, error) { - rec, err := service.LifeTask.GetByPk(ctx, req.Id) - if err != nil { - return nil, err - } - res := &dto.LifeTaskGetRes{} - if !rec.IsEmpty() { - res.LifeTask = lifeTaskItem(rec) - } - return res, nil -} - -func (c *lifeTask) List(ctx context.Context, req *dto.LifeTaskListReq) (*dto.LifeTaskListRes, error) { - recs, err := service.LifeTask.ListEnabled(ctx) - if err != nil { - return nil, err - } - res := &dto.LifeTaskListRes{List: make([]dto.LifeTaskItem, 0, len(recs))} - for _, r := range recs { - res.List = append(res.List, lifeTaskItem(r)) - } - return res, nil -} - -func lifeTaskItem(r gdb.Record) dto.LifeTaskItem { - return dto.LifeTaskItem{ - Id: r["id"].Int64(), - StrategyId: r["strategy_id"].Int64(), - Title: r["title"].String(), - Description: r["description"].String(), - Guide: r["guide"].String(), - RewardPoints: r["reward_points"].Int(), - Status: r["status"].Int(), - } -} diff --git a/biz/controller/node_option.go b/biz/controller/node_option.go index 4c4cb5e..5cc3dcc 100644 --- a/biz/controller/node_option.go +++ b/biz/controller/node_option.go @@ -1,53 +1,5 @@ package controller -import ( - "context" - - "github.com/gogf/gf/v2/database/gdb" - - "36wisdom/biz/model/dto" - "36wisdom/biz/service" -) - type nodeOption struct{} var NodeOption = &nodeOption{} - -func (c *nodeOption) Get(ctx context.Context, req *dto.NodeOptionGetReq) (*dto.NodeOptionGetRes, error) { - rec, err := service.NodeOption.GetInNode(ctx, req.OptionId, req.NodeId) - if err != nil { - return nil, err - } - res := &dto.NodeOptionGetRes{} - if !rec.IsEmpty() { - res.NodeOption = nodeOptionItem(rec) - } - return res, nil -} - -func (c *nodeOption) ListByNode(ctx context.Context, req *dto.NodeOptionListReq) (*dto.NodeOptionListRes, error) { - recs, err := service.NodeOption.ListEnabledByNode(ctx, req.NodeId) - if err != nil { - return nil, err - } - res := &dto.NodeOptionListRes{List: make([]dto.NodeOptionItem, 0, len(recs))} - for _, r := range recs { - res.List = append(res.List, nodeOptionItem(r)) - } - return res, nil -} - -func nodeOptionItem(r gdb.Record) dto.NodeOptionItem { - return dto.NodeOptionItem{ - Id: r["id"].Int64(), - NodeId: r["node_id"].Int64(), - Text: r["text"].String(), - PropId: r["prop_id"].Int64(), - Audio: r["audio"].String(), - NextNodeId: r["next_node_id"].Int64(), - Feedback: r["feedback"].String(), - FeedbackAudio: r["feedback_audio"].String(), - SortOrder: r["sort_order"].Int(), - Status: r["status"].Int(), - } -} diff --git a/biz/controller/prize.go b/biz/controller/prize.go index 18ce0bb..11a9f0c 100644 --- a/biz/controller/prize.go +++ b/biz/controller/prize.go @@ -1,52 +1,5 @@ package controller -import ( - "context" - - "github.com/gogf/gf/v2/database/gdb" - - "36wisdom/biz/model/dto" - "36wisdom/biz/service" -) - type prize struct{} var Prize = &prize{} - -func (c *prize) Get(ctx context.Context, req *dto.PrizeGetReq) (*dto.PrizeGetRes, error) { - rec, err := service.Prize.GetByPk(ctx, req.Id) - if err != nil { - return nil, err - } - res := &dto.PrizeGetRes{} - if !rec.IsEmpty() { - res.Prize = prizeItem(rec) - } - return res, nil -} - -func (c *prize) List(ctx context.Context, req *dto.PrizeListReq) (*dto.PrizeListRes, error) { - recs, err := service.Prize.ListEnabled(ctx) - if err != nil { - return nil, err - } - res := &dto.PrizeListRes{List: make([]dto.PrizeItem, 0, len(recs))} - for _, r := range recs { - res.List = append(res.List, prizeItem(r)) - } - return res, nil -} - -func prizeItem(r gdb.Record) dto.PrizeItem { - return dto.PrizeItem{ - Id: r["id"].Int64(), - Name: r["name"].String(), - Description: r["description"].String(), - Icon: r["icon"].String(), - PType: r["p_type"].Int(), - PointsCost: r["points_cost"].Int(), - Stock: r["stock"].Int(), - Status: r["status"].Int(), - SortOrder: r["sort_order"].Int(), - } -} diff --git a/biz/controller/redemption.go b/biz/controller/redemption.go index 65774d1..c1eba2a 100644 --- a/biz/controller/redemption.go +++ b/biz/controller/redemption.go @@ -1,37 +1,5 @@ package controller -import ( - "context" - - "github.com/gogf/gf/v2/database/gdb" - - "36wisdom/biz/model/dto" - "36wisdom/biz/service" -) - type redemption struct{} var Redemption = &redemption{} - -func (c *redemption) ListByUser(ctx context.Context, req *dto.RedemptionListReq) (*dto.RedemptionListRes, error) { - recs, err := service.Redemption.ListByUser(ctx, req.UserId) - if err != nil { - return nil, err - } - res := &dto.RedemptionListRes{List: make([]dto.RedemptionItem, 0, len(recs))} - for _, r := range recs { - res.List = append(res.List, redemptionItem(r)) - } - return res, nil -} - -func redemptionItem(r gdb.Record) dto.RedemptionItem { - return dto.RedemptionItem{ - Id: r["id"].Int64(), - UserId: r["user_id"].Int64(), - PrizeId: r["prize_id"].Int64(), - PointsCost: r["points_cost"].Int(), - Status: r["status"].Int(), - Code: r["code"].String(), - } -} diff --git a/biz/controller/scene_node.go b/biz/controller/scene_node.go index 85c1ffa..22c8cba 100644 --- a/biz/controller/scene_node.go +++ b/biz/controller/scene_node.go @@ -1,58 +1,5 @@ package controller -import ( - "context" - - "github.com/gogf/gf/v2/database/gdb" - - "36wisdom/biz/model/dto" - "36wisdom/biz/service" -) - type sceneNode struct{} var SceneNode = &sceneNode{} - -func (c *sceneNode) Get(ctx context.Context, req *dto.SceneNodeGetReq) (*dto.SceneNodeGetRes, error) { - rec, err := service.SceneNode.GetInLevel(ctx, req.NodeId, req.LevelId) - if err != nil { - return nil, err - } - res := &dto.SceneNodeGetRes{} - if !rec.IsEmpty() { - res.SceneNode = sceneNodeItem(rec) - } - return res, nil -} - -func (c *sceneNode) ListByLevel(ctx context.Context, req *dto.SceneNodeListReq) (*dto.SceneNodeListRes, error) { - recs, err := service.SceneNode.ListEnabledByLevel(ctx, req.LevelId) - if err != nil { - return nil, err - } - res := &dto.SceneNodeListRes{List: make([]dto.SceneNodeItem, 0, len(recs))} - for _, r := range recs { - res.List = append(res.List, sceneNodeItem(r)) - } - return res, nil -} - -func sceneNodeItem(r gdb.Record) dto.SceneNodeItem { - return dto.SceneNodeItem{ - Id: r["id"].Int64(), - LevelId: r["level_id"].Int64(), - Title: r["title"].String(), - CharacterId: r["character_id"].Int64(), - Content: r["content"].String(), - Image: r["image"].String(), - Audio: r["audio"].String(), - NodeType: r["node_type"].Int(), - InteractionType: r["interaction_type"].Int(), - Config: r["config"].String(), - Script: r["script"].String(), - ResultType: r["result_type"].Int(), - IsEntry: r["is_entry"].Int(), - SortOrder: r["sort_order"].Int(), - Status: r["status"].Int(), - } -} diff --git a/biz/controller/user_badge.go b/biz/controller/user_badge.go index b1dd35c..3aaa6cc 100644 --- a/biz/controller/user_badge.go +++ b/biz/controller/user_badge.go @@ -1,34 +1,5 @@ package controller -import ( - "context" - - "github.com/gogf/gf/v2/database/gdb" - - "36wisdom/biz/model/dto" - "36wisdom/biz/service" -) - type userBadge struct{} var UserBadge = &userBadge{} - -func (c *userBadge) ListByUser(ctx context.Context, req *dto.UserBadgeListReq) (*dto.UserBadgeListRes, error) { - recs, err := service.UserBadge.ListByUser(ctx, req.UserId) - if err != nil { - return nil, err - } - res := &dto.UserBadgeListRes{List: make([]dto.UserBadgeItem, 0, len(recs))} - for _, r := range recs { - res.List = append(res.List, userBadgeItem(r)) - } - return res, nil -} - -func userBadgeItem(r gdb.Record) dto.UserBadgeItem { - return dto.UserBadgeItem{ - Id: r["id"].Int64(), - UserId: r["user_id"].Int64(), - BadgeId: r["badge_id"].Int64(), - } -} diff --git a/biz/controller/user_collection.go b/biz/controller/user_collection.go index e882899..33e9752 100644 --- a/biz/controller/user_collection.go +++ b/biz/controller/user_collection.go @@ -1,34 +1,5 @@ package controller -import ( - "context" - - "github.com/gogf/gf/v2/database/gdb" - - "36wisdom/biz/model/dto" - "36wisdom/biz/service" -) - type userCollection struct{} var UserCollection = &userCollection{} - -func (c *userCollection) ListByUser(ctx context.Context, req *dto.UserCollectionListReq) (*dto.UserCollectionListRes, error) { - recs, err := service.UserCollection.ListByUser(ctx, req.UserId) - if err != nil { - return nil, err - } - res := &dto.UserCollectionListRes{List: make([]dto.UserCollectionItem, 0, len(recs))} - for _, r := range recs { - res.List = append(res.List, userCollectionItem(r)) - } - return res, nil -} - -func userCollectionItem(r gdb.Record) dto.UserCollectionItem { - return dto.UserCollectionItem{ - Id: r["id"].Int64(), - UserId: r["user_id"].Int64(), - StrategyId: r["strategy_id"].Int64(), - } -} diff --git a/biz/controller/user_progress.go b/biz/controller/user_progress.go index 4a7114b..b85d00b 100644 --- a/biz/controller/user_progress.go +++ b/biz/controller/user_progress.go @@ -1,50 +1,5 @@ package controller -import ( - "context" - - "github.com/gogf/gf/v2/database/gdb" - - "36wisdom/biz/model/dto" - "36wisdom/biz/service" -) - type userProgress struct{} var UserProgress = &userProgress{} - -func (c *userProgress) GetByChildLevel(ctx context.Context, req *dto.UserProgressGetReq) (*dto.UserProgressGetRes, error) { - rec, err := service.UserProgress.GetByChildLevel(ctx, req.ChildId, req.LevelId) - if err != nil { - return nil, err - } - res := &dto.UserProgressGetRes{} - if !rec.IsEmpty() { - res.UserProgress = userProgressItem(rec) - } - return res, nil -} - -func (c *userProgress) ListByChild(ctx context.Context, req *dto.UserProgressListReq) (*dto.UserProgressListRes, error) { - recs, err := service.UserProgress.ListByChildLevelIds(ctx, req.ChildId, req.LevelIds) - if err != nil { - return nil, err - } - res := &dto.UserProgressListRes{List: make([]dto.UserProgressItem, 0, len(recs))} - for _, r := range recs { - res.List = append(res.List, userProgressItem(r)) - } - return res, nil -} - -func userProgressItem(r gdb.Record) dto.UserProgressItem { - return dto.UserProgressItem{ - Id: r["id"].Int64(), - ChildId: r["child_id"].Int64(), - LevelId: r["level_id"].Int64(), - Stars: r["stars"].Int(), - Score: r["score"].Int(), - Perfect: r["perfect"].Int(), - ContentVersion: r["content_version"].Int(), - } -} diff --git a/biz/controller/user_route_log.go b/biz/controller/user_route_log.go index 7bbead9..2c7c87f 100644 --- a/biz/controller/user_route_log.go +++ b/biz/controller/user_route_log.go @@ -1,37 +1,5 @@ package controller -import ( - "context" - - "github.com/gogf/gf/v2/database/gdb" - - "36wisdom/biz/model/dto" - "36wisdom/biz/service" -) - type userRouteLog struct{} var UserRouteLog = &userRouteLog{} - -func (c *userRouteLog) ListByChildLevel(ctx context.Context, req *dto.UserRouteLogListReq) (*dto.UserRouteLogListRes, error) { - recs, err := service.UserRouteLog.ListFinalsByChildLevel(ctx, req.ChildId, req.LevelId) - if err != nil { - return nil, err - } - res := &dto.UserRouteLogListRes{List: make([]dto.UserRouteLogItem, 0, len(recs))} - for _, r := range recs { - res.List = append(res.List, userRouteLogItem(r)) - } - return res, nil -} - -func userRouteLogItem(r gdb.Record) dto.UserRouteLogItem { - return dto.UserRouteLogItem{ - Id: r["id"].Int64(), - ChildId: r["child_id"].Int64(), - LevelId: r["level_id"].Int64(), - NodeId: r["node_id"].Int64(), - OptionId: r["option_id"].Int64(), - ResultType: r["result_type"].Int(), - } -} diff --git a/biz/model/dto/admin_user.go b/biz/model/dto/admin_user.go index 4ac7c44..76d3a17 100644 --- a/biz/model/dto/admin_user.go +++ b/biz/model/dto/admin_user.go @@ -1,15 +1 @@ package dto - -type AdminUserItem struct { - Id int64 `json:"id"` - Username string `json:"username"` - Status int `json:"status"` -} - -type AdminUserGetReq struct { - Username string `v:"required" json:"username"` -} - -type AdminUserGetRes struct { - AdminUser AdminUserItem `json:"admin_user"` -} diff --git a/biz/model/dto/badge.go b/biz/model/dto/badge.go index 027a11c..76d3a17 100644 --- a/biz/model/dto/badge.go +++ b/biz/model/dto/badge.go @@ -1,24 +1 @@ package dto - -type BadgeItem struct { - Id int64 `json:"id"` - Name string `json:"name"` - Icon string `json:"icon"` - CondType int `json:"cond_type"` - CondValue int `json:"cond_value"` - Status int `json:"status"` -} - -type BadgeGetReq struct { - Id int64 `v:"required" json:"id"` -} - -type BadgeGetRes struct { - Badge BadgeItem `json:"badge"` -} - -type BadgeListReq struct{} - -type BadgeListRes struct { - List []BadgeItem `json:"list"` -} diff --git a/biz/model/dto/chapter_review.go b/biz/model/dto/chapter_review.go index 21abc9b..76d3a17 100644 --- a/biz/model/dto/chapter_review.go +++ b/biz/model/dto/chapter_review.go @@ -1,17 +1 @@ package dto - -type ChapterReviewItem struct { - Id int64 `json:"id"` - ChildId int64 `json:"child_id"` - StrategyId int64 `json:"strategy_id"` - LevelIds string `json:"level_ids"` - Status int `json:"status"` -} - -type ChapterReviewListReq struct { - ChildId int64 `v:"required" json:"child_id"` -} - -type ChapterReviewListRes struct { - List []ChapterReviewItem `json:"list"` -} diff --git a/biz/model/dto/element.go b/biz/model/dto/element.go index d90ae3e..76d3a17 100644 --- a/biz/model/dto/element.go +++ b/biz/model/dto/element.go @@ -1,20 +1 @@ package dto - -type ElementItem struct { - Id int64 `json:"id"` - EType int `json:"e_type"` - Name string `json:"name"` - Image string `json:"image"` - Audio string `json:"audio"` - Description string `json:"description"` - Status int `json:"status"` - SortOrder int `json:"sort_order"` -} - -type ElementListReq struct { - Ids []int64 `v:"required" json:"ids"` -} - -type ElementListRes struct { - List []ElementItem `json:"list"` -} diff --git a/biz/model/dto/life_task.go b/biz/model/dto/life_task.go index 4eff68c..76d3a17 100644 --- a/biz/model/dto/life_task.go +++ b/biz/model/dto/life_task.go @@ -1,25 +1 @@ package dto - -type LifeTaskItem struct { - Id int64 `json:"id"` - StrategyId int64 `json:"strategy_id"` - Title string `json:"title"` - Description string `json:"description"` - Guide string `json:"guide"` - RewardPoints int `json:"reward_points"` - Status int `json:"status"` -} - -type LifeTaskGetReq struct { - Id int64 `v:"required" json:"id"` -} - -type LifeTaskGetRes struct { - LifeTask LifeTaskItem `json:"life_task"` -} - -type LifeTaskListReq struct{} - -type LifeTaskListRes struct { - List []LifeTaskItem `json:"list"` -} diff --git a/biz/model/dto/node_option.go b/biz/model/dto/node_option.go index 96f060b..76d3a17 100644 --- a/biz/model/dto/node_option.go +++ b/biz/model/dto/node_option.go @@ -1,31 +1 @@ package dto - -type NodeOptionItem struct { - Id int64 `json:"id"` - NodeId int64 `json:"node_id"` - Text string `json:"text"` - PropId int64 `json:"prop_id"` - Audio string `json:"audio"` - NextNodeId int64 `json:"next_node_id"` - Feedback string `json:"feedback"` - FeedbackAudio string `json:"feedback_audio"` - SortOrder int `json:"sort_order"` - Status int `json:"status"` -} - -type NodeOptionGetReq struct { - OptionId int64 `v:"required" json:"option_id"` - NodeId int64 `v:"required" json:"node_id"` -} - -type NodeOptionGetRes struct { - NodeOption NodeOptionItem `json:"node_option"` -} - -type NodeOptionListReq struct { - NodeId int64 `v:"required" json:"node_id"` -} - -type NodeOptionListRes struct { - List []NodeOptionItem `json:"list"` -} diff --git a/biz/model/dto/prize.go b/biz/model/dto/prize.go index 53f248e..76d3a17 100644 --- a/biz/model/dto/prize.go +++ b/biz/model/dto/prize.go @@ -1,27 +1 @@ package dto - -type PrizeItem struct { - Id int64 `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Icon string `json:"icon"` - PType int `json:"p_type"` - PointsCost int `json:"points_cost"` - Stock int `json:"stock"` - Status int `json:"status"` - SortOrder int `json:"sort_order"` -} - -type PrizeGetReq struct { - Id int64 `v:"required" json:"id"` -} - -type PrizeGetRes struct { - Prize PrizeItem `json:"prize"` -} - -type PrizeListReq struct{} - -type PrizeListRes struct { - List []PrizeItem `json:"list"` -} diff --git a/biz/model/dto/redemption.go b/biz/model/dto/redemption.go index 991f99b..76d3a17 100644 --- a/biz/model/dto/redemption.go +++ b/biz/model/dto/redemption.go @@ -1,18 +1 @@ package dto - -type RedemptionItem struct { - Id int64 `json:"id"` - UserId int64 `json:"user_id"` - PrizeId int64 `json:"prize_id"` - PointsCost int `json:"points_cost"` - Status int `json:"status"` - Code string `json:"code"` -} - -type RedemptionListReq struct { - UserId int64 `v:"required" json:"user_id"` -} - -type RedemptionListRes struct { - List []RedemptionItem `json:"list"` -} diff --git a/biz/model/dto/scene_node.go b/biz/model/dto/scene_node.go index e01ed24..76d3a17 100644 --- a/biz/model/dto/scene_node.go +++ b/biz/model/dto/scene_node.go @@ -1,36 +1 @@ package dto - -type SceneNodeItem struct { - Id int64 `json:"id"` - LevelId int64 `json:"level_id"` - Title string `json:"title"` - CharacterId int64 `json:"character_id"` - Content string `json:"content"` - Image string `json:"image"` - Audio string `json:"audio"` - NodeType int `json:"node_type"` - InteractionType int `json:"interaction_type"` - Config string `json:"config"` - Script string `json:"script"` - ResultType int `json:"result_type"` - IsEntry int `json:"is_entry"` - SortOrder int `json:"sort_order"` - Status int `json:"status"` -} - -type SceneNodeGetReq struct { - NodeId int64 `v:"required" json:"node_id"` - LevelId int64 `v:"required" json:"level_id"` -} - -type SceneNodeGetRes struct { - SceneNode SceneNodeItem `json:"scene_node"` -} - -type SceneNodeListReq struct { - LevelId int64 `v:"required" json:"level_id"` -} - -type SceneNodeListRes struct { - List []SceneNodeItem `json:"list"` -} diff --git a/biz/model/dto/user_badge.go b/biz/model/dto/user_badge.go index 315a87d..76d3a17 100644 --- a/biz/model/dto/user_badge.go +++ b/biz/model/dto/user_badge.go @@ -1,15 +1 @@ package dto - -type UserBadgeItem struct { - Id int64 `json:"id"` - UserId int64 `json:"user_id"` - BadgeId int64 `json:"badge_id"` -} - -type UserBadgeListReq struct { - UserId int64 `v:"required" json:"user_id"` -} - -type UserBadgeListRes struct { - List []UserBadgeItem `json:"list"` -} diff --git a/biz/model/dto/user_collection.go b/biz/model/dto/user_collection.go index 54fd858..76d3a17 100644 --- a/biz/model/dto/user_collection.go +++ b/biz/model/dto/user_collection.go @@ -1,15 +1 @@ package dto - -type UserCollectionItem struct { - Id int64 `json:"id"` - UserId int64 `json:"user_id"` - StrategyId int64 `json:"strategy_id"` -} - -type UserCollectionListReq struct { - UserId int64 `v:"required" json:"user_id"` -} - -type UserCollectionListRes struct { - List []UserCollectionItem `json:"list"` -} diff --git a/biz/model/dto/user_progress.go b/biz/model/dto/user_progress.go index 6fe50bd..76d3a17 100644 --- a/biz/model/dto/user_progress.go +++ b/biz/model/dto/user_progress.go @@ -1,29 +1 @@ package dto - -type UserProgressItem struct { - Id int64 `json:"id"` - ChildId int64 `json:"child_id"` - LevelId int64 `json:"level_id"` - Stars int `json:"stars"` - Score int `json:"score"` - Perfect int `json:"perfect"` - ContentVersion int `json:"content_version"` -} - -type UserProgressGetReq struct { - ChildId int64 `v:"required" json:"child_id"` - LevelId int64 `v:"required" json:"level_id"` -} - -type UserProgressGetRes struct { - UserProgress UserProgressItem `json:"user_progress"` -} - -type UserProgressListReq struct { - ChildId int64 `v:"required" json:"child_id"` - LevelIds []int64 `v:"required" json:"level_ids"` -} - -type UserProgressListRes struct { - List []UserProgressItem `json:"list"` -} diff --git a/biz/model/dto/user_route_log.go b/biz/model/dto/user_route_log.go index 66576f2..76d3a17 100644 --- a/biz/model/dto/user_route_log.go +++ b/biz/model/dto/user_route_log.go @@ -1,19 +1 @@ package dto - -type UserRouteLogItem struct { - Id int64 `json:"id"` - ChildId int64 `json:"child_id"` - LevelId int64 `json:"level_id"` - NodeId int64 `json:"node_id"` - OptionId int64 `json:"option_id"` - ResultType int `json:"result_type"` -} - -type UserRouteLogListReq struct { - ChildId int64 `v:"required" json:"child_id"` - LevelId int64 `v:"required" json:"level_id"` -} - -type UserRouteLogListRes struct { - List []UserRouteLogItem `json:"list"` -} diff --git a/biz/service/admin_user.go b/biz/service/admin_user.go index de0d04f..b0a3f40 100644 --- a/biz/service/admin_user.go +++ b/biz/service/admin_user.go @@ -1,18 +1,5 @@ package service -import ( - "context" - - "github.com/gogf/gf/v2/database/gdb" - - "36wisdom/biz/dao" -) - type adminUser struct{} var AdminUser = &adminUser{} - -// GetByUsername 按用户名取管理员账号(含密码列,登录校验用)。 -func (s *adminUser) GetByUsername(ctx context.Context, username string) (gdb.Record, error) { - return dao.AdminUser.Model().Ctx(ctx).Where("username", username).One() -} diff --git a/biz/service/chapter_review.go b/biz/service/chapter_review.go index 5c60cf2..7f9c4c0 100644 --- a/biz/service/chapter_review.go +++ b/biz/service/chapter_review.go @@ -3,7 +3,6 @@ package service import ( "context" - "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" "36wisdom/biz/dao" @@ -13,11 +12,6 @@ type chapterReview struct{} var ChapterReview = &chapterReview{} -// ListByChild 孩子温故记录。 -func (s *chapterReview) ListByChild(ctx context.Context, childId int64) ([]gdb.Record, error) { - return dao.ChapterReview.Model().Ctx(ctx).Where("child_id", childId).Order("id DESC").All() -} - // Insert 创建温故记录。 func (s *chapterReview) Insert(ctx context.Context, childId, strategyId int64, levelIds string) (int64, error) { return dao.ChapterReview.InsertAndReturnId(ctx, g.Map{ diff --git a/common/auth/auth.go b/common/auth/auth.go index e007123..5431832 100644 --- a/common/auth/auth.go +++ b/common/auth/auth.go @@ -79,11 +79,3 @@ func GetUid(ctx context.Context) int64 { } return 0 } - -// GetRole 从请求上下文取登录角色。 -func GetRole(ctx context.Context) string { - if req := g.RequestFromCtx(ctx); req != nil { - return gconv.String(req.GetCtxVar(string(keyRole)).Val()) - } - return "" -} diff --git a/common/base_dao.go b/common/base_dao.go index 5cf33f3..6976f17 100644 --- a/common/base_dao.go +++ b/common/base_dao.go @@ -30,8 +30,3 @@ func (d *BaseDao) UpdateByPk(ctx context.Context, pk int64, data g.Map) error { _, err := d.Model().Ctx(ctx).WherePri(pk).Data(data).Update() return err } - -func (d *BaseDao) DeleteByPk(ctx context.Context, pk int64) error { - _, err := d.Model().Ctx(ctx).WherePri(pk).Delete() - return err -} diff --git a/docs/superpowers/plans/2026-08-13-m1-core.md b/docs/superpowers/plans/2026-08-13-m1-core.md deleted file mode 100644 index e2a94cc..0000000 --- a/docs/superpowers/plans/2026-08-13-m1-core.md +++ /dev/null @@ -1,786 +0,0 @@ -# M1 核心闯关实现计划(三十六计小课堂) - -> **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:** 完成 M1:Go+GoFrame 后端(19 张表、种子数据、家长/孩子体系、内容接口、分支闯关判分结算)+ uni-app 前端骨架,H5 端走通「家长注册 → 建孩子档案 → 计策学堂 → 分支闯关 → 完美解锁」全流程。 - -**Architecture:** 严格按 CLAUDE.md 分层 controller → service → dao;路由由 dto `g.Meta` 声明;种子数据 `go:embed` JSON 启动导入;判分/结算核心逻辑在 service 层纯函数化以便单测。 - -**Tech Stack:** Go 1.22+ / GoFrame v2 / SQLite(glebarez/sqlite 或 go-sqlite3)/ uni-app Vue3(H5 先行)。 - -**Spec 依据:** `技术设计.md`(DDL 与规则权威来源)、`README.md`(API 清单)。 - ---- - -## 文件结构(M1 创建) - -``` -go.mod, main.go, config.yml -common/base_dao.go, common/auth/auth.go -biz/consts/table_name.go, biz/consts/status.go, biz/consts/consts.go -biz/model/entity/(19 张表,每表一文件) -biz/model/dto/(parent.go, child.go, strategy.go, level.go, points.go, prize.go, common.go) -biz/dao/(19 个 dao 文件 + base 建表) -biz/service/seed/seed.go, seed/seed_36_ji.json(embed) -biz/service/(auth.go, parent.go, child.go, strategy.go, level.go, points.go, level_play.go) -biz/controller/(router.go, parent.go, child.go, strategy.go, level.go, points.go, prize.go) -ui-src/(uni-app Vue3 骨架:pages.json, manifest.json, main.js, App.vue, pages/, api/) -data/, workspace/(运行时,gitignore) -``` - -19 张表:parent, child, strategy, level, scene_node, node_option, element, user_progress, chapter_review, user_route_log, user_collection, badge, user_badge, point_log, prize, redemption, admin_user, life_task, life_task_log。字段定义以技术设计.md 第 3 节 DDL 为准,本计划不重复。 - ---- - -### Task 1: Go 工程骨架 - -**Files:** -- Create: `go.mod`, `config.yml`, `main.go`, `common/base_dao.go`, `.gitignore`(更新), `data/.gitkeep`, `workspace/.gitkeep` - -- [ ] **Step 1: go.mod + 依赖** - -```bash -go mod init 36wisdom -go get github.com/gogf/gf/v2@latest -go get github.com/glebarez/sqlite@latest -``` - -- [ ] **Step 2: config.yml** - -```yaml -server: - address: ":8080" - openapiPath: "/api.json" - swaggerPath: "/swagger" -logger: - level: "info" -database: - type: "sqlite" - link: "data/36wisdom.db" - cache: - ttl: 300 - maxSize: 10000 -pool: - defaultSize: 10 -``` - -- [ ] **Step 3: common/base_dao.go**(DAO 基类,所有 dao 嵌入) - -```go -package common - -import ( - "context" - "github.com/gogf/gf/v2/database/gdb" - "github.com/gogf/gf/v2/frame/g" -) - -type BaseDao struct{ Table string } - -func (d *BaseDao) Model() *gdb.Model { return g.DB().Model(d.Table) } - -func (d *BaseDao) InsertAndReturnId(ctx context.Context, data gdb.Record) (int64, error) { - res, err := d.Model().Ctx(ctx).Data(data).Insert() - if err != nil { - return 0, err - } - return res.LastInsertId() -} - -func (d *BaseDao) GetOneByPk(ctx context.Context, pk int64) (gdb.Record, error) { - return d.Model().Ctx(ctx).WherePri(pk).One() -} - -func (d *BaseDao) UpdateByPk(ctx context.Context, pk int64, data gdb.Record) error { - _, err := d.Model().Ctx(ctx).WherePri(pk).Data(data).Update() - return err -} - -func (d *BaseDao) DeleteByPk(ctx context.Context, pk int64) error { - _, err := d.Model().Ctx(ctx).WherePri(pk).Delete() - return err -} -``` - -- [ ] **Step 4: main.go**(GoFrame 引导 + SQLite 初始化 + 数据目录) - -```go -package main - -import ( - "context" - _ "github.com/glebarez/sqlite" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gctx" - "os" - "36wisdom/biz/controller" -) - -func main() { - var ctx context.Context = gctx.New() - for _, dir := range []string{"data", "workspace"} { - if err := os.MkdirAll(dir, 0o755); err != nil { - g.Log().Fatal(ctx, err) - } - } - s := g.Server() - controller.Register(s) - s.Run() -} -``` - -- [ ] **Step 5: 更新 .gitignore** 追加 `data/`、`workspace/`、`ui-src/dist/`、`admin-src/dist/` - -- [ ] **Step 6: 编译验证** - -```bash -go build ./... -``` - -Expected: 无输出(成功)。 - -- [ ] **Step 7: Commit** - -```bash -git add go.mod go.sum config.yml main.go common/ .gitignore data/.gitkeep workspace/.gitkeep -git commit -m "feat: go 工程骨架(GoFrame + SQLite 引导)" -``` - ---- - -### Task 2: 常量集中(biz/consts) - -**Files:** -- Create: `biz/consts/table_name.go`, `biz/consts/status.go`, `biz/consts/consts.go` - -- [ ] **Step 1: table_name.go**(19 张表名常量,含注释用途) - -```go -package consts - -const ( - TableParent = "parent" - TableChild = "child" - TableStrategy = "strategy" - TableLevel = "level" - TableSceneNode = "scene_node" - TableNodeOption = "node_option" - TableElement = "element" - TableUserProgress = "user_progress" - TableChapterReview = "chapter_review" - TableUserRouteLog = "user_route_log" - TableUserCollection = "user_collection" - TableBadge = "badge" - TableUserBadge = "user_badge" - TablePointLog = "point_log" - TablePrize = "prize" - TableRedemption = "redemption" - TableAdminUser = "admin_user" - TableLifeTask = "life_task" - TableLifeTaskLog = "life_task_log" -) -``` - -- [ ] **Step 2: status.go**(状态常量:内容状态 / 节点 / 终局评级 / 互动形态 / 徽章条件 / 积分原因 / 兑换状态 / 任务状态) - -```go -package consts - -// 内容通用状态 -const ( - StatusEnabled = 1 - StatusDisabled = 0 -) - -// 节点类型 -const ( - NodeDecision = 1 - NodeFinal = 2 -) - -// 终局评级 -const ( - ResultNone = 0 - ResultFail = 1 - ResultGood = 2 - ResultBest = 3 -) - -// 互动形态 -const ( - InteractionOption = 1 // 选项选择 - InteractionProp = 2 // 道具选择 - InteractionOrder = 3 // 步骤排序 - InteractionAction = 4 // 动作过关(跳跃/攀爬/躲避/奔跑,config 配子模式) - InteractionDrag = 5 // 拖拽放置 - InteractionCatch = 6 // 接取收集 - InteractionFind = 7 // 找线索 - InteractionConnect = 8 // 连线配对 -) - -// 徽章条件类型 -const ( - BadgeCollect = 1 // 集卡数 - BadgeSignIn = 2 // 连续签到 - BadgeClear = 3 // 通关数 - BadgePoints = 4 // 积分 - BadgePerfect = 5 // 完美关卡数 - BadgeReview = 6 // 复习完成数 -) - -// 积分原因 -const ( - ReasonLevel = 1 // 闯关结算 - ReasonSignIn = 2 // 每日签到 - ReasonRedeem = 3 // 兑换扣减 - ReasonAdmin = 4 // 管理调整 - ReasonReview = 5 // 章末温故 - ReasonSummary = 6 // 智慧总结 - ReasonTask = 7 // 生活任务 -) - -// 兑换状态 -const ( - RedeemPending = 1 // 待领取(虚拟) - RedeemWaiting = 2 // 待发货(实物) - RedeemShipped = 3 // 已发货 - RedeemCanceled = 4 // 已取消 - RedeemReceived = 5 // 已领取(家长确认) -) - -// 生活任务状态 -const ( - TaskIssued = 1 // 已下发 - TaskConfirming = 2 // 待家长确认 - TaskConfirmed = 3 // 已确认 -) - -// 年龄段档位 -const ( - AgeGroup4_6 = "4-6" - AgeGroup6_8 = "6-8" -) -``` - -- [ ] **Step 3: consts.go**(默认参数:教训分、积分档位、温故关卡数) - -```go -package consts - -const ( - PointsBest = 30 - PointsGood = 10 - PointsFail = -10 - PointsPerfect = 20 - PointsReview = 10 - PointsSummary = 5 - PointsSignIn = 5 - PointsTask = 20 - - FailNoDeductAfter = 2 // 同一关连续失败 N 次后不再扣分 - ReviewLevelCount = 3 // 章末温故抽取计谋数(2-3,取 3 且不超过已学) -) -``` - -- [ ] **Step 4: 编译 + 提交** - -```bash -go build ./... && git add biz/consts/ && git commit -m "feat: 常量集中(表名/状态/默认参数)" -``` - ---- - -### Task 3: entity(19 张表映射) - -**Files:** -- Create: `biz/model/entity/*.go`(19 文件,命名 `entity_parent.go` 等,按表名) - -- [ ] **Step 1: 每表一个 entity 文件**。模式(以 parent 为例): - -```go -package entity - -import "github.com/gogf/gf/v2/os/gtime" - -type Parent struct { - Id int64 `json:"id" orm:"id"` - Openid string `json:"openid" orm:"openid"` - Phone string `json:"phone" orm:"phone"` - Password string `json:"-" orm:"password"` - Nickname string `json:"nickname" orm:"nickname"` - Avatar string `json:"avatar" orm:"avatar"` - Status int `json:"status" orm:"status"` - CreatedAt *gtime.Time `json:"created_at" orm:"created_at"` - UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at"` -} -``` - -字段列表严格对应技术设计.md 第 3 节 DDL(含 2026-08-13 迭代新增字段:strategy.teach_content/summary_*、child.daily_limit_minutes、level.content_version、scene_node.interaction_type/config、node_option.prop_id、user_progress.perfect、redemption 状态 5)。password 字段 json 序列化排除。 - -- [ ] **Step 2: 编译** - -```bash -go build ./... -``` - -- [ ] **Step 3: 提交** - -```bash -git add biz/model/entity/ && git commit -m "feat: 19 张表 entity 映射" -``` - ---- - -### Task 4: DAO 层 + 建表(19 个 dao 文件) - -**Files:** -- Create: `biz/dao/*.go`(19 文件,命名 `dao_parent.go` 等) - -- [ ] **Step 1: 每表一个 dao 文件**,模式(以 parent 为例): - -```go -package dao - -import ( - "context" - "36wisdom/biz/consts" - "36wisdom/common" - "github.com/gogf/gf/v2/database/gdb" - "github.com/gogf/gf/v2/frame/g" -) - -type parentDao struct{ common.BaseDao } - -var Parent = &parentDao{BaseDao: common.BaseDao{Table: consts.TableParent}} - -func (d *parentDao) Init(ctx context.Context) { - _, err := d.Model().Ctx(ctx).Fields(`...`).Create() - ... -} -``` - -- [ ] **Step 2: dao 初始化统一入口 `biz/dao/init.go`**:`func InitTables(ctx)` 依序调用 19 个 dao 的建表(`CREATE TABLE IF NOT EXISTS`,DDL 与索引照技术设计.md 第 3 节),strategy/level/scene_node/node_option/element 建表后执行无环校验(拓扑检测 scene_node 图,有环则日志 Fatal)。 - -- [ ] **Step 3: 启动接线**:main.go 的 g.Run 前调用 `dao.InitTables(ctx)`(用 `s.BindHandler("GET /ping", ...)` 先验证服务可用)。 - -- [ ] **Step 4: 编译 + 冒烟启动** - -```bash -go build ./... && go run main.go & -curl -s http://localhost:8080/ping -``` - -Expected: `pong`;`data/36wisdom.db` 生成。 - -- [ ] **Step 5: 提交** - -```bash -git add biz/dao/ && git commit -m "feat: 19 张表 DAO 与建表初始化" -``` - ---- - -### Task 5: 种子数据(元素库 + 36 计决策树 + 奖品 + 徽章 + 管理员) - -**Files:** -- Create: `biz/service/seed/seed.go`, `biz/service/seed/seed_36_ji.json` -- Modify: `main.go`(启动调用 EnsureSeeded) - -- [ ] **Step 1: seed_36_ji.json 结构**(go:embed,数组式按依赖序:elements → strategies → levels → nodes → options → prizes → badges → admin) - -```json -{ - "elements": [ - {"e_type": 1, "name": "操场", "description": "孩子们游戏的地方"}, - {"e_type": 2, "name": "小明", "description": "爱踢足球的小男孩"}, - {"e_type": 3, "name": "足球", "description": "圆圆的皮球"} - ], - "strategies": [ - { - "name": "声东击西", - "pinyin": "shēng dōng jī xī", - "group_no": 1, "group_name": "胜战计", - "meaning": "假装要往东边去,其实是要往西边", - "teach_content": "小明想从右边突破,但他先假装往左边跑……", - "summary_q": "哪个说法总结了「声东击西」?", - "summary_options": "[{\"text\":\"假装往东其实往西\",\"correct\":true},{\"text\":\"一直往东跑\"},{\"text\":\"站在中间不动\"}]", - "sort_order": 1, - "unlock_before": null, - "levels": [ - { - "title": "足球场上的假动作", "scene_name": "操场", "age_group": "4-6", - "scene_content": "比赛最后一分钟,小明带着球冲向球门……", - "nodes": [ - {"title": "被挡住了", "character_name": "小明", "content": "大个子队员挡在面前,硬冲会被抢走球,怎么办?", - "interaction_type": 1, "is_entry": 1, "options": [ - {"text": "假装向右跑,骗他转身", "prop_name": "足球", "next_index": 1, "feedback": "好主意!他向右追,你就从左边冲过去"}, - {"text": "硬冲过去", "next_index": 2, "feedback": "硬冲被拦住了,球丢了……"} - ]}, - {"content": "成功了!趁防守转身,你从左边突破射门!", "node_type": 2, "result_type": 3}, - {"content": "球被抢走了……再试一次,换种办法!", "node_type": 2, "result_type": 1} - ] - } - ] - } - ], - "prizes": [ - {"name": "小军师徽章", "p_type": 1, "points_cost": 50, "stock": -1}, - {"name": "计策卡册", "p_type": 2, "points_cost": 200, "stock": 10} - ], - "badges": [ - {"name": "初出茅庐", "cond_type": 3, "cond_value": 1}, - {"name": "小将军", "cond_type": 3, "cond_value": 10} - ], - "admin": {"username": "admin", "password": "admin123"} -} -``` - -节点引用用 `next_index`(数组下标)+ 后台导入时解析为 next_node_id;`node_type` 缺省=1 决策节点;元素通过 `scene_name`/`character_name`/`prop_name` 引用元素库。 - -- [ ] **Step 2: 内容创作要求**:M1 必须内置**全部 36 计**(胜战计/敌战计/攻战计/混战计/并战计/败战计各 6 计),每计至少 1 个情境关卡、每关 5-8 节点(≥2 个决策节点、≥2 个终局,至少含 1 个最佳终局与 1 个非最佳终局),现代场景(操场/超市/公园/家庭/教室/游乐场…),内容遵循技术设计.md「决策树骨架」范式。剩余章节的补充情境(每计 2-3 关)M2 由后台/种子迭代补入。 - -- [ ] **Step 3: seed.go 实现 EnsureSeeded(ctx)**:查 strategy 表非空即跳过;空则事务内按依赖序插入(元素先入库取 id,按 name 索引;节点 `next_index` 转为 `next_node_id`);管理员密码 bcrypt 哈希;全程包事务,失败回滚并日志 Fatal。 - -- [ ] **Step 4: main.go 接线**:`dao.InitTables(ctx)` 后调用 `seed.EnsureSeeded(ctx)`。 - -- [ ] **Step 5: 冒烟验证** - -```bash -rm -f data/36wisdom.db && go run main.go & -sqlite3 data/36wisdom.db "SELECT count(*) FROM strategy; SELECT count(*) FROM scene_node;" -``` - -Expected: `36`、节点总数 ≥180。 - -- [ ] **Step 6: 提交** - -```bash -git add biz/service/seed/ main.go && git commit -m "feat: 种子数据(36 计决策树/元素库/奖品/徽章/管理员)" -``` - ---- - -### Task 6: 鉴权中间件(家长 token) - -**Files:** -- Create: `common/auth/auth.go`, `biz/service/auth.go`, `biz/controller/router.go`(骨架) -- Modify: `main.go` - -- [ ] **Step 1: token 生成与校验**(自签 HS256 JWT,密钥在 config.yml `auth.secret`;依赖 `github.com/golang-jwt/jwt/v5`) - -```go -// common/auth/auth.go -package auth - -func GenerateToken(secret string, uid int64, role string, expireSeconds int) (string, error) -func ParseToken(secret, tokenString string) (uid int64, role string, err error) -``` - -- [ ] **Step 2: 中间件**:`common/auth.Middleware(secret)` 返回 gin 风格 HandlerFunc;从 `Authorization: Bearer ` 解析,注入 gctx 键 `authUid`/`authRole`;role=admin 的接口额外校验 role。config.yml 加 `auth: { secret: "...", expire: 604800 }`。 - -- [ ] **Step 3: router.go** 注册分组:`/api`(家长鉴权组)、`/api/admin`(admin 鉴权组,M1 先注册登录接口骨架)、公开 `POST /api/parent/register` `POST /api/parent/login`。GoFrame v2 中控制器注册用 `s.Group("/api", middleware, func(g *ghttp.RouterGroup){ g.Bind(controller.Parent) })`。 - -- [ ] **Step 4: 编译 + 提交** - -```bash -go build ./... && git add common/auth/ biz/service/auth.go biz/controller/ config.yml main.go && git commit -m "feat: 家长 token 鉴权中间件与路由分组" -``` - ---- - -### Task 7: 家长/孩子接口 - -**Files:** -- Create: `biz/model/dto/parent.go`, `biz/model/dto/child.go`, `biz/service/parent.go`, `biz/service/child.go`, `biz/controller/parent.go`, `biz/controller/child.go` - -- [ ] **Step 1: dto**(g.Meta 声明路由,v tag 校验): - -```go -type RegisterReq struct { - g.Meta `path:"/api/parent/register" method:"post" summary:"家长注册"` - Phone string `v:"required|length:11,11|regex:^1[3-9][0-9]{9}$"` - Password string `v:"required|length:6,32"` - Nickname string `v:"required|length:1,32"` -} -type RegisterRes struct{ Token string; ParentId int64 } - -type LoginReq struct { - g.Meta `path:"/api/parent/login" method:"post"` - Phone string `v:"required"` - Password string `v:"required"` -} -type LoginRes struct{ Token string; ParentId int64 } - -type ChildCreateReq struct { - g.Meta `path:"/api/parent/child/create" method:"post"` - Nickname string `v:"required|length:1,32"` - AgeGroup string `v:"required|in:4-6,6-8"` -} -type ChildCreateRes struct{ ChildId int64 } - -type ChildUpdateReq struct { - g.Meta `path:"/api/parent/child/update" method:"post"` - ChildId int64 `v:"required"` - Nickname string `v:"length:1,32"` - AgeGroup string `v:"in:4-6,6-8"` - DailyLimitMinutes int `v:"min:0|max:600"` -} - -type ChildListReq struct { - g.Meta `path:"/api/parent/child/list" method:"get"` -} -type ChildListItem struct { - ChildId int64; Nickname string; AgeGroup string; Avatar string - Points int; Level int; DailyLimitMinutes int -} -type ChildListRes struct{ List []ChildListItem } -``` - -- [ ] **Step 2: service**:Register(查重手机号 → 插入 → 生成 token)、Login(校验 bcrypt → token)、ChildCreate(校验 parent 归属自动带 parent_id)、ChildUpdate(校验归属 + 必填项非空校验)、ChildList(含 points)。积分、等级派生(完美关卡数 → 称号映射函数放 `biz/service/level.go`)。 - -- [ ] **Step 3: controller**:方法签名 `(ctx, *dto.XxxReq) (*dto.XxxRes, error)`,从 ctx 取 authUid 传给 service。 - -- [ ] **Step 4: 冒烟测试** - -```bash -curl -s -X POST localhost:8080/api/parent/register -H 'Content-Type: application/json' -d '{"phone":"13800000000","password":"pass123456","nickname":"爸爸"}' -curl -s -X POST localhost:8080/api/parent/child/create -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"nickname":"小宝","age_group":"4-6"}' -``` - -Expected: token 与 child_id 正常返回;错误参数返回 400 校验错误。 - -- [ ] **Step 5: 提交** - -```bash -git add biz/model/dto/ biz/service/parent.go biz/service/child.go biz/service/level.go biz/controller/ && git commit -m "feat: 家长注册登录与孩子档案接口" -``` - ---- - -### Task 8: 内容接口(strategy / level + 缓存) - -**Files:** -- Create: `biz/model/dto/strategy.go`, `biz/model/dto/level.go`, `biz/service/strategy.go`, `biz/service/level.go`, `biz/controller/strategy.go`, `biz/controller/level.go` - -- [ ] **Step 1: dto** - -```go -type StrategyListReq struct { g.Meta `path:"/api/strategy/list" method:"get"` } -type StrategyItem struct { - StrategyId int64; Name string; Pinyin string; GroupNo int; GroupName string - Meaning string; Icon string; SortOrder int - Stars int // 本计总星数 - PerfectCount int // 已完美关卡数 - TotalLevels int - Unlocked bool; UnlockReason string -} -type StrategyListRes struct{ List []StrategyItem } - -type StrategyDetailReq struct { g.Meta `path:"/api/strategy/detail" method:"get"`; StrategyId int64 `v:"required"` } -type LevelBrief struct { LevelId int64; Title string; AgeGroup string; SceneName string; Stars int; Perfect bool; Unlocked bool; ContentVersion int; ProgressVersion int } -type StrategyDetailRes struct { - StrategyId int64; Name string; Pinyin string; Meaning string; GroupName string - TeachContent string; TeachImage string; TeachAudio string - SummaryQ string; SummaryOptions string - Levels []LevelBrief -} - -type LevelDetailReq struct { g.Meta `path:"/api/level/detail" method:"get"`; LevelId int64 `v:"required"` } -type ElementVO struct { Id int64; EType int; Name string; Image string; Audio string; Description string } -type OptionVO struct { OptionId int64; Text string; Prop *ElementVO } -type NodeVO struct { - NodeId int64; Title string; Content string; Image string; Audio string - Character *ElementVO; InteractionType int; Config string - NodeType int; ResultType int - Options []OptionVO -} -type LevelDetailRes struct { - LevelId int64; Title string; Scene *ElementVO; SceneContent string; SceneImage string; SceneAudio string - Entry NodeVO; TotalFinals int; Perfect bool; Stars int -} -``` - -- [ ] **Step 2: service 聚合规则**(严格单表 SQL + 内存组装,无 JOIN): -- strategy/list:strategy 全量(缓存,TTL 300s)+ 本用户 user_progress 按 level IN 取星(≤100 分批)→ 按 strategy 分组 → 解锁判断(unlock_before 为空 或 前置计完美关卡数=前置计关卡总数);content_version 不一致的关卡标记"可重新挑战" -- level/detail:level + scene_node + node_option + element 分表取回内存组装成树(节点拓扑排序按 sort_order),校验入口唯一;返回入口节点 + 终局总数(result_type>0 的节点数)+ 用户 perfect/stars -- **缓存**:strategy/level 内容缓存(gdb.CacheOption 或 gcache),**写操作(后台)后清对应缓存**——M1 后台未建,种子导入后清一次 -- 年龄段过滤:`4-6` 孩子只看 age_group='4-6' 关卡 - -- [ ] **Step 3: controller** 组装 DTO(entity → DTO 映射在 controller)。 - -- [ ] **Step 4: 冒烟** - -```bash -curl -s "localhost:8080/api/strategy/list?child_id=1" -H "Authorization: Bearer $TOKEN" -curl -s "localhost:8080/api/level/detail?level_id=1" -H "Authorization: Bearer $TOKEN" -``` - -Expected: 36 计列表(首计 unlocked=true,其余 false);关卡返回入口节点与选项。 - -- [ ] **Step 5: 提交** - -```bash -git add biz/model/dto/strategy.go biz/model/dto/level.go biz/service/strategy.go biz/service/level.go biz/controller/strategy.go biz/controller/level.go && git commit -m "feat: 内容接口(计策/关卡/元素,含缓存与解锁判定)" -``` - ---- - -### Task 9: 闯关判分与结算(核心) - -**Files:** -- Create: `biz/service/level_play.go`, `biz/controller/level_play.go`, `biz/model/dto/level_play.go`, `biz/service/level_play_test.go` - -- [ ] **Step 1: dto** - -```go -type ChooseReq struct { - g.Meta `path:"/api/level/choose" method:"post"` - LevelId int64 `v:"required"` - NodeId int64 `v:"required"` - OptionId int64 `v:"required"` -} -type ChooseRes struct { - Next *NodeVO // 下一节点(决策节点) - Final *FinalSettle // 终局结算(终局节点) -} -type FinalSettle struct { - ResultType int; Stars int; ScoreDelta int - Cleared bool // 本关是否首次通关(到最佳终局) - Perfect bool // 是否达成完美 - UnlockNext bool // 是否解锁下一关 - CollectionUnlocked bool // 计策卡解锁 - NewLevel int // 成长等级(升级时非 0) - BalanceAfter int -} -``` - -- [ ] **Step 2: service 核心逻辑(纯函数可测)**: - -```go -// SettleFinal 纯函数:给定历史状态返回结算结果(便于单测) -type SettleState struct { - LevelCleared bool // 该关已通关(到过最佳终局) - FailStreak int // 连续失败次数 - ReachedFinals map[int]bool // 已到达终局 result_type 集合 - TotalFinals int - BalanceAfter int -} -func SettleFinal(s SettleState, resultType int) FinalSettle -``` - -规则(技术设计 4.3/4.4): -- 首次到达终局才结算:`ReachedFinals` 含该 result_type 则 delta=0 -- 未通关:Best +30 / Good +10 / Fail -10(连续失败 ≥2 次后不再扣,且余额不为负) -- 已通关后补分支:delta=0(只记完成度) -- 最佳终局 → Cleared=true;全部 result_type 到达 → Perfect=true(解锁下一关 +20) -- 计策卡:本计所有关卡 Perfect 后解锁 -- 成长等级:完美关卡数 → 等级(biz/service/level.go 映射) - -- [ ] **Step 3: service 流程(Choose)**: -1. 校验:level 存在、node 属于 level、option 属于 node、关卡已解锁(按解锁链递归校验) -2. 取 next_node:决策节点 → 返回;终局节点 → 记 user_route_log(node/option/result_type) -3. 终局结算(事务):结算判定(按上面规则)→ 写 user_progress(取最高星 + perfect + content_version)→ 写 point_log + child.points(delta≠0 时)→ 完美解锁下一关(strategy 内下一关 unlocked 由"前置完美"推导,无需改表)→ 计策卡(user_collection)→ 清用户缓存 -4. 并发防重入:`common.WithLock(ctx, "child:"+childId+":level:"+levelId, 10s, ...)` -5. 返回 ChooseRes - -- [ ] **Step 4: 单元测试**(`level_play_test.go`,覆盖结算规则矩阵): - -```go -func TestSettleFinal(t *testing.T) { - // 未通关首次最佳 → +30 Cleared - // 未通关首次良好 → +10 - // 未通关首次失败 → -10 - // 连续失败第 3 次 → 0 - // 余额不足 → 扣到 0 - // 已通关补分支 → 0 - // 全部终局到达 → Perfect -} -``` - -- [ ] **Step 5: 运行测试** - -```bash -go test ./biz/service/ -run TestSettleFinal -v -``` - -Expected: PASS。 - -- [ ] **Step 6: 冒烟**(走通一关:首计第一关按最佳路线选 → 得 3 星 +30 分) - -```bash -curl -s "localhost:8080/api/level/detail?level_id=1" -H "Authorization: Bearer $TOKEN" -# 沿入口节点选项选 next_index 为最佳终局的路径逐次 choose -curl -s -X POST localhost:8080/api/level/choose -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"level_id":1,"node_id":N,"option_id":M}' -``` - -Expected: 终局返回 Stars=3、ScoreDelta=30。 - -- [ ] **Step 7: 提交** - -```bash -git add biz/service/level_play.go biz/service/level_play_test.go biz/controller/level_play.go biz/model/dto/level_play.go && git commit -m "feat: 分支闯关判分与终局结算(含单测)" -``` - ---- - -### Task 10: 统一验证 - -- [ ] **Step 1: 全量编译 + 测试** - -```bash -go build ./... && go vet ./... && go test ./... -``` - -- [ ] **Step 2: 冒烟完整流程**(脚本化 curl):注册 → 建孩子 → 列表 → 首计学堂 → 闯关(最佳路线 + 失败路线 + 补分支到完美)→ 解锁第二计 → 校验积分流水。 - -- [ ] **Step 3: 提交收尾** - -```bash -git add -A && git commit -m "chore: M1 后端验证通过" -``` - ---- - -### Task 11: uni-app 前端骨架(H5 先行) - -**Files:** -- Create: `ui-src/`(package.json, vite.config.js, index.html, src/main.js, src/App.vue, src/pages.json, src/manifest.json, src/pages/login/login.vue, src/pages/children/children.vue, src/pages/map/map.vue, src/pages/play/play.vue, src/pages/result/result.vue, src/api/request.js, src/api/index.js, src/store/user.js) - -- [ ] **Step 1: 工程初始化**(最小 uni-app Vue3 Vite 工程,H5 目标): - -```bash -cd ui-src && npm init -y && npm install vue@3 @dcloudio/uni-app@latest @dcloudio/uni-h5@latest vite@5 @vitejs/plugin-vue@latest -``` - -- [ ] **Step 2: 基础配置**:`pages.json` 注册 5 个页面(login → children → map → play → result);`manifest.json`(appid 占位、h5 配置);vite.config.js 代理 `/api` → `http://localhost:8080`。 - -- [ ] **Step 3: api/request.js**:uni.request 封装(baseURL `/api`、token 注入 `Authorization: Bearer`、401 跳登录、统一错误 toast)。 - -- [ ] **Step 4: 页面**(M1 交互可用、样式从简但卡通化配色): -- login:手机号 + 密码 + 昵称注册 / 登录 -- children:孩子列表 + 新建孩子(年龄段选择) -- map:strategy/list 渲染六组地图网格,计策卡图标 + 星星 + 锁定态;点解锁章节 → 学堂弹层(strategy/detail 的 teach_content + 音频播放按钮)→ 进入 play -- play:level/detail 渲染入口节点 → 选项列表(选择类互动;M1 只实现 interaction_type=1 选项选择,其余类型显示"敬请期待"占位并禁用)→ 点击选项调 choose → 展示 feedback → 下一节点;终局时展示结算(星/分/完美/解锁动画)→ result -- result:结算页(星数动画、积分变化、计策卡获得、下一关解锁提示) - -- [ ] **Step 5: H5 端联调** - -```bash -cd ui-src && npm run dev:h5 -``` - -浏览器走通:注册 → 建孩子 → 地图 → 学堂 → 闯关(最佳 + 补分支到完美)→ 解锁第二计。 - -- [ ] **Step 6: 提交** - -```bash -cd .. && git add ui-src/ && git commit -m "feat: uni-app 前端骨架(登录/孩子/地图/闯关/结算)" -``` - ---- - -## Self-Review 记录 - -- **Spec 覆盖**:技术设计 3 节 19 表 ✓(Task 3/4)、4.1 种子 ✓(Task 5)、4.3 学习闭环/判分/完美/温故——M1 覆盖判分与完美(温故与总结在 M2)✓、4.8 鉴权 ✓(Task 6)、API 清单 M1 范围 ✓。M2 内容(温故/总结/任务/徽章/签到/兑换/后台)不在本计划,属 M2 计划。 -- **占位符扫描**:seed JSON 给出完整结构与示例(声东击西整树),其余 35 计在实现时按同范式创作(内容创作任务,结构已定义)。 -- **类型一致性**:`FinalSettle`/`SettleFinal`/`NodeVO`/`OptionVO` 在 Task 8/9 间一致;积分常量与 settle 规则引用 `consts.Points*` 一致。 diff --git a/docs/superpowers/plans/2026-08-13-m1_5-interactions.md b/docs/superpowers/plans/2026-08-13-m1_5-interactions.md deleted file mode 100644 index b9e8a1d..0000000 --- a/docs/superpowers/plans/2026-08-13-m1_5-interactions.md +++ /dev/null @@ -1,3372 +0,0 @@ -# M1.5 互动体验补齐 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:** 把闯关从纯文字问答升级为完整儿童互动体验:拼音写时标注入库、朗读(浏览器语音降级)、场景/人物/道具视觉素材(oMLX 离线生成 SVG→PNG 打包入客户端)、对比点评(每个选择解释好处/坏处)、5 种触控互动(道具选择/步骤排序/拖拽放置/找线索/连线配对)、结算动画与路线回顾。 - -**Architecture:** 后端拼音走"写时一次性标注"(`common/pinyin.go` + `biz/consts` 多音字词表,逐字对齐 JSON 入库,`*_pinyin` 列);互动节点走"互动入口节点"模型——每关决策树入口前程序化插入一个互动节点(is_entry=1,两个出口选项:成功→原入口 / 失败→本关首个失败终局),后端 choose/判分零改动;视觉素材与对比点评由 `cmd/genasset` 离线管线一次性生成(本机 oMLX OpenAI 兼容接口,`enable_thinking=false`,SVG 经 sharp 转 PNG 入 `ui-src/static/generated/`,点评回填 `node_option.feedback_pros/cons`,运行时零 LLM 依赖);前端按 `interaction_type` 分发渲染 5 个 touch 组件,文本用 `` 注音,无音频文件时用浏览器 `SpeechSynthesis` 朗读,反馈弹层三段式展示对比点评,结算页彩带 + 路线回顾。 - -**Tech Stack:** Go + GoFrame v2、SQLite、`github.com/mozillazg/go-pinyin@v0.21.0`(已入 go.mod)、uni-app Vue3 + Vite(H5)、CSS/SVG touch 交互(无游戏引擎)、本机 oMLX(Qwen3.5-9B-MLX-4bit,OpenAI 兼容)、sharp(devDependency,SVG→PNG)。 - -**规格依据:** 技术设计.md 4.6(拼音与朗读)、4.7(触控互动组件)、4.8(视觉素材与对比点评生成管线);已与用户确认决策:后端写时标注 / 浏览器语音降级 / 触控 5 种 / 全量 36 计 / 本机 oMLX qwen 离线生成素材 / 对比点评 pros-cons 两列。 - ---- - -## 文件结构 - -**后端(新增/修改):** -- `common/pinyin.go`(新增)— `AnnotatePinyin(text) string`:词表最长匹配 + 逐字转拼音,返回逐字对齐 JSON 数组字符串 -- `common/pinyin_test.go`(新增)— 标注单测 -- `common/migrate.go`(新增)— `EnsureColumn(ctx, table, col, sqlType)` 幂等加列 -- `biz/consts/pinyin.go`(新增)— `PinyinOverrides` 词表(36 计成语 + 种子专名) -- `biz/dao/dao_strategy.go` / `dao_level.go` / `dao_scene_node.go` / `dao_node_option.go` / `dao_element.go`(修改)— CREATE TABLE 加列 + `EnsureColumn` 迁移 -- `biz/service/seed/seed.go`(修改)— 插入时标注拼音;`EnsureSeeded` 末尾调用 `annotateAll` + `ensureInteractions` -- `biz/service/seed/annotate.go`(新增)— `annotateAll` 补标(幂等) -- `biz/service/seed/interaction.go`(新增)— `ensureInteractions` + `planInteraction`(纯函数) -- `biz/service/seed/interaction_test.go`(新增)— 互动计划单测 -- `biz/service/level.go`(修改)— Element/Option/Node/LevelDetail 加拼音字段,buildNode/elementsOf/Detail 读取 -- `biz/service/strategy.go`(修改)— StrategyDetail 加 meaning_pinyin/teach_content_pinyin -- `biz/model/dto/level.go` / `dto/level_play.go` / `dto/strategy.go`(修改)— VO 加拼音字段 + OptionVO 加 feedback_pros/cons -- `biz/controller/level.go` / `strategy.go`(修改)— VO 映射 -- `cmd/genasset/omlx.go` / `prompts.go` / `main.go`(新增)— 离线生成管线(oMLX 素材 + 对比点评) -- `config.yml`(修改)— genasset 段 - -**前端(新增/修改,全部在 `ui-src/`):** -- `src/utils/speech.js`(新增)— 朗读:文件优先(InnerAudioContext),否则浏览器 SpeechSynthesis -- `src/utils/visual.js`(新增)— 场景/道具/人物占位视觉(emoji + 渐变色) -- `src/utils/pinyin.js`(新增)— pinyin JSON 解析 + 与原文 zip -- `src/components/RubyText.vue`(新增)— `` 注音文本组件 -- `src/components/SpeakButton.vue`(新增)— 朗读按钮 -- `src/components/interactions/PropPick.vue` / `FindSpot.vue` / `DragPlace.vue` / `StepSort.vue` / `LinkMatch.vue`(新增)— 5 个触控组件,统一 emit `done({success})` -- `src/pages/play/play.vue`(修改)— 场景面板 + 互动分发 + 拼音/朗读 + 对比点评弹层 + 路线回顾累积 + 表情/道具图/动效 -- `src/pages/map/map.vue`(修改)— 学堂弹层拼音 + 朗读 -- `src/pages/children/children.vue`(修改)— 选择孩子时记录年龄段 -- `src/store/user.js`(修改)— getChildAge/setChildAge -- `src/pages/result/result.vue`(修改)— 彩带动画 + 路线回顾展示 -- `scripts/svg2png.mjs`(新增)— sharp SVG→PNG 转换(genasset 调用) -- `package.json`(修改)— devDependencies 加 sharp - ---- - -### Task 1: 拼音标注服务(common/pinyin.go + 词表 + 单测) - -**Files:** -- Create: `biz/consts/pinyin.go` -- Create: `common/pinyin.go` -- Test: `common/pinyin_test.go` - -- [ ] **Step 1: 写词表 `biz/consts/pinyin.go`**(36 计成语整体拼音 + 种子内容常用专名;标注时最长匹配优先) - -```go -package consts - -// PinyinOverrides 拼音标注词表:整词 override(成语整体、专名),标注时最长匹配优先, -// 保证教育内容多音字准确(如「声东击西」不能拆成 zhong dong ji xi)。 -var PinyinOverrides = map[string]string{ - "瞒天过海": "mán tiān guò hǎi", - "围魏救赵": "wéi wèi jiù zhào", - "借刀杀人": "jiè dāo shā rén", - "以逸待劳": "yǐ yì dài láo", - "趁火打劫": "chèn huǒ dǎ jié", - "声东击西": "shēng dōng jī xī", - "无中生有": "wú zhōng shēng yǒu", - "暗渡陈仓": "àn dù chén cāng", - "隔岸观火": "gé àn guān huǒ", - "笑里藏刀": "xiào lǐ cáng dāo", - "李代桃僵": "lǐ dài táo jiāng", - "顺手牵羊": "shùn shǒu qiān yáng", - "打草惊蛇": "dǎ cǎo jīng shé", - "借尸还魂": "jiè shī huán hún", - "调虎离山": "diào hǔ lí shān", - "欲擒故纵": "yù qín gù zòng", - "抛砖引玉": "pāo zhuān yǐn yù", - "擒贼擒王": "qín zéi qín wáng", - "釜底抽薪": "fǔ dǐ chōu xīn", - "混水摸鱼": "hún shuǐ mō yú", - "金蝉脱壳": "jīn chán tuō qiào", - "关门捉贼": "guān mén zhuō zéi", - "远交近攻": "yuǎn jiāo jìn gōng", - "假道伐虢": "jiǎ dào fá guó", - "偷梁换柱": "tōu liáng huàn zhù", - "指桑骂槐": "zhǐ sāng mà huái", - "假痴不癫": "jiǎ chī bù diān", - "上屋抽梯": "shàng wū chōu tī", - "树上开花": "shù shàng kāi huā", - "反客为主": "fǎn kè wéi zhǔ", - "美人计": "měi rén jì", - "空城计": "kōng chéng jì", - "反间计": "fǎn jiàn jì", - "苦肉计": "kǔ ròu jì", - "连环计": "lián huán jì", - "走为上计": "zǒu wéi shàng jì", - // 种子内容常用专名(扫描 seed_36_ji/*.json 的人物/场景名补充,发现多音误标就加) - "小明": "xiǎo míng", - "小虎": "xiǎo hǔ", - "朵朵": "duǒ duǒ", - "幼儿园": "yòu ér yuán", - "足球场": "zú qiú chǎng", - "游乐场": "yóu lè chǎng", - "小兔子": "xiǎo tù zi", - "大哥哥": "dà gē ge", -} -``` - -- [ ] **Step 2: 写失败单测 `common/pinyin_test.go`** - -```go -package common - -import ( - "encoding/json" - "testing" -) - -func parse(t *testing.T, s string) []string { - t.Helper() - var out []string - if err := json.Unmarshal([]byte(s), &out); err != nil { - t.Fatalf("拼音 JSON 解析失败: %v", err) - } - return out -} - -func TestAnnotatePinyin_IdiomOverride(t *testing.T) { - // 词表命中整体转拼音(带声调) - pys := parse(t, AnnotatePinyin("声东击西")) - want := []string{"shēng", "dōng", "jī", "xī"} - if len(pys) != len(want) { - t.Fatalf("长度 %d != %d: %v", len(pys), len(want), pys) - } - for i := range want { - if pys[i] != want[i] { - t.Fatalf("第 %d 字 %q != %q", i, pys[i], want[i]) - } - } -} - -func TestAnnotatePinyin_Alignment(t *testing.T) { - // 逐字对齐:标点/非汉字对应空串 - pys := parse(t, AnnotatePinyin("小明,你好!")) - want := []string{"xiǎo", "míng", "", "nǐ", "hǎo", ""} - if len(pys) != len(want) { - t.Fatalf("长度 %d != %d: %v", len(pys), len(want), pys) - } - for i := range want { - if pys[i] != want[i] { - t.Fatalf("第 %d 字 %q != %q", i, pys[i], want[i]) - } - } -} - -func TestAnnotatePinyin_NonHan(t *testing.T) { - pys := parse(t, AnnotatePinyin("123abc")) - if len(pys) != 6 { - t.Fatalf("长度应 6,实际 %d", len(pys)) - } - for i, p := range pys { - if p != "" { - t.Fatalf("第 %d 字符应空串,实际 %q", i, p) - } - } -} - -func TestAnnotatePinyin_Empty(t *testing.T) { - if AnnotatePinyin("") != "[]" { - t.Fatal("空文本应返回 []") - } -} - -func TestAnnotatePinyin_MissingDictRune(t *testing.T) { - // 词典缺失字(兙 在 CJK 范围但 go-pinyin 词典未收录)保持空串,后续字不错位 - pys := parse(t, AnnotatePinyin("兙好")) - want := []string{"", "hǎo"} - if len(pys) != len(want) { - t.Fatalf("长度 %d != %d: %v", len(pys), len(want), pys) - } - for i := range want { - if pys[i] != want[i] { - t.Fatalf("第 %d 字 %q != %q", i, pys[i], want[i]) - } - } -} -``` - -- [ ] **Step 3: 跑测试确认失败** - -Run: `go test ./common/ -run TestAnnotatePinyin -v` -Expected: FAIL(`AnnotatePinyin` 未定义) - -- [ ] **Step 4: 实现 `common/pinyin.go`** - -```go -package common - -import ( - "encoding/json" - "sort" - "strings" - "unicode" - - "github.com/mozillazg/go-pinyin" - - "36wisdom/biz/consts" -) - -// AnnotatePinyin 写时一次性标注:词表最长匹配优先(整体转拼音),未命中逐字转 -// (pinyin.Tone 带声调);返回逐字对齐的 JSON 数组字符串,非汉字对应空串, -// 下标与原文 rune 一一对应。纯函数,文本未变结果不变(幂等)。 -func AnnotatePinyin(text string) string { - runes := []rune(text) - out := make([]string, len(runes)) - if len(runes) == 0 { - return "[]" - } - - words := make([]string, 0, len(consts.PinyinOverrides)) - for w := range consts.PinyinOverrides { - words = append(words, w) - } - sort.Slice(words, func(i, j int) bool { return len(words[i]) > len(words[j]) }) - - // 词表最长匹配切分:命中片段整体标注(词表拼音音节数须与字数一致) - for i := 0; i < len(runes); { - matched := false - for _, w := range words { - n := len([]rune(w)) - if i+n > len(runes) || string(runes[i:i+n]) != w { - continue - } - pys := strings.Fields(consts.PinyinOverrides[w]) - if len(pys) != n { - continue - } - copy(out[i:i+n], pys) - i += n - matched = true - break - } - if !matched { - i++ - } - } - - // 未命中汉字逐字转拼音:词典缺失字(生僻字等)SinglePinyin 返回空串, - // 该字自然保持 "",后续字不错位 - args := pinyin.Args{Style: pinyin.Tone} - i := 0 - for _, r := range text { - if out[i] == "" && unicode.Is(unicode.Han, r) { - if pys := pinyin.SinglePinyin(r, args); len(pys) > 0 { - out[i] = pys[0] - } - } - i++ - } - - b, _ := json.Marshal(out) - return string(b) -} -``` - -- [ ] **Step 5: 跑测试确认通过** - -Run: `go test ./common/ -run TestAnnotatePinyin -v` -Expected: PASS(4 个用例全绿) - -- [ ] **Step 6: 提交** - -```bash -git add biz/consts/pinyin.go common/pinyin.go common/pinyin_test.go go.mod go.sum -git commit -m "feat: 拼音写时标注服务(词表最长匹配 + 逐字对齐 JSON)" -``` - ---- - -### Task 2: 表迁移:5 张表加 `*_pinyin` 列 - -**Files:** -- Create: `common/migrate.go` -- Modify: `biz/dao/dao_strategy.go`、`biz/dao/dao_level.go`、`biz/dao/dao_scene_node.go`、`biz/dao/dao_node_option.go`、`biz/dao/dao_element.go` - -- [ ] **Step 1: 实现幂等加列 `common/migrate.go`**(SQLite 无 DROP COLUMN,只增不改;失败仅告警不阻断启动) - -```go -package common - -import ( - "context" - "fmt" - - "github.com/gogf/gf/v2/frame/g" -) - -// EnsureColumn 幂等加列:PRAGMA 查列不存在则 ALTER TABLE ADD COLUMN。 -// 失败只记警告,不阻断启动(列缺失时后续查询只会取到空值)。 -func EnsureColumn(ctx context.Context, table, col, sqlType string) { - cols, err := g.DB().GetAll(ctx, fmt.Sprintf("PRAGMA table_info(%s)", table)) - if err != nil { - g.Log().Warningf(ctx, "migrate: %s 表结构读取失败: %v", table, err) - return - } - for _, c := range cols { - if c["name"].String() == col { - return - } - } - if _, err := g.DB().Exec(ctx, fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, col, sqlType)); err != nil { - g.Log().Warningf(ctx, "migrate: %s.%s 加列失败: %v", table, col, err) - return - } - g.Log().Infof(ctx, "migrate: %s.%s 已添加", table, col) -} -``` - -- [ ] **Step 2: 更新 `biz/dao/dao_strategy.go`**——CREATE TABLE 加 4 列,Init 末尾补 `EnsureColumn` - -修改 CREATE TABLE(在 `meaning TEXT NOT NULL` 后加一行、`teach_content TEXT` 后加一行、`summary_q TEXT` 后加一行、`summary_options TEXT` 后加一行): - -```sql - meaning TEXT NOT NULL, -- 儿童语言释义 - meaning_pinyin TEXT, -- 释义拼音(逐字对齐 JSON) - teach_content TEXT, -- 计策学堂:儿童化讲解(名称/释义/使用时机) - teach_content_pinyin TEXT, - summary_q TEXT, -- 智慧总结反思题 - summary_q_pinyin TEXT, - summary_options TEXT, -- 反思题选项 JSON(含正确答案) - summary_options_pinyin TEXT, -``` - -修改 Init 末尾(`return err` 前): - -```go - common.EnsureColumn(ctx, consts.TableStrategy, "meaning_pinyin", "TEXT") - common.EnsureColumn(ctx, consts.TableStrategy, "teach_content_pinyin", "TEXT") - common.EnsureColumn(ctx, consts.TableStrategy, "summary_q_pinyin", "TEXT") - common.EnsureColumn(ctx, consts.TableStrategy, "summary_options_pinyin", "TEXT") - return err -``` - -- [ ] **Step 3: 更新 `biz/dao/dao_level.go`**——`scene_content TEXT NOT NULL` 后加 `scene_content_pinyin TEXT`;Init 末尾加 `common.EnsureColumn(ctx, consts.TableLevel, "scene_content_pinyin", "TEXT")` - -- [ ] **Step 4: 更新 `biz/dao/dao_scene_node.go`**——`content TEXT NOT NULL` 后加 `content_pinyin TEXT`;Init 末尾加 `common.EnsureColumn(ctx, consts.TableSceneNode, "content_pinyin", "TEXT")` - -- [ ] **Step 5: 更新 `biz/dao/dao_node_option.go`**——`text TEXT NOT NULL` 后加 `text_pinyin TEXT`、`feedback TEXT NOT NULL` 后加 `feedback_pinyin TEXT`、`feedback_pros TEXT`(对比点评:好处)、`feedback_cons TEXT`(对比点评:不足/错过的更好选择);Init 末尾加四个 `common.EnsureColumn(ctx, consts.TableNodeOption, "text_pinyin", "TEXT")` / `("feedback_pinyin", "TEXT")` / `("feedback_pros", "TEXT")` / `("feedback_cons", "TEXT")` - -- [ ] **Step 6: 更新 `biz/dao/dao_element.go`**——`name TEXT NOT NULL` 后加 `name_pinyin TEXT`、`description TEXT` 后加 `description_pinyin TEXT`;Init 末尾加两个 `EnsureColumn` - -- [ ] **Step 7: 编译验证 + 迁移验证(对现有库)** - -Run: `go build ./... && go vet ./...` -Run: `go run main.go` 观察日志出现 `migrate: level.scene_content_pinyin 已添加` 等 12 行(含 node_option 的 feedback_pros / feedback_cons);`sqlite3 data/36wisdom.db "PRAGMA table_info(node_option);"` 含 `text_pinyin`、`feedback_pros`、`feedback_cons`。启动后 Ctrl-C 停掉。 - -- [ ] **Step 8: 提交** - -```bash -git add common/migrate.go biz/dao/ -git commit -m "feat: 内容表 *_pinyin 列迁移(幂等 EnsureColumn)" -``` - ---- - -### Task 3: 拼音入库:种子插入时标注 + 启动补标 - -**Files:** -- Modify: `biz/service/seed/seed.go` -- Create: `biz/service/seed/annotate.go` - -- [ ] **Step 1: 种子插入时标注(修改 `seed.go` 四处)** - -`insertElement` 的 `g.Map`(第 208-211 行)改为: - -```go - res, err := tx.Model(consts.TableElement).Ctx(ctx).Data(g.Map{ - "e_type": el.EType, "name": el.Name, "name_pinyin": common.AnnotatePinyin(el.Name), - "image": el.Image, "audio": el.Audio, - "description": el.Description, "description_pinyin": common.AnnotatePinyin(el.Description), - "status": consts.StatusEnabled, - }).Insert() -``` - -`insertStrategy` 的 `g.Map`(第 236-241 行)改为: - -```go - data := g.Map{ - "name": s.Name, "pinyin": s.Pinyin, "group_no": s.GroupNo, "group_name": s.GroupName, - "meaning": s.Meaning, "meaning_pinyin": common.AnnotatePinyin(s.Meaning), - "teach_content": s.TeachContent, "teach_content_pinyin": common.AnnotatePinyin(s.TeachContent), - "teach_image": s.TeachImage, - "teach_audio": s.TeachAudio, "summary_q": s.SummaryQ, "summary_q_pinyin": common.AnnotatePinyin(s.SummaryQ), - "summary_options": s.SummaryOptions, "summary_options_pinyin": common.AnnotatePinyin(s.SummaryOptions), - "summary_audio": s.SummaryAudio, "icon": s.Icon, "sort_order": s.SortOrder, - } -``` - -`insertLevel` 的 `g.Map`(第 278-283 行)改为(补 `scene_content_pinyin`): - -```go - res, err := tx.Model(consts.TableLevel).Ctx(ctx).Data(g.Map{ - "strategy_id": strategyId, "title": lv.Title, "scene_id": sceneId, - "scene_content": lv.SceneContent, "scene_content_pinyin": common.AnnotatePinyin(lv.SceneContent), - "scene_image": lv.SceneImage, "scene_audio": lv.SceneAudio, - "age_group": ageGroup, "content_version": 1, "sort_order": sortOrder, - "status": consts.StatusEnabled, - }).Insert() -``` - -选项插入(第 336-342 行)改为(补 `text_pinyin` / `feedback_pinyin`): - -```go - if _, err := tx.Model(consts.TableNodeOption).Ctx(ctx).Data(g.Map{ - "node_id": nodeIds[i], "text": opt.Text, "text_pinyin": common.AnnotatePinyin(opt.Text), - "prop_id": propId, - "audio": opt.Audio, "next_node_id": nextNodeId, "feedback": opt.Feedback, - "feedback_pinyin": common.AnnotatePinyin(opt.Feedback), - "feedback_audio": opt.FeedbackAudio, "sort_order": oi + 1, - "status": consts.StatusEnabled, - }).Insert(); err != nil { - return err - } -``` - -seed.go 的 import 增加 `"36wisdom/common"`。 - -- [ ] **Step 2: 写补标 `biz/service/seed/annotate.go`**(对既有库与残留空值,幂等) - -```go -package seed - -import ( - "context" - - "github.com/gogf/gf/v2/frame/g" - - "36wisdom/biz/consts" - "36wisdom/common" -) - -// annotateAll 拼音补标:对 *_pinyin 为空的记录按原文标注。 -// 启动维护一次性(约 500 行),单事务内逐行 UPDATE,失败告警不阻断。 -func annotateAll(ctx context.Context) { - type target struct{ table, pinyinCol, textCol string } - targets := []target{ - {consts.TableStrategy, "meaning_pinyin", "meaning"}, - {consts.TableStrategy, "teach_content_pinyin", "teach_content"}, - {consts.TableStrategy, "summary_q_pinyin", "summary_q"}, - {consts.TableStrategy, "summary_options_pinyin", "summary_options"}, - {consts.TableLevel, "scene_content_pinyin", "scene_content"}, - {consts.TableSceneNode, "content_pinyin", "content"}, - {consts.TableNodeOption, "text_pinyin", "text"}, - {consts.TableNodeOption, "feedback_pinyin", "feedback"}, - {consts.TableElement, "name_pinyin", "name"}, - {consts.TableElement, "description_pinyin", "description"}, - } - if err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - for _, t := range targets { - recs, err := tx.Model(t.table).Ctx(ctx).Where(t.pinyinCol, "").Fields("id," + t.textCol).All() - if err != nil { - return err - } - for _, rec := range recs { - if _, err := tx.Model(t.table).Ctx(ctx).WherePri(rec["id"].Int64()). - Data(g.Map{t.pinyinCol: common.AnnotatePinyin(rec[t.textCol].String())}).Update(); err != nil { - return err - } - } - } - return nil - }); err != nil { - g.Log().Warningf(ctx, "seed: 拼音补标失败: %v", err) - return - } - g.Log().Info(ctx, "seed: 拼音补标完成") -} -``` - -注意:需要 import `"github.com/gogf/gf/v2/database/gdb"`。 - -- [ ] **Step 3: `EnsureSeeded` 末尾追加补标与互动节点调用(互动节点 Task 4 实现,先留注释占位)** - -修改 `EnsureSeeded`(`seed.go` 第 118-131 行)为: - -```go -func EnsureSeeded(ctx context.Context) { - count, err := dao.Strategy.Model().Ctx(ctx).Count() - if err != nil { - g.Log().Fatal(ctx, err) - } - if count > 0 { - g.Log().Info(ctx, "seed: 数据已存在,跳过种子导入") - } else { - if err := doSeed(ctx); err != nil { - g.Log().Fatal(ctx, err) - } - g.Log().Info(ctx, "seed: 种子数据导入完成") - } - annotateAll(ctx) - // ensureInteractions(ctx) // Task 4 打开 -} -``` - -- [ ] **Step 4: 编译 + 验证补标** - -Run: `go build ./...` -Run: `go run main.go`,日志出现 `seed: 拼音补标完成`;`sqlite3 data/36wisdom.db "SELECT content_pinyin FROM scene_node LIMIT 1;"` 返回 JSON 数组(如 `["小","明",",","..."]`)。Ctrl-C 停掉。 - -- [ ] **Step 5: 提交** - -```bash -git add biz/service/seed/seed.go biz/service/seed/annotate.go -git commit -m "feat: 种子拼音写时标注 + 启动补标" -``` - ---- - -### Task 4: 互动入口节点程序化生成(触控 5 种) - -**Files:** -- Create: `biz/service/seed/interaction.go` -- Test: `biz/service/seed/interaction_test.go` - -**模型**(技术设计.md 4.7):每关决策树入口前插入互动节点(is_entry=1、interaction_type 2/3/5/7/8 轮换),选项表预置两个出口——成功→原入口节点、失败→本关首个失败终局;原入口 is_entry 置 0。数据全部从该关元素派生:道具=该关选项的 prop 去重、正确答案=能到达最佳终局的第一个选项的道具、人物=该关节点 character 去重。 - -- [x] **Step 1: 写失败单测 `biz/service/seed/interaction_test.go`**(纯函数 `planInteraction`) - -```go -package seed - -import ( - "encoding/json" - "testing" - - "github.com/gogf/gf/v2/container/gvar" - "github.com/gogf/gf/v2/database/gdb" - - "36wisdom/biz/consts" -) - -// gdb.Record = map[string]gdb.Value = map[string]*gvar.Var(gf v2.10.2),字面量一律 gvar.New -// 构造与 level 1 同构的小树:entry(1) → 决策(2) → best(3)/good(4)/fail(5) -func testTree() (nodes []gdb.Record, options []gdb.Record) { - nodes = []gdb.Record{ - {"id": gvar.New(1), "character_id": gvar.New(11), "result_type": gvar.New(0)}, - {"id": gvar.New(2), "character_id": gvar.New(12), "result_type": gvar.New(0)}, - {"id": gvar.New(3), "character_id": gvar.New(12), "result_type": gvar.New(3)}, - {"id": gvar.New(4), "character_id": gvar.New(12), "result_type": gvar.New(2)}, - {"id": gvar.New(5), "character_id": gvar.New(11), "result_type": gvar.New(1)}, - } - options = []gdb.Record{ - {"id": gvar.New(101), "node_id": gvar.New(1), "prop_id": gvar.New(21), "next_node_id": gvar.New(2), "sort_order": gvar.New(1)}, - {"id": gvar.New(102), "node_id": gvar.New(1), "prop_id": gvar.New(22), "next_node_id": gvar.New(5), "sort_order": gvar.New(2)}, - {"id": gvar.New(103), "node_id": gvar.New(2), "prop_id": gvar.New(21), "next_node_id": gvar.New(3), "sort_order": gvar.New(1)}, - {"id": gvar.New(104), "node_id": gvar.New(2), "prop_id": gvar.New(23), "next_node_id": gvar.New(4), "sort_order": gvar.New(2)}, - {"id": gvar.New(105), "node_id": gvar.New(2), "prop_id": gvar.New(24), "next_node_id": gvar.New(5), "sort_order": gvar.New(3)}, - } - return -} - -func elems() map[int64]string { - return map[int64]string{11: "小明", 12: "妈妈", 21: "画", 22: "扫帚", 23: "剪刀", 24: "长杆"} -} - -func TestPlanInteraction_BestPropAndFailFinal(t *testing.T) { - nodes, options := testTree() - p := planInteraction(2, 1, nodes, options, elems()) - if p == nil { - t.Fatal("应生成互动计划") - } - // 正确答案 = 能到达最佳终局(3)的第一个选项(103)的道具 21 - if p.answer != 21 { - t.Fatalf("正确答案应 21(选项103的道具),实际 %d", p.answer) - } - // 失败出口 = 首个失败终局 5 - if p.failFinalID != 5 { - t.Fatalf("失败终局应 5,实际 %d", p.failFinalID) - } - // 道具按选项顺序去重:21,22,23,24 - if len(p.items) != 4 || p.items[0].ID != 21 || p.items[3].ID != 24 { - t.Fatalf("道具顺序错误: %+v", p.items) - } -} - -func TestPlanInteraction_StepSortReversed(t *testing.T) { - nodes, options := testTree() - p := planInteraction(3, 1, nodes, options, elems()) - if len(p.answerOrder) != 4 { - t.Fatalf("排序应有 4 项,实际 %d", len(p.answerOrder)) - } - // 正确顺序 = 道具顺序逆序 - if p.answerOrder[0] != 24 || p.answerOrder[3] != 21 { - t.Fatalf("逆序错误: %v", p.answerOrder) - } -} - -func TestPlanInteraction_LinkMatchPairs(t *testing.T) { - nodes, options := testTree() - p := planInteraction(8, 1, nodes, options, elems()) - if p == nil { - t.Fatal("应生成连线计划") - } - // 人物:11(小明)、12(妈妈);小明(节点1)首个带道具选项→扫帚22;妈妈(节点2)→画21 - found := false - for _, pair := range p.pairs { - if pair[0] == 11 && pair[1] == 22 { - found = true - } - } - if !found { - t.Fatalf("缺少 小明-扫帚 配对: %v", p.pairs) - } -} - -func TestPlanInteraction_FallbackToPropPick(t *testing.T) { - // 只有 1 个道具 → step_sort 退化为 prop_pick - nodes, options := testTree() - options = options[:1] - p := planInteraction(3, 1, nodes, options, elems()) - if p == nil || p.kind != "prop_pick" { - t.Fatalf("应退化为 prop_pick,实际 %+v", p) - } -} - -func TestPlanInteraction_NoFailFinalSkipped(t *testing.T) { - nodes, options := testTree() - for i := range nodes { - if nodes[i]["result_type"].Int() == consts.ResultFail { - nodes[i]["result_type"] = gconv.Int(2) - } - } - if p := planInteraction(2, 1, nodes, options, elems()); p != nil { - t.Fatal("无失败终局应返回 nil(跳过该关)") - } -} - -func TestInteractionConfig_JSON(t *testing.T) { - p := &interactionPlan{kind: "prop_pick", items: []configItem{{ID: 21, Name: "画"}}, answer: 21, prompt: "选一选"} - s := interactionConfigJSON(p) - var m map[string]any - if err := json.Unmarshal([]byte(s), &m); err != nil { - t.Fatalf("config 非法 JSON: %v", err) - } - if m["kind"] != "prop_pick" || m["answer"] != float64(21) { - t.Fatalf("config 字段错误: %v", m) - } -} -``` - -- [x] **Step 2: 跑测试确认失败** - -Run: `go test ./biz/service/seed/ -run TestPlanInteraction -v` -Expected: FAIL(`planInteraction` / `interactionPlan` / `configItem` / `interactionConfigJSON` 未定义) - -- [x] **Step 3: 实现 `biz/service/seed/interaction.go`** - -```go -package seed - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/gogf/gf/v2/database/gdb" - "github.com/gogf/gf/v2/frame/g" - - "36wisdom/biz/consts" - "36wisdom/biz/dao" - "36wisdom/common" -) - -// interactionKinds 触控互动类型轮换顺序(技术设计.md 4.7:2道具选择 3步骤排序 5拖拽放置 7找线索 8连线配对) -var interactionKinds = []int{2, 7, 5, 3, 8} - -// interactionPrompts 各互动类型的节点提示语 -var interactionPrompts = map[int]string{ - 2: "选一选:哪个道具能帮上忙?", - 7: "找一找:线索在哪里?点一点", - 5: "拖一拖:把道具放到框里", - 3: "排一排:按顺序摆好道具", - 8: "连一连:谁会用哪个道具?", -} - -type configItem struct { - ID int64 `json:"id"` - Name string `json:"name"` -} - -type interactionPlan struct { - kind string `json:"kind"` // prop_pick / find_spot / drag_place / step_sort / link_match - Items []configItem `json:"items"` - Answer int64 `json:"answer"` - AnswerOrder []int64 `json:"answer_order,omitempty"` - Persons []configItem `json:"persons,omitempty"` - Pairs [][2]int64 `json:"pairs,omitempty"` - EntryID int64 // 成功出口目标:原入口节点 - FailFinalID int64 // 失败出口目标:本关首个失败终局 - Prompt string `json:"-"` -} - -// ensureInteractions 每关生成互动入口节点(幂等):入口节点 interaction_type>1 视为已有,跳过。 -func ensureInteractions(ctx context.Context) { - levels, err := dao.Level.Model().Ctx(ctx). - Where("status", consts.StatusEnabled).Order("id ASC").All() - if err != nil { - g.Log().Warningf(ctx, "seed: 互动节点生成失败(查关卡): %v", err) - return - } - for i, lv := range levels { - if err := ensureLevelInteraction(ctx, lv, interactionKinds[i%len(interactionKinds)]); err != nil { - g.Log().Warningf(ctx, "seed: 互动节点生成失败 level=%d: %v", lv["id"].Int64(), err) - } - } -} - -func ensureLevelInteraction(ctx context.Context, lv gdb.Record, kind int) error { - levelId := lv["id"].Int64() - nodes, err := dao.SceneNode.Model().Ctx(ctx). - Where("level_id", levelId).Where("status", consts.StatusEnabled). - Order("sort_order ASC, id ASC").All() - if err != nil { - return err - } - var entry gdb.Record - for _, n := range nodes { - if n["is_entry"].Int() == 1 { - entry = n - break - } - } - if entry.IsEmpty() { - return fmt.Errorf("关卡 %d 无入口节点", levelId) - } - if entry["interaction_type"].Int() > 1 { - return nil // 已有互动入口,幂等跳过 - } - - options, err := dao.NodeOption.Model().Ctx(ctx). - WhereIn("node_id", nodeIdsOf(nodes)).Where("status", consts.StatusEnabled). - Order("sort_order ASC, id ASC").All() - if err != nil { - return err - } - elementNames := map[int64]string{} - for _, n := range nodes { - if cid := n["character_id"].Int64(); cid > 0 { - elementNames[cid] = "" - } - } - for _, o := range options { - if pid := o["prop_id"].Int64(); pid > 0 { - elementNames[pid] = "" - } - } - if len(elementNames) > 0 { - ids := make([]int64, 0, len(elementNames)) - for id := range elementNames { - ids = append(ids, id) - } - if recs, err := dao.Element.Model().Ctx(ctx). - WhereIn("id", uniqueInt64(ids)).Where("status", consts.StatusEnabled).All(); err == nil { - for _, r := range recs { - elementNames[r["id"].Int64()] = r["name"].String() - } - } - } - - plan := planInteraction(kind, entry["id"].Int64(), nodes, options, elementNames) - if plan == nil { - return nil // 数据不足(无失败终局等),该关不插互动节点 - } - return insertInteractionNode(ctx, levelId, kind, plan) -} - -// planInteraction 纯函数:从关卡节点/选项派生互动计划;数据不足返回 nil。 -func planInteraction(kind int, entryID int64, nodes, options []gdb.Record, elementNames map[int64]string) *interactionPlan { - // 失败终局:本关第一个 result_type=1 的节点;无则整关跳过 - failFinalID := int64(0) - for _, n := range nodes { - if n["result_type"].Int() == consts.ResultFail { - failFinalID = n["id"].Int64() - break - } - } - if failFinalID == 0 { - return nil - } - - // 道具:按选项出现顺序去重 - items := []configItem{} - seenProp := map[int64]bool{} - for _, o := range options { - pid := o["prop_id"].Int64() - if pid == 0 || seenProp[pid] { - continue - } - seenProp[pid] = true - items = append(items, configItem{ID: pid, Name: elementNames[pid]}) - } - - // 正确答案:第一个其子树能到达最佳终局(result_type=3)的选项的道具 - bestProp := bestPropOf(options, nodes) - if bestProp == 0 || len(items) == 0 { - return nil - } - - p := &interactionPlan{ - kind: kindOf(kind, items), - Items: items, - Answer: bestProp, - EntryID: entryID, - FailFinalID: failFinalID, - Prompt: interactionPrompts[kind], - } - switch p.kind { - case "step_sort": - for i := len(items) - 1; i >= 0; i-- { - p.AnswerOrder = append(p.AnswerOrder, items[i].ID) - } - case "link_match": - p.Persons, p.Pairs = pairsOf(nodes, options) - if len(p.Pairs) < 2 { - // 配对不足退化为道具选择 - p.kind = "prop_pick" - p.Prompt = interactionPrompts[2] - } - } - return p -} - -// kindOf 类型降级:步骤排序需 ≥2 道具,连线配对需 ≥2 人物配对,否则退化为道具选择。 -func kindOf(kind int, items []configItem) string { - switch kind { - case 3: - if len(items) >= 2 { - return "step_sort" - } - case 8: - return "link_match" - case 7: - return "find_spot" - case 5: - return "drag_place" - } - return "prop_pick" -} - -// bestPropOf 第一个(选项顺序)其子树内存在最佳终局的选项的道具 id。 -func bestPropOf(options []gdb.Record, nodes []gdb.Record) int64 { - nextOf := map[int64][]int64{} // node_id → 可达下一节点(通过其选项) - for _, o := range options { - nextOf[o["node_id"].Int64()] = append(nextOf[o["node_id"].Int64()], o["next_node_id"].Int64()) - } - best := map[int64]bool{} - for _, n := range nodes { - if n["result_type"].Int() == consts.ResultBest { - best[n["id"].Int64()] = true - } - } - canReachBest := func(start int64) bool { - visited := map[int64]bool{} - queue := []int64{start} - for len(queue) > 0 { - cur := queue[0] - queue = queue[1:] - if visited[cur] { - continue - } - visited[cur] = true - if best[cur] { - return true - } - queue = append(queue, nextOf[cur]...) - } - return false - } - for _, o := range options { - if canReachBest(o["next_node_id"].Int64()) { - return o["prop_id"].Int64() - } - } - return 0 -} - -// pairsOf 人物-道具配对:每个去重人物取其首个带道具选项的道具。 -func pairsOf(nodes, options []gdb.Record) ([]configItem, [][2]int64) { - persons := []configItem{} - seen := map[int64]bool{} - personOf := map[int64]int64{} // person id → 其所在节点 - for _, n := range nodes { - cid := n["character_id"].Int64() - if cid == 0 || seen[cid] { - continue - } - seen[cid] = true - personOf[cid] = n["id"].Int64() - } - propOf := map[int64]int64{} // node_id → 首个带道具选项的道具 - for _, o := range options { - pid := o["prop_id"].Int64() - if pid == 0 { - continue - } - nid := o["node_id"].Int64() - if _, ok := propOf[nid]; !ok { - propOf[nid] = pid - } - } - pairs := [][2]int64{} - for _, n := range nodes { - cid := n["character_id"].Int64() - if cid == 0 { - continue - } - if pid, ok := propOf[n["id"].Int64()]; ok { - pairs = append(pairs, [2]int64{cid, pid}) - if !seen[cid] { - seen[cid] = true - persons = append(persons, configItem{ID: cid}) - } - } - } - return persons, pairs -} - -// interactionConfigJSON 互动配置(前端渲染数据)。 -func interactionConfigJSON(p *interactionPlan) string { - b, _ := json.Marshal(p) - return string(b) -} - -func nodeIdsOf(nodes []gdb.Record) []int64 { - ids := make([]int64, 0, len(nodes)) - for _, n := range nodes { - ids = append(ids, n["id"].Int64()) - } - return ids -} - -// insertInteractionNode 事务:原入口 is_entry=0 → 插互动节点(is_entry=1) → 插成功/失败出口选项。 -func insertInteractionNode(ctx context.Context, levelId int64, kind int, plan *interactionPlan) error { - promptPinyin := common.AnnotatePinyin(plan.Prompt) - return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - if _, err := tx.Model(consts.TableSceneNode).Ctx(ctx). - Data(g.Map{"is_entry": 0}).WherePri(plan.EntryID).Update(); err != nil { - return err - } - res, err := tx.Model(consts.TableSceneNode).Ctx(ctx).Data(g.Map{ - "level_id": levelId, "title": "动动小脑筋", - "content": plan.Prompt, "content_pinyin": promptPinyin, - "node_type": consts.NodeDecision, "interaction_type": kind, - "config": interactionConfigJSON(plan), "result_type": consts.ResultNone, - "is_entry": 1, "sort_order": 1, "status": consts.StatusEnabled, - }).Insert() - if err != nil { - return err - } - nodeId, err := res.LastInsertId() - if err != nil { - return err - } - if _, err := tx.Model(consts.TableNodeOption).Ctx(ctx).Data(g.Map{ - "node_id": nodeId, "text": "成功了", - "text_pinyin": common.AnnotatePinyin("成功了"), "next_node_id": plan.EntryID, - "feedback": "真棒!我们看看接下来会发生什么~", - "feedback_pinyin": common.AnnotatePinyin("真棒!我们看看接下来会发生什么~"), - "sort_order": 1, "status": consts.StatusEnabled, - }).Insert(); err != nil { - return err - } - if _, err := tx.Model(consts.TableNodeOption).Ctx(ctx).Data(g.Map{ - "node_id": nodeId, "text": "没成功", - "text_pinyin": common.AnnotatePinyin("没成功"), "next_node_id": plan.FailFinalID, - "feedback": "没关系,再仔细观察一下~", - "feedback_pinyin": common.AnnotatePinyin("没关系,再仔细观察一下~"), - "sort_order": 2, "status": consts.StatusEnabled, - }).Insert(); err != nil { - return err - } - return nil - }) -} -``` - -注意:`uniqueInt64` 已在 `biz/service/strategy.go` 定义(同包 service,`seed` 是不同包)——`seed` 包内没有 `uniqueInt64`,用局部实现或改为 `biz/service` 的(不同包不可见)。在 interaction.go 内自建: - -```go -func uniqueInt64(in []int64) []int64 { - seen := make(map[int64]struct{}, len(in)) - out := make([]int64, 0, len(in)) - for _, v := range in { - if _, ok := seen[v]; ok { - continue - } - seen[v] = struct{}{} - out = append(out, v) - } - return out -} -``` - -- [x] **Step 4: 打开 `EnsureSeeded` 里的 `ensureInteractions(ctx)` 调用** - -修改 `seed.go`(Task 3 Step 3 留的注释行): - -```go - annotateAll(ctx) - ensureInteractions(ctx) -``` - -- [x] **Step 5: 跑单测确认通过** - -Run: `go test ./biz/service/seed/ -run TestPlanInteraction -v` -Expected: PASS(6 个用例全绿) - -- [x] **Step 6: 编译 + 启动验证(现有库补生成)** - -Run: `go build ./... && go vet ./...` -Run: `go run main.go`;Ctrl-C 停掉后再 `sqlite3 data/36wisdom.db` 验证: - -```sql -SELECT COUNT(*) FROM scene_node WHERE interaction_type > 1; -- 应为 36(36 关各 1 个) -SELECT id, interaction_type, is_entry, config FROM scene_node WHERE interaction_type > 1 LIMIT 2; -SELECT text, next_node_id, sort_order FROM node_option WHERE node_id = (SELECT id FROM scene_node WHERE interaction_type > 1 LIMIT 1); -``` - -预期:`text` 为「成功了 / 没成功」两行,`sort_order` 1/2。 - -- [x] **Step 7: 提交** - -```bash -git add biz/service/seed/interaction.go biz/service/seed/interaction_test.go biz/service/seed/seed.go -git commit -m "feat: 互动入口节点程序化生成(触控5种,成功/失败双出口,choose零改动)" -``` - -**实现偏差记录(2026-08-13,两轮审查均通过)**: -- 结构体字段小写化(计划 Step 3 为大写,Step 1 测试用 p.answer/p.items 小写访问)→ 实现按测试为验收规格小写化,并加自定义 MarshalJSON 输出 kind/items/answer 等 JSON 键(encoding/json 忽略未导出字段) -- pairsOf 取"最后一个带道具选项"(计划注释"小明→扫帚22"与"首个"代码自相矛盾,测试断言 22)并修复 persons 恒为空的死代码(seen 预标记 bug);persons 现带 Name -- bestProp==0 时回退 items[0].ID 而非返回 nil(计划测试 FallbackToPropPick 要求 prop_pick 不退关;真实数据无此场景) -- 测试数据 gvar.New 包装:gf v2.10.2 中 `gdb.Record = map[string]*gvar.Var`(gdb.go:678),gconv.Int64 字面量无法编译 -- Step 6 预期修正:互动节点数 = **14 而非 36**——种子数据仅 14 关存在带 prop 的选项(其余 22 关零道具),planInteraction 按设计返回 nil 跳过;数据驱动行为,非缺陷。M1.5 收尾时如需 36 关全互动,须先补种子道具数据 -- 审查后修复 commit 4744ba9:persons 补名字、seed 缓存一致性注释(启动期无读者不清缓存,同 annotateAll 先例)、元素名查询失败 warn - -**Task 8 偏差记录(审查后修复 7f7cf49)**: -- 游戏化 CSS 补全 47 行(进度点/场景横幅/漂浮装饰/连击徽章/角色卡/选项 c1-c4 四色边框/option-icon 定宽高/fb-in 错峰动画/顶部开关)——原计划 style 段缺失这些类导致进度点不可见、素材落地后 image 默认 320×240 撑爆布局 -- OptionVO 透出 audio(node_option.audio 列已存在零迁移)——前端"听点评"可播文件,避免静默降级 TTS - -**Task 9 走查修复(RubyText 布局)**: -- RubyText 外层 text → view + flex-wrap(原 text>text inline-flex 在 H5 基线错位、小程序 text 组件不支持 flex):拼音不再对齐在汉字正上方。使用处均为独立文本块(scene-text/node-content/option-text/fb-option/learn-*),无行内混排,块级化安全 - ---- - -### Task 5: 拼音字段透出(service / dto / controller) - -**Files:** -- Modify: `biz/service/level.go`、`biz/service/strategy.go`、`biz/model/dto/level.go`、`biz/model/dto/level_play.go`、`biz/model/dto/strategy.go`、`biz/controller/level.go`、`biz/controller/strategy.go` - -- [ ] **Step 1: service 层 `biz/service/level.go`** - -`Element` 结构体加字段(第 43-50 行): - -```go -type Element struct { - Id int64 - EType int - Name string - NamePinyin string - Image string - Audio string - Description string -} -``` - -`Option` 加 `TextPinyin string`、`FeedbackPros string`、`FeedbackCons string`;`Node` 加 `ContentPinyin string`;`LevelDetail` 加 `SceneContentPinyin string`。 - -`elementsOf` 映射处(第 191-198 行)加 `NamePinyin: r["name_pinyin"].String()`。 - -`buildNode`(第 205-227 行)加: - -```go - node := &Node{ - NodeId: nodeRec["id"].Int64(), - Title: nodeRec["title"].String(), - Content: nodeRec["content"].String(), - ContentPinyin: nodeRec["content_pinyin"].String(), - Image: nodeRec["image"].String(), - ... - } - for _, o := range optionRecs { - ... - node.Options = append(node.Options, &Option{ - OptionId: o["id"].Int64(), - Text: o["text"].String(), - TextPinyin: o["text_pinyin"].String(), - FeedbackPros: o["feedback_pros"].String(), - FeedbackCons: o["feedback_cons"].String(), - Prop: elements[o["prop_id"].Int64()], - }) - } -``` - -`Detail` 返回(第 152-163 行)加 `SceneContentPinyin: levelRec["scene_content_pinyin"].String()`。 - -- [ ] **Step 2: service 层 `biz/service/strategy.go`** - -`StrategyDetail` 加 `MeaningPinyin string`、`TeachContentPinyin string`;`Detail` 组装(第 179-190 行)加两个字段映射。 - -- [ ] **Step 3: dto 层** - -`biz/model/dto/level.go`:`ElementVO` 加 `NamePinyin string \`json:"name_pinyin"\``;`OptionVO` 加 `TextPinyin string \`json:"text_pinyin"\``、`FeedbackPros string \`json:"feedback_pros"\``、`FeedbackCons string \`json:"feedback_cons"\``;`NodeVO` 加 `ContentPinyin string \`json:"content_pinyin"\``;`LevelDetailRes` 加 `SceneContentPinyin string \`json:"scene_content_pinyin"\``。 - -`biz/model/dto/level_play.go`:`NodeVO`(ChooseRes.Next 用同一类型,在 dto/level.go 定义)——level_play.go 的 `ChooseRes` 引用 `*NodeVO`,无需改。`FinalSettle` 不加拼音。 - -`biz/model/dto/strategy.go`:`StrategyDetailRes` 加 `MeaningPinyin string \`json:"meaning_pinyin"\``、`TeachContentPinyin string \`json:"teach_content_pinyin"\``。 - -- [ ] **Step 4: controller 层** - -`biz/controller/level.go`:`elementVO` 加 `NamePinyin: e.NamePinyin`;`nodeVO` 加 `ContentPinyin: n.ContentPinyin`;OptionVO 映射加 `TextPinyin: o.TextPinyin`、`FeedbackPros: o.FeedbackPros`、`FeedbackCons: o.FeedbackCons`;`Detail` 加 `SceneContentPinyin: detail.SceneContentPinyin`。 - -`biz/controller/strategy.go`:Detail 返回加 `MeaningPinyin`、`TeachContentPinyin` 映射。 - -- [ ] **Step 5: 编译 + 接口验证** - -Run: `go build ./... && go vet ./...` -Run: `go run main.go` 后: - -```bash -TOKEN=$(curl -s -X POST localhost:8080/api/parent/login -H 'Content-Type: application/json' -d '{"phone":"13600000000","password":"pass123456"}' | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['token'])") -curl -s "localhost:8080/api/level/detail?child_id=3&level_id=1" -H "Authorization: Bearer $TOKEN" | python3 -c " -import sys,json -d=json.load(sys.stdin)['data'] -print('scene pinyin:', d['scene_content_pinyin'][:60]) -n=d['entry'] -print('entry type:', n['interaction_type'], '| content pinyin:', n['content_pinyin'][:60]) -print('opt1 pinyin:', n['options'][0]['text_pinyin']) -" -``` - -预期:三个 pinyin 字段都是 JSON 数组字符串。Ctrl-C 停掉。 - -- [ ] **Step 6: 提交** - -```bash -git add biz/service/level.go biz/service/strategy.go biz/model/dto/ biz/controller/level.go biz/controller/strategy.go -git commit -m "feat: 拼音字段透出到内容接口(level/strategy 详情)" -``` - ---- - -### Task 6: 前端基础能力(朗读 / 视觉 / 拼音 / 组件) - -**Files:** -- Create: `ui-src/src/utils/speech.js`、`ui-src/src/utils/visual.js`、`ui-src/src/utils/pinyin.js` -- Create: `ui-src/src/components/RubyText.vue`、`ui-src/src/components/SpeakButton.vue` - -- [ ] **Step 1: 朗读 `ui-src/src/utils/speech.js`**(文件优先,无文件浏览器语音降级) - -```js -let innerAudio = null -let uttering = false - -function playFile(url) { - stopSpeak() - if (!innerAudio) innerAudio = uni.createInnerAudioContext() - innerAudio.stop() - innerAudio.src = url - innerAudio.play() -} - -function speakBrowser(text) { - stopSpeak() - // H5 专用:浏览器原生语音,零成本朗读(小程序端后续接 TTS 文件) - if (typeof window === 'undefined' || !window.speechSynthesis) return - const u = new SpeechSynthesisUtterance(text) - u.lang = 'zh-CN' - u.rate = 0.9 - u.onend = u.onerror = () => { uttering = false } - window.speechSynthesis.speak(u) - uttering = true -} - -// speak 朗读一段文本:有音频文件优先播文件,否则浏览器语音降级 -export function speak(text, fileUrl) { - if (!text) return - if (fileUrl) { - playFile(fileUrl) - return - } - speakBrowser(text) -} - -export function stopSpeak() { - if (innerAudio) innerAudio.stop() - if (typeof window !== 'undefined' && window.speechSynthesis) { - window.speechSynthesis.cancel() - } - uttering = false -} - -export function isSpeaking() { - return uttering -} -``` - -- [ ] **Step 2: 占位视觉 `ui-src/src/utils/visual.js`**(无美术素材阶段,按名称关键词映射 emoji + 渐变色;后续素材接入只改此表) - -```js -const SCENE_MAP = { - 幼儿园: { emoji: '🏫', color: '#ffd08a' }, 教室: { emoji: '🏫', color: '#ffd08a' }, - 公园: { emoji: '🌳', color: '#a8e6a3' }, 花园: { emoji: '🌷', color: '#f4b8d0' }, - 家里: { emoji: '🏠', color: '#ffb347' }, 小区: { emoji: '🏘️', color: '#c9a7eb' }, - 操场: { emoji: '⚽', color: '#7ec8e3' }, 球场: { emoji: '⚽', color: '#7ec8e3' }, - 商店: { emoji: '🏪', color: '#f6d365' }, 超市: { emoji: '🛒', color: '#f6d365' }, - 游乐场: { emoji: '🎠', color: '#ff9a9e' }, 路上: { emoji: '🛤️', color: '#d5c4ae' }, - 食堂: { emoji: '🍚', color: '#ffe0ac' }, 图书馆: { emoji: '📚', color: '#b8a6d9' }, - 海滩: { emoji: '🏖️', color: '#83e3e8' }, 泳池: { emoji: '🏊', color: '#83e3e8' }, - 外婆家: { emoji: '🏡', color: '#ffd08a' } -} -const PROP_MAP = { - 画: '🖼️', 书: '📖', 剪刀: '✂️', 长杆: '🥢', 扫帚: '🧹', 绳子: '🪢', - 球: '⚽', 玩具: '🧸', 手机: '📱', 钥匙: '🔑', 雨伞: '☂️', 帽子: '🧢', - 凳子: '🪑', 书包: '🎒', 水杯: '🥤', 饼干: '🍪', 铅笔: '✏️', 纸: '📄', - 胶水: '🧴', 篮子: '🧺', 喇叭: '📢', 鼓: '🥁', 娃娃: '🎎', 积木: '🧱', - 调色盘: '🎨', 蜡笔: '✏️', 电池: '🔋', 瓜子: '🥜', 苹果: '🍎', 小汽车: '🚗' -} - -function pick(list, key, fallback) { - for (const k of Object.keys(list)) { - if (key.includes(k)) return list[k] - } - return fallback -} - -export function sceneVisual(name) { - const v = pick(SCENE_MAP, name || '', { emoji: '🏞️', color: '#cde8c4' }) - return { emoji: v.emoji, color: v.color } -} - -export function propVisual(name) { - // PROP_MAP 值是纯字符串 emoji,不能取 .emoji(会得 undefined 恒兜底 🎁) - return { emoji: pick(PROP_MAP, name || '', '🎁'), color: '#fff0e5' } -} - -const PERSON_COLORS = ['#ff9a3d', '#ff6b6b', '#4ecdc4', '#6c8cff', '#a58cff', '#ffb347'] - -export function personVisual(id) { - return { color: PERSON_COLORS[Number(id || 0) % PERSON_COLORS.length] } -} -``` - -- [ ] **Step 3: 拼音解析 `ui-src/src/utils/pinyin.js`**(后端 JSON 数组 + 与原文 zip) - -```js -// parsePinyin 后端 *_pinyin 字段(逐字对齐 JSON 数组字符串)→ 数组 -export function parsePinyin(raw) { - if (!raw) return [] - try { - const arr = JSON.parse(raw) - return Array.isArray(arr) ? arr : [] - } catch (e) { - return [] - } -} - -// zipText 原文与拼音逐字 zip:返回 [{c, p}],p 为空串时无注音 -export function zipText(text, pinyinArr) { - const chars = Array.from(text || '') - const out = [] - for (let i = 0; i < chars.length; i++) { - out.push({ c: chars[i], p: (pinyinArr && pinyinArr[i]) || '' }) - } - return out -} -``` - -- [ ] **Step 4: 注音组件 `ui-src/src/components/RubyText.vue`** - -```vue - - - - - -``` - -- [ ] **Step 5: 朗读按钮 `ui-src/src/components/SpeakButton.vue`** - -```vue - - - - - -``` - -- [ ] **Step 6: 音效合成 `ui-src/src/utils/sound.js`**(WebAudio 合成短音效,无音频文件依赖;小程序端 WebAudio 受限时静默降级) - -```js -// WebAudio 合成音效:点击/正确/失败/完成/金币。小程序端无 window.AudioContext 时全部静默。 -let ctx = null -let enabled = true -const stored = uni.getStorageSync('36wisdom_sound') -if (stored !== '') enabled = stored !== 'off' - -function ac() { - if (!enabled || typeof window === 'undefined') return null - if (!ctx) { - const AC = window.AudioContext || window.webkitAudioContext - if (!AC) return null - ctx = new AC() - } - if (ctx.state === 'suspended') ctx.resume() - return ctx -} - -function tone(freq, dur, type = 'sine', vol = 0.15, delay = 0) { - const c = ac() - if (!c) return - const t = c.currentTime + delay - const o = c.createOscillator() - const g = c.createGain() - o.type = type - o.frequency.setValueAtTime(freq, t) - g.gain.setValueAtTime(0, t) - g.gain.linearRampToValueAtTime(vol, t + 0.02) - g.gain.exponentialRampToValueAtTime(0.001, t + dur) - o.connect(g).connect(c.destination) - o.start(t) - o.stop(t + dur + 0.05) -} - -export function playSound(name) { - switch (name) { - case 'click': tone(520, 0.08, 'sine', 0.1); break - case 'good': tone(660, 0.12); tone(880, 0.18, 'sine', 0.14, 0.09); break - case 'bad': tone(220, 0.18, 'sine', 0.1); tone(180, 0.22, 'sine', 0.08, 0.12); break - case 'coin': tone(1046, 0.07, 'square', 0.06); tone(1568, 0.16, 'square', 0.06, 0.07); break - case 'finish': tone(523, 0.12); tone(659, 0.12, 'sine', 0.14, 0.1); tone(784, 0.2, 'sine', 0.14, 0.2); break - case 'pop': tone(880, 0.06, 'triangle', 0.12); break - } -} - -export function setSoundEnabled(on) { - enabled = on - uni.setStorageSync('36wisdom_sound', on ? 'on' : 'off') -} - -export function isSoundEnabled() { - return enabled -} -``` - -- [ ] **Step 7: H5 编译检查** - -Run: `cd ui-src && npm run dev:h5`(后台),`curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/src/components/RubyText.vue` 返回 200。 - -- [ ] **Step 8: 提交** - -```bash -cd .. && git add ui-src/src/utils/ ui-src/src/components/RubyText.vue ui-src/src/components/SpeakButton.vue -git commit -m "feat: 前端朗读/占位视觉/拼音注音/音效合成基础组件" -``` - ---- - -### Task 7: 触控互动组件 5 个 - -**Files:** -- Create: `ui-src/src/components/interactions/PropPick.vue`、`FindSpot.vue`、`DragPlace.vue`、`StepSort.vue`、`LinkMatch.vue` - -统一约定:props 为 `config`(后端 scene_node.config JSON 字符串);判定完成后 `this.$emit('done', { success })`;失败给一次重试机会(重试次数内不发射 done,超限发射 success=false)。 - -- [ ] **Step 1: 道具选择 `PropPick.vue`**(点选道具,点中 answer 即成功) - -```vue - - - - - -``` - -- [ ] **Step 2: 找线索 `FindSpot.vue`**(道具卡散布在"场景"里,点击寻找,点中 answer 即成功;视觉与 PropPick 同构,排布错落) - -```vue - - - - - -``` - -- [ ] **Step 3: 拖拽放置 `DragPlace.vue`**(touch 拖道具到目标框:命中且道具=answer 成功,未命中/道具错 重试) - -```vue - - - - - -``` - -- [ ] **Step 4: 步骤排序 `StepSort.vue`**(touch 拖拽排序:touchstart 拿起、touchmove 交换、touchend 判定全序 == answer_order) - -```vue - - - - - -``` - -- [ ] **Step 5: 连线配对 `LinkMatch.vue`**(SVG 画线:左列人物、右列道具,先点人物再点道具成对;全部配完且与 pairs 一致成功) - -```vue - - - - - -``` - -- [ ] **Step 6: H5 编译检查** - -Run: `curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/src/components/interactions/PropPick.vue` 等 5 个组件返回 200。 - -- [ ] **Step 7: 触控皮肤统一升级**(游戏化:按压回弹 + 成功脉冲 / 失败 shake + 音效。逐组件改,共 5 处) - -每个组件 3 处修改(类名见下表,按各组件实际类名替换): - -**① 引入音效**:script 顶部加 `import { playSound } from '../../utils/sound.js'`;成功判定处(`this.$emit('done', { success: true })` 前)加 `playSound('good')`;错误判定处(重试分支)加 `playSound('bad')`。 - -**② 成功/失败视觉态**:成功时对应元素加 `.win` 类(脉冲放大 + 金色发光);错误时错误元素加 `.wrong` 类(shake 动画)。 - -**③ style 末尾追加**(类名按上表替换,` -``` - -- [ ] **Step 3: 后端 OptionVO 透出 audio**(`node_option.audio` 列已存在,零迁移;支撑"听点评"文件朗读而非 TTS 降级) - -`biz/service/level.go`:`Option` 结构体加 `Audio string` 字段,`buildNode` 内选项组装处追加 `Audio: o["audio"].String()`: - -```go -type Option struct { - OptionId int64 - Text string - TextPinyin string - FeedbackPros string - FeedbackCons string - Audio string - Prop *Element -} -``` - -`buildNode` 的 `node.Options = append(...)` 处改为: - -```go - node.Options = append(node.Options, &Option{ - OptionId: o["id"].Int64(), - Text: o["text"].String(), - TextPinyin: o["text_pinyin"].String(), - FeedbackPros: o["feedback_pros"].String(), - FeedbackCons: o["feedback_cons"].String(), - Audio: o["audio"].String(), - Prop: elements[o["prop_id"].Int64()], - }) -``` - -`biz/model/dto/level.go`:`OptionVO` 加字段: - -```go -type OptionVO struct { - OptionId int64 `json:"option_id"` - Text string `json:"text"` - TextPinyin string `json:"text_pinyin"` - FeedbackPros string `json:"feedback_pros"` - FeedbackCons string `json:"feedback_cons"` - Audio string `json:"audio"` - Prop *ElementVO `json:"prop"` -} -``` - -`biz/controller/level.go`:`nodeVO` 的选项循环追加映射: - -```go - vo.Options = append(vo.Options, dto.OptionVO{ - OptionId: o.OptionId, - Text: o.Text, - TextPinyin: o.TextPinyin, - FeedbackPros: o.FeedbackPros, - FeedbackCons: o.FeedbackCons, - Audio: o.Audio, - Prop: elementVO(o.Prop), - }) -``` - -- [ ] **Step 4: map.vue 学堂弹层增强**(拼音注音 + 朗读) - -在 ` - - -``` - -- [ ] **Step 3: 语法验证** - -Run: `cd ui-src && npx vite build --mode h5 2>&1 | tail -3` -Expected: 构建成功(组件未被引用,仅语法检查) - -- [ ] **Step 4: Commit** - -```bash -git add ui-src/src/components/StoryPlayer.vue ui-src/src/utils/speech.js -git commit -m "feat: StoryPlayer 台词流演绎器(气泡+朗读+逐字高亮+表情切换+跳过)" -``` - -> 审查记录(58b3e57 双审查:第一 APPROVE,第二 2 SHOULD-FIX,合并修复): -> - SHOULD-FIX1(已修):speak 无完成事件,TTS 慢时逐字定时器推进会切掉上句音频 → speech.js speak 加 onEnd 回调,StoryPlayer「高亮完成 + 朗读 onEnd 都满足 → 停顿 → 下一句」,MAX_LINE_MS=20s 超时 forceNext 兜底防 TTS 无声卡死;nextLine 加 lineIndex/ended 竞态保护 -> - SHOULD-FIX2(已修):pageScrollTo 后跳过按钮滚出视口(4-8 岁孩子找不回)→ .story-skip 改 fixed(top 140rpx 避开 topbar) -> - NIT(已合并修):moodTick 300→350ms 复位(动画不截断);speaking 死状态删除 -> - NIT(记录待 Task 6 走查):.story-char fixed 角色立牌可能与选项区重叠,走查确认,必要时降 absolute;character.name 非空由数据保证 - ---- - -### Task 4: play.vue 剧情流集成 - -**Files:** -- Modify: `ui-src/src/pages/play/play.vue` -- Modify: `ui-src/src/components/ActionCard.vue`(仅当走查发现需要时) - -- [ ] **Step 1: import 与 data/computed** - -`play.vue` script 区: - -import 行加(`SceneTheater` 行后): - -```js -import StoryPlayer from '../../components/StoryPlayer.vue' -``` - -`pickInteraction` import 行改为同时引入 parseScript: - -```js -import { pickInteraction, entryProps, entryCharacter, parseScript } from '../../utils/theater.js' -``` - -components 注册加 `StoryPlayer`。 - -data() 加: - -```js - showOptions: false, - reply: null, -``` - -computed 加: - -```js - storyScript() { - return this.curNode ? parseScript(this.curNode.script) : null - }, - isStoryNode() { - return !!this.storyScript - } -``` - -- [ ] **Step 2: 模板加剧情流分支** - -在 `` 处,把现有 `` 整体改为: - -```html - - - - - - - - - - - 该互动玩法(类型 {{ curNode.interaction_type }})敬请期待 - - - - 🧑‍🎓 你的选择 - - {{ reply.pros }} - {{ reply.cons }} - - {{ reply.prop.name }} - {{ reply.prop.description }} - - - 继续 › - - - - - -``` - -(原节点卡片内触控组件分发、ActionCard、soon 分支原样保留在 v-else 分支中。) - -- [ ] **Step 3: submit/continuePlay 剧情流分支** - -`submit()` 中 `if (data.next) { … }` 分支整体替换为: - -```js - if (data.next) { - this.mood = o.feedback_cons ? '😢' : '😊' - this.combo = o.feedback_cons ? 0 : Math.min(this.combo + 1, 9) - playSound(o.feedback_cons ? 'bad' : 'good') - // 分支演出:场景晃动 + 0.6s 后出反馈(剧情流=回应气泡,v1=弹窗) - this.sceneFx = 'fx-shake' - const fb = { - text: o.text, - textPinyin: o.text_pinyin, - prop: o.prop, - audio: o.audio, - pros: o.feedback_pros, - cons: o.feedback_cons, - next: data.next - } - this.branchTimer = setTimeout(() => { - this.sceneFx = '' - if (this.isStoryNode) { - this.reply = fb - this.showOptions = false // 提交后隐藏选项区,防重复点击 - } else { - this.feedback = fb - } - this.choosing = false - }, 600) -``` - -`continuePlay()` 替换为: - -```js - continuePlay() { - if (this.isStoryNode) { - this.curNode = this.reply.next - this.reply = null - this.showOptions = false - } else { - this.curNode = this.feedback.next - this.feedback = null - } - this.mood = '🤔' - } -``` - -- [ ] **Step 4: 样式** - -style 区追加: - -```css -.story-node { padding: 32rpx 28rpx 48rpx; } -.story-options { margin-top: 8rpx; } -.story-reply { margin-top: 24rpx; background: #fffdf6; animation: fb-in 0.4s ease; } -.reply-char { font-size: 26rpx; color: #a08c74; margin-bottom: 16rpx; font-weight: 700; } -``` - -(`.fb-line/.fb-prop/.fb-continue` 等样式 v1 已有,直接复用。) - -- [ ] **Step 5: 端到端验证(手工造数据)** - -给第 1 计入口节点手工填一条剧本(后端与 H5 dev server 需在运行;命令在项目根目录执行): - -```bash -sqlite3 data/36wisdom.db "UPDATE scene_node SET script='[{\"speaker\":\"旁白\",\"text\":\"夜深了,城墙高高的。\",\"emotion\":\"normal\"},{\"speaker\":\"小军师\",\"text\":\"我们怎么才能悄悄进城呢?\",\"emotion\":\"think\"}]' WHERE id=(SELECT id FROM scene_node WHERE level_id=(SELECT id FROM level WHERE strategy_id=1 AND status=1 LIMIT 1) AND is_entry=1);" -``` - -H5 走查 `http://localhost:5173/#/pages/play/play?level_id=<第1计关卡>`(level_id 查:`sqlite3 data/36wisdom.db "SELECT id, title FROM level WHERE strategy_id=1 LIMIT 1;"`): -1. 剧情流显示两句气泡(旁白居中 + 角色提问),逐字高亮推进、自动朗读、mood 徽章 🤔 -2. 播完(或点跳过)ActionCard 出现在剧情流下方 -3. 选择后 0.6s 回应气泡融入剧情流(✓/✗ 两行 + 继续按钮),无遮罩弹窗 -4. 点「继续」进入下一节点剧情流 -5. 其他关卡(script 为空)仍是 v1 卡片呈现(回退保护) - -验证后还原测试数据:`sqlite3 data/36wisdom.db "UPDATE scene_node SET script='' WHERE script IS NOT NULL AND script != '';"`(仅当本节点 script 原为空;本步只改了这一个节点) - -- [ ] **Step 6: Commit** - -```bash -git add ui-src/src/pages/play/play.vue -git commit -m "feat: play.vue 剧情流集成——StoryPlayer + 选项嵌入 + 角色回应气泡(script 空回退 v1)" -``` - -> 审查记录(5e19174 双审查一致): -> - BLOCKER(已修):连续剧情节点卡死——StoryPlayer 无 `script` watch、play.vue 无 `:key`,节点切换时 v-if 恒真组件不重建不重播,`done` 永不触发 → 选项区永不出现。修复:`` 强制按节点重建重播 -> - SHOULD-FIX(已修):reply 出现后 showOptions 仍 true,ActionCard 仍可点击 → 可重复 submit。修复:submit 成功分支置 `showOptions = false` - ---- - -### Task 5: genasset 剧本生成管线 - -**Files:** -- Modify: `cmd/genasset/prompts.go` -- Create: `cmd/genasset/scripts.go` -- Create: `cmd/genasset/scripts_test.go` -- Modify: `cmd/genasset/main.go` - -- [ ] **Step 1: prompts.go 加剧本 prompt** - -`prompts.go` 末尾追加: - -```go -const scriptSystem = `你是儿童故事编剧。把下面的闯关情境改写成一场 3-6 句的微型剧情台词流,语言儿童化、口语化、温和,每句 8-30 字(含标点)。 -规则: -1. 至少 1 句旁白交代情境(speaker 为「旁白」),其余为角色台词 -2. speaker 只能是「旁白」或角色名 -3. 决策节点:最后一句必须是角色向孩子提问,以「?」结尾 -4. 终局节点:最后一句是角色的总结或鼓励 -5. 每句可标注情绪 emotion(normal/happy/sad/think/surprise,可不标) -只输出 JSON:{"script": [{"speaker": "旁白", "text": "……"}]}` - -const scriptUser = `情境:%s - -角色:%s -%s` -``` - -- [ ] **Step 2: 写失败单测** - -创建 `cmd/genasset/scripts_test.go`: - -```go -package main - -import "testing" - -func TestValidateScript_DecisionOK(t *testing.T) { - lines := []scriptLine{ - {Speaker: "旁白", Text: "夜深了,城里静悄悄的。"}, - {Speaker: "小军师", Text: "城门关得紧紧的。"}, - {Speaker: "小军师", Text: "我们怎么才能悄悄进城呢?", Emotion: "think"}, - } - if err := validateScript(lines, true, "小军师"); err != nil { - t.Fatalf("合法决策剧本应通过: %v", err) - } -} - -func TestValidateScript_FinalOK(t *testing.T) { - lines := []scriptLine{ - {Speaker: "旁白", Text: "天亮了,城门缓缓打开。"}, - {Speaker: "小军师", Text: "我们一起进城啦!", Emotion: "happy"}, - {Speaker: "小军师", Text: "大家平平安安,真好!"}, - } - if err := validateScript(lines, false, "小军师"); err != nil { - t.Fatalf("合法终局剧本应通过: %v", err) - } -} - -func TestValidateScript_TooFewLines(t *testing.T) { - lines := []scriptLine{{Speaker: "旁白", Text: "夜深了,城里静悄悄的。"}} - if err := validateScript(lines, true, "小军师"); err == nil { - t.Fatal("不足 3 句应报错") - } -} - -func TestValidateScript_MissingQuestion(t *testing.T) { - lines := []scriptLine{ - {Speaker: "旁白", Text: "夜深了,城里静悄悄的。"}, - {Speaker: "小军师", Text: "城门关得紧紧的。"}, - {Speaker: "小军师", Text: "我们悄悄等天亮吧。"}, - } - if err := validateScript(lines, true, "小军师"); err == nil { - t.Fatal("决策节点末句未提问应报错") - } -} - -func TestValidateScript_BadSpeaker(t *testing.T) { - lines := []scriptLine{ - {Speaker: "旁白", Text: "夜深了,城里静悄悄的。"}, - {Speaker: "路人甲", Text: "城门关得紧紧的。"}, - {Speaker: "小军师", Text: "我们怎么才能悄悄进城呢?"}, - } - if err := validateScript(lines, true, "小军师"); err == nil { - t.Fatal("非法 speaker 应报错") - } -} - -func TestValidateScript_BadLength(t *testing.T) { - lines := []scriptLine{ - {Speaker: "旁白", Text: "夜深了。"}, - {Speaker: "小军师", Text: "城门关得紧紧的。"}, - {Speaker: "小军师", Text: "我们怎么才能悄悄进城呢?"}, - } - if err := validateScript(lines, true, "小军师"); err == nil { - t.Fatal("台词不足 8 字应报错") - } -} -``` - -- [ ] **Step 3: 运行确认失败** - -Run: `go test ./cmd/genasset/ -run TestValidateScript -v` -Expected: 编译失败(validateScript / scriptLine 未定义) - -- [ ] **Step 4: 实现 scripts.go** - -创建 `cmd/genasset/scripts.go`: - -```go -package main - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "sync" - "unicode/utf8" - - "github.com/gogf/gf/v2/database/gdb" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/glog" - - "36wisdom/biz/consts" - "36wisdom/common" -) - -// scriptLine 台词流单句(导入时补 pinyin) -type scriptLine struct { - Speaker string `json:"speaker"` - Text string `json:"text"` - Emotion string `json:"emotion"` -} - -// scriptDraft 草稿文件结构(人工精修对象;pinyin 不入草稿,导入时统一生成) -type scriptDraft struct { - LevelID int64 `json:"level_id"` - NodeID int64 `json:"node_id"` - Character string `json:"character"` - Decision bool `json:"decision"` - Content string `json:"content"` - Options []string `json:"options,omitempty"` - Script []scriptLine `json:"script"` -} - -func scriptDir(levelID int64) string { - return filepath.Join(assetDir, "scripts", fmt.Sprintf("%d", levelID)) -} - -func scriptPath(levelID, nodeID int64) string { - return filepath.Join(scriptDir(levelID), fmt.Sprintf("%d.json", nodeID)) -} - -// pendingScriptNodes 待生成/待导入剧本的节点(script 为空) -func pendingScriptNodes(fl genFlags) []gdb.Record { - m := g.DB().Model(consts.TableSceneNode).Where("script IS NULL OR script = ''") - if fl.strategy > 0 { - levelIDs, _ := strategyScope(fl) - m = m.WhereIn("level_id", levelIDs) - } - rows, err := m.All(ctx) - if err != nil { - glog.Fatal(ctx, err) - } - return rows -} - -// ---------- 生成 ---------- - -func genScripts(fl genFlags) { - rows := pendingScriptNodes(fl) - if len(rows) == 0 { - fmt.Println("[scripts] 无待生成节点") - return - } - charNames := scriptCharNames(rows) - optsByNode := scriptOptions(rows) - sem := make(chan struct{}, 2) - var wg sync.WaitGroup - for _, r := range rows { - if !fl.force && fileExists(scriptPath(r["level_id"].Int64(), r["id"].Int64())) { - continue // 幂等:草稿已存在跳过(--force 覆盖;防重跑覆盖人工精修草稿) - } - wg.Add(1) - sem <- struct{}{} - go func(r gdb.Record) { - defer wg.Done() - defer func() { <-sem }() - if err := genNodeScript(r, charNames, optsByNode); err != nil { - glog.Errorf(ctx, "[scripts] node %d 失败: %v", r["id"].Int64(), err) - } - }(r) - } - wg.Wait() -} - -func scriptCharNames(rows []gdb.Record) map[int64]string { - charIDs := map[int64]bool{} - for _, r := range rows { - if cid := r["character_id"].Int64(); cid > 0 { - charIDs[cid] = true - } - } - names := map[int64]string{} - if len(charIDs) == 0 { - return names - } - ids := make([]int64, 0, len(charIDs)) - for id := range charIDs { - ids = append(ids, id) - } - recs, err := g.DB().Model(consts.TableElement).WhereIn("id", ids).All(ctx) - if err != nil { - glog.Fatal(ctx, err) - } - for _, r := range recs { - names[r["id"].Int64()] = r["name"].String() - } - return names -} - -func scriptOptions(rows []gdb.Record) map[int64][]string { - var nodeIDs []int64 - for _, r := range rows { - nodeIDs = append(nodeIDs, r["id"].Int64()) - } - byNode := map[int64][]string{} - if len(nodeIDs) == 0 { - return byNode - } - opts, err := g.DB().Model(consts.TableNodeOption).WhereIn("node_id", nodeIDs).All(ctx) - if err != nil { - glog.Fatal(ctx, err) - } - for _, o := range opts { - nid := o["node_id"].Int64() - byNode[nid] = append(byNode[nid], o["text"].String()) - } - return byNode -} - -func genNodeScript(node gdb.Record, charNames map[int64]string, optsByNode map[int64][]string) error { - nid := node["id"].Int64() - charName := charNames[node["character_id"].Int64()] - if charName == "" { - charName = "小军师" - } - decision := node["result_type"].Int() == 0 - var tail string - if decision { - opts := optsByNode[nid] - lines := make([]string, 0, len(opts)) - for i, o := range opts { - lines = append(lines, fmt.Sprintf("%d. %s", i+1, o)) - } - tail = "选项:\n" + strings.Join(lines, "\n") - } else { - tail = "这是结局节点:由角色说出结局与鼓励,不做提问。" - } - user := fmt.Sprintf(scriptUser, node["content"].String(), charName, tail) - out, err := genClient.chat(ctx, scriptSystem, user) - if err != nil { - return err - } - var resp struct { - Script []scriptLine `json:"script"` - } - if err := json.Unmarshal([]byte(out), &resp); err != nil { - return fmt.Errorf("剧本 JSON 解析失败: %v(输出: %s)", err, truncate(out, 200)) - } - if err := validateScript(resp.Script, decision, charName); err != nil { - return fmt.Errorf("剧本校验失败: %v(输出: %s)", err, truncate(out, 300)) - } - draft := scriptDraft{ - LevelID: node["level_id"].Int64(), NodeID: nid, Character: charName, - Decision: decision, Content: node["content"].String(), Script: resp.Script, - } - if decision { - draft.Options = optsByNode[nid] - } - b, _ := json.MarshalIndent(draft, "", " ") - if err := os.MkdirAll(scriptDir(node["level_id"].Int64()), 0o755); err != nil { - return err - } - if err := os.WriteFile(scriptPath(node["level_id"].Int64(), nid), b, 0o644); err != nil { - return err - } - fmt.Printf("[scripts] ok node %d(%d 句)\n", nid, len(resp.Script)) - return nil -} - -// validateScript 校验台词流结构(生成与导入共用) -func validateScript(lines []scriptLine, decision bool, charName string) error { - if len(lines) < 3 || len(lines) > 6 { - return fmt.Errorf("台词应为 3-6 句,实际 %d 句", len(lines)) - } - hasNar := false - last := lines[len(lines)-1] - for _, l := range lines { - if l.Speaker == "旁白" { - hasNar = true - } else if l.Speaker != charName { - return fmt.Errorf("speaker %q 非法(应为 旁白 或 %s)", l.Speaker, charName) - } - if n := utf8.RuneCountInString(l.Text); n < 8 || n > 30 { - return fmt.Errorf("台词应 8-30 字,实际 %d 字:%s", n, l.Text) - } - if l.Emotion != "" && !validEmotion(l.Emotion) { - return fmt.Errorf("emotion %q 非法", l.Emotion) - } - } - if !hasNar { - return fmt.Errorf("至少 1 句旁白") - } - if decision { - if last.Speaker != charName { - return fmt.Errorf("决策节点末句必须是 %s 提问", charName) - } - if !strings.HasSuffix(last.Text, "?") && !strings.HasSuffix(last.Text, "?") { - return fmt.Errorf("决策节点末句必须以问号结尾:%s", last.Text) - } - } else if last.Speaker == "旁白" { - return fmt.Errorf("终局节点末句应为角色总结") - } - return nil -} - -func validEmotion(e string) bool { - switch e { - case "normal", "happy", "sad", "think", "surprise": - return true - } - return false -} - -// ---------- 导入 ---------- - -func importScripts(fl genFlags) { - rows := pendingScriptNodes(fl) - imported := 0 - for _, r := range rows { - p := scriptPath(r["level_id"].Int64(), r["id"].Int64()) - b, err := os.ReadFile(p) - if err != nil { - continue // 无草稿跳过 - } - var d scriptDraft - if err := json.Unmarshal(b, &d); err != nil { - glog.Errorf(ctx, "[scripts] 草稿解析失败 %s: %v", p, err) - continue - } - if d.NodeID != r["id"].Int64() { - glog.Errorf(ctx, "[scripts] 草稿 node 不匹配 %s", p) - continue - } - if err := validateScript(d.Script, d.Decision, d.Character); err != nil { - glog.Errorf(ctx, "[scripts] 草稿校验失败 %s: %v", p, err) - continue - } - lines := make([]map[string]string, 0, len(d.Script)) - for _, l := range d.Script { - lines = append(lines, map[string]string{ - "speaker": l.Speaker, "text": l.Text, - "pinyin": common.AnnotatePinyin(l.Text), "emotion": l.Emotion, - }) - } - b2, _ := json.Marshal(lines) - if _, err := g.DB().Model(consts.TableSceneNode).Ctx(ctx).Data(g.Map{"script": string(b2)}).Where("id", d.NodeID).Update(); err != nil { - glog.Errorf(ctx, "[scripts] 导入失败 node %d: %v", d.NodeID, err) - continue - } - fmt.Printf("[scripts] 导入 node %d\n", d.NodeID) - imported++ - } - fmt.Printf("[scripts] 导入完成:%d 个节点\n", imported) -} -``` - -- [ ] **Step 5: main.go 接线** - -`main.go` 三处修改: - -genFlags struct 加字段: - -```go - importScripts bool -``` - -parseFlags 加: - -```go - flag.BoolVar(&fl.importScripts, "import-scripts", false, "从草稿导入剧本到数据库(--strategy 限定计策)") -``` - -main() 的分发逻辑替换为: - -```go - if fl.listOnly { - printInventory(fl) - return - } - if fl.importScripts { - importScripts(fl) - return - } - if fl.only == "scripts" { - genScripts(fl) - return - } - if fl.only == "" || fl.only == "comments" { - genComments(fl) - } - if fl.only == "" || fl.only == "visuals" { - genVisuals(fl) - } -``` - -printInventory 中 `comments` 行后加: - -```go - fmt.Printf("待生成剧本节点:%d\n", len(pendingScriptNodes(fl))) -``` - -文件头注释 `// --only=comments` 行后加两行: - -```go -// go run ./cmd/genasset --only=scripts # 生成剧本草稿(workspace/scripts/) -// go run ./cmd/genasset --import-scripts # 草稿导入回填 scene_node.script -``` - -- [ ] **Step 6: 单测 + 编译** - -Run: `go test ./cmd/genasset/ -v && go build ./...` -Expected: TestValidateScript_* 全部 PASS(6 个);`go build ./...` 成功 - -- [ ] **Step 7: 清单验证** - -Run: `go run ./cmd/genasset --list 2>&1 | head -5` -Expected: 输出含「待生成剧本节点:N」(N 为 DB 中 script 为空节点数,>0)与既有 comments/visuals 清单 - -- [ ] **Step 8: Commit** - -```bash -git add cmd/genasset/prompts.go cmd/genasset/scripts.go cmd/genasset/scripts_test.go cmd/genasset/main.go -git commit -m "feat: genasset 剧本管线——--only=scripts 草稿生成 + --import-scripts 校验注音回填" -``` - -> 审查记录(c40d833 双审查:第一 APPROVE,第二 1 SHOULD-FIX,合并修复): -> - SHOULD-FIX(已修):genScripts 无草稿幂等检查,重跑覆盖人工精修草稿 → 循环内加「草稿存在且非 --force 则跳过」(fileExists(scriptPath) 检查),与 main.go 幂等文档契约一致 -> - 计划偏差(implementer 修正合理):TestValidateScript_FinalOK 原数据 2 句违反 3-6 句校验,改 3 句 -> - NIT(不修,记录):导入校验信任草稿自身 Decision/Character 字段(人工精修对象);互动入口节点(2/3/5/7/8)祈使指令类 content 强制末句提问可能套路化——Task 6 精修时留意;pinyin 字段为字符串的链路无单测(与 content_pinyin 同模式,风险低) - ---- - -### Task 6: 第 1 计试点生成 + 精修 + 端到端验证 - -**Files:** -- 运行产物:`workspace/scripts/1/*.json`(人工精修对象) - -前置:oMLX 运行中(127.0.0.1:18080);后端运行中。 - -- [ ] **Step 1: 生成第 1 计剧本草稿** - -Run: `go run ./cmd/genasset --only=scripts --strategy=1 2>&1 | tail -8` -Expected: `[scripts] ok node N(3-6 句)` ×第 1 计节点数(约 5-8 个);本地模型每节点 2-4 分钟,总耗时约 10-30 分钟 - -- [ ] **Step 2: 检查失败与草稿质量** - -Run: `ls workspace/scripts/1/ && cat workspace/scripts/1/*.json | head -60` -检查: -1. 每个节点有草稿文件(校验失败的节点会有日志,可 `--force` 重跑——先修 prompt 或手工改草稿) -2. 台词符合儿童口语、末句提问(决策节点)/ 总结(终局节点)、emotion 合理 - -- [ ] **Step 3: 人工精修 1-2 处** - -编辑 `workspace/scripts/1/*.json`:把至少 1 句改得更口语(例如把「我们须设法潜入」改为「我们想办法偷偷进去吧」),确认 JSON 仍合法(`node -e "JSON.parse(require('fs').readFileSync('<文件>','utf8')); console.log('ok')"` 或编辑器格式化)。 - -- [ ] **Step 4: 导入第 1 计** - -Run: `go run ./cmd/genasset --import-scripts --strategy=1` -Expected: `[scripts] 导入 node N` × 节点数;`导入完成:N 个节点` - -验证回填: - -```bash -sqlite3 data/36wisdom.db "SELECT id, substr(script, 1, 80) FROM scene_node WHERE script != '' AND level_id IN (SELECT id FROM level WHERE strategy_id=1);" -``` -Expected: 每行 script 为 JSON 开头 `[{"speaker":` 且含 pinyin 字段 - -- [ ] **Step 5: H5 端到端走查** - -走查 `http://localhost:5173/#/pages/play/play?level_id=<第1计首个关卡 id>`(id 查:`sqlite3 data/36wisdom.db "SELECT id, title FROM level WHERE strategy_id=1 ORDER BY sort_order LIMIT 3;"`): -1. 开场 SceneTheater 不变,点击「开始」进入剧情流 -2. 每句:气泡 + 自动朗读 + 逐字高亮(含拼音)+ 角色 mood 表情切换;播完提问句后 ActionCard 出现 -3. 选择 → 分支演出(晃动/音效)→ 回应气泡 ✓/✗ 融入剧情流 → 继续 → 下一节点继续演绎 -4. 走到终局节点:结局台词演绎 → 结算页(既有 confetti) -5. 连走 3 关(第 1 计 3 个关卡)确认无 console 报错 -6. 走一关 script 为空的关卡(如第 2 计)确认 v1 回退正常 - -发现前端 bug:走「先修计划文件 → 派 fixer → 验证 → 提交」流程(既有双审查流)。 - -- [ ] **Step 6: Commit** - -```bash -git add workspace/scripts/ -git commit -m "feat: 第 1 计剧本试点生成 + 人工精修 + 导入(剧情演绎端到端跑通)" -``` - ---- - -### Task 7: 全量剧本生成 + 文档 - -**Files:** -- Modify: `README.md`(功能清单「情境闯关」行) -- Modify: `技术设计.md`(4.13 剧场化设计补 v2 剧情演绎) - -- [ ] **Step 1: 后台全量生成** - -Run: `nohup go run ./cmd/genasset --only=scripts > workspace/scripts/gen.log 2>&1 &`(或 run_in_background) -Expected: 日志逐节点输出 `[scripts] ok node N`;36 计约 200 节点、并发 2,约 2-4 小时完成(用 `tail -f workspace/scripts/gen.log` 抽查进度;期间可并行做 Step 2-3) - -- [ ] **Step 2: README 更新** - -`README.md` 功能清单「情境闯关」行补充:关卡以剧情台词流演绎(旁白/角色对话 + 自动朗读 + 逐字高亮 + 表情切换),决策与点评嵌入剧情流;剧本由离线管线生成(oMLX 草稿 + 人工精修),见 genasset 用法。 - -- [ ] **Step 3: 技术设计.md 更新** - -`技术设计.md` 4.13 剧场化设计章节后补 v2 小节: -- `scene_node.script` 列:JSON 台词流 `{speaker, text, pinyin, emotion}`,3-6 句,决策节点末句角色提问、终局节点末句总结 -- 前端:StoryPlayer 演绎(气泡/朗读/逐字高亮/表情),play.vue 剧情流(script 空回退) -- 管线:`--only=scripts` 草稿 → 人工精修 `workspace/scripts/{level_id}/{node_id}.json` → `--import-scripts`(校验 + AnnotatePinyin)回填 -- 接口与判分零改动 - -- [ ] **Step 4: 全量导入** - -生成完成后: - -Run: `go run ./cmd/genasset --import-scripts 2>&1 | tail -3` -Expected: `导入完成:N 个节点`(N ≈ 全量节点数;校验失败的草稿先人工修后重跑导入) - -- [ ] **Step 5: 抽查走查** - -第 1、18、36 计各走一关剧情演绎(H5),确认无报错、台词质量合格。 - -- [ ] **Step 6: Commit** - -```bash -git add README.md 技术设计.md workspace/scripts/ -git commit -m "docs: 剧情演绎文档补充 + 全量剧本生成导入" -``` - ---- - -### Task 8: 后端 FinalSettle.final_node(v2.1 终局演绎数据层) - -> v2.1:choose 到终局时,结局台词目前从未播放(前端直接跳结算页)。本任务让终局节点(含 script)随 FinalSettle 一并返回,前端 Task 9 先演绎再跳结算。 - -**Files:** -- Modify: `biz/service/level_play.go:32-43`(FinalSettle struct) -- Modify: `biz/service/level_play.go:149-166`(Choose 终局分支) -- Modify: `biz/model/dto/level_play.go:18-28` -- Modify: `biz/controller/level_play.go:25-37` - -- [ ] **Step 1: service FinalSettle 加 FinalNode** - -`biz/service/level_play.go` FinalSettle struct 末尾加: - -```go - FinalNode *Node // 终局节点(含 script,v2.1 前端演绎结局台词后跳结算) -``` - -- [ ] **Step 2: Choose 终局分支返回终局 Node** - -`biz/service/level_play.go` Choose 终局分支改为**先 nodeOf 再结算**(内容表只读查询失败则未结算,无副作用;与决策分支同顺序): - -```go - // v2.1:终局节点剧本先于结算构建(只读查询失败则未结算,无副作用) - finalNode, err := nodeOf(ctx, levelRec, nextRec) - if err != nil { - return nil, nil, err - } - // 终局:路径流水在锁内结算后记录,避免状态读到本次到达导致重复结算误判 - settle, err := common.WithLock(ctx, fmt.Sprintf("child:%d:level:%d", childId, levelId), 10*time.Second, 3, 200*time.Millisecond, func() (*FinalSettle, error) { - return s.settle(ctx, child, levelRec, nextRec, nodeId, optionId) - }) - if err != nil { - return nil, nil, err - } - settle.FinalNode = finalNode - return nil, settle, nil -``` - -(`nodeOf` 与决策分支同函数、同签名,构建选项 + 元素 + buildNode;只读查询,锁外执行安全。审查补充:nodeOf 与结算无数据依赖,先构建可避免「已结算但返回 err」的异常路径。) - -> 审查记录(aea79d8 双审查 APPROVE): -> - SHOULD-FIX(已修):原实现「锁内结算 → 锁外 nodeOf」,nodeOf 失败时已结算却返回 err(logRoute 多写一条终局流水 → FailStreak 误算)。改为 nodeOf 先于 WithLock,失败则未结算。 -> - NIT(不修):终局节点无选项,nodeOf 内 NodeOption 查询返回空集,成本极低;FinalNode 透出链路无新增单测,Step 5 既有测试通过即可 - -- [ ] **Step 3: dto FinalSettle 加 FinalNode** - -`biz/model/dto/level_play.go` FinalSettle 末尾加: - -```go - FinalNode *NodeVO `json:"final_node"` -``` - -- [ ] **Step 4: controller 映射** - -`biz/controller/level_play.go` Choose 的 `res.Final = &dto.FinalSettle{...}` 之后加: - -```go - if settle.FinalNode != nil { - vo := nodeVO(settle.FinalNode) - res.Final.FinalNode = &vo - } -``` - -- [ ] **Step 5: 编译 + 单测** - -Run: `go build ./... && go test ./biz/service/ ./cmd/genasset/` -Expected: 全部 PASS(SettleFinal 纯函数不受影响) - -- [ ] **Step 6: Commit** - -```bash -git add biz/service/level_play.go biz/model/dto/level_play.go biz/controller/level_play.go -git commit -m "feat: FinalSettle 增加 final_node——终局节点剧本透出(v2.1 终局演绎数据层)" -``` - ---- - -### Task 9: 前端全自动演出(v2.1) - -> 用户走查反馈:情景执行依赖点按钮、文字太多。本任务:开场自动进入、回应气泡自动继续、终局台词先演绎再跳结算、StoryPlayer 已播行折叠。 - -**Files:** -- Modify: `ui-src/src/components/SceneTheater.vue` -- Modify: `ui-src/src/components/StoryPlayer.vue` -- Modify: `ui-src/src/pages/play/play.vue` - -- [ ] **Step 1: SceneTheater 移除「开始」按钮,朗读结束自动 done** - -`SceneTheater.vue` 三处修改: - -a) 模板删除 `theater-actions` 整块: - -```html - - 开始 › - -``` - -b) mounted 改为朗读结束自动 emit done: - -```js - mounted() { - this.speaking = true - speak(this.content, this.audio) - // v2.1:朗读结束自动进入决策幕(「跳过」仍可手动跳过) - setTimeout(() => { this.speaking = false; this.$emit('done') }, Math.min(3000, this.content.length * 300)) - }, -``` - -c) 删除样式(不再有按钮): - -```css -.theater-actions { display: flex; justify-content: center; margin-top: 32rpx; position: relative; z-index: 2; } -.theater-go { font-size: 34rpx; min-width: 240rpx; animation: t-pop 0.5s ease 1.2s backwards; } -``` - -- [ ] **Step 2: StoryPlayer 已播行折叠** - -`StoryPlayer.vue` 模板 `story-lines` 循环改为只保留当前行 + 上一行(v-show 保持 DOM 存活,fading 淡出): - -```html - - -``` - -CSS `.story-line.done` 替换为: - -```css -.story-line.fading { opacity: 0.35; } -``` - -(`done` 类不再被引用,一并删除;`active` 保持现状。) - -- [ ] **Step 3: play.vue 终局演绎 + 回复自动继续** - -`play.vue` 六处修改: - -a) data 加: - -```js - replyTimer: null, - playingFinal: false, - finalNode: null, -``` - -b) onUnload 清回复计时器: - -```js - onUnload() { - if (this.branchTimer) clearTimeout(this.branchTimer) - if (this.replyTimer) clearTimeout(this.replyTimer) - }, -``` - -c) computed 加: - -```js - finalScript() { - return this.finalNode ? parseScript(this.finalNode.script) : null - } -``` - -d) 模板在 `v-if="curNode && isStoryNode"` 之前插入终局演绎分支(v-if 优先于剧情流,避免终局节点 script 非空时走错分支): - -```html - - - - -``` - -e) submit 的 `else if (data.final)` 分支替换为: - -```js - } else if (data.final) { - uni.setStorageSync(RESULT_KEY, { - level_id: this.levelId, - title: this.detail.title, - scene_name: this.detail.scene ? this.detail.scene.name : '', - route_steps: this.routeSteps, - final: data.final - }) - this.choosing = false - // v2.1:终局节点有剧本 → 先演绎结局台词再跳结算;无剧本直接跳(回退) - if (data.final.final_node && parseScript(data.final.final_node.script)) { - this.finalNode = data.final.final_node - this.playingFinal = true - } else { - playSound('finish') - uni.redirectTo({ url: '/pages/result/result' }) - } - } -``` - -f) branchTimer 内剧情流分支(`this.isStoryNode`)加 3 秒自动继续: - -```js - if (this.isStoryNode) { - this.reply = fb - this.showOptions = false // 提交后隐藏选项区,防重复点击 - // v2.1:回应气泡约 3 秒自动继续(点击「继续」立即继续) - this.replyTimer = setTimeout(() => this.autoContinue(), 3000) - } else { - this.feedback = fb - } -``` - -g) methods 加 `autoContinue` / `gotoResult`,`continuePlay` 开头清计时器: - -```js - autoContinue() { - if (this.reply && !this.choosing) this.continuePlay() - }, - gotoResult() { - playSound('finish') - uni.redirectTo({ url: '/pages/result/result' }) - }, - continuePlay() { - if (this.replyTimer) { clearTimeout(this.replyTimer); this.replyTimer = null } - if (this.isStoryNode) { - this.curNode = this.reply.next - this.reply = null - this.showOptions = false - } else { - this.curNode = this.feedback.next - this.feedback = null - } - this.mood = '🤔' - } -``` - -- [ ] **Step 4: 构建验证** - -Run: `cd ui-src && npx vite build --mode h5 2>&1 | tail -3` -Expected: 构建成功无报错 - -- [ ] **Step 5: H5 端到端走查** - -后端与 H5 dev server 运行中,走查 `http://localhost:5173/#/pages/play/play?level_id=<第1计关卡>`(第 1 计剧本已导入): -1. 开场朗读结束自动进入剧情流(无「开始」按钮);「跳过 ›」立即进入 -2. 剧情流只显示当前行 + 上一行淡出,无 3-6 行堆叠 -3. 选择后回应气泡约 3 秒自动进入下一节点;点击「继续 ›」立即进入;连点不双跳 -4. 走到终局:结局台词先演绎(跳过可直达结算),播完自动跳结算页;结算数据正常(final 已在 RESULT_KEY) - -- [ ] **Step 6: Commit** - -```bash -git add ui-src/src/components/SceneTheater.vue ui-src/src/components/StoryPlayer.vue ui-src/src/pages/play/play.vue -git commit -m "feat: 全自动演出——开场自动进入/回复自动继续/终局台词演绎/已播行折叠(v2.1)" -``` - -> 审查记录(0f8a371 双审查 APPROVE + d571e89 SHOULD-FIX + NIT 合并提交): -> - SHOULD-FIX(已修):终局演绎块与剧情流块是并列 v-if 而非互斥——终局播放期间 `curNode && isStoryNode` 仍为 true,旧节点块(已播行 + ActionCard,choosing=false 时可点击)残留可见可交互。修复:剧情流块与 v1 卡片块加 `!playingFinal` 守卫,三块互斥。 -> - NIT1(已修):continuePlay 无空守卫,点击与 3s 自动同帧时 reply/feedback 为 null 抛 TypeError → 开头加 `if (!this.reply && !this.feedback) return` -> - NIT2(已修):SceneTheater 开场计时器不随卸载清理,跳过后残留 done 触发 → minTimer/autoTimer 存字段,beforeUnmount 清理 -> - NIT3(已修):开场朗读 3s 上限必然截断长文本(30 字约 8-12s 朗读被砍)→ 改 speak onEnd 驱动自动进入(最短展示 1.2s 防无 TTS 闪退,15s 兜底防 onEnd 缺失卡死) -> - NIT4(已修):终局 StoryPlayer done 与「跳过」双触发 gotoResult → doneJumped 一次性守卫 - ---- - -### Task 10: 纯语音演绎 + 估时兜底 + 8080 托管(v2.2) - -> 用户走查反馈:① 必须点跳过才出选项(speak onEnd 部分环境不回调,逐句推进靠 20s forceNext 兜底,体验像卡死);② 剧情不需要文字展示区,直接语音播放;③ 前端应与后端共用 :8080。规格:spec「v2.2 修订」章节。 - -**Files:** -- Modify: `ui-src/src/components/StoryPlayer.vue`(纯语音 + 估时兜底 + 审查加固) -- Modify: `ui-src/src/components/SceneTheater.vue`(估时兜底 + 20s 封顶) -- Modify: `ui-src/src/pages/play/play.vue`(移除 :show-pinyin 传参) -- Modify: `main.go`(SetServerRoot 托管前端产物 + /static 素材挂载) - -- [ ] **Step 1: StoryPlayer 改纯语音演绎** - -`StoryPlayer.vue` 整体重写为:无文字展示区(删 RubyText 气泡、逐字高亮、pinyin),保留角色 + mood 表情 + 跳过;句推进改「speak onEnd + 文本长度估时兜底」: - -```vue - - - - - -``` - -- [ ] **Step 2: play.vue 移除 StoryPlayer 的 :show-pinyin 传参** - -两处 ``(剧情流 + 终局演绎)删除 `:show-pinyin="showPinyin"` 属性。 - -- [ ] **Step 3: main.go 托管前端产物(8080 单端口)** - -`main.go` 在 `controller.Register(s)` 后加(**须用 SetServerRoot**——走查发现 GoFrame v2.10.2 `AddStaticPath("/", ...)` 因前缀防误匹配守卫 `uri[len(prefix)] != '/'` 只服务根路径,`/assets/*` 子路径全部 404;素材目录 `ui-src/static` 以 `/static` 前缀单独挂载,与后端 `scene.image` 返回的 `/static/generated/...` URL 对应): - -```go - // 生产形态:前端产物 ui-src/dist 由后端 :8080 统一托管(前后端同端口) - if _, err := os.Stat("ui-src/dist"); err == nil { - s.SetServerRoot("ui-src/dist") - } - if _, err := os.Stat("ui-src/static"); err == nil { - s.AddStaticPath("/static", "ui-src/static") - } -``` - -- [ ] **Step 4: 构建 + 验证** - -Run: `cd ui-src && npx vite build --mode h5 2>&1 | tail -3 && cd .. && go build ./...` -Expected: 构建成功;产物在 `ui-src/dist` - -重启后端后走查 `http://localhost:8080`(H5 构建,前后端同端口): -1. 打开即应用首页(非 404) -2. 第 1 计关卡:剧情纯语音无文字区;台词播完选项自动出现(不点跳过) -3. 模拟 onEnd 不回调(playwright 注入 stub speechSynthesis:speak 不发声、不发事件):逐句按估时兜底推进,选项最终自动出现,不卡死 -4. 终局台词演绎后自动跳结算(8080 端口同样生效) - -- [ ] **Step 5: Commit** - -```bash -git add ui-src/src/components/StoryPlayer.vue ui-src/src/pages/play/play.vue main.go -git commit -m "feat: 剧情纯语音演绎(无文字区)+ 句推进估时兜底 + 前端产物由 :8080 托管(v2.2)" -``` - -> 审查记录(a775fc7 + 7386715 双审查 APPROVE + SHOULD-FIX 合并修复): -> - 走查发现 `AddStaticPath("/", ...)` 前缀守卫只服务根路径 → SetServerRoot + /static 挂载(main.go) -> - SHOULD-FIX 1:speak 抛异常时兜底定时器未注册 → 定时器前置 + try/catch(StoryPlayer playLine) -> - SHOULD-FIX 2:迟到 onEnd 提前放行当前句 → 行号守卫(lineIndex===i && !ended)加在 onEnd 与估时闭包 -> - SHOULD-FIX 3(记录待办):Dockerfile/docker-compose.yml 缺失——ui-src/dist 与 ui-src/static 均不入 git,全新环境 :8080 空白页;待 Task 7/10 素材管线收尾后补部署链 -> - NIT:lineDone.fb 死字段删除;SceneTheater 估时封顶 20s;注释同步 -> - playwright 双场景走查(:8080,addInitScript stub speechSynthesis + uni 格式 storage 种子):场景A onEnd 正常回调 / 场景B onEnd 永不回调,均「剧情无文字区 + 选项自动出现(未点跳过)」PASS - ---- - -## 验收对照(spec v2 + v2.1 + v2.2) - -| 验收标准 | 任务 | -|---|---| -| 决策节点台词流演绎(气泡/朗读/逐字高亮/表情,末句提问) | Task 3 + Task 6 | -| ActionCard 嵌入剧情流(触控 5 种同) | Task 4 | -| 角色回应气泡融入剧情流,无弹窗 | Task 4 Step 3 | -| script 空回退 v1 呈现 | Task 4 Step 2(v-else 分支) | -| 管线生成→精修→导入→前端生效;校验拒绝不合规草稿 | Task 5 + Task 6 | -| level/choose 判分与完美机制不变 | Task 1(仅加列透出);Task 6 Step 5 走查确认 | -| 开场自动进入决策幕(无「开始」按钮),跳过可用 | Task 9 Step 1 | -| 回应气泡约 3 秒自动继续,点击立即继续,无重复推进 | Task 9 Step 3 | -| 终局台词先演绎再自动跳结算;final_node.script 空直接跳 | Task 8 + Task 9 Step 3 | -| StoryPlayer 已播行折叠(当前行 + 上一行淡出) | Task 9 Step 2 | - -| 验收标准 | 任务 | -|---|---| -| 决策节点台词流演绎(气泡/朗读/逐字高亮/表情,末句提问) | Task 3 + Task 6 | -| ActionCard 嵌入剧情流(触控 5 种同) | Task 4 | -| 角色回应气泡融入剧情流,无弹窗 | Task 4 Step 3 | -| script 空回退 v1 呈现 | Task 4 Step 2(v-else 分支) | -| 管线生成→精修→导入→前端生效;校验拒绝不合规草稿 | Task 5 + Task 6 | -| level/choose 判分与完美机制不变 | Task 1(仅加列透出);Task 6 Step 5 走查确认 | diff --git a/docs/superpowers/plans/2026-08-13-story-theater.md b/docs/superpowers/plans/2026-08-13-story-theater.md deleted file mode 100644 index e127001..0000000 --- a/docs/superpowers/plans/2026-08-13-story-theater.md +++ /dev/null @@ -1,765 +0,0 @@ -# 故事剧场化实现计划(三幕演出 + 动作卡全互动呈现) - -> **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:** 把每关从"文字问答"升级为"儿童小剧场":开场演出(旁白+字幕淡入+角色道具入场)、决策动作卡(替代文字选项列表)、分支演出(选择后动画过渡)、结局演出(庆祝/鼓励动画),决策树数据模型零改动。 - -**Architecture:** 纯前端呈现层升级。新增 `theater.js`(互动形态判定 + 演出数据提取纯函数)、`ActionCard.vue`(动作卡)、`SceneTheater.vue`(开场演出覆盖层);改造 `play.vue`(开场集成 + 动作卡 + 分支演出)与 `result.vue`(结局演出)。触控互动(PropPick 等 5 种)仅用于已有 config 互动节点;普通决策节点一律用动作卡呈现(**计划级修正**,见下)。全部 CSS 动画,无新依赖。 - -**Tech Stack:** uni-app (Vue 3) H5 先行、现有 RubyText/SpeakButton/speech.js/visual.js/sound.js、CSS keyframes。 - -**对规格的修正(重要)**:spec 中"按选项内容特征自动判定互动形态 a-e"与现有交互协议冲突——触控互动组件只有 success/fail 双出口(提交 options[0]/options[1]),而普通决策节点的 2-4 个选项各自指向独立分支,前端无法静态判定"最佳选项"。修正:普通决策节点(interaction_type=1)一律渲染动作卡(多分支语义完整保留),触控互动仅用于 config 非空的互动节点。全 36 关文字选项列表消灭的目标不变(动作卡兜底所有普通节点)。 - ---- - -### Task 1: `ui-src/src/utils/theater.js`(互动判定 + 演出数据提取) - -**Files:** -- Create: `ui-src/src/utils/theater.js` - -- [ ] **Step 1: 写文件** - -```js -// 剧场化工具:互动形态判定 + 开场演出数据提取(决策树模型零改动) - -const INTERACTION_MAP = { 2: 'PropPick', 3: 'StepSort', 5: 'DragPlace', 7: 'FindSpot', 8: 'LinkMatch' } - -// 互动形态判定:config 互动节点 → 对应触控组件;普通决策节点 → 动作卡 -// (触控组件只有 success/fail 双出口,普通节点多分支选项语义由 ActionCard 完整保留) -export function pickInteraction(node) { - if (node && node.config && INTERACTION_MAP[node.interaction_type]) { - return INTERACTION_MAP[node.interaction_type] - } - return 'ActionCard' -} - -// 开场演出道具:入口节点选项的道具名去重,最多 4 个 -export function entryProps(entry) { - const names = [] - for (const o of (entry && entry.options) || []) { - if (o.prop && o.prop.name && !names.includes(o.prop.name)) { - names.push(o.prop.name) - if (names.length >= 4) break - } - } - return names -} - -// 开场演出角色:入口节点人物(可能为空) -export function entryCharacter(entry) { - return entry && entry.character ? entry.character : null -} -``` - -- [ ] **Step 2: H5 编译检查** - -Run: `curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/src/utils/theater.js` 返回 200。 - -- [ ] **Step 3: 提交** - -```bash -git add ui-src/src/utils/theater.js -git commit -m "feat: 剧场化工具 theater.js(互动形态判定 + 开场演出数据提取)" -``` - ---- - -### Task 2: `ui-src/src/components/ActionCard.vue`(动作卡组件) - -**Files:** -- Create: `ui-src/src/components/ActionCard.vue` - -动作卡 = 现有选项列表的升级呈现:每选项一张卡(序号徽章 + 道具图标/emoji + 短语拼音注音 + 听一听 + 按压弹跳 + c1-c4 四色边框)。 - -- [ ] **Step 1: 写组件** - -```vue - - - - - -``` - -- [ ] **Step 2: H5 编译检查** - -Run: `curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/src/components/ActionCard.vue` 返回 200。 - -- [ ] **Step 3: 提交** - -```bash -git add ui-src/src/components/ActionCard.vue -git commit -m "feat: 动作卡组件 ActionCard(选项图标/拼音/听读/按压动画/四色边框)" -``` - ---- - -### Task 3: `ui-src/src/components/SceneTheater.vue`(开场演出覆盖层) - -**Files:** -- Create: `ui-src/src/components/SceneTheater.vue` - -开场演出:场景横幅 + 角色滑入 + 道具飞入 + 旁白自动朗读 + 字幕逐字淡入 + 跳过按钮。演出播完(或点跳过)后 emit done,父组件隐藏本层。 - -- [ ] **Step 1: 写组件** - -```vue - - - - - -``` - -- [ ] **Step 2: H5 编译检查** - -Run: `curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/src/components/SceneTheater.vue` 返回 200。 - -- [ ] **Step 3: 提交** - -```bash -git add ui-src/src/components/SceneTheater.vue -git commit -m "feat: 开场演出组件 SceneTheater(旁白朗读+字幕逐字淡入+角色入场+道具飞入+跳过)" -``` - ---- - -### Task 4: `play.vue` 集成(开场演出 + 动作卡 + 分支演出) - -**Files:** -- Modify: `ui-src/src/pages/play/play.vue` - -- [ ] **Step 1: script 部分改造** - -` -``` - -- [ ] **Step 2: template 部分改造** - -在 `` 之前插入开场演出覆盖层,并把选项列表块替换为 ActionCard: - -开场覆盖层(插在 ``(topbar 结束)之后、`` 之前): - -```html - - -``` - -场景卡片加演出状态类(`.scene-card` 增加 `:class="{ 'fx-shake': sceneFx }"`): - -```html - -``` - -互动分发整块替换(原「互动组件(触控 5 种)」v-if 链 + 「选项选择(type 1)」v-else-if 的 `class="options"` 列表 + 「soon 兜底」三段,替换为 activeInteraction 驱动): - -```html - - - - - - - - - - 该互动玩法(类型 {{ curNode.interaction_type }})敬请期待 -``` - -- [ ] **Step 3: style 部分追加** - -在 `