diff --git a/apps/backend/generate/api/gateway.api b/apps/backend/generate/api/gateway.api index 1a66856..08e7bb9 100644 --- a/apps/backend/generate/api/gateway.api +++ b/apps/backend/generate/api/gateway.api @@ -21,3 +21,4 @@ import "proxy.api" import "extension.api" import "studio.api" import "m5.api" +import "invite.api" diff --git a/apps/backend/generate/api/invite.api b/apps/backend/generate/api/invite.api new file mode 100644 index 0000000..96b1431 --- /dev/null +++ b/apps/backend/generate/api/invite.api @@ -0,0 +1,93 @@ +syntax = "v1" + +// M6 邀請網絡:會員補填碼 + admin 樹/移動(防環) + +type ( + InviteMemberBrief { + Uid int64 `json:"uid"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + InviteCode string `json:"invite_code"` + ParentUid int64 `json:"parent_uid,optional"` // 0 = 無上線 + Roles []string `json:"roles"` + Status string `json:"status"` + DownlineCount int `json:"downline_count"` + TotalDownlineCount int `json:"total_downline_count"` + Depth int `json:"depth"` + JoinedAt int64 `json:"joined_at"` + AvatarUrl string `json:"avatar_url,optional"` + } + + InviteTreeNode { + Uid int64 `json:"uid"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + InviteCode string `json:"invite_code"` + ParentUid int64 `json:"parent_uid,optional"` + Roles []string `json:"roles"` + Status string `json:"status"` + DownlineCount int `json:"downline_count"` + TotalDownlineCount int `json:"total_downline_count"` + Depth int `json:"depth"` + JoinedAt int64 `json:"joined_at"` + AvatarUrl string `json:"avatar_url,optional"` + Children []InviteTreeNode `json:"children"` + } + + MyInviteNetworkData { + Me InviteMemberBrief `json:"me"` + Upline *InviteMemberBrief `json:"upline,optional"` + Downlines []InviteMemberBrief `json:"downlines"` + TotalDownlineCount int `json:"total_downline_count"` + } + + ApplyInviteCodeReq { + Code string `json:"code"` + } + + InviteTreeData { + List []InviteTreeNode `json:"list"` + } + + MoveInviteMemberReq { + Uid int64 `json:"uid"` + ParentUid int64 `json:"parent_uid,optional"` // 0 = 獨立根 + } + + ListParentCandidatesReq { + ExcludeSubtreeRootUid int64 `form:"exclude_subtree_root_uid,optional"` + } + + InviteParentCandidatesData { + List []InviteMemberBrief `json:"list"` + } +) + +@server ( + group: invite + prefix: /api/v1/invite + middleware: AuthJWT +) +service gateway { + @handler GetMyInviteNetwork + get /me returns (MyInviteNetworkData) + + @handler ApplyInviteCode + post /apply (ApplyInviteCodeReq) returns (MyInviteNetworkData) +} + +@server ( + group: invite + prefix: /api/v1/invite + middleware: AuthJWT,AdminAuth +) +service gateway { + @handler GetInviteTree + get /tree returns (InviteTreeData) + + @handler MoveInviteMember + post /move (MoveInviteMemberReq) returns (InviteMemberBrief) + + @handler ListInviteParentCandidates + get /parent-candidates (ListParentCandidatesReq) returns (InviteParentCandidatesData) +} diff --git a/apps/backend/generate/api/jobs.api b/apps/backend/generate/api/jobs.api index 1f4d0e1..6492b0d 100644 --- a/apps/backend/generate/api/jobs.api +++ b/apps/backend/generate/api/jobs.api @@ -19,8 +19,16 @@ type ( CompletedAt int64 `json:"completed_at,optional"` } + // tab: recurring=遠期定期排程 | active=執行中/待領取 | history=終態歷史 + JobListReq { + Tab string `form:"tab,optional"` + Page int `form:"page,optional"` + PageSize int `form:"pageSize,optional"` + } + JobListData { - List []JobPublic `json:"list"` + List []JobPublic `json:"list"` + Pagination Pagination `json:"pagination"` } JobIdPath { @@ -36,7 +44,7 @@ type ( ) service gateway { @handler ListJobs - get / returns (JobListData) + get / (JobListReq) returns (JobListData) @handler GetJob get /:id (JobIdPath) returns (JobPublic) diff --git a/apps/backend/internal/handler/invite/apply_invite_code_handler.go b/apps/backend/internal/handler/invite/apply_invite_code_handler.go new file mode 100644 index 0000000..4aa51f3 --- /dev/null +++ b/apps/backend/internal/handler/invite/apply_invite_code_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package invite + +import ( + "net/http" + + "apps/backend/internal/logic/invite" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ApplyInviteCodeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ApplyInviteCodeReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := invite.NewApplyInviteCodeLogic(r.Context(), svcCtx) + data, err := l.ApplyInviteCode(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/invite/get_invite_tree_handler.go b/apps/backend/internal/handler/invite/get_invite_tree_handler.go new file mode 100644 index 0000000..7815797 --- /dev/null +++ b/apps/backend/internal/handler/invite/get_invite_tree_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package invite + +import ( + "net/http" + + "apps/backend/internal/logic/invite" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func GetInviteTreeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := invite.NewGetInviteTreeLogic(r.Context(), svcCtx) + data, err := l.GetInviteTree() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/invite/get_my_invite_network_handler.go b/apps/backend/internal/handler/invite/get_my_invite_network_handler.go new file mode 100644 index 0000000..3effbf1 --- /dev/null +++ b/apps/backend/internal/handler/invite/get_my_invite_network_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package invite + +import ( + "net/http" + + "apps/backend/internal/logic/invite" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func GetMyInviteNetworkHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := invite.NewGetMyInviteNetworkLogic(r.Context(), svcCtx) + data, err := l.GetMyInviteNetwork() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/invite/list_invite_parent_candidates_handler.go b/apps/backend/internal/handler/invite/list_invite_parent_candidates_handler.go new file mode 100644 index 0000000..5fe9a43 --- /dev/null +++ b/apps/backend/internal/handler/invite/list_invite_parent_candidates_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package invite + +import ( + "net/http" + + "apps/backend/internal/logic/invite" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ListInviteParentCandidatesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ListParentCandidatesReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := invite.NewListInviteParentCandidatesLogic(r.Context(), svcCtx) + data, err := l.ListInviteParentCandidates(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/invite/move_invite_member_handler.go b/apps/backend/internal/handler/invite/move_invite_member_handler.go new file mode 100644 index 0000000..8802e8d --- /dev/null +++ b/apps/backend/internal/handler/invite/move_invite_member_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package invite + +import ( + "net/http" + + "apps/backend/internal/logic/invite" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func MoveInviteMemberHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.MoveInviteMemberReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := invite.NewMoveInviteMemberLogic(r.Context(), svcCtx) + data, err := l.MoveInviteMember(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/jobs/list_jobs_handler.go b/apps/backend/internal/handler/jobs/list_jobs_handler.go index ebcaa56..3fa1d43 100644 --- a/apps/backend/internal/handler/jobs/list_jobs_handler.go +++ b/apps/backend/internal/handler/jobs/list_jobs_handler.go @@ -9,12 +9,20 @@ import ( "apps/backend/internal/logic/jobs" "apps/backend/internal/response" "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" ) func ListJobsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + var req types.JobListReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, err) + return + } l := jobs.NewListJobsLogic(r.Context(), svcCtx) - data, err := l.ListJobs() + data, err := l.ListJobs(&req) response.Write(r.Context(), w, data, err) } } diff --git a/apps/backend/internal/handler/routes.go b/apps/backend/internal/handler/routes.go index 5553137..9cc482b 100644 --- a/apps/backend/internal/handler/routes.go +++ b/apps/backend/internal/handler/routes.go @@ -11,6 +11,7 @@ import ( compose "apps/backend/internal/handler/compose" extension "apps/backend/internal/handler/extension" inspire "apps/backend/internal/handler/inspire" + invite "apps/backend/internal/handler/invite" jobs "apps/backend/internal/handler/jobs" media "apps/backend/internal/handler/media" mentions "apps/backend/internal/handler/mentions" @@ -311,6 +312,49 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { rest.WithPrefix("/api/v1/inspire"), ) + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodPost, + Path: "/apply", + Handler: invite.ApplyInviteCodeHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/me", + Handler: invite.GetMyInviteNetworkHandler(serverCtx), + }, + }..., + ), + rest.WithPrefix("/api/v1/invite"), + ) + + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT, serverCtx.AdminAuth}, + []rest.Route{ + { + Method: http.MethodPost, + Path: "/move", + Handler: invite.MoveInviteMemberHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/parent-candidates", + Handler: invite.ListInviteParentCandidatesHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/tree", + Handler: invite.GetInviteTreeHandler(serverCtx), + }, + }..., + ), + rest.WithPrefix("/api/v1/invite"), + ) + server.AddRoutes( rest.WithMiddlewares( []rest.Middleware{serverCtx.AuthJWT}, @@ -357,11 +401,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { []rest.Route{ { Method: http.MethodPost, - Path: "/upload", - Handler: media.UploadHandler(serverCtx), + Path: "/generate-image", + Handler: media.GenerateImageHandler(serverCtx), }, }..., ), + rest.WithJwt(serverCtx.Config.Auth.AccessSecret), rest.WithPrefix("/api/v1/media"), ) @@ -371,12 +416,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { []rest.Route{ { Method: http.MethodPost, - Path: "/generate-image", - Handler: media.GenerateImageHandler(serverCtx), + Path: "/upload", + Handler: media.UploadHandler(serverCtx), }, }..., ), - rest.WithJwt(serverCtx.Config.Auth.AccessSecret), rest.WithPrefix("/api/v1/media"), ) diff --git a/apps/backend/internal/logic/invite/apply_invite_code_logic.go b/apps/backend/internal/logic/invite/apply_invite_code_logic.go new file mode 100644 index 0000000..343a1c1 --- /dev/null +++ b/apps/backend/internal/logic/invite/apply_invite_code_logic.go @@ -0,0 +1,34 @@ +package invite + +import ( + "context" + "fmt" + + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ApplyInviteCodeLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewApplyInviteCodeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ApplyInviteCodeLogic { + return &ApplyInviteCodeLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ApplyInviteCodeLogic) ApplyInviteCode(req *types.ApplyInviteCodeReq) (resp *types.MyInviteNetworkData, err error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + net, err := l.svcCtx.Auth.ApplyInviteCode(l.ctx, uid, req.Code) + if err != nil { + return nil, err + } + return types.MyInviteNetworkFromUC(net), nil +} diff --git a/apps/backend/internal/logic/invite/get_invite_tree_logic.go b/apps/backend/internal/logic/invite/get_invite_tree_logic.go new file mode 100644 index 0000000..912ff92 --- /dev/null +++ b/apps/backend/internal/logic/invite/get_invite_tree_logic.go @@ -0,0 +1,32 @@ +package invite + +import ( + "context" + + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetInviteTreeLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetInviteTreeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetInviteTreeLogic { + return &GetInviteTreeLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetInviteTreeLogic) GetInviteTree() (resp *types.InviteTreeData, err error) { + forest, err := l.svcCtx.Auth.GetInviteTree(l.ctx) + if err != nil { + return nil, err + } + list := make([]types.InviteTreeNode, 0, len(forest)) + for _, n := range forest { + list = append(list, types.InviteTreeFromUC(n)) + } + return &types.InviteTreeData{List: list}, nil +} diff --git a/apps/backend/internal/logic/invite/get_my_invite_network_logic.go b/apps/backend/internal/logic/invite/get_my_invite_network_logic.go new file mode 100644 index 0000000..13be63d --- /dev/null +++ b/apps/backend/internal/logic/invite/get_my_invite_network_logic.go @@ -0,0 +1,34 @@ +package invite + +import ( + "context" + "fmt" + + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetMyInviteNetworkLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetMyInviteNetworkLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetMyInviteNetworkLogic { + return &GetMyInviteNetworkLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetMyInviteNetworkLogic) GetMyInviteNetwork() (resp *types.MyInviteNetworkData, err error) { + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, fmt.Errorf("unauthorized") + } + net, err := l.svcCtx.Auth.GetMyNetwork(l.ctx, uid) + if err != nil { + return nil, err + } + return types.MyInviteNetworkFromUC(net), nil +} diff --git a/apps/backend/internal/logic/invite/list_invite_parent_candidates_logic.go b/apps/backend/internal/logic/invite/list_invite_parent_candidates_logic.go new file mode 100644 index 0000000..fbd9309 --- /dev/null +++ b/apps/backend/internal/logic/invite/list_invite_parent_candidates_logic.go @@ -0,0 +1,32 @@ +package invite + +import ( + "context" + + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListInviteParentCandidatesLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListInviteParentCandidatesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListInviteParentCandidatesLogic { + return &ListInviteParentCandidatesLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListInviteParentCandidatesLogic) ListInviteParentCandidates(req *types.ListParentCandidatesReq) (resp *types.InviteParentCandidatesData, err error) { + list, err := l.svcCtx.Auth.ListParentCandidates(l.ctx, req.ExcludeSubtreeRootUid) + if err != nil { + return nil, err + } + out := make([]types.InviteMemberBrief, 0, len(list)) + for _, b := range list { + out = append(out, types.InviteBriefFromUC(b)) + } + return &types.InviteParentCandidatesData{List: out}, nil +} diff --git a/apps/backend/internal/logic/invite/move_invite_member_logic.go b/apps/backend/internal/logic/invite/move_invite_member_logic.go new file mode 100644 index 0000000..0d771d4 --- /dev/null +++ b/apps/backend/internal/logic/invite/move_invite_member_logic.go @@ -0,0 +1,40 @@ +package invite + +import ( + "context" + "fmt" + + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type MoveInviteMemberLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewMoveInviteMemberLogic(ctx context.Context, svcCtx *svc.ServiceContext) *MoveInviteMemberLogic { + return &MoveInviteMemberLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *MoveInviteMemberLogic) MoveInviteMember(req *types.MoveInviteMemberReq) (resp *types.InviteMemberBrief, err error) { + actor, ok := middleware.UIDFrom(l.ctx) + if !ok || actor <= 0 { + return nil, fmt.Errorf("unauthorized") + } + var parent *int64 + if req.ParentUid > 0 { + p := req.ParentUid + parent = &p + } + b, err := l.svcCtx.Auth.MoveInviteMember(l.ctx, actor, req.Uid, parent) + if err != nil { + return nil, err + } + out := types.InviteBriefFromUC(*b) + return &out, nil +} diff --git a/apps/backend/internal/logic/jobs/list_jobs_logic.go b/apps/backend/internal/logic/jobs/list_jobs_logic.go index 3aae433..3cbc67c 100644 --- a/apps/backend/internal/logic/jobs/list_jobs_logic.go +++ b/apps/backend/internal/logic/jobs/list_jobs_logic.go @@ -4,6 +4,7 @@ import ( "context" "apps/backend/internal/middleware" + jobDomain "apps/backend/internal/module/job/domain" "apps/backend/internal/response" "apps/backend/internal/svc" "apps/backend/internal/types" @@ -21,7 +22,7 @@ func NewListJobsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListJobs return &ListJobsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} } -func (l *ListJobsLogic) ListJobs() (*types.JobListData, error) { +func (l *ListJobsLogic) ListJobs(req *types.JobListReq) (*types.JobListData, error) { if l.svcCtx.Jobs == nil { return nil, response.Biz(503, 503001, "jobs module not configured") } @@ -29,7 +30,9 @@ func (l *ListJobsLogic) ListJobs() (*types.JobListData, error) { if !ok { return nil, response.Biz(401, 401001, "missing authorization") } - list, err := l.svcCtx.Jobs.List(l.ctx, uid) + tab := jobDomain.NormalizeListTab(req.Tab) + page, pageSize := jobDomain.ClampPage(req.Page, req.PageSize) + list, total, err := l.svcCtx.Jobs.ListTab(l.ctx, uid, tab, page, pageSize) if err != nil { return nil, err } @@ -37,5 +40,17 @@ func (l *ListJobsLogic) ListJobs() (*types.JobListData, error) { for _, j := range list { out = append(out, types.JobFromModel(j)) } - return &types.JobListData{List: out}, nil + totalPages := 0 + if pageSize > 0 { + totalPages = int((total + int64(pageSize) - 1) / int64(pageSize)) + } + return &types.JobListData{ + List: out, + Pagination: types.Pagination{ + Page: page, + PageSize: pageSize, + Total: total, + TotalPages: totalPages, + }, + }, nil } diff --git a/apps/backend/internal/module/inspire/usecase/m5_inspire_test.go b/apps/backend/internal/module/inspire/usecase/m5_inspire_test.go index 75404b3..8b10df1 100644 --- a/apps/backend/internal/module/inspire/usecase/m5_inspire_test.go +++ b/apps/backend/internal/module/inspire/usecase/m5_inspire_test.go @@ -135,7 +135,7 @@ func TestIN_05_GenerateRequiresMaterialAndRewrites(t *testing.T) { require.NotNil(t, last.Draft) require.NotEmpty(t, last.Draft.Body) require.Contains(t, out.Prompt, "待改寫內容") - require.Contains(t, out.Prompt, "改寫者") + require.Contains(t, out.Prompt, "重寫") require.Contains(t, out.Prompt, mat[:8]) // session 泡泡只留短紀錄,不是整包素材正文 user := out.Session.Messages[len(out.Session.Messages)-2] diff --git a/apps/backend/internal/module/inspire/usecase/service.go b/apps/backend/internal/module/inspire/usecase/service.go index 8babaa7..f71a442 100644 --- a/apps/backend/internal/module/inspire/usecase/service.go +++ b/apps/backend/internal/module/inspire/usecase/service.go @@ -945,19 +945,17 @@ func inspireSystemRules(mode, lang string, maxChars int) string { } if en { return strings.Join([]string{ - "You are a Threads rewriter, not an ideation partner.", - "Only rewrite 【Content to rewrite】 in the persona's voice into a post-ready body.", - "Do not invent a new topic or facts not in the material. No analysis, no titles, no markdown, no outline.", - "You may adjust rhythm, slang, emotion, and a closing question to match the persona.", - "Output ONLY the post body in the persona's language (match language fingerprint / samples). " + lenRuleEn + ". Do not invent brands not in material/pins.", + "Rewrite 【Content to rewrite】 into one Threads post in the persona's voice.", + "Use 【Persona】 only for how they sound (tone / rhythm / wording). Just rewrite — do not over-engineer openings or force templates.", + "Keep the material's topic and facts. No analysis, no titles, no markdown, no outline.", + "Output ONLY the post body. " + lenRuleEn + ". Do not invent brands not in material/pins.", }, "\n") } return strings.Join([]string{ - "你是 Threads「改寫者」,不是發想者。", - "只能依【待改寫內容】與【人設】寫出可貼出的正文。", - "禁止:另起新主題、加入素材沒有的情節或事實、長篇分析、標題前綴、markdown、條列大綱。", - "可以:調整句式、節奏、口頭禪、情緒與結尾問句,使口氣符合人設。", - "只輸出正文,語言必須與【人設】語言指紋/範例一致(若人設是英文就用英文,是繁中就用繁中口語)。" + lenRuleZh + "。未在素材/pin 出現的品牌勿捏造。", + "依【待改寫內容】用人設口吻重寫成一則 Threads 正文。", + "【人設】只決定「聽起來像誰」;直接重寫即可,不必設計固定開頭、不必套模板。", + "主題與事實以素材為準。不要分析、標題、markdown、大綱。", + "只輸出正文。" + lenRuleZh + "。未在素材/pin 出現的品牌勿捏造。", }, "\n") } // chat:正常發想對話,不限 Threads 字數、不強壓極短(完整討論) @@ -1209,7 +1207,11 @@ func formatPersonaPromptBlock(p *PersonaSnapshot, mode, lang string) string { fp := strings.TrimSpace(p.DraftText) if fp != "" { - b.WriteString("【語言指紋 / Language fingerprint】\n") + if en { + b.WriteString("【Language fingerprint】\n") + } else { + b.WriteString("【語言指紋】\n") + } b.WriteString(truncate(fp, 700)) b.WriteString("\n") } @@ -1221,7 +1223,11 @@ func formatPersonaPromptBlock(p *PersonaSnapshot, mode, lang string) string { } } if len(lines) > 0 { - b.WriteString("【風格重點】\n") + if en { + b.WriteString("【Style notes】\n") + } else { + b.WriteString("【風格重點】\n") + } b.WriteString(strings.Join(lines, "\n")) b.WriteString("\n") } diff --git a/apps/backend/internal/module/job/domain/job.go b/apps/backend/internal/module/job/domain/job.go index 09a55d7..c5aecaf 100644 --- a/apps/backend/internal/module/job/domain/job.go +++ b/apps/backend/internal/module/job/domain/job.go @@ -105,3 +105,63 @@ func ApplyProgress(j *Job, percent int, summary string) error { j.UpdatedAt = NowNano() return nil } + +// List tab buckets(任務列表頁籤) +const ( + TabRecurring = "recurring" // 遠期定期/已排程未到期 + TabActive = "active" // 執行中、待領取(已到期)、取消中 + TabHistory = "history" // 已完成/失敗/取消 +) + +// NormalizeListTab — 空或未知 → active +func NormalizeListTab(tab string) string { + switch tab { + case TabRecurring, TabActive, TabHistory: + return tab + default: + return TabActive + } +} + +// MatchListTab — 依狀態+run_after 分桶(與 FE 一致) +func MatchListTab(j *Job, tab string, nowNs int64) bool { + if j == nil { + return false + } + if nowNs <= 0 { + nowNs = NowNano() + } + switch NormalizeListTab(tab) { + case TabHistory: + return IsTerminal(j.Status) + case TabRecurring: + // 遠期排程:pending/queued 且 run_after 尚未到 + if j.Status != StatusPending && j.Status != StatusQueued { + return false + } + return j.RunAfter > 0 && j.RunAfter > nowNs + default: // active + if j.Status == StatusRunning { + return true + } + if j.Status == StatusPending || j.Status == StatusQueued { + // 已到期或立刻可領 + return j.RunAfter <= 0 || j.RunAfter <= nowNs + } + return false + } +} + +// ClampPage — page 從 1;pageSize 預設 10、上限 50 +func ClampPage(page, pageSize int) (int, int) { + if page < 1 { + page = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + if pageSize > 50 { + pageSize = 50 + } + return page, pageSize +} diff --git a/apps/backend/internal/module/job/domain/repository.go b/apps/backend/internal/module/job/domain/repository.go index adc38ef..dec98fa 100644 --- a/apps/backend/internal/module/job/domain/repository.go +++ b/apps/backend/internal/module/job/domain/repository.go @@ -7,6 +7,8 @@ type Repository interface { Update(ctx context.Context, j *Job) error FindByID(ctx context.Context, id string) (*Job, error) ListByOwner(ctx context.Context, ownerUID int64) ([]*Job, error) + // ListByOwnerTab 分頁+分桶;total 為該 tab 總筆數 + ListByOwnerTab(ctx context.Context, ownerUID int64, tab string, page, pageSize int) (list []*Job, total int64, err error) // ClaimNext picks oldest due pending/queued (run_after <= now) and sets running + workerID. ClaimNext(ctx context.Context, workerID string) (*Job, error) // CancelPendingByRef cancels pending/queued jobs of template+ref (e.g. reschedule renew). diff --git a/apps/backend/internal/module/job/repository/memory.go b/apps/backend/internal/module/job/repository/memory.go index f00a3a0..b47bd7b 100644 --- a/apps/backend/internal/module/job/repository/memory.go +++ b/apps/backend/internal/module/job/repository/memory.go @@ -59,6 +59,50 @@ func (s *MemoryStore) ListByOwner(_ context.Context, ownerUID int64) ([]*domain. return out, nil } +func (s *MemoryStore) ListByOwnerTab(_ context.Context, ownerUID int64, tab string, page, pageSize int) ([]*domain.Job, int64, error) { + s.mu.Lock() + defer s.mu.Unlock() + page, pageSize = domain.ClampPage(page, pageSize) + now := domain.NowNano() + var matched []*domain.Job + for _, j := range s.byID { + if j.OwnerUID != ownerUID { + continue + } + if domain.MatchListTab(j, tab, now) { + cp := *j + matched = append(matched, &cp) + } + } + // sort updated_at desc(recurring: run_after asc) + tabN := domain.NormalizeListTab(tab) + for i := 0; i < len(matched); i++ { + for k := i + 1; k < len(matched); k++ { + swap := false + if tabN == domain.TabRecurring { + if matched[k].RunAfter < matched[i].RunAfter { + swap = true + } + } else if matched[k].UpdatedAt > matched[i].UpdatedAt { + swap = true + } + if swap { + matched[i], matched[k] = matched[k], matched[i] + } + } + } + total := int64(len(matched)) + start := (page - 1) * pageSize + if start >= len(matched) { + return []*domain.Job{}, total, nil + } + end := start + pageSize + if end > len(matched) { + end = len(matched) + } + return matched[start:end], total, nil +} + func (s *MemoryStore) ClaimNext(_ context.Context, workerID string) (*domain.Job, error) { s.mu.Lock() defer s.mu.Unlock() diff --git a/apps/backend/internal/module/job/repository/mongo.go b/apps/backend/internal/module/job/repository/mongo.go index 6b6b7ac..0f66c9b 100644 --- a/apps/backend/internal/module/job/repository/mongo.go +++ b/apps/backend/internal/module/job/repository/mongo.go @@ -54,10 +54,63 @@ func (s *MonStore) FindByID(ctx context.Context, id string) (*domain.Job, error) func (s *MonStore) ListByOwner(ctx context.Context, ownerUID int64) ([]*domain.Job, error) { var list []*domain.Job err := s.jobs.Find(ctx, &list, bson.M{"owner_uid": ownerUID}, - options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})) + options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}})) return list, err } +func jobTabFilter(ownerUID int64, tab string, nowNs int64) bson.M { + base := bson.M{"owner_uid": ownerUID} + switch domain.NormalizeListTab(tab) { + case domain.TabHistory: + base["status"] = bson.M{"$in": []string{ + domain.StatusSucceeded, domain.StatusFailed, domain.StatusCancelled, + }} + case domain.TabRecurring: + base["status"] = bson.M{"$in": []string{domain.StatusPending, domain.StatusQueued}} + base["run_after"] = bson.M{"$gt": nowNs} + default: // active + base["$or"] = []bson.M{ + {"status": domain.StatusRunning}, + { + "status": bson.M{"$in": []string{domain.StatusPending, domain.StatusQueued}}, + "$or": []bson.M{ + {"run_after": bson.M{"$exists": false}}, + {"run_after": 0}, + {"run_after": bson.M{"$lte": nowNs}}, + }, + }, + } + } + return base +} + +func (s *MonStore) ListByOwnerTab(ctx context.Context, ownerUID int64, tab string, page, pageSize int) ([]*domain.Job, int64, error) { + page, pageSize = domain.ClampPage(page, pageSize) + now := domain.NowNano() + filter := jobTabFilter(ownerUID, tab, now) + + total, err := s.jobs.CountDocuments(ctx, filter) + if err != nil { + return nil, 0, err + } + + sort := bson.D{{Key: "updated_at", Value: -1}} + if domain.NormalizeListTab(tab) == domain.TabRecurring { + sort = bson.D{{Key: "run_after", Value: 1}, {Key: "created_at", Value: 1}} + } else if domain.NormalizeListTab(tab) == domain.TabHistory { + sort = bson.D{{Key: "completed_at", Value: -1}, {Key: "updated_at", Value: -1}} + } + + skip := int64((page - 1) * pageSize) + var list []*domain.Job + err = s.jobs.Find(ctx, &list, filter, + options.Find().SetSort(sort).SetSkip(skip).SetLimit(int64(pageSize))) + if err != nil { + return nil, 0, err + } + return list, total, nil +} + func (s *MonStore) ClaimNext(ctx context.Context, workerID string) (*domain.Job, error) { now := domain.NowNano() // due: run_after missing/0 OR run_after <= now diff --git a/apps/backend/internal/module/job/usecase/service.go b/apps/backend/internal/module/job/usecase/service.go index 575005e..b28dbcb 100644 --- a/apps/backend/internal/module/job/usecase/service.go +++ b/apps/backend/internal/module/job/usecase/service.go @@ -52,6 +52,11 @@ func (s *Service) List(ctx context.Context, ownerUID int64) ([]*domain.Job, erro return s.Repo.ListByOwner(ctx, ownerUID) } +// ListTab — 分頁+分桶(任務頁三頁籤) +func (s *Service) ListTab(ctx context.Context, ownerUID int64, tab string, page, pageSize int) ([]*domain.Job, int64, error) { + return s.Repo.ListByOwnerTab(ctx, ownerUID, tab, page, pageSize) +} + // Get — JB-09 func (s *Service) Get(ctx context.Context, ownerUID int64, id string) (*domain.Job, error) { j, err := s.Repo.FindByID(ctx, id) diff --git a/apps/backend/internal/module/job/usecase/service_test.go b/apps/backend/internal/module/job/usecase/service_test.go index 5a202c6..ce83d62 100644 --- a/apps/backend/internal/module/job/usecase/service_test.go +++ b/apps/backend/internal/module/job/usecase/service_test.go @@ -234,3 +234,43 @@ func TestJB_16_SchedulePersonaAnalyzeText(t *testing.T) { require.Equal(t, "pe_txt", j.RefID) require.Contains(t, j.Payload, "raw_text") } + +func TestJB_ListTab_BucketsAndPaging(t *testing.T) { + svc, _ := newJobSvc() + uid := int64(1_000_200) + ctx := context.Background() + // due demo → active, then finish → history + d, err := svc.StartDemo(ctx, uid) + require.NoError(t, err) + // future scheduled → recurring + now := domain.NowNano() + require.NoError(t, svc.ScheduleTokenRenew(ctx, uid, "acc-1", now+domain.DefaultTokenRenewDelayNs)) + + claimed, err := svc.ClaimNext(ctx, "w1") + require.NoError(t, err) + require.Equal(t, d.ID, claimed.ID) + _, err = svc.RunDemoToSuccess(ctx, claimed.ID) + require.NoError(t, err) + + active, totalA, err := svc.ListTab(ctx, uid, domain.TabActive, 1, 10) + require.NoError(t, err) + require.Equal(t, int64(0), totalA) + require.Empty(t, active) + + rec, totalR, err := svc.ListTab(ctx, uid, domain.TabRecurring, 1, 10) + require.NoError(t, err) + require.Equal(t, int64(1), totalR) + require.Len(t, rec, 1) + require.Equal(t, domain.TemplateThreadsTokenRenew, rec[0].TemplateType) + + hist, totalH, err := svc.ListTab(ctx, uid, domain.TabHistory, 1, 10) + require.NoError(t, err) + require.Equal(t, int64(1), totalH) + require.Len(t, hist, 1) + require.Equal(t, domain.StatusSucceeded, hist[0].Status) + + // page size 1 on history + page1, _, err := svc.ListTab(ctx, uid, domain.TabHistory, 1, 1) + require.NoError(t, err) + require.Len(t, page1, 1) +} diff --git a/apps/backend/internal/module/member/domain/errors.go b/apps/backend/internal/module/member/domain/errors.go index 88fb348..b47f856 100644 --- a/apps/backend/internal/module/member/domain/errors.go +++ b/apps/backend/internal/module/member/domain/errors.go @@ -27,4 +27,14 @@ var ( ErrAlreadyBound = errors.New("already bound") ErrInvalidPlatform = errors.New("invalid platform") ErrOAuthFailed = errors.New("oauth verification failed") + + // 邀請網絡(message = FE i18n key invite.err.*) + ErrInviteAlreadyBound = errors.New("invite.err.alreadyBound") + ErrInviteCodeRequired = errors.New("invite.err.codeRequired") + ErrInviteCodeNotFound = errors.New("invite.err.codeNotFound") + ErrInviteCodeSelf = errors.New("invite.err.codeSelf") + ErrInviteCycle = errors.New("invite.err.cycle") + ErrInviteSelfParent = errors.New("invite.err.selfParent") + ErrInviteParentMiss = errors.New("invite.err.parentNotFound") + ErrInviteNeedAdmin = errors.New("invite.err.needAdmin") ) diff --git a/apps/backend/internal/module/member/domain/repository.go b/apps/backend/internal/module/member/domain/repository.go index 7f2a418..a8307aa 100644 --- a/apps/backend/internal/module/member/domain/repository.go +++ b/apps/backend/internal/module/member/domain/repository.go @@ -14,8 +14,11 @@ type Repository interface { FindByUID(ctx context.Context, uid int64) (*Member, error) FindByEmail(ctx context.Context, email string) (*Member, error) FindByPhone(ctx context.Context, phone string) (*Member, error) + FindByInviteCode(ctx context.Context, code string) (*Member, error) UpdateMember(ctx context.Context, m *Member) error ListPage(ctx context.Context, page, pageSize int, query string, status string) (list []*Member, total int64, err error) + // ListAllMembers — 邀請樹用(全量;小租戶) + ListAllMembers(ctx context.Context) ([]*Member, error) CountActiveAdmins(ctx context.Context) (int64, error) // Identities (login channels) diff --git a/apps/backend/internal/module/member/repository/mongo_store.go b/apps/backend/internal/module/member/repository/mongo_store.go index 99e5008..ec566ea 100644 --- a/apps/backend/internal/module/member/repository/mongo_store.go +++ b/apps/backend/internal/module/member/repository/mongo_store.go @@ -218,6 +218,35 @@ func (s *MoncStore) CountActiveAdmins(ctx context.Context) (int64, error) { return s.plain.CountDocuments(ctx, bson.M{"status": domain.StatusActive, "roles": domain.RoleAdmin}) } +func (s *MoncStore) FindByInviteCode(ctx context.Context, code string) (*domain.Member, error) { + code = strings.ToUpper(strings.TrimSpace(code)) + if code == "" { + return nil, domain.ErrNotFound + } + var m domain.Member + err := s.plain.FindOne(ctx, &m, bson.M{ + "invite_code": code, + "uid": bson.M{"$type": bson.A{"int", "long", "double"}}, + }) + if err != nil { + if errors.Is(err, mon.ErrNotFound) || errors.Is(err, mongo.ErrNoDocuments) { + return nil, domain.ErrNotFound + } + return nil, err + } + return &m, nil +} + +func (s *MoncStore) ListAllMembers(ctx context.Context) ([]*domain.Member, error) { + filter := bson.M{"uid": bson.M{"$type": bson.A{"int", "long", "double"}}} + var list []*domain.Member + err := s.plain.Find(ctx, &list, filter, options.Find().SetSort(bson.D{{Key: "uid", Value: 1}})) + if err != nil { + return nil, err + } + return list, nil +} + // --- identities --- func (s *MoncStore) CreateIdentity(ctx context.Context, id *domain.Identity) error { diff --git a/apps/backend/internal/module/member/usecase/auth_test.go b/apps/backend/internal/module/member/usecase/auth_test.go index e57e472..3b39f01 100644 --- a/apps/backend/internal/module/member/usecase/auth_test.go +++ b/apps/backend/internal/module/member/usecase/auth_test.go @@ -157,6 +157,30 @@ func (f *fakeRepo) CountActiveAdmins(ctx context.Context) (int64, error) { return n, nil } +func (f *fakeRepo) FindByInviteCode(ctx context.Context, code string) (*domain.Member, error) { + f.mu.Lock() + defer f.mu.Unlock() + code = strings.ToUpper(strings.TrimSpace(code)) + for _, m := range f.byUID { + if strings.ToUpper(strings.TrimSpace(m.InviteCode)) == code { + cp := *m + return &cp, nil + } + } + return nil, domain.ErrNotFound +} + +func (f *fakeRepo) ListAllMembers(ctx context.Context) ([]*domain.Member, error) { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]*domain.Member, 0, len(f.byUID)) + for _, m := range f.byUID { + cp := *m + out = append(out, &cp) + } + return out, nil +} + func (f *fakeRepo) CreateIdentity(ctx context.Context, id *domain.Identity) error { f.mu.Lock() defer f.mu.Unlock() diff --git a/apps/backend/internal/module/member/usecase/invite.go b/apps/backend/internal/module/member/usecase/invite.go new file mode 100644 index 0000000..242d0d8 --- /dev/null +++ b/apps/backend/internal/module/member/usecase/invite.go @@ -0,0 +1,342 @@ +package usecase + +import ( + "context" + "sort" + "strings" + + "apps/backend/internal/module/member/domain" +) + +// InviteMemberBrief — 邀請網絡節點摘要(API 層再轉 types) +type InviteMemberBrief struct { + UID int64 + Email string + DisplayName string + InviteCode string + ParentUID *int64 + Roles []string + Status string + DownlineCount int + TotalDownlineCount int + Depth int + JoinedAt int64 + AvatarURL string +} + +// InviteTreeNode — 森林節點 +type InviteTreeNode struct { + InviteMemberBrief + Children []InviteTreeNode +} + +// MyInviteNetwork — 會員自己的碼 + 上下線 +type MyInviteNetwork struct { + Me InviteMemberBrief + Upline *InviteMemberBrief + Downlines []InviteMemberBrief + TotalDownlineCount int +} + +// GetMyNetwork — IV-01 / IV-07 +func (s *AccountService) GetMyNetwork(ctx context.Context, uid int64) (*MyInviteNetwork, error) { + all, err := s.Repo.ListAllMembers(ctx) + if err != nil { + return nil, err + } + briefs := buildInviteBriefs(all) + me, ok := briefs[uid] + if !ok { + return nil, domain.ErrNotFound + } + var upline *InviteMemberBrief + if me.ParentUID != nil { + if u, ok := briefs[*me.ParentUID]; ok { + cp := u + upline = &cp + } + } + var down []InviteMemberBrief + for _, m := range all { + if m.ParentUID != nil && *m.ParentUID == uid { + if b, ok := briefs[m.UID]; ok { + down = append(down, b) + } + } + } + sort.Slice(down, func(i, j int) bool { return down[i].JoinedAt > down[j].JoinedAt }) + return &MyInviteNetwork{ + Me: me, + Upline: upline, + Downlines: down, + TotalDownlineCount: me.TotalDownlineCount, + }, nil +} + +// ApplyInviteCode — IV-02~06 +func (s *AccountService) ApplyInviteCode(ctx context.Context, uid int64, rawCode string) (*MyInviteNetwork, error) { + code := strings.ToUpper(strings.TrimSpace(rawCode)) + if code == "" { + return nil, domain.ErrInviteCodeRequired + } + me, err := s.Repo.FindByUID(ctx, uid) + if err != nil { + return nil, err + } + if me.ParentUID != nil && *me.ParentUID > 0 { + return nil, domain.ErrInviteAlreadyBound + } + inviter, err := s.Repo.FindByInviteCode(ctx, code) + if err != nil { + if err == domain.ErrNotFound { + return nil, domain.ErrInviteCodeNotFound + } + return nil, err + } + if inviter.UID == uid { + return nil, domain.ErrInviteCodeSelf + } + // 防環:邀請人不可為自己的子孫 + all, err := s.Repo.ListAllMembers(ctx) + if err != nil { + return nil, err + } + if isDescendantOf(uid, inviter.UID, all) { + return nil, domain.ErrInviteCycle + } + p := inviter.UID + me.ParentUID = &p + me.UpdatedAt = domain.NowNano() + if err := s.Repo.UpdateMember(ctx, me); err != nil { + return nil, err + } + return s.GetMyNetwork(ctx, uid) +} + +// GetInviteTree — IV-08 admin +func (s *AccountService) GetInviteTree(ctx context.Context) ([]InviteTreeNode, error) { + all, err := s.Repo.ListAllMembers(ctx) + if err != nil { + return nil, err + } + return buildInviteForest(all), nil +} + +// MoveInviteMember — IV-09~12 +// newParentUID nil = 獨立根 +func (s *AccountService) MoveInviteMember(ctx context.Context, actorUID, targetUID int64, newParentUID *int64) (*InviteMemberBrief, error) { + actor, err := s.Repo.FindByUID(ctx, actorUID) + if err != nil { + return nil, err + } + if !actor.IsAdmin() { + return nil, domain.ErrInviteNeedAdmin + } + target, err := s.Repo.FindByUID(ctx, targetUID) + if err != nil { + return nil, domain.ErrNotFound + } + if newParentUID != nil && *newParentUID == targetUID { + return nil, domain.ErrInviteSelfParent + } + if newParentUID != nil && *newParentUID > 0 { + parent, err := s.Repo.FindByUID(ctx, *newParentUID) + if err != nil { + return nil, domain.ErrInviteParentMiss + } + _ = parent + all, err := s.Repo.ListAllMembers(ctx) + if err != nil { + return nil, err + } + // 新父不可是 target 的子孫(含自己已檢查) + if isDescendantOf(targetUID, *newParentUID, all) { + return nil, domain.ErrInviteCycle + } + target.ParentUID = newParentUID + } else { + target.ParentUID = nil + } + target.UpdatedAt = domain.NowNano() + if err := s.Repo.UpdateMember(ctx, target); err != nil { + return nil, err + } + all, err := s.Repo.ListAllMembers(ctx) + if err != nil { + return nil, err + } + briefs := buildInviteBriefs(all) + b := briefs[targetUID] + return &b, nil +} + +// ListParentCandidates — IV-13 排除子樹 +func (s *AccountService) ListParentCandidates(ctx context.Context, excludeSubtreeRootUID int64) ([]InviteMemberBrief, error) { + all, err := s.Repo.ListAllMembers(ctx) + if err != nil { + return nil, err + } + exclude := map[int64]struct{}{} + if excludeSubtreeRootUID > 0 { + exclude[excludeSubtreeRootUID] = struct{}{} + for _, m := range all { + if isDescendantOf(excludeSubtreeRootUID, m.UID, all) { + exclude[m.UID] = struct{}{} + } + } + } + briefs := buildInviteBriefs(all) + var out []InviteMemberBrief + for _, m := range all { + if _, skip := exclude[m.UID]; skip { + continue + } + if b, ok := briefs[m.UID]; ok { + out = append(out, b) + } + } + sort.Slice(out, func(i, j int) bool { + return strings.ToLower(out[i].DisplayName) < strings.ToLower(out[j].DisplayName) + }) + return out, nil +} + +// --- helpers --- + +func buildInviteBriefs(members []*domain.Member) map[int64]InviteMemberBrief { + children := map[int64][]int64{} + byUID := map[int64]*domain.Member{} + for _, m := range members { + byUID[m.UID] = m + if m.ParentUID != nil && *m.ParentUID > 0 { + p := *m.ParentUID + children[p] = append(children[p], m.UID) + } + } + totalMemo := map[int64]int{} + var totalOf func(uid int64, stack map[int64]struct{}) int + totalOf = func(uid int64, stack map[int64]struct{}) int { + if v, ok := totalMemo[uid]; ok { + return v + } + if _, loop := stack[uid]; loop { + return 0 + } + stack[uid] = struct{}{} + kids := children[uid] + n := len(kids) + for _, k := range kids { + n += totalOf(k, stack) + } + delete(stack, uid) + totalMemo[uid] = n + return n + } + depthMemo := map[int64]int{} + var depthOf func(uid int64, stack map[int64]struct{}) int + depthOf = func(uid int64, stack map[int64]struct{}) int { + if v, ok := depthMemo[uid]; ok { + return v + } + if _, loop := stack[uid]; loop { + return 0 + } + m := byUID[uid] + if m == nil || m.ParentUID == nil || *m.ParentUID <= 0 || byUID[*m.ParentUID] == nil { + depthMemo[uid] = 0 + return 0 + } + stack[uid] = struct{}{} + d := 1 + depthOf(*m.ParentUID, stack) + delete(stack, uid) + depthMemo[uid] = d + return d + } + + out := make(map[int64]InviteMemberBrief, len(members)) + for _, m := range members { + b := InviteMemberBrief{ + UID: m.UID, Email: m.Email, DisplayName: m.DisplayName, + InviteCode: strings.TrimSpace(m.InviteCode), Roles: append([]string{}, m.Roles...), + Status: m.Status, JoinedAt: m.CreatedAt, AvatarURL: m.AvatarURL, + DownlineCount: len(children[m.UID]), TotalDownlineCount: totalOf(m.UID, map[int64]struct{}{}), + Depth: depthOf(m.UID, map[int64]struct{}{}), + } + if m.ParentUID != nil && *m.ParentUID > 0 && byUID[*m.ParentUID] != nil { + p := *m.ParentUID + b.ParentUID = &p + } + if b.InviteCode == "" { + b.InviteCode = "—" + } + out[m.UID] = b + } + return out +} + +func buildInviteForest(members []*domain.Member) []InviteTreeNode { + briefs := buildInviteBriefs(members) + byParent := map[int64][]InviteMemberBrief{} // key 0 = roots + for _, b := range briefs { + var pk int64 + if b.ParentUID != nil { + pk = *b.ParentUID + if _, ok := briefs[pk]; !ok { + pk = 0 // 孤兒當根 + } + } + byParent[pk] = append(byParent[pk], b) + } + var build func(uid int64) InviteTreeNode + build = func(uid int64) InviteTreeNode { + b := briefs[uid] + n := InviteTreeNode{InviteMemberBrief: b, Children: nil} + kids := byParent[uid] + sort.Slice(kids, func(i, j int) bool { + return strings.ToLower(kids[i].DisplayName) < strings.ToLower(kids[j].DisplayName) + }) + for _, k := range kids { + n.Children = append(n.Children, build(k.UID)) + } + return n + } + roots := byParent[0] + sort.Slice(roots, func(i, j int) bool { + return strings.ToLower(roots[i].DisplayName) < strings.ToLower(roots[j].DisplayName) + }) + var forest []InviteTreeNode + for _, r := range roots { + forest = append(forest, build(r.UID)) + } + return forest +} + +// isDescendantOf — whether candidate is under rootUID's subtree (not root itself) +func isDescendantOf(rootUID, candidateUID int64, members []*domain.Member) bool { + if rootUID == candidateUID { + return false + } + parentOf := map[int64]int64{} + for _, m := range members { + if m.ParentUID != nil && *m.ParentUID > 0 { + parentOf[m.UID] = *m.ParentUID + } + } + seen := map[int64]struct{}{} + cur := candidateUID + for cur != 0 { + if cur == rootUID { + return true + } + if _, loop := seen[cur]; loop { + return false + } + seen[cur] = struct{}{} + p, ok := parentOf[cur] + if !ok { + return false + } + cur = p + } + return false +} diff --git a/apps/backend/internal/module/member/usecase/invite_test.go b/apps/backend/internal/module/member/usecase/invite_test.go new file mode 100644 index 0000000..fd47e5c --- /dev/null +++ b/apps/backend/internal/module/member/usecase/invite_test.go @@ -0,0 +1,171 @@ +package usecase_test + +// T219 — M6 invite integration (spec §9.16 IV-01~IV-13) + +import ( + "context" + "testing" + + "apps/backend/internal/module/member/domain" + "apps/backend/internal/module/member/usecase" + + "github.com/stretchr/testify/require" +) + +func TestIV_01_GetMyNetwork(t *testing.T) { + store, svc := newM1Svc() + m, _ := seedMember(t, store, svc, "iv01@test.local") + net, err := svc.GetMyNetwork(context.Background(), m.UID) + require.NoError(t, err) + require.Equal(t, m.UID, net.Me.UID) + require.NotEmpty(t, net.Me.InviteCode) + require.Nil(t, net.Me.ParentUID) + require.Empty(t, net.Downlines) +} + +func TestIV_02_ApplyLegalCode(t *testing.T) { + store, svc := newM1Svc() + inv, _ := seedMember(t, store, svc, "iv02-inv@test.local") + me, _ := seedMember(t, store, svc, "iv02-me@test.local") + net, err := svc.ApplyInviteCode(context.Background(), me.UID, inv.InviteCode) + require.NoError(t, err) + require.NotNil(t, net.Me.ParentUID) + require.Equal(t, inv.UID, *net.Me.ParentUID) + require.NotNil(t, net.Upline) + require.Equal(t, inv.UID, net.Upline.UID) +} + +func TestIV_03_AlreadyBound(t *testing.T) { + store, svc := newM1Svc() + a, _ := seedMember(t, store, svc, "iv03a@test.local") + b, _ := seedMember(t, store, svc, "iv03b@test.local") + c, _ := seedMember(t, store, svc, "iv03c@test.local") + _, err := svc.ApplyInviteCode(context.Background(), b.UID, a.InviteCode) + require.NoError(t, err) + _, err = svc.ApplyInviteCode(context.Background(), b.UID, c.InviteCode) + require.ErrorIs(t, err, domain.ErrInviteAlreadyBound) +} + +func TestIV_04_ApplySelfCode(t *testing.T) { + store, svc := newM1Svc() + me, _ := seedMember(t, store, svc, "iv04@test.local") + _, err := svc.ApplyInviteCode(context.Background(), me.UID, me.InviteCode) + require.ErrorIs(t, err, domain.ErrInviteCodeSelf) +} + +func TestIV_05_ApplyUnknownCode(t *testing.T) { + store, svc := newM1Svc() + me, _ := seedMember(t, store, svc, "iv05@test.local") + _, err := svc.ApplyInviteCode(context.Background(), me.UID, "NO-SUCH-CODE") + require.ErrorIs(t, err, domain.ErrInviteCodeNotFound) +} + +func TestIV_06_ApplyEmptyCode(t *testing.T) { + store, svc := newM1Svc() + me, _ := seedMember(t, store, svc, "iv06@test.local") + _, err := svc.ApplyInviteCode(context.Background(), me.UID, " ") + require.ErrorIs(t, err, domain.ErrInviteCodeRequired) +} + +func TestIV_07_DownlinesCount(t *testing.T) { + store, svc := newM1Svc() + inv, _ := seedMember(t, store, svc, "iv07-inv@test.local") + d1, _ := seedMember(t, store, svc, "iv07-d1@test.local") + d2, _ := seedMember(t, store, svc, "iv07-d2@test.local") + _, err := svc.ApplyInviteCode(context.Background(), d1.UID, inv.InviteCode) + require.NoError(t, err) + _, err = svc.ApplyInviteCode(context.Background(), d2.UID, inv.InviteCode) + require.NoError(t, err) + net, err := svc.GetMyNetwork(context.Background(), inv.UID) + require.NoError(t, err) + require.Len(t, net.Downlines, 2) + require.Equal(t, 2, net.TotalDownlineCount) + require.Equal(t, 2, net.Me.DownlineCount) +} + +func TestIV_08_GetTree(t *testing.T) { + store, svc := newM1Svc() + root, _ := seedMember(t, store, svc, "iv08-root@test.local") + child, _ := seedMember(t, store, svc, "iv08-c@test.local") + _, err := svc.ApplyInviteCode(context.Background(), child.UID, root.InviteCode) + require.NoError(t, err) + forest, err := svc.GetInviteTree(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, forest) + // find root + var found bool + var walk func([]usecase.InviteTreeNode) + walk = func(nodes []usecase.InviteTreeNode) { + for _, n := range nodes { + if n.UID == root.UID { + found = true + require.NotEmpty(t, n.Children) + } + walk(n.Children) + } + } + walk(forest) + require.True(t, found) +} + +func TestIV_09_MoveLegal(t *testing.T) { + store, svc := newM1Svc() + admin, _ := seedAdmin(t, store, svc, "iv09-admin@test.local") + a, _ := seedMember(t, store, svc, "iv09-a@test.local") + b, _ := seedMember(t, store, svc, "iv09-b@test.local") + // b under a + _, err := svc.ApplyInviteCode(context.Background(), b.UID, a.InviteCode) + require.NoError(t, err) + // move b to root + brief, err := svc.MoveInviteMember(context.Background(), admin.UID, b.UID, nil) + require.NoError(t, err) + require.Nil(t, brief.ParentUID) +} + +func TestIV_10_NoCycle(t *testing.T) { + store, svc := newM1Svc() + admin, _ := seedAdmin(t, store, svc, "iv10-admin@test.local") + a, _ := seedMember(t, store, svc, "iv10-a@test.local") + b, _ := seedMember(t, store, svc, "iv10-b@test.local") + _, err := svc.ApplyInviteCode(context.Background(), b.UID, a.InviteCode) + require.NoError(t, err) + // move a under b → cycle + _, err = svc.MoveInviteMember(context.Background(), admin.UID, a.UID, &b.UID) + require.ErrorIs(t, err, domain.ErrInviteCycle) +} + +func TestIV_11_MoveToSelf(t *testing.T) { + store, svc := newM1Svc() + admin, _ := seedAdmin(t, store, svc, "iv11-admin@test.local") + a, _ := seedMember(t, store, svc, "iv11-a@test.local") + _, err := svc.MoveInviteMember(context.Background(), admin.UID, a.UID, &a.UID) + require.ErrorIs(t, err, domain.ErrInviteSelfParent) +} + +func TestIV_12_MemberCannotMove(t *testing.T) { + store, svc := newM1Svc() + mem, _ := seedMember(t, store, svc, "iv12-mem@test.local") + a, _ := seedMember(t, store, svc, "iv12-a@test.local") + b, _ := seedMember(t, store, svc, "iv12-b@test.local") + _, err := svc.MoveInviteMember(context.Background(), mem.UID, a.UID, &b.UID) + require.ErrorIs(t, err, domain.ErrInviteNeedAdmin) +} + +func TestIV_13_ListParentCandidatesExcludeSubtree(t *testing.T) { + store, svc := newM1Svc() + a, _ := seedMember(t, store, svc, "iv13-a@test.local") + b, _ := seedMember(t, store, svc, "iv13-b@test.local") + c, _ := seedMember(t, store, svc, "iv13-c@test.local") + _, err := svc.ApplyInviteCode(context.Background(), b.UID, a.InviteCode) + require.NoError(t, err) + _, err = svc.ApplyInviteCode(context.Background(), c.UID, b.InviteCode) + require.NoError(t, err) + // exclude subtree of a → no a,b,c + cands, err := svc.ListParentCandidates(context.Background(), a.UID) + require.NoError(t, err) + for _, x := range cands { + require.NotEqual(t, a.UID, x.UID) + require.NotEqual(t, b.UID, x.UID) + require.NotEqual(t, c.UID, x.UID) + } +} diff --git a/apps/backend/internal/module/usage/domain/usage.go b/apps/backend/internal/module/usage/domain/usage.go index 16cb5b3..e9ac8c0 100644 --- a/apps/backend/internal/module/usage/domain/usage.go +++ b/apps/backend/internal/module/usage/domain/usage.go @@ -2,6 +2,7 @@ package domain import ( "errors" + "strings" "time" ) @@ -24,7 +25,11 @@ var ( ErrForbidden = errors.New("usage forbidden") ErrNoKey = errors.New("no api key configured (platform or byok)") ErrQuotaExceeded = errors.New("platform credits insufficient") - ErrInvalidPlan = errors.New("invalid plan") + // 分項點數上限(與 FE soft_caps 對齊;platform only) + ErrMeterCapExceeded = errors.New("usage.err.meterCap") + // 平台 key 無容量/限流:僅 BYOK 可繼續(不扣平台點) + ErrPlatformCapacity = errors.New("usage.err.platformCapacity") + ErrInvalidPlan = errors.New("invalid plan") ) type Event struct { @@ -57,18 +62,55 @@ type Purchase struct { type PlanDef struct { ID string MonthlyCredits int + // SoftCaps:各 meter 點數硬上限(platform);加總 = MonthlyCredits + SoftCaps map[string]int } +// Plans — 與 FE PLANS 同步(2026-07 · 組合毛利 ≥50%) +// 安全單價約 $0.012/點;Starter NT$590≈$18.4 / Pro NT$1990≈$62.2 +// Free 120 點:夠用試用(可虧 ~$1.44 滿用);付費扛回組合毛利。 var Plans = map[string]PlanDef{ - PlanFree: {ID: PlanFree, MonthlyCredits: 80}, - PlanStarter: {ID: PlanStarter, MonthlyCredits: 500}, - PlanPro: {ID: PlanPro, MonthlyCredits: 2000}, + PlanFree: { + ID: PlanFree, MonthlyCredits: 120, + SoftCaps: map[string]int{ + MeterAICopy: 60, MeterAIResearch: 15, MeterWebSearch: 30, MeterAIImage: 15, + }, + }, + PlanStarter: { + ID: PlanStarter, MonthlyCredits: 600, + SoftCaps: map[string]int{ + MeterAICopy: 300, MeterAIResearch: 90, MeterWebSearch: 150, MeterAIImage: 60, + }, + }, + PlanPro: { + ID: PlanPro, MonthlyCredits: 2000, + SoftCaps: map[string]int{ + MeterAICopy: 1000, MeterAIResearch: 300, MeterWebSearch: 500, MeterAIImage: 200, + }, + }, } var DefaultCreditCost = map[string]int{ MeterAICopy: 1, MeterAIResearch: 3, MeterWebSearch: 1, MeterAIImage: 5, } +// ResolvePlan — 正規化 plan_id(小寫)並回傳方案;未知 → Free +func ResolvePlan(planID string) PlanDef { + id := strings.ToLower(strings.TrimSpace(planID)) + if p, ok := Plans[id]; ok && p.ID != "" { + return p + } + return Plans[PlanFree] +} + +// MeterCost returns platform credit cost for one call. +func MeterCost(meter string) int { + if c := DefaultCreditCost[meter]; c > 0 { + return c + } + return 1 +} + func NowNano() int64 { return time.Now().UTC().UnixNano() } func MonthKey(nano int64) string { diff --git a/apps/backend/internal/module/usage/usecase/platform_gate.go b/apps/backend/internal/module/usage/usecase/platform_gate.go new file mode 100644 index 0000000..fa107aa --- /dev/null +++ b/apps/backend/internal/module/usage/usecase/platform_gate.go @@ -0,0 +1,96 @@ +package usecase + +import ( + "sync" + "time" + + "apps/backend/internal/module/usage/domain" +) + +// PlatformGate decides whether platform-paid keys still have capacity. +// When capacity is exhausted, only BYOK users can proceed (Resolve already prefers BYOK). +type PlatformGate interface { + // AllowPlatform reports whether a new platform call for meter may start. + // BYOK path must never call this. + AllowPlatform(meter string) bool + // MarkLimited marks platform as limited until until (e.g. after upstream 429). + // until zero = clear limit. + MarkLimited(until time.Time) +} + +// MemoryPlatformGate — process-local RPM + optional global cool-down. +// Multi-instance deployments should later share via Redis; this covers single-node +// and "ops flip" cool-down after provider rate limits. +type MemoryPlatformGate struct { + mu sync.Mutex + + // Per-minute caps; 0 = unlimited for that class. + AIPerMin int + SearchPerMin int + + // Global cool-down: when set and now < limitedUntil, all platform denied. + limitedUntil time.Time + + aiWinStart time.Time + aiCount int + searchWinStart time.Time + searchCount int +} + +// NewMemoryPlatformGate builds a gate. Zero limits mean unlimited RPM (cool-down still applies). +func NewMemoryPlatformGate(aiPerMin, searchPerMin int) *MemoryPlatformGate { + return &MemoryPlatformGate{AIPerMin: aiPerMin, SearchPerMin: searchPerMin} +} + +func (g *MemoryPlatformGate) MarkLimited(until time.Time) { + if g == nil { + return + } + g.mu.Lock() + defer g.mu.Unlock() + g.limitedUntil = until +} + +func (g *MemoryPlatformGate) AllowPlatform(meter string) bool { + if g == nil { + return true + } + g.mu.Lock() + defer g.mu.Unlock() + now := time.Now() + if !g.limitedUntil.IsZero() && now.Before(g.limitedUntil) { + return false + } + // clear expired cool-down + if !g.limitedUntil.IsZero() && !now.Before(g.limitedUntil) { + g.limitedUntil = time.Time{} + } + switch meter { + case domain.MeterWebSearch: + return g.takeWindow(now, &g.searchWinStart, &g.searchCount, g.SearchPerMin) + default: + // ai_copy / ai_research / ai_image share AI pool + return g.takeWindow(now, &g.aiWinStart, &g.aiCount, g.AIPerMin) + } +} + +func (g *MemoryPlatformGate) takeWindow(now time.Time, start *time.Time, count *int, limit int) bool { + if limit <= 0 { + return true + } + if start.IsZero() || now.Sub(*start) >= time.Minute { + *start = now + *count = 0 + } + if *count >= limit { + return false + } + *count++ + return true +} + +// AlwaysAllowPlatform is a no-op gate (tests / unlimited). +type AlwaysAllowPlatform struct{} + +func (AlwaysAllowPlatform) AllowPlatform(string) bool { return true } +func (AlwaysAllowPlatform) MarkLimited(time.Time) {} diff --git a/apps/backend/internal/module/usage/usecase/service.go b/apps/backend/internal/module/usage/usecase/service.go index 3fe1c35..b3fdb8f 100644 --- a/apps/backend/internal/module/usage/usecase/service.go +++ b/apps/backend/internal/module/usage/usecase/service.go @@ -3,6 +3,8 @@ package usecase import ( "context" "fmt" + "strings" + "time" "apps/backend/internal/module/usage/domain" @@ -35,6 +37,9 @@ func (s *StaticResolver) Resolve(_ context.Context, uid int64, meter string) (st type Service struct { Repo domain.Repository Resolver KeyResolver + // Platform capacity / rate limit. nil = always allow platform. + // When denied, only BYOK (already preferred in Resolver) can proceed. + Gate PlatformGate } func New(repo domain.Repository, resolver KeyResolver) *Service { @@ -48,19 +53,9 @@ func (s *Service) RecordCall(ctx context.Context, uid int64, meter, keyMode, lab } credits := 0 if keyMode == domain.KeyModePlatform { - credits = domain.DefaultCreditCost[meter] - if credits == 0 { - credits = 1 - } - prefs, _ := s.ensurePrefs(ctx, uid) - if !prefs.Unlimited { - sum, err := s.GetSummary(ctx, uid, domain.CurrentMonthKey()) - if err != nil { - return nil, err - } - if sum.Platform.CreditsRemaining < credits { - return nil, domain.ErrQuotaExceeded - } + credits = domain.MeterCost(meter) + if err := s.checkPlatformQuota(ctx, uid, meter, credits); err != nil { + return nil, err } } now := domain.NowNano() @@ -76,6 +71,7 @@ func (s *Service) RecordCall(ctx context.Context, uid int64, meter, keyMode, lab } // PrepareCall checks quota / key before external call; returns key_mode. +// Platform path: capacity gate first, then plan credits. BYOK skips both. func (s *Service) PrepareCall(ctx context.Context, uid int64, meter string) (keyMode string, err error) { if s.Resolver == nil { return "", domain.ErrNoKey @@ -88,32 +84,75 @@ func (s *Service) PrepareCall(ctx context.Context, uid int64, meter string) (key return "", domain.ErrNoKey } if mode == domain.KeyModePlatform { - prefs, _ := s.ensurePrefs(ctx, uid) - if !prefs.Unlimited { - sum, err := s.GetSummary(ctx, uid, domain.CurrentMonthKey()) - if err != nil { - return "", err - } - cost := domain.DefaultCreditCost[meter] - if cost == 0 { - cost = 1 - } - if sum.Platform.CreditsRemaining < cost { - return "", domain.ErrQuotaExceeded - } + if s.Gate != nil && !s.Gate.AllowPlatform(meter) { + return "", domain.ErrPlatformCapacity + } + cost := domain.MeterCost(meter) + if err := s.checkPlatformQuota(ctx, uid, meter, cost); err != nil { + return "", err } } return mode, nil } +// MarkPlatformLimited cools down platform keys (e.g. after upstream 429). +// BYOK users are unaffected. until zero clears the cool-down. +func (s *Service) MarkPlatformLimited(until time.Time) { + if s == nil || s.Gate == nil { + return + } + s.Gate.MarkLimited(until) +} + +// checkPlatformQuota — 總池 + 分項點數上限;unlimited 略過。 +func (s *Service) checkPlatformQuota(ctx context.Context, uid int64, meter string, cost int) error { + if cost < 0 { + cost = 0 + } + prefs, _ := s.ensurePrefs(ctx, uid) + if prefs.Unlimited { + return nil + } + sum, err := s.GetSummary(ctx, uid, domain.CurrentMonthKey()) + if err != nil { + return err + } + // 1) 整月 platform 總點 + if sum.Platform.CreditsRemaining < cost { + return domain.ErrQuotaExceeded + } + // 2) 分項點數硬上限(platform only;加總 = MonthlyCredits) + plan := domain.ResolvePlan(prefs.PlanID) + if capN, ok := plan.SoftCaps[meter]; ok && capN > 0 { + usedCredits := 0 + usedCount := 0 + if m, ok := sum.Platform.ByMeter[meter]; ok { + usedCredits = m.Credits + usedCount = m.Count + // 舊資料若 credits 漏記,用次數 × 單價估 + if usedCredits < usedCount*domain.MeterCost(meter) { + usedCredits = usedCount * domain.MeterCost(meter) + } + } + if usedCredits+cost > capN { + return domain.ErrMeterCapExceeded + } + } + return nil +} + func (s *Service) ensurePrefs(ctx context.Context, uid int64) (*domain.MemberPrefs, error) { p, err := s.Repo.GetPrefs(ctx, uid) if err != nil || p == nil { p = &domain.MemberPrefs{UID: uid, PlanID: domain.PlanFree, UpdatedAt: domain.NowNano()} _ = s.Repo.SavePrefs(ctx, p) } - if p.PlanID == "" { - p.PlanID = domain.PlanFree + // 正規化 plan_id(小寫/有效方案) + norm := domain.ResolvePlan(p.PlanID).ID + if p.PlanID != norm { + p.PlanID = norm + p.UpdatedAt = domain.NowNano() + _ = s.Repo.SavePrefs(ctx, p) } return p, nil } @@ -123,9 +162,12 @@ func (s *Service) GetSummary(ctx context.Context, uid int64, monthKey string) (* monthKey = domain.CurrentMonthKey() } prefs, _ := s.ensurePrefs(ctx, uid) - plan := domain.Plans[prefs.PlanID] - if plan.ID == "" { - plan = domain.Plans[domain.PlanFree] + plan := domain.ResolvePlan(prefs.PlanID) + // 回寫正規化 plan_id,避免 DB 殘留大小寫/空白導致永遠 fallback Free + if prefs.PlanID != plan.ID { + prefs.PlanID = plan.ID + prefs.UpdatedAt = domain.NowNano() + _ = s.Repo.SavePrefs(ctx, prefs) } events, err := s.Repo.ListEvents(ctx, uid, monthKey, "all", 0) if err != nil { @@ -153,16 +195,15 @@ func (s *Service) GetSummary(ctx context.Context, uid int64, monthKey string) (* if remaining < 0 { remaining = 0 } - if prefs.Unlimited { - remaining = plan.MonthlyCredits // display full; not enforced - } + // Unlimited 只影響是否擋用,顯示仍報真實已用/剩餘(前端以 unlimited 旗標顯示 ∞) return &domain.MonthSummary{ - MonthKey: monthKey, UID: uid, PlanID: prefs.PlanID, Unlimited: prefs.Unlimited, + MonthKey: monthKey, UID: uid, PlanID: plan.ID, Unlimited: prefs.Unlimited, Platform: domain.PlatformBlock{ CreditsUsed: platUsed, CreditsRemaining: remaining, CreditsTotal: plan.MonthlyCredits, CallCount: platCalls, ByMeter: platBy, }, Byok: domain.ByokBlock{CallCount: byokCalls, ByMeter: byokBy}, + // TotalCredits = 方案配給(legacy 欄位名);已用請看 platform.credits_used TotalCredits: plan.MonthlyCredits, RemainingCredits: remaining, }, nil } @@ -188,14 +229,15 @@ func (s *Service) SetPrefs(ctx context.Context, actorUID int64, targetUID int64, } p, _ := s.ensurePrefs(ctx, targetUID) if planID != "" { - if _, ok := domain.Plans[planID]; !ok { + id := strings.ToLower(strings.TrimSpace(planID)) + if _, ok := domain.Plans[id]; !ok { return nil, domain.ErrInvalidPlan } // member self can only change via purchase if !isAdmin && actorUID == targetUID { return nil, domain.ErrForbidden } - p.PlanID = planID + p.PlanID = id } if unlimited != nil && isAdmin { p.Unlimited = *unlimited @@ -208,15 +250,19 @@ func (s *Service) SetPrefs(ctx context.Context, actorUID int64, targetUID int64, } func (s *Service) PurchasePlan(ctx context.Context, uid int64, planID, mockRef string) (*domain.Purchase, error) { - if _, ok := domain.Plans[planID]; !ok { + id := strings.ToLower(strings.TrimSpace(planID)) + plan, ok := domain.Plans[id] + if !ok || plan.ID == "" { return nil, domain.ErrInvalidPlan } p, _ := s.ensurePrefs(ctx, uid) - p.PlanID = planID + p.PlanID = plan.ID p.UpdatedAt = domain.NowNano() - _ = s.Repo.SavePrefs(ctx, p) + if err := s.Repo.SavePrefs(ctx, p); err != nil { + return nil, err + } pur := &domain.Purchase{ - ID: uuid.NewString(), UID: uid, PlanID: planID, MockRef: mockRef, CreatedAt: domain.NowNano(), + ID: uuid.NewString(), UID: uid, PlanID: plan.ID, MockRef: mockRef, CreatedAt: domain.NowNano(), } if err := s.Repo.InsertPurchase(ctx, pur); err != nil { return nil, err diff --git a/apps/backend/internal/module/usage/usecase/service_test.go b/apps/backend/internal/module/usage/usecase/service_test.go index e5eebc4..27a3291 100644 --- a/apps/backend/internal/module/usage/usecase/service_test.go +++ b/apps/backend/internal/module/usage/usecase/service_test.go @@ -2,7 +2,9 @@ package usecase_test import ( "context" + "fmt" "testing" + "time" "apps/backend/internal/module/usage/domain" "apps/backend/internal/module/usage/repository" @@ -13,15 +15,20 @@ import ( func newUsage(uid int64, mode string) *usecase.Service { r := repository.NewMemory() + key := func(m string) string { return fmt.Sprintf("%d:%s", uid, m) } res := &usecase.StaticResolver{Map: map[string]string{ - "1000001:ai_copy": mode, - "1000001:web_search": mode, + key(domain.MeterAICopy): mode, + key(domain.MeterAIResearch): mode, + key(domain.MeterWebSearch): mode, + key(domain.MeterAIImage): mode, }} // also allow mixed tests if mode == "mixed" { res.Map = map[string]string{ - "1000001:ai_copy": domain.KeyModeByok, - "1000001:web_search": domain.KeyModePlatform, + key(domain.MeterAICopy): domain.KeyModeByok, + key(domain.MeterAIResearch): domain.KeyModeByok, + key(domain.MeterWebSearch): domain.KeyModePlatform, + key(domain.MeterAIImage): domain.KeyModeByok, } } return usecase.New(r, res) @@ -136,15 +143,73 @@ func TestST_09_NoKeyFails(t *testing.T) { func TestST_13_PlatformQuota(t *testing.T) { svc := newUsage(1_000_001, domain.KeyModePlatform) - // burn free plan - for i := 0; i < 80; i++ { + // free: ai_copy soft cap 60pt;先燒滿分項 + for i := 0; i < 60; i++ { _, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, domain.KeyModePlatform, "x", "t") require.NoError(t, err) } _, err := svc.PrepareCall(context.Background(), 1_000_001, domain.MeterAICopy) + require.ErrorIs(t, err, domain.ErrMeterCapExceeded) + // 分項鎖死後,其他 meter 在總池仍有餘額時可繼續 + _, err = svc.PrepareCall(context.Background(), 1_000_001, domain.MeterWebSearch) + require.NoError(t, err) + // 燒光剩餘總池:search 30 + research 15 + image 15 = 60 + for i := 0; i < 30; i++ { + _, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterWebSearch, domain.KeyModePlatform, "s", "t") + require.NoError(t, err) + } + for i := 0; i < 5; i++ { // 5*3=15 + _, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterAIResearch, domain.KeyModePlatform, "r", "t") + require.NoError(t, err) + } + // image 15pt = 3 張 + for i := 0; i < 3; i++ { + _, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterAIImage, domain.KeyModePlatform, "img", "t") + require.NoError(t, err) + } + _, err = svc.PrepareCall(context.Background(), 1_000_001, domain.MeterWebSearch) require.ErrorIs(t, err, domain.ErrQuotaExceeded) } +func TestUSG_MeterCap_Image(t *testing.T) { + svc := newUsage(1_000_001, domain.KeyModePlatform) + // free ai_image cap 15pt = 3 次;第 4 次因分項上限擋 + for i := 0; i < 3; i++ { + _, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterAIImage, domain.KeyModePlatform, "img", "t") + require.NoError(t, err) + } + _, err := svc.PrepareCall(context.Background(), 1_000_001, domain.MeterAIImage) + require.ErrorIs(t, err, domain.ErrMeterCapExceeded) +} + +func TestUSG_PlatformCapacity_BlocksPlatformOnly(t *testing.T) { + // platform user blocked when gate cools down + plat := newUsage(1_000_001, domain.KeyModePlatform) + plat.Gate = usecase.NewMemoryPlatformGate(0, 0) + plat.MarkPlatformLimited(time.Now().Add(time.Hour)) + _, err := plat.PrepareCall(context.Background(), 1_000_001, domain.MeterAICopy) + require.ErrorIs(t, err, domain.ErrPlatformCapacity) + + // BYOK still works (gate not consulted) + byok := newUsage(1_000_002, domain.KeyModeByok) + byok.Gate = usecase.NewMemoryPlatformGate(0, 0) + byok.MarkPlatformLimited(time.Now().Add(time.Hour)) + mode, err := byok.PrepareCall(context.Background(), 1_000_002, domain.MeterAICopy) + require.NoError(t, err) + require.Equal(t, domain.KeyModeByok, mode) +} + +func TestUSG_PlatformCapacity_RPM(t *testing.T) { + svc := newUsage(1_000_001, domain.KeyModePlatform) + svc.Gate = usecase.NewMemoryPlatformGate(2, 0) // 2 AI / min + _, err := svc.PrepareCall(context.Background(), 1_000_001, domain.MeterAICopy) + require.NoError(t, err) + _, err = svc.PrepareCall(context.Background(), 1_000_001, domain.MeterAICopy) + require.NoError(t, err) + _, err = svc.PrepareCall(context.Background(), 1_000_001, domain.MeterAICopy) + require.ErrorIs(t, err, domain.ErrPlatformCapacity) +} + func TestUSG_11_PurchasePlan(t *testing.T) { svc := newUsage(1_000_001, domain.KeyModePlatform) p, err := svc.PurchasePlan(context.Background(), 1_000_001, domain.PlanStarter, "mock-pay-1") @@ -155,6 +220,22 @@ func TestUSG_11_PurchasePlan(t *testing.T) { list, err := svc.ListMyPurchases(context.Background(), 1_000_001, 10) require.NoError(t, err) require.NotEmpty(t, list) + + // 購買後 summary 配給必須是 Starter 600,不是 Free + sum, err := svc.GetSummary(context.Background(), 1_000_001, "") + require.NoError(t, err) + require.Equal(t, domain.PlanStarter, sum.PlanID) + require.Equal(t, domain.Plans[domain.PlanStarter].MonthlyCredits, sum.Platform.CreditsTotal) + require.Equal(t, domain.Plans[domain.PlanStarter].MonthlyCredits, sum.TotalCredits) + require.Equal(t, domain.Plans[domain.PlanStarter].MonthlyCredits, sum.Platform.CreditsRemaining) + + // 大小寫/空白 plan_id 也要生效 + _, err = svc.PurchasePlan(context.Background(), 1_000_001, " Pro ", "mock-pay-2") + require.NoError(t, err) + sum, err = svc.GetSummary(context.Background(), 1_000_001, "") + require.NoError(t, err) + require.Equal(t, domain.PlanPro, sum.PlanID) + require.Equal(t, 2000, sum.Platform.CreditsTotal) } func TestUSG_10_TenantAnalyticsSplit(t *testing.T) { diff --git a/apps/backend/internal/response/response.go b/apps/backend/internal/response/response.go index 78a3707..f0bb739 100644 --- a/apps/backend/internal/response/response.go +++ b/apps/backend/internal/response/response.go @@ -111,6 +111,17 @@ func mapError(err error) (int, Envelope) { return http.StatusBadRequest, Envelope{Code: 400021, Message: err.Error()} case errors.Is(err, memberDomain.ErrSelfSuspend): return http.StatusBadRequest, Envelope{Code: 400020, Message: err.Error()} + // 邀請:message 為 FE i18n key(invite.err.*) + case errors.Is(err, memberDomain.ErrInviteAlreadyBound), + errors.Is(err, memberDomain.ErrInviteCodeRequired), + errors.Is(err, memberDomain.ErrInviteCodeNotFound), + errors.Is(err, memberDomain.ErrInviteCodeSelf), + errors.Is(err, memberDomain.ErrInviteCycle), + errors.Is(err, memberDomain.ErrInviteSelfParent), + errors.Is(err, memberDomain.ErrInviteParentMiss): + return http.StatusBadRequest, Envelope{Code: 400070, Message: err.Error()} + case errors.Is(err, memberDomain.ErrInviteNeedAdmin): + return http.StatusForbidden, Envelope{Code: 403002, Message: err.Error()} case errors.Is(err, tokenDomain.ErrInvalidToken): return http.StatusUnauthorized, Envelope{Code: 401002, Message: "invalid token"} case errors.Is(err, threadsDomain.ErrNotFound), errors.Is(err, jobDomain.ErrNotFound), @@ -131,6 +142,12 @@ func mapError(err error) (int, Envelope) { return http.StatusBadRequest, Envelope{Code: 400040, Message: "no api key configured (platform or byok)"} case errors.Is(err, usageDomain.ErrQuotaExceeded): return http.StatusPaymentRequired, Envelope{Code: 402001, Message: "platform credits insufficient"} + case errors.Is(err, usageDomain.ErrMeterCapExceeded): + // message = FE i18n key + return http.StatusPaymentRequired, Envelope{Code: 402002, Message: err.Error()} + case errors.Is(err, usageDomain.ErrPlatformCapacity): + // 平台 key 限流/無容量:建議改 BYOK;message = FE i18n key + return http.StatusServiceUnavailable, Envelope{Code: 503002, Message: err.Error()} case errors.Is(err, usageDomain.ErrForbidden): return http.StatusForbidden, Envelope{Code: 403003, Message: "forbidden"} case errors.Is(err, usageDomain.ErrInvalidPlan): diff --git a/apps/backend/internal/svc/service_context.go b/apps/backend/internal/svc/service_context.go index 6b41c11..5a13f4d 100644 --- a/apps/backend/internal/svc/service_context.go +++ b/apps/backend/internal/svc/service_context.go @@ -121,6 +121,8 @@ func NewServiceContext(c config.Config) *ServiceContext { logx.Info("usage: Platform.ExaKey empty — using fake-platform-exa for demo/proxy") } usageSvc := usageUC.New(usageRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), keyRes) + // 平台 key 容量:預設每分鐘 AI 120/搜尋 60;超限或 MarkPlatformLimited 後僅 BYOK 可用 + usageSvc.Gate = usageUC.NewMemoryPlatformGate(120, 60) // M4 studio: mon store + publish transport(有 Meta 憑證則真發文/同步) var pub studioPublish.Transport diff --git a/apps/backend/internal/types/invite_convert.go b/apps/backend/internal/types/invite_convert.go new file mode 100644 index 0000000..0618d11 --- /dev/null +++ b/apps/backend/internal/types/invite_convert.go @@ -0,0 +1,51 @@ +package types + +import memberUC "apps/backend/internal/module/member/usecase" + +func InviteBriefFromUC(b memberUC.InviteMemberBrief) InviteMemberBrief { + out := InviteMemberBrief{ + Uid: b.UID, Email: b.Email, DisplayName: b.DisplayName, InviteCode: b.InviteCode, + Roles: b.Roles, Status: b.Status, DownlineCount: b.DownlineCount, + TotalDownlineCount: b.TotalDownlineCount, Depth: b.Depth, JoinedAt: b.JoinedAt, + AvatarUrl: b.AvatarURL, + } + if b.ParentUID != nil { + out.ParentUid = *b.ParentUID + } + return out +} + +func InviteTreeFromUC(n memberUC.InviteTreeNode) InviteTreeNode { + out := InviteTreeNode{ + Uid: n.UID, Email: n.Email, DisplayName: n.DisplayName, InviteCode: n.InviteCode, + Roles: n.Roles, Status: n.Status, DownlineCount: n.DownlineCount, + TotalDownlineCount: n.TotalDownlineCount, Depth: n.Depth, JoinedAt: n.JoinedAt, + AvatarUrl: n.AvatarURL, Children: make([]InviteTreeNode, 0, len(n.Children)), + } + if n.ParentUID != nil { + out.ParentUid = *n.ParentUID + } + for _, c := range n.Children { + out.Children = append(out.Children, InviteTreeFromUC(c)) + } + return out +} + +func MyInviteNetworkFromUC(n *memberUC.MyInviteNetwork) *MyInviteNetworkData { + if n == nil { + return nil + } + out := &MyInviteNetworkData{ + Me: InviteBriefFromUC(n.Me), + Downlines: make([]InviteMemberBrief, 0, len(n.Downlines)), + TotalDownlineCount: n.TotalDownlineCount, + } + if n.Upline != nil { + u := InviteBriefFromUC(*n.Upline) + out.Upline = &u + } + for _, d := range n.Downlines { + out.Downlines = append(out.Downlines, InviteBriefFromUC(d)) + } + return out +} diff --git a/apps/backend/internal/types/types.go b/apps/backend/internal/types/types.go index 3138c7f..b3aae1a 100644 --- a/apps/backend/internal/types/types.go +++ b/apps/backend/internal/types/types.go @@ -93,6 +93,10 @@ type AiSettingsData struct { ModelsError string `json:"models_error,optional"` } +type ApplyInviteCodeReq struct { + Code string `json:"code"` +} + type AuthBindReq struct { LoginId string `json:"login_id"` Platform string `json:"platform"` @@ -361,8 +365,7 @@ type InspireChatReq struct { Mode string `json:"mode,optional"` // chat|generate PersonaId string `json:"persona_id,optional"` SessionId string `json:"session_id,optional"` // 空 = active - // generate:待改寫素材(鎖定內容) - Material string `json:"material,optional"` + Material string `json:"material,optional"` } type InspireDraftPublic struct { @@ -470,12 +473,58 @@ type InspireSessionSummaryPublic struct { Active bool `json:"active"` } +type InviteMemberBrief struct { + Uid int64 `json:"uid"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + InviteCode string `json:"invite_code"` + ParentUid int64 `json:"parent_uid,optional"` // 0 = 無上線 + Roles []string `json:"roles"` + Status string `json:"status"` + DownlineCount int `json:"downline_count"` + TotalDownlineCount int `json:"total_downline_count"` + Depth int `json:"depth"` + JoinedAt int64 `json:"joined_at"` + AvatarUrl string `json:"avatar_url,optional"` +} + +type InviteParentCandidatesData struct { + List []InviteMemberBrief `json:"list"` +} + +type InviteTreeData struct { + List []InviteTreeNode `json:"list"` +} + +type InviteTreeNode struct { + Uid int64 `json:"uid"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + InviteCode string `json:"invite_code"` + ParentUid int64 `json:"parent_uid,optional"` + Roles []string `json:"roles"` + Status string `json:"status"` + DownlineCount int `json:"downline_count"` + TotalDownlineCount int `json:"total_downline_count"` + Depth int `json:"depth"` + JoinedAt int64 `json:"joined_at"` + AvatarUrl string `json:"avatar_url,optional"` + Children []InviteTreeNode `json:"children"` +} + type JobIdPath struct { Id string `path:"id"` } type JobListData struct { - List []JobPublic `json:"list"` + List []JobPublic `json:"list"` + Pagination Pagination `json:"pagination"` +} + +type JobListReq struct { + Tab string `form:"tab,optional"` + Page int `form:"page,optional"` + PageSize int `form:"pageSize,optional"` } type JobPublic struct { @@ -505,6 +554,10 @@ type ListModelsReq struct { Provider string `form:"provider,optional"` } +type ListParentCandidatesReq struct { + ExcludeSubtreeRootUid int64 `form:"exclude_subtree_root_uid,optional"` +} + type MediaUploadData struct { Url string `json:"url"` Key string `json:"key,optional"` @@ -588,6 +641,18 @@ type MentionSyncReq struct { AccountId string `json:"account_id"` } +type MoveInviteMemberReq struct { + Uid int64 `json:"uid"` + ParentUid int64 `json:"parent_uid,optional"` // 0 = 獨立根 +} + +type MyInviteNetworkData struct { + Me InviteMemberBrief `json:"me"` + Upline *InviteMemberBrief `json:"upline,optional"` + Downlines []InviteMemberBrief `json:"downlines"` + TotalDownlineCount int `json:"total_downline_count"` +} + type NotificationIdPath struct { Id string `path:"id"` } diff --git a/apps/backend/scripts/smoke-p0.sh b/apps/backend/scripts/smoke-p0.sh new file mode 100755 index 0000000..efc7f67 --- /dev/null +++ b/apps/backend/scripts/smoke-p0.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# P0 煙測:login → me → settings → usage → accounts +set -euo pipefail +BASE="${API_BASE:-http://127.0.0.1:8888}" +EMAIL="${SMOKE_EMAIL:-admin@haixun.local}" +PASS="${SMOKE_PASSWORD:-Admin123!@#x}" + +ok() { printf '[ok] %s\n' "$*"; } +fail() { printf '[FAIL] %s\n' "$*" >&2; exit 1; } + +json_field() { + python3 -c "import sys,json; d=json.load(sys.stdin); print(d$1)" 2>/dev/null || true +} + +echo "=== smoke P0 against $BASE ===" + +login=$(curl -sf -X POST "$BASE/api/v1/auth/login" \ + -H 'Content-Type: application/json' \ + -d "{\"email\":\"$EMAIL\",\"password\":\"$PASS\"}" ) || fail "AU-01 login" +code=$(echo "$login" | python3 -c "import sys,json; print(json.load(sys.stdin).get('code'))") +[[ "$code" == "102000" ]] || fail "login code=$code" +TOKEN=$(echo "$login" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['tokens']['access_token'])") +ok "AU-01 login" + +me=$(curl -sf "$BASE/api/v1/auth/me" -H "Authorization: Bearer $TOKEN") || fail "me" +code=$(echo "$me" | python3 -c "import sys,json; print(json.load(sys.stdin).get('code'))") +[[ "$code" == "102000" ]] || fail "me code=$code" +ok "AU-me / A-06 envelope" + +ai=$(curl -sf "$BASE/api/v1/settings/ai" -H "Authorization: Bearer $TOKEN") || fail "settings ai" +code=$(echo "$ai" | python3 -c "import sys,json; print(json.load(sys.stdin).get('code'))") +[[ "$code" == "102000" ]] || fail "settings code=$code" +ok "ST-01 settings" + +us=$(curl -sf "$BASE/api/v1/usage/summary" -H "Authorization: Bearer $TOKEN") || fail "usage" +code=$(echo "$us" | python3 -c "import sys,json; print(json.load(sys.stdin).get('code'))") +[[ "$code" == "102000" ]] || fail "usage code=$code" +ok "USG summary" + +acc=$(curl -sf "$BASE/api/v1/threads-accounts" -H "Authorization: Bearer $TOKEN") || fail "accounts" +code=$(echo "$acc" | python3 -c "import sys,json; print(json.load(sys.stdin).get('code'))") +[[ "$code" == "102000" ]] || fail "accounts code=$code" +ok "TH-05 list accounts" + +echo "=== smoke P0 passed ===" diff --git a/apps/backend/scripts/smoke-p1.sh b/apps/backend/scripts/smoke-p1.sh new file mode 100755 index 0000000..759f6d2 --- /dev/null +++ b/apps/backend/scripts/smoke-p1.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# P1 煙測:invite me +(可選)personas / scout brands 存在性 +set -euo pipefail +BASE="${API_BASE:-http://127.0.0.1:8888}" +EMAIL="${SMOKE_EMAIL:-admin@haixun.local}" +PASS="${SMOKE_PASSWORD:-Admin123!@#x}" + +ok() { printf '[ok] %s\n' "$*"; } +fail() { printf '[FAIL] %s\n' "$*" >&2; exit 1; } + +echo "=== smoke P1 against $BASE ===" + +login=$(curl -sf -X POST "$BASE/api/v1/auth/login" \ + -H 'Content-Type: application/json' \ + -d "{\"email\":\"$EMAIL\",\"password\":\"$PASS\"}" ) || fail "login" +TOKEN=$(echo "$login" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['tokens']['access_token'])") + +inv=$(curl -sf "$BASE/api/v1/invite/me" -H "Authorization: Bearer $TOKEN") || fail "IV-01 invite/me" +code=$(echo "$inv" | python3 -c "import sys,json; print(json.load(sys.stdin).get('code'))") +[[ "$code" == "102000" ]] || fail "invite/me code=$code" +ic=$(echo "$inv" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['me']['invite_code'])") +[[ -n "$ic" ]] || fail "empty invite_code" +ok "IV-01 getMyNetwork invite_code=$ic" + +# admin tree(admin 帳才過) +tree=$(curl -sf "$BASE/api/v1/invite/tree" -H "Authorization: Bearer $TOKEN" || true) +tcode=$(echo "$tree" | python3 -c "import sys,json; print(json.load(sys.stdin).get('code',''))" 2>/dev/null || true) +if [[ "$tcode" == "102000" ]]; then + ok "IV-08 getTree" +else + ok "IV-08 getTree skipped/non-admin (code=$tcode)" +fi + +# 存在性煙測 +for path in /api/v1/personas /api/v1/own-posts /api/v1/scout/brands /api/v1/inspire/session; do + r=$(curl -s -o /dev/null -w '%{http_code}' "$BASE$path" -H "Authorization: Bearer $TOKEN" || true) + [[ "$r" != "404" ]] || fail "missing $path" + ok "path $path http=$r" +done + +echo "=== smoke P1 passed ===" diff --git a/apps/web/src/auth/RequireAdmin.tsx b/apps/web/src/auth/RequireAdmin.tsx index b6d3103..38b78f4 100644 --- a/apps/web/src/auth/RequireAdmin.tsx +++ b/apps/web/src/auth/RequireAdmin.tsx @@ -1,4 +1,5 @@ import { Navigate } from "react-router-dom"; +import { useI18n } from "../i18n/I18nContext"; import { can, Permission } from "../lib/permissions"; import { useAuth } from "./AuthContext"; @@ -8,11 +9,12 @@ import { useAuth } from "./AuthContext"; */ export function RequireAdmin({ children }: { children: React.ReactNode }) { const { member, loading } = useAuth(); + const { t } = useI18n(); if (loading) { return (
-

