fix frontend

This commit is contained in:
王性驊 2026-07-13 03:18:08 +00:00
parent e41c1f96a2
commit ef6322b635
76 changed files with 4557 additions and 1117 deletions

View File

@ -21,3 +21,4 @@ import "proxy.api"
import "extension.api" import "extension.api"
import "studio.api" import "studio.api"
import "m5.api" import "m5.api"
import "invite.api"

View File

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

View File

@ -19,8 +19,16 @@ type (
CompletedAt int64 `json:"completed_at,optional"` 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 { JobListData {
List []JobPublic `json:"list"` List []JobPublic `json:"list"`
Pagination Pagination `json:"pagination"`
} }
JobIdPath { JobIdPath {
@ -36,7 +44,7 @@ type (
) )
service gateway { service gateway {
@handler ListJobs @handler ListJobs
get / returns (JobListData) get / (JobListReq) returns (JobListData)
@handler GetJob @handler GetJob
get /:id (JobIdPath) returns (JobPublic) get /:id (JobIdPath) returns (JobPublic)

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,20 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,20 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
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)
}
}

View File

@ -9,12 +9,20 @@ import (
"apps/backend/internal/logic/jobs" "apps/backend/internal/logic/jobs"
"apps/backend/internal/response" "apps/backend/internal/response"
"apps/backend/internal/svc" "apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
) )
func ListJobsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { func ListJobsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { 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) l := jobs.NewListJobsLogic(r.Context(), svcCtx)
data, err := l.ListJobs() data, err := l.ListJobs(&req)
response.Write(r.Context(), w, data, err) response.Write(r.Context(), w, data, err)
} }
} }

View File

@ -11,6 +11,7 @@ import (
compose "apps/backend/internal/handler/compose" compose "apps/backend/internal/handler/compose"
extension "apps/backend/internal/handler/extension" extension "apps/backend/internal/handler/extension"
inspire "apps/backend/internal/handler/inspire" inspire "apps/backend/internal/handler/inspire"
invite "apps/backend/internal/handler/invite"
jobs "apps/backend/internal/handler/jobs" jobs "apps/backend/internal/handler/jobs"
media "apps/backend/internal/handler/media" media "apps/backend/internal/handler/media"
mentions "apps/backend/internal/handler/mentions" mentions "apps/backend/internal/handler/mentions"
@ -311,6 +312,49 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1/inspire"), 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( server.AddRoutes(
rest.WithMiddlewares( rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT}, []rest.Middleware{serverCtx.AuthJWT},
@ -357,11 +401,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
[]rest.Route{ []rest.Route{
{ {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/upload", Path: "/generate-image",
Handler: media.UploadHandler(serverCtx), Handler: media.GenerateImageHandler(serverCtx),
}, },
}..., }...,
), ),
rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
rest.WithPrefix("/api/v1/media"), rest.WithPrefix("/api/v1/media"),
) )
@ -371,12 +416,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
[]rest.Route{ []rest.Route{
{ {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/generate-image", Path: "/upload",
Handler: media.GenerateImageHandler(serverCtx), Handler: media.UploadHandler(serverCtx),
}, },
}..., }...,
), ),
rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
rest.WithPrefix("/api/v1/media"), rest.WithPrefix("/api/v1/media"),
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"apps/backend/internal/middleware" "apps/backend/internal/middleware"
jobDomain "apps/backend/internal/module/job/domain"
"apps/backend/internal/response" "apps/backend/internal/response"
"apps/backend/internal/svc" "apps/backend/internal/svc"
"apps/backend/internal/types" "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} 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 { if l.svcCtx.Jobs == nil {
return nil, response.Biz(503, 503001, "jobs module not configured") return nil, response.Biz(503, 503001, "jobs module not configured")
} }
@ -29,7 +30,9 @@ func (l *ListJobsLogic) ListJobs() (*types.JobListData, error) {
if !ok { if !ok {
return nil, response.Biz(401, 401001, "missing authorization") 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 { if err != nil {
return nil, err return nil, err
} }
@ -37,5 +40,17 @@ func (l *ListJobsLogic) ListJobs() (*types.JobListData, error) {
for _, j := range list { for _, j := range list {
out = append(out, types.JobFromModel(j)) 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
} }

View File

@ -135,7 +135,7 @@ func TestIN_05_GenerateRequiresMaterialAndRewrites(t *testing.T) {
require.NotNil(t, last.Draft) require.NotNil(t, last.Draft)
require.NotEmpty(t, last.Draft.Body) 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, "重寫")
require.Contains(t, out.Prompt, mat[:8]) require.Contains(t, out.Prompt, mat[:8])
// session 泡泡只留短紀錄,不是整包素材正文 // session 泡泡只留短紀錄,不是整包素材正文
user := out.Session.Messages[len(out.Session.Messages)-2] user := out.Session.Messages[len(out.Session.Messages)-2]

View File

@ -945,19 +945,17 @@ func inspireSystemRules(mode, lang string, maxChars int) string {
} }
if en { if en {
return strings.Join([]string{ return strings.Join([]string{
"You are a Threads rewriter, not an ideation partner.", "Rewrite 【Content to rewrite】 into one Threads post in the persona's voice.",
"Only rewrite 【Content to rewrite】 in the persona's voice into a post-ready body.", "Use 【Persona】 only for how they sound (tone / rhythm / wording). Just rewrite — do not over-engineer openings or force templates.",
"Do not invent a new topic or facts not in the material. No analysis, no titles, no markdown, no outline.", "Keep the material's topic and facts. 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. " + lenRuleEn + ". Do not invent brands not in material/pins.",
"Output ONLY the post body in the persona's language (match language fingerprint / samples). " + lenRuleEn + ". Do not invent brands not in material/pins.",
}, "\n") }, "\n")
} }
return strings.Join([]string{ return strings.Join([]string{
"你是 Threads「改寫者」不是發想者。", "依【待改寫內容】用人設口吻重寫成一則 Threads 正文。",
"只能依【待改寫內容】與【人設】寫出可貼出的正文。", "【人設】只決定「聽起來像誰」;直接重寫即可,不必設計固定開頭、不必套模板。",
"禁止另起新主題、加入素材沒有的情節或事實、長篇分析、標題前綴、markdown、條列大綱。", "主題與事實以素材為準。不要分析、標題、markdown、大綱。",
"可以:調整句式、節奏、口頭禪、情緒與結尾問句,使口氣符合人設。", "只輸出正文。" + lenRuleZh + "。未在素材pin 出現的品牌勿捏造。",
"只輸出正文,語言必須與【人設】語言指紋/範例一致(若人設是英文就用英文,是繁中就用繁中口語)。" + lenRuleZh + "。未在素材pin 出現的品牌勿捏造。",
}, "\n") }, "\n")
} }
// chat正常發想對話不限 Threads 字數、不強壓極短(完整討論) // chat正常發想對話不限 Threads 字數、不強壓極短(完整討論)
@ -1209,7 +1207,11 @@ func formatPersonaPromptBlock(p *PersonaSnapshot, mode, lang string) string {
fp := strings.TrimSpace(p.DraftText) fp := strings.TrimSpace(p.DraftText)
if fp != "" { 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(truncate(fp, 700))
b.WriteString("\n") b.WriteString("\n")
} }
@ -1221,7 +1223,11 @@ func formatPersonaPromptBlock(p *PersonaSnapshot, mode, lang string) string {
} }
} }
if len(lines) > 0 { 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(strings.Join(lines, "\n"))
b.WriteString("\n") b.WriteString("\n")
} }

View File

@ -105,3 +105,63 @@ func ApplyProgress(j *Job, percent int, summary string) error {
j.UpdatedAt = NowNano() j.UpdatedAt = NowNano()
return nil 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 從 1pageSize 預設 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
}

View File

