This commit is contained in:
2026-08-14 13:34:21 +08:00
parent 8ba749234e
commit 96348ce39b
53 changed files with 43 additions and 7586 deletions
-1
View File
@@ -1,6 +1,5 @@
# 运行时数据与本地环境 # 运行时数据与本地环境
workspace/* workspace/*
!workspace/.gitkeep
data/* data/*
!data/.gitkeep !data/.gitkeep
.idea/ .idea/
+1
View File
@@ -12,6 +12,7 @@
| biz/dao/ | 单表数据访问,每表一个文件 | 无业务逻辑;查询经 base_dao 缓存 | | biz/dao/ | 单表数据访问,每表一个文件 | 无业务逻辑;查询经 base_dao 缓存 |
| biz/service/ | 业务逻辑:规则校验、文件读写、事务、跨表组装、调用 dao、LLM 编排 | 不直接写 HTTP 响应(例外见下);并行任务走 common 协程池 | | biz/service/ | 业务逻辑:规则校验、文件读写、事务、跨表组装、调用 dao、LLM 编排 | 不直接写 HTTP 响应(例外见下);并行任务走 common 协程池 |
| biz/controller/ | 接口层:接收参数、调用 service、组装返回值 | 见「分层职责规范」;禁止调用 dao | | 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 | | 前端目录 | 前台 `ui-src/` 为 uni-app (Vue 3) 多端工程(Android/iOS/平板/微信小程序/H5);后台 `admin-src/` 为 Vue 3 + Element Plus Web 工程 | 前台/后台开发用 vite 代理,生产构建产物 `dist` 由后端托管;多端工程差异见技术设计.md |
| 运行时数据目录 | SQLite 库、上传/解析文件(本项目 `data/` `workspace/`) | 不提交 git;删除即丢失数据,改动前先确认 | | 运行时数据目录 | SQLite 库、上传/解析文件(本项目 `data/` `workspace/`) | 不提交 git;删除即丢失数据,改动前先确认 |
-29
View File
@@ -1,34 +1,5 @@
package controller package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type adminUser struct{} type adminUser struct{}
var AdminUser = &adminUser{} 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(),
}
}
-44
View File
@@ -1,49 +1,5 @@
package controller package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type badge struct{} type badge struct{}
var Badge = &badge{} 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(),
}
}
-31
View File
@@ -1,36 +1,5 @@
package controller package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type chapterReview struct{} type chapterReview struct{}
var ChapterReview = &chapterReview{} 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(),
}
}
-34
View File
@@ -1,39 +1,5 @@
package controller package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type element struct{} type element struct{}
var Element = &element{} 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(),
}
}
-45
View File
@@ -1,50 +1,5 @@
package controller package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type lifeTask struct{} type lifeTask struct{}
var LifeTask = &lifeTask{} 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(),
}
}
-48
View File
@@ -1,53 +1,5 @@
package controller package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type nodeOption struct{} type nodeOption struct{}
var NodeOption = &nodeOption{} 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(),
}
}
-47
View File
@@ -1,52 +1,5 @@
package controller package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type prize struct{} type prize struct{}
var Prize = &prize{} 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(),
}
}
-32
View File
@@ -1,37 +1,5 @@
package controller package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type redemption struct{} type redemption struct{}
var Redemption = &redemption{} 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(),
}
}
-53
View File
@@ -1,58 +1,5 @@
package controller package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type sceneNode struct{} type sceneNode struct{}
var SceneNode = &sceneNode{} 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(),
}
}
-29
View File
@@ -1,34 +1,5 @@
package controller package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type userBadge struct{} type userBadge struct{}
var UserBadge = &userBadge{} 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(),
}
}
-29
View File
@@ -1,34 +1,5 @@
package controller package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type userCollection struct{} type userCollection struct{}
var UserCollection = &userCollection{} 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(),
}
}
-45
View File
@@ -1,50 +1,5 @@
package controller package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type userProgress struct{} type userProgress struct{}
var UserProgress = &userProgress{} 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(),
}
}
-32
View File
@@ -1,37 +1,5 @@
package controller package controller
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/model/dto"
"36wisdom/biz/service"
)
type userRouteLog struct{} type userRouteLog struct{}
var UserRouteLog = &userRouteLog{} 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(),
}
}
-14
View File
@@ -1,15 +1 @@
package dto 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"`
}
-23
View File
@@ -1,24 +1 @@
package dto 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"`
}
-16
View File
@@ -1,17 +1 @@
package dto 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"`
}
-19
View File
@@ -1,20 +1 @@
package dto 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"`
}
-24
View File
@@ -1,25 +1 @@
package dto 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"`
}
-30
View File
@@ -1,31 +1 @@
package dto 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"`
}
-26
View File
@@ -1,27 +1 @@
package dto 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"`
}
-17
View File
@@ -1,18 +1 @@
package dto 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"`
}
-35
View File
@@ -1,36 +1 @@
package dto 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"`
}
-14
View File
@@ -1,15 +1 @@
package dto 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"`
}
-14
View File
@@ -1,15 +1 @@
package dto 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"`
}
-28
View File
@@ -1,29 +1 @@
package dto 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"`
}
-18
View File
@@ -1,19 +1 @@
package dto 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"`
}
-13
View File
@@ -1,18 +1,5 @@
package service package service
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"36wisdom/biz/dao"
)
type adminUser struct{} type adminUser struct{}
var AdminUser = &adminUser{} 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()
}
-6
View File
@@ -3,7 +3,6 @@ package service
import ( import (
"context" "context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/frame/g"
"36wisdom/biz/dao" "36wisdom/biz/dao"
@@ -13,11 +12,6 @@ type chapterReview struct{}
var ChapterReview = &chapterReview{} 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 创建温故记录。 // Insert 创建温故记录。
func (s *chapterReview) Insert(ctx context.Context, childId, strategyId int64, levelIds string) (int64, error) { func (s *chapterReview) Insert(ctx context.Context, childId, strategyId int64, levelIds string) (int64, error) {
return dao.ChapterReview.InsertAndReturnId(ctx, g.Map{ return dao.ChapterReview.InsertAndReturnId(ctx, g.Map{
-8
View File
@@ -79,11 +79,3 @@ func GetUid(ctx context.Context) int64 {
} }
return 0 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 ""
}
-5
View File
@@ -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() _, err := d.Model().Ctx(ctx).WherePri(pk).Data(data).Update()
return err return err
} }
func (d *BaseDao) DeleteByPk(ctx context.Context, pk int64) error {
_, err := d.Model().Ctx(ctx).WherePri(pk).Delete()
return err
}
@@ -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:** 完成 M1Go+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 / SQLiteglebarez/sqlite 或 go-sqlite3/ uni-app Vue3H5 先行)。
**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.jsonembed
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: entity19 张表映射)
**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 <token>` 解析,注入 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/liststrategy 全量(缓存,TTL 300s+ 本用户 user_progress 按 level IN 取星(≤100 分批)→ 按 strategy 分组 → 解锁判断(unlock_before 为空 或 前置计完美关卡数=前置计关卡总数);content_version 不一致的关卡标记"可重新挑战"
- level/detaillevel + 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** 组装 DTOentity → 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_lognode/option/result_type
3. 终局结算(事务):结算判定(按上面规则)→ 写 user_progress(取最高星 + perfect + content_version)→ 写 point_log + child.pointsdelta≠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:孩子列表 + 新建孩子(年龄段选择)
- mapstrategy/list 渲染六组地图网格,计策卡图标 + 星星 + 锁定态;点解锁章节 → 学堂弹层(strategy/detail 的 teach_content + 音频播放按钮)→ 进入 play
- playlevel/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*` 一致。
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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
<template>
<view class="action-cards">
<view
v-for="(o, idx) in node.options"
:key="o.option_id"
class="action-card"
:class="['c' + (idx + 1), { disabled: disabled }]"
@click="pick(o)"
>
<view class="ac-mark">{{ ['A', 'B', 'C', 'D'][idx] }}</view>
<view v-if="o.prop" class="ac-icon">
<image v-if="o.prop.image" class="ac-icon-img" :src="o.prop.image" mode="aspectFit" />
<text v-else class="ac-icon-emoji">{{ propEmoji(o.prop.name) }}</text>
</view>
<view class="ac-text">
<RubyText :text="o.text" :pinyin="o.text_pinyin" :show="showPinyin" />
</view>
<view class="ac-listen" @click.stop="listen(o)">🔊</view>
</view>
</view>
</template>
<script>
import RubyText from './RubyText.vue'
import { propVisual } from '../utils/visual.js'
import { speak } from '../utils/speech.js'
export default {
name: 'ActionCard',
components: { RubyText },
props: {
node: { type: Object, required: true }, // 决策节点(options 数组)
showPinyin: { type: Boolean, default: true },
disabled: { type: Boolean, default: false }
},
emits: ['choose'],
methods: {
pick(o) {
if (this.disabled) return
this.$emit('choose', o)
},
listen(o) {
speak(o.text, o.audio)
},
propEmoji(name) {
return propVisual(name).emoji
}
}
}
</script>
<style scoped>
.action-cards { display: flex; flex-direction: column; gap: 20rpx; }
.action-card { display: flex; align-items: center; background: #f8f2e8; border: 6rpx solid transparent; border-radius: 24rpx; padding: 26rpx 28rpx; transition: transform 0.15s; }
.action-card.disabled { opacity: 0.6; }
.action-card:active { transform: scale(0.96); }
.action-card.c1 { border-color: #ff9a3d; }
.action-card.c2 { border-color: #4ecdc4; }
.action-card.c3 { border-color: #6c8cff; }
.action-card.c4 { border-color: #a58cff; }
.ac-mark { width: 56rpx; height: 56rpx; border-radius: 50%; background: #ff9a3d; color: #fff; font-weight: 800; display: flex; align-items: center; justify-content: center; margin-right: 24rpx; flex-shrink: 0; }
.ac-icon { width: 88rpx; height: 88rpx; background: #fff7ec; border-radius: 20rpx; display: flex; align-items: center; justify-content: center; margin-right: 20rpx; flex-shrink: 0; }
.ac-icon-img { width: 64rpx; height: 64rpx; }
.ac-icon-emoji { font-size: 48rpx; }
.ac-text { flex: 1; font-size: 30rpx; font-weight: 600; color: #5b4636; }
.ac-listen { font-size: 36rpx; width: 64rpx; height: 64rpx; display: flex; align-items: center; justify-content: center; background: #fff0e5; border-radius: 50%; margin-left: 16rpx; flex-shrink: 0; }
</style>
```
- [ ] **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
<template>
<view class="theater" :style="{ background: sceneColor + 'ee' }">
<image v-if="sceneImage" class="theater-bg" :src="sceneImage" mode="aspectFill" />
<view class="theater-skip" @click="finish">跳过 </view>
<view class="theater-scene">
<view class="theater-emoji">{{ sceneEmoji }}</view>
<view class="theater-name">{{ sceneName }}</view>
</view>
<!-- 道具飞入右侧逐个 -->
<view class="theater-props">
<view
v-for="(p, i) in props"
:key="i"
class="theater-prop"
:style="{ animationDelay: 0.6 + i * 0.3 + 's' }"
>{{ propEmoji(p) }}</view>
</view>
<!-- 角色滑入左侧 -->
<view v-if="character" class="theater-char">
<image v-if="character.image" class="theater-char-img" :src="character.image" mode="aspectFit" />
<view v-else class="theater-char-avatar" :style="{ background: charColor }">{{ character.name.slice(0, 1) }}</view>
<view class="theater-char-mood">🤔</view>
</view>
<!-- 旁白字幕逐字淡入 + 拼音 -->
<view class="theater-sub card">
<view class="theater-sub-head">
<text class="theater-sub-label">📖 情境</text>
<text class="theater-sub-state">{{ speaking ? '正在朗读…' : '' }}</text>
</view>
<view class="theater-sub-ruby">
<view
v-for="(ch, i) in pairs"
:key="i"
class="theater-unit"
:style="{ animationDelay: i * 0.05 + 's' }"
>
<text v-if="ch.p && showPinyin" class="theater-py">{{ ch.p }}</text>
<text class="theater-ch">{{ ch.c }}</text>
</view>
</view>
</view>
<view class="theater-actions">
<view class="btn-primary theater-go" @click="finish">开始 </view>
</view>
</view>
</template>
<script>
import { parsePinyin, zipText } from '../utils/pinyin.js'
import { propVisual, personVisual } from '../utils/visual.js'
import { speak, stopSpeak } from '../utils/speech.js'
export default {
name: 'SceneTheater',
props: {
sceneName: { type: String, default: '' },
sceneEmoji: { type: String, default: '🏞️' },
sceneColor: { type: String, default: '#ffd08a' },
sceneImage: { type: String, default: '' },
content: { type: String, default: '' },
contentPinyin: { type: String, default: '' },
audio: { type: String, default: '' },
character: { type: Object, default: null }, // {name, image, id}
props: { type: Array, default: () => [] }, // 道具名数组
showPinyin: { type: Boolean, default: true }
},
emits: ['done'],
data() {
return { speaking: false }
},
computed: {
pairs() {
return zipText(this.content, parsePinyin(this.contentPinyin))
},
charColor() {
return personVisual(this.character && this.character.id ? this.character.id : 0).color
}
},
mounted() {
this.speaking = true
speak(this.content, this.audio)
setTimeout(() => { this.speaking = false }, Math.min(3000, this.content.length * 300))
},
beforeUnmount() {
stopSpeak()
},
methods: {
finish() {
stopSpeak()
this.$emit('done')
},
propEmoji(name) {
return propVisual(name).emoji
}
}
}
</script>
<style scoped>
.theater { position: fixed; inset: 0; z-index: 30; padding: 120rpx 48rpx 80rpx; display: flex; flex-direction: column; overflow: hidden; }
.theater-bg { position: absolute; inset: 0; width: 100%; height: 100%; }
.theater-skip { position: absolute; top: calc(24rpx + env(safe-area-inset-top)); right: 32rpx; font-size: 28rpx; color: #5b4636; background: rgba(255, 255, 255, 0.8); border-radius: 30rpx; padding: 10rpx 28rpx; z-index: 2; }
.theater-scene { display: flex; align-items: center; justify-content: center; gap: 16rpx; margin-bottom: 48rpx; animation: t-pop 0.6s ease; }
.theater-emoji { font-size: 88rpx; }
.theater-name { font-size: 44rpx; font-weight: 800; color: #5b4636; }
@keyframes t-pop { 0% { opacity: 0; transform: scale(0.5); } 70% { transform: scale(1.1); } 100% { opacity: 1; transform: scale(1); } }
.theater-props { position: absolute; top: 40%; right: 32rpx; display: flex; flex-direction: column; gap: 24rpx; z-index: 1; }
.theater-prop { font-size: 64rpx; opacity: 0; animation: prop-fly 0.8s ease forwards; }
@keyframes prop-fly { 0% { opacity: 0; transform: translateX(140rpx) rotate(40deg); } 70% { transform: translateX(-16rpx) rotate(-10deg); } 100% { opacity: 1; transform: translateX(0) rotate(0); } }
.theater-char { position: absolute; left: 32rpx; bottom: 36%; z-index: 1; opacity: 0; animation: char-in 0.9s cubic-bezier(0.34, 1.56, 0.64, 1) 0.2s forwards; }
.theater-char-img { width: 180rpx; height: 180rpx; }
.theater-char-avatar { width: 160rpx; height: 160rpx; border-radius: 50%; color: #fff; font-size: 72rpx; font-weight: 800; display: flex; align-items: center; justify-content: center; }
.theater-char-mood { position: absolute; right: -16rpx; bottom: -16rpx; font-size: 56rpx; }
@keyframes char-in { 0% { opacity: 0; transform: translateX(-220rpx); } 70% { transform: translateX(16rpx); } 100% { opacity: 1; transform: translateX(0); } }
.theater-sub { margin-top: auto; padding: 32rpx 36rpx; position: relative; z-index: 2; }
.theater-sub-head { display: flex; justify-content: space-between; margin-bottom: 12rpx; }
.theater-sub-label { font-size: 26rpx; color: #a08c74; font-weight: 700; }
.theater-sub-state { font-size: 24rpx; color: #ff6b35; }
.theater-sub-ruby { display: flex; flex-wrap: wrap; line-height: 1.9; }
.theater-unit { display: flex; flex-direction: column; align-items: center; margin: 0 2rpx; opacity: 0; animation: t-word 0.4s ease forwards; }
@keyframes t-word { 0% { opacity: 0; transform: translateY(12rpx); } 100% { opacity: 1; transform: translateY(0); } }
.theater-py { font-size: 20rpx; color: #ff6b35; line-height: 1.3; height: 28rpx; }
.theater-ch { font-size: 34rpx; line-height: 1.5; color: #5b4636; }
.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; }
</style>
```
- [ ] **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 部分改造**
`<script>` 段整体替换为:
```vue
<script>
import { api } from '../../api/index.js'
import { getChildId, getChildAge } from '../../store/user.js'
import { sceneVisual, personVisual } from '../../utils/visual.js'
import { playSound, isSoundEnabled, setSoundEnabled } from '../../utils/sound.js'
import { pickInteraction, entryProps, entryCharacter } from '../../utils/theater.js'
import RubyText from '../../components/RubyText.vue'
import SpeakButton from '../../components/SpeakButton.vue'
import SceneTheater from '../../components/SceneTheater.vue'
import ActionCard from '../../components/ActionCard.vue'
import PropPick from '../../components/interactions/PropPick.vue'
import FindSpot from '../../components/interactions/FindSpot.vue'
import DragPlace from '../../components/interactions/DragPlace.vue'
import StepSort from '../../components/interactions/StepSort.vue'
import LinkMatch from '../../components/interactions/LinkMatch.vue'
const RESULT_KEY = '36wisdom_result'
export default {
components: { RubyText, SpeakButton, SceneTheater, ActionCard, PropPick, FindSpot, DragPlace, StepSort, LinkMatch },
data() {
return {
childId: getChildId(),
levelId: 0,
detail: null,
curNode: null,
feedback: null,
choosing: false,
showPinyin: getChildAge() === '4-6',
routeSteps: [],
mood: '🤔',
combo: 0,
soundOn: isSoundEnabled(),
showIntro: false,
sceneFx: ''
}
},
onLoad(options) {
this.levelId = Number(options.level_id || 0)
this.load()
},
computed: {
scene() {
return sceneVisual(this.detail && this.detail.scene ? this.detail.scene.name : '')
},
sceneImg() {
return this.detail && this.detail.scene && this.detail.scene.image ? this.detail.scene.image : ''
},
charImg() {
return this.curNode && this.curNode.character && this.curNode.character.image ? this.curNode.character.image : ''
},
stepCount() {
return Math.min(this.routeSteps.length + 1, 6)
},
activeInteraction() {
return this.curNode ? pickInteraction(this.curNode) : ''
},
introCharacter() {
return this.detail ? entryCharacter(this.detail.entry) : null
},
introProps() {
return this.detail ? entryProps(this.detail.entry) : []
}
},
methods: {
async load() {
try {
const data = await api.levelDetail(this.childId, this.levelId)
this.detail = data
this.curNode = data.entry
this.showIntro = true
} catch (e) {
setTimeout(() => this.goBack(), 800)
}
},
goBack() {
uni.navigateBack({ fail: () => uni.reLaunch({ url: '/pages/map/map' }) })
},
personColor(id) {
return personVisual(id).color
},
// 触控互动判定完成:提交成功/失败出口选项
interactionDone({ success }) {
this.mood = success ? '😊' : '😢'
const opt = success ? this.curNode.options[0] : this.curNode.options[1]
if (opt) this.submit(opt)
},
toggleSound() {
this.soundOn = !this.soundOn
setSoundEnabled(this.soundOn)
},
choose(o) {
if (this.choosing) return
playSound('click')
this.submit(o)
},
async submit(o) {
this.choosing = true
try {
const data = await api.levelChoose(this.childId, this.levelId, this.curNode.node_id, o.option_id)
this.routeSteps.push({ text: o.text, pros: o.feedback_pros || '' })
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 后弹点评
this.sceneFx = 'fx-shake'
setTimeout(() => {
this.sceneFx = ''
this.feedback = {
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.choosing = false
}, 600)
} 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
})
playSound('finish')
uni.redirectTo({ url: '/pages/result/result' })
} else {
this.choosing = false
}
} catch (e) {
this.choosing = false
}
},
continuePlay() {
this.curNode = this.feedback.next
this.feedback = null
this.mood = '🤔'
}
}
}
</script>
```
- [ ] **Step 2: template 部分改造**
`<view v-if="detail" class="content">` 之前插入开场演出覆盖层,并把选项列表块替换为 ActionCard
开场覆盖层(插在 `</view>`topbar 结束)之后、`<view v-if="detail" class="content">` 之前):
```html
<!-- 开场演出(覆盖层,播完/跳过进入决策) -->
<SceneTheater
v-if="showIntro && detail"
:scene-name="detail.scene ? detail.scene.name : ''"
:scene-emoji="scene.emoji"
:scene-color="scene.color"
:scene-image="detail.scene_image"
:content="detail.scene_content"
:content-pinyin="detail.scene_content_pinyin"
:audio="detail.scene_audio"
:character="introCharacter"
:props="introProps"
:show-pinyin="showPinyin"
@done="showIntro = false"
/>
```
场景卡片加演出状态类(`.scene-card` 增加 `:class="{ 'fx-shake': sceneFx }"`):
```html
<view class="scene-card card" :class="{ 'fx-shake': sceneFx }" :style="{ background: scene.color + '44' }">
```
互动分发整块替换(原「互动组件(触控 5 种)」v-if 链 + 「选项选择(type 1)」v-else-if 的 `class="options"` 列表 + 「soon 兜底」三段,替换为 activeInteraction 驱动):
```html
<!-- 互动组件(触控 5 种,config 互动节点) -->
<PropPick v-if="activeInteraction === 'PropPick'" :config="curNode.config" @done="interactionDone" />
<FindSpot v-else-if="activeInteraction === 'FindSpot'" :config="curNode.config" @done="interactionDone" />
<DragPlace v-else-if="activeInteraction === 'DragPlace'" :config="curNode.config" @done="interactionDone" />
<StepSort v-else-if="activeInteraction === 'StepSort'" :config="curNode.config" @done="interactionDone" />
<LinkMatch v-else-if="activeInteraction === 'LinkMatch'" :config="curNode.config" @done="interactionDone" />
<!-- 动作卡(普通决策节点;多分支选项语义完整保留) -->
<ActionCard
v-else-if="activeInteraction === 'ActionCard'"
:node="curNode"
:show-pinyin="showPinyin"
:disabled="choosing"
@choose="choose"
/>
<view v-else class="soon card">该互动玩法(类型 {{ curNode.interaction_type }})敬请期待</view>
```
- [ ] **Step 3: style 部分追加**
`<style scoped>` 末尾(`.fb-continue` 规则后)追加:
```css
.fx-shake { animation: fx-shake 0.5s ease; }
@keyframes fx-shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-12rpx); } 50% { transform: translateX(10rpx); } 75% { transform: translateX(-6rpx); } }
```
- [ ] **Step 4: H5 编译检查**
Run: `curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/src/pages/play/play.vue` 返回 200。
- [ ] **Step 5: 提交**
```bash
git add ui-src/src/pages/play/play.vue
git commit -m "feat: 闯关页剧场化(开场演出覆盖层+动作卡呈现+分支演出晃动过渡)"
```
**审查后修复(b48bb41 双审查 ApprovedNIT 合并修复)**
- 删除残留死样式 `.options`/`.option`/`.option-mark`/`.option-text`/`.option.disabled`/`.option:active`(模板已由 ActionCard 接管)
- 分支演出 `setTimeout` 句柄存 `this.branchTimer`,页面 `onUnload()` 清理(防卸载后回调写状态)
- `submit` 幽灵步修复:`routeSteps.push` 改为 `if (data.next || data.final)` 时才 push(API 异常无返回时不污染进度点)
- ActionCard `listen()``if (this.disabled) return` 守卫(演出 600ms 期间不可点听读,防音效重叠)
---
### Task 5: `result.vue` 结局演出(彩带 + 动态标题 + 鼓励语)
**Files:**
- Modify: `ui-src/src/pages/result/result.vue`
- [ ] **Step 1: script 部分改造**
`<script>` 整体替换为:
```vue
<script>
import { sceneVisual } from '../../utils/visual.js'
import { playSound } from '../../utils/sound.js'
const RESULT_KEY = '36wisdom_result'
export default {
data() {
return { result: null, pieces: [] }
},
onLoad() {
const saved = uni.getStorageSync(RESULT_KEY)
this.result = saved || null
if (!saved) {
uni.reLaunch({ url: '/pages/map/map' })
return
}
this.pieces = this.makePieces(30)
playSound(this.result.final.stars > 0 ? 'finish' : 'bad')
},
computed: {
stars() {
return this.result.final.stars
},
title() {
if (this.stars >= 3) return '完美通关!'
if (this.stars > 0) return '通关啦!'
return '再试一次!'
},
sceneEmoji() {
return sceneVisual(this.result.scene_name).emoji
},
encourage() {
if (this.stars === 0) return '你已经很棒了,再试一次会更好!'
if (this.stars < 3) return '还差一点点,补完分支就能完美!'
return ''
}
},
methods: {
replay() {
uni.redirectTo({ url: '/pages/play/play?level_id=' + this.result.level_id })
},
backMap() {
uni.reLaunch({ url: '/pages/map/map' })
},
makePieces(n) {
const colors = ['#ff6b35', '#ffb347', '#4ecdc4', '#6c8cff', '#a58cff', '#ff6b9a']
const list = []
for (let i = 0; i < n; i++) {
list.push({
left: Math.random() * 100,
delay: Math.random() * 1.5,
duration: 2 + Math.random() * 2,
color: colors[i % colors.length],
rotate: Math.random() * 360
})
}
return list
}
}
}
</script>
```
- [ ] **Step 2: template 部分改造**
`<template>` 整体替换为:
```html
<template>
<view class="page">
<!-- 彩带(通关/完美时) -->
<view v-if="result && stars > 0" class="confetti">
<view
v-for="(p, i) in pieces"
:key="i"
class="confetti-piece"
:style="{ left: p.left + '%', background: p.color, animationDelay: p.delay + 's', animationDuration: p.duration + 's', transform: 'rotate(' + p.rotate + 'deg)' }"
></view>
</view>
<view v-if="result" class="result">
<view class="result-emoji">{{ sceneEmoji }}</view>
<view class="result-title" :class="{ great: stars >= 3, ok: stars > 0 && stars < 3, again: stars === 0 }">{{ title }}</view>
<view class="stars-area">
<view
v-for="i in 3"
:key="i"
class="big-star"
:class="{ on: i <= stars, pop: i <= stars }"
:style="{ animationDelay: i * 0.35 + 's' }"
></view>
</view>
<view v-if="encourage" class="encourage">💪 {{ encourage }}</view>
<view class="card panel">
<view class="panel-row">
<text class="label">关卡</text>
<text class="value">{{ result.title }}</text>
</view>
<view class="panel-row">
<text class="label">积分</text>
<text class="value delta" :class="result.final.score_delta >= 0 ? 'up' : 'down'">
{{ result.final.score_delta >= 0 ? '+' : '' }}{{ result.final.score_delta }}
</text>
</view>
<view class="panel-row">
<text class="label">当前余额</text>
<text class="value">{{ result.final.balance_after }}</text>
</view>
</view>
<view v-if="result.final.perfect" class="badge perfect">✨ 完美通关!</view>
<view v-if="result.final.collection_unlocked" class="badge card-collect">🃏 获得计策卡</view>
<view v-if="result.final.unlock_next" class="badge next-unlock">🔓 下一计已解锁</view>
<view v-if="result.final.new_level > 0" class="badge level-up">🎉 成长等级提升!</view>
<view class="actions">
<view class="btn-primary action" @click="replay">再玩一次</view>
<view class="btn-primary action ghost" @click="backMap">返回地图</view>
</view>
</view>
</view>
</template>
```
- [ ] **Step 3: style 部分追加**
`<style scoped>` 末尾追加(保留原有所有规则):
```css
.result-emoji { font-size: 96rpx; margin-bottom: 8rpx; animation: t-pop 0.6s ease; }
.result-title.great { color: #ffb347; }
.result-title.ok { color: #ff6b35; }
.result-title.again { color: #6c8cff; }
.encourage { font-size: 30rpx; color: #7a6248; background: #fff0e5; border-radius: 30rpx; padding: 16rpx 32rpx; margin-bottom: 32rpx; display: inline-block; }
@keyframes t-pop { 0% { opacity: 0; transform: scale(0.5); } 70% { transform: scale(1.1); } 100% { opacity: 1; transform: scale(1); } }
.confetti { position: fixed; inset: 0; pointer-events: none; overflow: hidden; z-index: 5; }
.confetti-piece { position: absolute; top: -40rpx; width: 16rpx; height: 28rpx; border-radius: 4rpx; opacity: 0; animation: confetti-fall linear forwards; }
@keyframes confetti-fall { 0% { opacity: 1; transform: translateY(0) rotate(0); } 100% { opacity: 0.7; transform: translateY(110vh) rotate(720deg); } }
```
> 注意:`@keyframes t-pop` 已在 Task 3 的 SceneTheater 内定义(scoped 隔离,互不冲突);若本文件重复定义同名 keyframes 无碍(各文件 scoped)。
- [ ] **Step 4: H5 编译检查**
Run: `curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/src/pages/result/result.vue` 返回 200。
- [ ] **Step 5: 提交**
```bash
git add ui-src/src/pages/result/result.vue
git commit -m "feat: 结算页结局演出(彩带+动态标题+鼓励语+场景 emoji"
```
---
### Task 6: 文档更新 + 端到端走查 + 提交
**Files:**
- Modify: `README.md`(情境闯关行)
- Modify: `技术设计.md`(4.13 游戏化 UI 设计补充三幕剧场化)
- [ ] **Step 1: README「情境闯关」行补充**
`README.md` 功能模块表中「情境闯关」行末尾追加(保持现有行文风格):
```
呈现为三幕小剧场:开场演出(旁白朗读+字幕逐字淡入+角色入场+道具飞入,可跳过)→ 决策演出(普通节点为动作卡——图标/拼音/听读/按压动画,config 互动节点为触控互动,选择后分支演出过渡再弹对比点评)→ 结局演出(成功彩带/失败鼓励语)
```
- [ ] **Step 2: 技术设计.md 4.13 补充**
`技术设计.md` 的「4.13 游戏化 UI 设计」小节末尾追加「三幕剧场化」说明(含:开场演出覆盖层 SceneTheater、动作卡 ActionCard、分支演出 0.6s 晃动过渡、结局彩带 CSS 粒子;互动形态判定:config 互动节点用触控组件,普通决策节点一律动作卡——理由:触控组件 success/fail 双出口与多分支选项语义不兼容)。
- [ ] **Step 3: 后端回归 + 全量编译**
Run: `go build ./... && go vet ./... && go test ./...` 全部通过(无后端改动,确认无回归)。
- [ ] **Step 4: H5 走查(浏览器人工)**
Run: `curl -s http://localhost:5173/` 200dev server 已在跑)。浏览器打开 http://localhost:5173 走查:
1. 进入任意关卡 → 开场演出自动播放(旁白朗读、字幕逐字淡入、角色滑入、道具飞入),点「跳过」立即进入
2. 决策节点显示动作卡(图标/拼音/听读/四色边框),点击有按压动画
3. 选择后场景晃动 0.6s 再弹点评(你的选择 + 好处/坏处)
4. 走完分支到结算 → 通关显示彩带 + 庆祝标题;故意走错到未通关 → 鼓励语
5. 拼音开关、声音开关、进度点正常
- [ ] **Step 5: 提交**
```bash
git add README.md 技术设计.md
git commit -m "docs: 三幕剧场化设计(README 情境闯关行 + 技术设计 4.13 补充)"
```
---
## 计划外可选(本次不实施)
- 36 关入口互动数据补全(种子补道具选项):触控互动覆盖面由后台配置扩展,非本计划范围
- genasset 角色表情帧/动作帧素材(阶段 C):依赖 Task 10 素材管线完成后另行计划
@@ -1,256 +0,0 @@
# 故事剧场化设计(表现形式升级:剧场 + 全互动决策)
> 日期:2026-08-13 | 状态:已获用户批准(含 v2 修订)| 关联:M1.5 互动体验(docs/superpowers/plans/2026-08-13-m1_5-interactions.md 之后的新增设计)
## 背景与问题
当前每计谋关卡的表现形式为「文字情境 + 文字选项列表 + 点评弹层」。走查反馈:**呈现形式生硬、考试感强,无法引起 4-8 岁儿童兴趣**。核心矛盾:决策树数据模型(情境 → 决策 → 分支 → 结果 → 点评 → 完美机制)是教育价值的载体,必须完整保留;需要改变的是**呈现层**——把「读题、答题」变成「看小剧场、动手玩」。
## v2 修订(2026-08-13):互动剧情演绎
> 用户走查反馈(v1 落地后):关卡仍是「读一段描述 → 答题」,**决策没有融入剧情流**,情境描述简略,孩子没兴趣。v1 解决「怎么演」(三幕演出壳),v2 解决「演什么」——内容剧本化:每个决策节点从一段描述文字变成一场微型剧情演绎(台词流),决策与反馈嵌入剧情流。决策树模型仍零改动:v2 只新增 `scene_node.script` 列 + 前端 StoryPlayer + 剧本生成管线。
>
> **剧本内容来源(方案 3):oMLX 批量生成草稿 + 人工精修 + 导入回填。**
### 核心转变
| 维度 | v1(现状) | v2(剧情演绎) |
|---|---|---|
| 内容 | `node.content` 一段描述文字 | `script` 3-6 句台词流:旁白 + 角色对话 + 末句角色提问 |
| 呈现 | 静态节点卡片 + 朗读按钮 | StoryPlayer 逐句演绎:对话气泡 + 自动朗读 + 逐字高亮 + 表情切换 |
| 决策 | 卡片下方「答题」 | 剧情流内角色提问 → ActionCard 直接出现在剧情流 |
| 反馈 | 弹窗点评(你的选择 + ✓/✗) | 角色回应气泡融入剧情流(pros/cons 转角色台词) |
| 内容生产 | 种子数据手工撰写 | oMLX 生成草稿 → 人工精修 → 导入回填 |
### 数据模型(最小改动)
`scene_node` 新增一列(dao 迁移 + EnsureColumn,模式同 `*_pinyin` 列):
- `script TEXT`:JSON 数组台词流,每节点 3-6 句:
```json
[
{"speaker": "旁白", "text": "夜深了,城里静悄悄的。", "emotion": "normal"},
{"speaker": "小军师", "text": "城门关着,怎么进去呢?", "emotion": "think"}
]
```
- `speaker``旁白` 或角色名(`node.character.name`),旁白居中无头像,角色带头像气泡
- `text`:一句台词 8-30 字(含标点)
- `emotion``normal | happy | sad | think | surprise` → mood 徽章 🤔 😊 😢 🧐 😮
- 结构约束(生成校验器强制):决策节点(`interaction_type=1`)末句必须为角色提问(speaker=角色名、结尾「?」);终局节点(`result_type>0`)末句为角色总结(欣慰/鼓励);其他句 speaker 自由(旁白/角色交替)
- **pinyin 不入草稿**:导入时用 `common.AnnotatePinyin` 对每句 text 生成逐字注音 JSON(人工精修会改文本,导入时统一重算),前端 RubyText 既有机制逐字对齐
### StoryPlayer 组件(新)
输入 `script` + `character` + `scene` + `showPinyin`,逐句演绎:
1. 对话气泡入场(CSS 淡入上滑):旁白居中(无头像、斜体/描边区分),角色左侧头像 + 名字 + 气泡
2. 自动朗读该句(`speech.speak` 逐句,TTS 降级)
3. 逐字高亮:RubyText 扩展 `highlight` prop(当前朗读字下标),点亮进度跟随朗读
4. 句间停顿(≤300ms/字),emotion 切换 mood 徽章弹跳(复用 `.char-mood` 动画)
5. 播完 `@done`;右上「跳过」立即完成
`script` 为空:StoryPlayer 不渲染,回退现有卡片呈现(零变化)。
### play.vue 剧情流
- 节点区从「独立卡片」改为「剧情流 scroll-view」:当前节点剧本演绎 → 播完 ActionCard 出现在剧情流底部(复用现有 ActionCard/触控组件)→ 选择提交 → **角色回应气泡融入剧情流**(用 `feedback_pros/cons` 作为角色台词 + 分支演出 mood/音效,替代弹窗)→ 「继续」→ 下一节点剧本继续演绎,剧情流滚动增长
- 场景横幅(scene-card 精简为顶部横幅)固定,镜头感;进度点保留
- `level/choose` 接口、判分、完美机制、路由记录**零改动**;反馈 pros/cons 文本由 genasset 点评管线生成(已有)
### 剧本生成管线(并入 genasset)
- `--only=scripts`:对 `scene_node` 逐节点调 oMLXprompt:情境 content + 选项 + 角色名 → 台词流,风格约束:儿童化口语、每句 8-30 字、3-6 句、末句提问/总结)→ 校验(结构约束 + JSON 解析 + 长度)→ 草稿写 `workspace/scripts/{level_id}/{node_id}.json`(含待修标记,人工可编辑)
- `--import-scripts`:草稿 → 再校验 → 每句 `AnnotatePinyin``UPDATE scene_node.script`,幂等(已有 script 跳过)
- 与素材/点评管线同池并发 2、同 `--strategy/--force` 控制
### 前端回退与渐进升级
- `script` 为空 → 现有 v1 呈现零变化;36 关逐关生成回填,前端自动升级为剧情演绎
- 不阻塞 v1:阶段 A 落地即可验收;B/C 逐关生效
### 验收标准(v2
1. 决策节点演绎台词流:气泡 + 自动朗读 + 逐字高亮 + 表情切换,末句角色提问
2. ActionCard 出现在剧情流内(非独立答题区),触控 5 种互动同样嵌入
3. 选择后角色回应气泡融入剧情流,无弹窗;分支演出(mood/音效)保留
4. `script` 为空节点完整回退现有呈现
5. 管线:`--only=scripts` 草稿 → 人工精修 → `--import-scripts` → 前端生效;校验拒绝不合规草稿
6. `level/choose` 判分与完美机制行为不变(单测全过)
### 实施拆分
- **阶段 A(数据 + 后端 + StoryPlayer + 剧情流)**`scene_node.script` 列迁移 + `NodeVO.Script` 透出 + RubyText `highlight` prop + StoryPlayer 组件 + play.vue 剧情流集成(回退保护)
- **阶段 B(生成管线)**genasset `--only=scripts` / `--import-scripts` + 第 1 计试点生成并精修 1 关
- **阶段 C(全量)**:36 关剧本后台生成 + 人工精修流程 + 走查验收
## v2.1 修订(2026-08-13):全自动演出 + 文字精简
> 用户走查反馈(v2 落地后):**「现在的情景执行依赖于点击按钮,这是不对的,应该自动的,而且也不需要显示这么多文字,文字多了,孩子更不喜欢」**。v2 解决「演什么」,v2.1 解决「怎么演得像动画」——消除按钮依赖、削减屏幕文字量。数据模型零改动;接口仅 `FinalSettle` 多返回 `final_node`(复用既有 NodeVO 链路,含 script)。
### 核心转变
| 维度 | v2(现状) | v2.1(自动演出) |
|---|---|---|
| 开场 | SceneTheater 朗读后需点「开始 ›」 | 朗读完成后自动进入决策幕(保留「跳过 ›」) |
| 反馈 | 回应气泡需点「继续 ›」 | 气泡出现约 3 秒后自动继续(点击可立即继续,计时器防重复) |
| 终局 | choose 到终局直接跳结算页,结局台词从未播放 | 先由 StoryPlayer 演绎终局节点台词(`final_node.script`),播完自动跳结算 |
| 文字量 | StoryPlayer 3-6 行完整堆叠 | 已播行折叠:只保留当前行 + 上一行淡出 |
### 数据与接口(最小改动)
- `FinalSettle` 增加 `final_node`NodeVO,含 script):service Choose 终局分支把终局节点记录(既有 `nextRec`)经 `nodeOf` 构建为 Node 一并返回;controller 映射到 dto
- play.vue `data.final` 分支:先存 RESULT_KEY(同现状),若 `final_node.script` 非空 → 进入终局演绎态(StoryPlayer 播 `final_node` 剧本)→ `@done``redirectTo` 结算页;script 为空 → 直接跳结算(行为同现状)
### 自动节奏
- SceneTheater:移除「开始 ›」按钮;朗读结束(mounted 的 setTimeout,同现状)自动 emit done;「跳过 ›」保留
- 回应气泡(剧情流 reply):出现后 3 秒自动 `continuePlay()`;点击「继续 ›」立即继续;自动计时触发前点击需 clearTimeout 防重复推进;v1 弹窗反馈(feedback)不加自动继续(保持人工节奏)
- 终局演绎:StoryPlayer 播完自动跳结算(3-6 句 × 8-30 字,约 10-25 秒);跳过按钮同样可跳过终局台词直接跳结算
### 前端回退与渐进升级
- `final_node.script` 为空 → 直接跳结算(现状不变)
- 终局演绎复用 StoryPlayer 与剧情流形态(`:key` 强制重建、done → 跳转),不新增组件
### 验收标准(v2.1
1. 开场自动进入决策幕(无「开始」按钮),「跳过」仍可用
2. 回应气泡约 3 秒自动继续;点击可立即继续;无重复推进(连点/自动+点击不双跳)
3. 终局节点台词先演绎再自动跳结算;`final_node.script` 为空直接跳结算(回退)
4. StoryPlayer 已播行折叠:当前行 + 上一行淡出,无 3-6 行完整堆叠
5. 判分与完美机制行为不变(level_play 单测全过)
---
## v2.2 修订(2026-08-13):纯语音演绎 + 单端口托管
> 用户走查反馈(v2.1 落地后):① **「必须点跳过按钮才显示出选项」**——StoryPlayer 自然播完不触发 donespeak onEnd 在部分浏览器环境不回调,逐句推进只能靠 20s forceNext 兜底,体验像卡死);② **「剧情也不应该显示文字,直接语音播放就可以了不需要文字展示区」**;③ **「前端应该跟后端共用端口」**(后端 :8080,前端产物由后端托管)。
### 核心转变
| 维度 | v2.1 | v2.2 |
|---|---|---|
| 剧情呈现 | 气泡文字 + 逐字高亮 + 拼音 | 纯语音演绎:无文字展示区,角色立绘 + mood 表情 + 逐句朗读 |
| 句推进 | speak onEnd + 逐字高亮双门控 | speak onEnd 驱动 + **文本长度估时兜底**320ms/字 + 1.2s,最短 2.5s);20s forceNext 保留为最后防线 |
| 部署 | H5 dev server 5173(开发代理) | 前端产物 `ui-src/dist` 由后端 :8080 `AddStaticPath` 托管(生产单端口) |
### 验收标准(v2.2
1. 剧情无任何文字展示:只有角色 + 表情徽章 + 语音(用户确认后扩展到开场:**SceneTheater 情境字幕**(含拼音逐字、「正在朗读…」状态)与**场景面板情境描述**scene-card 的 RubyText + 「读一读情境」按钮)一并移除,仅保留场景名/emoji/装饰;开场与情境内容仍自动朗读)
2. 台词播完后选项自动出现(无需点跳过);跳过按钮仅作可选的手动加速
3. speak onEnd 不回调的环境(模拟浏览器静音策略/无语音)下按估时兜底自动推进,不卡死
4. `http://localhost:8080` 直接打开应用(前后端同端口)
---
## 设计总览
每关 = 一部 2-3 分钟儿童小剧场,三幕结构:
```
第 1 幕 开场演出 → 第 2 幕 决策演出(循环 2-6 次) → 第 3 幕 结局演出
旁白+字幕高亮 动作卡 / 触控互动 / 分支演出 庆祝 / 鼓励
角色道具入场 决策后弹点评气泡
```
- **剧场化是容器**(怎么演给孩子看):开场/分支/结局的动画演出
- **全互动是交互方式**(孩子怎么动手):每个决策点按内容映射为动作卡或触控互动,消灭纯文字选项列表
- **决策树模型零改动**`scene_node` / `node_option` 表结构、`level/choose` 接口、判分与完美机制全部不动
## 第 1 幕:开场演出
替代现在静止的场景卡片(play.vue 的 `.scene-card`)。
| 元素 | 实现 | 现状 |
|---|---|---|
| 场景横幅 | 场景插画(genasset 素材)或 emoji 兜底 | 已有 |
| 角色入场 | 角色立绘从画面外滑入/弹跳落地(CSS keyframes | 素材已有管线 |
| 道具飞入 | 情境相关道具图标依次飞入(CSS 动画 + 延迟) | 素材已有管线 |
| 旁白朗读 | 自动调用 SpeakButton 的朗读(TTS 降级),场景 audio 优先 | 已有 |
| 字幕高亮 | RubyText 逐字显示(拼音注音同步) | 已有 |
| 播完状态 | 角色 mood 切「🤔」,显示「开始」按钮进入决策幕 | 新 |
- 提供「跳过」按钮(右上角),跳过直接进入决策幕
- 时间预算:8-15 秒,可跳过
## 第 2 幕:决策演出
### 互动形态自动判定(前端运行时,后端零改动)
对每个决策节点,按以下顺序判定互动形态:
```
1. 节点 config 非空(入口互动节点,interaction_type ∈ {2,7,5,3,8})→ 用现有互动组件
2. 节点 interaction_type === 1(普通决策节点)→ 按选项内容特征自动选形态:
a. 全部选项都带 prop 且 prop 去重后数量 = 选项数 → 道具选择 PropPick
b. 选项文本含步骤序词("先/再/最后")或选项含"排序"特征 → 步骤排序 StepSort
c. 选项文本含"找到/发现/寻找" → 找线索 FindSpot
d. 选项文本含"配对/搭配/谁+什么" → 连线配对 LinkMatch
e. 其余 → 动作卡(兜底)
```
> 判定规则 a-e 为启发式,误判时教育语义不受影响:互动成功/失败只决定走向 success/fail 出口(现有机制),动作卡兜底保证任何节点都有可玩呈现。规则实现在 play.vue 的单一函数 `pickInteraction(node)` 中,返回组件名,渲染 v-if 分发保持不变。
### 动作卡组件(新,替代文字选项列表)
interaction_type === 1 且不满足 a-e 的节点,渲染 `ActionCard` 组件:
- 每个选项一张卡:道具图标/表情(prop 素材或 emoji 兜底)+ 短语(RubyText 拼音注音)+ 序号徽章
- 按压反馈:点击时卡片下沉+弹跳(CSS active 变换 + keyframes
- 每张卡可「听一听」(SpeakButton 复用,读选项文字)
- 卡片底色按选项序号取 c1-c4 四色边框(现有样式类)
- 点击 → 播放「选择演出」后提交 choose(见下)
### 分支演出动画(新)
每个决策提交后、弹点评前,播放 **0.8-1.5 秒轻量过渡**(不阻断节奏):
- 角色表情切换(mood 徽章 😊/😢/😮 弹跳,现有 `.char-mood` 动画扩展)
- 场景元素变化(道具图标飞入/消失,CSS keyframes
- 配套音效(playSound good/bad 已有)
- 演出结束后弹点评气泡(现有三段式:你的选择 + ✓好处 + ✗对比)
### 节奏控制
- 开场可跳过;分支演出 ≤1.5 秒;单关时长保持 5-10 分钟
- 点评弹层已有「继续」按钮,流程不变
## 第 3 幕:结局演出
### 闯关内结算(result 页升级)
- 成功(通关/完美):角色跳舞 + 彩带/烟花粒子(CSS 动画)+ 星星雨 + finish 音效
- 失败:角色安慰表情 + 鼓励语("再试一次,你已经很棒了")+ 无惩罚感
### 配套
- 结算页的彩带/星星雨动画 = Task 11 已规划的 confetti,合并实现
- 路线回顾卡片保留(已规划)
## 配套改动
| 项 | 内容 | 阶段 |
|---|---|---|
| 素材规格扩展 | genasset prompt 模板增加角色表情帧(高兴/难过/困惑/正常)与动作帧(入场/跳舞/安慰);素材未生成时 emoji 兜底 | C |
| 互动覆盖全 36 关 | 种子数据为 22 个无道具选项的关卡补互动入口所需道具数据(或后台运营补),使入口互动覆盖全关卡 | B |
| 结算页升级 | confetti + 角色表情 + 鼓励语 | A |
| 文档 | README「情境闯关」行、技术设计.md 4.13 补充剧场化设计 | A |
## 实施拆分(3 个阶段,各自可独立验收)
- **阶段 A(纯前端,立即见效)**:开场演出(scene 面板升级 + 跳过)+ 动作卡组件(ActionCard)+ 分支演出动画 结局演出(result 页)
- **阶段 B(覆盖补齐)**:前端 `pickInteraction` 自动判定(5 种触控互动按内容特征命中)+ 种子数据补 22 关互动入口
- **阶段 C(素材增强)**:genasset 表情/动作素材规格扩展(角色 3 态表情帧 + 动作帧)
阶段 A 独立成里程碑(M1.6 或并入 M1.5 收尾),B/C 视素材管线(Task 10)完成度顺延。
## 验收标准
1. 关卡开场自动演出(旁白 + 字幕高亮 + 角色/道具入场),可跳过
2. 决策点无纯文字选项列表:全部渲染为动作卡或触控互动
3. 每个决策提交后有分支演出动画 + 点评弹层
4. 结算页成功/失败有动画演出
5. 现有接口/判分/完美机制行为不变(level_play 单测全过)
6. 36 关均有关卡入口互动(阶段 B 完成时)
+9 -1
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"os" "os"
"strings"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2" _ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
@@ -12,9 +13,9 @@ import (
"36wisdom/biz/controller" "36wisdom/biz/controller"
"36wisdom/biz/dao" "36wisdom/biz/dao"
"36wisdom/biz/service/seed"
"36wisdom/common" "36wisdom/common"
"36wisdom/common/auth" "36wisdom/common/auth"
"36wisdom/seed"
) )
func main() { func main() {
@@ -31,6 +32,13 @@ func main() {
// 路由注册与中间件装配(静态,不属业务表分层) // 路由注册与中间件装配(静态,不属业务表分层)
s.Use(common.MiddlewareNoiseSilence) s.Use(common.MiddlewareNoiseSilence)
s.Use(ghttp.MiddlewareHandlerResponse) s.Use(ghttp.MiddlewareHandlerResponse)
// 静态生成资产(PNG/音频)路径不可变:强缓存,客户端每文件只下载一次。
// 静态文件服务不走中间件链,须用 HookBeforeServe(对静态/动态请求均执行)
s.BindHookHandler("/*", ghttp.HookBeforeServe, func(r *ghttp.Request) {
if strings.HasPrefix(r.URL.Path, "/static/") {
r.Response.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
})
s.BindHandler("GET:/ping", func(r *ghttp.Request) { s.BindHandler("GET:/ping", func(r *ghttp.Request) {
r.Response.Write("pong") r.Response.Write("pong")
}) })
@@ -12,7 +12,7 @@ import (
// annotateAll 拼音补标:对 *_pinyin 为空的记录按原文标注。 // annotateAll 拼音补标:对 *_pinyin 为空的记录按原文标注。
// 启动维护一次性(约 500 行),单事务内逐行 UPDATE,失败告警不阻断。 // 启动维护一次性(约 500 行),单事务内逐行 UPDATE,失败告警不阻断。
// 注意:迁移补列的历史记录为 NULL,须同时匹配 '' 与 NULL。 // 注意:迁移补列的历史记录为 NULL,须同时匹配 与 NULL。
func annotateAll(ctx context.Context) { func annotateAll(ctx context.Context) {
type target struct{ table, pinyinCol, textCol string } type target struct{ table, pinyinCol, textCol string }
targets := []target{ targets := []target{
+4 -1
View File
@@ -13,7 +13,7 @@
<script> <script>
import { personVisual } from '../utils/visual.js' import { personVisual } from '../utils/visual.js'
import { speak, stopSpeak } from '../utils/speech.js' import { speak, stopSpeak, prefetch } from '../utils/speech.js'
const EMOJI = { normal: '🙂', think: '🤔', happy: '😊', sad: '😢', surprise: '😮' } const EMOJI = { normal: '🙂', think: '🤔', happy: '😊', sad: '😢', surprise: '😮' }
const PAUSE_MS = 700 // const PAUSE_MS = 700 //
@@ -73,6 +73,9 @@ export default {
} }
}) })
this.after(MAX_LINE_MS, () => this.forceNext(i)) this.after(MAX_LINE_MS, () => this.forceNext(i))
//
const next = this.script[i + 1]
if (next && next.audio) prefetch(next.audio)
try { try {
speak(line.text, line.audio, () => { speak(line.text, line.audio, () => {
// i onEnd // i onEnd
+15
View File
@@ -35,6 +35,21 @@ export function speak(text, fileUrl, onEnd) {
speakBrowser(text, onEnd) speakBrowser(text, onEnd)
} }
// prefetch 预载下一句音频(句间衔接不卡):H5 用原生 Audio 预载,App/小程序用 innerAudioContext 预载
export function prefetch(url) {
if (!url) return
if (typeof uni !== 'undefined' && uni.createInnerAudioContext) {
const a = uni.createInnerAudioContext()
a.src = url
a.stop()
setTimeout(() => a.destroy(), 60000)
} else if (typeof window !== 'undefined' && window.Audio) {
const a = new Audio()
a.preload = 'auto'
a.src = url
}
}
export function stopSpeak() { export function stopSpeak() {
if (innerAudio) innerAudio.stop() if (innerAudio) innerAudio.stop()
if (typeof window !== 'undefined' && window.speechSynthesis) { if (typeof window !== 'undefined' && window.speechSynthesis) {
View File
+7 -1
View File
@@ -320,7 +320,7 @@ CREATE TABLE admin_user (
### 4.1 种子数据初始化 ### 4.1 种子数据初始化
- 种子 JSON 用 `go:embed` 打进二进制(`biz/service/seed/`),含:元素库(场景约 15 / 人物约 10 / 道具约 20)、36 计全部内容、每计 1-3 个现代情境关卡(决策树:节点 + 分支选项 + 元素关联)、初始奖品、初始徽章、默认管理员账号 - 种子 JSON 用 `go:embed` 打进二进制(根级 `seed/` 目录,非表分层目录:跨表启动导入不属任何业务表,禁放 `biz/service/`),含:元素库(场景约 15 / 人物约 10 / 道具约 20)、36 计全部内容、每计 1-3 个现代情境关卡(决策树:节点 + 分支选项 + 元素关联)、初始奖品、初始徽章、默认管理员账号
- 启动时先建表(main.go 依次调各 dao 的 `Init``CREATE TABLE IF NOT EXISTS` 幂等),随后执行 `seed.EnsureSeeded()`:查 `strategy` 空表则按序插入,插入包事务;随后对全部内容调用标注服务生成拼音(见 4.6),TTS 音频异步生成,均只执行一次 - 启动时先建表(main.go 依次调各 dao 的 `Init``CREATE TABLE IF NOT EXISTS` 幂等),随后执行 `seed.EnsureSeeded()`:查 `strategy` 空表则按序插入,插入包事务;随后对全部内容调用标注服务生成拼音(见 4.6),TTS 音频异步生成,均只执行一次
- 后台运营迭代 = 后台改内容 + 可选导出种子;不覆盖用户数据表 - 后台运营迭代 = 后台改内容 + 可选导出种子;不覆盖用户数据表
@@ -571,6 +571,12 @@ config JSON 结构(`scene_node.config`,前端解析渲染):`{"kind":"pro
**降级链**:文件缺失 → H5 浏览器 `speechSynthesis` 兜底(speech.js 既有逻辑);文件存在 → 五端一律播 mp3。 **降级链**:文件缺失 → H5 浏览器 `speechSynthesis` 兜底(speech.js 既有逻辑);文件存在 → 五端一律播 mp3。
**缓存与预取(弱网策略,不做全量打包与真离线)**:
- 音频是内容资产不是应用代码:不打包进客户端(小程序包体积硬限制、App 无必要),按需拉取 + 本地缓存;H5 关卡内容本身依赖网络接口,完全离线非目标
- 后端:`/static` 响应加 `Cache-Control: public, max-age=31536000, immutable`(生成资产路径不可变,安全)——H5 每文件只下载一次,重复播放零网络
- 前端:StoryPlayer 播第 N 句时预载第 N+1 句(speech.js `prefetch`H5 用 `new Audio().preload='auto'`App/小程序用 `createInnerAudioContext` 预载)——单句 mp3 仅 20-50KB,句间衔接不卡
- App/小程序本地文件缓存(首播落沙盒/文件系统)留 M4 多端发布时做
## 5. 开发计划 ## 5. 开发计划
| 里程碑 | 内容 | 验收标准 | | 里程碑 | 内容 | 验收标准 |