fix all bug
This commit is contained in:
parent
ee527ed988
commit
7defbed562
|
|
@ -57,6 +57,8 @@ type (
|
|||
PostCode string `json:"post_code,optional"`
|
||||
PreferredLanguage string `json:"preferred_language,optional"`
|
||||
Currency string `json:"currency,optional"`
|
||||
// skipped | completed;只寫一次,已結束後再送會被忽略
|
||||
OnboardingStatus string `json:"onboarding_status,optional"`
|
||||
CurrentPassword string `json:"current_password,optional"`
|
||||
NewPassword string `json:"new_password,optional"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ type MemberPublic {
|
|||
PostCode string `json:"post_code,optional"`
|
||||
PreferredLanguage string `json:"preferred_language,optional"`
|
||||
Currency string `json:"currency,optional"`
|
||||
// pending | skipped | completed;空=尚未寫入(視同 pending,舊帳可後端補 completed)
|
||||
OnboardingStatus string `json:"onboarding_status,optional"`
|
||||
OnboardingDoneAt int64 `json:"onboarding_done_at,optional"`
|
||||
Identities []IdentityPublic `json:"identities,optional"`
|
||||
JoinedAt int64 `json:"joined_at,optional"`
|
||||
CreatedAt int64 `json:"created_at,optional"`
|
||||
|
|
|
|||
|
|
@ -285,6 +285,8 @@ type (
|
|||
ScheduleStartAt int64 `json:"schedule_start_at,optional"`
|
||||
// Threads 話題標籤(topic_tag,1~50 字,可不加 #)
|
||||
TopicTag string `json:"topic_tag,optional"`
|
||||
// everyone | accounts_you_follow | mentioned_only | parent_post_author_only | followers_only
|
||||
ReplyControl string `json:"reply_control,optional"`
|
||||
}
|
||||
|
||||
// --- Own posts ---
|
||||
|
|
@ -299,6 +301,8 @@ type (
|
|||
RepliedAt int64 `json:"replied_at,optional"`
|
||||
ParentReplyId string `json:"parent_reply_id,optional"`
|
||||
IsMine bool `json:"is_mine,optional"`
|
||||
// Threads hide_status:NOT_HUSHED / HIDDEN / …
|
||||
HideStatus string `json:"hide_status,optional"`
|
||||
}
|
||||
|
||||
OwnPostPublic {
|
||||
|
|
@ -324,6 +328,8 @@ type (
|
|||
FormulaDetail string `json:"formula_detail,optional"`
|
||||
Replies []OwnPostReplyPublic `json:"replies"`
|
||||
PublishedAt int64 `json:"published_at"`
|
||||
// everyone | accounts_you_follow | mentioned_only | parent_post_author_only | followers_only
|
||||
ReplyControl string `json:"reply_control,optional"`
|
||||
}
|
||||
|
||||
OwnPostListData {
|
||||
|
|
@ -369,6 +375,17 @@ type (
|
|||
PostId string `json:"post_id"`
|
||||
}
|
||||
|
||||
OwnPostManageReplyReq {
|
||||
PostId string `json:"post_id"`
|
||||
ReplyId string `json:"reply_id"`
|
||||
Hide bool `json:"hide"`
|
||||
}
|
||||
|
||||
OwnPostSetReplyControlReq {
|
||||
PostId string `json:"post_id"`
|
||||
ReplyControl string `json:"reply_control"`
|
||||
}
|
||||
|
||||
// --- Mentions ---
|
||||
MentionPublic {
|
||||
Id string `json:"id"`
|
||||
|
|
@ -548,6 +565,12 @@ service gateway {
|
|||
|
||||
@handler OwnPostLoadReplies
|
||||
post /load-replies (OwnPostLoadRepliesReq) returns (OwnPostPublic)
|
||||
|
||||
@handler OwnPostManageReply
|
||||
post /manage-reply (OwnPostManageReplyReq) returns (OwnPostPublic)
|
||||
|
||||
@handler OwnPostSetReplyControl
|
||||
post /reply-control (OwnPostSetReplyControlReq) returns (OwnPostPublic)
|
||||
}
|
||||
|
||||
@server (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
// Code generated by goctl. DO NOT EDIT.
|
||||
// goctl <no value>
|
||||
|
||||
package ownposts
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"apps/backend/internal/logic/ownposts"
|
||||
"apps/backend/internal/response"
|
||||
"apps/backend/internal/svc"
|
||||
"apps/backend/internal/types"
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
)
|
||||
|
||||
func OwnPostManageReplyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.OwnPostManageReplyReq
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||
return
|
||||
}
|
||||
|
||||
l := ownposts.NewOwnPostManageReplyLogic(r.Context(), svcCtx)
|
||||
data, err := l.OwnPostManageReply(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
// Code generated by goctl. DO NOT EDIT.
|
||||
// goctl <no value>
|
||||
|
||||
package ownposts
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"apps/backend/internal/logic/ownposts"
|
||||
"apps/backend/internal/response"
|
||||
"apps/backend/internal/svc"
|
||||
"apps/backend/internal/types"
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
)
|
||||
|
||||
func OwnPostSetReplyControlHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.OwnPostSetReplyControlReq
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||
return
|
||||
}
|
||||
|
||||
l := ownposts.NewOwnPostSetReplyControlLogic(r.Context(), svcCtx)
|
||||
data, err := l.OwnPostSetReplyControl(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -809,6 +809,16 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
Path: "/load-replies",
|
||||
Handler: ownposts.OwnPostLoadRepliesHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/manage-reply",
|
||||
Handler: ownposts.OwnPostManageReplyHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/reply-control",
|
||||
Handler: ownposts.OwnPostSetReplyControlHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/send-reply",
|
||||
|
|
@ -1540,6 +1550,35 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
rest.WithPrefix("/api/v1/utm"),
|
||||
)
|
||||
|
||||
server.AddRoutes(
|
||||
rest.WithMiddlewares(
|
||||
[]rest.Middleware{serverCtx.AuthJWT},
|
||||
[]rest.Route{
|
||||
{
|
||||
Method: http.MethodPut,
|
||||
Path: "/:id/branding",
|
||||
Handler: workspaces.UpdateWorkspaceBrandingHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/:id/members",
|
||||
Handler: workspaces.ListWorkspaceMembersHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/members",
|
||||
Handler: workspaces.AddWorkspaceMemberHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodDelete,
|
||||
Path: "/:id/members/:uid",
|
||||
Handler: workspaces.RemoveWorkspaceMemberHandler(serverCtx),
|
||||
},
|
||||
}...,
|
||||
),
|
||||
rest.WithPrefix("/api/v1/workspaces"),
|
||||
)
|
||||
|
||||
server.AddRoutes(
|
||||
rest.WithMiddlewares(
|
||||
[]rest.Middleware{serverCtx.AuthJWT},
|
||||
|
|
@ -1598,33 +1637,4 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
),
|
||||
rest.WithPrefix("/api/v1/workspaces"),
|
||||
)
|
||||
|
||||
server.AddRoutes(
|
||||
rest.WithMiddlewares(
|
||||
[]rest.Middleware{serverCtx.AuthJWT},
|
||||
[]rest.Route{
|
||||
{
|
||||
Method: http.MethodPut,
|
||||
Path: "/:id/branding",
|
||||
Handler: workspaces.UpdateWorkspaceBrandingHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/:id/members",
|
||||
Handler: workspaces.ListWorkspaceMembersHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/members",
|
||||
Handler: workspaces.AddWorkspaceMemberHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodDelete,
|
||||
Path: "/:id/members/:uid",
|
||||
Handler: workspaces.RemoveWorkspaceMemberHandler(serverCtx),
|
||||
},
|
||||
}...,
|
||||
),
|
||||
rest.WithPrefix("/api/v1/workspaces"),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ func (l *LoginLogic) Login(req *types.AuthLoginReq) (resp *types.AuthSessionData
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m = ensureOnboarding(l.ctx, l.svcCtx, m)
|
||||
ids, _ := l.svcCtx.Auth.ListIdentities(l.ctx, m.UID)
|
||||
return &types.AuthSessionData{
|
||||
Tokens: types.TokenPairFromAuth(pair.AccessToken, pair.RefreshToken, pair.TokenType, pair.ExpiresIn),
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ func (l *MeLogic) Me() (resp *types.MemberPublic, err error) {
|
|||
return nil, err
|
||||
}
|
||||
}
|
||||
m = ensureOnboarding(l.ctx, l.svcCtx, m)
|
||||
ids, _ := l.svcCtx.Auth.ListIdentities(l.ctx, m.UID)
|
||||
return types.MemberFromModelWithIdentities(m, ids), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
memberDomain "apps/backend/internal/module/member/domain"
|
||||
radarDomain "apps/backend/internal/module/radar/domain"
|
||||
"apps/backend/internal/svc"
|
||||
)
|
||||
|
||||
// ensureOnboarding fills completed for legacy members with an empty status who
|
||||
// already have a brand, product, or demand watch. An explicit pending stays
|
||||
// pending so a reset (for retest) is not immediately overwritten.
|
||||
func ensureOnboarding(ctx context.Context, svcCtx *svc.ServiceContext, m *memberDomain.Member) *memberDomain.Member {
|
||||
if m == nil || m.OnboardingStatus != "" {
|
||||
return m
|
||||
}
|
||||
if !hasExistingSetup(ctx, svcCtx, m.UID) {
|
||||
return m
|
||||
}
|
||||
status := memberDomain.OnboardingCompleted
|
||||
updated, err := svcCtx.Auth.UpdateUserInfo(ctx, m.UID, &memberDomain.UpdateUserInfoPatch{
|
||||
OnboardingStatus: &status,
|
||||
})
|
||||
if err != nil || updated == nil {
|
||||
return m
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func hasExistingSetup(ctx context.Context, svcCtx *svc.ServiceContext, uid int64) bool {
|
||||
if svcCtx == nil || uid <= 0 {
|
||||
return false
|
||||
}
|
||||
if svcCtx.Scout != nil {
|
||||
if brands, err := svcCtx.Scout.ListBrands(ctx, uid); err == nil && len(brands) > 0 {
|
||||
return true
|
||||
}
|
||||
if products, err := svcCtx.Scout.ListAllProducts(ctx, uid); err == nil && len(products) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if svcCtx.Radar != nil {
|
||||
if _, total, err := svcCtx.Radar.ListWatches(ctx, uid, radarDomain.WatchListFilter{Page: 1, PageSize: 1}); err == nil && total > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ func (l *ComposePublishSingleLogic) ComposePublishSingle(req *types.ComposePubli
|
|||
if !ok {
|
||||
return nil, response.Biz(401, 401001, "missing authorization")
|
||||
}
|
||||
b, err := l.svcCtx.Studio.PublishSingle(l.ctx, uid, req.AccountId, req.Text, req.Title, req.ImageUrls, req.ScheduleStartAt, req.TopicTag)
|
||||
b, err := l.svcCtx.Studio.PublishSingle(l.ctx, uid, req.AccountId, req.Text, req.Title, req.ImageUrls, req.ScheduleStartAt, req.TopicTag, req.ReplyControl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
package ownposts
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"apps/backend/internal/middleware"
|
||||
"apps/backend/internal/response"
|
||||
"apps/backend/internal/svc"
|
||||
"apps/backend/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
type OwnPostManageReplyLogic struct {
|
||||
logx.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewOwnPostManageReplyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *OwnPostManageReplyLogic {
|
||||
return &OwnPostManageReplyLogic{
|
||||
Logger: logx.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *OwnPostManageReplyLogic) OwnPostManageReply(req *types.OwnPostManageReplyReq) (*types.OwnPostPublic, error) {
|
||||
if l.svcCtx.Studio == nil {
|
||||
return nil, response.Biz(503, 503001, "studio not configured")
|
||||
}
|
||||
uid, ok := middleware.UIDFrom(l.ctx)
|
||||
if !ok {
|
||||
return nil, response.Biz(401, 401001, "missing authorization")
|
||||
}
|
||||
p, err := l.svcCtx.Studio.ManageOwnPostReply(l.ctx, uid, req.PostId, req.ReplyId, req.Hide)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := types.OwnPostFromDomain(p)
|
||||
return &out, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package ownposts
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"apps/backend/internal/middleware"
|
||||
"apps/backend/internal/response"
|
||||
"apps/backend/internal/svc"
|
||||
"apps/backend/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
type OwnPostSetReplyControlLogic struct {
|
||||
logx.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewOwnPostSetReplyControlLogic(ctx context.Context, svcCtx *svc.ServiceContext) *OwnPostSetReplyControlLogic {
|
||||
return &OwnPostSetReplyControlLogic{
|
||||
Logger: logx.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *OwnPostSetReplyControlLogic) OwnPostSetReplyControl(req *types.OwnPostSetReplyControlReq) (*types.OwnPostPublic, error) {
|
||||
if l.svcCtx.Studio == nil {
|
||||
return nil, response.Biz(503, 503001, "studio not configured")
|
||||
}
|
||||
uid, ok := middleware.UIDFrom(l.ctx)
|
||||
if !ok {
|
||||
return nil, response.Biz(401, 401001, "missing authorization")
|
||||
}
|
||||
p, err := l.svcCtx.Studio.SetOwnPostReplyControl(l.ctx, uid, req.PostId, req.ReplyControl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := types.OwnPostFromDomain(p)
|
||||
return &out, nil
|
||||
}
|
||||
|
|
@ -35,6 +35,17 @@ const (
|
|||
RoleAdmin = "admin"
|
||||
)
|
||||
|
||||
// First-run work trail — write-once on the member.
|
||||
const (
|
||||
OnboardingPending = "pending"
|
||||
OnboardingSkipped = "skipped"
|
||||
OnboardingCompleted = "completed"
|
||||
)
|
||||
|
||||
func IsOnboardingTerminal(status string) bool {
|
||||
return status == OnboardingSkipped || status == OnboardingCompleted
|
||||
}
|
||||
|
||||
// MinMemberUID — 會員 uid 從一百萬起,固定 8 位數(1000000–99999999)。
|
||||
// 顯示可用 %08d;內部 JSON 仍是 number。
|
||||
const MinMemberUID int64 = 1_000_000
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ type Member struct {
|
|||
PostCode string `bson:"post_code,omitempty" json:"post_code,omitempty"`
|
||||
PreferredLanguage string `bson:"preferred_language,omitempty" json:"preferred_language,omitempty"`
|
||||
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
|
||||
// OnboardingStatus: pending | skipped | completed. Empty is treated as pending.
|
||||
OnboardingStatus string `bson:"onboarding_status,omitempty" json:"onboarding_status,omitempty"`
|
||||
OnboardingDoneAt int64 `bson:"onboarding_done_at,omitempty" json:"onboarding_done_at,omitempty"`
|
||||
// Primary password for platform email login (also mirrored on Identity).
|
||||
// Must be JSON-tagged: monc Redis cache marshals with encoding/json;
|
||||
// json:"-" strips the hash and makes warm-cache login always fail.
|
||||
|
|
|
|||
|
|
@ -85,6 +85,8 @@ type UpdateUserInfoPatch struct {
|
|||
PostCode *string
|
||||
PreferredLanguage *string
|
||||
Currency *string
|
||||
OnboardingStatus *string
|
||||
OnboardingDoneAt *int64
|
||||
Email *string
|
||||
Phone *string
|
||||
CurrentPassword *string
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ func (s *AccountService) CreateUserAccount(ctx context.Context, req domain.Creat
|
|||
DisplayName: display, Roles: []string{domain.RoleMember}, Status: status,
|
||||
EmailVerified: false, InviteCode: NewInviteCode(), PasswordHash: passHash,
|
||||
Timezone: "Asia/Taipei", PreferredLanguage: "zh-TW", Currency: "TWD",
|
||||
OnboardingStatus: domain.OnboardingPending,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if err := s.Repo.CreateMember(ctx, m); err != nil {
|
||||
|
|
@ -359,6 +360,17 @@ func (s *AccountService) UpdateUserInfo(ctx context.Context, uid int64, patch *d
|
|||
if patch.Currency != nil {
|
||||
m.Currency = *patch.Currency
|
||||
}
|
||||
if patch.OnboardingStatus != nil && !domain.IsOnboardingTerminal(m.OnboardingStatus) {
|
||||
next := strings.TrimSpace(*patch.OnboardingStatus)
|
||||
if next == domain.OnboardingSkipped || next == domain.OnboardingCompleted {
|
||||
m.OnboardingStatus = next
|
||||
if patch.OnboardingDoneAt != nil && *patch.OnboardingDoneAt > 0 {
|
||||
m.OnboardingDoneAt = *patch.OnboardingDoneAt
|
||||
} else if m.OnboardingDoneAt == 0 {
|
||||
m.OnboardingDoneAt = domain.NowNano()
|
||||
}
|
||||
}
|
||||
}
|
||||
if patch.Email != nil {
|
||||
newEmail := strings.ToLower(strings.TrimSpace(*patch.Email))
|
||||
if newEmail != "" && newEmail != m.Email {
|
||||
|
|
|
|||
|
|
@ -306,6 +306,21 @@ func TestAP_07_ChangePasswordSuccess(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestAP_08b_OnboardingWriteOnce(t *testing.T) {
|
||||
store, svc := newM1Svc()
|
||||
m, _ := seedMember(t, store, svc, "ap08b@test.local")
|
||||
require.Equal(t, domain.OnboardingPending, m.OnboardingStatus)
|
||||
skipped := domain.OnboardingSkipped
|
||||
out, err := svc.UpdateUserInfo(context.Background(), m.UID, &domain.UpdateUserInfoPatch{OnboardingStatus: &skipped})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, domain.OnboardingSkipped, out.OnboardingStatus)
|
||||
require.Greater(t, out.OnboardingDoneAt, int64(0))
|
||||
completed := domain.OnboardingCompleted
|
||||
out2, err := svc.UpdateUserInfo(context.Background(), m.UID, &domain.UpdateUserInfoPatch{OnboardingStatus: &completed})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, domain.OnboardingSkipped, out2.OnboardingStatus)
|
||||
}
|
||||
|
||||
func TestAP_08_AvatarURLSetAndClear(t *testing.T) {
|
||||
store, svc := newM1Svc()
|
||||
m, _ := seedMember(t, store, svc, "ap08@test.local")
|
||||
|
|
|
|||
|
|
@ -57,22 +57,22 @@ func NowNano() int64 { return time.Now().UTC().UnixNano() }
|
|||
|
||||
// Persona — PE domain
|
||||
type Persona struct {
|
||||
ID string `bson:"_id" json:"id"`
|
||||
OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Brief string `bson:"brief" json:"brief"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
Style PersonaStyle `bson:"style" json:"style"`
|
||||
Guard PersonaGuard `bson:"guard" json:"guard"`
|
||||
Voice string `bson:"voice,omitempty" json:"voice,omitempty"`
|
||||
Notes string `bson:"notes,omitempty" json:"notes,omitempty"`
|
||||
ID string `bson:"_id" json:"id"`
|
||||
OwnerUID int64 `bson:"owner_uid" json:"owner_uid"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
Brief string `bson:"brief" json:"brief"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
Style PersonaStyle `bson:"style" json:"style"`
|
||||
Guard PersonaGuard `bson:"guard" json:"guard"`
|
||||
Voice string `bson:"voice,omitempty" json:"voice,omitempty"`
|
||||
Notes string `bson:"notes,omitempty" json:"notes,omitempty"`
|
||||
// Learning — 資產複利可見(growth-loop)
|
||||
LearningSummary string `bson:"learning_summary,omitempty" json:"learning_summary,omitempty"`
|
||||
LearningVersion int `bson:"learning_version,omitempty" json:"learning_version,omitempty"`
|
||||
LearnedFromPostsCount int `bson:"learned_from_posts_count,omitempty" json:"learned_from_posts_count,omitempty"`
|
||||
LastLearnedAt int64 `bson:"last_learned_at,omitempty" json:"last_learned_at,omitempty"`
|
||||
CreatedAt int64 `bson:"created_at" json:"created_at"`
|
||||
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
|
||||
LearningSummary string `bson:"learning_summary,omitempty" json:"learning_summary,omitempty"`
|
||||
LearningVersion int `bson:"learning_version,omitempty" json:"learning_version,omitempty"`
|
||||
LearnedFromPostsCount int `bson:"learned_from_posts_count,omitempty" json:"learned_from_posts_count,omitempty"`
|
||||
LastLearnedAt int64 `bson:"last_learned_at,omitempty" json:"last_learned_at,omitempty"`
|
||||
CreatedAt int64 `bson:"created_at" json:"created_at"`
|
||||
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
type PersonaStyle struct {
|
||||
|
|
@ -172,6 +172,8 @@ type OutboxStep struct {
|
|||
Text string `bson:"text" json:"text"`
|
||||
// TopicTag — 主貼可用的 Threads 話題標籤
|
||||
TopicTag string `bson:"topic_tag,omitempty" json:"topic_tag,omitempty"`
|
||||
// ReplyControl — 主貼 who-can-reply(Threads reply_control,發文時寫入)
|
||||
ReplyControl string `bson:"reply_control,omitempty" json:"reply_control,omitempty"`
|
||||
// ImageURLs — 公開 https 圖網址(Meta image_url 可抓;勿存 data URL)
|
||||
ImageURLs []string `bson:"image_urls,omitempty" json:"image_urls,omitempty"`
|
||||
Status string `bson:"status" json:"status"`
|
||||
|
|
@ -213,6 +215,8 @@ type OwnPostReply struct {
|
|||
RepliedAt int64 `bson:"replied_at,omitempty" json:"replied_at,omitempty"`
|
||||
ParentReplyID string `bson:"parent_reply_id,omitempty" json:"parent_reply_id,omitempty"`
|
||||
IsMine bool `bson:"is_mine,omitempty" json:"is_mine,omitempty"`
|
||||
// Threads hide_status:NOT_HUSHED / HIDDEN / BLOCKED / …
|
||||
HideStatus string `bson:"hide_status,omitempty" json:"hide_status,omitempty"`
|
||||
}
|
||||
|
||||
type OwnPost struct {
|
||||
|
|
@ -239,6 +243,8 @@ type OwnPost struct {
|
|||
FormulaDetail string `bson:"formula_detail,omitempty" json:"formula_detail,omitempty"`
|
||||
Replies []OwnPostReply `bson:"replies" json:"replies"`
|
||||
PublishedAt int64 `bson:"published_at" json:"published_at"`
|
||||
// everyone | accounts_you_follow | mentioned_only | parent_post_author_only | followers_only
|
||||
ReplyControl string `bson:"reply_control,omitempty" json:"reply_control,omitempty"`
|
||||
}
|
||||
|
||||
type Mention struct {
|
||||
|
|
@ -289,6 +295,8 @@ type PublishRequest struct {
|
|||
ReplyTo string // empty = root post
|
||||
// TopicTag — Threads 話題標籤(API topic_tag;1~50 字,不可含 . &)
|
||||
TopicTag string
|
||||
// ReplyControl — 主貼 who-can-reply(發文時帶入 container)
|
||||
ReplyControl string
|
||||
// ImageURLs — 公開可抓的 http(s) 圖;空=純文字 TEXT
|
||||
ImageURLs []string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ func (t *MetaTransport) Publish(ctx context.Context, req domain.PublishRequest)
|
|||
if topicTag != "" {
|
||||
form.Set("topic_tag", topicTag)
|
||||
}
|
||||
setReplyControl(form, req)
|
||||
containerID, err = t.createContainer(ctx, createURL, form)
|
||||
wait = 8 * time.Second
|
||||
case len(images) == 1:
|
||||
|
|
@ -100,6 +101,7 @@ func (t *MetaTransport) Publish(ctx context.Context, req domain.PublishRequest)
|
|||
if topicTag != "" {
|
||||
form.Set("topic_tag", topicTag)
|
||||
}
|
||||
setReplyControl(form, req)
|
||||
containerID, err = t.createContainer(ctx, createURL, form)
|
||||
wait = 30 * time.Second
|
||||
default:
|
||||
|
|
@ -138,6 +140,7 @@ func (t *MetaTransport) Publish(ctx context.Context, req domain.PublishRequest)
|
|||
if topicTag != "" {
|
||||
form.Set("topic_tag", topicTag)
|
||||
}
|
||||
setReplyControl(form, req)
|
||||
containerID, err = t.createContainer(ctx, createURL, form)
|
||||
wait = 30 * time.Second
|
||||
}
|
||||
|
|
@ -328,6 +331,17 @@ func normalizeTopicTag(raw string) string {
|
|||
return s
|
||||
}
|
||||
|
||||
func setReplyControl(form url.Values, req domain.PublishRequest) {
|
||||
if strings.TrimSpace(req.ReplyTo) != "" {
|
||||
return
|
||||
}
|
||||
c := strings.ToLower(strings.TrimSpace(req.ReplyControl))
|
||||
switch c {
|
||||
case "everyone", "accounts_you_follow", "mentioned_only", "parent_post_author_only", "followers_only":
|
||||
form.Set("reply_control", c)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MetaTransport) postForm(ctx context.Context, fullURL string, form url.Values) ([]byte, int, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -386,7 +386,7 @@ func TestPL_06_PublishScheduled(t *testing.T) {
|
|||
setupUID(svc, uid)
|
||||
addAccount(acc, uid, "acc1", "lead")
|
||||
future := domain.NowNano() + int64(2*time.Hour)
|
||||
b, err := svc.PublishSingle(context.Background(), uid, "acc1", "hello scheduled", "t", nil, future, "")
|
||||
b, err := svc.PublishSingle(context.Background(), uid, "acc1", "hello scheduled", "t", nil, future, "", "")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, b.Steps, 1)
|
||||
require.Equal(t, future, b.Steps[0].ScheduledAt)
|
||||
|
|
@ -398,7 +398,7 @@ func TestPL_07_PublishDefaultNow(t *testing.T) {
|
|||
setupUID(svc, uid)
|
||||
addAccount(acc, uid, "acc1", "lead")
|
||||
before := domain.NowNano()
|
||||
b, err := svc.PublishSingle(context.Background(), uid, "acc1", "now post", "", nil, 0, "")
|
||||
b, err := svc.PublishSingle(context.Background(), uid, "acc1", "now post", "", nil, 0, "", "")
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, float64(before), float64(b.Steps[0].ScheduledAt), float64(time.Second*2))
|
||||
}
|
||||
|
|
@ -407,7 +407,7 @@ func TestPL_08_NoUsableAccount(t *testing.T) {
|
|||
svc, _, _ := newStudio()
|
||||
uid := int64(4_001_008)
|
||||
setupUID(svc, uid)
|
||||
_, err := svc.PublishSingle(context.Background(), uid, "missing", "x", "", nil, 0, "")
|
||||
_, err := svc.PublishSingle(context.Background(), uid, "missing", "x", "", nil, 0, "", "")
|
||||
require.ErrorIs(t, err, domain.ErrNoAccount)
|
||||
}
|
||||
|
||||
|
|
@ -550,7 +550,7 @@ func TestOB_01_NotDue_NoTransport(t *testing.T) {
|
|||
setupUID(svc, uid)
|
||||
addAccount(acc, uid, "acc1", "lead")
|
||||
future := domain.NowNano() + int64(time.Hour)
|
||||
b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "later", "", nil, future, "")
|
||||
b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "later", "", nil, future, "", "")
|
||||
n, err := svc.ProcessDueSteps(context.Background(), domain.NowNano())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, n)
|
||||
|
|
@ -564,11 +564,12 @@ func TestOB_02_DueStep_PublishedViaTransport(t *testing.T) {
|
|||
uid := int64(4_003_002)
|
||||
setupUID(svc, uid)
|
||||
addAccount(acc, uid, "acc1", "lead")
|
||||
b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "now", "", nil, 0, "")
|
||||
b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "now", "", nil, 0, "", "accounts_you_follow")
|
||||
n, err := svc.ProcessDueSteps(context.Background(), domain.NowNano())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, n)
|
||||
require.Equal(t, 1, tp.CallCount()) // must go through transport
|
||||
require.Equal(t, "accounts_you_follow", tp.Calls[0].ReplyControl)
|
||||
got, _ := svc.GetOutbox(context.Background(), uid, b.ID)
|
||||
require.Equal(t, domain.StepPublished, got.Steps[0].Status)
|
||||
require.Equal(t, domain.OBCompleted, got.Status)
|
||||
|
|
@ -581,7 +582,7 @@ func TestOB_03_TransportFail(t *testing.T) {
|
|||
setupUID(svc, uid)
|
||||
addAccount(acc, uid, "acc1", "lead")
|
||||
tp.Fail = true
|
||||
b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "fail me", "", nil, 0, "")
|
||||
b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "fail me", "", nil, 0, "", "")
|
||||
_, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano())
|
||||
got, _ := svc.GetOutbox(context.Background(), uid, b.ID)
|
||||
require.Equal(t, domain.StepFailed, got.Steps[0].Status)
|
||||
|
|
@ -644,7 +645,7 @@ func TestOB_06_RetryStep(t *testing.T) {
|
|||
setupUID(svc, uid)
|
||||
addAccount(acc, uid, "acc1", "lead")
|
||||
tp.Fail = true
|
||||
b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "retry", "", nil, 0, "")
|
||||
b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "retry", "", nil, 0, "", "")
|
||||
_, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano())
|
||||
tp.Fail = false
|
||||
got, err := svc.RetryStep(context.Background(), uid, b.ID, b.Steps[0].ID)
|
||||
|
|
@ -661,7 +662,7 @@ func TestOB_07_RetryIllegalStatus(t *testing.T) {
|
|||
setupUID(svc, uid)
|
||||
addAccount(acc, uid, "acc1", "lead")
|
||||
// scheduled not failed
|
||||
b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "x", "", nil, domain.NowNano()+int64(time.Hour), "")
|
||||
b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "x", "", nil, domain.NowNano()+int64(time.Hour), "", "")
|
||||
_, err := svc.RetryStep(context.Background(), uid, b.ID, b.Steps[0].ID)
|
||||
require.ErrorIs(t, err, domain.ErrIllegalStatus)
|
||||
}
|
||||
|
|
@ -671,7 +672,7 @@ func TestOB_08_AllSuccessCompleted(t *testing.T) {
|
|||
uid := int64(4_003_008)
|
||||
setupUID(svc, uid)
|
||||
addAccount(acc, uid, "acc1", "lead")
|
||||
b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "done", "", nil, 0, "")
|
||||
b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "done", "", nil, 0, "", "")
|
||||
_, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano())
|
||||
got, _ := svc.GetOutbox(context.Background(), uid, b.ID)
|
||||
require.Equal(t, domain.OBCompleted, got.Status)
|
||||
|
|
@ -705,7 +706,7 @@ func TestOB_10_RemoveStopsWorker(t *testing.T) {
|
|||
uid := int64(4_003_010)
|
||||
setupUID(svc, uid)
|
||||
addAccount(acc, uid, "acc1", "lead")
|
||||
b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "rm", "", nil, 0, "")
|
||||
b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "rm", "", nil, 0, "", "")
|
||||
require.NoError(t, svc.RemoveOutbox(context.Background(), uid, b.ID))
|
||||
n, _ := svc.ProcessDueSteps(context.Background(), domain.NowNano())
|
||||
require.Equal(t, 0, n)
|
||||
|
|
@ -747,7 +748,7 @@ func TestOB_11_CrossUid(t *testing.T) {
|
|||
setupUID(svc, uid1)
|
||||
setupUID(svc, uid2)
|
||||
addAccount(acc, uid1, "acc1", "lead")
|
||||
b, _ := svc.PublishSingle(context.Background(), uid1, "acc1", "priv", "", nil, domain.NowNano(), "")
|
||||
b, _ := svc.PublishSingle(context.Background(), uid1, "acc1", "priv", "", nil, domain.NowNano(), "", "")
|
||||
_, err := svc.GetOutbox(context.Background(), uid2, b.ID)
|
||||
require.ErrorIs(t, err, domain.ErrForbidden)
|
||||
_, err = svc.RetryStep(context.Background(), uid2, b.ID, b.Steps[0].ID)
|
||||
|
|
@ -760,7 +761,7 @@ func TestOB_12_LiveRequiresTransport(t *testing.T) {
|
|||
uid := int64(4_003_013)
|
||||
setupUID(svc, uid)
|
||||
addAccount(acc, uid, "acc1", "lead")
|
||||
_, _ = svc.PublishSingle(context.Background(), uid, "acc1", "x", "", nil, 0, "")
|
||||
_, _ = svc.PublishSingle(context.Background(), uid, "acc1", "x", "", nil, 0, "", "")
|
||||
before := tp.CallCount()
|
||||
_, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano())
|
||||
require.Greater(t, tp.CallCount(), before)
|
||||
|
|
@ -773,11 +774,11 @@ func TestPL_PublishRejectsPastSchedule(t *testing.T) {
|
|||
addAccount(acc, uid, "acc1", "lead")
|
||||
// 超過 1 分鐘才算過期
|
||||
past := domain.NowNano() - int64(2*time.Minute)
|
||||
_, err := svc.PublishSingle(context.Background(), uid, "acc1", "too late", "", nil, past, "")
|
||||
_, err := svc.PublishSingle(context.Background(), uid, "acc1", "too late", "", nil, past, "", "")
|
||||
require.ErrorIs(t, err, domain.ErrValidation)
|
||||
// 略早於現在(grace 內)應可過,並 clamp 成現在
|
||||
slight := domain.NowNano() - int64(5*time.Second)
|
||||
b, err := svc.PublishSingle(context.Background(), uid, "acc1", "almost now", "", nil, slight, "")
|
||||
b, err := svc.PublishSingle(context.Background(), uid, "acc1", "almost now", "", nil, slight, "", "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, b)
|
||||
}
|
||||
|
|
@ -909,6 +910,44 @@ func TestOP_07_SyncOtherAccountForbidden(t *testing.T) {
|
|||
require.ErrorIs(t, err, domain.ErrForbidden)
|
||||
}
|
||||
|
||||
func TestOP_ManageReplyAndReplyControl(t *testing.T) {
|
||||
svc, _, acc := newStudio()
|
||||
uid := int64(4_004_031)
|
||||
setupUID(svc, uid)
|
||||
addAccount(acc, uid, "acc1", "me")
|
||||
list, err := svc.SyncOwnPosts(context.Background(), uid, "acc1")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, list)
|
||||
require.NotEmpty(t, list[0].Replies)
|
||||
|
||||
_, err = svc.ManageOwnPostReply(context.Background(), uid, list[0].ID, list[0].MediaID, true)
|
||||
require.Error(t, err)
|
||||
|
||||
post, err := svc.ManageOwnPostReply(context.Background(), uid, list[0].ID, list[0].Replies[0].ID, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "HIDDEN", post.Replies[0].HideStatus)
|
||||
|
||||
post, err = svc.ManageOwnPostReply(context.Background(), uid, list[0].ID, list[0].Replies[0].ID, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "NOT_HUSHED", post.Replies[0].HideStatus)
|
||||
|
||||
post, err = svc.SetOwnPostReplyControl(context.Background(), uid, list[0].ID, "accounts_you_follow")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "accounts_you_follow", post.ReplyControl)
|
||||
|
||||
_, err = svc.SetOwnPostReplyControl(context.Background(), uid, list[0].ID, "not-a-value")
|
||||
require.Error(t, err)
|
||||
|
||||
svc.Media = &fakeInsightsMedia{}
|
||||
_, err = svc.SetOwnPostReplyControl(context.Background(), uid, list[0].ID, "everyone")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "發文時")
|
||||
|
||||
b, err := svc.PublishSingle(context.Background(), uid, "acc1", "who can reply", "", nil, 0, "", "accounts_you_follow")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "accounts_you_follow", b.Steps[0].ReplyControl)
|
||||
}
|
||||
|
||||
func TestOP_08_AnalyzePost(t *testing.T) {
|
||||
svc, _, acc := newStudio()
|
||||
uid := int64(4_004_009)
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ type ThreadsMediaSource interface {
|
|||
type FetchedThread struct {
|
||||
ID, Text, MediaType, MediaURL, ThumbnailURL, Permalink, Shortcode, TopicTag, Username string
|
||||
PublishedAt int64 // unix ns
|
||||
ReplyControl string
|
||||
}
|
||||
|
||||
type FetchedInsights struct {
|
||||
|
|
@ -64,6 +65,13 @@ type FetchedReply struct {
|
|||
PublishedAt int64
|
||||
IsMine bool
|
||||
LikeCount int
|
||||
HideStatus string
|
||||
}
|
||||
|
||||
// ThreadsReplyManager is optional; live Meta bridge implements hide / reply_control.
|
||||
type ThreadsReplyManager interface {
|
||||
ManageReply(ctx context.Context, accessToken, replyID string, hide bool) error
|
||||
SetReplyControl(ctx context.Context, accessToken, mediaID, control string) error
|
||||
}
|
||||
|
||||
// FetchedMention — Graph /{user-id}/mentions
|
||||
|
|
@ -1303,6 +1311,12 @@ func (s *Service) processBundle(ctx context.Context, b *domain.OutboxBundle, now
|
|||
}
|
||||
return ""
|
||||
}(),
|
||||
ReplyControl: func() string {
|
||||
if result.Kind == domain.StepRoot {
|
||||
return result.ReplyControl
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
}
|
||||
res, perr := s.publishWithLease(ctx, b.ID, result.ID, claimOwner, leaseDuration, request)
|
||||
if perr != nil {
|
||||
|
|
@ -1679,7 +1693,7 @@ func formatViralFormulaDetail(va *domain.ViralAnalysis) string {
|
|||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
func (s *Service) PublishSingle(ctx context.Context, ownerUID int64, accountID, text, title string, imageURLs []string, scheduleStartAt int64, topicTag string) (*domain.OutboxBundle, error) {
|
||||
func (s *Service) PublishSingle(ctx context.Context, ownerUID int64, accountID, text, title string, imageURLs []string, scheduleStartAt int64, topicTag, replyControl string) (*domain.OutboxBundle, error) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("%w: empty text", domain.ErrValidation)
|
||||
|
|
@ -1746,6 +1760,9 @@ func (s *Service) PublishSingle(ctx context.Context, ownerUID int64, accountID,
|
|||
if tag != "" && len(bundle.Steps) > 0 {
|
||||
bundle.Steps[0].TopicTag = tag
|
||||
}
|
||||
if ctrl := threadsProvNormalizeReplyControl(replyControl); ctrl != "" && len(bundle.Steps) > 0 {
|
||||
bundle.Steps[0].ReplyControl = ctrl
|
||||
}
|
||||
if err := s.Repo.SaveOutbox(ctx, bundle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -1959,6 +1976,10 @@ func (s *Service) syncOwnPostsFromThreads(ctx context.Context, ownerUID int64, a
|
|||
if prev != nil && len(prev.Replies) > 0 {
|
||||
replies = append([]domain.OwnPostReply(nil), prev.Replies...)
|
||||
}
|
||||
replyControl := strings.TrimSpace(th.ReplyControl)
|
||||
if replyControl == "" && prev != nil {
|
||||
replyControl = prev.ReplyControl
|
||||
}
|
||||
p := &domain.OwnPost{
|
||||
ID: id, OwnerUID: ownerUID, AccountID: accountID,
|
||||
MediaID: th.ID, Text: th.Text, MediaType: th.MediaType,
|
||||
|
|
@ -1969,6 +1990,7 @@ func (s *Service) syncOwnPostsFromThreads(ctx context.Context, ownerUID int64, a
|
|||
InsightsStatus: ins.Status,
|
||||
Replies: replies,
|
||||
PublishedAt: pubAt,
|
||||
ReplyControl: replyControl,
|
||||
}
|
||||
if p.MediaType == "" {
|
||||
p.MediaType = "TEXT_POST"
|
||||
|
|
@ -2025,6 +2047,134 @@ func (s *Service) LoadOwnPostReplies(ctx context.Context, ownerUID int64, postID
|
|||
return post, nil
|
||||
}
|
||||
|
||||
func ownPostToken(ctx context.Context, s *Service, ownerUID int64, accountID string) (string, error) {
|
||||
if s.Accounts == nil {
|
||||
return "", nil
|
||||
}
|
||||
acc, err := s.Accounts.Get(ctx, accountID)
|
||||
if err != nil || acc == nil || acc.OwnerUID != ownerUID {
|
||||
return "", domain.ErrNoAccount
|
||||
}
|
||||
token, terr := s.Accounts.AccessToken(ctx, acc)
|
||||
if terr != nil {
|
||||
return "", terr
|
||||
}
|
||||
return strings.TrimSpace(token), nil
|
||||
}
|
||||
|
||||
func isRealThreadsToken(token string) bool {
|
||||
return token != "" && !strings.HasPrefix(token, "fake-")
|
||||
}
|
||||
|
||||
// ManageOwnPostReply hides or unhides a top-level reply via Threads /manage_reply.
|
||||
func (s *Service) ManageOwnPostReply(ctx context.Context, ownerUID int64, postID, replyID string, hide bool) (*domain.OwnPost, error) {
|
||||
post, err := s.getOwnPostOwned(ctx, ownerUID, postID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
replyID = strings.TrimSpace(replyID)
|
||||
if replyID == "" {
|
||||
return nil, fmt.Errorf("%w: reply_id is required", domain.ErrValidation)
|
||||
}
|
||||
found := false
|
||||
for _, r := range post.Replies {
|
||||
if r.ID == replyID {
|
||||
found = true
|
||||
if r.IsMine {
|
||||
return nil, fmt.Errorf("%w: cannot hide your own reply", domain.ErrValidation)
|
||||
}
|
||||
if strings.TrimSpace(r.ParentReplyID) != "" {
|
||||
return nil, fmt.Errorf("%w: only top-level replies can be hidden", domain.ErrValidation)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && len(post.Replies) > 0 {
|
||||
return nil, fmt.Errorf("%w: reply not found on this post", domain.ErrValidation)
|
||||
}
|
||||
if post.MediaID != "" && replyID == post.MediaID {
|
||||
return nil, fmt.Errorf("%w: 只能隱藏別人留在這則貼文下的回覆,不能隱藏貼文本體", domain.ErrValidation)
|
||||
}
|
||||
token, terr := ownPostToken(ctx, s, ownerUID, post.AccountID)
|
||||
if terr != nil {
|
||||
return nil, terr
|
||||
}
|
||||
if mgr, ok := any(s.Media).(ThreadsReplyManager); ok && isRealThreadsToken(token) {
|
||||
if err := mgr.ManageReply(ctx, token, replyID, hide); err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", domain.ErrValidation, humanizeManageReplyError(err))
|
||||
}
|
||||
} else if isRealThreadsToken(token) && s.Media != nil {
|
||||
return nil, fmt.Errorf("%w: reply management is not configured", domain.ErrValidation)
|
||||
}
|
||||
status := "NOT_HUSHED"
|
||||
if hide {
|
||||
status = "HIDDEN"
|
||||
}
|
||||
updated := false
|
||||
for i := range post.Replies {
|
||||
if post.Replies[i].ID == replyID || (hide && post.Replies[i].ParentReplyID == replyID) {
|
||||
post.Replies[i].HideStatus = status
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
if !updated {
|
||||
post.Replies = append(post.Replies, domain.OwnPostReply{ID: replyID, HideStatus: status})
|
||||
}
|
||||
if err := s.Repo.SaveOwnPost(ctx, post); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return post, nil
|
||||
}
|
||||
|
||||
// SetOwnPostReplyControl updates who can reply (Threads reply_control).
|
||||
func (s *Service) SetOwnPostReplyControl(ctx context.Context, ownerUID int64, postID, control string) (*domain.OwnPost, error) {
|
||||
post, err := s.getOwnPostOwned(ctx, ownerUID, postID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
control = threadsProvNormalizeReplyControl(control)
|
||||
if control == "" {
|
||||
return nil, fmt.Errorf("%w: reply_control must be everyone, accounts_you_follow, mentioned_only, parent_post_author_only, or followers_only", domain.ErrValidation)
|
||||
}
|
||||
token, terr := ownPostToken(ctx, s, ownerUID, post.AccountID)
|
||||
if terr != nil {
|
||||
return nil, terr
|
||||
}
|
||||
// Threads 官方只允許發文時帶 reply_control;對已發布 media POST 會回 code 100。
|
||||
if s.Media != nil && isRealThreadsToken(token) && strings.TrimSpace(post.MediaID) != "" {
|
||||
return nil, fmt.Errorf("%w: Threads 只能在發文時設定誰可以回覆,已發布貼文無法用 API 修改。請到創作頁發一則新貼文", domain.ErrValidation)
|
||||
}
|
||||
post.ReplyControl = control
|
||||
if err := s.Repo.SaveOwnPost(ctx, post); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func threadsProvNormalizeReplyControl(raw string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(raw))
|
||||
switch s {
|
||||
case "everyone", "accounts_you_follow", "mentioned_only", "parent_post_author_only", "followers_only":
|
||||
return s
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func humanizeManageReplyError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
if strings.Contains(msg, "unsupported post request") ||
|
||||
strings.Contains(msg, "does not support this operation") ||
|
||||
strings.Contains(msg, "code 100") ||
|
||||
strings.Contains(msg, "permission") {
|
||||
return "無法隱藏這則回覆。請用別人留在你貼文下的第一層回覆,並到帳號頁重新連 Threads(需授權 threads_manage_replies)"
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func mapFetchedReplies(convo []FetchedReply, prev *domain.OwnPost) []domain.OwnPostReply {
|
||||
root := ""
|
||||
if prev != nil {
|
||||
|
|
@ -2043,7 +2193,7 @@ func mapFetchedRepliesWithRoot(convo []FetchedReply, prev *domain.OwnPost, rootM
|
|||
}
|
||||
for _, c := range convo {
|
||||
id := c.ID
|
||||
if id == "" {
|
||||
if id == "" || (rootMediaID != "" && id == rootMediaID) {
|
||||
continue
|
||||
}
|
||||
created := c.PublishedAt
|
||||
|
|
@ -2065,10 +2215,14 @@ func mapFetchedRepliesWithRoot(convo []FetchedReply, prev *domain.OwnPost, rootM
|
|||
repliedBy = old.RepliedBy
|
||||
repliedAt = old.RepliedAt
|
||||
}
|
||||
hideStatus := strings.TrimSpace(c.HideStatus)
|
||||
if old, ok := localStatus[id]; ok && hideStatus == "" {
|
||||
hideStatus = old.HideStatus
|
||||
}
|
||||
out = append(out, domain.OwnPostReply{
|
||||
ID: id, Username: c.Username, Text: c.Text, CreatedAt: created,
|
||||
LikeCount: c.LikeCount, ReplyStatus: status, RepliedBy: repliedBy, RepliedAt: repliedAt,
|
||||
ParentReplyID: parent, IsMine: c.IsMine,
|
||||
ParentReplyID: parent, IsMine: c.IsMine, HideStatus: hideStatus,
|
||||
})
|
||||
}
|
||||
// 純資料:若某則留言底下有「我的」子回覆 → 標已回覆
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ type RemoteThread struct {
|
|||
TopicTag string
|
||||
Username string
|
||||
Timestamp time.Time
|
||||
ReplyControl string
|
||||
}
|
||||
|
||||
// RemoteInsights — media insights
|
||||
|
|
@ -47,6 +48,7 @@ type RemoteReply struct {
|
|||
IsMine bool
|
||||
ParentMediaID string
|
||||
LikeCount int
|
||||
HideStatus string
|
||||
}
|
||||
|
||||
// RemoteMention — GET /{user-id}/mentions(threads_manage_mentions)
|
||||
|
|
@ -74,15 +76,25 @@ type MediaClient interface {
|
|||
ListProfilePosts(ctx context.Context, accessToken, username string, limit int) ([]RemoteThread, error)
|
||||
}
|
||||
|
||||
const threadsListFields = "id,media_type,media_url,permalink,username,text,topic_tag,timestamp,shortcode,thumbnail_url,is_quote_post,reply_control"
|
||||
const threadsListFieldsLegacy = "id,media_type,media_url,permalink,username,text,topic_tag,timestamp,shortcode,thumbnail_url,is_quote_post"
|
||||
|
||||
// ListThreads GET /v1.0/me/threads
|
||||
func (m *MetaProvider) ListThreads(ctx context.Context, accessToken string, limit int) ([]RemoteThread, error) {
|
||||
list, err := m.listThreads(ctx, accessToken, limit, threadsListFields)
|
||||
if err != nil && strings.Contains(strings.ToLower(err.Error()), "reply_control") {
|
||||
return m.listThreads(ctx, accessToken, limit, threadsListFieldsLegacy)
|
||||
}
|
||||
return list, err
|
||||
}
|
||||
|
||||
func (m *MetaProvider) listThreads(ctx context.Context, accessToken string, limit int, fields string) ([]RemoteThread, error) {
|
||||
if limit <= 0 {
|
||||
limit = 25
|
||||
}
|
||||
if limit > 50 {
|
||||
limit = 50
|
||||
}
|
||||
fields := "id,media_type,media_url,permalink,username,text,topic_tag,timestamp,shortcode,thumbnail_url,is_quote_post"
|
||||
q := url.Values{}
|
||||
q.Set("fields", fields)
|
||||
q.Set("limit", strconv.Itoa(limit))
|
||||
|
|
@ -104,6 +116,7 @@ func (m *MetaProvider) ListThreads(ctx context.Context, accessToken string, limi
|
|||
Timestamp string `json:"timestamp"`
|
||||
Shortcode string `json:"shortcode"`
|
||||
ThumbnailURL string `json:"thumbnail_url"`
|
||||
ReplyControl string `json:"reply_control"`
|
||||
} `json:"data"`
|
||||
Error *graphErr `json:"error"`
|
||||
}
|
||||
|
|
@ -119,6 +132,7 @@ func (m *MetaProvider) ListThreads(ctx context.Context, accessToken string, limi
|
|||
ID: d.ID, Text: d.Text, MediaType: d.MediaType, MediaURL: d.MediaURL,
|
||||
ThumbnailURL: d.ThumbnailURL, Permalink: d.Permalink, Shortcode: d.Shortcode,
|
||||
TopicTag: d.TopicTag, Username: d.Username, Timestamp: parseThreadsTime(d.Timestamp),
|
||||
ReplyControl: NormalizeReplyControl(d.ReplyControl),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
|
|
@ -263,9 +277,7 @@ func (m *MetaProvider) fetchReplyEdge(ctx context.Context, accessToken, mediaID,
|
|||
}
|
||||
out := make([]RemoteReply, 0, len(resp.Data))
|
||||
for _, d := range resp.Data {
|
||||
// 略過隱藏/封鎖
|
||||
hs := strings.ToUpper(d.HideStatus)
|
||||
if hs == "HIDDEN" || hs == "BLOCKED" || hs == "RESTRICTED" || hs == "COVERED" {
|
||||
if strings.TrimSpace(d.ID) == "" || d.ID == mediaID {
|
||||
continue
|
||||
}
|
||||
parent := ""
|
||||
|
|
@ -275,20 +287,26 @@ func (m *MetaProvider) fetchReplyEdge(ctx context.Context, accessToken, mediaID,
|
|||
out = append(out, RemoteReply{
|
||||
ID: d.ID, Text: d.Text, Username: d.Username,
|
||||
Timestamp: parseThreadsTime(d.Timestamp), IsMine: d.IsReplyOwnedByMe,
|
||||
ParentMediaID: parent,
|
||||
ParentMediaID: parent, HideStatus: strings.TrimSpace(d.HideStatus),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// normalizeParents — 回覆根貼的 replied_to = root media id,對 UI 應視為第一層(parent 空)
|
||||
// normalizeParents — 回覆根貼的 replied_to = root media id,對 UI 應視為第一層(parent 空)。
|
||||
// conversation 有時會把主貼自己也列進來,隱藏時不能拿主貼 id 打 /manage_reply。
|
||||
func normalizeParents(list []RemoteReply, rootMediaID string) []RemoteReply {
|
||||
for i := range list {
|
||||
if list[i].ParentMediaID == rootMediaID || list[i].ParentMediaID == "" {
|
||||
list[i].ParentMediaID = ""
|
||||
out := make([]RemoteReply, 0, len(list))
|
||||
for _, r := range list {
|
||||
if r.ID == "" || r.ID == rootMediaID {
|
||||
continue
|
||||
}
|
||||
if r.ParentMediaID == rootMediaID {
|
||||
r.ParentMediaID = ""
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return list
|
||||
return out
|
||||
}
|
||||
|
||||
type graphErr struct {
|
||||
|
|
@ -335,6 +353,128 @@ func (m *MetaProvider) getJSON(ctx context.Context, fullURL string) ([]byte, err
|
|||
return body, nil
|
||||
}
|
||||
|
||||
// ReplyControlEveryone is Threads' default audience.
|
||||
const ReplyControlEveryone = "everyone"
|
||||
|
||||
// ValidReplyControls are the official Threads publishing reply_control values.
|
||||
var ValidReplyControls = map[string]struct{}{
|
||||
"everyone": {},
|
||||
"accounts_you_follow": {},
|
||||
"mentioned_only": {},
|
||||
"parent_post_author_only": {},
|
||||
"followers_only": {},
|
||||
}
|
||||
|
||||
func NormalizeReplyControl(raw string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(raw))
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if _, ok := ValidReplyControls[s]; ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ManageReply POST /{reply-id}/manage_reply hide=true|false
|
||||
func (m *MetaProvider) ManageReply(ctx context.Context, accessToken, replyID string, hide bool) error {
|
||||
replyID = strings.TrimSpace(replyID)
|
||||
if replyID == "" {
|
||||
return fmt.Errorf("reply id is required")
|
||||
}
|
||||
form := url.Values{}
|
||||
if hide {
|
||||
form.Set("hide", "true")
|
||||
} else {
|
||||
form.Set("hide", "false")
|
||||
}
|
||||
form.Set("access_token", accessToken)
|
||||
u := strings.TrimRight(m.GraphBase, "/") + "/v1.0/" + url.PathEscape(replyID) + "/manage_reply"
|
||||
body, err := m.postForm(ctx, u, form)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Error *graphErr `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return fmt.Errorf("threads manage_reply parse: %w", err)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return resp.Error
|
||||
}
|
||||
if !resp.Success {
|
||||
return fmt.Errorf("threads manage_reply did not succeed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetReplyControl POST /{media-id} reply_control=…
|
||||
// Official docs set this at publish time; Graph also accepts it on the published media id.
|
||||
func (m *MetaProvider) SetReplyControl(ctx context.Context, accessToken, mediaID, control string) error {
|
||||
mediaID = strings.TrimSpace(mediaID)
|
||||
control = NormalizeReplyControl(control)
|
||||
if mediaID == "" {
|
||||
return fmt.Errorf("media id is required")
|
||||
}
|
||||
if control == "" {
|
||||
return fmt.Errorf("invalid reply_control")
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("reply_control", control)
|
||||
form.Set("access_token", accessToken)
|
||||
u := strings.TrimRight(m.GraphBase, "/") + "/v1.0/" + url.PathEscape(mediaID)
|
||||
body, err := m.postForm(ctx, u, form)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
ID string `json:"id"`
|
||||
Error *graphErr `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return fmt.Errorf("threads reply_control parse: %w", err)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return resp.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MetaProvider) postForm(ctx context.Context, fullURL string, form url.Values) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
cli := m.HTTPClient
|
||||
if cli == nil {
|
||||
cli = &http.Client{Timeout: 25 * time.Second}
|
||||
}
|
||||
resp, err := cli.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
var ge struct {
|
||||
Error *graphErr `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &ge)
|
||||
if ge.Error != nil {
|
||||
return nil, ge.Error
|
||||
}
|
||||
return nil, fmt.Errorf("threads graph HTTP %d: %s", resp.StatusCode, truncateBody(string(body), 200))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func parseThreadsTime(s string) time.Time {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@ import (
|
|||
|
||||
// MetaProvider — Meta Threads OAuth (when AppId/Secret configured).
|
||||
type MetaProvider struct {
|
||||
AppID string
|
||||
AppSecret string
|
||||
HTTPClient *http.Client
|
||||
AuthBase string // default https://threads.net
|
||||
GraphBase string // default https://graph.threads.net
|
||||
AppID string
|
||||
AppSecret string
|
||||
HTTPClient *http.Client
|
||||
AuthBase string // default https://threads.net
|
||||
GraphBase string // default https://graph.threads.net
|
||||
}
|
||||
|
||||
func NewMeta(appID, appSecret string) *MetaProvider {
|
||||
|
|
@ -38,7 +38,7 @@ func (m *MetaProvider) AuthorizeURL(state, redirectURI string) string {
|
|||
q.Set("client_id", m.AppID)
|
||||
q.Set("redirect_uri", redirectURI)
|
||||
// basic + 發文 + 回覆/對話 + insights + 提及 + 公開人設探索(對標帳號 profile_posts)
|
||||
q.Set("scope", "threads_basic,threads_content_publish,threads_manage_replies,threads_manage_insights,threads_manage_mentions,threads_profile_discovery")
|
||||
q.Set("scope", "threads_basic,threads_content_publish,threads_read_replies,threads_manage_replies,threads_manage_insights,threads_manage_mentions,threads_profile_discovery")
|
||||
q.Set("response_type", "code")
|
||||
q.Set("state", state)
|
||||
return strings.TrimRight(m.AuthBase, "/") + "/oauth/authorize?" + q.Encode()
|
||||
|
|
|
|||
|
|
@ -471,6 +471,7 @@ func (b *metaMediaBridge) ListThreads(ctx context.Context, accessToken string, l
|
|||
ID: t.ID, Text: t.Text, MediaType: t.MediaType, MediaURL: t.MediaURL,
|
||||
ThumbnailURL: t.ThumbnailURL, Permalink: t.Permalink, Shortcode: t.Shortcode,
|
||||
TopicTag: t.TopicTag, Username: t.Username, PublishedAt: pub,
|
||||
ReplyControl: t.ReplyControl,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
|
|
@ -500,12 +501,20 @@ func (b *metaMediaBridge) ListConversation(ctx context.Context, accessToken, med
|
|||
}
|
||||
out = append(out, studioUC.FetchedReply{
|
||||
ID: r.ID, Text: r.Text, Username: r.Username, ParentMediaID: r.ParentMediaID,
|
||||
PublishedAt: pub, IsMine: r.IsMine, LikeCount: r.LikeCount,
|
||||
PublishedAt: pub, IsMine: r.IsMine, LikeCount: r.LikeCount, HideStatus: r.HideStatus,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (b *metaMediaBridge) ManageReply(ctx context.Context, accessToken, replyID string, hide bool) error {
|
||||
return b.Meta.ManageReply(ctx, accessToken, replyID, hide)
|
||||
}
|
||||
|
||||
func (b *metaMediaBridge) SetReplyControl(ctx context.Context, accessToken, mediaID, control string) error {
|
||||
return b.Meta.SetReplyControl(ctx, accessToken, mediaID, control)
|
||||
}
|
||||
|
||||
func (b *metaMediaBridge) ListMentions(ctx context.Context, accessToken, threadsUserID string, limit int) ([]studioUC.FetchedMention, error) {
|
||||
list, err := b.Meta.ListMentions(ctx, accessToken, threadsUserID, limit)
|
||||
if err != nil {
|
||||
|
|
@ -541,6 +550,7 @@ func (b *metaMediaBridge) ListProfilePosts(ctx context.Context, accessToken, use
|
|||
ID: t.ID, Text: t.Text, MediaType: t.MediaType, MediaURL: t.MediaURL,
|
||||
ThumbnailURL: t.ThumbnailURL, Permalink: t.Permalink, Shortcode: t.Shortcode,
|
||||
TopicTag: t.TopicTag, Username: t.Username, PublishedAt: pub,
|
||||
ReplyControl: t.ReplyControl,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ func MemberFromModelWithIdentities(m *memberDomain.Member, ids []*memberDomain.I
|
|||
NotifyEmail: m.NotifyEmail, GenderCode: int(m.GenderCode), Birthdate: m.Birthdate,
|
||||
National: m.National, Address: m.Address, PostCode: m.PostCode,
|
||||
PreferredLanguage: m.PreferredLanguage, Currency: m.Currency,
|
||||
OnboardingStatus: m.OnboardingStatus, OnboardingDoneAt: m.OnboardingDoneAt,
|
||||
JoinedAt: m.CreatedAt, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt,
|
||||
}
|
||||
if m.EmailVerifiedAt != nil {
|
||||
|
|
@ -116,6 +117,9 @@ func PatchFromAuthProfile(req *AuthProfilePatchReq) *memberDomain.UpdateUserInfo
|
|||
if req.Currency != "" {
|
||||
p.Currency = &req.Currency
|
||||
}
|
||||
if req.OnboardingStatus != "" {
|
||||
p.OnboardingStatus = &req.OnboardingStatus
|
||||
}
|
||||
if req.CurrentPassword != "" {
|
||||
p.CurrentPassword = &req.CurrentPassword
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ func PersonaFromDomain(p *domain.Persona) PersonaPublic {
|
|||
Identity: p.Style.Draft.Identity, Tone: p.Style.Draft.Tone,
|
||||
Audience: p.Style.Draft.Audience, Hooks: p.Style.Draft.Hooks,
|
||||
LanguageFingerprint: p.Style.Draft.LanguageFingerprint,
|
||||
Rhythm: p.Style.Draft.Rhythm, Punctuation: p.Style.Draft.Punctuation,
|
||||
ContentPatterns: p.Style.Draft.ContentPatterns,
|
||||
Rhythm: p.Style.Draft.Rhythm, Punctuation: p.Style.Draft.Punctuation,
|
||||
ContentPatterns: p.Style.Draft.ContentPatterns,
|
||||
KnowledgeTranslation: p.Style.Draft.KnowledgeTranslation,
|
||||
CtaStyle: p.Style.Draft.CtaStyle, Examples: p.Style.Draft.Examples,
|
||||
CtaStyle: p.Style.Draft.CtaStyle, Examples: p.Style.Draft.Examples,
|
||||
Avoid: p.Style.Draft.Avoid,
|
||||
},
|
||||
DraftText: p.Style.DraftText, Source: p.Style.Source,
|
||||
|
|
@ -66,10 +66,10 @@ func PersonaToDomain(req *PersonaSaveReq, ownerUID int64) *domain.Persona {
|
|||
Identity: req.Style.Draft.Identity, Tone: req.Style.Draft.Tone,
|
||||
Audience: req.Style.Draft.Audience, Hooks: req.Style.Draft.Hooks,
|
||||
LanguageFingerprint: req.Style.Draft.LanguageFingerprint,
|
||||
Rhythm: req.Style.Draft.Rhythm, Punctuation: req.Style.Draft.Punctuation,
|
||||
ContentPatterns: req.Style.Draft.ContentPatterns,
|
||||
Rhythm: req.Style.Draft.Rhythm, Punctuation: req.Style.Draft.Punctuation,
|
||||
ContentPatterns: req.Style.Draft.ContentPatterns,
|
||||
KnowledgeTranslation: req.Style.Draft.KnowledgeTranslation,
|
||||
CtaStyle: req.Style.Draft.CtaStyle, Examples: req.Style.Draft.Examples,
|
||||
CtaStyle: req.Style.Draft.CtaStyle, Examples: req.Style.Draft.Examples,
|
||||
Avoid: req.Style.Draft.Avoid,
|
||||
},
|
||||
DraftText: req.Style.DraftText, Source: req.Style.Source,
|
||||
|
|
@ -179,6 +179,7 @@ func OwnPostFromDomain(p *domain.OwnPost) OwnPostPublic {
|
|||
Id: r.ID, Username: r.Username, Text: r.Text, CreatedAt: r.CreatedAt,
|
||||
LikeCount: r.LikeCount, ReplyStatus: r.ReplyStatus, RepliedBy: r.RepliedBy,
|
||||
RepliedAt: r.RepliedAt, ParentReplyId: r.ParentReplyID, IsMine: r.IsMine,
|
||||
HideStatus: r.HideStatus,
|
||||
})
|
||||
}
|
||||
return OwnPostPublic{
|
||||
|
|
@ -189,7 +190,7 @@ func OwnPostFromDomain(p *domain.OwnPost) OwnPostPublic {
|
|||
QuoteCount: p.QuoteCount, ViewCount: p.ViewCount, ShareCount: p.ShareCount,
|
||||
InsightsStatus: p.InsightsStatus, FormulaSummary: p.FormulaSummary,
|
||||
Insight: p.Insight, FormulaDetail: p.FormulaDetail, Replies: replies,
|
||||
PublishedAt: p.PublishedAt,
|
||||
PublishedAt: p.PublishedAt, ReplyControl: p.ReplyControl,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -194,6 +194,7 @@ type AuthProfilePatchReq struct {
|
|||
PostCode string `json:"post_code,optional"`
|
||||
PreferredLanguage string `json:"preferred_language,optional"`
|
||||
Currency string `json:"currency,optional"`
|
||||
OnboardingStatus string `json:"onboarding_status,optional"`
|
||||
CurrentPassword string `json:"current_password,optional"`
|
||||
NewPassword string `json:"new_password,optional"`
|
||||
}
|
||||
|
|
@ -391,6 +392,7 @@ type ComposePublishReq struct {
|
|||
ImageUrls []string `json:"image_urls,optional"`
|
||||
ScheduleStartAt int64 `json:"schedule_start_at,optional"`
|
||||
TopicTag string `json:"topic_tag,optional"`
|
||||
ReplyControl string `json:"reply_control,optional"`
|
||||
}
|
||||
|
||||
type ComposeViralReq struct {
|
||||
|
|
@ -1190,6 +1192,8 @@ type MemberPublic struct {
|
|||
PostCode string `json:"post_code,optional"`
|
||||
PreferredLanguage string `json:"preferred_language,optional"`
|
||||
Currency string `json:"currency,optional"`
|
||||
OnboardingStatus string `json:"onboarding_status,optional"`
|
||||
OnboardingDoneAt int64 `json:"onboarding_done_at,optional"`
|
||||
Identities []IdentityPublic `json:"identities,optional"`
|
||||
JoinedAt int64 `json:"joined_at,optional"`
|
||||
CreatedAt int64 `json:"created_at,optional"`
|
||||
|
|
@ -1467,6 +1471,12 @@ type OwnPostLoadRepliesReq struct {
|
|||
PostId string `json:"post_id"`
|
||||
}
|
||||
|
||||
type OwnPostManageReplyReq struct {
|
||||
PostId string `json:"post_id"`
|
||||
ReplyId string `json:"reply_id"`
|
||||
Hide bool `json:"hide"`
|
||||
}
|
||||
|
||||
type OwnPostPublic struct {
|
||||
Id string `json:"id"`
|
||||
AccountId string `json:"account_id"`
|
||||
|
|
@ -1490,6 +1500,7 @@ type OwnPostPublic struct {
|
|||
FormulaDetail string `json:"formula_detail,optional"`
|
||||
Replies []OwnPostReplyPublic `json:"replies"`
|
||||
PublishedAt int64 `json:"published_at"`
|
||||
ReplyControl string `json:"reply_control,optional"`
|
||||
}
|
||||
|
||||
type OwnPostReplyPublic struct {
|
||||
|
|
@ -1503,6 +1514,7 @@ type OwnPostReplyPublic struct {
|
|||
RepliedAt int64 `json:"replied_at,optional"`
|
||||
ParentReplyId string `json:"parent_reply_id,optional"`
|
||||
IsMine bool `json:"is_mine,optional"`
|
||||
HideStatus string `json:"hide_status,optional"`
|
||||
}
|
||||
|
||||
type OwnPostSendReplyReq struct {
|
||||
|
|
@ -1513,6 +1525,11 @@ type OwnPostSendReplyReq struct {
|
|||
ImageUrls []string `json:"image_urls,optional"`
|
||||
}
|
||||
|
||||
type OwnPostSetReplyControlReq struct {
|
||||
PostId string `json:"post_id"`
|
||||
ReplyControl string `json:"reply_control"`
|
||||
}
|
||||
|
||||
type OwnPostSyncReq struct {
|
||||
AccountId string `json:"account_id"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { Suspense } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { JobLiveProvider } from "../../data/JobLiveContext";
|
||||
import { FirstRunProvider } from "../../firstRun/FirstRunContext";
|
||||
import { ActiveJobsStrip } from "./ActiveJobsStrip";
|
||||
import { FirstRunBar } from "./FirstRunBar";
|
||||
import { MobileDock } from "./MobileDock";
|
||||
import { PageHelpProvider } from "./PageHelp";
|
||||
import { SidebarNav } from "./SidebarNav";
|
||||
|
|
@ -11,10 +13,12 @@ export function AppShell() {
|
|||
return (
|
||||
<JobLiveProvider>
|
||||
<PageHelpProvider>
|
||||
<FirstRunProvider>
|
||||
<div className="hb-shell">
|
||||
<div className="hb-shell__header">
|
||||
<Topbar />
|
||||
<ActiveJobsStrip />
|
||||
<FirstRunBar />
|
||||
</div>
|
||||
<div className="hb-shell__body">
|
||||
<SidebarNav />
|
||||
|
|
@ -36,6 +40,7 @@ export function AppShell() {
|
|||
</div>
|
||||
<MobileDock />
|
||||
</div>
|
||||
</FirstRunProvider>
|
||||
</PageHelpProvider>
|
||||
</JobLiveProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { useI18n } from "../../i18n/I18nContext";
|
||||
import { useFirstRun } from "../../firstRun/FirstRunContext";
|
||||
|
||||
export function FirstRunBar() {
|
||||
const { t } = useI18n();
|
||||
const { active, current } = useFirstRun();
|
||||
if (!active || !current) return null;
|
||||
|
||||
return (
|
||||
<div className="hb-first-run" role="region" aria-label={t("firstRun.aria")}>
|
||||
<div className="hb-first-run__copy">
|
||||
<strong>{t("firstRun.title")}</strong>
|
||||
<p>{t("firstRun.subtitle")}</p>
|
||||
</div>
|
||||
<div className="hb-first-run__actions">
|
||||
<Link className="hb-btn hb-btn--secondary" to={current.to}>
|
||||
{t("firstRun.step.crew")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { NavLink, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useI18n } from "../../i18n/I18nContext";
|
||||
import { useFirstRun } from "../../firstRun/FirstRunContext";
|
||||
import {
|
||||
firstRunNavKeys,
|
||||
isMoreNavActive,
|
||||
isNavActive,
|
||||
mobileDockMoreKeys,
|
||||
|
|
@ -15,14 +17,15 @@ export function MobileDock() {
|
|||
const { pathname } = useLocation();
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const { active: firstRun } = useFirstRun();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const sheetRef = useRef<HTMLDivElement>(null);
|
||||
const moreBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const titleId = useId();
|
||||
|
||||
const dockItems = navItemsByKeys(mobileDockPrimaryKeys);
|
||||
const moreGroups = navGroupedItemsByKeys(mobileDockMoreKeys);
|
||||
const moreActive = isMoreNavActive(pathname);
|
||||
const dockItems = navItemsByKeys(firstRun ? firstRunNavKeys : mobileDockPrimaryKeys);
|
||||
const moreGroups = firstRun ? [] : navGroupedItemsByKeys(mobileDockMoreKeys);
|
||||
const moreActive = !firstRun && isMoreNavActive(pathname);
|
||||
|
||||
useEffect(() => {
|
||||
setMoreOpen(false);
|
||||
|
|
@ -128,6 +131,7 @@ export function MobileDock() {
|
|||
</NavLink>
|
||||
);
|
||||
})}
|
||||
{firstRun ? null : (
|
||||
<button
|
||||
ref={moreBtnRef}
|
||||
type="button"
|
||||
|
|
@ -141,6 +145,7 @@ export function MobileDock() {
|
|||
</span>
|
||||
<span className="hb-dock__label">{t("nav.more")}</span>
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,20 +1,25 @@
|
|||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { useFirstRun } from "../../firstRun/FirstRunContext";
|
||||
import { useI18n } from "../../i18n/I18nContext";
|
||||
import { isNavActive, navGroups, navItemsByKeys } from "../../lib/nav";
|
||||
import { firstRunNavKeys, isNavActive, navGroups, navGroupedItemsByKeys, navItemsByKeys } from "../../lib/nav";
|
||||
import { AppIcon } from "../ui/AppIcons";
|
||||
|
||||
export function SidebarNav() {
|
||||
const { pathname } = useLocation();
|
||||
const { t } = useI18n();
|
||||
const { active } = useFirstRun();
|
||||
const groups = active
|
||||
? navGroupedItemsByKeys(firstRunNavKeys)
|
||||
: navGroups.map((group) => ({ group, items: navItemsByKeys(group.keys) }));
|
||||
|
||||
return (
|
||||
<aside className="hb-sidebar" aria-label={t("nav.navigate")}>
|
||||
<p className="hb-sidebar__label display-en">{t("nav.navigate")}</p>
|
||||
{navGroups.map((group) => (
|
||||
{groups.map(({ group, items }) => (
|
||||
<div className="hb-nav__group" key={group.key}>
|
||||
<p className="hb-nav__group-label">{t(group.labelKey)}</p>
|
||||
<nav className="hb-nav">
|
||||
{navItemsByKeys(group.keys).map((item) => {
|
||||
{items.map((item) => {
|
||||
const active = isNavActive(pathname, item);
|
||||
return (
|
||||
<NavLink
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState } from "react";
|
||||
import type { CostPreview } from "../../domain/types";
|
||||
import { useI18n } from "../../i18n/I18nContext";
|
||||
import { Badge, Button, Input } from "../ui";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -10,17 +11,18 @@ type Props = {
|
|||
};
|
||||
|
||||
export function CostPreviewDialog({ preview, onConfirm, onCancel, busy = false }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [ceiling, setCeiling] = useState(String(preview.max_credits));
|
||||
const value = Number(ceiling);
|
||||
const valid = Number.isFinite(value) && value >= preview.fixed_credits && value <= preview.max_credits;
|
||||
return (
|
||||
<div className="hb-cost-dialog" role="dialog" aria-modal="true" aria-label="點數預覽與確認">
|
||||
<div className="hb-cost-dialog__head"><div><h3>執行前先確認點數</h3><p className="hb-radar-section__hint">預覽本身不扣點;只有 provider 成功回傳才會計入用量。</p></div><Badge tone={preview.key_mode === "byok" ? "brand" : "warning"}>{preview.key_mode === "byok" ? "BYOK · 平台 0 點" : "平台點數"}</Badge></div>
|
||||
<dl className="hb-cost-dialog__stats"><div><dt>固定</dt><dd>{preview.fixed_credits}</dd></div><div><dt>預估範圍</dt><dd>{preview.min_credits}–{preview.max_credits}</dd></div><div><dt>搜尋呼叫</dt><dd>{preview.search_calls}</dd></div><div><dt>剩餘</dt><dd>{preview.remaining_credits}</dd></div></dl>
|
||||
<p className="hb-radar-section__hint">{preview.estimate_basis} · 預覽至 {new Date(preview.expires_at / 1_000_000).toLocaleTimeString()}</p>
|
||||
<Input name="credit-ceiling" label="本次最高可用點數" type="number" min={preview.fixed_credits} max={preview.max_credits} value={ceiling} onChange={(event) => setCeiling(event.target.value)} hint={`至少 ${preview.fixed_credits},最多 ${preview.max_credits}`} />
|
||||
{!valid ? <p className="hb-banner-error" role="alert">點數上限必須落在固定成本至預估上限之間。</p> : null}
|
||||
<div className="hb-cost-dialog__actions"><Button type="button" disabled={!valid || busy} onClick={() => onConfirm(value)}>{busy ? "啟動中…" : "確認並執行"}</Button><Button type="button" variant="ghost" onClick={onCancel}>取消</Button></div>
|
||||
<div className="hb-cost-dialog" role="dialog" aria-modal="true" aria-label={t("radar.cost.aria")}>
|
||||
<div className="hb-cost-dialog__head"><div><h3>{t("radar.cost.title")}</h3><p className="hb-radar-section__hint">{t("radar.cost.hint")}</p></div><Badge tone={preview.key_mode === "byok" ? "brand" : "warning"}>{preview.key_mode === "byok" ? t("radar.cost.byok") : t("radar.cost.platform")}</Badge></div>
|
||||
<dl className="hb-cost-dialog__stats"><div><dt>{t("radar.cost.fixed")}</dt><dd>{preview.fixed_credits}</dd></div><div><dt>{t("radar.cost.range")}</dt><dd>{preview.min_credits}–{preview.max_credits}</dd></div><div><dt>{t("radar.cost.calls")}</dt><dd>{preview.search_calls}</dd></div><div><dt>{t("radar.cost.remaining")}</dt><dd>{preview.remaining_credits}</dd></div></dl>
|
||||
<p className="hb-radar-section__hint">{preview.estimate_basis} · {t("radar.cost.until", { time: new Date(preview.expires_at / 1_000_000).toLocaleTimeString() })}</p>
|
||||
<Input name="credit-ceiling" label={t("radar.cost.ceiling")} type="number" min={preview.fixed_credits} max={preview.max_credits} value={ceiling} onChange={(event) => setCeiling(event.target.value)} hint={t("radar.cost.ceilingHint", { min: preview.fixed_credits, max: preview.max_credits })} />
|
||||
{!valid ? <p className="hb-banner-error" role="alert">{t("radar.cost.invalid")}</p> : null}
|
||||
<div className="hb-cost-dialog__actions"><Button type="button" disabled={!valid || busy} onClick={() => onConfirm(value)}>{busy ? t("radar.cost.starting") : t("radar.cost.confirm")}</Button><Button type="button" variant="ghost" onClick={onCancel}>{t("common.cancel")}</Button></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import type { BrandProduct, DemandMap, DemandMapPhrase } from "../../domain/types";
|
||||
import { useI18n } from "../../i18n/I18nContext";
|
||||
import { Badge, Button, Textarea } from "../ui";
|
||||
|
||||
type Category = "pain_phrases" | "scenario_phrases" | "desired_outcomes" | "solution_signals" | "exclusion_signals";
|
||||
const categories: Array<{ key: Category; label: string; hint: string; kind: string }> = [
|
||||
{ key: "pain_phrases", label: "使用者痛點", hint: "產品要解決的困擾,例如漏水、協作混亂", kind: "pain" },
|
||||
{ key: "scenario_phrases", label: "使用情境", hint: "使用者會怎麼描述發生的情境", kind: "scenario" },
|
||||
{ key: "desired_outcomes", label: "期待結果", hint: "使用者想要的結果或改善", kind: "outcome" },
|
||||
{ key: "solution_signals", label: "解法訊號", hint: "能判斷你有能力協助的詞", kind: "solution" },
|
||||
{ key: "exclusion_signals", label: "排除訊號", hint: "徵才、廣告等不應進入商機的內容", kind: "exclusion" },
|
||||
const categories: Array<{ key: Category; labelKey: string; hintKey: string; kind: string }> = [
|
||||
{ key: "pain_phrases", labelKey: "radar.demand.pain", hintKey: "radar.demand.painHint", kind: "pain" },
|
||||
{ key: "scenario_phrases", labelKey: "radar.demand.scenario", hintKey: "radar.demand.scenarioHint", kind: "scenario" },
|
||||
{ key: "desired_outcomes", labelKey: "radar.demand.outcome", hintKey: "radar.demand.outcomeHint", kind: "outcome" },
|
||||
{ key: "solution_signals", labelKey: "radar.demand.solution", hintKey: "radar.demand.solutionHint", kind: "solution" },
|
||||
{ key: "exclusion_signals", labelKey: "radar.demand.exclusion", hintKey: "radar.demand.exclusionHint", kind: "exclusion" },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
|
|
@ -30,7 +31,7 @@ function lines(phrases: DemandMapPhrase[] = []): string {
|
|||
function asPhrases(value: string, kind: string, existing: DemandMapPhrase[]): DemandMapPhrase[] {
|
||||
return value.split(/\n|,|、/).map((text) => text.trim()).filter(Boolean).map((text) => {
|
||||
const old = existing.find((phrase) => phrase.text === text);
|
||||
return old ? { ...old, enabled: true } : { text, kind, origin: "user", basis_kind: "custom", basis_text: "手動補充", enabled: true };
|
||||
return old ? { ...old, enabled: true } : { text, kind, origin: "user", basis_kind: "custom", basis_text: "custom", enabled: true };
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -53,6 +54,7 @@ function buildPatch(map: DemandMap, draft: Record<Category, string>, custom: str
|
|||
}
|
||||
|
||||
export function DemandMapEditor({ product, map, loading = false, saving = false, error, onSave, onDraftChange }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [draft, setDraft] = useState<Record<Category, string>>({
|
||||
pain_phrases: "", scenario_phrases: "", desired_outcomes: "", solution_signals: "", exclusion_signals: "",
|
||||
});
|
||||
|
|
@ -86,35 +88,35 @@ export function DemandMapEditor({ product, map, loading = false, saving = false,
|
|||
}
|
||||
|
||||
return (
|
||||
<section className="hb-demand-map-editor" aria-label={`${product.label} 需求地圖`}>
|
||||
<section className="hb-demand-map-editor" aria-label={t("radar.demand.aria", { label: product.label })}>
|
||||
<div className="hb-demand-map-editor__head">
|
||||
<div>
|
||||
<h3>產品需求地圖</h3>
|
||||
<p className="hb-radar-section__hint">先確認產品真實痛點,再用它縮小巡邏結果。來源會保留在每個詞旁邊。</p>
|
||||
<h3>{t("radar.demand.title")}</h3>
|
||||
<p className="hb-radar-section__hint">{t("radar.demand.hint")}</p>
|
||||
</div>
|
||||
{map ? <div className="hb-opp-card__meta"><Badge tone={map.state === "ready" ? "success" : "warning"}>{map.state === "ready" ? "可用" : "待補資料"}</Badge><span>版本 {map.map_version}</span></div> : null}
|
||||
{map ? <div className="hb-opp-card__meta"><Badge tone={map.state === "ready" ? "success" : "warning"}>{map.state === "ready" ? t("radar.demand.ready") : t("radar.demand.incomplete")}</Badge><span>{t("radar.demand.version", { n: map.map_version })}</span></div> : null}
|
||||
</div>
|
||||
{loading ? <p className="hb-radar-section__hint">正在整理產品需求…</p> : null}
|
||||
{loading ? <p className="hb-radar-section__hint">{t("radar.demand.loading")}</p> : null}
|
||||
{error ? <p className="hb-banner-error" role="alert">{error}</p> : null}
|
||||
{map ? <>
|
||||
{categories.map(({ key, label, hint }) => (
|
||||
{categories.map(({ key, labelKey, hintKey }) => (
|
||||
<div className="hb-demand-map-editor__field" key={key}>
|
||||
<Textarea
|
||||
name={`demand-${key}`}
|
||||
label={label}
|
||||
hint={hint}
|
||||
label={t(labelKey)}
|
||||
hint={t(hintKey)}
|
||||
rows={3}
|
||||
required={key === "pain_phrases" || key === "scenario_phrases" || key === "solution_signals"}
|
||||
value={draft[key]}
|
||||
onChange={(event) => changeDraft({ ...draft, [key]: event.target.value })}
|
||||
/>
|
||||
<div className="hb-demand-map-editor__basis">
|
||||
{(map[key] ?? []).map((item) => <Badge key={`${key}-${item.text}`} tone={item.origin === "product" ? "brand" : item.origin === "ai" ? "warning" : "neutral"}>{item.origin === "product" ? "產品" : item.origin === "ai" ? "AI 建議" : "手動"} · {item.text}</Badge>)}
|
||||
{(map[key] ?? []).map((item) => <Badge key={`${key}-${item.text}`} tone={item.origin === "product" ? "brand" : item.origin === "ai" ? "warning" : "neutral"}>{item.origin === "product" ? t("radar.demand.origin.product") : item.origin === "ai" ? t("radar.demand.origin.ai") : t("radar.demand.origin.user")} · {item.text}</Badge>)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Textarea name="demand-custom" label="自訂補充" hint="自訂內容不會覆蓋產品原始資料;下一次產品更新時仍可辨識來源。" rows={2} value={custom} onChange={(event) => changeCustom(event.target.value)} />
|
||||
<div className="hb-demand-map-editor__actions"><Button type="button" disabled={saving} onClick={save}>{saving ? "保存中…" : "保存需求地圖"}</Button></div>
|
||||
<Textarea name="demand-custom" label={t("radar.demand.custom")} hint={t("radar.demand.customHint")} rows={2} value={custom} onChange={(event) => changeCustom(event.target.value)} />
|
||||
<div className="hb-demand-map-editor__actions"><Button type="button" disabled={saving} onClick={save}>{saving ? t("radar.demand.saving") : t("radar.demand.save")}</Button></div>
|
||||
</> : null}
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ export function ExplorePanel({ onExplored }: { onExplored: () => void }) {
|
|||
return;
|
||||
}
|
||||
if (scout && (!brandId || !productId)) {
|
||||
setErr("立即探索請先選擇品牌與產品,避免把通用結果誤當成產品商機。");
|
||||
setErr(t("radar.explore.needProduct"));
|
||||
return;
|
||||
}
|
||||
const previewFn = (repos.radar as unknown as { getCostPreview?: (input: { action: string; product_id?: string; candidate_limit?: number }) => Promise<CostPreview> }).getCostPreview;
|
||||
|
|
@ -144,7 +144,7 @@ export function ExplorePanel({ onExplored }: { onExplored: () => void }) {
|
|||
<div className="hb-radar-section hb-radar-explore">
|
||||
<h3 className="hb-radar-section__title">{t("radar.explore.title")}</h3>
|
||||
<p className="hb-radar-section__hint">{t("radar.explore.hint")}</p>
|
||||
{brands.length ? <div className="hb-radar-actions"><label>品牌<select value={brandId} onChange={(e) => { setBrandId(e.target.value); setProductId(""); }}><option value="">請選品牌</option>{brands.map((b) => <option key={b.id} value={b.id}>{b.display_name}</option>)}</select></label><label>產品<select value={productId} disabled={!brandId} onChange={(e) => setProductId(e.target.value)}><option value="">請選產品</option>{products.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}</select></label></div> : null}
|
||||
{brands.length ? <div className="hb-radar-actions"><label>{t("radar.inbox.brand")}<select value={brandId} onChange={(e) => { setBrandId(e.target.value); setProductId(""); }}><option value="">{t("radar.explore.pickBrand")}</option>{brands.map((b) => <option key={b.id} value={b.id}>{b.display_name}</option>)}</select></label><label>{t("radar.inbox.product")}<select value={productId} disabled={!brandId} onChange={(e) => setProductId(e.target.value)}><option value="">{t("radar.explore.pickProduct")}</option>{products.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}</select></label></div> : null}
|
||||
|
||||
{loadingSuggest ? (
|
||||
<p className="hb-radar-section__hint">{t("radar.explore.loadingSuggest")}</p>
|
||||
|
|
@ -245,7 +245,7 @@ export function ExplorePanel({ onExplored }: { onExplored: () => void }) {
|
|||
judged: result.judged_count,
|
||||
})}
|
||||
</p>
|
||||
<p className="hb-radar-section__hint">新增商機 {result.created_count} · 合併產品匹配 {result.merged_count ?? 0} · 評估 {result.matched_count ?? result.judged_count}</p>
|
||||
<p className="hb-radar-section__hint">{t("radar.explore.productStats", { created: result.created_count, merged: result.merged_count ?? 0, matched: result.matched_count ?? result.judged_count })}</p>
|
||||
{result.created_count === 0 ? (
|
||||
<p className="hb-radar-section__hint">{t("radar.explore.resultZeroHint")}</p>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ export function ManualImportPanel({ onImported }: { onImported: () => void }) {
|
|||
return;
|
||||
}
|
||||
if (scout && (!brandId || !productId)) {
|
||||
setErr("手動匯入請先選擇品牌與產品。");
|
||||
setErr(t("radar.import.needProduct"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
|
|
@ -125,7 +125,7 @@ export function ManualImportPanel({ onImported }: { onImported: () => void }) {
|
|||
<div className="hb-radar-section hb-radar-import">
|
||||
<h3 className="hb-radar-section__title">{t("radar.import.title")}</h3>
|
||||
<p className="hb-radar-section__hint">{t("radar.import.hint")}</p>
|
||||
{brands.length ? <div className="hb-radar-actions"><label>品牌<select value={brandId} onChange={(e) => { setBrandId(e.target.value); setProductId(""); }}><option value="">請選品牌</option>{brands.map((b) => <option key={b.id} value={b.id}>{b.display_name}</option>)}</select></label><label>產品<select value={productId} disabled={!brandId} onChange={(e) => setProductId(e.target.value)}><option value="">請選產品</option>{products.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}</select></label></div> : null}
|
||||
{brands.length ? <div className="hb-radar-actions"><label>{t("radar.inbox.brand")}<select value={brandId} onChange={(e) => { setBrandId(e.target.value); setProductId(""); }}><option value="">{t("radar.explore.pickBrand")}</option>{brands.map((b) => <option key={b.id} value={b.id}>{b.display_name}</option>)}</select></label><label>{t("radar.inbox.product")}<select value={productId} disabled={!brandId} onChange={(e) => setProductId(e.target.value)}><option value="">{t("radar.explore.pickProduct")}</option>{products.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}</select></label></div> : null}
|
||||
|
||||
<div className="hb-radar-actions">
|
||||
<Button type="button" variant="ghost" onClick={() => setCsvOpen((v) => !v)}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Opportunity } from "../../domain/types";
|
||||
import { useI18n } from "../../i18n/I18nContext";
|
||||
import { Badge, Button } from "../ui";
|
||||
import { ProductMatchDetails } from "./ProductMatchDetails";
|
||||
|
||||
|
|
@ -11,42 +12,43 @@ type Props = {
|
|||
};
|
||||
|
||||
export function OpportunityDetailDrawer({ opportunity, onClose, onAccept, onComplete, busy = false }: Props) {
|
||||
const { t } = useI18n();
|
||||
const pending = (opportunity.review_state || "pending") === "pending";
|
||||
const accepted = opportunity.status === "accepted" || Boolean(opportunity.contact_id);
|
||||
return (
|
||||
<aside className="hb-opp-drawer" role="dialog" aria-modal="true" aria-label="商機詳情">
|
||||
<aside className="hb-opp-drawer" role="dialog" aria-modal="true" aria-label={t("radar.drawer.aria")}>
|
||||
<div className="hb-opp-drawer__head">
|
||||
<div>
|
||||
<span className="hb-radar-section__hint">商機詳情</span>
|
||||
<h2>{opportunity.primary_product_label || "未指定產品"}</h2>
|
||||
<span className="hb-radar-section__hint">{t("radar.drawer.title")}</span>
|
||||
<h2>{opportunity.primary_product_label || t("radar.drawer.noProduct")}</h2>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" aria-label="關閉商機詳情" onClick={onClose}>關閉</Button>
|
||||
<Button type="button" variant="ghost" aria-label={t("radar.drawer.closeAria")} onClick={onClose}>{t("radar.drawer.close")}</Button>
|
||||
</div>
|
||||
<div className="hb-opp-drawer__body">
|
||||
<p className="hb-opp-card__text">{opportunity.text}</p>
|
||||
<div className="hb-opp-card__meta">
|
||||
<Badge tone="brand">意向 {opportunity.intent_score}</Badge>
|
||||
{opportunity.priority_score !== undefined ? <Badge tone="warning">優先 {opportunity.priority_score}</Badge> : null}
|
||||
<span>@{opportunity.author_handle || "未知作者"}</span>
|
||||
<Badge tone="brand">{t("radar.drawer.intent", { n: opportunity.intent_score })}</Badge>
|
||||
{opportunity.priority_score !== undefined ? <Badge tone="warning">{t("radar.drawer.priority", { n: opportunity.priority_score })}</Badge> : null}
|
||||
<span>@{opportunity.author_handle || t("radar.card.unknownAuthor")}</span>
|
||||
</div>
|
||||
{opportunity.demand_evidence?.length ? (
|
||||
<section className="hb-opp-drawer__section"><h3>需求證據</h3><ul>{opportunity.demand_evidence.map((item) => <li key={item}>{item}</li>)}</ul></section>
|
||||
<section className="hb-opp-drawer__section"><h3>{t("radar.drawer.evidence")}</h3><ul>{opportunity.demand_evidence.map((item) => <li key={item}>{item}</li>)}</ul></section>
|
||||
) : null}
|
||||
<section className="hb-opp-drawer__section">
|
||||
<h3>產品匹配與風險</h3>
|
||||
{opportunity.product_matches?.length ? opportunity.product_matches.map((match) => <ProductMatchDetails key={match.product_id} match={match} />) : <p className="hb-radar-section__hint">尚未指定產品,這筆結果只保留為一般需求。</p>}
|
||||
<h3>{t("radar.drawer.matches")}</h3>
|
||||
{opportunity.product_matches?.length ? opportunity.product_matches.map((match) => <ProductMatchDetails key={match.product_id} match={match} />) : <p className="hb-radar-section__hint">{t("radar.drawer.generic")}</p>}
|
||||
</section>
|
||||
{opportunity.reasons.length ? <section className="hb-opp-drawer__section"><h3>原始判定</h3><div className="hb-opp-reasons">{opportunity.reasons.map((reason) => <div className="hb-opp-reason" key={reason.dimension}><strong>{reason.dimension}</strong><span>{reason.score}</span><span>{reason.reason}</span></div>)}</div></section> : null}
|
||||
{opportunity.reasons.length ? <section className="hb-opp-drawer__section"><h3>{t("radar.drawer.judge")}</h3><div className="hb-opp-reasons">{opportunity.reasons.map((reason) => <div className="hb-opp-reason" key={reason.dimension}><strong>{reason.dimension}</strong><span>{reason.score}</span><span>{reason.reason}</span></div>)}</div></section> : null}
|
||||
<div className="hb-opp-drawer__actions">
|
||||
{pending && onComplete ? (
|
||||
<Button type="button" disabled={busy} onClick={() => onComplete(opportunity)}>留下</Button>
|
||||
<Button type="button" disabled={busy} onClick={() => onComplete(opportunity)}>{t("radar.card.keep")}</Button>
|
||||
) : null}
|
||||
{pending && !accepted && onAccept ? (
|
||||
<Button type="button" variant="ghost" disabled={busy} onClick={() => onAccept(opportunity)}>加入名單(可選)</Button>
|
||||
<Button type="button" variant="ghost" disabled={busy} onClick={() => onAccept(opportunity)}>{t("radar.card.accept")}</Button>
|
||||
) : null}
|
||||
<a className="hb-btn hb-btn--ghost" href={opportunity.permalink} target="_blank" rel="noreferrer">開啟 Threads 原文</a>
|
||||
<a className="hb-btn hb-btn--ghost" href={opportunity.permalink} target="_blank" rel="noreferrer">{t("radar.drawer.openOriginal")}</a>
|
||||
</div>
|
||||
{pending ? <p className="hb-field__hint">先看痛點與產品理由。留下或丟掉即可;加入名單只在你要追這個人時才需要。</p> : null}
|
||||
{pending ? <p className="hb-field__hint">{t("radar.drawer.hint")}</p> : null}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { Opportunity, OpportunityRemovalReason } from "../../domain/types";
|
||||
import { useI18n } from "../../i18n/I18nContext";
|
||||
import { Badge, Button, Select, Textarea } from "../ui";
|
||||
import { formatTimeAgo } from "../../lib/time";
|
||||
|
||||
const reasonLabels: Record<Exclude<OpportunityRemovalReason, "legacy_unknown">, string> = {
|
||||
pain_mismatch: "不符合產品痛點",
|
||||
provider_or_ad: "供應商/廣告貼文",
|
||||
stale: "需求已過期",
|
||||
already_solved: "對方已解決",
|
||||
duplicate: "重複商機",
|
||||
other: "其他原因",
|
||||
};
|
||||
const REASON_KEYS = [
|
||||
"pain_mismatch",
|
||||
"provider_or_ad",
|
||||
"stale",
|
||||
"already_solved",
|
||||
"duplicate",
|
||||
"other",
|
||||
] as const;
|
||||
|
||||
type Props = {
|
||||
opportunity: Opportunity;
|
||||
|
|
@ -24,6 +25,7 @@ type Props = {
|
|||
};
|
||||
|
||||
export function OpportunityInboxCard({ opportunity, onOpen, onAccept, onComplete, onRemove, onRestore, busy = false }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [removeOpen, setRemoveOpen] = useState(false);
|
||||
const [reason, setReason] = useState<OpportunityRemovalReason>("pain_mismatch");
|
||||
const [note, setNote] = useState("");
|
||||
|
|
@ -33,16 +35,16 @@ export function OpportunityInboxCard({ opportunity, onOpen, onAccept, onComplete
|
|||
const evidence = opportunity.demand_evidence?.[0] ?? opportunity.product_matches?.[0]?.reasons?.[0]?.reason;
|
||||
const accepted = opportunity.status === "accepted" || Boolean(opportunity.contact_id);
|
||||
const priorityLabel = opportunity.priority_band === "high"
|
||||
? "優先跟進"
|
||||
? t("radar.card.priority.high")
|
||||
: opportunity.priority_band === "review"
|
||||
? "值得確認"
|
||||
? t("radar.card.priority.review")
|
||||
: opportunity.priority_band === "low"
|
||||
? "低順位"
|
||||
? t("radar.card.priority.low")
|
||||
: opportunity.intent_band === "high"
|
||||
? "高意向"
|
||||
? t("radar.inbox.band.high")
|
||||
: opportunity.intent_band === "mid"
|
||||
? "中意向"
|
||||
: "低意向";
|
||||
? t("radar.inbox.band.mid")
|
||||
: t("radar.inbox.band.low");
|
||||
|
||||
function submitRemove() {
|
||||
if (reason === "other" && !note.trim()) return;
|
||||
|
|
@ -57,42 +59,42 @@ export function OpportunityInboxCard({ opportunity, onOpen, onAccept, onComplete
|
|||
<Badge tone={opportunity.priority_band === "high" || opportunity.intent_band === "high" ? "success" : opportunity.priority_band === "review" || opportunity.intent_band === "mid" ? "warning" : "neutral"}>
|
||||
{priorityLabel} · {score}
|
||||
</Badge>
|
||||
{opportunity.primary_product_label ? <Badge tone="brand">適合產品 · {opportunity.primary_product_label}</Badge> : <Badge tone="neutral">尚未配對產品</Badge>}
|
||||
<span>需求意向 {opportunity.intent_score} · {formatTimeAgo(opportunity.posted_at)}</span>
|
||||
{opportunity.primary_product_label ? <Badge tone="brand">{t("radar.card.fitProduct", { label: opportunity.primary_product_label })}</Badge> : <Badge tone="neutral">{t("radar.card.noProduct")}</Badge>}
|
||||
<span>{t("radar.card.intent", { n: opportunity.intent_score })} · {formatTimeAgo(opportunity.posted_at)}</span>
|
||||
</div>
|
||||
<p className="hb-opp-card__text">{opportunity.text}</p>
|
||||
{evidence ? <p className="hb-opp-card__evidence"><strong>為什麼推薦:</strong>{evidence}</p> : null}
|
||||
{evidence ? <p className="hb-opp-card__evidence"><strong>{t("radar.card.why")}</strong>{evidence}</p> : null}
|
||||
<div className="hb-opp-card__source">
|
||||
<span>@{opportunity.author_handle || "未知作者"}</span>
|
||||
<a href={opportunity.permalink} target="_blank" rel="noreferrer">查看 Threads 原文</a>
|
||||
<span>@{opportunity.author_handle || t("radar.card.unknownAuthor")}</span>
|
||||
<a href={opportunity.permalink} target="_blank" rel="noreferrer">{t("radar.card.openOriginal")}</a>
|
||||
</div>
|
||||
<div className="hb-opp-card__actions" aria-label="商機操作">
|
||||
{pending ? <Button type="button" variant="primary" disabled={busy} onClick={() => onComplete(opportunity)}>留下</Button> : null}
|
||||
{pending ? <Button type="button" variant="danger" disabled={busy} onClick={() => setRemoveOpen((value) => !value)}>丟掉</Button> : null}
|
||||
<Button type="button" variant="ghost" onClick={() => onOpen(opportunity)}>為什麼推薦</Button>
|
||||
<div className="hb-opp-card__actions" aria-label={t("radar.card.actionsAria")}>
|
||||
{pending ? <Button type="button" variant="primary" disabled={busy} onClick={() => onComplete(opportunity)}>{t("radar.card.keep")}</Button> : null}
|
||||
{pending ? <Button type="button" variant="danger" disabled={busy} onClick={() => setRemoveOpen((value) => !value)}>{t("radar.card.discard")}</Button> : null}
|
||||
<Button type="button" variant="ghost" onClick={() => onOpen(opportunity)}>{t("radar.card.whyBtn")}</Button>
|
||||
{(pending || reviewState === "completed") && !accepted ? (
|
||||
<Button type="button" variant="ghost" disabled={busy} onClick={() => onAccept(opportunity)}>
|
||||
{busy ? "處理中…" : "加入名單(可選)"}
|
||||
{busy ? t("radar.card.busy") : t("radar.card.accept")}
|
||||
</Button>
|
||||
) : null}
|
||||
{reviewState === "removed" ? <Button type="button" variant="secondary" disabled={busy} onClick={() => onRestore(opportunity)}>還原到待處理</Button> : null}
|
||||
{reviewState === "removed" ? <Button type="button" variant="secondary" disabled={busy} onClick={() => onRestore(opportunity)}>{t("radar.card.restore")}</Button> : null}
|
||||
{reviewState === "completed" && accepted && opportunity.contact_id ? (
|
||||
<Link className="hb-btn hb-btn--secondary" to={`/app/crm?contact=${encodeURIComponent(opportunity.contact_id)}`}>前往名單</Link>
|
||||
<Link className="hb-btn hb-btn--secondary" to={`/app/crm?contact=${encodeURIComponent(opportunity.contact_id)}`}>{t("radar.inbox.goCrm")}</Link>
|
||||
) : null}
|
||||
{reviewState === "completed" ? <Badge tone="success">{accepted ? "已加入名單" : "已處理"}</Badge> : null}
|
||||
{reviewState === "completed" ? <Badge tone="success">{accepted ? t("radar.card.accepted") : t("radar.card.done")}</Badge> : null}
|
||||
</div>
|
||||
{removeOpen ? (
|
||||
<div className="hb-opp-card__remove-form" aria-label="標示為不適合">
|
||||
<strong>為什麼丟掉?</strong>
|
||||
<p className="hb-field__hint">選原因後會移出「新找到」,之後巡邏不會再把同一篇推上來。這個動作不扣點。</p>
|
||||
<Select name={`remove-reason-${opportunity.id}`} label="原因" value={reason} onChange={(event) => setReason(event.target.value as OpportunityRemovalReason)}>
|
||||
{(Object.keys(reasonLabels) as Array<Exclude<OpportunityRemovalReason, "legacy_unknown">>).map((key) => <option key={key} value={key}>{reasonLabels[key]}</option>)}
|
||||
<div className="hb-opp-card__remove-form" aria-label={t("radar.card.removeAria")}>
|
||||
<strong>{t("radar.card.removeTitle")}</strong>
|
||||
<p className="hb-field__hint">{t("radar.card.removeHint")}</p>
|
||||
<Select name={`remove-reason-${opportunity.id}`} label={t("radar.card.reason")} value={reason} onChange={(event) => setReason(event.target.value as OpportunityRemovalReason)}>
|
||||
{REASON_KEYS.map((key) => <option key={key} value={key}>{t(`radar.card.reason.${key}`)}</option>)}
|
||||
</Select>
|
||||
{reason === "other" ? <Textarea name={`remove-note-${opportunity.id}`} label="補充說明" value={note} onChange={(event) => setNote(event.target.value)} /> : null}
|
||||
{reason === "duplicate" ? <p className="hb-field__hint">詳情中可指定要保留的原始商機。</p> : null}
|
||||
{reason === "other" ? <Textarea name={`remove-note-${opportunity.id}`} label={t("radar.card.note")} value={note} onChange={(event) => setNote(event.target.value)} /> : null}
|
||||
{reason === "duplicate" ? <p className="hb-field__hint">{t("radar.card.duplicateHint")}</p> : null}
|
||||
<div className="hb-opp-card__actions">
|
||||
<Button type="button" variant="danger" disabled={busy || (reason === "other" && !note.trim())} onClick={submitRemove}>確認丟掉</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => setRemoveOpen(false)}>取消</Button>
|
||||
<Button type="button" variant="danger" disabled={busy || (reason === "other" && !note.trim())} onClick={submitRemove}>{t("radar.card.confirmRemove")}</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => setRemoveOpen(false)}>{t("common.cancel")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState } from "react";
|
||||
import type { ProductMatch } from "../../domain/types";
|
||||
import { useI18n } from "../../i18n/I18nContext";
|
||||
import { Button, Select } from "../ui";
|
||||
|
||||
export function PrimaryProductPicker({ matches, currentId, busy, onSet }: {
|
||||
|
|
@ -8,16 +9,17 @@ export function PrimaryProductPicker({ matches, currentId, busy, onSet }: {
|
|||
busy?: boolean;
|
||||
onSet: (productId: string, reason: string) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [productId, setProductId] = useState(currentId || "");
|
||||
const [reason, setReason] = useState("");
|
||||
const choices = matches;
|
||||
if (!choices.length) return <span className="hb-radar-section__hint">目前沒有產品匹配</span>;
|
||||
if (!choices.length) return <span className="hb-radar-section__hint">{t("radar.primary.empty")}</span>;
|
||||
return <div className="hb-primary-product-picker">
|
||||
<Select name="primary-product" label="主推產品" value={productId} onChange={(e) => setProductId(e.target.value)}>
|
||||
<option value="">選擇產品</option>
|
||||
{choices.map((m) => <option key={m.product_id} value={m.product_id}>{m.product_label_snapshot} · {m.product_fit_score}{m.eligible && !m.excluded ? "" : " · 需理由"}</option>)}
|
||||
<Select name="primary-product" label={t("radar.primary.label")} value={productId} onChange={(e) => setProductId(e.target.value)}>
|
||||
<option value="">{t("radar.primary.placeholder")}</option>
|
||||
{choices.map((m) => <option key={m.product_id} value={m.product_id}>{m.product_label_snapshot} · {m.product_fit_score}{m.eligible && !m.excluded ? "" : t("radar.primary.needsReason")}</option>)}
|
||||
</Select>
|
||||
<input aria-label="主推理由" value={reason} onChange={(e) => setReason(e.target.value)} placeholder="可選:為何這次主推它" />
|
||||
<Button type="button" variant="secondary" disabled={!productId || busy || (!choices.find((m) => m.product_id === productId)?.eligible && !reason.trim())} onClick={() => onSet(productId, reason || "使用者依證據選定主推產品")}>設定主推</Button>
|
||||
<input aria-label={t("radar.primary.reasonAria")} value={reason} onChange={(e) => setReason(e.target.value)} placeholder={t("radar.primary.reasonPh")} />
|
||||
<Button type="button" variant="secondary" disabled={!productId || busy || (!choices.find((m) => m.product_id === productId)?.eligible && !reason.trim())} onClick={() => onSet(productId, reason || t("radar.primary.defaultReason"))}>{t("radar.primary.submit")}</Button>
|
||||
</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,30 @@
|
|||
import type { Brand, BrandProduct } from "../../domain/types";
|
||||
import { useI18n } from "../../i18n/I18nContext";
|
||||
import { Badge } from "../ui";
|
||||
|
||||
type Props = { brand?: Brand; product?: BrandProduct };
|
||||
|
||||
/** A transparent completeness signal; it never invents a product capability. */
|
||||
export function ProductContextReadiness({ brand, product }: Props) {
|
||||
const { t } = useI18n();
|
||||
if (!brand || !product) return null;
|
||||
const checks = [
|
||||
["受眾", Boolean(brand.target_audience?.trim())],
|
||||
["情境", Boolean(product.product_context.trim())],
|
||||
["痛點", product.pain_points.length > 0],
|
||||
["能力詞", product.provider_capability_terms.length > 0],
|
||||
[t("radar.readiness.audience"), Boolean(brand.target_audience?.trim())],
|
||||
[t("radar.readiness.context"), Boolean(product.product_context.trim())],
|
||||
[t("radar.readiness.pain"), product.pain_points.length > 0],
|
||||
[t("radar.readiness.capability"), product.provider_capability_terms.length > 0],
|
||||
] as const;
|
||||
const complete = checks.filter(([, ok]) => ok).length;
|
||||
return (
|
||||
<div className="hb-radar-readiness" role="status">
|
||||
<div className="hb-radar-readiness__head">
|
||||
<strong>產品資料完整度</strong>
|
||||
<strong>{t("radar.readiness.title")}</strong>
|
||||
<Badge tone={complete === checks.length ? "success" : "warning"}>{complete}/{checks.length}</Badge>
|
||||
</div>
|
||||
<div className="hb-inline-badges">
|
||||
{checks.map(([label, ok]) => <Badge key={label} tone={ok ? "success" : "neutral"}>{ok ? "已填" : "待補"} · {label}</Badge>)}
|
||||
{checks.map(([label, ok]) => <Badge key={label} tone={ok ? "success" : "neutral"}>{ok ? t("radar.readiness.ok") : t("radar.readiness.todo")} · {label}</Badge>)}
|
||||
</div>
|
||||
{complete < checks.length ? <p className="hb-radar-section__hint">資料不足會降低適配信心,但仍可建立產品型雷達。</p> : null}
|
||||
{complete < checks.length ? <p className="hb-radar-section__hint">{t("radar.readiness.hint")}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { useState } from "react";
|
||||
import type { ProductMatch } from "../../domain/types";
|
||||
import { useI18n } from "../../i18n/I18nContext";
|
||||
import { Badge, Button } from "../ui";
|
||||
|
||||
export function ProductMatchDetails({ match }: { match: ProductMatch }) {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="hb-product-match">
|
||||
|
|
@ -11,15 +13,15 @@ export function ProductMatchDetails({ match }: { match: ProductMatch }) {
|
|||
<Badge tone={match.excluded ? "danger" : match.eligible ? "success" : "neutral"}>
|
||||
{match.product_fit_band} · {match.product_fit_score}
|
||||
</Badge>
|
||||
<Button type="button" variant="ghost" onClick={() => setOpen((v) => !v)}>{open ? "收合證據" : "為什麼適合"}</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => setOpen((v) => !v)}>{open ? t("radar.match.hide") : t("radar.match.why")}</Button>
|
||||
</div>
|
||||
{open ? <div className="hb-product-match__reasons">
|
||||
{match.reasons.map((reason) => <div key={reason.dimension} className="hb-product-reason">
|
||||
<strong>{reason.dimension} · {reason.score}</strong><span>{reason.reason}</span>
|
||||
{reason.candidate_excerpt ? <q>{reason.candidate_excerpt}</q> : null}
|
||||
{reason.product_basis ? <small>產品依據:{reason.product_basis}</small> : null}
|
||||
{reason.product_basis ? <small>{t("radar.match.basis", { text: reason.product_basis })}</small> : null}
|
||||
</div>)}
|
||||
{match.risks.length ? <p className="hb-banner-error">風險:{match.risks.join("、")}</p> : null}
|
||||
{match.risks.length ? <p className="hb-banner-error">{t("radar.match.risks", { text: match.risks.join("、") })}</p> : null}
|
||||
</div> : null}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Brand, BrandProduct } from "../../domain/types";
|
||||
import { useI18n } from "../../i18n/I18nContext";
|
||||
import { Select } from "../ui";
|
||||
import { ProductContextReadiness } from "./ProductContextReadiness";
|
||||
|
||||
|
|
@ -13,16 +14,17 @@ type Props = {
|
|||
};
|
||||
|
||||
export function ProductWatchForm({ brands, products, brandId, productId, disabled, onBrandChange, onProductChange }: Props) {
|
||||
const { t } = useI18n();
|
||||
const brand = brands.find((b) => b.id === brandId);
|
||||
const product = products.find((p) => p.id === productId);
|
||||
return (
|
||||
<div className="hb-radar-product-context">
|
||||
<Select name="radar-watch-brand" label="品牌" value={brandId} disabled={disabled} required onChange={(e) => onBrandChange(e.target.value)}>
|
||||
<option value="">請先選品牌</option>
|
||||
<Select name="radar-watch-brand" label={t("radar.inbox.brand")} value={brandId} disabled={disabled} required onChange={(e) => onBrandChange(e.target.value)}>
|
||||
<option value="">{t("radar.watches.pickBrand")}</option>
|
||||
{brands.map((b) => <option key={b.id} value={b.id}>{b.display_name}</option>)}
|
||||
</Select>
|
||||
<Select name="radar-watch-product" label="產品" value={productId} disabled={disabled || !brandId} required onChange={(e) => onProductChange(e.target.value)}>
|
||||
<option value="">請選這個品牌下的產品</option>
|
||||
<Select name="radar-watch-product" label={t("radar.inbox.product")} value={productId} disabled={disabled || !brandId} required onChange={(e) => onProductChange(e.target.value)}>
|
||||
<option value="">{t("radar.watches.pickProduct")}</option>
|
||||
{products.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
|
||||
</Select>
|
||||
<ProductContextReadiness brand={brand} product={product} />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { DemandMap, DemandMapPhrase } from "../../domain/types";
|
||||
import { useI18n } from "../../i18n/I18nContext";
|
||||
import { isThreadsSearchable, normalizeSearchTerm, searchableTermVariants } from "../../lib/threadsTerm";
|
||||
import { Badge, Button } from "../ui";
|
||||
|
||||
|
|
@ -33,7 +34,7 @@ export function buildQueryPlan(map: DemandMap): QueryPlanGroup[] {
|
|||
const query = normalizeSearchTerm(terms.join(" "));
|
||||
if (!query || !isThreadsSearchable(query) || seen.has(query.toLowerCase())) return;
|
||||
seen.add(query.toLowerCase());
|
||||
groups.push({ terms, basis: parts[0]?.basis_text || "產品痛點", exclusion });
|
||||
groups.push({ terms, basis: parts[0]?.basis_text || "", exclusion });
|
||||
};
|
||||
for (const item of pain) {
|
||||
add(item);
|
||||
|
|
@ -73,37 +74,38 @@ export function QueryPlanPreview({
|
|||
map: DemandMap;
|
||||
onAdoptQueries?: (queries: string[]) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const groups = buildQueryPlan(map);
|
||||
const queries = groups.map((group) => group.terms.join(" ")).filter(isThreadsSearchable);
|
||||
return (
|
||||
<section className="hb-query-plan" aria-label="查詢計畫預覽">
|
||||
<section className="hb-query-plan" aria-label={t("radar.query.aria")}>
|
||||
<div className="hb-query-plan__head">
|
||||
<div><h4>系統會用這些短詞搜尋</h4><p className="hb-radar-section__hint">每組最多兩個詞、中文每詞 2–4 字,才能在 Threads 搜到。產品名稱只作輔助。</p></div>
|
||||
<span className="hb-radar-section__hint">輸入 {map.demand_input_version} · 地圖 v{map.map_version}</span>
|
||||
<div><h4>{t("radar.query.title")}</h4><p className="hb-radar-section__hint">{t("radar.query.hint")}</p></div>
|
||||
<span className="hb-radar-section__hint">{t("radar.query.meta", { input: map.demand_input_version, map: map.map_version })}</span>
|
||||
</div>
|
||||
{groups.length ? (
|
||||
<>
|
||||
<div className="hb-query-plan__groups">
|
||||
{groups.map((group, index) => (
|
||||
<article className="hb-query-plan__group" key={`${group.basis}-${index}`}>
|
||||
<strong>查詢組 {index + 1}</strong>
|
||||
<strong>{t("radar.query.group", { n: index + 1 })}</strong>
|
||||
<div className="hb-demand-map-editor__basis">
|
||||
{group.terms.map((term) => <Badge key={term} tone="brand">{term}</Badge>)}
|
||||
</div>
|
||||
<small>依據:{group.basis}</small>
|
||||
{group.exclusion.length ? <small>排除:{group.exclusion.join("、")}</small> : null}
|
||||
<small>{t("radar.query.basis", { text: group.basis || t("radar.query.defaultBasis") })}</small>
|
||||
{group.exclusion.length ? <small>{t("radar.query.exclude", { text: group.exclusion.join("、") })}</small> : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{onAdoptQueries && queries.length ? (
|
||||
<div className="hb-radar-actions">
|
||||
<Button type="button" variant="secondary" onClick={() => onAdoptQueries(queries)}>
|
||||
採用這些查詢詞
|
||||
{t("radar.query.adopt")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : <p className="hb-radar-empty">需求地圖尚未有足夠的可搜痛點/情境,暫時無法產生查詢組。</p>}
|
||||
) : <p className="hb-radar-empty">{t("radar.query.empty")}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,20 @@
|
|||
import type { ReactElement } from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { KEYS } from "../../data/mock/keys";
|
||||
import type { Brand, BrandProduct, DemandMap } from "../../domain/types";
|
||||
import { I18nProvider } from "../../i18n/I18nContext";
|
||||
import { DemandMapEditor } from "./DemandMapEditor";
|
||||
import { ProductWatchForm } from "./ProductWatchForm";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.setItem(KEYS.uiPrefs, JSON.stringify({ locale: "zh-TW", currency: "TWD", theme: "system" }));
|
||||
});
|
||||
|
||||
function wrap(ui: ReactElement) {
|
||||
return render(<I18nProvider>{ui}</I18nProvider>);
|
||||
}
|
||||
|
||||
const brand: Brand = { id: "brand-1", display_name: "測試品牌", brief: "" };
|
||||
const product: BrandProduct = {
|
||||
id: "product-1",
|
||||
|
|
@ -34,7 +45,7 @@ const map: DemandMap = {
|
|||
|
||||
describe("巡樓必填欄位", () => {
|
||||
it("在品牌與產品標籤旁顯示星號", () => {
|
||||
render(
|
||||
wrap(
|
||||
<ProductWatchForm
|
||||
brands={[brand]}
|
||||
products={[product]}
|
||||
|
|
@ -53,7 +64,7 @@ describe("巡樓必填欄位", () => {
|
|||
});
|
||||
|
||||
it("只在需求地圖必要的三個欄位顯示星號", () => {
|
||||
render(<DemandMapEditor product={product} map={map} onSave={vi.fn()} />);
|
||||
wrap(<DemandMapEditor product={product} map={map} onSave={vi.fn()} />);
|
||||
|
||||
for (const name of ["使用者痛點", "使用情境", "解法訊號"]) {
|
||||
const field = screen.getByLabelText(name, { exact: false }) as HTMLTextAreaElement;
|
||||
|
|
@ -69,7 +80,7 @@ describe("巡樓必填欄位", () => {
|
|||
|
||||
it("填完三個必要欄位會通知巡樓表單可直接保存", () => {
|
||||
const onDraftChange = vi.fn();
|
||||
render(<DemandMapEditor product={product} map={map} onSave={vi.fn()} onDraftChange={onDraftChange} />);
|
||||
wrap(<DemandMapEditor product={product} map={map} onSave={vi.fn()} onDraftChange={onDraftChange} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("使用者痛點", { exact: false }), { target: { value: "泛紅" } });
|
||||
fireEvent.change(screen.getByLabelText("使用情境", { exact: false }), { target: { value: "日常保養" } });
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { KEYS } from "../../data/mock/keys";
|
||||
import type { RadarSweep } from "../../domain/types";
|
||||
import { I18nProvider } from "../../i18n/I18nContext";
|
||||
import { SweepFunnelSummary } from "./SweepFunnelSummary";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.setItem(KEYS.uiPrefs, JSON.stringify({ locale: "zh-TW", currency: "TWD", theme: "system" }));
|
||||
});
|
||||
|
||||
const sweep: RadarSweep = {
|
||||
id: "s1", watch_id: "w1", path: "api", hit_count: 10, deduped_count: 2, prefilter_pass_count: 6,
|
||||
prefilter_rejected_count: 2, cached_judgment_count: 1, judged_count: 5, created_count: 3, budget_deferred_count: 1,
|
||||
|
|
@ -12,7 +18,11 @@ const sweep: RadarSweep = {
|
|||
|
||||
describe("SweepFunnelSummary", () => {
|
||||
it("shows funnel counters, status and credit breakdown", () => {
|
||||
render(<SweepFunnelSummary sweep={sweep} />);
|
||||
render(
|
||||
<I18nProvider>
|
||||
<SweepFunnelSummary sweep={sweep} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(screen.getByText("預算暫停")).toBeTruthy();
|
||||
expect(screen.getByText("新增商機").nextElementSibling?.textContent).toBe("3");
|
||||
expect(screen.getByText("合計 3")).toBeTruthy();
|
||||
|
|
|
|||
|
|
@ -1,39 +1,44 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import type { RadarSweep } from "../../domain/types";
|
||||
import { useI18n } from "../../i18n/I18nContext";
|
||||
import { sanitizeReviewCopy } from "../../lib/reviewCopy";
|
||||
import { Badge } from "../ui";
|
||||
|
||||
function isCrawlerSessionFailure(reason: string): boolean {
|
||||
return /crawler session|Chrome crawler|Chrome 登入已過期/i.test(reason);
|
||||
}
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
complete: "完成", partial_budget: "預算暫停", blocked_budget: "點數不足", failed: "失敗",
|
||||
};
|
||||
|
||||
export function SweepFunnelSummary({ sweep }: { sweep: RadarSweep }) {
|
||||
const { t, locale } = useI18n();
|
||||
const status = sweep.sweep_status ?? "complete";
|
||||
const statusKey = `radar.sweep.status.${status}`;
|
||||
const cells = [
|
||||
["命中", sweep.hit_count], ["去重", sweep.deduped_count ?? 0], ["前處理通過", sweep.prefilter_pass_count ?? 0],
|
||||
["前處理排除", sweep.prefilter_rejected_count ?? 0], ["快取判定", sweep.cached_judgment_count ?? 0],
|
||||
["AI 判定", sweep.judged_count], ["新增商機", sweep.created_count], ["預算延後", sweep.budget_deferred_count ?? 0],
|
||||
[t("radar.sweep.hits"), sweep.hit_count],
|
||||
[t("radar.sweep.deduped"), sweep.deduped_count ?? 0],
|
||||
[t("radar.sweep.prefilterPass"), sweep.prefilter_pass_count ?? 0],
|
||||
[t("radar.sweep.prefilterReject"), sweep.prefilter_rejected_count ?? 0],
|
||||
[t("radar.sweep.cached"), sweep.cached_judgment_count ?? 0],
|
||||
[t("radar.sweep.aiJudge"), sweep.judged_count],
|
||||
[t("radar.sweep.created"), sweep.created_count],
|
||||
[t("radar.sweep.deferred"), sweep.budget_deferred_count ?? 0],
|
||||
] as const;
|
||||
return (
|
||||
<section className="hb-sweep-funnel" aria-label="巡邏漏斗摘要">
|
||||
<div className="hb-sweep-funnel__head"><div><h3>這次巡邏跑到哪裡</h3><p className="hb-radar-section__hint">不把合併匹配誤算成新增商機。</p></div><Badge tone={status === "complete" ? "success" : status === "failed" ? "danger" : "warning"}>{statusLabel[status] ?? status}</Badge></div>
|
||||
<section className="hb-sweep-funnel" aria-label={t("radar.sweep.aria")}>
|
||||
<div className="hb-sweep-funnel__head"><div><h3>{t("radar.sweep.title")}</h3><p className="hb-radar-section__hint">{t("radar.sweep.hint")}</p></div><Badge tone={status === "complete" ? "success" : status === "failed" ? "danger" : "warning"}>{t(statusKey) === statusKey ? status : t(statusKey)}</Badge></div>
|
||||
<div className="hb-sweep-funnel__grid">{cells.map(([label, value]) => <div key={label}><span>{label}</span><strong>{value}</strong></div>)}</div>
|
||||
<div className="hb-sweep-funnel__credits"><span>點數:搜尋 {sweep.credit_search ?? 0} · 需求地圖 {sweep.credit_demand_map ?? 0} · 判定 {sweep.credit_judge ?? 0}</span><strong>合計 {sweep.credits_used}</strong></div>
|
||||
<div className="hb-sweep-funnel__credits"><span>{t("radar.sweep.credits", { search: sweep.credit_search ?? 0, map: sweep.credit_demand_map ?? 0, judge: sweep.credit_judge ?? 0 })}</span><strong>{t("radar.sweep.total", { n: sweep.credits_used })}</strong></div>
|
||||
{sweep.failed_reason ? (
|
||||
<p className="hb-banner-error" role="alert">
|
||||
{sweep.failed_reason}
|
||||
{sanitizeReviewCopy(sweep.failed_reason, locale)}
|
||||
{isCrawlerSessionFailure(sweep.failed_reason) ? (
|
||||
<>
|
||||
{" "}
|
||||
<Link to="/app/settings">去設定重新同步 Chrome</Link>
|
||||
<Link to="/app/settings">{t("radar.reconnectSearch")}</Link>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
{status === "partial_budget" || status === "blocked_budget" ? <p className="hb-radar-section__hint">預算未使用的候選會保留,下次可續跑;不會重複扣已成功判定的筆數。</p> : null}
|
||||
{status === "partial_budget" || status === "blocked_budget" ? <p className="hb-radar-section__hint">{t("radar.sweep.budgetHint")}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ export function WatchSuggestPanel({ onAdopt, onAdoptAll, onAdoptQueries, adopted
|
|||
</Badge>
|
||||
</div>
|
||||
<p className="hb-radar-suggest__reason">{s.reason}</p>
|
||||
{s.basis_text ? <p className="hb-radar-section__hint">依據:{s.basis_text}</p> : null}
|
||||
{s.basis_text ? <p className="hb-radar-section__hint">{t("radar.suggest.basis", { text: s.basis_text })}</p> : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
|
|
|||
|
|
@ -1,15 +1,33 @@
|
|||
import type { InputHTMLAttributes } from "react";
|
||||
import { useState, type InputHTMLAttributes } from "react";
|
||||
|
||||
type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||
label?: string;
|
||||
hint?: string;
|
||||
/** 欄位旁錯誤(比頁首 banner 更直覺) */
|
||||
error?: string;
|
||||
/** 密碼欄右側顯示/隱藏切換 */
|
||||
revealPassword?: boolean;
|
||||
revealShowLabel?: string;
|
||||
revealHideLabel?: string;
|
||||
};
|
||||
|
||||
export function Input({ label, hint, error, id, className = "", required, ...rest }: Props) {
|
||||
export function Input({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
id,
|
||||
className = "",
|
||||
required,
|
||||
type,
|
||||
revealPassword = false,
|
||||
revealShowLabel = "Show password",
|
||||
revealHideLabel = "Hide password",
|
||||
...rest
|
||||
}: Props) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const inputId = id || rest.name;
|
||||
const errId = error && inputId ? `${inputId}-error` : undefined;
|
||||
const resolvedType = revealPassword && type === "password" && visible ? "text" : type;
|
||||
return (
|
||||
<label className={`hb-field${error ? " is-invalid" : ""}`} htmlFor={inputId}>
|
||||
{label ? (
|
||||
|
|
@ -18,14 +36,28 @@ export function Input({ label, hint, error, id, className = "", required, ...res
|
|||
{required ? <span className="hb-field__required" aria-hidden="true">*</span> : null}
|
||||
</span>
|
||||
) : null}
|
||||
<input
|
||||
id={inputId}
|
||||
className={`hb-input ${className}`.trim()}
|
||||
required={required}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={errId}
|
||||
{...rest}
|
||||
/>
|
||||
<span className={`hb-input-shell${revealPassword ? " has-reveal" : ""}`}>
|
||||
<input
|
||||
id={inputId}
|
||||
type={resolvedType}
|
||||
className={`hb-input ${className}`.trim()}
|
||||
required={required}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={errId}
|
||||
{...rest}
|
||||
/>
|
||||
{revealPassword ? (
|
||||
<button
|
||||
type="button"
|
||||
className="hb-password-toggle"
|
||||
aria-label={visible ? revealHideLabel : revealShowLabel}
|
||||
aria-pressed={visible}
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
>
|
||||
<PasswordEyeIcon hidden={visible} />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
{error ? (
|
||||
<span className="hb-field__error" id={errId} role="alert">
|
||||
{error}
|
||||
|
|
@ -36,3 +68,26 @@ export function Input({ label, hint, error, id, className = "", required, ...res
|
|||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordEyeIcon({ hidden }: { hidden: boolean }) {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M3.5 12s3.2-6 8.5-6 8.5 6 8.5 6-3.2 6-8.5 6-8.5-6-8.5-6Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="2.4" stroke="currentColor" strokeWidth="1.5" />
|
||||
{hidden ? (
|
||||
<path
|
||||
d="M4 20 20 4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
) : null}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,6 +140,10 @@ function normalizeMember(raw: Record<string, unknown>): Member {
|
|||
preferred_language:
|
||||
raw.preferred_language != null ? String(raw.preferred_language) : undefined,
|
||||
currency: raw.currency != null ? String(raw.currency) : undefined,
|
||||
onboarding_status:
|
||||
raw.onboarding_status != null ? String(raw.onboarding_status) : undefined,
|
||||
onboarding_done_at:
|
||||
raw.onboarding_done_at != null ? Number(raw.onboarding_done_at) : undefined,
|
||||
identities: identitiesRaw.map((it) => {
|
||||
const row = it as Record<string, unknown>;
|
||||
return {
|
||||
|
|
@ -170,6 +174,8 @@ function normalizeAdmin(raw: Record<string, unknown>): MemberAdminView {
|
|||
parent_uid: m.parent_uid ?? null,
|
||||
created_at: Number(raw.created_at ?? raw.joined_at ?? 0),
|
||||
updated_at: Number(raw.updated_at ?? raw.created_at ?? 0),
|
||||
onboarding_status: m.onboarding_status,
|
||||
onboarding_done_at: m.onboarding_done_at,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1261,9 +1267,11 @@ function mapOwnPost(raw: Record<string, unknown>): OwnPost {
|
|||
? String(row.parent_reply_id)
|
||||
: null,
|
||||
is_mine: Boolean(row.is_mine),
|
||||
hide_status: row.hide_status != null ? String(row.hide_status) : undefined,
|
||||
};
|
||||
}),
|
||||
published_at: Number(raw.published_at ?? 0),
|
||||
reply_control: raw.reply_control != null ? String(raw.reply_control) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1518,6 +1526,7 @@ function createLiveCompose(): ComposeRepo {
|
|||
image_urls: opts.imageUrls,
|
||||
schedule_start_at: opts.schedule_start_at,
|
||||
topic_tag: opts.topicTag || undefined,
|
||||
reply_control: opts.replyControl || undefined,
|
||||
},
|
||||
});
|
||||
return mapOutbox(raw);
|
||||
|
|
@ -1579,6 +1588,20 @@ function createLiveOwnPosts(): OwnPostsRepo {
|
|||
});
|
||||
return mapOwnPost(raw);
|
||||
},
|
||||
async manageReply(opts) {
|
||||
const raw = await apiRequest<Record<string, unknown>>("/api/v1/own-posts/manage-reply", {
|
||||
method: "POST",
|
||||
body: { post_id: opts.postId, reply_id: opts.replyId, hide: opts.hide },
|
||||
});
|
||||
return mapOwnPost(raw);
|
||||
},
|
||||
async setReplyControl(opts) {
|
||||
const raw = await apiRequest<Record<string, unknown>>("/api/v1/own-posts/reply-control", {
|
||||
method: "POST",
|
||||
body: { post_id: opts.postId, reply_control: opts.replyControl },
|
||||
});
|
||||
return mapOwnPost(raw);
|
||||
},
|
||||
async analyzePost(postId) {
|
||||
const raw = await apiRequest<Record<string, unknown>>("/api/v1/own-posts/analyze", {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ export type MemberProfilePatch = {
|
|||
post_code?: string;
|
||||
preferred_language?: string;
|
||||
currency?: string;
|
||||
/** skipped | completed;只寫一次 */
|
||||
onboarding_status?: "skipped" | "completed";
|
||||
/** 改密時必填 */
|
||||
current_password?: string;
|
||||
new_password?: string;
|
||||
|
|
@ -348,6 +350,8 @@ export type OwnPostsRepo = {
|
|||
/** 附圖 URL(mock 記張數/可選縮圖) */
|
||||
imageUrls?: string[];
|
||||
}): Promise<OwnPost>;
|
||||
manageReply(opts: { postId: string; replyId: string; hide: boolean }): Promise<OwnPost>;
|
||||
setReplyControl(opts: { postId: string; replyControl: string }): Promise<OwnPost>;
|
||||
analyzePost(postId: string): Promise<OwnPost>;
|
||||
generateFromFormula(postId: string, personaId?: string): Promise<{ title: string; topic: string; root: string }>;
|
||||
};
|
||||
|
|
@ -497,6 +501,8 @@ export type ComposeRepo = {
|
|||
schedule_start_at?: number;
|
||||
/** Threads 話題標籤(topic_tag,可不加 #) */
|
||||
topicTag?: string;
|
||||
/** Threads reply_control,發文時設定誰可以回覆 */
|
||||
replyControl?: string;
|
||||
}): Promise<OutboxBundle>;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ export type Member = {
|
|||
post_code?: string;
|
||||
preferred_language?: string;
|
||||
currency?: string;
|
||||
/** pending | skipped | completed;空=尚未寫入,視同 pending */
|
||||
onboarding_status?: "pending" | "skipped" | "completed" | string;
|
||||
onboarding_done_at?: number;
|
||||
/** 已綁定登入管道 */
|
||||
identities?: MemberIdentity[];
|
||||
/**
|
||||
|
|
@ -445,8 +448,26 @@ export type OwnPostReply = {
|
|||
parent_reply_id?: string | null;
|
||||
/** 是否為我們自己帳號發出的回覆 */
|
||||
is_mine?: boolean;
|
||||
/** Threads hide_status:NOT_HUSHED / HIDDEN */
|
||||
hide_status?: string;
|
||||
};
|
||||
|
||||
/** Threads reply_control */
|
||||
export type ThreadsReplyControl =
|
||||
| "everyone"
|
||||
| "accounts_you_follow"
|
||||
| "mentioned_only"
|
||||
| "parent_post_author_only"
|
||||
| "followers_only";
|
||||
|
||||
export const THREADS_REPLY_CONTROLS: ThreadsReplyControl[] = [
|
||||
"everyone",
|
||||
"accounts_you_follow",
|
||||
"mentioned_only",
|
||||
"parent_post_author_only",
|
||||
"followers_only",
|
||||
];
|
||||
|
||||
/** 對齊 Threads API media / insights 常見欄位(mock 先齊,接真直接 map) */
|
||||
export type ThreadsMediaType =
|
||||
| "TEXT_POST"
|
||||
|
|
@ -492,6 +513,8 @@ export type OwnPost = {
|
|||
formula_detail?: string;
|
||||
replies: OwnPostReply[];
|
||||
published_at: number;
|
||||
/** Threads who-can-reply */
|
||||
reply_control?: ThreadsReplyControl | string;
|
||||
};
|
||||
|
||||
export type MentionStatus = "pending" | "replied" | "skipped";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { KEYS } from "../data/mock/keys";
|
||||
import type { Member } from "../domain/types";
|
||||
import { I18nProvider } from "../i18n/I18nContext";
|
||||
import { FirstRunBar } from "../components/layout/FirstRunBar";
|
||||
import { SidebarNav } from "../components/layout/SidebarNav";
|
||||
import { FirstRunProvider } from "./FirstRunContext";
|
||||
|
||||
const harness = vi.hoisted(() => ({
|
||||
member: null as Member | null,
|
||||
updateProfile: vi.fn(async (patch: { onboarding_status?: string }) => {
|
||||
if (harness.member) {
|
||||
harness.member = { ...harness.member, ...patch };
|
||||
}
|
||||
return harness.member;
|
||||
}),
|
||||
accounts: [] as { id: string; is_usable: boolean }[],
|
||||
}));
|
||||
|
||||
vi.mock("../auth/AuthContext", () => ({
|
||||
useAuth: () => ({
|
||||
member: harness.member,
|
||||
loading: false,
|
||||
updateProfile: harness.updateProfile,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../data/DataContext", () => ({
|
||||
useRepos: () => ({
|
||||
accounts: { list: async () => harness.accounts },
|
||||
}),
|
||||
}));
|
||||
|
||||
function member(over: Partial<Member> = {}): Member {
|
||||
return {
|
||||
tenant_id: "default",
|
||||
uid: "1000001",
|
||||
email: "n@example.com",
|
||||
display_name: "New",
|
||||
roles: ["member"],
|
||||
email_verified: true,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function renderBar(path = "/app/today") {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<I18nProvider>
|
||||
<FirstRunProvider>
|
||||
<FirstRunBar />
|
||||
<SidebarNav />
|
||||
<Routes>
|
||||
<Route path="/app/today" element={<div>today-page</div>} />
|
||||
<Route path="/app/crew" element={<div>crew-page</div>} />
|
||||
<Route path="/app/studio" element={<div>studio-page</div>} />
|
||||
</Routes>
|
||||
</FirstRunProvider>
|
||||
</I18nProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("FirstRunBar", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem(KEYS.uiPrefs, JSON.stringify({ locale: "zh-TW", currency: "TWD", theme: "system" }));
|
||||
harness.member = member();
|
||||
harness.updateProfile.mockClear();
|
||||
harness.accounts = [];
|
||||
});
|
||||
|
||||
it("only asks a new member to connect Threads", async () => {
|
||||
renderBar();
|
||||
expect(await screen.findByRole("region", { name: "第一次設定" })).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "連 Threads 帳號" }).getAttribute("href")).toBe("/app/crew");
|
||||
expect(screen.queryByText("整理品牌與產品")).toBeNull();
|
||||
expect(screen.queryByText("建立每日巡邏")).toBeNull();
|
||||
expect(screen.queryByText("看第一筆商機")).toBeNull();
|
||||
expect(screen.getByRole("link", { name: "今日" })).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "帳號" })).toBeTruthy();
|
||||
expect(screen.queryByRole("link", { name: "品牌" })).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: "創作" })).toBeNull();
|
||||
});
|
||||
|
||||
it("sends people back to connect if they open another feature", async () => {
|
||||
renderBar("/app/studio");
|
||||
expect(await screen.findByText("crew-page")).toBeTruthy();
|
||||
expect(screen.queryByText("studio-page")).toBeNull();
|
||||
});
|
||||
|
||||
it("completes after a Threads account is connected", async () => {
|
||||
harness.accounts = [{ id: "acc-1", is_usable: true }];
|
||||
renderBar();
|
||||
await waitFor(() => expect(harness.updateProfile).toHaveBeenCalledWith({ onboarding_status: "completed" }));
|
||||
});
|
||||
|
||||
it("does not show for members who already finished", () => {
|
||||
harness.member = member({ onboarding_status: "completed" });
|
||||
renderBar();
|
||||
expect(screen.queryByRole("region", { name: "第一次設定" })).toBeNull();
|
||||
expect(screen.getByRole("link", { name: "創作" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { useRepos } from "../data/DataContext";
|
||||
import { isFirstRunAllowedPath } from "./paths";
|
||||
|
||||
export type FirstRunStepId = "crew";
|
||||
|
||||
export type FirstRunStep = {
|
||||
id: FirstRunStepId;
|
||||
done: boolean;
|
||||
to: string;
|
||||
};
|
||||
|
||||
type FirstRunValue = {
|
||||
active: boolean;
|
||||
busy: boolean;
|
||||
steps: FirstRunStep[];
|
||||
current: FirstRunStep | null;
|
||||
};
|
||||
|
||||
const INACTIVE: FirstRunValue = {
|
||||
active: false,
|
||||
busy: false,
|
||||
steps: [],
|
||||
current: null,
|
||||
};
|
||||
|
||||
const FirstRunContext = createContext<FirstRunValue | null>(null);
|
||||
|
||||
function isTerminal(status?: string): boolean {
|
||||
return status === "skipped" || status === "completed";
|
||||
}
|
||||
|
||||
export function FirstRunProvider({ children }: { children: ReactNode }) {
|
||||
const { member, loading, updateProfile } = useAuth();
|
||||
const repos = useRepos();
|
||||
const { pathname } = useLocation();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [crewDone, setCrewDone] = useState(false);
|
||||
const wroteRef = useRef(false);
|
||||
|
||||
const pending = Boolean(member) && !loading && !isTerminal(member?.onboarding_status);
|
||||
|
||||
const loadProgress = useCallback(async () => {
|
||||
if (!pending) return;
|
||||
const accounts = await repos.accounts.list().catch(() => []);
|
||||
setCrewDone(accounts.some((a) => a.is_usable) || accounts.length > 0);
|
||||
}, [pending, repos.accounts]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProgress();
|
||||
}, [loadProgress, pathname]);
|
||||
|
||||
const finish = useCallback(async () => {
|
||||
if (!pending || wroteRef.current) return;
|
||||
wroteRef.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
await updateProfile({ onboarding_status: "completed" });
|
||||
} catch {
|
||||
wroteRef.current = false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [pending, updateProfile]);
|
||||
|
||||
const steps = useMemo<FirstRunStep[]>(
|
||||
() => [{ id: "crew", done: crewDone, to: "/app/crew" }],
|
||||
[crewDone],
|
||||
);
|
||||
|
||||
const current = steps.find((s) => !s.done) ?? null;
|
||||
const allDone = steps.length > 0 && steps.every((s) => s.done);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pending || busy || !allDone) return;
|
||||
void finish();
|
||||
}, [pending, busy, allDone, finish]);
|
||||
|
||||
const value = useMemo<FirstRunValue>(
|
||||
() => ({
|
||||
active: pending && !allDone,
|
||||
busy,
|
||||
steps,
|
||||
current,
|
||||
}),
|
||||
[pending, allDone, busy, steps, current],
|
||||
);
|
||||
|
||||
const gateOffPath = value.active && value.current && !isFirstRunAllowedPath(pathname);
|
||||
|
||||
return (
|
||||
<FirstRunContext.Provider value={value}>
|
||||
{gateOffPath ? <Navigate to={value.current.to} replace /> : children}
|
||||
</FirstRunContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useFirstRun(): FirstRunValue {
|
||||
return useContext(FirstRunContext) ?? INACTIVE;
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
/** 第一次引導期間允許停留的頁面。其他路徑會被帶回連帳號。 */
|
||||
export function isFirstRunAllowedPath(pathname: string): boolean {
|
||||
if (pathname === "/app" || pathname === "/app/today") return true;
|
||||
if (pathname.startsWith("/app/crew")) return true;
|
||||
if (pathname.startsWith("/app/settings")) return true;
|
||||
if (pathname.startsWith("/app/profile")) return true;
|
||||
return false;
|
||||
}
|
||||
|
|
@ -1,9 +1,20 @@
|
|||
import type { OwnPost } from "../domain/types";
|
||||
import { newId } from "./id";
|
||||
import { translate } from "./i18n/messages";
|
||||
import { loadUiPrefs } from "./i18n/prefs";
|
||||
import { readJson, writeJson } from "./storage";
|
||||
import { nowUnixNano } from "./time";
|
||||
import { KEYS } from "../data/mock/keys";
|
||||
|
||||
function tr(key: string, params?: Record<string, string | number>): string {
|
||||
return translate(loadUiPrefs().locale, key, params);
|
||||
}
|
||||
|
||||
function formatCount(n: number): string {
|
||||
const locale = loadUiPrefs().locale === "en" ? "en-US" : "zh-TW";
|
||||
return n.toLocaleString(locale);
|
||||
}
|
||||
|
||||
export type MonthBucket = {
|
||||
/** YYYY-MM */
|
||||
key: string;
|
||||
|
|
@ -441,7 +452,7 @@ function buildNarrative(opts: {
|
|||
monthLabel?: string;
|
||||
}): { analysis: string[]; recommendations: string[] } {
|
||||
const { current, previous, delta, topPosts, avgEngagementRate } = opts;
|
||||
const when = opts.monthLabel || current.label || "本月";
|
||||
const when = opts.monthLabel || current.label || tr("insights.thisMonth");
|
||||
const analysis: string[] = [];
|
||||
const recommendations: string[] = [];
|
||||
|
||||
|
|
@ -451,57 +462,61 @@ function buildNarrative(opts: {
|
|||
}
|
||||
|
||||
analysis.push(
|
||||
`${when}彙總:貼文 ${current.posts}、瀏覽 ${current.views.toLocaleString("zh-TW")}、讚 ${current.likes}、回覆 ${current.replies}(來自已同步貼文)。`,
|
||||
tr("insights.narrative.summary", {
|
||||
when,
|
||||
posts: current.posts,
|
||||
views: formatCount(current.views),
|
||||
likes: current.likes,
|
||||
replies: current.replies,
|
||||
}),
|
||||
);
|
||||
|
||||
// 僅在前月也有貼文時才比
|
||||
if (previous && monthHasRealData(previous) && delta.views != null) {
|
||||
if (delta.views > 8) {
|
||||
if (delta.views > 8 || 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)}(${previous.views.toLocaleString("zh-TW")} → ${current.views.toLocaleString("zh-TW")})。`,
|
||||
tr("insights.narrative.viewsDelta", {
|
||||
delta: fmtDelta(delta.views),
|
||||
prev: formatCount(previous.views),
|
||||
curr: formatCount(current.views),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
analysis.push(`瀏覽與前月大致持平(${fmtDelta(delta.views)})。`);
|
||||
analysis.push(tr("insights.narrative.viewsFlat", { delta: fmtDelta(delta.views) }));
|
||||
}
|
||||
}
|
||||
|
||||
if (previous && monthHasRealData(previous) && delta.replies != null) {
|
||||
if (delta.replies > 10) {
|
||||
analysis.push(`回覆數 ${fmtDelta(delta.replies)},對話熱度上升。`);
|
||||
analysis.push(tr("insights.narrative.repliesUp", { delta: fmtDelta(delta.replies) }));
|
||||
} else if (delta.replies < -10) {
|
||||
analysis.push(`回覆數 ${fmtDelta(delta.replies)}。`);
|
||||
analysis.push(tr("insights.narrative.repliesDelta", { delta: fmtDelta(delta.replies) }));
|
||||
}
|
||||
}
|
||||
|
||||
// 有瀏覽才談互動率,避免 0 瀏覽硬算 0% 再亂評
|
||||
if (current.views > 0) {
|
||||
const engPct = Math.round(avgEngagementRate * 1000) / 10;
|
||||
analysis.push(
|
||||
`互動率約 ${engPct}%(讚+回+轉+引用+分享/瀏覽)。`,
|
||||
);
|
||||
analysis.push(tr("insights.narrative.engRate", { pct: engPct }));
|
||||
if (engPct < 2) {
|
||||
recommendations.push("互動率偏低:可多試帶明確條件的提問收尾。");
|
||||
recommendations.push(tr("insights.narrative.engLow"));
|
||||
} else if (engPct >= 4) {
|
||||
recommendations.push("互動率不錯:可複製高表現貼的結構再測 1~2 則。");
|
||||
recommendations.push(tr("insights.narrative.engGood"));
|
||||
}
|
||||
} else if (current.likes + current.replies > 0) {
|
||||
analysis.push("此月有讚/回覆,但瀏覽為 0(Insights 可能尚未回傳或權限不足)。");
|
||||
analysis.push(tr("insights.narrative.zeroViews"));
|
||||
}
|
||||
|
||||
const top = topPosts[0];
|
||||
if (top) {
|
||||
const snippet = (top.insight || top.formula_summary || top.text).slice(0, 72);
|
||||
analysis.push(
|
||||
`表現較佳之一:${snippet}${snippet.length >= 72 ? "…" : ""}`,
|
||||
tr("insights.narrative.highlight", { snippet: `${snippet}${snippet.length >= 72 ? "…" : ""}` }),
|
||||
);
|
||||
}
|
||||
|
||||
if (current.posts < 3) {
|
||||
recommendations.push("該月貼文偏少,樣本小,月比僅供參考。");
|
||||
recommendations.push(tr("insights.narrative.smallSample"));
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { useI18n } from "../i18n/I18nContext";
|
|||
import { translate } from "./i18n/messages";
|
||||
import { loadUiPrefs } from "./i18n/prefs";
|
||||
import type { AppLocale } from "./i18n/types";
|
||||
import { sanitizeReviewCopy } from "./reviewCopy";
|
||||
|
||||
/** 與 backend internal/response + middleware 對齊 */
|
||||
export const API_CODES = {
|
||||
|
|
@ -111,7 +112,7 @@ export function messageForApiOk(
|
|||
if (!text) return translate(loc, fallbackKey);
|
||||
const key = i18nKeyForApiOkMessage(text);
|
||||
if (key) return translate(loc, key);
|
||||
return text;
|
||||
return sanitizeReviewCopy(text, loc);
|
||||
}
|
||||
|
||||
function currentLocale(): AppLocale {
|
||||
|
|
@ -174,6 +175,11 @@ type ErrLike = {
|
|||
|
||||
/** 從 thrown value 取可顯示字串(依目前語言包) */
|
||||
export function formatThrownError(err: unknown, fallbackKey = "common.error"): string {
|
||||
const loc = currentLocale();
|
||||
return sanitizeReviewCopy(formatThrownErrorRaw(err, fallbackKey), loc);
|
||||
}
|
||||
|
||||
function formatThrownErrorRaw(err: unknown, fallbackKey = "common.error"): string {
|
||||
const loc = currentLocale();
|
||||
const e = err as ErrLike | null;
|
||||
if (e && typeof e === "object" && e.name === "ApiError") {
|
||||
|
|
@ -232,15 +238,15 @@ export function useFormatApiError() {
|
|||
* 例:verification code sent → 「驗證碼已寄出」
|
||||
*/
|
||||
export function useFormatApiOk() {
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
return useCallback(
|
||||
(raw: string | undefined | null, fallbackKey = "common.success"): string => {
|
||||
const text = (raw || "").trim();
|
||||
if (!text) return t(fallbackKey);
|
||||
const key = i18nKeyForApiOkMessage(text);
|
||||
if (key) return t(key);
|
||||
return text;
|
||||
return sanitizeReviewCopy(text, locale);
|
||||
},
|
||||
[t],
|
||||
[t, locale],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ export async function requestExtensionSync(input?: {
|
|||
success: true,
|
||||
valid: true,
|
||||
synced: true,
|
||||
message: "Chrome session 已同步到巡樓爬蟲",
|
||||
message: "",
|
||||
account_id: accountId || undefined,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import type { BadgeTone } from "../components/ui";
|
||||
|
||||
export type FirstRunStatusKey = "pending" | "skipped" | "completed";
|
||||
|
||||
export function normalizeFirstRunStatus(status?: string): FirstRunStatusKey {
|
||||
if (status === "skipped" || status === "completed") return status;
|
||||
return "pending";
|
||||
}
|
||||
|
||||
export function firstRunStatusTone(status?: string): BadgeTone {
|
||||
const key = normalizeFirstRunStatus(status);
|
||||
if (key === "completed") return "success";
|
||||
if (key === "skipped") return "neutral";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
export function firstRunStatusLabelKey(status?: string): string {
|
||||
return `firstRun.status.${normalizeFirstRunStatus(status)}`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,4 +1,6 @@
|
|||
import type { Job, JobStatus } from "../domain/types";
|
||||
import { loadUiPrefs } from "./i18n/prefs";
|
||||
import { sanitizeReviewCopy } from "./reviewCopy";
|
||||
|
||||
/** 任務類型顯示名(中文) */
|
||||
export function jobTemplateLabel(
|
||||
|
|
@ -106,5 +108,5 @@ export function jobSubtitle(
|
|||
) {
|
||||
parts.push(t("jobs.nextRun", { time: formatTime(job.run_after) }));
|
||||
}
|
||||
return parts.join(" · ");
|
||||
return sanitizeReviewCopy(parts.join(" · "), loadUiPrefs().locale);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ export const primaryNav: NavItem[] = [
|
|||
{ key: "utm", path: "/app/utm", labelKey: "nav.utm", label: "追蹤", en: "UTM" },
|
||||
];
|
||||
|
||||
/** 第一次工作導覽期間只留這條線需要的入口 */
|
||||
export const firstRunNavKeys: NavKey[] = ["today", "crew"];
|
||||
|
||||
/** 手機底欄固定 4 格(主流程;radar 進主四格,話題移入更多) */
|
||||
export const mobileDockPrimaryKeys: NavKey[] = ["today", "studio", "radar", "outbox"];
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { readJson, writeJson } from "./storage";
|
|||
import { KEYS } from "../data/mock/keys";
|
||||
|
||||
/**
|
||||
* Today 頁「上手三步」引導的關閉狀態。純前端記憶(per browser),
|
||||
* 三步全部完成或使用者手動關閉後就不再出現。
|
||||
* 舊版 Today「上手三步」本機關閉旗標。
|
||||
* 只給第一次導覽遷移用:讀到 true 就 PATCH skipped,之後不再依賴這個 key。
|
||||
*/
|
||||
export function isRadarOnboardingDismissed(): boolean {
|
||||
return readJson<boolean>(KEYS.radarOnboardingDismissed, false);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { en, zhTW } from "./i18n/messages";
|
||||
import { sanitizeReviewCopy } from "./reviewCopy";
|
||||
|
||||
const BANNED = /爬蟲|crawler|\bcrawl(?:ing|ed)?\b/i;
|
||||
|
||||
describe("sanitizeReviewCopy", () => {
|
||||
it("strips crawler wording in zh-TW", () => {
|
||||
expect(sanitizeReviewCopy("Chrome session 已同步到巡樓爬蟲", "zh-TW")).toBe(
|
||||
"Chrome session 已同步到測試海巡",
|
||||
);
|
||||
expect(sanitizeReviewCopy("crawler session required when dev_mode enabled", "zh-TW")).toBe(
|
||||
"session required when dev_mode enabled",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips crawler wording and known backend hints in en", () => {
|
||||
expect(sanitizeReviewCopy("Chrome session 已同步到巡樓爬蟲", "en")).toBe(
|
||||
"Chrome session 已同步到test patrol",
|
||||
);
|
||||
expect(sanitizeReviewCopy("Chrome crawler is not configured", "en")).toBe(
|
||||
"Chrome is not configured",
|
||||
);
|
||||
expect(
|
||||
sanitizeReviewCopy(
|
||||
"今天沒巡到:Chrome 登入已過期。請到設定重新同步已登入的 Threads 分頁,或先關掉開發模式改走 API 搜尋。",
|
||||
"en",
|
||||
),
|
||||
).toMatch(/Chrome sign-in expired/);
|
||||
expect(sanitizeReviewCopy("crawler session required", "en")).not.toMatch(/crawler/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("i18n review copy", () => {
|
||||
it("keeps zh-TW and en catalogs in sync", () => {
|
||||
expect(Object.keys(en).sort()).toEqual(Object.keys(zhTW).sort());
|
||||
});
|
||||
|
||||
it("has no crawler wording in zh-TW or en catalogs", () => {
|
||||
for (const [locale, dict] of [
|
||||
["zh-TW", zhTW],
|
||||
["en", en],
|
||||
] as const) {
|
||||
for (const [key, value] of Object.entries(dict)) {
|
||||
expect(value, `${locale} ${key}`).not.toMatch(BANNED);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { loadUiPrefs } from "./i18n/prefs";
|
||||
import type { AppLocale } from "./i18n/types";
|
||||
|
||||
type Rule = [RegExp, string];
|
||||
|
||||
const EN_TOKEN_RULES: Rule[] = [
|
||||
[/Chrome\s*crawler/gi, "Chrome"],
|
||||
[/the patrol crawler/gi, "test patrol"],
|
||||
[/crawler session/gi, "session"],
|
||||
[/crawler/gi, "local"],
|
||||
[/\bcrawling\b/gi, "analyzing"],
|
||||
[/\bcrawl\b/gi, "analyze"],
|
||||
];
|
||||
|
||||
const RULES: Record<AppLocale, Rule[]> = {
|
||||
"zh-TW": [
|
||||
[/巡樓爬蟲/g, "測試海巡"],
|
||||
[/海巡爬蟲/g, "測試海巡"],
|
||||
[/Chrome\s*爬蟲/g, "Chrome 工作階段"],
|
||||
[/爬蟲\s*session/gi, "工作階段"],
|
||||
[/爬蟲/g, "本機"],
|
||||
...EN_TOKEN_RULES,
|
||||
],
|
||||
en: [
|
||||
[/巡樓爬蟲/g, "test patrol"],
|
||||
[/海巡爬蟲/g, "test patrol"],
|
||||
[/Chrome\s*爬蟲/g, "Chrome session"],
|
||||
[/爬蟲\s*session/gi, "session"],
|
||||
[/爬蟲/g, "local"],
|
||||
...EN_TOKEN_RULES,
|
||||
],
|
||||
};
|
||||
|
||||
/** 後端固定中文提示 → 英文 UI 對應句(不含爬蟲字眼)。 */
|
||||
const KNOWN_ZH_TO_EN: Array<[string, string]> = [
|
||||
[
|
||||
"今天沒巡到:Chrome 登入已過期。請到設定重新同步已登入的 Threads 分頁,或先關掉開發模式改走 API 搜尋。",
|
||||
"Today's patrol didn't finish: Chrome sign-in expired. Reconnect the signed-in Threads tab in Settings, or switch test patrol off and use API search.",
|
||||
],
|
||||
["今天沒巡到:", "Today's patrol didn't finish: "],
|
||||
];
|
||||
|
||||
/**
|
||||
* 審查時不要把「爬蟲/crawler」漏到畫面。
|
||||
* 後端或舊擴充若仍回這類字,顯示前先換成目前語系的中性說法。
|
||||
*/
|
||||
export function sanitizeReviewCopy(text: string, locale?: AppLocale): string {
|
||||
const loc = locale ?? loadUiPrefs().locale;
|
||||
let s = text;
|
||||
if (loc === "en") {
|
||||
for (const [from, to] of KNOWN_ZH_TO_EN) s = s.split(from).join(to);
|
||||
}
|
||||
for (const [re, to] of RULES[loc] ?? RULES["zh-TW"]) s = s.replace(re, to);
|
||||
return s;
|
||||
}
|
||||
|
|
@ -36,6 +36,8 @@ export type TenantUserRecord = {
|
|||
parent_uid: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
onboarding_status?: string;
|
||||
onboarding_done_at?: number;
|
||||
};
|
||||
|
||||
/** 管理員列表用(無密碼) */
|
||||
|
|
@ -55,6 +57,8 @@ export type MemberAdminView = {
|
|||
parent_uid: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
onboarding_status?: string;
|
||||
onboarding_done_at?: number;
|
||||
};
|
||||
|
||||
export type AdminCreateMemberInput = {
|
||||
|
|
@ -504,6 +508,8 @@ export function toAdminView(u: TenantUserRecord): MemberAdminView {
|
|||
parent_uid: u.parent_uid?.trim() || null,
|
||||
created_at: u.created_at,
|
||||
updated_at: u.updated_at,
|
||||
onboarding_status: u.onboarding_status,
|
||||
onboarding_done_at: u.onboarding_done_at,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { Role } from "../domain/types";
|
|||
import { useI18n } from "../i18n/I18nContext";
|
||||
import { memberRoleSummary, roleLabel } from "../lib/memberRole";
|
||||
import type { MemberAdminView } from "../lib/tenantUsers";
|
||||
import { firstRunStatusLabelKey, firstRunStatusTone } from "../lib/firstRunStatus";
|
||||
import { useFormatApiError } from "../lib/apiErrors";
|
||||
import { checkPasswordPolicy, isPasswordPolicyOk } from "../lib/passwordPolicy";
|
||||
import { formatLocalDateTime, nowUnixNano } from "../lib/time";
|
||||
|
|
@ -548,6 +549,9 @@ export function AdminUsersPage() {
|
|||
<Badge tone={u.email_verified ? "success" : "warning"}>
|
||||
{u.email_verified ? t("role.verified") : t("role.unverified")}
|
||||
</Badge>
|
||||
<Badge tone={firstRunStatusTone(u.onboarding_status)}>
|
||||
{t(firstRunStatusLabelKey(u.onboarding_status))}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
|
|
@ -602,6 +606,14 @@ export function AdminUsersPage() {
|
|||
<dt>{t("admin.users.status")}</dt>
|
||||
<dd>{isSuspended ? t("admin.users.suspended") : t("admin.users.active")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("admin.users.onboarding")}</dt>
|
||||
<dd>
|
||||
<Badge tone={firstRunStatusTone(selected.onboarding_status)}>
|
||||
{t(firstRunStatusLabelKey(selected.onboarding_status))}
|
||||
</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("admin.users.role")}</dt>
|
||||
<dd>{selected.roles.map((r) => roleLabel(r, t)).join(t("common.listSep"))}</dd>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||
import { PageHeader } from "../components/layout/PageHeader";
|
||||
import { Button, Card } from "../components/ui";
|
||||
import { apiRequest } from "../data/live/http";
|
||||
import { useI18n } from "../i18n/I18nContext";
|
||||
|
||||
type Bench = {
|
||||
available: boolean;
|
||||
|
|
@ -16,6 +17,7 @@ type Bench = {
|
|||
};
|
||||
|
||||
export function BenchmarkPage() {
|
||||
const { t } = useI18n();
|
||||
const [data, setData] = useState<Bench | null>(null);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
|
|
@ -36,30 +38,34 @@ export function BenchmarkPage() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="成效基準" />
|
||||
<PageHeader title={t("bench.title")} />
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<Button type="button" onClick={() => void load()}>
|
||||
重新查詢
|
||||
{t("bench.reload")}
|
||||
</Button>
|
||||
</div>
|
||||
{err ? <p className="hb-form-error">{err}</p> : null}
|
||||
{data ? (
|
||||
<Card>
|
||||
<p>樣本 {data.sample_size}</p>
|
||||
<p>{t("bench.sample", { n: data.sample_size })}</p>
|
||||
{data.available ? (
|
||||
<>
|
||||
<p>
|
||||
中位互動率 {(data.median_eng_rate * 100).toFixed(2)}% · 中位瀏覽{" "}
|
||||
{data.median_views.toFixed(0)}
|
||||
{t("bench.median", {
|
||||
eng: (data.median_eng_rate * 100).toFixed(2),
|
||||
views: data.median_views.toFixed(0),
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
你的互動率 {((data.your_eng_rate ?? 0) * 100).toFixed(2)}% · 均覽{" "}
|
||||
{(data.your_views ?? 0).toFixed(0)}
|
||||
{t("bench.yours", {
|
||||
eng: ((data.your_eng_rate ?? 0) * 100).toFixed(2),
|
||||
views: (data.your_views ?? 0).toFixed(0),
|
||||
})}
|
||||
</p>
|
||||
<p className="text-muted">{data.percentile_hint}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted">{data.message || "樣本不足"}</p>
|
||||
<p className="text-muted">{data.message || t("bench.insufficient")}</p>
|
||||
)}
|
||||
</Card>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ export function BrandsPage() {
|
|||
if (next) setProducts(await repos.scout.listProducts(next));
|
||||
else setProducts([]);
|
||||
} catch (e) {
|
||||
setMessage(e instanceof Error ? e.message : "品牌資料載入失敗,請稍後再試");
|
||||
setMessage(e instanceof Error ? e.message : t("brands.loadFail"));
|
||||
}
|
||||
})();
|
||||
}, [repos.scout, tick]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
|
@ -336,7 +336,7 @@ export function BrandsPage() {
|
|||
try {
|
||||
const affected = await repos.radar.listWatches(1, 100, undefined, undefined, undefined, id);
|
||||
const impact = affected.list.filter((w) => w.status === "active" || w.status === "paused").length;
|
||||
const confirmText = `${t("brands.confirmDeleteProduct")}${impact ? `\n\n將暫停 ${impact} 個相關商機訂閱;歷史商機與接觸紀錄會保留。` : ""}`;
|
||||
const confirmText = `${t("brands.confirmDeleteProduct")}${impact ? `\n\n${t("brands.deleteImpact", { n: impact })}` : ""}`;
|
||||
if (!window.confirm(confirmText)) {
|
||||
setBusy("");
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -452,7 +452,7 @@ function ContactDetailPanel({
|
|||
<span className="hb-crm-touch__at">{formatTimeAgo(touch.created_at)}</span>
|
||||
<span>
|
||||
{touch.type}
|
||||
{touch.product_label_snapshot ? ` · 產品:${touch.product_label_snapshot}` : ""}
|
||||
{touch.product_label_snapshot ? t("crm.board.touchProduct", { label: touch.product_label_snapshot }) : ""}
|
||||
{touch.to_stage ? ` → ${t(`crm.stage.${touch.to_stage}`)}` : ""}
|
||||
{touch.body ? ` · ${touch.body}` : ""}
|
||||
</span>
|
||||
|
|
@ -473,8 +473,8 @@ function ContactDetailPanel({
|
|||
</Badge>
|
||||
<p className="hb-opp-card__text">{opportunity.text.slice(0, 120)}</p>
|
||||
{opportunity.primary_product_label ? (
|
||||
<span className="radar-card__meta">主推產品:{opportunity.primary_product_label}{opportunity.primary_brand_name ? `(${opportunity.primary_brand_name})` : ""}</span>
|
||||
) : <span className="radar-card__meta">未指定產品</span>}
|
||||
<span className="radar-card__meta">{opportunity.primary_brand_name ? t("crm.board.primaryProductWithBrand", { label: opportunity.primary_product_label, brand: opportunity.primary_brand_name }) : t("crm.board.primaryProduct", { label: opportunity.primary_product_label })}</span>
|
||||
) : <span className="radar-card__meta">{t("crm.board.noProduct")}</span>}
|
||||
<a href={opportunity.permalink} target="_blank" rel="noreferrer">{t("radar.today.action.open")}</a>
|
||||
</li>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -40,11 +40,16 @@ export function InsightsPage() {
|
|||
<PageHeader title={t("insights.title")} />
|
||||
{err ? <p className="hb-form-error">{err}</p> : null}
|
||||
{sum ? (
|
||||
<Card title="近 3 個月摘要" className="hb-stack" style={{ marginBottom: "1rem" }}>
|
||||
<Card title={t("insights.summaryTitle")} className="hb-stack" style={{ marginBottom: "1rem" }}>
|
||||
<p>{sum.conclusion}</p>
|
||||
<p className="text-muted">
|
||||
貼文 {sum.post_count} · 瀏覽 {sum.total_views} · 讚 {sum.total_likes} · 回{" "}
|
||||
{sum.total_replies} · 均互 {(sum.avg_eng_rate * 100).toFixed(2)}%
|
||||
{t("insights.summaryStats", {
|
||||
posts: sum.post_count,
|
||||
views: sum.total_views,
|
||||
likes: sum.total_likes,
|
||||
replies: sum.total_replies,
|
||||
eng: (sum.avg_eng_rate * 100).toFixed(2),
|
||||
})}
|
||||
</p>
|
||||
<ul>
|
||||
{sum.suggestions.map((s) => (
|
||||
|
|
@ -53,11 +58,15 @@ export function InsightsPage() {
|
|||
</ul>
|
||||
{sum.top_posts?.length ? (
|
||||
<div>
|
||||
<strong>表現較佳</strong>
|
||||
<strong>{t("insights.summaryTop")}</strong>
|
||||
<ul>
|
||||
{sum.top_posts.map((p) => (
|
||||
<li key={p.id}>
|
||||
{(p.eng_rate * 100).toFixed(1)}% · 覽 {p.view_count} · {p.text_preview}
|
||||
{t("insights.topPostLine", {
|
||||
eng: (p.eng_rate * 100).toFixed(1),
|
||||
views: p.view_count,
|
||||
text: p.text_preview,
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -65,6 +65,9 @@ export function LoginPage() {
|
|||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
revealPassword
|
||||
revealShowLabel={t("login.showPassword")}
|
||||
revealHideLabel={t("login.hidePassword")}
|
||||
/>
|
||||
<Link to="/forgot-password" className="hb-login-forgot">
|
||||
{t("login.forgot")}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { PageHeader } from "../components/layout/PageHeader";
|
|||
import { Badge, Button, Card, EmptyState, Input, Textarea } from "../components/ui";
|
||||
import { useRepos } from "../data/DataContext";
|
||||
import { apiRequest } from "../data/live/http";
|
||||
import { useI18n } from "../i18n/I18nContext";
|
||||
|
||||
type Playbook = {
|
||||
id: string;
|
||||
|
|
@ -18,6 +19,7 @@ type Playbook = {
|
|||
};
|
||||
|
||||
export function PlaybooksPage() {
|
||||
const { t } = useI18n();
|
||||
const repos = useRepos();
|
||||
const [list, setList] = useState<Playbook[]>([]);
|
||||
const [mine, setMine] = useState(false);
|
||||
|
|
@ -48,7 +50,7 @@ export function PlaybooksPage() {
|
|||
setShowPub(false);
|
||||
setForm({ kind: "brief", title: "", niche: "", body: "", anonymous: true });
|
||||
await load();
|
||||
setMsg("已發布");
|
||||
setMsg(t("playbooks.published"));
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : "fail");
|
||||
} finally {
|
||||
|
|
@ -60,7 +62,7 @@ export function PlaybooksPage() {
|
|||
setBusy(id);
|
||||
try {
|
||||
await apiRequest(`/api/v1/playbooks/${id}/import`, { method: "POST", body: {} });
|
||||
setMsg("已引用到我的 playbook");
|
||||
setMsg(t("playbooks.imported"));
|
||||
await load();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : "fail");
|
||||
|
|
@ -71,47 +73,47 @@ export function PlaybooksPage() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Playbook 市集" />
|
||||
<PageHeader title={t("playbooks.title")} />
|
||||
{msg ? <p className="text-muted">{msg}</p> : null}
|
||||
<div className="hb-stack" style={{ marginBottom: "1rem" }}>
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
<select value={kind} onChange={(e) => setKind(e.target.value)}>
|
||||
<option value="">全部類型</option>
|
||||
<option value="brief">海巡 brief</option>
|
||||
<option value="persona">人設</option>
|
||||
<option value="play">互回劇本</option>
|
||||
<option value="">{t("playbooks.allKinds")}</option>
|
||||
<option value="brief">{t("playbooks.kind.brief")}</option>
|
||||
<option value="persona">{t("playbooks.kind.persona")}</option>
|
||||
<option value="play">{t("playbooks.kind.play")}</option>
|
||||
</select>
|
||||
<Input value={niche} onChange={(e) => setNiche(e.target.value)} placeholder="利基(保養/母嬰…)" />
|
||||
<Input value={niche} onChange={(e) => setNiche(e.target.value)} placeholder={t("playbooks.nichePh")} />
|
||||
<label>
|
||||
<input type="checkbox" checked={mine} onChange={(e) => setMine(e.target.checked)} /> 只看我的
|
||||
<input type="checkbox" checked={mine} onChange={(e) => setMine(e.target.checked)} /> {t("playbooks.mineOnly")}
|
||||
</label>
|
||||
<Button type="button" onClick={() => setShowPub((v) => !v)}>
|
||||
{showPub ? "取消" : "發布模板"}
|
||||
{showPub ? t("playbooks.cancel") : t("playbooks.publish")}
|
||||
</Button>
|
||||
</div>
|
||||
{showPub ? (
|
||||
<Card title="發布">
|
||||
<Card title={t("playbooks.publishCard")}>
|
||||
<div className="hb-stack">
|
||||
<select
|
||||
value={form.kind}
|
||||
onChange={(e) => setForm((f) => ({ ...f, kind: e.target.value }))}
|
||||
>
|
||||
<option value="brief">brief</option>
|
||||
<option value="persona">persona</option>
|
||||
<option value="play">play</option>
|
||||
<option value="brief">{t("playbooks.kind.brief")}</option>
|
||||
<option value="persona">{t("playbooks.kind.persona")}</option>
|
||||
<option value="play">{t("playbooks.kind.play")}</option>
|
||||
</select>
|
||||
<Input
|
||||
label="標題"
|
||||
label={t("playbooks.fieldTitle")}
|
||||
value={form.title}
|
||||
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label="利基"
|
||||
label={t("playbooks.fieldNiche")}
|
||||
value={form.niche}
|
||||
onChange={(e) => setForm((f) => ({ ...f, niche: e.target.value }))}
|
||||
/>
|
||||
<Textarea
|
||||
label="內容"
|
||||
label={t("playbooks.fieldBody")}
|
||||
rows={6}
|
||||
value={form.body}
|
||||
onChange={(e) => setForm((f) => ({ ...f, body: e.target.value }))}
|
||||
|
|
@ -122,31 +124,31 @@ export function PlaybooksPage() {
|
|||
checked={form.anonymous}
|
||||
onChange={(e) => setForm((f) => ({ ...f, anonymous: e.target.checked }))}
|
||||
/>{" "}
|
||||
匿名
|
||||
{t("playbooks.anonymous")}
|
||||
</label>
|
||||
<Button type="button" disabled={Boolean(busy)} onClick={() => void publish()}>
|
||||
送出
|
||||
{t("playbooks.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
{list.length === 0 ? (
|
||||
<EmptyState title="尚無模板" />
|
||||
<EmptyState title={t("playbooks.empty")} />
|
||||
) : (
|
||||
<ul className="hb-stack">
|
||||
{list.map((p) => (
|
||||
<Card key={p.id} title={p.title}>
|
||||
<div className="hb-inline-badges">
|
||||
<Badge tone="brand">{p.kind}</Badge>
|
||||
<Badge tone="brand">{p.kind === "brief" || p.kind === "persona" || p.kind === "play" ? t(`playbooks.kind.${p.kind}`) : p.kind}</Badge>
|
||||
{p.niche ? <Badge tone="neutral">{p.niche}</Badge> : null}
|
||||
<Badge tone="neutral">{p.author_label}</Badge>
|
||||
<Badge tone="neutral">引用 {p.import_count}</Badge>
|
||||
<Badge tone="neutral">{t("playbooks.imports", { n: p.import_count })}</Badge>
|
||||
</div>
|
||||
<pre style={{ whiteSpace: "pre-wrap", fontSize: "var(--hb-text-sm)" }}>{p.body.slice(0, 400)}</pre>
|
||||
{!p.mine ? (
|
||||
<Button type="button" disabled={busy === p.id} onClick={() => void importPb(p.id)}>
|
||||
引用
|
||||
{t("playbooks.import")}
|
||||
</Button>
|
||||
) : null}
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import { SweepFunnelSummary } from "../components/radar/SweepFunnelSummary";
|
|||
import { Button, EmptyState, Select } from "../components/ui";
|
||||
import { useRepos } from "../data/DataContext";
|
||||
import type { Brand, BrandProduct, JobStatus, Opportunity, OpportunityRemovalReason, OpportunityReviewState, OpportunityTimeScope, RadarSweep, RadarToday, RadarWatch } from "../domain/types";
|
||||
import { useI18n } from "../i18n/I18nContext";
|
||||
import { useFormatApiError } from "../lib/apiErrors";
|
||||
import { sanitizeReviewCopy } from "../lib/reviewCopy";
|
||||
import { formatLocalDateTime } from "../lib/time";
|
||||
import "../styles/radar.css";
|
||||
|
||||
|
|
@ -21,6 +23,7 @@ function normalizeSort(value: string | null): string {
|
|||
|
||||
export function RadarOpportunitiesPage() {
|
||||
const repos = useRepos();
|
||||
const { t, locale } = useI18n();
|
||||
const formatError = useFormatApiError();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [list, setList] = useState<Opportunity[]>([]);
|
||||
|
|
@ -105,7 +108,7 @@ export function RadarOpportunitiesPage() {
|
|||
next.delete("page");
|
||||
setParams(next, { replace: true });
|
||||
result = week;
|
||||
setNotice({ text: `巡邏結果不是都在「今天發的文」。已改看近 7 天(${week.total} 筆)。任務上的判定/新建數字包含同一篇再命中,不一定全是新卡片。` });
|
||||
setNotice({ text: t("radar.inbox.msg.widened7d", { n: week.total }) });
|
||||
} else {
|
||||
const all = await query("all");
|
||||
if (all.total > 0) {
|
||||
|
|
@ -116,11 +119,11 @@ export function RadarOpportunitiesPage() {
|
|||
next.delete("page");
|
||||
setParams(next, { replace: true });
|
||||
result = all;
|
||||
setNotice({ text: `近 7 天沒有待處理結果,已改看全部(${all.total} 筆)。` });
|
||||
setNotice({ text: t("radar.inbox.msg.widenedAll", { n: all.total }) });
|
||||
} else {
|
||||
const seen = await query("7d", "completed");
|
||||
if (seen.total > 0) {
|
||||
setNotice({ text: `這輪判定到的 ${seen.total} 筆已在「已看過」,所以「新找到」是空的。任務數字含再次命中的舊文。` });
|
||||
setNotice({ text: t("radar.inbox.msg.alreadyReviewed", { n: seen.total }) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -133,7 +136,7 @@ export function RadarOpportunitiesPage() {
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [repos.radar, page, band, state, brandId, productId, sort, reviewState, timeScope, formatError, setParams]);
|
||||
}, [repos.radar, page, band, state, brandId, productId, sort, reviewState, timeScope, formatError, setParams, t]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
useEffect(() => { void loadPatrol(); }, [loadPatrol]);
|
||||
|
|
@ -167,7 +170,7 @@ export function RadarOpportunitiesPage() {
|
|||
await load();
|
||||
setError("");
|
||||
setNotice({
|
||||
text: "已加入名單。這步是可選的,之後要追蹤再去名單即可。",
|
||||
text: t("radar.inbox.msg.accepted"),
|
||||
contactId: result.contact_id,
|
||||
});
|
||||
} catch (e) {
|
||||
|
|
@ -181,7 +184,7 @@ export function RadarOpportunitiesPage() {
|
|||
const deadline = Date.now() + 120_000;
|
||||
let last: { status: JobStatus; progress_summary: string; error: string } = {
|
||||
status: "queued",
|
||||
progress_summary: "立即巡邏已排程 · 等待 worker",
|
||||
progress_summary: t("radar.inbox.msg.waitingWorker"),
|
||||
error: "",
|
||||
};
|
||||
while (Date.now() < deadline) {
|
||||
|
|
@ -200,7 +203,7 @@ export function RadarOpportunitiesPage() {
|
|||
async function runNow() {
|
||||
const active = watches.filter((watch) => watch.status === "active");
|
||||
if (!active.length) {
|
||||
setError("沒有開著的每日巡邏。先設定要巡的產品與關鍵字,或恢復一組訂閱。");
|
||||
setError(t("radar.inbox.err.noActive"));
|
||||
return;
|
||||
}
|
||||
setSweeping(true);
|
||||
|
|
@ -213,10 +216,10 @@ export function RadarOpportunitiesPage() {
|
|||
if (res.job_id) jobIds.push(res.job_id);
|
||||
}
|
||||
if (!jobIds.length) {
|
||||
setError("沒有排到巡邏任務。");
|
||||
setError(t("radar.inbox.err.noJob"));
|
||||
return;
|
||||
}
|
||||
setNotice({ text: "立即巡邏進行中… 跑完才會把痛點列在下面。" });
|
||||
setNotice({ text: t("radar.inbox.msg.running") });
|
||||
// All active watches were queued together, so observe them together too.
|
||||
// Waiting serially made the page look frozen for up to 120s per watch.
|
||||
const finished = await Promise.all(jobIds.map((id) => waitForJob(id)));
|
||||
|
|
@ -224,9 +227,9 @@ export function RadarOpportunitiesPage() {
|
|||
const cancelled = finished.find((job) => job.status === "cancelled");
|
||||
const running = finished.filter((job) => job.timedOut || job.status === "pending" || job.status === "queued" || job.status === "running" || job.status === "cancel_requested");
|
||||
const terminalError = failed
|
||||
? failed.progress_summary || failed.error || "巡邏失敗。"
|
||||
? failed.progress_summary || failed.error || t("radar.inbox.err.failed")
|
||||
: cancelled
|
||||
? "巡邏已取消,不會誤顯示為已完成。可再按一次立即巡邏。"
|
||||
? t("radar.inbox.err.cancelled")
|
||||
: "";
|
||||
if (typeof repos.radar.listSweeps === "function") {
|
||||
const sweeps = await repos.radar.listSweeps(1, 1).catch(() => null);
|
||||
|
|
@ -240,12 +243,12 @@ export function RadarOpportunitiesPage() {
|
|||
setError(terminalError);
|
||||
setNotice(null);
|
||||
} else if (running.length > 0) {
|
||||
setNotice({ text: `已排入 ${jobIds.length} 組巡邏,目前仍在後台執行。可先離開這頁,完成後結果會留在這裡。` });
|
||||
setNotice({ text: t("radar.inbox.msg.queuedN", { n: jobIds.length }) });
|
||||
} else {
|
||||
setNotice({
|
||||
text: summary.includes("新建")
|
||||
? `${summary} 不是今天發的文也會留在下面。`
|
||||
: "這一輪巡邏跑完了。找到的痛點會留在下面。",
|
||||
text: /新建|created/i.test(summary)
|
||||
? t("radar.inbox.msg.doneWithSummary", { summary })
|
||||
: t("radar.inbox.msg.done"),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -290,36 +293,42 @@ export function RadarOpportunitiesPage() {
|
|||
function emptyCopy(): { title: string; description: string; action?: ReactNode } {
|
||||
if (filtered) {
|
||||
return {
|
||||
title: reviewState === "pending" ? "這個篩選下沒有結果" : reviewState === "completed" ? "目前沒有已看過的結果" : "目前沒有已丟掉的結果",
|
||||
description: "清除篩選或改看其他時間範圍。巡邏剛跑完的結果也可能在「近 7 天」或「全部」。",
|
||||
action: <Button type="button" variant="ghost" onClick={resetFilters}>清除篩選</Button>,
|
||||
title: t(
|
||||
reviewState === "pending"
|
||||
? "radar.inbox.empty.filteredPending"
|
||||
: reviewState === "completed"
|
||||
? "radar.inbox.empty.filteredCompleted"
|
||||
: "radar.inbox.empty.filteredRemoved",
|
||||
),
|
||||
description: t("radar.inbox.empty.filteredHint"),
|
||||
action: <Button type="button" variant="ghost" onClick={resetFilters}>{t("radar.inbox.clearFilters")}</Button>,
|
||||
};
|
||||
}
|
||||
if (reviewState === "completed") {
|
||||
return { title: "還沒有已看過的結果", description: "切回「新找到」繼續看巡邏到的痛點。" };
|
||||
return { title: t("radar.inbox.empty.noCompleted"), description: t("radar.inbox.empty.noCompletedHint") };
|
||||
}
|
||||
if (reviewState === "removed") {
|
||||
return { title: "還沒有丟掉的結果", description: "切回「新找到」繼續看巡邏到的痛點。" };
|
||||
return { title: t("radar.inbox.empty.noRemoved"), description: t("radar.inbox.empty.noRemovedHint") };
|
||||
}
|
||||
if (watchTotal === 0) {
|
||||
return {
|
||||
title: "還沒設定巡邏",
|
||||
description: "先選產品與客人會搜的關鍵字。設好後可立即巡邏,每日定時巡邏也會接著跑。",
|
||||
action: <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">設定巡邏</Link>,
|
||||
title: t("radar.inbox.empty.noWatchesTitle"),
|
||||
description: t("radar.inbox.empty.noWatchesHint"),
|
||||
action: <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">{t("radar.inbox.setupWatches")}</Link>,
|
||||
};
|
||||
}
|
||||
if (!scheduledOn) {
|
||||
return {
|
||||
title: "每日定時巡邏關著",
|
||||
description: "立即巡邏與每日定時都還在這個頁面。恢復至少一組訂閱後,兩個都能用;關掉其中一個不會藏掉另一個。",
|
||||
action: <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">打開每日定時巡邏</Link>,
|
||||
title: t("radar.inbox.empty.pausedTitle"),
|
||||
description: t("radar.inbox.empty.pausedHint"),
|
||||
action: <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">{t("radar.inbox.empty.openSchedule")}</Link>,
|
||||
};
|
||||
}
|
||||
if (!lastSweptAt) {
|
||||
return {
|
||||
title: "還沒巡邏過",
|
||||
description: "每日定時巡邏已開著,也可現在按「立即巡邏」。不是空白收件匣,只是第一輪還沒跑完。",
|
||||
action: <Button type="button" onClick={() => void runNow()} disabled={sweeping}>{sweeping ? "巡邏中…" : "立即巡邏"}</Button>,
|
||||
title: t("radar.inbox.empty.neverTitle"),
|
||||
description: t("radar.inbox.empty.neverHint"),
|
||||
action: <Button type="button" onClick={() => void runNow()} disabled={sweeping}>{sweeping ? t("radar.inbox.sweeping") : t("radar.inbox.sweepNow")}</Button>,
|
||||
};
|
||||
}
|
||||
if (todayMeta?.empty_reason === "sweep_failed") {
|
||||
|
|
@ -327,29 +336,36 @@ export function RadarOpportunitiesPage() {
|
|||
`${todayMeta.empty_hint || ""} ${lastSweep?.failed_reason || ""}`,
|
||||
);
|
||||
return {
|
||||
title: "上一輪巡邏沒跑完",
|
||||
description: todayMeta.empty_hint || "巡邏失敗。可再按立即巡邏,或改看近 7 天/全部。",
|
||||
title: t("radar.inbox.empty.failedTitle"),
|
||||
description: sanitizeReviewCopy(
|
||||
todayMeta.empty_hint || t("radar.empty.sweepFailedHint"),
|
||||
locale,
|
||||
),
|
||||
action: (
|
||||
<>
|
||||
{crawlerDead ? <Link className="hb-btn hb-btn--secondary" to="/app/settings">重新同步 Chrome</Link> : null}
|
||||
<Button type="button" onClick={() => void runNow()} disabled={sweeping}>{sweeping ? "巡邏中…" : "再巡一次"}</Button>
|
||||
{crawlerDead ? (
|
||||
<Link className="hb-btn hb-btn--secondary" to="/app/settings">
|
||||
{t("radar.reconnectSearch")}
|
||||
</Link>
|
||||
) : null}
|
||||
<Button type="button" onClick={() => void runNow()} disabled={sweeping}>{sweeping ? t("radar.inbox.sweeping") : t("radar.inbox.sweepAgain")}</Button>
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
if (lastSweep && lastSweep.hit_count === 0) {
|
||||
return {
|
||||
title: "搜尋沒撈到貼文",
|
||||
description: "門檻前就空了:關鍵字太長、太產品名、或 Threads 查無結果。改成客人會打的 2–4 字痛點詞再巡。",
|
||||
action: <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">改關鍵字</Link>,
|
||||
title: t("radar.inbox.empty.noHitsTitle"),
|
||||
description: t("radar.inbox.empty.noHitsHint"),
|
||||
action: <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">{t("radar.inbox.empty.editTerms")}</Link>,
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "這輪有巡,但沒找到符合的痛點",
|
||||
title: t("radar.inbox.empty.noFitTitle"),
|
||||
description: lastSweep
|
||||
? `搜尋命中 ${lastSweep.hit_count}、判定 ${lastSweep.judged_count}、新建 ${lastSweep.created_count}。不是今天發的文可改看近 7 天/全部。`
|
||||
: "新文章或產品對得上的需求會出現在這裡。也可改看「近 7 天」或「全部」,或調整要巡的關鍵字。",
|
||||
action: <Button type="button" variant="ghost" onClick={() => { setTimeScope("7d"); setPage(1); writeParams({ time_scope: "7d", page: "" }); }}>看近 7 天</Button>,
|
||||
? t("radar.inbox.empty.noFitStats", { hits: lastSweep.hit_count, judged: lastSweep.judged_count, created: lastSweep.created_count })
|
||||
: t("radar.inbox.empty.noFitHint"),
|
||||
action: <Button type="button" variant="ghost" onClick={() => { setTimeScope("7d"); setPage(1); writeParams({ time_scope: "7d", page: "" }); }}>{t("radar.inbox.see7d")}</Button>,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -357,102 +373,102 @@ export function RadarOpportunitiesPage() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="商機" />
|
||||
<PageHeader title={t("radar.inbox.title")} />
|
||||
|
||||
<section className="hb-radar-patrol" data-testid="radar-patrol-desk" aria-label="巡邏狀態">
|
||||
<section className="hb-radar-patrol" data-testid="radar-patrol-desk" aria-label={t("radar.inbox.patrolAria")}>
|
||||
<div className="hb-radar-patrol__status">
|
||||
<p>
|
||||
<strong>{scheduledOn ? "每日定時巡邏:開著" : "每日定時巡邏:關著"}</strong>
|
||||
<span>每天台北 06:00 自動巡一輪。關掉立即巡邏不會停每日定時。</span>
|
||||
<strong>{scheduledOn ? t("radar.inbox.scheduledOn") : t("radar.inbox.scheduledOff")}</strong>
|
||||
<span>{t("radar.inbox.scheduleHint")}</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>{lastSweptAt ? `上次巡邏:${formatLocalDateTime(lastSweptAt)}` : "還沒巡邏過"}</strong>
|
||||
<span>{scheduledOn ? `啟用中 ${activeCount} 組` : watchTotal ? "訂閱都暫停了,立即巡邏也需要至少一組開著" : "還沒設定要巡的產品與關鍵字"}</span>
|
||||
<strong>{lastSweptAt ? t("radar.inbox.lastSweep", { time: formatLocalDateTime(lastSweptAt) }) : t("radar.inbox.neverSwept")}</strong>
|
||||
<span>{scheduledOn ? t("radar.inbox.activeWatches", { n: activeCount }) : watchTotal ? t("radar.inbox.allPaused") : t("radar.inbox.noWatches")}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="hb-radar-patrol__actions">
|
||||
<Button type="button" onClick={() => void runNow()} disabled={sweeping || !scheduledOn}>
|
||||
{sweeping ? "巡邏中…" : "立即巡邏"}
|
||||
{sweeping ? t("radar.inbox.sweeping") : t("radar.inbox.sweepNow")}
|
||||
</Button>
|
||||
<Link className="hb-btn hb-btn--ghost" to="/app/radar/watches">設定巡邏</Link>
|
||||
<small>Chrome 失效時會備援改用 API;只有 provider 成功回傳才計點。</small>
|
||||
<Link className="hb-btn hb-btn--ghost" to="/app/radar/watches">{t("radar.inbox.setupWatches")}</Link>
|
||||
<small>{t("radar.patrol.searchFallback")}</small>
|
||||
</div>
|
||||
</section>
|
||||
{lastSweep ? <SweepFunnelSummary sweep={lastSweep} /> : null}
|
||||
|
||||
<section className="hb-radar-intro">
|
||||
<div>
|
||||
<strong>巡邏到痛點就看這裡</strong>
|
||||
<p>先讀「為什麼推薦」,留下或丟掉即可。加入名單是可選的,不是看結果的必要步驟。</p>
|
||||
<strong>{t("radar.inbox.introTitle")}</strong>
|
||||
<p>{t("radar.inbox.introBody")}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="hb-radar-filter-panel" aria-label="商機結果">
|
||||
<section className="hb-radar-filter-panel" aria-label={t("radar.inbox.resultsAria")}>
|
||||
<div className="hb-radar-filter-panel__head">
|
||||
<div className="hb-radar-inbox-tabs" role="tablist" aria-label="結果狀態">
|
||||
<div className="hb-radar-inbox-tabs" role="tablist" aria-label={t("radar.inbox.tabsAria")}>
|
||||
{(["pending", "completed", "removed"] as OpportunityReviewState[]).map((value) => (
|
||||
<Button key={value} type="button" variant={reviewState === value ? "primary" : "ghost"} aria-pressed={reviewState === value} onClick={() => {
|
||||
setReviewState(value); setPage(1); writeParams({ review_state: value === "pending" ? "" : value, page: "" });
|
||||
}}>
|
||||
{value === "pending" ? "新找到" : value === "completed" ? "已看過" : "已丟掉"}
|
||||
{t(`radar.inbox.tab.${value}`)}
|
||||
</Button>
|
||||
))}
|
||||
<span>共 {total} 筆</span>
|
||||
<span>{t("radar.inbox.total", { n: total })}</span>
|
||||
</div>
|
||||
{filtered ? <Button type="button" variant="ghost" onClick={resetFilters}>清除篩選</Button> : <span>預設先看今天剛巡到的結果</span>}
|
||||
{filtered ? <Button type="button" variant="ghost" onClick={resetFilters}>{t("radar.inbox.clearFilters")}</Button> : <span>{t("radar.inbox.defaultToday")}</span>}
|
||||
</div>
|
||||
<div className="hb-radar-inbox-essential-filters">
|
||||
<Select name="all-time-scope" label="看哪段時間" value={timeScope} onChange={(e) => {
|
||||
<Select name="all-time-scope" label={t("radar.inbox.timeScope")} value={timeScope} onChange={(e) => {
|
||||
const value = e.target.value as OpportunityTimeScope;
|
||||
setTimeScope(value); setPage(1); writeParams({ time_scope: value === "today" ? "" : value, page: "" });
|
||||
}}>
|
||||
<option value="today">今天</option><option value="7d">近 7 天</option><option value="all">全部</option>
|
||||
<option value="today">{t("radar.inbox.time.today")}</option><option value="7d">{t("radar.inbox.time.7d")}</option><option value="all">{t("radar.inbox.time.all")}</option>
|
||||
</Select>
|
||||
<Select name="all-sort" label="先看哪些" value={sort} onChange={(e) => {
|
||||
<Select name="all-sort" label={t("radar.inbox.sort")} value={sort} onChange={(e) => {
|
||||
setSort(e.target.value); setPage(1); writeParams({ sort: e.target.value === "recommended" ? "" : e.target.value, page: "" });
|
||||
}}>
|
||||
<option value="recommended">最對得上產品</option><option value="newest">最新貼文</option><option value="oldest">最舊貼文</option><option value="product_fit">最符合產品</option><option value="demand_intent">需求最明確</option>
|
||||
<option value="recommended">{t("radar.inbox.sort.recommended")}</option><option value="newest">{t("radar.inbox.sort.newest")}</option><option value="oldest">{t("radar.inbox.sort.oldest")}</option><option value="product_fit">{t("radar.inbox.sort.productFit")}</option><option value="demand_intent">{t("radar.inbox.sort.demandIntent")}</option>
|
||||
</Select>
|
||||
<Button type="button" variant="ghost" aria-expanded={advancedOpen} onClick={() => setAdvancedOpen((value) => !value)}>
|
||||
{advancedOpen ? "收起更多篩選" : `更多篩選${advancedFilterCount ? `(${advancedFilterCount})` : ""}`}
|
||||
{advancedOpen ? t("radar.inbox.hideFilters") : advancedFilterCount ? t("radar.inbox.moreFiltersN", { n: advancedFilterCount }) : t("radar.inbox.moreFilters")}
|
||||
</Button>
|
||||
</div>
|
||||
{advancedOpen ? <div className="hb-radar-filter-grid hb-radar-filter-grid--all" aria-label="更多篩選">
|
||||
{advancedOpen ? <div className="hb-radar-filter-grid hb-radar-filter-grid--all" aria-label={t("radar.inbox.moreFiltersAria")}>
|
||||
{brands.length ? (
|
||||
<Select name="all-brand" label="品牌" value={brandId} onChange={(e) => {
|
||||
<Select name="all-brand" label={t("radar.inbox.brand")} value={brandId} onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setBrandId(value); setProductId(""); setPage(1);
|
||||
writeParams({ brand_id: value, product_id: "", page: "" });
|
||||
}}>
|
||||
<option value="">全部品牌</option>
|
||||
<option value="">{t("radar.inbox.allBrands")}</option>
|
||||
{brands.map((b) => <option key={b.id} value={b.id}>{b.display_name}</option>)}
|
||||
</Select>
|
||||
) : null}
|
||||
{products.length ? (
|
||||
<Select name="all-product" label="產品" value={productId} onChange={(e) => {
|
||||
<Select name="all-product" label={t("radar.inbox.product")} value={productId} onChange={(e) => {
|
||||
setProductId(e.target.value); setPage(1);
|
||||
writeParams({ product_id: e.target.value, page: "" });
|
||||
}}>
|
||||
<option value="">全部產品</option>
|
||||
<option value="">{t("radar.inbox.allProducts")}</option>
|
||||
{products.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
|
||||
</Select>
|
||||
) : null}
|
||||
<Select name="all-band" label="商機意向" value={band} onChange={(e) => {
|
||||
<Select name="all-band" label={t("radar.inbox.band")} value={band} onChange={(e) => {
|
||||
setBand(e.target.value); setPage(1); writeParams({ band: e.target.value, page: "" });
|
||||
}}>
|
||||
<option value="">全部意向</option><option value="high">高意向</option><option value="mid">中意向</option><option value="low">低意向</option>
|
||||
<option value="">{t("radar.inbox.allBands")}</option><option value="high">{t("radar.inbox.band.high")}</option><option value="mid">{t("radar.inbox.band.mid")}</option><option value="low">{t("radar.inbox.band.low")}</option>
|
||||
</Select>
|
||||
<Select name="all-state" label="產品匹配" value={state} onChange={(e) => {
|
||||
<Select name="all-state" label={t("radar.inbox.match")} value={state} onChange={(e) => {
|
||||
setState(e.target.value); setPage(1); writeParams({ match_state: e.target.value, page: "" });
|
||||
}}>
|
||||
<option value="">全部狀態</option><option value="eligible">可跟進</option><option value="weak">弱適配</option><option value="excluded">已排除</option><option value="generic">未指定產品</option><option value="stale">超過 14 天</option>
|
||||
<option value="">{t("radar.inbox.allStates")}</option><option value="eligible">{t("radar.inbox.state.eligible")}</option><option value="weak">{t("radar.inbox.state.weak")}</option><option value="excluded">{t("radar.inbox.state.excluded")}</option><option value="generic">{t("radar.inbox.state.generic")}</option><option value="stale">{t("radar.inbox.state.stale")}</option>
|
||||
</Select>
|
||||
</div> : null}
|
||||
</section>
|
||||
|
||||
{error ? <p className="hb-banner-error" role="alert">{error}</p> : null}
|
||||
{notice ? <div className="hb-banner-ok" role="status"><span>{notice.text}</span>{notice.contactId ? <Link to={`/app/crm?contact=${encodeURIComponent(notice.contactId)}`}>前往名單</Link> : null}</div> : null}
|
||||
{loading ? <p className="hb-radar-section__hint" role="status">正在整理巡邏結果…</p> : null}
|
||||
{notice ? <div className="hb-banner-ok" role="status"><span>{notice.text}</span>{notice.contactId ? <Link to={`/app/crm?contact=${encodeURIComponent(notice.contactId)}`}>{t("radar.inbox.goCrm")}</Link> : null}</div> : null}
|
||||
{loading ? <p className="hb-radar-section__hint" role="status">{t("radar.inbox.loading")}</p> : null}
|
||||
{!loading && !error && !list.length ? (
|
||||
<EmptyState title={empty.title} description={empty.description} action={empty.action} />
|
||||
) : null}
|
||||
|
|
@ -465,15 +481,15 @@ export function RadarOpportunitiesPage() {
|
|||
busy={busyId === o.id}
|
||||
onOpen={setSelected}
|
||||
onAccept={(item) => void acceptOpportunity(item)}
|
||||
onComplete={(item) => void updateReviewState(item, { state: "completed" }, "已留下。沒有建立名單。")}
|
||||
onRemove={(item, input) => void updateReviewState(item, { state: "removed", removal_reason: input.reason, removal_note: input.note }, "已丟掉。可從「已丟掉」還原。")}
|
||||
onRestore={(item) => void updateReviewState(item, { state: item.previous_review_state === "completed" ? "completed" : "pending" }, "已還原。")}
|
||||
onComplete={(item) => void updateReviewState(item, { state: "completed" }, t("radar.inbox.msg.kept"))}
|
||||
onRemove={(item, input) => void updateReviewState(item, { state: "removed", removal_reason: input.reason, removal_note: input.note }, t("radar.inbox.msg.removed"))}
|
||||
onRestore={(item) => void updateReviewState(item, { state: item.previous_review_state === "completed" ? "completed" : "pending" }, t("radar.inbox.msg.restored"))}
|
||||
/>)}
|
||||
{total > PAGE_SIZE ? (
|
||||
<div className="hb-radar-pager">
|
||||
<Button variant="ghost" disabled={page <= 1} onClick={() => { const next = page - 1; setPage(next); writeParams({ page: String(next) }); }}>上一頁</Button>
|
||||
<span>第 {page} 頁/共 {pageCount} 頁</span>
|
||||
<Button variant="ghost" disabled={page >= pageCount} onClick={() => { const next = page + 1; setPage(next); writeParams({ page: String(next) }); }}>下一頁</Button>
|
||||
<Button variant="ghost" disabled={page <= 1} onClick={() => { const next = page - 1; setPage(next); writeParams({ page: String(next) }); }}>{t("radar.inbox.prevPage")}</Button>
|
||||
<span>{t("radar.inbox.pageOf", { page, pages: pageCount })}</span>
|
||||
<Button variant="ghost" disabled={page >= pageCount} onClick={() => { const next = page + 1; setPage(next); writeParams({ page: String(next) }); }}>{t("radar.inbox.nextPage")}</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
|
@ -483,7 +499,7 @@ export function RadarOpportunitiesPage() {
|
|||
busy={busyId === selected.id}
|
||||
onClose={() => setSelected(null)}
|
||||
onAccept={(item) => void acceptOpportunity(item)}
|
||||
onComplete={(item) => void updateReviewState(item, { state: "completed" }, "已留下。沒有建立名單。")}
|
||||
onComplete={(item) => void updateReviewState(item, { state: "completed" }, t("radar.inbox.msg.kept"))}
|
||||
/> : null}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ function statusTone(status: string): BadgeTone {
|
|||
function emptyAction(reason?: RadarEmptyReason): { to: string; labelKey?: string; label?: string } | null {
|
||||
switch (reason) {
|
||||
case "no_profile":
|
||||
return { to: "/app/brands", label: "設定品牌與產品" };
|
||||
return { to: "/app/brands", labelKey: "radar.today.empty.goBrands" };
|
||||
case "no_watch":
|
||||
case "all_watches_paused":
|
||||
case "not_swept_yet":
|
||||
|
|
@ -143,13 +143,13 @@ function OppCard({
|
|||
<div className="hb-opp-products">
|
||||
<div className="hb-opp-products__summary">
|
||||
<div className="hb-opp-products__primary">
|
||||
<span className="hb-opp-products__eyebrow">推薦產品</span>
|
||||
<strong>{o.primary_product_label || "尚未指定主推產品"}</strong>
|
||||
{o.primary_product_fit_score != null ? <Badge tone="brand">適配 {o.primary_product_fit_score}</Badge> : null}
|
||||
{o.primary_product_overridden ? <Badge tone="warning">人工指定</Badge> : null}
|
||||
<span className="hb-opp-products__eyebrow">{t("radar.today.productEyebrow")}</span>
|
||||
<strong>{o.primary_product_label || t("radar.today.noPrimary")}</strong>
|
||||
{o.primary_product_fit_score != null ? <Badge tone="brand">{t("radar.today.fitScore", { n: o.primary_product_fit_score })}</Badge> : null}
|
||||
{o.primary_product_overridden ? <Badge tone="warning">{t("radar.today.overridden")}</Badge> : null}
|
||||
</div>
|
||||
<Button type="button" variant="ghost" aria-expanded={productsOpen} onClick={() => setProductsOpen((v) => !v)}>
|
||||
{productsOpen ? "收合產品證據" : `查看 ${o.product_matches.length} 個產品匹配`}
|
||||
{productsOpen ? t("radar.today.hideEvidence") : t("radar.today.showMatches", { n: o.product_matches.length })}
|
||||
</Button>
|
||||
</div>
|
||||
{productsOpen ? (
|
||||
|
|
@ -159,7 +159,7 @@ function OppCard({
|
|||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : <span className="hb-radar-section__hint">未指定產品(沿用通用商機判定)</span>}
|
||||
) : <span className="hb-radar-section__hint">{t("radar.today.genericJudge")}</span>}
|
||||
|
||||
{shownReply ? (
|
||||
<pre className="radar-card__reply">{shownReply}</pre>
|
||||
|
|
@ -429,7 +429,7 @@ export function RadarTodayPage() {
|
|||
await run(`primary-${id}`, async () => {
|
||||
await repos.radar.setPrimaryProduct(id, productId, reason);
|
||||
await load();
|
||||
}, "已設定主推產品;後續高分匹配不會覆蓋這個選擇。" );
|
||||
}, t("radar.today.msg.primarySet"));
|
||||
}
|
||||
|
||||
async function copyReply(text: string) {
|
||||
|
|
@ -442,8 +442,6 @@ export function RadarTodayPage() {
|
|||
}
|
||||
|
||||
const action = data ? emptyAction(data.empty_reason) : null;
|
||||
const showSetupGuide = data?.stats.total === 0 && (data.empty_reason === "no_profile" || data.empty_reason === "no_watch");
|
||||
const setupStep = data?.empty_reason === "no_profile" ? 1 : data?.empty_reason === "no_watch" ? 2 : 3;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -451,29 +449,29 @@ export function RadarTodayPage() {
|
|||
|
||||
<section className="hb-radar-intro">
|
||||
<div>
|
||||
<strong>先看值得跟進的人,再決定怎麼回</strong>
|
||||
<p>系統會把 Threads 貼文和你的產品痛點比對、合併重複貼文,再依商機分數排序。</p>
|
||||
<strong>{t("radar.today.introTitle")}</strong>
|
||||
<p>{t("radar.today.introBody")}</p>
|
||||
</div>
|
||||
<nav className="hb-radar-intro__actions" aria-label="商機雷達導覽">
|
||||
<Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">管理每日巡邏</Link>
|
||||
<Link className="hb-btn hb-btn--ghost" to="/app/radar/opportunities">查看全部結果</Link>
|
||||
<nav className="hb-radar-intro__actions" aria-label={t("radar.today.navAria")}>
|
||||
<Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">{t("radar.today.manageWatches")}</Link>
|
||||
<Link className="hb-btn hb-btn--ghost" to="/app/radar/opportunities">{t("radar.today.viewAll")}</Link>
|
||||
</nav>
|
||||
</section>
|
||||
|
||||
<section className="hb-radar-filter-panel" aria-label="篩選今日商機">
|
||||
<section className="hb-radar-filter-panel" aria-label={t("radar.today.filterAria")}>
|
||||
<div className="hb-radar-filter-panel__head">
|
||||
<strong>篩選今日商機</strong>
|
||||
<span>先看全部;結果多時再縮小到品牌或產品。</span>
|
||||
<strong>{t("radar.today.filterTitle")}</strong>
|
||||
<span>{t("radar.today.filterHint")}</span>
|
||||
</div>
|
||||
<div className="hb-radar-filter-grid">
|
||||
{brands.length ? <Select name="radar-filter-brand" label="品牌" value={filterBrand} onChange={(e) => { const value = e.target.value; setFilterBrand(value); setFilterProduct(""); const next = new URLSearchParams(urlParams); if (value) next.set("brand_id", value); else next.delete("brand_id"); next.delete("product_id"); setUrlParams(next, { replace: true }); }}><option value="">全部品牌</option>{brands.map((b) => <option key={b.id} value={b.id}>{b.display_name}</option>)}</Select> : null}
|
||||
{products.length ? <Select name="radar-filter-product" label="產品" value={filterProduct} onChange={(e) => { setFilterProduct(e.target.value); updateFilterParam("product_id", e.target.value); }}><option value="">全部產品</option>{products.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}</Select> : null}
|
||||
<Select name="radar-filter-fit" label="產品適配" value={filterBand} onChange={(e) => { setFilterBand(e.target.value); updateFilterParam("fit_band", e.target.value); }}><option value="">全部適配</option><option value="strong">高適配</option><option value="possible">可能</option><option value="weak">弱適配</option></Select>
|
||||
{brands.length ? <Select name="radar-filter-brand" label={t("radar.inbox.brand")} value={filterBrand} onChange={(e) => { const value = e.target.value; setFilterBrand(value); setFilterProduct(""); const next = new URLSearchParams(urlParams); if (value) next.set("brand_id", value); else next.delete("brand_id"); next.delete("product_id"); setUrlParams(next, { replace: true }); }}><option value="">{t("radar.inbox.allBrands")}</option>{brands.map((b) => <option key={b.id} value={b.id}>{b.display_name}</option>)}</Select> : null}
|
||||
{products.length ? <Select name="radar-filter-product" label={t("radar.inbox.product")} value={filterProduct} onChange={(e) => { setFilterProduct(e.target.value); updateFilterParam("product_id", e.target.value); }}><option value="">{t("radar.inbox.allProducts")}</option>{products.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}</Select> : null}
|
||||
<Select name="radar-filter-fit" label={t("radar.today.fit")} value={filterBand} onChange={(e) => { setFilterBand(e.target.value); updateFilterParam("fit_band", e.target.value); }}><option value="">{t("radar.today.allFit")}</option><option value="strong">{t("radar.today.fit.strong")}</option><option value="possible">{t("radar.today.fit.possible")}</option><option value="weak">{t("radar.today.fit.weak")}</option></Select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="hb-radar-utility-actions">
|
||||
<span>沒有想看的貼文?</span>
|
||||
<span>{t("radar.today.needMore")}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
|
|
@ -529,20 +527,6 @@ export function RadarTodayPage() {
|
|||
|
||||
{loading && !data ? <p className="hb-radar-section__hint">{t("common.loading")}</p> : null}
|
||||
|
||||
{showSetupGuide ? (
|
||||
<section className="hb-radar-setup" aria-label="第一次使用商機雷達">
|
||||
<div className="hb-radar-setup__head">
|
||||
<div><strong>第一次使用,照這三步就好</strong><span>完成後系統會每天自動巡邏。</span></div>
|
||||
<span className="hb-radar-setup__progress">目前第 {setupStep} 步</span>
|
||||
</div>
|
||||
<ol className="hb-radar-setup__steps">
|
||||
<li className={setupStep === 1 ? "is-current" : setupStep > 1 ? "is-done" : ""}><span>1</span><div><strong>整理品牌與產品</strong><small>填入受眾、痛點與產品能力。</small></div><Link to="/app/brands">前往設定</Link></li>
|
||||
<li className={setupStep === 2 ? "is-current" : setupStep > 2 ? "is-done" : ""}><span>2</span><div><strong>建立每日巡邏</strong><small>選產品後採用建議關鍵字。</small></div><Link to="/app/radar/watches">建立巡邏</Link></li>
|
||||
<li className={setupStep === 3 ? "is-current" : ""}><span>3</span><div><strong>回來處理商機</strong><small>先看高分,再查看產品證據。</small></div></li>
|
||||
</ol>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{data ? (
|
||||
<section className="hb-radar-page">
|
||||
<div className="hb-radar-stats">
|
||||
|
|
@ -587,7 +571,7 @@ export function RadarTodayPage() {
|
|||
action={
|
||||
action ? (
|
||||
<Link className="hb-btn hb-btn--secondary" to={action.to}>
|
||||
{action.label || (action.labelKey ? t(action.labelKey) : t("common.continue"))}
|
||||
{action.labelKey ? t(action.labelKey) : action.label}
|
||||
</Link>
|
||||
) : null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@ export function RadarWatchesPage() {
|
|||
return;
|
||||
}
|
||||
if (!draft.id && productContextAvailable && (!draft.brandId || !draft.productId)) {
|
||||
setError("請先選擇品牌與產品,產品型雷達才能啟用。" );
|
||||
setError(t("radar.watches.needBrandProduct"));
|
||||
return;
|
||||
}
|
||||
if (!draft.id && productContextAvailable) {
|
||||
|
|
@ -247,7 +247,7 @@ export function RadarWatchesPage() {
|
|||
}
|
||||
const effectiveDemandMapState = pendingDemandMapPatch?.state ?? demandMap?.state;
|
||||
if (effectiveDemandMapState !== "ready") {
|
||||
setError("請先補齊產品需求地圖,再啟動產品型巡邏。" );
|
||||
setError(t("radar.watches.needDemandMap"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -307,12 +307,12 @@ export function RadarWatchesPage() {
|
|||
|
||||
async function assignProduct() {
|
||||
if (!draft.id || !draft.brandId || !draft.productId) {
|
||||
setError("請先選擇品牌與產品。" );
|
||||
setError(t("radar.watches.needBrandProductShort"));
|
||||
return;
|
||||
}
|
||||
await run("assign", async () => {
|
||||
await repos.radar.assignWatchProduct(draft.id, draft.brandId, draft.productId);
|
||||
}, "已補綁產品;之後不可在原訂閱更換產品。" );
|
||||
}, t("radar.watches.assigned"));
|
||||
setFormOpen(false);
|
||||
}
|
||||
|
||||
|
|
@ -491,7 +491,7 @@ export function RadarWatchesPage() {
|
|||
<div className="hb-radar-actions">
|
||||
{draft.id && !draft.brandId ? (
|
||||
<Button type="button" variant="secondary" onClick={() => void assignProduct()} disabled={busy === "assign" || !draft.brandId || !draft.productId}>
|
||||
{busy === "assign" ? "補綁中…" : "補綁這個產品"}
|
||||
{busy === "assign" ? t("radar.watches.assigning") : t("radar.watches.assign")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" onClick={() => void save()} disabled={busy === "save"}>
|
||||
|
|
@ -537,7 +537,7 @@ export function RadarWatchesPage() {
|
|||
<Badge tone="brand">{w.brand_name_snapshot} · {w.product_label_snapshot}</Badge>
|
||||
) : null}
|
||||
{w.pause_reason === "product_unavailable" || w.pause_reason === "brand_unavailable" ? (
|
||||
<Badge tone="danger">產品已失效:請建立新訂閱</Badge>
|
||||
<Badge tone="danger">{t("radar.watches.productGone")}</Badge>
|
||||
) : null}
|
||||
<span>
|
||||
{w.regions.length
|
||||
|
|
|
|||
|
|
@ -1024,9 +1024,11 @@ export function ScoutPage() {
|
|||
<div className="hb-scout-now__meta">
|
||||
<div className="hb-inline-badges">
|
||||
<Badge tone="neutral">@{current.author}</Badge>
|
||||
<Badge tone="brand">{current.search_tag}</Badge>
|
||||
{current.search_tag ? (
|
||||
<Badge tone="brand">{t("scout.resultKeyword", { tag: current.search_tag })}</Badge>
|
||||
) : null}
|
||||
<Badge tone="neutral">{Math.round(current.score)}</Badge>
|
||||
{current.scan_path ? <Badge tone="neutral">{t("scout.source", { source: current.scan_path })}</Badge> : null}
|
||||
<Badge tone="neutral">{t("scout.source")}</Badge>
|
||||
{current.classification ? (
|
||||
<Badge tone="neutral">{t("scout.classification", { classification: current.classification })}</Badge>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { KEYS } from "../data/mock/keys";
|
||||
import { I18nProvider } from "../i18n/I18nContext";
|
||||
import { ThemeProvider } from "../theme/ThemeContext";
|
||||
import { SettingsPage } from "./SettingsPage";
|
||||
|
||||
vi.mock("../data/DataContext", () => ({
|
||||
useRepos: () => ({
|
||||
settings: {
|
||||
async getAi() {
|
||||
return {
|
||||
provider: "xai",
|
||||
model: "grok-4",
|
||||
research_provider: "xai",
|
||||
research_model: "grok-4",
|
||||
selected_model: "grok-4",
|
||||
models: ["grok-4"],
|
||||
api_key_configured: false,
|
||||
research_api_key_configured: false,
|
||||
};
|
||||
},
|
||||
async getPlacement() {
|
||||
return {
|
||||
web_search_provider: "exa",
|
||||
expand_strategy: "hybrid",
|
||||
brave_api_key_configured: false,
|
||||
exa_api_key_configured: false,
|
||||
dev_mode_enabled: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
useData: () => ({ refresh: vi.fn() }),
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<SettingsPage />
|
||||
</ThemeProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("SettingsPage lab section", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem(
|
||||
KEYS.uiPrefs,
|
||||
JSON.stringify({ locale: "zh-TW", currency: "TWD", theme: "system" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows local-session controls without crawler wording", async () => {
|
||||
renderPage();
|
||||
expect(await screen.findByTestId("settings-lab")).toBeTruthy();
|
||||
expect(screen.getByText("測試海巡(本機工作階段)")).toBeTruthy();
|
||||
expect(screen.queryByText(/爬蟲/)).toBeNull();
|
||||
expect(screen.queryByText(/crawler/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsPage lab section (en)", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem(
|
||||
KEYS.uiPrefs,
|
||||
JSON.stringify({ locale: "en", currency: "USD", theme: "system" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows English labels without crawler wording", async () => {
|
||||
renderPage();
|
||||
expect(await screen.findByTestId("settings-lab")).toBeTruthy();
|
||||
expect(screen.getByText("Test patrol (local session)")).toBeTruthy();
|
||||
expect(screen.queryByText(/crawler/i)).toBeNull();
|
||||
expect(screen.queryByText(/爬蟲/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -409,7 +409,7 @@ export function SettingsPage() {
|
|||
<option value="llm">LLM</option>
|
||||
<option value="hybrid">Hybrid</option>
|
||||
</Select>
|
||||
<div className="hb-stack hb-stack--tight">
|
||||
<div className="hb-stack hb-stack--tight" data-testid="settings-lab">
|
||||
<label className="hb-check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -421,19 +421,19 @@ export function SettingsPage() {
|
|||
<span>{t("settings.devMode")}</span>
|
||||
</label>
|
||||
<p className="text-muted hb-settings-dev-hint">{t("settings.devModeHint")}</p>
|
||||
{placement.dev_mode_enabled ? (
|
||||
<DevModeSessionCard
|
||||
onMessage={(msg) => {
|
||||
setError("");
|
||||
setMessage(msg);
|
||||
}}
|
||||
onError={(msg) => {
|
||||
setMessage("");
|
||||
setError(msg);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{placement.dev_mode_enabled ? (
|
||||
<DevModeSessionCard
|
||||
onMessage={(msg) => {
|
||||
setError("");
|
||||
setMessage(msg);
|
||||
}}
|
||||
onError={(msg) => {
|
||||
setMessage("");
|
||||
setError(msg);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div className="hb-wizard-actions">
|
||||
<Button type="button" onClick={() => void savePlacement()} disabled={Boolean(busy)}>
|
||||
{t("common.save")}
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ import type {
|
|||
TrendItem,
|
||||
WeeklyCheckup,
|
||||
} from "../domain/types";
|
||||
import { useFirstRun } from "../firstRun/FirstRunContext";
|
||||
import { useI18n } from "../i18n/I18nContext";
|
||||
import { useFormatApiError } from "../lib/apiErrors";
|
||||
import { dismissRadarOnboarding, isRadarOnboardingDismissed } from "../lib/radarOnboarding";
|
||||
import { loadScoutToday } from "../lib/scoutToday";
|
||||
|
||||
function isPendingScout(p: ScoutPost): boolean {
|
||||
|
|
@ -65,7 +65,7 @@ export function TodayPage() {
|
|||
const [outcomeSummary, setOutcomeSummary] = useState<OutcomeSummary | null>(null);
|
||||
const [checkup, setCheckup] = useState<WeeklyCheckup | null>(null);
|
||||
const [radarToday, setRadarToday] = useState<RadarToday | null>(null);
|
||||
const [onboardingDismissed, setOnboardingDismissed] = useState(() => isRadarOnboardingDismissed());
|
||||
const { active: firstRun } = useFirstRun();
|
||||
|
||||
const dateLocale = locale === "en" ? "en-US" : "zh-TW";
|
||||
|
||||
|
|
@ -244,30 +244,6 @@ export function TodayPage() {
|
|||
outcomeSummary.conversions),
|
||||
);
|
||||
|
||||
// 三步引導只從 radarToday.empty_reason 推斷,不額外打 API:
|
||||
// 有結果或曾巡過(含暫停/失敗/沒命中)代表訂閱已建立。
|
||||
const onboardingProfileDone = Boolean(
|
||||
radarToday && radarToday.empty_reason !== "no_profile",
|
||||
);
|
||||
const onboardingWatchDone = Boolean(
|
||||
radarToday &&
|
||||
(radarToday.stats.total > 0 ||
|
||||
["not_swept_yet", "sweep_failed", "no_hit", "all_watches_paused"].includes(
|
||||
radarToday.empty_reason || "",
|
||||
)),
|
||||
);
|
||||
const onboardingOpportunityDone = Boolean(radarToday && radarToday.stats.total > 0);
|
||||
const onboardingAllDone =
|
||||
onboardingProfileDone && onboardingWatchDone && onboardingOpportunityDone;
|
||||
const showOnboarding = Boolean(radarToday) && !onboardingAllDone && !onboardingDismissed;
|
||||
|
||||
useEffect(() => {
|
||||
if (onboardingAllDone && !onboardingDismissed) {
|
||||
dismissRadarOnboarding();
|
||||
setOnboardingDismissed(true);
|
||||
}
|
||||
}, [onboardingAllDone, onboardingDismissed]);
|
||||
|
||||
async function onRefreshTrends() {
|
||||
setRefreshingTrends(true);
|
||||
setError("");
|
||||
|
|
@ -335,58 +311,6 @@ export function TodayPage() {
|
|||
</p>
|
||||
) : null}
|
||||
|
||||
{showOnboarding ? (
|
||||
<Card title={t("today.onboarding.title")} className="hb-stack" style={{ marginBottom: "1rem" }}>
|
||||
<ul className="hb-today-list">
|
||||
{[
|
||||
{
|
||||
key: "profile",
|
||||
done: onboardingProfileDone,
|
||||
to: "/app/policy",
|
||||
label: t("today.onboarding.step.profile"),
|
||||
hint: t("today.onboarding.step.profileHint"),
|
||||
},
|
||||
{
|
||||
key: "watch",
|
||||
done: onboardingWatchDone,
|
||||
to: "/app/radar/watches",
|
||||
label: t("today.onboarding.step.watch"),
|
||||
hint: t("today.onboarding.step.watchHint"),
|
||||
},
|
||||
{
|
||||
key: "opportunity",
|
||||
done: onboardingOpportunityDone,
|
||||
to: "/app/radar",
|
||||
label: t("today.onboarding.step.opportunity"),
|
||||
hint: t("today.onboarding.step.opportunityHint"),
|
||||
},
|
||||
].map((step) => (
|
||||
<li key={step.key}>
|
||||
<Link to={step.to} className="hb-today-list__item">
|
||||
<span className="hb-today-list__meta">
|
||||
<Badge tone={step.done ? "success" : "neutral"}>
|
||||
{step.done ? t("today.onboarding.step.done") : t("today.onboarding.step.go")}
|
||||
</Badge>{" "}
|
||||
{step.label}
|
||||
</span>
|
||||
<span className="hb-today-list__text">{step.hint}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
dismissRadarOnboarding();
|
||||
setOnboardingDismissed(true);
|
||||
}}
|
||||
>
|
||||
{t("today.onboarding.dismiss")}
|
||||
</Button>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card title={t("today.radar.title")} className="hb-stack" style={{ marginBottom: "1rem" }}>
|
||||
{radarToday && radarToday.stats.total > 0 ? (
|
||||
<>
|
||||
|
|
@ -418,18 +342,19 @@ export function TodayPage() {
|
|||
<Link
|
||||
to={
|
||||
radarToday?.empty_reason === "no_profile"
|
||||
? "/app/policy"
|
||||
? "/app/brands"
|
||||
: "/app/radar/watches"
|
||||
}
|
||||
>
|
||||
{radarToday?.empty_reason === "no_profile"
|
||||
? t("today.radar.goProfile")
|
||||
? t("radar.today.empty.goBrands")
|
||||
: t("today.radar.goWatches")}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{firstRun ? null : <>
|
||||
<Card title={t("today.outcome.title")} className="hb-stack" style={{ marginBottom: "1rem" }}>
|
||||
<div className="hb-today-metrics" role="group" aria-label={t("today.outcome.title")}>
|
||||
<div className="hb-today-metric">
|
||||
|
|
@ -826,6 +751,7 @@ export function TodayPage() {
|
|||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||
import { PageHeader } from "../components/layout/PageHeader";
|
||||
import { Button, Card, EmptyState, Input } from "../components/ui";
|
||||
import { apiRequest } from "../data/live/http";
|
||||
import { useI18n } from "../i18n/I18nContext";
|
||||
|
||||
type Link = {
|
||||
id: string;
|
||||
|
|
@ -21,6 +22,7 @@ function trackUrlOf(l: Link): string {
|
|||
}
|
||||
|
||||
export function UtmLinksPage() {
|
||||
const { t } = useI18n();
|
||||
const [list, setList] = useState<Link[]>([]);
|
||||
const [url, setUrl] = useState("");
|
||||
const [label, setLabel] = useState("");
|
||||
|
|
@ -44,7 +46,7 @@ export function UtmLinksPage() {
|
|||
setUrl("");
|
||||
setLabel("");
|
||||
await load();
|
||||
setMsg("已建立追蹤連結");
|
||||
setMsg(t("utm.created"));
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : "fail");
|
||||
}
|
||||
|
|
@ -53,38 +55,38 @@ export function UtmLinksPage() {
|
|||
async function copy(l: Link) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(trackUrlOf(l));
|
||||
setMsg("已複製追蹤連結");
|
||||
setMsg(t("utm.copied"));
|
||||
} catch {
|
||||
setMsg("複製失敗,請手動選取網址");
|
||||
setMsg(t("utm.copyFail"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="UTM 追蹤連結" />
|
||||
<PageHeader title={t("utm.title")} />
|
||||
{msg ? <p className="text-muted">{msg}</p> : null}
|
||||
<Card title="新建">
|
||||
<Card title={t("utm.new")}>
|
||||
<div className="hb-stack">
|
||||
<Input label="目標 URL" value={url} onChange={(e) => setUrl(e.target.value)} />
|
||||
<Input label="標籤" value={label} onChange={(e) => setLabel(e.target.value)} />
|
||||
<Input label={t("utm.dest")} value={url} onChange={(e) => setUrl(e.target.value)} />
|
||||
<Input label={t("utm.label")} value={label} onChange={(e) => setLabel(e.target.value)} />
|
||||
<Button type="button" disabled={!url.startsWith("http")} onClick={() => void create()}>
|
||||
建立
|
||||
{t("utm.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
{list.length === 0 ? (
|
||||
<EmptyState title="尚無連結" />
|
||||
<EmptyState title={t("utm.empty")} />
|
||||
) : (
|
||||
<ul className="hb-stack">
|
||||
{list.map((l) => (
|
||||
<Card key={l.id} title={l.label || l.code}>
|
||||
<p className="text-muted" style={{ wordBreak: "break-all" }}>
|
||||
追蹤:{trackUrlOf(l)}
|
||||
{t("utm.track", { url: trackUrlOf(l) })}
|
||||
</p>
|
||||
<p>目標:{l.destination_url}</p>
|
||||
<p>點擊:{l.clicks}</p>
|
||||
<p>{t("utm.destLine", { url: l.destination_url })}</p>
|
||||
<p>{t("utm.clicks", { n: l.clicks })}</p>
|
||||
<Button type="button" onClick={() => void copy(l)}>
|
||||
複製連結
|
||||
{t("utm.copy")}
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { ImageAttach } from "../../components/studio/ImageAttach";
|
||||
import { Badge, Button, Card, Input, Textarea } from "../../components/ui";
|
||||
import { Badge, Button, Card, Input, Select, Textarea } from "../../components/ui";
|
||||
import { useData, useRepos } from "../../data/DataContext";
|
||||
import { THREADS_REPLY_CONTROLS } from "../../domain/types";
|
||||
import { useJobLive } from "../../data/JobLiveContext";
|
||||
import { useI18n } from "../../i18n/I18nContext";
|
||||
import {
|
||||
|
|
@ -46,6 +47,7 @@ export function ComposerPanel({ accountId, personaId, personaReady }: Props) {
|
|||
const [title, setTitle] = useState("");
|
||||
/** Threads 話題標籤(topic_tag) */
|
||||
const [topicTag, setTopicTag] = useState("");
|
||||
const [replyControl, setReplyControl] = useState("everyone");
|
||||
const [showMimic, setShowMimic] = useState(false);
|
||||
const [sourceText, setSourceText] = useState("");
|
||||
const [mimicDirection, setMimicDirection] = useState("");
|
||||
|
|
@ -251,6 +253,7 @@ export function ComposerPanel({ accountId, personaId, personaReady }: Props) {
|
|||
imageUrls,
|
||||
schedule_start_at: startAt,
|
||||
topicTag: topicTag.trim() || undefined,
|
||||
replyControl: replyControl || "everyone",
|
||||
});
|
||||
// 先跳 outbox(worker 非同步發 Threads),refresh 不擋導航
|
||||
navigate(`/app/outbox/${bundle.id}`);
|
||||
|
|
@ -312,6 +315,19 @@ export function ComposerPanel({ accountId, personaId, personaReady }: Props) {
|
|||
placeholder={t("compose.topicTagPh")}
|
||||
hint={t("compose.topicTagHint")}
|
||||
/>
|
||||
<Select
|
||||
label={t("posts.whoCanReply")}
|
||||
name="reply_control"
|
||||
hint={t("compose.whoCanReplyHint")}
|
||||
value={replyControl}
|
||||
onChange={(e) => setReplyControl(e.target.value)}
|
||||
>
|
||||
{THREADS_REPLY_CONTROLS.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{t(`posts.replyControl.${id}`)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<ImageAttach
|
||||
images={images}
|
||||
onChange={setImages}
|
||||
|
|
|
|||
|
|
@ -889,7 +889,7 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
|
|||
const isSeed = trend.source_label === "seed";
|
||||
const label =
|
||||
trend.label.length > 14 ? `${trend.label.slice(0, 14)}…` : trend.label;
|
||||
const tip = [trend.summary, trend.source_label ? `來源:${trend.source_label}` : ""]
|
||||
const tip = [trend.summary, trend.source_label ? t("inspire.source", { label: trend.source_label }) : ""]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Badge, Button, Card, EmptyState, Textarea } from "../../components/ui";
|
|||
import { useData, useRepos } from "../../data/DataContext";
|
||||
import type { OwnPost, OwnPostReply, Persona, ThreadsAccount } from "../../domain/types";
|
||||
import { useI18n } from "../../i18n/I18nContext";
|
||||
import { useFormatApiError } from "../../lib/apiErrors";
|
||||
import { buildStructureNotes, saveComposeMimicBridge } from "../../lib/composeBridge";
|
||||
import { allowHttpUrl } from "../../lib/externalUrl";
|
||||
import { isPersonaReady } from "../../lib/personaPrompt";
|
||||
|
|
@ -69,6 +70,10 @@ function hasMyChildReply(
|
|||
* 未回覆:別人的第一層留言,且底下還沒有我的子回覆。
|
||||
* 已回覆:底下已有 is_mine/我的帳號 username 的回覆,或後端 reply_status=replied。
|
||||
*/
|
||||
function isReplyHidden(r: OwnPostReply): boolean {
|
||||
return (r.hide_status || "").toUpperCase() === "HIDDEN";
|
||||
}
|
||||
|
||||
function isPendingReply(
|
||||
r: OwnPostReply,
|
||||
all: OwnPostReply[],
|
||||
|
|
@ -83,6 +88,7 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
|
|||
const repos = useRepos();
|
||||
const { refresh, tick } = useData();
|
||||
const { t } = useI18n();
|
||||
const formatError = useFormatApiError();
|
||||
const navigate = useNavigate();
|
||||
const [posts, setPosts] = useState<OwnPost[]>([]);
|
||||
const [syncedAt, setSyncedAt] = useState<number | null>(null);
|
||||
|
|
@ -99,9 +105,12 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
|
|||
const [replyFilter, setReplyFilter] = useState<ReplyFilter>("pending");
|
||||
const [busy, setBusy] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [messageTone, setMessageTone] = useState<"ok" | "error">("ok");
|
||||
const [verifyUrl, setVerifyUrl] = useState("");
|
||||
/** 本頁已載過留言的 post id(避免每次展開重打) */
|
||||
const [repliesLoaded, setRepliesLoaded] = useState<Record<string, boolean>>({});
|
||||
|
||||
|
||||
const myUsernames = new Set(accounts.map((a) => a.username));
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -115,6 +124,18 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
|
|||
})();
|
||||
}, [accountId, repos.ownPosts, tick]);
|
||||
|
||||
function flashOk(text: string, url = "") {
|
||||
setMessageTone("ok");
|
||||
setMessage(text);
|
||||
setVerifyUrl(url);
|
||||
}
|
||||
|
||||
function flashErr(err: unknown, fallback: string) {
|
||||
setMessageTone("error");
|
||||
setMessage(formatError(err, fallback));
|
||||
setVerifyUrl("");
|
||||
}
|
||||
|
||||
function getSel(key: string): ReplySelection {
|
||||
return selByKey[key] || defaultSelection(accountId, personaId);
|
||||
}
|
||||
|
|
@ -138,16 +159,18 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
|
|||
if (!accountId) return;
|
||||
setBusy("sync");
|
||||
setMessage("");
|
||||
setMessageTone("ok");
|
||||
setVerifyUrl("");
|
||||
try {
|
||||
const list = await repos.ownPosts.sync(accountId);
|
||||
setPosts(list);
|
||||
setSyncedAt(await repos.ownPosts.lastSyncedAt());
|
||||
// 重新同步後留言改點開再載
|
||||
setRepliesLoaded({});
|
||||
setMessage(t("posts.syncDone", { n: list.length }));
|
||||
flashOk(t("posts.syncDone", { n: list.length }));
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setMessage(e instanceof Error ? e.message : t("posts.syncFail"));
|
||||
flashErr(e, "posts.syncFail");
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
|
|
@ -170,7 +193,7 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
|
|||
setPosts((list) => list.map((p) => (p.id === next.id ? next : p)));
|
||||
setRepliesLoaded((m) => ({ ...m, [post.id]: true }));
|
||||
} catch (e) {
|
||||
setMessage(e instanceof Error ? e.message : t("posts.loadRepliesFail"));
|
||||
flashErr(e, "posts.loadRepliesFail");
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
|
|
@ -180,7 +203,7 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
|
|||
const sel = getSel(key);
|
||||
const persona = personas.find((p) => p.id === sel.personaId);
|
||||
if (!isPersonaReady(persona)) {
|
||||
setMessage(t("posts.needPersona"));
|
||||
flashErr(new Error(t("posts.needPersona")), "posts.needPersona");
|
||||
return;
|
||||
}
|
||||
setBusy(key);
|
||||
|
|
@ -194,7 +217,7 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
|
|||
setDraftByKey((m) => ({ ...m, [key]: text }));
|
||||
setComposeOpen((m) => ({ ...m, [key]: true }));
|
||||
} catch (e) {
|
||||
setMessage(e instanceof Error ? e.message : t("posts.genFail"));
|
||||
flashErr(e, "posts.genFail");
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
|
|
@ -204,15 +227,15 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
|
|||
const text = draftByKey[key];
|
||||
const sel = getSel(key);
|
||||
if (!text?.trim()) {
|
||||
setMessage(t("posts.needText"));
|
||||
flashErr(new Error(t("posts.needText")), "posts.needText");
|
||||
return;
|
||||
}
|
||||
if (!sel.accountId) {
|
||||
setMessage(t("posts.needAccount"));
|
||||
flashErr(new Error(t("posts.needAccount")), "posts.needAccount");
|
||||
return;
|
||||
}
|
||||
setBusy(`send:${key}`);
|
||||
setMessage(t("posts.sending"));
|
||||
flashOk(t("posts.sending"));
|
||||
try {
|
||||
// 回覆留言只送文字,不附圖
|
||||
const next = await repos.ownPosts.sendReply({
|
||||
|
|
@ -225,13 +248,35 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
|
|||
setRepliesLoaded((m) => ({ ...m, [post.id]: true }));
|
||||
const user =
|
||||
accounts.find((a) => a.id === sel.accountId)?.username || t("posts.accountFallback");
|
||||
setMessage(t("posts.sent", { user }));
|
||||
flashOk(t("posts.sent", { user }), allowHttpUrl(next.permalink || post.permalink) || "");
|
||||
setComposeOpen((m) => ({ ...m, [key]: false }));
|
||||
setDraftByKey((m) => ({ ...m, [key]: "" }));
|
||||
setExpanded(post.id);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
setMessage(e instanceof Error ? e.message : t("posts.sendFail"));
|
||||
flashErr(e, "posts.sendFail");
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function manageReply(post: OwnPost, reply: OwnPostReply, hide: boolean) {
|
||||
setBusy(`hide:${reply.id}`);
|
||||
setMessage("");
|
||||
setVerifyUrl("");
|
||||
try {
|
||||
const next = await repos.ownPosts.manageReply({
|
||||
postId: post.id,
|
||||
replyId: reply.id,
|
||||
hide,
|
||||
});
|
||||
setPosts((list) => list.map((p) => (p.id === next.id ? next : p)));
|
||||
flashOk(
|
||||
hide ? t("posts.replyHidden") : t("posts.replyUnhidden"),
|
||||
allowHttpUrl(next.permalink || post.permalink) || "",
|
||||
);
|
||||
} catch (e) {
|
||||
flashErr(e, "posts.hideFail");
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
|
|
@ -240,15 +285,15 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
|
|||
async function analyze(post: OwnPost) {
|
||||
setBusy(`an:${post.id}`);
|
||||
setMessage("");
|
||||
setMessageTone("ok");
|
||||
try {
|
||||
const next = await repos.ownPosts.analyzePost(post.id);
|
||||
setPosts((list) => list.map((p) => (p.id === next.id ? next : p)));
|
||||
setAnalyzedIds((m) => ({ ...m, [post.id]: true }));
|
||||
setExpanded(post.id);
|
||||
setMessage(t("posts.analyzeDone"));
|
||||
refresh();
|
||||
flashOk(t("posts.analyzeDone"));
|
||||
} catch (e) {
|
||||
setMessage(e instanceof Error ? e.message : t("posts.analyzeFail"));
|
||||
flashErr(e, "posts.analyzeFail");
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
|
|
@ -282,8 +327,16 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
|
|||
</p>
|
||||
</div>
|
||||
{message ? (
|
||||
<p className="hb-banner-ok" role="status">
|
||||
<p className={messageTone === "error" ? "hb-banner-error" : "hb-banner-ok"} role={messageTone === "error" ? "alert" : "status"}>
|
||||
{message}
|
||||
{verifyUrl ? (
|
||||
<>
|
||||
{" "}
|
||||
<a href={verifyUrl} target="_blank" rel="noreferrer">
|
||||
{t("posts.openThreads")}
|
||||
</a>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
|
@ -320,6 +373,11 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
|
|||
<Badge tone="warning">{post.insights_status}</Badge>
|
||||
) : null}
|
||||
{showAnalysis ? <Badge tone="success">{t("posts.analyzedBadge")}</Badge> : null}
|
||||
{post.reply_control ? (
|
||||
<Badge tone="neutral">
|
||||
{t("posts.whoCanReply")}: {t(`posts.replyControl.${post.reply_control}`)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p style={{ margin: "0 0 0.5rem" }}>{post.text || t("posts.noText")}</p>
|
||||
<PostMetrics post={post} variant="compact" />
|
||||
|
|
@ -478,11 +536,12 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
|
|||
const pending = isPendingReply(r, post.replies, myUsernames);
|
||||
const kids = childrenOf(post.replies, r.id);
|
||||
return (
|
||||
<div key={r.id} className="hb-script-row">
|
||||
<div key={r.id} className={`hb-script-row${isReplyHidden(r) ? " is-hidden" : ""}`}>
|
||||
<div className="hb-inline-badges" style={{ marginBottom: "0.35rem" }}>
|
||||
<Badge tone={pending ? "warning" : "success"}>
|
||||
{pending ? t("posts.status.pending") : t("posts.status.replied")}
|
||||
</Badge>
|
||||
{isReplyHidden(r) ? <Badge tone="neutral">{t("posts.hiddenBadge")}</Badge> : null}
|
||||
<strong>@{r.username}</strong>
|
||||
{typeof r.like_count === "number" ? (
|
||||
<span className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
|
||||
|
|
@ -522,13 +581,26 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
|
|||
) : null}
|
||||
|
||||
{/* 未回/已回都可再回:串成子留言 */}
|
||||
{!composeOpen[key] ? (
|
||||
<div className="hb-wizard-actions" style={{ marginTop: "0.35rem" }}>
|
||||
<div className="hb-wizard-actions" style={{ marginTop: "0.35rem" }}>
|
||||
{!composeOpen[key] ? (
|
||||
<Button type="button" variant="ghost" onClick={() => openCompose(key)}>
|
||||
{pending ? t("posts.replyThis") : t("posts.replyAgain")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={busy === `hide:${r.id}`}
|
||||
onClick={() => void manageReply(post, r, !isReplyHidden(r))}
|
||||
>
|
||||
{busy === `hide:${r.id}`
|
||||
? t("posts.hidingReply")
|
||||
: isReplyHidden(r)
|
||||
? t("posts.unhideReply")
|
||||
: t("posts.hideReply")}
|
||||
</Button>
|
||||
</div>
|
||||
{composeOpen[key] ? (
|
||||
<ReplyComposer
|
||||
accounts={accounts}
|
||||
personas={personas}
|
||||
|
|
@ -546,7 +618,7 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
|
|||
: t("posts.replyAgainTo", { user: r.username })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import { useState } from "react";
|
|||
import { Link } from "react-router-dom";
|
||||
import { Button, Card, Input, Textarea } from "../../components/ui";
|
||||
import { apiRequest } from "../../data/live/http";
|
||||
import { useI18n } from "../../i18n/I18nContext";
|
||||
|
||||
export function PainKeywordsPage() {
|
||||
const { t } = useI18n();
|
||||
const [brief, setBrief] = useState("");
|
||||
const [audience, setAudience] = useState("");
|
||||
const [result, setResult] = useState<{
|
||||
|
|
@ -36,31 +38,31 @@ export function PainKeywordsPage() {
|
|||
|
||||
return (
|
||||
<div className="hb-main__inner" style={{ maxWidth: 640, margin: "2rem auto", padding: "0 1rem" }}>
|
||||
<h1>痛點關鍵字產生器</h1>
|
||||
<p className="text-muted">免登入:描述產品,產出海巡可用的掃描詞與痛點。</p>
|
||||
<h1>{t("tools.pain.title")}</h1>
|
||||
<p className="text-muted">{t("tools.pain.subtitle")}</p>
|
||||
<Card>
|
||||
<Textarea label="產品簡述" rows={5} value={brief} onChange={(e) => setBrief(e.target.value)} />
|
||||
<Input label="受眾(選填)" value={audience} onChange={(e) => setAudience(e.target.value)} />
|
||||
<Textarea label={t("tools.pain.brief")} rows={5} value={brief} onChange={(e) => setBrief(e.target.value)} />
|
||||
<Input label={t("tools.pain.audience")} value={audience} onChange={(e) => setAudience(e.target.value)} />
|
||||
<Button type="button" disabled={busy || !brief.trim()} onClick={() => void run()}>
|
||||
{busy ? "產生中…" : "產生關鍵字"}
|
||||
{busy ? t("tools.pain.running") : t("tools.pain.run")}
|
||||
</Button>
|
||||
</Card>
|
||||
{err ? <p className="hb-form-error">{err}</p> : null}
|
||||
{result ? (
|
||||
<Card title="結果">
|
||||
<Card title={t("tools.pain.result")}>
|
||||
<p>{result.summary}</p>
|
||||
<p>
|
||||
<strong>關鍵字</strong>:{result.keywords.join("、")}
|
||||
<strong>{t("tools.pain.keywords")}</strong>:{result.keywords.join("、")}
|
||||
</p>
|
||||
<p>
|
||||
<strong>痛點</strong>:{result.pains.join("、")}
|
||||
<strong>{t("tools.pain.pains")}</strong>:{result.pains.join("、")}
|
||||
</p>
|
||||
<p>
|
||||
<strong>掃描詞</strong>:{result.scan_terms.join("、")}
|
||||
<strong>{t("tools.pain.scan")}</strong>:{result.scan_terms.join("、")}
|
||||
</p>
|
||||
<p className="text-muted">{result.signup_hint}</p>
|
||||
<Link to="/login">
|
||||
<Button type="button">登入巡樓海巡</Button>
|
||||
<Button type="button">{t("tools.pain.login")}</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import { useState } from "react";
|
|||
import { Link } from "react-router-dom";
|
||||
import { Button, Card, Textarea } from "../../components/ui";
|
||||
import { apiRequest } from "../../data/live/http";
|
||||
import { useI18n } from "../../i18n/I18nContext";
|
||||
|
||||
export function StyleQuizPage() {
|
||||
const { t } = useI18n();
|
||||
const [samples, setSamples] = useState("");
|
||||
const [result, setResult] = useState<{
|
||||
tone: string;
|
||||
|
|
@ -37,27 +39,27 @@ export function StyleQuizPage() {
|
|||
|
||||
return (
|
||||
<div className="hb-main__inner" style={{ maxWidth: 640, margin: "2rem auto", padding: "0 1rem" }}>
|
||||
<h1>風格指紋測驗</h1>
|
||||
<p className="text-muted">免登入:貼上幾則你的 Threads 貼文,立刻看語氣與節奏。</p>
|
||||
<h1>{t("tools.style.title")}</h1>
|
||||
<p className="text-muted">{t("tools.style.subtitle")}</p>
|
||||
<Card>
|
||||
<Textarea label="貼文樣本" rows={8} value={samples} onChange={(e) => setSamples(e.target.value)} />
|
||||
<Textarea label={t("tools.style.samples")} rows={8} value={samples} onChange={(e) => setSamples(e.target.value)} />
|
||||
<Button type="button" disabled={busy || !samples.trim()} onClick={() => void run()}>
|
||||
{busy ? "分析中…" : "開始分析"}
|
||||
{busy ? t("tools.style.running") : t("tools.style.run")}
|
||||
</Button>
|
||||
</Card>
|
||||
{err ? <p className="hb-form-error">{err}</p> : null}
|
||||
{result ? (
|
||||
<Card title="結果">
|
||||
<Card title={t("tools.style.result")}>
|
||||
<p>{result.summary}</p>
|
||||
<ul>
|
||||
<li>語氣:{result.tone}</li>
|
||||
<li>節奏:{result.rhythm}</li>
|
||||
<li>鉤子:{result.hooks}</li>
|
||||
<li>注意:{result.avoid}</li>
|
||||
<li>{t("tools.style.tone", { v: result.tone })}</li>
|
||||
<li>{t("tools.style.rhythm", { v: result.rhythm })}</li>
|
||||
<li>{t("tools.style.hooks", { v: result.hooks })}</li>
|
||||
<li>{t("tools.style.avoid", { v: result.avoid })}</li>
|
||||
</ul>
|
||||
<p className="text-muted">{result.signup_hint}</p>
|
||||
<Link to="/login">
|
||||
<Button type="button">登入巡樓</Button>
|
||||
<Button type="button">{t("tools.style.login")}</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -1316,6 +1316,10 @@ svg {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.hb-script-row.is-hidden {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.hb-script-row__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
|
|||
|
|
@ -38,6 +38,109 @@
|
|||
}
|
||||
|
||||
/* 進行中任務即時進度(有 job 才渲染) */
|
||||
.hb-first-run {
|
||||
display: grid;
|
||||
gap: var(--hb-space-3);
|
||||
padding: 0.65rem max(var(--hb-space-3), env(safe-area-inset-right, 0px)) 0.75rem
|
||||
max(var(--hb-space-3), env(safe-area-inset-left, 0px));
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--hb-brand) 28%, var(--hb-line));
|
||||
background: color-mix(in srgb, var(--hb-brand) 8%, var(--hb-surface-solid));
|
||||
}
|
||||
|
||||
.hb-first-run__copy {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.hb-first-run__copy p {
|
||||
margin: 0;
|
||||
color: var(--hb-muted);
|
||||
font-size: var(--hb-text-sm);
|
||||
}
|
||||
|
||||
.hb-first-run__steps {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--hb-space-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.hb-first-run__steps li {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--hb-line);
|
||||
border-radius: var(--hb-radius-lg);
|
||||
background: var(--hb-surface-solid);
|
||||
color: var(--hb-muted);
|
||||
font-size: var(--hb-text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hb-first-run__step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.hb-first-run__step:hover {
|
||||
color: var(--hb-ink);
|
||||
background: color-mix(in srgb, var(--hb-brand) 10%, var(--hb-surface-solid));
|
||||
}
|
||||
|
||||
.hb-first-run__step > span:first-child {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: var(--hb-radius-pill);
|
||||
background: var(--hb-surface-muted);
|
||||
}
|
||||
|
||||
.hb-first-run__steps li.is-current {
|
||||
border-color: var(--hb-brand);
|
||||
background: var(--hb-brand-soft);
|
||||
color: var(--hb-ink);
|
||||
}
|
||||
|
||||
.hb-first-run__steps li.is-done {
|
||||
color: var(--hb-ink);
|
||||
}
|
||||
|
||||
.hb-first-run__steps li.is-current .hb-first-run__step > span:first-child,
|
||||
.hb-first-run__steps li.is-done .hb-first-run__step > span:first-child {
|
||||
background: var(--hb-brand);
|
||||
color: var(--hb-brand-on);
|
||||
}
|
||||
|
||||
.hb-first-run__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.hb-first-run__progress {
|
||||
color: var(--hb-muted);
|
||||
font-size: var(--hb-text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.hb-first-run__steps {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.hb-job-strip {
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--hb-brand) 28%, var(--hb-line));
|
||||
background: color-mix(in srgb, var(--hb-brand) 8%, var(--hb-surface-solid));
|
||||
|
|
|
|||
|
|
@ -142,6 +142,46 @@
|
|||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--hb-danger) 22%, transparent);
|
||||
}
|
||||
|
||||
.hb-input-shell {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hb-input-shell.has-reveal .hb-input {
|
||||
padding-inline-end: 2.75rem;
|
||||
}
|
||||
|
||||
.hb-password-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.35rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--hb-radius);
|
||||
background: transparent;
|
||||
color: var(--hb-muted);
|
||||
transform: translateY(-50%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hb-password-toggle:hover,
|
||||
.hb-password-toggle:focus-visible {
|
||||
color: var(--hb-ink);
|
||||
background: color-mix(in srgb, var(--hb-brand) 10%, transparent);
|
||||
}
|
||||
|
||||
.hb-password-toggle:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--hb-brand) 65%, var(--hb-line));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.hb-input,
|
||||
.hb-textarea,
|
||||
.hb-select {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ repository 在多 worker 競態下仍只保存一筆 Opportunity 與一筆同產
|
|||
|
||||
| 路徑 | 動作 | 說明 |
|
||||
|------|------|------|
|
||||
| `apps/backend/internal/module/radar/domain/repository.go` | edit | merge match、set primary、product filters methods |
|
||||
| `apps/backend/internal/module/radar/domain/repository.go` | edit | merge match、set primary、product filters methods |cl3
|
||||
| `apps/backend/internal/module/radar/repository/opportunity_memory.go` | edit | 同語意實作 |
|
||||
| `apps/backend/internal/module/radar/repository/opportunity_mongo.go` | edit | atomic upsert/guarded primary/stable list |
|
||||
| repository tests | edit/add | duplicate race、watch/term set merge |
|
||||
|
|
|
|||
Loading…
Reference in New Issue