@ -7,6 +7,8 @@ type Repository interface {
Update(ctx context.Context, j *Job) error Update(ctx context.Context, j *Job) error
FindByID(ctx context.Context, id string) (*Job, error) FindByID(ctx context.Context, id string) (*Job, error)
ListByOwner(ctx context.Context, ownerUID int64) ([]*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 picks oldest due pending/queued (run_after <= now) and sets running + workerID.
ClaimNext(ctx context.Context, workerID string) (*Job, error) ClaimNext(ctx context.Context, workerID string) (*Job, error)
// CancelPendingByRef cancels pending/queued jobs of template+ref (e.g. reschedule renew). // CancelPendingByRef cancels pending/queued jobs of template+ref (e.g. reschedule renew).

View File

@ -59,6 +59,50 @@ func (s *MemoryStore) ListByOwner(_ context.Context, ownerUID int64) ([]*domain.
return out, nil 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 descrecurring: 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) { func (s *MemoryStore) ClaimNext(_ context.Context, workerID string) (*domain.Job, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()

View File

@ -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) { func (s *MonStore) ListByOwner(ctx context.Context, ownerUID int64) ([]*domain.Job, error) {
var list []*domain.Job var list []*domain.Job
err := s.jobs.Find(ctx, &list, bson.M{"owner_uid": ownerUID}, 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 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) { func (s *MonStore) ClaimNext(ctx context.Context, workerID string) (*domain.Job, error) {
now := domain.NowNano() now := domain.NowNano()
// due: run_after missing/0 OR run_after <= now // due: run_after missing/0 OR run_after <= now

View File

@ -52,6 +52,11 @@ func (s *Service) List(ctx context.Context, ownerUID int64) ([]*domain.Job, erro
return s.Repo.ListByOwner(ctx, ownerUID) 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 // Get — JB-09
func (s *Service) Get(ctx context.Context, ownerUID int64, id string) (*domain.Job, error) { func (s *Service) Get(ctx context.Context, ownerUID int64, id string) (*domain.Job, error) {
j, err := s.Repo.FindByID(ctx, id) j, err := s.Repo.FindByID(ctx, id)

View File

@ -234,3 +234,43 @@ func TestJB_16_SchedulePersonaAnalyzeText(t *testing.T) {
require.Equal(t, "pe_txt", j.RefID) require.Equal(t, "pe_txt", j.RefID)
require.Contains(t, j.Payload, "raw_text") 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)
}

View File

@ -27,4 +27,14 @@ var (
ErrAlreadyBound = errors.New("already bound") ErrAlreadyBound = errors.New("already bound")
ErrInvalidPlatform = errors.New("invalid platform") ErrInvalidPlatform = errors.New("invalid platform")
ErrOAuthFailed = errors.New("oauth verification failed") 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")
) )

View File

@ -14,8 +14,11 @@ type Repository interface {
FindByUID(ctx context.Context, uid int64) (*Member, error) FindByUID(ctx context.Context, uid int64) (*Member, error)
FindByEmail(ctx context.Context, email string) (*Member, error) FindByEmail(ctx context.Context, email string) (*Member, error)
FindByPhone(ctx context.Context, phone 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 UpdateMember(ctx context.Context, m *Member) error
ListPage(ctx context.Context, page, pageSize int, query string, status string) (list []*Member, total int64, err 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) CountActiveAdmins(ctx context.Context) (int64, error)
// Identities (login channels) // Identities (login channels)

View File

@ -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}) 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 --- // --- identities ---
func (s *MoncStore) CreateIdentity(ctx context.Context, id *domain.Identity) error { func (s *MoncStore) CreateIdentity(ctx context.Context, id *domain.Identity) error {

View File

@ -157,6 +157,30 @@ func (f *fakeRepo) CountActiveAdmins(ctx context.Context) (int64, error) {
return n, nil 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 { func (f *fakeRepo) CreateIdentity(ctx context.Context, id *domain.Identity) error {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()

View File

@ -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-0206
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-0912
// 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
}

View File

@ -0,0 +1,171 @@
package usecase_test
// T219 — M6 invite integration (spec §9.16 IV-01IV-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)
}
}

View File

@ -2,6 +2,7 @@ package domain
import ( import (
"errors" "errors"
"strings"
"time" "time"
) )
@ -24,7 +25,11 @@ var (
ErrForbidden = errors.New("usage forbidden") ErrForbidden = errors.New("usage forbidden")
ErrNoKey = errors.New("no api key configured (platform or byok)") ErrNoKey = errors.New("no api key configured (platform or byok)")
ErrQuotaExceeded = errors.New("platform credits insufficient") 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 { type Event struct {
@ -57,18 +62,55 @@ type Purchase struct {
type PlanDef struct { type PlanDef struct {
ID string ID string
MonthlyCredits int MonthlyCredits int
// SoftCaps各 meter 點數硬上限platform加總 = MonthlyCredits
SoftCaps map[string]int
} }
// Plans — 與 FE PLANS 同步2026-07 · 組合毛利 ≥50%
// 安全單價約 $0.012Starter NT$590≈$18.4 / Pro NT$1990≈$62.2
// Free 120 點:夠用試用(可虧 ~$1.44 滿用);付費扛回組合毛利。
var Plans = map[string]PlanDef{ var Plans = map[string]PlanDef{
PlanFree: {ID: PlanFree, MonthlyCredits: 80}, PlanFree: {
PlanStarter: {ID: PlanStarter, MonthlyCredits: 500}, ID: PlanFree, MonthlyCredits: 120,
PlanPro: {ID: PlanPro, MonthlyCredits: 2000}, 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{ var DefaultCreditCost = map[string]int{
MeterAICopy: 1, MeterAIResearch: 3, MeterWebSearch: 1, MeterAIImage: 5, 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 NowNano() int64 { return time.Now().UTC().UnixNano() }
func MonthKey(nano int64) string { func MonthKey(nano int64) string {

View File

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

View File

@ -3,6 +3,8 @@ package usecase
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"time"
"apps/backend/internal/module/usage/domain" "apps/backend/internal/module/usage/domain"
@ -35,6 +37,9 @@ func (s *StaticResolver) Resolve(_ context.Context, uid int64, meter string) (st
type Service struct { type Service struct {
Repo domain.Repository Repo domain.Repository
Resolver KeyResolver 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 { 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 credits := 0
if keyMode == domain.KeyModePlatform { if keyMode == domain.KeyModePlatform {
credits = domain.DefaultCreditCost[meter] credits = domain.MeterCost(meter)
if credits == 0 { if err := s.checkPlatformQuota(ctx, uid, meter, credits); err != nil {
credits = 1 return nil, err
}
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
}
} }
} }
now := domain.NowNano() 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. // 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) { func (s *Service) PrepareCall(ctx context.Context, uid int64, meter string) (keyMode string, err error) {
if s.Resolver == nil { if s.Resolver == nil {
return "", domain.ErrNoKey return "", domain.ErrNoKey
@ -88,32 +84,75 @@ func (s *Service) PrepareCall(ctx context.Context, uid int64, meter string) (key
return "", domain.ErrNoKey return "", domain.ErrNoKey
} }
if mode == domain.KeyModePlatform { if mode == domain.KeyModePlatform {
prefs, _ := s.ensurePrefs(ctx, uid) if s.Gate != nil && !s.Gate.AllowPlatform(meter) {
if !prefs.Unlimited { return "", domain.ErrPlatformCapacity
sum, err := s.GetSummary(ctx, uid, domain.CurrentMonthKey()) }
if err != nil { cost := domain.MeterCost(meter)
return "", err if err := s.checkPlatformQuota(ctx, uid, meter, cost); err != nil {
} return "", err
cost := domain.DefaultCreditCost[meter]
if cost == 0 {
cost = 1
}
if sum.Platform.CreditsRemaining < cost {
return "", domain.ErrQuotaExceeded
}
} }
} }
return mode, nil 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) { func (s *Service) ensurePrefs(ctx context.Context, uid int64) (*domain.MemberPrefs, error) {
p, err := s.Repo.GetPrefs(ctx, uid) p, err := s.Repo.GetPrefs(ctx, uid)
if err != nil || p == nil { if err != nil || p == nil {
p = &domain.MemberPrefs{UID: uid, PlanID: domain.PlanFree, UpdatedAt: domain.NowNano()} p = &domain.MemberPrefs{UID: uid, PlanID: domain.PlanFree, UpdatedAt: domain.NowNano()}
_ = s.Repo.SavePrefs(ctx, p) _ = s.Repo.SavePrefs(ctx, p)
} }
if p.PlanID == "" { // 正規化 plan_id小寫有效方案
p.PlanID = domain.PlanFree 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 return p, nil
} }
@ -123,9 +162,12 @@ func (s *Service) GetSummary(ctx context.Context, uid int64, monthKey string) (*
monthKey = domain.CurrentMonthKey() monthKey = domain.CurrentMonthKey()
} }
prefs, _ := s.ensurePrefs(ctx, uid) prefs, _ := s.ensurePrefs(ctx, uid)
plan := domain.Plans[prefs.PlanID] plan := domain.ResolvePlan(prefs.PlanID)
if plan.ID == "" { // 回寫正規化 plan_id避免 DB 殘留大小寫/空白導致永遠 fallback Free
plan = domain.Plans[domain.PlanFree] 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) events, err := s.Repo.ListEvents(ctx, uid, monthKey, "all", 0)
if err != nil { if err != nil {
@ -153,16 +195,15 @@ func (s *Service) GetSummary(ctx context.Context, uid int64, monthKey string) (*
if remaining < 0 { if remaining < 0 {
remaining = 0 remaining = 0
} }
if prefs.Unlimited { // Unlimited 只影響是否擋用,顯示仍報真實已用/剩餘(前端以 unlimited 旗標顯示 ∞)
remaining = plan.MonthlyCredits // display full; not enforced
}
return &domain.MonthSummary{ 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{ Platform: domain.PlatformBlock{
CreditsUsed: platUsed, CreditsRemaining: remaining, CreditsTotal: plan.MonthlyCredits, CreditsUsed: platUsed, CreditsRemaining: remaining, CreditsTotal: plan.MonthlyCredits,
CallCount: platCalls, ByMeter: platBy, CallCount: platCalls, ByMeter: platBy,
}, },
Byok: domain.ByokBlock{CallCount: byokCalls, ByMeter: byokBy}, Byok: domain.ByokBlock{CallCount: byokCalls, ByMeter: byokBy},
// TotalCredits = 方案配給legacy 欄位名);已用請看 platform.credits_used
TotalCredits: plan.MonthlyCredits, RemainingCredits: remaining, TotalCredits: plan.MonthlyCredits, RemainingCredits: remaining,
}, nil }, nil
} }
@ -188,14 +229,15 @@ func (s *Service) SetPrefs(ctx context.Context, actorUID int64, targetUID int64,
} }
p, _ := s.ensurePrefs(ctx, targetUID) p, _ := s.ensurePrefs(ctx, targetUID)
if planID != "" { if planID != "" {
if _, ok := domain.Plans[planID]; !ok { id := strings.ToLower(strings.TrimSpace(planID))
if _, ok := domain.Plans[id]; !ok {
return nil, domain.ErrInvalidPlan return nil, domain.ErrInvalidPlan
} }
// member self can only change via purchase // member self can only change via purchase
if !isAdmin && actorUID == targetUID { if !isAdmin && actorUID == targetUID {
return nil, domain.ErrForbidden return nil, domain.ErrForbidden
} }
p.PlanID = planID p.PlanID = id
} }
if unlimited != nil && isAdmin { if unlimited != nil && isAdmin {
p.Unlimited = *unlimited 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) { 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 return nil, domain.ErrInvalidPlan
} }
p, _ := s.ensurePrefs(ctx, uid) p, _ := s.ensurePrefs(ctx, uid)
p.PlanID = planID p.PlanID = plan.ID
p.UpdatedAt = domain.NowNano() p.UpdatedAt = domain.NowNano()
_ = s.Repo.SavePrefs(ctx, p) if err := s.Repo.SavePrefs(ctx, p); err != nil {
return nil, err
}
pur := &domain.Purchase{ 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 { if err := s.Repo.InsertPurchase(ctx, pur); err != nil {
return nil, err return nil, err

View File

@ -2,7 +2,9 @@ package usecase_test
import ( import (
"context" "context"
"fmt"
"testing" "testing"
"time"
"apps/backend/internal/module/usage/domain" "apps/backend/internal/module/usage/domain"
"apps/backend/internal/module/usage/repository" "apps/backend/internal/module/usage/repository"
@ -13,15 +15,20 @@ import (
func newUsage(uid int64, mode string) *usecase.Service { func newUsage(uid int64, mode string) *usecase.Service {
r := repository.NewMemory() r := repository.NewMemory()
key := func(m string) string { return fmt.Sprintf("%d:%s", uid, m) }
res := &usecase.StaticResolver{Map: map[string]string{ res := &usecase.StaticResolver{Map: map[string]string{
"1000001:ai_copy": mode, key(domain.MeterAICopy): mode,
"1000001:web_search": mode, key(domain.MeterAIResearch): mode,
key(domain.MeterWebSearch): mode,
key(domain.MeterAIImage): mode,
}} }}
// also allow mixed tests // also allow mixed tests
if mode == "mixed" { if mode == "mixed" {
res.Map = map[string]string{ res.Map = map[string]string{
"1000001:ai_copy": domain.KeyModeByok, key(domain.MeterAICopy): domain.KeyModeByok,
"1000001:web_search": domain.KeyModePlatform, key(domain.MeterAIResearch): domain.KeyModeByok,
key(domain.MeterWebSearch): domain.KeyModePlatform,
key(domain.MeterAIImage): domain.KeyModeByok,
} }
} }
return usecase.New(r, res) return usecase.New(r, res)
@ -136,15 +143,73 @@ func TestST_09_NoKeyFails(t *testing.T) {
func TestST_13_PlatformQuota(t *testing.T) { func TestST_13_PlatformQuota(t *testing.T) {
svc := newUsage(1_000_001, domain.KeyModePlatform) svc := newUsage(1_000_001, domain.KeyModePlatform)
// burn free plan // free: ai_copy soft cap 60pt先燒滿分項
for i := 0; i < 80; i++ { for i := 0; i < 60; i++ {
_, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, domain.KeyModePlatform, "x", "t") _, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, domain.KeyModePlatform, "x", "t")
require.NoError(t, err) require.NoError(t, err)
} }
_, err := svc.PrepareCall(context.Background(), 1_000_001, domain.MeterAICopy) _, 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) 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) { func TestUSG_11_PurchasePlan(t *testing.T) {
svc := newUsage(1_000_001, domain.KeyModePlatform) svc := newUsage(1_000_001, domain.KeyModePlatform)
p, err := svc.PurchasePlan(context.Background(), 1_000_001, domain.PlanStarter, "mock-pay-1") 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) list, err := svc.ListMyPurchases(context.Background(), 1_000_001, 10)
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, list) 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) { func TestUSG_10_TenantAnalyticsSplit(t *testing.T) {

View File

@ -111,6 +111,17 @@ func mapError(err error) (int, Envelope) {
return http.StatusBadRequest, Envelope{Code: 400021, Message: err.Error()} return http.StatusBadRequest, Envelope{Code: 400021, Message: err.Error()}
case errors.Is(err, memberDomain.ErrSelfSuspend): case errors.Is(err, memberDomain.ErrSelfSuspend):
return http.StatusBadRequest, Envelope{Code: 400020, Message: err.Error()} return http.StatusBadRequest, Envelope{Code: 400020, Message: err.Error()}
// 邀請message 為 FE i18n keyinvite.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): case errors.Is(err, tokenDomain.ErrInvalidToken):
return http.StatusUnauthorized, Envelope{Code: 401002, Message: "invalid token"} return http.StatusUnauthorized, Envelope{Code: 401002, Message: "invalid token"}
case errors.Is(err, threadsDomain.ErrNotFound), errors.Is(err, jobDomain.ErrNotFound), 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)"} return http.StatusBadRequest, Envelope{Code: 400040, Message: "no api key configured (platform or byok)"}
case errors.Is(err, usageDomain.ErrQuotaExceeded): case errors.Is(err, usageDomain.ErrQuotaExceeded):
return http.StatusPaymentRequired, Envelope{Code: 402001, Message: "platform credits insufficient"} 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 限流/無容量:建議改 BYOKmessage = FE i18n key
return http.StatusServiceUnavailable, Envelope{Code: 503002, Message: err.Error()}
case errors.Is(err, usageDomain.ErrForbidden): case errors.Is(err, usageDomain.ErrForbidden):
return http.StatusForbidden, Envelope{Code: 403003, Message: "forbidden"} return http.StatusForbidden, Envelope{Code: 403003, Message: "forbidden"}
case errors.Is(err, usageDomain.ErrInvalidPlan): case errors.Is(err, usageDomain.ErrInvalidPlan):

View File

@ -121,6 +121,8 @@ func NewServiceContext(c config.Config) *ServiceContext {
logx.Info("usage: Platform.ExaKey empty — using fake-platform-exa for demo/proxy") 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) 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 憑證則真發文/同步) // M4 studio: mon store + publish transport有 Meta 憑證則真發文/同步)
var pub studioPublish.Transport var pub studioPublish.Transport

View File

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

View File

@ -93,6 +93,10 @@ type AiSettingsData struct {
ModelsError string `json:"models_error,optional"` ModelsError string `json:"models_error,optional"`
} }
type ApplyInviteCodeReq struct {
Code string `json:"code"`
}
type AuthBindReq struct { type AuthBindReq struct {
LoginId string `json:"login_id"` LoginId string `json:"login_id"`
Platform string `json:"platform"` Platform string `json:"platform"`
@ -361,8 +365,7 @@ type InspireChatReq struct {
Mode string `json:"mode,optional"` // chat|generate Mode string `json:"mode,optional"` // chat|generate
PersonaId string `json:"persona_id,optional"` PersonaId string `json:"persona_id,optional"`
SessionId string `json:"session_id,optional"` // 空 = active SessionId string `json:"session_id,optional"` // 空 = active
// generate待改寫素材鎖定內容 Material string `json:"material,optional"`
Material string `json:"material,optional"`
} }
type InspireDraftPublic struct { type InspireDraftPublic struct {
@ -470,12 +473,58 @@ type InspireSessionSummaryPublic struct {
Active bool `json:"active"` 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 { type JobIdPath struct {
Id string `path:"id"` Id string `path:"id"`
} }
type JobListData struct { 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 { type JobPublic struct {
@ -505,6 +554,10 @@ type ListModelsReq struct {
Provider string `form:"provider,optional"` Provider string `form:"provider,optional"`
} }
type ListParentCandidatesReq struct {
ExcludeSubtreeRootUid int64 `form:"exclude_subtree_root_uid,optional"`
}
type MediaUploadData struct { type MediaUploadData struct {
Url string `json:"url"` Url string `json:"url"`
Key string `json:"key,optional"` Key string `json:"key,optional"`
@ -588,6 +641,18 @@ type MentionSyncReq struct {
AccountId string `json:"account_id"` 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 { type NotificationIdPath struct {
Id string `path:"id"` Id string `path:"id"`
} }

View File

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

View File

@ -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 treeadmin 帳才過)
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 ==="

View File

@ -1,4 +1,5 @@
import { Navigate } from "react-router-dom"; import { Navigate } from "react-router-dom";
import { useI18n } from "../i18n/I18nContext";
import { can, Permission } from "../lib/permissions"; import { can, Permission } from "../lib/permissions";
import { useAuth } from "./AuthContext"; import { useAuth } from "./AuthContext";
@ -8,11 +9,12 @@ import { useAuth } from "./AuthContext";
*/ */
export function RequireAdmin({ children }: { children: React.ReactNode }) { export function RequireAdmin({ children }: { children: React.ReactNode }) {
const { member, loading } = useAuth(); const { member, loading } = useAuth();
const { t } = useI18n();
if (loading) { if (loading) {
return ( return (
<div className="hb-login"> <div className="hb-login">
<p className="text-muted"></p> <p className="text-muted">{t("common.loading")}</p>
</div> </div>
); );
} }

View File

@ -1,14 +1,16 @@
import { Navigate, useLocation } from "react-router-dom"; import { Navigate, useLocation } from "react-router-dom";
import { useI18n } from "../i18n/I18nContext";
import { useAuth } from "./AuthContext"; import { useAuth } from "./AuthContext";
export function RequireAuth({ children }: { children: React.ReactNode }) { export function RequireAuth({ children }: { children: React.ReactNode }) {
const { member, loading } = useAuth(); const { member, loading } = useAuth();
const location = useLocation(); const location = useLocation();
const { t } = useI18n();
if (loading) { if (loading) {
return ( return (
<div className="hb-login"> <div className="hb-login">
<p className="text-muted"></p> <p className="text-muted">{t("common.loading")}</p>
</div> </div>
); );
} }

View File

@ -45,11 +45,19 @@ export function UsagePlanWidget() {
} }
const planId = summary.plan.id as PlanId; 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 cap = summary.plan.monthly_credits;
const used = summary.total_credits; const used = summary.platform?.credits_used ?? summary.total_credits;
const left = Math.max(0, summary.remaining_credits); const left = summary.unlimited
const pct = cap > 0 ? Math.min(100, Math.round((used / cap) * 100)) : 0; ? 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 tight = !summary.unlimited && (pct >= 70 || used >= cap);
const over = !summary.unlimited && used > cap; const over = !summary.unlimited && used > cap;
const isFree = planId === "free"; const isFree = planId === "free";
@ -93,13 +101,19 @@ export function UsagePlanWidget() {
<span className="hb-usage-widget__stat hb-usage-widget__stat--warn"> <span className="hb-usage-widget__stat hb-usage-widget__stat--warn">
{t("usage.widget.overShort")} {t("usage.widget.overShort")}
</span> </span>
) : isFree && pct < 70 ? (
<span className="hb-usage-widget__stat hb-usage-widget__stat--cta">
{t("usage.widget.upgradeShort")}
</span>
) : ( ) : (
<span className="hb-usage-widget__stat"> // 一律顯示 已用/上限,避免「還剩 80」被看成「上限 80」Free 是 120
{t("usage.widget.leftShort", { n: left })} <span
className={[
"hb-usage-widget__stat",
isFree && pct < 70 ? "hb-usage-widget__stat--cta" : "",
tight ? "hb-usage-widget__stat--warn" : "",
]
.filter(Boolean)
.join(" ")}
title={t("usage.widget.titleUsed", { name: summary.plan.name, used, cap })}
>
{t("usage.widget.usedOfCap", { used, cap })}
</span> </span>
)} )}
</button> </button>
@ -162,6 +176,8 @@ export function UsagePlanWidget() {
<li key={b}>{b}</li> <li key={b}>{b}</li>
))} ))}
</ul> </ul>
<p className="hb-usage-widget__caps">{rights.approxCallsLine}</p>
<p className="hb-usage-widget__caps hb-usage-widget__caps--pts">{rights.softCapsLine}</p>
{tight ? <p className="hb-usage-widget__nudge">{t("usage.widget.nudge")}</p> : null} {tight ? <p className="hb-usage-widget__nudge">{t("usage.widget.nudge")}</p> : null}

View File

@ -1,7 +1,7 @@
import type { ReactNode, SVGProps } from "react"; import type { ReactNode, SVGProps } from "react";
import type { NavKey } from "../../lib/nav"; import type { NavKey } from "../../lib/nav";
export type AppIconName = NavKey | "more" | "spark"; export type AppIconName = NavKey | "more" | "spark" | "send";
type Props = { type Props = {
name: AppIconName; name: AppIconName;
@ -126,4 +126,14 @@ const glyphs: Record<AppIconName, ReactNode> = {
<circle cx="12" cy="12" r="1.1" fill="currentColor" opacity={0.35} /> <circle cx="12" cy="12" r="1.1" fill="currentColor" opacity={0.35} />
</> </>
), ),
// 送出:置中紙飛機(小圓鈕專用,無星芒避免破圖感)
send: (
<>
<path
d="M4 11.5 19.5 4.2a.7.7 0 0 1 .95.9L13.6 20.3a.7.7 0 0 1-1.28.08L9.7 14.1 4 11.5z"
{...stroke}
/>
<path d="M9.7 14.1 19.7 5" {...stroke} opacity={0.55} />
</>
),
}; };

View File

@ -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 <div className={`hb-md ${className}`.trim()} />;
}
const blocks = parseBlocks(source);
return (
<div className={`hb-md ${className}`.trim()}>
{blocks.map((block, i) => (
<Fragment key={i}>{renderBlock(block)}</Fragment>
))}
</div>
);
}
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 (
<div className="hb-md__line">
{block.text ? renderInline(block.text) : "\u00a0"}
</div>
);
case "gap":
return <div className="hb-md__gap" aria-hidden />;
case "h": {
const Tag = `h${block.level}` as "h1" | "h2" | "h3";
return (
<Tag className={`hb-md__h hb-md__h--${block.level}`}>
{renderInline(block.text)}
</Tag>
);
}
case "ul":
return (
<ul className="hb-md__ul">
{block.items.map((item, j) => (
<li key={j}>{renderInline(item)}</li>
))}
</ul>
);
case "ol":
return (
<ol className="hb-md__ol">
{block.items.map((item, j) => (
<li key={j}>{renderInline(item)}</li>
))}
</ol>
);
case "quote":
return (
<blockquote className="hb-md__quote">
{block.lines.map((line, j) => (
<div key={j} className="hb-md__line">
{line ? renderInline(line) : "\u00a0"}
</div>
))}
</blockquote>
);
case "code":
return (
<pre className="hb-md__pre" data-lang={block.lang || undefined}>
<code>{block.code}</code>
</pre>
);
case "hr":
return <hr className="hb-md__hr" />;
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(
<code key={`c${ci}`} className="hb-md__code">
{chunk.slice(1, -1)}
</code>,
);
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(
<Fragment key={`${keyPrefix}-p${idx++}`}>{text.slice(last, m.index)}</Fragment>,
);
}
const token = m[0];
if (token.startsWith("**") && token.endsWith("**")) {
nodes.push(
<strong key={`${keyPrefix}-b${idx++}`}>{token.slice(2, -2)}</strong>,
);
} else if (token.startsWith("~~") && token.endsWith("~~")) {
nodes.push(
<del key={`${keyPrefix}-d${idx++}`}>{token.slice(2, -2)}</del>,
);
} else if (token.startsWith("*") && token.endsWith("*")) {
nodes.push(
<em key={`${keyPrefix}-i${idx++}`}>{token.slice(1, -1)}</em>,
);
} else if (m[2] != null && m[3] != null) {
const href = sanitizeHref(m[3]);
if (href) {
nodes.push(
<a
key={`${keyPrefix}-a${idx++}`}
href={href}
target="_blank"
rel="noopener noreferrer"
className="hb-md__a"
>
{m[2]}
</a>,
);
} else {
nodes.push(
<Fragment key={`${keyPrefix}-al${idx++}`}>{m[2]}</Fragment>,
);
}
}
last = m.index + token.length;
}
if (last < text.length) {
nodes.push(<Fragment key={`${keyPrefix}-e`}>{text.slice(last)}</Fragment>);
}
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;
}

View File

@ -12,3 +12,4 @@ export type { BadgeTone } from "./Badge";
export { EmptyState } from "./EmptyState"; export { EmptyState } from "./EmptyState";
export { Pager } from "./Pager"; export { Pager } from "./Pager";
export { AccountAvatar, mockAvatarUrl } from "./AccountAvatar"; export { AccountAvatar, mockAvatarUrl } from "./AccountAvatar";
export { MarkdownText } from "./MarkdownText";

View File

@ -27,7 +27,7 @@ export function PlanPricingGrid({
<div className="hb-pricing"> <div className="hb-pricing">
{(Object.keys(PLANS) as PlanId[]).map((id) => { {(Object.keys(PLANS) as PlanId[]).map((id) => {
const p = PLANS[id]; const p = PLANS[id];
const rights = getPlanRights(id, t); const rights = getPlanRights(id, t, formatPrice);
const current = currentId === id; const current = currentId === id;
const popular = id === highlighted; const popular = id === highlighted;
const cta = planCtaLabel(currentId, id, t); const cta = planCtaLabel(currentId, id, t);
@ -51,6 +51,8 @@ export function PlanPricingGrid({
<p className="hb-pricing__credits"> <p className="hb-pricing__credits">
{t("plans.creditsPerMonth", { n: p.monthly_credits })} {t("plans.creditsPerMonth", { n: p.monthly_credits })}
</p> </p>
<p className="hb-pricing__softcaps">{rights.approxCallsLine}</p>
<p className="hb-pricing__softcaps hb-pricing__softcaps--pts">{rights.softCapsLine}</p>
</header> </header>
<ul className="hb-pricing__bullets"> <ul className="hb-pricing__bullets">

View File

@ -4,8 +4,11 @@ import { METER_META, type UsageMeter } from "../../lib/usageMeter";
export type MeterBarRow = { export type MeterBarRow = {
meter: UsageMeter; meter: UsageMeter;
/** 已用點數platform only */
credits: number; credits: number;
/** 平台呼叫次數(不含 byok */
count: number; count: number;
/** 分項點數硬上限(與 PLANS.soft_caps 同步,單位=點) */
soft_cap: number; soft_cap: number;
}; };
@ -15,7 +18,8 @@ type Props = {
}; };
/** /**
* hover * = / monthly_credits
* hover
*/ */
export function UsageMeterBars({ rows, unlimited = false }: Props) { export function UsageMeterBars({ rows, unlimited = false }: Props) {
const { t } = useI18n(); const { t } = useI18n();
@ -28,17 +32,18 @@ export function UsageMeterBars({ rows, unlimited = false }: Props) {
<ul className="hb-usage-mbars" onMouseLeave={() => setActive(null)}> <ul className="hb-usage-mbars" onMouseLeave={() => setActive(null)}>
{ordered.map((row) => { {ordered.map((row) => {
const label = t(`usage.meter.${row.meter}`); 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 = const pct =
row.soft_cap > 0 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; : 0;
const on = active === row.meter; const on = active === row.meter;
const over = row.soft_cap > 0 && row.count > row.soft_cap;
return ( return (
<li key={row.meter}> <li key={row.meter}>
<button <button
type="button" type="button"
className={`hb-usage-mbar${on ? " is-on" : ""}${over ? " is-over" : ""}`} className={`hb-usage-mbar${on ? " is-on" : ""}${over || atCap ? " is-over" : ""}`}
onMouseEnter={() => setActive(row.meter)} onMouseEnter={() => setActive(row.meter)}
onFocus={() => setActive(row.meter)} onFocus={() => setActive(row.meter)}
aria-label={t("usage.meter.barAria", { aria-label={t("usage.meter.barAria", {
@ -52,14 +57,25 @@ export function UsageMeterBars({ rows, unlimited = false }: Props) {
<span className="hb-usage-mbar__track" aria-hidden> <span className="hb-usage-mbar__track" aria-hidden>
<span <span
className="hb-usage-mbar__fill" className="hb-usage-mbar__fill"
style={{ width: `${Math.max(pct, row.count > 0 ? 3 : 0)}%` }} style={{ width: `${Math.max(pct, row.credits > 0 ? 3 : 0)}%` }}
/> />
</span> </span>
<span className="hb-usage-mbar__val"> <span className="hb-usage-mbar__val">
<strong>{row.count}</strong> <strong>{row.credits}</strong>
<span className="text-muted">/{row.soft_cap}</span> <span className="text-muted">/{row.soft_cap}</span>
{on ? <span className="hb-usage-mbar__pts">{row.credits}pt</span> : null} <span className="hb-usage-mbar__unit">{t("usage.meter.pt")}</span>
{unlimited && over ? <span className="hb-usage-mbar__inf"></span> : null} {on ? (
<span className="hb-usage-mbar__pts">
{t("usage.meter.timesShort", { n: row.count })}
</span>
) : null}
{unlimited ? (
<span className="hb-usage-mbar__inf"></span>
) : over ? (
<span className="hb-usage-mbar__over">{t("usage.meter.over")}</span>
) : atCap ? (
<span className="hb-usage-mbar__over">{t("usage.meter.locked")}</span>
) : null}
</span> </span>
</button> </button>
</li> </li>

View File

@ -58,8 +58,10 @@ export function JobLiveProvider({ children }: { children: ReactNode }) {
return; return;
} }
try { try {
const list = await repos.jobs.list(); // 只輪詢「執行中」分桶,避免一次拉全歷史
const res = await repos.jobs.list({ tab: "active", page: 1, pageSize: 50 });
if (!mounted.current) return; if (!mounted.current) return;
const list = res.list;
setJobs(list); setJobs(list);
const fp = fingerprint(list); const fp = fingerprint(list);
if (fp !== fpRef.current) { if (fp !== fpRef.current) {

View File

@ -1,16 +1,7 @@
import type { Repos } from "./repos"; import type { Repos } from "./repos";
import { createMockInviteRepo } from "./mock/inviteRepo";
import { createLiveRepos } from "./live/repos"; import { createLiveRepos } from "./live/repos";
/** /** 一律 live API含邀請 M6。 */
* live API
* inviteRepolocal mock seedcreateMock
*/
export function createRepos(): Repos { export function createRepos(): Repos {
const repos = createLiveRepos(); return createLiveRepos();
return {
...repos,
dataSource: "live",
invite: createMockInviteRepo(),
};
} }

View File

@ -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<string, unknown>): 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<string, unknown>): InviteTreeNode {
const childrenRaw = Array.isArray(raw.children) ? raw.children : [];
return {
...mapBrief(raw),
children: childrenRaw.map((c) => mapTree(c as Record<string, unknown>)),
};
}
function mapNetwork(raw: Record<string, unknown>): MyInviteNetwork {
const me = mapBrief((raw.me || {}) as Record<string, unknown>);
const uplineRaw = raw.upline as Record<string, unknown> | 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<string, unknown>)),
total_downline_count: Number(raw.total_downline_count ?? me.total_downline_count ?? 0),
};
}
export function createLiveInviteRepo(): InviteRepo {
return {
async getMyNetwork() {
const data = await apiRequest<Record<string, unknown>>("/api/v1/invite/me");
return mapNetwork(data);
},
async applyInviteCode(code) {
const data = await apiRequest<Record<string, unknown>>("/api/v1/invite/apply", {
method: "POST",
body: { code },
});
return mapNetwork(data);
},
async getTree() {
const data = await apiRequest<{ list?: Record<string, unknown>[] }>("/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<Record<string, unknown>>("/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<string, unknown>[] }>(
`/api/v1/invite/parent-candidates${q}`,
);
return (data.list ?? []).map((b) => mapBrief(b));
},
};
}

View File

@ -1,6 +1,5 @@
/** /**
* Live repository implementations gateway * Live repository implementations gateway
* invite createRepos local stubM6
*/ */
import type { import type {
@ -49,13 +48,13 @@ import type {
ThreadsPlatformStatus, ThreadsPlatformStatus,
UsageRepo, UsageRepo,
} from "../repos"; } from "../repos";
import { createMockInviteRepo } from "../mock/inviteRepo";
import { import {
createLiveInspiration, createLiveInspiration,
createLiveMedia, createLiveMedia,
createLiveResearch, createLiveResearch,
createLiveScout, createLiveScout,
} from "./m5Repos"; } from "./m5Repos";
import { createLiveInviteRepo } from "./inviteRepo";
import { apiRequest, loadTokens, saveTokens, type StoredTokens } from "./http"; import { apiRequest, loadTokens, saveTokens, type StoredTokens } from "./http";
function uidToString(uid: unknown): string { function uidToString(uid: unknown): string {
@ -533,9 +532,41 @@ function createLiveAccounts(): AccountsRepo {
function createLiveJobs(): JobsRepo { function createLiveJobs(): JobsRepo {
return { return {
async list() { async list(query) {
const data = await apiRequest<{ list: Record<string, unknown>[] }>("/api/v1/jobs"); const tab = query?.tab ?? "active";
return (data.list ?? []).map(mapJob); 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<string, unknown>[];
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) { async get(id) {
try { try {
@ -589,17 +620,22 @@ function createLiveNotifications(): NotificationsRepo {
function mapMeterBlock( function mapMeterBlock(
plan: (typeof PLANS)[PlanId], plan: (typeof PLANS)[PlanId],
platformBy: Record<string, unknown> | undefined, platformBy: Record<string, unknown> | undefined,
byokBy: Record<string, unknown> | undefined, _byokBy: Record<string, unknown> | undefined,
): UsageMonthSummary["by_meter"] { ): UsageMonthSummary["by_meter"] {
const by_meter = {} as UsageMonthSummary["by_meter"]; const by_meter = {} as UsageMonthSummary["by_meter"];
for (const m of Object.keys(plan.soft_caps) as UsageMeter[]) { for (const m of Object.keys(plan.soft_caps) as UsageMeter[]) {
const p = (platformBy?.[m] as Record<string, unknown> | undefined) ?? {}; const p = (platformBy?.[m] as Record<string, unknown> | undefined) ?? {};
const b = (byokBy?.[m] as Record<string, unknown> | undefined) ?? {}; // 上限對照只看 platformbyok 不進分項鎖/進度(只出現在 ledger
const credits = Number(p.credits ?? 0); const credits = Number(p.credits ?? 0);
const count = Number(p.count ?? 0) + Number(b.count ?? 0); const platformCount = Number(p.count ?? 0);
const soft = plan.soft_caps[m] || 1; const soft = plan.soft_caps[m] || 1; // 分項點數上限(非次數)
const pct = soft > 0 ? Math.min(999, Math.round((count / soft) * 100)) : 0; const pct = soft > 0 ? Math.min(999, Math.round((credits / soft) * 100)) : 0;
by_meter[m] = { credits, count, soft_cap: soft, pct }; by_meter[m] = {
credits,
count: platformCount,
soft_cap: soft,
pct,
};
} }
return by_meter; return by_meter;
} }
@ -622,17 +658,19 @@ function createLiveUsage(): UsageRepo {
async getSummary(monthKey) { async getSummary(monthKey) {
const q = monthKey ? `?month_key=${encodeURIComponent(monthKey)}` : ""; const q = monthKey ? `?month_key=${encodeURIComponent(monthKey)}` : "";
const raw = await apiRequest<Record<string, unknown>>(`/api/v1/usage/summary${q}`); const raw = await apiRequest<Record<string, unknown>>(`/api/v1/usage/summary${q}`);
const planId = String(raw.plan_id ?? "free") as PlanId; // plan_id 正規化;配給一律以 FE PLANS 為準(與後端 domain.Plans 同步數字)
const plan = PLANS[planId] ?? PLANS.free; // 不可盲信後端舊 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<string, unknown>) ?? {}; const platform = (raw.platform as Record<string, unknown>) ?? {};
const byok = (raw.byok as Record<string, unknown>) ?? {}; const byok = (raw.byok as Record<string, unknown>) ?? {};
const total = Number(raw.total_credits ?? platform.credits_total ?? plan.monthly_credits); const cap = plan.monthly_credits;
const remaining = Number( const used = Number(platform.credits_used ?? 0);
raw.remaining_credits ?? platform.credits_remaining ?? plan.monthly_credits, const remaining = Boolean(raw.unlimited)
); ? Math.max(0, Number(platform.credits_remaining ?? Math.max(0, cap - used)))
const used = Number(platform.credits_used ?? total - remaining); : Math.max(0, cap - used);
const pct = const pct = cap > 0 ? Math.min(999, Math.round((used / cap) * 100)) : 0;
total > 0 ? Math.min(999, Math.round((used / total) * 100)) : 0;
const platBy = (platform.by_meter as Record<string, unknown> | undefined) ?? {}; const platBy = (platform.by_meter as Record<string, unknown> | undefined) ?? {};
const byokBy = (byok.by_meter as Record<string, unknown> | undefined) ?? {}; const byokBy = (byok.by_meter as Record<string, unknown> | undefined) ?? {};
@ -652,18 +690,20 @@ function createLiveUsage(): UsageRepo {
events = []; events = [];
} }
// 狀態列 AISearch顯示「點數」才與方案配給同一單位不是混加次數
const ai_calls = const ai_calls =
(by_meter.ai_copy?.count ?? 0) + (by_meter.ai_copy?.credits ?? 0) +
(by_meter.ai_research?.count ?? 0) + (by_meter.ai_research?.credits ?? 0) +
(by_meter.ai_image?.count ?? 0); (by_meter.ai_image?.credits ?? 0);
const search_calls = by_meter.web_search?.count ?? 0; const search_calls = by_meter.web_search?.credits ?? 0;
return { return {
month_key: String(raw.month_key ?? ""), month_key: String(raw.month_key ?? ""),
uid: String(raw.uid ?? ""), uid: String(raw.uid ?? ""),
plan, plan,
unlimited: Boolean(raw.unlimited), unlimited: Boolean(raw.unlimited),
total_credits: total, // FE 慣例total_credits = 本月已用platform 點數);配給看 plan.monthly_credits
total_credits: used,
remaining_credits: remaining, remaining_credits: remaining,
pct, pct,
ai_calls, ai_calls,
@ -673,7 +713,7 @@ function createLiveUsage(): UsageRepo {
platform: { platform: {
credits_used: used, credits_used: used,
credits_remaining: remaining, credits_remaining: remaining,
credits_total: total, credits_total: cap,
call_count: Number(platform.call_count ?? 0), call_count: Number(platform.call_count ?? 0),
}, },
byok: { call_count: Number(byok.call_count ?? 0) }, byok: { call_count: Number(byok.call_count ?? 0) },
@ -694,8 +734,9 @@ function createLiveUsage(): UsageRepo {
}, },
async getMemberPrefs() { async getMemberPrefs() {
const raw = await apiRequest<Record<string, unknown>>("/api/v1/usage/prefs"); const raw = await apiRequest<Record<string, unknown>>("/api/v1/usage/prefs");
const id = String(raw.plan_id ?? "free").toLowerCase().trim();
return { return {
plan_id: String(raw.plan_id ?? "free") as PlanId, plan_id: (id in PLANS ? id : "free") as PlanId,
unlimited: Boolean(raw.unlimited), unlimited: Boolean(raw.unlimited),
}; };
}, },
@ -715,12 +756,57 @@ function createLiveUsage(): UsageRepo {
async getTenantSummary(monthKey) { async getTenantSummary(monthKey) {
const q = monthKey ? `?month_key=${encodeURIComponent(monthKey)}` : ""; const q = monthKey ? `?month_key=${encodeURIComponent(monthKey)}` : "";
const raw = await apiRequest<Record<string, unknown>>(`/api/v1/usage/tenant-summary${q}`); const raw = await apiRequest<Record<string, unknown>>(`/api/v1/usage/tenant-summary${q}`);
// 補顯示名稱admin members
const nameByUid = new Map<string, { email: string; display_name: string }>();
try {
const admin = await apiRequest<{
list?: Record<string, unknown>[];
}>("/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<string, unknown>[]) ?? []).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 { return {
month_key: String(raw.month_key ?? ""), month_key: String(raw.month_key ?? ""),
total_credits: Number(raw.platform_credits_used ?? 0), total_credits: consumed,
purchased_credits: 0, purchased_credits: purchasedSum,
consumed_credits: Number(raw.platform_credits_used ?? 0), consumed_credits: consumed,
remaining_credits: 0, remaining_credits: Math.max(0, purchasedSum - consumed),
ai_calls: 0, ai_calls: 0,
search_calls: Number(raw.byok_call_count ?? 0), search_calls: Number(raw.byok_call_count ?? 0),
by_meter: { by_meter: {
@ -729,36 +815,40 @@ function createLiveUsage(): UsageRepo {
web_search: { credits: 0, count: 0 }, web_search: { credits: 0, count: 0 },
ai_image: { credits: 0, count: 0 }, ai_image: { credits: 0, count: 0 },
}, },
members: ((raw.members as Record<string, unknown>[]) ?? []).map((m) => ({ members,
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,
})),
}; };
}, },
async getTenantAnalytics() { async getTenantAnalytics(query) {
const sum = await this.getTenantSummary(); const sum = await this.getTenantSummary();
const purchased = sum.purchased_credits || 1;
return { return {
granularity: "month" as const, granularity: (query?.granularity ?? "month") as "day" | "month" | "year",
from: "", from: query?.from ?? "",
to: "", to: query?.to ?? "",
range_label: sum.month_key, range_label: sum.month_key,
purchased_credits: 0, purchased_credits: sum.purchased_credits,
consumed_credits: sum.consumed_credits, consumed_credits: sum.consumed_credits,
remaining_credits: 0, remaining_credits: sum.remaining_credits,
pct: 0, pct:
ai_calls: 0, 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, search_calls: sum.search_calls,
by_meter: sum.by_meter, 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, members: sum.members,
}; };
}, },
@ -1436,7 +1526,6 @@ function createLiveMentions(): MentionsRepo {
} }
export function createLiveRepos(): Repos { export function createLiveRepos(): Repos {
// 除邀請外全部 live不再 fallback mock seed
return { return {
dataSource: "live", dataSource: "live",
auth: createLiveAuth(), auth: createLiveAuth(),
@ -1446,7 +1535,7 @@ export function createLiveRepos(): Repos {
jobs: createLiveJobs(), jobs: createLiveJobs(),
notifications: createLiveNotifications(), notifications: createLiveNotifications(),
usage: createLiveUsage(), usage: createLiveUsage(),
invite: createMockInviteRepo(), invite: createLiveInviteRepo(),
personas: createLivePersonas(), personas: createLivePersonas(),
plays: createLivePlays(), plays: createLivePlays(),
outbox: createLiveOutbox(), outbox: createLiveOutbox(),

View File

@ -59,6 +59,8 @@ export const KEYS = {
activePersonaId: "harbor.active_persona_id", activePersonaId: "harbor.active_persona_id",
activeBrandId: "harbor.active_brand_id", activeBrandId: "harbor.active_brand_id",
ownPostsSyncedAt: "harbor.own_posts_synced_at", ownPostsSyncedAt: "harbor.own_posts_synced_at",
/** 帳號成效 sync 閘:{ [accountId]: { lastSyncMs, pastOnceDone } } */
ownPostsSyncMeta: "harbor.own_posts.sync_meta",
/** 帳號月度分析歷史 AccountInsightsSnapshot[] */ /** 帳號月度分析歷史 AccountInsightsSnapshot[] */
accountInsightHistory: "harbor.account.insight_history", accountInsightHistory: "harbor.account.insight_history",
} as const; } as const;

View File

@ -223,8 +223,31 @@ export type OutboxRepo = {
remove(id: string): Promise<void>; remove(id: string): Promise<void>;
}; };
/** 任務列表分桶:定期排程 / 執行中 / 歷史 */
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 = { export type JobsRepo = {
list(): Promise<Job[]>; /**
* query tab+page query active 50
* JobListResult .list
*/
list(query?: JobListQuery): Promise<JobListResult>;
get(id: string): Promise<Job | null>; get(id: string): Promise<Job | null>;
startDemo(): Promise<Job>; startDemo(): Promise<Job>;
/** 手動刪除(執行中不可刪;後端終態約 2 天後也會自動清) */ /** 手動刪除(執行中不可刪;後端終態約 2 天後也會自動清) */

View File

@ -118,6 +118,19 @@ export function saveInsightSnapshot(snap: AccountInsightsSnapshot): AccountInsig
return snap; 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<MonthBucket, "posts">): boolean {
return (month.posts || 0) > 0;
}
function snapshotFromParts(opts: { function snapshotFromParts(opts: {
accountId: string; accountId: string;
month: MonthBucket; month: MonthBucket;
@ -159,48 +172,80 @@ function snapshotFromParts(opts: {
} }
/** /**
* report N force * report
* - posts=0 snapshot
* - force
*/ */
export function ensureInsightHistory( export function ensureInsightHistory(
report: AccountInsightsReport, report: AccountInsightsReport,
opts?: { forceCurrent?: boolean }, opts?: { forceCurrent?: boolean },
): AccountInsightsSnapshot[] { ): AccountInsightsSnapshot[] {
const months = report.months; const months = report.months;
const topHighlights = report.topPosts.slice(0, 3).map((p) => { const monthKeys = new Set(months.map((m) => m.key));
const head = (p.insight || p.formula_summary || p.text).slice(0, 80);
return `${p.topic_tag ? `${p.topic_tag}` : ""}${head}${head.length >= 80 ? "…" : ""}`; // 清掉已不在視窗外、或空月卻殘留假結論的紀錄
}); 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++) { for (let i = 0; i < months.length; i++) {
const month = months[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 existing = getInsightSnapshot(report.accountId, month.key);
const isCurrent = i === months.length - 1; const isCurrent = i === months.length - 1;
if (existing && !(isCurrent && opts?.forceCurrent)) continue; if (existing && !(isCurrent && opts?.forceCurrent)) continue;
const delta = { const monthTop = topPostsForMonth(
views: previous ? pctChange(month.views, previous.views) : null, // report.topPosts 是全帳本月 top歷史月用 metrics 敘事 + 空 highlights 即可
likes: previous ? pctChange(month.likes, previous.likes) : null, isCurrent ? report.topPosts : [],
replies: previous ? pctChange(month.replies, previous.replies) : null, month.key,
posts: previous ? pctChange(month.posts, previous.posts) : null, 3,
engagementRate: previous );
? pctChange(month.engagementRate * 100, previous.engagementRate * 100) // 若是本月topPosts 已是本月;仍再 filter 保險
: null, 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 = buildNarrative({
const narrative = current: month,
isCurrent && !opts?.forceCurrent && report.analysis.length > 0 previous,
? { analysis: report.analysis, recommendations: report.recommendations } delta: {
: buildNarrative({ views: previous ? pctChange(month.views, previous.views) : null,
current: month, likes: previous ? pctChange(month.likes, previous.likes) : null,
previous, replies: previous ? pctChange(month.replies, previous.replies) : null,
delta, posts: previous ? pctChange(month.posts, previous.posts) : null,
topPosts: isCurrent ? report.topPosts : [], engagementRate: previous
avgEngagementRate: isCurrent ? report.avgEngagementRate : month.engagementRate, ? pctChange(month.engagementRate * 100, previous.engagementRate * 100)
totalPosts: isCurrent ? report.totalPosts : month.posts, : null,
monthLabel: month.label, },
}); 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( saveInsightSnapshot(
snapshotFromParts({ snapshotFromParts({
@ -209,14 +254,7 @@ export function ensureInsightHistory(
previous, previous,
analysis: narrative.analysis, analysis: narrative.analysis,
recommendations: narrative.recommendations, recommendations: narrative.recommendations,
topHighlights: isCurrent topHighlights: highlights,
? topHighlights
: existing?.top_highlights?.length
? existing.top_highlights
: [
`${month.label} 互動率 ${Math.round(month.engagementRate * 1000) / 10}%`,
`瀏覽 ${month.views.toLocaleString("zh-TW")} · 回覆 ${month.replies}`,
],
existingId: existing?.id, existingId: existing?.id,
}), }),
); );
@ -237,6 +275,34 @@ function engagementOf(p: OwnPost): number {
return eng / views; 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 { function emptyBucket(key: string, source: MonthBucket["source"] = "estimate"): MonthBucket {
return { return {
key, key,
@ -278,15 +344,8 @@ function pctChange(curr: number, prev: number): number | null {
return Math.round(((curr - prev) / prev) * 1000) / 10; 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 便 * N 0
* live snapshot
*/ */
export function buildAccountInsights( export function buildAccountInsights(
accountId: string, accountId: string,
@ -300,8 +359,10 @@ export function buildAccountInsights(
for (const k of keys) byMonth.set(k, emptyBucket(k, "posts")); for (const k of keys) byMonth.set(k, emptyBucket(k, "posts"));
for (const p of mine) { for (const p of mine) {
const d = new Date(Math.floor(p.published_at / 1_000_000)); // published_at = unix ns異常 0 則歸入本月,避免整批落在窗外
const key = monthKeyFromDate(d); 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; if (!byMonth.has(key)) continue;
const b = byMonth.get(key)!; const b = byMonth.get(key)!;
b.posts += 1; b.posts += 1;
@ -314,72 +375,29 @@ export function buildAccountInsights(
b.source = "posts"; b.source = "posts";
} }
// 有真實數據的月份均值,用來補齊空月 for (const k of keys) {
const real = keys byMonth.set(k, finalizeBucket(byMonth.get(k)!));
.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));
} }
const months = keys.map((k) => byMonth.get(k)!); const months = keys.map((k) => byMonth.get(k)!);
const current = months[months.length - 1]!; 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 = { const delta = {
views: previous ? pctChange(current.views, previous.views) : null, views: previous && monthHasRealData(current) ? pctChange(current.views, previous.views) : null,
likes: previous ? pctChange(current.likes, previous.likes) : null, likes: previous && monthHasRealData(current) ? pctChange(current.likes, previous.likes) : null,
replies: previous ? pctChange(current.replies, previous.replies) : null, replies:
posts: previous ? pctChange(current.posts, previous.posts) : null, previous && monthHasRealData(current) ? pctChange(current.replies, previous.replies) : null,
engagementRate: previous posts: previous && monthHasRealData(current) ? pctChange(current.posts, previous.posts) : null,
? pctChange(current.engagementRate * 100, previous.engagementRate * 100) engagementRate:
: null, previous && monthHasRealData(current)
? pctChange(current.engagementRate * 100, previous.engagementRate * 100)
: null,
}; };
const topPosts = mine const topPosts = topPostsForMonth(mine, current.key, 5);
.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 withViews = mine.filter((p) => (p.view_count || 0) > 0); const withViews = mine.filter((p) => (p.view_count || 0) > 0);
const avgEngagementRate = withViews.length const avgEngagementRate = withViews.length
@ -410,6 +428,9 @@ export function buildAccountInsights(
}; };
} }
/**
*
*/
function buildNarrative(opts: { function buildNarrative(opts: {
current: MonthBucket; current: MonthBucket;
previous: MonthBucket | null; previous: MonthBucket | null;
@ -419,79 +440,73 @@ function buildNarrative(opts: {
totalPosts: number; totalPosts: number;
monthLabel?: string; monthLabel?: string;
}): { analysis: string[]; recommendations: 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 when = opts.monthLabel || current.label || "本月";
const analysis: string[] = []; const analysis: string[] = [];
const recommendations: string[] = []; const recommendations: string[] = [];
if (totalPosts === 0 && current.posts === 0) { // 空月:完全不說話
return { if (!monthHasRealData(current)) {
analysis: [`${when}:尚無足夠貼文數據,無法比較月成效。`], return { analysis: [], recommendations: [] };
recommendations: ["先同步我的貼文,或該月至少發 12 則再分析。"],
};
} }
analysis.push( 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) { if (delta.views > 8) {
analysis.push( analysis.push(
`瀏覽較前月 ${fmtDelta(delta.views)}${previous.views.toLocaleString("zh-TW")}${current.views.toLocaleString("zh-TW")})。`, `瀏覽較前月 ${fmtDelta(delta.views)}${previous.views.toLocaleString("zh-TW")}${current.views.toLocaleString("zh-TW")})。`,
); );
} else if (delta.views < -8) { } else if (delta.views < -8) {
analysis.push( analysis.push(
`瀏覽較前月 ${fmtDelta(delta.views)},曝光偏弱,宜檢查發文節奏與開場鉤子`, `瀏覽較前月 ${fmtDelta(delta.views)}${previous.views.toLocaleString("zh-TW")}${current.views.toLocaleString("zh-TW")}`,
); );
} else { } else {
analysis.push(`瀏覽與前月大致持平(${fmtDelta(delta.views)})。`); analysis.push(`瀏覽與前月大致持平(${fmtDelta(delta.views)})。`);
} }
} }
if (delta.replies != null) { if (previous && monthHasRealData(previous) && delta.replies != null) {
if (delta.replies > 10) { if (delta.replies > 10) {
analysis.push(`回覆數 ${fmtDelta(delta.replies)},對話熱度上升,適合延續可接話題材`); analysis.push(`回覆數 ${fmtDelta(delta.replies)},對話熱度上升`);
} else if (delta.replies < -10) { } else if (delta.replies < -10) {
analysis.push(`回覆數 ${fmtDelta(delta.replies)},可多試「求經驗/明確條件」開問`); analysis.push(`回覆數 ${fmtDelta(delta.replies)}`);
} }
} }
const engPct = Math.round(avgEngagementRate * 1000) / 10; // 有瀏覽才談互動率,避免 0 瀏覽硬算 0% 再亂評
analysis.push( if (current.views > 0) {
`互動率約 ${engPct}%(讚+回+轉+引用+分享瀏覽。Threads 上 ${engPct >= 4 ? "屬不錯" : engPct >= 2 ? "中等,還有拉高空間" : "偏低,優先優化鉤子與問句"}`, const engPct = Math.round(avgEngagementRate * 1000) / 10;
); analysis.push(
`互動率約 ${engPct}%(讚+回+轉+引用+分享/瀏覽)。`,
);
if (engPct < 2) {
recommendations.push("互動率偏低:可多試帶明確條件的提問收尾。");
} else if (engPct >= 4) {
recommendations.push("互動率不錯:可複製高表現貼的結構再測 12 則。");
}
} else if (current.likes + current.replies > 0) {
analysis.push("此月有讚/回覆,但瀏覽為 0Insights 可能尚未回傳或權限不足)。");
}
const top = topPosts[0]; const top = topPosts[0];
if (top) { if (top) {
const tag = top.topic_tag ? `${top.topic_tag}` : "高互動"; const snippet = (top.insight || top.formula_summary || top.text).slice(0, 72);
analysis.push( 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) { if (current.posts < 3) {
recommendations.push("該月發文偏少,可固定每週 23 則,曲線與月比才比較得準。"); recommendations.push("該月貼文偏少,樣本小,月比僅供參考。");
} else {
recommendations.push("下月回看本頁歷史:對照瀏覽與回覆是否同向,避免只追讚數。");
} }
return { return {
analysis: analysis.slice(0, 5), analysis: analysis.slice(0, 5),
recommendations: recommendations.slice(0, 4), recommendations: recommendations.slice(0, 3),
}; };
} }

View File

@ -26,6 +26,9 @@ export const API_CODES = {
NOT_FOUND: 404001, NOT_FOUND: 404001,
EMAIL_NOT_REGISTERED: 404002, EMAIL_NOT_REGISTERED: 404002,
EMAIL_TAKEN: 409001, EMAIL_TAKEN: 409001,
QUOTA: 402001,
METER_CAP: 402002,
PLATFORM_CAPACITY: 503002,
SERVER: 500000, SERVER: 500000,
NOT_IMPLEMENTED: 501000, NOT_IMPLEMENTED: 501000,
NETWORK: 0, NETWORK: 0,
@ -47,12 +50,16 @@ export const API_ERROR_I18N_KEY: Record<number, string> = {
[API_CODES.NOT_FOUND]: "api.err.404001", [API_CODES.NOT_FOUND]: "api.err.404001",
[API_CODES.EMAIL_NOT_REGISTERED]: "api.err.404002", [API_CODES.EMAIL_NOT_REGISTERED]: "api.err.404002",
[API_CODES.EMAIL_TAKEN]: "api.err.409001", [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.SERVER]: "api.err.500000",
[API_CODES.NOT_IMPLEMENTED]: "api.err.501000", [API_CODES.NOT_IMPLEMENTED]: "api.err.501000",
[API_CODES.NETWORK]: "api.err.network", [API_CODES.NETWORK]: "api.err.network",
// 400050/400060 不進表:後端 message 常是具體中文原因(人設分析等),由 rawMessage 顯示 // 400050/400060 不進表:後端 message 常是具體中文原因(人設分析等),由 rawMessage 顯示
400061: "api.err.crawlerSession", 400061: "api.err.crawlerSession",
503001: "api.err.timeoutAI", 503001: "api.err.timeoutAI",
// 503002 = PLATFORM_CAPACITY已在上方 API_CODES 映射
}; };
export function i18nKeyForApiCode(code: number): string | undefined { export function i18nKeyForApiCode(code: number): string | undefined {
@ -143,6 +150,9 @@ export function messageForApiCode(
locale?: AppLocale, locale?: AppLocale,
): string { ): string {
const loc = locale ?? currentLocale(); const loc = locale ?? currentLocale();
// 後端業務錯誤直接回 i18n keyinvite.err.* / usage.err.*
const fb = (fallback || "").trim();
if (fb.startsWith("invite.err.") || fb.startsWith("usage.err.")) return translate(loc, fb);
const key = i18nKeyForApiCode(code); const key = i18nKeyForApiCode(code);
if (key) return translate(loc, key); if (key) return translate(loc, key);
if (isUserFacingBizMessage(fallback)) return cleanBizMessage(fallback!); 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") { if (e && typeof e === "object" && e.name === "ApiError") {
const code = e.code != null ? Number(e.code) : NaN; const code = e.code != null ? Number(e.code) : NaN;
const raw = e.rawMessage || e.message; 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)) { if (!Number.isNaN(code)) {
const key = i18nKeyForApiCode(code); const key = i18nKeyForApiCode(code);
// 驗證類業務碼:優先後端中文原因 // 驗證類業務碼:優先後端中文原因

View File

@ -67,6 +67,9 @@ export const zhTW: MessageDict = {
"api.err.404001": "找不到資料", "api.err.404001": "找不到資料",
"api.err.404002": "查無此信箱,請確認後再試", "api.err.404002": "查無此信箱,請確認後再試",
"api.err.409001": "此 Email 已被註冊", "api.err.409001": "此 Email 已被註冊",
"api.err.402001": "本月平台點數已用完,請升級方案或下月再試",
"usage.err.meterCap": "此功能本月點數已達上限,請改用其他功能或升級方案",
"usage.err.platformCapacity": "平台 AI搜尋目前忙碌或已限流。可稍後再試或到設定填入自己的 API KeyBYOK繼續使用",
"api.err.500000": "伺服器發生錯誤,請稍後再試", "api.err.500000": "伺服器發生錯誤,請稍後再試",
"api.err.timeoutAI": "AI 回應逾時(模型思考太久)。可縮短結構備註後再試,或到設定換較快的模型", "api.err.timeoutAI": "AI 回應逾時(模型思考太久)。可縮短結構備註後再試,或到設定換較快的模型",
"api.err.501000": "此功能尚未實作", "api.err.501000": "此功能尚未實作",
@ -140,14 +143,14 @@ export const zhTW: MessageDict = {
"settings.localeCurrency": "語言與幣別", "settings.localeCurrency": "語言與幣別",
"settings.locale": "介面語言", "settings.locale": "介面語言",
"settings.currency": "顯示幣別", "settings.currency": "顯示幣別",
"settings.currencyHint": "", "settings.currencyHint": "金額顯示用;方案計費仍以台幣為準。",
"settings.localeSaved": "語言與幣別已更新", "settings.localeSaved": "語言與幣別已更新",
"settings.appearance": "外觀", "settings.appearance": "外觀",
"settings.theme": "主題", "settings.theme": "主題",
"settings.themeLight": "淺色", "settings.themeLight": "淺色",
"settings.themeDark": "深色", "settings.themeDark": "深色",
"settings.themeSystem": "跟隨系統", "settings.themeSystem": "跟隨系統",
"settings.themeHint": "", "settings.themeHint": "可選淺色、深色,或跟隨系統外觀。",
"settings.themeSaved": "主題已更新", "settings.themeSaved": "主題已更新",
"settings.themeToLight": "切換成淺色", "settings.themeToLight": "切換成淺色",
"settings.themeToDark": "切換成深色", "settings.themeToDark": "切換成深色",
@ -180,14 +183,14 @@ export const zhTW: MessageDict = {
"settings.threadsGoCrew": "前往 Crew 連帳", "settings.threadsGoCrew": "前往 Crew 連帳",
"settings.member": "會員與登入", "settings.member": "會員與登入",
"settings.usageCard": "AI搜尋額度", "settings.usageCard": "AI搜尋額度",
"settings.usageCardHint": "", "settings.usageCardHint": "平台代付點數與方案上限;自備 Key 不占平台點。",
"settings.viewUsage": "查看用量", "settings.viewUsage": "查看用量",
"settings.editProfile": "編輯會員資料", "settings.editProfile": "編輯會員資料",
"settings.mockLogin": "demo@harbor.local / demo", "settings.mockLogin": "demo@harbor.local / demo",
"settings.memberHint": "", "settings.memberHint": "登入帳號、顯示名稱與通知偏好。",
"usage.title": "用量與方案", "usage.title": "用量與方案",
"usage.desc": "", "usage.desc": "查看本月平台點數、分項用量,並變更方案。",
"usage.creditsUsed": "本月已用點數", "usage.creditsUsed": "本月已用點數",
"usage.remaining": "剩餘 {n} 點", "usage.remaining": "剩餘 {n} 點",
"usage.percentUsed": "{n}% 已使用", "usage.percentUsed": "{n}% 已使用",
@ -199,17 +202,23 @@ export const zhTW: MessageDict = {
"usage.perMonth": "點/月", "usage.perMonth": "點/月",
"usage.ledger": "最近使用紀錄", "usage.ledger": "最近使用紀錄",
"usage.emptyTitle": "本月還沒有扣點", "usage.emptyTitle": "本月還沒有扣點",
"usage.emptyDesc": "", "usage.emptyDesc": "開始創作、海巡或生圖後,這裡會出現扣點紀錄。",
"usage.planNote": "", "usage.planNote": "自然月重置;未用完點數不累積至下月。",
"usage.switched": "已切換為 {name}", "usage.switched": "已切換為 {name}",
"usage.meter.times": "{count} 次 · {credits} 點", "usage.meter.times": "{count} 次 · {credits} 點",
"usage.meter.cap": "單項參考上限 {count}/{cap} 次", "usage.meter.timesShort": "{n} 次",
"usage.plan.free.blurb": "試用與個人輕量經營", "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.starter.blurb": "小團隊日常發文與海巡",
"usage.plan.pro.blurb": "多帳、重度 AI 與研究", "usage.plan.pro.blurb": "多帳、重度 AI 與研究",
"profile.title": "會員資料", "profile.title": "會員資料",
"profile.desc": "", "profile.desc": "管理顯示名稱、頭像與密碼。",
"profile.accountStatus": "帳號開通狀態", "profile.accountStatus": "帳號開通狀態",
"profile.basic": "基本資料", "profile.basic": "基本資料",
"profile.avatar": "頭像", "profile.avatar": "頭像",
@ -315,7 +324,7 @@ export const zhTW: MessageDict = {
"invite.err.codeSelf": "不能填自己的邀請碼", "invite.err.codeSelf": "不能填自己的邀請碼",
"admin.users.title": "島民管理", "admin.users.title": "島民管理",
"admin.users.desc": "", "admin.users.desc": "管理島民帳號、權限、方案與不擋額度。",
"admin.users.needAdmin": "需要管理員權限", "admin.users.needAdmin": "需要管理員權限",
"admin.users.list": "島民列表 · {n}", "admin.users.list": "島民列表 · {n}",
"admin.users.detail": "島民詳情", "admin.users.detail": "島民詳情",
@ -357,35 +366,47 @@ export const zhTW: MessageDict = {
"crew.msg.refreshFail": "延長失敗token 失效時請重新「連接帳號」)", "crew.msg.refreshFail": "延長失敗token 失效時請重新「連接帳號」)",
"crew.msg.oauthOk": "Threads 帳號已連線;已排程約 30 天後自動延長 token見任務", "crew.msg.oauthOk": "Threads 帳號已連線;已排程約 30 天後自動延長 token見任務",
"crew.msg.oauthFail": "OAuth 連線失敗", "crew.msg.oauthFail": "OAuth 連線失敗",
"crew.msg.oauthUrlFail": "無法取得授權網址,請稍後再試或檢查平台 Threads 設定",
"crew.msg.deleted": "已移除 @{user}",
"crew.confirmDelete": "確定刪除帳號 @{user}\n刪除後無法再選為 lead / cast。", "crew.confirmDelete": "確定刪除帳號 @{user}\n刪除後無法再選為 lead / cast。",
"today.findTopic": "找話題", "today.findTopic": "找話題",
"today.refreshTopics": "刷新話題",
"today.reload": "重新整理",
"today.loadFail": "無法載入今日資料",
"today.trendsFail": "無法刷新話題(可能額度不足或搜尋未設定)",
"today.syncPosts": "同步已發文",
"today.syncPostsFail": "同步貼文失敗",
"today.needAccount": "請先連接 Threads 帳號",
"today.pendingReplies": "待回覆", "today.pendingReplies": "待回覆",
"today.pendingRepliesN": "待回覆 · {n}", "today.pendingRepliesN": "待回覆 · {n}",
"today.newThread": "開新串", "today.newThread": "寫一則",
"today.metricsAria": "今日數值", "today.metricsAria": "今日數值",
"today.metric.pending": "待回", "today.metric.pending": "待回",
"today.metric.pendingHint": "海巡佇列", "today.metric.pendingHint": "海巡佇列",
"today.metric.doneGoal": "已回/目標", "today.metric.doneGoal": "已回/目標",
"today.metric.doneGoalHint": "今日海巡完成數/目標(海巡標記已發會累加)",
"today.metric.sentToday": "今日已發", "today.metric.sentToday": "今日已發",
"today.metric.running": "進行中 {n}", "today.metric.running": "進行中 {n}",
"today.metric.sentDone": "完成的發送", "today.metric.sentDone": "完成的發送",
"today.metric.failed": "發送異常", "today.metric.failed": "發送異常",
"today.metric.needAction": "需處理", "today.metric.needAction": "需處理",
"today.metric.ok": "正常", "today.metric.ok": "正常",
"today.pending.title": "待回覆 · {n}", "today.metric.mentions": "提及",
"today.pending.empty": "目前沒有待回", "today.metric.mentionsHint": "待回提及",
"today.pending.title": "海巡待回 · {n}",
"today.pending.empty": "目前沒有待回,去海巡掃一輪",
"today.goScout": "去海巡", "today.goScout": "去海巡",
"today.pending.more": "還有 {n} 則 →", "today.pending.more": "還有 {n} 則 →",
"today.pending.handle": "海巡處理", "today.pending.handle": "海巡處理",
"today.pending.start": "開始處理", "today.pending.start": "開始處理",
"today.topics.title": "找話題", "today.topics.title": "找話題",
"today.topics.empty": "還沒有話題", "today.topics.empty": "還沒有話題,按「刷新話題」抓一輪",
"today.goStudio": "去創作", "today.goStudio": "去靈感",
"today.heat": "熱度 {n}", "today.heat": "熱度 {n}",
"today.topicAngle": "可當開場角度", "today.topicAngle": "可當開場角度",
"today.moreInspire": "更多靈感", "today.moreInspire": "更多靈感",
"today.useTopic": "用話題開串", "today.useTopic": "用話題發想",
"today.outbox.title": "今日發送", "today.outbox.title": "今日發送",
"today.outbox.empty": "還沒有今日發送。可先", "today.outbox.empty": "還沒有今日發送。可先",
"today.outbox.emptyMid": ",完成後會出現在", "today.outbox.emptyMid": ",完成後會出現在",
@ -394,9 +415,10 @@ export const zhTW: MessageDict = {
"today.badge.failed": "失敗", "today.badge.failed": "失敗",
"today.badge.scheduling": "排程", "today.badge.scheduling": "排程",
"today.badge.sending": "發送中", "today.badge.sending": "發送中",
"today.badge.drafted": "已草稿",
"today.openOutbox": "開啟發送", "today.openOutbox": "開啟發送",
"today.accounts.title": "帳號成效", "today.accounts.title": "帳號成效",
"today.accounts.empty": "尚無成效", "today.accounts.empty": "尚無成效資料,可先同步已發文",
"today.postsCount": "{n} 則貼文", "today.postsCount": "{n} 則貼文",
"today.views": "瀏覽", "today.views": "瀏覽",
"today.likes": "讚", "today.likes": "讚",
@ -405,6 +427,7 @@ export const zhTW: MessageDict = {
"today.viewPosts": "看已發文", "today.viewPosts": "看已發文",
"today.manageAccounts": "管理帳號", "today.manageAccounts": "管理帳號",
"outbox.title": "發送", "outbox.title": "發送",
"outbox.tabsAria": "發送分頁", "outbox.tabsAria": "發送分頁",
"outbox.tab.active": "進行中", "outbox.tab.active": "進行中",
@ -706,7 +729,7 @@ export const zhTW: MessageDict = {
"metrics.type.post": "貼文", "metrics.type.post": "貼文",
"jobs.title": "任務", "jobs.title": "任務",
"jobs.desc": "背景任務列表。含定期任務(例如 Threads Token 約每 30 天自動延長一次)。已完成/失敗/取消的任務約保留 2 天後自動清除,也可手動刪除。", "jobs.desc": "背景任務分三區:執行中、定期排程、歷史。每頁可調筆數,不會一次拉完全部。",
"jobs.startDemo": "產生測試任務", "jobs.startDemo": "產生測試任務",
"jobs.demoHint": "需 worker 執行;狀態會自動輪詢更新。", "jobs.demoHint": "需 worker 執行;狀態會自動輪詢更新。",
"jobs.demoLabel": "Demo 測試任務", "jobs.demoLabel": "Demo 測試任務",
@ -731,6 +754,14 @@ export const zhTW: MessageDict = {
"jobs.status.cancel_requested": "取消中", "jobs.status.cancel_requested": "取消中",
"jobs.loadFail": "無法載入任務列表", "jobs.loadFail": "無法載入任務列表",
"jobs.empty": "尚無任務", "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.total": "共 {n} 筆",
"jobs.showing": " · 顯示前 {n}", "jobs.showing": " · 顯示前 {n}",
"jobs.detail": "詳情", "jobs.detail": "詳情",
@ -765,45 +796,46 @@ export const zhTW: MessageDict = {
"plan.cta.downgrade": "降級", "plan.cta.downgrade": "降級",
"plan.cta.switch": "切換", "plan.cta.switch": "切換",
"plan.free.headline": "試用與個人輕量經營", "plan.free.headline": "夠用試用,體驗完整流程",
"plan.free.bullet1": "每月 {n} 點 AI搜尋", "plan.free.bullet1": "每月 {n} 點(約 23 週輕度日常)",
"plan.free.bullet2": "完整功能:創作、海巡、發送", "plan.free.bullet2": "完整功能:創作、海巡、發送、生圖",
"plan.free.bullet3": "適合先熟悉流程", "plan.free.bullet3": "用得出價值再升級 Starter",
"plan.free.bullet4": "可隨時升級", "plan.free.bullet4": "平台忙碌時可改填自己的 Key",
"plan.free.right1": "可使用完整功能:帳號、創作、海巡、發送、任務與靈感。", "plan.free.right1": "可使用完整功能:帳號、創作、海巡、發送、任務與靈感。",
"plan.free.right2": "每月固定點數,用在文案、研究、搜尋與生圖。", "plan.free.right2": "點數夠你真實試跑文案、搜尋與幾次生圖,不是空殼 demo。",
"plan.free.right3": "達上限後需等下月或升級(除非管理員開不擋額度)。", "plan.free.right3": "達上限後升級 Starter 繼續;或設定自備 KeyBYOK不占平台點。",
"plan.free.quota1": "每月配給 {n} 點。", "plan.free.quota1": "每月配給 {n} 點。",
"plan.free.quota2": "參考:文案 {copy}、研究 {research}、搜尋 {search}、生圖 {image} 次。",
"plan.free.note1": "無需付款,確認即切換。", "plan.free.note1": "無需付款,確認即切換。",
"plan.free.note2": "正式金流後的發票規則另訂。", "plan.free.note2": "正式金流後的發票規則另訂。",
"plan.starter.headline": "小團隊日常發文與海巡", "plan.starter.headline": "小團隊日常發文與海巡",
"plan.starter.bullet1": "每月 {n} 點(約 6× Free", "plan.starter.bullet1": "每月 {n} 點(約 5× Free",
"plan.starter.bullet2": "穩定發文、回覆、海巡", "plan.starter.bullet2": "穩定發文、回覆、海巡",
"plan.starter.bullet3": "適合 13 人節奏", "plan.starter.bullet3": "適合 13 人節奏 · 付費主力",
"plan.starter.bullet4": "付款成功立即生效", "plan.starter.bullet4": "付款成功立即生效",
"plan.starter.right1": "付款成功後本帳改為 Starter當月依新額度計算。", "plan.starter.right1": "付款成功後本帳改為 Starter當月依新額度計算。",
"plan.starter.right2": "點數支撐固定發文、回覆草稿與定期海巡。", "plan.starter.right2": "點數支撐固定發文、回覆草稿與定期海巡。",
"plan.starter.right3": "功能與 Free 相同,差在能用多久。", "plan.starter.right3": "功能與 Free 相同,差在能用多久;日常節奏建議由此開始。",
"plan.starter.quota1": "每月 {n} 點 · {price}。", "plan.starter.quota1": "每月 {n} 點 · {price}。",
"plan.starter.quota2": "參考:文案 {copy}、研究 {research}、搜尋 {search}、生圖 {image} 次。",
"plan.starter.note1": "需付款成功才變更方案。", "plan.starter.note1": "需付款成功才變更方案。",
"plan.starter.note2": "自然月重置,未用完點數不累積至下月。", "plan.starter.note2": "自然月重置,未用完點數不累積至下月。",
"plan.pro.headline": "多帳、重度 AI 與研究", "plan.pro.headline": "多帳、重度 AI 與研究",
"plan.pro.bullet1": "每月 {n} 點(約 4× Starter", "plan.pro.bullet1": "每月 {n} 點(約 3× Starter",
"plan.pro.bullet2": "高頻文案/研究/生圖", "plan.pro.bullet2": "高頻文案/研究/生圖 · 重度天花板",
"plan.pro.bullet3": "適合代理與多品牌", "plan.pro.bullet3": "適合代理與多品牌",
"plan.pro.bullet4": "付款成功立即生效", "plan.pro.bullet4": "付款成功立即生效",
"plan.pro.right1": "付款成功後本帳改為 Pro當月依 Pro 額度計算。", "plan.pro.right1": "付款成功後本帳改為 Pro當月依 Pro 額度計算。",
"plan.pro.right2": "適合多帳、大量回覆與深研究,減少中途額度見底。", "plan.pro.right2": "適合多帳、大量回覆與深研究,減少中途額度見底。",
"plan.pro.right3": "功能相同;買的是容量與節奏。", "plan.pro.right3": "功能相同;買的是容量。再高可改 BYOK平台限流時也不中斷。",
"plan.pro.quota1": "每月 {n} 點 · {price}。", "plan.pro.quota1": "每月 {n} 點 · {price}。",
"plan.pro.quota2": "參考:文案 {copy}、研究 {research}、搜尋 {search}、生圖 {image} 次。",
"plan.pro.note1": "付款失敗不會改方案。", "plan.pro.note1": "付款失敗不會改方案。",
"plan.pro.note2": "付款完成後可於帳務紀錄查詢收據。", "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.title": "確認方案",
"checkout.pickFirst": "請先選擇方案。", "checkout.pickFirst": "請先選擇方案。",
"checkout.viewPlans": "看方案", "checkout.viewPlans": "看方案",
@ -838,6 +870,7 @@ export const zhTW: MessageDict = {
"usage.widget.monthUsage": "本月用量", "usage.widget.monthUsage": "本月用量",
"usage.widget.remaining": "還剩 {n} 點", "usage.widget.remaining": "還剩 {n} 點",
"usage.widget.leftShort": "還剩 {n}", "usage.widget.leftShort": "還剩 {n}",
"usage.widget.usedOfCap": "{used}/{cap}",
"usage.widget.overShort": "已超額", "usage.widget.overShort": "已超額",
"usage.widget.upgradeShort": "升級", "usage.widget.upgradeShort": "升級",
"usage.widget.upgrade": "升級方案", "usage.widget.upgrade": "升級方案",
@ -850,7 +883,7 @@ export const zhTW: MessageDict = {
"usage.meter.ai_research": "AI 研究", "usage.meter.ai_research": "AI 研究",
"usage.meter.web_search": "搜尋", "usage.meter.web_search": "搜尋",
"usage.meter.ai_image": "AI 生圖", "usage.meter.ai_image": "AI 生圖",
"usage.meter.barAria": "{label} {count} 次 {credits} 點,上限 {cap}", "usage.meter.barAria": "{label} 已用 {credits} 點/上限 {cap} 點({count} 次)",
"usage.ledger.costAria": "消耗 {n} 點", "usage.ledger.costAria": "消耗 {n} 點",
"usage.event.keyMode.platform": "平台點數", "usage.event.keyMode.platform": "平台點數",
"usage.event.keyMode.byok": "自備 Key", "usage.event.keyMode.byok": "自備 Key",
@ -1168,6 +1201,24 @@ export const zhTW: MessageDict = {
"insights.postStats": "瀏覽 {views} · 讚 {likes} · 回 {replies}", "insights.postStats": "瀏覽 {views} · 讚 {likes} · 回 {replies}",
"insights.openThreads": "開啟 Threads", "insights.openThreads": "開啟 Threads",
"insights.zeroPct": "0%", "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.tabOwn": "我的貼文",
"plays.tabLink": "Threads 連結", "plays.tabLink": "Threads 連結",
@ -1236,6 +1287,7 @@ export const zhTW: MessageDict = {
"inspire.trendsLabel": "話題靈感", "inspire.trendsLabel": "話題靈感",
"inspire.trendsHint": "網搜彙整,非官方熱搜 · 找靈感會扣搜尋點數", "inspire.trendsHint": "網搜彙整,非官方熱搜 · 找靈感會扣搜尋點數",
"inspire.trendsSeed": "示意", "inspire.trendsSeed": "示意",
"inspire.topicSeed": "想寫跟「{topic}」有關的 Threads幫我發想開場與角度。",
"inspire.trendsEmpty": "點「找靈感」才會搜尋(扣點)", "inspire.trendsEmpty": "點「找靈感」才會搜尋(扣點)",
"inspire.refreshConfirm": "找靈感會消耗 1 次「搜尋」點數,確定?", "inspire.refreshConfirm": "找靈感會消耗 1 次「搜尋」點數,確定?",
"inspire.refreshOk": "已更新 {n} 則話題靈感", "inspire.refreshOk": "已更新 {n} 則話題靈感",
@ -1521,6 +1573,11 @@ export const zhTW: MessageDict = {
"usage.currentPlan": "目前方案", "usage.currentPlan": "目前方案",
"usage.planMeta": "/月 · 每月 {n} 點額度", "usage.planMeta": "/月 · 每月 {n} 點額度",
"usage.changePlan": "變更方案", "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.usedThisMonth": "本月已用",
"usage.remainLabel": "剩餘", "usage.remainLabel": "剩餘",
"usage.ledgerToggle": "使用紀錄", "usage.ledgerToggle": "使用紀錄",
@ -1664,6 +1721,9 @@ export const en: MessageDict = {
"api.err.404001": "Not found", "api.err.404001": "Not found",
"api.err.404002": "This email is not registered", "api.err.404002": "This email is not registered",
"api.err.409001": "This email is already 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.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.timeoutAI": "AI timed out. Shorten structure notes, or pick a faster model in Settings",
"api.err.501000": "This feature is not implemented yet", "api.err.501000": "This feature is not implemented yet",
@ -1736,14 +1796,14 @@ export const en: MessageDict = {
"settings.localeCurrency": "Language & currency", "settings.localeCurrency": "Language & currency",
"settings.locale": "Interface language", "settings.locale": "Interface language",
"settings.currency": "Display currency", "settings.currency": "Display currency",
"settings.currencyHint": "", "settings.currencyHint": "Display only; plan billing stays in TWD.",
"settings.localeSaved": "Language and currency updated", "settings.localeSaved": "Language and currency updated",
"settings.appearance": "Appearance", "settings.appearance": "Appearance",
"settings.theme": "Theme", "settings.theme": "Theme",
"settings.themeLight": "Light", "settings.themeLight": "Light",
"settings.themeDark": "Dark", "settings.themeDark": "Dark",
"settings.themeSystem": "System", "settings.themeSystem": "System",
"settings.themeHint": "", "settings.themeHint": "Light, dark, or follow system appearance.",
"settings.themeSaved": "Theme updated", "settings.themeSaved": "Theme updated",
"settings.themeToLight": "Switch to light", "settings.themeToLight": "Switch to light",
"settings.themeToDark": "Switch to dark", "settings.themeToDark": "Switch to dark",
@ -1777,14 +1837,14 @@ export const en: MessageDict = {
"settings.threadsGoCrew": "Open Crew to connect", "settings.threadsGoCrew": "Open Crew to connect",
"settings.member": "Account & sign-in", "settings.member": "Account & sign-in",
"settings.usageCard": "AI / search quota", "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.viewUsage": "View usage",
"settings.editProfile": "Edit profile", "settings.editProfile": "Edit profile",
"settings.mockLogin": "demo@harbor.local / demo", "settings.mockLogin": "demo@harbor.local / demo",
"settings.memberHint": "", "settings.memberHint": "Sign-in account, display name, and notification prefs.",
"usage.title": "Usage & plans", "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.creditsUsed": "Credits used this month",
"usage.remaining": "{n} left", "usage.remaining": "{n} left",
"usage.percentUsed": "{n}% used", "usage.percentUsed": "{n}% used",
@ -1796,17 +1856,23 @@ export const en: MessageDict = {
"usage.perMonth": "credits / mo", "usage.perMonth": "credits / mo",
"usage.ledger": "Recent usage", "usage.ledger": "Recent usage",
"usage.emptyTitle": "No usage this month", "usage.emptyTitle": "No usage this month",
"usage.emptyDesc": "", "usage.emptyDesc": "After you create, patrol, or generate images, usage events show up here.",
"usage.planNote": "", "usage.planNote": "Resets each calendar month; unused credits do not roll over.",
"usage.switched": "Switched to {name}", "usage.switched": "Switched to {name}",
"usage.meter.times": "{count} runs · {credits} credits", "usage.meter.times": "{count} runs · {credits} credits",
"usage.meter.cap": "Soft cap {count}/{cap} runs", "usage.meter.timesShort": "{n} runs",
"usage.plan.free.blurb": "Trial and light personal use", "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.starter.blurb": "Small teams posting and patrolling daily",
"usage.plan.pro.blurb": "Multi-account, heavy AI and research", "usage.plan.pro.blurb": "Multi-account, heavy AI and research",
"profile.title": "Profile", "profile.title": "Profile",
"profile.desc": "", "profile.desc": "Manage display name, avatar, and password.",
"profile.accountStatus": "Account status", "profile.accountStatus": "Account status",
"profile.basic": "Basics", "profile.basic": "Basics",
"profile.avatar": "Avatar", "profile.avatar": "Avatar",
@ -1912,7 +1978,7 @@ export const en: MessageDict = {
"invite.err.codeSelf": "You cant use your own code", "invite.err.codeSelf": "You cant use your own code",
"admin.users.title": "Islanders", "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.needAdmin": "Admin access required",
"admin.users.list": "Islanders · {n}", "admin.users.list": "Islanders · {n}",
"admin.users.detail": "Islander detail", "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.refreshFail": "Extend failed (reconnect if token is invalid)",
"crew.msg.oauthOk": "Threads connected; token renew scheduled ~day 30 (see Jobs)", "crew.msg.oauthOk": "Threads connected; token renew scheduled ~day 30 (see Jobs)",
"crew.msg.oauthFail": "OAuth connection failed", "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.", "crew.confirmDelete": "Delete account @{user}?\nIt can no longer be used as lead / cast.",
"today.findTopic": "Find topics", "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.pendingReplies": "Pending replies",
"today.pendingRepliesN": "Pending · {n}", "today.pendingRepliesN": "Pending · {n}",
"today.newThread": "New thread", "today.newThread": "Compose",
"today.metricsAria": "Today metrics", "today.metricsAria": "Today metrics",
"today.metric.pending": "Pending", "today.metric.pending": "Pending",
"today.metric.pendingHint": "Patrol queue", "today.metric.pendingHint": "Patrol queue",
"today.metric.doneGoal": "Done / goal", "today.metric.doneGoal": "Done / goal",
"today.metric.doneGoalHint": "Patrol completions today / goal (mark published counts)",
"today.metric.sentToday": "Sent today", "today.metric.sentToday": "Sent today",
"today.metric.running": "{n} in progress", "today.metric.running": "{n} in progress",
"today.metric.sentDone": "Completed sends", "today.metric.sentDone": "Completed sends",
"today.metric.failed": "Send issues", "today.metric.failed": "Send issues",
"today.metric.needAction": "Needs action", "today.metric.needAction": "Needs action",
"today.metric.ok": "All good", "today.metric.ok": "All good",
"today.pending.title": "Pending replies · {n}", "today.metric.mentions": "Mentions",
"today.pending.empty": "Nothing pending", "today.metric.mentionsHint": "Pending mentions",
"today.pending.title": "Patrol pending · {n}",
"today.pending.empty": "Nothing pending — run a patrol",
"today.goScout": "Go patrol", "today.goScout": "Go patrol",
"today.pending.more": "{n} more →", "today.pending.more": "{n} more →",
"today.pending.handle": "Handle in patrol", "today.pending.handle": "Handle in patrol",
"today.pending.start": "Start handling", "today.pending.start": "Start handling",
"today.topics.title": "Find topics", "today.topics.title": "Find topics",
"today.topics.empty": "No topics yet", "today.topics.empty": "No topics yet — tap Refresh topics",
"today.goStudio": "Go to Studio", "today.goStudio": "Open inspire",
"today.heat": "Heat {n}", "today.heat": "Heat {n}",
"today.topicAngle": "Good opening angle", "today.topicAngle": "Good opening angle",
"today.moreInspire": "More inspiration", "today.moreInspire": "More inspiration",
"today.useTopic": "Start from topic", "today.useTopic": "Brainstorm topic",
"today.outbox.title": "Today's outbox", "today.outbox.title": "Today's outbox",
"today.outbox.empty": "No sends today. Try", "today.outbox.empty": "No sends today. Try",
"today.outbox.emptyMid": ", then check", "today.outbox.emptyMid": ", then check",
@ -1991,9 +2069,10 @@ export const en: MessageDict = {
"today.badge.failed": "Failed", "today.badge.failed": "Failed",
"today.badge.scheduling": "Scheduled", "today.badge.scheduling": "Scheduled",
"today.badge.sending": "Sending", "today.badge.sending": "Sending",
"today.badge.drafted": "Drafted",
"today.openOutbox": "Open outbox", "today.openOutbox": "Open outbox",
"today.accounts.title": "Account performance", "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.postsCount": "{n} posts",
"today.views": "Views", "today.views": "Views",
"today.likes": "Likes", "today.likes": "Likes",
@ -2002,6 +2081,7 @@ export const en: MessageDict = {
"today.viewPosts": "View posts", "today.viewPosts": "View posts",
"today.manageAccounts": "Manage accounts", "today.manageAccounts": "Manage accounts",
"outbox.title": "Outbox", "outbox.title": "Outbox",
"outbox.tabsAria": "Outbox tabs", "outbox.tabsAria": "Outbox tabs",
"outbox.tab.active": "Active", "outbox.tab.active": "Active",
@ -2303,7 +2383,7 @@ export const en: MessageDict = {
"metrics.type.post": "Post", "metrics.type.post": "Post",
"jobs.title": "Jobs", "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.startDemo": "Create test job",
"jobs.demoHint": "Requires worker; list auto-refreshes while jobs are active.", "jobs.demoHint": "Requires worker; list auto-refreshes while jobs are active.",
"jobs.demoLabel": "Demo test job", "jobs.demoLabel": "Demo test job",
@ -2328,6 +2408,14 @@ export const en: MessageDict = {
"jobs.status.cancel_requested": "Cancel requested", "jobs.status.cancel_requested": "Cancel requested",
"jobs.loadFail": "Could not load jobs", "jobs.loadFail": "Could not load jobs",
"jobs.empty": "No jobs yet", "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.total": "{n} total",
"jobs.showing": " · showing first {n}", "jobs.showing": " · showing first {n}",
"jobs.detail": "Details", "jobs.detail": "Details",
@ -2362,45 +2450,46 @@ export const en: MessageDict = {
"plan.cta.downgrade": "Downgrade", "plan.cta.downgrade": "Downgrade",
"plan.cta.switch": "Switch", "plan.cta.switch": "Switch",
"plan.free.headline": "Trial and light personal use", "plan.free.headline": "Enough to try the full product",
"plan.free.bullet1": "{n} AI/search credits per month", "plan.free.bullet1": "{n} credits / month (~23 weeks light use)",
"plan.free.bullet2": "Full product: Studio, Patrol, Outbox", "plan.free.bullet2": "Full product: Studio, Patrol, Outbox, images",
"plan.free.bullet3": "Good for learning the flow", "plan.free.bullet3": "Feel the value, then upgrade to Starter",
"plan.free.bullet4": "Upgrade anytime", "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.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.right2": "Enough credits for real copy, search, and a few images—not a hollow demo.",
"plan.free.right3": "After the cap, wait for next month or upgrade (unless an admin turns off limits).", "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.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.note1": "No payment required; confirms immediately.",
"plan.free.note2": "Invoice rules will be defined when billing goes live.", "plan.free.note2": "Invoice rules will be defined when billing goes live.",
"plan.starter.headline": "Small teams posting and patrolling daily", "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.bullet2": "Steady posting, replies, and patrol",
"plan.starter.bullet3": "Fits a 13 person cadence", "plan.starter.bullet3": "Fits a 13 person cadence · main paid tier",
"plan.starter.bullet4": "Takes effect right after payment", "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.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.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.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.note1": "Plan changes only after successful payment.",
"plan.starter.note2": "Resets on calendar month; unused credits do not roll over.", "plan.starter.note2": "Resets on calendar month; unused credits do not roll over.",
"plan.pro.headline": "Multi-account, heavy AI and research", "plan.pro.headline": "Multi-account, heavy AI and research",
"plan.pro.bullet1": "{n} credits / month (about 4× Starter)", "plan.pro.bullet1": "{n} credits / month (about 3× Starter)",
"plan.pro.bullet2": "High-volume copy, research, and images", "plan.pro.bullet2": "High-volume copy, research, and images · heavy ceiling",
"plan.pro.bullet3": "Built for agencies and multi-brand work", "plan.pro.bullet3": "Built for agencies and multi-brand work",
"plan.pro.bullet4": "Takes effect right after payment", "plan.pro.bullet4": "Takes effect right after payment",
"plan.pro.right1": "After payment this account becomes Pro; the month uses Pro quota.", "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.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.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.note1": "Failed payment does not change your plan.",
"plan.pro.note2": "After payment you can find receipts in billing history.", "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.title": "Confirm plan",
"checkout.pickFirst": "Please choose a plan first.", "checkout.pickFirst": "Please choose a plan first.",
"checkout.viewPlans": "View plans", "checkout.viewPlans": "View plans",
@ -2435,6 +2524,7 @@ export const en: MessageDict = {
"usage.widget.monthUsage": "This month", "usage.widget.monthUsage": "This month",
"usage.widget.remaining": "{n} credits left", "usage.widget.remaining": "{n} credits left",
"usage.widget.leftShort": "{n} left", "usage.widget.leftShort": "{n} left",
"usage.widget.usedOfCap": "{used}/{cap}",
"usage.widget.overShort": "Over", "usage.widget.overShort": "Over",
"usage.widget.upgradeShort": "Upgrade", "usage.widget.upgradeShort": "Upgrade",
"usage.widget.upgrade": "Upgrade", "usage.widget.upgrade": "Upgrade",
@ -2447,7 +2537,7 @@ export const en: MessageDict = {
"usage.meter.ai_research": "AI research", "usage.meter.ai_research": "AI research",
"usage.meter.web_search": "Search", "usage.meter.web_search": "Search",
"usage.meter.ai_image": "AI image", "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.ledger.costAria": "Used {n} credits",
"usage.event.keyMode.platform": "Platform credits", "usage.event.keyMode.platform": "Platform credits",
"usage.event.keyMode.byok": "Your own key", "usage.event.keyMode.byok": "Your own key",
@ -2765,6 +2855,24 @@ export const en: MessageDict = {
"insights.postStats": "Views {views} · likes {likes} · replies {replies}", "insights.postStats": "Views {views} · likes {likes} · replies {replies}",
"insights.openThreads": "Open Threads", "insights.openThreads": "Open Threads",
"insights.zeroPct": "0%", "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.tabOwn": "My posts",
"plays.tabLink": "Threads link", "plays.tabLink": "Threads link",
@ -2833,6 +2941,7 @@ export const en: MessageDict = {
"inspire.trendsLabel": "Topic ideas", "inspire.trendsLabel": "Topic ideas",
"inspire.trendsHint": "Web-sourced ideas · costs a search credit", "inspire.trendsHint": "Web-sourced ideas · costs a search credit",
"inspire.trendsSeed": "sample", "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.trendsEmpty": "Tap “Find ideas” to search (uses credits)",
"inspire.refreshConfirm": "Finding ideas costs 1 search credit. Continue?", "inspire.refreshConfirm": "Finding ideas costs 1 search credit. Continue?",
"inspire.refreshOk": "Updated {n} topic ideas", "inspire.refreshOk": "Updated {n} topic ideas",
@ -3118,6 +3227,11 @@ export const en: MessageDict = {
"usage.currentPlan": "Current plan", "usage.currentPlan": "Current plan",
"usage.planMeta": "/ mo · {n} credits monthly", "usage.planMeta": "/ mo · {n} credits monthly",
"usage.changePlan": "Change plan", "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.usedThisMonth": "Used this month",
"usage.remainLabel": "Remaining", "usage.remainLabel": "Remaining",
"usage.ledgerToggle": "Usage log", "usage.ledgerToggle": "Usage log",

View File

@ -58,37 +58,19 @@ export function resolveInspireContext(
return { roles, snippets, trends, persona, brand, labels }; return { roles, snippets, trends, persona, brand, labels };
} }
function toneLead(ctx: ResolvedInspireContext): string { /** mock 產文:依素材重寫即可,不 fortify 固定開頭 */
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 "";
}
function buildDraftBody(userText: string, ctx: ResolvedInspireContext): string { function buildDraftBody(userText: string, ctx: ResolvedInspireContext): string {
const topic = const topic =
userText.trim() || userText.trim() ||
ctx.trends[0] || ctx.trends[0] ||
ctx.brand?.display_name || ctx.brand?.display_name ||
"日常小事"; "日常小事";
const lead = toneLead(ctx);
const lines: string[] = []; const lines: string[] = [];
if (lead) lines.push(lead);
if (ctx.trends.length) { if (ctx.trends.length) {
lines.push(`最近大家在聊 ${ctx.trends[0]!.replace(/^主題:/, "").slice(0, 40)}`); 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 const brandBit = ctx.brand
? `(想到 ${ctx.brand.display_name}${ctx.brand.brief ? `${ctx.brand.brief.slice(0, 40)}` : ""}` ? `(想到 ${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)) { if (/改短|短一點|精簡/.test(userText)) {
lines.push(`${topic.slice(0, 60)}——有人也卡在這嗎?`); lines.push(`${topic.slice(0, 60)}——有人也卡在這嗎?`);
} else if (/更口語|隨便|碎念/.test(userText)) { } else if (/更口語|隨便|碎念/.test(userText)) {
lines.push(`所以 ${topic.slice(0, 50)} 這件事,我真的想問你們怎麼處理的。`); lines.push(`所以 ${topic.slice(0, 50)} 這件事,我真的想問你們怎麼處理的。`);
} else { } else {
lines.push( if (!ctx.trends.length) {
`${topic.slice(0, 80)}${personaBit ? `${personaBit.slice(0, 48)}` : ""}${brandBit}`, lines.push(`${topic.slice(0, 80)}${brandBit}`);
); } else if (brandBit) {
lines.push(brandBit);
}
lines.push("我自己目前比較在意「實際用起來」而不是包裝怎麼寫。"); lines.push("我自己目前比較在意「實際用起來」而不是包裝怎麼寫。");
} }
@ -111,11 +95,6 @@ function buildDraftBody(userText: string, ctx: ResolvedInspireContext): string {
lines.push("你們最近有類似經驗嗎?"); lines.push("你們最近有類似經驗嗎?");
} }
if (ctx.snippets.some((s) => /誇大|療效|禁止/.test(s))) {
// 語氣收斂:不另加誇大句
}
// 短貼感
let body = lines.filter(Boolean).join("\n"); let body = lines.filter(Boolean).join("\n");
if (ctx.snippets.some((s) => /短貼|180|280/.test(s)) && body.length > 200) { if (ctx.snippets.some((s) => /短貼|180|280/.test(s)) && body.length > 200) {
body = body.slice(0, 180) + "…"; body = body.slice(0, 180) + "…";

View File

@ -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<string, AccountSyncMeta>;
/** accountId → in-flight sync promisesingleflight */
const inflight = new Map<string, Promise<OwnPost[]>>();
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<AccountSyncMeta>): 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 APIfalse = 走 list 或共用 in-flight 已完成後的讀取) */
fetched: boolean;
/** 因 TTLpast-once 已完成而跳過 */
skipped: boolean;
reason: OwnPostsSyncReason;
};
type Ops = {
list: (accountId: string) => Promise<OwnPost[]>;
sync: (accountId: string) => Promise<OwnPost[]>;
};
/**
* 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<OwnPostsSyncResult> {
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);
}

View File

@ -1,4 +1,4 @@
import { PLANS, type PlanId } from "./usageMeter"; import { DEFAULT_CREDIT_COST, PLANS, type PlanId } from "./usageMeter";
export type PlanRightsCopy = { export type PlanRightsCopy = {
headline: string; headline: string;
@ -9,19 +9,45 @@ export type PlanRightsCopy = {
/** 結帳頁:額度 */ /** 結帳頁:額度 */
quota: string[]; quota: string[];
notes: string[]; notes: string[];
/** 比價卡/頂欄短行:分項點數 + 約當次數 */
softCapsLine: string;
/** 約當次數短行(行銷可讀) */
approxCallsLine: string;
}; };
type TFn = (key: string, params?: Record<string, string | number>) => string; type TFn = (key: string, params?: Record<string, string | number>) => string;
/** 購買/比價用文案(用量首頁不堆字)— 由 i18n keys 組成 */ /** soft_caps 是點數;約當次數 = floor(點 / 單次扣點) */
export function getPlanRights(id: PlanId, t: TFn): PlanRightsCopy { export function planCapParams(id: PlanId, priceLabel?: string) {
const p = PLANS[id]; const p = PLANS[id];
const caps = { const copy = p.soft_caps.ai_copy;
copy: p.soft_caps.ai_copy, const research = p.soft_caps.ai_research;
research: p.soft_caps.ai_research, const search = p.soft_caps.web_search;
search: p.soft_caps.web_search, const image = p.soft_caps.ai_image;
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 { return {
headline: t(`plan.${id}.headline`), headline: t(`plan.${id}.headline`),
bullets: [ bullets: [
@ -36,10 +62,12 @@ export function getPlanRights(id: PlanId, t: TFn): PlanRightsCopy {
t(`plan.${id}.right3`), t(`plan.${id}.right3`),
], ],
quota: [ quota: [
t(`plan.${id}.quota1`, { n: p.monthly_credits, price: p.price_label }), t(`plan.${id}.quota1`, { n: p.monthly_credits, price }),
t(`plan.${id}.quota2`, caps), t("plan.quota2", caps),
], ],
notes: [t(`plan.${id}.note1`), t(`plan.${id}.note2`)], notes: [t(`plan.${id}.note1`), t(`plan.${id}.note2`)],
softCapsLine: t("plan.softCapsLine", caps),
approxCallsLine: t("plan.approxCallsLine", caps),
}; };
} }

View File

@ -36,7 +36,12 @@ export type PlanDef = {
price_twd: number; price_twd: number;
blurb: string; blurb: string;
blurb_key: string; blurb_key: string;
/** 本月 platform 總點數配給 */
monthly_credits: number; monthly_credits: number;
/**
*
* monthly_credits
*/
soft_caps: Record<UsageMeter, number>; soft_caps: Record<UsageMeter, number>;
}; };
@ -77,21 +82,41 @@ export const METER_META: Record<
}, },
}; };
/**
* 2026-07 ·
*
*
* - Free Starter
* - Starter
* - Pro
* - key BYOK
*
* platformUSD
* - ~$0.0040.008 · Exa ~$0.007 · ~$0.015 · ~$0.020.04
* - **$0.012**TWD32 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<PlanId, PlanDef> = { export const PLANS: Record<PlanId, PlanDef> = {
free: { free: {
id: "free", id: "free",
name: "Free", name: "Free",
price_label: "NT$0月", price_label: "NT$0月",
price_twd: 0, price_twd: 0,
blurb: "試用與個人輕量經營", blurb: "夠用試用,體驗完整創作流程",
blurb_key: "usage.plan.free.blurb", blurb_key: "usage.plan.free.blurb",
monthly_credits: 80, // 獲客可虧:滿用 ~$1.44;約 23 週輕度日常,用得出價值再升級
monthly_credits: 120,
soft_caps: { soft_caps: {
ai_copy: 40, ai_copy: 60, // 60 次文案/回覆
ai_research: 8, ai_research: 15, // 5 次研究
web_search: 30, web_search: 30, // 30 次搜尋
ai_image: 3, ai_image: 15, // 3 張生圖
}, }, // sum = 120
}, },
starter: { starter: {
id: "starter", id: "starter",
@ -100,13 +125,15 @@ export const PLANS: Record<PlanId, PlanDef> = {
price_twd: 590, price_twd: 590,
blurb: "小團隊日常發文與海巡", blurb: "小團隊日常發文與海巡",
blurb_key: "usage.plan.starter.blurb", blurb_key: "usage.plan.starter.blurb",
monthly_credits: 500, // 滿用 COGS ≤ $7.2 → 單方案毛利 ~61%;轉付費主力
// 約當:文案 300 · 研究 30 · 搜尋 150 · 生圖 12
monthly_credits: 600,
soft_caps: { soft_caps: {
ai_copy: 250, ai_copy: 300,
ai_research: 60, ai_research: 90,
web_search: 200, web_search: 150,
ai_image: 20, ai_image: 60,
}, }, // sum = 600
}, },
pro: { pro: {
id: "pro", id: "pro",
@ -115,16 +142,19 @@ export const PLANS: Record<PlanId, PlanDef> = {
price_twd: 1990, price_twd: 1990,
blurb: "多帳、重度 AI 與研究", blurb: "多帳、重度 AI 與研究",
blurb_key: "usage.plan.pro.blurb", blurb_key: "usage.plan.pro.blurb",
// 滿用 COGS ≤ $24 → 單方案毛利 ~61%;重度天花板
// 約當:文案 1000 · 研究 100 · 搜尋 500 · 生圖 40
monthly_credits: 2000, monthly_credits: 2000,
soft_caps: { soft_caps: {
ai_copy: 1000, ai_copy: 1000,
ai_research: 250, ai_research: 300,
web_search: 800, web_search: 500,
ai_image: 80, ai_image: 200,
}, }, // sum = 2000
}, },
}; };
/** 記帳點數platform與後端 DefaultCreditCost 對齊 */
export const DEFAULT_CREDIT_COST: Record<UsageMeter, number> = { export const DEFAULT_CREDIT_COST: Record<UsageMeter, number> = {
ai_copy: 1, ai_copy: 1,
ai_research: 3, ai_research: 3,
@ -137,10 +167,13 @@ export type UsageMonthSummary = {
uid: string; uid: string;
plan: PlanDef; plan: PlanDef;
unlimited: boolean; unlimited: boolean;
/** 方案點數(僅 platform— 進度條用 */ /**
* platform
* plan.monthly_credits platform.credits_total
*/
total_credits: number; total_credits: number;
remaining_credits: number; remaining_credits: number;
/** 無限時為 0有上限才算用掉比例platform only */ /** 已用 / 方案配給 百分比platform only */
pct: number; pct: number;
/** @deprecated 平台 AI 次數;請用 platform / byok 分欄 */ /** @deprecated 平台 AI 次數;請用 platform / byok 分欄 */
ai_calls: number; ai_calls: number;
@ -417,8 +450,9 @@ function aggregateEvents(
} }
for (const m of Object.keys(by_meter) as UsageMeter[]) { for (const m of Object.keys(by_meter) as UsageMeter[]) {
const row = by_meter[m]; const row = by_meter[m];
// soft_cap 是點數上限,進度用已用點數
row.pct = 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 remaining = Math.max(0, plan.monthly_credits - platformCredits);
const pct = const pct =
@ -426,15 +460,17 @@ function aggregateEvents(
? Math.min(999, Math.round((platformCredits / plan.monthly_credits) * 100)) ? Math.min(999, Math.round((platformCredits / plan.monthly_credits) * 100))
: 0; : 0;
// 狀態列用「點數」加總,與方案配給同一單位
let ai_calls = 0; let ai_calls = 0;
let search_calls = 0; let search_calls = 0;
for (const m of Object.keys(METER_META) as UsageMeter[]) { for (const m of Object.keys(METER_META) as UsageMeter[]) {
if (METER_META[m].group === "ai") ai_calls += by_meter[m].count; if (METER_META[m].group === "ai") ai_calls += by_meter[m].credits;
else search_calls += by_meter[m].count; else search_calls += by_meter[m].credits;
} }
return { return {
total_credits: plan.monthly_credits, // 與 UIwidget 一致total_credits = 已用
total_credits: platformCredits,
remaining_credits: remaining, remaining_credits: remaining,
pct, pct,
by_meter, by_meter,
@ -850,24 +886,52 @@ export function summarizeTenantUsage(
}; };
} }
export function usageWarning(summary: UsageMonthSummary): string | null { type WarnTFn = (key: string, params?: Record<string, string | number>) => 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<string, string | number>) => {
// fallback 中文(測試/無 i18n 時)
const fb: Record<string, string> = {
"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.unlimited) {
if (summary.total_credits > summary.plan.monthly_credits) { if (used > cap) {
return `本月已用 ${summary.total_credits} 點(方案 ${summary.plan.monthly_credits},不擋額度,仍可繼續)。`; return tr("usage.warn.unlimitedOver", { used, cap });
} }
return null; return null;
} }
if (summary.pct >= 100) { if (pct >= 100) {
return "本月點數已用完。可升級方案或等待下月重置。"; return tr("usage.warn.exhausted");
} }
if (summary.pct >= 80) { if (pct >= 80) {
return `本月已用 ${summary.pct}% 點數。`; return tr("usage.warn.high", { pct });
} }
for (const m of Object.keys(METER_META) as UsageMeter[]) { for (const m of Object.keys(METER_META) as UsageMeter[]) {
const row = summary.by_meter[m]; const row = summary.by_meter[m];
if (row.pct >= 90) { 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; return null;

View File

@ -107,11 +107,11 @@ export function CrewPage() {
const oauth = q.get("oauth"); const oauth = q.get("oauth");
if (!oauth) return; if (!oauth) return;
if (oauth === "ok") { if (oauth === "ok") {
setMessage(t("crew.msg.oauthOk") || "Threads 帳號已連線"); setMessage(t("crew.msg.oauthOk"));
void load(); void load();
refresh(); refresh();
} else { } else {
setMessage(q.get("msg") || t("crew.msg.oauthFail") || "OAuth 失敗"); setMessage(q.get("msg") || t("crew.msg.oauthFail"));
} }
// 清 query避免刷新重複提示 // 清 query避免刷新重複提示
const url = new URL(window.location.href); const url = new URL(window.location.href);
@ -129,7 +129,7 @@ export function CrewPage() {
window.location.href = authorize_url; window.location.href = authorize_url;
return; return;
} }
throw new Error(t("crew.msg.oauthFail") || "OAuth 無法取得授權網址"); throw new Error(t("crew.msg.oauthUrlFail"));
} catch (e) { } catch (e) {
setMessage(e instanceof Error ? e.message : t("crew.msg.refreshFail")); setMessage(e instanceof Error ? e.message : t("crew.msg.refreshFail"));
} finally { } finally {
@ -145,7 +145,7 @@ export function CrewPage() {
setMessage(""); setMessage("");
try { try {
await repos.accounts.remove(acc.id); await repos.accounts.remove(acc.id);
setMessage(t("crew.msg.deleted") || `已移除 @${acc.username}`); setMessage(t("crew.msg.deleted", { user: acc.username }));
refresh(); refresh();
await load(); await load();
} catch (e) { } catch (e) {

View File

@ -1,497 +1,14 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { PageHeader } from "../components/layout/PageHeader"; 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 { useI18n } from "../i18n/I18nContext";
import { InsightsPanel } from "./studio/InsightsPanel";
function DeltaBadge({ value }: { value: number | null }) { /** 獨立路由 /app/insights今日頁捷徑創作分頁內嵌同一面板 */
const { t } = useI18n();
if (value == null) return <Badge tone="neutral">{t("common.dash")}</Badge>;
if (value > 0) return <Badge tone="success">{fmtDelta(value)}</Badge>;
if (value < 0) return <Badge tone="danger">{fmtDelta(value)}</Badge>;
return <Badge tone="neutral">{t("insights.zeroPct")}</Badge>;
}
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<typeof metric, string> = {
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 (
<div className="hb-insights-bars" aria-label={t("insights.barsAria", { metric: labels[metric] })}>
<p className="hb-field__label" style={{ margin: "0 0 0.5rem" }}>
{t("insights.barsLabel", { metric: labels[metric], n: months.length })}
<span className="text-muted" style={{ fontWeight: 500 }}>
{t("insights.clickBar")}
</span>
</p>
<div className="hb-insights-bars__row" role="listbox" aria-label={t("insights.pickMonthAria")}>
{months.map((m) => {
const h = Math.round((m[metric] / max) * 100);
const selected = m.key === selectedKey;
const valStr = m[metric].toLocaleString(loc);
return (
<button
key={m.key}
type="button"
role="option"
aria-selected={selected}
className={`hb-insights-bar${selected ? " is-selected" : ""}${m.source === "estimate" ? " is-estimate" : ""}`}
onClick={() => onSelect(m.key)}
title={t("insights.barTitle", {
label: m.label,
value: valStr,
est: m.source === "estimate" ? t("insights.est") : "",
})}
>
<div className="hb-insights-bar__track">
<div
className={`hb-insights-bar__fill${selected ? " is-current" : ""}${m.source === "estimate" ? " is-estimate" : ""}`}
style={{ height: `${Math.max(h, m[metric] > 0 ? 6 : 2)}%` }}
/>
</div>
<span className="hb-insights-bar__val">
{metric === "views" && m.views >= 1000
? `${Math.round(m.views / 100) / 10}k`
: m[metric]}
</span>
<span className={`hb-insights-bar__lab${selected ? " is-current" : ""}`}>
{t("insights.monthSuffix", { m: m.label.replace(/^\d+\//, "") })}
</span>
</button>
);
})}
</div>
</div>
);
}
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 (
<svg
className="hb-insights-spark"
viewBox={`0 0 ${w} ${h}`}
width="100%"
height={h}
role="img"
aria-label={t("insights.sparkAria")}
>
<polyline
fill="none"
stroke="var(--hb-brand)"
strokeWidth="2.2"
strokeLinejoin="round"
strokeLinecap="round"
points={poly}
/>
{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 (
<g key={m.key} style={{ cursor: "pointer" }} onClick={() => onSelect(m.key)}>
<circle cx={x} cy={y} r={12} fill="transparent" />
<circle
cx={x}
cy={y}
r={selected ? 4.5 : 2.4}
fill={selected ? "var(--hb-brand)" : "var(--hb-surface)"}
stroke="var(--hb-brand)"
strokeWidth="1.5"
/>
</g>
);
})}
</svg>
);
}
export function InsightsPage() { export function InsightsPage() {
const repos = useRepos(); const { t } = useI18n();
const { tick, refresh } = useData();
const { t, locale } = useI18n();
const numLoc = locale === "en" ? "en-US" : "zh-TW";
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
const [accountId, setAccountId] = useState("");
const [posts, setPosts] = useState<OwnPost[]>([]);
const [metric, setMetric] = useState<"views" | "likes" | "replies" | "posts">("views");
const [busy, setBusy] = useState(false);
/** 分析歷史:選中的月份 */
const [analysisMonth, setAnalysisMonth] = useState(monthKeyFromDate());
const [history, setHistory] = useState<AccountInsightsSnapshot[]>([]);
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);
return ( return (
<> <>
<PageHeader title={t("insights.title")} /> <PageHeader title={t("insights.title")} />
<InsightsPanel showTitle />
<div className="hb-insights-toolbar">
<Select
label={t("insights.account")}
value={accountId}
onChange={(e) => setAccountId(e.target.value)}
disabled={!accounts.length}
>
{accounts.length === 0 ? (
<option value="">{t("insights.noAccount")}</option>
) : (
accounts.map((a) => (
<option key={a.id} value={a.id}>
@{a.username}
{a.display_name ? ` · ${a.display_name}` : ""}
</option>
))
)}
</Select>
<div className="hb-insights-toolbar__actions">
<Button type="button" variant="ghost" disabled={busy || !accountId} onClick={() => void syncPosts()}>
{busy ? t("insights.syncing") : t("insights.syncPosts")}
</Button>
<Link to="/app/studio?tab=posts">
<Button type="button" variant="ghost">
{t("insights.myPosts")}
</Button>
</Link>
</div>
</div>
{!accountId || !report ? (
<EmptyState
title={t("insights.pickAccount")}
action={
<Link to="/app/crew">
<Button type="button">{t("insights.goAccounts")}</Button>
</Link>
}
/>
) : (
<div className="hb-stack">
{/* 本月總覽 */}
<div className="hb-today-metrics hb-insights-kpis" role="group" aria-label={t("insights.kpiMonth")}>
<div className="hb-today-metric">
<span className="hb-today-metric__label">{t("insights.monthViews")}</span>
<span className="hb-today-metric__value">
{report.current.views.toLocaleString(numLoc)}
</span>
<span className="hb-today-metric__hint">
{t("insights.vsPrev")} <DeltaBadge value={report.delta.views} />
</span>
</div>
<div className="hb-today-metric">
<span className="hb-today-metric__label">{t("insights.monthLikes")}</span>
<span className="hb-today-metric__value">{report.current.likes}</span>
<span className="hb-today-metric__hint">
{t("insights.vsPrev")} <DeltaBadge value={report.delta.likes} />
</span>
</div>
<div className="hb-today-metric">
<span className="hb-today-metric__label">{t("insights.monthReplies")}</span>
<span className="hb-today-metric__value">{report.current.replies}</span>
<span className="hb-today-metric__hint">
{t("insights.vsPrev")} <DeltaBadge value={report.delta.replies} />
</span>
</div>
<div className="hb-today-metric">
<span className="hb-today-metric__label">{t("insights.engRate")}</span>
<span className="hb-today-metric__value">
{fmtEngRate(report.current.engagementRate)}
</span>
<span className="hb-today-metric__hint">
{t("insights.avgNear", { rate: fmtEngRate(report.avgEngagementRate) })} ·{" "}
<DeltaBadge value={report.delta.engagementRate} />
</span>
</div>
</div>
<Card title={t("insights.trendTitle", { user: account?.username || "" })}>
<div className="hb-stack">
<Sparkline
months={report.months}
metric={metric === "likes" ? "likes" : "views"}
selectedKey={analysisMonth}
onSelect={selectMonth}
/>
<div className="hb-tabs hb-tabs--sm" role="tablist" aria-label={t("insights.chartMetrics")}>
{(["views", "likes", "replies", "posts"] as const).map((m) => (
<button
key={m}
type="button"
className={`hb-tab ${metric === m ? "is-active" : ""}`}
onClick={() => setMetric(m)}
>
{m === "views"
? t("insights.metricViews")
: m === "likes"
? t("insights.metricLikes")
: m === "replies"
? t("insights.metricReplies")
: t("insights.metricPosts")}
</button>
))}
</div>
<MonthBars
months={report.months}
metric={metric}
selectedKey={analysisMonth}
onSelect={selectMonth}
/>
{selectedSnap ? (
<div className="hb-insights-snap-panel">
<div className="hb-insights-snap-meta">
<strong>{t("insights.analysisOf", { label: selectedSnap.month_label })}</strong>
<span className="text-muted" style={{ fontSize: "0.8rem" }}>
{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) })
: ""}
</span>
<div className="hb-insights-snap-metrics">
<span>
{t("insights.statPosts")} <strong>{selectedSnap.metrics.posts}</strong>
</span>
<span>
{t("insights.statViews")}{" "}
<strong>{selectedSnap.metrics.views.toLocaleString(numLoc)}</strong>
</span>
<span>
{t("insights.statLikes")} <strong>{selectedSnap.metrics.likes}</strong>
</span>
<span>
{t("insights.statReplies")} <strong>{selectedSnap.metrics.replies}</strong>
</span>
<span>
{t("insights.engRate")}{" "}
<strong>{fmtEngRate(selectedSnap.metrics.engagementRate)}</strong>
</span>
</div>
</div>
<div>
<p className="hb-field__label" style={{ margin: "0 0 0.4rem" }}>
{t("insights.conclusions")}
</p>
<ul className="hb-insights-bullets">
{selectedSnap.analysis.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</div>
<div>
<p className="hb-field__label" style={{ margin: "0 0 0.4rem" }}>
{t("insights.recommendations")}
</p>
<ul className="hb-insights-bullets hb-insights-bullets--action">
{selectedSnap.recommendations.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</div>
{selectedSnap.top_highlights?.length ? (
<div>
<p className="hb-field__label" style={{ margin: "0 0 0.4rem" }}>
{t("insights.highlights")}
</p>
<ul className="hb-insights-bullets">
{selectedSnap.top_highlights.map((h) => (
<li key={h}>{h}</li>
))}
</ul>
</div>
) : null}
<div className="hb-wizard-actions">
<Link to="/app/studio?tab=inspire">
<Button type="button" variant="ghost">
{t("insights.findTopics")}
</Button>
</Link>
<Link to="/app/scout">
<Button type="button" variant="ghost">
{t("insights.goScout")}
</Button>
</Link>
</div>
</div>
) : (
<p className="text-muted" style={{ margin: 0 }}>
{t("insights.selectMonth")}
</p>
)}
</div>
</Card>
<Card title={t("insights.topPosts")}>
{report.topPosts.length === 0 ? (
<EmptyState
title={t("insights.noPosts")}
action={
<Button type="button" onClick={() => void syncPosts()} disabled={busy}>
{t("insights.syncPosts")}
</Button>
}
/>
) : (
<ul className="hb-today-list">
{report.topPosts.map((p, i) => (
<li key={p.id}>
<div className="hb-today-list__item" style={{ cursor: "default" }}>
<span className="hb-today-list__meta">
<Badge tone={i === 0 ? "success" : "neutral"}>#{i + 1}</Badge>
{p.topic_tag ? <Badge tone="brand">{p.topic_tag}</Badge> : null}
<span>
{t("insights.postStats", {
views: p.view_count.toLocaleString(numLoc),
likes: p.like_count,
replies: p.reply_count,
})}
</span>
<span>{formatLocalDateTime(p.published_at)}</span>
</span>
<span className="hb-today-list__text">
{p.text.slice(0, 120)}
{p.text.length > 120 ? "…" : ""}
</span>
{p.insight || p.formula_summary ? (
<span className="text-muted" style={{ fontSize: "0.8rem" }}>
{p.insight || p.formula_summary}
</span>
) : null}
{p.permalink ? (
<a href={p.permalink} target="_blank" rel="noreferrer" style={{ fontSize: "0.8rem" }}>
{t("insights.openThreads")}
</a>
) : null}
</div>
</li>
))}
</ul>
)}
</Card>
</div>
)}
</> </>
); );
} }

View File

@ -1,10 +1,11 @@
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { PageHeader } from "../components/layout/PageHeader"; 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 { useData, useRepos } from "../data/DataContext";
import { useJobLive } from "../data/JobLiveContext"; import { useJobLive } from "../data/JobLiveContext";
import type { JobListTab } from "../data/repos";
import type { Job } from "../domain/types"; import type { Job } from "../domain/types";
import { useI18n } from "../i18n/I18nContext"; import { useI18n } from "../i18n/I18nContext";
import { useFormatApiError } from "../lib/apiErrors"; import { useFormatApiError } from "../lib/apiErrors";
@ -19,60 +20,72 @@ import {
} from "../lib/jobLabels"; } from "../lib/jobLabels";
import { formatLocalDateTime } from "../lib/time"; import { formatLocalDateTime } from "../lib/time";
/** 首屏顯示筆數;太多時用「載入更多」而不是分頁 */ const DEFAULT_PAGE_SIZE = 10;
const INITIAL = 15;
const STEP = 15; 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() { export function JobsPage() {
const repos = useRepos(); const repos = useRepos();
const { member } = useAuth(); const { member } = useAuth();
const { refresh, tick } = useData(); const { refresh } = useData();
const { jobs: liveJobs, reload: reloadLive, revision } = useJobLive(); const { revision, reload: reloadLive } = useJobLive();
const { t } = useI18n(); const { t } = useI18n();
const formatApiError = useFormatApiError(); const formatApiError = useFormatApiError();
const canCreateDemo = can(member?.roles, Permission.JobsDemoCreate); const canCreateDemo = can(member?.roles, Permission.JobsDemoCreate);
const [tab, setTab] = useState<JobListTab>("active");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [jobs, setJobs] = useState<Job[]>([]); const [jobs, setJobs] = useState<Job[]>([]);
const [visible, setVisible] = useState(INITIAL); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const load = async () => { const load = useCallback(async () => {
setLoading(true);
setError("");
try { try {
await reloadLive(); const res = await repos.jobs.list({ tab, page, pageSize });
setJobs(await repos.jobs.list()); 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) { } catch (e) {
setError(formatApiError(e, "jobs.loadFail")); setError(formatApiError(e, "jobs.loadFail"));
} finally {
setLoading(false);
} }
}; }, [repos.jobs, tab, page, pageSize, formatApiError]);
// 優先用全域 live 列表有進度就更新fallback 本頁 list
useEffect(() => { useEffect(() => {
if (liveJobs.length > 0 || revision > 0) {
setJobs(liveJobs);
return;
}
void load(); void load();
}, [liveJobs, revision, repos.jobs, tick]); // eslint-disable-line react-hooks/exhaustive-deps }, [load]);
// 執行中分頁:跟全域 live revision 同步刷新(進度條)
useEffect(() => { useEffect(() => {
const onStore = () => refresh(); if (tab !== "active" || revision <= 0) return;
window.addEventListener("harbor:store", onStore); void load();
return () => window.removeEventListener("harbor:store", onStore); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [refresh]); }, [revision, tab]);
const sorted = useMemo( function switchTab(next: JobListTab) {
() => jobs.slice().sort((a, b) => b.updated_at - a.updated_at), if (next === tab) return;
[jobs], setTab(next);
); setPage(1);
setMessage("");
useEffect(() => { setError("");
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;
async function startDemo() { async function startDemo() {
setBusy(true); setBusy(true);
@ -81,7 +94,9 @@ export function JobsPage() {
try { try {
const job = await repos.jobs.startDemo(); const job = await repos.jobs.startDemo();
setMessage(t("jobs.demoCreated", { id: job.id.slice(0, 8) })); setMessage(t("jobs.demoCreated", { id: job.id.slice(0, 8) }));
setVisible(INITIAL); setTab("active");
setPage(1);
await reloadLive();
await load(); await load();
refresh(); refresh();
} catch (e) { } catch (e) {
@ -104,6 +119,7 @@ export function JobsPage() {
try { try {
await repos.jobs.remove(job.id); await repos.jobs.remove(job.id);
setMessage(t("jobs.deleted")); setMessage(t("jobs.deleted"));
await reloadLive();
await load(); await load();
refresh(); refresh();
} catch (e) { } 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 ( return (
<> <>
<PageHeader title={t("jobs.title")} description={t("jobs.desc")} /> <PageHeader title={t("jobs.title")} description={t("jobs.desc")} />
@ -136,28 +159,65 @@ export function JobsPage() {
</p> </p>
) : null} ) : null}
{sorted.length === 0 ? ( <div className="hb-tabs" role="tablist" aria-label={t("jobs.tabsAria")}>
<EmptyState title={t("jobs.empty")} /> {TABS.map((tb) => (
<button
key={tb.id}
type="button"
role="tab"
aria-selected={tab === tb.id}
className={`hb-tab ${tab === tb.id ? "is-active" : ""}`}
onClick={() => switchTab(tb.id)}
>
{t(tb.labelKey)}
</button>
))}
</div>
{tab === "history" ? (
<p className="text-muted" style={{ margin: "0 0 0.5rem", fontSize: "var(--hb-text-xs)" }}>
{t("jobs.retentionHint")}
</p>
) : null}
{tab === "recurring" ? (
<p className="text-muted" style={{ margin: "0 0 0.5rem", fontSize: "var(--hb-text-xs)" }}>
{t("jobs.recurringHint")}
</p>
) : null}
{loading && jobs.length === 0 ? (
<p className="text-muted">{t("common.loading")}</p>
) : jobs.length === 0 ? (
<EmptyState title={emptyTitle} />
) : ( ) : (
<div className="hb-stack"> <div className="hb-stack">
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}> <p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("jobs.total", { n: sorted.length })} {t("jobs.total", { n: total })}
{hasMore ? t("jobs.showing", { n: shown.length }) : ""}
</p> </p>
{shown.map((job) => ( {jobs.map((job) => (
<Card key={job.id}> <Card key={job.id}>
<div className="hb-list-row"> <div className="hb-list-row">
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<strong>{jobTemplateLabel(job.template_type, t)}</strong> <strong>{jobTemplateLabel(job.template_type, t)}</strong>
<p className="text-muted">{jobSubtitle(job, t, formatLocalDateTime)}</p> <p className="text-muted">{jobSubtitle(job, t, formatLocalDateTime)}</p>
<div className="hb-progress hb-progress--sm" aria-hidden style={{ marginTop: "0.35rem" }}> {tab === "active" || tab === "history" ? (
<div <div
className="hb-progress__bar" className="hb-progress hb-progress--sm"
style={{ width: `${Math.min(100, Math.max(0, job.progress_percent))}%` }} aria-hidden
/> style={{ marginTop: "0.35rem" }}
</div> >
<div
className="hb-progress__bar"
style={{
width: `${Math.min(100, Math.max(0, job.progress_percent))}%`,
}}
/>
</div>
) : null}
<p className="text-muted" style={{ fontSize: "var(--hb-text-sm)" }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-sm)" }}>
{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}` : ""} {job.error ? ` · ${job.error}` : ""}
</p> </p>
</div> </div>
@ -185,13 +245,14 @@ export function JobsPage() {
</div> </div>
</Card> </Card>
))} ))}
{hasMore ? ( <Pager
<div className="hb-wizard-actions"> total={total}
<Button type="button" variant="secondary" onClick={() => setVisible((v) => v + STEP)}> page={page}
{t("jobs.loadMore", { n: sorted.length - visible })} pageSize={pageSize}
</Button> onPageChange={setPage}
</div> onPageSizeChange={setPageSize}
) : null} showWhenSingle
/>
</div> </div>
)} )}
</> </>

View File

@ -3,7 +3,7 @@ import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { PageHeader } from "../components/layout/PageHeader"; import { PageHeader } from "../components/layout/PageHeader";
import { Button, Card, Input } from "../components/ui"; import { Button, Card, Input } from "../components/ui";
import { useRepos } from "../data/DataContext"; import { useData, useRepos } from "../data/DataContext";
import { useI18n } from "../i18n/I18nContext"; import { useI18n } from "../i18n/I18nContext";
import { getPlanRights, planCtaLabel } from "../lib/planRights"; import { getPlanRights, planCtaLabel } from "../lib/planRights";
import { PLANS, type PlanId } from "../lib/usageMeter"; import { PLANS, type PlanId } from "../lib/usageMeter";
@ -17,6 +17,7 @@ function isPlanId(v: string | null): v is PlanId {
*/ */
export function PlanCheckoutPage() { export function PlanCheckoutPage() {
const repos = useRepos(); const repos = useRepos();
const { refresh } = useData();
const { member } = useAuth(); const { member } = useAuth();
const { t, formatPlanPrice } = useI18n(); const { t, formatPlanPrice } = useI18n();
const navigate = useNavigate(); const navigate = useNavigate();
@ -25,7 +26,7 @@ export function PlanCheckoutPage() {
const planId: PlanId | null = isPlanId(planParam) ? planParam : null; const planId: PlanId | null = isPlanId(planParam) ? planParam : null;
const plan = planId ? PLANS[planId] : 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<PlanId>("free"); const [currentId, setCurrentId] = useState<PlanId>("free");
const [cardName, setCardName] = useState(member?.display_name || ""); const [cardName, setCardName] = useState(member?.display_name || "");
@ -78,6 +79,8 @@ export function PlanCheckoutPage() {
await repos.usage.purchasePlan(planId, { await repos.usage.purchasePlan(planId, {
mock_ref: free ? "free" : `****${cardLast4.replace(/\D/g, "").slice(-4)}`, mock_ref: free ? "free" : `****${cardLast4.replace(/\D/g, "").slice(-4)}`,
}); });
// 強制重載 repos頂欄用量PLANS 配給依新 plan_id
refresh();
navigate("/app/usage", { navigate("/app/usage", {
replace: true, replace: true,
state: { purchaseOk: planId }, state: { purchaseOk: planId },
@ -128,6 +131,9 @@ export function PlanCheckoutPage() {
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}> <p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("checkout.monthlyCredits", { n: plan.monthly_credits })} {t("checkout.monthlyCredits", { n: plan.monthly_credits })}
</p> </p>
<p className="text-muted" style={{ margin: "0.25rem 0 0", fontSize: "var(--hb-text-xs)" }}>
{rights.approxCallsLine}
</p>
</div> </div>
</div> </div>

View File

@ -5,6 +5,7 @@ import { PageHeader } from "../components/layout/PageHeader";
import { PlanPricingGrid } from "../components/usage/PlanPricingGrid"; import { PlanPricingGrid } from "../components/usage/PlanPricingGrid";
import { useRepos } from "../data/DataContext"; import { useRepos } from "../data/DataContext";
import { useI18n } from "../i18n/I18nContext"; import { useI18n } from "../i18n/I18nContext";
import { getPlanRights } from "../lib/planRights";
import type { PlanId } from "../lib/usageMeter"; import type { PlanId } from "../lib/usageMeter";
import { PLANS } from "../lib/usageMeter"; import { PLANS } from "../lib/usageMeter";
@ -25,6 +26,7 @@ export function PlansPage() {
if (!member) return null; if (!member) return null;
const current = currentId ?? "free"; const current = currentId ?? "free";
const currentRights = getPlanRights(current, t, formatPlanPrice);
return ( return (
<> <>
@ -41,6 +43,7 @@ export function PlansPage() {
<span className="text-muted"> <span className="text-muted">
{t("plans.monthlyCredits", { n: PLANS[current].monthly_credits })} {t("plans.monthlyCredits", { n: PLANS[current].monthly_credits })}
</span> </span>
<span className="text-muted hb-plans-page__caps">{currentRights.approxCallsLine}</span>
<Link to="/app/usage" className="hb-plans-page__back"> <Link to="/app/usage" className="hb-plans-page__back">
{t("plans.usageLink")} {t("plans.usageLink")}
</Link> </Link>

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; 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 { PageHeader } from "../components/layout/PageHeader";
import { Select } from "../components/ui"; import { Select } from "../components/ui";
import { useData, useRepos } from "../data/DataContext"; import { useData, useRepos } from "../data/DataContext";
@ -8,6 +8,7 @@ import { useI18n } from "../i18n/I18nContext";
import { isPersonaReady, personaOptionLabel } from "../lib/personaPrompt"; import { isPersonaReady, personaOptionLabel } from "../lib/personaPrompt";
import { ComposerPanel } from "./studio/ComposerPanel"; import { ComposerPanel } from "./studio/ComposerPanel";
import { InspirePanel } from "./studio/InspirePanel"; import { InspirePanel } from "./studio/InspirePanel";
import { InsightsPanel } from "./studio/InsightsPanel";
import { MentionsPanel } from "./studio/MentionsPanel"; import { MentionsPanel } from "./studio/MentionsPanel";
import { OwnPostsPanel } from "./studio/OwnPostsPanel"; import { OwnPostsPanel } from "./studio/OwnPostsPanel";
import { PlaysPanel } from "./studio/PlaysPanel"; import { PlaysPanel } from "./studio/PlaysPanel";
@ -27,7 +28,6 @@ export function StudioPage() {
const repos = useRepos(); const repos = useRepos();
const { tick } = useData(); const { tick } = useData();
const { t } = useI18n(); const { t } = useI18n();
const navigate = useNavigate();
const [params, setParams] = useSearchParams(); const [params, setParams] = useSearchParams();
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]); const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
@ -72,10 +72,6 @@ export function StudioPage() {
); );
useEffect(() => { useEffect(() => {
if (tab === "insights") {
navigate("/app/insights", { replace: true });
return;
}
if (!TAB_KEYS.some((item) => item.key === tab)) { if (!TAB_KEYS.some((item) => item.key === tab)) {
setTab("posts"); setTab("posts");
} }
@ -127,13 +123,7 @@ export function StudioPage() {
key={item.key} key={item.key}
type="button" type="button"
className={`hb-tab ${tab === item.key ? "is-active" : ""}`} className={`hb-tab ${tab === item.key ? "is-active" : ""}`}
onClick={() => { onClick={() => setTab(item.key)}
if (item.key === "insights") {
navigate("/app/insights");
return;
}
setTab(item.key);
}}
> >
{t(item.labelKey)} {t(item.labelKey)}
</button> </button>
@ -165,6 +155,9 @@ export function StudioPage() {
{tab === "inspire" ? ( {tab === "inspire" ? (
<InspirePanel accountId={accountId} personaId={personaId} personaReady={personaReady} /> <InspirePanel accountId={accountId} personaId={personaId} personaReady={personaReady} />
) : null} ) : null}
{tab === "insights" ? (
<InsightsPanel accountId={accountId} hideAccountSelect />
) : null}
</> </>
); );
} }

View File

@ -1,9 +1,10 @@
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { PageHeader } from "../components/layout/PageHeader"; import { PageHeader } from "../components/layout/PageHeader";
import { Badge, Button, Card, EmptyState } from "../components/ui"; import { Badge, Button, Card, EmptyState } from "../components/ui";
import { useData, useRepos } from "../data/DataContext"; import { useData, useRepos } from "../data/DataContext";
import type { import type {
MentionItem,
OutboxBundle, OutboxBundle,
OwnPost, OwnPost,
ScoutPost, ScoutPost,
@ -11,6 +12,7 @@ import type {
TrendItem, TrendItem,
} from "../domain/types"; } from "../domain/types";
import { useI18n } from "../i18n/I18nContext"; import { useI18n } from "../i18n/I18nContext";
import { useFormatApiError } from "../lib/apiErrors";
import { loadScoutToday } from "../lib/scoutToday"; import { loadScoutToday } from "../lib/scoutToday";
function isPendingScout(p: ScoutPost): boolean { function isPendingScout(p: ScoutPost): boolean {
@ -32,15 +34,26 @@ type AccountPulse = {
topInsight?: string; topInsight?: string;
}; };
/**
*
* live repos tab
*/
export function TodayPage() { export function TodayPage() {
const repos = useRepos(); const repos = useRepos();
const { tick, refresh } = useData(); const { tick, refresh } = useData();
const { t, locale } = useI18n(); 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<ScoutPost[]>([]); const [scoutPosts, setScoutPosts] = useState<ScoutPost[]>([]);
const [outbox, setOutbox] = useState<OutboxBundle[]>([]); const [outbox, setOutbox] = useState<OutboxBundle[]>([]);
const [trends, setTrends] = useState<TrendItem[]>([]); const [trends, setTrends] = useState<TrendItem[]>([]);
const [ownPosts, setOwnPosts] = useState<OwnPost[]>([]); const [ownPosts, setOwnPosts] = useState<OwnPost[]>([]);
const [mentions, setMentions] = useState<MentionItem[]>([]);
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]); const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
const [scoutDone, setScoutDone] = useState(0); const [scoutDone, setScoutDone] = useState(0);
const [scoutGoal, setScoutGoal] = useState(8); const [scoutGoal, setScoutGoal] = useState(8);
@ -57,25 +70,73 @@ export function TodayPage() {
[dateLocale, tick], [dateLocale, tick],
); );
useEffect(() => { const load = useCallback(async () => {
void (async () => { setLoading(true);
const scout = loadScoutToday(); setError("");
setScoutDone(scout.done); try {
setScoutGoal(scout.goalValue); const scoutLocal = loadScoutToday();
const [posts, box, trendList, owns, acc] = await Promise.all([ setScoutGoal(scoutLocal.goalValue);
const [posts, box, trendList, acc] = await Promise.all([
repos.scout.listPosts(), repos.scout.listPosts(),
repos.outbox.list(), repos.outbox.list(),
repos.inspiration.listTrends("all"), repos.inspiration.listTrends("all").catch(() => [] as TrendItem[]),
repos.ownPosts.list(),
repos.accounts.list(), repos.accounts.list(),
]); ]);
const usable = acc.filter((a) => a.is_usable);
setAccounts(usable.length ? usable : acc);
setScoutPosts(posts); setScoutPosts(posts);
setOutbox(box); 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); 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(() => { useEffect(() => {
const onStore = () => refresh(); const onStore = () => refresh();
@ -94,6 +155,11 @@ export function TodayPage() {
[scoutPosts], [scoutPosts],
); );
const pendingMentions = useMemo(
() => mentions.filter((m) => m.status === "pending"),
[mentions],
);
const failedOutbox = useMemo( const failedOutbox = useMemo(
() => outbox.filter((o) => o.status === "partial_failed"), () => outbox.filter((o) => o.status === "partial_failed"),
[outbox], [outbox],
@ -111,7 +177,7 @@ export function TodayPage() {
const goalPct = Math.min(100, Math.round((scoutDone / Math.max(1, scoutGoal)) * 100)); 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 accountPulses = useMemo((): AccountPulse[] => {
const byAcc = new Map<string, OwnPost[]>(); const byAcc = new Map<string, OwnPost[]>();
@ -137,7 +203,6 @@ export function TodayPage() {
topInsight: top?.insight || top?.formula_summary, topInsight: top?.insight || top?.formula_summary,
}); });
} }
// 沒 usable 時仍展示有貼文的帳
if (!rows.length) { if (!rows.length) {
for (const [accId, posts] of byAcc) { for (const [accId, posts] of byAcc) {
const acc = const acc =
@ -165,16 +230,78 @@ export function TodayPage() {
.slice(0, 4); .slice(0, 4);
}, [accounts, ownPosts]); }, [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 (
<>
<PageHeader title={t("nav.today")} description={todayLabel} />
<p className="text-muted">{t("common.loading")}</p>
</>
);
}
return ( return (
<> <>
<PageHeader title={t("nav.today")} description={todayLabel} /> <PageHeader title={t("nav.today")} description={todayLabel} />
{error ? (
<p className="hb-form-error" role="alert">
{error}
</p>
) : null}
<div className="hb-today-actions"> <div className="hb-today-actions">
<Link to="/app/studio"> <Button
<Button type="button">{t("today.findTopic")}</Button> type="button"
</Link> onClick={() => void onRefreshTrends()}
disabled={refreshingTrends}
>
{refreshingTrends ? t("common.loading") : t("today.findTopic")}
</Button>
<Link to="/app/scout"> <Link to="/app/scout">
<Button type="button" variant={pendingScout.length ? "primary" : "ghost"}> <Button type="button" variant={pendingScout.length ? "primary" : "ghost"}>
{pendingScout.length {pendingScout.length
@ -182,11 +309,14 @@ export function TodayPage() {
: t("today.pendingReplies")} : t("today.pendingReplies")}
</Button> </Button>
</Link> </Link>
<Link to="/app/studio/new"> <Link to="/app/studio?tab=compose">
<Button type="button" variant="ghost"> <Button type="button" variant="ghost">
{t("today.newThread")} {t("today.newThread")}
</Button> </Button>
</Link> </Link>
<Button type="button" variant="ghost" onClick={() => void load()} disabled={loading}>
{t("today.reload")}
</Button>
</div> </div>
{/* 數值列 */} {/* 數值列 */}
@ -196,7 +326,7 @@ export function TodayPage() {
<span className="hb-today-metric__value">{pendingScout.length}</span> <span className="hb-today-metric__value">{pendingScout.length}</span>
<span className="hb-today-metric__hint">{t("today.metric.pendingHint")}</span> <span className="hb-today-metric__hint">{t("today.metric.pendingHint")}</span>
</Link> </Link>
<div className="hb-today-metric"> <div className="hb-today-metric" title={t("today.metric.doneGoalHint")}>
<span className="hb-today-metric__label">{t("today.metric.doneGoal")}</span> <span className="hb-today-metric__label">{t("today.metric.doneGoal")}</span>
<span className="hb-today-metric__value"> <span className="hb-today-metric__value">
{scoutDone} {scoutDone}
@ -225,9 +355,14 @@ export function TodayPage() {
{failedOutbox.length ? t("today.metric.needAction") : t("today.metric.ok")} {failedOutbox.length ? t("today.metric.needAction") : t("today.metric.ok")}
</span> </span>
</Link> </Link>
<Link to="/app/studio?tab=mentions" className="hb-today-metric">
<span className="hb-today-metric__label">{t("today.metric.mentions")}</span>
<span className="hb-today-metric__value">{pendingMentions.length}</span>
<span className="hb-today-metric__hint">{t("today.metric.mentionsHint")}</span>
</Link>
</div> </div>
{/* 待回覆 */} {/* 待回覆 · 海巡 */}
<Card title={t("today.pending.title", { n: pendingScout.length })}> <Card title={t("today.pending.title", { n: pendingScout.length })}>
{pendingPreview.length === 0 ? ( {pendingPreview.length === 0 ? (
<EmptyState <EmptyState
@ -248,11 +383,20 @@ export function TodayPage() {
@{p.author} @{p.author}
{p.search_tag ? ` · ${p.search_tag}` : ""} {p.search_tag ? ` · ${p.search_tag}` : ""}
{typeof p.score === "number" ? ` · ${Math.round(p.score)}` : ""} {typeof p.score === "number" ? ` · ${Math.round(p.score)}` : ""}
{p.outreach_status === "drafted" ? (
<>
{" · "}
<Badge tone="brand">{t("today.badge.drafted")}</Badge>
</>
) : null}
</span> </span>
<span className="hb-today-list__text"> <span className="hb-today-list__text">
{p.text.slice(0, 96)} {p.text.slice(0, 96)}
{p.text.length > 96 ? "…" : ""} {p.text.length > 96 ? "…" : ""}
</span> </span>
{p.opportunity ? (
<span className="hb-today-list__meta">{p.opportunity}</span>
) : null}
</Link> </Link>
</li> </li>
))} ))}
@ -275,13 +419,34 @@ export function TodayPage() {
{/* 找話題 */} {/* 找話題 */}
<Card title={t("today.topics.title")}> <Card title={t("today.topics.title")}>
<div className="hb-today-actions" style={{ margin: "0 0 0.5rem" }}>
<Button
type="button"
variant="ghost"
disabled={refreshingTrends}
onClick={() => void onRefreshTrends()}
>
{refreshingTrends ? t("common.loading") : t("today.refreshTopics")}
</Button>
</div>
{topicCards.length === 0 ? ( {topicCards.length === 0 ? (
<EmptyState <EmptyState
title={t("today.topics.empty")} title={t("today.topics.empty")}
action={ action={
<Link to="/app/studio"> <div className="hb-today-actions" style={{ margin: 0 }}>
<Button type="button">{t("today.goStudio")}</Button> <Button
</Link> type="button"
onClick={() => void onRefreshTrends()}
disabled={refreshingTrends}
>
{refreshingTrends ? t("common.loading") : t("today.refreshTopics")}
</Button>
<Link to="/app/studio?tab=inspire">
<Button type="button" variant="ghost">
{t("today.goStudio")}
</Button>
</Link>
</div>
} }
/> />
) : ( ) : (
@ -289,15 +454,20 @@ export function TodayPage() {
<ul className="hb-today-list"> <ul className="hb-today-list">
{topicCards.map((item) => ( {topicCards.map((item) => (
<li key={item.id}> <li key={item.id}>
<Link to="/app/studio" className="hb-today-list__item"> <Link to={topicHref(item.label)} className="hb-today-list__item">
<span className="hb-today-list__meta"> <span className="hb-today-list__meta">
{item.label} {item.label}
{typeof item.heat === "number" ? ( {typeof item.heat === "number" && item.heat > 0 ? (
<Badge tone="brand">{t("today.heat", { n: item.heat })}</Badge> <Badge tone="brand">{t("today.heat", { n: item.heat })}</Badge>
) : null} ) : null}
{item.source_label ? (
<span className="text-muted"> · {item.source_label}</span>
) : null}
</span> </span>
<span className="hb-today-list__text"> <span className="hb-today-list__text">
{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 ? "…" : ""} {(item.summary?.length || 0) > 100 ? "…" : ""}
</span> </span>
</Link> </Link>
@ -305,16 +475,18 @@ export function TodayPage() {
))} ))}
</ul> </ul>
<div className="hb-today-actions" style={{ margin: 0 }}> <div className="hb-today-actions" style={{ margin: 0 }}>
<Link to="/app/studio"> <Link to="/app/studio?tab=inspire">
<Button type="button" variant="ghost"> <Button type="button" variant="ghost">
{t("today.moreInspire")} {t("today.moreInspire")}
</Button> </Button>
</Link> </Link>
<Link to="/app/studio/new"> {topicCards[0] ? (
<Button type="button" variant="ghost"> <Link to={topicHref(topicCards[0].label)}>
{t("today.useTopic")} <Button type="button" variant="ghost">
</Button> {t("today.useTopic")}
</Link> </Button>
</Link>
) : null}
</div> </div>
</div> </div>
)} )}
@ -325,7 +497,7 @@ export function TodayPage() {
{failedOutbox.length === 0 && runningOutbox.length === 0 && sentToday === 0 ? ( {failedOutbox.length === 0 && runningOutbox.length === 0 && sentToday === 0 ? (
<p className="text-muted" style={{ margin: 0 }}> <p className="text-muted" style={{ margin: 0 }}>
{t("today.outbox.empty")}{" "} {t("today.outbox.empty")}{" "}
<Link to="/app/studio/new">{t("today.newThread")}</Link> <Link to="/app/studio?tab=compose">{t("today.newThread")}</Link>
{t("today.outbox.emptyMid")}{" "} {t("today.outbox.emptyMid")}{" "}
<Link to="/app/outbox">{t("nav.outbox")}</Link> <Link to="/app/outbox">{t("nav.outbox")}</Link>
{t("today.outbox.emptyEnd")} {t("today.outbox.emptyEnd")}
@ -342,15 +514,17 @@ export function TodayPage() {
{failedOutbox.slice(0, 2).map((o) => ( {failedOutbox.slice(0, 2).map((o) => (
<p key={o.id} style={{ margin: 0, fontSize: "0.85rem" }}> <p key={o.id} style={{ margin: 0, fontSize: "0.85rem" }}>
<Badge tone="danger">{t("today.badge.failed")}</Badge>{" "} <Badge tone="danger">{t("today.badge.failed")}</Badge>{" "}
<Link to={`/app/outbox/${o.id}`}>{o.title}</Link> <Link to={`/app/outbox/${o.id}`}>{o.title || o.id.slice(0, 8)}</Link>
</p> </p>
))} ))}
{runningOutbox.slice(0, 2).map((o) => ( {runningOutbox.slice(0, 2).map((o) => (
<p key={o.id} style={{ margin: 0, fontSize: "0.85rem" }}> <p key={o.id} style={{ margin: 0, fontSize: "0.85rem" }}>
<Badge tone="brand"> <Badge tone="brand">
{o.status === "scheduling" ? t("today.badge.scheduling") : t("today.badge.sending")} {o.status === "scheduling"
? t("today.badge.scheduling")
: t("today.badge.sending")}
</Badge>{" "} </Badge>{" "}
<Link to={`/app/outbox/${o.id}`}>{o.title}</Link> <Link to={`/app/outbox/${o.id}`}>{o.title || o.id.slice(0, 8)}</Link>
</p> </p>
))} ))}
<Link to="/app/outbox"> <Link to="/app/outbox">
@ -362,17 +536,29 @@ export function TodayPage() {
)} )}
</Card> </Card>
{/* 帳號成效(輕量) */} {/* 帳號成效 */}
<Card title={t("today.accounts.title")}> <Card title={t("today.accounts.title")}>
{accountPulses.length === 0 ? ( {accountPulses.length === 0 ? (
<EmptyState <EmptyState
title={t("today.accounts.empty")} title={t("today.accounts.empty")}
action={ action={
<Link to="/app/crew"> <div className="hb-today-actions" style={{ margin: 0 }}>
<Button type="button" variant="ghost"> {accounts.length > 0 ? (
{t("nav.crew")} <Button
</Button> type="button"
</Link> onClick={() => void onSyncOwnPosts()}
disabled={syncingPosts}
>
{syncingPosts ? t("common.loading") : t("today.syncPosts")}
</Button>
) : (
<Link to="/app/crew">
<Button type="button" variant="ghost">
{t("nav.crew")}
</Button>
</Link>
)}
</div>
} }
/> />
) : ( ) : (
@ -388,7 +574,8 @@ export function TodayPage() {
</div> </div>
<div className="hb-today-account__stats"> <div className="hb-today-account__stats">
<span> <span>
{t("today.views")} <strong>{row.views.toLocaleString(dateLocale)}</strong> {t("today.views")}{" "}
<strong>{row.views.toLocaleString(dateLocale)}</strong>
</span> </span>
<span> <span>
{t("today.likes")} <strong>{row.likes}</strong> {t("today.likes")} <strong>{row.likes}</strong>
@ -412,6 +599,14 @@ export function TodayPage() {
{t("today.viewPosts")} {t("today.viewPosts")}
</Button> </Button>
</Link> </Link>
<Button
type="button"
variant="ghost"
onClick={() => void onSyncOwnPosts()}
disabled={syncingPosts || !accounts.length}
>
{syncingPosts ? t("common.loading") : t("today.syncPosts")}
</Button>
<Link to="/app/crew"> <Link to="/app/crew">
<Button type="button" variant="ghost"> <Button type="button" variant="ghost">
{t("today.manageAccounts")} {t("today.manageAccounts")}

View File

@ -10,6 +10,7 @@ import { useData, useRepos } from "../data/DataContext";
import { useI18n } from "../i18n/I18nContext"; import { useI18n } from "../i18n/I18nContext";
import { memberRoleSummary } from "../lib/memberRole"; import { memberRoleSummary } from "../lib/memberRole";
import { pageSlice } from "../lib/pagination"; import { pageSlice } from "../lib/pagination";
import { getPlanRights } from "../lib/planRights";
import { import {
METER_META, METER_META,
PLANS, PLANS,
@ -141,7 +142,11 @@ export function UsagePage() {
setApplied({ granularity: g, from, to }); setApplied({ granularity: g, from, to });
} }
const warn = useMemo(() => (summary ? usageWarning(summary) : null), [summary]); const warn = useMemo(() => (summary ? usageWarning(summary, t) : null), [summary, t]);
const planRights = useMemo(
() => (summary ? getPlanRights(summary.plan.id as PlanId, t, formatPlanPrice) : null),
[summary, t, formatPlanPrice],
);
const meterRows = useMemo(() => { const meterRows = useMemo(() => {
if (!summary) return []; if (!summary) return [];
@ -193,18 +198,22 @@ export function UsagePage() {
} }
const pagedMembers = tenant ? pageSlice(tenant.members, memberPage, MEMBER_PAGE) : []; const pagedMembers = tenant ? pageSlice(tenant.members, memberPage, MEMBER_PAGE) : [];
// total_credits = 已用;配給一律用目前方案 monthly_creditsPLANS
const usedCredits = summary.platform?.credits_used ?? summary.total_credits;
const capCredits = summary.plan.monthly_credits;
const remainCredits = summary.unlimited
? summary.remaining_credits
: Math.max(0, capCredits - usedCredits);
const usedPct = Math.min( const usedPct = Math.min(
100, 100,
summary.plan.monthly_credits > 0 summary.pct > 0
? Math.round((summary.total_credits / summary.plan.monthly_credits) * 100) ? Math.min(100, summary.pct)
: 0, : capCredits > 0
? Math.round((usedCredits / capCredits) * 100)
: 0,
); );
const barTone = const barTone =
summary.total_credits > summary.plan.monthly_credits usedCredits > capCredits ? " is-danger" : usedPct >= 70 ? " is-warn" : "";
? " is-danger"
: usedPct >= 70
? " is-warn"
: "";
return ( return (
<> <>
@ -260,9 +269,17 @@ export function UsagePage() {
{formatPlanPrice(summary.plan.price_twd)} {formatPlanPrice(summary.plan.price_twd)}
{t("usage.planMeta", { n: summary.plan.monthly_credits })} {t("usage.planMeta", { n: summary.plan.monthly_credits })}
</p> </p>
{planRights ? (
<>
<p className="hb-usage-sub__caps">{planRights.approxCallsLine}</p>
<p className="hb-usage-sub__caps hb-usage-sub__caps--pts">
{planRights.softCapsLine}
</p>
</>
) : null}
</div> </div>
<Button type="button" onClick={() => navigate("/app/usage/plans")}> <Button type="button" onClick={() => navigate("/app/usage/plans")}>
{t("usage.changePlan")} {summary.plan.id === "free" ? t("usage.upgradePlan") : t("usage.changePlan")}
</Button> </Button>
</div> </div>
</Card> </Card>
@ -274,13 +291,15 @@ export function UsagePage() {
<div> <div>
<span className="hb-usage-chart__name">{t("usage.usedThisMonth")}</span> <span className="hb-usage-chart__name">{t("usage.usedThisMonth")}</span>
<p className="hb-usage-status__big"> <p className="hb-usage-status__big">
{summary.total_credits} {usedCredits}
<span className="text-muted"> / {summary.plan.monthly_credits}</span> <span className="text-muted"> / {capCredits}</span>
</p> </p>
</div> </div>
<div className="hb-usage-status__remain"> <div className="hb-usage-status__remain">
<span className="hb-usage-chart__name">{t("usage.remainLabel")}</span> <span className="hb-usage-chart__name">{t("usage.remainLabel")}</span>
<p className="hb-usage-status__big">{summary.remaining_credits}</p> <p className="hb-usage-status__big">
{summary.unlimited ? "∞" : remainCredits}
</p>
</div> </div>
</div> </div>
<div className="hb-progress" aria-hidden> <div className="hb-progress" aria-hidden>
@ -292,11 +311,13 @@ export function UsagePage() {
<div className="hb-usage-status__foot"> <div className="hb-usage-status__foot">
<span className="text-muted">{usedPct}%</span> <span className="text-muted">{usedPct}%</span>
<span className="hb-usage-side-metrics" style={{ margin: 0, border: 0, padding: 0 }}> <span className="hb-usage-side-metrics" style={{ margin: 0, border: 0, padding: 0 }}>
<span> <span title={t("usage.side.aiCredits")}>
AI <strong>{summary.ai_calls}</strong> AI <strong>{summary.ai_calls}</strong>
<span className="text-muted">{t("usage.meter.pt")}</span>
</span> </span>
<span> <span title={t("usage.side.searchCredits")}>
Search <strong>{summary.search_calls}</strong> Search <strong>{summary.search_calls}</strong>
<span className="text-muted">{t("usage.meter.pt")}</span>
</span> </span>
</span> </span>
</div> </div>
@ -465,7 +486,10 @@ export function UsagePage() {
> >
{(Object.keys(PLANS) as PlanId[]).map((id) => ( {(Object.keys(PLANS) as PlanId[]).map((id) => (
<option key={id} value={id}> <option key={id} value={id}>
{PLANS[id].name} {t("admin.users.planOption", {
name: PLANS[id].name,
credits: PLANS[id].monthly_credits,
})}
</option> </option>
))} ))}
</Select> </Select>

View File

@ -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 <Badge tone="neutral">{t("common.dash")}</Badge>;
if (value > 0) return <Badge tone="success">{fmtDelta(value)}</Badge>;
if (value < 0) return <Badge tone="danger">{fmtDelta(value)}</Badge>;
return <Badge tone="neutral">{t("insights.zeroPct")}</Badge>;
}
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<typeof metric, string> = {
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 (
<div className="hb-insights-bars" aria-label={t("insights.barsAria", { metric: labels[metric] })}>
<p className="hb-field__label" style={{ margin: "0 0 0.5rem" }}>
{t("insights.barsLabel", { metric: labels[metric], n: months.length })}
<span className="text-muted" style={{ fontWeight: 500 }}>
{t("insights.clickBar")}
</span>
</p>
<div className="hb-insights-bars__row" role="listbox" aria-label={t("insights.pickMonthAria")}>
{months.map((m) => {
const h = Math.round((m[metric] / max) * 100);
const selected = m.key === selectedKey;
const valStr = m[metric].toLocaleString(loc);
return (
<button
key={m.key}
type="button"
role="option"
aria-selected={selected}
className={`hb-insights-bar${selected ? " is-selected" : ""}${m.source === "estimate" ? " is-estimate" : ""}`}
onClick={() => onSelect(m.key)}
title={t("insights.barTitle", {
label: m.label,
value: valStr,
est: m.source === "estimate" ? t("insights.est") : "",
})}
>
<div className="hb-insights-bar__track">
<div
className={`hb-insights-bar__fill${selected ? " is-current" : ""}${m.source === "estimate" ? " is-estimate" : ""}`}
style={{ height: `${Math.max(h, m[metric] > 0 ? 6 : 2)}%` }}
/>
</div>
<span className="hb-insights-bar__val">
{metric === "views" && m.views >= 1000
? `${Math.round(m.views / 100) / 10}k`
: m[metric]}
</span>
<span className={`hb-insights-bar__lab${selected ? " is-current" : ""}`}>
{t("insights.monthSuffix", { m: m.label.replace(/^\d+\//, "") })}
</span>
</button>
);
})}
</div>
</div>
);
}
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 (
<svg
className="hb-insights-spark"
viewBox={`0 0 ${w} ${h}`}
width="100%"
height={h}
role="img"
aria-label={t("insights.sparkAria")}
>
<polyline
fill="none"
stroke="var(--hb-brand)"
strokeWidth="2.2"
strokeLinejoin="round"
strokeLinecap="round"
points={poly}
/>
{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 (
<g key={m.key} style={{ cursor: "pointer" }} onClick={() => onSelect(m.key)}>
<circle cx={x} cy={y} r={12} fill="transparent" />
<circle
cx={x}
cy={y}
r={selected ? 4.5 : 2.4}
fill={selected ? "var(--hb-brand)" : "var(--hb-surface)"}
stroke="var(--hb-brand)"
strokeWidth="1.5"
/>
</g>
);
})}
</svg>
);
}
/**
* 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<ThreadsAccount[]>([]);
const [localAccountId, setLocalAccountId] = useState("");
const [posts, setPosts] = useState<OwnPost[]>([]);
const [syncedAt, setSyncedAt] = useState<number | null>(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<AccountInsightsSnapshot[]>([]);
const [historyTick, setHistoryTick] = useState(0);
const bootstrappedRef = useRef<string>("");
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 真 syncsingleflight
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 (
<div className={`hb-insights-panel${showTitle ? " is-page" : ""}`}>
<div className="hb-insights-toolbar">
{!hideAccountSelect ? (
<Select
label={t("insights.account")}
value={accountId}
onChange={(e) => setLocalAccountId(e.target.value)}
disabled={!accounts.length || Boolean(accountIdProp)}
>
{accounts.length === 0 ? (
<option value="">{t("insights.noAccount")}</option>
) : (
accounts.map((a) => (
<option key={a.id} value={a.id}>
@{a.username}
{a.display_name ? ` · ${a.display_name}` : ""}
</option>
))
)}
</Select>
) : (
<div className="hb-insights-toolbar__hint">
<p className="text-muted" style={{ margin: 0, fontSize: "0.85rem" }}>
{t("insights.panelHint")}
{syncedAt ? (
<>
{" · "}
{t("insights.lastSynced", { time: formatLocalDateTime(syncedAt) })}
</>
) : (
<> · {t("insights.neverSynced")}</>
)}
</p>
</div>
)}
<div className="hb-insights-toolbar__actions">
<Button
type="button"
disabled={Boolean(busy) || !accountId}
onClick={() => void syncPosts()}
>
{busy === "sync" ? t("insights.syncing") : t("insights.syncPosts")}
</Button>
<Link to="/app/studio?tab=posts">
<Button type="button" variant="ghost">
{t("insights.myPosts")}
</Button>
</Link>
</div>
</div>
{message ? (
<p className="hb-banner-ok hb-insights-status" role="status">
{message}
</p>
) : null}
{!accountId || !report ? (
<EmptyState
title={t("insights.pickAccount")}
action={
<Link to="/app/crew">
<Button type="button">{t("insights.goAccounts")}</Button>
</Link>
}
/>
) : !hasPosts ? (
<EmptyState
title={t("insights.emptyTitle")}
description={t("insights.emptyDesc")}
action={
<Button type="button" disabled={Boolean(busy)} onClick={() => void syncPosts()}>
{busy === "sync" ? t("insights.syncing") : t("insights.syncPosts")}
</Button>
}
/>
) : (
<div className="hb-stack">
{zeroViews ? (
<p className="hb-banner-warn hb-insights-status" role="status">
{t("insights.zeroViewsHint")}
</p>
) : null}
<div
className="hb-today-metrics hb-insights-kpis"
role="group"
aria-label={t("insights.kpiForMonth", {
label: monthLabel(analysisMonth),
})}
>
<div className="hb-today-metric">
<span className="hb-today-metric__label">
{isCurrentSelected ? t("insights.monthViews") : t("insights.metricViews")}
</span>
<span className="hb-today-metric__value">
{(kpi?.views ?? 0).toLocaleString(numLoc)}
</span>
<span className="hb-today-metric__hint">
{t("insights.vsPrev")} <DeltaBadge value={selectedDelta.views} />
</span>
</div>
<div className="hb-today-metric">
<span className="hb-today-metric__label">
{isCurrentSelected ? t("insights.monthLikes") : t("insights.metricLikes")}
</span>
<span className="hb-today-metric__value">{kpi?.likes ?? 0}</span>
<span className="hb-today-metric__hint">
{t("insights.vsPrev")} <DeltaBadge value={selectedDelta.likes} />
</span>
</div>
<div className="hb-today-metric">
<span className="hb-today-metric__label">
{isCurrentSelected ? t("insights.monthReplies") : t("insights.metricReplies")}
</span>
<span className="hb-today-metric__value">{kpi?.replies ?? 0}</span>
<span className="hb-today-metric__hint">
{t("insights.vsPrev")} <DeltaBadge value={selectedDelta.replies} />
</span>
</div>
<div className="hb-today-metric">
<span className="hb-today-metric__label">{t("insights.engRate")}</span>
<span className="hb-today-metric__value">
{fmtEngRate(kpi?.engagementRate ?? 0)}
</span>
<span className="hb-today-metric__hint">
{t("insights.postsInMonth", { n: kpi?.posts ?? 0 })} ·{" "}
<DeltaBadge value={selectedDelta.engagementRate} />
</span>
</div>
</div>
<Card title={t("insights.trendTitle", { user: account?.username || "—" })}>
<div className="hb-stack">
<Sparkline
months={report.months}
metric={metric === "likes" ? "likes" : "views"}
selectedKey={analysisMonth}
onSelect={selectMonth}
/>
<div className="hb-tabs hb-tabs--sm" role="tablist" aria-label={t("insights.chartMetrics")}>
{(["views", "likes", "replies", "posts"] as const).map((m) => (
<button
key={m}
type="button"
className={`hb-tab ${metric === m ? "is-active" : ""}`}
onClick={() => setMetric(m)}
>
{m === "views"
? t("insights.metricViews")
: m === "likes"
? t("insights.metricLikes")
: m === "replies"
? t("insights.metricReplies")
: t("insights.metricPosts")}
</button>
))}
</div>
<MonthBars
months={report.months}
metric={metric}
selectedKey={analysisMonth}
onSelect={selectMonth}
/>
{(kpi?.posts ?? 0) <= 0 ? (
<p className="text-muted hb-insights-no-analysis" style={{ margin: 0 }}>
{t("insights.noDataNoAnalysis", { label: monthLabel(analysisMonth) })}
</p>
) : selectedSnap &&
(selectedSnap.analysis.length > 0 || selectedSnap.recommendations.length > 0) ? (
<div className="hb-insights-snap-panel">
<div className="hb-insights-snap-meta">
<strong>{t("insights.analysisOf", { label: selectedSnap.month_label })}</strong>
<span className="text-muted" style={{ fontSize: "0.8rem" }}>
{t("insights.producedAt", { time: formatLocalDateTime(selectedSnap.analyzed_at) })}
{selectedSnap.delta.views != null
? t("insights.viewsVsPrev", { delta: fmtDelta(selectedSnap.delta.views) })
: ""}
</span>
<div className="hb-insights-snap-metrics">
<span>
{t("insights.statPosts")} <strong>{selectedSnap.metrics.posts}</strong>
</span>
<span>
{t("insights.statViews")}{" "}
<strong>{selectedSnap.metrics.views.toLocaleString(numLoc)}</strong>
</span>
<span>
{t("insights.statLikes")} <strong>{selectedSnap.metrics.likes}</strong>
</span>
<span>
{t("insights.statReplies")} <strong>{selectedSnap.metrics.replies}</strong>
</span>
<span>
{t("insights.engRate")}{" "}
<strong>{fmtEngRate(selectedSnap.metrics.engagementRate)}</strong>
</span>
</div>
</div>
{selectedSnap.analysis.length > 0 ? (
<div>
<p className="hb-field__label" style={{ margin: "0 0 0.4rem" }}>
{t("insights.conclusions")}
</p>
<ul className="hb-insights-bullets">
{selectedSnap.analysis.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</div>
) : null}
{selectedSnap.recommendations.length > 0 ? (
<div>
<p className="hb-field__label" style={{ margin: "0 0 0.4rem" }}>
{t("insights.recommendations")}
</p>
<ul className="hb-insights-bullets hb-insights-bullets--action">
{selectedSnap.recommendations.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</div>
) : null}
{selectedSnap.top_highlights?.length ? (
<div>
<p className="hb-field__label" style={{ margin: "0 0 0.4rem" }}>
{t("insights.highlights")}
</p>
<ul className="hb-insights-bullets">
{selectedSnap.top_highlights.map((h) => (
<li key={h}>{h}</li>
))}
</ul>
</div>
) : null}
<div className="hb-wizard-actions">
<Link to="/app/studio?tab=inspire">
<Button type="button" variant="ghost">
{t("insights.findTopics")}
</Button>
</Link>
<Link to="/app/scout">
<Button type="button" variant="ghost">
{t("insights.goScout")}
</Button>
</Link>
</div>
</div>
) : (
<p className="text-muted" style={{ margin: 0 }}>
{t("insights.noAnalysisYet", { label: monthLabel(analysisMonth) })}
</p>
)}
</div>
</Card>
<Card
title={t("insights.topPostsOfMonth", {
label: monthLabel(analysisMonth),
})}
>
{monthTopPosts.length === 0 ? (
<EmptyState
title={t("insights.noPostsInMonth", { label: monthLabel(analysisMonth) })}
description={
isCurrentSelected
? t("insights.emptyDesc")
: t("insights.pastMonthEmptyHint")
}
action={
isCurrentSelected ? (
<Button type="button" onClick={() => void syncPosts()} disabled={Boolean(busy)}>
{busy === "sync" ? t("insights.syncing") : t("insights.syncPosts")}
</Button>
) : undefined
}
/>
) : (
<ul className="hb-today-list">
{monthTopPosts.map((p, i) => (
<li key={p.id}>
<div className="hb-today-list__item" style={{ cursor: "default" }}>
<span className="hb-today-list__meta">
<Badge tone={i === 0 ? "success" : "neutral"}>#{i + 1}</Badge>
{p.topic_tag ? <Badge tone="brand">{p.topic_tag}</Badge> : null}
<span>
{t("insights.postStats", {
views: p.view_count.toLocaleString(numLoc),
likes: p.like_count,
replies: p.reply_count,
})}
</span>
<span>{formatLocalDateTime(p.published_at)}</span>
</span>
<span className="hb-today-list__text">
{p.text.slice(0, 120)}
{p.text.length > 120 ? "…" : ""}
</span>
{p.insight || p.formula_summary ? (
<span className="text-muted" style={{ fontSize: "0.8rem" }}>
{p.insight || p.formula_summary}
</span>
) : null}
{p.permalink ? (
<a href={p.permalink} target="_blank" rel="noreferrer" style={{ fontSize: "0.8rem" }}>
{t("insights.openThreads")}
</a>
) : null}
</div>
</li>
))}
</ul>
)}
</Card>
</div>
)}
</div>
);
}

View File

@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useNavigate } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { Button, EmptyState, Input, Select, Textarea } from "../../components/ui"; import { Button, EmptyState, Input, MarkdownText, Select, Textarea } from "../../components/ui";
import { AppIcon } from "../../components/ui/AppIcons"; import { AppIcon } from "../../components/ui/AppIcons";
import { useData, useRepos } from "../../data/DataContext"; import { useData, useRepos } from "../../data/DataContext";
import type { import type {
@ -53,7 +53,10 @@ export function InspirePanel({ accountId, personaId }: Props) {
const { refresh, tick } = useData(); const { refresh, tick } = useData();
const { t } = useI18n(); const { t } = useI18n();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
/** 今日/外連帶入的 topic 只套一次 */
const topicSeeded = useRef(false);
const [session, setSession] = useState<InspireSession | null>(null); const [session, setSession] = useState<InspireSession | null>(null);
const [sessionList, setSessionList] = useState<InspireSessionSummary[]>([]); const [sessionList, setSessionList] = useState<InspireSessionSummary[]>([]);
@ -186,6 +189,19 @@ export function InspirePanel({ accountId, personaId }: Props) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" }); bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [session?.messages.length, busy, streamingText]); }, [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( const pinnedElements = useMemo(
() => elements.filter((e) => pinnedIds.includes(e.id)), () => elements.filter((e) => pinnedIds.includes(e.id)),
[elements, pinnedIds], [elements, pinnedIds],
@ -997,7 +1013,11 @@ export function InspirePanel({ accountId, personaId }: Props) {
? t("inspire.ai") ? t("inspire.ai")
: t("inspire.system")} : t("inspire.system")}
</div> </div>
<div className="hb-inspire-msg__text">{m.text}</div> {m.role === "assistant" || m.role === "system" ? (
<MarkdownText text={m.text} className="hb-inspire-msg__md" />
) : (
<div className="hb-inspire-msg__text">{m.text}</div>
)}
{m.draft?.body ? ( {m.draft?.body ? (
<div className="hb-inspire-draft"> <div className="hb-inspire-draft">
<pre className="hb-inspire-draft__body">{m.draft.body}</pre> <pre className="hb-inspire-draft__body">{m.draft.body}</pre>
@ -1016,8 +1036,8 @@ export function InspirePanel({ accountId, personaId }: Props) {
{streamingText ? ( {streamingText ? (
<div className="hb-inspire-msg hb-inspire-msg--assistant"> <div className="hb-inspire-msg hb-inspire-msg--assistant">
<div className="hb-inspire-msg__role">{t("inspire.ai")}</div> <div className="hb-inspire-msg__role">{t("inspire.ai")}</div>
<div className="hb-inspire-msg__text"> <div className="hb-inspire-msg__stream">
{streamingText} <MarkdownText text={streamingText} className="hb-inspire-msg__md" />
<span className="hb-inspire-cursor" aria-hidden> <span className="hb-inspire-cursor" aria-hidden>
</span> </span>
@ -1133,7 +1153,7 @@ export function InspirePanel({ accountId, personaId }: Props) {
aria-label={t("inspire.inputAria")} aria-label={t("inspire.inputAria")}
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
rows={2} rows={1}
placeholder={t("inspire.inputPh")} placeholder={t("inspire.inputPh")}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
@ -1155,7 +1175,7 @@ export function InspirePanel({ accountId, personaId }: Props) {
</span> </span>
) : ( ) : (
<AppIcon name="outbox" size={18} /> <AppIcon name="send" size={18} />
)} )}
</button> </button>
</div> </div>

View File

@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react"; 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 { ImageAttach } from "../../components/studio/ImageAttach";
import { Badge, Button, Card, EmptyState, Input, Select, Textarea } from "../../components/ui"; import { Badge, Button, Card, EmptyState, Input, Select, Textarea } from "../../components/ui";
import { useData, useRepos } from "../../data/DataContext"; import { useData, useRepos } from "../../data/DataContext";
@ -388,11 +388,6 @@ export function PlaysPanel({ accountId, personas: personasProp = [] }: Props) {
return isPersonaReady(p) ? p! : null; 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( async function generateForStep(
step: PlayStep, step: PlayStep,
index: number, index: number,

View File

@ -841,8 +841,179 @@ svg {
} }
.hb-inspire-msg__text { .hb-inspire-msg__text {
font-size: 0.9rem; 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; line-height: 1.5;
white-space: pre-wrap; 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 { .hb-inspire-draft {
@ -970,13 +1141,14 @@ svg {
flex-wrap: wrap; flex-wrap: wrap;
} }
/* 聊天軟體列:輸入 + 右側圓形發送 */ /* 聊天軟體列:輸入 + 右側圓形發送(與對話框底緣對齊) */
.hb-inspire-composer__row { .hb-inspire-composer__row {
display: grid; display: flex;
grid-template-columns: minmax(0, 1fr) auto; flex-direction: row;
gap: 0.45rem; align-items: flex-end;
align-items: end; gap: 0.5rem;
min-width: 0; min-width: 0;
width: 100%;
} }
/* 本輪參考:多 pin 橫向捲 */ /* 本輪參考:多 pin 橫向捲 */
@ -1130,9 +1302,12 @@ svg {
.hb-inspire-composer__row .hb-field { .hb-inspire-composer__row .hb-field {
margin: 0; margin: 0;
display: block; display: flex;
width: 100%; flex: 1 1 auto;
flex-direction: column;
width: auto;
min-width: 0; min-width: 0;
gap: 0;
} }
.hb-inspire-composer__row .hb-field__label:empty { .hb-inspire-composer__row .hb-field__label:empty {
@ -1141,30 +1316,36 @@ svg {
.hb-inspire-composer .hb-textarea { .hb-inspire-composer .hb-textarea {
resize: none; resize: none;
display: block;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
min-height: 2.75rem; /* 明確蓋掉全域 .hb-textarea min-height: 6.5rem,與送出圓鈕同高對齊 */
min-height: 2.75rem !important;
max-height: 5.5rem; max-height: 5.5rem;
height: auto; height: auto;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.4; line-height: 1.4;
padding: 0.55rem 0.75rem; padding: 0.6rem 0.85rem;
border-radius: 1.15rem; border-radius: 1.25rem;
field-sizing: content;
} }
.hb-inspire-send-btn { .hb-inspire-send-btn {
flex-shrink: 0; flex: 0 0 2.75rem;
width: 2.5rem; width: 2.75rem;
height: 2.5rem; height: 2.75rem;
margin-bottom: 0.1rem; margin: 0;
padding: 0;
border: 0; border: 0;
border-radius: 999px; border-radius: 999px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
align-self: flex-end;
background: var(--hb-brand); background: var(--hb-brand);
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;
line-height: 0;
box-shadow: 0 1px 2px color-mix(in srgb, #000 12%, transparent); 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; 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 { .hb-inspire-send-btn .hb-app-icon {
display: block; display: block;
flex-shrink: 0;
/* 紙飛機視覺重心略偏左,微調置中 */
transform: translate(1px, 0.5px);
} }
.hb-inspire-send-btn__dots { .hb-inspire-send-btn__dots {
display: block;
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
width: 1em;
text-align: center;
} }
.hb-inspire-fp-bar__ok { .hb-inspire-fp-bar__ok {
@ -1476,13 +1663,14 @@ svg {
} }
.hb-inspire-composer .hb-textarea { .hb-inspire-composer .hb-textarea {
min-height: 2.5rem; min-height: 2.5rem !important;
max-height: 4.5rem; max-height: 4.5rem;
} }
.hb-inspire-send-btn { .hb-inspire-send-btn {
width: 2.35rem; flex-basis: 2.5rem;
height: 2.35rem; width: 2.5rem;
height: 2.5rem;
} }
} }
@ -2747,6 +2935,19 @@ svg {
color: var(--hb-muted); 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 { .hb-usage-status {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -2819,6 +3020,13 @@ svg {
font-weight: 750; 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 { .hb-plans-page__back {
margin-left: auto; margin-left: auto;
font-size: var(--hb-text-xs); font-size: var(--hb-text-xs);
@ -2917,6 +3125,20 @@ svg {
font-weight: 700; 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 { .hb-pricing__bullets {
margin: 0; margin: 0;
padding: 0 0 0 1.05rem; padding: 0 0 0 1.05rem;
@ -3185,6 +3407,19 @@ svg {
color: var(--hb-brand); 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 { .hb-usage-ppick {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); 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 { .hb-insights-toolbar {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
@ -3897,6 +4143,11 @@ a.hb-today-metric:hover {
align-items: end; align-items: end;
} }
.hb-insights-toolbar__hint {
min-width: 0;
align-self: center;
}
@media (max-width: 560px) { @media (max-width: 560px) {
.hb-insights-toolbar { .hb-insights-toolbar {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@ -549,6 +549,19 @@ a.hb-topbar__chip:hover {
color: var(--hb-text); 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 { .hb-usage-widget__nudge {
margin: 0; margin: 0;
padding: 0.45rem 0.55rem; padding: 0.45rem 0.55rem;

View File

@ -4,7 +4,7 @@
|------|-----| |------|-----|
| **Goal** | 依 `apps/web` **現 UI MUST** 交付真後端Phase C live | | **Goal** | 依 `apps/web` **現 UI MUST** 交付真後端Phase C live |
| **Phase** | `ready-to-implement` | | **Phase** | `ready-to-implement` |
| **Status** | **M0M5 done** · 下一槍 M6邀請冒煙契約閘 | | **Status** | **M0M6 done**(邀請 live + IV 閘 + smoke |
| **Backend** | **`apps/backend`** | | **Backend** | **`apps/backend`** |
| **Updated** | 2026-07-10 | | **Updated** | 2026-07-10 |

View File

@ -71,20 +71,20 @@
| T195 | scout outreach homework | M5 | feat | done | ~160 | | T195 | scout outreach homework | M5 | feat | done | ~160 |
| T196 | test m5 inspire scout integration | M5 | test | done | ~200 | | T196 | test m5 inspire scout integration | M5 | test | done | ~200 |
| T197 | fe live scout inspire | M5 | feat | done | ~150 | | T197 | fe live scout inspire | M5 | feat | done | ~150 |
| T215 | invite member network apply | M6 | feat | todo | ~160 | | T215 | invite member network apply | M6 | feat | done | ~160 |
| T216 | invite admin tree move | M6 | feat | todo | ~180 | | T216 | invite admin tree move | M6 | feat | done | ~180 |
| T217 | fe live invite | M6 | feat | todo | ~120 | | T217 | fe live invite | M6 | feat | done | ~120 |
| T218 | smoke p0 p1 | M6 | test | todo | ~150 | | T218 | smoke p0 p1 | M6 | test | done | ~150 |
| T219 | test m6 invite integration | M6 | test | todo | ~160 | | T219 | test m6 invite integration | M6 | test | done | ~160 |
| T220 | test fe be live integration | M6 | test | todo | ~200 | | T220 | test fe be live integration | M6 | test | done | ~200 |
| T221 | test contract security x | M6 | test | todo | ~150 | | T221 | test contract security x | M6 | test | done | ~150 |
| T222 | acceptance matrix ci gate | M6 | test | todo | ~180 | | T222 | acceptance matrix ci gate | M6 | test | done | ~180 |
## Counts ## Counts
- **Total: 59**feat 47 · test 12 - **Total: 59**feat 47 · test 12
- **done: 35**M0 + M1 + M2 + M3 - **done: 59**M0M6
- **todo: 24** - **todo: 0**
## Milestone map ## Milestone map
@ -94,19 +94,20 @@
| M1 會員 Admin 設定 | T110T118 | T119 | **done** | | M1 會員 Admin 設定 | T110T118 | T119 | **done** |
| M2 OAuth Job 通知 FE 地基 | T130T135 | T136 | **done** | | M2 OAuth Job 通知 FE 地基 | T130T135 | T136 | **done** |
| M3 用量 BYOK AI 真上傳 擴充 | T150T155 T157 T158 | T156 | **done** | | M3 用量 BYOK AI 真上傳 擴充 | T150T155 T157 T158 | T156 | **done** |
| M4 創作 Outbox 真發 | T165T171 | T172 | | M4 創作 Outbox 真發 | T165T171 | T172 | **done** |
| M5 靈感(現UI) 海巡 | T190T195 T197 | T196 | | M5 靈感(現UI) 海巡 | T190T195 T197 | T196 | **done** |
| M6 邀請 整合 矩陣 | T215T217 | T218T222 | | M6 邀請 整合 矩陣 | T215T217 | T218T222 | **done** |
## 建議開工序 ## 建議開工序
1. ~~T100 → T108M0~~ **done** 1. ~~T100 → T108M0~~ **done**
2. ~~T110 → T119M1~~ **done**(含 FE live auth/admin/settings 2. ~~T110 → T119M1~~ **done**
3. ~~T130 → T136M2~~ **done** 3. ~~T130 → T136M2~~ **done**
4. ~~T150 → T158M3~~ **done**(用量 platform/byok、AI/Exa proxy、方案購買、擴充 ZIP 4. ~~T150 → T158M3~~ **done**
5. 下一槍:**M6** T215邀請 live冒煙契約閘 5. ~~T165 → T172M4~~ **done**
6. 每 M 功能做完必做該 M 的 **test** task 才算 M 完成 6. ~~T190 → T197M5~~ **done**
7. 使用者指定 `T###` 才寫碼 7. ~~T215 → T222M6~~ **done**(邀請 live + IV 測試 + smoke
## Remove 提醒(禁止當 task 交付) ## Remove 提醒(禁止當 task 交付)