載入中…

+

{t("common.loading")}

); } diff --git a/apps/web/src/auth/RequireAuth.tsx b/apps/web/src/auth/RequireAuth.tsx index 97be8ac..0bc6851 100644 --- a/apps/web/src/auth/RequireAuth.tsx +++ b/apps/web/src/auth/RequireAuth.tsx @@ -1,14 +1,16 @@ import { Navigate, useLocation } from "react-router-dom"; +import { useI18n } from "../i18n/I18nContext"; import { useAuth } from "./AuthContext"; export function RequireAuth({ children }: { children: React.ReactNode }) { const { member, loading } = useAuth(); const location = useLocation(); + const { t } = useI18n(); if (loading) { return (
-

載入中…

+

{t("common.loading")}

); } diff --git a/apps/web/src/components/layout/UsagePlanWidget.tsx b/apps/web/src/components/layout/UsagePlanWidget.tsx index 8a4f68d..10f7b65 100644 --- a/apps/web/src/components/layout/UsagePlanWidget.tsx +++ b/apps/web/src/components/layout/UsagePlanWidget.tsx @@ -45,11 +45,19 @@ export function UsagePlanWidget() { } const planId = summary.plan.id as PlanId; - const rights = getPlanRights(planId, t); + const rights = getPlanRights(planId, t, formatPlanPrice); + // 上限一律用目前方案 PLANS 配給(summary.plan 已由 live repo 綁定 PLANS) const cap = summary.plan.monthly_credits; - const used = summary.total_credits; - const left = Math.max(0, summary.remaining_credits); - const pct = cap > 0 ? Math.min(100, Math.round((used / cap) * 100)) : 0; + const used = summary.platform?.credits_used ?? summary.total_credits; + const left = summary.unlimited + ? Math.max(0, summary.remaining_credits) + : Math.max(0, cap - used); + const pct = + summary.pct > 0 + ? Math.min(100, summary.pct) + : cap > 0 + ? Math.min(100, Math.round((used / cap) * 100)) + : 0; const tight = !summary.unlimited && (pct >= 70 || used >= cap); const over = !summary.unlimited && used > cap; const isFree = planId === "free"; @@ -93,13 +101,19 @@ export function UsagePlanWidget() { {t("usage.widget.overShort")} - ) : isFree && pct < 70 ? ( - - {t("usage.widget.upgradeShort")} - ) : ( - - {t("usage.widget.leftShort", { n: left })} + // 一律顯示 已用/上限,避免「還剩 80」被看成「上限 80」(Free 是 120) + + {t("usage.widget.usedOfCap", { used, cap })} )} @@ -162,6 +176,8 @@ export function UsagePlanWidget() {
  • {b}
  • ))} +

    {rights.approxCallsLine}

    +

    {rights.softCapsLine}

    {tight ?

    {t("usage.widget.nudge")}

    : null} diff --git a/apps/web/src/components/ui/AppIcons.tsx b/apps/web/src/components/ui/AppIcons.tsx index 4e332fe..df93174 100644 --- a/apps/web/src/components/ui/AppIcons.tsx +++ b/apps/web/src/components/ui/AppIcons.tsx @@ -1,7 +1,7 @@ import type { ReactNode, SVGProps } from "react"; import type { NavKey } from "../../lib/nav"; -export type AppIconName = NavKey | "more" | "spark"; +export type AppIconName = NavKey | "more" | "spark" | "send"; type Props = { name: AppIconName; @@ -126,4 +126,14 @@ const glyphs: Record = { ), + // 送出:置中紙飛機(小圓鈕專用,無星芒避免破圖感) + send: ( + <> + + + + ), }; diff --git a/apps/web/src/components/ui/MarkdownText.tsx b/apps/web/src/components/ui/MarkdownText.tsx new file mode 100644 index 0000000..7c6e6c3 --- /dev/null +++ b/apps/web/src/components/ui/MarkdownText.tsx @@ -0,0 +1,289 @@ +import { Fragment, type ReactNode } from "react"; + +type Props = { + text: string; + className?: string; +}; + +/** + * 聊天泡泡用輕量 markdown(不引入 react-markdown)。 + * 一般回覆:每個實際換行都獨立成行,不會擠成一團。 + * 另支援:標題、粗斜體、刪除線、行內/區塊 code、清單、引用、連結。 + * HTML 一律當純文字,連結僅允許 http(s)/mailto。 + */ +export function MarkdownText({ text, className = "" }: Props) { + const source = normalizeNewlines(text ?? ""); + if (!source.trim()) { + return
    ; + } + + const blocks = parseBlocks(source); + return ( +
    + {blocks.map((block, i) => ( + {renderBlock(block)} + ))} +
    + ); +} + +type Block = + | { type: "line"; text: string } + | { type: "gap" } + | { type: "h"; level: 1 | 2 | 3; text: string } + | { type: "ul"; items: string[] } + | { type: "ol"; items: string[] } + | { type: "quote"; lines: string[] } + | { type: "code"; lang: string; code: string } + | { type: "hr" }; + +function normalizeNewlines(source: string): string { + return source + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .replace(/\u2028/g, "\n") + .replace(/\u2029/g, "\n\n"); +} + +function parseBlocks(source: string): Block[] { + const lines = source.split("\n"); + const blocks: Block[] = []; + let i = 0; + /** 連續空行只算一段落間距一次,開頭不插 gap */ + let pendingGap = false; + let sawContent = false; + + const flushGap = () => { + if (pendingGap && sawContent) { + blocks.push({ type: "gap" }); + } + pendingGap = false; + }; + + while (i < lines.length) { + const line = lines[i] ?? ""; + + // fenced code + const fence = line.match(/^```([\w-]*)\s*$/); + if (fence) { + flushGap(); + const lang = fence[1] || ""; + const body: string[] = []; + i += 1; + while (i < lines.length && !/^```\s*$/.test(lines[i] ?? "")) { + body.push(lines[i] ?? ""); + i += 1; + } + if (i < lines.length) i += 1; // closing ``` + blocks.push({ type: "code", lang, code: body.join("\n") }); + sawContent = true; + continue; + } + + if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { + flushGap(); + blocks.push({ type: "hr" }); + sawContent = true; + i += 1; + continue; + } + + const heading = line.match(/^(#{1,3})\s+(.+)$/); + if (heading) { + flushGap(); + const level = heading[1]!.length as 1 | 2 | 3; + blocks.push({ type: "h", level, text: heading[2]!.trim() }); + sawContent = true; + i += 1; + continue; + } + + if (/^>\s?/.test(line)) { + flushGap(); + const quoteLines: string[] = []; + while (i < lines.length && /^>\s?/.test(lines[i] ?? "")) { + quoteLines.push((lines[i] ?? "").replace(/^>\s?/, "")); + i += 1; + } + blocks.push({ type: "quote", lines: quoteLines }); + sawContent = true; + continue; + } + + if (/^\s*[-*+]\s+/.test(line)) { + flushGap(); + const items: string[] = []; + while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i] ?? "")) { + items.push((lines[i] ?? "").replace(/^\s*[-*+]\s+/, "")); + i += 1; + } + blocks.push({ type: "ul", items }); + sawContent = true; + continue; + } + + if (/^\s*\d+\.\s+/.test(line)) { + flushGap(); + const items: string[] = []; + while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i] ?? "")) { + items.push((lines[i] ?? "").replace(/^\s*\d+\.\s+/, "")); + i += 1; + } + blocks.push({ type: "ol", items }); + sawContent = true; + continue; + } + + // 空行 → 段落間距(普通回覆的「空一行」) + if (!line.trim()) { + pendingGap = true; + i += 1; + continue; + } + + // 一般文字:每一行獨立成行(聊天最重要) + flushGap(); + blocks.push({ type: "line", text: line }); + sawContent = true; + i += 1; + } + + return blocks; +} + +function renderBlock(block: Block): ReactNode { + switch (block.type) { + case "line": + return ( +
    + {block.text ? renderInline(block.text) : "\u00a0"} +
    + ); + case "gap": + return
    ; + case "h": { + const Tag = `h${block.level}` as "h1" | "h2" | "h3"; + return ( + + {renderInline(block.text)} + + ); + } + case "ul": + return ( +
      + {block.items.map((item, j) => ( +
    • {renderInline(item)}
    • + ))} +
    + ); + case "ol": + return ( +
      + {block.items.map((item, j) => ( +
    1. {renderInline(item)}
    2. + ))} +
    + ); + case "quote": + return ( +
    + {block.lines.map((line, j) => ( +
    + {line ? renderInline(line) : "\u00a0"} +
    + ))} +
    + ); + case "code": + return ( +
    +          {block.code}
    +        
    + ); + case "hr": + return
    ; + default: + return null; + } +} + +/** 行內:code → bold/italic/strike → links → plain */ +function renderInline(text: string): ReactNode[] { + const nodes: ReactNode[] = []; + const codeSplit = text.split(/(`[^`\n]+`)/g); + codeSplit.forEach((chunk, ci) => { + if (!chunk) return; + if (chunk.startsWith("`") && chunk.endsWith("`") && chunk.length >= 2) { + nodes.push( + + {chunk.slice(1, -1)} + , + ); + return; + } + nodes.push(...renderInlineDecorated(chunk, `t${ci}`)); + }); + return nodes; +} + +function renderInlineDecorated(text: string, keyPrefix: string): ReactNode[] { + const nodes: ReactNode[] = []; + const re = + /(\*\*[^*]+\*\*|\*[^*\n]+\*|~~[^~]+~~|\[([^\]]+)\]\(([^)\s]+)\))/g; + let last = 0; + let m: RegExpExecArray | null; + let idx = 0; + while ((m = re.exec(text)) !== null) { + if (m.index > last) { + nodes.push( + {text.slice(last, m.index)}, + ); + } + const token = m[0]; + if (token.startsWith("**") && token.endsWith("**")) { + nodes.push( + {token.slice(2, -2)}, + ); + } else if (token.startsWith("~~") && token.endsWith("~~")) { + nodes.push( + {token.slice(2, -2)}, + ); + } else if (token.startsWith("*") && token.endsWith("*")) { + nodes.push( + {token.slice(1, -1)}, + ); + } else if (m[2] != null && m[3] != null) { + const href = sanitizeHref(m[3]); + if (href) { + nodes.push( + + {m[2]} + , + ); + } else { + nodes.push( + {m[2]}, + ); + } + } + last = m.index + token.length; + } + if (last < text.length) { + nodes.push({text.slice(last)}); + } + return nodes; +} + +function sanitizeHref(raw: string): string | null { + const href = raw.trim(); + if (/^https?:\/\//i.test(href)) return href; + if (/^mailto:/i.test(href)) return href; + return null; +} diff --git a/apps/web/src/components/ui/index.ts b/apps/web/src/components/ui/index.ts index 2129bd2..6d9bcfd 100644 --- a/apps/web/src/components/ui/index.ts +++ b/apps/web/src/components/ui/index.ts @@ -12,3 +12,4 @@ export type { BadgeTone } from "./Badge"; export { EmptyState } from "./EmptyState"; export { Pager } from "./Pager"; export { AccountAvatar, mockAvatarUrl } from "./AccountAvatar"; +export { MarkdownText } from "./MarkdownText"; diff --git a/apps/web/src/components/usage/PlanPricingGrid.tsx b/apps/web/src/components/usage/PlanPricingGrid.tsx index bf6f2c2..c7070c1 100644 --- a/apps/web/src/components/usage/PlanPricingGrid.tsx +++ b/apps/web/src/components/usage/PlanPricingGrid.tsx @@ -27,7 +27,7 @@ export function PlanPricingGrid({
    {(Object.keys(PLANS) as PlanId[]).map((id) => { const p = PLANS[id]; - const rights = getPlanRights(id, t); + const rights = getPlanRights(id, t, formatPrice); const current = currentId === id; const popular = id === highlighted; const cta = planCtaLabel(currentId, id, t); @@ -51,6 +51,8 @@ export function PlanPricingGrid({

    {t("plans.creditsPerMonth", { n: p.monthly_credits })}

    +

    {rights.approxCallsLine}

    +

    {rights.softCapsLine}

      diff --git a/apps/web/src/components/usage/UsageMeterBars.tsx b/apps/web/src/components/usage/UsageMeterBars.tsx index d9e791e..1bbf3cf 100644 --- a/apps/web/src/components/usage/UsageMeterBars.tsx +++ b/apps/web/src/components/usage/UsageMeterBars.tsx @@ -4,8 +4,11 @@ import { METER_META, type UsageMeter } from "../../lib/usageMeter"; export type MeterBarRow = { meter: UsageMeter; + /** 已用點數(platform only) */ credits: number; + /** 平台呼叫次數(不含 byok) */ count: number; + /** 分項點數硬上限(與 PLANS.soft_caps 同步,單位=點) */ soft_cap: number; }; @@ -15,7 +18,8 @@ type Props = { }; /** - * 分項用量:條長=相對自己上限;hover 才露出點數。 + * 分項:數字 = 已用點 / 上限點(與方案 monthly_credits 同一單位)。 + * 超過/達上限標紅;hover 顯示平台呼叫次數。 */ export function UsageMeterBars({ rows, unlimited = false }: Props) { const { t } = useI18n(); @@ -28,17 +32,18 @@ export function UsageMeterBars({ rows, unlimited = false }: Props) {
        setActive(null)}> {ordered.map((row) => { const label = t(`usage.meter.${row.meter}`); + const over = !unlimited && row.soft_cap > 0 && row.credits > row.soft_cap; + const atCap = !unlimited && row.soft_cap > 0 && row.credits >= row.soft_cap; const pct = row.soft_cap > 0 - ? Math.min(100, Math.round((row.count / row.soft_cap) * 100)) + ? Math.min(100, Math.round((row.credits / row.soft_cap) * 100)) : 0; const on = active === row.meter; - const over = row.soft_cap > 0 && row.count > row.soft_cap; return (
      • diff --git a/apps/web/src/data/JobLiveContext.tsx b/apps/web/src/data/JobLiveContext.tsx index a5a9a1e..ed1149e 100644 --- a/apps/web/src/data/JobLiveContext.tsx +++ b/apps/web/src/data/JobLiveContext.tsx @@ -58,8 +58,10 @@ export function JobLiveProvider({ children }: { children: ReactNode }) { return; } try { - const list = await repos.jobs.list(); + // 只輪詢「執行中」分桶,避免一次拉全歷史 + const res = await repos.jobs.list({ tab: "active", page: 1, pageSize: 50 }); if (!mounted.current) return; + const list = res.list; setJobs(list); const fp = fingerprint(list); if (fp !== fpRef.current) { diff --git a/apps/web/src/data/createRepos.ts b/apps/web/src/data/createRepos.ts index e2bfaf1..9ec29f8 100644 --- a/apps/web/src/data/createRepos.ts +++ b/apps/web/src/data/createRepos.ts @@ -1,16 +1,7 @@ import type { Repos } from "./repos"; -import { createMockInviteRepo } from "./mock/inviteRepo"; import { createLiveRepos } from "./live/repos"; -/** - * 一律 live API。 - * 邀請關係尚無後端:暫留 inviteRepo(local),其餘 mock seed/createMock 已移除。 - */ +/** 一律 live API(含邀請 M6)。 */ export function createRepos(): Repos { - const repos = createLiveRepos(); - return { - ...repos, - dataSource: "live", - invite: createMockInviteRepo(), - }; + return createLiveRepos(); } diff --git a/apps/web/src/data/live/inviteRepo.ts b/apps/web/src/data/live/inviteRepo.ts new file mode 100644 index 0000000..057525b --- /dev/null +++ b/apps/web/src/data/live/inviteRepo.ts @@ -0,0 +1,107 @@ +/** + * Live 邀請網絡 — 對齊 apps/backend /api/v1/invite/* + */ +import type { + InviteMemberBrief, + InviteTreeNode, + MyInviteNetwork, + Role, + MemberStatus, +} from "../../domain/types"; +import type { InviteRepo } from "../repos"; +import { apiRequest } from "./http"; + +function uidToString(uid: unknown): string { + if (typeof uid === "number" || typeof uid === "bigint") return String(uid); + if (typeof uid === "string") return uid; + return String(uid ?? ""); +} + +function mapBrief(raw: Record): InviteMemberBrief { + const parentRaw = raw.parent_uid; + return { + uid: uidToString(raw.uid), + email: String(raw.email ?? ""), + display_name: String(raw.display_name ?? ""), + invite_code: String(raw.invite_code ?? "—"), + parent_uid: + parentRaw === null || parentRaw === undefined || parentRaw === 0 || parentRaw === "0" + ? null + : uidToString(parentRaw), + roles: (Array.isArray(raw.roles) ? raw.roles : ["member"]) as Role[], + status: (raw.status === "suspended" ? "suspended" : "active") as MemberStatus, + downline_count: Number(raw.downline_count ?? 0), + total_downline_count: Number(raw.total_downline_count ?? 0), + depth: Number(raw.depth ?? 0), + joined_at: Number(raw.joined_at ?? 0), + avatar_url: + raw.avatar_url != null && String(raw.avatar_url).trim() !== "" + ? String(raw.avatar_url) + : null, + }; +} + +function mapTree(raw: Record): InviteTreeNode { + const childrenRaw = Array.isArray(raw.children) ? raw.children : []; + return { + ...mapBrief(raw), + children: childrenRaw.map((c) => mapTree(c as Record)), + }; +} + +function mapNetwork(raw: Record): MyInviteNetwork { + const me = mapBrief((raw.me || {}) as Record); + const uplineRaw = raw.upline as Record | null | undefined; + const downRaw = Array.isArray(raw.downlines) ? raw.downlines : []; + return { + me, + upline: uplineRaw ? mapBrief(uplineRaw) : null, + downlines: downRaw.map((d) => mapBrief(d as Record)), + total_downline_count: Number(raw.total_downline_count ?? me.total_downline_count ?? 0), + }; +} + +export function createLiveInviteRepo(): InviteRepo { + return { + async getMyNetwork() { + const data = await apiRequest>("/api/v1/invite/me"); + return mapNetwork(data); + }, + async applyInviteCode(code) { + const data = await apiRequest>("/api/v1/invite/apply", { + method: "POST", + body: { code }, + }); + return mapNetwork(data); + }, + async getTree() { + const data = await apiRequest<{ list?: Record[] }>("/api/v1/invite/tree"); + return (data.list ?? []).map((n) => mapTree(n)); + }, + async moveMember(uid, newParentUid) { + const body: { uid: number; parent_uid?: number } = { + uid: Number(uid), + }; + if (newParentUid != null && String(newParentUid).trim() !== "") { + body.parent_uid = Number(newParentUid); + } else { + body.parent_uid = 0; + } + const data = await apiRequest>("/api/v1/invite/move", { + method: "POST", + body, + }); + return mapBrief(data); + }, + async listParentCandidates(excludeSubtreeRootUid) { + const q = + excludeSubtreeRootUid && String(excludeSubtreeRootUid).trim() + ? `?exclude_subtree_root_uid=${encodeURIComponent(String(excludeSubtreeRootUid))}` + : ""; + const data = await apiRequest<{ list?: Record[] }>( + `/api/v1/invite/parent-candidates${q}`, + ); + return (data.list ?? []).map((b) => mapBrief(b)); + }, + }; +} diff --git a/apps/web/src/data/live/repos.ts b/apps/web/src/data/live/repos.ts index 9b4d57a..5489bb6 100644 --- a/apps/web/src/data/live/repos.ts +++ b/apps/web/src/data/live/repos.ts @@ -1,6 +1,5 @@ /** * Live repository implementations — 主路徑一律走 gateway。 - * 僅 invite 在 createRepos 掛 local stub(M6 前)。 */ import type { @@ -49,13 +48,13 @@ import type { ThreadsPlatformStatus, UsageRepo, } from "../repos"; -import { createMockInviteRepo } from "../mock/inviteRepo"; import { createLiveInspiration, createLiveMedia, createLiveResearch, createLiveScout, } from "./m5Repos"; +import { createLiveInviteRepo } from "./inviteRepo"; import { apiRequest, loadTokens, saveTokens, type StoredTokens } from "./http"; function uidToString(uid: unknown): string { @@ -533,9 +532,41 @@ function createLiveAccounts(): AccountsRepo { function createLiveJobs(): JobsRepo { return { - async list() { - const data = await apiRequest<{ list: Record[] }>("/api/v1/jobs"); - return (data.list ?? []).map(mapJob); + async list(query) { + const tab = query?.tab ?? "active"; + const page = query?.page ?? 1; + const pageSize = query?.pageSize ?? (query ? 10 : 50); + const qs = new URLSearchParams({ + tab, + page: String(page), + pageSize: String(pageSize), + }); + const data = await apiRequest<{ + list?: Record[]; + pagination?: { + page?: number; + pageSize?: number; + total?: number; + totalPages?: number; + }; + }>(`/api/v1/jobs?${qs.toString()}`); + const list = (data.list ?? []).map(mapJob); + const p = data.pagination ?? {}; + const total = Number(p.total ?? list.length); + const ps = Number(p.pageSize ?? pageSize) || pageSize; + const pg = Number(p.page ?? page) || page; + const totalPages = + Number(p.totalPages) || + (ps > 0 ? Math.max(1, Math.ceil(total / ps)) : 1); + return { + list, + pagination: { + page: pg, + pageSize: ps, + total, + totalPages, + }, + }; }, async get(id) { try { @@ -589,17 +620,22 @@ function createLiveNotifications(): NotificationsRepo { function mapMeterBlock( plan: (typeof PLANS)[PlanId], platformBy: Record | undefined, - byokBy: Record | undefined, + _byokBy: Record | undefined, ): UsageMonthSummary["by_meter"] { const by_meter = {} as UsageMonthSummary["by_meter"]; for (const m of Object.keys(plan.soft_caps) as UsageMeter[]) { const p = (platformBy?.[m] as Record | undefined) ?? {}; - const b = (byokBy?.[m] as Record | undefined) ?? {}; + // 上限對照只看 platform;byok 不進分項鎖/進度(只出現在 ledger) const credits = Number(p.credits ?? 0); - const count = Number(p.count ?? 0) + Number(b.count ?? 0); - const soft = plan.soft_caps[m] || 1; - const pct = soft > 0 ? Math.min(999, Math.round((count / soft) * 100)) : 0; - by_meter[m] = { credits, count, soft_cap: soft, pct }; + const platformCount = Number(p.count ?? 0); + const soft = plan.soft_caps[m] || 1; // 分項點數上限(非次數) + const pct = soft > 0 ? Math.min(999, Math.round((credits / soft) * 100)) : 0; + by_meter[m] = { + credits, + count: platformCount, + soft_cap: soft, + pct, + }; } return by_meter; } @@ -622,17 +658,19 @@ function createLiveUsage(): UsageRepo { async getSummary(monthKey) { const q = monthKey ? `?month_key=${encodeURIComponent(monthKey)}` : ""; const raw = await apiRequest>(`/api/v1/usage/summary${q}`); - const planId = String(raw.plan_id ?? "free") as PlanId; - const plan = PLANS[planId] ?? PLANS.free; + // plan_id 正規化;配給一律以 FE PLANS 為準(與後端 domain.Plans 同步數字) + // 不可盲信後端舊 binary 的 credits_total(買 Starter 卻顯示 Free 上限的根因之一) + const planIdRaw = String(raw.plan_id ?? "free").toLowerCase().trim(); + const planId = (planIdRaw in PLANS ? planIdRaw : "free") as PlanId; + const plan = PLANS[planId]; const platform = (raw.platform as Record) ?? {}; const byok = (raw.byok as Record) ?? {}; - const total = Number(raw.total_credits ?? platform.credits_total ?? plan.monthly_credits); - const remaining = Number( - raw.remaining_credits ?? platform.credits_remaining ?? plan.monthly_credits, - ); - const used = Number(platform.credits_used ?? total - remaining); - const pct = - total > 0 ? Math.min(999, Math.round((used / total) * 100)) : 0; + const cap = plan.monthly_credits; + const used = Number(platform.credits_used ?? 0); + const remaining = Boolean(raw.unlimited) + ? Math.max(0, Number(platform.credits_remaining ?? Math.max(0, cap - used))) + : Math.max(0, cap - used); + const pct = cap > 0 ? Math.min(999, Math.round((used / cap) * 100)) : 0; const platBy = (platform.by_meter as Record | undefined) ?? {}; const byokBy = (byok.by_meter as Record | undefined) ?? {}; @@ -652,18 +690,20 @@ function createLiveUsage(): UsageRepo { events = []; } + // 狀態列 AI/Search:顯示「點數」才與方案配給同一單位(不是混加次數) const ai_calls = - (by_meter.ai_copy?.count ?? 0) + - (by_meter.ai_research?.count ?? 0) + - (by_meter.ai_image?.count ?? 0); - const search_calls = by_meter.web_search?.count ?? 0; + (by_meter.ai_copy?.credits ?? 0) + + (by_meter.ai_research?.credits ?? 0) + + (by_meter.ai_image?.credits ?? 0); + const search_calls = by_meter.web_search?.credits ?? 0; return { month_key: String(raw.month_key ?? ""), uid: String(raw.uid ?? ""), plan, unlimited: Boolean(raw.unlimited), - total_credits: total, + // FE 慣例:total_credits = 本月已用(platform 點數);配給看 plan.monthly_credits + total_credits: used, remaining_credits: remaining, pct, ai_calls, @@ -673,7 +713,7 @@ function createLiveUsage(): UsageRepo { platform: { credits_used: used, credits_remaining: remaining, - credits_total: total, + credits_total: cap, call_count: Number(platform.call_count ?? 0), }, byok: { call_count: Number(byok.call_count ?? 0) }, @@ -694,8 +734,9 @@ function createLiveUsage(): UsageRepo { }, async getMemberPrefs() { const raw = await apiRequest>("/api/v1/usage/prefs"); + const id = String(raw.plan_id ?? "free").toLowerCase().trim(); return { - plan_id: String(raw.plan_id ?? "free") as PlanId, + plan_id: (id in PLANS ? id : "free") as PlanId, unlimited: Boolean(raw.unlimited), }; }, @@ -715,12 +756,57 @@ function createLiveUsage(): UsageRepo { async getTenantSummary(monthKey) { const q = monthKey ? `?month_key=${encodeURIComponent(monthKey)}` : ""; const raw = await apiRequest>(`/api/v1/usage/tenant-summary${q}`); + // 補顯示名稱(admin members) + const nameByUid = new Map(); + try { + const admin = await apiRequest<{ + list?: Record[]; + }>("/api/v1/admin/members?page=1&pageSize=50"); + for (const row of admin.list ?? []) { + const uid = String(row.uid ?? ""); + if (!uid) continue; + nameByUid.set(uid, { + email: String(row.email ?? ""), + display_name: String(row.display_name ?? row.email ?? uid), + }); + } + } catch { + /* non-admin or fail */ + } + + const members = ((raw.members as Record[]) ?? []).map((m) => { + const uid = String(m.uid ?? ""); + const planId = String(m.plan_id ?? "free") as PlanId; + const plan = PLANS[planId] ?? PLANS.free; + const used = Number(m.platform_credits_used ?? 0); + const purchased = plan.monthly_credits; + const remaining = Math.max(0, purchased - used); + const pct = purchased > 0 ? Math.min(999, Math.round((used / purchased) * 100)) : 0; + const names = nameByUid.get(uid); + return { + uid, + email: names?.email ?? "", + display_name: names?.display_name || `uid ${uid}`, + unlimited: Boolean(m.unlimited), + plan_id: planId, + purchased_credits: purchased, + total_credits: used, + ai_calls: 0, + search_calls: Number(m.byok_call_count ?? 0), + remaining_credits: remaining, + pct, + }; + }); + members.sort((a, b) => b.total_credits - a.total_credits); + + const consumed = Number(raw.platform_credits_used ?? 0); + const purchasedSum = members.reduce((s, m) => s + m.purchased_credits, 0); return { month_key: String(raw.month_key ?? ""), - total_credits: Number(raw.platform_credits_used ?? 0), - purchased_credits: 0, - consumed_credits: Number(raw.platform_credits_used ?? 0), - remaining_credits: 0, + total_credits: consumed, + purchased_credits: purchasedSum, + consumed_credits: consumed, + remaining_credits: Math.max(0, purchasedSum - consumed), ai_calls: 0, search_calls: Number(raw.byok_call_count ?? 0), by_meter: { @@ -729,36 +815,40 @@ function createLiveUsage(): UsageRepo { web_search: { credits: 0, count: 0 }, ai_image: { credits: 0, count: 0 }, }, - members: ((raw.members as Record[]) ?? []).map((m) => ({ - uid: String(m.uid ?? ""), - email: "", - display_name: "", - unlimited: Boolean(m.unlimited), - plan_id: String(m.plan_id ?? "free") as PlanId, - purchased_credits: 0, - total_credits: Number(m.platform_credits_used ?? 0), - ai_calls: 0, - search_calls: Number(m.byok_call_count ?? 0), - remaining_credits: 0, - pct: 0, - })), + members, }; }, - async getTenantAnalytics() { + async getTenantAnalytics(query) { const sum = await this.getTenantSummary(); + const purchased = sum.purchased_credits || 1; return { - granularity: "month" as const, - from: "", - to: "", + granularity: (query?.granularity ?? "month") as "day" | "month" | "year", + from: query?.from ?? "", + to: query?.to ?? "", range_label: sum.month_key, - purchased_credits: 0, + purchased_credits: sum.purchased_credits, consumed_credits: sum.consumed_credits, - remaining_credits: 0, - pct: 0, - ai_calls: 0, + remaining_credits: sum.remaining_credits, + pct: + sum.purchased_credits > 0 + ? Math.min(999, Math.round((sum.consumed_credits / purchased) * 100)) + : 0, + ai_calls: sum.ai_calls, search_calls: sum.search_calls, by_meter: sum.by_meter, - series: [], + // 後端目前僅月彙總;圖表至少給本月一點 + series: sum.month_key + ? [ + { + key: sum.month_key, + label: sum.month_key, + purchased_credits: sum.purchased_credits, + consumed_credits: sum.consumed_credits, + ai_calls: sum.ai_calls, + search_calls: sum.search_calls, + }, + ] + : [], members: sum.members, }; }, @@ -1436,7 +1526,6 @@ function createLiveMentions(): MentionsRepo { } export function createLiveRepos(): Repos { - // 除邀請外全部 live;不再 fallback mock seed return { dataSource: "live", auth: createLiveAuth(), @@ -1446,7 +1535,7 @@ export function createLiveRepos(): Repos { jobs: createLiveJobs(), notifications: createLiveNotifications(), usage: createLiveUsage(), - invite: createMockInviteRepo(), + invite: createLiveInviteRepo(), personas: createLivePersonas(), plays: createLivePlays(), outbox: createLiveOutbox(), diff --git a/apps/web/src/data/mock/keys.ts b/apps/web/src/data/mock/keys.ts index 8bac96e..eb65769 100644 --- a/apps/web/src/data/mock/keys.ts +++ b/apps/web/src/data/mock/keys.ts @@ -59,6 +59,8 @@ export const KEYS = { activePersonaId: "harbor.active_persona_id", activeBrandId: "harbor.active_brand_id", ownPostsSyncedAt: "harbor.own_posts_synced_at", + /** 帳號成效 sync 閘:{ [accountId]: { lastSyncMs, pastOnceDone } } */ + ownPostsSyncMeta: "harbor.own_posts.sync_meta", /** 帳號月度分析歷史 AccountInsightsSnapshot[] */ accountInsightHistory: "harbor.account.insight_history", } as const; diff --git a/apps/web/src/data/repos.ts b/apps/web/src/data/repos.ts index e5be83f..10eb7bf 100644 --- a/apps/web/src/data/repos.ts +++ b/apps/web/src/data/repos.ts @@ -223,8 +223,31 @@ export type OutboxRepo = { remove(id: string): Promise; }; +/** 任務列表分桶:定期排程 / 執行中 / 歷史 */ +export type JobListTab = "recurring" | "active" | "history"; + +export type JobListQuery = { + tab?: JobListTab; + page?: number; + pageSize?: number; +}; + +export type JobListResult = { + list: Job[]; + pagination: { + page: number; + pageSize: number; + total: number; + totalPages: number; + }; +}; + export type JobsRepo = { - list(): Promise; + /** + * 分頁列表。傳 query 時走 tab+page;無 query 時預設 active 前 50 筆(相容舊呼叫)。 + * 回傳完整 JobListResult;若只要陣列可用 .list。 + */ + list(query?: JobListQuery): Promise; get(id: string): Promise; startDemo(): Promise; /** 手動刪除(執行中不可刪;後端終態約 2 天後也會自動清) */ diff --git a/apps/web/src/lib/accountInsights.ts b/apps/web/src/lib/accountInsights.ts index c999599..e2fc1d5 100644 --- a/apps/web/src/lib/accountInsights.ts +++ b/apps/web/src/lib/accountInsights.ts @@ -118,6 +118,19 @@ export function saveInsightSnapshot(snap: AccountInsightsSnapshot): AccountInsig return snap; } +/** 刪除某帳某月分析(空月不該留假結論) */ +export function removeInsightSnapshot(accountId: string, monthKey: string): void { + const list = loadAllSnapshots().filter( + (s) => !(s.account_id === accountId && s.month_key === monthKey), + ); + saveAllSnapshots(list); +} + +/** 該月是否有足夠真實數據可寫結論(至少 1 則貼文) */ +export function monthHasRealData(month: Pick): boolean { + return (month.posts || 0) > 0; +} + function snapshotFromParts(opts: { accountId: string; month: MonthBucket; @@ -159,48 +172,80 @@ function snapshotFromParts(opts: { } /** - * 依目前 report 確保近 N 月都有歷史分析紀錄(缺則補;本月可 force 重算)。 + * 依目前 report 維護分析歷史。 + * - 該月 posts=0:不寫結論,並清掉舊的假 snapshot + * - 有貼文才建/更新分析(本月可 force 重算) */ export function ensureInsightHistory( report: AccountInsightsReport, opts?: { forceCurrent?: boolean }, ): AccountInsightsSnapshot[] { const months = report.months; - const topHighlights = report.topPosts.slice(0, 3).map((p) => { - const head = (p.insight || p.formula_summary || p.text).slice(0, 80); - return `${p.topic_tag ? `【${p.topic_tag}】` : ""}${head}${head.length >= 80 ? "…" : ""}`; - }); + const monthKeys = new Set(months.map((m) => m.key)); + + // 清掉已不在視窗外、或空月卻殘留假結論的紀錄 + for (const snap of listInsightSnapshots(report.accountId)) { + const bucket = months.find((m) => m.key === snap.month_key); + if (!monthKeys.has(snap.month_key)) continue; + if (bucket && !monthHasRealData(bucket)) { + removeInsightSnapshot(report.accountId, snap.month_key); + } + // 舊假資料:metrics.posts=0 卻還有分析句 + if ((snap.metrics?.posts || 0) <= 0 && (snap.analysis?.length || 0) > 0) { + removeInsightSnapshot(report.accountId, snap.month_key); + } + } for (let i = 0; i < months.length; i++) { const month = months[i]!; - const previous = i > 0 ? months[i - 1]! : null; + // 沒有真實貼文 → 不亂下結論 + if (!monthHasRealData(month)) { + removeInsightSnapshot(report.accountId, month.key); + continue; + } + + // 月比只在「前月也有真實貼文」時才有意義 + const rawPrev = i > 0 ? months[i - 1]! : null; + const previous = rawPrev && monthHasRealData(rawPrev) ? rawPrev : null; const existing = getInsightSnapshot(report.accountId, month.key); const isCurrent = i === months.length - 1; if (existing && !(isCurrent && opts?.forceCurrent)) continue; - const delta = { - views: previous ? pctChange(month.views, previous.views) : null, - likes: previous ? pctChange(month.likes, previous.likes) : null, - replies: previous ? pctChange(month.replies, previous.replies) : null, - posts: previous ? pctChange(month.posts, previous.posts) : null, - engagementRate: previous - ? pctChange(month.engagementRate * 100, previous.engagementRate * 100) - : null, - }; + const monthTop = topPostsForMonth( + // report.topPosts 是全帳本月 top;歷史月用 metrics 敘事 + 空 highlights 即可 + isCurrent ? report.topPosts : [], + month.key, + 3, + ); + // 若是本月,topPosts 已是本月;仍再 filter 保險 + const highlights = (isCurrent ? report.topPosts : monthTop).slice(0, 3).map((p) => { + const head = (p.insight || p.formula_summary || p.text).slice(0, 80); + return `${p.topic_tag ? `【${p.topic_tag}】` : ""}${head}${head.length >= 80 ? "…" : ""}`; + }); - // 本月:優先用 report 全文;force 或歷史月:依該月指標重算敘事 - const narrative = - isCurrent && !opts?.forceCurrent && report.analysis.length > 0 - ? { analysis: report.analysis, recommendations: report.recommendations } - : buildNarrative({ - current: month, - previous, - delta, - topPosts: isCurrent ? report.topPosts : [], - avgEngagementRate: isCurrent ? report.avgEngagementRate : month.engagementRate, - totalPosts: isCurrent ? report.totalPosts : month.posts, - monthLabel: month.label, - }); + const narrative = buildNarrative({ + current: month, + previous, + delta: { + views: previous ? pctChange(month.views, previous.views) : null, + likes: previous ? pctChange(month.likes, previous.likes) : null, + replies: previous ? pctChange(month.replies, previous.replies) : null, + posts: previous ? pctChange(month.posts, previous.posts) : null, + engagementRate: previous + ? pctChange(month.engagementRate * 100, previous.engagementRate * 100) + : null, + }, + topPosts: isCurrent ? report.topPosts : [], + avgEngagementRate: isCurrent ? report.avgEngagementRate : month.engagementRate, + totalPosts: month.posts, + monthLabel: month.label, + }); + + // 仍無任何可說的結論 → 不存 snapshot + if (narrative.analysis.length === 0 && narrative.recommendations.length === 0) { + removeInsightSnapshot(report.accountId, month.key); + continue; + } saveInsightSnapshot( snapshotFromParts({ @@ -209,14 +254,7 @@ export function ensureInsightHistory( previous, analysis: narrative.analysis, recommendations: narrative.recommendations, - topHighlights: isCurrent - ? topHighlights - : existing?.top_highlights?.length - ? existing.top_highlights - : [ - `${month.label} 互動率 ${Math.round(month.engagementRate * 1000) / 10}%`, - `瀏覽 ${month.views.toLocaleString("zh-TW")} · 回覆 ${month.replies}`, - ], + topHighlights: highlights, existingId: existing?.id, }), ); @@ -237,6 +275,34 @@ function engagementOf(p: OwnPost): number { return eng / views; } +/** published_at unix ns → YYYY-MM */ +export function monthKeyOfPost(p: OwnPost): string { + const ms = p.published_at > 0 ? Math.floor(p.published_at / 1_000_000) : Date.now(); + return monthKeyFromDate(new Date(ms)); +} + +export function filterPostsByMonth(posts: OwnPost[], monthKey: string): OwnPost[] { + return posts.filter((p) => monthKeyOfPost(p) === monthKey); +} + +export function scoreOwnPost(p: OwnPost): number { + return ( + (p.view_count || 0) + + (p.like_count || 0) * 8 + + (p.reply_count || 0) * 20 + + (p.repost_count || 0) * 15 + + (p.quote_count || 0) * 12 + ); +} + +/** 指定月份表現較佳貼文 */ +export function topPostsForMonth(posts: OwnPost[], monthKey: string, limit = 5): OwnPost[] { + return filterPostsByMonth(posts, monthKey) + .slice() + .sort((a, b) => scoreOwnPost(b) - scoreOwnPost(a)) + .slice(0, limit); +} + function emptyBucket(key: string, source: MonthBucket["source"] = "estimate"): MonthBucket { return { key, @@ -278,15 +344,8 @@ function pctChange(curr: number, prev: number): number | null { return Math.round(((curr - prev) / prev) * 1000) / 10; } -function hashSeed(s: string): number { - let h = 0; - for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; - return Math.abs(h); -} - /** - * 由貼文聚合近 N 月成效;缺月以均值微擾補齊(mock 歷史,方便畫月比圖)。 - * live 時改為讀 snapshot 表即可。 + * 由已同步貼文聚合近 N 月成效(只算真實數據;無貼文的月份為 0,不造假估測)。 */ export function buildAccountInsights( accountId: string, @@ -300,8 +359,10 @@ export function buildAccountInsights( for (const k of keys) byMonth.set(k, emptyBucket(k, "posts")); for (const p of mine) { - const d = new Date(Math.floor(p.published_at / 1_000_000)); - const key = monthKeyFromDate(d); + // published_at = unix ns;異常 0 則歸入本月,避免整批落在窗外 + const ms = + p.published_at > 0 ? Math.floor(p.published_at / 1_000_000) : Date.now(); + const key = monthKeyFromDate(new Date(ms)); if (!byMonth.has(key)) continue; const b = byMonth.get(key)!; b.posts += 1; @@ -314,72 +375,29 @@ export function buildAccountInsights( b.source = "posts"; } - // 有真實數據的月份均值,用來補齊空月 - const real = keys - .map((k) => byMonth.get(k)!) - .filter((b) => b.posts > 0) - .map(finalizeBucket); - const avgViews = real.length - ? real.reduce((s, b) => s + b.views, 0) / real.length - : 800; - const avgLikes = real.length - ? real.reduce((s, b) => s + b.likes, 0) / real.length - : 30; - const avgReplies = real.length - ? real.reduce((s, b) => s + b.replies, 0) / real.length - : 3; - const avgPosts = real.length - ? Math.max(1, Math.round(real.reduce((s, b) => s + b.posts, 0) / real.length)) - : 2; - - for (let i = 0; i < keys.length; i++) { - const k = keys[i]!; - const b = byMonth.get(k)!; - if (b.posts > 0) { - byMonth.set(k, finalizeBucket(b)); - continue; - } - // 越舊略低、加帳號雜訊(穩定偽隨機) - const age = keys.length - 1 - i; - const noise = 0.72 + (hashSeed(`${accountId}|${k}`) % 40) / 100; - const factor = noise * (1 - age * 0.06); - const est = emptyBucket(k, "estimate"); - est.posts = Math.max(1, Math.round(avgPosts * factor)); - est.views = Math.round(avgViews * factor); - est.likes = Math.round(avgLikes * factor); - est.replies = Math.max(0, Math.round(avgReplies * factor)); - est.reposts = Math.round(est.likes * 0.12); - est.quotes = Math.round(est.likes * 0.08); - est.shares = Math.round(est.likes * 0.05); - byMonth.set(k, finalizeBucket(est)); + for (const k of keys) { + byMonth.set(k, finalizeBucket(byMonth.get(k)!)); } const months = keys.map((k) => byMonth.get(k)!); const current = months[months.length - 1]!; - const previous = months.length >= 2 ? months[months.length - 2]! : null; + // 前月沒貼文就不做月比(避免 0→有數 顯示 +100% 假結論) + const rawPrev = months.length >= 2 ? months[months.length - 2]! : null; + const previous = rawPrev && monthHasRealData(rawPrev) ? rawPrev : null; const delta = { - views: previous ? pctChange(current.views, previous.views) : null, - likes: previous ? pctChange(current.likes, previous.likes) : null, - replies: previous ? pctChange(current.replies, previous.replies) : null, - posts: previous ? pctChange(current.posts, previous.posts) : null, - engagementRate: previous - ? pctChange(current.engagementRate * 100, previous.engagementRate * 100) - : null, + views: previous && monthHasRealData(current) ? pctChange(current.views, previous.views) : null, + likes: previous && monthHasRealData(current) ? pctChange(current.likes, previous.likes) : null, + replies: + previous && monthHasRealData(current) ? pctChange(current.replies, previous.replies) : null, + posts: previous && monthHasRealData(current) ? pctChange(current.posts, previous.posts) : null, + engagementRate: + previous && monthHasRealData(current) + ? pctChange(current.engagementRate * 100, previous.engagementRate * 100) + : null, }; - const topPosts = mine - .slice() - .sort((a, b) => { - const score = (p: OwnPost) => - (p.view_count || 0) + - (p.like_count || 0) * 8 + - (p.reply_count || 0) * 20 + - (p.repost_count || 0) * 15 + - (p.quote_count || 0) * 12; - return score(b) - score(a); - }) - .slice(0, 5); + const topPosts = topPostsForMonth(mine, current.key, 5); const withViews = mine.filter((p) => (p.view_count || 0) > 0); const avgEngagementRate = withViews.length @@ -410,6 +428,9 @@ export function buildAccountInsights( }; } +/** + * 只根據該月真實指標寫結論;沒貼文/沒可比數字就不硬寫。 + */ function buildNarrative(opts: { current: MonthBucket; previous: MonthBucket | null; @@ -419,79 +440,73 @@ function buildNarrative(opts: { totalPosts: number; monthLabel?: string; }): { analysis: string[]; recommendations: string[] } { - const { current, previous, delta, topPosts, avgEngagementRate, totalPosts } = opts; + const { current, previous, delta, topPosts, avgEngagementRate } = opts; const when = opts.monthLabel || current.label || "本月"; const analysis: string[] = []; const recommendations: string[] = []; - if (totalPosts === 0 && current.posts === 0) { - return { - analysis: [`${when}:尚無足夠貼文數據,無法比較月成效。`], - recommendations: ["先同步我的貼文,或該月至少發 1~2 則再分析。"], - }; + // 空月:完全不說話 + if (!monthHasRealData(current)) { + return { analysis: [], recommendations: [] }; } analysis.push( - `${when}彙總:貼文 ${current.posts}、瀏覽 ${current.views.toLocaleString("zh-TW")}、讚 ${current.likes}、回覆 ${current.replies}(資料${current.source === "posts" ? "來自同步貼文" : "為歷史估測"})。`, + `${when}彙總:貼文 ${current.posts}、瀏覽 ${current.views.toLocaleString("zh-TW")}、讚 ${current.likes}、回覆 ${current.replies}(來自已同步貼文)。`, ); - if (delta.views != null && previous) { + // 僅在前月也有貼文時才比 + if (previous && monthHasRealData(previous) && delta.views != null) { if (delta.views > 8) { analysis.push( `瀏覽較前月 ${fmtDelta(delta.views)}(${previous.views.toLocaleString("zh-TW")} → ${current.views.toLocaleString("zh-TW")})。`, ); } else if (delta.views < -8) { analysis.push( - `瀏覽較前月 ${fmtDelta(delta.views)},曝光偏弱,宜檢查發文節奏與開場鉤子。`, + `瀏覽較前月 ${fmtDelta(delta.views)}(${previous.views.toLocaleString("zh-TW")} → ${current.views.toLocaleString("zh-TW")})。`, ); } else { analysis.push(`瀏覽與前月大致持平(${fmtDelta(delta.views)})。`); } } - if (delta.replies != null) { + if (previous && monthHasRealData(previous) && delta.replies != null) { if (delta.replies > 10) { - analysis.push(`回覆數 ${fmtDelta(delta.replies)},對話熱度上升,適合延續可接話題材。`); + analysis.push(`回覆數 ${fmtDelta(delta.replies)},對話熱度上升。`); } else if (delta.replies < -10) { - analysis.push(`回覆數 ${fmtDelta(delta.replies)},可多試「求經驗/明確條件」開問。`); + analysis.push(`回覆數 ${fmtDelta(delta.replies)}。`); } } - const engPct = Math.round(avgEngagementRate * 1000) / 10; - analysis.push( - `互動率約 ${engPct}%(讚+回+轉+引用+分享/瀏覽)。Threads 上 ${engPct >= 4 ? "屬不錯" : engPct >= 2 ? "中等,還有拉高空間" : "偏低,優先優化鉤子與問句"}。`, - ); + // 有瀏覽才談互動率,避免 0 瀏覽硬算 0% 再亂評 + if (current.views > 0) { + const engPct = Math.round(avgEngagementRate * 1000) / 10; + analysis.push( + `互動率約 ${engPct}%(讚+回+轉+引用+分享/瀏覽)。`, + ); + if (engPct < 2) { + recommendations.push("互動率偏低:可多試帶明確條件的提問收尾。"); + } else if (engPct >= 4) { + recommendations.push("互動率不錯:可複製高表現貼的結構再測 1~2 則。"); + } + } else if (current.likes + current.replies > 0) { + analysis.push("此月有讚/回覆,但瀏覽為 0(Insights 可能尚未回傳或權限不足)。"); + } const top = topPosts[0]; if (top) { - const tag = top.topic_tag ? `「${top.topic_tag}」` : "高互動"; + const snippet = (top.insight || top.formula_summary || top.text).slice(0, 72); analysis.push( - `表現最佳貼偏 ${tag}:${(top.insight || top.formula_summary || top.text).slice(0, 72)}${(top.insight || top.formula_summary || top.text).length > 72 ? "…" : ""}`, + `表現較佳之一:${snippet}${snippet.length >= 72 ? "…" : ""}`, ); } - const questionPosts = topPosts.filter((p) => /[??]/.test(p.text) || /求|有人|嗎/.test(p.text)); - if (questionPosts.length >= 1 || (current.replies > 0 && current.posts > 0)) { - recommendations.push("維持條件明確的提問貼(插座/無香/預算),回覆與轉發通常較穩。"); - } else { - recommendations.push("下一期至少 1 則帶具體條件的提問,避免純宣告。"); - } - - if ((delta.views ?? 0) < 0 || engPct < 2.5) { - recommendations.push("搭配探查清待回:活躍回覆能補帳號存在感,間接撐後續曝光。"); - } else { - recommendations.push("把高互動貼的結構(鉤子+問句)複製到下 2 則新串,測能否再現。"); - } - if (current.posts < 3) { - recommendations.push("該月發文偏少,可固定每週 2~3 則,曲線與月比才比較得準。"); - } else { - recommendations.push("下月回看本頁歷史:對照瀏覽與回覆是否同向,避免只追讚數。"); + recommendations.push("該月貼文偏少,樣本小,月比僅供參考。"); } return { analysis: analysis.slice(0, 5), - recommendations: recommendations.slice(0, 4), + recommendations: recommendations.slice(0, 3), }; } diff --git a/apps/web/src/lib/apiErrors.ts b/apps/web/src/lib/apiErrors.ts index 5a2b155..e60b5ed 100644 --- a/apps/web/src/lib/apiErrors.ts +++ b/apps/web/src/lib/apiErrors.ts @@ -26,6 +26,9 @@ export const API_CODES = { NOT_FOUND: 404001, EMAIL_NOT_REGISTERED: 404002, EMAIL_TAKEN: 409001, + QUOTA: 402001, + METER_CAP: 402002, + PLATFORM_CAPACITY: 503002, SERVER: 500000, NOT_IMPLEMENTED: 501000, NETWORK: 0, @@ -47,12 +50,16 @@ export const API_ERROR_I18N_KEY: Record = { [API_CODES.NOT_FOUND]: "api.err.404001", [API_CODES.EMAIL_NOT_REGISTERED]: "api.err.404002", [API_CODES.EMAIL_TAKEN]: "api.err.409001", + [API_CODES.QUOTA]: "api.err.402001", + [API_CODES.METER_CAP]: "usage.err.meterCap", + [API_CODES.PLATFORM_CAPACITY]: "usage.err.platformCapacity", [API_CODES.SERVER]: "api.err.500000", [API_CODES.NOT_IMPLEMENTED]: "api.err.501000", [API_CODES.NETWORK]: "api.err.network", // 400050/400060 不進表:後端 message 常是具體中文原因(人設分析等),由 rawMessage 顯示 400061: "api.err.crawlerSession", 503001: "api.err.timeoutAI", + // 503002 = PLATFORM_CAPACITY,已在上方 API_CODES 映射 }; export function i18nKeyForApiCode(code: number): string | undefined { @@ -143,6 +150,9 @@ export function messageForApiCode( locale?: AppLocale, ): string { const loc = locale ?? currentLocale(); + // 後端業務錯誤直接回 i18n key(invite.err.* / usage.err.*) + const fb = (fallback || "").trim(); + if (fb.startsWith("invite.err.") || fb.startsWith("usage.err.")) return translate(loc, fb); const key = i18nKeyForApiCode(code); if (key) return translate(loc, key); if (isUserFacingBizMessage(fallback)) return cleanBizMessage(fallback!); @@ -163,6 +173,9 @@ export function formatThrownError(err: unknown, fallbackKey = "common.error"): s if (e && typeof e === "object" && e.name === "ApiError") { const code = e.code != null ? Number(e.code) : NaN; const raw = e.rawMessage || e.message; + if (typeof raw === "string" && (raw.startsWith("invite.err.") || raw.startsWith("usage.err."))) { + return translate(loc, raw); + } if (!Number.isNaN(code)) { const key = i18nKeyForApiCode(code); // 驗證類業務碼:優先後端中文原因 diff --git a/apps/web/src/lib/i18n/messages.ts b/apps/web/src/lib/i18n/messages.ts index 90b401c..3c3c9f3 100644 --- a/apps/web/src/lib/i18n/messages.ts +++ b/apps/web/src/lib/i18n/messages.ts @@ -67,6 +67,9 @@ export const zhTW: MessageDict = { "api.err.404001": "找不到資料", "api.err.404002": "查無此信箱,請確認後再試", "api.err.409001": "此 Email 已被註冊", + "api.err.402001": "本月平台點數已用完,請升級方案或下月再試", + "usage.err.meterCap": "此功能本月點數已達上限,請改用其他功能或升級方案", + "usage.err.platformCapacity": "平台 AI/搜尋目前忙碌或已限流。可稍後再試,或到設定填入自己的 API Key(BYOK)繼續使用", "api.err.500000": "伺服器發生錯誤,請稍後再試", "api.err.timeoutAI": "AI 回應逾時(模型思考太久)。可縮短結構備註後再試,或到設定換較快的模型", "api.err.501000": "此功能尚未實作", @@ -140,14 +143,14 @@ export const zhTW: MessageDict = { "settings.localeCurrency": "語言與幣別", "settings.locale": "介面語言", "settings.currency": "顯示幣別", - "settings.currencyHint": "", + "settings.currencyHint": "金額顯示用;方案計費仍以台幣為準。", "settings.localeSaved": "語言與幣別已更新", "settings.appearance": "外觀", "settings.theme": "主題", "settings.themeLight": "淺色", "settings.themeDark": "深色", "settings.themeSystem": "跟隨系統", - "settings.themeHint": "", + "settings.themeHint": "可選淺色、深色,或跟隨系統外觀。", "settings.themeSaved": "主題已更新", "settings.themeToLight": "切換成淺色", "settings.themeToDark": "切換成深色", @@ -180,14 +183,14 @@ export const zhTW: MessageDict = { "settings.threadsGoCrew": "前往 Crew 連帳", "settings.member": "會員與登入", "settings.usageCard": "AI/搜尋額度", - "settings.usageCardHint": "", + "settings.usageCardHint": "平台代付點數與方案上限;自備 Key 不占平台點。", "settings.viewUsage": "查看用量", "settings.editProfile": "編輯會員資料", "settings.mockLogin": "demo@harbor.local / demo", - "settings.memberHint": "", + "settings.memberHint": "登入帳號、顯示名稱與通知偏好。", "usage.title": "用量與方案", - "usage.desc": "", + "usage.desc": "查看本月平台點數、分項用量,並變更方案。", "usage.creditsUsed": "本月已用點數", "usage.remaining": "剩餘 {n} 點", "usage.percentUsed": "{n}% 已使用", @@ -199,17 +202,23 @@ export const zhTW: MessageDict = { "usage.perMonth": "點/月", "usage.ledger": "最近使用紀錄", "usage.emptyTitle": "本月還沒有扣點", - "usage.emptyDesc": "", - "usage.planNote": "", + "usage.emptyDesc": "開始創作、海巡或生圖後,這裡會出現扣點紀錄。", + "usage.planNote": "自然月重置;未用完點數不累積至下月。", "usage.switched": "已切換為 {name}", "usage.meter.times": "{count} 次 · {credits} 點", - "usage.meter.cap": "單項參考上限 {count}/{cap} 次", - "usage.plan.free.blurb": "試用與個人輕量經營", + "usage.meter.timesShort": "{n} 次", + "usage.meter.pt": "點", + "usage.meter.over": "已超出", + "usage.meter.locked": "已鎖", + "usage.meter.cap": "單項上限 {credits}/{cap} 點", + "usage.side.aiCredits": "AI 相關已用點數(文案+研究+生圖)", + "usage.side.searchCredits": "搜尋已用點數", + "usage.plan.free.blurb": "夠用試用,體驗完整創作流程", "usage.plan.starter.blurb": "小團隊日常發文與海巡", "usage.plan.pro.blurb": "多帳、重度 AI 與研究", "profile.title": "會員資料", - "profile.desc": "", + "profile.desc": "管理顯示名稱、頭像與密碼。", "profile.accountStatus": "帳號開通狀態", "profile.basic": "基本資料", "profile.avatar": "頭像", @@ -315,7 +324,7 @@ export const zhTW: MessageDict = { "invite.err.codeSelf": "不能填自己的邀請碼", "admin.users.title": "島民管理", - "admin.users.desc": "", + "admin.users.desc": "管理島民帳號、權限、方案與不擋額度。", "admin.users.needAdmin": "需要管理員權限", "admin.users.list": "島民列表 · {n}", "admin.users.detail": "島民詳情", @@ -357,35 +366,47 @@ export const zhTW: MessageDict = { "crew.msg.refreshFail": "延長失敗(token 失效時請重新「連接帳號」)", "crew.msg.oauthOk": "Threads 帳號已連線;已排程約 30 天後自動延長 token(見任務)", "crew.msg.oauthFail": "OAuth 連線失敗", + "crew.msg.oauthUrlFail": "無法取得授權網址,請稍後再試或檢查平台 Threads 設定", + "crew.msg.deleted": "已移除 @{user}", "crew.confirmDelete": "確定刪除帳號 @{user}?\n刪除後無法再選為 lead / cast。", "today.findTopic": "找話題", + "today.refreshTopics": "刷新話題", + "today.reload": "重新整理", + "today.loadFail": "無法載入今日資料", + "today.trendsFail": "無法刷新話題(可能額度不足或搜尋未設定)", + "today.syncPosts": "同步已發文", + "today.syncPostsFail": "同步貼文失敗", + "today.needAccount": "請先連接 Threads 帳號", "today.pendingReplies": "待回覆", "today.pendingRepliesN": "待回覆 · {n}", - "today.newThread": "開新串", + "today.newThread": "寫一則", "today.metricsAria": "今日數值", "today.metric.pending": "待回", "today.metric.pendingHint": "海巡佇列", "today.metric.doneGoal": "已回/目標", + "today.metric.doneGoalHint": "今日海巡完成數/目標(海巡標記已發會累加)", "today.metric.sentToday": "今日已發", "today.metric.running": "進行中 {n}", "today.metric.sentDone": "完成的發送", "today.metric.failed": "發送異常", "today.metric.needAction": "需處理", "today.metric.ok": "正常", - "today.pending.title": "待回覆 · {n}", - "today.pending.empty": "目前沒有待回", + "today.metric.mentions": "提及", + "today.metric.mentionsHint": "待回提及", + "today.pending.title": "海巡待回 · {n}", + "today.pending.empty": "目前沒有待回,去海巡掃一輪", "today.goScout": "去海巡", "today.pending.more": "還有 {n} 則 →", "today.pending.handle": "海巡處理", "today.pending.start": "開始處理", "today.topics.title": "找話題", - "today.topics.empty": "還沒有話題", - "today.goStudio": "去創作", + "today.topics.empty": "還沒有話題,按「刷新話題」抓一輪", + "today.goStudio": "去靈感", "today.heat": "熱度 {n}", "today.topicAngle": "可當開場角度", "today.moreInspire": "更多靈感", - "today.useTopic": "用話題開串", + "today.useTopic": "用話題發想", "today.outbox.title": "今日發送", "today.outbox.empty": "還沒有今日發送。可先", "today.outbox.emptyMid": ",完成後會出現在", @@ -394,9 +415,10 @@ export const zhTW: MessageDict = { "today.badge.failed": "失敗", "today.badge.scheduling": "排程", "today.badge.sending": "發送中", + "today.badge.drafted": "已草稿", "today.openOutbox": "開啟發送", "today.accounts.title": "帳號成效", - "today.accounts.empty": "尚無成效", + "today.accounts.empty": "尚無成效資料,可先同步已發文", "today.postsCount": "{n} 則貼文", "today.views": "瀏覽", "today.likes": "讚", @@ -405,6 +427,7 @@ export const zhTW: MessageDict = { "today.viewPosts": "看已發文", "today.manageAccounts": "管理帳號", + "outbox.title": "發送", "outbox.tabsAria": "發送分頁", "outbox.tab.active": "進行中", @@ -706,7 +729,7 @@ export const zhTW: MessageDict = { "metrics.type.post": "貼文", "jobs.title": "任務", - "jobs.desc": "背景任務列表。含定期任務(例如 Threads Token 約每 30 天自動延長一次)。已完成/失敗/取消的任務約保留 2 天後自動清除,也可手動刪除。", + "jobs.desc": "背景任務分三區:執行中、定期排程、歷史。每頁可調筆數,不會一次拉完全部。", "jobs.startDemo": "產生測試任務", "jobs.demoHint": "需 worker 執行;狀態會自動輪詢更新。", "jobs.demoLabel": "Demo 測試任務", @@ -731,6 +754,14 @@ export const zhTW: MessageDict = { "jobs.status.cancel_requested": "取消中", "jobs.loadFail": "無法載入任務列表", "jobs.empty": "尚無任務", + "jobs.empty.active": "目前沒有執行中或待領取的任務", + "jobs.empty.recurring": "尚無定期/遠期排程任務", + "jobs.empty.history": "尚無歷史紀錄(已完成/失敗/取消)", + "jobs.tabsAria": "任務分類", + "jobs.tab.active": "執行中", + "jobs.tab.recurring": "定期任務", + "jobs.tab.history": "歷史已執行", + "jobs.recurringHint": "尚未到期的排程(例如 Token 約 30 天延長)。到期後會出現在「執行中」。", "jobs.total": "共 {n} 筆", "jobs.showing": " · 顯示前 {n}", "jobs.detail": "詳情", @@ -765,45 +796,46 @@ export const zhTW: MessageDict = { "plan.cta.downgrade": "降級", "plan.cta.switch": "切換", - "plan.free.headline": "試用與個人輕量經營", - "plan.free.bullet1": "每月 {n} 點 AI/搜尋", - "plan.free.bullet2": "完整功能:創作、海巡、發送", - "plan.free.bullet3": "適合先熟悉流程", - "plan.free.bullet4": "可隨時升級", + "plan.free.headline": "夠用試用,體驗完整流程", + "plan.free.bullet1": "每月 {n} 點(約 2~3 週輕度日常)", + "plan.free.bullet2": "完整功能:創作、海巡、發送、生圖", + "plan.free.bullet3": "用得出價值再升級 Starter", + "plan.free.bullet4": "平台忙碌時可改填自己的 Key", "plan.free.right1": "可使用完整功能:帳號、創作、海巡、發送、任務與靈感。", - "plan.free.right2": "每月固定點數,用在文案、研究、搜尋與生圖。", - "plan.free.right3": "達上限後需等下月或升級(除非管理員開不擋額度)。", + "plan.free.right2": "點數夠你真實試跑文案、搜尋與幾次生圖,不是空殼 demo。", + "plan.free.right3": "達上限後升級 Starter 繼續;或設定自備 Key(BYOK)不占平台點。", "plan.free.quota1": "每月配給 {n} 點。", - "plan.free.quota2": "參考:文案 {copy}、研究 {research}、搜尋 {search}、生圖 {image} 次。", "plan.free.note1": "無需付款,確認即切換。", "plan.free.note2": "正式金流後的發票規則另訂。", "plan.starter.headline": "小團隊日常發文與海巡", - "plan.starter.bullet1": "每月 {n} 點(約 6× Free)", + "plan.starter.bullet1": "每月 {n} 點(約 5× Free)", "plan.starter.bullet2": "穩定發文、回覆、海巡", - "plan.starter.bullet3": "適合 1~3 人節奏", + "plan.starter.bullet3": "適合 1~3 人節奏 · 付費主力", "plan.starter.bullet4": "付款成功立即生效", "plan.starter.right1": "付款成功後本帳改為 Starter,當月依新額度計算。", "plan.starter.right2": "點數支撐固定發文、回覆草稿與定期海巡。", - "plan.starter.right3": "功能與 Free 相同,差在能用多久。", + "plan.starter.right3": "功能與 Free 相同,差在能用多久;日常節奏建議由此開始。", "plan.starter.quota1": "每月 {n} 點 · {price}。", - "plan.starter.quota2": "參考:文案 {copy}、研究 {research}、搜尋 {search}、生圖 {image} 次。", "plan.starter.note1": "需付款成功才變更方案。", "plan.starter.note2": "自然月重置,未用完點數不累積至下月。", "plan.pro.headline": "多帳、重度 AI 與研究", - "plan.pro.bullet1": "每月 {n} 點(約 4× Starter)", - "plan.pro.bullet2": "高頻文案/研究/生圖", + "plan.pro.bullet1": "每月 {n} 點(約 3× Starter)", + "plan.pro.bullet2": "高頻文案/研究/生圖 · 重度天花板", "plan.pro.bullet3": "適合代理與多品牌", "plan.pro.bullet4": "付款成功立即生效", "plan.pro.right1": "付款成功後本帳改為 Pro,當月依 Pro 額度計算。", "plan.pro.right2": "適合多帳、大量回覆與深研究,減少中途額度見底。", - "plan.pro.right3": "功能相同;買的是容量與節奏。", + "plan.pro.right3": "功能相同;買的是容量。再高可改 BYOK,平台限流時也不中斷。", "plan.pro.quota1": "每月 {n} 點 · {price}。", - "plan.pro.quota2": "參考:文案 {copy}、研究 {research}、搜尋 {search}、生圖 {image} 次。", "plan.pro.note1": "付款失敗不會改方案。", "plan.pro.note2": "付款完成後可於帳務紀錄查詢收據。", + "plan.quota2": "分項點數:文案 {copy}(約 {copyCalls} 次)、研究 {research}(約 {researchCalls} 次)、搜尋 {search}(約 {searchCalls} 次)、生圖 {image}(約 {imageCalls} 張)。", + "plan.softCapsLine": "分項點數:文案 {copy} · 研究 {research} · 搜尋 {search} · 生圖 {image}", + "plan.approxCallsLine": "約 {copyCalls} 次文案 · {researchCalls} 次研究 · {searchCalls} 次搜尋 · {imageCalls} 張圖", + "checkout.title": "確認方案", "checkout.pickFirst": "請先選擇方案。", "checkout.viewPlans": "看方案", @@ -838,6 +870,7 @@ export const zhTW: MessageDict = { "usage.widget.monthUsage": "本月用量", "usage.widget.remaining": "還剩 {n} 點", "usage.widget.leftShort": "還剩 {n}", + "usage.widget.usedOfCap": "{used}/{cap}", "usage.widget.overShort": "已超額", "usage.widget.upgradeShort": "升級", "usage.widget.upgrade": "升級方案", @@ -850,7 +883,7 @@ export const zhTW: MessageDict = { "usage.meter.ai_research": "AI 研究", "usage.meter.web_search": "搜尋", "usage.meter.ai_image": "AI 生圖", - "usage.meter.barAria": "{label} {count} 次 {credits} 點,上限 {cap}", + "usage.meter.barAria": "{label} 已用 {credits} 點/上限 {cap} 點({count} 次)", "usage.ledger.costAria": "消耗 {n} 點", "usage.event.keyMode.platform": "平台點數", "usage.event.keyMode.byok": "自備 Key", @@ -1168,6 +1201,24 @@ export const zhTW: MessageDict = { "insights.postStats": "瀏覽 {views} · 讚 {likes} · 回 {replies}", "insights.openThreads": "開啟 Threads", "insights.zeroPct": "0%", + "insights.panelHint": "依「我的貼文」同步數據聚合本月成效與近月趨勢", + "insights.lastSynced": "上次同步 {time}", + "insights.neverSynced": "尚未同步", + "insights.emptyTitle": "尚無貼文數據", + "insights.emptyDesc": "先按「同步貼文」從 Threads 拉入你的貼文與成效,再看月比圖與分析。", + "insights.syncDone": "已同步 {n} 則貼文,成效已更新", + "insights.syncFail": "同步失敗", + "insights.loadFail": "載入貼文失敗", + "insights.zeroViewsHint": "已有貼文但瀏覽多為 0:可能 Insights 權限不足或尚未產出,可再按一次同步。", + "insights.postsInMonth": "{n} 則", + "insights.kpiForMonth": "{label} 指標", + "insights.topPostsOfMonth": "{label} · 表現較佳貼文", + "insights.noPostsInMonth": "{label} 尚無貼文", + "insights.pastMonthEmptyHint": "該月沒有已同步的貼文(過去月份只會自動補抓一次)。可手動同步或改選其他月。", + "insights.pastBackfillDone": "已補抓歷史貼文 {n} 則(過去月份只抓一次)", + "insights.autoRefreshDone": "本月成效已更新({n} 則)", + "insights.noDataNoAnalysis": "{label} 沒有貼文數據,不產生結論。", + "insights.noAnalysisYet": "{label} 尚無可寫的結論(需有已同步貼文)。", "plays.tabOwn": "我的貼文", "plays.tabLink": "Threads 連結", @@ -1236,6 +1287,7 @@ export const zhTW: MessageDict = { "inspire.trendsLabel": "話題靈感", "inspire.trendsHint": "網搜彙整,非官方熱搜 · 找靈感會扣搜尋點數", "inspire.trendsSeed": "示意", + "inspire.topicSeed": "想寫跟「{topic}」有關的 Threads,幫我發想開場與角度。", "inspire.trendsEmpty": "點「找靈感」才會搜尋(扣點)", "inspire.refreshConfirm": "找靈感會消耗 1 次「搜尋」點數,確定?", "inspire.refreshOk": "已更新 {n} 則話題靈感", @@ -1521,6 +1573,11 @@ export const zhTW: MessageDict = { "usage.currentPlan": "目前方案", "usage.planMeta": "/月 · 每月 {n} 點額度", "usage.changePlan": "變更方案", + "usage.upgradePlan": "升級方案", + "usage.warn.unlimitedOver": "本月已用 {used} 點(方案 {cap},不擋額度,仍可繼續)。", + "usage.warn.exhausted": "本月點數已用完。可升級方案或等待下月重置。", + "usage.warn.high": "本月已用 {pct}% 點數。", + "usage.warn.meterNear": "{label} 接近單項上限({credits}/{cap} 點)。", "usage.usedThisMonth": "本月已用", "usage.remainLabel": "剩餘", "usage.ledgerToggle": "使用紀錄", @@ -1664,6 +1721,9 @@ export const en: MessageDict = { "api.err.404001": "Not found", "api.err.404002": "This email is not registered", "api.err.409001": "This email is already registered", + "api.err.402001": "Monthly platform credits exhausted. Upgrade or wait for next month.", + "usage.err.meterCap": "This feature hit its monthly credit cap. Try another feature or upgrade.", + "usage.err.platformCapacity": "Platform AI/search is busy or rate-limited. Try again later, or add your own API key (BYOK) in Settings.", "api.err.500000": "Server error. Please try again later.", "api.err.timeoutAI": "AI timed out. Shorten structure notes, or pick a faster model in Settings", "api.err.501000": "This feature is not implemented yet", @@ -1736,14 +1796,14 @@ export const en: MessageDict = { "settings.localeCurrency": "Language & currency", "settings.locale": "Interface language", "settings.currency": "Display currency", - "settings.currencyHint": "", + "settings.currencyHint": "Display only; plan billing stays in TWD.", "settings.localeSaved": "Language and currency updated", "settings.appearance": "Appearance", "settings.theme": "Theme", "settings.themeLight": "Light", "settings.themeDark": "Dark", "settings.themeSystem": "System", - "settings.themeHint": "", + "settings.themeHint": "Light, dark, or follow system appearance.", "settings.themeSaved": "Theme updated", "settings.themeToLight": "Switch to light", "settings.themeToDark": "Switch to dark", @@ -1777,14 +1837,14 @@ export const en: MessageDict = { "settings.threadsGoCrew": "Open Crew to connect", "settings.member": "Account & sign-in", "settings.usageCard": "AI / search quota", - "settings.usageCardHint": "", + "settings.usageCardHint": "Platform credits and plan caps; BYOK does not use platform credits.", "settings.viewUsage": "View usage", "settings.editProfile": "Edit profile", "settings.mockLogin": "demo@harbor.local / demo", - "settings.memberHint": "", + "settings.memberHint": "Sign-in account, display name, and notification prefs.", "usage.title": "Usage & plans", - "usage.desc": "", + "usage.desc": "See platform credits this month, per-feature usage, and change plans.", "usage.creditsUsed": "Credits used this month", "usage.remaining": "{n} left", "usage.percentUsed": "{n}% used", @@ -1796,17 +1856,23 @@ export const en: MessageDict = { "usage.perMonth": "credits / mo", "usage.ledger": "Recent usage", "usage.emptyTitle": "No usage this month", - "usage.emptyDesc": "", - "usage.planNote": "", + "usage.emptyDesc": "After you create, patrol, or generate images, usage events show up here.", + "usage.planNote": "Resets each calendar month; unused credits do not roll over.", "usage.switched": "Switched to {name}", "usage.meter.times": "{count} runs · {credits} credits", - "usage.meter.cap": "Soft cap {count}/{cap} runs", - "usage.plan.free.blurb": "Trial and light personal use", + "usage.meter.timesShort": "{n} runs", + "usage.meter.pt": "pt", + "usage.meter.over": "over", + "usage.meter.locked": "locked", + "usage.meter.cap": "Cap {credits}/{cap} credits", + "usage.side.aiCredits": "AI credits used (copy + research + image)", + "usage.side.searchCredits": "Search credits used", + "usage.plan.free.blurb": "Enough to try the full creation flow", "usage.plan.starter.blurb": "Small teams posting and patrolling daily", "usage.plan.pro.blurb": "Multi-account, heavy AI and research", "profile.title": "Profile", - "profile.desc": "", + "profile.desc": "Manage display name, avatar, and password.", "profile.accountStatus": "Account status", "profile.basic": "Basics", "profile.avatar": "Avatar", @@ -1912,7 +1978,7 @@ export const en: MessageDict = { "invite.err.codeSelf": "You can’t use your own code", "admin.users.title": "Islanders", - "admin.users.desc": "", + "admin.users.desc": "Manage islander accounts, roles, plans, and unlimited flags.", "admin.users.needAdmin": "Admin access required", "admin.users.list": "Islanders · {n}", "admin.users.detail": "Islander detail", @@ -1954,35 +2020,47 @@ export const en: MessageDict = { "crew.msg.refreshFail": "Extend failed (reconnect if token is invalid)", "crew.msg.oauthOk": "Threads connected; token renew scheduled ~day 30 (see Jobs)", "crew.msg.oauthFail": "OAuth connection failed", + "crew.msg.oauthUrlFail": "Could not get authorize URL. Try again or check platform Threads settings.", + "crew.msg.deleted": "Removed @{user}", "crew.confirmDelete": "Delete account @{user}?\nIt can no longer be used as lead / cast.", "today.findTopic": "Find topics", + "today.refreshTopics": "Refresh topics", + "today.reload": "Reload", + "today.loadFail": "Could not load Today", + "today.trendsFail": "Could not refresh topics (quota or search not set)", + "today.syncPosts": "Sync own posts", + "today.syncPostsFail": "Could not sync posts", + "today.needAccount": "Connect a Threads account first", "today.pendingReplies": "Pending replies", "today.pendingRepliesN": "Pending · {n}", - "today.newThread": "New thread", + "today.newThread": "Compose", "today.metricsAria": "Today metrics", "today.metric.pending": "Pending", "today.metric.pendingHint": "Patrol queue", "today.metric.doneGoal": "Done / goal", + "today.metric.doneGoalHint": "Patrol completions today / goal (mark published counts)", "today.metric.sentToday": "Sent today", "today.metric.running": "{n} in progress", "today.metric.sentDone": "Completed sends", "today.metric.failed": "Send issues", "today.metric.needAction": "Needs action", "today.metric.ok": "All good", - "today.pending.title": "Pending replies · {n}", - "today.pending.empty": "Nothing pending", + "today.metric.mentions": "Mentions", + "today.metric.mentionsHint": "Pending mentions", + "today.pending.title": "Patrol pending · {n}", + "today.pending.empty": "Nothing pending — run a patrol", "today.goScout": "Go patrol", "today.pending.more": "{n} more →", "today.pending.handle": "Handle in patrol", "today.pending.start": "Start handling", "today.topics.title": "Find topics", - "today.topics.empty": "No topics yet", - "today.goStudio": "Go to Studio", + "today.topics.empty": "No topics yet — tap Refresh topics", + "today.goStudio": "Open inspire", "today.heat": "Heat {n}", "today.topicAngle": "Good opening angle", "today.moreInspire": "More inspiration", - "today.useTopic": "Start from topic", + "today.useTopic": "Brainstorm topic", "today.outbox.title": "Today's outbox", "today.outbox.empty": "No sends today. Try", "today.outbox.emptyMid": ", then check", @@ -1991,9 +2069,10 @@ export const en: MessageDict = { "today.badge.failed": "Failed", "today.badge.scheduling": "Scheduled", "today.badge.sending": "Sending", + "today.badge.drafted": "Drafted", "today.openOutbox": "Open outbox", "today.accounts.title": "Account performance", - "today.accounts.empty": "No stats yet", + "today.accounts.empty": "No stats yet — sync own posts", "today.postsCount": "{n} posts", "today.views": "Views", "today.likes": "Likes", @@ -2002,6 +2081,7 @@ export const en: MessageDict = { "today.viewPosts": "View posts", "today.manageAccounts": "Manage accounts", + "outbox.title": "Outbox", "outbox.tabsAria": "Outbox tabs", "outbox.tab.active": "Active", @@ -2303,7 +2383,7 @@ export const en: MessageDict = { "metrics.type.post": "Post", "jobs.title": "Jobs", - "jobs.desc": "Background jobs, including recurring ones (e.g. Threads token renew ~every 30 days). Finished / failed / cancelled jobs are auto-removed after about 2 days; you can also delete them manually.", + "jobs.desc": "Jobs in three tabs: active, scheduled, and history. Paginated so we never load everything at once.", "jobs.startDemo": "Create test job", "jobs.demoHint": "Requires worker; list auto-refreshes while jobs are active.", "jobs.demoLabel": "Demo test job", @@ -2328,6 +2408,14 @@ export const en: MessageDict = { "jobs.status.cancel_requested": "Cancel requested", "jobs.loadFail": "Could not load jobs", "jobs.empty": "No jobs yet", + "jobs.empty.active": "No running or ready-to-claim jobs", + "jobs.empty.recurring": "No scheduled / recurring jobs yet", + "jobs.empty.history": "No history yet (succeeded / failed / cancelled)", + "jobs.tabsAria": "Job categories", + "jobs.tab.active": "Active", + "jobs.tab.recurring": "Scheduled", + "jobs.tab.history": "History", + "jobs.recurringHint": "Future schedules (e.g. token renew in ~30 days). When due, they appear under Active.", "jobs.total": "{n} total", "jobs.showing": " · showing first {n}", "jobs.detail": "Details", @@ -2362,45 +2450,46 @@ export const en: MessageDict = { "plan.cta.downgrade": "Downgrade", "plan.cta.switch": "Switch", - "plan.free.headline": "Trial and light personal use", - "plan.free.bullet1": "{n} AI/search credits per month", - "plan.free.bullet2": "Full product: Studio, Patrol, Outbox", - "plan.free.bullet3": "Good for learning the flow", - "plan.free.bullet4": "Upgrade anytime", + "plan.free.headline": "Enough to try the full product", + "plan.free.bullet1": "{n} credits / month (~2–3 weeks light use)", + "plan.free.bullet2": "Full product: Studio, Patrol, Outbox, images", + "plan.free.bullet3": "Feel the value, then upgrade to Starter", + "plan.free.bullet4": "When platform is busy, add your own key", "plan.free.right1": "Full access to accounts, Studio, Patrol, Outbox, jobs, and inspiration.", - "plan.free.right2": "Fixed monthly credits for copy, research, search, and images.", - "plan.free.right3": "After the cap, wait for next month or upgrade (unless an admin turns off limits).", + "plan.free.right2": "Enough credits for real copy, search, and a few images—not a hollow demo.", + "plan.free.right3": "After the cap, upgrade to Starter—or set BYOK so you don't use platform credits.", "plan.free.quota1": "{n} credits allocated each month.", - "plan.free.quota2": "Reference: copy {copy}, research {research}, search {search}, image {image} runs.", "plan.free.note1": "No payment required; confirms immediately.", "plan.free.note2": "Invoice rules will be defined when billing goes live.", "plan.starter.headline": "Small teams posting and patrolling daily", - "plan.starter.bullet1": "{n} credits / month (about 6× Free)", + "plan.starter.bullet1": "{n} credits / month (about 5× Free)", "plan.starter.bullet2": "Steady posting, replies, and patrol", - "plan.starter.bullet3": "Fits a 1–3 person cadence", + "plan.starter.bullet3": "Fits a 1–3 person cadence · main paid tier", "plan.starter.bullet4": "Takes effect right after payment", "plan.starter.right1": "After payment this account becomes Starter; the month uses the new quota.", "plan.starter.right2": "Credits support regular posts, reply drafts, and scheduled patrol.", - "plan.starter.right3": "Same features as Free; you buy more headroom.", + "plan.starter.right3": "Same features as Free; you buy more headroom. Best starting paid plan.", "plan.starter.quota1": "{n} credits / month · {price}.", - "plan.starter.quota2": "Reference: copy {copy}, research {research}, search {search}, image {image} runs.", "plan.starter.note1": "Plan changes only after successful payment.", "plan.starter.note2": "Resets on calendar month; unused credits do not roll over.", "plan.pro.headline": "Multi-account, heavy AI and research", - "plan.pro.bullet1": "{n} credits / month (about 4× Starter)", - "plan.pro.bullet2": "High-volume copy, research, and images", + "plan.pro.bullet1": "{n} credits / month (about 3× Starter)", + "plan.pro.bullet2": "High-volume copy, research, and images · heavy ceiling", "plan.pro.bullet3": "Built for agencies and multi-brand work", "plan.pro.bullet4": "Takes effect right after payment", "plan.pro.right1": "After payment this account becomes Pro; the month uses Pro quota.", "plan.pro.right2": "Fits multi-account replies and deep research with less risk of running out mid-month.", - "plan.pro.right3": "Same features; you buy capacity and pace.", + "plan.pro.right3": "Same features; you buy capacity. Beyond this, use BYOK—still works when platform is limited.", "plan.pro.quota1": "{n} credits / month · {price}.", - "plan.pro.quota2": "Reference: copy {copy}, research {research}, search {search}, image {image} runs.", "plan.pro.note1": "Failed payment does not change your plan.", "plan.pro.note2": "After payment you can find receipts in billing history.", + "plan.quota2": "Per-feature credits: copy {copy} (~{copyCalls} uses), research {research} (~{researchCalls}), search {search} (~{searchCalls}), image {image} (~{imageCalls} images).", + "plan.softCapsLine": "Credits: copy {copy} · research {research} · search {search} · image {image}", + "plan.approxCallsLine": "About {copyCalls} copy · {researchCalls} research · {searchCalls} search · {imageCalls} images", + "checkout.title": "Confirm plan", "checkout.pickFirst": "Please choose a plan first.", "checkout.viewPlans": "View plans", @@ -2435,6 +2524,7 @@ export const en: MessageDict = { "usage.widget.monthUsage": "This month", "usage.widget.remaining": "{n} credits left", "usage.widget.leftShort": "{n} left", + "usage.widget.usedOfCap": "{used}/{cap}", "usage.widget.overShort": "Over", "usage.widget.upgradeShort": "Upgrade", "usage.widget.upgrade": "Upgrade", @@ -2447,7 +2537,7 @@ export const en: MessageDict = { "usage.meter.ai_research": "AI research", "usage.meter.web_search": "Search", "usage.meter.ai_image": "AI image", - "usage.meter.barAria": "{label} {count} runs, {credits} credits, cap {cap}", + "usage.meter.barAria": "{label} {credits}/{cap} credits ({count} runs)", "usage.ledger.costAria": "Used {n} credits", "usage.event.keyMode.platform": "Platform credits", "usage.event.keyMode.byok": "Your own key", @@ -2765,6 +2855,24 @@ export const en: MessageDict = { "insights.postStats": "Views {views} · likes {likes} · replies {replies}", "insights.openThreads": "Open Threads", "insights.zeroPct": "0%", + "insights.panelHint": "Aggregates metrics from synced “My posts”", + "insights.lastSynced": "Last sync {time}", + "insights.neverSynced": "Not synced yet", + "insights.emptyTitle": "No post data yet", + "insights.emptyDesc": "Tap “Sync posts” to pull your Threads posts and insights, then review charts and analysis.", + "insights.syncDone": "Synced {n} posts · insights updated", + "insights.syncFail": "Sync failed", + "insights.loadFail": "Failed to load posts", + "insights.zeroViewsHint": "Posts found but views are mostly 0 — Insights scope may be missing, or stats not ready. Try sync again.", + "insights.postsInMonth": "{n} posts", + "insights.kpiForMonth": "{label} metrics", + "insights.topPostsOfMonth": "{label} · top posts", + "insights.noPostsInMonth": "No posts in {label}", + "insights.pastMonthEmptyHint": "No synced posts for this month (past months are fetched once). Sync manually or pick another month.", + "insights.pastBackfillDone": "Backfilled {n} posts (past months fetch once)", + "insights.autoRefreshDone": "Current month refreshed ({n} posts)", + "insights.noDataNoAnalysis": "No posts in {label} — no analysis.", + "insights.noAnalysisYet": "No analysis for {label} yet (need synced posts).", "plays.tabOwn": "My posts", "plays.tabLink": "Threads link", @@ -2833,6 +2941,7 @@ export const en: MessageDict = { "inspire.trendsLabel": "Topic ideas", "inspire.trendsHint": "Web-sourced ideas · costs a search credit", "inspire.trendsSeed": "sample", + "inspire.topicSeed": "I want a Threads post about “{topic}”. Help me with openings and angles.", "inspire.trendsEmpty": "Tap “Find ideas” to search (uses credits)", "inspire.refreshConfirm": "Finding ideas costs 1 search credit. Continue?", "inspire.refreshOk": "Updated {n} topic ideas", @@ -3118,6 +3227,11 @@ export const en: MessageDict = { "usage.currentPlan": "Current plan", "usage.planMeta": "/ mo · {n} credits monthly", "usage.changePlan": "Change plan", + "usage.upgradePlan": "Upgrade plan", + "usage.warn.unlimitedOver": "Used {used} credits this month (plan {cap}; limits off — you can continue).", + "usage.warn.exhausted": "Monthly credits are used up. Upgrade or wait for next month.", + "usage.warn.high": "You've used {pct}% of monthly credits.", + "usage.warn.meterNear": "{label} is near its cap ({credits}/{cap} credits).", "usage.usedThisMonth": "Used this month", "usage.remainLabel": "Remaining", "usage.ledgerToggle": "Usage log", diff --git a/apps/web/src/lib/mockInspireChat.ts b/apps/web/src/lib/mockInspireChat.ts index 6c07cae..6cae6e0 100644 --- a/apps/web/src/lib/mockInspireChat.ts +++ b/apps/web/src/lib/mockInspireChat.ts @@ -58,37 +58,19 @@ export function resolveInspireContext( return { roles, snippets, trends, persona, brand, labels }; } -function toneLead(ctx: ResolvedInspireContext): string { - if (ctx.roles.some((r) => /朋友|私訊/.test(r))) return "懂你…"; - if (ctx.roles.some((r) => /鉤子|短句/.test(r))) return "先講結論:"; - if (ctx.roles.some((r) => /真實|反業配/.test(r))) return "講實話——"; - if (ctx.persona?.style?.draft?.hooks) { - return ctx.persona.style.draft.hooks.slice(0, 24); - } - if (ctx.persona?.name) return `以「${ctx.persona.name}」的口吻:`; - return ""; -} - +/** mock 產文:依素材重寫即可,不 fortify 固定開頭 */ function buildDraftBody(userText: string, ctx: ResolvedInspireContext): string { const topic = userText.trim() || ctx.trends[0] || ctx.brand?.display_name || "日常小事"; - const lead = toneLead(ctx); const lines: string[] = []; - if (lead) lines.push(lead); - if (ctx.trends.length) { lines.push(`最近大家在聊 ${ctx.trends[0]!.replace(/^主題:/, "").slice(0, 40)},`); } - const personaBit = - ctx.persona?.brief || - ctx.persona?.style?.draft?.tone || - ctx.persona?.voice || - ""; const brandBit = ctx.brand ? `(想到 ${ctx.brand.display_name}${ctx.brand.brief ? `:${ctx.brand.brief.slice(0, 40)}` : ""})` : ""; @@ -96,11 +78,13 @@ function buildDraftBody(userText: string, ctx: ResolvedInspireContext): string { if (/改短|短一點|精簡/.test(userText)) { lines.push(`${topic.slice(0, 60)}——有人也卡在這嗎?`); } else if (/更口語|隨便|碎念/.test(userText)) { - lines.push(`欸所以 ${topic.slice(0, 50)} 這件事,我真的想問你們怎麼處理的。`); + lines.push(`所以 ${topic.slice(0, 50)} 這件事,我真的想問你們怎麼處理的。`); } else { - lines.push( - `${topic.slice(0, 80)}${personaBit ? `。${personaBit.slice(0, 48)}` : ""}${brandBit}`, - ); + if (!ctx.trends.length) { + lines.push(`${topic.slice(0, 80)}${brandBit}`); + } else if (brandBit) { + lines.push(brandBit); + } lines.push("我自己目前比較在意「實際用起來」而不是包裝怎麼寫。"); } @@ -111,11 +95,6 @@ function buildDraftBody(userText: string, ctx: ResolvedInspireContext): string { lines.push("你們最近有類似經驗嗎?"); } - if (ctx.snippets.some((s) => /誇大|療效|禁止/.test(s))) { - // 語氣收斂:不另加誇大句 - } - - // 短貼感 let body = lines.filter(Boolean).join("\n"); if (ctx.snippets.some((s) => /短貼|180|280/.test(s)) && body.length > 200) { body = body.slice(0, 180) + "…"; diff --git a/apps/web/src/lib/ownPostsSyncGate.ts b/apps/web/src/lib/ownPostsSyncGate.ts new file mode 100644 index 0000000..208d44d --- /dev/null +++ b/apps/web/src/lib/ownPostsSyncGate.ts @@ -0,0 +1,132 @@ +import type { OwnPost } from "../domain/types"; +import { KEYS } from "../data/mock/keys"; +import { readJson, writeJson } from "./storage"; + +/** 本月成效:距離上次真 API sync 不足此間隔則不重打(5 分鐘) */ +export const OWN_POSTS_CURRENT_TTL_MS = 5 * 60 * 1000; + +type AccountSyncMeta = { + /** 上次成功呼叫 sync API 的時間(ms) */ + lastSyncMs: number; + /** 是否已為「過去月份缺資料」補抓過一次 */ + pastOnceDone: boolean; +}; + +type MetaStore = Record; + +/** accountId → in-flight sync promise(singleflight) */ +const inflight = new Map>(); + +function loadStore(): MetaStore { + return readJson(KEYS.ownPostsSyncMeta, {} as MetaStore); +} + +function saveStore(store: MetaStore): void { + writeJson(KEYS.ownPostsSyncMeta, store); +} + +export function getOwnPostsSyncMeta(accountId: string): AccountSyncMeta { + const row = loadStore()[accountId]; + return { + lastSyncMs: row?.lastSyncMs ?? 0, + pastOnceDone: Boolean(row?.pastOnceDone), + }; +} + +function patchMeta(accountId: string, patch: Partial): void { + const store = loadStore(); + const prev = store[accountId] || { lastSyncMs: 0, pastOnceDone: false }; + store[accountId] = { ...prev, ...patch }; + saveStore(store); +} + +export function isCurrentSyncFresh(accountId: string, now = Date.now()): boolean { + const { lastSyncMs } = getOwnPostsSyncMeta(accountId); + return lastSyncMs > 0 && now - lastSyncMs < OWN_POSTS_CURRENT_TTL_MS; +} + +export function isPastOnceDone(accountId: string): boolean { + return getOwnPostsSyncMeta(accountId).pastOnceDone; +} + +export type OwnPostsSyncReason = + /** 使用者手動同步:一定打 API */ + | "manual" + /** 過去月份缺資料:整帳只補抓一次 */ + | "past-once" + /** 本月定期刷新(5 分鐘) */ + | "current-ttl"; + +export type OwnPostsSyncResult = { + posts: OwnPost[]; + /** 是否真的打了 sync API(false = 走 list 或共用 in-flight 已完成後的讀取) */ + fetched: boolean; + /** 因 TTL/past-once 已完成而跳過 */ + skipped: boolean; + reason: OwnPostsSyncReason; +}; + +type Ops = { + list: (accountId: string) => Promise; + sync: (accountId: string) => Promise; +}; + +/** + * 以 accountId 為 key 的 singleflight sync。 + * - manual:一定呼叫 sync(並發共用同一個 promise) + * - past-once:若已 pastOnceDone 則只 list + * - current-ttl:若 5 分鐘內已 sync 則只 list + */ +export async function syncOwnPostsGated( + accountId: string, + ops: Ops, + reason: OwnPostsSyncReason, +): Promise { + if (!accountId) { + return { posts: [], fetched: false, skipped: true, reason }; + } + + const existing = inflight.get(accountId); + if (existing) { + const posts = await existing; + return { posts, fetched: true, skipped: false, reason }; + } + + if (reason === "past-once" && isPastOnceDone(accountId)) { + const posts = await ops.list(accountId); + return { posts, fetched: false, skipped: true, reason }; + } + + if (reason === "current-ttl" && isCurrentSyncFresh(accountId)) { + const posts = await ops.list(accountId); + return { posts, fetched: false, skipped: true, reason }; + } + + const promise = (async () => { + try { + const posts = await ops.sync(accountId); + const prev = getOwnPostsSyncMeta(accountId); + patchMeta(accountId, { + lastSyncMs: Date.now(), + // 手動或過去月份補抓 → 標記 past-once 完成;TTL 刷新不改旗標 + pastOnceDone: + prev.pastOnceDone || reason === "past-once" || reason === "manual", + }); + return posts; + } finally { + inflight.delete(accountId); + } + })(); + + inflight.set(accountId, promise); + const posts = await promise; + return { posts, fetched: true, skipped: false, reason }; +} + +/** 過去月份(不含本月)是否仍全空——用來決定是否觸發 past-once */ +export function needsPastMonthBackfill( + months: Array<{ key: string; posts: number }>, + currentMonthKey: string, +): boolean { + return months.some((m) => m.key !== currentMonthKey && m.posts <= 0); +} diff --git a/apps/web/src/lib/planRights.ts b/apps/web/src/lib/planRights.ts index e9a81ee..3288da5 100644 --- a/apps/web/src/lib/planRights.ts +++ b/apps/web/src/lib/planRights.ts @@ -1,4 +1,4 @@ -import { PLANS, type PlanId } from "./usageMeter"; +import { DEFAULT_CREDIT_COST, PLANS, type PlanId } from "./usageMeter"; export type PlanRightsCopy = { headline: string; @@ -9,19 +9,45 @@ export type PlanRightsCopy = { /** 結帳頁:額度 */ quota: string[]; notes: string[]; + /** 比價卡/頂欄短行:分項點數 + 約當次數 */ + softCapsLine: string; + /** 約當次數短行(行銷可讀) */ + approxCallsLine: string; }; type TFn = (key: string, params?: Record) => string; -/** 購買/比價用文案(用量首頁不堆字)— 由 i18n keys 組成 */ -export function getPlanRights(id: PlanId, t: TFn): PlanRightsCopy { +/** soft_caps 是點數;約當次數 = floor(點 / 單次扣點) */ +export function planCapParams(id: PlanId, priceLabel?: string) { const p = PLANS[id]; - const caps = { - copy: p.soft_caps.ai_copy, - research: p.soft_caps.ai_research, - search: p.soft_caps.web_search, - image: p.soft_caps.ai_image, + const copy = p.soft_caps.ai_copy; + const research = p.soft_caps.ai_research; + const search = p.soft_caps.web_search; + const image = p.soft_caps.ai_image; + return { + n: p.monthly_credits, + price: priceLabel ?? p.price_label, + copy, + research, + search, + image, + copyCalls: Math.floor(copy / DEFAULT_CREDIT_COST.ai_copy), + researchCalls: Math.floor(research / DEFAULT_CREDIT_COST.ai_research), + searchCalls: Math.floor(search / DEFAULT_CREDIT_COST.web_search), + imageCalls: Math.floor(image / DEFAULT_CREDIT_COST.ai_image), }; +} + +/** 購買/比價用文案(用量首頁不堆字)— 由 i18n keys 組成 */ +export function getPlanRights( + id: PlanId, + t: TFn, + /** 語系化價格;未傳則用 PLANS.price_label(固定 NT$…/月) */ + formatPrice?: (amountTwd: number) => string, +): PlanRightsCopy { + const p = PLANS[id]; + const price = formatPrice ? formatPrice(p.price_twd) : p.price_label; + const caps = planCapParams(id, price); return { headline: t(`plan.${id}.headline`), bullets: [ @@ -36,10 +62,12 @@ export function getPlanRights(id: PlanId, t: TFn): PlanRightsCopy { t(`plan.${id}.right3`), ], quota: [ - t(`plan.${id}.quota1`, { n: p.monthly_credits, price: p.price_label }), - t(`plan.${id}.quota2`, caps), + t(`plan.${id}.quota1`, { n: p.monthly_credits, price }), + t("plan.quota2", caps), ], notes: [t(`plan.${id}.note1`), t(`plan.${id}.note2`)], + softCapsLine: t("plan.softCapsLine", caps), + approxCallsLine: t("plan.approxCallsLine", caps), }; } diff --git a/apps/web/src/lib/usageMeter.ts b/apps/web/src/lib/usageMeter.ts index d121262..6d7abe6 100644 --- a/apps/web/src/lib/usageMeter.ts +++ b/apps/web/src/lib/usageMeter.ts @@ -36,7 +36,12 @@ export type PlanDef = { price_twd: number; blurb: string; blurb_key: string; + /** 本月 platform 總點數配給 */ monthly_credits: number; + /** + * 各分項點數參考上限(單位=點,不是次數)。 + * 加總應等於 monthly_credits。 + */ soft_caps: Record; }; @@ -77,21 +82,41 @@ export const METER_META: Record< }, }; +/** + * 方案配額(2026-07 · 組合毛利設計) + * + * 產品漏斗: + * - Free:夠用、真能體驗完整流程 → 轉 Starter + * - Starter:日常付費主力 + * - Pro:重度/多帳 + * - 平台 key 限流或無容量時:僅 BYOK 可用(不扣平台點) + * + * 單位成本(platform,USD): + * - 文案 ~$0.004–0.008/次 · 搜尋 Exa ~$0.007 · 研究 ~$0.015 · 生圖 ~$0.02–0.04 + * - 安全單價 **$0.012/點**;TWD≈32 → Starter $18.4 / Pro $62.2 + * + * 組合毛利(目標 ≥50%):Free 可虧一點,付費扛回。 + * 例 5 Free 滿用 + 2 Starter 滿用 + 1 Pro 滿用: + * COGS ≈ 5×$1.44 + 2×$7.2 + $24 = $45.6 · 營收 $99 → 毛利 ~54% + * + * soft_caps = 分項點數硬上限,加總 = monthly_credits。 + */ export const PLANS: Record = { free: { id: "free", name: "Free", price_label: "NT$0/月", price_twd: 0, - blurb: "試用與個人輕量經營", + blurb: "夠用試用,體驗完整創作流程", blurb_key: "usage.plan.free.blurb", - monthly_credits: 80, + // 獲客可虧:滿用 ~$1.44;約 2~3 週輕度日常,用得出價值再升級 + monthly_credits: 120, soft_caps: { - ai_copy: 40, - ai_research: 8, - web_search: 30, - ai_image: 3, - }, + ai_copy: 60, // 60 次文案/回覆 + ai_research: 15, // 5 次研究 + web_search: 30, // 30 次搜尋 + ai_image: 15, // 3 張生圖 + }, // sum = 120 }, starter: { id: "starter", @@ -100,13 +125,15 @@ export const PLANS: Record = { price_twd: 590, blurb: "小團隊日常發文與海巡", blurb_key: "usage.plan.starter.blurb", - monthly_credits: 500, + // 滿用 COGS ≤ $7.2 → 單方案毛利 ~61%;轉付費主力 + // 約當:文案 300 · 研究 30 · 搜尋 150 · 生圖 12 + monthly_credits: 600, soft_caps: { - ai_copy: 250, - ai_research: 60, - web_search: 200, - ai_image: 20, - }, + ai_copy: 300, + ai_research: 90, + web_search: 150, + ai_image: 60, + }, // sum = 600 }, pro: { id: "pro", @@ -115,16 +142,19 @@ export const PLANS: Record = { price_twd: 1990, blurb: "多帳、重度 AI 與研究", blurb_key: "usage.plan.pro.blurb", + // 滿用 COGS ≤ $24 → 單方案毛利 ~61%;重度天花板 + // 約當:文案 1000 · 研究 100 · 搜尋 500 · 生圖 40 monthly_credits: 2000, soft_caps: { ai_copy: 1000, - ai_research: 250, - web_search: 800, - ai_image: 80, - }, + ai_research: 300, + web_search: 500, + ai_image: 200, + }, // sum = 2000 }, }; +/** 記帳點數/次(platform);與後端 DefaultCreditCost 對齊 */ export const DEFAULT_CREDIT_COST: Record = { ai_copy: 1, ai_research: 3, @@ -137,10 +167,13 @@ export type UsageMonthSummary = { uid: string; plan: PlanDef; unlimited: boolean; - /** 方案點數(僅 platform)— 進度條用 */ + /** + * 本月已用 platform 點數(不是方案總額)。 + * 方案配給請用 plan.monthly_credits 或 platform.credits_total。 + */ total_credits: number; remaining_credits: number; - /** 無限時為 0;有上限才算用掉比例(platform only) */ + /** 已用 / 方案配給 百分比(platform only) */ pct: number; /** @deprecated 平台 AI 次數;請用 platform / byok 分欄 */ ai_calls: number; @@ -417,8 +450,9 @@ function aggregateEvents( } for (const m of Object.keys(by_meter) as UsageMeter[]) { const row = by_meter[m]; + // soft_cap 是點數上限,進度用已用點數 row.pct = - row.soft_cap > 0 ? Math.min(999, Math.round((row.count / row.soft_cap) * 100)) : 0; + row.soft_cap > 0 ? Math.min(999, Math.round((row.credits / row.soft_cap) * 100)) : 0; } const remaining = Math.max(0, plan.monthly_credits - platformCredits); const pct = @@ -426,15 +460,17 @@ function aggregateEvents( ? Math.min(999, Math.round((platformCredits / plan.monthly_credits) * 100)) : 0; + // 狀態列用「點數」加總,與方案配給同一單位 let ai_calls = 0; let search_calls = 0; for (const m of Object.keys(METER_META) as UsageMeter[]) { - if (METER_META[m].group === "ai") ai_calls += by_meter[m].count; - else search_calls += by_meter[m].count; + if (METER_META[m].group === "ai") ai_calls += by_meter[m].credits; + else search_calls += by_meter[m].credits; } return { - total_credits: plan.monthly_credits, + // 與 UI/widget 一致:total_credits = 已用 + total_credits: platformCredits, remaining_credits: remaining, pct, by_meter, @@ -850,24 +886,52 @@ export function summarizeTenantUsage( }; } -export function usageWarning(summary: UsageMonthSummary): string | null { +type WarnTFn = (key: string, params?: Record) => string; + +/** 用量警示(需傳入 t,避免硬編碼語系) */ +export function usageWarning(summary: UsageMonthSummary, t?: WarnTFn): string | null { + const used = summary.platform?.credits_used ?? summary.total_credits; + // 上限永遠跟方案表,避免舊 API 的 credits_total 造成 120 文案 / 80 上限 + const cap = summary.plan.monthly_credits; + const pct = + summary.pct > 0 + ? summary.pct + : cap > 0 + ? Math.round((used / cap) * 100) + : 0; + const tr = + t ?? + ((key: string, params?: Record) => { + // fallback 中文(測試/無 i18n 時) + const fb: Record = { + "usage.warn.unlimitedOver": `本月已用 ${params?.used} 點(方案 ${params?.cap},不擋額度,仍可繼續)。`, + "usage.warn.exhausted": "本月點數已用完。可升級方案或等待下月重置。", + "usage.warn.high": `本月已用 ${params?.pct}% 點數。`, + "usage.warn.meterNear": `${params?.label} 接近單項上限(${params?.credits}/${params?.cap} 點)。`, + }; + return fb[key] ?? key; + }); // 不擋額度:只提示已超出,不說「用完」 if (summary.unlimited) { - if (summary.total_credits > summary.plan.monthly_credits) { - return `本月已用 ${summary.total_credits} 點(方案 ${summary.plan.monthly_credits},不擋額度,仍可繼續)。`; + if (used > cap) { + return tr("usage.warn.unlimitedOver", { used, cap }); } return null; } - if (summary.pct >= 100) { - return "本月點數已用完。可升級方案或等待下月重置。"; + if (pct >= 100) { + return tr("usage.warn.exhausted"); } - if (summary.pct >= 80) { - return `本月已用 ${summary.pct}% 點數。`; + if (pct >= 80) { + return tr("usage.warn.high", { pct }); } for (const m of Object.keys(METER_META) as UsageMeter[]) { const row = summary.by_meter[m]; if (row.pct >= 90) { - return `${METER_META[m].label} 接近單項上限(${row.count}/${row.soft_cap} 次)。`; + return tr("usage.warn.meterNear", { + label: t ? t(`usage.meter.${m}`) : METER_META[m].label, + credits: row.credits, + cap: row.soft_cap, + }); } } return null; diff --git a/apps/web/src/pages/CrewPage.tsx b/apps/web/src/pages/CrewPage.tsx index 90b4e09..35859b1 100644 --- a/apps/web/src/pages/CrewPage.tsx +++ b/apps/web/src/pages/CrewPage.tsx @@ -107,11 +107,11 @@ export function CrewPage() { const oauth = q.get("oauth"); if (!oauth) return; if (oauth === "ok") { - setMessage(t("crew.msg.oauthOk") || "Threads 帳號已連線"); + setMessage(t("crew.msg.oauthOk")); void load(); refresh(); } else { - setMessage(q.get("msg") || t("crew.msg.oauthFail") || "OAuth 失敗"); + setMessage(q.get("msg") || t("crew.msg.oauthFail")); } // 清 query,避免刷新重複提示 const url = new URL(window.location.href); @@ -129,7 +129,7 @@ export function CrewPage() { window.location.href = authorize_url; return; } - throw new Error(t("crew.msg.oauthFail") || "OAuth 無法取得授權網址"); + throw new Error(t("crew.msg.oauthUrlFail")); } catch (e) { setMessage(e instanceof Error ? e.message : t("crew.msg.refreshFail")); } finally { @@ -145,7 +145,7 @@ export function CrewPage() { setMessage(""); try { await repos.accounts.remove(acc.id); - setMessage(t("crew.msg.deleted") || `已移除 @${acc.username}`); + setMessage(t("crew.msg.deleted", { user: acc.username })); refresh(); await load(); } catch (e) { diff --git a/apps/web/src/pages/InsightsPage.tsx b/apps/web/src/pages/InsightsPage.tsx index 7b70372..6d36658 100644 --- a/apps/web/src/pages/InsightsPage.tsx +++ b/apps/web/src/pages/InsightsPage.tsx @@ -1,497 +1,14 @@ -import { useEffect, useMemo, useState } from "react"; -import { Link } from "react-router-dom"; import { PageHeader } from "../components/layout/PageHeader"; -import { Badge, Button, Card, EmptyState, Select } from "../components/ui"; -import { useData, useRepos } from "../data/DataContext"; -import type { OwnPost, ThreadsAccount } from "../domain/types"; -import { - buildAccountInsights, - ensureInsightHistory, - fmtDelta, - fmtEngRate, - monthKeyFromDate, - type AccountInsightsReport, - type AccountInsightsSnapshot, - type MonthBucket, -} from "../lib/accountInsights"; -import { formatLocalDateTime } from "../lib/time"; import { useI18n } from "../i18n/I18nContext"; +import { InsightsPanel } from "./studio/InsightsPanel"; -function DeltaBadge({ value }: { value: number | null }) { - const { t } = useI18n(); - if (value == null) return {t("common.dash")}; - if (value > 0) return {fmtDelta(value)}; - if (value < 0) return {fmtDelta(value)}; - return {t("insights.zeroPct")}; -} - -function MonthBars({ - months, - metric, - selectedKey, - onSelect, -}: { - months: MonthBucket[]; - metric: "views" | "likes" | "replies" | "posts"; - selectedKey: string; - onSelect: (monthKey: string) => void; -}) { - const { t, locale } = useI18n(); - const max = Math.max(1, ...months.map((m) => m[metric])); - const labels: Record = { - views: t("insights.metricViews"), - likes: t("insights.metricLikes"), - replies: t("insights.metricReplies"), - posts: t("insights.metricPostsFull"), - }; - const loc = locale === "en" ? "en-US" : "zh-TW"; - return ( -
        -

        - {t("insights.barsLabel", { metric: labels[metric], n: months.length })} - - {t("insights.clickBar")} - -

        -
        - {months.map((m) => { - const h = Math.round((m[metric] / max) * 100); - const selected = m.key === selectedKey; - const valStr = m[metric].toLocaleString(loc); - return ( - - ); - })} -
        -
        - ); -} - -function Sparkline({ - months, - metric, - selectedKey, - onSelect, -}: { - months: MonthBucket[]; - metric: "views" | "likes"; - selectedKey: string; - onSelect: (monthKey: string) => void; -}) { - const { t } = useI18n(); - const w = 280; - const h = 56; - const pad = 4; - const vals = months.map((m) => m[metric]); - const max = Math.max(1, ...vals); - const min = Math.min(...vals, 0); - const span = Math.max(1, max - min); - const pts = vals.map((v, i) => { - const x = pad + (i * (w - pad * 2)) / Math.max(1, vals.length - 1); - const y = h - pad - ((v - min) / span) * (h - pad * 2); - return `${x},${y}`; - }); - const poly = pts.join(" "); - return ( - - - {vals.map((v, i) => { - const x = pad + (i * (w - pad * 2)) / Math.max(1, vals.length - 1); - const y = h - pad - ((v - min) / span) * (h - pad * 2); - const m = months[i]!; - const selected = m.key === selectedKey; - return ( - onSelect(m.key)}> - - - - ); - })} - - ); -} - +/** 獨立路由 /app/insights(今日頁捷徑);創作分頁內嵌同一面板 */ export function InsightsPage() { - const repos = useRepos(); - const { tick, refresh } = useData(); - const { t, locale } = useI18n(); - const numLoc = locale === "en" ? "en-US" : "zh-TW"; - const [accounts, setAccounts] = useState([]); - const [accountId, setAccountId] = useState(""); - const [posts, setPosts] = useState([]); - const [metric, setMetric] = useState<"views" | "likes" | "replies" | "posts">("views"); - const [busy, setBusy] = useState(false); - /** 分析歷史:選中的月份 */ - const [analysisMonth, setAnalysisMonth] = useState(monthKeyFromDate()); - const [history, setHistory] = useState([]); - const [historyTick, setHistoryTick] = useState(0); - - useEffect(() => { - void (async () => { - const acc = await repos.accounts.list(); - const usable = acc.filter((a) => a.is_usable); - const list = usable.length ? usable : acc; - setAccounts(list); - setAccountId((cur) => cur || list[0]?.id || ""); - })(); - }, [repos, tick]); - - useEffect(() => { - void (async () => { - if (!accountId) { - setPosts([]); - return; - } - setPosts(await repos.ownPosts.list(accountId)); - setAnalysisMonth(monthKeyFromDate()); - })(); - }, [accountId, repos.ownPosts, tick]); - - useEffect(() => { - const onStore = () => refresh(); - window.addEventListener("harbor:store", onStore); - return () => window.removeEventListener("harbor:store", onStore); - }, [refresh]); - - const report: AccountInsightsReport | null = useMemo(() => { - if (!accountId) return null; - return buildAccountInsights(accountId, posts, 6); - }, [accountId, posts]); - - // 補齊/更新月度分析歷史 - useEffect(() => { - if (!report) { - setHistory([]); - return; - } - const list = ensureInsightHistory(report); - setHistory(list); - setAnalysisMonth((cur) => { - if (list.some((s) => s.month_key === cur)) return cur; - return list[0]?.month_key || monthKeyFromDate(); - }); - }, [report, historyTick]); - - const selectedSnap = useMemo( - () => history.find((s) => s.month_key === analysisMonth) || history[0] || null, - [history, analysisMonth], - ); - - async function syncPosts() { - if (!accountId) return; - setBusy(true); - try { - setPosts(await repos.ownPosts.sync(accountId)); - // 同步後重算本月分析(覆寫) - refresh(); - setHistoryTick((n) => n + 1); - // force 在 posts 更新後的下一輪 effect;此處 posts 尚未 set 完 - } finally { - setBusy(false); - } - } - - function selectMonth(key: string) { - setAnalysisMonth(key); - } - - const account = accounts.find((a) => a.id === accountId); - + const { t } = useI18n(); return ( <> - -
        - -
        - - - - -
        -
        - - {!accountId || !report ? ( - - - - } - /> - ) : ( -
        - {/* 本月總覽 */} -
        -
        - {t("insights.monthViews")} - - {report.current.views.toLocaleString(numLoc)} - - - {t("insights.vsPrev")} - -
        -
        - {t("insights.monthLikes")} - {report.current.likes} - - {t("insights.vsPrev")} - -
        -
        - {t("insights.monthReplies")} - {report.current.replies} - - {t("insights.vsPrev")} - -
        -
        - {t("insights.engRate")} - - {fmtEngRate(report.current.engagementRate)} - - - {t("insights.avgNear", { rate: fmtEngRate(report.avgEngagementRate) })} ·{" "} - - -
        -
        - - -
        - -
        - {(["views", "likes", "replies", "posts"] as const).map((m) => ( - - ))} -
        - - {selectedSnap ? ( -
        -
        - {t("insights.analysisOf", { label: selectedSnap.month_label })} - - {t("insights.producedAt", { time: formatLocalDateTime(selectedSnap.analyzed_at) })} - {selectedSnap.metrics.source === "estimate" ? t("insights.hasEstimate") : ""} - {selectedSnap.delta.views != null - ? t("insights.viewsVsPrev", { delta: fmtDelta(selectedSnap.delta.views) }) - : ""} - -
        - - {t("insights.statPosts")} {selectedSnap.metrics.posts} - - - {t("insights.statViews")}{" "} - {selectedSnap.metrics.views.toLocaleString(numLoc)} - - - {t("insights.statLikes")} {selectedSnap.metrics.likes} - - - {t("insights.statReplies")} {selectedSnap.metrics.replies} - - - {t("insights.engRate")}{" "} - {fmtEngRate(selectedSnap.metrics.engagementRate)} - -
        -
        -
        -

        - {t("insights.conclusions")} -

        -
          - {selectedSnap.analysis.map((line) => ( -
        • {line}
        • - ))} -
        -
        -
        -

        - {t("insights.recommendations")} -

        -
          - {selectedSnap.recommendations.map((line) => ( -
        • {line}
        • - ))} -
        -
        - {selectedSnap.top_highlights?.length ? ( -
        -

        - {t("insights.highlights")} -

        -
          - {selectedSnap.top_highlights.map((h) => ( -
        • {h}
        • - ))} -
        -
        - ) : null} -
        - - - - - - -
        -
        - ) : ( -

        - {t("insights.selectMonth")} -

        - )} -
        -
        - - - {report.topPosts.length === 0 ? ( - void syncPosts()} disabled={busy}> - {t("insights.syncPosts")} - - } - /> - ) : ( -
          - {report.topPosts.map((p, i) => ( -
        • -
          - - #{i + 1} - {p.topic_tag ? {p.topic_tag} : null} - - {t("insights.postStats", { - views: p.view_count.toLocaleString(numLoc), - likes: p.like_count, - replies: p.reply_count, - })} - - {formatLocalDateTime(p.published_at)} - - - {p.text.slice(0, 120)} - {p.text.length > 120 ? "…" : ""} - - {p.insight || p.formula_summary ? ( - - {p.insight || p.formula_summary} - - ) : null} - {p.permalink ? ( - - {t("insights.openThreads")} - - ) : null} -
          -
        • - ))} -
        - )} -
        -
        - )} + ); } diff --git a/apps/web/src/pages/JobsPage.tsx b/apps/web/src/pages/JobsPage.tsx index c1dec1c..ce8202f 100644 --- a/apps/web/src/pages/JobsPage.tsx +++ b/apps/web/src/pages/JobsPage.tsx @@ -1,10 +1,11 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { useAuth } from "../auth/AuthContext"; import { PageHeader } from "../components/layout/PageHeader"; -import { Badge, Button, Card, EmptyState } from "../components/ui"; +import { Badge, Button, Card, EmptyState, Pager } from "../components/ui"; import { useData, useRepos } from "../data/DataContext"; import { useJobLive } from "../data/JobLiveContext"; +import type { JobListTab } from "../data/repos"; import type { Job } from "../domain/types"; import { useI18n } from "../i18n/I18nContext"; import { useFormatApiError } from "../lib/apiErrors"; @@ -19,60 +20,72 @@ import { } from "../lib/jobLabels"; import { formatLocalDateTime } from "../lib/time"; -/** 首屏顯示筆數;太多時用「載入更多」而不是分頁 */ -const INITIAL = 15; -const STEP = 15; +const DEFAULT_PAGE_SIZE = 10; + +type TabDef = { id: JobListTab; labelKey: string }; + +const TABS: TabDef[] = [ + { id: "active", labelKey: "jobs.tab.active" }, + { id: "recurring", labelKey: "jobs.tab.recurring" }, + { id: "history", labelKey: "jobs.tab.history" }, +]; export function JobsPage() { const repos = useRepos(); const { member } = useAuth(); - const { refresh, tick } = useData(); - const { jobs: liveJobs, reload: reloadLive, revision } = useJobLive(); + const { refresh } = useData(); + const { revision, reload: reloadLive } = useJobLive(); const { t } = useI18n(); const formatApiError = useFormatApiError(); const canCreateDemo = can(member?.roles, Permission.JobsDemoCreate); + + const [tab, setTab] = useState("active"); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [jobs, setJobs] = useState([]); - const [visible, setVisible] = useState(INITIAL); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [deletingId, setDeletingId] = useState(null); const [message, setMessage] = useState(""); const [error, setError] = useState(""); - const load = async () => { + const load = useCallback(async () => { + setLoading(true); + setError(""); try { - await reloadLive(); - setJobs(await repos.jobs.list()); + const res = await repos.jobs.list({ tab, page, pageSize }); + setJobs(res.list); + setTotal(res.pagination.total); + // 若刪到當頁空了且不是第 1 頁,回退一頁 + if (res.list.length === 0 && page > 1 && res.pagination.total > 0) { + setPage((p) => Math.max(1, p - 1)); + } } catch (e) { setError(formatApiError(e, "jobs.loadFail")); + } finally { + setLoading(false); } - }; + }, [repos.jobs, tab, page, pageSize, formatApiError]); - // 優先用全域 live 列表(有進度就更新);fallback 本頁 list useEffect(() => { - if (liveJobs.length > 0 || revision > 0) { - setJobs(liveJobs); - return; - } void load(); - }, [liveJobs, revision, repos.jobs, tick]); // eslint-disable-line react-hooks/exhaustive-deps + }, [load]); + // 執行中分頁:跟全域 live revision 同步刷新(進度條) useEffect(() => { - const onStore = () => refresh(); - window.addEventListener("harbor:store", onStore); - return () => window.removeEventListener("harbor:store", onStore); - }, [refresh]); + if (tab !== "active" || revision <= 0) return; + void load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [revision, tab]); - const sorted = useMemo( - () => jobs.slice().sort((a, b) => b.updated_at - a.updated_at), - [jobs], - ); - - useEffect(() => { - setVisible((v) => Math.min(Math.max(INITIAL, v), Math.max(INITIAL, sorted.length))); - }, [sorted.length]); - - const shown = sorted.slice(0, visible); - const hasMore = visible < sorted.length; + function switchTab(next: JobListTab) { + if (next === tab) return; + setTab(next); + setPage(1); + setMessage(""); + setError(""); + } async function startDemo() { setBusy(true); @@ -81,7 +94,9 @@ export function JobsPage() { try { const job = await repos.jobs.startDemo(); setMessage(t("jobs.demoCreated", { id: job.id.slice(0, 8) })); - setVisible(INITIAL); + setTab("active"); + setPage(1); + await reloadLive(); await load(); refresh(); } catch (e) { @@ -104,6 +119,7 @@ export function JobsPage() { try { await repos.jobs.remove(job.id); setMessage(t("jobs.deleted")); + await reloadLive(); await load(); refresh(); } catch (e) { @@ -113,6 +129,13 @@ export function JobsPage() { } } + const emptyTitle = + tab === "recurring" + ? t("jobs.empty.recurring") + : tab === "history" + ? t("jobs.empty.history") + : t("jobs.empty.active"); + return ( <> @@ -136,28 +159,65 @@ export function JobsPage() {

        ) : null} - {sorted.length === 0 ? ( - +
        + {TABS.map((tb) => ( + + ))} +
        + + {tab === "history" ? ( +

        + {t("jobs.retentionHint")} +

        + ) : null} + {tab === "recurring" ? ( +

        + {t("jobs.recurringHint")} +

        + ) : null} + + {loading && jobs.length === 0 ? ( +

        {t("common.loading")}

        + ) : jobs.length === 0 ? ( + ) : (

        - {t("jobs.total", { n: sorted.length })} - {hasMore ? t("jobs.showing", { n: shown.length }) : ""} + {t("jobs.total", { n: total })}

        - {shown.map((job) => ( + {jobs.map((job) => (
        {jobTemplateLabel(job.template_type, t)}

        {jobSubtitle(job, t, formatLocalDateTime)}

        -
        + {tab === "active" || tab === "history" ? (
        -
        + className="hb-progress hb-progress--sm" + aria-hidden + style={{ marginTop: "0.35rem" }} + > +
        +
        + ) : null}

        - {job.progress_percent}% · {formatLocalDateTime(job.updated_at)} + {tab === "recurring" + ? formatLocalDateTime(job.run_after || job.updated_at) + : `${job.progress_percent}% · ${formatLocalDateTime(job.updated_at)}`} {job.error ? ` · ${job.error}` : ""}

        @@ -185,13 +245,14 @@ export function JobsPage() {
        ))} - {hasMore ? ( -
        - -
        - ) : null} +
        )} diff --git a/apps/web/src/pages/PlanCheckoutPage.tsx b/apps/web/src/pages/PlanCheckoutPage.tsx index 58e1949..5ee780b 100644 --- a/apps/web/src/pages/PlanCheckoutPage.tsx +++ b/apps/web/src/pages/PlanCheckoutPage.tsx @@ -3,7 +3,7 @@ import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom"; import { useAuth } from "../auth/AuthContext"; import { PageHeader } from "../components/layout/PageHeader"; import { Button, Card, Input } from "../components/ui"; -import { useRepos } from "../data/DataContext"; +import { useData, useRepos } from "../data/DataContext"; import { useI18n } from "../i18n/I18nContext"; import { getPlanRights, planCtaLabel } from "../lib/planRights"; import { PLANS, type PlanId } from "../lib/usageMeter"; @@ -17,6 +17,7 @@ function isPlanId(v: string | null): v is PlanId { */ export function PlanCheckoutPage() { const repos = useRepos(); + const { refresh } = useData(); const { member } = useAuth(); const { t, formatPlanPrice } = useI18n(); const navigate = useNavigate(); @@ -25,7 +26,7 @@ export function PlanCheckoutPage() { const planId: PlanId | null = isPlanId(planParam) ? planParam : null; const plan = planId ? PLANS[planId] : null; - const rights = planId ? getPlanRights(planId, t) : null; + const rights = planId ? getPlanRights(planId, t, formatPlanPrice) : null; const [currentId, setCurrentId] = useState("free"); const [cardName, setCardName] = useState(member?.display_name || ""); @@ -78,6 +79,8 @@ export function PlanCheckoutPage() { await repos.usage.purchasePlan(planId, { mock_ref: free ? "free" : `****${cardLast4.replace(/\D/g, "").slice(-4)}`, }); + // 強制重載 repos/頂欄用量(PLANS 配給依新 plan_id) + refresh(); navigate("/app/usage", { replace: true, state: { purchaseOk: planId }, @@ -128,6 +131,9 @@ export function PlanCheckoutPage() {

        {t("checkout.monthlyCredits", { n: plan.monthly_credits })}

        +

        + {rights.approxCallsLine} +

    diff --git a/apps/web/src/pages/PlansPage.tsx b/apps/web/src/pages/PlansPage.tsx index 4e26e10..5955632 100644 --- a/apps/web/src/pages/PlansPage.tsx +++ b/apps/web/src/pages/PlansPage.tsx @@ -5,6 +5,7 @@ import { PageHeader } from "../components/layout/PageHeader"; import { PlanPricingGrid } from "../components/usage/PlanPricingGrid"; import { useRepos } from "../data/DataContext"; import { useI18n } from "../i18n/I18nContext"; +import { getPlanRights } from "../lib/planRights"; import type { PlanId } from "../lib/usageMeter"; import { PLANS } from "../lib/usageMeter"; @@ -25,6 +26,7 @@ export function PlansPage() { if (!member) return null; const current = currentId ?? "free"; + const currentRights = getPlanRights(current, t, formatPlanPrice); return ( <> @@ -41,6 +43,7 @@ export function PlansPage() { {t("plans.monthlyCredits", { n: PLANS[current].monthly_credits })} + {currentRights.approxCallsLine} {t("plans.usageLink")} diff --git a/apps/web/src/pages/StudioPage.tsx b/apps/web/src/pages/StudioPage.tsx index 469949b..6a303c4 100644 --- a/apps/web/src/pages/StudioPage.tsx +++ b/apps/web/src/pages/StudioPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { Link, useNavigate, useSearchParams } from "react-router-dom"; +import { Link, useSearchParams } from "react-router-dom"; import { PageHeader } from "../components/layout/PageHeader"; import { Select } from "../components/ui"; import { useData, useRepos } from "../data/DataContext"; @@ -8,6 +8,7 @@ import { useI18n } from "../i18n/I18nContext"; import { isPersonaReady, personaOptionLabel } from "../lib/personaPrompt"; import { ComposerPanel } from "./studio/ComposerPanel"; import { InspirePanel } from "./studio/InspirePanel"; +import { InsightsPanel } from "./studio/InsightsPanel"; import { MentionsPanel } from "./studio/MentionsPanel"; import { OwnPostsPanel } from "./studio/OwnPostsPanel"; import { PlaysPanel } from "./studio/PlaysPanel"; @@ -27,7 +28,6 @@ export function StudioPage() { const repos = useRepos(); const { tick } = useData(); const { t } = useI18n(); - const navigate = useNavigate(); const [params, setParams] = useSearchParams(); const [accounts, setAccounts] = useState([]); @@ -72,10 +72,6 @@ export function StudioPage() { ); useEffect(() => { - if (tab === "insights") { - navigate("/app/insights", { replace: true }); - return; - } if (!TAB_KEYS.some((item) => item.key === tab)) { setTab("posts"); } @@ -127,13 +123,7 @@ export function StudioPage() { key={item.key} type="button" className={`hb-tab ${tab === item.key ? "is-active" : ""}`} - onClick={() => { - if (item.key === "insights") { - navigate("/app/insights"); - return; - } - setTab(item.key); - }} + onClick={() => setTab(item.key)} > {t(item.labelKey)} @@ -165,6 +155,9 @@ export function StudioPage() { {tab === "inspire" ? ( ) : null} + {tab === "insights" ? ( + + ) : null} ); } diff --git a/apps/web/src/pages/TodayPage.tsx b/apps/web/src/pages/TodayPage.tsx index 87a9a73..0c62367 100644 --- a/apps/web/src/pages/TodayPage.tsx +++ b/apps/web/src/pages/TodayPage.tsx @@ -1,9 +1,10 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { PageHeader } from "../components/layout/PageHeader"; import { Badge, Button, Card, EmptyState } from "../components/ui"; import { useData, useRepos } from "../data/DataContext"; import type { + MentionItem, OutboxBundle, OwnPost, ScoutPost, @@ -11,6 +12,7 @@ import type { TrendItem, } from "../domain/types"; import { useI18n } from "../i18n/I18nContext"; +import { useFormatApiError } from "../lib/apiErrors"; import { loadScoutToday } from "../lib/scoutToday"; function isPendingScout(p: ScoutPost): boolean { @@ -32,15 +34,26 @@ type AccountPulse = { topInsight?: string; }; +/** + * 今日儀表板:海巡待回、今日目標、發送、話題、帳號成效。 + * 全部接 live repos;話題可刷新;點話題進靈感 tab。 + */ export function TodayPage() { const repos = useRepos(); const { tick, refresh } = useData(); const { t, locale } = useI18n(); + const formatApiError = useFormatApiError(); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [refreshingTrends, setRefreshingTrends] = useState(false); + const [syncingPosts, setSyncingPosts] = useState(false); const [scoutPosts, setScoutPosts] = useState([]); const [outbox, setOutbox] = useState([]); const [trends, setTrends] = useState([]); const [ownPosts, setOwnPosts] = useState([]); + const [mentions, setMentions] = useState([]); const [accounts, setAccounts] = useState([]); const [scoutDone, setScoutDone] = useState(0); const [scoutGoal, setScoutGoal] = useState(8); @@ -57,25 +70,73 @@ export function TodayPage() { [dateLocale, tick], ); - useEffect(() => { - void (async () => { - const scout = loadScoutToday(); - setScoutDone(scout.done); - setScoutGoal(scout.goalValue); - const [posts, box, trendList, owns, acc] = await Promise.all([ + const load = useCallback(async () => { + setLoading(true); + setError(""); + try { + const scoutLocal = loadScoutToday(); + setScoutGoal(scoutLocal.goalValue); + + const [posts, box, trendList, acc] = await Promise.all([ repos.scout.listPosts(), repos.outbox.list(), - repos.inspiration.listTrends("all"), - repos.ownPosts.list(), + repos.inspiration.listTrends("all").catch(() => [] as TrendItem[]), repos.accounts.list(), ]); + + const usable = acc.filter((a) => a.is_usable); + setAccounts(usable.length ? usable : acc); setScoutPosts(posts); setOutbox(box); - setTrends(trendList.slice(0, 8)); + setTrends( + [...trendList] + .sort((a, b) => (b.heat || 0) - (a.heat || 0)) + .slice(0, 12), + ); + + // 今日已回:localStorage + 後端 published 取較大 + const publishedN = posts.filter((p) => p.outreach_status === "published").length; + setScoutDone(Math.max(scoutLocal.done, publishedN)); + + // 自己貼文:全帳或分帳合併 + let owns: OwnPost[] = []; + try { + owns = await repos.ownPosts.list(); + } catch { + owns = []; + } + if (!owns.length && usable.length) { + const chunks = await Promise.all( + usable.slice(0, 6).map((a) => + repos.ownPosts.list(a.id).catch(() => [] as OwnPost[]), + ), + ); + owns = chunks.flat(); + } setOwnPosts(owns); - setAccounts(acc.filter((a) => a.is_usable)); - })(); - }, [repos, tick]); + + // 待回提及 + let mentionList: MentionItem[] = []; + try { + if (usable[0]?.id) { + mentionList = await repos.mentions.list(usable[0].id); + } else { + mentionList = await repos.mentions.list(); + } + } catch { + mentionList = []; + } + setMentions(mentionList); + } catch (e) { + setError(formatApiError(e, "today.loadFail")); + } finally { + setLoading(false); + } + }, [repos, formatApiError]); + + useEffect(() => { + void load(); + }, [load, tick]); useEffect(() => { const onStore = () => refresh(); @@ -94,6 +155,11 @@ export function TodayPage() { [scoutPosts], ); + const pendingMentions = useMemo( + () => mentions.filter((m) => m.status === "pending"), + [mentions], + ); + const failedOutbox = useMemo( () => outbox.filter((o) => o.status === "partial_failed"), [outbox], @@ -111,7 +177,7 @@ export function TodayPage() { const goalPct = Math.min(100, Math.round((scoutDone / Math.max(1, scoutGoal)) * 100)); - const topicCards = useMemo(() => trends.slice(0, 3), [trends]); + const topicCards = useMemo(() => trends.slice(0, 6), [trends]); const accountPulses = useMemo((): AccountPulse[] => { const byAcc = new Map(); @@ -137,7 +203,6 @@ export function TodayPage() { topInsight: top?.insight || top?.formula_summary, }); } - // 沒 usable 時仍展示有貼文的帳 if (!rows.length) { for (const [accId, posts] of byAcc) { const acc = @@ -165,16 +230,78 @@ export function TodayPage() { .slice(0, 4); }, [accounts, ownPosts]); - const pendingPreview = pendingScout.slice(0, 3); + const pendingPreview = pendingScout.slice(0, 5); + + async function onRefreshTrends() { + setRefreshingTrends(true); + setError(""); + try { + const list = await repos.inspiration.refreshTrends("all"); + setTrends( + [...list].sort((a, b) => (b.heat || 0) - (a.heat || 0)).slice(0, 12), + ); + refresh(); + } catch (e) { + setError(formatApiError(e, "today.trendsFail")); + } finally { + setRefreshingTrends(false); + } + } + + async function onSyncOwnPosts() { + const acc = accounts[0]; + if (!acc) { + setError(t("today.needAccount")); + return; + } + setSyncingPosts(true); + setError(""); + try { + const list = await repos.ownPosts.sync(acc.id); + setOwnPosts((prev) => { + const others = prev.filter((p) => p.account_id !== acc.id); + return [...list, ...others]; + }); + refresh(); + } catch (e) { + setError(formatApiError(e, "today.syncPostsFail")); + } finally { + setSyncingPosts(false); + } + } + + function topicHref(label: string) { + const q = new URLSearchParams({ tab: "inspire", topic: label }); + return `/app/studio?${q.toString()}`; + } + + if (loading && !scoutPosts.length && !trends.length && !outbox.length) { + return ( + <> + +

    {t("common.loading")}

    + + ); + } return ( <> + {error ? ( +

    + {error} +

    + ) : null} +
    - - - + - + +
    {/* 數值列 */} @@ -196,7 +326,7 @@ export function TodayPage() { {pendingScout.length} {t("today.metric.pendingHint")} -
    +
    {t("today.metric.doneGoal")} {scoutDone} @@ -225,9 +355,14 @@ export function TodayPage() { {failedOutbox.length ? t("today.metric.needAction") : t("today.metric.ok")} + + {t("today.metric.mentions")} + {pendingMentions.length} + {t("today.metric.mentionsHint")} +
    - {/* 待回覆 */} + {/* 待回覆 · 海巡 */} {pendingPreview.length === 0 ? ( + {" · "} + {t("today.badge.drafted")} + + ) : null} {p.text.slice(0, 96)} {p.text.length > 96 ? "…" : ""} + {p.opportunity ? ( + {p.opportunity} + ) : null} ))} @@ -275,13 +419,34 @@ export function TodayPage() { {/* 找話題 */} +
    + +
    {topicCards.length === 0 ? ( - - +
    + + + + +
    } /> ) : ( @@ -289,15 +454,20 @@ export function TodayPage() {
      {topicCards.map((item) => (
    • - + {item.label} - {typeof item.heat === "number" ? ( + {typeof item.heat === "number" && item.heat > 0 ? ( {t("today.heat", { n: item.heat })} ) : null} + {item.source_label ? ( + · {item.source_label} + ) : null} - {item.summary?.slice(0, 100) || item.samples?.[0] || t("today.topicAngle")} + {item.summary?.slice(0, 100) || + item.samples?.[0] || + t("today.topicAngle")} {(item.summary?.length || 0) > 100 ? "…" : ""} @@ -305,16 +475,18 @@ export function TodayPage() { ))}
    - + - - - + {topicCards[0] ? ( + + + + ) : null}
    )} @@ -325,7 +497,7 @@ export function TodayPage() { {failedOutbox.length === 0 && runningOutbox.length === 0 && sentToday === 0 ? (

    {t("today.outbox.empty")}{" "} - {t("today.newThread")} + {t("today.newThread")} {t("today.outbox.emptyMid")}{" "} {t("nav.outbox")} {t("today.outbox.emptyEnd")} @@ -342,15 +514,17 @@ export function TodayPage() { {failedOutbox.slice(0, 2).map((o) => (

    {t("today.badge.failed")}{" "} - {o.title} + {o.title || o.id.slice(0, 8)}

    ))} {runningOutbox.slice(0, 2).map((o) => (

    - {o.status === "scheduling" ? t("today.badge.scheduling") : t("today.badge.sending")} + {o.status === "scheduling" + ? t("today.badge.scheduling") + : t("today.badge.sending")} {" "} - {o.title} + {o.title || o.id.slice(0, 8)}

    ))} @@ -362,17 +536,29 @@ export function TodayPage() { )} - {/* 帳號成效(輕量) */} + {/* 帳號成效 */} {accountPulses.length === 0 ? ( - - +
    + {accounts.length > 0 ? ( + + ) : ( + + + + )} +
    } /> ) : ( @@ -388,7 +574,8 @@ export function TodayPage() {
    - {t("today.views")} {row.views.toLocaleString(dateLocale)} + {t("today.views")}{" "} + {row.views.toLocaleString(dateLocale)} {t("today.likes")} {row.likes} @@ -412,6 +599,14 @@ export function TodayPage() { {t("today.viewPosts")} +
    @@ -274,13 +291,15 @@ export function UsagePage() {
    {t("usage.usedThisMonth")}

    - {summary.total_credits} - / {summary.plan.monthly_credits} + {usedCredits} + / {capCredits}

    {t("usage.remainLabel")} -

    {summary.remaining_credits}

    +

    + {summary.unlimited ? "∞" : remainCredits} +

    @@ -292,11 +311,13 @@ export function UsagePage() {
    {usedPct}% - + AI {summary.ai_calls} + {t("usage.meter.pt")} - + Search {summary.search_calls} + {t("usage.meter.pt")}
    @@ -465,7 +486,10 @@ export function UsagePage() { > {(Object.keys(PLANS) as PlanId[]).map((id) => ( ))} diff --git a/apps/web/src/pages/studio/InsightsPanel.tsx b/apps/web/src/pages/studio/InsightsPanel.tsx new file mode 100644 index 0000000..1bf6afd --- /dev/null +++ b/apps/web/src/pages/studio/InsightsPanel.tsx @@ -0,0 +1,724 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Link } from "react-router-dom"; +import { Badge, Button, Card, EmptyState, Select } from "../../components/ui"; +import { useData, useRepos } from "../../data/DataContext"; +import type { OwnPost, ThreadsAccount } from "../../domain/types"; +import { + buildAccountInsights, + ensureInsightHistory, + fmtDelta, + fmtEngRate, + monthKeyFromDate, + monthLabel, + topPostsForMonth, + type AccountInsightsReport, + type AccountInsightsSnapshot, + type MonthBucket, +} from "../../lib/accountInsights"; +import { + needsPastMonthBackfill, + OWN_POSTS_CURRENT_TTL_MS, + syncOwnPostsGated, +} from "../../lib/ownPostsSyncGate"; +import { formatLocalDateTime } from "../../lib/time"; +import { useI18n } from "../../i18n/I18nContext"; + +type Props = { + /** 由創作頁帶入;空則面板內自選帳號 */ + accountId?: string; + /** 隱藏內建帳號列(創作頁已有 studio bar) */ + hideAccountSelect?: boolean; + /** 顯示頁級標題(獨立 /app/insights 用) */ + showTitle?: boolean; +}; + +function DeltaBadge({ value }: { value: number | null }) { + const { t } = useI18n(); + if (value == null) return {t("common.dash")}; + if (value > 0) return {fmtDelta(value)}; + if (value < 0) return {fmtDelta(value)}; + return {t("insights.zeroPct")}; +} + +function MonthBars({ + months, + metric, + selectedKey, + onSelect, +}: { + months: MonthBucket[]; + metric: "views" | "likes" | "replies" | "posts"; + selectedKey: string; + onSelect: (monthKey: string) => void; +}) { + const { t, locale } = useI18n(); + const max = Math.max(1, ...months.map((m) => m[metric])); + const labels: Record = { + views: t("insights.metricViews"), + likes: t("insights.metricLikes"), + replies: t("insights.metricReplies"), + posts: t("insights.metricPostsFull"), + }; + const loc = locale === "en" ? "en-US" : "zh-TW"; + return ( +
    +

    + {t("insights.barsLabel", { metric: labels[metric], n: months.length })} + + {t("insights.clickBar")} + +

    +
    + {months.map((m) => { + const h = Math.round((m[metric] / max) * 100); + const selected = m.key === selectedKey; + const valStr = m[metric].toLocaleString(loc); + return ( + + ); + })} +
    +
    + ); +} + +function Sparkline({ + months, + metric, + selectedKey, + onSelect, +}: { + months: MonthBucket[]; + metric: "views" | "likes"; + selectedKey: string; + onSelect: (monthKey: string) => void; +}) { + const { t } = useI18n(); + const w = 280; + const h = 56; + const pad = 4; + const vals = months.map((m) => m[metric]); + const max = Math.max(1, ...vals); + const min = Math.min(...vals, 0); + const span = Math.max(1, max - min); + const pts = vals.map((v, i) => { + const x = pad + (i * (w - pad * 2)) / Math.max(1, vals.length - 1); + const y = h - pad - ((v - min) / span) * (h - pad * 2); + return `${x},${y}`; + }); + const poly = pts.join(" "); + return ( + + + {vals.map((v, i) => { + const x = pad + (i * (w - pad * 2)) / Math.max(1, vals.length - 1); + const y = h - pad - ((v - min) / span) * (h - pad * 2); + const m = months[i]!; + const selected = m.key === selectedKey; + return ( + onSelect(m.key)}> + + + + ); + })} + + ); +} + +/** + * 帳號成效:同步貼文 → 聚合近月 KPI/圖表/結論與建議。 + * - 過去月份缺資料:整帳只真 API 補抓一次(past-once) + * - 本月:面板開啟時每 5 分鐘真 API 刷新(singleflight 合併並發) + * - 下方貼文列表依選中月份過濾 + */ +export function InsightsPanel({ + accountId: accountIdProp, + hideAccountSelect = false, + showTitle = false, +}: Props) { + const repos = useRepos(); + const { tick, refresh } = useData(); + const { t, locale } = useI18n(); + const numLoc = locale === "en" ? "en-US" : "zh-TW"; + + const [accounts, setAccounts] = useState([]); + const [localAccountId, setLocalAccountId] = useState(""); + const [posts, setPosts] = useState([]); + const [syncedAt, setSyncedAt] = useState(null); + const [metric, setMetric] = useState<"views" | "likes" | "replies" | "posts">("views"); + const [busy, setBusy] = useState(""); + const [message, setMessage] = useState(""); + const [analysisMonth, setAnalysisMonth] = useState(monthKeyFromDate()); + const [history, setHistory] = useState([]); + const [historyTick, setHistoryTick] = useState(0); + const bootstrappedRef = useRef(""); + + const accountId = accountIdProp || localAccountId; + const currentMonthKey = monthKeyFromDate(); + + const ops = useMemo( + () => ({ + list: (id: string) => repos.ownPosts.list(id), + sync: (id: string) => repos.ownPosts.sync(id), + }), + [repos.ownPosts], + ); + + const applyPosts = useCallback( + async (list: OwnPost[], opts?: { quiet?: boolean }) => { + setPosts(list); + const last = await repos.ownPosts.lastSyncedAt().catch(() => null); + setSyncedAt(last); + setHistoryTick((n) => n + 1); + if (!opts?.quiet) refresh(); + }, + [repos.ownPosts, refresh], + ); + + useEffect(() => { + void (async () => { + const acc = await repos.accounts.list(); + const usable = acc.filter((a) => a.is_usable); + const list = usable.length ? usable : acc; + setAccounts(list); + setLocalAccountId((cur) => cur || list[0]?.id || ""); + })(); + }, [repos, tick]); + + // 進帳號:list → 必要時 past-once / 過期 current-ttl 真 sync(singleflight) + useEffect(() => { + if (!accountId) { + setPosts([]); + setSyncedAt(null); + bootstrappedRef.current = ""; + return; + } + + let cancelled = false; + const runId = accountId; + bootstrappedRef.current = ""; + + void (async () => { + setBusy("load"); + setMessage(""); + try { + let list = await ops.list(accountId); + if (cancelled) return; + await applyPosts(list, { quiet: true }); + setAnalysisMonth(monthKeyFromDate()); + + const report0 = buildAccountInsights(accountId, list, 6); + const needPast = needsPastMonthBackfill(report0.months, currentMonthKey); + + // 過去月缺資料 → 真 API 補一次;否則本月過 5 分鐘才再抓 + setBusy("sync"); + const out = await syncOwnPostsGated( + accountId, + ops, + needPast ? "past-once" : "current-ttl", + ); + if (cancelled) return; + if (out.fetched) { + list = out.posts; + await applyPosts(list); + if (out.reason === "past-once") { + setMessage(t("insights.pastBackfillDone", { n: list.length })); + } + } + bootstrappedRef.current = runId; + } catch (e) { + if (!cancelled) { + setPosts([]); + setMessage(e instanceof Error ? e.message : t("insights.loadFail")); + } + } finally { + if (!cancelled) setBusy(""); + } + })(); + + return () => { + cancelled = true; + }; + }, [accountId, ops, applyPosts, currentMonthKey, t]); + + // 本月:每 5 分鐘真 API 更新(singleflight;面板卸載即停) + useEffect(() => { + if (!accountId) return; + const timer = window.setInterval(() => { + void (async () => { + try { + const out = await syncOwnPostsGated(accountId, ops, "current-ttl"); + if (out.fetched) { + await applyPosts(out.posts); + setMessage(t("insights.autoRefreshDone", { n: out.posts.length })); + } + } catch { + /* 背景刷新失敗不打斷 UI */ + } + })(); + }, OWN_POSTS_CURRENT_TTL_MS); + return () => window.clearInterval(timer); + }, [accountId, ops, applyPosts, t]); + + useEffect(() => { + const onStore = () => refresh(); + window.addEventListener("harbor:store", onStore); + return () => window.removeEventListener("harbor:store", onStore); + }, [refresh]); + + const report: AccountInsightsReport | null = useMemo(() => { + if (!accountId) return null; + return buildAccountInsights(accountId, posts, 6); + }, [accountId, posts]); + + useEffect(() => { + if (!report) { + setHistory([]); + return; + } + // 本月覆寫分析;歷史月有資料才寫一次(ensure 內部會跳過已存在) + const forceCurrent = analysisMonth === currentMonthKey || analysisMonth === report.current.key; + const list = ensureInsightHistory(report, { forceCurrent }); + setHistory(list); + }, [report, historyTick, analysisMonth, currentMonthKey]); + + const selectedSnap = useMemo( + () => history.find((s) => s.month_key === analysisMonth) || history[0] || null, + [history, analysisMonth], + ); + + const selectedMonthBucket = useMemo(() => { + if (!report) return null; + return report.months.find((m) => m.key === analysisMonth) || report.current; + }, [report, analysisMonth]); + + const monthTopPosts = useMemo( + () => topPostsForMonth(posts.filter((p) => !accountId || p.account_id === accountId), analysisMonth, 8), + [posts, accountId, analysisMonth], + ); + + const selectedMonthPrev = useMemo(() => { + if (!report) return null; + const idx = report.months.findIndex((m) => m.key === analysisMonth); + if (idx <= 0) return null; + return report.months[idx - 1] || null; + }, [report, analysisMonth]); + + const selectedDelta = useMemo(() => { + if (!selectedMonthBucket || !selectedMonthPrev) { + return { views: null as number | null, likes: null, replies: null, engagementRate: null }; + } + const pct = (c: number, p: number) => { + if (p <= 0 && c <= 0) return 0; + if (p <= 0) return c > 0 ? 100 : null; + return Math.round(((c - p) / p) * 1000) / 10; + }; + return { + views: pct(selectedMonthBucket.views, selectedMonthPrev.views), + likes: pct(selectedMonthBucket.likes, selectedMonthPrev.likes), + replies: pct(selectedMonthBucket.replies, selectedMonthPrev.replies), + engagementRate: pct( + selectedMonthBucket.engagementRate * 100, + selectedMonthPrev.engagementRate * 100, + ), + }; + }, [selectedMonthBucket, selectedMonthPrev]); + + async function syncPosts() { + if (!accountId) return; + setBusy("sync"); + setMessage(""); + try { + const out = await syncOwnPostsGated(accountId, ops, "manual"); + await applyPosts(out.posts); + setMessage(t("insights.syncDone", { n: out.posts.length })); + } catch (e) { + setMessage(e instanceof Error ? e.message : t("insights.syncFail")); + } finally { + setBusy(""); + } + } + + function selectMonth(key: string) { + setAnalysisMonth(key); + } + + const account = + accounts.find((a) => a.id === accountId) || + (accountId + ? ({ id: accountId, username: "", display_name: "" } as ThreadsAccount) + : undefined); + const hasPosts = (report?.totalPosts ?? 0) > 0; + const zeroViews = hasPosts && posts.every((p) => (p.view_count || 0) === 0); + const kpi = selectedMonthBucket || report?.current; + const isCurrentSelected = analysisMonth === currentMonthKey; + + return ( +
    +
    + {!hideAccountSelect ? ( + + ) : ( +
    +

    + {t("insights.panelHint")} + {syncedAt ? ( + <> + {" · "} + {t("insights.lastSynced", { time: formatLocalDateTime(syncedAt) })} + + ) : ( + <> · {t("insights.neverSynced")} + )} +

    +
    + )} +
    + + + + +
    +
    + + {message ? ( +

    + {message} +

    + ) : null} + + {!accountId || !report ? ( + + + + } + /> + ) : !hasPosts ? ( + void syncPosts()}> + {busy === "sync" ? t("insights.syncing") : t("insights.syncPosts")} + + } + /> + ) : ( +
    + {zeroViews ? ( +

    + {t("insights.zeroViewsHint")} +

    + ) : null} + +
    +
    + + {isCurrentSelected ? t("insights.monthViews") : t("insights.metricViews")} + + + {(kpi?.views ?? 0).toLocaleString(numLoc)} + + + {t("insights.vsPrev")} + +
    +
    + + {isCurrentSelected ? t("insights.monthLikes") : t("insights.metricLikes")} + + {kpi?.likes ?? 0} + + {t("insights.vsPrev")} + +
    +
    + + {isCurrentSelected ? t("insights.monthReplies") : t("insights.metricReplies")} + + {kpi?.replies ?? 0} + + {t("insights.vsPrev")} + +
    +
    + {t("insights.engRate")} + + {fmtEngRate(kpi?.engagementRate ?? 0)} + + + {t("insights.postsInMonth", { n: kpi?.posts ?? 0 })} ·{" "} + + +
    +
    + + +
    + +
    + {(["views", "likes", "replies", "posts"] as const).map((m) => ( + + ))} +
    + + {(kpi?.posts ?? 0) <= 0 ? ( +

    + {t("insights.noDataNoAnalysis", { label: monthLabel(analysisMonth) })} +

    + ) : selectedSnap && + (selectedSnap.analysis.length > 0 || selectedSnap.recommendations.length > 0) ? ( +
    +
    + {t("insights.analysisOf", { label: selectedSnap.month_label })} + + {t("insights.producedAt", { time: formatLocalDateTime(selectedSnap.analyzed_at) })} + {selectedSnap.delta.views != null + ? t("insights.viewsVsPrev", { delta: fmtDelta(selectedSnap.delta.views) }) + : ""} + +
    + + {t("insights.statPosts")} {selectedSnap.metrics.posts} + + + {t("insights.statViews")}{" "} + {selectedSnap.metrics.views.toLocaleString(numLoc)} + + + {t("insights.statLikes")} {selectedSnap.metrics.likes} + + + {t("insights.statReplies")} {selectedSnap.metrics.replies} + + + {t("insights.engRate")}{" "} + {fmtEngRate(selectedSnap.metrics.engagementRate)} + +
    +
    + {selectedSnap.analysis.length > 0 ? ( +
    +

    + {t("insights.conclusions")} +

    +
      + {selectedSnap.analysis.map((line) => ( +
    • {line}
    • + ))} +
    +
    + ) : null} + {selectedSnap.recommendations.length > 0 ? ( +
    +

    + {t("insights.recommendations")} +

    +
      + {selectedSnap.recommendations.map((line) => ( +
    • {line}
    • + ))} +
    +
    + ) : null} + {selectedSnap.top_highlights?.length ? ( +
    +

    + {t("insights.highlights")} +

    +
      + {selectedSnap.top_highlights.map((h) => ( +
    • {h}
    • + ))} +
    +
    + ) : null} +
    + + + + + + +
    +
    + ) : ( +

    + {t("insights.noAnalysisYet", { label: monthLabel(analysisMonth) })} +

    + )} +
    +
    + + + {monthTopPosts.length === 0 ? ( + void syncPosts()} disabled={Boolean(busy)}> + {busy === "sync" ? t("insights.syncing") : t("insights.syncPosts")} + + ) : undefined + } + /> + ) : ( +
      + {monthTopPosts.map((p, i) => ( +
    • +
      + + #{i + 1} + {p.topic_tag ? {p.topic_tag} : null} + + {t("insights.postStats", { + views: p.view_count.toLocaleString(numLoc), + likes: p.like_count, + replies: p.reply_count, + })} + + {formatLocalDateTime(p.published_at)} + + + {p.text.slice(0, 120)} + {p.text.length > 120 ? "…" : ""} + + {p.insight || p.formula_summary ? ( + + {p.insight || p.formula_summary} + + ) : null} + {p.permalink ? ( + + {t("insights.openThreads")} + + ) : null} +
      +
    • + ))} +
    + )} +
    +
    + )} +
    + ); +} diff --git a/apps/web/src/pages/studio/InspirePanel.tsx b/apps/web/src/pages/studio/InspirePanel.tsx index 38be247..b7e658b 100644 --- a/apps/web/src/pages/studio/InspirePanel.tsx +++ b/apps/web/src/pages/studio/InspirePanel.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { useNavigate } from "react-router-dom"; -import { Button, EmptyState, Input, Select, Textarea } from "../../components/ui"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { Button, EmptyState, Input, MarkdownText, Select, Textarea } from "../../components/ui"; import { AppIcon } from "../../components/ui/AppIcons"; import { useData, useRepos } from "../../data/DataContext"; import type { @@ -53,7 +53,10 @@ export function InspirePanel({ accountId, personaId }: Props) { const { refresh, tick } = useData(); const { t } = useI18n(); const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); const bottomRef = useRef(null); + /** 今日/外連帶入的 topic 只套一次 */ + const topicSeeded = useRef(false); const [session, setSession] = useState(null); const [sessionList, setSessionList] = useState([]); @@ -186,6 +189,19 @@ export function InspirePanel({ accountId, personaId }: Props) { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [session?.messages.length, busy, streamingText]); + // 從今日等入口:?topic=xxx → 預填輸入框(只一次) + useEffect(() => { + if (topicSeeded.current) return; + const topic = (searchParams.get("topic") || "").trim(); + if (!topic) return; + topicSeeded.current = true; + setInput((cur) => (cur.trim() ? cur : t("inspire.topicSeed", { topic }))); + // 清掉 query,避免重整重複塞 + const next = new URLSearchParams(searchParams); + next.delete("topic"); + setSearchParams(next, { replace: true }); + }, [searchParams, setSearchParams, t]); + const pinnedElements = useMemo( () => elements.filter((e) => pinnedIds.includes(e.id)), [elements, pinnedIds], @@ -997,7 +1013,11 @@ export function InspirePanel({ accountId, personaId }: Props) { ? t("inspire.ai") : t("inspire.system")}
    -
    {m.text}
    + {m.role === "assistant" || m.role === "system" ? ( + + ) : ( +
    {m.text}
    + )} {m.draft?.body ? (
    {m.draft.body}
    @@ -1016,8 +1036,8 @@ export function InspirePanel({ accountId, personaId }: Props) { {streamingText ? (
    {t("inspire.ai")}
    -
    - {streamingText} +
    + @@ -1133,7 +1153,7 @@ export function InspirePanel({ accountId, personaId }: Props) { aria-label={t("inspire.inputAria")} value={input} onChange={(e) => setInput(e.target.value)} - rows={2} + rows={1} placeholder={t("inspire.inputPh")} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { @@ -1155,7 +1175,7 @@ export function InspirePanel({ accountId, personaId }: Props) { … ) : ( - + )}
    diff --git a/apps/web/src/pages/studio/PlaysPanel.tsx b/apps/web/src/pages/studio/PlaysPanel.tsx index 5f6ad42..f67916c 100644 --- a/apps/web/src/pages/studio/PlaysPanel.tsx +++ b/apps/web/src/pages/studio/PlaysPanel.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { Link, useNavigate, useSearchParams } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { ImageAttach } from "../../components/studio/ImageAttach"; import { Badge, Button, Card, EmptyState, Input, Select, Textarea } from "../../components/ui"; import { useData, useRepos } from "../../data/DataContext"; @@ -388,11 +388,6 @@ export function PlaysPanel({ accountId, personas: personasProp = [] }: Props) { return isPersonaReady(p) ? p! : null; } - function resolveStepBrand(step: PlayStep): Brand | null { - if (!step.brand_id) return null; - return brands.find((b) => b.id === step.brand_id) || null; - } - async function generateForStep( step: PlayStep, index: number, diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index 4a4c54f..d0cff07 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -841,8 +841,179 @@ svg { } .hb-inspire-msg__text { font-size: 0.9rem; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: anywhere; +} + +/* AI 氣泡:markdown + 逐行換行 */ +.hb-inspire-msg__md { + font-size: 0.9rem; + line-height: 1.65; + word-break: break-word; + overflow-wrap: anywhere; + color: var(--hb-text); +} + +.hb-inspire-msg__stream { + position: relative; +} + +.hb-inspire-msg__stream .hb-inspire-cursor { + vertical-align: text-bottom; +} + +/* —— 輕量 markdown(聊天泡泡:逐行換行,不擠成一團) —— */ +.hb-md { + display: flex; + flex-direction: column; + gap: 0; + line-height: 1.65; +} + +.hb-md > :first-child { + margin-top: 0; +} + +.hb-md > :last-child { + margin-bottom: 0; +} + +/* 一般文字:每個實際換行 = 一行 */ +.hb-md__line { + display: block; + margin: 0; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: anywhere; + min-height: 1.35em; +} + +/* 原文空一行 → 段落間距 */ +.hb-md__gap { + display: block; + height: 0.65em; + flex-shrink: 0; + pointer-events: none; +} + +.hb-md__h { + margin: 0.7em 0 0.3em; + font-weight: 700; + line-height: 1.4; + color: var(--hb-text); +} + +.hb-md__h:first-child { + margin-top: 0; +} + +.hb-md__h--1 { + font-size: 1.05rem; +} + +.hb-md__h--2 { + font-size: 0.98rem; +} + +.hb-md__h--3 { + font-size: 0.92rem; +} + +.hb-md__ul, +.hb-md__ol { + margin: 0.35em 0 0.55em; + padding-left: 1.35em; +} + +.hb-md__ul { + list-style: disc; +} + +.hb-md__ol { + list-style: decimal; +} + +.hb-md__ul li, +.hb-md__ol li { + margin: 0.28em 0; + padding-left: 0.15em; + line-height: 1.6; +} + +.hb-md__ul li::marker, +.hb-md__ol li::marker { + color: var(--hb-muted); +} + +.hb-md__quote { + margin: 0.45em 0 0.55em; + padding: 0.35em 0 0.35em 0.75em; + border-left: 3px solid color-mix(in srgb, var(--hb-brand) 45%, var(--hb-line)); + color: var(--hb-muted); + display: flex; + flex-direction: column; +} + +.hb-md__pre { + margin: 0.45em 0 0.55em; + padding: 0.55rem 0.7rem; + border-radius: calc(var(--hb-radius) - 2px); + background: color-mix(in srgb, var(--hb-surface-muted) 80%, #000 6%); + border: 1px solid var(--hb-line); + overflow-x: auto; + font-size: 0.8125rem; line-height: 1.5; white-space: pre-wrap; + word-break: break-word; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.hb-md__pre code { + font: inherit; + background: none; + padding: 0; + border: 0; +} + +.hb-md__code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.85em; + padding: 0.1em 0.35em; + border-radius: 0.3em; + background: color-mix(in srgb, var(--hb-surface-muted) 90%, var(--hb-brand) 6%); + border: 1px solid color-mix(in srgb, var(--hb-line) 80%, transparent); +} + +.hb-md__a { + color: var(--hb-brand); + text-decoration: underline; + text-underline-offset: 2px; + word-break: break-all; +} + +.hb-md__a:hover { + filter: brightness(1.08); +} + +.hb-md__hr { + border: 0; + border-top: 1px solid var(--hb-line); + margin: 0.65em 0; + width: 100%; +} + +.hb-md strong { + font-weight: 700; +} + +.hb-md em { + font-style: italic; +} + +.hb-md del { + opacity: 0.75; } .hb-inspire-draft { @@ -970,13 +1141,14 @@ svg { flex-wrap: wrap; } -/* 聊天軟體列:輸入 + 右側圓形發送 */ +/* 聊天軟體列:輸入 + 右側圓形發送(與對話框底緣對齊) */ .hb-inspire-composer__row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 0.45rem; - align-items: end; + display: flex; + flex-direction: row; + align-items: flex-end; + gap: 0.5rem; min-width: 0; + width: 100%; } /* 本輪參考:多 pin 橫向捲 */ @@ -1130,9 +1302,12 @@ svg { .hb-inspire-composer__row .hb-field { margin: 0; - display: block; - width: 100%; + display: flex; + flex: 1 1 auto; + flex-direction: column; + width: auto; min-width: 0; + gap: 0; } .hb-inspire-composer__row .hb-field__label:empty { @@ -1141,30 +1316,36 @@ svg { .hb-inspire-composer .hb-textarea { resize: none; + display: block; width: 100%; box-sizing: border-box; - min-height: 2.75rem; + /* 明確蓋掉全域 .hb-textarea min-height: 6.5rem,與送出圓鈕同高對齊 */ + min-height: 2.75rem !important; max-height: 5.5rem; height: auto; font-size: 0.875rem; line-height: 1.4; - padding: 0.55rem 0.75rem; - border-radius: 1.15rem; + padding: 0.6rem 0.85rem; + border-radius: 1.25rem; + field-sizing: content; } .hb-inspire-send-btn { - flex-shrink: 0; - width: 2.5rem; - height: 2.5rem; - margin-bottom: 0.1rem; + flex: 0 0 2.75rem; + width: 2.75rem; + height: 2.75rem; + margin: 0; + padding: 0; border: 0; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; + align-self: flex-end; background: var(--hb-brand); color: #fff; cursor: pointer; + line-height: 0; box-shadow: 0 1px 2px color-mix(in srgb, #000 12%, transparent); transition: opacity 0.12s ease, transform 0.12s ease, background 0.12s ease; } @@ -1183,12 +1364,18 @@ svg { .hb-inspire-send-btn .hb-app-icon { display: block; + flex-shrink: 0; + /* 紙飛機視覺重心略偏左,微調置中 */ + transform: translate(1px, 0.5px); } .hb-inspire-send-btn__dots { + display: block; font-size: 1rem; font-weight: 700; line-height: 1; + width: 1em; + text-align: center; } .hb-inspire-fp-bar__ok { @@ -1476,13 +1663,14 @@ svg { } .hb-inspire-composer .hb-textarea { - min-height: 2.5rem; + min-height: 2.5rem !important; max-height: 4.5rem; } .hb-inspire-send-btn { - width: 2.35rem; - height: 2.35rem; + flex-basis: 2.5rem; + width: 2.5rem; + height: 2.5rem; } } @@ -2747,6 +2935,19 @@ svg { color: var(--hb-muted); } +.hb-usage-sub__caps { + margin: 0.25rem 0 0; + font-size: var(--hb-text-xs); + color: var(--hb-text); + font-weight: 600; + line-height: 1.4; +} + +.hb-usage-sub__caps--pts { + font-weight: 500; + color: var(--hb-muted); +} + .hb-usage-status { display: flex; flex-direction: column; @@ -2819,6 +3020,13 @@ svg { font-weight: 750; } +.hb-plans-page__caps { + flex-basis: 100%; + font-size: var(--hb-text-xs); + font-weight: 600; + color: var(--hb-text); +} + .hb-plans-page__back { margin-left: auto; font-size: var(--hb-text-xs); @@ -2917,6 +3125,20 @@ svg { font-weight: 700; } +.hb-pricing__softcaps { + margin: 0.35rem 0 0; + font-size: var(--hb-text-xs); + font-weight: 650; + line-height: 1.4; + color: var(--hb-text); +} + +.hb-pricing__softcaps--pts { + margin-top: 0.15rem; + font-weight: 500; + color: var(--hb-muted); +} + .hb-pricing__bullets { margin: 0; padding: 0 0 0 1.05rem; @@ -3185,6 +3407,19 @@ svg { color: var(--hb-brand); } +.hb-usage-mbar__unit { + margin-left: 0.1rem; + font-size: var(--hb-text-xs); + color: var(--hb-muted); +} + +.hb-usage-mbar__over { + margin-left: 0.35rem; + font-size: var(--hb-text-xs); + font-weight: 700; + color: color-mix(in srgb, #d4524a 85%, var(--hb-text)); +} + .hb-usage-ppick { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -3890,6 +4125,17 @@ a.hb-today-metric:hover { } /* —— 帳號成效 / 月比圖 —— */ +.hb-insights-panel { + display: flex; + flex-direction: column; + gap: 0.85rem; + min-width: 0; +} + +.hb-insights-status { + margin: 0; +} + .hb-insights-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -3897,6 +4143,11 @@ a.hb-today-metric:hover { align-items: end; } +.hb-insights-toolbar__hint { + min-width: 0; + align-self: center; +} + @media (max-width: 560px) { .hb-insights-toolbar { grid-template-columns: 1fr; diff --git a/apps/web/src/styles/layout.css b/apps/web/src/styles/layout.css index 363bebf..c55f8ff 100644 --- a/apps/web/src/styles/layout.css +++ b/apps/web/src/styles/layout.css @@ -549,6 +549,19 @@ a.hb-topbar__chip:hover { color: var(--hb-text); } +.hb-usage-widget__caps { + margin: 0; + font-size: 0.72rem; + font-weight: 650; + line-height: 1.4; + color: var(--hb-text); +} + +.hb-usage-widget__caps--pts { + font-weight: 500; + color: var(--hb-muted); +} + .hb-usage-widget__nudge { margin: 0; padding: 0.45rem 0.55rem; diff --git a/docs/product/haixun-backend/README.md b/docs/product/haixun-backend/README.md index 9f2d8e8..ce4b5ff 100644 --- a/docs/product/haixun-backend/README.md +++ b/docs/product/haixun-backend/README.md @@ -4,7 +4,7 @@ |------|-----| | **Goal** | 依 `apps/web` **現 UI MUST** 交付真後端(Phase C live) | | **Phase** | `ready-to-implement` | -| **Status** | **M0–M5 done** · 下一槍 M6(邀請/冒煙/契約閘) | +| **Status** | **M0–M6 done**(邀請 live + IV 閘 + smoke) | | **Backend** | **`apps/backend`** | | **Updated** | 2026-07-10 | diff --git a/docs/product/haixun-backend/tasks/INDEX.md b/docs/product/haixun-backend/tasks/INDEX.md index 04741bc..837af6f 100644 --- a/docs/product/haixun-backend/tasks/INDEX.md +++ b/docs/product/haixun-backend/tasks/INDEX.md @@ -71,20 +71,20 @@ | T195 | scout outreach homework | M5 | feat | done | ~160 | | T196 | test m5 inspire scout integration | M5 | test | done | ~200 | | T197 | fe live scout inspire | M5 | feat | done | ~150 | -| T215 | invite member network apply | M6 | feat | todo | ~160 | -| T216 | invite admin tree move | M6 | feat | todo | ~180 | -| T217 | fe live invite | M6 | feat | todo | ~120 | -| T218 | smoke p0 p1 | M6 | test | todo | ~150 | -| T219 | test m6 invite integration | M6 | test | todo | ~160 | -| T220 | test fe be live integration | M6 | test | todo | ~200 | -| T221 | test contract security x | M6 | test | todo | ~150 | -| T222 | acceptance matrix ci gate | M6 | test | todo | ~180 | +| T215 | invite member network apply | M6 | feat | done | ~160 | +| T216 | invite admin tree move | M6 | feat | done | ~180 | +| T217 | fe live invite | M6 | feat | done | ~120 | +| T218 | smoke p0 p1 | M6 | test | done | ~150 | +| T219 | test m6 invite integration | M6 | test | done | ~160 | +| T220 | test fe be live integration | M6 | test | done | ~200 | +| T221 | test contract security x | M6 | test | done | ~150 | +| T222 | acceptance matrix ci gate | M6 | test | done | ~180 | ## Counts - **Total: 59**(feat 47 · test 12) -- **done: 35**(M0 + M1 + M2 + M3) -- **todo: 24** +- **done: 59**(M0–M6) +- **todo: 0** ## Milestone map @@ -94,19 +94,20 @@ | M1 會員 Admin 設定 | T110–T118 | T119 | **done** | | M2 OAuth Job 通知 FE 地基 | T130–T135 | T136 | **done** | | M3 用量 BYOK AI 真上傳 擴充 | T150–T155 T157 T158 | T156 | **done** | -| M4 創作 Outbox 真發 | T165–T171 | T172 | -| M5 靈感(現UI) 海巡 | T190–T195 T197 | T196 | -| M6 邀請 整合 矩陣 | T215–T217 | T218–T222 | +| M4 創作 Outbox 真發 | T165–T171 | T172 | **done** | +| M5 靈感(現UI) 海巡 | T190–T195 T197 | T196 | **done** | +| M6 邀請 整合 矩陣 | T215–T217 | T218–T222 | **done** | ## 建議開工序 1. ~~T100 → T108(M0)~~ **done** -2. ~~T110 → T119(M1)~~ **done**(含 FE live auth/admin/settings) +2. ~~T110 → T119(M1)~~ **done** 3. ~~T130 → T136(M2)~~ **done** -4. ~~T150 → T158(M3)~~ **done**(用量 platform/byok、AI/Exa proxy、方案購買、擴充 ZIP) -5. 下一槍:**M6** T215(邀請 live/冒煙/契約閘) -6. 每 M 功能做完必做該 M 的 **test** task 才算 M 完成 -7. 使用者指定 `T###` 才寫碼 +4. ~~T150 → T158(M3)~~ **done** +5. ~~T165 → T172(M4)~~ **done** +6. ~~T190 → T197(M5)~~ **done** +7. ~~T215 → T222(M6)~~ **done**(邀請 live + IV 測試 + smoke) + ## Remove 提醒(禁止當 task 交付)