fix all bug

This commit is contained in:
王性驊 2026-08-18 16:26:10 +00:00
parent ee527ed988
commit 7defbed562
88 changed files with 2962 additions and 629 deletions

View File

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

View File

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

View File

@ -285,6 +285,8 @@ type (
ScheduleStartAt int64 `json:"schedule_start_at,optional"`
// Threads 話題標籤topic_tag150 字,可不加 #
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_statusNOT_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 (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 位數100000099999999
// 顯示可用 %08d內部 JSON 仍是 number。
const MinMemberUID int64 = 1_000_000

View File

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

View File

@ -85,6 +85,8 @@ type UpdateUserInfoPatch struct {
PostCode *string
PreferredLanguage *string
Currency *string
OnboardingStatus *string
OnboardingDoneAt *int64
Email *string
Phone *string
CurrentPassword *string

View File

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

View File

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

View File

@ -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-replyThreads 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_statusNOT_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_tag150 字,不可含 . &
TopicTag string
// ReplyControl — 主貼 who-can-reply發文時帶入 container
ReplyControl string
// ImageURLs — 公開可抓的 http(s) 圖;空=純文字 TEXT
ImageURLs []string
}

View File

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

View File

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

View File

@ -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,
})
}
// 純資料:若某則留言底下有「我的」子回覆 → 標已回覆

View File

@ -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}/mentionsthreads_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 = ""
}
return list
out = append(out, r)
}
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 == "" {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>
</>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"> 24 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>
);
}

View File

@ -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: "日常保養" } });

View File

@ -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();

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = {
/** 附圖 URLmock 記張數/可選縮圖) */
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>;
};

View File

@ -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_statusNOT_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";

View File

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

View File

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

View File

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

View File

@ -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("互動率不錯:可複製高表現貼的結構再測 12 則。");
recommendations.push(tr("insights.narrative.engGood"));
}
} else if (current.likes + current.replies > 0) {
analysis.push("此月有讚/回覆,但瀏覽為 0Insights 可能尚未回傳或權限不足)。");
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 {

View File

@ -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],
);
}

View File

@ -156,7 +156,7 @@ export async function requestExtensionSync(input?: {
success: true,
valid: true,
synced: true,
message: "Chrome session 已同步到巡樓爬蟲",
message: "",
account_id: accountId || undefined,
};
}

View File

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

View File

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

View File

@ -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"];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 查無結果。改成客人會打的 24 字痛點詞再巡。",
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}
</>
);

View File

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

View File

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

View File

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

View File

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

View File

@ -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,7 +421,6 @@ export function SettingsPage() {
<span>{t("settings.devMode")}</span>
</label>
<p className="text-muted hb-settings-dev-hint">{t("settings.devModeHint")}</p>
</div>
{placement.dev_mode_enabled ? (
<DevModeSessionCard
onMessage={(msg) => {
@ -434,6 +433,7 @@ export function SettingsPage() {
}}
/>
) : null}
</div>
<div className="hb-wizard-actions">
<Button type="button" onClick={() => void savePlacement()} disabled={Boolean(busy)}>
{t("common.save")}

View File

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

View File

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

View File

@ -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",
});
// 先跳 outboxworker 非同步發 Threadsrefresh 不擋導航
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}

View File

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

View File

@ -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" }}>
{!composeOpen[key] ? (
<Button type="button" variant="ghost" onClick={() => openCompose(key)}>
{pending ? t("posts.replyThis") : t("posts.replyAgain")}
</Button>
) : 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>
);
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 upsertguarded primarystable list |
| repository tests | edit/add | duplicate race、watchterm set merge |