fix frontend
This commit is contained in:
parent
e41c1f96a2
commit
ef6322b635
|
|
@ -21,3 +21,4 @@ import "proxy.api"
|
|||
import "extension.api"
|
||||
import "studio.api"
|
||||
import "m5.api"
|
||||
import "invite.api"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
syntax = "v1"
|
||||
|
||||
// M6 邀請網絡:會員補填碼 + admin 樹/移動(防環)
|
||||
|
||||
type (
|
||||
InviteMemberBrief {
|
||||
Uid int64 `json:"uid"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
ParentUid int64 `json:"parent_uid,optional"` // 0 = 無上線
|
||||
Roles []string `json:"roles"`
|
||||
Status string `json:"status"`
|
||||
DownlineCount int `json:"downline_count"`
|
||||
TotalDownlineCount int `json:"total_downline_count"`
|
||||
Depth int `json:"depth"`
|
||||
JoinedAt int64 `json:"joined_at"`
|
||||
AvatarUrl string `json:"avatar_url,optional"`
|
||||
}
|
||||
|
||||
InviteTreeNode {
|
||||
Uid int64 `json:"uid"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
ParentUid int64 `json:"parent_uid,optional"`
|
||||
Roles []string `json:"roles"`
|
||||
Status string `json:"status"`
|
||||
DownlineCount int `json:"downline_count"`
|
||||
TotalDownlineCount int `json:"total_downline_count"`
|
||||
Depth int `json:"depth"`
|
||||
JoinedAt int64 `json:"joined_at"`
|
||||
AvatarUrl string `json:"avatar_url,optional"`
|
||||
Children []InviteTreeNode `json:"children"`
|
||||
}
|
||||
|
||||
MyInviteNetworkData {
|
||||
Me InviteMemberBrief `json:"me"`
|
||||
Upline *InviteMemberBrief `json:"upline,optional"`
|
||||
Downlines []InviteMemberBrief `json:"downlines"`
|
||||
TotalDownlineCount int `json:"total_downline_count"`
|
||||
}
|
||||
|
||||
ApplyInviteCodeReq {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
InviteTreeData {
|
||||
List []InviteTreeNode `json:"list"`
|
||||
}
|
||||
|
||||
MoveInviteMemberReq {
|
||||
Uid int64 `json:"uid"`
|
||||
ParentUid int64 `json:"parent_uid,optional"` // 0 = 獨立根
|
||||
}
|
||||
|
||||
ListParentCandidatesReq {
|
||||
ExcludeSubtreeRootUid int64 `form:"exclude_subtree_root_uid,optional"`
|
||||
}
|
||||
|
||||
InviteParentCandidatesData {
|
||||
List []InviteMemberBrief `json:"list"`
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
group: invite
|
||||
prefix: /api/v1/invite
|
||||
middleware: AuthJWT
|
||||
)
|
||||
service gateway {
|
||||
@handler GetMyInviteNetwork
|
||||
get /me returns (MyInviteNetworkData)
|
||||
|
||||
@handler ApplyInviteCode
|
||||
post /apply (ApplyInviteCodeReq) returns (MyInviteNetworkData)
|
||||
}
|
||||
|
||||
@server (
|
||||
group: invite
|
||||
prefix: /api/v1/invite
|
||||
middleware: AuthJWT,AdminAuth
|
||||
)
|
||||
service gateway {
|
||||
@handler GetInviteTree
|
||||
get /tree returns (InviteTreeData)
|
||||
|
||||
@handler MoveInviteMember
|
||||
post /move (MoveInviteMemberReq) returns (InviteMemberBrief)
|
||||
|
||||
@handler ListInviteParentCandidates
|
||||
get /parent-candidates (ListParentCandidatesReq) returns (InviteParentCandidatesData)
|
||||
}
|
||||
|
|
@ -19,8 +19,16 @@ type (
|
|||
CompletedAt int64 `json:"completed_at,optional"`
|
||||
}
|
||||
|
||||
// tab: recurring=遠期定期排程 | active=執行中/待領取 | history=終態歷史
|
||||
JobListReq {
|
||||
Tab string `form:"tab,optional"`
|
||||
Page int `form:"page,optional"`
|
||||
PageSize int `form:"pageSize,optional"`
|
||||
}
|
||||
|
||||
JobListData {
|
||||
List []JobPublic `json:"list"`
|
||||
List []JobPublic `json:"list"`
|
||||
Pagination Pagination `json:"pagination"`
|
||||
}
|
||||
|
||||
JobIdPath {
|
||||
|
|
@ -36,7 +44,7 @@ type (
|
|||
)
|
||||
service gateway {
|
||||
@handler ListJobs
|
||||
get / returns (JobListData)
|
||||
get / (JobListReq) returns (JobListData)
|
||||
|
||||
@handler GetJob
|
||||
get /:id (JobIdPath) returns (JobPublic)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -9,12 +9,20 @@ import (
|
|||
"apps/backend/internal/logic/jobs"
|
||||
"apps/backend/internal/response"
|
||||
"apps/backend/internal/svc"
|
||||
"apps/backend/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
)
|
||||
|
||||
func ListJobsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.JobListReq
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
response.Write(r.Context(), w, nil, err)
|
||||
return
|
||||
}
|
||||
l := jobs.NewListJobsLogic(r.Context(), svcCtx)
|
||||
data, err := l.ListJobs()
|
||||
data, err := l.ListJobs(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
compose "apps/backend/internal/handler/compose"
|
||||
extension "apps/backend/internal/handler/extension"
|
||||
inspire "apps/backend/internal/handler/inspire"
|
||||
invite "apps/backend/internal/handler/invite"
|
||||
jobs "apps/backend/internal/handler/jobs"
|
||||
media "apps/backend/internal/handler/media"
|
||||
mentions "apps/backend/internal/handler/mentions"
|
||||
|
|
@ -311,6 +312,49 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
rest.WithPrefix("/api/v1/inspire"),
|
||||
)
|
||||
|
||||
server.AddRoutes(
|
||||
rest.WithMiddlewares(
|
||||
[]rest.Middleware{serverCtx.AuthJWT},
|
||||
[]rest.Route{
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/apply",
|
||||
Handler: invite.ApplyInviteCodeHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/me",
|
||||
Handler: invite.GetMyInviteNetworkHandler(serverCtx),
|
||||
},
|
||||
}...,
|
||||
),
|
||||
rest.WithPrefix("/api/v1/invite"),
|
||||
)
|
||||
|
||||
server.AddRoutes(
|
||||
rest.WithMiddlewares(
|
||||
[]rest.Middleware{serverCtx.AuthJWT, serverCtx.AdminAuth},
|
||||
[]rest.Route{
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/move",
|
||||
Handler: invite.MoveInviteMemberHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/parent-candidates",
|
||||
Handler: invite.ListInviteParentCandidatesHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/tree",
|
||||
Handler: invite.GetInviteTreeHandler(serverCtx),
|
||||
},
|
||||
}...,
|
||||
),
|
||||
rest.WithPrefix("/api/v1/invite"),
|
||||
)
|
||||
|
||||
server.AddRoutes(
|
||||
rest.WithMiddlewares(
|
||||
[]rest.Middleware{serverCtx.AuthJWT},
|
||||
|
|
@ -357,11 +401,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
[]rest.Route{
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/upload",
|
||||
Handler: media.UploadHandler(serverCtx),
|
||||
Path: "/generate-image",
|
||||
Handler: media.GenerateImageHandler(serverCtx),
|
||||
},
|
||||
}...,
|
||||
),
|
||||
rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
|
||||
rest.WithPrefix("/api/v1/media"),
|
||||
)
|
||||
|
||||
|
|
@ -371,12 +416,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
[]rest.Route{
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/generate-image",
|
||||
Handler: media.GenerateImageHandler(serverCtx),
|
||||
Path: "/upload",
|
||||
Handler: media.UploadHandler(serverCtx),
|
||||
},
|
||||
}...,
|
||||
),
|
||||
rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
|
||||
rest.WithPrefix("/api/v1/media"),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
|
||||
"apps/backend/internal/middleware"
|
||||
jobDomain "apps/backend/internal/module/job/domain"
|
||||
"apps/backend/internal/response"
|
||||
"apps/backend/internal/svc"
|
||||
"apps/backend/internal/types"
|
||||
|
|
@ -21,7 +22,7 @@ func NewListJobsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListJobs
|
|||
return &ListJobsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *ListJobsLogic) ListJobs() (*types.JobListData, error) {
|
||||
func (l *ListJobsLogic) ListJobs(req *types.JobListReq) (*types.JobListData, error) {
|
||||
if l.svcCtx.Jobs == nil {
|
||||
return nil, response.Biz(503, 503001, "jobs module not configured")
|
||||
}
|
||||
|
|
@ -29,7 +30,9 @@ func (l *ListJobsLogic) ListJobs() (*types.JobListData, error) {
|
|||
if !ok {
|
||||
return nil, response.Biz(401, 401001, "missing authorization")
|
||||
}
|
||||
list, err := l.svcCtx.Jobs.List(l.ctx, uid)
|
||||
tab := jobDomain.NormalizeListTab(req.Tab)
|
||||
page, pageSize := jobDomain.ClampPage(req.Page, req.PageSize)
|
||||
list, total, err := l.svcCtx.Jobs.ListTab(l.ctx, uid, tab, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -37,5 +40,17 @@ func (l *ListJobsLogic) ListJobs() (*types.JobListData, error) {
|
|||
for _, j := range list {
|
||||
out = append(out, types.JobFromModel(j))
|
||||
}
|
||||
return &types.JobListData{List: out}, nil
|
||||
totalPages := 0
|
||||
if pageSize > 0 {
|
||||
totalPages = int((total + int64(pageSize) - 1) / int64(pageSize))
|
||||
}
|
||||
return &types.JobListData{
|
||||
List: out,
|
||||
Pagination: types.Pagination{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Total: total,
|
||||
TotalPages: totalPages,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ func TestIN_05_GenerateRequiresMaterialAndRewrites(t *testing.T) {
|
|||
require.NotNil(t, last.Draft)
|
||||
require.NotEmpty(t, last.Draft.Body)
|
||||
require.Contains(t, out.Prompt, "待改寫內容")
|
||||
require.Contains(t, out.Prompt, "改寫者")
|
||||
require.Contains(t, out.Prompt, "重寫")
|
||||
require.Contains(t, out.Prompt, mat[:8])
|
||||
// session 泡泡只留短紀錄,不是整包素材正文
|
||||
user := out.Session.Messages[len(out.Session.Messages)-2]
|
||||
|
|
|
|||
|
|
@ -945,19 +945,17 @@ func inspireSystemRules(mode, lang string, maxChars int) string {
|
|||
}
|
||||
if en {
|
||||
return strings.Join([]string{
|
||||
"You are a Threads rewriter, not an ideation partner.",
|
||||
"Only rewrite 【Content to rewrite】 in the persona's voice into a post-ready body.",
|
||||
"Do not invent a new topic or facts not in the material. No analysis, no titles, no markdown, no outline.",
|
||||
"You may adjust rhythm, slang, emotion, and a closing question to match the persona.",
|
||||
"Output ONLY the post body in the persona's language (match language fingerprint / samples). " + lenRuleEn + ". Do not invent brands not in material/pins.",
|
||||
"Rewrite 【Content to rewrite】 into one Threads post in the persona's voice.",
|
||||
"Use 【Persona】 only for how they sound (tone / rhythm / wording). Just rewrite — do not over-engineer openings or force templates.",
|
||||
"Keep the material's topic and facts. No analysis, no titles, no markdown, no outline.",
|
||||
"Output ONLY the post body. " + lenRuleEn + ". Do not invent brands not in material/pins.",
|
||||
}, "\n")
|
||||
}
|
||||
return strings.Join([]string{
|
||||
"你是 Threads「改寫者」,不是發想者。",
|
||||
"只能依【待改寫內容】與【人設】寫出可貼出的正文。",
|
||||
"禁止:另起新主題、加入素材沒有的情節或事實、長篇分析、標題前綴、markdown、條列大綱。",
|
||||
"可以:調整句式、節奏、口頭禪、情緒與結尾問句,使口氣符合人設。",
|
||||
"只輸出正文,語言必須與【人設】語言指紋/範例一致(若人設是英文就用英文,是繁中就用繁中口語)。" + lenRuleZh + "。未在素材/pin 出現的品牌勿捏造。",
|
||||
"依【待改寫內容】用人設口吻重寫成一則 Threads 正文。",
|
||||
"【人設】只決定「聽起來像誰」;直接重寫即可,不必設計固定開頭、不必套模板。",
|
||||
"主題與事實以素材為準。不要分析、標題、markdown、大綱。",
|
||||
"只輸出正文。" + lenRuleZh + "。未在素材/pin 出現的品牌勿捏造。",
|
||||
}, "\n")
|
||||
}
|
||||
// chat:正常發想對話,不限 Threads 字數、不強壓極短(完整討論)
|
||||
|
|
@ -1209,7 +1207,11 @@ func formatPersonaPromptBlock(p *PersonaSnapshot, mode, lang string) string {
|
|||
|
||||
fp := strings.TrimSpace(p.DraftText)
|
||||
if fp != "" {
|
||||
b.WriteString("【語言指紋 / Language fingerprint】\n")
|
||||
if en {
|
||||
b.WriteString("【Language fingerprint】\n")
|
||||
} else {
|
||||
b.WriteString("【語言指紋】\n")
|
||||
}
|
||||
b.WriteString(truncate(fp, 700))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
|
@ -1221,7 +1223,11 @@ func formatPersonaPromptBlock(p *PersonaSnapshot, mode, lang string) string {
|
|||
}
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
b.WriteString("【風格重點】\n")
|
||||
if en {
|
||||
b.WriteString("【Style notes】\n")
|
||||
} else {
|
||||
b.WriteString("【風格重點】\n")
|
||||
}
|
||||
b.WriteString(strings.Join(lines, "\n"))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,3 +105,63 @@ func ApplyProgress(j *Job, percent int, summary string) error {
|
|||
j.UpdatedAt = NowNano()
|
||||
return nil
|
||||
}
|
||||
|
||||
// List tab buckets(任務列表頁籤)
|
||||
const (
|
||||
TabRecurring = "recurring" // 遠期定期/已排程未到期
|
||||
TabActive = "active" // 執行中、待領取(已到期)、取消中
|
||||
TabHistory = "history" // 已完成/失敗/取消
|
||||
)
|
||||
|
||||
// NormalizeListTab — 空或未知 → active
|
||||
func NormalizeListTab(tab string) string {
|
||||
switch tab {
|
||||
case TabRecurring, TabActive, TabHistory:
|
||||
return tab
|
||||
default:
|
||||
return TabActive
|
||||
}
|
||||
}
|
||||
|
||||
// MatchListTab — 依狀態+run_after 分桶(與 FE 一致)
|
||||
func MatchListTab(j *Job, tab string, nowNs int64) bool {
|
||||
if j == nil {
|
||||
return false
|
||||
}
|
||||
if nowNs <= 0 {
|
||||
nowNs = NowNano()
|
||||
}
|
||||
switch NormalizeListTab(tab) {
|
||||
case TabHistory:
|
||||
return IsTerminal(j.Status)
|
||||
case TabRecurring:
|
||||
// 遠期排程:pending/queued 且 run_after 尚未到
|
||||
if j.Status != StatusPending && j.Status != StatusQueued {
|
||||
return false
|
||||
}
|
||||
return j.RunAfter > 0 && j.RunAfter > nowNs
|
||||
default: // active
|
||||
if j.Status == StatusRunning {
|
||||
return true
|
||||
}
|
||||
if j.Status == StatusPending || j.Status == StatusQueued {
|
||||
// 已到期或立刻可領
|
||||
return j.RunAfter <= 0 || j.RunAfter <= nowNs
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ClampPage — page 從 1;pageSize 預設 10、上限 50
|
||||
func ClampPage(page, pageSize int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
if pageSize > 50 {
|
||||
pageSize = 50
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ type Repository interface {
|
|||
Update(ctx context.Context, j *Job) error
|
||||
FindByID(ctx context.Context, id string) (*Job, error)
|
||||
ListByOwner(ctx context.Context, ownerUID int64) ([]*Job, error)
|
||||
// ListByOwnerTab 分頁+分桶;total 為該 tab 總筆數
|
||||
ListByOwnerTab(ctx context.Context, ownerUID int64, tab string, page, pageSize int) (list []*Job, total int64, err error)
|
||||
// ClaimNext picks oldest due pending/queued (run_after <= now) and sets running + workerID.
|
||||
ClaimNext(ctx context.Context, workerID string) (*Job, error)
|
||||
// CancelPendingByRef cancels pending/queued jobs of template+ref (e.g. reschedule renew).
|
||||
|
|
|
|||
|
|
@ -59,6 +59,50 @@ func (s *MemoryStore) ListByOwner(_ context.Context, ownerUID int64) ([]*domain.
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListByOwnerTab(_ context.Context, ownerUID int64, tab string, page, pageSize int) ([]*domain.Job, int64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
page, pageSize = domain.ClampPage(page, pageSize)
|
||||
now := domain.NowNano()
|
||||
var matched []*domain.Job
|
||||
for _, j := range s.byID {
|
||||
if j.OwnerUID != ownerUID {
|
||||
continue
|
||||
}
|
||||
if domain.MatchListTab(j, tab, now) {
|
||||
cp := *j
|
||||
matched = append(matched, &cp)
|
||||
}
|
||||
}
|
||||
// sort updated_at desc(recurring: run_after asc)
|
||||
tabN := domain.NormalizeListTab(tab)
|
||||
for i := 0; i < len(matched); i++ {
|
||||
for k := i + 1; k < len(matched); k++ {
|
||||
swap := false
|
||||
if tabN == domain.TabRecurring {
|
||||
if matched[k].RunAfter < matched[i].RunAfter {
|
||||
swap = true
|
||||
}
|
||||
} else if matched[k].UpdatedAt > matched[i].UpdatedAt {
|
||||
swap = true
|
||||
}
|
||||
if swap {
|
||||
matched[i], matched[k] = matched[k], matched[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
total := int64(len(matched))
|
||||
start := (page - 1) * pageSize
|
||||
if start >= len(matched) {
|
||||
return []*domain.Job{}, total, nil
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > len(matched) {
|
||||
end = len(matched)
|
||||
}
|
||||
return matched[start:end], total, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ClaimNext(_ context.Context, workerID string) (*domain.Job, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
|
|
|||
|
|
@ -54,10 +54,63 @@ func (s *MonStore) FindByID(ctx context.Context, id string) (*domain.Job, error)
|
|||
func (s *MonStore) ListByOwner(ctx context.Context, ownerUID int64) ([]*domain.Job, error) {
|
||||
var list []*domain.Job
|
||||
err := s.jobs.Find(ctx, &list, bson.M{"owner_uid": ownerUID},
|
||||
options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}))
|
||||
options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}}))
|
||||
return list, err
|
||||
}
|
||||
|
||||
func jobTabFilter(ownerUID int64, tab string, nowNs int64) bson.M {
|
||||
base := bson.M{"owner_uid": ownerUID}
|
||||
switch domain.NormalizeListTab(tab) {
|
||||
case domain.TabHistory:
|
||||
base["status"] = bson.M{"$in": []string{
|
||||
domain.StatusSucceeded, domain.StatusFailed, domain.StatusCancelled,
|
||||
}}
|
||||
case domain.TabRecurring:
|
||||
base["status"] = bson.M{"$in": []string{domain.StatusPending, domain.StatusQueued}}
|
||||
base["run_after"] = bson.M{"$gt": nowNs}
|
||||
default: // active
|
||||
base["$or"] = []bson.M{
|
||||
{"status": domain.StatusRunning},
|
||||
{
|
||||
"status": bson.M{"$in": []string{domain.StatusPending, domain.StatusQueued}},
|
||||
"$or": []bson.M{
|
||||
{"run_after": bson.M{"$exists": false}},
|
||||
{"run_after": 0},
|
||||
{"run_after": bson.M{"$lte": nowNs}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func (s *MonStore) ListByOwnerTab(ctx context.Context, ownerUID int64, tab string, page, pageSize int) ([]*domain.Job, int64, error) {
|
||||
page, pageSize = domain.ClampPage(page, pageSize)
|
||||
now := domain.NowNano()
|
||||
filter := jobTabFilter(ownerUID, tab, now)
|
||||
|
||||
total, err := s.jobs.CountDocuments(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
sort := bson.D{{Key: "updated_at", Value: -1}}
|
||||
if domain.NormalizeListTab(tab) == domain.TabRecurring {
|
||||
sort = bson.D{{Key: "run_after", Value: 1}, {Key: "created_at", Value: 1}}
|
||||
} else if domain.NormalizeListTab(tab) == domain.TabHistory {
|
||||
sort = bson.D{{Key: "completed_at", Value: -1}, {Key: "updated_at", Value: -1}}
|
||||
}
|
||||
|
||||
skip := int64((page - 1) * pageSize)
|
||||
var list []*domain.Job
|
||||
err = s.jobs.Find(ctx, &list, filter,
|
||||
options.Find().SetSort(sort).SetSkip(skip).SetLimit(int64(pageSize)))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
func (s *MonStore) ClaimNext(ctx context.Context, workerID string) (*domain.Job, error) {
|
||||
now := domain.NowNano()
|
||||
// due: run_after missing/0 OR run_after <= now
|
||||
|
|
|
|||
|
|
@ -52,6 +52,11 @@ func (s *Service) List(ctx context.Context, ownerUID int64) ([]*domain.Job, erro
|
|||
return s.Repo.ListByOwner(ctx, ownerUID)
|
||||
}
|
||||
|
||||
// ListTab — 分頁+分桶(任務頁三頁籤)
|
||||
func (s *Service) ListTab(ctx context.Context, ownerUID int64, tab string, page, pageSize int) ([]*domain.Job, int64, error) {
|
||||
return s.Repo.ListByOwnerTab(ctx, ownerUID, tab, page, pageSize)
|
||||
}
|
||||
|
||||
// Get — JB-09
|
||||
func (s *Service) Get(ctx context.Context, ownerUID int64, id string) (*domain.Job, error) {
|
||||
j, err := s.Repo.FindByID(ctx, id)
|
||||
|
|
|
|||
|
|
@ -234,3 +234,43 @@ func TestJB_16_SchedulePersonaAnalyzeText(t *testing.T) {
|
|||
require.Equal(t, "pe_txt", j.RefID)
|
||||
require.Contains(t, j.Payload, "raw_text")
|
||||
}
|
||||
|
||||
func TestJB_ListTab_BucketsAndPaging(t *testing.T) {
|
||||
svc, _ := newJobSvc()
|
||||
uid := int64(1_000_200)
|
||||
ctx := context.Background()
|
||||
// due demo → active, then finish → history
|
||||
d, err := svc.StartDemo(ctx, uid)
|
||||
require.NoError(t, err)
|
||||
// future scheduled → recurring
|
||||
now := domain.NowNano()
|
||||
require.NoError(t, svc.ScheduleTokenRenew(ctx, uid, "acc-1", now+domain.DefaultTokenRenewDelayNs))
|
||||
|
||||
claimed, err := svc.ClaimNext(ctx, "w1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, d.ID, claimed.ID)
|
||||
_, err = svc.RunDemoToSuccess(ctx, claimed.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
active, totalA, err := svc.ListTab(ctx, uid, domain.TabActive, 1, 10)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(0), totalA)
|
||||
require.Empty(t, active)
|
||||
|
||||
rec, totalR, err := svc.ListTab(ctx, uid, domain.TabRecurring, 1, 10)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), totalR)
|
||||
require.Len(t, rec, 1)
|
||||
require.Equal(t, domain.TemplateThreadsTokenRenew, rec[0].TemplateType)
|
||||
|
||||
hist, totalH, err := svc.ListTab(ctx, uid, domain.TabHistory, 1, 10)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), totalH)
|
||||
require.Len(t, hist, 1)
|
||||
require.Equal(t, domain.StatusSucceeded, hist[0].Status)
|
||||
|
||||
// page size 1 on history
|
||||
page1, _, err := svc.ListTab(ctx, uid, domain.TabHistory, 1, 1)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, page1, 1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,4 +27,14 @@ var (
|
|||
ErrAlreadyBound = errors.New("already bound")
|
||||
ErrInvalidPlatform = errors.New("invalid platform")
|
||||
ErrOAuthFailed = errors.New("oauth verification failed")
|
||||
|
||||
// 邀請網絡(message = FE i18n key invite.err.*)
|
||||
ErrInviteAlreadyBound = errors.New("invite.err.alreadyBound")
|
||||
ErrInviteCodeRequired = errors.New("invite.err.codeRequired")
|
||||
ErrInviteCodeNotFound = errors.New("invite.err.codeNotFound")
|
||||
ErrInviteCodeSelf = errors.New("invite.err.codeSelf")
|
||||
ErrInviteCycle = errors.New("invite.err.cycle")
|
||||
ErrInviteSelfParent = errors.New("invite.err.selfParent")
|
||||
ErrInviteParentMiss = errors.New("invite.err.parentNotFound")
|
||||
ErrInviteNeedAdmin = errors.New("invite.err.needAdmin")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,8 +14,11 @@ type Repository interface {
|
|||
FindByUID(ctx context.Context, uid int64) (*Member, error)
|
||||
FindByEmail(ctx context.Context, email string) (*Member, error)
|
||||
FindByPhone(ctx context.Context, phone string) (*Member, error)
|
||||
FindByInviteCode(ctx context.Context, code string) (*Member, error)
|
||||
UpdateMember(ctx context.Context, m *Member) error
|
||||
ListPage(ctx context.Context, page, pageSize int, query string, status string) (list []*Member, total int64, err error)
|
||||
// ListAllMembers — 邀請樹用(全量;小租戶)
|
||||
ListAllMembers(ctx context.Context) ([]*Member, error)
|
||||
CountActiveAdmins(ctx context.Context) (int64, error)
|
||||
|
||||
// Identities (login channels)
|
||||
|
|
|
|||
|
|
@ -218,6 +218,35 @@ func (s *MoncStore) CountActiveAdmins(ctx context.Context) (int64, error) {
|
|||
return s.plain.CountDocuments(ctx, bson.M{"status": domain.StatusActive, "roles": domain.RoleAdmin})
|
||||
}
|
||||
|
||||
func (s *MoncStore) FindByInviteCode(ctx context.Context, code string) (*domain.Member, error) {
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
if code == "" {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
var m domain.Member
|
||||
err := s.plain.FindOne(ctx, &m, bson.M{
|
||||
"invite_code": code,
|
||||
"uid": bson.M{"$type": bson.A{"int", "long", "double"}},
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, mon.ErrNotFound) || errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (s *MoncStore) ListAllMembers(ctx context.Context) ([]*domain.Member, error) {
|
||||
filter := bson.M{"uid": bson.M{"$type": bson.A{"int", "long", "double"}}}
|
||||
var list []*domain.Member
|
||||
err := s.plain.Find(ctx, &list, filter, options.Find().SetSort(bson.D{{Key: "uid", Value: 1}}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// --- identities ---
|
||||
|
||||
func (s *MoncStore) CreateIdentity(ctx context.Context, id *domain.Identity) error {
|
||||
|
|
|
|||
|
|
@ -157,6 +157,30 @@ func (f *fakeRepo) CountActiveAdmins(ctx context.Context) (int64, error) {
|
|||
return n, nil
|
||||
}
|
||||
|
||||
func (f *fakeRepo) FindByInviteCode(ctx context.Context, code string) (*domain.Member, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
for _, m := range f.byUID {
|
||||
if strings.ToUpper(strings.TrimSpace(m.InviteCode)) == code {
|
||||
cp := *m
|
||||
return &cp, nil
|
||||
}
|
||||
}
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
|
||||
func (f *fakeRepo) ListAllMembers(ctx context.Context) ([]*domain.Member, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := make([]*domain.Member, 0, len(f.byUID))
|
||||
for _, m := range f.byUID {
|
||||
cp := *m
|
||||
out = append(out, &cp)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeRepo) CreateIdentity(ctx context.Context, id *domain.Identity) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,342 @@
|
|||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"apps/backend/internal/module/member/domain"
|
||||
)
|
||||
|
||||
// InviteMemberBrief — 邀請網絡節點摘要(API 層再轉 types)
|
||||
type InviteMemberBrief struct {
|
||||
UID int64
|
||||
Email string
|
||||
DisplayName string
|
||||
InviteCode string
|
||||
ParentUID *int64
|
||||
Roles []string
|
||||
Status string
|
||||
DownlineCount int
|
||||
TotalDownlineCount int
|
||||
Depth int
|
||||
JoinedAt int64
|
||||
AvatarURL string
|
||||
}
|
||||
|
||||
// InviteTreeNode — 森林節點
|
||||
type InviteTreeNode struct {
|
||||
InviteMemberBrief
|
||||
Children []InviteTreeNode
|
||||
}
|
||||
|
||||
// MyInviteNetwork — 會員自己的碼 + 上下線
|
||||
type MyInviteNetwork struct {
|
||||
Me InviteMemberBrief
|
||||
Upline *InviteMemberBrief
|
||||
Downlines []InviteMemberBrief
|
||||
TotalDownlineCount int
|
||||
}
|
||||
|
||||
// GetMyNetwork — IV-01 / IV-07
|
||||
func (s *AccountService) GetMyNetwork(ctx context.Context, uid int64) (*MyInviteNetwork, error) {
|
||||
all, err := s.Repo.ListAllMembers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
briefs := buildInviteBriefs(all)
|
||||
me, ok := briefs[uid]
|
||||
if !ok {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
var upline *InviteMemberBrief
|
||||
if me.ParentUID != nil {
|
||||
if u, ok := briefs[*me.ParentUID]; ok {
|
||||
cp := u
|
||||
upline = &cp
|
||||
}
|
||||
}
|
||||
var down []InviteMemberBrief
|
||||
for _, m := range all {
|
||||
if m.ParentUID != nil && *m.ParentUID == uid {
|
||||
if b, ok := briefs[m.UID]; ok {
|
||||
down = append(down, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(down, func(i, j int) bool { return down[i].JoinedAt > down[j].JoinedAt })
|
||||
return &MyInviteNetwork{
|
||||
Me: me,
|
||||
Upline: upline,
|
||||
Downlines: down,
|
||||
TotalDownlineCount: me.TotalDownlineCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ApplyInviteCode — IV-02~06
|
||||
func (s *AccountService) ApplyInviteCode(ctx context.Context, uid int64, rawCode string) (*MyInviteNetwork, error) {
|
||||
code := strings.ToUpper(strings.TrimSpace(rawCode))
|
||||
if code == "" {
|
||||
return nil, domain.ErrInviteCodeRequired
|
||||
}
|
||||
me, err := s.Repo.FindByUID(ctx, uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if me.ParentUID != nil && *me.ParentUID > 0 {
|
||||
return nil, domain.ErrInviteAlreadyBound
|
||||
}
|
||||
inviter, err := s.Repo.FindByInviteCode(ctx, code)
|
||||
if err != nil {
|
||||
if err == domain.ErrNotFound {
|
||||
return nil, domain.ErrInviteCodeNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if inviter.UID == uid {
|
||||
return nil, domain.ErrInviteCodeSelf
|
||||
}
|
||||
// 防環:邀請人不可為自己的子孫
|
||||
all, err := s.Repo.ListAllMembers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isDescendantOf(uid, inviter.UID, all) {
|
||||
return nil, domain.ErrInviteCycle
|
||||
}
|
||||
p := inviter.UID
|
||||
me.ParentUID = &p
|
||||
me.UpdatedAt = domain.NowNano()
|
||||
if err := s.Repo.UpdateMember(ctx, me); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetMyNetwork(ctx, uid)
|
||||
}
|
||||
|
||||
// GetInviteTree — IV-08 admin
|
||||
func (s *AccountService) GetInviteTree(ctx context.Context) ([]InviteTreeNode, error) {
|
||||
all, err := s.Repo.ListAllMembers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildInviteForest(all), nil
|
||||
}
|
||||
|
||||
// MoveInviteMember — IV-09~12
|
||||
// newParentUID nil = 獨立根
|
||||
func (s *AccountService) MoveInviteMember(ctx context.Context, actorUID, targetUID int64, newParentUID *int64) (*InviteMemberBrief, error) {
|
||||
actor, err := s.Repo.FindByUID(ctx, actorUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !actor.IsAdmin() {
|
||||
return nil, domain.ErrInviteNeedAdmin
|
||||
}
|
||||
target, err := s.Repo.FindByUID(ctx, targetUID)
|
||||
if err != nil {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
if newParentUID != nil && *newParentUID == targetUID {
|
||||
return nil, domain.ErrInviteSelfParent
|
||||
}
|
||||
if newParentUID != nil && *newParentUID > 0 {
|
||||
parent, err := s.Repo.FindByUID(ctx, *newParentUID)
|
||||
if err != nil {
|
||||
return nil, domain.ErrInviteParentMiss
|
||||
}
|
||||
_ = parent
|
||||
all, err := s.Repo.ListAllMembers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 新父不可是 target 的子孫(含自己已檢查)
|
||||
if isDescendantOf(targetUID, *newParentUID, all) {
|
||||
return nil, domain.ErrInviteCycle
|
||||
}
|
||||
target.ParentUID = newParentUID
|
||||
} else {
|
||||
target.ParentUID = nil
|
||||
}
|
||||
target.UpdatedAt = domain.NowNano()
|
||||
if err := s.Repo.UpdateMember(ctx, target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all, err := s.Repo.ListAllMembers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
briefs := buildInviteBriefs(all)
|
||||
b := briefs[targetUID]
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// ListParentCandidates — IV-13 排除子樹
|
||||
func (s *AccountService) ListParentCandidates(ctx context.Context, excludeSubtreeRootUID int64) ([]InviteMemberBrief, error) {
|
||||
all, err := s.Repo.ListAllMembers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
exclude := map[int64]struct{}{}
|
||||
if excludeSubtreeRootUID > 0 {
|
||||
exclude[excludeSubtreeRootUID] = struct{}{}
|
||||
for _, m := range all {
|
||||
if isDescendantOf(excludeSubtreeRootUID, m.UID, all) {
|
||||
exclude[m.UID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
briefs := buildInviteBriefs(all)
|
||||
var out []InviteMemberBrief
|
||||
for _, m := range all {
|
||||
if _, skip := exclude[m.UID]; skip {
|
||||
continue
|
||||
}
|
||||
if b, ok := briefs[m.UID]; ok {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return strings.ToLower(out[i].DisplayName) < strings.ToLower(out[j].DisplayName)
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func buildInviteBriefs(members []*domain.Member) map[int64]InviteMemberBrief {
|
||||
children := map[int64][]int64{}
|
||||
byUID := map[int64]*domain.Member{}
|
||||
for _, m := range members {
|
||||
byUID[m.UID] = m
|
||||
if m.ParentUID != nil && *m.ParentUID > 0 {
|
||||
p := *m.ParentUID
|
||||
children[p] = append(children[p], m.UID)
|
||||
}
|
||||
}
|
||||
totalMemo := map[int64]int{}
|
||||
var totalOf func(uid int64, stack map[int64]struct{}) int
|
||||
totalOf = func(uid int64, stack map[int64]struct{}) int {
|
||||
if v, ok := totalMemo[uid]; ok {
|
||||
return v
|
||||
}
|
||||
if _, loop := stack[uid]; loop {
|
||||
return 0
|
||||
}
|
||||
stack[uid] = struct{}{}
|
||||
kids := children[uid]
|
||||
n := len(kids)
|
||||
for _, k := range kids {
|
||||
n += totalOf(k, stack)
|
||||
}
|
||||
delete(stack, uid)
|
||||
totalMemo[uid] = n
|
||||
return n
|
||||
}
|
||||
depthMemo := map[int64]int{}
|
||||
var depthOf func(uid int64, stack map[int64]struct{}) int
|
||||
depthOf = func(uid int64, stack map[int64]struct{}) int {
|
||||
if v, ok := depthMemo[uid]; ok {
|
||||
return v
|
||||
}
|
||||
if _, loop := stack[uid]; loop {
|
||||
return 0
|
||||
}
|
||||
m := byUID[uid]
|
||||
if m == nil || m.ParentUID == nil || *m.ParentUID <= 0 || byUID[*m.ParentUID] == nil {
|
||||
depthMemo[uid] = 0
|
||||
return 0
|
||||
}
|
||||
stack[uid] = struct{}{}
|
||||
d := 1 + depthOf(*m.ParentUID, stack)
|
||||
delete(stack, uid)
|
||||
depthMemo[uid] = d
|
||||
return d
|
||||
}
|
||||
|
||||
out := make(map[int64]InviteMemberBrief, len(members))
|
||||
for _, m := range members {
|
||||
b := InviteMemberBrief{
|
||||
UID: m.UID, Email: m.Email, DisplayName: m.DisplayName,
|
||||
InviteCode: strings.TrimSpace(m.InviteCode), Roles: append([]string{}, m.Roles...),
|
||||
Status: m.Status, JoinedAt: m.CreatedAt, AvatarURL: m.AvatarURL,
|
||||
DownlineCount: len(children[m.UID]), TotalDownlineCount: totalOf(m.UID, map[int64]struct{}{}),
|
||||
Depth: depthOf(m.UID, map[int64]struct{}{}),
|
||||
}
|
||||
if m.ParentUID != nil && *m.ParentUID > 0 && byUID[*m.ParentUID] != nil {
|
||||
p := *m.ParentUID
|
||||
b.ParentUID = &p
|
||||
}
|
||||
if b.InviteCode == "" {
|
||||
b.InviteCode = "—"
|
||||
}
|
||||
out[m.UID] = b
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildInviteForest(members []*domain.Member) []InviteTreeNode {
|
||||
briefs := buildInviteBriefs(members)
|
||||
byParent := map[int64][]InviteMemberBrief{} // key 0 = roots
|
||||
for _, b := range briefs {
|
||||
var pk int64
|
||||
if b.ParentUID != nil {
|
||||
pk = *b.ParentUID
|
||||
if _, ok := briefs[pk]; !ok {
|
||||
pk = 0 // 孤兒當根
|
||||
}
|
||||
}
|
||||
byParent[pk] = append(byParent[pk], b)
|
||||
}
|
||||
var build func(uid int64) InviteTreeNode
|
||||
build = func(uid int64) InviteTreeNode {
|
||||
b := briefs[uid]
|
||||
n := InviteTreeNode{InviteMemberBrief: b, Children: nil}
|
||||
kids := byParent[uid]
|
||||
sort.Slice(kids, func(i, j int) bool {
|
||||
return strings.ToLower(kids[i].DisplayName) < strings.ToLower(kids[j].DisplayName)
|
||||
})
|
||||
for _, k := range kids {
|
||||
n.Children = append(n.Children, build(k.UID))
|
||||
}
|
||||
return n
|
||||
}
|
||||
roots := byParent[0]
|
||||
sort.Slice(roots, func(i, j int) bool {
|
||||
return strings.ToLower(roots[i].DisplayName) < strings.ToLower(roots[j].DisplayName)
|
||||
})
|
||||
var forest []InviteTreeNode
|
||||
for _, r := range roots {
|
||||
forest = append(forest, build(r.UID))
|
||||
}
|
||||
return forest
|
||||
}
|
||||
|
||||
// isDescendantOf — whether candidate is under rootUID's subtree (not root itself)
|
||||
func isDescendantOf(rootUID, candidateUID int64, members []*domain.Member) bool {
|
||||
if rootUID == candidateUID {
|
||||
return false
|
||||
}
|
||||
parentOf := map[int64]int64{}
|
||||
for _, m := range members {
|
||||
if m.ParentUID != nil && *m.ParentUID > 0 {
|
||||
parentOf[m.UID] = *m.ParentUID
|
||||
}
|
||||
}
|
||||
seen := map[int64]struct{}{}
|
||||
cur := candidateUID
|
||||
for cur != 0 {
|
||||
if cur == rootUID {
|
||||
return true
|
||||
}
|
||||
if _, loop := seen[cur]; loop {
|
||||
return false
|
||||
}
|
||||
seen[cur] = struct{}{}
|
||||
p, ok := parentOf[cur]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
cur = p
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
package usecase_test
|
||||
|
||||
// T219 — M6 invite integration (spec §9.16 IV-01~IV-13)
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"apps/backend/internal/module/member/domain"
|
||||
"apps/backend/internal/module/member/usecase"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIV_01_GetMyNetwork(t *testing.T) {
|
||||
store, svc := newM1Svc()
|
||||
m, _ := seedMember(t, store, svc, "iv01@test.local")
|
||||
net, err := svc.GetMyNetwork(context.Background(), m.UID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, m.UID, net.Me.UID)
|
||||
require.NotEmpty(t, net.Me.InviteCode)
|
||||
require.Nil(t, net.Me.ParentUID)
|
||||
require.Empty(t, net.Downlines)
|
||||
}
|
||||
|
||||
func TestIV_02_ApplyLegalCode(t *testing.T) {
|
||||
store, svc := newM1Svc()
|
||||
inv, _ := seedMember(t, store, svc, "iv02-inv@test.local")
|
||||
me, _ := seedMember(t, store, svc, "iv02-me@test.local")
|
||||
net, err := svc.ApplyInviteCode(context.Background(), me.UID, inv.InviteCode)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, net.Me.ParentUID)
|
||||
require.Equal(t, inv.UID, *net.Me.ParentUID)
|
||||
require.NotNil(t, net.Upline)
|
||||
require.Equal(t, inv.UID, net.Upline.UID)
|
||||
}
|
||||
|
||||
func TestIV_03_AlreadyBound(t *testing.T) {
|
||||
store, svc := newM1Svc()
|
||||
a, _ := seedMember(t, store, svc, "iv03a@test.local")
|
||||
b, _ := seedMember(t, store, svc, "iv03b@test.local")
|
||||
c, _ := seedMember(t, store, svc, "iv03c@test.local")
|
||||
_, err := svc.ApplyInviteCode(context.Background(), b.UID, a.InviteCode)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.ApplyInviteCode(context.Background(), b.UID, c.InviteCode)
|
||||
require.ErrorIs(t, err, domain.ErrInviteAlreadyBound)
|
||||
}
|
||||
|
||||
func TestIV_04_ApplySelfCode(t *testing.T) {
|
||||
store, svc := newM1Svc()
|
||||
me, _ := seedMember(t, store, svc, "iv04@test.local")
|
||||
_, err := svc.ApplyInviteCode(context.Background(), me.UID, me.InviteCode)
|
||||
require.ErrorIs(t, err, domain.ErrInviteCodeSelf)
|
||||
}
|
||||
|
||||
func TestIV_05_ApplyUnknownCode(t *testing.T) {
|
||||
store, svc := newM1Svc()
|
||||
me, _ := seedMember(t, store, svc, "iv05@test.local")
|
||||
_, err := svc.ApplyInviteCode(context.Background(), me.UID, "NO-SUCH-CODE")
|
||||
require.ErrorIs(t, err, domain.ErrInviteCodeNotFound)
|
||||
}
|
||||
|
||||
func TestIV_06_ApplyEmptyCode(t *testing.T) {
|
||||
store, svc := newM1Svc()
|
||||
me, _ := seedMember(t, store, svc, "iv06@test.local")
|
||||
_, err := svc.ApplyInviteCode(context.Background(), me.UID, " ")
|
||||
require.ErrorIs(t, err, domain.ErrInviteCodeRequired)
|
||||
}
|
||||
|
||||
func TestIV_07_DownlinesCount(t *testing.T) {
|
||||
store, svc := newM1Svc()
|
||||
inv, _ := seedMember(t, store, svc, "iv07-inv@test.local")
|
||||
d1, _ := seedMember(t, store, svc, "iv07-d1@test.local")
|
||||
d2, _ := seedMember(t, store, svc, "iv07-d2@test.local")
|
||||
_, err := svc.ApplyInviteCode(context.Background(), d1.UID, inv.InviteCode)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.ApplyInviteCode(context.Background(), d2.UID, inv.InviteCode)
|
||||
require.NoError(t, err)
|
||||
net, err := svc.GetMyNetwork(context.Background(), inv.UID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, net.Downlines, 2)
|
||||
require.Equal(t, 2, net.TotalDownlineCount)
|
||||
require.Equal(t, 2, net.Me.DownlineCount)
|
||||
}
|
||||
|
||||
func TestIV_08_GetTree(t *testing.T) {
|
||||
store, svc := newM1Svc()
|
||||
root, _ := seedMember(t, store, svc, "iv08-root@test.local")
|
||||
child, _ := seedMember(t, store, svc, "iv08-c@test.local")
|
||||
_, err := svc.ApplyInviteCode(context.Background(), child.UID, root.InviteCode)
|
||||
require.NoError(t, err)
|
||||
forest, err := svc.GetInviteTree(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, forest)
|
||||
// find root
|
||||
var found bool
|
||||
var walk func([]usecase.InviteTreeNode)
|
||||
walk = func(nodes []usecase.InviteTreeNode) {
|
||||
for _, n := range nodes {
|
||||
if n.UID == root.UID {
|
||||
found = true
|
||||
require.NotEmpty(t, n.Children)
|
||||
}
|
||||
walk(n.Children)
|
||||
}
|
||||
}
|
||||
walk(forest)
|
||||
require.True(t, found)
|
||||
}
|
||||
|
||||
func TestIV_09_MoveLegal(t *testing.T) {
|
||||
store, svc := newM1Svc()
|
||||
admin, _ := seedAdmin(t, store, svc, "iv09-admin@test.local")
|
||||
a, _ := seedMember(t, store, svc, "iv09-a@test.local")
|
||||
b, _ := seedMember(t, store, svc, "iv09-b@test.local")
|
||||
// b under a
|
||||
_, err := svc.ApplyInviteCode(context.Background(), b.UID, a.InviteCode)
|
||||
require.NoError(t, err)
|
||||
// move b to root
|
||||
brief, err := svc.MoveInviteMember(context.Background(), admin.UID, b.UID, nil)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, brief.ParentUID)
|
||||
}
|
||||
|
||||
func TestIV_10_NoCycle(t *testing.T) {
|
||||
store, svc := newM1Svc()
|
||||
admin, _ := seedAdmin(t, store, svc, "iv10-admin@test.local")
|
||||
a, _ := seedMember(t, store, svc, "iv10-a@test.local")
|
||||
b, _ := seedMember(t, store, svc, "iv10-b@test.local")
|
||||
_, err := svc.ApplyInviteCode(context.Background(), b.UID, a.InviteCode)
|
||||
require.NoError(t, err)
|
||||
// move a under b → cycle
|
||||
_, err = svc.MoveInviteMember(context.Background(), admin.UID, a.UID, &b.UID)
|
||||
require.ErrorIs(t, err, domain.ErrInviteCycle)
|
||||
}
|
||||
|
||||
func TestIV_11_MoveToSelf(t *testing.T) {
|
||||
store, svc := newM1Svc()
|
||||
admin, _ := seedAdmin(t, store, svc, "iv11-admin@test.local")
|
||||
a, _ := seedMember(t, store, svc, "iv11-a@test.local")
|
||||
_, err := svc.MoveInviteMember(context.Background(), admin.UID, a.UID, &a.UID)
|
||||
require.ErrorIs(t, err, domain.ErrInviteSelfParent)
|
||||
}
|
||||
|
||||
func TestIV_12_MemberCannotMove(t *testing.T) {
|
||||
store, svc := newM1Svc()
|
||||
mem, _ := seedMember(t, store, svc, "iv12-mem@test.local")
|
||||
a, _ := seedMember(t, store, svc, "iv12-a@test.local")
|
||||
b, _ := seedMember(t, store, svc, "iv12-b@test.local")
|
||||
_, err := svc.MoveInviteMember(context.Background(), mem.UID, a.UID, &b.UID)
|
||||
require.ErrorIs(t, err, domain.ErrInviteNeedAdmin)
|
||||
}
|
||||
|
||||
func TestIV_13_ListParentCandidatesExcludeSubtree(t *testing.T) {
|
||||
store, svc := newM1Svc()
|
||||
a, _ := seedMember(t, store, svc, "iv13-a@test.local")
|
||||
b, _ := seedMember(t, store, svc, "iv13-b@test.local")
|
||||
c, _ := seedMember(t, store, svc, "iv13-c@test.local")
|
||||
_, err := svc.ApplyInviteCode(context.Background(), b.UID, a.InviteCode)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.ApplyInviteCode(context.Background(), c.UID, b.InviteCode)
|
||||
require.NoError(t, err)
|
||||
// exclude subtree of a → no a,b,c
|
||||
cands, err := svc.ListParentCandidates(context.Background(), a.UID)
|
||||
require.NoError(t, err)
|
||||
for _, x := range cands {
|
||||
require.NotEqual(t, a.UID, x.UID)
|
||||
require.NotEqual(t, b.UID, x.UID)
|
||||
require.NotEqual(t, c.UID, x.UID)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package domain
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
|
@ -24,7 +25,11 @@ var (
|
|||
ErrForbidden = errors.New("usage forbidden")
|
||||
ErrNoKey = errors.New("no api key configured (platform or byok)")
|
||||
ErrQuotaExceeded = errors.New("platform credits insufficient")
|
||||
ErrInvalidPlan = errors.New("invalid plan")
|
||||
// 分項點數上限(與 FE soft_caps 對齊;platform only)
|
||||
ErrMeterCapExceeded = errors.New("usage.err.meterCap")
|
||||
// 平台 key 無容量/限流:僅 BYOK 可繼續(不扣平台點)
|
||||
ErrPlatformCapacity = errors.New("usage.err.platformCapacity")
|
||||
ErrInvalidPlan = errors.New("invalid plan")
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
|
|
@ -57,18 +62,55 @@ type Purchase struct {
|
|||
type PlanDef struct {
|
||||
ID string
|
||||
MonthlyCredits int
|
||||
// SoftCaps:各 meter 點數硬上限(platform);加總 = MonthlyCredits
|
||||
SoftCaps map[string]int
|
||||
}
|
||||
|
||||
// Plans — 與 FE PLANS 同步(2026-07 · 組合毛利 ≥50%)
|
||||
// 安全單價約 $0.012/點;Starter NT$590≈$18.4 / Pro NT$1990≈$62.2
|
||||
// Free 120 點:夠用試用(可虧 ~$1.44 滿用);付費扛回組合毛利。
|
||||
var Plans = map[string]PlanDef{
|
||||
PlanFree: {ID: PlanFree, MonthlyCredits: 80},
|
||||
PlanStarter: {ID: PlanStarter, MonthlyCredits: 500},
|
||||
PlanPro: {ID: PlanPro, MonthlyCredits: 2000},
|
||||
PlanFree: {
|
||||
ID: PlanFree, MonthlyCredits: 120,
|
||||
SoftCaps: map[string]int{
|
||||
MeterAICopy: 60, MeterAIResearch: 15, MeterWebSearch: 30, MeterAIImage: 15,
|
||||
},
|
||||
},
|
||||
PlanStarter: {
|
||||
ID: PlanStarter, MonthlyCredits: 600,
|
||||
SoftCaps: map[string]int{
|
||||
MeterAICopy: 300, MeterAIResearch: 90, MeterWebSearch: 150, MeterAIImage: 60,
|
||||
},
|
||||
},
|
||||
PlanPro: {
|
||||
ID: PlanPro, MonthlyCredits: 2000,
|
||||
SoftCaps: map[string]int{
|
||||
MeterAICopy: 1000, MeterAIResearch: 300, MeterWebSearch: 500, MeterAIImage: 200,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var DefaultCreditCost = map[string]int{
|
||||
MeterAICopy: 1, MeterAIResearch: 3, MeterWebSearch: 1, MeterAIImage: 5,
|
||||
}
|
||||
|
||||
// ResolvePlan — 正規化 plan_id(小寫)並回傳方案;未知 → Free
|
||||
func ResolvePlan(planID string) PlanDef {
|
||||
id := strings.ToLower(strings.TrimSpace(planID))
|
||||
if p, ok := Plans[id]; ok && p.ID != "" {
|
||||
return p
|
||||
}
|
||||
return Plans[PlanFree]
|
||||
}
|
||||
|
||||
// MeterCost returns platform credit cost for one call.
|
||||
func MeterCost(meter string) int {
|
||||
if c := DefaultCreditCost[meter]; c > 0 {
|
||||
return c
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func NowNano() int64 { return time.Now().UTC().UnixNano() }
|
||||
|
||||
func MonthKey(nano int64) string {
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
@ -3,6 +3,8 @@ package usecase
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"apps/backend/internal/module/usage/domain"
|
||||
|
||||
|
|
@ -35,6 +37,9 @@ func (s *StaticResolver) Resolve(_ context.Context, uid int64, meter string) (st
|
|||
type Service struct {
|
||||
Repo domain.Repository
|
||||
Resolver KeyResolver
|
||||
// Platform capacity / rate limit. nil = always allow platform.
|
||||
// When denied, only BYOK (already preferred in Resolver) can proceed.
|
||||
Gate PlatformGate
|
||||
}
|
||||
|
||||
func New(repo domain.Repository, resolver KeyResolver) *Service {
|
||||
|
|
@ -48,19 +53,9 @@ func (s *Service) RecordCall(ctx context.Context, uid int64, meter, keyMode, lab
|
|||
}
|
||||
credits := 0
|
||||
if keyMode == domain.KeyModePlatform {
|
||||
credits = domain.DefaultCreditCost[meter]
|
||||
if credits == 0 {
|
||||
credits = 1
|
||||
}
|
||||
prefs, _ := s.ensurePrefs(ctx, uid)
|
||||
if !prefs.Unlimited {
|
||||
sum, err := s.GetSummary(ctx, uid, domain.CurrentMonthKey())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sum.Platform.CreditsRemaining < credits {
|
||||
return nil, domain.ErrQuotaExceeded
|
||||
}
|
||||
credits = domain.MeterCost(meter)
|
||||
if err := s.checkPlatformQuota(ctx, uid, meter, credits); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
now := domain.NowNano()
|
||||
|
|
@ -76,6 +71,7 @@ func (s *Service) RecordCall(ctx context.Context, uid int64, meter, keyMode, lab
|
|||
}
|
||||
|
||||
// PrepareCall checks quota / key before external call; returns key_mode.
|
||||
// Platform path: capacity gate first, then plan credits. BYOK skips both.
|
||||
func (s *Service) PrepareCall(ctx context.Context, uid int64, meter string) (keyMode string, err error) {
|
||||
if s.Resolver == nil {
|
||||
return "", domain.ErrNoKey
|
||||
|
|
@ -88,32 +84,75 @@ func (s *Service) PrepareCall(ctx context.Context, uid int64, meter string) (key
|
|||
return "", domain.ErrNoKey
|
||||
}
|
||||
if mode == domain.KeyModePlatform {
|
||||
prefs, _ := s.ensurePrefs(ctx, uid)
|
||||
if !prefs.Unlimited {
|
||||
sum, err := s.GetSummary(ctx, uid, domain.CurrentMonthKey())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cost := domain.DefaultCreditCost[meter]
|
||||
if cost == 0 {
|
||||
cost = 1
|
||||
}
|
||||
if sum.Platform.CreditsRemaining < cost {
|
||||
return "", domain.ErrQuotaExceeded
|
||||
}
|
||||
if s.Gate != nil && !s.Gate.AllowPlatform(meter) {
|
||||
return "", domain.ErrPlatformCapacity
|
||||
}
|
||||
cost := domain.MeterCost(meter)
|
||||
if err := s.checkPlatformQuota(ctx, uid, meter, cost); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return mode, nil
|
||||
}
|
||||
|
||||
// MarkPlatformLimited cools down platform keys (e.g. after upstream 429).
|
||||
// BYOK users are unaffected. until zero clears the cool-down.
|
||||
func (s *Service) MarkPlatformLimited(until time.Time) {
|
||||
if s == nil || s.Gate == nil {
|
||||
return
|
||||
}
|
||||
s.Gate.MarkLimited(until)
|
||||
}
|
||||
|
||||
// checkPlatformQuota — 總池 + 分項點數上限;unlimited 略過。
|
||||
func (s *Service) checkPlatformQuota(ctx context.Context, uid int64, meter string, cost int) error {
|
||||
if cost < 0 {
|
||||
cost = 0
|
||||
}
|
||||
prefs, _ := s.ensurePrefs(ctx, uid)
|
||||
if prefs.Unlimited {
|
||||
return nil
|
||||
}
|
||||
sum, err := s.GetSummary(ctx, uid, domain.CurrentMonthKey())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 1) 整月 platform 總點
|
||||
if sum.Platform.CreditsRemaining < cost {
|
||||
return domain.ErrQuotaExceeded
|
||||
}
|
||||
// 2) 分項點數硬上限(platform only;加總 = MonthlyCredits)
|
||||
plan := domain.ResolvePlan(prefs.PlanID)
|
||||
if capN, ok := plan.SoftCaps[meter]; ok && capN > 0 {
|
||||
usedCredits := 0
|
||||
usedCount := 0
|
||||
if m, ok := sum.Platform.ByMeter[meter]; ok {
|
||||
usedCredits = m.Credits
|
||||
usedCount = m.Count
|
||||
// 舊資料若 credits 漏記,用次數 × 單價估
|
||||
if usedCredits < usedCount*domain.MeterCost(meter) {
|
||||
usedCredits = usedCount * domain.MeterCost(meter)
|
||||
}
|
||||
}
|
||||
if usedCredits+cost > capN {
|
||||
return domain.ErrMeterCapExceeded
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ensurePrefs(ctx context.Context, uid int64) (*domain.MemberPrefs, error) {
|
||||
p, err := s.Repo.GetPrefs(ctx, uid)
|
||||
if err != nil || p == nil {
|
||||
p = &domain.MemberPrefs{UID: uid, PlanID: domain.PlanFree, UpdatedAt: domain.NowNano()}
|
||||
_ = s.Repo.SavePrefs(ctx, p)
|
||||
}
|
||||
if p.PlanID == "" {
|
||||
p.PlanID = domain.PlanFree
|
||||
// 正規化 plan_id(小寫/有效方案)
|
||||
norm := domain.ResolvePlan(p.PlanID).ID
|
||||
if p.PlanID != norm {
|
||||
p.PlanID = norm
|
||||
p.UpdatedAt = domain.NowNano()
|
||||
_ = s.Repo.SavePrefs(ctx, p)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
|
@ -123,9 +162,12 @@ func (s *Service) GetSummary(ctx context.Context, uid int64, monthKey string) (*
|
|||
monthKey = domain.CurrentMonthKey()
|
||||
}
|
||||
prefs, _ := s.ensurePrefs(ctx, uid)
|
||||
plan := domain.Plans[prefs.PlanID]
|
||||
if plan.ID == "" {
|
||||
plan = domain.Plans[domain.PlanFree]
|
||||
plan := domain.ResolvePlan(prefs.PlanID)
|
||||
// 回寫正規化 plan_id,避免 DB 殘留大小寫/空白導致永遠 fallback Free
|
||||
if prefs.PlanID != plan.ID {
|
||||
prefs.PlanID = plan.ID
|
||||
prefs.UpdatedAt = domain.NowNano()
|
||||
_ = s.Repo.SavePrefs(ctx, prefs)
|
||||
}
|
||||
events, err := s.Repo.ListEvents(ctx, uid, monthKey, "all", 0)
|
||||
if err != nil {
|
||||
|
|
@ -153,16 +195,15 @@ func (s *Service) GetSummary(ctx context.Context, uid int64, monthKey string) (*
|
|||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
if prefs.Unlimited {
|
||||
remaining = plan.MonthlyCredits // display full; not enforced
|
||||
}
|
||||
// Unlimited 只影響是否擋用,顯示仍報真實已用/剩餘(前端以 unlimited 旗標顯示 ∞)
|
||||
return &domain.MonthSummary{
|
||||
MonthKey: monthKey, UID: uid, PlanID: prefs.PlanID, Unlimited: prefs.Unlimited,
|
||||
MonthKey: monthKey, UID: uid, PlanID: plan.ID, Unlimited: prefs.Unlimited,
|
||||
Platform: domain.PlatformBlock{
|
||||
CreditsUsed: platUsed, CreditsRemaining: remaining, CreditsTotal: plan.MonthlyCredits,
|
||||
CallCount: platCalls, ByMeter: platBy,
|
||||
},
|
||||
Byok: domain.ByokBlock{CallCount: byokCalls, ByMeter: byokBy},
|
||||
// TotalCredits = 方案配給(legacy 欄位名);已用請看 platform.credits_used
|
||||
TotalCredits: plan.MonthlyCredits, RemainingCredits: remaining,
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -188,14 +229,15 @@ func (s *Service) SetPrefs(ctx context.Context, actorUID int64, targetUID int64,
|
|||
}
|
||||
p, _ := s.ensurePrefs(ctx, targetUID)
|
||||
if planID != "" {
|
||||
if _, ok := domain.Plans[planID]; !ok {
|
||||
id := strings.ToLower(strings.TrimSpace(planID))
|
||||
if _, ok := domain.Plans[id]; !ok {
|
||||
return nil, domain.ErrInvalidPlan
|
||||
}
|
||||
// member self can only change via purchase
|
||||
if !isAdmin && actorUID == targetUID {
|
||||
return nil, domain.ErrForbidden
|
||||
}
|
||||
p.PlanID = planID
|
||||
p.PlanID = id
|
||||
}
|
||||
if unlimited != nil && isAdmin {
|
||||
p.Unlimited = *unlimited
|
||||
|
|
@ -208,15 +250,19 @@ func (s *Service) SetPrefs(ctx context.Context, actorUID int64, targetUID int64,
|
|||
}
|
||||
|
||||
func (s *Service) PurchasePlan(ctx context.Context, uid int64, planID, mockRef string) (*domain.Purchase, error) {
|
||||
if _, ok := domain.Plans[planID]; !ok {
|
||||
id := strings.ToLower(strings.TrimSpace(planID))
|
||||
plan, ok := domain.Plans[id]
|
||||
if !ok || plan.ID == "" {
|
||||
return nil, domain.ErrInvalidPlan
|
||||
}
|
||||
p, _ := s.ensurePrefs(ctx, uid)
|
||||
p.PlanID = planID
|
||||
p.PlanID = plan.ID
|
||||
p.UpdatedAt = domain.NowNano()
|
||||
_ = s.Repo.SavePrefs(ctx, p)
|
||||
if err := s.Repo.SavePrefs(ctx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pur := &domain.Purchase{
|
||||
ID: uuid.NewString(), UID: uid, PlanID: planID, MockRef: mockRef, CreatedAt: domain.NowNano(),
|
||||
ID: uuid.NewString(), UID: uid, PlanID: plan.ID, MockRef: mockRef, CreatedAt: domain.NowNano(),
|
||||
}
|
||||
if err := s.Repo.InsertPurchase(ctx, pur); err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ package usecase_test
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apps/backend/internal/module/usage/domain"
|
||||
"apps/backend/internal/module/usage/repository"
|
||||
|
|
@ -13,15 +15,20 @@ import (
|
|||
|
||||
func newUsage(uid int64, mode string) *usecase.Service {
|
||||
r := repository.NewMemory()
|
||||
key := func(m string) string { return fmt.Sprintf("%d:%s", uid, m) }
|
||||
res := &usecase.StaticResolver{Map: map[string]string{
|
||||
"1000001:ai_copy": mode,
|
||||
"1000001:web_search": mode,
|
||||
key(domain.MeterAICopy): mode,
|
||||
key(domain.MeterAIResearch): mode,
|
||||
key(domain.MeterWebSearch): mode,
|
||||
key(domain.MeterAIImage): mode,
|
||||
}}
|
||||
// also allow mixed tests
|
||||
if mode == "mixed" {
|
||||
res.Map = map[string]string{
|
||||
"1000001:ai_copy": domain.KeyModeByok,
|
||||
"1000001:web_search": domain.KeyModePlatform,
|
||||
key(domain.MeterAICopy): domain.KeyModeByok,
|
||||
key(domain.MeterAIResearch): domain.KeyModeByok,
|
||||
key(domain.MeterWebSearch): domain.KeyModePlatform,
|
||||
key(domain.MeterAIImage): domain.KeyModeByok,
|
||||
}
|
||||
}
|
||||
return usecase.New(r, res)
|
||||
|
|
@ -136,15 +143,73 @@ func TestST_09_NoKeyFails(t *testing.T) {
|
|||
|
||||
func TestST_13_PlatformQuota(t *testing.T) {
|
||||
svc := newUsage(1_000_001, domain.KeyModePlatform)
|
||||
// burn free plan
|
||||
for i := 0; i < 80; i++ {
|
||||
// free: ai_copy soft cap 60pt;先燒滿分項
|
||||
for i := 0; i < 60; i++ {
|
||||
_, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, domain.KeyModePlatform, "x", "t")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
_, err := svc.PrepareCall(context.Background(), 1_000_001, domain.MeterAICopy)
|
||||
require.ErrorIs(t, err, domain.ErrMeterCapExceeded)
|
||||
// 分項鎖死後,其他 meter 在總池仍有餘額時可繼續
|
||||
_, err = svc.PrepareCall(context.Background(), 1_000_001, domain.MeterWebSearch)
|
||||
require.NoError(t, err)
|
||||
// 燒光剩餘總池:search 30 + research 15 + image 15 = 60
|
||||
for i := 0; i < 30; i++ {
|
||||
_, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterWebSearch, domain.KeyModePlatform, "s", "t")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
for i := 0; i < 5; i++ { // 5*3=15
|
||||
_, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterAIResearch, domain.KeyModePlatform, "r", "t")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
// image 15pt = 3 張
|
||||
for i := 0; i < 3; i++ {
|
||||
_, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterAIImage, domain.KeyModePlatform, "img", "t")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
_, err = svc.PrepareCall(context.Background(), 1_000_001, domain.MeterWebSearch)
|
||||
require.ErrorIs(t, err, domain.ErrQuotaExceeded)
|
||||
}
|
||||
|
||||
func TestUSG_MeterCap_Image(t *testing.T) {
|
||||
svc := newUsage(1_000_001, domain.KeyModePlatform)
|
||||
// free ai_image cap 15pt = 3 次;第 4 次因分項上限擋
|
||||
for i := 0; i < 3; i++ {
|
||||
_, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterAIImage, domain.KeyModePlatform, "img", "t")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
_, err := svc.PrepareCall(context.Background(), 1_000_001, domain.MeterAIImage)
|
||||
require.ErrorIs(t, err, domain.ErrMeterCapExceeded)
|
||||
}
|
||||
|
||||
func TestUSG_PlatformCapacity_BlocksPlatformOnly(t *testing.T) {
|
||||
// platform user blocked when gate cools down
|
||||
plat := newUsage(1_000_001, domain.KeyModePlatform)
|
||||
plat.Gate = usecase.NewMemoryPlatformGate(0, 0)
|
||||
plat.MarkPlatformLimited(time.Now().Add(time.Hour))
|
||||
_, err := plat.PrepareCall(context.Background(), 1_000_001, domain.MeterAICopy)
|
||||
require.ErrorIs(t, err, domain.ErrPlatformCapacity)
|
||||
|
||||
// BYOK still works (gate not consulted)
|
||||
byok := newUsage(1_000_002, domain.KeyModeByok)
|
||||
byok.Gate = usecase.NewMemoryPlatformGate(0, 0)
|
||||
byok.MarkPlatformLimited(time.Now().Add(time.Hour))
|
||||
mode, err := byok.PrepareCall(context.Background(), 1_000_002, domain.MeterAICopy)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, domain.KeyModeByok, mode)
|
||||
}
|
||||
|
||||
func TestUSG_PlatformCapacity_RPM(t *testing.T) {
|
||||
svc := newUsage(1_000_001, domain.KeyModePlatform)
|
||||
svc.Gate = usecase.NewMemoryPlatformGate(2, 0) // 2 AI / min
|
||||
_, err := svc.PrepareCall(context.Background(), 1_000_001, domain.MeterAICopy)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.PrepareCall(context.Background(), 1_000_001, domain.MeterAICopy)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.PrepareCall(context.Background(), 1_000_001, domain.MeterAICopy)
|
||||
require.ErrorIs(t, err, domain.ErrPlatformCapacity)
|
||||
}
|
||||
|
||||
func TestUSG_11_PurchasePlan(t *testing.T) {
|
||||
svc := newUsage(1_000_001, domain.KeyModePlatform)
|
||||
p, err := svc.PurchasePlan(context.Background(), 1_000_001, domain.PlanStarter, "mock-pay-1")
|
||||
|
|
@ -155,6 +220,22 @@ func TestUSG_11_PurchasePlan(t *testing.T) {
|
|||
list, err := svc.ListMyPurchases(context.Background(), 1_000_001, 10)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, list)
|
||||
|
||||
// 購買後 summary 配給必須是 Starter 600,不是 Free
|
||||
sum, err := svc.GetSummary(context.Background(), 1_000_001, "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, domain.PlanStarter, sum.PlanID)
|
||||
require.Equal(t, domain.Plans[domain.PlanStarter].MonthlyCredits, sum.Platform.CreditsTotal)
|
||||
require.Equal(t, domain.Plans[domain.PlanStarter].MonthlyCredits, sum.TotalCredits)
|
||||
require.Equal(t, domain.Plans[domain.PlanStarter].MonthlyCredits, sum.Platform.CreditsRemaining)
|
||||
|
||||
// 大小寫/空白 plan_id 也要生效
|
||||
_, err = svc.PurchasePlan(context.Background(), 1_000_001, " Pro ", "mock-pay-2")
|
||||
require.NoError(t, err)
|
||||
sum, err = svc.GetSummary(context.Background(), 1_000_001, "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, domain.PlanPro, sum.PlanID)
|
||||
require.Equal(t, 2000, sum.Platform.CreditsTotal)
|
||||
}
|
||||
|
||||
func TestUSG_10_TenantAnalyticsSplit(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -111,6 +111,17 @@ func mapError(err error) (int, Envelope) {
|
|||
return http.StatusBadRequest, Envelope{Code: 400021, Message: err.Error()}
|
||||
case errors.Is(err, memberDomain.ErrSelfSuspend):
|
||||
return http.StatusBadRequest, Envelope{Code: 400020, Message: err.Error()}
|
||||
// 邀請:message 為 FE i18n key(invite.err.*)
|
||||
case errors.Is(err, memberDomain.ErrInviteAlreadyBound),
|
||||
errors.Is(err, memberDomain.ErrInviteCodeRequired),
|
||||
errors.Is(err, memberDomain.ErrInviteCodeNotFound),
|
||||
errors.Is(err, memberDomain.ErrInviteCodeSelf),
|
||||
errors.Is(err, memberDomain.ErrInviteCycle),
|
||||
errors.Is(err, memberDomain.ErrInviteSelfParent),
|
||||
errors.Is(err, memberDomain.ErrInviteParentMiss):
|
||||
return http.StatusBadRequest, Envelope{Code: 400070, Message: err.Error()}
|
||||
case errors.Is(err, memberDomain.ErrInviteNeedAdmin):
|
||||
return http.StatusForbidden, Envelope{Code: 403002, Message: err.Error()}
|
||||
case errors.Is(err, tokenDomain.ErrInvalidToken):
|
||||
return http.StatusUnauthorized, Envelope{Code: 401002, Message: "invalid token"}
|
||||
case errors.Is(err, threadsDomain.ErrNotFound), errors.Is(err, jobDomain.ErrNotFound),
|
||||
|
|
@ -131,6 +142,12 @@ func mapError(err error) (int, Envelope) {
|
|||
return http.StatusBadRequest, Envelope{Code: 400040, Message: "no api key configured (platform or byok)"}
|
||||
case errors.Is(err, usageDomain.ErrQuotaExceeded):
|
||||
return http.StatusPaymentRequired, Envelope{Code: 402001, Message: "platform credits insufficient"}
|
||||
case errors.Is(err, usageDomain.ErrMeterCapExceeded):
|
||||
// message = FE i18n key
|
||||
return http.StatusPaymentRequired, Envelope{Code: 402002, Message: err.Error()}
|
||||
case errors.Is(err, usageDomain.ErrPlatformCapacity):
|
||||
// 平台 key 限流/無容量:建議改 BYOK;message = FE i18n key
|
||||
return http.StatusServiceUnavailable, Envelope{Code: 503002, Message: err.Error()}
|
||||
case errors.Is(err, usageDomain.ErrForbidden):
|
||||
return http.StatusForbidden, Envelope{Code: 403003, Message: "forbidden"}
|
||||
case errors.Is(err, usageDomain.ErrInvalidPlan):
|
||||
|
|
|
|||
|
|
@ -121,6 +121,8 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
|||
logx.Info("usage: Platform.ExaKey empty — using fake-platform-exa for demo/proxy")
|
||||
}
|
||||
usageSvc := usageUC.New(usageRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), keyRes)
|
||||
// 平台 key 容量:預設每分鐘 AI 120/搜尋 60;超限或 MarkPlatformLimited 後僅 BYOK 可用
|
||||
usageSvc.Gate = usageUC.NewMemoryPlatformGate(120, 60)
|
||||
|
||||
// M4 studio: mon store + publish transport(有 Meta 憑證則真發文/同步)
|
||||
var pub studioPublish.Transport
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -93,6 +93,10 @@ type AiSettingsData struct {
|
|||
ModelsError string `json:"models_error,optional"`
|
||||
}
|
||||
|
||||
type ApplyInviteCodeReq struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type AuthBindReq struct {
|
||||
LoginId string `json:"login_id"`
|
||||
Platform string `json:"platform"`
|
||||
|
|
@ -361,8 +365,7 @@ type InspireChatReq struct {
|
|||
Mode string `json:"mode,optional"` // chat|generate
|
||||
PersonaId string `json:"persona_id,optional"`
|
||||
SessionId string `json:"session_id,optional"` // 空 = active
|
||||
// generate:待改寫素材(鎖定內容)
|
||||
Material string `json:"material,optional"`
|
||||
Material string `json:"material,optional"`
|
||||
}
|
||||
|
||||
type InspireDraftPublic struct {
|
||||
|
|
@ -470,12 +473,58 @@ type InspireSessionSummaryPublic struct {
|
|||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
type InviteMemberBrief struct {
|
||||
Uid int64 `json:"uid"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
ParentUid int64 `json:"parent_uid,optional"` // 0 = 無上線
|
||||
Roles []string `json:"roles"`
|
||||
Status string `json:"status"`
|
||||
DownlineCount int `json:"downline_count"`
|
||||
TotalDownlineCount int `json:"total_downline_count"`
|
||||
Depth int `json:"depth"`
|
||||
JoinedAt int64 `json:"joined_at"`
|
||||
AvatarUrl string `json:"avatar_url,optional"`
|
||||
}
|
||||
|
||||
type InviteParentCandidatesData struct {
|
||||
List []InviteMemberBrief `json:"list"`
|
||||
}
|
||||
|
||||
type InviteTreeData struct {
|
||||
List []InviteTreeNode `json:"list"`
|
||||
}
|
||||
|
||||
type InviteTreeNode struct {
|
||||
Uid int64 `json:"uid"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
ParentUid int64 `json:"parent_uid,optional"`
|
||||
Roles []string `json:"roles"`
|
||||
Status string `json:"status"`
|
||||
DownlineCount int `json:"downline_count"`
|
||||
TotalDownlineCount int `json:"total_downline_count"`
|
||||
Depth int `json:"depth"`
|
||||
JoinedAt int64 `json:"joined_at"`
|
||||
AvatarUrl string `json:"avatar_url,optional"`
|
||||
Children []InviteTreeNode `json:"children"`
|
||||
}
|
||||
|
||||
type JobIdPath struct {
|
||||
Id string `path:"id"`
|
||||
}
|
||||
|
||||
type JobListData struct {
|
||||
List []JobPublic `json:"list"`
|
||||
List []JobPublic `json:"list"`
|
||||
Pagination Pagination `json:"pagination"`
|
||||
}
|
||||
|
||||
type JobListReq struct {
|
||||
Tab string `form:"tab,optional"`
|
||||
Page int `form:"page,optional"`
|
||||
PageSize int `form:"pageSize,optional"`
|
||||
}
|
||||
|
||||
type JobPublic struct {
|
||||
|
|
@ -505,6 +554,10 @@ type ListModelsReq struct {
|
|||
Provider string `form:"provider,optional"`
|
||||
}
|
||||
|
||||
type ListParentCandidatesReq struct {
|
||||
ExcludeSubtreeRootUid int64 `form:"exclude_subtree_root_uid,optional"`
|
||||
}
|
||||
|
||||
type MediaUploadData struct {
|
||||
Url string `json:"url"`
|
||||
Key string `json:"key,optional"`
|
||||
|
|
@ -588,6 +641,18 @@ type MentionSyncReq struct {
|
|||
AccountId string `json:"account_id"`
|
||||
}
|
||||
|
||||
type MoveInviteMemberReq struct {
|
||||
Uid int64 `json:"uid"`
|
||||
ParentUid int64 `json:"parent_uid,optional"` // 0 = 獨立根
|
||||
}
|
||||
|
||||
type MyInviteNetworkData struct {
|
||||
Me InviteMemberBrief `json:"me"`
|
||||
Upline *InviteMemberBrief `json:"upline,optional"`
|
||||
Downlines []InviteMemberBrief `json:"downlines"`
|
||||
TotalDownlineCount int `json:"total_downline_count"`
|
||||
}
|
||||
|
||||
type NotificationIdPath struct {
|
||||
Id string `path:"id"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ==="
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
#!/usr/bin/env bash
|
||||
# P1 煙測:invite me +(可選)personas / scout brands 存在性
|
||||
set -euo pipefail
|
||||
BASE="${API_BASE:-http://127.0.0.1:8888}"
|
||||
EMAIL="${SMOKE_EMAIL:-admin@haixun.local}"
|
||||
PASS="${SMOKE_PASSWORD:-Admin123!@#x}"
|
||||
|
||||
ok() { printf '[ok] %s\n' "$*"; }
|
||||
fail() { printf '[FAIL] %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
echo "=== smoke P1 against $BASE ==="
|
||||
|
||||
login=$(curl -sf -X POST "$BASE/api/v1/auth/login" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASS\"}" ) || fail "login"
|
||||
TOKEN=$(echo "$login" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['tokens']['access_token'])")
|
||||
|
||||
inv=$(curl -sf "$BASE/api/v1/invite/me" -H "Authorization: Bearer $TOKEN") || fail "IV-01 invite/me"
|
||||
code=$(echo "$inv" | python3 -c "import sys,json; print(json.load(sys.stdin).get('code'))")
|
||||
[[ "$code" == "102000" ]] || fail "invite/me code=$code"
|
||||
ic=$(echo "$inv" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['me']['invite_code'])")
|
||||
[[ -n "$ic" ]] || fail "empty invite_code"
|
||||
ok "IV-01 getMyNetwork invite_code=$ic"
|
||||
|
||||
# admin tree(admin 帳才過)
|
||||
tree=$(curl -sf "$BASE/api/v1/invite/tree" -H "Authorization: Bearer $TOKEN" || true)
|
||||
tcode=$(echo "$tree" | python3 -c "import sys,json; print(json.load(sys.stdin).get('code',''))" 2>/dev/null || true)
|
||||
if [[ "$tcode" == "102000" ]]; then
|
||||
ok "IV-08 getTree"
|
||||
else
|
||||
ok "IV-08 getTree skipped/non-admin (code=$tcode)"
|
||||
fi
|
||||
|
||||
# 存在性煙測
|
||||
for path in /api/v1/personas /api/v1/own-posts /api/v1/scout/brands /api/v1/inspire/session; do
|
||||
r=$(curl -s -o /dev/null -w '%{http_code}' "$BASE$path" -H "Authorization: Bearer $TOKEN" || true)
|
||||
[[ "$r" != "404" ]] || fail "missing $path"
|
||||
ok "path $path http=$r"
|
||||
done
|
||||
|
||||
echo "=== smoke P1 passed ==="
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { Navigate } from "react-router-dom";
|
||||
import { useI18n } from "../i18n/I18nContext";
|
||||
import { can, Permission } from "../lib/permissions";
|
||||
import { useAuth } from "./AuthContext";
|
||||
|
||||
|
|
@ -8,11 +9,12 @@ import { useAuth } from "./AuthContext";
|
|||
*/
|
||||
export function RequireAdmin({ children }: { children: React.ReactNode }) {
|
||||
const { member, loading } = useAuth();
|
||||
const { t } = useI18n();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="hb-login">
|
||||
<p className="text-muted">載入中…</p>
|
||||
<p className="text-muted">{t("common.loading")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { Navigate, useLocation } from "react-router-dom";
|
||||
import { useI18n } from "../i18n/I18nContext";
|
||||
import { useAuth } from "./AuthContext";
|
||||
|
||||
export function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const { member, loading } = useAuth();
|
||||
const location = useLocation();
|
||||
const { t } = useI18n();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="hb-login">
|
||||
<p className="text-muted">載入中…</p>
|
||||
<p className="text-muted">{t("common.loading")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,11 +45,19 @@ export function UsagePlanWidget() {
|
|||
}
|
||||
|
||||
const planId = summary.plan.id as PlanId;
|
||||
const rights = getPlanRights(planId, t);
|
||||
const rights = getPlanRights(planId, t, formatPlanPrice);
|
||||
// 上限一律用目前方案 PLANS 配給(summary.plan 已由 live repo 綁定 PLANS)
|
||||
const cap = summary.plan.monthly_credits;
|
||||
const used = summary.total_credits;
|
||||
const left = Math.max(0, summary.remaining_credits);
|
||||
const pct = cap > 0 ? Math.min(100, Math.round((used / cap) * 100)) : 0;
|
||||
const used = summary.platform?.credits_used ?? summary.total_credits;
|
||||
const left = summary.unlimited
|
||||
? Math.max(0, summary.remaining_credits)
|
||||
: Math.max(0, cap - used);
|
||||
const pct =
|
||||
summary.pct > 0
|
||||
? Math.min(100, summary.pct)
|
||||
: cap > 0
|
||||
? Math.min(100, Math.round((used / cap) * 100))
|
||||
: 0;
|
||||
const tight = !summary.unlimited && (pct >= 70 || used >= cap);
|
||||
const over = !summary.unlimited && used > cap;
|
||||
const isFree = planId === "free";
|
||||
|
|
@ -93,13 +101,19 @@ export function UsagePlanWidget() {
|
|||
<span className="hb-usage-widget__stat hb-usage-widget__stat--warn">
|
||||
{t("usage.widget.overShort")}
|
||||
</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">
|
||||
{t("usage.widget.leftShort", { n: left })}
|
||||
// 一律顯示 已用/上限,避免「還剩 80」被看成「上限 80」(Free 是 120)
|
||||
<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>
|
||||
)}
|
||||
</button>
|
||||
|
|
@ -162,6 +176,8 @@ export function UsagePlanWidget() {
|
|||
<li key={b}>{b}</li>
|
||||
))}
|
||||
</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}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { ReactNode, SVGProps } from "react";
|
||||
import type { NavKey } from "../../lib/nav";
|
||||
|
||||
export type AppIconName = NavKey | "more" | "spark";
|
||||
export type AppIconName = NavKey | "more" | "spark" | "send";
|
||||
|
||||
type Props = {
|
||||
name: AppIconName;
|
||||
|
|
@ -126,4 +126,14 @@ const glyphs: Record<AppIconName, ReactNode> = {
|
|||
<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} />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -12,3 +12,4 @@ export type { BadgeTone } from "./Badge";
|
|||
export { EmptyState } from "./EmptyState";
|
||||
export { Pager } from "./Pager";
|
||||
export { AccountAvatar, mockAvatarUrl } from "./AccountAvatar";
|
||||
export { MarkdownText } from "./MarkdownText";
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export function PlanPricingGrid({
|
|||
<div className="hb-pricing">
|
||||
{(Object.keys(PLANS) as PlanId[]).map((id) => {
|
||||
const p = PLANS[id];
|
||||
const rights = getPlanRights(id, t);
|
||||
const rights = getPlanRights(id, t, formatPrice);
|
||||
const current = currentId === id;
|
||||
const popular = id === highlighted;
|
||||
const cta = planCtaLabel(currentId, id, t);
|
||||
|
|
@ -51,6 +51,8 @@ export function PlanPricingGrid({
|
|||
<p className="hb-pricing__credits">
|
||||
{t("plans.creditsPerMonth", { n: p.monthly_credits })}
|
||||
</p>
|
||||
<p className="hb-pricing__softcaps">{rights.approxCallsLine}</p>
|
||||
<p className="hb-pricing__softcaps hb-pricing__softcaps--pts">{rights.softCapsLine}</p>
|
||||
</header>
|
||||
|
||||
<ul className="hb-pricing__bullets">
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@ import { METER_META, type UsageMeter } from "../../lib/usageMeter";
|
|||
|
||||
export type MeterBarRow = {
|
||||
meter: UsageMeter;
|
||||
/** 已用點數(platform only) */
|
||||
credits: number;
|
||||
/** 平台呼叫次數(不含 byok) */
|
||||
count: number;
|
||||
/** 分項點數硬上限(與 PLANS.soft_caps 同步,單位=點) */
|
||||
soft_cap: number;
|
||||
};
|
||||
|
||||
|
|
@ -15,7 +18,8 @@ type Props = {
|
|||
};
|
||||
|
||||
/**
|
||||
* 分項用量:條長=相對自己上限;hover 才露出點數。
|
||||
* 分項:數字 = 已用點 / 上限點(與方案 monthly_credits 同一單位)。
|
||||
* 超過/達上限標紅;hover 顯示平台呼叫次數。
|
||||
*/
|
||||
export function UsageMeterBars({ rows, unlimited = false }: Props) {
|
||||
const { t } = useI18n();
|
||||
|
|
@ -28,17 +32,18 @@ export function UsageMeterBars({ rows, unlimited = false }: Props) {
|
|||
<ul className="hb-usage-mbars" onMouseLeave={() => setActive(null)}>
|
||||
{ordered.map((row) => {
|
||||
const label = t(`usage.meter.${row.meter}`);
|
||||
const over = !unlimited && row.soft_cap > 0 && row.credits > row.soft_cap;
|
||||
const atCap = !unlimited && row.soft_cap > 0 && row.credits >= row.soft_cap;
|
||||
const pct =
|
||||
row.soft_cap > 0
|
||||
? Math.min(100, Math.round((row.count / row.soft_cap) * 100))
|
||||
? Math.min(100, Math.round((row.credits / row.soft_cap) * 100))
|
||||
: 0;
|
||||
const on = active === row.meter;
|
||||
const over = row.soft_cap > 0 && row.count > row.soft_cap;
|
||||
return (
|
||||
<li key={row.meter}>
|
||||
<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)}
|
||||
onFocus={() => setActive(row.meter)}
|
||||
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__fill"
|
||||
style={{ width: `${Math.max(pct, row.count > 0 ? 3 : 0)}%` }}
|
||||
style={{ width: `${Math.max(pct, row.credits > 0 ? 3 : 0)}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="hb-usage-mbar__val">
|
||||
<strong>{row.count}</strong>
|
||||
<strong>{row.credits}</strong>
|
||||
<span className="text-muted">/{row.soft_cap}</span>
|
||||
{on ? <span className="hb-usage-mbar__pts">{row.credits}pt</span> : null}
|
||||
{unlimited && over ? <span className="hb-usage-mbar__inf">∞</span> : null}
|
||||
<span className="hb-usage-mbar__unit">{t("usage.meter.pt")}</span>
|
||||
{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>
|
||||
</button>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -58,8 +58,10 @@ export function JobLiveProvider({ children }: { children: ReactNode }) {
|
|||
return;
|
||||
}
|
||||
try {
|
||||
const list = await repos.jobs.list();
|
||||
// 只輪詢「執行中」分桶,避免一次拉全歷史
|
||||
const res = await repos.jobs.list({ tab: "active", page: 1, pageSize: 50 });
|
||||
if (!mounted.current) return;
|
||||
const list = res.list;
|
||||
setJobs(list);
|
||||
const fp = fingerprint(list);
|
||||
if (fp !== fpRef.current) {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,7 @@
|
|||
import type { Repos } from "./repos";
|
||||
import { createMockInviteRepo } from "./mock/inviteRepo";
|
||||
import { createLiveRepos } from "./live/repos";
|
||||
|
||||
/**
|
||||
* 一律 live API。
|
||||
* 邀請關係尚無後端:暫留 inviteRepo(local),其餘 mock seed/createMock 已移除。
|
||||
*/
|
||||
/** 一律 live API(含邀請 M6)。 */
|
||||
export function createRepos(): Repos {
|
||||
const repos = createLiveRepos();
|
||||
return {
|
||||
...repos,
|
||||
dataSource: "live",
|
||||
invite: createMockInviteRepo(),
|
||||
};
|
||||
return createLiveRepos();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
/**
|
||||
* Live repository implementations — 主路徑一律走 gateway。
|
||||
* 僅 invite 在 createRepos 掛 local stub(M6 前)。
|
||||
*/
|
||||
|
||||
import type {
|
||||
|
|
@ -49,13 +48,13 @@ import type {
|
|||
ThreadsPlatformStatus,
|
||||
UsageRepo,
|
||||
} from "../repos";
|
||||
import { createMockInviteRepo } from "../mock/inviteRepo";
|
||||
import {
|
||||
createLiveInspiration,
|
||||
createLiveMedia,
|
||||
createLiveResearch,
|
||||
createLiveScout,
|
||||
} from "./m5Repos";
|
||||
import { createLiveInviteRepo } from "./inviteRepo";
|
||||
import { apiRequest, loadTokens, saveTokens, type StoredTokens } from "./http";
|
||||
|
||||
function uidToString(uid: unknown): string {
|
||||
|
|
@ -533,9 +532,41 @@ function createLiveAccounts(): AccountsRepo {
|
|||
|
||||
function createLiveJobs(): JobsRepo {
|
||||
return {
|
||||
async list() {
|
||||
const data = await apiRequest<{ list: Record<string, unknown>[] }>("/api/v1/jobs");
|
||||
return (data.list ?? []).map(mapJob);
|
||||
async list(query) {
|
||||
const tab = query?.tab ?? "active";
|
||||
const page = query?.page ?? 1;
|
||||
const pageSize = query?.pageSize ?? (query ? 10 : 50);
|
||||
const qs = new URLSearchParams({
|
||||
tab,
|
||||
page: String(page),
|
||||
pageSize: String(pageSize),
|
||||
});
|
||||
const data = await apiRequest<{
|
||||
list?: Record<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) {
|
||||
try {
|
||||
|
|
@ -589,17 +620,22 @@ function createLiveNotifications(): NotificationsRepo {
|
|||
function mapMeterBlock(
|
||||
plan: (typeof PLANS)[PlanId],
|
||||
platformBy: Record<string, unknown> | undefined,
|
||||
byokBy: Record<string, unknown> | undefined,
|
||||
_byokBy: Record<string, unknown> | undefined,
|
||||
): UsageMonthSummary["by_meter"] {
|
||||
const by_meter = {} as UsageMonthSummary["by_meter"];
|
||||
for (const m of Object.keys(plan.soft_caps) as UsageMeter[]) {
|
||||
const p = (platformBy?.[m] as Record<string, unknown> | undefined) ?? {};
|
||||
const b = (byokBy?.[m] as Record<string, unknown> | undefined) ?? {};
|
||||
// 上限對照只看 platform;byok 不進分項鎖/進度(只出現在 ledger)
|
||||
const credits = Number(p.credits ?? 0);
|
||||
const count = Number(p.count ?? 0) + Number(b.count ?? 0);
|
||||
const soft = plan.soft_caps[m] || 1;
|
||||
const pct = soft > 0 ? Math.min(999, Math.round((count / soft) * 100)) : 0;
|
||||
by_meter[m] = { credits, count, soft_cap: soft, pct };
|
||||
const platformCount = Number(p.count ?? 0);
|
||||
const soft = plan.soft_caps[m] || 1; // 分項點數上限(非次數)
|
||||
const pct = soft > 0 ? Math.min(999, Math.round((credits / soft) * 100)) : 0;
|
||||
by_meter[m] = {
|
||||
credits,
|
||||
count: platformCount,
|
||||
soft_cap: soft,
|
||||
pct,
|
||||
};
|
||||
}
|
||||
return by_meter;
|
||||
}
|
||||
|
|
@ -622,17 +658,19 @@ function createLiveUsage(): UsageRepo {
|
|||
async getSummary(monthKey) {
|
||||
const q = monthKey ? `?month_key=${encodeURIComponent(monthKey)}` : "";
|
||||
const raw = await apiRequest<Record<string, unknown>>(`/api/v1/usage/summary${q}`);
|
||||
const planId = String(raw.plan_id ?? "free") as PlanId;
|
||||
const plan = PLANS[planId] ?? PLANS.free;
|
||||
// plan_id 正規化;配給一律以 FE PLANS 為準(與後端 domain.Plans 同步數字)
|
||||
// 不可盲信後端舊 binary 的 credits_total(買 Starter 卻顯示 Free 上限的根因之一)
|
||||
const planIdRaw = String(raw.plan_id ?? "free").toLowerCase().trim();
|
||||
const planId = (planIdRaw in PLANS ? planIdRaw : "free") as PlanId;
|
||||
const plan = PLANS[planId];
|
||||
const platform = (raw.platform as Record<string, unknown>) ?? {};
|
||||
const byok = (raw.byok as Record<string, unknown>) ?? {};
|
||||
const total = Number(raw.total_credits ?? platform.credits_total ?? plan.monthly_credits);
|
||||
const remaining = Number(
|
||||
raw.remaining_credits ?? platform.credits_remaining ?? plan.monthly_credits,
|
||||
);
|
||||
const used = Number(platform.credits_used ?? total - remaining);
|
||||
const pct =
|
||||
total > 0 ? Math.min(999, Math.round((used / total) * 100)) : 0;
|
||||
const cap = plan.monthly_credits;
|
||||
const used = Number(platform.credits_used ?? 0);
|
||||
const remaining = Boolean(raw.unlimited)
|
||||
? Math.max(0, Number(platform.credits_remaining ?? Math.max(0, cap - used)))
|
||||
: Math.max(0, cap - used);
|
||||
const pct = cap > 0 ? Math.min(999, Math.round((used / cap) * 100)) : 0;
|
||||
|
||||
const platBy = (platform.by_meter as Record<string, unknown> | undefined) ?? {};
|
||||
const byokBy = (byok.by_meter as Record<string, unknown> | undefined) ?? {};
|
||||
|
|
@ -652,18 +690,20 @@ function createLiveUsage(): UsageRepo {
|
|||
events = [];
|
||||
}
|
||||
|
||||
// 狀態列 AI/Search:顯示「點數」才與方案配給同一單位(不是混加次數)
|
||||
const ai_calls =
|
||||
(by_meter.ai_copy?.count ?? 0) +
|
||||
(by_meter.ai_research?.count ?? 0) +
|
||||
(by_meter.ai_image?.count ?? 0);
|
||||
const search_calls = by_meter.web_search?.count ?? 0;
|
||||
(by_meter.ai_copy?.credits ?? 0) +
|
||||
(by_meter.ai_research?.credits ?? 0) +
|
||||
(by_meter.ai_image?.credits ?? 0);
|
||||
const search_calls = by_meter.web_search?.credits ?? 0;
|
||||
|
||||
return {
|
||||
month_key: String(raw.month_key ?? ""),
|
||||
uid: String(raw.uid ?? ""),
|
||||
plan,
|
||||
unlimited: Boolean(raw.unlimited),
|
||||
total_credits: total,
|
||||
// FE 慣例:total_credits = 本月已用(platform 點數);配給看 plan.monthly_credits
|
||||
total_credits: used,
|
||||
remaining_credits: remaining,
|
||||
pct,
|
||||
ai_calls,
|
||||
|
|
@ -673,7 +713,7 @@ function createLiveUsage(): UsageRepo {
|
|||
platform: {
|
||||
credits_used: used,
|
||||
credits_remaining: remaining,
|
||||
credits_total: total,
|
||||
credits_total: cap,
|
||||
call_count: Number(platform.call_count ?? 0),
|
||||
},
|
||||
byok: { call_count: Number(byok.call_count ?? 0) },
|
||||
|
|
@ -694,8 +734,9 @@ function createLiveUsage(): UsageRepo {
|
|||
},
|
||||
async getMemberPrefs() {
|
||||
const raw = await apiRequest<Record<string, unknown>>("/api/v1/usage/prefs");
|
||||
const id = String(raw.plan_id ?? "free").toLowerCase().trim();
|
||||
return {
|
||||
plan_id: String(raw.plan_id ?? "free") as PlanId,
|
||||
plan_id: (id in PLANS ? id : "free") as PlanId,
|
||||
unlimited: Boolean(raw.unlimited),
|
||||
};
|
||||
},
|
||||
|
|
@ -715,12 +756,57 @@ function createLiveUsage(): UsageRepo {
|
|||
async getTenantSummary(monthKey) {
|
||||
const q = monthKey ? `?month_key=${encodeURIComponent(monthKey)}` : "";
|
||||
const raw = await apiRequest<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 {
|
||||
month_key: String(raw.month_key ?? ""),
|
||||
total_credits: Number(raw.platform_credits_used ?? 0),
|
||||
purchased_credits: 0,
|
||||
consumed_credits: Number(raw.platform_credits_used ?? 0),
|
||||
remaining_credits: 0,
|
||||
total_credits: consumed,
|
||||
purchased_credits: purchasedSum,
|
||||
consumed_credits: consumed,
|
||||
remaining_credits: Math.max(0, purchasedSum - consumed),
|
||||
ai_calls: 0,
|
||||
search_calls: Number(raw.byok_call_count ?? 0),
|
||||
by_meter: {
|
||||
|
|
@ -729,36 +815,40 @@ function createLiveUsage(): UsageRepo {
|
|||
web_search: { credits: 0, count: 0 },
|
||||
ai_image: { credits: 0, count: 0 },
|
||||
},
|
||||
members: ((raw.members as Record<string, unknown>[]) ?? []).map((m) => ({
|
||||
uid: String(m.uid ?? ""),
|
||||
email: "",
|
||||
display_name: "",
|
||||
unlimited: Boolean(m.unlimited),
|
||||
plan_id: String(m.plan_id ?? "free") as PlanId,
|
||||
purchased_credits: 0,
|
||||
total_credits: Number(m.platform_credits_used ?? 0),
|
||||
ai_calls: 0,
|
||||
search_calls: Number(m.byok_call_count ?? 0),
|
||||
remaining_credits: 0,
|
||||
pct: 0,
|
||||
})),
|
||||
members,
|
||||
};
|
||||
},
|
||||
async getTenantAnalytics() {
|
||||
async getTenantAnalytics(query) {
|
||||
const sum = await this.getTenantSummary();
|
||||
const purchased = sum.purchased_credits || 1;
|
||||
return {
|
||||
granularity: "month" as const,
|
||||
from: "",
|
||||
to: "",
|
||||
granularity: (query?.granularity ?? "month") as "day" | "month" | "year",
|
||||
from: query?.from ?? "",
|
||||
to: query?.to ?? "",
|
||||
range_label: sum.month_key,
|
||||
purchased_credits: 0,
|
||||
purchased_credits: sum.purchased_credits,
|
||||
consumed_credits: sum.consumed_credits,
|
||||
remaining_credits: 0,
|
||||
pct: 0,
|
||||
ai_calls: 0,
|
||||
remaining_credits: sum.remaining_credits,
|
||||
pct:
|
||||
sum.purchased_credits > 0
|
||||
? Math.min(999, Math.round((sum.consumed_credits / purchased) * 100))
|
||||
: 0,
|
||||
ai_calls: sum.ai_calls,
|
||||
search_calls: sum.search_calls,
|
||||
by_meter: sum.by_meter,
|
||||
series: [],
|
||||
// 後端目前僅月彙總;圖表至少給本月一點
|
||||
series: sum.month_key
|
||||
? [
|
||||
{
|
||||
key: sum.month_key,
|
||||
label: sum.month_key,
|
||||
purchased_credits: sum.purchased_credits,
|
||||
consumed_credits: sum.consumed_credits,
|
||||
ai_calls: sum.ai_calls,
|
||||
search_calls: sum.search_calls,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
members: sum.members,
|
||||
};
|
||||
},
|
||||
|
|
@ -1436,7 +1526,6 @@ function createLiveMentions(): MentionsRepo {
|
|||
}
|
||||
|
||||
export function createLiveRepos(): Repos {
|
||||
// 除邀請外全部 live;不再 fallback mock seed
|
||||
return {
|
||||
dataSource: "live",
|
||||
auth: createLiveAuth(),
|
||||
|
|
@ -1446,7 +1535,7 @@ export function createLiveRepos(): Repos {
|
|||
jobs: createLiveJobs(),
|
||||
notifications: createLiveNotifications(),
|
||||
usage: createLiveUsage(),
|
||||
invite: createMockInviteRepo(),
|
||||
invite: createLiveInviteRepo(),
|
||||
personas: createLivePersonas(),
|
||||
plays: createLivePlays(),
|
||||
outbox: createLiveOutbox(),
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ export const KEYS = {
|
|||
activePersonaId: "harbor.active_persona_id",
|
||||
activeBrandId: "harbor.active_brand_id",
|
||||
ownPostsSyncedAt: "harbor.own_posts_synced_at",
|
||||
/** 帳號成效 sync 閘:{ [accountId]: { lastSyncMs, pastOnceDone } } */
|
||||
ownPostsSyncMeta: "harbor.own_posts.sync_meta",
|
||||
/** 帳號月度分析歷史 AccountInsightsSnapshot[] */
|
||||
accountInsightHistory: "harbor.account.insight_history",
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -223,8 +223,31 @@ export type OutboxRepo = {
|
|||
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 = {
|
||||
list(): Promise<Job[]>;
|
||||
/**
|
||||
* 分頁列表。傳 query 時走 tab+page;無 query 時預設 active 前 50 筆(相容舊呼叫)。
|
||||
* 回傳完整 JobListResult;若只要陣列可用 .list。
|
||||
*/
|
||||
list(query?: JobListQuery): Promise<JobListResult>;
|
||||
get(id: string): Promise<Job | null>;
|
||||
startDemo(): Promise<Job>;
|
||||
/** 手動刪除(執行中不可刪;後端終態約 2 天後也會自動清) */
|
||||
|
|
|
|||
|
|
@ -118,6 +118,19 @@ export function saveInsightSnapshot(snap: AccountInsightsSnapshot): AccountInsig
|
|||
return snap;
|
||||
}
|
||||
|
||||
/** 刪除某帳某月分析(空月不該留假結論) */
|
||||
export function removeInsightSnapshot(accountId: string, monthKey: string): void {
|
||||
const list = loadAllSnapshots().filter(
|
||||
(s) => !(s.account_id === accountId && s.month_key === monthKey),
|
||||
);
|
||||
saveAllSnapshots(list);
|
||||
}
|
||||
|
||||
/** 該月是否有足夠真實數據可寫結論(至少 1 則貼文) */
|
||||
export function monthHasRealData(month: Pick<MonthBucket, "posts">): boolean {
|
||||
return (month.posts || 0) > 0;
|
||||
}
|
||||
|
||||
function snapshotFromParts(opts: {
|
||||
accountId: string;
|
||||
month: MonthBucket;
|
||||
|
|
@ -159,48 +172,80 @@ function snapshotFromParts(opts: {
|
|||
}
|
||||
|
||||
/**
|
||||
* 依目前 report 確保近 N 月都有歷史分析紀錄(缺則補;本月可 force 重算)。
|
||||
* 依目前 report 維護分析歷史。
|
||||
* - 該月 posts=0:不寫結論,並清掉舊的假 snapshot
|
||||
* - 有貼文才建/更新分析(本月可 force 重算)
|
||||
*/
|
||||
export function ensureInsightHistory(
|
||||
report: AccountInsightsReport,
|
||||
opts?: { forceCurrent?: boolean },
|
||||
): AccountInsightsSnapshot[] {
|
||||
const months = report.months;
|
||||
const topHighlights = report.topPosts.slice(0, 3).map((p) => {
|
||||
const head = (p.insight || p.formula_summary || p.text).slice(0, 80);
|
||||
return `${p.topic_tag ? `【${p.topic_tag}】` : ""}${head}${head.length >= 80 ? "…" : ""}`;
|
||||
});
|
||||
const monthKeys = new Set(months.map((m) => m.key));
|
||||
|
||||
// 清掉已不在視窗外、或空月卻殘留假結論的紀錄
|
||||
for (const snap of listInsightSnapshots(report.accountId)) {
|
||||
const bucket = months.find((m) => m.key === snap.month_key);
|
||||
if (!monthKeys.has(snap.month_key)) continue;
|
||||
if (bucket && !monthHasRealData(bucket)) {
|
||||
removeInsightSnapshot(report.accountId, snap.month_key);
|
||||
}
|
||||
// 舊假資料:metrics.posts=0 卻還有分析句
|
||||
if ((snap.metrics?.posts || 0) <= 0 && (snap.analysis?.length || 0) > 0) {
|
||||
removeInsightSnapshot(report.accountId, snap.month_key);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < months.length; i++) {
|
||||
const month = months[i]!;
|
||||
const previous = i > 0 ? months[i - 1]! : null;
|
||||
// 沒有真實貼文 → 不亂下結論
|
||||
if (!monthHasRealData(month)) {
|
||||
removeInsightSnapshot(report.accountId, month.key);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 月比只在「前月也有真實貼文」時才有意義
|
||||
const rawPrev = i > 0 ? months[i - 1]! : null;
|
||||
const previous = rawPrev && monthHasRealData(rawPrev) ? rawPrev : null;
|
||||
const existing = getInsightSnapshot(report.accountId, month.key);
|
||||
const isCurrent = i === months.length - 1;
|
||||
if (existing && !(isCurrent && opts?.forceCurrent)) continue;
|
||||
|
||||
const delta = {
|
||||
views: previous ? pctChange(month.views, previous.views) : null,
|
||||
likes: previous ? pctChange(month.likes, previous.likes) : null,
|
||||
replies: previous ? pctChange(month.replies, previous.replies) : null,
|
||||
posts: previous ? pctChange(month.posts, previous.posts) : null,
|
||||
engagementRate: previous
|
||||
? pctChange(month.engagementRate * 100, previous.engagementRate * 100)
|
||||
: null,
|
||||
};
|
||||
const monthTop = topPostsForMonth(
|
||||
// report.topPosts 是全帳本月 top;歷史月用 metrics 敘事 + 空 highlights 即可
|
||||
isCurrent ? report.topPosts : [],
|
||||
month.key,
|
||||
3,
|
||||
);
|
||||
// 若是本月,topPosts 已是本月;仍再 filter 保險
|
||||
const highlights = (isCurrent ? report.topPosts : monthTop).slice(0, 3).map((p) => {
|
||||
const head = (p.insight || p.formula_summary || p.text).slice(0, 80);
|
||||
return `${p.topic_tag ? `【${p.topic_tag}】` : ""}${head}${head.length >= 80 ? "…" : ""}`;
|
||||
});
|
||||
|
||||
// 本月:優先用 report 全文;force 或歷史月:依該月指標重算敘事
|
||||
const narrative =
|
||||
isCurrent && !opts?.forceCurrent && report.analysis.length > 0
|
||||
? { analysis: report.analysis, recommendations: report.recommendations }
|
||||
: buildNarrative({
|
||||
current: month,
|
||||
previous,
|
||||
delta,
|
||||
topPosts: isCurrent ? report.topPosts : [],
|
||||
avgEngagementRate: isCurrent ? report.avgEngagementRate : month.engagementRate,
|
||||
totalPosts: isCurrent ? report.totalPosts : month.posts,
|
||||
monthLabel: month.label,
|
||||
});
|
||||
const narrative = buildNarrative({
|
||||
current: month,
|
||||
previous,
|
||||
delta: {
|
||||
views: previous ? pctChange(month.views, previous.views) : null,
|
||||
likes: previous ? pctChange(month.likes, previous.likes) : null,
|
||||
replies: previous ? pctChange(month.replies, previous.replies) : null,
|
||||
posts: previous ? pctChange(month.posts, previous.posts) : null,
|
||||
engagementRate: previous
|
||||
? pctChange(month.engagementRate * 100, previous.engagementRate * 100)
|
||||
: null,
|
||||
},
|
||||
topPosts: isCurrent ? report.topPosts : [],
|
||||
avgEngagementRate: isCurrent ? report.avgEngagementRate : month.engagementRate,
|
||||
totalPosts: month.posts,
|
||||
monthLabel: month.label,
|
||||
});
|
||||
|
||||
// 仍無任何可說的結論 → 不存 snapshot
|
||||
if (narrative.analysis.length === 0 && narrative.recommendations.length === 0) {
|
||||
removeInsightSnapshot(report.accountId, month.key);
|
||||
continue;
|
||||
}
|
||||
|
||||
saveInsightSnapshot(
|
||||
snapshotFromParts({
|
||||
|
|
@ -209,14 +254,7 @@ export function ensureInsightHistory(
|
|||
previous,
|
||||
analysis: narrative.analysis,
|
||||
recommendations: narrative.recommendations,
|
||||
topHighlights: isCurrent
|
||||
? topHighlights
|
||||
: existing?.top_highlights?.length
|
||||
? existing.top_highlights
|
||||
: [
|
||||
`${month.label} 互動率 ${Math.round(month.engagementRate * 1000) / 10}%`,
|
||||
`瀏覽 ${month.views.toLocaleString("zh-TW")} · 回覆 ${month.replies}`,
|
||||
],
|
||||
topHighlights: highlights,
|
||||
existingId: existing?.id,
|
||||
}),
|
||||
);
|
||||
|
|
@ -237,6 +275,34 @@ function engagementOf(p: OwnPost): number {
|
|||
return eng / views;
|
||||
}
|
||||
|
||||
/** published_at unix ns → YYYY-MM */
|
||||
export function monthKeyOfPost(p: OwnPost): string {
|
||||
const ms = p.published_at > 0 ? Math.floor(p.published_at / 1_000_000) : Date.now();
|
||||
return monthKeyFromDate(new Date(ms));
|
||||
}
|
||||
|
||||
export function filterPostsByMonth(posts: OwnPost[], monthKey: string): OwnPost[] {
|
||||
return posts.filter((p) => monthKeyOfPost(p) === monthKey);
|
||||
}
|
||||
|
||||
export function scoreOwnPost(p: OwnPost): number {
|
||||
return (
|
||||
(p.view_count || 0) +
|
||||
(p.like_count || 0) * 8 +
|
||||
(p.reply_count || 0) * 20 +
|
||||
(p.repost_count || 0) * 15 +
|
||||
(p.quote_count || 0) * 12
|
||||
);
|
||||
}
|
||||
|
||||
/** 指定月份表現較佳貼文 */
|
||||
export function topPostsForMonth(posts: OwnPost[], monthKey: string, limit = 5): OwnPost[] {
|
||||
return filterPostsByMonth(posts, monthKey)
|
||||
.slice()
|
||||
.sort((a, b) => scoreOwnPost(b) - scoreOwnPost(a))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function emptyBucket(key: string, source: MonthBucket["source"] = "estimate"): MonthBucket {
|
||||
return {
|
||||
key,
|
||||
|
|
@ -278,15 +344,8 @@ function pctChange(curr: number, prev: number): number | null {
|
|||
return Math.round(((curr - prev) / prev) * 1000) / 10;
|
||||
}
|
||||
|
||||
function hashSeed(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
return Math.abs(h);
|
||||
}
|
||||
|
||||
/**
|
||||
* 由貼文聚合近 N 月成效;缺月以均值微擾補齊(mock 歷史,方便畫月比圖)。
|
||||
* live 時改為讀 snapshot 表即可。
|
||||
* 由已同步貼文聚合近 N 月成效(只算真實數據;無貼文的月份為 0,不造假估測)。
|
||||
*/
|
||||
export function buildAccountInsights(
|
||||
accountId: string,
|
||||
|
|
@ -300,8 +359,10 @@ export function buildAccountInsights(
|
|||
for (const k of keys) byMonth.set(k, emptyBucket(k, "posts"));
|
||||
|
||||
for (const p of mine) {
|
||||
const d = new Date(Math.floor(p.published_at / 1_000_000));
|
||||
const key = monthKeyFromDate(d);
|
||||
// published_at = unix ns;異常 0 則歸入本月,避免整批落在窗外
|
||||
const ms =
|
||||
p.published_at > 0 ? Math.floor(p.published_at / 1_000_000) : Date.now();
|
||||
const key = monthKeyFromDate(new Date(ms));
|
||||
if (!byMonth.has(key)) continue;
|
||||
const b = byMonth.get(key)!;
|
||||
b.posts += 1;
|
||||
|
|
@ -314,72 +375,29 @@ export function buildAccountInsights(
|
|||
b.source = "posts";
|
||||
}
|
||||
|
||||
// 有真實數據的月份均值,用來補齊空月
|
||||
const real = keys
|
||||
.map((k) => byMonth.get(k)!)
|
||||
.filter((b) => b.posts > 0)
|
||||
.map(finalizeBucket);
|
||||
const avgViews = real.length
|
||||
? real.reduce((s, b) => s + b.views, 0) / real.length
|
||||
: 800;
|
||||
const avgLikes = real.length
|
||||
? real.reduce((s, b) => s + b.likes, 0) / real.length
|
||||
: 30;
|
||||
const avgReplies = real.length
|
||||
? real.reduce((s, b) => s + b.replies, 0) / real.length
|
||||
: 3;
|
||||
const avgPosts = real.length
|
||||
? Math.max(1, Math.round(real.reduce((s, b) => s + b.posts, 0) / real.length))
|
||||
: 2;
|
||||
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const k = keys[i]!;
|
||||
const b = byMonth.get(k)!;
|
||||
if (b.posts > 0) {
|
||||
byMonth.set(k, finalizeBucket(b));
|
||||
continue;
|
||||
}
|
||||
// 越舊略低、加帳號雜訊(穩定偽隨機)
|
||||
const age = keys.length - 1 - i;
|
||||
const noise = 0.72 + (hashSeed(`${accountId}|${k}`) % 40) / 100;
|
||||
const factor = noise * (1 - age * 0.06);
|
||||
const est = emptyBucket(k, "estimate");
|
||||
est.posts = Math.max(1, Math.round(avgPosts * factor));
|
||||
est.views = Math.round(avgViews * factor);
|
||||
est.likes = Math.round(avgLikes * factor);
|
||||
est.replies = Math.max(0, Math.round(avgReplies * factor));
|
||||
est.reposts = Math.round(est.likes * 0.12);
|
||||
est.quotes = Math.round(est.likes * 0.08);
|
||||
est.shares = Math.round(est.likes * 0.05);
|
||||
byMonth.set(k, finalizeBucket(est));
|
||||
for (const k of keys) {
|
||||
byMonth.set(k, finalizeBucket(byMonth.get(k)!));
|
||||
}
|
||||
|
||||
const months = keys.map((k) => byMonth.get(k)!);
|
||||
const current = months[months.length - 1]!;
|
||||
const previous = months.length >= 2 ? months[months.length - 2]! : null;
|
||||
// 前月沒貼文就不做月比(避免 0→有數 顯示 +100% 假結論)
|
||||
const rawPrev = months.length >= 2 ? months[months.length - 2]! : null;
|
||||
const previous = rawPrev && monthHasRealData(rawPrev) ? rawPrev : null;
|
||||
|
||||
const delta = {
|
||||
views: previous ? pctChange(current.views, previous.views) : null,
|
||||
likes: previous ? pctChange(current.likes, previous.likes) : null,
|
||||
replies: previous ? pctChange(current.replies, previous.replies) : null,
|
||||
posts: previous ? pctChange(current.posts, previous.posts) : null,
|
||||
engagementRate: previous
|
||||
? pctChange(current.engagementRate * 100, previous.engagementRate * 100)
|
||||
: null,
|
||||
views: previous && monthHasRealData(current) ? pctChange(current.views, previous.views) : null,
|
||||
likes: previous && monthHasRealData(current) ? pctChange(current.likes, previous.likes) : null,
|
||||
replies:
|
||||
previous && monthHasRealData(current) ? pctChange(current.replies, previous.replies) : null,
|
||||
posts: previous && monthHasRealData(current) ? pctChange(current.posts, previous.posts) : null,
|
||||
engagementRate:
|
||||
previous && monthHasRealData(current)
|
||||
? pctChange(current.engagementRate * 100, previous.engagementRate * 100)
|
||||
: null,
|
||||
};
|
||||
|
||||
const topPosts = mine
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const score = (p: OwnPost) =>
|
||||
(p.view_count || 0) +
|
||||
(p.like_count || 0) * 8 +
|
||||
(p.reply_count || 0) * 20 +
|
||||
(p.repost_count || 0) * 15 +
|
||||
(p.quote_count || 0) * 12;
|
||||
return score(b) - score(a);
|
||||
})
|
||||
.slice(0, 5);
|
||||
const topPosts = topPostsForMonth(mine, current.key, 5);
|
||||
|
||||
const withViews = mine.filter((p) => (p.view_count || 0) > 0);
|
||||
const avgEngagementRate = withViews.length
|
||||
|
|
@ -410,6 +428,9 @@ export function buildAccountInsights(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 只根據該月真實指標寫結論;沒貼文/沒可比數字就不硬寫。
|
||||
*/
|
||||
function buildNarrative(opts: {
|
||||
current: MonthBucket;
|
||||
previous: MonthBucket | null;
|
||||
|
|
@ -419,79 +440,73 @@ function buildNarrative(opts: {
|
|||
totalPosts: number;
|
||||
monthLabel?: string;
|
||||
}): { analysis: string[]; recommendations: string[] } {
|
||||
const { current, previous, delta, topPosts, avgEngagementRate, totalPosts } = opts;
|
||||
const { current, previous, delta, topPosts, avgEngagementRate } = opts;
|
||||
const when = opts.monthLabel || current.label || "本月";
|
||||
const analysis: string[] = [];
|
||||
const recommendations: string[] = [];
|
||||
|
||||
if (totalPosts === 0 && current.posts === 0) {
|
||||
return {
|
||||
analysis: [`${when}:尚無足夠貼文數據,無法比較月成效。`],
|
||||
recommendations: ["先同步我的貼文,或該月至少發 1~2 則再分析。"],
|
||||
};
|
||||
// 空月:完全不說話
|
||||
if (!monthHasRealData(current)) {
|
||||
return { analysis: [], recommendations: [] };
|
||||
}
|
||||
|
||||
analysis.push(
|
||||
`${when}彙總:貼文 ${current.posts}、瀏覽 ${current.views.toLocaleString("zh-TW")}、讚 ${current.likes}、回覆 ${current.replies}(資料${current.source === "posts" ? "來自同步貼文" : "為歷史估測"})。`,
|
||||
`${when}彙總:貼文 ${current.posts}、瀏覽 ${current.views.toLocaleString("zh-TW")}、讚 ${current.likes}、回覆 ${current.replies}(來自已同步貼文)。`,
|
||||
);
|
||||
|
||||
if (delta.views != null && previous) {
|
||||
// 僅在前月也有貼文時才比
|
||||
if (previous && monthHasRealData(previous) && delta.views != null) {
|
||||
if (delta.views > 8) {
|
||||
analysis.push(
|
||||
`瀏覽較前月 ${fmtDelta(delta.views)}(${previous.views.toLocaleString("zh-TW")} → ${current.views.toLocaleString("zh-TW")})。`,
|
||||
);
|
||||
} else if (delta.views < -8) {
|
||||
analysis.push(
|
||||
`瀏覽較前月 ${fmtDelta(delta.views)},曝光偏弱,宜檢查發文節奏與開場鉤子。`,
|
||||
`瀏覽較前月 ${fmtDelta(delta.views)}(${previous.views.toLocaleString("zh-TW")} → ${current.views.toLocaleString("zh-TW")})。`,
|
||||
);
|
||||
} else {
|
||||
analysis.push(`瀏覽與前月大致持平(${fmtDelta(delta.views)})。`);
|
||||
}
|
||||
}
|
||||
|
||||
if (delta.replies != null) {
|
||||
if (previous && monthHasRealData(previous) && delta.replies != null) {
|
||||
if (delta.replies > 10) {
|
||||
analysis.push(`回覆數 ${fmtDelta(delta.replies)},對話熱度上升,適合延續可接話題材。`);
|
||||
analysis.push(`回覆數 ${fmtDelta(delta.replies)},對話熱度上升。`);
|
||||
} else if (delta.replies < -10) {
|
||||
analysis.push(`回覆數 ${fmtDelta(delta.replies)},可多試「求經驗/明確條件」開問。`);
|
||||
analysis.push(`回覆數 ${fmtDelta(delta.replies)}。`);
|
||||
}
|
||||
}
|
||||
|
||||
const engPct = Math.round(avgEngagementRate * 1000) / 10;
|
||||
analysis.push(
|
||||
`互動率約 ${engPct}%(讚+回+轉+引用+分享/瀏覽)。Threads 上 ${engPct >= 4 ? "屬不錯" : engPct >= 2 ? "中等,還有拉高空間" : "偏低,優先優化鉤子與問句"}。`,
|
||||
);
|
||||
// 有瀏覽才談互動率,避免 0 瀏覽硬算 0% 再亂評
|
||||
if (current.views > 0) {
|
||||
const engPct = Math.round(avgEngagementRate * 1000) / 10;
|
||||
analysis.push(
|
||||
`互動率約 ${engPct}%(讚+回+轉+引用+分享/瀏覽)。`,
|
||||
);
|
||||
if (engPct < 2) {
|
||||
recommendations.push("互動率偏低:可多試帶明確條件的提問收尾。");
|
||||
} else if (engPct >= 4) {
|
||||
recommendations.push("互動率不錯:可複製高表現貼的結構再測 1~2 則。");
|
||||
}
|
||||
} else if (current.likes + current.replies > 0) {
|
||||
analysis.push("此月有讚/回覆,但瀏覽為 0(Insights 可能尚未回傳或權限不足)。");
|
||||
}
|
||||
|
||||
const top = topPosts[0];
|
||||
if (top) {
|
||||
const tag = top.topic_tag ? `「${top.topic_tag}」` : "高互動";
|
||||
const snippet = (top.insight || top.formula_summary || top.text).slice(0, 72);
|
||||
analysis.push(
|
||||
`表現最佳貼偏 ${tag}:${(top.insight || top.formula_summary || top.text).slice(0, 72)}${(top.insight || top.formula_summary || top.text).length > 72 ? "…" : ""}`,
|
||||
`表現較佳之一:${snippet}${snippet.length >= 72 ? "…" : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
const questionPosts = topPosts.filter((p) => /[??]/.test(p.text) || /求|有人|嗎/.test(p.text));
|
||||
if (questionPosts.length >= 1 || (current.replies > 0 && current.posts > 0)) {
|
||||
recommendations.push("維持條件明確的提問貼(插座/無香/預算),回覆與轉發通常較穩。");
|
||||
} else {
|
||||
recommendations.push("下一期至少 1 則帶具體條件的提問,避免純宣告。");
|
||||
}
|
||||
|
||||
if ((delta.views ?? 0) < 0 || engPct < 2.5) {
|
||||
recommendations.push("搭配探查清待回:活躍回覆能補帳號存在感,間接撐後續曝光。");
|
||||
} else {
|
||||
recommendations.push("把高互動貼的結構(鉤子+問句)複製到下 2 則新串,測能否再現。");
|
||||
}
|
||||
|
||||
if (current.posts < 3) {
|
||||
recommendations.push("該月發文偏少,可固定每週 2~3 則,曲線與月比才比較得準。");
|
||||
} else {
|
||||
recommendations.push("下月回看本頁歷史:對照瀏覽與回覆是否同向,避免只追讚數。");
|
||||
recommendations.push("該月貼文偏少,樣本小,月比僅供參考。");
|
||||
}
|
||||
|
||||
return {
|
||||
analysis: analysis.slice(0, 5),
|
||||
recommendations: recommendations.slice(0, 4),
|
||||
recommendations: recommendations.slice(0, 3),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ export const API_CODES = {
|
|||
NOT_FOUND: 404001,
|
||||
EMAIL_NOT_REGISTERED: 404002,
|
||||
EMAIL_TAKEN: 409001,
|
||||
QUOTA: 402001,
|
||||
METER_CAP: 402002,
|
||||
PLATFORM_CAPACITY: 503002,
|
||||
SERVER: 500000,
|
||||
NOT_IMPLEMENTED: 501000,
|
||||
NETWORK: 0,
|
||||
|
|
@ -47,12 +50,16 @@ export const API_ERROR_I18N_KEY: Record<number, string> = {
|
|||
[API_CODES.NOT_FOUND]: "api.err.404001",
|
||||
[API_CODES.EMAIL_NOT_REGISTERED]: "api.err.404002",
|
||||
[API_CODES.EMAIL_TAKEN]: "api.err.409001",
|
||||
[API_CODES.QUOTA]: "api.err.402001",
|
||||
[API_CODES.METER_CAP]: "usage.err.meterCap",
|
||||
[API_CODES.PLATFORM_CAPACITY]: "usage.err.platformCapacity",
|
||||
[API_CODES.SERVER]: "api.err.500000",
|
||||
[API_CODES.NOT_IMPLEMENTED]: "api.err.501000",
|
||||
[API_CODES.NETWORK]: "api.err.network",
|
||||
// 400050/400060 不進表:後端 message 常是具體中文原因(人設分析等),由 rawMessage 顯示
|
||||
400061: "api.err.crawlerSession",
|
||||
503001: "api.err.timeoutAI",
|
||||
// 503002 = PLATFORM_CAPACITY,已在上方 API_CODES 映射
|
||||
};
|
||||
|
||||
export function i18nKeyForApiCode(code: number): string | undefined {
|
||||
|
|
@ -143,6 +150,9 @@ export function messageForApiCode(
|
|||
locale?: AppLocale,
|
||||
): string {
|
||||
const loc = locale ?? currentLocale();
|
||||
// 後端業務錯誤直接回 i18n key(invite.err.* / usage.err.*)
|
||||
const fb = (fallback || "").trim();
|
||||
if (fb.startsWith("invite.err.") || fb.startsWith("usage.err.")) return translate(loc, fb);
|
||||
const key = i18nKeyForApiCode(code);
|
||||
if (key) return translate(loc, key);
|
||||
if (isUserFacingBizMessage(fallback)) return cleanBizMessage(fallback!);
|
||||
|
|
@ -163,6 +173,9 @@ export function formatThrownError(err: unknown, fallbackKey = "common.error"): s
|
|||
if (e && typeof e === "object" && e.name === "ApiError") {
|
||||
const code = e.code != null ? Number(e.code) : NaN;
|
||||
const raw = e.rawMessage || e.message;
|
||||
if (typeof raw === "string" && (raw.startsWith("invite.err.") || raw.startsWith("usage.err."))) {
|
||||
return translate(loc, raw);
|
||||
}
|
||||
if (!Number.isNaN(code)) {
|
||||
const key = i18nKeyForApiCode(code);
|
||||
// 驗證類業務碼:優先後端中文原因
|
||||
|
|
|
|||
|
|
@ -67,6 +67,9 @@ export const zhTW: MessageDict = {
|
|||
"api.err.404001": "找不到資料",
|
||||
"api.err.404002": "查無此信箱,請確認後再試",
|
||||
"api.err.409001": "此 Email 已被註冊",
|
||||
"api.err.402001": "本月平台點數已用完,請升級方案或下月再試",
|
||||
"usage.err.meterCap": "此功能本月點數已達上限,請改用其他功能或升級方案",
|
||||
"usage.err.platformCapacity": "平台 AI/搜尋目前忙碌或已限流。可稍後再試,或到設定填入自己的 API Key(BYOK)繼續使用",
|
||||
"api.err.500000": "伺服器發生錯誤,請稍後再試",
|
||||
"api.err.timeoutAI": "AI 回應逾時(模型思考太久)。可縮短結構備註後再試,或到設定換較快的模型",
|
||||
"api.err.501000": "此功能尚未實作",
|
||||
|
|
@ -140,14 +143,14 @@ export const zhTW: MessageDict = {
|
|||
"settings.localeCurrency": "語言與幣別",
|
||||
"settings.locale": "介面語言",
|
||||
"settings.currency": "顯示幣別",
|
||||
"settings.currencyHint": "",
|
||||
"settings.currencyHint": "金額顯示用;方案計費仍以台幣為準。",
|
||||
"settings.localeSaved": "語言與幣別已更新",
|
||||
"settings.appearance": "外觀",
|
||||
"settings.theme": "主題",
|
||||
"settings.themeLight": "淺色",
|
||||
"settings.themeDark": "深色",
|
||||
"settings.themeSystem": "跟隨系統",
|
||||
"settings.themeHint": "",
|
||||
"settings.themeHint": "可選淺色、深色,或跟隨系統外觀。",
|
||||
"settings.themeSaved": "主題已更新",
|
||||
"settings.themeToLight": "切換成淺色",
|
||||
"settings.themeToDark": "切換成深色",
|
||||
|
|
@ -180,14 +183,14 @@ export const zhTW: MessageDict = {
|
|||
"settings.threadsGoCrew": "前往 Crew 連帳",
|
||||
"settings.member": "會員與登入",
|
||||
"settings.usageCard": "AI/搜尋額度",
|
||||
"settings.usageCardHint": "",
|
||||
"settings.usageCardHint": "平台代付點數與方案上限;自備 Key 不占平台點。",
|
||||
"settings.viewUsage": "查看用量",
|
||||
"settings.editProfile": "編輯會員資料",
|
||||
"settings.mockLogin": "demo@harbor.local / demo",
|
||||
"settings.memberHint": "",
|
||||
"settings.memberHint": "登入帳號、顯示名稱與通知偏好。",
|
||||
|
||||
"usage.title": "用量與方案",
|
||||
"usage.desc": "",
|
||||
"usage.desc": "查看本月平台點數、分項用量,並變更方案。",
|
||||
"usage.creditsUsed": "本月已用點數",
|
||||
"usage.remaining": "剩餘 {n} 點",
|
||||
"usage.percentUsed": "{n}% 已使用",
|
||||
|
|
@ -199,17 +202,23 @@ export const zhTW: MessageDict = {
|
|||
"usage.perMonth": "點/月",
|
||||
"usage.ledger": "最近使用紀錄",
|
||||
"usage.emptyTitle": "本月還沒有扣點",
|
||||
"usage.emptyDesc": "",
|
||||
"usage.planNote": "",
|
||||
"usage.emptyDesc": "開始創作、海巡或生圖後,這裡會出現扣點紀錄。",
|
||||
"usage.planNote": "自然月重置;未用完點數不累積至下月。",
|
||||
"usage.switched": "已切換為 {name}",
|
||||
"usage.meter.times": "{count} 次 · {credits} 點",
|
||||
"usage.meter.cap": "單項參考上限 {count}/{cap} 次",
|
||||
"usage.plan.free.blurb": "試用與個人輕量經營",
|
||||
"usage.meter.timesShort": "{n} 次",
|
||||
"usage.meter.pt": "點",
|
||||
"usage.meter.over": "已超出",
|
||||
"usage.meter.locked": "已鎖",
|
||||
"usage.meter.cap": "單項上限 {credits}/{cap} 點",
|
||||
"usage.side.aiCredits": "AI 相關已用點數(文案+研究+生圖)",
|
||||
"usage.side.searchCredits": "搜尋已用點數",
|
||||
"usage.plan.free.blurb": "夠用試用,體驗完整創作流程",
|
||||
"usage.plan.starter.blurb": "小團隊日常發文與海巡",
|
||||
"usage.plan.pro.blurb": "多帳、重度 AI 與研究",
|
||||
|
||||
"profile.title": "會員資料",
|
||||
"profile.desc": "",
|
||||
"profile.desc": "管理顯示名稱、頭像與密碼。",
|
||||
"profile.accountStatus": "帳號開通狀態",
|
||||
"profile.basic": "基本資料",
|
||||
"profile.avatar": "頭像",
|
||||
|
|
@ -315,7 +324,7 @@ export const zhTW: MessageDict = {
|
|||
"invite.err.codeSelf": "不能填自己的邀請碼",
|
||||
|
||||
"admin.users.title": "島民管理",
|
||||
"admin.users.desc": "",
|
||||
"admin.users.desc": "管理島民帳號、權限、方案與不擋額度。",
|
||||
"admin.users.needAdmin": "需要管理員權限",
|
||||
"admin.users.list": "島民列表 · {n}",
|
||||
"admin.users.detail": "島民詳情",
|
||||
|
|
@ -357,35 +366,47 @@ export const zhTW: MessageDict = {
|
|||
"crew.msg.refreshFail": "延長失敗(token 失效時請重新「連接帳號」)",
|
||||
"crew.msg.oauthOk": "Threads 帳號已連線;已排程約 30 天後自動延長 token(見任務)",
|
||||
"crew.msg.oauthFail": "OAuth 連線失敗",
|
||||
"crew.msg.oauthUrlFail": "無法取得授權網址,請稍後再試或檢查平台 Threads 設定",
|
||||
"crew.msg.deleted": "已移除 @{user}",
|
||||
"crew.confirmDelete": "確定刪除帳號 @{user}?\n刪除後無法再選為 lead / cast。",
|
||||
|
||||
"today.findTopic": "找話題",
|
||||
"today.refreshTopics": "刷新話題",
|
||||
"today.reload": "重新整理",
|
||||
"today.loadFail": "無法載入今日資料",
|
||||
"today.trendsFail": "無法刷新話題(可能額度不足或搜尋未設定)",
|
||||
"today.syncPosts": "同步已發文",
|
||||
"today.syncPostsFail": "同步貼文失敗",
|
||||
"today.needAccount": "請先連接 Threads 帳號",
|
||||
"today.pendingReplies": "待回覆",
|
||||
"today.pendingRepliesN": "待回覆 · {n}",
|
||||
"today.newThread": "開新串",
|
||||
"today.newThread": "寫一則",
|
||||
"today.metricsAria": "今日數值",
|
||||
"today.metric.pending": "待回",
|
||||
"today.metric.pendingHint": "海巡佇列",
|
||||
"today.metric.doneGoal": "已回/目標",
|
||||
"today.metric.doneGoalHint": "今日海巡完成數/目標(海巡標記已發會累加)",
|
||||
"today.metric.sentToday": "今日已發",
|
||||
"today.metric.running": "進行中 {n}",
|
||||
"today.metric.sentDone": "完成的發送",
|
||||
"today.metric.failed": "發送異常",
|
||||
"today.metric.needAction": "需處理",
|
||||
"today.metric.ok": "正常",
|
||||
"today.pending.title": "待回覆 · {n}",
|
||||
"today.pending.empty": "目前沒有待回",
|
||||
"today.metric.mentions": "提及",
|
||||
"today.metric.mentionsHint": "待回提及",
|
||||
"today.pending.title": "海巡待回 · {n}",
|
||||
"today.pending.empty": "目前沒有待回,去海巡掃一輪",
|
||||
"today.goScout": "去海巡",
|
||||
"today.pending.more": "還有 {n} 則 →",
|
||||
"today.pending.handle": "海巡處理",
|
||||
"today.pending.start": "開始處理",
|
||||
"today.topics.title": "找話題",
|
||||
"today.topics.empty": "還沒有話題",
|
||||
"today.goStudio": "去創作",
|
||||
"today.topics.empty": "還沒有話題,按「刷新話題」抓一輪",
|
||||
"today.goStudio": "去靈感",
|
||||
"today.heat": "熱度 {n}",
|
||||
"today.topicAngle": "可當開場角度",
|
||||
"today.moreInspire": "更多靈感",
|
||||
"today.useTopic": "用話題開串",
|
||||
"today.useTopic": "用話題發想",
|
||||
"today.outbox.title": "今日發送",
|
||||
"today.outbox.empty": "還沒有今日發送。可先",
|
||||
"today.outbox.emptyMid": ",完成後會出現在",
|
||||
|
|
@ -394,9 +415,10 @@ export const zhTW: MessageDict = {
|
|||
"today.badge.failed": "失敗",
|
||||
"today.badge.scheduling": "排程",
|
||||
"today.badge.sending": "發送中",
|
||||
"today.badge.drafted": "已草稿",
|
||||
"today.openOutbox": "開啟發送",
|
||||
"today.accounts.title": "帳號成效",
|
||||
"today.accounts.empty": "尚無成效",
|
||||
"today.accounts.empty": "尚無成效資料,可先同步已發文",
|
||||
"today.postsCount": "{n} 則貼文",
|
||||
"today.views": "瀏覽",
|
||||
"today.likes": "讚",
|
||||
|
|
@ -405,6 +427,7 @@ export const zhTW: MessageDict = {
|
|||
"today.viewPosts": "看已發文",
|
||||
"today.manageAccounts": "管理帳號",
|
||||
|
||||
|
||||
"outbox.title": "發送",
|
||||
"outbox.tabsAria": "發送分頁",
|
||||
"outbox.tab.active": "進行中",
|
||||
|
|
@ -706,7 +729,7 @@ export const zhTW: MessageDict = {
|
|||
"metrics.type.post": "貼文",
|
||||
|
||||
"jobs.title": "任務",
|
||||
"jobs.desc": "背景任務列表。含定期任務(例如 Threads Token 約每 30 天自動延長一次)。已完成/失敗/取消的任務約保留 2 天後自動清除,也可手動刪除。",
|
||||
"jobs.desc": "背景任務分三區:執行中、定期排程、歷史。每頁可調筆數,不會一次拉完全部。",
|
||||
"jobs.startDemo": "產生測試任務",
|
||||
"jobs.demoHint": "需 worker 執行;狀態會自動輪詢更新。",
|
||||
"jobs.demoLabel": "Demo 測試任務",
|
||||
|
|
@ -731,6 +754,14 @@ export const zhTW: MessageDict = {
|
|||
"jobs.status.cancel_requested": "取消中",
|
||||
"jobs.loadFail": "無法載入任務列表",
|
||||
"jobs.empty": "尚無任務",
|
||||
"jobs.empty.active": "目前沒有執行中或待領取的任務",
|
||||
"jobs.empty.recurring": "尚無定期/遠期排程任務",
|
||||
"jobs.empty.history": "尚無歷史紀錄(已完成/失敗/取消)",
|
||||
"jobs.tabsAria": "任務分類",
|
||||
"jobs.tab.active": "執行中",
|
||||
"jobs.tab.recurring": "定期任務",
|
||||
"jobs.tab.history": "歷史已執行",
|
||||
"jobs.recurringHint": "尚未到期的排程(例如 Token 約 30 天延長)。到期後會出現在「執行中」。",
|
||||
"jobs.total": "共 {n} 筆",
|
||||
"jobs.showing": " · 顯示前 {n}",
|
||||
"jobs.detail": "詳情",
|
||||
|
|
@ -765,45 +796,46 @@ export const zhTW: MessageDict = {
|
|||
"plan.cta.downgrade": "降級",
|
||||
"plan.cta.switch": "切換",
|
||||
|
||||
"plan.free.headline": "試用與個人輕量經營",
|
||||
"plan.free.bullet1": "每月 {n} 點 AI/搜尋",
|
||||
"plan.free.bullet2": "完整功能:創作、海巡、發送",
|
||||
"plan.free.bullet3": "適合先熟悉流程",
|
||||
"plan.free.bullet4": "可隨時升級",
|
||||
"plan.free.headline": "夠用試用,體驗完整流程",
|
||||
"plan.free.bullet1": "每月 {n} 點(約 2~3 週輕度日常)",
|
||||
"plan.free.bullet2": "完整功能:創作、海巡、發送、生圖",
|
||||
"plan.free.bullet3": "用得出價值再升級 Starter",
|
||||
"plan.free.bullet4": "平台忙碌時可改填自己的 Key",
|
||||
"plan.free.right1": "可使用完整功能:帳號、創作、海巡、發送、任務與靈感。",
|
||||
"plan.free.right2": "每月固定點數,用在文案、研究、搜尋與生圖。",
|
||||
"plan.free.right3": "達上限後需等下月或升級(除非管理員開不擋額度)。",
|
||||
"plan.free.right2": "點數夠你真實試跑文案、搜尋與幾次生圖,不是空殼 demo。",
|
||||
"plan.free.right3": "達上限後升級 Starter 繼續;或設定自備 Key(BYOK)不占平台點。",
|
||||
"plan.free.quota1": "每月配給 {n} 點。",
|
||||
"plan.free.quota2": "參考:文案 {copy}、研究 {research}、搜尋 {search}、生圖 {image} 次。",
|
||||
"plan.free.note1": "無需付款,確認即切換。",
|
||||
"plan.free.note2": "正式金流後的發票規則另訂。",
|
||||
|
||||
"plan.starter.headline": "小團隊日常發文與海巡",
|
||||
"plan.starter.bullet1": "每月 {n} 點(約 6× Free)",
|
||||
"plan.starter.bullet1": "每月 {n} 點(約 5× Free)",
|
||||
"plan.starter.bullet2": "穩定發文、回覆、海巡",
|
||||
"plan.starter.bullet3": "適合 1~3 人節奏",
|
||||
"plan.starter.bullet3": "適合 1~3 人節奏 · 付費主力",
|
||||
"plan.starter.bullet4": "付款成功立即生效",
|
||||
"plan.starter.right1": "付款成功後本帳改為 Starter,當月依新額度計算。",
|
||||
"plan.starter.right2": "點數支撐固定發文、回覆草稿與定期海巡。",
|
||||
"plan.starter.right3": "功能與 Free 相同,差在能用多久。",
|
||||
"plan.starter.right3": "功能與 Free 相同,差在能用多久;日常節奏建議由此開始。",
|
||||
"plan.starter.quota1": "每月 {n} 點 · {price}。",
|
||||
"plan.starter.quota2": "參考:文案 {copy}、研究 {research}、搜尋 {search}、生圖 {image} 次。",
|
||||
"plan.starter.note1": "需付款成功才變更方案。",
|
||||
"plan.starter.note2": "自然月重置,未用完點數不累積至下月。",
|
||||
|
||||
"plan.pro.headline": "多帳、重度 AI 與研究",
|
||||
"plan.pro.bullet1": "每月 {n} 點(約 4× Starter)",
|
||||
"plan.pro.bullet2": "高頻文案/研究/生圖",
|
||||
"plan.pro.bullet1": "每月 {n} 點(約 3× Starter)",
|
||||
"plan.pro.bullet2": "高頻文案/研究/生圖 · 重度天花板",
|
||||
"plan.pro.bullet3": "適合代理與多品牌",
|
||||
"plan.pro.bullet4": "付款成功立即生效",
|
||||
"plan.pro.right1": "付款成功後本帳改為 Pro,當月依 Pro 額度計算。",
|
||||
"plan.pro.right2": "適合多帳、大量回覆與深研究,減少中途額度見底。",
|
||||
"plan.pro.right3": "功能相同;買的是容量與節奏。",
|
||||
"plan.pro.right3": "功能相同;買的是容量。再高可改 BYOK,平台限流時也不中斷。",
|
||||
"plan.pro.quota1": "每月 {n} 點 · {price}。",
|
||||
"plan.pro.quota2": "參考:文案 {copy}、研究 {research}、搜尋 {search}、生圖 {image} 次。",
|
||||
"plan.pro.note1": "付款失敗不會改方案。",
|
||||
"plan.pro.note2": "付款完成後可於帳務紀錄查詢收據。",
|
||||
|
||||
"plan.quota2": "分項點數:文案 {copy}(約 {copyCalls} 次)、研究 {research}(約 {researchCalls} 次)、搜尋 {search}(約 {searchCalls} 次)、生圖 {image}(約 {imageCalls} 張)。",
|
||||
"plan.softCapsLine": "分項點數:文案 {copy} · 研究 {research} · 搜尋 {search} · 生圖 {image}",
|
||||
"plan.approxCallsLine": "約 {copyCalls} 次文案 · {researchCalls} 次研究 · {searchCalls} 次搜尋 · {imageCalls} 張圖",
|
||||
|
||||
"checkout.title": "確認方案",
|
||||
"checkout.pickFirst": "請先選擇方案。",
|
||||
"checkout.viewPlans": "看方案",
|
||||
|
|
@ -838,6 +870,7 @@ export const zhTW: MessageDict = {
|
|||
"usage.widget.monthUsage": "本月用量",
|
||||
"usage.widget.remaining": "還剩 {n} 點",
|
||||
"usage.widget.leftShort": "還剩 {n}",
|
||||
"usage.widget.usedOfCap": "{used}/{cap}",
|
||||
"usage.widget.overShort": "已超額",
|
||||
"usage.widget.upgradeShort": "升級",
|
||||
"usage.widget.upgrade": "升級方案",
|
||||
|
|
@ -850,7 +883,7 @@ export const zhTW: MessageDict = {
|
|||
"usage.meter.ai_research": "AI 研究",
|
||||
"usage.meter.web_search": "搜尋",
|
||||
"usage.meter.ai_image": "AI 生圖",
|
||||
"usage.meter.barAria": "{label} {count} 次 {credits} 點,上限 {cap}",
|
||||
"usage.meter.barAria": "{label} 已用 {credits} 點/上限 {cap} 點({count} 次)",
|
||||
"usage.ledger.costAria": "消耗 {n} 點",
|
||||
"usage.event.keyMode.platform": "平台點數",
|
||||
"usage.event.keyMode.byok": "自備 Key",
|
||||
|
|
@ -1168,6 +1201,24 @@ export const zhTW: MessageDict = {
|
|||
"insights.postStats": "瀏覽 {views} · 讚 {likes} · 回 {replies}",
|
||||
"insights.openThreads": "開啟 Threads",
|
||||
"insights.zeroPct": "0%",
|
||||
"insights.panelHint": "依「我的貼文」同步數據聚合本月成效與近月趨勢",
|
||||
"insights.lastSynced": "上次同步 {time}",
|
||||
"insights.neverSynced": "尚未同步",
|
||||
"insights.emptyTitle": "尚無貼文數據",
|
||||
"insights.emptyDesc": "先按「同步貼文」從 Threads 拉入你的貼文與成效,再看月比圖與分析。",
|
||||
"insights.syncDone": "已同步 {n} 則貼文,成效已更新",
|
||||
"insights.syncFail": "同步失敗",
|
||||
"insights.loadFail": "載入貼文失敗",
|
||||
"insights.zeroViewsHint": "已有貼文但瀏覽多為 0:可能 Insights 權限不足或尚未產出,可再按一次同步。",
|
||||
"insights.postsInMonth": "{n} 則",
|
||||
"insights.kpiForMonth": "{label} 指標",
|
||||
"insights.topPostsOfMonth": "{label} · 表現較佳貼文",
|
||||
"insights.noPostsInMonth": "{label} 尚無貼文",
|
||||
"insights.pastMonthEmptyHint": "該月沒有已同步的貼文(過去月份只會自動補抓一次)。可手動同步或改選其他月。",
|
||||
"insights.pastBackfillDone": "已補抓歷史貼文 {n} 則(過去月份只抓一次)",
|
||||
"insights.autoRefreshDone": "本月成效已更新({n} 則)",
|
||||
"insights.noDataNoAnalysis": "{label} 沒有貼文數據,不產生結論。",
|
||||
"insights.noAnalysisYet": "{label} 尚無可寫的結論(需有已同步貼文)。",
|
||||
|
||||
"plays.tabOwn": "我的貼文",
|
||||
"plays.tabLink": "Threads 連結",
|
||||
|
|
@ -1236,6 +1287,7 @@ export const zhTW: MessageDict = {
|
|||
"inspire.trendsLabel": "話題靈感",
|
||||
"inspire.trendsHint": "網搜彙整,非官方熱搜 · 找靈感會扣搜尋點數",
|
||||
"inspire.trendsSeed": "示意",
|
||||
"inspire.topicSeed": "想寫跟「{topic}」有關的 Threads,幫我發想開場與角度。",
|
||||
"inspire.trendsEmpty": "點「找靈感」才會搜尋(扣點)",
|
||||
"inspire.refreshConfirm": "找靈感會消耗 1 次「搜尋」點數,確定?",
|
||||
"inspire.refreshOk": "已更新 {n} 則話題靈感",
|
||||
|
|
@ -1521,6 +1573,11 @@ export const zhTW: MessageDict = {
|
|||
"usage.currentPlan": "目前方案",
|
||||
"usage.planMeta": "/月 · 每月 {n} 點額度",
|
||||
"usage.changePlan": "變更方案",
|
||||
"usage.upgradePlan": "升級方案",
|
||||
"usage.warn.unlimitedOver": "本月已用 {used} 點(方案 {cap},不擋額度,仍可繼續)。",
|
||||
"usage.warn.exhausted": "本月點數已用完。可升級方案或等待下月重置。",
|
||||
"usage.warn.high": "本月已用 {pct}% 點數。",
|
||||
"usage.warn.meterNear": "{label} 接近單項上限({credits}/{cap} 點)。",
|
||||
"usage.usedThisMonth": "本月已用",
|
||||
"usage.remainLabel": "剩餘",
|
||||
"usage.ledgerToggle": "使用紀錄",
|
||||
|
|
@ -1664,6 +1721,9 @@ export const en: MessageDict = {
|
|||
"api.err.404001": "Not found",
|
||||
"api.err.404002": "This email is not registered",
|
||||
"api.err.409001": "This email is already registered",
|
||||
"api.err.402001": "Monthly platform credits exhausted. Upgrade or wait for next month.",
|
||||
"usage.err.meterCap": "This feature hit its monthly credit cap. Try another feature or upgrade.",
|
||||
"usage.err.platformCapacity": "Platform AI/search is busy or rate-limited. Try again later, or add your own API key (BYOK) in Settings.",
|
||||
"api.err.500000": "Server error. Please try again later.",
|
||||
"api.err.timeoutAI": "AI timed out. Shorten structure notes, or pick a faster model in Settings",
|
||||
"api.err.501000": "This feature is not implemented yet",
|
||||
|
|
@ -1736,14 +1796,14 @@ export const en: MessageDict = {
|
|||
"settings.localeCurrency": "Language & currency",
|
||||
"settings.locale": "Interface language",
|
||||
"settings.currency": "Display currency",
|
||||
"settings.currencyHint": "",
|
||||
"settings.currencyHint": "Display only; plan billing stays in TWD.",
|
||||
"settings.localeSaved": "Language and currency updated",
|
||||
"settings.appearance": "Appearance",
|
||||
"settings.theme": "Theme",
|
||||
"settings.themeLight": "Light",
|
||||
"settings.themeDark": "Dark",
|
||||
"settings.themeSystem": "System",
|
||||
"settings.themeHint": "",
|
||||
"settings.themeHint": "Light, dark, or follow system appearance.",
|
||||
"settings.themeSaved": "Theme updated",
|
||||
"settings.themeToLight": "Switch to light",
|
||||
"settings.themeToDark": "Switch to dark",
|
||||
|
|
@ -1777,14 +1837,14 @@ export const en: MessageDict = {
|
|||
"settings.threadsGoCrew": "Open Crew to connect",
|
||||
"settings.member": "Account & sign-in",
|
||||
"settings.usageCard": "AI / search quota",
|
||||
"settings.usageCardHint": "",
|
||||
"settings.usageCardHint": "Platform credits and plan caps; BYOK does not use platform credits.",
|
||||
"settings.viewUsage": "View usage",
|
||||
"settings.editProfile": "Edit profile",
|
||||
"settings.mockLogin": "demo@harbor.local / demo",
|
||||
"settings.memberHint": "",
|
||||
"settings.memberHint": "Sign-in account, display name, and notification prefs.",
|
||||
|
||||
"usage.title": "Usage & plans",
|
||||
"usage.desc": "",
|
||||
"usage.desc": "See platform credits this month, per-feature usage, and change plans.",
|
||||
"usage.creditsUsed": "Credits used this month",
|
||||
"usage.remaining": "{n} left",
|
||||
"usage.percentUsed": "{n}% used",
|
||||
|
|
@ -1796,17 +1856,23 @@ export const en: MessageDict = {
|
|||
"usage.perMonth": "credits / mo",
|
||||
"usage.ledger": "Recent usage",
|
||||
"usage.emptyTitle": "No usage this month",
|
||||
"usage.emptyDesc": "",
|
||||
"usage.planNote": "",
|
||||
"usage.emptyDesc": "After you create, patrol, or generate images, usage events show up here.",
|
||||
"usage.planNote": "Resets each calendar month; unused credits do not roll over.",
|
||||
"usage.switched": "Switched to {name}",
|
||||
"usage.meter.times": "{count} runs · {credits} credits",
|
||||
"usage.meter.cap": "Soft cap {count}/{cap} runs",
|
||||
"usage.plan.free.blurb": "Trial and light personal use",
|
||||
"usage.meter.timesShort": "{n} runs",
|
||||
"usage.meter.pt": "pt",
|
||||
"usage.meter.over": "over",
|
||||
"usage.meter.locked": "locked",
|
||||
"usage.meter.cap": "Cap {credits}/{cap} credits",
|
||||
"usage.side.aiCredits": "AI credits used (copy + research + image)",
|
||||
"usage.side.searchCredits": "Search credits used",
|
||||
"usage.plan.free.blurb": "Enough to try the full creation flow",
|
||||
"usage.plan.starter.blurb": "Small teams posting and patrolling daily",
|
||||
"usage.plan.pro.blurb": "Multi-account, heavy AI and research",
|
||||
|
||||
"profile.title": "Profile",
|
||||
"profile.desc": "",
|
||||
"profile.desc": "Manage display name, avatar, and password.",
|
||||
"profile.accountStatus": "Account status",
|
||||
"profile.basic": "Basics",
|
||||
"profile.avatar": "Avatar",
|
||||
|
|
@ -1912,7 +1978,7 @@ export const en: MessageDict = {
|
|||
"invite.err.codeSelf": "You can’t use your own code",
|
||||
|
||||
"admin.users.title": "Islanders",
|
||||
"admin.users.desc": "",
|
||||
"admin.users.desc": "Manage islander accounts, roles, plans, and unlimited flags.",
|
||||
"admin.users.needAdmin": "Admin access required",
|
||||
"admin.users.list": "Islanders · {n}",
|
||||
"admin.users.detail": "Islander detail",
|
||||
|
|
@ -1954,35 +2020,47 @@ export const en: MessageDict = {
|
|||
"crew.msg.refreshFail": "Extend failed (reconnect if token is invalid)",
|
||||
"crew.msg.oauthOk": "Threads connected; token renew scheduled ~day 30 (see Jobs)",
|
||||
"crew.msg.oauthFail": "OAuth connection failed",
|
||||
"crew.msg.oauthUrlFail": "Could not get authorize URL. Try again or check platform Threads settings.",
|
||||
"crew.msg.deleted": "Removed @{user}",
|
||||
"crew.confirmDelete": "Delete account @{user}?\nIt can no longer be used as lead / cast.",
|
||||
|
||||
"today.findTopic": "Find topics",
|
||||
"today.refreshTopics": "Refresh topics",
|
||||
"today.reload": "Reload",
|
||||
"today.loadFail": "Could not load Today",
|
||||
"today.trendsFail": "Could not refresh topics (quota or search not set)",
|
||||
"today.syncPosts": "Sync own posts",
|
||||
"today.syncPostsFail": "Could not sync posts",
|
||||
"today.needAccount": "Connect a Threads account first",
|
||||
"today.pendingReplies": "Pending replies",
|
||||
"today.pendingRepliesN": "Pending · {n}",
|
||||
"today.newThread": "New thread",
|
||||
"today.newThread": "Compose",
|
||||
"today.metricsAria": "Today metrics",
|
||||
"today.metric.pending": "Pending",
|
||||
"today.metric.pendingHint": "Patrol queue",
|
||||
"today.metric.doneGoal": "Done / goal",
|
||||
"today.metric.doneGoalHint": "Patrol completions today / goal (mark published counts)",
|
||||
"today.metric.sentToday": "Sent today",
|
||||
"today.metric.running": "{n} in progress",
|
||||
"today.metric.sentDone": "Completed sends",
|
||||
"today.metric.failed": "Send issues",
|
||||
"today.metric.needAction": "Needs action",
|
||||
"today.metric.ok": "All good",
|
||||
"today.pending.title": "Pending replies · {n}",
|
||||
"today.pending.empty": "Nothing pending",
|
||||
"today.metric.mentions": "Mentions",
|
||||
"today.metric.mentionsHint": "Pending mentions",
|
||||
"today.pending.title": "Patrol pending · {n}",
|
||||
"today.pending.empty": "Nothing pending — run a patrol",
|
||||
"today.goScout": "Go patrol",
|
||||
"today.pending.more": "{n} more →",
|
||||
"today.pending.handle": "Handle in patrol",
|
||||
"today.pending.start": "Start handling",
|
||||
"today.topics.title": "Find topics",
|
||||
"today.topics.empty": "No topics yet",
|
||||
"today.goStudio": "Go to Studio",
|
||||
"today.topics.empty": "No topics yet — tap Refresh topics",
|
||||
"today.goStudio": "Open inspire",
|
||||
"today.heat": "Heat {n}",
|
||||
"today.topicAngle": "Good opening angle",
|
||||
"today.moreInspire": "More inspiration",
|
||||
"today.useTopic": "Start from topic",
|
||||
"today.useTopic": "Brainstorm topic",
|
||||
"today.outbox.title": "Today's outbox",
|
||||
"today.outbox.empty": "No sends today. Try",
|
||||
"today.outbox.emptyMid": ", then check",
|
||||
|
|
@ -1991,9 +2069,10 @@ export const en: MessageDict = {
|
|||
"today.badge.failed": "Failed",
|
||||
"today.badge.scheduling": "Scheduled",
|
||||
"today.badge.sending": "Sending",
|
||||
"today.badge.drafted": "Drafted",
|
||||
"today.openOutbox": "Open outbox",
|
||||
"today.accounts.title": "Account performance",
|
||||
"today.accounts.empty": "No stats yet",
|
||||
"today.accounts.empty": "No stats yet — sync own posts",
|
||||
"today.postsCount": "{n} posts",
|
||||
"today.views": "Views",
|
||||
"today.likes": "Likes",
|
||||
|
|
@ -2002,6 +2081,7 @@ export const en: MessageDict = {
|
|||
"today.viewPosts": "View posts",
|
||||
"today.manageAccounts": "Manage accounts",
|
||||
|
||||
|
||||
"outbox.title": "Outbox",
|
||||
"outbox.tabsAria": "Outbox tabs",
|
||||
"outbox.tab.active": "Active",
|
||||
|
|
@ -2303,7 +2383,7 @@ export const en: MessageDict = {
|
|||
"metrics.type.post": "Post",
|
||||
|
||||
"jobs.title": "Jobs",
|
||||
"jobs.desc": "Background jobs, including recurring ones (e.g. Threads token renew ~every 30 days). Finished / failed / cancelled jobs are auto-removed after about 2 days; you can also delete them manually.",
|
||||
"jobs.desc": "Jobs in three tabs: active, scheduled, and history. Paginated so we never load everything at once.",
|
||||
"jobs.startDemo": "Create test job",
|
||||
"jobs.demoHint": "Requires worker; list auto-refreshes while jobs are active.",
|
||||
"jobs.demoLabel": "Demo test job",
|
||||
|
|
@ -2328,6 +2408,14 @@ export const en: MessageDict = {
|
|||
"jobs.status.cancel_requested": "Cancel requested",
|
||||
"jobs.loadFail": "Could not load jobs",
|
||||
"jobs.empty": "No jobs yet",
|
||||
"jobs.empty.active": "No running or ready-to-claim jobs",
|
||||
"jobs.empty.recurring": "No scheduled / recurring jobs yet",
|
||||
"jobs.empty.history": "No history yet (succeeded / failed / cancelled)",
|
||||
"jobs.tabsAria": "Job categories",
|
||||
"jobs.tab.active": "Active",
|
||||
"jobs.tab.recurring": "Scheduled",
|
||||
"jobs.tab.history": "History",
|
||||
"jobs.recurringHint": "Future schedules (e.g. token renew in ~30 days). When due, they appear under Active.",
|
||||
"jobs.total": "{n} total",
|
||||
"jobs.showing": " · showing first {n}",
|
||||
"jobs.detail": "Details",
|
||||
|
|
@ -2362,45 +2450,46 @@ export const en: MessageDict = {
|
|||
"plan.cta.downgrade": "Downgrade",
|
||||
"plan.cta.switch": "Switch",
|
||||
|
||||
"plan.free.headline": "Trial and light personal use",
|
||||
"plan.free.bullet1": "{n} AI/search credits per month",
|
||||
"plan.free.bullet2": "Full product: Studio, Patrol, Outbox",
|
||||
"plan.free.bullet3": "Good for learning the flow",
|
||||
"plan.free.bullet4": "Upgrade anytime",
|
||||
"plan.free.headline": "Enough to try the full product",
|
||||
"plan.free.bullet1": "{n} credits / month (~2–3 weeks light use)",
|
||||
"plan.free.bullet2": "Full product: Studio, Patrol, Outbox, images",
|
||||
"plan.free.bullet3": "Feel the value, then upgrade to Starter",
|
||||
"plan.free.bullet4": "When platform is busy, add your own key",
|
||||
"plan.free.right1": "Full access to accounts, Studio, Patrol, Outbox, jobs, and inspiration.",
|
||||
"plan.free.right2": "Fixed monthly credits for copy, research, search, and images.",
|
||||
"plan.free.right3": "After the cap, wait for next month or upgrade (unless an admin turns off limits).",
|
||||
"plan.free.right2": "Enough credits for real copy, search, and a few images—not a hollow demo.",
|
||||
"plan.free.right3": "After the cap, upgrade to Starter—or set BYOK so you don't use platform credits.",
|
||||
"plan.free.quota1": "{n} credits allocated each month.",
|
||||
"plan.free.quota2": "Reference: copy {copy}, research {research}, search {search}, image {image} runs.",
|
||||
"plan.free.note1": "No payment required; confirms immediately.",
|
||||
"plan.free.note2": "Invoice rules will be defined when billing goes live.",
|
||||
|
||||
"plan.starter.headline": "Small teams posting and patrolling daily",
|
||||
"plan.starter.bullet1": "{n} credits / month (about 6× Free)",
|
||||
"plan.starter.bullet1": "{n} credits / month (about 5× Free)",
|
||||
"plan.starter.bullet2": "Steady posting, replies, and patrol",
|
||||
"plan.starter.bullet3": "Fits a 1–3 person cadence",
|
||||
"plan.starter.bullet3": "Fits a 1–3 person cadence · main paid tier",
|
||||
"plan.starter.bullet4": "Takes effect right after payment",
|
||||
"plan.starter.right1": "After payment this account becomes Starter; the month uses the new quota.",
|
||||
"plan.starter.right2": "Credits support regular posts, reply drafts, and scheduled patrol.",
|
||||
"plan.starter.right3": "Same features as Free; you buy more headroom.",
|
||||
"plan.starter.right3": "Same features as Free; you buy more headroom. Best starting paid plan.",
|
||||
"plan.starter.quota1": "{n} credits / month · {price}.",
|
||||
"plan.starter.quota2": "Reference: copy {copy}, research {research}, search {search}, image {image} runs.",
|
||||
"plan.starter.note1": "Plan changes only after successful payment.",
|
||||
"plan.starter.note2": "Resets on calendar month; unused credits do not roll over.",
|
||||
|
||||
"plan.pro.headline": "Multi-account, heavy AI and research",
|
||||
"plan.pro.bullet1": "{n} credits / month (about 4× Starter)",
|
||||
"plan.pro.bullet2": "High-volume copy, research, and images",
|
||||
"plan.pro.bullet1": "{n} credits / month (about 3× Starter)",
|
||||
"plan.pro.bullet2": "High-volume copy, research, and images · heavy ceiling",
|
||||
"plan.pro.bullet3": "Built for agencies and multi-brand work",
|
||||
"plan.pro.bullet4": "Takes effect right after payment",
|
||||
"plan.pro.right1": "After payment this account becomes Pro; the month uses Pro quota.",
|
||||
"plan.pro.right2": "Fits multi-account replies and deep research with less risk of running out mid-month.",
|
||||
"plan.pro.right3": "Same features; you buy capacity and pace.",
|
||||
"plan.pro.right3": "Same features; you buy capacity. Beyond this, use BYOK—still works when platform is limited.",
|
||||
"plan.pro.quota1": "{n} credits / month · {price}.",
|
||||
"plan.pro.quota2": "Reference: copy {copy}, research {research}, search {search}, image {image} runs.",
|
||||
"plan.pro.note1": "Failed payment does not change your plan.",
|
||||
"plan.pro.note2": "After payment you can find receipts in billing history.",
|
||||
|
||||
"plan.quota2": "Per-feature credits: copy {copy} (~{copyCalls} uses), research {research} (~{researchCalls}), search {search} (~{searchCalls}), image {image} (~{imageCalls} images).",
|
||||
"plan.softCapsLine": "Credits: copy {copy} · research {research} · search {search} · image {image}",
|
||||
"plan.approxCallsLine": "About {copyCalls} copy · {researchCalls} research · {searchCalls} search · {imageCalls} images",
|
||||
|
||||
"checkout.title": "Confirm plan",
|
||||
"checkout.pickFirst": "Please choose a plan first.",
|
||||
"checkout.viewPlans": "View plans",
|
||||
|
|
@ -2435,6 +2524,7 @@ export const en: MessageDict = {
|
|||
"usage.widget.monthUsage": "This month",
|
||||
"usage.widget.remaining": "{n} credits left",
|
||||
"usage.widget.leftShort": "{n} left",
|
||||
"usage.widget.usedOfCap": "{used}/{cap}",
|
||||
"usage.widget.overShort": "Over",
|
||||
"usage.widget.upgradeShort": "Upgrade",
|
||||
"usage.widget.upgrade": "Upgrade",
|
||||
|
|
@ -2447,7 +2537,7 @@ export const en: MessageDict = {
|
|||
"usage.meter.ai_research": "AI research",
|
||||
"usage.meter.web_search": "Search",
|
||||
"usage.meter.ai_image": "AI image",
|
||||
"usage.meter.barAria": "{label} {count} runs, {credits} credits, cap {cap}",
|
||||
"usage.meter.barAria": "{label} {credits}/{cap} credits ({count} runs)",
|
||||
"usage.ledger.costAria": "Used {n} credits",
|
||||
"usage.event.keyMode.platform": "Platform credits",
|
||||
"usage.event.keyMode.byok": "Your own key",
|
||||
|
|
@ -2765,6 +2855,24 @@ export const en: MessageDict = {
|
|||
"insights.postStats": "Views {views} · likes {likes} · replies {replies}",
|
||||
"insights.openThreads": "Open Threads",
|
||||
"insights.zeroPct": "0%",
|
||||
"insights.panelHint": "Aggregates metrics from synced “My posts”",
|
||||
"insights.lastSynced": "Last sync {time}",
|
||||
"insights.neverSynced": "Not synced yet",
|
||||
"insights.emptyTitle": "No post data yet",
|
||||
"insights.emptyDesc": "Tap “Sync posts” to pull your Threads posts and insights, then review charts and analysis.",
|
||||
"insights.syncDone": "Synced {n} posts · insights updated",
|
||||
"insights.syncFail": "Sync failed",
|
||||
"insights.loadFail": "Failed to load posts",
|
||||
"insights.zeroViewsHint": "Posts found but views are mostly 0 — Insights scope may be missing, or stats not ready. Try sync again.",
|
||||
"insights.postsInMonth": "{n} posts",
|
||||
"insights.kpiForMonth": "{label} metrics",
|
||||
"insights.topPostsOfMonth": "{label} · top posts",
|
||||
"insights.noPostsInMonth": "No posts in {label}",
|
||||
"insights.pastMonthEmptyHint": "No synced posts for this month (past months are fetched once). Sync manually or pick another month.",
|
||||
"insights.pastBackfillDone": "Backfilled {n} posts (past months fetch once)",
|
||||
"insights.autoRefreshDone": "Current month refreshed ({n} posts)",
|
||||
"insights.noDataNoAnalysis": "No posts in {label} — no analysis.",
|
||||
"insights.noAnalysisYet": "No analysis for {label} yet (need synced posts).",
|
||||
|
||||
"plays.tabOwn": "My posts",
|
||||
"plays.tabLink": "Threads link",
|
||||
|
|
@ -2833,6 +2941,7 @@ export const en: MessageDict = {
|
|||
"inspire.trendsLabel": "Topic ideas",
|
||||
"inspire.trendsHint": "Web-sourced ideas · costs a search credit",
|
||||
"inspire.trendsSeed": "sample",
|
||||
"inspire.topicSeed": "I want a Threads post about “{topic}”. Help me with openings and angles.",
|
||||
"inspire.trendsEmpty": "Tap “Find ideas” to search (uses credits)",
|
||||
"inspire.refreshConfirm": "Finding ideas costs 1 search credit. Continue?",
|
||||
"inspire.refreshOk": "Updated {n} topic ideas",
|
||||
|
|
@ -3118,6 +3227,11 @@ export const en: MessageDict = {
|
|||
"usage.currentPlan": "Current plan",
|
||||
"usage.planMeta": "/ mo · {n} credits monthly",
|
||||
"usage.changePlan": "Change plan",
|
||||
"usage.upgradePlan": "Upgrade plan",
|
||||
"usage.warn.unlimitedOver": "Used {used} credits this month (plan {cap}; limits off — you can continue).",
|
||||
"usage.warn.exhausted": "Monthly credits are used up. Upgrade or wait for next month.",
|
||||
"usage.warn.high": "You've used {pct}% of monthly credits.",
|
||||
"usage.warn.meterNear": "{label} is near its cap ({credits}/{cap} credits).",
|
||||
"usage.usedThisMonth": "Used this month",
|
||||
"usage.remainLabel": "Remaining",
|
||||
"usage.ledgerToggle": "Usage log",
|
||||
|
|
|
|||
|
|
@ -58,37 +58,19 @@ export function resolveInspireContext(
|
|||
return { roles, snippets, trends, persona, brand, labels };
|
||||
}
|
||||
|
||||
function toneLead(ctx: ResolvedInspireContext): string {
|
||||
if (ctx.roles.some((r) => /朋友|私訊/.test(r))) return "懂你…";
|
||||
if (ctx.roles.some((r) => /鉤子|短句/.test(r))) return "先講結論:";
|
||||
if (ctx.roles.some((r) => /真實|反業配/.test(r))) return "講實話——";
|
||||
if (ctx.persona?.style?.draft?.hooks) {
|
||||
return ctx.persona.style.draft.hooks.slice(0, 24);
|
||||
}
|
||||
if (ctx.persona?.name) return `以「${ctx.persona.name}」的口吻:`;
|
||||
return "";
|
||||
}
|
||||
|
||||
/** mock 產文:依素材重寫即可,不 fortify 固定開頭 */
|
||||
function buildDraftBody(userText: string, ctx: ResolvedInspireContext): string {
|
||||
const topic =
|
||||
userText.trim() ||
|
||||
ctx.trends[0] ||
|
||||
ctx.brand?.display_name ||
|
||||
"日常小事";
|
||||
const lead = toneLead(ctx);
|
||||
const lines: string[] = [];
|
||||
|
||||
if (lead) lines.push(lead);
|
||||
|
||||
if (ctx.trends.length) {
|
||||
lines.push(`最近大家在聊 ${ctx.trends[0]!.replace(/^主題:/, "").slice(0, 40)},`);
|
||||
}
|
||||
|
||||
const personaBit =
|
||||
ctx.persona?.brief ||
|
||||
ctx.persona?.style?.draft?.tone ||
|
||||
ctx.persona?.voice ||
|
||||
"";
|
||||
const brandBit = ctx.brand
|
||||
? `(想到 ${ctx.brand.display_name}${ctx.brand.brief ? `:${ctx.brand.brief.slice(0, 40)}` : ""})`
|
||||
: "";
|
||||
|
|
@ -96,11 +78,13 @@ function buildDraftBody(userText: string, ctx: ResolvedInspireContext): string {
|
|||
if (/改短|短一點|精簡/.test(userText)) {
|
||||
lines.push(`${topic.slice(0, 60)}——有人也卡在這嗎?`);
|
||||
} else if (/更口語|隨便|碎念/.test(userText)) {
|
||||
lines.push(`欸所以 ${topic.slice(0, 50)} 這件事,我真的想問你們怎麼處理的。`);
|
||||
lines.push(`所以 ${topic.slice(0, 50)} 這件事,我真的想問你們怎麼處理的。`);
|
||||
} else {
|
||||
lines.push(
|
||||
`${topic.slice(0, 80)}${personaBit ? `。${personaBit.slice(0, 48)}` : ""}${brandBit}`,
|
||||
);
|
||||
if (!ctx.trends.length) {
|
||||
lines.push(`${topic.slice(0, 80)}${brandBit}`);
|
||||
} else if (brandBit) {
|
||||
lines.push(brandBit);
|
||||
}
|
||||
lines.push("我自己目前比較在意「實際用起來」而不是包裝怎麼寫。");
|
||||
}
|
||||
|
||||
|
|
@ -111,11 +95,6 @@ function buildDraftBody(userText: string, ctx: ResolvedInspireContext): string {
|
|||
lines.push("你們最近有類似經驗嗎?");
|
||||
}
|
||||
|
||||
if (ctx.snippets.some((s) => /誇大|療效|禁止/.test(s))) {
|
||||
// 語氣收斂:不另加誇大句
|
||||
}
|
||||
|
||||
// 短貼感
|
||||
let body = lines.filter(Boolean).join("\n");
|
||||
if (ctx.snippets.some((s) => /短貼|180|280/.test(s)) && body.length > 200) {
|
||||
body = body.slice(0, 180) + "…";
|
||||
|
|
|
|||
|
|
@ -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 promise(singleflight) */
|
||||
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 API(false = 走 list 或共用 in-flight 已完成後的讀取) */
|
||||
fetched: boolean;
|
||||
/** 因 TTL/past-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);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { PLANS, type PlanId } from "./usageMeter";
|
||||
import { DEFAULT_CREDIT_COST, PLANS, type PlanId } from "./usageMeter";
|
||||
|
||||
export type PlanRightsCopy = {
|
||||
headline: string;
|
||||
|
|
@ -9,19 +9,45 @@ export type PlanRightsCopy = {
|
|||
/** 結帳頁:額度 */
|
||||
quota: string[];
|
||||
notes: string[];
|
||||
/** 比價卡/頂欄短行:分項點數 + 約當次數 */
|
||||
softCapsLine: string;
|
||||
/** 約當次數短行(行銷可讀) */
|
||||
approxCallsLine: string;
|
||||
};
|
||||
|
||||
type TFn = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
/** 購買/比價用文案(用量首頁不堆字)— 由 i18n keys 組成 */
|
||||
export function getPlanRights(id: PlanId, t: TFn): PlanRightsCopy {
|
||||
/** soft_caps 是點數;約當次數 = floor(點 / 單次扣點) */
|
||||
export function planCapParams(id: PlanId, priceLabel?: string) {
|
||||
const p = PLANS[id];
|
||||
const caps = {
|
||||
copy: p.soft_caps.ai_copy,
|
||||
research: p.soft_caps.ai_research,
|
||||
search: p.soft_caps.web_search,
|
||||
image: p.soft_caps.ai_image,
|
||||
const copy = p.soft_caps.ai_copy;
|
||||
const research = p.soft_caps.ai_research;
|
||||
const search = p.soft_caps.web_search;
|
||||
const image = p.soft_caps.ai_image;
|
||||
return {
|
||||
n: p.monthly_credits,
|
||||
price: priceLabel ?? p.price_label,
|
||||
copy,
|
||||
research,
|
||||
search,
|
||||
image,
|
||||
copyCalls: Math.floor(copy / DEFAULT_CREDIT_COST.ai_copy),
|
||||
researchCalls: Math.floor(research / DEFAULT_CREDIT_COST.ai_research),
|
||||
searchCalls: Math.floor(search / DEFAULT_CREDIT_COST.web_search),
|
||||
imageCalls: Math.floor(image / DEFAULT_CREDIT_COST.ai_image),
|
||||
};
|
||||
}
|
||||
|
||||
/** 購買/比價用文案(用量首頁不堆字)— 由 i18n keys 組成 */
|
||||
export function getPlanRights(
|
||||
id: PlanId,
|
||||
t: TFn,
|
||||
/** 語系化價格;未傳則用 PLANS.price_label(固定 NT$…/月) */
|
||||
formatPrice?: (amountTwd: number) => string,
|
||||
): PlanRightsCopy {
|
||||
const p = PLANS[id];
|
||||
const price = formatPrice ? formatPrice(p.price_twd) : p.price_label;
|
||||
const caps = planCapParams(id, price);
|
||||
return {
|
||||
headline: t(`plan.${id}.headline`),
|
||||
bullets: [
|
||||
|
|
@ -36,10 +62,12 @@ export function getPlanRights(id: PlanId, t: TFn): PlanRightsCopy {
|
|||
t(`plan.${id}.right3`),
|
||||
],
|
||||
quota: [
|
||||
t(`plan.${id}.quota1`, { n: p.monthly_credits, price: p.price_label }),
|
||||
t(`plan.${id}.quota2`, caps),
|
||||
t(`plan.${id}.quota1`, { n: p.monthly_credits, price }),
|
||||
t("plan.quota2", caps),
|
||||
],
|
||||
notes: [t(`plan.${id}.note1`), t(`plan.${id}.note2`)],
|
||||
softCapsLine: t("plan.softCapsLine", caps),
|
||||
approxCallsLine: t("plan.approxCallsLine", caps),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,12 @@ export type PlanDef = {
|
|||
price_twd: number;
|
||||
blurb: string;
|
||||
blurb_key: string;
|
||||
/** 本月 platform 總點數配給 */
|
||||
monthly_credits: number;
|
||||
/**
|
||||
* 各分項點數參考上限(單位=點,不是次數)。
|
||||
* 加總應等於 monthly_credits。
|
||||
*/
|
||||
soft_caps: Record<UsageMeter, number>;
|
||||
};
|
||||
|
||||
|
|
@ -77,21 +82,41 @@ export const METER_META: Record<
|
|||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 方案配額(2026-07 · 組合毛利設計)
|
||||
*
|
||||
* 產品漏斗:
|
||||
* - Free:夠用、真能體驗完整流程 → 轉 Starter
|
||||
* - Starter:日常付費主力
|
||||
* - Pro:重度/多帳
|
||||
* - 平台 key 限流或無容量時:僅 BYOK 可用(不扣平台點)
|
||||
*
|
||||
* 單位成本(platform,USD):
|
||||
* - 文案 ~$0.004–0.008/次 · 搜尋 Exa ~$0.007 · 研究 ~$0.015 · 生圖 ~$0.02–0.04
|
||||
* - 安全單價 **$0.012/點**;TWD≈32 → Starter $18.4 / Pro $62.2
|
||||
*
|
||||
* 組合毛利(目標 ≥50%):Free 可虧一點,付費扛回。
|
||||
* 例 5 Free 滿用 + 2 Starter 滿用 + 1 Pro 滿用:
|
||||
* COGS ≈ 5×$1.44 + 2×$7.2 + $24 = $45.6 · 營收 $99 → 毛利 ~54%
|
||||
*
|
||||
* soft_caps = 分項點數硬上限,加總 = monthly_credits。
|
||||
*/
|
||||
export const PLANS: Record<PlanId, PlanDef> = {
|
||||
free: {
|
||||
id: "free",
|
||||
name: "Free",
|
||||
price_label: "NT$0/月",
|
||||
price_twd: 0,
|
||||
blurb: "試用與個人輕量經營",
|
||||
blurb: "夠用試用,體驗完整創作流程",
|
||||
blurb_key: "usage.plan.free.blurb",
|
||||
monthly_credits: 80,
|
||||
// 獲客可虧:滿用 ~$1.44;約 2~3 週輕度日常,用得出價值再升級
|
||||
monthly_credits: 120,
|
||||
soft_caps: {
|
||||
ai_copy: 40,
|
||||
ai_research: 8,
|
||||
web_search: 30,
|
||||
ai_image: 3,
|
||||
},
|
||||
ai_copy: 60, // 60 次文案/回覆
|
||||
ai_research: 15, // 5 次研究
|
||||
web_search: 30, // 30 次搜尋
|
||||
ai_image: 15, // 3 張生圖
|
||||
}, // sum = 120
|
||||
},
|
||||
starter: {
|
||||
id: "starter",
|
||||
|
|
@ -100,13 +125,15 @@ export const PLANS: Record<PlanId, PlanDef> = {
|
|||
price_twd: 590,
|
||||
blurb: "小團隊日常發文與海巡",
|
||||
blurb_key: "usage.plan.starter.blurb",
|
||||
monthly_credits: 500,
|
||||
// 滿用 COGS ≤ $7.2 → 單方案毛利 ~61%;轉付費主力
|
||||
// 約當:文案 300 · 研究 30 · 搜尋 150 · 生圖 12
|
||||
monthly_credits: 600,
|
||||
soft_caps: {
|
||||
ai_copy: 250,
|
||||
ai_research: 60,
|
||||
web_search: 200,
|
||||
ai_image: 20,
|
||||
},
|
||||
ai_copy: 300,
|
||||
ai_research: 90,
|
||||
web_search: 150,
|
||||
ai_image: 60,
|
||||
}, // sum = 600
|
||||
},
|
||||
pro: {
|
||||
id: "pro",
|
||||
|
|
@ -115,16 +142,19 @@ export const PLANS: Record<PlanId, PlanDef> = {
|
|||
price_twd: 1990,
|
||||
blurb: "多帳、重度 AI 與研究",
|
||||
blurb_key: "usage.plan.pro.blurb",
|
||||
// 滿用 COGS ≤ $24 → 單方案毛利 ~61%;重度天花板
|
||||
// 約當:文案 1000 · 研究 100 · 搜尋 500 · 生圖 40
|
||||
monthly_credits: 2000,
|
||||
soft_caps: {
|
||||
ai_copy: 1000,
|
||||
ai_research: 250,
|
||||
web_search: 800,
|
||||
ai_image: 80,
|
||||
},
|
||||
ai_research: 300,
|
||||
web_search: 500,
|
||||
ai_image: 200,
|
||||
}, // sum = 2000
|
||||
},
|
||||
};
|
||||
|
||||
/** 記帳點數/次(platform);與後端 DefaultCreditCost 對齊 */
|
||||
export const DEFAULT_CREDIT_COST: Record<UsageMeter, number> = {
|
||||
ai_copy: 1,
|
||||
ai_research: 3,
|
||||
|
|
@ -137,10 +167,13 @@ export type UsageMonthSummary = {
|
|||
uid: string;
|
||||
plan: PlanDef;
|
||||
unlimited: boolean;
|
||||
/** 方案點數(僅 platform)— 進度條用 */
|
||||
/**
|
||||
* 本月已用 platform 點數(不是方案總額)。
|
||||
* 方案配給請用 plan.monthly_credits 或 platform.credits_total。
|
||||
*/
|
||||
total_credits: number;
|
||||
remaining_credits: number;
|
||||
/** 無限時為 0;有上限才算用掉比例(platform only) */
|
||||
/** 已用 / 方案配給 百分比(platform only) */
|
||||
pct: number;
|
||||
/** @deprecated 平台 AI 次數;請用 platform / byok 分欄 */
|
||||
ai_calls: number;
|
||||
|
|
@ -417,8 +450,9 @@ function aggregateEvents(
|
|||
}
|
||||
for (const m of Object.keys(by_meter) as UsageMeter[]) {
|
||||
const row = by_meter[m];
|
||||
// soft_cap 是點數上限,進度用已用點數
|
||||
row.pct =
|
||||
row.soft_cap > 0 ? Math.min(999, Math.round((row.count / row.soft_cap) * 100)) : 0;
|
||||
row.soft_cap > 0 ? Math.min(999, Math.round((row.credits / row.soft_cap) * 100)) : 0;
|
||||
}
|
||||
const remaining = Math.max(0, plan.monthly_credits - platformCredits);
|
||||
const pct =
|
||||
|
|
@ -426,15 +460,17 @@ function aggregateEvents(
|
|||
? Math.min(999, Math.round((platformCredits / plan.monthly_credits) * 100))
|
||||
: 0;
|
||||
|
||||
// 狀態列用「點數」加總,與方案配給同一單位
|
||||
let ai_calls = 0;
|
||||
let search_calls = 0;
|
||||
for (const m of Object.keys(METER_META) as UsageMeter[]) {
|
||||
if (METER_META[m].group === "ai") ai_calls += by_meter[m].count;
|
||||
else search_calls += by_meter[m].count;
|
||||
if (METER_META[m].group === "ai") ai_calls += by_meter[m].credits;
|
||||
else search_calls += by_meter[m].credits;
|
||||
}
|
||||
|
||||
return {
|
||||
total_credits: plan.monthly_credits,
|
||||
// 與 UI/widget 一致:total_credits = 已用
|
||||
total_credits: platformCredits,
|
||||
remaining_credits: remaining,
|
||||
pct,
|
||||
by_meter,
|
||||
|
|
@ -850,24 +886,52 @@ export function summarizeTenantUsage(
|
|||
};
|
||||
}
|
||||
|
||||
export function usageWarning(summary: UsageMonthSummary): string | null {
|
||||
type WarnTFn = (key: string, params?: Record<string, 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.total_credits > summary.plan.monthly_credits) {
|
||||
return `本月已用 ${summary.total_credits} 點(方案 ${summary.plan.monthly_credits},不擋額度,仍可繼續)。`;
|
||||
if (used > cap) {
|
||||
return tr("usage.warn.unlimitedOver", { used, cap });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (summary.pct >= 100) {
|
||||
return "本月點數已用完。可升級方案或等待下月重置。";
|
||||
if (pct >= 100) {
|
||||
return tr("usage.warn.exhausted");
|
||||
}
|
||||
if (summary.pct >= 80) {
|
||||
return `本月已用 ${summary.pct}% 點數。`;
|
||||
if (pct >= 80) {
|
||||
return tr("usage.warn.high", { pct });
|
||||
}
|
||||
for (const m of Object.keys(METER_META) as UsageMeter[]) {
|
||||
const row = summary.by_meter[m];
|
||||
if (row.pct >= 90) {
|
||||
return `${METER_META[m].label} 接近單項上限(${row.count}/${row.soft_cap} 次)。`;
|
||||
return tr("usage.warn.meterNear", {
|
||||
label: t ? t(`usage.meter.${m}`) : METER_META[m].label,
|
||||
credits: row.credits,
|
||||
cap: row.soft_cap,
|
||||
});
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -107,11 +107,11 @@ export function CrewPage() {
|
|||
const oauth = q.get("oauth");
|
||||
if (!oauth) return;
|
||||
if (oauth === "ok") {
|
||||
setMessage(t("crew.msg.oauthOk") || "Threads 帳號已連線");
|
||||
setMessage(t("crew.msg.oauthOk"));
|
||||
void load();
|
||||
refresh();
|
||||
} else {
|
||||
setMessage(q.get("msg") || t("crew.msg.oauthFail") || "OAuth 失敗");
|
||||
setMessage(q.get("msg") || t("crew.msg.oauthFail"));
|
||||
}
|
||||
// 清 query,避免刷新重複提示
|
||||
const url = new URL(window.location.href);
|
||||
|
|
@ -129,7 +129,7 @@ export function CrewPage() {
|
|||
window.location.href = authorize_url;
|
||||
return;
|
||||
}
|
||||
throw new Error(t("crew.msg.oauthFail") || "OAuth 無法取得授權網址");
|
||||
throw new Error(t("crew.msg.oauthUrlFail"));
|
||||
} catch (e) {
|
||||
setMessage(e instanceof Error ? e.message : t("crew.msg.refreshFail"));
|
||||
} finally {
|
||||
|
|
@ -145,7 +145,7 @@ export function CrewPage() {
|
|||
setMessage("");
|
||||
try {
|
||||
await repos.accounts.remove(acc.id);
|
||||
setMessage(t("crew.msg.deleted") || `已移除 @${acc.username}`);
|
||||
setMessage(t("crew.msg.deleted", { user: acc.username }));
|
||||
refresh();
|
||||
await load();
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -1,497 +1,14 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { PageHeader } from "../components/layout/PageHeader";
|
||||
import { Badge, Button, Card, EmptyState, Select } from "../components/ui";
|
||||
import { useData, useRepos } from "../data/DataContext";
|
||||
import type { OwnPost, ThreadsAccount } from "../domain/types";
|
||||
import {
|
||||
buildAccountInsights,
|
||||
ensureInsightHistory,
|
||||
fmtDelta,
|
||||
fmtEngRate,
|
||||
monthKeyFromDate,
|
||||
type AccountInsightsReport,
|
||||
type AccountInsightsSnapshot,
|
||||
type MonthBucket,
|
||||
} from "../lib/accountInsights";
|
||||
import { formatLocalDateTime } from "../lib/time";
|
||||
import { useI18n } from "../i18n/I18nContext";
|
||||
import { InsightsPanel } from "./studio/InsightsPanel";
|
||||
|
||||
function DeltaBadge({ value }: { value: number | null }) {
|
||||
const { t } = useI18n();
|
||||
if (value == null) return <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>
|
||||
);
|
||||
}
|
||||
|
||||
/** 獨立路由 /app/insights(今日頁捷徑);創作分頁內嵌同一面板 */
|
||||
export function InsightsPage() {
|
||||
const repos = useRepos();
|
||||
const { tick, refresh } = useData();
|
||||
const { t, locale } = useI18n();
|
||||
const numLoc = locale === "en" ? "en-US" : "zh-TW";
|
||||
const [accounts, setAccounts] = useState<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);
|
||||
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t("insights.title")} />
|
||||
|
||||
<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>
|
||||
)}
|
||||
<InsightsPanel showTitle />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { PageHeader } from "../components/layout/PageHeader";
|
||||
import { Badge, Button, Card, EmptyState } from "../components/ui";
|
||||
import { Badge, Button, Card, EmptyState, Pager } from "../components/ui";
|
||||
import { useData, useRepos } from "../data/DataContext";
|
||||
import { useJobLive } from "../data/JobLiveContext";
|
||||
import type { JobListTab } from "../data/repos";
|
||||
import type { Job } from "../domain/types";
|
||||
import { useI18n } from "../i18n/I18nContext";
|
||||
import { useFormatApiError } from "../lib/apiErrors";
|
||||
|
|
@ -19,60 +20,72 @@ import {
|
|||
} from "../lib/jobLabels";
|
||||
import { formatLocalDateTime } from "../lib/time";
|
||||
|
||||
/** 首屏顯示筆數;太多時用「載入更多」而不是分頁 */
|
||||
const INITIAL = 15;
|
||||
const STEP = 15;
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
type TabDef = { id: JobListTab; labelKey: string };
|
||||
|
||||
const TABS: TabDef[] = [
|
||||
{ id: "active", labelKey: "jobs.tab.active" },
|
||||
{ id: "recurring", labelKey: "jobs.tab.recurring" },
|
||||
{ id: "history", labelKey: "jobs.tab.history" },
|
||||
];
|
||||
|
||||
export function JobsPage() {
|
||||
const repos = useRepos();
|
||||
const { member } = useAuth();
|
||||
const { refresh, tick } = useData();
|
||||
const { jobs: liveJobs, reload: reloadLive, revision } = useJobLive();
|
||||
const { refresh } = useData();
|
||||
const { revision, reload: reloadLive } = useJobLive();
|
||||
const { t } = useI18n();
|
||||
const formatApiError = useFormatApiError();
|
||||
const canCreateDemo = can(member?.roles, Permission.JobsDemoCreate);
|
||||
|
||||
const [tab, setTab] = useState<JobListTab>("active");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
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 [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = async () => {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
await reloadLive();
|
||||
setJobs(await repos.jobs.list());
|
||||
const res = await repos.jobs.list({ tab, page, pageSize });
|
||||
setJobs(res.list);
|
||||
setTotal(res.pagination.total);
|
||||
// 若刪到當頁空了且不是第 1 頁,回退一頁
|
||||
if (res.list.length === 0 && page > 1 && res.pagination.total > 0) {
|
||||
setPage((p) => Math.max(1, p - 1));
|
||||
}
|
||||
} catch (e) {
|
||||
setError(formatApiError(e, "jobs.loadFail"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [repos.jobs, tab, page, pageSize, formatApiError]);
|
||||
|
||||
// 優先用全域 live 列表(有進度就更新);fallback 本頁 list
|
||||
useEffect(() => {
|
||||
if (liveJobs.length > 0 || revision > 0) {
|
||||
setJobs(liveJobs);
|
||||
return;
|
||||
}
|
||||
void load();
|
||||
}, [liveJobs, revision, repos.jobs, tick]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [load]);
|
||||
|
||||
// 執行中分頁:跟全域 live revision 同步刷新(進度條)
|
||||
useEffect(() => {
|
||||
const onStore = () => refresh();
|
||||
window.addEventListener("harbor:store", onStore);
|
||||
return () => window.removeEventListener("harbor:store", onStore);
|
||||
}, [refresh]);
|
||||
if (tab !== "active" || revision <= 0) return;
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [revision, tab]);
|
||||
|
||||
const sorted = useMemo(
|
||||
() => jobs.slice().sort((a, b) => b.updated_at - a.updated_at),
|
||||
[jobs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setVisible((v) => Math.min(Math.max(INITIAL, v), Math.max(INITIAL, sorted.length)));
|
||||
}, [sorted.length]);
|
||||
|
||||
const shown = sorted.slice(0, visible);
|
||||
const hasMore = visible < sorted.length;
|
||||
function switchTab(next: JobListTab) {
|
||||
if (next === tab) return;
|
||||
setTab(next);
|
||||
setPage(1);
|
||||
setMessage("");
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function startDemo() {
|
||||
setBusy(true);
|
||||
|
|
@ -81,7 +94,9 @@ export function JobsPage() {
|
|||
try {
|
||||
const job = await repos.jobs.startDemo();
|
||||
setMessage(t("jobs.demoCreated", { id: job.id.slice(0, 8) }));
|
||||
setVisible(INITIAL);
|
||||
setTab("active");
|
||||
setPage(1);
|
||||
await reloadLive();
|
||||
await load();
|
||||
refresh();
|
||||
} catch (e) {
|
||||
|
|
@ -104,6 +119,7 @@ export function JobsPage() {
|
|||
try {
|
||||
await repos.jobs.remove(job.id);
|
||||
setMessage(t("jobs.deleted"));
|
||||
await reloadLive();
|
||||
await load();
|
||||
refresh();
|
||||
} catch (e) {
|
||||
|
|
@ -113,6 +129,13 @@ export function JobsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const emptyTitle =
|
||||
tab === "recurring"
|
||||
? t("jobs.empty.recurring")
|
||||
: tab === "history"
|
||||
? t("jobs.empty.history")
|
||||
: t("jobs.empty.active");
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t("jobs.title")} description={t("jobs.desc")} />
|
||||
|
|
@ -136,28 +159,65 @@ export function JobsPage() {
|
|||
</p>
|
||||
) : null}
|
||||
|
||||
{sorted.length === 0 ? (
|
||||
<EmptyState title={t("jobs.empty")} />
|
||||
<div className="hb-tabs" role="tablist" aria-label={t("jobs.tabsAria")}>
|
||||
{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">
|
||||
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
|
||||
{t("jobs.total", { n: sorted.length })}
|
||||
{hasMore ? t("jobs.showing", { n: shown.length }) : ""}
|
||||
{t("jobs.total", { n: total })}
|
||||
</p>
|
||||
{shown.map((job) => (
|
||||
{jobs.map((job) => (
|
||||
<Card key={job.id}>
|
||||
<div className="hb-list-row">
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<strong>{jobTemplateLabel(job.template_type, t)}</strong>
|
||||
<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
|
||||
className="hb-progress__bar"
|
||||
style={{ width: `${Math.min(100, Math.max(0, job.progress_percent))}%` }}
|
||||
/>
|
||||
</div>
|
||||
className="hb-progress hb-progress--sm"
|
||||
aria-hidden
|
||||
style={{ marginTop: "0.35rem" }}
|
||||
>
|
||||
<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)" }}>
|
||||
{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}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -185,13 +245,14 @@ export function JobsPage() {
|
|||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{hasMore ? (
|
||||
<div className="hb-wizard-actions">
|
||||
<Button type="button" variant="secondary" onClick={() => setVisible((v) => v + STEP)}>
|
||||
{t("jobs.loadMore", { n: sorted.length - visible })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<Pager
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
showWhenSingle
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
|
|||
import { useAuth } from "../auth/AuthContext";
|
||||
import { PageHeader } from "../components/layout/PageHeader";
|
||||
import { Button, Card, Input } from "../components/ui";
|
||||
import { useRepos } from "../data/DataContext";
|
||||
import { useData, useRepos } from "../data/DataContext";
|
||||
import { useI18n } from "../i18n/I18nContext";
|
||||
import { getPlanRights, planCtaLabel } from "../lib/planRights";
|
||||
import { PLANS, type PlanId } from "../lib/usageMeter";
|
||||
|
|
@ -17,6 +17,7 @@ function isPlanId(v: string | null): v is PlanId {
|
|||
*/
|
||||
export function PlanCheckoutPage() {
|
||||
const repos = useRepos();
|
||||
const { refresh } = useData();
|
||||
const { member } = useAuth();
|
||||
const { t, formatPlanPrice } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -25,7 +26,7 @@ export function PlanCheckoutPage() {
|
|||
|
||||
const planId: PlanId | null = isPlanId(planParam) ? planParam : null;
|
||||
const plan = planId ? PLANS[planId] : null;
|
||||
const rights = planId ? getPlanRights(planId, t) : null;
|
||||
const rights = planId ? getPlanRights(planId, t, formatPlanPrice) : null;
|
||||
|
||||
const [currentId, setCurrentId] = useState<PlanId>("free");
|
||||
const [cardName, setCardName] = useState(member?.display_name || "");
|
||||
|
|
@ -78,6 +79,8 @@ export function PlanCheckoutPage() {
|
|||
await repos.usage.purchasePlan(planId, {
|
||||
mock_ref: free ? "free" : `****${cardLast4.replace(/\D/g, "").slice(-4)}`,
|
||||
});
|
||||
// 強制重載 repos/頂欄用量(PLANS 配給依新 plan_id)
|
||||
refresh();
|
||||
navigate("/app/usage", {
|
||||
replace: true,
|
||||
state: { purchaseOk: planId },
|
||||
|
|
@ -128,6 +131,9 @@ export function PlanCheckoutPage() {
|
|||
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
|
||||
{t("checkout.monthlyCredits", { n: plan.monthly_credits })}
|
||||
</p>
|
||||
<p className="text-muted" style={{ margin: "0.25rem 0 0", fontSize: "var(--hb-text-xs)" }}>
|
||||
{rights.approxCallsLine}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { PageHeader } from "../components/layout/PageHeader";
|
|||
import { PlanPricingGrid } from "../components/usage/PlanPricingGrid";
|
||||
import { useRepos } from "../data/DataContext";
|
||||
import { useI18n } from "../i18n/I18nContext";
|
||||
import { getPlanRights } from "../lib/planRights";
|
||||
import type { PlanId } from "../lib/usageMeter";
|
||||
import { PLANS } from "../lib/usageMeter";
|
||||
|
||||
|
|
@ -25,6 +26,7 @@ export function PlansPage() {
|
|||
if (!member) return null;
|
||||
|
||||
const current = currentId ?? "free";
|
||||
const currentRights = getPlanRights(current, t, formatPlanPrice);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -41,6 +43,7 @@ export function PlansPage() {
|
|||
<span className="text-muted">
|
||||
{t("plans.monthlyCredits", { n: PLANS[current].monthly_credits })}
|
||||
</span>
|
||||
<span className="text-muted hb-plans-page__caps">{currentRights.approxCallsLine}</span>
|
||||
<Link to="/app/usage" className="hb-plans-page__back">
|
||||
{t("plans.usageLink")}
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { PageHeader } from "../components/layout/PageHeader";
|
||||
import { Select } from "../components/ui";
|
||||
import { useData, useRepos } from "../data/DataContext";
|
||||
|
|
@ -8,6 +8,7 @@ import { useI18n } from "../i18n/I18nContext";
|
|||
import { isPersonaReady, personaOptionLabel } from "../lib/personaPrompt";
|
||||
import { ComposerPanel } from "./studio/ComposerPanel";
|
||||
import { InspirePanel } from "./studio/InspirePanel";
|
||||
import { InsightsPanel } from "./studio/InsightsPanel";
|
||||
import { MentionsPanel } from "./studio/MentionsPanel";
|
||||
import { OwnPostsPanel } from "./studio/OwnPostsPanel";
|
||||
import { PlaysPanel } from "./studio/PlaysPanel";
|
||||
|
|
@ -27,7 +28,6 @@ export function StudioPage() {
|
|||
const repos = useRepos();
|
||||
const { tick } = useData();
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const [params, setParams] = useSearchParams();
|
||||
|
||||
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
|
||||
|
|
@ -72,10 +72,6 @@ export function StudioPage() {
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === "insights") {
|
||||
navigate("/app/insights", { replace: true });
|
||||
return;
|
||||
}
|
||||
if (!TAB_KEYS.some((item) => item.key === tab)) {
|
||||
setTab("posts");
|
||||
}
|
||||
|
|
@ -127,13 +123,7 @@ export function StudioPage() {
|
|||
key={item.key}
|
||||
type="button"
|
||||
className={`hb-tab ${tab === item.key ? "is-active" : ""}`}
|
||||
onClick={() => {
|
||||
if (item.key === "insights") {
|
||||
navigate("/app/insights");
|
||||
return;
|
||||
}
|
||||
setTab(item.key);
|
||||
}}
|
||||
onClick={() => setTab(item.key)}
|
||||
>
|
||||
{t(item.labelKey)}
|
||||
</button>
|
||||
|
|
@ -165,6 +155,9 @@ export function StudioPage() {
|
|||
{tab === "inspire" ? (
|
||||
<InspirePanel accountId={accountId} personaId={personaId} personaReady={personaReady} />
|
||||
) : null}
|
||||
{tab === "insights" ? (
|
||||
<InsightsPanel accountId={accountId} hideAccountSelect />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { PageHeader } from "../components/layout/PageHeader";
|
||||
import { Badge, Button, Card, EmptyState } from "../components/ui";
|
||||
import { useData, useRepos } from "../data/DataContext";
|
||||
import type {
|
||||
MentionItem,
|
||||
OutboxBundle,
|
||||
OwnPost,
|
||||
ScoutPost,
|
||||
|
|
@ -11,6 +12,7 @@ import type {
|
|||
TrendItem,
|
||||
} from "../domain/types";
|
||||
import { useI18n } from "../i18n/I18nContext";
|
||||
import { useFormatApiError } from "../lib/apiErrors";
|
||||
import { loadScoutToday } from "../lib/scoutToday";
|
||||
|
||||
function isPendingScout(p: ScoutPost): boolean {
|
||||
|
|
@ -32,15 +34,26 @@ type AccountPulse = {
|
|||
topInsight?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 今日儀表板:海巡待回、今日目標、發送、話題、帳號成效。
|
||||
* 全部接 live repos;話題可刷新;點話題進靈感 tab。
|
||||
*/
|
||||
export function TodayPage() {
|
||||
const repos = useRepos();
|
||||
const { tick, refresh } = useData();
|
||||
const { t, locale } = useI18n();
|
||||
const formatApiError = useFormatApiError();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [refreshingTrends, setRefreshingTrends] = useState(false);
|
||||
const [syncingPosts, setSyncingPosts] = useState(false);
|
||||
|
||||
const [scoutPosts, setScoutPosts] = useState<ScoutPost[]>([]);
|
||||
const [outbox, setOutbox] = useState<OutboxBundle[]>([]);
|
||||
const [trends, setTrends] = useState<TrendItem[]>([]);
|
||||
const [ownPosts, setOwnPosts] = useState<OwnPost[]>([]);
|
||||
const [mentions, setMentions] = useState<MentionItem[]>([]);
|
||||
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
|
||||
const [scoutDone, setScoutDone] = useState(0);
|
||||
const [scoutGoal, setScoutGoal] = useState(8);
|
||||
|
|
@ -57,25 +70,73 @@ export function TodayPage() {
|
|||
[dateLocale, tick],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const scout = loadScoutToday();
|
||||
setScoutDone(scout.done);
|
||||
setScoutGoal(scout.goalValue);
|
||||
const [posts, box, trendList, owns, acc] = await Promise.all([
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const scoutLocal = loadScoutToday();
|
||||
setScoutGoal(scoutLocal.goalValue);
|
||||
|
||||
const [posts, box, trendList, acc] = await Promise.all([
|
||||
repos.scout.listPosts(),
|
||||
repos.outbox.list(),
|
||||
repos.inspiration.listTrends("all"),
|
||||
repos.ownPosts.list(),
|
||||
repos.inspiration.listTrends("all").catch(() => [] as TrendItem[]),
|
||||
repos.accounts.list(),
|
||||
]);
|
||||
|
||||
const usable = acc.filter((a) => a.is_usable);
|
||||
setAccounts(usable.length ? usable : acc);
|
||||
setScoutPosts(posts);
|
||||
setOutbox(box);
|
||||
setTrends(trendList.slice(0, 8));
|
||||
setTrends(
|
||||
[...trendList]
|
||||
.sort((a, b) => (b.heat || 0) - (a.heat || 0))
|
||||
.slice(0, 12),
|
||||
);
|
||||
|
||||
// 今日已回:localStorage + 後端 published 取較大
|
||||
const publishedN = posts.filter((p) => p.outreach_status === "published").length;
|
||||
setScoutDone(Math.max(scoutLocal.done, publishedN));
|
||||
|
||||
// 自己貼文:全帳或分帳合併
|
||||
let owns: OwnPost[] = [];
|
||||
try {
|
||||
owns = await repos.ownPosts.list();
|
||||
} catch {
|
||||
owns = [];
|
||||
}
|
||||
if (!owns.length && usable.length) {
|
||||
const chunks = await Promise.all(
|
||||
usable.slice(0, 6).map((a) =>
|
||||
repos.ownPosts.list(a.id).catch(() => [] as OwnPost[]),
|
||||
),
|
||||
);
|
||||
owns = chunks.flat();
|
||||
}
|
||||
setOwnPosts(owns);
|
||||
setAccounts(acc.filter((a) => a.is_usable));
|
||||
})();
|
||||
}, [repos, tick]);
|
||||
|
||||
// 待回提及
|
||||
let mentionList: MentionItem[] = [];
|
||||
try {
|
||||
if (usable[0]?.id) {
|
||||
mentionList = await repos.mentions.list(usable[0].id);
|
||||
} else {
|
||||
mentionList = await repos.mentions.list();
|
||||
}
|
||||
} catch {
|
||||
mentionList = [];
|
||||
}
|
||||
setMentions(mentionList);
|
||||
} catch (e) {
|
||||
setError(formatApiError(e, "today.loadFail"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [repos, formatApiError]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load, tick]);
|
||||
|
||||
useEffect(() => {
|
||||
const onStore = () => refresh();
|
||||
|
|
@ -94,6 +155,11 @@ export function TodayPage() {
|
|||
[scoutPosts],
|
||||
);
|
||||
|
||||
const pendingMentions = useMemo(
|
||||
() => mentions.filter((m) => m.status === "pending"),
|
||||
[mentions],
|
||||
);
|
||||
|
||||
const failedOutbox = useMemo(
|
||||
() => outbox.filter((o) => o.status === "partial_failed"),
|
||||
[outbox],
|
||||
|
|
@ -111,7 +177,7 @@ export function TodayPage() {
|
|||
|
||||
const goalPct = Math.min(100, Math.round((scoutDone / Math.max(1, scoutGoal)) * 100));
|
||||
|
||||
const topicCards = useMemo(() => trends.slice(0, 3), [trends]);
|
||||
const topicCards = useMemo(() => trends.slice(0, 6), [trends]);
|
||||
|
||||
const accountPulses = useMemo((): AccountPulse[] => {
|
||||
const byAcc = new Map<string, OwnPost[]>();
|
||||
|
|
@ -137,7 +203,6 @@ export function TodayPage() {
|
|||
topInsight: top?.insight || top?.formula_summary,
|
||||
});
|
||||
}
|
||||
// 沒 usable 時仍展示有貼文的帳
|
||||
if (!rows.length) {
|
||||
for (const [accId, posts] of byAcc) {
|
||||
const acc =
|
||||
|
|
@ -165,16 +230,78 @@ export function TodayPage() {
|
|||
.slice(0, 4);
|
||||
}, [accounts, ownPosts]);
|
||||
|
||||
const pendingPreview = pendingScout.slice(0, 3);
|
||||
const pendingPreview = pendingScout.slice(0, 5);
|
||||
|
||||
async function onRefreshTrends() {
|
||||
setRefreshingTrends(true);
|
||||
setError("");
|
||||
try {
|
||||
const list = await repos.inspiration.refreshTrends("all");
|
||||
setTrends(
|
||||
[...list].sort((a, b) => (b.heat || 0) - (a.heat || 0)).slice(0, 12),
|
||||
);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setError(formatApiError(e, "today.trendsFail"));
|
||||
} finally {
|
||||
setRefreshingTrends(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSyncOwnPosts() {
|
||||
const acc = accounts[0];
|
||||
if (!acc) {
|
||||
setError(t("today.needAccount"));
|
||||
return;
|
||||
}
|
||||
setSyncingPosts(true);
|
||||
setError("");
|
||||
try {
|
||||
const list = await repos.ownPosts.sync(acc.id);
|
||||
setOwnPosts((prev) => {
|
||||
const others = prev.filter((p) => p.account_id !== acc.id);
|
||||
return [...list, ...others];
|
||||
});
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setError(formatApiError(e, "today.syncPostsFail"));
|
||||
} finally {
|
||||
setSyncingPosts(false);
|
||||
}
|
||||
}
|
||||
|
||||
function topicHref(label: string) {
|
||||
const q = new URLSearchParams({ tab: "inspire", topic: label });
|
||||
return `/app/studio?${q.toString()}`;
|
||||
}
|
||||
|
||||
if (loading && !scoutPosts.length && !trends.length && !outbox.length) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t("nav.today")} description={todayLabel} />
|
||||
<p className="text-muted">{t("common.loading")}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t("nav.today")} description={todayLabel} />
|
||||
|
||||
{error ? (
|
||||
<p className="hb-form-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="hb-today-actions">
|
||||
<Link to="/app/studio">
|
||||
<Button type="button">{t("today.findTopic")}</Button>
|
||||
</Link>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void onRefreshTrends()}
|
||||
disabled={refreshingTrends}
|
||||
>
|
||||
{refreshingTrends ? t("common.loading") : t("today.findTopic")}
|
||||
</Button>
|
||||
<Link to="/app/scout">
|
||||
<Button type="button" variant={pendingScout.length ? "primary" : "ghost"}>
|
||||
{pendingScout.length
|
||||
|
|
@ -182,11 +309,14 @@ export function TodayPage() {
|
|||
: t("today.pendingReplies")}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/app/studio/new">
|
||||
<Link to="/app/studio?tab=compose">
|
||||
<Button type="button" variant="ghost">
|
||||
{t("today.newThread")}
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="button" variant="ghost" onClick={() => void load()} disabled={loading}>
|
||||
{t("today.reload")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 數值列 */}
|
||||
|
|
@ -196,7 +326,7 @@ export function TodayPage() {
|
|||
<span className="hb-today-metric__value">{pendingScout.length}</span>
|
||||
<span className="hb-today-metric__hint">{t("today.metric.pendingHint")}</span>
|
||||
</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__value">
|
||||
{scoutDone}
|
||||
|
|
@ -225,9 +355,14 @@ export function TodayPage() {
|
|||
{failedOutbox.length ? t("today.metric.needAction") : t("today.metric.ok")}
|
||||
</span>
|
||||
</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>
|
||||
|
||||
{/* 待回覆 */}
|
||||
{/* 待回覆 · 海巡 */}
|
||||
<Card title={t("today.pending.title", { n: pendingScout.length })}>
|
||||
{pendingPreview.length === 0 ? (
|
||||
<EmptyState
|
||||
|
|
@ -248,11 +383,20 @@ export function TodayPage() {
|
|||
@{p.author}
|
||||
{p.search_tag ? ` · ${p.search_tag}` : ""}
|
||||
{typeof p.score === "number" ? ` · ${Math.round(p.score)}` : ""}
|
||||
{p.outreach_status === "drafted" ? (
|
||||
<>
|
||||
{" · "}
|
||||
<Badge tone="brand">{t("today.badge.drafted")}</Badge>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="hb-today-list__text">
|
||||
{p.text.slice(0, 96)}
|
||||
{p.text.length > 96 ? "…" : ""}
|
||||
</span>
|
||||
{p.opportunity ? (
|
||||
<span className="hb-today-list__meta">{p.opportunity}</span>
|
||||
) : null}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
|
|
@ -275,13 +419,34 @@ export function TodayPage() {
|
|||
|
||||
{/* 找話題 */}
|
||||
<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 ? (
|
||||
<EmptyState
|
||||
title={t("today.topics.empty")}
|
||||
action={
|
||||
<Link to="/app/studio">
|
||||
<Button type="button">{t("today.goStudio")}</Button>
|
||||
</Link>
|
||||
<div className="hb-today-actions" style={{ margin: 0 }}>
|
||||
<Button
|
||||
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">
|
||||
{topicCards.map((item) => (
|
||||
<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">
|
||||
{item.label}
|
||||
{typeof item.heat === "number" ? (
|
||||
{typeof item.heat === "number" && item.heat > 0 ? (
|
||||
<Badge tone="brand">{t("today.heat", { n: item.heat })}</Badge>
|
||||
) : null}
|
||||
{item.source_label ? (
|
||||
<span className="text-muted"> · {item.source_label}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<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 ? "…" : ""}
|
||||
</span>
|
||||
</Link>
|
||||
|
|
@ -305,16 +475,18 @@ export function TodayPage() {
|
|||
))}
|
||||
</ul>
|
||||
<div className="hb-today-actions" style={{ margin: 0 }}>
|
||||
<Link to="/app/studio">
|
||||
<Link to="/app/studio?tab=inspire">
|
||||
<Button type="button" variant="ghost">
|
||||
{t("today.moreInspire")}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/app/studio/new">
|
||||
<Button type="button" variant="ghost">
|
||||
{t("today.useTopic")}
|
||||
</Button>
|
||||
</Link>
|
||||
{topicCards[0] ? (
|
||||
<Link to={topicHref(topicCards[0].label)}>
|
||||
<Button type="button" variant="ghost">
|
||||
{t("today.useTopic")}
|
||||
</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -325,7 +497,7 @@ export function TodayPage() {
|
|||
{failedOutbox.length === 0 && runningOutbox.length === 0 && sentToday === 0 ? (
|
||||
<p className="text-muted" style={{ margin: 0 }}>
|
||||
{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")}{" "}
|
||||
<Link to="/app/outbox">{t("nav.outbox")}</Link>
|
||||
{t("today.outbox.emptyEnd")}
|
||||
|
|
@ -342,15 +514,17 @@ export function TodayPage() {
|
|||
{failedOutbox.slice(0, 2).map((o) => (
|
||||
<p key={o.id} style={{ margin: 0, fontSize: "0.85rem" }}>
|
||||
<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>
|
||||
))}
|
||||
{runningOutbox.slice(0, 2).map((o) => (
|
||||
<p key={o.id} style={{ margin: 0, fontSize: "0.85rem" }}>
|
||||
<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>{" "}
|
||||
<Link to={`/app/outbox/${o.id}`}>{o.title}</Link>
|
||||
<Link to={`/app/outbox/${o.id}`}>{o.title || o.id.slice(0, 8)}</Link>
|
||||
</p>
|
||||
))}
|
||||
<Link to="/app/outbox">
|
||||
|
|
@ -362,17 +536,29 @@ export function TodayPage() {
|
|||
)}
|
||||
</Card>
|
||||
|
||||
{/* 帳號成效(輕量) */}
|
||||
{/* 帳號成效 */}
|
||||
<Card title={t("today.accounts.title")}>
|
||||
{accountPulses.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t("today.accounts.empty")}
|
||||
action={
|
||||
<Link to="/app/crew">
|
||||
<Button type="button" variant="ghost">
|
||||
{t("nav.crew")}
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="hb-today-actions" style={{ margin: 0 }}>
|
||||
{accounts.length > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
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 className="hb-today-account__stats">
|
||||
<span>
|
||||
{t("today.views")} <strong>{row.views.toLocaleString(dateLocale)}</strong>
|
||||
{t("today.views")}{" "}
|
||||
<strong>{row.views.toLocaleString(dateLocale)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{t("today.likes")} <strong>{row.likes}</strong>
|
||||
|
|
@ -412,6 +599,14 @@ export function TodayPage() {
|
|||
{t("today.viewPosts")}
|
||||
</Button>
|
||||
</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">
|
||||
<Button type="button" variant="ghost">
|
||||
{t("today.manageAccounts")}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { useData, useRepos } from "../data/DataContext";
|
|||
import { useI18n } from "../i18n/I18nContext";
|
||||
import { memberRoleSummary } from "../lib/memberRole";
|
||||
import { pageSlice } from "../lib/pagination";
|
||||
import { getPlanRights } from "../lib/planRights";
|
||||
import {
|
||||
METER_META,
|
||||
PLANS,
|
||||
|
|
@ -141,7 +142,11 @@ export function UsagePage() {
|
|||
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(() => {
|
||||
if (!summary) return [];
|
||||
|
|
@ -193,18 +198,22 @@ export function UsagePage() {
|
|||
}
|
||||
|
||||
const pagedMembers = tenant ? pageSlice(tenant.members, memberPage, MEMBER_PAGE) : [];
|
||||
// total_credits = 已用;配給一律用目前方案 monthly_credits(PLANS)
|
||||
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(
|
||||
100,
|
||||
summary.plan.monthly_credits > 0
|
||||
? Math.round((summary.total_credits / summary.plan.monthly_credits) * 100)
|
||||
: 0,
|
||||
summary.pct > 0
|
||||
? Math.min(100, summary.pct)
|
||||
: capCredits > 0
|
||||
? Math.round((usedCredits / capCredits) * 100)
|
||||
: 0,
|
||||
);
|
||||
const barTone =
|
||||
summary.total_credits > summary.plan.monthly_credits
|
||||
? " is-danger"
|
||||
: usedPct >= 70
|
||||
? " is-warn"
|
||||
: "";
|
||||
usedCredits > capCredits ? " is-danger" : usedPct >= 70 ? " is-warn" : "";
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -260,9 +269,17 @@ export function UsagePage() {
|
|||
{formatPlanPrice(summary.plan.price_twd)}
|
||||
{t("usage.planMeta", { n: summary.plan.monthly_credits })}
|
||||
</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>
|
||||
<Button type="button" onClick={() => navigate("/app/usage/plans")}>
|
||||
{t("usage.changePlan")}
|
||||
{summary.plan.id === "free" ? t("usage.upgradePlan") : t("usage.changePlan")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
@ -274,13 +291,15 @@ export function UsagePage() {
|
|||
<div>
|
||||
<span className="hb-usage-chart__name">{t("usage.usedThisMonth")}</span>
|
||||
<p className="hb-usage-status__big">
|
||||
{summary.total_credits}
|
||||
<span className="text-muted"> / {summary.plan.monthly_credits}</span>
|
||||
{usedCredits}
|
||||
<span className="text-muted"> / {capCredits}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="hb-usage-status__remain">
|
||||
<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 className="hb-progress" aria-hidden>
|
||||
|
|
@ -292,11 +311,13 @@ export function UsagePage() {
|
|||
<div className="hb-usage-status__foot">
|
||||
<span className="text-muted">{usedPct}%</span>
|
||||
<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>
|
||||
<span className="text-muted">{t("usage.meter.pt")}</span>
|
||||
</span>
|
||||
<span>
|
||||
<span title={t("usage.side.searchCredits")}>
|
||||
Search <strong>{summary.search_calls}</strong>
|
||||
<span className="text-muted">{t("usage.meter.pt")}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -465,7 +486,10 @@ export function UsagePage() {
|
|||
>
|
||||
{(Object.keys(PLANS) as PlanId[]).map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{PLANS[id].name}
|
||||
{t("admin.users.planOption", {
|
||||
name: PLANS[id].name,
|
||||
credits: PLANS[id].monthly_credits,
|
||||
})}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
|
|
|||
|
|
@ -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 真 sync(singleflight)
|
||||
useEffect(() => {
|
||||
if (!accountId) {
|
||||
setPosts([]);
|
||||
setSyncedAt(null);
|
||||
bootstrappedRef.current = "";
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const runId = accountId;
|
||||
bootstrappedRef.current = "";
|
||||
|
||||
void (async () => {
|
||||
setBusy("load");
|
||||
setMessage("");
|
||||
try {
|
||||
let list = await ops.list(accountId);
|
||||
if (cancelled) return;
|
||||
await applyPosts(list, { quiet: true });
|
||||
setAnalysisMonth(monthKeyFromDate());
|
||||
|
||||
const report0 = buildAccountInsights(accountId, list, 6);
|
||||
const needPast = needsPastMonthBackfill(report0.months, currentMonthKey);
|
||||
|
||||
// 過去月缺資料 → 真 API 補一次;否則本月過 5 分鐘才再抓
|
||||
setBusy("sync");
|
||||
const out = await syncOwnPostsGated(
|
||||
accountId,
|
||||
ops,
|
||||
needPast ? "past-once" : "current-ttl",
|
||||
);
|
||||
if (cancelled) return;
|
||||
if (out.fetched) {
|
||||
list = out.posts;
|
||||
await applyPosts(list);
|
||||
if (out.reason === "past-once") {
|
||||
setMessage(t("insights.pastBackfillDone", { n: list.length }));
|
||||
}
|
||||
}
|
||||
bootstrappedRef.current = runId;
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setPosts([]);
|
||||
setMessage(e instanceof Error ? e.message : t("insights.loadFail"));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setBusy("");
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accountId, ops, applyPosts, currentMonthKey, t]);
|
||||
|
||||
// 本月:每 5 分鐘真 API 更新(singleflight;面板卸載即停)
|
||||
useEffect(() => {
|
||||
if (!accountId) return;
|
||||
const timer = window.setInterval(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const out = await syncOwnPostsGated(accountId, ops, "current-ttl");
|
||||
if (out.fetched) {
|
||||
await applyPosts(out.posts);
|
||||
setMessage(t("insights.autoRefreshDone", { n: out.posts.length }));
|
||||
}
|
||||
} catch {
|
||||
/* 背景刷新失敗不打斷 UI */
|
||||
}
|
||||
})();
|
||||
}, OWN_POSTS_CURRENT_TTL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [accountId, ops, applyPosts, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const onStore = () => refresh();
|
||||
window.addEventListener("harbor:store", onStore);
|
||||
return () => window.removeEventListener("harbor:store", onStore);
|
||||
}, [refresh]);
|
||||
|
||||
const report: AccountInsightsReport | null = useMemo(() => {
|
||||
if (!accountId) return null;
|
||||
return buildAccountInsights(accountId, posts, 6);
|
||||
}, [accountId, posts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!report) {
|
||||
setHistory([]);
|
||||
return;
|
||||
}
|
||||
// 本月覆寫分析;歷史月有資料才寫一次(ensure 內部會跳過已存在)
|
||||
const forceCurrent = analysisMonth === currentMonthKey || analysisMonth === report.current.key;
|
||||
const list = ensureInsightHistory(report, { forceCurrent });
|
||||
setHistory(list);
|
||||
}, [report, historyTick, analysisMonth, currentMonthKey]);
|
||||
|
||||
const selectedSnap = useMemo(
|
||||
() => history.find((s) => s.month_key === analysisMonth) || history[0] || null,
|
||||
[history, analysisMonth],
|
||||
);
|
||||
|
||||
const selectedMonthBucket = useMemo(() => {
|
||||
if (!report) return null;
|
||||
return report.months.find((m) => m.key === analysisMonth) || report.current;
|
||||
}, [report, analysisMonth]);
|
||||
|
||||
const monthTopPosts = useMemo(
|
||||
() => topPostsForMonth(posts.filter((p) => !accountId || p.account_id === accountId), analysisMonth, 8),
|
||||
[posts, accountId, analysisMonth],
|
||||
);
|
||||
|
||||
const selectedMonthPrev = useMemo(() => {
|
||||
if (!report) return null;
|
||||
const idx = report.months.findIndex((m) => m.key === analysisMonth);
|
||||
if (idx <= 0) return null;
|
||||
return report.months[idx - 1] || null;
|
||||
}, [report, analysisMonth]);
|
||||
|
||||
const selectedDelta = useMemo(() => {
|
||||
if (!selectedMonthBucket || !selectedMonthPrev) {
|
||||
return { views: null as number | null, likes: null, replies: null, engagementRate: null };
|
||||
}
|
||||
const pct = (c: number, p: number) => {
|
||||
if (p <= 0 && c <= 0) return 0;
|
||||
if (p <= 0) return c > 0 ? 100 : null;
|
||||
return Math.round(((c - p) / p) * 1000) / 10;
|
||||
};
|
||||
return {
|
||||
views: pct(selectedMonthBucket.views, selectedMonthPrev.views),
|
||||
likes: pct(selectedMonthBucket.likes, selectedMonthPrev.likes),
|
||||
replies: pct(selectedMonthBucket.replies, selectedMonthPrev.replies),
|
||||
engagementRate: pct(
|
||||
selectedMonthBucket.engagementRate * 100,
|
||||
selectedMonthPrev.engagementRate * 100,
|
||||
),
|
||||
};
|
||||
}, [selectedMonthBucket, selectedMonthPrev]);
|
||||
|
||||
async function syncPosts() {
|
||||
if (!accountId) return;
|
||||
setBusy("sync");
|
||||
setMessage("");
|
||||
try {
|
||||
const out = await syncOwnPostsGated(accountId, ops, "manual");
|
||||
await applyPosts(out.posts);
|
||||
setMessage(t("insights.syncDone", { n: out.posts.length }));
|
||||
} catch (e) {
|
||||
setMessage(e instanceof Error ? e.message : t("insights.syncFail"));
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
function selectMonth(key: string) {
|
||||
setAnalysisMonth(key);
|
||||
}
|
||||
|
||||
const account =
|
||||
accounts.find((a) => a.id === accountId) ||
|
||||
(accountId
|
||||
? ({ id: accountId, username: "", display_name: "" } as ThreadsAccount)
|
||||
: undefined);
|
||||
const hasPosts = (report?.totalPosts ?? 0) > 0;
|
||||
const zeroViews = hasPosts && posts.every((p) => (p.view_count || 0) === 0);
|
||||
const kpi = selectedMonthBucket || report?.current;
|
||||
const isCurrentSelected = analysisMonth === currentMonthKey;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, EmptyState, Input, Select, Textarea } from "../../components/ui";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Button, EmptyState, Input, MarkdownText, Select, Textarea } from "../../components/ui";
|
||||
import { AppIcon } from "../../components/ui/AppIcons";
|
||||
import { useData, useRepos } from "../../data/DataContext";
|
||||
import type {
|
||||
|
|
@ -53,7 +53,10 @@ export function InspirePanel({ accountId, personaId }: Props) {
|
|||
const { refresh, tick } = useData();
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
/** 今日/外連帶入的 topic 只套一次 */
|
||||
const topicSeeded = useRef(false);
|
||||
|
||||
const [session, setSession] = useState<InspireSession | null>(null);
|
||||
const [sessionList, setSessionList] = useState<InspireSessionSummary[]>([]);
|
||||
|
|
@ -186,6 +189,19 @@ export function InspirePanel({ accountId, personaId }: Props) {
|
|||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [session?.messages.length, busy, streamingText]);
|
||||
|
||||
// 從今日等入口:?topic=xxx → 預填輸入框(只一次)
|
||||
useEffect(() => {
|
||||
if (topicSeeded.current) return;
|
||||
const topic = (searchParams.get("topic") || "").trim();
|
||||
if (!topic) return;
|
||||
topicSeeded.current = true;
|
||||
setInput((cur) => (cur.trim() ? cur : t("inspire.topicSeed", { topic })));
|
||||
// 清掉 query,避免重整重複塞
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("topic");
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [searchParams, setSearchParams, t]);
|
||||
|
||||
const pinnedElements = useMemo(
|
||||
() => elements.filter((e) => pinnedIds.includes(e.id)),
|
||||
[elements, pinnedIds],
|
||||
|
|
@ -997,7 +1013,11 @@ export function InspirePanel({ accountId, personaId }: Props) {
|
|||
? t("inspire.ai")
|
||||
: t("inspire.system")}
|
||||
</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 ? (
|
||||
<div className="hb-inspire-draft">
|
||||
<pre className="hb-inspire-draft__body">{m.draft.body}</pre>
|
||||
|
|
@ -1016,8 +1036,8 @@ export function InspirePanel({ accountId, personaId }: Props) {
|
|||
{streamingText ? (
|
||||
<div className="hb-inspire-msg hb-inspire-msg--assistant">
|
||||
<div className="hb-inspire-msg__role">{t("inspire.ai")}</div>
|
||||
<div className="hb-inspire-msg__text">
|
||||
{streamingText}
|
||||
<div className="hb-inspire-msg__stream">
|
||||
<MarkdownText text={streamingText} className="hb-inspire-msg__md" />
|
||||
<span className="hb-inspire-cursor" aria-hidden>
|
||||
▍
|
||||
</span>
|
||||
|
|
@ -1133,7 +1153,7 @@ export function InspirePanel({ accountId, personaId }: Props) {
|
|||
aria-label={t("inspire.inputAria")}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
rows={2}
|
||||
rows={1}
|
||||
placeholder={t("inspire.inputPh")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
|
|
@ -1155,7 +1175,7 @@ export function InspirePanel({ accountId, personaId }: Props) {
|
|||
…
|
||||
</span>
|
||||
) : (
|
||||
<AppIcon name="outbox" size={18} />
|
||||
<AppIcon name="send" size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { ImageAttach } from "../../components/studio/ImageAttach";
|
||||
import { Badge, Button, Card, EmptyState, Input, Select, Textarea } from "../../components/ui";
|
||||
import { useData, useRepos } from "../../data/DataContext";
|
||||
|
|
@ -388,11 +388,6 @@ export function PlaysPanel({ accountId, personas: personasProp = [] }: Props) {
|
|||
return isPersonaReady(p) ? p! : null;
|
||||
}
|
||||
|
||||
function resolveStepBrand(step: PlayStep): Brand | null {
|
||||
if (!step.brand_id) return null;
|
||||
return brands.find((b) => b.id === step.brand_id) || null;
|
||||
}
|
||||
|
||||
async function generateForStep(
|
||||
step: PlayStep,
|
||||
index: number,
|
||||
|
|
|
|||
|
|
@ -841,8 +841,179 @@ svg {
|
|||
}
|
||||
.hb-inspire-msg__text {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* AI 氣泡:markdown + 逐行換行 */
|
||||
.hb-inspire-msg__md {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.65;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--hb-text);
|
||||
}
|
||||
|
||||
.hb-inspire-msg__stream {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hb-inspire-msg__stream .hb-inspire-cursor {
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
/* —— 輕量 markdown(聊天泡泡:逐行換行,不擠成一團) —— */
|
||||
.hb-md {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.hb-md > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.hb-md > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 一般文字:每個實際換行 = 一行 */
|
||||
.hb-md__line {
|
||||
display: block;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
min-height: 1.35em;
|
||||
}
|
||||
|
||||
/* 原文空一行 → 段落間距 */
|
||||
.hb-md__gap {
|
||||
display: block;
|
||||
height: 0.65em;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hb-md__h {
|
||||
margin: 0.7em 0 0.3em;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
color: var(--hb-text);
|
||||
}
|
||||
|
||||
.hb-md__h:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.hb-md__h--1 {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.hb-md__h--2 {
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.hb-md__h--3 {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.hb-md__ul,
|
||||
.hb-md__ol {
|
||||
margin: 0.35em 0 0.55em;
|
||||
padding-left: 1.35em;
|
||||
}
|
||||
|
||||
.hb-md__ul {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.hb-md__ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.hb-md__ul li,
|
||||
.hb-md__ol li {
|
||||
margin: 0.28em 0;
|
||||
padding-left: 0.15em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hb-md__ul li::marker,
|
||||
.hb-md__ol li::marker {
|
||||
color: var(--hb-muted);
|
||||
}
|
||||
|
||||
.hb-md__quote {
|
||||
margin: 0.45em 0 0.55em;
|
||||
padding: 0.35em 0 0.35em 0.75em;
|
||||
border-left: 3px solid color-mix(in srgb, var(--hb-brand) 45%, var(--hb-line));
|
||||
color: var(--hb-muted);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hb-md__pre {
|
||||
margin: 0.45em 0 0.55em;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-radius: calc(var(--hb-radius) - 2px);
|
||||
background: color-mix(in srgb, var(--hb-surface-muted) 80%, #000 6%);
|
||||
border: 1px solid var(--hb-line);
|
||||
overflow-x: auto;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.hb-md__pre code {
|
||||
font: inherit;
|
||||
background: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.hb-md__code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.85em;
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 0.3em;
|
||||
background: color-mix(in srgb, var(--hb-surface-muted) 90%, var(--hb-brand) 6%);
|
||||
border: 1px solid color-mix(in srgb, var(--hb-line) 80%, transparent);
|
||||
}
|
||||
|
||||
.hb-md__a {
|
||||
color: var(--hb-brand);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.hb-md__a:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.hb-md__hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--hb-line);
|
||||
margin: 0.65em 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hb-md strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hb-md em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hb-md del {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.hb-inspire-draft {
|
||||
|
|
@ -970,13 +1141,14 @@ svg {
|
|||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 聊天軟體列:輸入 + 右側圓形發送 */
|
||||
/* 聊天軟體列:輸入 + 右側圓形發送(與對話框底緣對齊) */
|
||||
.hb-inspire-composer__row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.45rem;
|
||||
align-items: end;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 本輪參考:多 pin 橫向捲 */
|
||||
|
|
@ -1130,9 +1302,12 @@ svg {
|
|||
|
||||
.hb-inspire-composer__row .hb-field {
|
||||
margin: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.hb-inspire-composer__row .hb-field__label:empty {
|
||||
|
|
@ -1141,30 +1316,36 @@ svg {
|
|||
|
||||
.hb-inspire-composer .hb-textarea {
|
||||
resize: none;
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 2.75rem;
|
||||
/* 明確蓋掉全域 .hb-textarea min-height: 6.5rem,與送出圓鈕同高對齊 */
|
||||
min-height: 2.75rem !important;
|
||||
max-height: 5.5rem;
|
||||
height: auto;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-radius: 1.15rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border-radius: 1.25rem;
|
||||
field-sizing: content;
|
||||
}
|
||||
|
||||
.hb-inspire-send-btn {
|
||||
flex-shrink: 0;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
margin-bottom: 0.1rem;
|
||||
flex: 0 0 2.75rem;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: flex-end;
|
||||
background: var(--hb-brand);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
line-height: 0;
|
||||
box-shadow: 0 1px 2px color-mix(in srgb, #000 12%, transparent);
|
||||
transition: opacity 0.12s ease, transform 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
|
|
@ -1183,12 +1364,18 @@ svg {
|
|||
|
||||
.hb-inspire-send-btn .hb-app-icon {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
/* 紙飛機視覺重心略偏左,微調置中 */
|
||||
transform: translate(1px, 0.5px);
|
||||
}
|
||||
|
||||
.hb-inspire-send-btn__dots {
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
width: 1em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hb-inspire-fp-bar__ok {
|
||||
|
|
@ -1476,13 +1663,14 @@ svg {
|
|||
}
|
||||
|
||||
.hb-inspire-composer .hb-textarea {
|
||||
min-height: 2.5rem;
|
||||
min-height: 2.5rem !important;
|
||||
max-height: 4.5rem;
|
||||
}
|
||||
|
||||
.hb-inspire-send-btn {
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
flex-basis: 2.5rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2747,6 +2935,19 @@ svg {
|
|||
color: var(--hb-muted);
|
||||
}
|
||||
|
||||
.hb-usage-sub__caps {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: var(--hb-text-xs);
|
||||
color: var(--hb-text);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.hb-usage-sub__caps--pts {
|
||||
font-weight: 500;
|
||||
color: var(--hb-muted);
|
||||
}
|
||||
|
||||
.hb-usage-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -2819,6 +3020,13 @@ svg {
|
|||
font-weight: 750;
|
||||
}
|
||||
|
||||
.hb-plans-page__caps {
|
||||
flex-basis: 100%;
|
||||
font-size: var(--hb-text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--hb-text);
|
||||
}
|
||||
|
||||
.hb-plans-page__back {
|
||||
margin-left: auto;
|
||||
font-size: var(--hb-text-xs);
|
||||
|
|
@ -2917,6 +3125,20 @@ svg {
|
|||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hb-pricing__softcaps {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: var(--hb-text-xs);
|
||||
font-weight: 650;
|
||||
line-height: 1.4;
|
||||
color: var(--hb-text);
|
||||
}
|
||||
|
||||
.hb-pricing__softcaps--pts {
|
||||
margin-top: 0.15rem;
|
||||
font-weight: 500;
|
||||
color: var(--hb-muted);
|
||||
}
|
||||
|
||||
.hb-pricing__bullets {
|
||||
margin: 0;
|
||||
padding: 0 0 0 1.05rem;
|
||||
|
|
@ -3185,6 +3407,19 @@ svg {
|
|||
color: var(--hb-brand);
|
||||
}
|
||||
|
||||
.hb-usage-mbar__unit {
|
||||
margin-left: 0.1rem;
|
||||
font-size: var(--hb-text-xs);
|
||||
color: var(--hb-muted);
|
||||
}
|
||||
|
||||
.hb-usage-mbar__over {
|
||||
margin-left: 0.35rem;
|
||||
font-size: var(--hb-text-xs);
|
||||
font-weight: 700;
|
||||
color: color-mix(in srgb, #d4524a 85%, var(--hb-text));
|
||||
}
|
||||
|
||||
.hb-usage-ppick {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
|
@ -3890,6 +4125,17 @@ a.hb-today-metric:hover {
|
|||
}
|
||||
|
||||
/* —— 帳號成效 / 月比圖 —— */
|
||||
.hb-insights-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hb-insights-status {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hb-insights-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
|
|
@ -3897,6 +4143,11 @@ a.hb-today-metric:hover {
|
|||
align-items: end;
|
||||
}
|
||||
|
||||
.hb-insights-toolbar__hint {
|
||||
min-width: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.hb-insights-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
|
|
|
|||
|
|
@ -549,6 +549,19 @@ a.hb-topbar__chip:hover {
|
|||
color: var(--hb-text);
|
||||
}
|
||||
|
||||
.hb-usage-widget__caps {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.4;
|
||||
color: var(--hb-text);
|
||||
}
|
||||
|
||||
.hb-usage-widget__caps--pts {
|
||||
font-weight: 500;
|
||||
color: var(--hb-muted);
|
||||
}
|
||||
|
||||
.hb-usage-widget__nudge {
|
||||
margin: 0;
|
||||
padding: 0.45rem 0.55rem;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|------|-----|
|
||||
| **Goal** | 依 `apps/web` **現 UI MUST** 交付真後端(Phase C live) |
|
||||
| **Phase** | `ready-to-implement` |
|
||||
| **Status** | **M0–M5 done** · 下一槍 M6(邀請/冒煙/契約閘) |
|
||||
| **Status** | **M0–M6 done**(邀請 live + IV 閘 + smoke) |
|
||||
| **Backend** | **`apps/backend`** |
|
||||
| **Updated** | 2026-07-10 |
|
||||
|
||||
|
|
|
|||
|
|
@ -71,20 +71,20 @@
|
|||
| T195 | scout outreach homework | M5 | feat | done | ~160 |
|
||||
| T196 | test m5 inspire scout integration | M5 | test | done | ~200 |
|
||||
| T197 | fe live scout inspire | M5 | feat | done | ~150 |
|
||||
| T215 | invite member network apply | M6 | feat | todo | ~160 |
|
||||
| T216 | invite admin tree move | M6 | feat | todo | ~180 |
|
||||
| T217 | fe live invite | M6 | feat | todo | ~120 |
|
||||
| T218 | smoke p0 p1 | M6 | test | todo | ~150 |
|
||||
| T219 | test m6 invite integration | M6 | test | todo | ~160 |
|
||||
| T220 | test fe be live integration | M6 | test | todo | ~200 |
|
||||
| T221 | test contract security x | M6 | test | todo | ~150 |
|
||||
| T222 | acceptance matrix ci gate | M6 | test | todo | ~180 |
|
||||
| T215 | invite member network apply | M6 | feat | done | ~160 |
|
||||
| T216 | invite admin tree move | M6 | feat | done | ~180 |
|
||||
| T217 | fe live invite | M6 | feat | done | ~120 |
|
||||
| T218 | smoke p0 p1 | M6 | test | done | ~150 |
|
||||
| T219 | test m6 invite integration | M6 | test | done | ~160 |
|
||||
| T220 | test fe be live integration | M6 | test | done | ~200 |
|
||||
| T221 | test contract security x | M6 | test | done | ~150 |
|
||||
| T222 | acceptance matrix ci gate | M6 | test | done | ~180 |
|
||||
|
||||
## Counts
|
||||
|
||||
- **Total: 59**(feat 47 · test 12)
|
||||
- **done: 35**(M0 + M1 + M2 + M3)
|
||||
- **todo: 24**
|
||||
- **done: 59**(M0–M6)
|
||||
- **todo: 0**
|
||||
|
||||
## Milestone map
|
||||
|
||||
|
|
@ -94,19 +94,20 @@
|
|||
| M1 會員 Admin 設定 | T110–T118 | T119 | **done** |
|
||||
| M2 OAuth Job 通知 FE 地基 | T130–T135 | T136 | **done** |
|
||||
| M3 用量 BYOK AI 真上傳 擴充 | T150–T155 T157 T158 | T156 | **done** |
|
||||
| M4 創作 Outbox 真發 | T165–T171 | T172 |
|
||||
| M5 靈感(現UI) 海巡 | T190–T195 T197 | T196 |
|
||||
| M6 邀請 整合 矩陣 | T215–T217 | T218–T222 |
|
||||
| M4 創作 Outbox 真發 | T165–T171 | T172 | **done** |
|
||||
| M5 靈感(現UI) 海巡 | T190–T195 T197 | T196 | **done** |
|
||||
| M6 邀請 整合 矩陣 | T215–T217 | T218–T222 | **done** |
|
||||
|
||||
## 建議開工序
|
||||
|
||||
1. ~~T100 → T108(M0)~~ **done**
|
||||
2. ~~T110 → T119(M1)~~ **done**(含 FE live auth/admin/settings)
|
||||
2. ~~T110 → T119(M1)~~ **done**
|
||||
3. ~~T130 → T136(M2)~~ **done**
|
||||
4. ~~T150 → T158(M3)~~ **done**(用量 platform/byok、AI/Exa proxy、方案購買、擴充 ZIP)
|
||||
5. 下一槍:**M6** T215(邀請 live/冒煙/契約閘)
|
||||
6. 每 M 功能做完必做該 M 的 **test** task 才算 M 完成
|
||||
7. 使用者指定 `T###` 才寫碼
|
||||
4. ~~T150 → T158(M3)~~ **done**
|
||||
5. ~~T165 → T172(M4)~~ **done**
|
||||
6. ~~T190 → T197(M5)~~ **done**
|
||||
7. ~~T215 → T222(M6)~~ **done**(邀請 live + IV 測試 + smoke)
|
||||
|
||||
|
||||
## Remove 提醒(禁止當 task 交付)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue