fix all bug

This commit is contained in:
王性驊 2026-08-13 07:54:25 +00:00
parent 806cd51333
commit ee527ed988
62 changed files with 2291 additions and 393 deletions

View File

@ -408,12 +408,17 @@ func runRadarSweep(ctx context.Context, jobs *jobUC.Service, radar *radarUC.Serv
}
res, err := radar.RunSweep(ctx, j.OwnerUID, watchID, j.ID)
if err != nil {
// RunSweep persists a user-facing reason on fetch failures. Return that
// instead of leaking the crawler/provider transport response into Jobs UI.
if res != nil && res.FetchFailed && strings.TrimSpace(res.FailedReason) != "" {
return errors.New(res.FailedReason)
}
return err
}
summary := fmt.Sprintf("雷達巡檢完成 · 新建 %d · 判定 %d · 截斷 %d", res.Created, res.Judged, res.Truncated)
if res.FetchFailed {
return fmt.Errorf("%s", res.FailedReason)
}
summary := fmt.Sprintf("雷達巡檢完成 · 新建 %d · 再次命中 %d · 判定 %d · 截斷 %d", res.Created, res.Rematched, res.Judged, res.Truncated)
if _, err := jobs.MarkRunningProgress(ctx, j.ID, 90, summary); err != nil {
return err
}

View File

@ -31,7 +31,7 @@ func (l *ListOpportunitiesLogic) ListOpportunities(req *types.ListOpportunitiesR
postedFrom, postedTo := req.From, req.To
now := domain.NowNano()
if req.TimeScope == "today" {
postedFrom, postedTo = domain.UTCDayBounds(now)
postedFrom, postedTo = domain.LocalDayBounds(now, domain.DisplayLocation())
} else if req.TimeScope == "7d" {
postedFrom, postedTo = now-7*int64(24*time.Hour), 0
}

View File

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"sync"
"time"
@ -227,6 +228,61 @@ func (s *Service) findRadarSweepForRef(ctx context.Context, ownerUID int64, ref
return nil, nil
}
// ScheduleManualRadarSweep starts an extra patrol now.
// Daily jobs are keyed watch+UTC day and must stay idempotent; a finished
// daily job must not swallow 「立即巡邏」. If this watch already has a
// queued/running sweep, that in-flight job is returned instead of stacking.
func (s *Service) ScheduleManualRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (*domain.Job, error) {
watchID = strings.TrimSpace(watchID)
if ownerUID <= 0 || watchID == "" {
return nil, domain.ErrForbidden
}
if runAt <= 0 {
runAt = domain.NowNano()
}
list, err := s.Repo.ListByOwner(ctx, ownerUID)
if err != nil {
return nil, err
}
prefix := watchID + ":"
for _, j := range list {
if j == nil || j.TemplateType != domain.TemplateRadarSweep {
continue
}
if !strings.HasPrefix(j.RefID, prefix) {
continue
}
if !domain.IsTerminal(j.Status) {
return j, nil
}
}
day := time.Unix(0, runAt).UTC().Format("2006-01-02")
ref := watchID + ":manual:" + strconv.FormatInt(runAt, 10)
body, err := json.Marshal(RadarSweepPayload{WatchID: watchID, Day: day})
if err != nil {
return nil, err
}
now := domain.NowNano()
j := &domain.Job{
ID: uuid.NewString(),
OwnerUID: ownerUID,
TemplateType: domain.TemplateRadarSweep,
Status: domain.StatusQueued,
RefID: ref,
Payload: string(body),
RunAfter: runAt,
ProgressSummary: "立即巡邏已排程 · 等待 worker",
ProgressPercent: 0,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.Repo.Insert(ctx, j); err != nil {
return nil, err
}
s.notify(ctx, j)
return j, nil
}
func (s *Service) List(ctx context.Context, ownerUID int64) ([]*domain.Job, error) {
return s.Repo.ListByOwner(ctx, ownerUID)
}

View File

@ -374,6 +374,32 @@ func TestJobLease_HeartbeatRenewsWhileRunning(t *testing.T) {
}, 200*time.Millisecond, 5*time.Millisecond)
}
func TestScheduleManualRadarSweep_ReusesInFlightAndCreatesAfterSuccess(t *testing.T) {
ctx := context.Background()
svc := usecase.New(jobRepo.NewMemory())
first, err := svc.ScheduleManualRadarSweep(ctx, 42, "watch-1", 1)
require.NoError(t, err)
require.Contains(t, first.RefID, ":manual:")
again, err := svc.ScheduleManualRadarSweep(ctx, 42, "watch-1", 2)
require.NoError(t, err)
require.Equal(t, first.ID, again.ID)
claimed, err := svc.ClaimNext(ctx, "worker-manual")
require.NoError(t, err)
require.Equal(t, first.ID, claimed.ID)
_, err = svc.SucceedJob(ctx, first.ID, "done")
require.NoError(t, err)
second, err := svc.ScheduleManualRadarSweep(ctx, 42, "watch-1", 3)
require.NoError(t, err)
require.NotEqual(t, first.ID, second.ID)
require.Contains(t, second.RefID, ":manual:")
daily, err := svc.ScheduleRadarSweep(ctx, 42, "watch-1", 1)
require.NoError(t, err)
require.NotEqual(t, second.ID, daily.ID)
require.NotContains(t, daily.RefID, ":manual:")
}
func TestJobLease_ReclaimsLegacyRunningDocumentWithoutLease(t *testing.T) {
repo := jobRepo.NewMemory()
now := domain.NowNano()

View File

@ -431,3 +431,51 @@ func UTCDayBounds(at int64) (start, end int64) {
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
return day.UnixNano(), day.Add(24 * time.Hour).UnixNano()
}
// DisplayLocation is the product default timezone (Harbor Desk is Taipei-first).
func DisplayLocation() *time.Location {
return time.FixedZone("Asia/Taipei", 8*60*60)
}
// LocalDayBounds returns [start, end) unix ns for the local calendar day that contains at.
func LocalDayBounds(at int64, loc *time.Location) (start, end int64) {
if loc == nil {
loc = DisplayLocation()
}
if at <= 0 {
at = NowNano()
}
t := time.Unix(0, at).In(loc)
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
return day.UnixNano(), day.Add(24 * time.Hour).UnixNano()
}
// InInboxTimeScope is true when a row belongs on today/7d.
// Posted-in-range is the primary meaning; a just-finished sweep must still
// surface older or undated posts via last_matched_at / created_at, otherwise
// the default pending+today inbox looks empty after patrol.
func InInboxTimeScope(postedAt, lastMatchedAt, createdAt, from, to int64) bool {
if from <= 0 && to <= 0 {
return true
}
in := func(ts int64) bool {
if ts <= 0 {
return false
}
if from > 0 && ts < from {
return false
}
if to > 0 && ts >= to {
return false
}
return true
}
if in(postedAt) {
return true
}
discovered := lastMatchedAt
if discovered <= 0 {
discovered = createdAt
}
return in(discovered)
}

View File

@ -3,6 +3,7 @@ package domain
import (
"errors"
"testing"
"time"
)
func TestBandFromScore_LockedThresholds(t *testing.T) {
@ -34,6 +35,18 @@ func TestUnknownPublishedTimeIsNotStale(t *testing.T) {
}
}
func TestTwentyDayOldPostIsNotHardRejected(t *testing.T) {
now := NowNano()
posted := now - 20*24*int64(time.Hour)
if IsStaleHardReject(posted, now) {
t.Fatal("a 20-day-old demand post must still be judgeable, not hard-rejected")
}
old := now - 40*24*int64(time.Hour)
if !IsStaleHardReject(old, now) {
t.Fatal("a 40-day-old post should still be hard-rejected")
}
}
func TestValidateReasons_RequiresAllFive(t *testing.T) {
full := fiveReasons()
if err := ValidateReasons(full); err != nil {

View File

@ -0,0 +1,43 @@
package domain
import (
"testing"
"time"
)
func TestLocalDayBoundsUsesTaipeiNotUTC(t *testing.T) {
// 2026-08-13 02:44 UTC is 10:44 in Taipei, same local calendar day.
// 2026-08-13 00:30 Taipei is still 2026-08-12 16:30 UTC.
morningTaipei := time.Date(2026, 8, 13, 0, 30, 0, 0, DisplayLocation()).UnixNano()
start, end := LocalDayBounds(morningTaipei, DisplayLocation())
utcStart, utcEnd := UTCDayBounds(morningTaipei)
if start == utcStart {
t.Fatalf("local day must not collapse to UTC day: local=%d utc=%d", start, utcStart)
}
if morningTaipei < start || morningTaipei >= end {
t.Fatalf("Taipei 00:30 should sit inside local today [%d, %d)", start, end)
}
if utcEnd <= start {
t.Fatal("expected UTC Aug 12 window to end at Taipei midnight Aug 13")
}
}
func TestInInboxTimeScopeKeepsResweptAndUndatedRows(t *testing.T) {
from, to := LocalDayBounds(NowNano(), DisplayLocation())
oldPost := from - int64(48*time.Hour)
if InInboxTimeScope(oldPost, 0, oldPost, from, to) {
t.Fatal("stale post that was not rematched today should stay out of today")
}
if !InInboxTimeScope(oldPost, from+1, oldPost, from, to) {
t.Fatal("a post rematched today must remain visible after patrol")
}
if !InInboxTimeScope(0, 0, from+1, from, to) {
t.Fatal("undated post created today must remain visible after patrol")
}
if !InInboxTimeScope(oldPost, 0, from+1, from, to) {
t.Fatal("older post created today without last_matched must remain visible (no-backfill)")
}
if !InInboxTimeScope(from+1, 0, oldPost, from, to) {
t.Fatal("post published today should stay on today even if created earlier")
}
}

View File

@ -11,8 +11,10 @@ const (
WeightFit = 10
)
// Freshness hard-reject after 14 days.
// Freshness scoring decays after 14 days. Hard-reject is wider so Exa/Threads
// hits from the last month still reach the inbox as stale, not as "found nothing".
const MaxFreshnessDays = 14
const StaleHardRejectDays = 30
// FreshnessScore maps age in hours to the 015 freshness dimension score.
func FreshnessScore(hours int) int {
@ -50,12 +52,14 @@ func FreshnessHoursSince(postedAt, now int64) int {
return h
}
// IsStaleHardReject is true when the post is older than 14 days.
// IsStaleHardReject is true when the post is older than a month.
// 1430 day posts stay scoreable (low freshness) so a just-run patrol can
// still surface a real pain that Threads/Exa returned as an older result.
func IsStaleHardReject(postedAt, now int64) bool {
if postedAt <= 0 {
return false
}
return FreshnessHoursSince(postedAt, now) > MaxFreshnessDays*24
return FreshnessHoursSince(postedAt, now) > StaleHardRejectDays*24
}
// SumReasonScores totals dimension scores (capped components assumed already).

View File

@ -52,8 +52,8 @@ func NormalizeSuggestUsage(s string) string {
CleanSuggestions 收掉空白與重複丟掉沒有理由的項目並套用數量上限
沒有理由的項目直接丟補一句AI 建議等於假裝有理由比少一則更糟
include 關鍵字必須通過 Threads 短詞規則IsThreadsSearchable不合規整條丟掉不截短
避免產出半截怪詞exclude 仍用較寬的長度界線訂閱排除詞可能較長
include 必須能在 Threads 搜到已合規的原詞保留過長或超過兩個 token 的先收成短詞變體
變體也沒有才丟掉exclude 仍用較寬的長度界線訂閱排除詞可能較長
*/
func CleanSuggestions(in []WatchTermSuggestion, limit int) []WatchTermSuggestion {
if limit <= 0 || limit > MaxSuggestions {
@ -68,9 +68,17 @@ func CleanSuggestions(in []WatchTermSuggestion, limit int) []WatchTermSuggestion
continue
}
usage := NormalizeSuggestUsage(s.Usage)
basisText := strings.TrimSpace(s.BasisText)
if usage == SuggestUsageInclude {
if !IsThreadsSearchable(term) {
continue
variants := SearchableTermVariants(term, true)
if len(variants) == 0 {
continue
}
if basisText == "" {
basisText = term
}
term = variants[0]
}
} else {
// exclude: keep broader length; still reject empty after normalize
@ -86,7 +94,7 @@ func CleanSuggestions(in []WatchTermSuggestion, limit int) []WatchTermSuggestion
seen[key] = true
out = append(out, WatchTermSuggestion{
Term: term, Reason: reason, Usage: usage,
BasisKind: strings.TrimSpace(s.BasisKind), BasisText: strings.TrimSpace(s.BasisText),
BasisKind: strings.TrimSpace(s.BasisKind), BasisText: basisText,
})
if len(out) >= limit {
break

View File

@ -9,11 +9,14 @@ import (
// Threads 搜尋短詞硬約束(中文斷詞差、長字串常查無結果)。
// 一組查詢 = 最多 2 個 token半形空格分隔中文 token 24 字;整組去掉空格後 ≤12 字元。
const (
MaxThreadsTokens = 2
MinCJKTokenRunes = 2
MaxCJKTokenRunes = 4
MaxThreadsTermRunes = 12 // 去掉空白後
MaxExploreTerms = 6
MaxThreadsTokens = 2
MinCJKTokenRunes = 2
MaxCJKTokenRunes = 4
MaxThreadsTermRunes = 12 // 去掉空白後
MaxExploreTerms = 6
// 一則長句最多收成幾組可搜短詞,避免滑窗把訂閱或需求地圖塞滿半截字。
MaxSearchableVariantsPerTerm = 3
MaxSearchableVariants = 8
)
// NormalizeSearchTerm trims, converts full-width spaces to half-width, and collapses whitespace.
@ -109,3 +112,180 @@ func isCJK(r rune) bool {
unicode.Is(unicode.Katakana, r) ||
(r >= 0x3000 && r <= 0x303F) // CJK punctuation block — treated as CJK char class but punct rejected above
}
var searchFillers = []string{
"怎麼辦", "求推薦", "有沒有人", "有人知道", "請問一下", "請問",
"想問", "想找", "有沒有", "可以嗎", "好不好",
}
// SearchableTermVariants turns a user/product phrase into Threads-searchable queries.
// If the input already passes IsThreadsSearchable it is the only result.
// Longer phrases are shortened: adjacent token pairs, filler tails stripped,
// then first/last 24 CJK windows. include=false keeps the broader exclude length.
func SearchableTermVariants(raw string, include bool) []string {
raw = NormalizeSearchTerm(raw)
if raw == "" {
return nil
}
seen := map[string]bool{}
out := make([]string, 0, MaxSearchableVariants)
add := func(term string) {
term = NormalizeSearchTerm(term)
if term == "" || seen[strings.ToLower(term)] {
return
}
if include {
if !IsThreadsSearchable(term) {
return
}
} else if n := utf8.RuneCountInString(term); n < MinTermLen || n > MaxTermLen {
return
}
seen[strings.ToLower(term)] = true
out = append(out, term)
}
if include && IsThreadsSearchable(raw) {
add(raw)
return out
}
if !include {
add(raw)
if len(out) > 0 {
return out
}
}
tokens := strings.Fields(raw)
if len(tokens) > MaxThreadsTokens {
for i := 0; i+1 < len(tokens) && len(out) < MaxSearchableVariants; i++ {
add(tokens[i] + " " + tokens[i+1])
}
for _, tok := range tokens {
if len(out) >= MaxSearchableVariants {
break
}
for _, v := range SearchableTermVariants(tok, include) {
add(v)
if len(out) >= MaxSearchableVariants {
break
}
}
}
return out
}
parts := splitSearchParts(raw)
if len(parts) == 0 {
parts = []string{raw}
}
for _, part := range parts {
if len(out) >= MaxSearchableVariants {
break
}
if include && IsThreadsSearchable(part) {
add(part)
continue
}
stripped := stripSearchFillers(part)
if stripped != "" && stripped != part {
add(stripped)
}
runes := []rune(stripped)
if len(runes) == 0 {
runes = []rune(part)
}
if len(runes) == 5 {
add(string(runes[:3]))
add(string(runes[3:]))
}
for _, width := range []int{4, 3, 2} {
if len(runes) < width {
continue
}
add(string(runes[:width]))
if len(runes) > width {
add(string(runes[len(runes)-width:]))
}
}
if len(out) >= 3 {
continue
}
for width := 4; width >= 2; width-- {
if len(runes) < width {
continue
}
for start := 0; start+width <= len(runes) && len(out) < MaxSearchableVariants; start++ {
add(string(runes[start : start+width]))
}
}
}
return out
}
// ExpandSearchTerms keeps already-searchable include terms and shortens the rest.
// Used when persisting a watch and when fanning out a sweep.
func ExpandSearchTerms(terms []string, max int) []string {
if max <= 0 {
max = MaxWatchTerms
}
out := make([]string, 0, max)
seen := map[string]bool{}
add := func(term string) bool {
term = strings.ToLower(NormalizeSearchTerm(term))
if term == "" || !IsThreadsSearchable(term) || seen[term] {
return len(out) < max
}
seen[term] = true
out = append(out, term)
return len(out) < max
}
for _, raw := range terms {
raw = NormalizeSearchTerm(raw)
if raw == "" {
continue
}
if IsThreadsSearchable(raw) {
if !add(raw) {
return out
}
continue
}
for i, v := range SearchableTermVariants(raw, true) {
if i >= MaxSearchableVariantsPerTerm {
break
}
if !add(v) {
return out
}
}
}
return out
}
func stripSearchFillers(s string) string {
for _, filler := range searchFillers {
s = strings.ReplaceAll(s, filler, "")
}
return strings.Trim(s, " ,,、。!?!?:的了嗎呢啊喔唷")
}
func splitSearchParts(raw string) []string {
var b strings.Builder
parts := make([]string, 0, 4)
flush := func() {
if value := strings.TrimSpace(b.String()); value != "" {
parts = append(parts, value)
}
b.Reset()
}
for _, r := range raw {
if unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.Is(unicode.Han, r) || unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r) {
b.WriteRune(r)
} else {
flush()
}
}
flush()
return parts
}

View File

@ -34,7 +34,7 @@ func TestIsThreadsSearchable(t *testing.T) {
bad := []string{
"",
"a",
"台北 婚攝 推薦", // 3 tokens
"台北 婚攝 推薦", // 3 tokens
"這是一個超長關鍵字超過十二字", // too long
"保母 AND 求推薦",
"保母#推薦",
@ -50,3 +50,51 @@ func TestIsThreadsSearchable(t *testing.T) {
}
}
}
func TestSearchableTermVariantsKeepsShortAndShortensLong(t *testing.T) {
got := SearchableTermVariants("保母 求推薦", true)
if len(got) != 1 || got[0] != "保母 求推薦" {
t.Fatalf("short term variants=%v", got)
}
got = SearchableTermVariants("晚上睡覺容易口乾舌燥怎麼辦", true)
if len(got) == 0 {
t.Fatal("long sentence produced no variants")
}
for _, term := range got {
if !IsThreadsSearchable(term) {
t.Fatalf("variant %q is not searchable", term)
}
}
hasDry := false
hasSleep := false
for _, term := range got {
if term == "口乾舌燥" {
hasDry = true
}
if term == "晚上睡覺" {
hasSleep = true
}
}
if !hasDry || !hasSleep {
t.Fatalf("variants=%v want 晚上睡覺 and 口乾舌燥", got)
}
got = SearchableTermVariants("台北 婚攝 推薦 價格", true)
if len(got) == 0 || got[0] != "台北 婚攝" {
t.Fatalf("multi-token variants=%v want 台北 婚攝 first", got)
}
}
func TestExpandSearchTermsShortensUnsearchable(t *testing.T) {
got := ExpandSearchTerms([]string{"求推薦", "晚上睡覺容易口乾舌燥怎麼辦"}, 20)
if len(got) < 2 {
t.Fatalf("expanded=%v", got)
}
if got[0] != "求推薦" {
t.Fatalf("kept original first, got %v", got)
}
for _, term := range got {
if !IsThreadsSearchable(term) {
t.Fatalf("expanded term %q is not searchable", term)
}
}
}

View File

@ -231,6 +231,13 @@ func (w *RadarWatch) Normalize() error {
}
func normalizeTerms(in []string, field string, max int) ([]string, error) {
if field == "terms" {
out := ExpandSearchTerms(in, max)
if len(out) > max {
return nil, fmt.Errorf("%w: %s exceeds %d items", ErrValidation, field, max)
}
return out, nil
}
out := make([]string, 0, len(in))
seen := map[string]bool{}
for _, raw := range in {

View File

@ -0,0 +1,155 @@
package repository
import (
"context"
"fmt"
"strings"
"testing"
"time"
"apps/backend/internal/module/radar/domain"
"go.mongodb.org/mongo-driver/bson"
)
func TestReviewStateListClauseLegacyPending(t *testing.T) {
pending := fmt.Sprintf("%#v", reviewStateListClause(domain.ReviewPending))
for _, part := range []string{domain.ReviewPending, "$nin", domain.OppAccepted, domain.OppDismissed} {
if !strings.Contains(pending, part) {
t.Fatalf("legacy pending clause missing %q: %s", part, pending)
}
}
completed := fmt.Sprintf("%#v", reviewStateListClause(domain.ReviewCompleted))
if !strings.Contains(completed, domain.ReviewCompleted) || !strings.Contains(completed, domain.OppAccepted) {
t.Fatalf("completed clause: %s", completed)
}
removed := fmt.Sprintf("%#v", reviewStateListClause(domain.ReviewRemoved))
if !strings.Contains(removed, domain.ReviewRemoved) || !strings.Contains(removed, domain.OppDismissed) {
t.Fatalf("removed clause: %s", removed)
}
}
func TestInboxTimeScopeClauseOldPostedCreatedTodayNoLastMatched(t *testing.T) {
from, to := domain.LocalDayBounds(domain.NowNano(), domain.DisplayLocation())
old := from - int64(48*time.Hour)
clause := inboxTimeScopeClause(from, to)
justFound := bson.M{"posted_at": old, "created_at": from + 1}
if !bsonMMatches(clause, justFound) {
t.Fatalf("posted=old created=today last_matched missing must match today $or; clause=%#v", clause)
}
zeroLast := bson.M{"posted_at": old, "created_at": from + 1, "last_matched_at": int64(0)}
if !bsonMMatches(clause, zeroLast) {
t.Fatalf("posted=old created=today last_matched=0 must match today $or; clause=%#v", clause)
}
staleMatch := bson.M{"posted_at": old, "created_at": from + 1, "last_matched_at": from - int64(24*time.Hour)}
if bsonMMatches(clause, staleMatch) {
t.Fatal("old last_matched must not fall back to created_at")
}
}
func TestListOpportunitiesCreatedTodayOldPostedWithoutUpsert(t *testing.T) {
ctx := context.Background()
m := NewMemory()
now := domain.NowNano()
from, to := domain.LocalDayBounds(now, domain.DisplayLocation())
row := sampleOpp("seed-no-upsert", []string{"漏水"}, 80)
row.ID = "seed-no-upsert"
row.PostedAt = from - int64(48*time.Hour)
row.CreatedAt = from + 1
row.LastMatchedAt = 0
row.ReviewState = ""
m.opportunities[row.ID] = row
pending, total, err := m.ListOpportunities(ctx, 42, domain.OpportunityListFilter{
ReviewState: domain.ReviewPending, TimeScope: "today", PostedFrom: from, PostedTo: to,
})
if err != nil || total != 1 || len(pending) != 1 || pending[0].ID != "seed-no-upsert" {
t.Fatalf("no-backfill just-found row missing from pending today: total=%d list=%d err=%v", total, len(pending), err)
}
}
func bsonMMatches(query bson.M, doc bson.M) bool {
if or, ok := query["$or"]; ok && !bsonOrMatches(or, doc) {
return false
}
if and, ok := query["$and"]; ok && !bsonAndMatches(and, doc) {
return false
}
for key, cond := range query {
if key == "$or" || key == "$and" {
continue
}
if !bsonFieldMatches(key, cond, doc) {
return false
}
}
return true
}
func bsonOrMatches(raw any, doc bson.M) bool {
items, ok := raw.(bson.A)
if !ok {
return false
}
for _, item := range items {
clause, ok := item.(bson.M)
if ok && bsonMMatches(clause, doc) {
return true
}
}
return false
}
func bsonAndMatches(raw any, doc bson.M) bool {
items, ok := raw.(bson.A)
if !ok {
return false
}
for _, item := range items {
clause, ok := item.(bson.M)
if !ok || !bsonMMatches(clause, doc) {
return false
}
}
return true
}
func bsonFieldMatches(key string, cond any, doc bson.M) bool {
got, exists := doc[key]
switch c := cond.(type) {
case bson.M:
if flag, ok := c["$exists"]; ok {
want, _ := flag.(bool)
if exists != want {
return false
}
}
if gte, ok := c["$gte"]; ok {
if !exists || toQueryInt64(got) < toQueryInt64(gte) {
return false
}
}
if lt, ok := c["$lt"]; ok {
if !exists || toQueryInt64(got) >= toQueryInt64(lt) {
return false
}
}
return true
default:
return exists && toQueryInt64(got) == toQueryInt64(c)
}
}
func toQueryInt64(v any) int64 {
switch n := v.(type) {
case int64:
return n
case int:
return int64(n)
case float64:
return int64(n)
default:
return 0
}
}

View File

@ -253,11 +253,19 @@ func (m *Memory) ListOpportunities(_ context.Context, ownerUID int64, f domain.O
if f.CreatedTo > 0 && o.CreatedAt >= f.CreatedTo {
continue
}
if f.PostedFrom > 0 && o.PostedAt < f.PostedFrom {
continue
}
if f.PostedTo > 0 && o.PostedAt >= f.PostedTo {
continue
if f.PostedFrom > 0 || f.PostedTo > 0 {
if f.TimeScope == "today" || f.TimeScope == "7d" {
if !domain.InInboxTimeScope(o.PostedAt, o.LastMatchedAt, o.CreatedAt, f.PostedFrom, f.PostedTo) {
continue
}
} else {
if f.PostedFrom > 0 && o.PostedAt < f.PostedFrom {
continue
}
if f.PostedTo > 0 && o.PostedAt >= f.PostedTo {
continue
}
}
}
matched = append(matched, cloneOpportunity(o))
}

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"testing"
"time"
"apps/backend/internal/module/radar/domain"
)
@ -146,6 +147,52 @@ func TestUpdateStatusAndOverride(t *testing.T) {
}
}
func TestListOpportunitiesLegacyPendingAndResweptToday(t *testing.T) {
ctx := context.Background()
m := NewMemory()
now := domain.NowNano()
from, to := domain.LocalDayBounds(now, domain.DisplayLocation())
old := from - int64(72*time.Hour)
legacy := sampleOpp("legacy-pending", []string{"漏水"}, 80)
legacy.ReviewState = ""
legacy.PostedAt = old
legacy.CreatedAt = old
legacy.LastMatchedAt = 0
if _, err := m.UpsertByExternalID(ctx, legacy); err != nil {
t.Fatal(err)
}
// Re-sweep the same source: merge must not hide the card from today's inbox.
if _, err := m.UpsertByExternalID(ctx, &domain.Opportunity{
OwnerUID: 42, ExternalID: "legacy-pending", MatchedTerms: []string{"抓漏"},
}); err != nil {
t.Fatal(err)
}
pending, total, err := m.ListOpportunities(ctx, 42, domain.OpportunityListFilter{
ReviewState: domain.ReviewPending, TimeScope: "today", PostedFrom: from, PostedTo: to,
})
if err != nil || total != 1 || len(pending) != 1 {
t.Fatalf("legacy rematch missing from pending today: total=%d list=%d err=%v", total, len(pending), err)
}
if pending[0].ReviewState != domain.ReviewPending {
t.Fatalf("legacy review_state = %q", pending[0].ReviewState)
}
undated := sampleOpp("undated-today", []string{"漏水"}, 70)
undated.PostedAt = 0
undated.CreatedAt = now
if _, err := m.UpsertByExternalID(ctx, undated); err != nil {
t.Fatal(err)
}
pending, total, err = m.ListOpportunities(ctx, 42, domain.OpportunityListFilter{
ReviewState: domain.ReviewPending, TimeScope: "today", PostedFrom: from, PostedTo: to,
})
if err != nil || total != 2 {
t.Fatalf("undated created-today row should join pending today: total=%d err=%v", total, err)
}
}
func TestReviewStateGuardAndTombstone(t *testing.T) {
ctx := context.Background()
m := NewMemory()

View File

@ -37,21 +37,17 @@ func (s *MonStore) UpsertByExternalID(ctx context.Context, o *domain.Opportunity
"external_id": externalID,
})
if err == nil {
terms := domain.NormalizeMatchedTerms(o.MatchedTerms)
if len(terms) > 0 {
_, uerr := s.opportunities.UpdateOne(ctx,
bson.M{"_id": existing.ID},
bson.M{
"$addToSet": bson.M{"matched_terms": bson.M{"$each": terms}},
"$set": bson.M{"updated_at": domain.NowNano()},
})
if uerr != nil {
return nil, uerr
}
// re-read after merge
if rerr := s.opportunities.FindOne(ctx, &existing, bson.M{"_id": existing.ID}); rerr != nil {
return nil, rerr
}
now := domain.NowNano()
set := bson.M{"updated_at": now, "last_matched_at": now}
update := bson.M{"$set": set}
if terms := domain.NormalizeMatchedTerms(o.MatchedTerms); len(terms) > 0 {
update["$addToSet"] = bson.M{"matched_terms": bson.M{"$each": terms}}
}
if _, uerr := s.opportunities.UpdateOne(ctx, bson.M{"_id": existing.ID}, update); uerr != nil {
return nil, uerr
}
if rerr := s.opportunities.FindOne(ctx, &existing, bson.M{"_id": existing.ID}); rerr != nil {
return nil, rerr
}
for _, match := range o.ProductMatches {
if _, merr := s.MergeProductMatch(ctx, o.OwnerUID, existing.ID, match); merr != nil {
@ -85,6 +81,9 @@ func (s *MonStore) UpsertByExternalID(ctx context.Context, o *domain.Opportunity
if o.ReviewState == "" {
o.ReviewState = reviewStateFor(o)
}
if o.LastMatchedAt == 0 {
o.LastMatchedAt = now
}
o.ExternalID = externalID
_, err = s.opportunities.InsertOne(ctx, o)
@ -242,10 +241,7 @@ func (s *MonStore) ListOpportunities(ctx context.Context, ownerUID int64, f doma
q["priority_band"] = f.PriorityBand
}
if f.ReviewState != "" {
q["$or"] = bson.A{
bson.M{"review_state": f.ReviewState},
bson.M{"review_state": bson.M{"$exists": false}, "status": map[string]string{domain.ReviewCompleted: domain.OppAccepted, domain.ReviewRemoved: domain.OppDismissed}[f.ReviewState]},
}
appendAnd(q, reviewStateListClause(f.ReviewState))
}
switch f.MatchState {
case "eligible":
@ -255,9 +251,9 @@ func (s *MonStore) ListOpportunities(ctx context.Context, ownerUID int64, f doma
case "excluded":
q["product_matches.excluded"] = true
case "generic":
q["$or"] = bson.A{bson.M{"product_matches": bson.M{"$exists": false}}, bson.M{"product_matches": bson.M{"$size": 0}}}
appendAnd(q, bson.M{"$or": bson.A{bson.M{"product_matches": bson.M{"$exists": false}}, bson.M{"product_matches": bson.M{"$size": 0}}}})
case "stale":
q["posted_at"] = bson.M{"$lt": domain.NowNano() - int64(domain.MaxFreshnessDays)*24*int64(time.Hour)}
appendAnd(q, bson.M{"posted_at": bson.M{"$lt": domain.NowNano() - int64(domain.MaxFreshnessDays)*24*int64(time.Hour)}})
}
if f.CreatedFrom > 0 || f.CreatedTo > 0 {
rng := bson.M{}
@ -270,14 +266,18 @@ func (s *MonStore) ListOpportunities(ctx context.Context, ownerUID int64, f doma
q["created_at"] = rng
}
if f.PostedFrom > 0 || f.PostedTo > 0 {
rng := bson.M{}
if f.PostedFrom > 0 {
rng["$gte"] = f.PostedFrom
if f.TimeScope == "today" || f.TimeScope == "7d" {
appendAnd(q, inboxTimeScopeClause(f.PostedFrom, f.PostedTo))
} else {
rng := bson.M{}
if f.PostedFrom > 0 {
rng["$gte"] = f.PostedFrom
}
if f.PostedTo > 0 {
rng["$lt"] = f.PostedTo
}
q["posted_at"] = rng
}
if f.PostedTo > 0 {
rng["$lt"] = f.PostedTo
}
q["posted_at"] = rng
}
total, err := s.opportunities.CountDocuments(ctx, q)
@ -446,3 +446,77 @@ func (s *MonStore) SetOpportunityOverride(ctx context.Context, id string, ov *do
}
return nil
}
func appendAnd(q bson.M, clause bson.M) {
if len(clause) == 0 {
return
}
if existing, ok := q["$and"].(bson.A); ok {
q["$and"] = append(existing, clause)
return
}
q["$and"] = bson.A{clause}
}
// reviewStateListClause implements spec §3.2: missing review_state is derived
// from OpportunityStatus so a list query never requires a one-shot migration.
func reviewStateListClause(state string) bson.M {
missing := bson.A{
bson.M{"review_state": bson.M{"$exists": false}},
bson.M{"review_state": ""},
}
switch state {
case domain.ReviewPending:
return bson.M{"$or": bson.A{
bson.M{"review_state": domain.ReviewPending},
bson.M{"$and": bson.A{
bson.M{"$or": missing},
bson.M{"status": bson.M{"$nin": []string{domain.OppAccepted, domain.OppDismissed}}},
}},
}}
case domain.ReviewCompleted:
return bson.M{"$or": bson.A{
bson.M{"review_state": domain.ReviewCompleted},
bson.M{"$and": bson.A{
bson.M{"$or": missing},
bson.M{"status": domain.OppAccepted},
}},
}}
case domain.ReviewRemoved:
return bson.M{"$or": bson.A{
bson.M{"review_state": domain.ReviewRemoved},
bson.M{"$and": bson.A{
bson.M{"$or": missing},
bson.M{"status": domain.OppDismissed},
}},
}}
default:
return bson.M{"review_state": state}
}
}
func inboxTimeScopeClause(from, to int64) bson.M {
rng := bson.M{}
if from > 0 {
rng["$gte"] = from
}
if to > 0 {
rng["$lt"] = to
}
// Same predicate as domain.InInboxTimeScope: posted in range, or
// last_matched in range, or last_matched missing/0 and created in range.
// Must not gate created_at on posted_at — a just-found older post has a
// real posted_at from yesterday and no last_matched_at (no-backfill).
missingLastMatched := bson.A{
bson.M{"last_matched_at": bson.M{"$exists": false}},
bson.M{"last_matched_at": int64(0)},
}
return bson.M{"$or": bson.A{
bson.M{"posted_at": rng},
bson.M{"last_matched_at": rng},
bson.M{"$and": bson.A{
bson.M{"$or": missingLastMatched},
bson.M{"created_at": rng},
}},
}}
}

View File

@ -62,23 +62,35 @@ func (s *Service) UpdateDemandMap(ctx context.Context, ownerUID int64, value *do
}
func baselineDemandMap(product *ProductCatalogProduct) *domain.DemandMap {
mapPhrase := func(text, kind, basisKind string) domain.DemandMapPhrase {
return domain.DemandMapPhrase{Text: strings.TrimSpace(text), Kind: kind, BasisKind: basisKind, BasisText: product.Label, Origin: "product", Enabled: strings.TrimSpace(text) != ""}
mapPhrase := func(text, kind, basisKind, basisText string) domain.DemandMapPhrase {
return domain.DemandMapPhrase{Text: strings.TrimSpace(text), Kind: kind, BasisKind: basisKind, BasisText: basisText, Origin: "product", Enabled: strings.TrimSpace(text) != ""}
}
phrases := func(items []string, kind, basisKind string) []domain.DemandMapPhrase {
phrases := func(items []string, kind, basisKind string, include bool) []domain.DemandMapPhrase {
out := make([]domain.DemandMapPhrase, 0, len(items))
for _, item := range items {
if strings.TrimSpace(item) != "" {
out = append(out, mapPhrase(item, kind, basisKind))
raw := strings.TrimSpace(item)
if raw == "" {
continue
}
if !include {
out = append(out, mapPhrase(raw, kind, basisKind, product.Label))
continue
}
variants := domain.SearchableTermVariants(raw, true)
if len(variants) > domain.MaxSearchableVariantsPerTerm {
variants = variants[:domain.MaxSearchableVariantsPerTerm]
}
for _, term := range variants {
out = append(out, mapPhrase(term, kind, basisKind, raw))
}
}
return out
}
pain := phrases(product.PainPoints, "pain", "pain_point")
scenario := phrases([]string{product.ProductContext}, "scenario", "product_context")
outcomes := phrases(product.MatchTags, "outcome", "match_tag")
solution := phrases(product.ProviderCapabilityTerms, "solution", "provider_capability")
exclusions := phrases(product.ProviderExcludeTerms, "exclusion", "provider_exclude")
pain := phrases(product.PainPoints, "pain", "pain_point", true)
scenario := phrases([]string{product.ProductContext}, "scenario", "product_context", true)
outcomes := phrases(product.MatchTags, "outcome", "match_tag", true)
solution := phrases(product.ProviderCapabilityTerms, "solution", "provider_capability", true)
exclusions := phrases(product.ProviderExcludeTerms, "exclusion", "provider_exclude", false)
state := "incomplete"
if len(pain) > 0 && len(scenario) > 0 && len(solution) > 0 {
state = "ready"

View File

@ -41,7 +41,7 @@ func (s *Service) EnrichDemandMap(ctx context.Context, ownerUID int64, productID
if s.AI == nil && s.AIRegistry == nil && s.ResolveAI == nil {
return nil, fmt.Errorf("%w: AI provider unavailable", domain.ErrNotReady)
}
prompt := fmt.Sprintf("請只輸出 JSON 物件,根據需求地圖補充使用者會說的短詞,不要使用品牌或產品名稱。痛點=%q情境=%q結果=%q能力=%q。欄位為 pain_phrases、scenario_phrases、desired_outcomes、solution_signals、exclusion_signals、custom_phrases每則含 text、kind、basis_kind、basis_text、origin=ai、enabled=true。", phraseTexts(current.PainPhrases), phraseTexts(current.ScenarioPhrases), phraseTexts(current.DesiredOutcomes), phraseTexts(current.SolutionSignals))
prompt := fmt.Sprintf("請只輸出 JSON 物件,根據需求地圖補充使用者會說的短詞,不要使用品牌或產品名稱。痛點=%q情境=%q結果=%q能力=%q。欄位為 pain_phrases、scenario_phrases、desired_outcomes、solution_signals、exclusion_signals、custom_phrases每則含 text、kind、basis_kind、basis_text、origin=ai、enabled=true。include 類 text 必須是 Threads 可搜短詞:最多 2 個 token、中文每 token 24 字、去空格後 ≤12 字、禁止標點。", phraseTexts(current.PainPhrases), phraseTexts(current.ScenarioPhrases), phraseTexts(current.DesiredOutcomes), phraseTexts(current.SolutionSignals))
raw, err := s.completeAI(ctx, ownerUID, prompt)
if err != nil {
return nil, err
@ -61,9 +61,30 @@ func (s *Service) EnrichDemandMap(ctx context.Context, ownerUID int64, productID
p.Text = strings.TrimSpace(p.Text)
p.Origin = "ai"
p.Enabled = p.Enabled && p.Text != ""
if p.Enabled && !seen[strings.ToLower(p.Text)] {
out = append(out, p)
seen[strings.ToLower(p.Text)] = true
if !p.Enabled {
continue
}
texts := []string{p.Text}
if p.Kind != "exclusion" {
texts = domain.SearchableTermVariants(p.Text, true)
if len(texts) > domain.MaxSearchableVariantsPerTerm {
texts = texts[:domain.MaxSearchableVariantsPerTerm]
}
}
basis := strings.TrimSpace(p.BasisText)
if basis == "" {
basis = p.Text
}
for _, text := range texts {
key := strings.ToLower(text)
if text == "" || seen[key] {
continue
}
cp := p
cp.Text = text
cp.BasisText = basis
out = append(out, cp)
seen[key] = true
}
}
return out

View File

@ -37,6 +37,11 @@ func TestDemandMapBaselineAndOptimisticVersion(t *testing.T) {
if first.State != "ready" || first.MapVersion != 1 || len(first.DemandInputVersion) != len("demand-")+16 {
t.Fatalf("unexpected baseline: %+v", first)
}
for _, p := range append(append([]domain.DemandMapPhrase{}, first.PainPhrases...), append(first.ScenarioPhrases, first.SolutionSignals...)...) {
if !domain.IsThreadsSearchable(p.Text) {
t.Fatalf("baseline phrase %q is not searchable", p.Text)
}
}
first.PainPhrases[0].Text = "新的痛點"
updated, err := svc.UpdateDemandMap(context.Background(), owner, first, 1)
if err != nil {

View File

@ -77,10 +77,7 @@ func (s *Service) exploreOpportunities(ctx context.Context, ownerUID int64, rawT
if productContext != nil {
if dm, derr := s.GetDemandMap(ctx, ownerUID, productID); derr == nil {
if plan, perr := BuildQueryPlan(dm, productContext.ProductLabel); perr == nil && plan != nil {
w.Terms = make([]string, 0, len(plan.Groups))
for _, group := range plan.Groups {
w.Terms = append(w.Terms, group.Query)
}
w = mergeFetchWatch(w, plan)
}
}
}
@ -110,7 +107,7 @@ func (s *Service) exploreOpportunities(ctx context.Context, ownerUID int64, rawT
PrefilterReviewCount: prefilter.Review, PrefilterRejectedCount: prefilter.Rejected,
})
created, judged, truncated, failed, judgeCredits, perr := s.ProcessCandidates(
created, _, judged, truncated, failed, judgeCredits, perr := s.ProcessCandidates(
ctx, ownerUID, w, profile, sw.ID, cands, nil,
)
if perr != nil {

View File

@ -29,18 +29,18 @@ func (s *Service) ProcessCandidates(
sweepID string,
cands []*domain.CandidatePost,
alreadyJudged map[string]bool,
) (created, judged, truncated, failed int, credits int, err error) {
) (created, rematched, judged, truncated, failed int, credits int, err error) {
matchEvaluated, matchMerged, fitRejected, budgetDeferred := 0, 0, 0, 0
if alreadyJudged == nil {
alreadyJudged = map[string]bool{}
}
maxDaily, err := s.MaxDailyOpportunities(ctx, ownerUID)
if err != nil {
return 0, 0, 0, 0, 0, err
return 0, 0, 0, 0, 0, 0, err
}
todayCount, err := s.Repo.CountToday(ctx, ownerUID, domain.NowNano())
if err != nil {
return 0, 0, 0, 0, 0, err
return 0, 0, 0, 0, 0, 0, err
}
remaining := maxDaily - int(todayCount)
if remaining < 0 {
@ -64,6 +64,7 @@ func (s *Service) ProcessCandidates(
pcredits, perr := s.mergeExistingProductCandidate(ctx, ownerUID, watch, c, existing)
credits += pcredits
judged++
rematched++
if perr == nil {
matchMerged++
} else {
@ -77,7 +78,7 @@ func (s *Service) ProcessCandidates(
if sweepID != "" {
_, _ = s.Repo.UpdateSweep(ctx, sweepID, domain.SweepDelta{JudgedCount: judged, TruncatedCount: truncated, BudgetDeferredCount: budgetDeferred, CreditsUsed: credits, CreditJudge: credits, MatchEvaluatedCount: matchEvaluated, MatchMergedCount: matchMerged, FitRejectedCount: fitRejected})
}
return 0, judged, truncated, failed, credits, nil
return 0, rematched, judged, truncated, failed, credits, nil
}
var scored []scoredCandidate
@ -112,6 +113,7 @@ func (s *Service) ProcessCandidates(
failed++
} else {
credits += pcredits
rematched++
if watch != nil && watch.ContextMode == domain.WatchContextProduct && productMatchFor(existing, watch.ProductID) == nil {
matchMerged++
}
@ -158,11 +160,16 @@ func (s *Service) ProcessCandidates(
res := sc.result
// rejected always stored if reasons ok
if res.Status == domain.OppRejected {
if perr := s.persistOne(ctx, ownerUID, watch, sc.cand, res); perr != nil {
inserted, perr := s.persistOne(ctx, ownerUID, watch, sc.cand, res)
if perr != nil {
failed++
continue
}
created++
if inserted {
created++
} else {
rematched++
}
continue
}
if remaining <= 0 {
@ -170,11 +177,16 @@ func (s *Service) ProcessCandidates(
budgetDeferred++
continue
}
if perr := s.persistOne(ctx, ownerUID, watch, sc.cand, res); perr != nil {
inserted, perr := s.persistOne(ctx, ownerUID, watch, sc.cand, res)
if perr != nil {
failed++
continue
}
created++
if inserted {
created++
} else {
rematched++
}
remaining--
}
@ -192,15 +204,15 @@ func (s *Service) ProcessCandidates(
BudgetDeferredCount: budgetDeferred,
}
if _, uerr := s.Repo.UpdateSweep(ctx, sweepID, delta); uerr != nil {
return created, judged, truncated, failed, credits, uerr
return created, rematched, judged, truncated, failed, credits, uerr
}
}
return created, judged, truncated, failed, credits, nil
return created, rematched, judged, truncated, failed, credits, nil
}
func (s *Service) persistOne(ctx context.Context, ownerUID int64, watch *domain.RadarWatch, cand *domain.CandidatePost, res *JudgeResult) error {
func (s *Service) persistOne(ctx context.Context, ownerUID int64, watch *domain.RadarWatch, cand *domain.CandidatePost, res *JudgeResult) (inserted bool, err error) {
if err := domain.ValidateReasons(res.Reasons); err != nil {
return err
return false, err
}
watchID := ""
if watch != nil {
@ -235,6 +247,7 @@ func (s *Service) persistOne(ctx context.Context, ownerUID int64, watch *domain.
EvidenceQualityScore: priority.EvidenceQuality,
FreshnessScore: priority.Freshness,
DemandEvidence: priority.Evidence,
LastMatchedAt: domain.NowNano(),
}
if watch != nil && watch.ContextMode == domain.WatchContextProduct {
if dm, derr := s.GetDemandMap(ctx, ownerUID, watch.ProductID); derr == nil && dm != nil {
@ -245,11 +258,15 @@ func (s *Service) persistOne(ctx context.Context, ownerUID int64, watch *domain.
o.ProductMatches = []*domain.ProductMatch{domain.CloneProductMatch(res.ProductMatch)}
}
if err := mergeProductMatchIntoOpportunity(o, res.ProductMatch); res.ProductMatch != nil && err != nil {
return err
return false, err
}
if o.IntentBand == "" {
o.ApplyBandFromScore()
}
_, err := s.Repo.UpsertByExternalID(ctx, o)
return err
existing, gerr := s.Repo.GetByExternalID(ctx, ownerUID, cand.ExternalID)
already := gerr == nil && existing != nil
if _, err := s.Repo.UpsertByExternalID(ctx, o); err != nil {
return false, err
}
return !already, nil
}

View File

@ -27,10 +27,10 @@ func seedProfileWatch(t *testing.T, svc *Service, owner int64) *domain.RadarWatc
t.Helper()
ctx := context.Background()
p := &domain.ServiceProfile{
OwnerUID: owner,
Services: []domain.ServiceItem{{Name: "婚禮攝影", Currency: "TWD"}},
OwnerUID: owner,
Services: []domain.ServiceItem{{Name: "婚禮攝影", Currency: "TWD"}},
ServiceAreas: []string{"TPE"},
RemoteOk: false,
RemoteOk: false,
}
if err := p.Normalize(); err != nil {
// minimal normalize via save path
@ -98,7 +98,7 @@ func TestM2_OP02_ProviderOfferRejected(t *testing.T) {
svc.HitFetch = &fakeHits{
path: domain.SweepPathAPI,
hits: []ThreadHit{{
URL: "https://www.threads.net/@biz/post/2",
URL: "https://www.threads.net/@biz/post/2",
Snippet: "婚攝接案中 歡迎洽詢我 限時優惠 dm me",
}},
}
@ -232,6 +232,57 @@ func TestM2_QT01_AtCapNoError(t *testing.T) {
}
}
func TestM2_ManualTriggerRunsAgainAfterDailySuccess(t *testing.T) {
ctx := context.Background()
jobs := jobUC.New(jobRepo.NewMemory())
svc := New(repository.NewMemory())
svc.SweepJobs = radarManualJobs{jobs: jobs}
now := domain.NowNano()
w := &domain.RadarWatch{
ID: "w-live", OwnerUID: 17, Terms: []string{"痛點"}, Status: domain.WatchActive,
CreatedAt: now, UpdatedAt: now,
}
if err := svc.Repo.SaveWatch(ctx, w); err != nil {
t.Fatal(err)
}
daily, err := jobs.ScheduleRadarSweep(ctx, 17, w.ID, now)
if err != nil {
t.Fatal(err)
}
claimed, err := jobs.ClaimNext(ctx, "w")
if err != nil || claimed.ID != daily.ID {
t.Fatalf("claim daily: %v", err)
}
if _, err := jobs.SucceedJob(ctx, daily.ID, "daily done"); err != nil {
t.Fatal(err)
}
manualID, err := svc.TriggerSweep(ctx, 17, w.ID)
if err != nil {
t.Fatal(err)
}
if manualID == daily.ID {
t.Fatal("立即巡邏 must not reuse the finished daily job")
}
}
type radarManualJobs struct{ jobs *jobUC.Service }
func (a radarManualJobs) ScheduleRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) {
j, err := a.jobs.ScheduleRadarSweep(ctx, ownerUID, watchID, runAt)
if err != nil {
return "", err
}
return j.ID, nil
}
func (a radarManualJobs) ScheduleManualRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) {
j, err := a.jobs.ScheduleManualRadarSweep(ctx, ownerUID, watchID, runAt)
if err != nil {
return "", err
}
return j.ID, nil
}
func TestM2_ManualTriggerPausedRejected(t *testing.T) {
ctx := context.Background()
jobs := jobUC.New(jobRepo.NewMemory())

View File

@ -73,7 +73,10 @@ func (s *Service) TriggerSweep(ctx context.Context, ownerUID int64, watchID stri
if w.Status != domain.WatchActive {
return "", fmt.Errorf("%w: only active watches can be swept (status=%s)", domain.ErrValidation, w.Status)
}
// Manual trigger is due now.
// Manual trigger is due now and must not reuse a finished daily slot.
if manual, ok := s.SweepJobs.(ManualSweepJobScheduler); ok {
return manual.ScheduleManualRadarSweep(ctx, ownerUID, watchID, domain.NowNano())
}
return s.SweepJobs.ScheduleRadarSweep(ctx, ownerUID, watchID, domain.NowNano())
}

View File

@ -16,7 +16,10 @@ func TestProductFitStaleCandidateStillPersistsCompleteMatch(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if result.Status != domain.OppRejected || result.ProductMatch == nil || len(result.ProductMatch.Reasons) != 4 {
if result.Status == domain.OppRejected {
t.Fatalf("a 20-day-old demand post must stay judgeable, got rejected: %+v", result)
}
if result.ProductMatch == nil || len(result.ProductMatch.Reasons) != 4 {
t.Fatalf("stale candidate lost product evidence: %+v", result)
}
if result.ProductMatch.ProductFitScore == 0 {

View File

@ -37,13 +37,39 @@ func BuildQueryPlan(m *domain.DemandMap, productLabel string) (*domain.QueryPlan
}
productLabel = strings.ToLower(domain.NormalizeSearchTerm(productLabel))
include := func(list []domain.DemandMapPhrase) []domain.DemandMapPhrase {
out := make([]domain.DemandMapPhrase, 0, len(list))
for _, p := range list {
if !p.Enabled {
continue
}
variants := domain.SearchableTermVariants(p.Text, true)
if len(variants) > domain.MaxSearchableVariantsPerTerm {
variants = variants[:domain.MaxSearchableVariantsPerTerm]
}
basis := strings.TrimSpace(p.BasisText)
if basis == "" {
basis = strings.TrimSpace(p.Text)
}
for _, text := range variants {
if text == "" || strings.ToLower(text) == productLabel {
continue
}
cp := p
cp.Text = text
cp.BasisText = basis
out = append(out, cp)
}
}
return out
}
excludePhrases := func(list []domain.DemandMapPhrase) []domain.DemandMapPhrase {
out := make([]domain.DemandMapPhrase, 0, len(list))
for _, p := range list {
if !p.Enabled {
continue
}
text := domain.NormalizeSearchTerm(p.Text)
if text == "" || strings.ToLower(text) == productLabel || !domain.IsThreadsSearchable(text) {
if text == "" {
continue
}
p.Text = text
@ -53,11 +79,14 @@ func BuildQueryPlan(m *domain.DemandMap, productLabel string) (*domain.QueryPlan
}
pains, scenarios := include(m.PainPhrases), include(m.ScenarioPhrases)
outcomes, solutions := include(m.DesiredOutcomes), include(m.SolutionSignals)
exclusions := include(m.ExclusionSignals)
exclusions := excludePhrases(m.ExclusionSignals)
groups := make([]domain.QueryPlanGroup, 0, domain.MaxExploreTerms)
seen := map[string]bool{}
add := func(parts ...domain.DemandMapPhrase) {
if len(groups) >= domain.MaxExploreTerms {
return
}
terms := make([]string, 0, len(parts))
basisKinds := make([]string, 0, len(parts))
basisTexts := make([]string, 0, len(parts))
@ -82,12 +111,18 @@ func BuildQueryPlan(m *domain.DemandMap, productLabel string) (*domain.QueryPlan
}
for _, pain := range pains {
add(pain)
if len(groups) >= domain.MaxExploreTerms {
break
}
for _, scenario := range scenarios {
add(pain, scenario)
if len(groups) >= domain.MaxExploreTerms {
break
}
}
if len(groups) >= domain.MaxExploreTerms {
break
}
for _, outcome := range outcomes {
add(pain, outcome)
if len(groups) >= domain.MaxExploreTerms {

View File

@ -38,3 +38,24 @@ func TestBuildQueryPlanRejectsIncomplete(t *testing.T) {
t.Fatal("incomplete map must not produce a plan")
}
}
func TestBuildQueryPlanShortensLongDemandPhrases(t *testing.T) {
m := &domain.DemandMap{
ProductID: "p1", DemandInputVersion: "demand-a", MapVersion: 1, State: "ready",
PainPhrases: []domain.DemandMapPhrase{{Text: "晚上睡覺容易口乾舌燥怎麼辦", Kind: "pain", Enabled: true}},
ScenarioPhrases: []domain.DemandMapPhrase{{Text: "換季日常修護", Kind: "scenario", Enabled: true}},
SolutionSignals: []domain.DemandMapPhrase{{Text: "保濕", Kind: "solution", Enabled: true}},
}
plan, err := BuildQueryPlan(m, "舒緩精華")
if err != nil {
t.Fatal(err)
}
if len(plan.Groups) == 0 {
t.Fatal("long demand phrases must still compile into a plan")
}
for _, group := range plan.Groups {
if !domain.IsThreadsSearchable(group.Query) {
t.Fatalf("unsearchable query: %+v", group)
}
}
}

View File

@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"strings"
"unicode"
"apps/backend/internal/module/radar/domain"
usageDomain "apps/backend/internal/module/usage/domain"
@ -294,70 +293,7 @@ func productSuggestionFallback(p *ProductContextSnapshot, limit int) []domain.Wa
// productSearchTermVariants keeps fallback terms short without inventing
// product names. Exact short fields win; longer CJK fields yield small windows.
func productSearchTermVariants(raw string, include bool) []string {
raw = domain.NormalizeSearchTerm(raw)
if raw == "" {
return nil
}
parts := splitProductSearchParts(raw)
if len(parts) == 0 {
parts = []string{raw}
}
seen := map[string]bool{}
out := make([]string, 0, 8)
add := func(term string) {
term = domain.NormalizeSearchTerm(term)
if term == "" || seen[term] {
return
}
if include {
if !domain.IsThreadsSearchable(term) {
return
}
} else if n := len([]rune(term)); n < domain.MinTermLen || n > domain.MaxTermLen {
return
}
seen[term] = true
out = append(out, term)
}
if domain.IsThreadsSearchable(raw) {
add(raw)
}
for _, part := range parts {
if domain.IsThreadsSearchable(part) {
add(part)
continue
}
runes := []rune(part)
for width := 4; width >= 2; width-- {
if len(runes) < width {
continue
}
for start := 0; start+width <= len(runes) && len(out) < 8; start++ {
add(string(runes[start : start+width]))
}
}
}
return out
}
func splitProductSearchParts(raw string) []string {
var b strings.Builder
parts := make([]string, 0, 4)
flush := func() {
if value := strings.TrimSpace(b.String()); value != "" {
parts = append(parts, value)
}
b.Reset()
}
for _, r := range raw {
if unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.Is(unicode.Han, r) || unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r) {
b.WriteRune(r)
} else {
flush()
}
}
flush()
return parts
return domain.SearchableTermVariants(raw, include)
}
/*

View File

@ -160,7 +160,7 @@ func TestSuggestRespectsLimit(t *testing.T) {
}
func TestSuggestDropsUnusableItems(t *testing.T) {
// 沒理由、太短、重複、超過 Threads 短詞規則的 include 都要丟掉
// 沒理由、太短、重複丟掉;過長 include 收成可搜短詞,不再整條丟
svc, _, ctx := suggestService(t, `[
{"term":"婚攝 求推薦","reason":"在找攝影師的人常這樣問","usage":"include"},
{"term":"沒有理由的詞","reason":" ","usage":"include"},
@ -173,8 +173,13 @@ func TestSuggestDropsUnusableItems(t *testing.T) {
if err != nil {
t.Fatalf("suggest: %v", err)
}
if len(list) != 1 || list[0].Term != "婚攝 求推薦" {
t.Fatalf("got %+v, want only the one usable suggestion", list)
if len(list) != 2 || list[0].Term != "婚攝 求推薦" || list[1].Term != "台北 婚攝" {
t.Fatalf("got %+v, want shortened multi-token plus the original short term", list)
}
for _, item := range list {
if item.Usage == domain.SuggestUsageInclude && !domain.IsThreadsSearchable(item.Term) {
t.Fatalf("include not searchable: %+v", item)
}
}
}

View File

@ -59,7 +59,7 @@ func (s *Service) FetchCandidates(ctx context.Context, ownerUID int64, w *domain
if w == nil {
return nil, "", 0, fmt.Errorf("%w: watch required", domain.ErrValidation)
}
terms := w.Terms
terms := domain.ExpandSearchTerms(w.Terms, domain.MaxWatchTerms)
if len(terms) == 0 {
return nil, "", 0, fmt.Errorf("%w: watch has no terms", domain.ErrValidation)
}
@ -104,11 +104,15 @@ func (s *Service) FetchCandidates(ctx context.Context, ownerUID int64, w *domain
out := make([]*domain.CandidatePost, 0, len(hits))
for _, h := range hits {
permalink := strings.TrimSpace(h.URL)
text := strings.TrimSpace(h.Snippet)
if text == "" {
text = strings.TrimSpace(h.Title)
}
if text == "" {
if text == "" && permalink != "" {
text = permalink
}
if text == "" || permalink == "" {
continue
}
blob := strings.ToLower(text + " " + h.Title)
@ -122,10 +126,6 @@ func (s *Service) FetchCandidates(ctx context.Context, ownerUID int64, w *domain
if skip {
continue
}
permalink := strings.TrimSpace(h.URL)
if permalink == "" {
continue
}
term := matchingWatchTerm(blob, terms)
class := classifyCandidate(blob)
out = append(out, &domain.CandidatePost{

View File

@ -0,0 +1,65 @@
package usecase
import (
"context"
"fmt"
"strings"
"testing"
"apps/backend/internal/module/radar/domain"
)
func TestMergeFetchWatchKeepsSubscriberTerms(t *testing.T) {
w := &domain.RadarWatch{Terms: []string{"求推薦", "泛紅"}, ExcludeTerms: []string{"抽獎"}}
plan := &domain.QueryPlan{Groups: []domain.QueryPlanGroup{
{Query: "換季 不適", Exclude: []string{"業配"}},
{Query: "求推薦"},
}}
got := mergeFetchWatch(w, plan)
if len(got.Terms) != 3 || got.Terms[0] != "求推薦" || got.Terms[1] != "泛紅" || got.Terms[2] != "換季 不適" {
t.Fatalf("terms=%v want subscriber keywords first, then new plan queries", got.Terms)
}
if len(got.ExcludeTerms) != 2 {
t.Fatalf("exclude=%v", got.ExcludeTerms)
}
}
func TestMergeFetchWatchExpandsLongSubscriberTerms(t *testing.T) {
w := &domain.RadarWatch{Terms: []string{"晚上睡覺容易口乾舌燥怎麼辦"}}
got := mergeFetchWatch(w, nil)
if len(got.Terms) == 0 {
t.Fatal("long subscriber term must expand before fetch")
}
for _, term := range got.Terms {
if !domain.IsThreadsSearchable(term) {
t.Fatalf("fetch term %q is not searchable", term)
}
}
}
func TestHumanFetchErrorCrawlerSessionExpired(t *testing.T) {
got := humanFetchError(fmt.Errorf(`Chrome crawler status 422: {"error":"crawler session expired"}`))
if !strings.Contains(got, "Chrome 登入已過期") || !strings.Contains(got, "設定") {
t.Fatalf("human message=%q", got)
}
}
func TestFetchCandidatesKeepsTitleOnlyHits(t *testing.T) {
svc := New(nil)
svc.HitFetch = HitFetcherFunc(func(context.Context, int64, []string, int) ([]ThreadHit, string, error) {
return []ThreadHit{{
URL: "https://www.threads.net/@a/post/xyz",
Title: "皮膚泛紅怎麼辦",
}}, "api", nil
})
// bill path: HitFetch returns api so FetchCandidates will try to bill.
// Avoid billing by setting path after... FetchCandidates bills API path.
// Use empty Usage so bill is a no-op if implemented that way.
cands, _, _, err := svc.FetchCandidates(context.Background(), 1, &domain.RadarWatch{Terms: []string{"泛紅"}}, 10)
if err != nil {
t.Fatalf("fetch: %v", err)
}
if len(cands) != 1 || cands[0].Text == "" {
t.Fatalf("title-only hit dropped: %+v err=%v", cands, err)
}
}

View File

@ -12,6 +12,7 @@ import (
type SweepRunResult struct {
Sweep *domain.RadarSweep
Created int
Rematched int
Judged int
Truncated int
FailedJudges int
@ -85,17 +86,10 @@ func (s *Service) RunSweep(ctx context.Context, ownerUID int64, watchID, jobID s
already[id] = true
}
fetchWatch := w
fetchWatch := mergeFetchWatch(w, nil)
if w.ContextMode == domain.WatchContextProduct {
if plan, perr := s.BuildProductQueryPlan(ctx, ownerUID, w.ProductID); perr == nil && plan != nil && len(plan.Groups) > 0 {
planned := *w
planned.Terms = make([]string, 0, len(plan.Groups))
planned.ExcludeTerms = append([]string(nil), w.ExcludeTerms...)
for _, group := range plan.Groups {
planned.Terms = append(planned.Terms, group.Query)
planned.ExcludeTerms = append(planned.ExcludeTerms, group.Exclude...)
}
fetchWatch = &planned
fetchWatch = mergeFetchWatch(w, plan)
}
}
cands, path, fetchCredits, ferr := s.FetchCandidates(ctx, ownerUID, fetchWatch, 40)
@ -135,7 +129,7 @@ func (s *Service) RunSweep(ctx context.Context, ownerUID int64, watchID, jobID s
}
}
created, judged, truncated, failed, judgeCredits, perr := s.ProcessCandidates(
created, rematched, judged, truncated, failed, judgeCredits, perr := s.ProcessCandidates(
ctx, ownerUID, w, profile, sw.ID, cands, already,
)
if perr != nil {
@ -162,6 +156,7 @@ func (s *Service) RunSweep(ctx context.Context, ownerUID int64, watchID, jobID s
return &SweepRunResult{
Sweep: sw,
Created: created,
Rematched: rematched,
Judged: judged,
Truncated: truncated,
FailedJudges: failed,
@ -199,15 +194,50 @@ func (s *Service) notifySweepFailed(ctx context.Context, ownerUID int64, sweepID
return s.Notifier.NotifySweepFailed(ctx, ownerUID, sweepID, watchID, reason)
}
// mergeFetchWatch keeps the subscriber's own keywords and adds demand-map
// queries. Replacing the watch terms with only plan groups made patrols
// miss the phrases the user actually typed.
func mergeFetchWatch(w *domain.RadarWatch, plan *domain.QueryPlan) *domain.RadarWatch {
if w == nil {
return nil
}
planned := *w
terms := append([]string{}, w.Terms...)
excludes := append([]string{}, w.ExcludeTerms...)
seen := map[string]bool{}
for _, t := range terms {
seen[strings.ToLower(strings.TrimSpace(t))] = true
}
if plan != nil {
for _, group := range plan.Groups {
q := strings.TrimSpace(group.Query)
if q != "" && !seen[strings.ToLower(q)] {
terms = append(terms, q)
seen[strings.ToLower(q)] = true
}
excludes = append(excludes, group.Exclude...)
}
}
planned.Terms = domain.ExpandSearchTerms(terms, domain.MaxWatchTerms)
planned.ExcludeTerms = excludes
return &planned
}
func humanFetchError(err error) string {
if err == nil {
return "抓取失敗"
}
msg := err.Error()
low := strings.ToLower(msg)
// never include token-like blobs
if strings.Contains(strings.ToLower(msg), "bearer ") {
if strings.Contains(low, "bearer ") {
return "抓取路徑不可用"
}
if strings.Contains(low, "crawler session expired") ||
strings.Contains(low, "crawler session is invalid") ||
strings.Contains(low, "crawler session required") {
return "今天沒巡到Chrome 登入已過期。請到設定重新同步已登入的 Threads 分頁,或先關掉開發模式改走 API 搜尋。"
}
if len(msg) > 200 {
msg = msg[:200]
}

View File

@ -18,6 +18,12 @@ type SweepJobScheduler interface {
ScheduleRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (jobID string, err error)
}
// ManualSweepJobScheduler is optional. TriggerSweep uses it so 「立即巡邏」
// is not swallowed by a finished same-day daily job.
type ManualSweepJobScheduler interface {
ScheduleManualRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (jobID string, err error)
}
// SweepJobSchedulerFunc adapts a function to SweepJobScheduler.
type SweepJobSchedulerFunc func(ctx context.Context, ownerUID int64, watchID string, runAt int64) (jobID string, err error)

View File

@ -69,6 +69,25 @@ func TestCreateWatchRejectsBadInput(t *testing.T) {
}
}
func TestCreateWatchExpandsUnsearchableTerms(t *testing.T) {
svc, ctx := serviceWithProfile(t, 5)
w, err := svc.CreateWatch(ctx, 42, WatchInput{
Terms: []string{"晚上睡覺容易口乾舌燥怎麼辦"},
Enabled: true,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if len(w.Terms) == 0 {
t.Fatal("long sentence must expand into searchable terms")
}
for _, term := range w.Terms {
if !domain.IsThreadsSearchable(term) {
t.Fatalf("persisted term %q is not searchable", term)
}
}
}
func TestWatchPauseResumeRoundTrip(t *testing.T) {
svc, ctx := serviceWithProfile(t, 5)

View File

@ -7,13 +7,14 @@ import (
)
var (
ErrNotFound = errors.New("scout not found")
ErrForbidden = errors.New("scout forbidden")
ErrValidation = errors.New("scout validation")
ErrNoCrawlerSession = errors.New("crawler session required when dev_mode enabled")
ErrTopicRemoved = errors.New("ScoutTopic CRUD removed")
ErrHasProducts = errors.New("brand has products; remove products first")
ErrIllegalRunStatus = errors.New("illegal scout run status transition")
ErrNotFound = errors.New("scout not found")
ErrForbidden = errors.New("scout forbidden")
ErrValidation = errors.New("scout validation")
ErrNoCrawlerSession = errors.New("crawler session required when dev_mode enabled")
ErrCrawlerSessionExpired = errors.New("crawler session expired")
ErrTopicRemoved = errors.New("ScoutTopic CRUD removed")
ErrHasProducts = errors.New("brand has products; remove products first")
ErrIllegalRunStatus = errors.New("illegal scout run status transition")
)
func NowNano() int64 { return time.Now().UTC().UnixNano() }

View File

@ -4,11 +4,14 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"apps/backend/internal/module/scout/domain"
)
// ChromeCrawlerProvider is the private worker-to-browser boundary. The
@ -43,7 +46,12 @@ func (p *HTTPCrawlerProvider) ResolveMediaID(ctx context.Context, storageState,
defer res.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(res.Body, 64<<10))
if res.StatusCode < 200 || res.StatusCode >= 300 {
return "", fmt.Errorf("Chrome resolver status %d: %s", res.StatusCode, truncate(string(raw), 160))
body := truncate(string(raw), 160)
err := fmt.Errorf("Chrome resolver status %d: %s", res.StatusCode, body)
if isCrawlerSessionDead(err) {
return "", fmt.Errorf("%w (%s)", domain.ErrCrawlerSessionExpired, body)
}
return "", err
}
var out struct {
MediaID string `json:"media_id"`
@ -104,7 +112,12 @@ func (p *HTTPCrawlerProvider) SearchChrome(ctx context.Context, storageState str
defer res.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("Chrome crawler status %d: %s", res.StatusCode, truncate(string(raw), 160))
body := truncate(string(raw), 160)
err := fmt.Errorf("Chrome crawler status %d: %s", res.StatusCode, body)
if isCrawlerSessionDead(err) {
return nil, fmt.Errorf("%w (%s)", domain.ErrCrawlerSessionExpired, body)
}
return nil, err
}
var out struct {
Posts []struct {
@ -165,3 +178,16 @@ func isSoftAged(publishedAtNano int64, softDays int) bool {
cutoff := time.Now().UTC().AddDate(0, 0, -softDays).UnixNano()
return publishedAtNano < cutoff
}
func isCrawlerSessionDead(err error) bool {
if err == nil {
return false
}
if errors.Is(err, domain.ErrNoCrawlerSession) || errors.Is(err, domain.ErrCrawlerSessionExpired) {
return true
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "crawler session expired") ||
strings.Contains(msg, "crawler session is invalid") ||
strings.Contains(msg, "crawler session required")
}

View File

@ -2,9 +2,11 @@ package usecase
import (
"context"
"fmt"
"testing"
"apps/backend/internal/module/scout/domain"
"apps/backend/internal/module/scout/repository"
)
type recordingProvider struct {
@ -39,8 +41,8 @@ func TestSearchHitsOnlyFansOutPerTermAndDedupes(t *testing.T) {
if path != domain.PathAPI {
t.Fatalf("path=%q want %q", path, domain.PathAPI)
}
if len(prov.calls) != 2 {
t.Fatalf("provider calls=%d want 2 (fan-out), calls=%v", len(prov.calls), prov.calls)
if len(prov.calls) < 2 {
t.Fatalf("provider calls=%d want >=2 (fan-out, plus sparse top-up)", len(prov.calls))
}
for _, c := range prov.calls {
if len(c) != 1 {
@ -129,6 +131,87 @@ func TestMergeHitsDedupe(t *testing.T) {
}
}
type alwaysDevMode struct{}
func (alwaysDevMode) DevModeEnabled(context.Context, int64) (bool, error) { return true, nil }
type expiredCrawler struct{ calls int }
func (c *expiredCrawler) SearchChrome(context.Context, string, []string, int) ([]ThreadSearchResult, error) {
c.calls++
return nil, fmt.Errorf(`Chrome crawler status 422: {"error":"crawler session expired"}`)
}
func (c *expiredCrawler) ResolveMediaID(context.Context, string, string) (string, error) {
return "", fmt.Errorf("crawler session expired")
}
func TestSearchHitsOnlyFallsBackToAPIWhenCrawlerSessionExpired(t *testing.T) {
crawler := &expiredCrawler{}
prov := &recordingProvider{}
svc := &Service{
Provider: prov,
Crawler: crawler,
Settings: alwaysDevMode{},
Repo: repository.NewMemory(),
SessionSecret: "test-crawler-session-secret",
}
if err := svc.SetCrawlerSession(context.Background(), 1, `{"cookies":[{"domain":".threads.net","expires":4102444800}]}`); err != nil {
t.Fatalf("seed session: %v", err)
}
hits, path, err := svc.SearchHitsOnly(context.Background(), 1, []string{"保母", "求推薦"}, 20)
if err != nil {
t.Fatalf("expired crawler must fall back to api: %v", err)
}
if path != domain.PathAPI {
t.Fatalf("path=%q want api after crawler session expired", path)
}
if len(hits) == 0 {
t.Fatal("api fallback returned no hits")
}
if crawler.calls != 1 {
t.Fatalf("dead session should stop crawler fan-out, calls=%d", crawler.calls)
}
if len(prov.calls) == 0 {
t.Fatal("api provider was not used")
}
}
func TestSearchHitsOnlyFallsBackToAPIWhenCrawlerSessionMissing(t *testing.T) {
prov := &recordingProvider{}
svc := &Service{Provider: prov, Settings: alwaysDevMode{}}
hits, path, err := svc.SearchHitsOnly(context.Background(), 1, []string{"保母"}, 10)
if err != nil {
t.Fatalf("missing session must fall back to api: %v", err)
}
if path != domain.PathAPI || len(hits) == 0 {
t.Fatalf("path=%q hits=%d", path, len(hits))
}
}
func TestSearchHitsOnlyErrorsWhenCrawlerExpiredAndAPIUnavailable(t *testing.T) {
crawler := &expiredCrawler{}
svc := &Service{
Crawler: crawler,
Settings: alwaysDevMode{},
Repo: repository.NewMemory(),
SessionSecret: "test-crawler-session-secret",
}
if err := svc.SetCrawlerSession(context.Background(), 1, `{"cookies":[{"domain":".threads.net","expires":4102444800}]}`); err != nil {
t.Fatalf("seed session: %v", err)
}
_, path, err := svc.SearchHitsOnly(context.Background(), 1, []string{"保母"}, 10)
if err == nil {
t.Fatal("both paths down must error")
}
if path != domain.PathCrawler {
t.Fatalf("path=%q want crawler", path)
}
if !isCrawlerSessionDead(err) {
t.Fatalf("err=%v want session-dead", err)
}
}
func TestCanonicalPostIdentityDedupesThreadsURLAliases(t *testing.T) {
aliases := []string{
"https://www.threads.net/@alice/post/AbC123?xmt=AQG",

View File

@ -138,6 +138,10 @@ func (r *searchPipelineRunner) runStageForTerms(perQuery int, source func(contex
if err != nil {
r.lastErr = err
r.diagnostics.SourceUnavailable = true
if isCrawlerSessionDead(err) {
r.source = nil
break
}
}
if err == nil {
r.sourceOK = true

View File

@ -515,31 +515,49 @@ func (s *Service) SearchHitsOnly(ctx context.Context, ownerUID int64, terms []st
}
}
if devMode {
storageState, serr := s.GetCrawlerSessionToken(ctx, ownerUID)
if serr != nil {
return nil, domain.PathCrawler, domain.ErrNoCrawlerSession
var storageState string
var serr error
if s.Repo != nil {
storageState, serr = s.GetCrawlerSessionToken(ctx, ownerUID)
} else {
serr = domain.ErrNoCrawlerSession
}
path = domain.PathCrawler
if s.Crawler == nil {
return nil, path, fmt.Errorf("Chrome crawler is not configured")
if serr != nil || strings.TrimSpace(storageState) == "" {
if s.Provider == nil {
return nil, domain.PathCrawler, domain.ErrNoCrawlerSession
}
logx.Infof("scout SearchHitsOnly: no crawler session uid=%d; falling back to api", ownerUID)
} else if s.Crawler == nil {
if s.Provider == nil {
return nil, domain.PathCrawler, fmt.Errorf("Chrome crawler is not configured")
}
logx.Infof("scout SearchHitsOnly: crawler not configured uid=%d; falling back to api", ownerUID)
} else {
path = domain.PathCrawler
hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, n int) ([]ThreadSearchResult, error) {
return s.Crawler.SearchChrome(ctx, storageState, []string{q}, n)
})
if err == nil {
hits = fillSearchHitsToTarget(ctx, s, terms, hits, limit, path, storageState)
return capHits(hits, limit), path, nil
}
if s.Provider == nil {
return nil, path, err
}
logx.Errorf("scout SearchHitsOnly: crawler failed uid=%d: %v; falling back to api", ownerUID, err)
}
hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, n int) ([]ThreadSearchResult, error) {
return s.Crawler.SearchChrome(ctx, storageState, []string{q}, n)
})
if err != nil {
return nil, path, err
}
return capHits(hits, limit), path, nil
}
if s.Provider == nil {
return nil, path, fmt.Errorf("scout search provider is not configured")
}
path = domain.PathAPI
hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, n int) ([]ThreadSearchResult, error) {
return s.Provider.SearchThreads(ctx, []string{q}, n)
})
if err != nil {
return nil, path, err
}
hits = fillSearchHitsToTarget(ctx, s, terms, hits, limit, path, "")
return capHits(hits, limit), path, nil
}
@ -761,6 +779,9 @@ func fanOutSearch(ctx context.Context, terms []string, perQuery int, search func
if firstErr == nil {
firstErr = err
}
if isCrawlerSessionDead(err) {
break
}
continue
}
for _, hit := range hits {

View File

@ -247,14 +247,8 @@ func NewServiceContext(c config.Config) *ServiceContext {
// 商機回覆一鍵送出:同一條 Outbox 佇列+同一套 crawler media id 解析,不重造第二套送出路徑。
radarSvc.ReplyQueue = &scoutReplyQueue{Studio: studioSvc}
radarSvc.MediaResolver = scoutSvc
// 每日巡與手動觸發共用 job.ScheduleRadarSweep同 template、同日去重
radarSvc.SweepJobs = radarUC.SweepJobSchedulerFunc(func(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) {
j, err := jobs.ScheduleRadarSweep(ctx, ownerUID, watchID, runAt)
if err != nil {
return "", err
}
return j.ID, nil
})
// 每日巡走同日去重;立即巡邏另開 manual job避免當天已成功的日巡把按鈕吞掉。
radarSvc.SweepJobs = radarSweepJobs{jobs: jobs}
zipPath := findExtensionZip()
@ -552,6 +546,32 @@ func (b *metaMediaBridge) ListProfilePosts(ctx context.Context, accessToken, use
return out, nil
}
type radarSweepJobs struct {
jobs *jobUC.Service
}
func (a radarSweepJobs) ScheduleRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) {
if a.jobs == nil {
return "", fmt.Errorf("jobs not configured")
}
j, err := a.jobs.ScheduleRadarSweep(ctx, ownerUID, watchID, runAt)
if err != nil {
return "", err
}
return j.ID, nil
}
func (a radarSweepJobs) ScheduleManualRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) {
if a.jobs == nil {
return "", fmt.Errorf("jobs not configured")
}
j, err := a.jobs.ScheduleManualRadarSweep(ctx, ownerUID, watchID, runAt)
if err != nil {
return "", err
}
return j.ID, nil
}
// personaJobBridge adapts job.Service → studio.PersonaAnalyzeScheduler (jobID only).
type personaJobBridge struct {
Jobs *jobUC.Service

View File

@ -87,9 +87,9 @@ export default function App() {
<Route path="outbox" element={<OutboxPage />} />
<Route path="outbox/:id" element={<OutboxDetailPage />} />
<Route path="scout" element={<ScoutPage />} />
<Route path="radar" element={<Navigate to="/app/radar/today" replace />} />
<Route path="radar" element={<RadarOpportunitiesPage />} />
<Route path="radar/watches" element={<RadarWatchesPage />} />
<Route path="radar/today" element={<Navigate to="/app/radar/opportunities?review_state=pending&time_scope=today" replace />} />
<Route path="radar/today" element={<RadarOpportunitiesPage />} />
<Route path="radar/opportunities" element={<RadarOpportunitiesPage />} />
<Route path="crm" element={<CrmBoardPage />} />
<Route path="crm/followups" element={<CrmFollowUpsPage />} />

View File

@ -38,15 +38,15 @@ export function OpportunityDetailDrawer({ opportunity, onClose, onAccept, onComp
</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}
<div className="hb-opp-drawer__actions">
{pending && !accepted && onAccept ? (
<Button type="button" disabled={busy} onClick={() => onAccept(opportunity)}></Button>
) : null}
{pending && onComplete ? (
<Button type="button" variant="secondary" disabled={busy} onClick={() => onComplete(opportunity)}></Button>
<Button type="button" disabled={busy} onClick={() => onComplete(opportunity)}></Button>
) : null}
{pending && !accepted && onAccept ? (
<Button type="button" variant="ghost" disabled={busy} onClick={() => onAccept(opportunity)}></Button>
) : null}
<a className="hb-btn hb-btn--ghost" href={opportunity.permalink} target="_blank" rel="noreferrer"> Threads </a>
</div>
{pending ? <p className="hb-field__hint">使</p> : null}
{pending ? <p className="hb-field__hint"></p> : null}
</div>
</aside>
);

View File

@ -67,14 +67,14 @@ export function OpportunityInboxCard({ opportunity, onOpen, onAccept, onComplete
<a href={opportunity.permalink} target="_blank" rel="noreferrer"> Threads </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>
{(pending || reviewState === "completed") && !accepted ? (
<Button type="button" variant="primary" disabled={busy} onClick={() => onAccept(opportunity)}>
{busy ? "處理中…" : "加入名單並追蹤"}
<Button type="button" variant="ghost" disabled={busy} onClick={() => onAccept(opportunity)}>
{busy ? "處理中…" : "加入名單(可選)"}
</Button>
) : null}
{pending ? <Button type="button" variant="secondary" 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>
{reviewState === "removed" ? <Button type="button" variant="secondary" disabled={busy} onClick={() => onRestore(opportunity)}></Button> : null}
{reviewState === "completed" && accepted && opportunity.contact_id ? (
<Link className="hb-btn hb-btn--secondary" to={`/app/crm?contact=${encodeURIComponent(opportunity.contact_id)}`}></Link>
@ -83,15 +83,15 @@ export function OpportunityInboxCard({ opportunity, onOpen, onAccept, onComplete
</div>
{removeOpen ? (
<div className="hb-opp-card__remove-form" aria-label="標示為不適合">
<strong></strong>
<p className="hb-field__hint"></p>
<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>)}
</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}
<div className="hb-opp-card__actions">
<Button type="button" variant="danger" disabled={busy || (reason === "other" && !note.trim())} onClick={submitRemove}></Button>
<Button type="button" variant="danger" disabled={busy || (reason === "other" && !note.trim())} onClick={submitRemove}></Button>
<Button type="button" variant="ghost" onClick={() => setRemoveOpen(false)}></Button>
</div>
</div>

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { buildQueryPlan } from "./QueryPlanPreview";
import type { DemandMap } from "../../domain/types";
import { isThreadsSearchable } from "../../lib/threadsTerm";
const map: DemandMap = {
product_id: "p1", demand_input_version: "v1", map_version: 2, state: "ready",
@ -13,16 +14,32 @@ const map: DemandMap = {
};
describe("QueryPlanPreview", () => {
it("generates at most six groups with pain/context/outcome and exclusions", () => {
it("generates at most six searchable groups and keeps exclusions", () => {
const groups = buildQueryPlan(map);
expect(groups).toHaveLength(2);
expect(groups[0].terms).toEqual(["漏水", "天花板滴水", "找人處理"]);
expect(groups.length).toBeGreaterThan(0);
expect(groups.length).toBeLessThanOrEqual(6);
expect(groups[0].exclusion).toEqual(["徵才"]);
expect(groups.every((group) => group.terms.length > 0 && group.terms.every((term) => term !== "安心抓漏"))).toBe(true);
expect(groups.every((group) => {
const query = group.terms.join(" ");
return group.terms.length > 0 && group.terms.length <= 2 && isThreadsSearchable(query)
&& group.terms.every((term) => term !== "安心抓漏" && isThreadsSearchable(term));
})).toBe(true);
});
it("falls back to scenario/outcome when the pain list is empty", () => {
const groups = buildQueryPlan({ ...map, pain_phrases: [] });
expect(groups.map((group) => group.terms[0])).toEqual(["天花板滴水", "找人處理"]);
expect(groups.length).toBeGreaterThan(0);
expect(groups.every((group) => isThreadsSearchable(group.terms.join(" ")))).toBe(true);
});
it("shortens long demand sentences into searchable queries", () => {
const groups = buildQueryPlan({
...map,
pain_phrases: [{ text: "晚上睡覺容易口乾舌燥怎麼辦", kind: "pain", origin: "product", enabled: true }],
scenario_phrases: [{ text: "換季日常修護", kind: "scenario", origin: "product", enabled: true }],
});
expect(groups.length).toBeGreaterThan(0);
expect(groups.every((group) => isThreadsSearchable(group.terms.join(" ")))).toBe(true);
expect(groups.some((group) => group.terms.includes("口乾舌燥") || group.terms.includes("晚上睡覺"))).toBe(true);
});
});

View File

@ -1,33 +1,109 @@
import type { DemandMap } from "../../domain/types";
import { Badge } from "../ui";
import type { DemandMap, DemandMapPhrase } from "../../domain/types";
import { isThreadsSearchable, normalizeSearchTerm, searchableTermVariants } from "../../lib/threadsTerm";
import { Badge, Button } from "../ui";
export type QueryPlanGroup = { terms: string[]; basis: string; exclusion: string[] };
/** Deterministic preview used by mock UX; the live planner will keep this shape. */
export function buildQueryPlan(map: DemandMap): QueryPlanGroup[] {
const pain = map.pain_phrases.filter((item) => item.enabled);
const scenario = map.scenario_phrases.filter((item) => item.enabled);
const outcomes = map.desired_outcomes.filter((item) => item.enabled);
const exclusion = map.exclusion_signals.filter((item) => item.enabled).map((item) => item.text);
const groups = pain.slice(0, 6).map((item, index) => ({
terms: [item.text, scenario[index % Math.max(1, scenario.length)]?.text, outcomes[index % Math.max(1, outcomes.length)]?.text].filter(Boolean) as string[],
basis: item.basis_text || "產品痛點",
exclusion,
}));
if (groups.length) return groups;
const fallback = [...scenario, ...outcomes].slice(0, 6);
return fallback.map((item) => ({ terms: [item.text], basis: item.basis_text || "產品需求地圖", exclusion }));
const MAX_GROUPS = 6;
function expandPhrases(list: DemandMapPhrase[] = []): DemandMapPhrase[] {
const out: DemandMapPhrase[] = [];
for (const item of list.filter((phrase) => phrase.enabled)) {
const variants = searchableTermVariants(item.text).slice(0, 3);
const basis = item.basis_text || item.text;
for (const text of variants) {
out.push({ ...item, text, basis_text: basis });
}
}
return out;
}
export function QueryPlanPreview({ map }: { map: DemandMap }) {
/** Deterministic preview aligned with backend BuildQueryPlan: max 2 searchable tokens. */
export function buildQueryPlan(map: DemandMap): QueryPlanGroup[] {
const pain = expandPhrases(map.pain_phrases);
const scenario = expandPhrases(map.scenario_phrases);
const outcomes = expandPhrases(map.desired_outcomes);
const solutions = expandPhrases(map.solution_signals);
const exclusion = (map.exclusion_signals ?? []).filter((item) => item.enabled).map((item) => item.text);
const groups: QueryPlanGroup[] = [];
const seen = new Set<string>();
const add = (...parts: DemandMapPhrase[]) => {
if (groups.length >= MAX_GROUPS) return;
const terms = parts.map((part) => part.text).filter(Boolean);
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 });
};
for (const item of pain) {
add(item);
if (groups.length >= MAX_GROUPS) break;
for (const next of scenario) {
add(item, next);
if (groups.length >= MAX_GROUPS) break;
}
if (groups.length >= MAX_GROUPS) break;
for (const next of outcomes) {
add(item, next);
if (groups.length >= MAX_GROUPS) break;
}
if (groups.length >= MAX_GROUPS) break;
}
if (!groups.length) {
for (const item of scenario) {
for (const next of solutions) {
add(item, next);
if (groups.length >= MAX_GROUPS) break;
}
}
}
if (!groups.length) {
for (const item of [...scenario, ...outcomes, ...solutions]) {
add(item);
if (groups.length >= MAX_GROUPS) break;
}
}
return groups;
}
export function QueryPlanPreview({
map,
onAdoptQueries,
}: {
map: DemandMap;
onAdoptQueries?: (queries: string[]) => void;
}) {
const groups = buildQueryPlan(map);
const queries = groups.map((group) => group.terms.join(" ")).filter(isThreadsSearchable);
return (
<section className="hb-query-plan" aria-label="查詢計畫預覽">
<div className="hb-query-plan__head">
<div><h4></h4><p className="hb-radar-section__hint"></p></div>
<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>
{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><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}</article>)}</div> : <p className="hb-radar-empty"></p>}
{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>
<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}
</article>
))}
</div>
{onAdoptQueries && queries.length ? (
<div className="hb-radar-actions">
<Button type="button" variant="secondary" onClick={() => onAdoptQueries(queries)}>
</Button>
</div>
) : null}
</>
) : <p className="hb-radar-empty"></p>}
</section>
);
}

View File

@ -1,6 +1,11 @@
import { Link } from "react-router-dom";
import type { RadarSweep } from "../../domain/types";
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: "失敗",
};
@ -17,7 +22,17 @@ export function SweepFunnelSummary({ sweep }: { sweep: RadarSweep }) {
<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>
<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>
{sweep.failed_reason ? <p className="hb-banner-error">{sweep.failed_reason}</p> : null}
{sweep.failed_reason ? (
<p className="hb-banner-error" role="alert">
{sweep.failed_reason}
{isCrawlerSessionFailure(sweep.failed_reason) ? (
<>
{" "}
<Link to="/app/settings"> Chrome</Link>
</>
) : null}
</p>
) : null}
{status === "partial_budget" || status === "blocked_budget" ? <p className="hb-radar-section__hint">使</p> : null}
</section>
);

View File

@ -10,6 +10,8 @@ type Props = {
/** 採用一條include 進關鍵字、exclude 進排除詞,由呼叫端決定放哪 */
onAdopt: (suggestion: WatchTermSuggestion) => void;
onAdoptAll: (suggestions: WatchTermSuggestion[]) => void;
/** 採用查詢計畫裡已收成的可搜短詞 */
onAdoptQueries?: (queries: string[]) => void;
/** 已在表單裡的詞(含排除詞),用來標示重複,避免使用者按了沒反應 */
adopted: string[];
context?: { brand_id?: string; product_id?: string };
@ -23,7 +25,7 @@ const SUGGEST_LIMIT = 8;
*
* 使
*/
export function WatchSuggestPanel({ onAdopt, onAdoptAll, adopted, context, demandMap }: Props) {
export function WatchSuggestPanel({ onAdopt, onAdoptAll, onAdoptQueries, adopted, context, demandMap }: Props) {
const { t } = useI18n();
const repos = useRepos();
const formatError = useFormatApiError();
@ -52,7 +54,7 @@ export function WatchSuggestPanel({ onAdopt, onAdoptAll, adopted, context, deman
<div className="hb-radar-section">
<h3 className="hb-radar-section__title">{t("radar.suggest.title")}</h3>
<p className="hb-radar-section__hint">{t("radar.suggest.hint")}</p>
{demandMap ? <QueryPlanPreview map={demandMap} /> : null}
{demandMap ? <QueryPlanPreview map={demandMap} onAdoptQueries={onAdoptQueries} /> : null}
<div className="hb-radar-actions">
<Button type="button" variant="secondary" onClick={() => void ask()} disabled={loading}>

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import type { Brand, BrandProduct, Opportunity } from "../../domain/types";
import { isThreadsSearchable } from "../../lib/threadsTerm";
import { createMockRadarRepo } from "./radarRepo";
const brand: Brand = { id: "b1", display_name: "品牌一", brief: "", target_audience: "店家" };
@ -53,6 +54,26 @@ describe("mock product radar repository", () => {
await expect(repo.resumeWatch("unavailable")).rejects.toMatchObject({ code: 409001 });
});
it("stamps last_matched_at on sweep so older posted hits stay on today", async () => {
const yesterday = Date.now() * 1e6 - 2 * 86_400_000_000_000;
const repo = createMockRadarRepo({
watches: [{
id: "w1", terms: ["敏感肌"], exclude_terms: [], regions: [], status: "active",
created_at: 1, updated_at: 1,
}],
opportunities: [
opportunity("old-hit", yesterday, { created_at: yesterday, watch_id: "w1" }),
],
});
expect((await repo.listOpportunities({ review_state: "pending", time_scope: "today" })).list.map((o) => o.id))
.not.toContain("old-hit");
await repo.triggerWatchSweep("w1");
const after = await repo.listOpportunities({ review_state: "pending", time_scope: "today" });
expect(after.list.map((o) => o.id)).toContain("old-hit");
expect(after.list.find((o) => o.id === "old-hit")).toMatchObject({ review_state: "pending" });
expect(after.list.find((o) => o.id === "old-hit")?.last_matched_at).toBeGreaterThan(yesterday);
});
it("maps legacy status and supports an idempotent review-state lifecycle", async () => {
const repo = createMockRadarRepo({
opportunities: [
@ -121,4 +142,22 @@ describe("mock product radar repository", () => {
expect(updated.custom_phrases[0].text).toBe("晚上漏水");
await expect(repo.updateDemandMap("p1", { ...updated, expected_map_version: 1 })).rejects.toMatchObject({ code: 409002 });
});
it("shortens long product sentences into searchable demand phrases and suggestions", async () => {
const longProduct: BrandProduct = {
...product,
id: "p-long",
product_context: "換季日常修護",
pain_points: ["晚上睡覺容易口乾舌燥怎麼辦"],
match_tags: ["求推薦"],
provider_capability_terms: ["保濕"],
};
const repo = createMockRadarRepo({ brands: [brand], products: [longProduct] });
const baseline = await repo.getDemandMap("p-long");
expect(baseline.pain_phrases.every((item) => item.text.length <= 4)).toBe(true);
expect(baseline.pain_phrases.some((item) => item.text === "口乾舌燥" || item.text === "晚上睡覺")).toBe(true);
const suggestions = await repo.suggestWatchTerms({ product_id: "p-long", limit: 8 });
expect(suggestions.length).toBeGreaterThan(0);
expect(suggestions.filter((item) => item.usage === "include").every((item) => isThreadsSearchable(item.term))).toBe(true);
});
});

View File

@ -11,10 +11,12 @@ import type {
RadarSweep,
RadarWatch,
ServiceProfile,
WatchTermSuggestion,
} from "../../domain/types";
import type { RadarRepo } from "../repos";
import { ApiError } from "../live/http";
import { productRadarSeed } from "../fixtures/radarProduct";
import { expandIncludeTerms, searchableTermVariants } from "../../lib/threadsTerm";
type MockRadarSeed = {
brands?: Brand[];
@ -41,12 +43,28 @@ function phrase(text: string, kind: string, origin: DemandMapPhrase["origin"] =
return { text, kind, origin, basis_kind: origin === "product" ? kind : "custom", basis_text: basisText, enabled: true };
}
function searchablePhrases(items: string[], kind: string, basisLabel: string, include: boolean): DemandMapPhrase[] {
const out: DemandMapPhrase[] = [];
for (const item of items) {
const raw = item.trim();
if (!raw) continue;
if (!include) {
out.push(phrase(raw, kind, "product", basisLabel));
continue;
}
for (const text of searchableTermVariants(raw).slice(0, 3)) {
out.push(phrase(text, kind, "product", raw));
}
}
return out;
}
function baselineDemandMap(product: BrandProduct): DemandMap {
const pain = product.pain_points.map((text) => phrase(text, "pain", "product", "產品痛點"));
const scenario = product.product_context.trim() ? [phrase(product.product_context.trim(), "scenario", "product", "產品情境")] : [];
const outcome = product.match_tags.map((text) => phrase(text, "outcome", "product", "產品標籤"));
const solution = product.provider_capability_terms.map((text) => phrase(text, "solution", "product", "服務能力"));
const exclusion = product.provider_exclude_terms.map((text) => phrase(text, "exclusion", "product", "排除詞"));
const pain = searchablePhrases(product.pain_points, "pain", "產品痛點", true);
const scenario = searchablePhrases(product.product_context.trim() ? [product.product_context.trim()] : [], "scenario", "產品情境", true);
const outcome = searchablePhrases(product.match_tags, "outcome", "產品標籤", true);
const solution = searchablePhrases(product.provider_capability_terms, "solution", "產品能力", true);
const exclusion = searchablePhrases(product.provider_exclude_terms, "exclusion", "排除詞", false);
const ready = pain.length > 0 && scenario.length > 0 && solution.length > 0;
return {
product_id: product.id,
@ -173,11 +191,13 @@ export function createMockRadarRepo(seed: MockRadarSeed = {}): RadarRepo {
if (productMode && (!input.brand_id || !input.product_id)) error("brand_id and product_id must be provided together");
if (productMode) productFor(input.brand_id!, input.product_id!);
if (!productMode && input.enabled !== false && !profile.exists) error("service profile required", 400100);
const terms = expandIncludeTerms(input.terms);
if (!terms.length) error("terms required");
const id = `watch-${watches.size + 1}`;
const now = nanoNow();
const row: RadarWatch = {
id,
terms: copy(input.terms),
terms,
exclude_terms: copy(input.exclude_terms ?? []),
regions: copy(input.regions ?? []),
status: input.enabled === false ? "paused" : "active",
@ -211,7 +231,12 @@ export function createMockRadarRepo(seed: MockRadarSeed = {}): RadarRepo {
},
async updateWatch(id, patch) {
const row = watch(id);
Object.assign(row, copy(patch), { updated_at: nanoNow() });
const next = { ...copy(patch) };
if (patch.terms) {
next.terms = expandIncludeTerms(patch.terms);
if (!next.terms.length) error("terms required");
}
Object.assign(row, next, { updated_at: nanoNow() });
return copy(row);
},
async pauseWatch(id) {
@ -239,8 +264,44 @@ export function createMockRadarRepo(seed: MockRadarSeed = {}): RadarRepo {
if (row.status !== "archived") error("only archived watch can be permanently deleted", 400100);
watches.delete(id);
},
async suggestWatchTerms() {
return [];
async suggestWatchTerms(input) {
const parsed = typeof input === "number" ? { limit: input } : input ?? {};
const limit = parsed.limit ?? 8;
const product = parsed.product_id ? products.get(parsed.product_id) : undefined;
if (!product) return [];
const fields: Array<{ kind: WatchTermSuggestion["basis_kind"]; label: string; terms: string[]; usage: WatchTermSuggestion["usage"] }> = [
{ kind: "pain", label: "痛點", terms: product.pain_points, usage: "include" },
{ kind: "tag", label: "標籤", terms: product.match_tags, usage: "include" },
{ kind: "capability", label: "能力", terms: product.provider_capability_terms, usage: "include" },
{ kind: "audience", label: "受眾", terms: [], usage: "include" },
{ kind: "context", label: "情境", terms: product.product_context.trim() ? [product.product_context.trim()] : [], usage: "include" },
{ kind: "exclude", label: "排除詞", terms: product.provider_exclude_terms, usage: "exclude" },
];
const out: WatchTermSuggestion[] = [];
const seen = new Set<string>();
for (const field of fields) {
for (const basis of field.terms) {
const raw = basis.trim();
if (!raw) continue;
const variants = field.usage === "include" ? searchableTermVariants(raw) : [raw];
for (const term of variants) {
const key = `${field.usage}:${term.toLowerCase()}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({
term,
reason: field.usage === "exclude"
? `產品設定將「${raw}」列為排除詞,避免混入非目標貼文`
: `依產品設定的${field.label}${raw}」,可找相關需求貼文`,
usage: field.usage,
basis_kind: field.kind,
basis_text: raw,
});
if (out.length >= limit) return out;
}
}
}
return out;
},
async triggerWatchSweep(id) {
const row = watch(id);
@ -280,6 +341,13 @@ export function createMockRadarRepo(seed: MockRadarSeed = {}): RadarRepo {
sweeps.set(sweep.id, sweep);
row.last_swept_at = ended;
row.updated_at = ended;
for (const opportunity of candidates) {
opportunity.last_matched_at = ended;
const state = reviewState(opportunity);
if (state !== "removed" && state !== "completed") {
opportunity.review_state = "pending";
}
}
return { job_id: sweep.job_id!, sweep_id: sweep.id };
},
async getToday(filter) {
@ -297,9 +365,11 @@ export function createMockRadarRepo(seed: MockRadarSeed = {}): RadarRepo {
if (filter.time_scope === "all" || !filter.time_scope) return true;
const now = Date.now() * 1_000_000;
const start = filter.time_scope === "today"
? new Date().setUTCHours(0, 0, 0, 0) * 1_000_000
? new Date(new Date().setHours(0, 0, 0, 0)).getTime() * 1_000_000
: now - 7 * DAY_NS;
return o.posted_at >= start;
const posted = o.posted_at ?? 0;
const discovered = o.last_matched_at || o.created_at || 0;
return posted >= start || discovered >= start;
})
.sort((a, b) => {
switch (filter.sort) {

View File

@ -104,19 +104,19 @@ export const zhTW: MessageDict = {
"help.page.scout.step3": "寫草稿、開 Threads 回覆、標記完成。",
"help.page.scout.tips": "話題=內容靈感;商機=找需求客戶。找客戶請用商機頁,不要在這裡掃痛點。",
"help.page.radar_today.title": "今日商機(自動名單)",
"help.page.radar_today.what": "依產品痛點與需求意圖排序的工作收件匣;每張卡只需決定加入名單、只標示已處理,或標示不適合。",
"help.page.radar_today.step1": "先讀貼文與「為什麼推薦」,預設由最值得跟進的結果開始。",
"help.page.radar_today.step2": "值得跟進按「加入名單並追蹤」;已自行看完按「只標示已處理」。",
"help.page.radar_today.step3": "不是你的客戶按「不適合」並選原因;需要時可從已移除還原。",
"help.page.radar_today.tips": "找客戶=這裡(訂閱/立即探索/匯入)。找內容話題用側欄「話題」。",
"help.page.radar_today.title": "商機巡邏",
"help.page.radar_today.what": "定期或立刻巡邏,找出產品能解決的痛點或新文章。看懂理由後留下或丟掉即可。",
"help.page.radar_today.step1": "看頁頂巡邏狀態:每日定時是否開著、上次何時巡、要不要立即再巡一輪。",
"help.page.radar_today.step2": "讀「為什麼推薦」。對得上就「留下」,不是你的就「丟掉」。",
"help.page.radar_today.step3": "只有真的要追這個人時才「加入名單」。名單不是看結果的必要步驟。",
"help.page.radar_today.tips": "立即巡邏與每日定時可同時開著。關掉其中一個不會藏掉另一個。",
"help.page.radar_watches.title": "商機訂閱",
"help.page.radar_watches.what": "設定常駐關鍵字後,系統每日自動巡並寫入今日商機。這不是海巡的「按一次掃一次」。",
"help.page.radar_watches.step1": "先到「商機政策」填服務與判定資料(沒填不能啟用)。",
"help.page.radar_watches.step2": "新增訂閱:客人會搜的關鍵字、排除詞、可選地區。",
"help.page.radar_watches.step3": "啟用後等每日排程,或按「立即巡」補一輪。",
"help.page.radar_watches.tips": "啟用數有方案上限;滿了要先暫停或封存一組。",
"help.page.radar_watches.title": "設定巡邏",
"help.page.radar_watches.what": "選定產品與關鍵字後,每日定時巡邏會自動跑;也可隨時按立即巡邏。結果回到側欄「商機」。",
"help.page.radar_watches.step1": "選品牌與產品,補需求地圖裡的痛點。",
"help.page.radar_watches.step2": "填客人會搜的關鍵字與排除詞。",
"help.page.radar_watches.step3": "打開每日定時,或按「立即巡邏」現在跑一輪。",
"help.page.radar_watches.tips": "啟用數有方案上限;滿了要先暫停一組。",
"help.page.crm_board.title": "名單管理",
"help.page.crm_board.what": "從「今日商機」加入後的聯絡人工作清單,可搜尋、篩選、排序、備註、成交與查看時間軸。",
@ -972,6 +972,7 @@ export const zhTW: MessageDict = {
"jobs.template.personaAnalyzeText": "人設分析 · 文字來源",
"jobs.template.composeMimic": "仿寫貼文",
"jobs.template.playGenerateScript": "劇本一次產全文",
"jobs.template.radarSweep": "商機巡邏",
"jobs.template.unknown": "其他任務",
"jobs.stripMore": "還有 {n} 個進行中…",
"jobs.nextRun": "下次執行:{time}",
@ -2197,9 +2198,10 @@ export const zhTW: MessageDict = {
"radar.watches.editTitle": "編輯商機訂閱",
"radar.watches.requiredHint": "為必填欄位",
"radar.watches.terms": "關鍵字",
"radar.watches.termsHint": "一行一個,也可用逗號分隔。客人會搜的說法,命中後進意向判定。",
"radar.watches.threadsWarn": "有關鍵字不合 Threads 短詞規則:每組最多 2 詞、中文每詞 24 字、整組 ≤12 字、勿用標點/#emoji否則常搜不到。",
"radar.watches.termsPh": "推薦室內設計\n找設計師",
"radar.watches.termsHint": "一行一個短詞。每組最多 2 詞、中文每詞 24 字,才能在 Threads 搜到;長句儲存時會自動收成短詞。",
"radar.watches.threadsWarn": "有關鍵字不合 Threads 短詞規則:每組最多 2 詞、中文每詞 24 字、整組 ≤12 字、勿用標點/#emoji。儲存時會自動收成可搜短詞。",
"radar.watches.threadsRequired": "這些關鍵字收不成 Threads 可搜的短詞。請改成每組最多 2 詞、中文每詞 24 字。",
"radar.watches.termsPh": "室內設計\n找設計師",
"radar.watches.excludeTerms": "排除詞",
"radar.watches.excludeHint": "命中這些字就整筆跳過,例如同業自我推銷、抽獎文。",
"radar.watches.excludePh": "徵才\n抽獎",
@ -2228,12 +2230,12 @@ export const zhTW: MessageDict = {
"radar.watches.lastSwept": "上次巡:{at}",
"radar.watches.neverSwept": "還沒巡過",
"radar.watches.empty": "還沒有商機訂閱",
"radar.watches.emptyHint": "加客人會用的說法(例如「推薦室內設計」),系統會每天自動幫你巡。若要現在手動掃痛點/話題,用側欄「海巡」。",
"radar.watches.emptyHint": "加客人會用的短詞(例如「室內設計」「找設計師」),系統會每天自動幫你巡。若要現在手動掃痛點/話題,用側欄「海巡」。",
"radar.watches.emptyFiltered": "這個狀態下沒有訂閱",
"radar.watches.scheduleTitle": "每日自動排程UTC 22:00台北隔日 06:00",
"radar.watches.scheduleHint": "啟用中的訂閱會在每天排程建立巡邏任務;想現在跑一次,請到該列按「立即補巡」。排程時間目前固定,不能個別設定。",
"radar.watches.openToday": "查看今日商機",
"radar.watches.sweepNow": "立即巡",
"radar.watches.scheduleTitle": "每日定時巡邏:每天台北 06:00UTC 22:00",
"radar.watches.scheduleHint": "開著的訂閱每天自動巡一輪。要現在看結果,按「立即巡邏」。關掉立即巡邏不會停每日定時。",
"radar.watches.openToday": "回商機結果",
"radar.watches.sweepNow": "立即",
"radar.watches.sweepQueued": "已排入商機巡檢",
"radar.watches.sweepStarted": "商機巡檢已開始(任務 {job}…)",
@ -2324,7 +2326,7 @@ export const zhTW: MessageDict = {
"radar.today.empty.reason.no_profile": "還沒有服務檔案,無法判定需求適不適合你。",
"radar.today.empty.reason.no_watch": "還沒有商機訂閱;建立關鍵字後才會每天自動巡(不是海巡那一輪手動掃)。",
"radar.today.empty.reason.all_watches_paused": "訂閱都暫停了,恢復一組才會繼續自動巡。",
"radar.today.empty.reason.not_swept_yet": "今日自動巡還沒跑完,也可在商機訂閱頁按「立即補巡」。",
"radar.today.empty.reason.not_swept_yet": "每日定時還沒跑完,也可在商機頁按「立即巡邏」。",
"radar.today.empty.reason.sweep_failed": "這輪自動巡失敗,請到商機訂閱頁查看或重試。",
"radar.today.empty.reason.no_hit": "有巡但沒有符合的需求,可放寬關鍵字或排除詞。",
"radar.today.msg.accepted": "已加入名單",
@ -2585,19 +2587,19 @@ export const en: MessageDict = {
"help.page.scout.step3": "Draft, open Threads to reply, mark done.",
"help.page.scout.tips": "Topics = content ideas. Demand = finding customers. Use the Demand page for leads.",
"help.page.radar_today.title": "Today's demand (auto list)",
"help.page.radar_today.what": "A work inbox ranked by product pain fit and demand intent. Decide whether to track, mark handled, or remove each result.",
"help.page.radar_today.step1": "Read the post and Why recommended; the best opportunities come first by default.",
"help.page.radar_today.step2": "Use Add to contacts and track for a real lead, or Mark handled if no follow-up is needed.",
"help.page.radar_today.step3": "Use Not a fit with a reason for irrelevant results; removed items remain restorable.",
"help.page.radar_today.tips": "Finding customers = this page (watches / Explore / import). Content topics live under Topics.",
"help.page.radar_today.title": "Demand patrol",
"help.page.radar_today.what": "Run a scheduled or immediate patrol to find pains your product can solve, or new posts. Keep or discard after you read the reason.",
"help.page.radar_today.step1": "Check the patrol desk: whether daily patrol is on, when it last ran, and run one now.",
"help.page.radar_today.step2": "Read why it was recommended. Keep a fit, discard the rest.",
"help.page.radar_today.step3": "Add to contacts only if you want to follow that person. Contacts are optional.",
"help.page.radar_today.tips": "Immediate and daily patrol can both stay on. Turning one off does not hide the other.",
"help.page.radar_watches.title": "Demand watches",
"help.page.radar_watches.what": "Always-on keywords swept daily into Todays demand. This is not Patrols “run once” scan.",
"help.page.radar_watches.step1": "Fill services and qualification rules under Opportunity policy first.",
"help.page.radar_watches.step2": "Add a watch: terms buyers type, excludes, optional regions.",
"help.page.radar_watches.step3": "Stay active for the daily job, or hit “Sweep now” for an extra pass.",
"help.page.radar_watches.tips": "Active slots are plan-capped; pause or archive to free one.",
"help.page.radar_watches.title": "Patrol setup",
"help.page.radar_watches.what": "Pick a product and keywords. Daily patrol runs on a schedule; you can also run one immediately. Results land on Demand.",
"help.page.radar_watches.step1": "Choose brand and product, then fill the pain map.",
"help.page.radar_watches.step2": "Add terms buyers type and excludes.",
"help.page.radar_watches.step3": "Leave daily patrol on, or hit Run now.",
"help.page.radar_watches.tips": "Active slots are plan-capped; pause one to free a slot.",
"help.page.crm_board.title": "Contact management",
"help.page.crm_board.what": "A searchable, filterable work list of contacts accepted from Todays demand, with notes, wins, and timelines.",
@ -3452,6 +3454,7 @@ export const en: MessageDict = {
"jobs.template.personaAnalyzeText": "Persona analyze · from text",
"jobs.template.composeMimic": "Mimic post",
"jobs.template.playGenerateScript": "Play full-script AI",
"jobs.template.radarSweep": "Demand patrol",
"jobs.template.unknown": "Other job",
"jobs.stripMore": "+{n} more running…",
"jobs.nextRun": "Next run: {time}",
@ -4681,9 +4684,10 @@ export const en: MessageDict = {
"radar.watches.editTitle": "Edit demand watch",
"radar.watches.requiredHint": "Required field",
"radar.watches.terms": "Terms",
"radar.watches.termsHint": "One per line, commas work too. Phrases buyers type; hits go to intent scoring.",
"radar.watches.threadsWarn": "Some terms break Threads short-query rules: max 2 words, CJK 24 chars each, ≤12 chars total, no punctuation/#/emoji — long queries often return nothing.",
"radar.watches.termsPh": "interior designer recommendation\nlooking for a designer",
"radar.watches.termsHint": "One short query per line. Max 2 tokens, CJK 24 chars each — long sentences are shortened on save so Threads can search them.",
"radar.watches.threadsWarn": "Some terms break Threads short-query rules: max 2 words, CJK 24 chars each, ≤12 chars total, no punctuation/#/emoji. Save shortens them into searchable queries.",
"radar.watches.threadsRequired": "These terms cannot be shortened into Threads-searchable queries. Use at most 2 tokens, CJK 24 chars each.",
"radar.watches.termsPh": "interior design\nfind designer",
"radar.watches.excludeTerms": "Exclude terms",
"radar.watches.excludeHint": "A hit here skips the post entirely, e.g. job ads or giveaways.",
"radar.watches.excludePh": "hiring\ngiveaway",
@ -4714,12 +4718,12 @@ export const en: MessageDict = {
"radar.watches.neverSwept": "Never swept",
"radar.watches.empty": "No demand watches yet",
"radar.watches.emptyHint":
"Add phrases buyers type (e.g. “looking for a designer”); the system sweeps daily. For a one-off pain/topic sortie, use Patrol.",
"Add short buyer phrases (e.g. “find designer”); the system sweeps daily. For a one-off pain/topic sortie, use Patrol.",
"radar.watches.emptyFiltered": "No watches in this status",
"radar.watches.scheduleTitle": "Daily schedule: 22:00 UTC (06:00 next day Taipei)",
"radar.watches.scheduleHint": "Active watches create a patrol job at the daily schedule. To run one now, use “Sweep now” on that watch. The time is currently fixed and cannot be customized per watch.",
"radar.watches.openToday": "Open today's demand",
"radar.watches.sweepNow": "Sweep now",
"radar.watches.scheduleTitle": "Daily patrol: 06:00 Taipei (22:00 UTC)",
"radar.watches.scheduleHint": "Active watches run once a day. Use Run now for an extra pass. Turning off Run now does not stop the daily patrol.",
"radar.watches.openToday": "Back to findings",
"radar.watches.sweepNow": "Run now",
"radar.watches.sweepQueued": "Demand sweep queued",
"radar.watches.sweepStarted": "Demand sweep started (job {job}…)",
@ -4810,7 +4814,7 @@ export const en: MessageDict = {
"radar.today.empty.reason.no_profile": "No service profile yet — fit cannot be scored.",
"radar.today.empty.reason.no_watch": "No demand watches yet; create keywords for daily auto sweeps (not Patrols manual scan).",
"radar.today.empty.reason.all_watches_paused": "All watches are paused. Resume one to keep daily sweeps.",
"radar.today.empty.reason.not_swept_yet": "Todays auto sweep isnt done; you can also hit Sweep now on watches.",
"radar.today.empty.reason.not_swept_yet": "Daily patrol hasnt finished yet; you can also hit Run now on Demand.",
"radar.today.empty.reason.sweep_failed": "This auto sweep failed — check demand watches and retry.",
"radar.today.empty.reason.no_hit": "Swept but no matching demand. Loosen terms or exclusions.",
"radar.today.msg.accepted": "Added to CRM",

View File

@ -18,6 +18,8 @@ export function jobTemplateLabel(
return t("jobs.template.composeMimic");
case "play_generate_script":
return t("jobs.template.playGenerateScript");
case "radar_sweep":
return t("jobs.template.radarSweep");
default:
return templateType || t("jobs.template.unknown");
}

View File

@ -32,7 +32,7 @@ export const primaryNav: NavItem[] = [
/** 話題靈感(原海巡活躍話題);找需求請用商機頁「立即探索」 */
{ key: "scout", path: "/app/scout", labelKey: "nav.scout", label: "話題", en: "Topics" },
/** 側欄用「商機」:每日自動名單+立即探索+手動匯入 */
{ key: "radar", path: "/app/radar/today", labelKey: "nav.radar", label: "商機", en: "Demand" },
{ key: "radar", path: "/app/radar", labelKey: "nav.radar", label: "商機", en: "Demand" },
{ key: "crm", path: "/app/crm", labelKey: "nav.crm", label: "名單", en: "CRM" },
{ key: "outbox", path: "/app/outbox", labelKey: "nav.outbox", label: "發送", en: "Outbox" },
{ key: "jobs", path: "/app/jobs", labelKey: "nav.jobs", label: "任務", en: "Jobs" },
@ -145,9 +145,9 @@ export function pathForNotification(n: {
if (n.ref_type === "contact" && n.ref_id) return `/app/crm?contact=${encodeURIComponent(n.ref_id)}`;
if (n.ref_type === "contact") return "/app/crm/followups";
if (n.ref_type === "followup" || n.ref_type === "follow_up") return "/app/crm/followups";
if (n.ref_type === "opportunity" && n.ref_id) return "/app/radar/today";
if (n.ref_type === "opportunity" && n.ref_id) return "/app/radar";
if (n.ref_type === "sweep" || n.ref_type === "radar_watch") return "/app/radar/watches";
if (n.ref_type === "radar") return "/app/radar/today";
if (n.ref_type === "radar") return "/app/radar";
// 任務/系統/無 ref任務中心
return "/app/jobs";
}

View File

@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { primaryNav } from "./nav";
import { normalizeAppPath, pageHelpKeys, resolvePageHelpId } from "./pageHelp";
describe("pageHelp", () => {
@ -6,6 +7,7 @@ describe("pageHelp", () => {
expect(resolvePageHelpId("/app/radar/watches")).toBe("radar_watches");
expect(resolvePageHelpId("/app/radar/today")).toBe("radar_today");
expect(resolvePageHelpId("/app/radar")).toBe("radar_today");
expect(primaryNav.find((item) => item.key === "radar")?.path).toBe("/app/radar");
expect(resolvePageHelpId("/app/crm/followups")).toBe("crm_followups");
expect(resolvePageHelpId("/app/crm/stats")).toBe("crm_stats");
expect(resolvePageHelpId("/app/crm?contact=x")).toBe("crm_board");

View File

@ -69,7 +69,7 @@ const RULES: { prefix: string; id: PageHelpId }[] = [
/** 每個說明可連到的相關頁(可選) */
export const PAGE_HELP_RELATED: Partial<Record<PageHelpId, PageHelpRelated[]>> = {
today: [
{ path: "/app/radar/today", labelKey: "nav.radar" },
{ path: "/app/radar", labelKey: "nav.radar" },
{ path: "/app/scout", labelKey: "nav.scout" },
{ path: "/app/outbox", labelKey: "nav.outbox" },
],
@ -79,21 +79,21 @@ export const PAGE_HELP_RELATED: Partial<Record<PageHelpId, PageHelpRelated[]>> =
{ path: "/app/policy", labelKey: "nav.policy" },
],
radar_watches: [
{ path: "/app/radar/today", labelKey: "nav.radar" },
{ path: "/app/radar", labelKey: "nav.radar" },
{ path: "/app/policy", labelKey: "nav.policy" },
],
crm_board: [
{ path: "/app/crm/followups", labelKey: "crm.board.link.followups" },
{ path: "/app/crm/stats", labelKey: "crm.board.link.stats" },
{ path: "/app/radar/today", labelKey: "nav.radar" },
{ path: "/app/radar", labelKey: "nav.radar" },
],
crm_followups: [
{ path: "/app/crm", labelKey: "crm.followups.link.board" },
{ path: "/app/radar/today", labelKey: "nav.radar" },
{ path: "/app/radar", labelKey: "nav.radar" },
],
crm_stats: [{ path: "/app/crm", labelKey: "crm.followups.link.board" }],
scout: [
{ path: "/app/radar/today", labelKey: "nav.radar" },
{ path: "/app/radar", labelKey: "nav.radar" },
{ path: "/app/brands", labelKey: "nav.brands" },
],
brands: [

View File

@ -1,8 +1,10 @@
import { describe, expect, it } from "vitest";
import {
checkThreadsTerm,
expandIncludeTerms,
isThreadsSearchable,
normalizeSearchTerm,
searchableTermVariants,
selectThreadsSearchTerms,
threadsPostIdentity,
} from "./threadsTerm";
@ -27,6 +29,19 @@ describe("threadsTerm", () => {
expect(checkThreadsTerm("")).toEqual({ ok: false, reason: "empty" });
});
it("shortens long CJK sentences into searchable windows", () => {
expect(searchableTermVariants("保母 求推薦")).toEqual(["保母 求推薦"]);
const variants = searchableTermVariants("晚上睡覺容易口乾舌燥怎麼辦");
expect(variants.length).toBeGreaterThan(0);
expect(variants.every(isThreadsSearchable)).toBe(true);
expect(variants).toContain("晚上睡覺");
expect(variants).toContain("口乾舌燥");
expect(searchableTermVariants("台北 婚攝 推薦 價格")[0]).toBe("台北 婚攝");
expect(expandIncludeTerms(["求推薦", "晚上睡覺容易口乾舌燥怎麼辦"])).toEqual(
expect.arrayContaining(["求推薦", "晚上睡覺", "口乾舌燥"]),
);
});
it("selects inferred work queries when the original has too many tokens", () => {
expect(
selectThreadsSearchTerms("外包 工程師 後端", [

View File

@ -74,6 +74,141 @@ export function isThreadsSearchable(term: string): boolean {
return checkThreadsTerm(term).ok;
}
const SEARCH_FILLERS = [
"怎麼辦",
"求推薦",
"有沒有人",
"有人知道",
"請問一下",
"請問",
"想問",
"想找",
"有沒有",
"可以嗎",
"好不好",
];
const MAX_VARIANTS = 8;
const MAX_VARIANTS_PER_TERM = 3;
function splitSearchParts(raw: string): string[] {
const parts: string[] = [];
let buf = "";
const flush = () => {
const value = buf.trim();
if (value) parts.push(value);
buf = "";
};
for (const ch of raw) {
if (/[0-9A-Za-z]/.test(ch) || isCJK(ch)) buf += ch;
else flush();
}
flush();
return parts;
}
function stripSearchFillers(value: string): string {
let next = value;
for (const filler of SEARCH_FILLERS) next = next.split(filler).join("");
return next.replace(/[ ,,、。!?!?:的了嗎呢啊喔唷]+$/g, "").trim();
}
/**
* 使 Threads domain.SearchableTermVariants
* token 24
*/
export function searchableTermVariants(raw: string, include = true): string[] {
const normalized = normalizeSearchTerm(raw);
if (!normalized) return [];
const seen = new Set<string>();
const out: string[] = [];
const add = (term: string) => {
const next = normalizeSearchTerm(term);
if (!next || seen.has(next.toLowerCase())) return;
if (include) {
if (!isThreadsSearchable(next)) return;
} else if ([...next].length < 2 || [...next].length > 60) {
return;
}
seen.add(next.toLowerCase());
out.push(next);
};
if (include && isThreadsSearchable(normalized)) {
add(normalized);
return out;
}
if (!include) {
add(normalized);
if (out.length) return out;
}
const tokens = normalized.split(/\s+/).filter(Boolean);
if (tokens.length > MAX_TOKENS) {
for (let i = 0; i + 1 < tokens.length && out.length < MAX_VARIANTS; i += 1) {
add(`${tokens[i]} ${tokens[i + 1]}`);
}
for (const tok of tokens) {
if (out.length >= MAX_VARIANTS) break;
for (const variant of searchableTermVariants(tok, include)) {
add(variant);
if (out.length >= MAX_VARIANTS) break;
}
}
return out;
}
const parts = splitSearchParts(normalized);
for (const part of parts.length ? parts : [normalized]) {
if (out.length >= MAX_VARIANTS) break;
if (include && isThreadsSearchable(part)) {
add(part);
continue;
}
const stripped = stripSearchFillers(part);
if (stripped && stripped !== part) add(stripped);
const runes = [...(stripped || part)];
if (runes.length === 5) {
add(runes.slice(0, 3).join(""));
add(runes.slice(3).join(""));
}
for (const width of [4, 3, 2]) {
if (runes.length < width) continue;
add(runes.slice(0, width).join(""));
if (runes.length > width) add(runes.slice(-width).join(""));
}
if (out.length >= 3) continue;
for (let width = 4; width >= 2; width -= 1) {
if (runes.length < width) continue;
for (let start = 0; start + width <= runes.length && out.length < MAX_VARIANTS; start += 1) {
add(runes.slice(start, start + width).join(""));
}
}
}
return out;
}
/** 訂閱關鍵字:已合規保留,長句各收成最多 3 則可搜短詞。 */
export function expandIncludeTerms(terms: string[], maxTotal = 20): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const raw of terms) {
const normalized = normalizeSearchTerm(raw);
if (!normalized) continue;
const variants = isThreadsSearchable(normalized)
? [normalized]
: searchableTermVariants(normalized).slice(0, MAX_VARIANTS_PER_TERM);
for (const term of variants) {
const key = term.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(term);
if (out.length >= maxTotal) return out;
}
}
return out;
}
/**
* fan-out
* 使

View File

@ -197,7 +197,7 @@ export function CrmBoardPage() {
<>
<PageHeader title={t("crm.board.title")} />
<div className="hb-radar-actions hb-radar-actions--toolbar">
<Link className="hb-btn hb-btn--secondary" to="/app/radar/today">
<Link className="hb-btn hb-btn--secondary" to="/app/radar">
{t("crm.board.link.today")}
</Link>
<Link className="hb-btn hb-btn--ghost" to="/app/crm/followups">
@ -262,7 +262,7 @@ export function CrmBoardPage() {
<EmptyState
title={t("crm.board.empty")}
description={t("crm.board.emptyHint")}
action={<Link className="hb-btn hb-btn--secondary" to="/app/radar/today">{t("crm.board.link.today")}</Link>}
action={<Link className="hb-btn hb-btn--secondary" to="/app/radar">{t("crm.board.link.today")}</Link>}
/>
) : (
<div className={`crm-workspace${detail ? " crm-workspace--detail" : ""}`}>

View File

@ -3,7 +3,7 @@ import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "../data/live/http";
import { KEYS } from "../data/mock/keys";
import type { Opportunity } from "../domain/types";
import type { Opportunity, RadarWatch } from "../domain/types";
import { I18nProvider } from "../i18n/I18nContext";
import { RadarOpportunitiesPage } from "./RadarOpportunitiesPage";
@ -12,7 +12,12 @@ const backend = vi.hoisted(() => ({
calls: [] as Array<Record<string, unknown>>,
accepted: [] as string[],
reviews: [] as Array<{ id: string; patch: Record<string, unknown> }>,
sweeps: [] as string[],
result: null as { list: Opportunity[]; total: number } | null,
byScope: null as null | ((filter: Record<string, unknown>) => { list: Opportunity[]; total: number }),
watches: [] as RadarWatch[],
jobResult: null as null | ((id: string) => { status: "succeeded" | "failed" | "running"; progress_summary: string; error?: string }),
lastSweptAt: Date.now() * 1e6,
}));
function opportunity(): Opportunity {
@ -49,12 +54,52 @@ vi.mock("../data/DataContext", () => ({
async listBrands() { return [{ id: "b1", display_name: "澄光品牌", brief: "" }]; },
async listProducts() { return [{ id: "p1", brand_id: "b1", label: "舒緩精華", product_context: "", match_tags: [], pain_points: [], provider_capability_terms: [], provider_exclude_terms: [], created_at: 1, updated_at: 1 }]; },
},
jobs: {
async get(id: string) {
const configured = backend.jobResult?.(id);
return {
id,
template_type: "radar_sweep",
status: configured?.status ?? "succeeded",
progress_summary: configured?.progress_summary ?? "雷達巡檢完成 · 新建 1 · 再次命中 0 · 判定 1 · 截斷 0",
error: configured?.error,
progress_percent: 100,
created_at: 1,
updated_at: 1,
};
},
},
radar: {
async listOpportunities(filter: Record<string, unknown>) {
backend.calls.push(filter);
if (backend.error) throw backend.error;
if (backend.byScope) return backend.byScope(filter);
return backend.result ?? { list: [opportunity()], total: 1 };
},
async listWatches() {
return {
list: backend.watches,
total: backend.watches.length,
active_count: backend.watches.filter((w) => w.status === "active").length,
max_active: 5,
profile_exists: true,
};
},
async getToday() {
return {
stats: { total: 0, high: 0, mid: 0, low: 0 },
high: [], mid: [], low: [],
truncated_count: 0,
last_swept_at: backend.lastSweptAt,
};
},
async triggerWatchSweep(id: string) {
backend.sweeps.push(id);
return { job_id: `job-${id}` };
},
async listSweeps() {
return { list: [], total: 0 };
},
async acceptOpportunity(id: string) {
backend.accepted.push(id);
return { opportunity_id: id, contact_id: "contact-buyer", status: "accepted" };
@ -69,9 +114,9 @@ vi.mock("../data/DataContext", () => ({
})(),
}));
function renderPage() {
function renderPage(path = "/app/radar/opportunities?brand_id=b1&product_id=p1&sort=posted") {
return render(
<MemoryRouter initialEntries={["/app/radar/opportunities?brand_id=b1&product_id=p1&sort=posted"]}>
<MemoryRouter initialEntries={[path]}>
<I18nProvider><RadarOpportunitiesPage /></I18nProvider>
</MemoryRouter>,
);
@ -83,10 +128,52 @@ beforeEach(() => {
backend.calls = [];
backend.accepted = [];
backend.reviews = [];
backend.sweeps = [];
backend.result = null;
backend.byScope = null;
backend.jobResult = null;
backend.lastSweptAt = Date.now() * 1e6;
backend.watches = [{
id: "w1",
terms: ["敏感肌"],
exclude_terms: [],
regions: [],
status: "active",
last_swept_at: backend.lastSweptAt,
created_at: 1,
updated_at: 1,
}];
});
describe("RadarOpportunitiesPage", () => {
it("shows scheduled patrol, last run, and run-now on the primary workplace", async () => {
renderPage("/app/radar");
expect(await screen.findByTestId("radar-patrol-desk")).toBeTruthy();
expect(screen.getByText("每日定時巡邏:開著")).toBeTruthy();
expect(screen.getByText(/上次巡邏:/)).toBeTruthy();
expect(screen.getByRole("button", { name: "立即巡邏" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "立即巡邏" }));
await waitFor(() => expect(backend.sweeps).toEqual(["w1"]));
});
it("runs every active watch and reports a real patrol failure", async () => {
backend.watches.push({
...backend.watches[0],
id: "w2",
terms: ["求推薦"],
});
backend.jobResult = (id) => id === "job-w2"
? { status: "failed", progress_summary: "今天沒巡到Chrome 登入已過期。" }
: { status: "succeeded", progress_summary: "雷達巡檢完成 · 新建 1" };
renderPage("/app/radar");
fireEvent.click(await screen.findByRole("button", { name: "立即巡邏" }));
await waitFor(() => expect(backend.sweeps).toEqual(["w1", "w2"]));
expect(await screen.findByRole("alert")).toHaveTextContent("今天沒巡到Chrome 登入已過期。");
expect(screen.queryByText("這一輪巡邏跑完了。找到的痛點會留在下面。")).toBeNull();
});
it("restores URL filters and removes product when brand is cleared", async () => {
renderPage();
expect(await screen.findByText("正在找適合敏感肌的日常修護產品")).toBeTruthy();
@ -109,37 +196,62 @@ describe("RadarOpportunitiesPage", () => {
await screen.findByText("正在找適合敏感肌的日常修護產品");
fireEvent.change(screen.getByRole("combobox", { name: "先看哪些" }), { target: { value: "newest" } });
fireEvent.change(screen.getByRole("combobox", { name: "看哪段時間" }), { target: { value: "7d" } });
fireEvent.click(screen.getByRole("button", { name: "已處理" }));
fireEvent.click(screen.getByRole("button", { name: "已看過" }));
await waitFor(() => expect(backend.calls.at(-1)).toMatchObject({ sort: "newest", time_scope: "7d", review_state: "completed" }));
});
it("explains a filtered empty result instead of rendering a blank page", async () => {
backend.result = { list: [], total: 0 };
renderPage();
expect(await screen.findByText("目前沒有待決定的商機")).toBeTruthy();
expect(screen.getByText("清除篩選或改看其他時間範圍。")).toBeTruthy();
expect(await screen.findByText("這個篩選下沒有結果")).toBeTruthy();
expect(screen.getAllByText(/近 7 天/).length).toBeGreaterThan(0);
});
it("把最重要的三個決策直接放在卡片,加入名單後提供下一步", async () => {
it("widens today to 7d when patrol hits are older posts", async () => {
const older = opportunity();
older.id = "older-hit";
older.text = "舊文但是這輪才判定";
backend.result = { list: [], total: 0 };
backend.byScope = (filter: Record<string, unknown>) => {
if (filter.time_scope === "7d" && filter.review_state === "pending") {
return { list: [older], total: 1 };
}
return { list: [], total: 0 };
};
renderPage("/app/radar");
expect(await screen.findByText("舊文但是這輪才判定")).toBeTruthy();
expect(await screen.findByText(/已改看近 7 天/)).toBeTruthy();
});
it("explains an unfiltered empty workplace in patrol language", async () => {
backend.result = { list: [], total: 0 };
renderPage("/app/radar");
expect(await screen.findByTestId("radar-patrol-desk")).toBeTruthy();
expect(await screen.findByText("這輪有巡,但沒找到符合的痛點")).toBeTruthy();
expect(screen.getAllByText(/近 7 天/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/全部/).length).toBeGreaterThan(0);
});
it("default actions are keep or discard; joining 名單 is optional", async () => {
renderPage();
await screen.findByText("正在找適合敏感肌的日常修護產品");
expect(screen.getByRole("button", { name: "加入名單並追蹤" })).toBeTruthy();
expect(screen.getByRole("button", { name: "只標示已處理" })).toBeTruthy();
expect(screen.getByRole("button", { name: "不適合" })).toBeTruthy();
expect(screen.getByRole("button", { name: "留下" })).toBeTruthy();
expect(screen.getByRole("button", { name: "丟掉" })).toBeTruthy();
expect(screen.getByRole("button", { name: "加入名單(可選)" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "加入名單並追蹤" }));
await screen.findByText("已加入名單並移到已處理。接下來可到名單安排追蹤、備註或回報成交。");
fireEvent.click(screen.getByRole("button", { name: "加入名單(可選)" }));
await screen.findByText("已加入名單。這步是可選的,之後要追蹤再去名單即可。");
expect(backend.accepted).toEqual(["opp-archive"]);
expect(screen.getByRole("link", { name: "現在前往名單" }).getAttribute("href")).toBe("/app/crm?contact=contact-buyer");
expect(screen.getByRole("link", { name: "前往名單" }).getAttribute("href")).toBe("/app/crm?contact=contact-buyer");
});
it("只標示已處理不會誤建名單", async () => {
it("留下不會誤建名單", async () => {
renderPage();
await screen.findByText("正在找適合敏感肌的日常修護產品");
fireEvent.click(screen.getByRole("button", { name: "只標示已處理" }));
fireEvent.click(screen.getByRole("button", { name: "留下" }));
await screen.findByText("已標示為已處理;沒有建立聯絡人名單。");
await screen.findByText("已留下。沒有建立名單。");
expect(backend.accepted).toEqual([]);
expect(backend.reviews).toEqual([{ id: "opp-archive", patch: { state: "completed" } }]);
});

View File

@ -1,12 +1,14 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { PageHeader } from "../components/layout/PageHeader";
import { OpportunityInboxCard } from "../components/radar/OpportunityInboxCard";
import { OpportunityDetailDrawer } from "../components/radar/OpportunityDetailDrawer";
import { SweepFunnelSummary } from "../components/radar/SweepFunnelSummary";
import { Button, EmptyState, Select } from "../components/ui";
import { useRepos } from "../data/DataContext";
import type { Brand, BrandProduct, Opportunity, OpportunityRemovalReason, OpportunityReviewState, OpportunityTimeScope } from "../domain/types";
import type { Brand, BrandProduct, JobStatus, Opportunity, OpportunityRemovalReason, OpportunityReviewState, OpportunityTimeScope, RadarSweep, RadarToday, RadarWatch } from "../domain/types";
import { useFormatApiError } from "../lib/apiErrors";
import { formatLocalDateTime } from "../lib/time";
import "../styles/radar.css";
const PAGE_SIZE = 20;
@ -38,6 +40,12 @@ export function RadarOpportunitiesPage() {
const [products, setProducts] = useState<BrandProduct[]>([]);
const [selected, setSelected] = useState<Opportunity | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [sweeping, setSweeping] = useState(false);
const [lastSweep, setLastSweep] = useState<RadarSweep | null>(null);
const [watches, setWatches] = useState<RadarWatch[]>([]);
const [activeCount, setActiveCount] = useState(0);
const [watchTotal, setWatchTotal] = useState(0);
const [todayMeta, setTodayMeta] = useState<RadarToday | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(() => Boolean(params.get("band") || params.get("match_state") || params.get("brand_id") || params.get("product_id")));
const scout = (repos as unknown as { scout?: { listBrands: () => Promise<Brand[]>; listProducts: (id: string) => Promise<BrandProduct[]> } }).scout;
@ -53,20 +61,70 @@ export function RadarOpportunitiesPage() {
void scout.listProducts(brandId).then(setProducts).catch(() => setProducts([]));
}, [scout, brandId]);
const loadPatrol = useCallback(async () => {
const [watchRes, today] = await Promise.all([
repos.radar.listWatches(1, 50).catch(() => null),
repos.radar.getToday().catch(() => null),
]);
if (watchRes) {
setWatches(watchRes.list);
setActiveCount(watchRes.active_count);
setWatchTotal(watchRes.total);
}
setTodayMeta(today);
if (typeof repos.radar.listSweeps === "function") {
const sweeps = await repos.radar.listSweeps(1, 1).catch(() => null);
setLastSweep(sweeps?.list[0] ?? null);
}
}, [repos.radar]);
const load = useCallback(async () => {
setLoading(true);
try {
const result = await repos.radar.listOpportunities({
page,
const query = (scope: OpportunityTimeScope, review: OpportunityReviewState = reviewState) =>
repos.radar.listOpportunities({
page: scope === timeScope ? page : 1,
pageSize: PAGE_SIZE,
band: band || undefined,
match_state: state || undefined,
brand_id: brandId || undefined,
product_id: productId || undefined,
review_state: reviewState,
time_scope: timeScope,
review_state: review,
time_scope: scope,
sort,
});
try {
let result = await query(timeScope);
const canWiden = reviewState === "pending" && timeScope === "today" && !band && !state && !brandId && !productId;
if (canWiden && result.total === 0) {
const week = await query("7d");
if (week.total > 0) {
setTimeScope("7d");
setPage(1);
const next = new URLSearchParams(typeof window === "undefined" ? "" : window.location.search);
next.set("time_scope", "7d");
next.delete("page");
setParams(next, { replace: true });
result = week;
setNotice({ text: `巡邏結果不是都在「今天發的文」。已改看近 7 天(${week.total} 筆)。任務上的判定/新建數字包含同一篇再命中,不一定全是新卡片。` });
} else {
const all = await query("all");
if (all.total > 0) {
setTimeScope("all");
setPage(1);
const next = new URLSearchParams(typeof window === "undefined" ? "" : window.location.search);
next.set("time_scope", "all");
next.delete("page");
setParams(next, { replace: true });
result = all;
setNotice({ text: `近 7 天沒有待處理結果,已改看全部(${all.total} 筆)。` });
} else {
const seen = await query("7d", "completed");
if (seen.total > 0) {
setNotice({ text: `這輪判定到的 ${seen.total} 筆已在「已看過」,所以「新找到」是空的。任務數字含再次命中的舊文。` });
}
}
}
}
setList(result.list);
setTotal(result.total);
setError("");
@ -75,9 +133,10 @@ export function RadarOpportunitiesPage() {
} finally {
setLoading(false);
}
}, [repos.radar, page, band, state, brandId, productId, sort, reviewState, timeScope, formatError]);
}, [repos.radar, page, band, state, brandId, productId, sort, reviewState, timeScope, formatError, setParams]);
useEffect(() => { void load(); }, [load]);
useEffect(() => { void loadPatrol(); }, [loadPatrol]);
async function updateReviewState(
opportunity: Opportunity,
@ -108,7 +167,7 @@ export function RadarOpportunitiesPage() {
await load();
setError("");
setNotice({
text: "已加入名單並移到已處理。接下來可到名單安排追蹤、備註或回報成交。",
text: "已加入名單。這步是可選的,之後要追蹤再去名單即可。",
contactId: result.contact_id,
});
} catch (e) {
@ -118,6 +177,84 @@ export function RadarOpportunitiesPage() {
}
}
async function waitForJob(jobId: string): Promise<{ status: JobStatus; progress_summary: string; error: string; timedOut: boolean }> {
const deadline = Date.now() + 120_000;
let last: { status: JobStatus; progress_summary: string; error: string } = {
status: "queued",
progress_summary: "立即巡邏已排程 · 等待 worker",
error: "",
};
while (Date.now() < deadline) {
const job = await repos.jobs.get(jobId);
if (job) {
last = { status: job.status, progress_summary: job.progress_summary, error: job.error || "" };
if (job.status === "succeeded" || job.status === "failed" || job.status === "cancelled") {
return { ...last, timedOut: false };
}
}
await new Promise((resolve) => window.setTimeout(resolve, 1500));
}
return { ...last, timedOut: true };
}
async function runNow() {
const active = watches.filter((watch) => watch.status === "active");
if (!active.length) {
setError("沒有開著的每日巡邏。先設定要巡的產品與關鍵字,或恢復一組訂閱。");
return;
}
setSweeping(true);
setError("");
setNotice(null);
try {
const jobIds: string[] = [];
for (const watch of active) {
const res = await repos.radar.triggerWatchSweep(watch.id);
if (res.job_id) jobIds.push(res.job_id);
}
if (!jobIds.length) {
setError("沒有排到巡邏任務。");
return;
}
setNotice({ text: "立即巡邏進行中… 跑完才會把痛點列在下面。" });
// 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)));
const failed = finished.find((job) => job.status === "failed");
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 || "巡邏失敗。"
: cancelled
? "巡邏已取消,不會誤顯示為已完成。可再按一次立即巡邏。"
: "";
if (typeof repos.radar.listSweeps === "function") {
const sweeps = await repos.radar.listSweeps(1, 1).catch(() => null);
setLastSweep(sweeps?.list[0] ?? null);
}
await Promise.all([load(), loadPatrol()]);
const summary = finished.map((job) => job.progress_summary).filter(Boolean).join(" ");
// load() clears stale list errors on a successful refresh. Re-apply the
// job outcome afterwards so the actual patrol failure stays visible.
if (terminalError) {
setError(terminalError);
setNotice(null);
} else if (running.length > 0) {
setNotice({ text: `已排入 ${jobIds.length} 組巡邏,目前仍在後台執行。可先離開這頁,完成後結果會留在這裡。` });
} else {
setNotice({
text: summary.includes("新建")
? `${summary} 不是今天發的文也會留在下面。`
: "這一輪巡邏跑完了。找到的痛點會留在下面。",
});
}
} catch (e) {
setError(formatError(e));
} finally {
setSweeping(false);
}
}
function writeParams(changes: Record<string, string>) {
const next = new URLSearchParams(params);
for (const [name, value] of Object.entries(changes)) {
@ -137,60 +274,144 @@ export function RadarOpportunitiesPage() {
setAdvancedOpen(false);
setPage(1);
const next = new URLSearchParams();
next.set("review_state", reviewState);
next.set("time_scope", "today");
if (reviewState !== "pending") next.set("review_state", reviewState);
setParams(next, { replace: true });
}
const lastSweptAt = Math.max(
todayMeta?.last_swept_at ?? 0,
...watches.map((watch) => watch.last_swept_at ?? 0),
);
const scheduledOn = activeCount > 0;
const advancedFilterCount = [band, state, brandId, productId].filter(Boolean).length;
const filtered = Boolean(advancedFilterCount || sort !== "recommended" || timeScope !== "today");
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
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>,
};
}
if (reviewState === "completed") {
return { title: "還沒有已看過的結果", description: "切回「新找到」繼續看巡邏到的痛點。" };
}
if (reviewState === "removed") {
return { title: "還沒有丟掉的結果", description: "切回「新找到」繼續看巡邏到的痛點。" };
}
if (watchTotal === 0) {
return {
title: "還沒設定巡邏",
description: "先選產品與客人會搜的關鍵字。設好後可立即巡邏,每日定時巡邏也會接著跑。",
action: <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches"></Link>,
};
}
if (!scheduledOn) {
return {
title: "每日定時巡邏關著",
description: "立即巡邏與每日定時都還在這個頁面。恢復至少一組訂閱後,兩個都能用;關掉其中一個不會藏掉另一個。",
action: <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches"></Link>,
};
}
if (!lastSweptAt) {
return {
title: "還沒巡邏過",
description: "每日定時巡邏已開著,也可現在按「立即巡邏」。不是空白收件匣,只是第一輪還沒跑完。",
action: <Button type="button" onClick={() => void runNow()} disabled={sweeping}>{sweeping ? "巡邏中…" : "立即巡邏"}</Button>,
};
}
if (todayMeta?.empty_reason === "sweep_failed") {
const crawlerDead = /crawler session|Chrome crawler|Chrome 登入已過期/i.test(
`${todayMeta.empty_hint || ""} ${lastSweep?.failed_reason || ""}`,
);
return {
title: "上一輪巡邏沒跑完",
description: todayMeta.empty_hint || "巡邏失敗。可再按立即巡邏,或改看近 7 天/全部。",
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>
</>
),
};
}
if (lastSweep && lastSweep.hit_count === 0) {
return {
title: "搜尋沒撈到貼文",
description: "門檻前就空了:關鍵字太長、太產品名、或 Threads 查無結果。改成客人會打的 24 字痛點詞再巡。",
action: <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches"></Link>,
};
}
return {
title: "這輪有巡,但沒找到符合的痛點",
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>,
};
}
const empty = emptyCopy();
return (
<>
<PageHeader title="商機收件匣" />
<PageHeader title="商機" />
<section className="hb-radar-patrol" data-testid="radar-patrol-desk" aria-label="巡邏狀態">
<div className="hb-radar-patrol__status">
<p>
<strong>{scheduledOn ? "每日定時巡邏:開著" : "每日定時巡邏:關著"}</strong>
<span> 06:00 </span>
</p>
<p>
<strong>{lastSweptAt ? `上次巡邏:${formatLocalDateTime(lastSweptAt)}` : "還沒巡邏過"}</strong>
<span>{scheduledOn ? `啟用中 ${activeCount}` : watchTotal ? "訂閱都暫停了,立即巡邏也需要至少一組開著" : "還沒設定要巡的產品與關鍵字"}</span>
</p>
</div>
<div className="hb-radar-patrol__actions">
<Button type="button" onClick={() => void runNow()} disabled={sweeping || !scheduledOn}>
{sweeping ? "巡邏中…" : "立即巡邏"}
</Button>
<Link className="hb-btn hb-btn--ghost" to="/app/radar/watches"></Link>
<small>Chrome API provider </small>
</div>
</section>
{lastSweep ? <SweepFunnelSummary sweep={lastSweep} /> : null}
<section className="hb-radar-intro">
<div>
<strong></strong>
<p></p>
<strong></strong>
<p></p>
</div>
<nav className="hb-radar-intro__actions" aria-label="商機結果導覽">
<Link className="hb-btn hb-btn--ghost" to="/app/radar/watches"></Link>
</nav>
</section>
<ol className="hb-inbox-decision-guide" aria-label="商機收件匣操作方式">
<li><span>1</span><div><strong></strong><small></small></div></li>
<li><span>2</span><div><strong></strong><small></small></div></li>
<li><span>3</span><div><strong></strong><small></small></div></li>
</ol>
<section className="hb-radar-filter-panel" aria-label="篩選商機收件匣">
<section className="hb-radar-filter-panel" aria-label="商機結果">
<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="結果狀態">
{(["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, page: "" });
setReviewState(value); setPage(1); writeParams({ review_state: value === "pending" ? "" : value, page: "" });
}}>
{value === "pending" ? "待決定" : value === "completed" ? "已處理" : "已移除"}
{value === "pending" ? "新找到" : value === "completed" ? "已看過" : "已丟掉"}
</Button>
))}
<span> {total} </span>
</div>
{filtered ? <Button type="button" variant="ghost" onClick={resetFilters}></Button> : <span></span>}
{filtered ? <Button type="button" variant="ghost" onClick={resetFilters}></Button> : <span></span>}
</div>
<div className="hb-radar-inbox-essential-filters">
<Select name="all-time-scope" label="看哪段時間" value={timeScope} onChange={(e) => {
const value = e.target.value as OpportunityTimeScope;
setTimeScope(value); setPage(1); writeParams({ time_scope: value, page: "" });
setTimeScope(value); setPage(1); writeParams({ time_scope: value === "today" ? "" : value, page: "" });
}}>
<option value="today"></option><option value="7d"> 7 </option><option value="all"></option>
</Select>
<Select name="all-sort" label="先看哪些" 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"></option><option value="newest"></option><option value="oldest"></option><option value="product_fit"></option><option value="demand_intent"></option>
</Select>
<Button type="button" variant="ghost" aria-expanded={advancedOpen} onClick={() => setAdvancedOpen((value) => !value)}>
{advancedOpen ? "收起更多篩選" : `更多篩選${advancedFilterCount ? `${advancedFilterCount}` : ""}`}
@ -230,14 +451,10 @@ export function RadarOpportunitiesPage() {
</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)}`}></Link> : null}</div> : null}
{loading ? <p className="hb-radar-section__hint" role="status"></p> : null}
{!loading && !error && !list.length ? (
<EmptyState
title={reviewState === "pending" ? "目前沒有待決定的商機" : reviewState === "completed" ? "目前沒有已處理的商機" : "目前沒有已移除的商機"}
description={filtered ? "清除篩選或改看其他時間範圍。" : reviewState === "pending" ? "等待下一輪巡邏,或先建立產品巡邏。" : "切換到「待決定」繼續處理商機。"}
action={filtered ? <Button type="button" variant="ghost" onClick={resetFilters}></Button> : reviewState === "pending" ? <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches"></Link> : undefined}
/>
<EmptyState title={empty.title} description={empty.description} action={empty.action} />
) : null}
{!error ? (
@ -248,9 +465,9 @@ 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" }, "已留下。沒有建立名單。")}
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" }, "已還原。")}
/>)}
{total > PAGE_SIZE ? (
<div className="hb-radar-pager">
@ -266,7 +483,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" }, "已留下。沒有建立名單。")}
/> : null}
</>
);

View File

@ -175,29 +175,29 @@ describe("RadarWatchesPage", () => {
renderPage();
await screen.findByText(t("radar.watches.empty"));
await createWatch("推薦室內設計\n找設計師");
await createWatch("室內設計\n找設計師");
await screen.findByText(t("radar.watches.created"));
expect(within(row("推薦室內設計")).getByText(t("radar.watches.status.active"))).toBeTruthy();
expect(within(row("室內設計")).getByText(t("radar.watches.status.active"))).toBeTruthy();
expect(screen.getByText(t("radar.watches.quota", { used: 1, max: 5 }))).toBeTruthy();
fireEvent.click(within(row("推薦室內設計")).getByRole("button", { name: t("radar.watches.pause") }));
fireEvent.click(within(row("室內設計")).getByRole("button", { name: t("radar.watches.pause") }));
await screen.findByText(t("radar.watches.paused"));
expect(within(row("推薦室內設計")).getByText(t("radar.watches.status.paused"))).toBeTruthy();
expect(within(row("室內設計")).getByText(t("radar.watches.status.paused"))).toBeTruthy();
expect(screen.getByText(t("radar.watches.quota", { used: 0, max: 5 }))).toBeTruthy();
fireEvent.click(within(row("推薦室內設計")).getByRole("button", { name: t("radar.watches.resume") }));
fireEvent.click(within(row("室內設計")).getByRole("button", { name: t("radar.watches.resume") }));
await screen.findByText(t("radar.watches.resumed"));
expect(within(row("推薦室內設計")).getByText(t("radar.watches.status.active"))).toBeTruthy();
expect(within(row("室內設計")).getByText(t("radar.watches.status.active"))).toBeTruthy();
fireEvent.click(within(row("推薦室內設計")).getByRole("button", { name: t("radar.watches.archive") }));
fireEvent.click(within(row("室內設計")).getByRole("button", { name: t("radar.watches.archive") }));
await screen.findByText(t("radar.watches.archived"));
const archived = row("推薦室內設計");
const archived = row("室內設計");
expect(within(archived).getByText(t("radar.watches.status.archived"))).toBeTruthy();
// 封存後只能刪除設定;既有歷史資料不會被這個動作連帶刪除。
const deleteButton = within(archived).getByRole("button", { name: t("radar.watches.deleteArchived") });
fireEvent.click(deleteButton);
await screen.findByText(t("radar.watches.deletedArchived"));
expect(screen.queryByText("推薦室內設計")).toBeNull();
expect(screen.queryByText("室內設計")).toBeNull();
});
it("配額滿時照後端訊息說上限與升級,表單留著讓人改", async () => {
@ -205,7 +205,7 @@ describe("RadarWatchesPage", () => {
renderPage();
await screen.findByText(t("radar.watches.empty"));
await createWatch("推薦室內設計");
await createWatch("室內設計");
await screen.findByText(t("radar.watches.created"));
expect(screen.getByText(t("radar.watches.quotaFull"), { exact: false })).toBeTruthy();
@ -218,7 +218,7 @@ describe("RadarWatchesPage", () => {
it("關鍵字建議可逐條採用include 進關鍵字、exclude 進排除詞", async () => {
backend.suggestions = [
{ term: "推薦室內設計", reason: "客人找設計師時最常這樣問", usage: "include" },
{ term: "室內設計", reason: "客人找設計師時最常這樣問", usage: "include" },
{ term: "徵才", reason: "這類是招募文,不是客人", usage: "exclude" },
];
renderPage();
@ -233,7 +233,7 @@ describe("RadarWatchesPage", () => {
expect(
(screen.getByLabelText(t("radar.watches.terms"), { exact: false }) as HTMLTextAreaElement).value,
).toBe("推薦室內設計");
).toBe("室內設計");
expect(
(screen.getByLabelText(t("radar.watches.excludeTerms"), { exact: false }) as HTMLTextAreaElement)
.value,
@ -259,4 +259,13 @@ describe("RadarWatchesPage", () => {
expect(banner.textContent).toContain("service profile required before suggesting watch terms");
expect(screen.queryByText(t("radar.suggest.none"))).toBeNull();
});
it("長句關鍵字儲存時收成可搜短詞", async () => {
renderPage();
await screen.findByText(t("radar.watches.empty"));
await createWatch("晚上睡覺容易口乾舌燥怎麼辦");
await screen.findByText(t("radar.watches.created"));
expect(screen.getByText("晚上睡覺")).toBeTruthy();
expect(screen.getByText("口乾舌燥")).toBeTruthy();
});
});

View File

@ -9,7 +9,8 @@ import type { Brand, BrandProduct, RadarSweep, RadarWatch, RadarWatchStatus, Wat
import type { DemandMap } from "../domain/types";
import { useI18n } from "../i18n/I18nContext";
import { useFormatApiError } from "../lib/apiErrors";
import { isThreadsSearchable } from "../lib/threadsTerm";
import { expandIncludeTerms, isThreadsSearchable } from "../lib/threadsTerm";
import { buildQueryPlan } from "../components/radar/QueryPlanPreview";
import { formatLocalDateTime } from "../lib/time";
import { ProductWatchForm } from "../components/radar/ProductWatchForm";
import { DemandMapEditor, type DemandMapPatch } from "../components/radar/DemandMapEditor";
@ -96,6 +97,16 @@ export function RadarWatchesPage() {
return () => { alive = false; };
}, [draft.productId, productContextAvailable, repos.radar, formatError]);
useEffect(() => {
if (!formOpen || draft.id || !demandMap || demandMap.state !== "ready") return;
setDraft((d) => {
if (splitTerms(d.terms).length > 0) return d;
const seeded = expandIncludeTerms(buildQueryPlan(demandMap).map((group) => group.terms.join(" ")));
if (!seeded.length) return d;
return { ...d, terms: seeded.join("\n") };
});
}, [demandMap, formOpen, draft.id]);
useEffect(() => {
if (!scout) return;
void scout.listBrands().then(setBrands).catch(() => setBrands([]));
@ -167,9 +178,14 @@ export function RadarWatchesPage() {
function adopt(s: WatchTermSuggestion) {
setDraft((d) => {
const key = s.usage === "exclude" ? "excludeTerms" : "terms";
const incoming = s.usage === "exclude" ? [s.term] : expandIncludeTerms([s.term]);
const current = splitTerms(d[key]);
if (current.some((x) => x.toLowerCase() === s.term.toLowerCase())) return d;
return { ...d, [key]: [...current, s.term].join("\n") };
const next = [...current];
for (const term of incoming) {
if (!next.some((x) => x.toLowerCase() === term.toLowerCase())) next.push(term);
}
if (next.length === current.length) return d;
return { ...d, [key]: next.join("\n") };
});
}
@ -177,6 +193,19 @@ export function RadarWatchesPage() {
for (const s of list) adopt(s);
}
function adoptQueries(queries: string[]) {
setDraft((d) => {
const incoming = expandIncludeTerms(queries);
const current = splitTerms(d.terms);
const next = [...current];
for (const term of incoming) {
if (!next.some((x) => x.toLowerCase() === term.toLowerCase())) next.push(term);
}
if (next.length === current.length) return d;
return { ...d, terms: next.join("\n") };
});
}
/** 一次動作 → 重讀清單:狀態變化會連動配額與 profile 提示,局部改 state 容易對不起來。 */
async function run(
key: string,
@ -201,8 +230,12 @@ export function RadarWatchesPage() {
}
async function save() {
const terms = splitTerms(draft.terms);
const terms = expandIncludeTerms(splitTerms(draft.terms));
const excludeTerms = splitTerms(draft.excludeTerms);
if (!terms.length) {
setError(t("radar.watches.threadsRequired"));
return;
}
if (!draft.id && productContextAvailable && (!draft.brandId || !draft.productId)) {
setError("請先選擇品牌與產品,產品型雷達才能啟用。" );
return;
@ -308,7 +341,7 @@ export function RadarWatchesPage() {
{justTriggeredFirstSweep ? (
<>
{" "}
<Link to="/app/radar/today">{t("today.radar.open")}</Link>
<Link to="/app/radar">{t("today.radar.open")}</Link>
</>
) : null}
</p>
@ -326,7 +359,7 @@ export function RadarWatchesPage() {
<strong>{t("radar.watches.scheduleTitle")}</strong>
<p>{t("radar.watches.scheduleHint")}</p>
</div>
<Link className="hb-btn hb-btn--ghost" to="/app/radar/opportunities?review_state=pending&time_scope=today">
<Link className="hb-btn hb-btn--ghost" to="/app/radar">
{t("radar.watches.openToday")}
</Link>
</div>
@ -366,7 +399,7 @@ export function RadarWatchesPage() {
brandId={draft.brandId}
productId={draft.productId}
onBrandChange={(brandId) => setDraft((d) => ({ ...d, brandId, productId: "" }))}
onProductChange={(productId) => setDraft((d) => ({ ...d, productId }))}
onProductChange={(productId) => setDraft((d) => ({ ...d, productId, terms: d.id ? d.terms : "" }))}
/>
) : null}
{productContextAvailable && draft.productId ? (() => {
@ -449,9 +482,10 @@ export function RadarWatchesPage() {
<WatchSuggestPanel
onAdopt={adopt}
onAdoptAll={adoptAll}
onAdoptQueries={adoptQueries}
adopted={[...splitTerms(draft.terms), ...splitTerms(draft.excludeTerms)]}
context={draft.brandId && draft.productId ? { brand_id: draft.brandId, product_id: draft.productId } : undefined}
demandMap={demandMap ?? undefined}
demandMap={pendingDemandMapPatch && demandMap ? { ...demandMap, ...pendingDemandMapPatch } : demandMap ?? undefined}
/>
<div className="hb-radar-actions">
@ -472,12 +506,12 @@ export function RadarWatchesPage() {
{loading ? (
<p className="text-muted">{t("common.loading")}</p>
) : error ? null : watches.length === 0 ? (
) : watches.length === 0 && !error ? (
<EmptyState
title={statusFilter ? t("radar.watches.emptyFiltered") : t("radar.watches.empty")}
description={statusFilter ? undefined : t("radar.watches.emptyHint")}
/>
) : (
) : watches.length === 0 ? null : (
<>
<div className="hb-radar-watch-list">
{watches.map((w) => (

View File

@ -356,7 +356,7 @@ export function TodayPage() {
{
key: "opportunity",
done: onboardingOpportunityDone,
to: "/app/radar/today",
to: "/app/radar",
label: t("today.onboarding.step.opportunity"),
hint: t("today.onboarding.step.opportunityHint"),
},
@ -408,7 +408,7 @@ export function TodayPage() {
<span className="hb-today-metric__value">{radarToday.stats.low}</span>
</div>
</div>
<Link className="hb-btn hb-btn--secondary" to="/app/radar/today">
<Link className="hb-btn hb-btn--secondary" to="/app/radar">
{t("today.radar.open")}
</Link>
</>

View File

@ -38,6 +38,52 @@
color: var(--hb-muted);
}
.hb-radar-patrol {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: var(--hb-space-4);
margin-bottom: var(--hb-space-5);
padding: var(--hb-space-4);
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg);
background: var(--hb-surface);
}
.hb-radar-patrol__status {
display: flex;
flex-direction: column;
gap: var(--hb-space-2);
min-width: 0;
}
.hb-radar-patrol__status p {
display: flex;
flex-direction: column;
gap: 0.15rem;
margin: 0;
}
.hb-radar-patrol__status span {
font-size: var(--hb-text-sm);
color: var(--hb-muted);
}
.hb-radar-patrol__actions {
display: flex;
flex-wrap: wrap;
gap: var(--hb-space-2);
max-width: 25rem;
}
.hb-radar-patrol__actions small {
flex-basis: 100%;
color: var(--hb-muted);
font-size: var(--hb-text-xs);
line-height: 1.5;
}
/** 空狀態一律要說「為什麼空」與「下一步做什麼」,不是只寫「沒有資料」。 */
.hb-radar-empty {
display: flex;