fix all bug

This commit is contained in:
王性驊 2026-08-27 05:35:07 +00:00
parent 2f1336b932
commit e4569d1c2a
41 changed files with 1196 additions and 456 deletions

View File

@ -164,6 +164,9 @@ func indexModels() map[string][]mongo.IndexModel {
// demand-radar. Names and specs match migration 000014 exactly so both paths are
// idempotent; the two unique keys (radar_opportunities.owner_opportunity_external and
// crm_contacts.owner_contact_identity) are intentionally absent and owned by that migration.
"radar_schedules": {
{Keys: bson.D{{Key: "updated_at", Value: 1}}, Options: options.Index().SetName("schedule_updated")},
},
"radar_watches": {
{Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "status", Value: 1}}, Options: options.Index().SetName("owner_watches_status")},
ownerIndex("created_at", "owner_watches_created"),

View File

@ -323,7 +323,7 @@ func runMaintenance(
} else if purged > 0 {
logx.Infof("worker %s purged %d expired terminal job(s)", workerID, purged)
}
// 雷達每日排程UTC 22:00 之後為每個 active watch 建一筆 radar_sweep同日去重
// 雷達排程:為每個 active watch 補齊今天(台北)已到期的時段 Job
if n, err := radarSvc.ScheduleDailySweeps(ctx, time.Now().UTC()); err != nil {
logx.Errorf("worker %s radar daily schedule: %v", workerID, err)
} else if n > 0 {

View File

@ -115,24 +115,34 @@ async function readPosts(page: Page, query: string, limit: number): Promise<Post
if (seen.has(key)) continue;
seen.add(key);
// 找小範圍卡片:往上最多 8 層,取文字長度 20800 的最近祖先
const flatten = (s: string) => s.replace(/\s+/g, " ").trim();
const keepBreaks = (s: string) =>
s
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.split("\n")
.map((line) => line.replace(/[ \t\u00a0\u3000]+/g, " ").trim())
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
// 找小範圍卡片:往上最多 8 層。長度用壓平後的字數判斷,正文保留換行。
let el: HTMLElement | null = a;
let best = "";
for (let depth = 0; depth < 8 && el; depth++) {
const t = (el.innerText || "").replace(/\s+/g, " ").trim();
const raw = el.innerText || "";
const t = flatten(raw);
if (t.length >= 20 && t.length <= 1200) {
best = t;
// 再往上若突然暴衝(整欄 feed就停在 best
best = keepBreaks(raw);
const parent = el.parentElement;
if (parent) {
const pt = (parent.innerText || "").replace(/\s+/g, " ").trim();
const pt = flatten(parent.innerText || "");
if (pt.length > t.length * 3 && pt.length > 1500) break;
}
}
el = el.parentElement;
}
if (best.length < 12) {
best = (a.innerText || "").replace(/\s+/g, " ").trim();
if (flatten(best).length < 12) {
best = keepBreaks(a.innerText || "");
}
if (best.length < 8) continue;
const author = href.match(/@([^/]+)\/post/)?.[1] || "";

View File

@ -95,6 +95,17 @@ type (
ProductId string `json:"product_id,optional"`
}
// Hours are 023 in Asia/Taipei. Empty GET means default [6] (台北 06:00).
// int64go-zero JSON 對 []int 常解不出來,會變成 400001。
RadarSchedulePublic {
Hours []int64 `json:"hours"`
Timezone string `json:"timezone"`
}
PutRadarScheduleReq {
Hours []int64 `json:"hours,optional"`
}
UpdateWatchReq {
Id string `path:"id"`
Terms []string `json:"terms,optional"`
@ -545,6 +556,12 @@ service gateway {
@handler UpsertServiceProfile
put /service-profile (UpsertServiceProfileReq) returns (ServiceProfilePublic)
@handler GetRadarSchedule
get /schedule returns (RadarSchedulePublic)
@handler PutRadarSchedule
put /schedule (PutRadarScheduleReq) returns (RadarSchedulePublic)
@handler ListWatches
get /watches (ListWatchesReq) returns (WatchListData)

View File

@ -0,0 +1,20 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package radar
import (
"net/http"
"apps/backend/internal/logic/radar"
"apps/backend/internal/response"
"apps/backend/internal/svc"
)
func GetRadarScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := radar.NewGetRadarScheduleLogic(r.Context(), svcCtx)
data, err := l.GetRadarSchedule()
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package radar
import (
"net/http"
"apps/backend/internal/logic/radar"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func PutRadarScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PutRadarScheduleReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := radar.NewPutRadarScheduleLogic(r.Context(), svcCtx)
data, err := l.PutRadarSchedule(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -623,11 +623,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
[]rest.Route{
{
Method: http.MethodPost,
Path: "/upload",
Handler: media.UploadHandler(serverCtx),
Path: "/generate-image",
Handler: media.GenerateImageHandler(serverCtx),
},
}...,
),
rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
rest.WithPrefix("/api/v1/media"),
)
@ -637,12 +638,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
[]rest.Route{
{
Method: http.MethodPost,
Path: "/generate-image",
Handler: media.GenerateImageHandler(serverCtx),
Path: "/upload",
Handler: media.UploadHandler(serverCtx),
},
}...,
),
rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
rest.WithPrefix("/api/v1/media"),
)
@ -1126,6 +1126,16 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/products/:productId/demand-map/enrich",
Handler: radar.EnrichDemandMapHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/schedule",
Handler: radar.GetRadarScheduleHandler(serverCtx),
},
{
Method: http.MethodPut,
Path: "/schedule",
Handler: radar.PutRadarScheduleHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/service-profile",

View File

@ -0,0 +1,40 @@
package radar
import (
"context"
radarDomain "apps/backend/internal/module/radar/domain"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type GetRadarScheduleLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetRadarScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetRadarScheduleLogic {
return &GetRadarScheduleLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetRadarScheduleLogic) GetRadarSchedule() (resp *types.RadarSchedulePublic, err error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
row, err := l.svcCtx.Radar.GetRadarSchedule(l.ctx, uid)
if err != nil {
return nil, err
}
return &types.RadarSchedulePublic{
Hours: int64Hours(row.Hours),
Timezone: radarDomain.ScheduleTimezone,
}, nil
}

View File

@ -20,3 +20,19 @@ func ownerUID(ctx context.Context) (int64, error) {
}
return uid, nil
}
func intHours(in []int64) []int {
out := make([]int, 0, len(in))
for _, h := range in {
out = append(out, int(h))
}
return out
}
func int64Hours(in []int) []int64 {
out := make([]int64, 0, len(in))
for _, h := range in {
out = append(out, int64(h))
}
return out
}

View File

@ -0,0 +1,44 @@
package radar
import (
"context"
radarDomain "apps/backend/internal/module/radar/domain"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type PutRadarScheduleLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewPutRadarScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PutRadarScheduleLogic {
return &PutRadarScheduleLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *PutRadarScheduleLogic) PutRadarSchedule(req *types.PutRadarScheduleReq) (resp *types.RadarSchedulePublic, err error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
hours := []int{}
if req != nil {
hours = intHours(req.Hours)
}
row, err := l.svcCtx.Radar.PutRadarSchedule(l.ctx, uid, hours)
if err != nil {
return nil, err
}
return &types.RadarSchedulePublic{
Hours: int64Hours(row.Hours),
Timezone: radarDomain.ScheduleTimezone,
}, nil
}

View File

@ -0,0 +1,22 @@
package radar
import (
"encoding/json"
"testing"
"apps/backend/internal/types"
)
func TestPutRadarScheduleReqJSON(t *testing.T) {
var req types.PutRadarScheduleReq
if err := json.Unmarshal([]byte(`{"hours":[6,18,21]}`), &req); err != nil {
t.Fatal(err)
}
if len(req.Hours) != 3 || req.Hours[0] != 6 || req.Hours[2] != 21 {
t.Fatalf("hours=%v", req.Hours)
}
got := intHours(req.Hours)
if len(got) != 3 || got[1] != 18 {
t.Fatalf("intHours=%v", got)
}
}

View File

@ -160,13 +160,28 @@ type RadarSweepPayload struct {
Day string `json:"day"`
}
// RadarSweepRef builds the job RefID for a watch's daily slot.
// RadarSweepRef builds the job RefID for a watch's Taipei local date+hour slot.
func RadarSweepRef(watchID, day string) string {
return strings.TrimSpace(watchID) + ":" + strings.TrimSpace(day)
}
// ScheduleRadarSweep enqueues one radar_sweep job for a watch on the UTC day of runAt.
// If a job for the same watch+day already exists (any status), returns it without inserting.
func taipeiLoc() *time.Location {
loc, err := time.LoadLocation("Asia/Taipei")
if err != nil {
return time.FixedZone("Asia/Taipei", 8*3600)
}
return loc
}
func radarSweepSlotRef(watchID string, runAt int64) (ref, day string) {
local := time.Unix(0, runAt).In(taipeiLoc())
day = local.Format("2006-01-02")
ref = fmt.Sprintf("%s:tp:%s:%02d", strings.TrimSpace(watchID), day, local.Hour())
return ref, day
}
// ScheduleRadarSweep enqueues one radar_sweep job for a watch on the Taipei slot of runAt.
// If a job for the same watch+date+hour already exists (any status), returns it without inserting.
func (s *Service) ScheduleRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (*domain.Job, error) {
watchID = strings.TrimSpace(watchID)
if ownerUID <= 0 || watchID == "" {
@ -175,8 +190,7 @@ func (s *Service) ScheduleRadarSweep(ctx context.Context, ownerUID int64, watchI
if runAt <= 0 {
runAt = domain.NowNano()
}
day := time.Unix(0, runAt).UTC().Format("2006-01-02")
ref := RadarSweepRef(watchID, day)
ref, day := radarSweepSlotRef(watchID, runAt)
body, err := json.Marshal(RadarSweepPayload{WatchID: watchID, Day: day})
if err != nil {

View File

@ -11,6 +11,9 @@ type Repository interface {
GetServiceProfile(ctx context.Context, ownerUID int64) (*ServiceProfile, error)
SaveServiceProfile(ctx context.Context, p *ServiceProfile) error
GetRadarSchedule(ctx context.Context, ownerUID int64) (*RadarSchedule, error)
SaveRadarSchedule(ctx context.Context, s *RadarSchedule) error
// DemandMap is the product-specific, user-editable search contract.
GetDemandMap(ctx context.Context, ownerUID int64, productID string) (*DemandMap, error)
SaveDemandMap(ctx context.Context, m *DemandMap, expectedVersion int64) (*DemandMap, error)

View File

@ -0,0 +1,92 @@
package domain
import (
"fmt"
"sort"
"time"
)
const (
ScheduleTimezone = "Asia/Taipei"
DefaultSweepHour = 6
MaxSweepHours = 6
minSweepHour = 0
maxSweepHour = 23
)
// RadarSchedule is the owner's automatic patrol timetable (Taipei local hours).
type RadarSchedule struct {
OwnerUID int64 `bson:"_id" json:"owner_uid"`
Hours []int `bson:"hours" json:"hours"`
UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
}
func TaipeiLocation() *time.Location {
loc, err := time.LoadLocation(ScheduleTimezone)
if err != nil {
return time.FixedZone(ScheduleTimezone, 8*3600)
}
return loc
}
func DefaultSweepHours() []int {
return []int{DefaultSweepHour}
}
func NormalizeSweepHours(hours []int) ([]int, error) {
if len(hours) == 0 {
return DefaultSweepHours(), nil
}
seen := map[int]bool{}
out := make([]int, 0, len(hours))
for _, h := range hours {
if h < minSweepHour || h > maxSweepHour {
return nil, fmt.Errorf("%w: hours must be 023 (got %d)", ErrValidation, h)
}
if seen[h] {
continue
}
seen[h] = true
out = append(out, h)
}
if len(out) == 0 {
return DefaultSweepHours(), nil
}
if len(out) > MaxSweepHours {
return nil, fmt.Errorf("%w: at most %d patrol hours", ErrValidation, MaxSweepHours)
}
sort.Ints(out)
return out, nil
}
// SweepSlot is one due automatic patrol (Taipei calendar day + hour).
type SweepSlot struct {
Date string
Hour int
RunAt int64
}
// DueSweepSlots returns selected hours that have already started today (Taipei)
// plus any earlier selected hours the same day, so a late worker still catches up.
func DueSweepSlots(now time.Time, hours []int) []SweepSlot {
hours, err := NormalizeSweepHours(hours)
if err != nil {
hours = DefaultSweepHours()
}
loc := TaipeiLocation()
local := now.In(loc)
day := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, loc)
out := make([]SweepSlot, 0, len(hours))
for _, h := range hours {
slot := day.Add(time.Duration(h) * time.Hour)
if local.Before(slot) {
continue
}
out = append(out, SweepSlot{
Date: day.Format("2006-01-02"),
Hour: h,
RunAt: slot.UnixNano(),
})
}
return out
}

View File

@ -0,0 +1,48 @@
package domain
import (
"testing"
"time"
)
func TestNormalizeSweepHours(t *testing.T) {
got, err := NormalizeSweepHours(nil)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0] != 6 {
t.Fatalf("empty → default [6], got %v", got)
}
got, err = NormalizeSweepHours([]int{18, 6, 6, 12})
if err != nil {
t.Fatal(err)
}
if len(got) != 3 || got[0] != 6 || got[1] != 12 || got[2] != 18 {
t.Fatalf("dedupe+sort, got %v", got)
}
if _, err := NormalizeSweepHours([]int{24}); err == nil {
t.Fatal("hour 24 should fail")
}
if _, err := NormalizeSweepHours([]int{0, 3, 6, 9, 12, 15, 18}); err == nil {
t.Fatal("too many hours should fail")
}
}
func TestDueSweepSlots_TaipeiHours(t *testing.T) {
loc := TaipeiLocation()
// 05:59 Taipei 31 Jul → no 06:00 slot yet
before := time.Date(2026, 7, 31, 5, 59, 0, 0, loc)
if slots := DueSweepSlots(before, []int{6, 18}); len(slots) != 0 {
t.Fatalf("before 06:00 want 0, got %+v", slots)
}
at := time.Date(2026, 7, 31, 6, 0, 0, 0, loc)
slots := DueSweepSlots(at, []int{6, 18})
if len(slots) != 1 || slots[0].Hour != 6 {
t.Fatalf("at 06:00 want [6], got %+v", slots)
}
evening := time.Date(2026, 7, 31, 18, 5, 0, 0, loc)
slots = DueSweepSlots(evening, []int{6, 18})
if len(slots) != 2 || slots[0].Hour != 6 || slots[1].Hour != 18 {
t.Fatalf("after 18:00 want [6,18], got %+v", slots)
}
}

View File

@ -12,6 +12,7 @@ import (
type Memory struct {
mu sync.Mutex
profiles map[int64]*domain.ServiceProfile
schedules map[int64]*domain.RadarSchedule
demandMaps map[string]*domain.DemandMap
watches map[string]*domain.RadarWatch
opportunities map[string]*domain.Opportunity
@ -27,6 +28,7 @@ type Memory struct {
func NewMemory() *Memory {
return &Memory{
profiles: map[int64]*domain.ServiceProfile{},
schedules: map[int64]*domain.RadarSchedule{},
demandMaps: map[string]*domain.DemandMap{},
watches: map[string]*domain.RadarWatch{},
opportunities: map[string]*domain.Opportunity{},
@ -60,3 +62,24 @@ func (m *Memory) SaveServiceProfile(_ context.Context, p *domain.ServiceProfile)
m.profiles[p.OwnerUID] = &cp
return nil
}
func (m *Memory) GetRadarSchedule(_ context.Context, ownerUID int64) (*domain.RadarSchedule, error) {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.schedules[ownerUID]
if !ok {
return nil, domain.ErrNotFound
}
cp := *s
cp.Hours = append([]int(nil), s.Hours...)
return &cp, nil
}
func (m *Memory) SaveRadarSchedule(_ context.Context, s *domain.RadarSchedule) error {
m.mu.Lock()
defer m.mu.Unlock()
cp := *s
cp.Hours = append([]int(nil), s.Hours...)
m.schedules[s.OwnerUID] = &cp
return nil
}

View File

@ -13,6 +13,7 @@ import (
type MonStore struct {
profiles *mon.Model
schedules *mon.Model
demandMaps *mon.Model
watches *mon.Model
opportunities *mon.Model
@ -25,6 +26,7 @@ func NewMonStore(uri, database string) *MonStore {
uri = libmongo.MustMongoURI(uri)
return &MonStore{
profiles: mon.MustNewModel(uri, database, "radar_service_profiles"),
schedules: mon.MustNewModel(uri, database, "radar_schedules"),
demandMaps: mon.MustNewModel(uri, database, "radar_demand_maps"),
watches: mon.MustNewModel(uri, database, "radar_watches"),
opportunities: mon.MustNewModel(uri, database, "radar_opportunities"),
@ -52,3 +54,20 @@ func (s *MonStore) SaveServiceProfile(ctx context.Context, p *domain.ServiceProf
_, err := s.profiles.ReplaceOne(ctx, bson.M{"_id": p.OwnerUID}, p, options.Replace().SetUpsert(true))
return err
}
func (s *MonStore) GetRadarSchedule(ctx context.Context, ownerUID int64) (*domain.RadarSchedule, error) {
var row domain.RadarSchedule
err := s.schedules.FindOne(ctx, &row, bson.M{"_id": ownerUID})
if err == mon.ErrNotFound {
return nil, domain.ErrNotFound
}
if err != nil {
return nil, err
}
return &row, nil
}
func (s *MonStore) SaveRadarSchedule(ctx context.Context, row *domain.RadarSchedule) error {
_, err := s.schedules.ReplaceOne(ctx, bson.M{"_id": row.OwnerUID}, row, options.Replace().SetUpsert(true))
return err
}

View File

@ -33,7 +33,7 @@ func NormalizeCandidate(c *domain.CandidatePost) *domain.CandidatePost {
if out.Permalink == "" {
out.Permalink = out.ExternalID
}
out.Text = strings.TrimSpace(strings.Join(strings.Fields(out.Text), " "))
out.Text = NormalizePostBody(out.Text)
out.Title = strings.TrimSpace(strings.Join(strings.Fields(out.Title), " "))
out.AuthorHandle = strings.TrimPrefix(strings.TrimSpace(out.AuthorHandle), "@")
out.Classification = strings.ToLower(strings.TrimSpace(out.Classification))
@ -43,6 +43,49 @@ func NormalizeCandidate(c *domain.CandidatePost) *domain.CandidatePost {
return &out
}
// NormalizePostBody keeps paragraph breaks so the inbox can show the post as
// written. Horizontal runs of space/tab are still collapsed.
func NormalizePostBody(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
s = strings.ReplaceAll(s, "\u2028", "\n")
s = strings.ReplaceAll(s, "\u2029", "\n")
lines := strings.Split(s, "\n")
out := make([]string, 0, len(lines))
blank := 0
for _, line := range lines {
line = collapseHorizontalSpace(line)
if line == "" {
blank++
if blank == 1 {
out = append(out, "")
}
continue
}
blank = 0
out = append(out, line)
}
return strings.Trim(strings.Join(out, "\n"), "\n")
}
func collapseHorizontalSpace(s string) string {
var b strings.Builder
b.Grow(len(s))
space := false
for _, r := range s {
if r == ' ' || r == '\t' || r == '\u00a0' || r == '\u3000' {
if !space {
b.WriteByte(' ')
space = true
}
continue
}
space = false
b.WriteRune(r)
}
return strings.TrimSpace(b.String())
}
func canonicalCandidateURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {

View File

@ -21,6 +21,21 @@ func TestPrefilterNormalizesDedupesAndKeepsWeakDemandForReview(t *testing.T) {
}
}
func TestNormalizePostBodyKeepsParagraphs(t *testing.T) {
got := NormalizePostBody(" 最近皮膚泛紅\n\n\n換季怎麼辦 \t有人知道嗎 ")
want := "最近皮膚泛紅\n\n換季怎麼辦 有人知道嗎"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
c := NormalizeCandidate(&domain.CandidatePost{
Permalink: "https://threads.net/@a/post/1",
Text: "第一段\n第二段",
})
if c.Text != "第一段\n第二段" {
t.Fatalf("candidate text collapsed: %q", c.Text)
}
}
func TestPrefilterDoesNotUseProductNameAsDemandSignal(t *testing.T) {
items, stats := PrefilterCandidates([]*domain.CandidatePost{{Permalink: "https://threads.net/@a/post/1", Text: "舒緩精華好漂亮"}}, nil, &ProductContextSnapshot{ProductLabel: "舒緩精華", PainPoints: []string{"泛紅"}})
if len(items) != 1 || stats.Review != 1 || stats.Rejected != 0 {

View File

@ -76,6 +76,40 @@ func (s *Service) GetServiceProfile(ctx context.Context, ownerUID int64) (*domai
return s.Repo.GetServiceProfile(ctx, ownerUID)
}
func (s *Service) GetRadarSchedule(ctx context.Context, ownerUID int64) (*domain.RadarSchedule, error) {
if ownerUID <= 0 {
return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation)
}
row, err := s.Repo.GetRadarSchedule(ctx, ownerUID)
if errors.Is(err, domain.ErrNotFound) || row == nil {
return &domain.RadarSchedule{OwnerUID: ownerUID, Hours: domain.DefaultSweepHours()}, nil
}
if err != nil {
return nil, err
}
hours, nerr := domain.NormalizeSweepHours(row.Hours)
if nerr != nil {
hours = domain.DefaultSweepHours()
}
row.Hours = hours
return row, nil
}
func (s *Service) PutRadarSchedule(ctx context.Context, ownerUID int64, hours []int) (*domain.RadarSchedule, error) {
if ownerUID <= 0 {
return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation)
}
normalized, err := domain.NormalizeSweepHours(hours)
if err != nil {
return nil, err
}
row := &domain.RadarSchedule{OwnerUID: ownerUID, Hours: normalized, UpdatedAt: domain.NowNano()}
if err := s.Repo.SaveRadarSchedule(ctx, row); err != nil {
return nil, err
}
return row, nil
}
func (s *Service) HasServiceProfile(ctx context.Context, ownerUID int64) (bool, error) {
_, err := s.GetServiceProfile(ctx, ownerUID)
if errors.Is(err, domain.ErrNotFound) {

View File

@ -31,54 +31,62 @@ func (f SweepJobSchedulerFunc) ScheduleRadarSweep(ctx context.Context, ownerUID
return f(ctx, ownerUID, watchID, runAt)
}
// ScheduleDailySweeps 在已過當日 UTC 22:00 時,為每個 active watch 建一筆 radar_sweep Job。
// ScheduleDailySweeps 為每個 active watch 補齊「今天(台北)已到期」的時段 Job。
//
// 呼叫端必須已持有 worker maintenance Redis lockvalue = workerID
// 本函式本身不做分散式鎖;未過 22:00 時回 0 且不建 Job。
//
// 回傳建立(或已存在而回傳)的 job 數。
// 預設時段是台北 06:00等同舊的 UTC 22:00。會員可在 /radar/schedule 多選時段。
// 呼叫端必須已持有 worker maintenance Redis lock。回傳建立或已存在的 slot 數。
func (s *Service) ScheduleDailySweeps(ctx context.Context, now time.Time) (int, error) {
if s.SweepJobs == nil {
return 0, fmt.Errorf("%w: sweep job scheduler not configured", domain.ErrNotReady)
}
if now.IsZero() {
now = time.Now().UTC()
} else {
now = now.UTC()
now = time.Now()
}
if !PastDailySweepSlot(now) {
return 0, nil
}
runAt := DailySweepRunAt(now)
watches, err := s.Repo.ListAllActiveWatches(ctx)
if err != nil {
return 0, err
}
hoursByOwner := map[int64][]int{}
n := 0
for _, w := range watches {
if w == nil || w.Status != domain.WatchActive {
continue
}
if _, err := s.SweepJobs.ScheduleRadarSweep(ctx, w.OwnerUID, w.ID, runAt); err != nil {
return n, fmt.Errorf("schedule watch %s owner %d: %w", w.ID, w.OwnerUID, err)
hours, ok := hoursByOwner[w.OwnerUID]
if !ok {
sch, gerr := s.GetRadarSchedule(ctx, w.OwnerUID)
if gerr != nil {
return n, fmt.Errorf("schedule owner %d: %w", w.OwnerUID, gerr)
}
hours = sch.Hours
hoursByOwner[w.OwnerUID] = hours
}
slots := domain.DueSweepSlots(now, hours)
for _, slot := range slots {
if _, err := s.SweepJobs.ScheduleRadarSweep(ctx, w.OwnerUID, w.ID, slot.RunAt); err != nil {
return n, fmt.Errorf("schedule watch %s owner %d hour %d: %w", w.ID, w.OwnerUID, slot.Hour, err)
}
n++
}
n++
}
return n, nil
}
// PastDailySweepSlot reports whether now is at or after today's UTC 22:00.
// PastDailySweepSlot reports whether the default Taipei 06:00 slot has started.
func PastDailySweepSlot(now time.Time) bool {
now = now.UTC()
slot := time.Date(now.Year(), now.Month(), now.Day(), DailySweepHourUTC, 0, 0, 0, time.UTC)
return !now.Before(slot)
return len(domain.DueSweepSlots(now, domain.DefaultSweepHours())) > 0
}
// DailySweepRunAt returns unix-ns for today's UTC 22:00 (the slot being scheduled).
// DailySweepRunAt returns unix-ns for today's default Taipei 06:00 slot (or next if not yet).
func DailySweepRunAt(now time.Time) int64 {
now = now.UTC()
slot := time.Date(now.Year(), now.Month(), now.Day(), DailySweepHourUTC, 0, 0, 0, time.UTC)
slots := domain.DueSweepSlots(now, domain.DefaultSweepHours())
if len(slots) > 0 {
return slots[0].RunAt
}
loc := domain.TaipeiLocation()
local := now.In(loc)
slot := time.Date(local.Year(), local.Month(), local.Day(), domain.DefaultSweepHour, 0, 0, 0, loc)
return slot.UnixNano()
}

View File

@ -60,8 +60,8 @@ func TestScheduleDailySweeps_SW01_TwoActiveWatches(t *testing.T) {
CreatedAt: now, UpdatedAt: now,
})
// Before 22:00 → no jobs
morning := time.Date(2026, 7, 31, 10, 0, 0, 0, time.UTC)
// Before Taipei 06:00 → no jobs
morning := time.Date(2026, 7, 31, 5, 0, 0, 0, domain.TaipeiLocation())
n, err := svc.ScheduleDailySweeps(ctx, morning)
if err != nil {
t.Fatal(err)
@ -70,8 +70,8 @@ func TestScheduleDailySweeps_SW01_TwoActiveWatches(t *testing.T) {
t.Fatalf("before slot: want 0 jobs, got %d", n)
}
// After 22:00 → two jobs (active only)
evening := time.Date(2026, 7, 31, 22, 5, 0, 0, time.UTC)
// After Taipei 06:00 → two jobs (active only)
evening := time.Date(2026, 7, 31, 6, 5, 0, 0, domain.TaipeiLocation())
n, err = svc.ScheduleDailySweeps(ctx, evening)
if err != nil {
t.Fatal(err)
@ -123,6 +123,50 @@ func TestScheduleDailySweeps_SW01_TwoActiveWatches(t *testing.T) {
}
}
func TestScheduleDailySweeps_MultipleHours(t *testing.T) {
ctx := context.Background()
radarMem := repository.NewMemory()
jobMem := jobRepo.NewMemory()
jobs := jobUC.New(jobMem)
svc := New(radarMem)
svc.SweepJobs = 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
})
now := domain.NowNano()
if err := radarMem.SaveWatch(ctx, &domain.RadarWatch{
ID: "w-a", OwnerUID: 100, Terms: []string{"婚攝"}, Status: domain.WatchActive,
CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
if _, err := svc.PutRadarSchedule(ctx, 100, []int{6, 18}); err != nil {
t.Fatal(err)
}
loc := domain.TaipeiLocation()
n, err := svc.ScheduleDailySweeps(ctx, time.Date(2026, 7, 31, 7, 0, 0, 0, loc))
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("07:00 Taipei: want 1 slot, got %d", n)
}
n, err = svc.ScheduleDailySweeps(ctx, time.Date(2026, 7, 31, 18, 10, 0, 0, loc))
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Fatalf("18:10 Taipei: want 2 slots, got %d", n)
}
list, _ := jobs.List(ctx, 100)
if len(list) != 2 {
t.Fatalf("want 2 stored jobs, got %d", len(list))
}
}
func TestScheduleRadarSweep_ConcurrentNoDuplicate(t *testing.T) {
ctx := context.Background()
jobs := jobUC.New(jobRepo.NewMemory())

View File

@ -1834,6 +1834,15 @@ type PublishPlaybookReq struct {
Anonymous bool `json:"anonymous,optional"`
}
type PutRadarScheduleReq struct {
Hours []int64 `json:"hours,optional"`
}
type RadarSchedulePublic struct {
Hours []int64 `json:"hours"`
Timezone string `json:"timezone"`
}
type RadarSweepPublic struct {
Id string `json:"id"`
WatchId string `json:"watch_id"`

View File

@ -47,10 +47,10 @@ export function BellMenu() {
}
}, [repos.notifications]);
// 全域資料變更、任務數改變或打開面板時才立即刷新
// 全域資料變更或任務數改變時刷新。打開面板改走 markSeen
useEffect(() => {
void loadNotifs();
}, [revision, loadNotifs, tick, open]);
}, [revision, loadNotifs, tick]);
// 關閉時低頻更新;頁面不可見時暫停,避免背景流量。
useEffect(() => {
@ -90,16 +90,27 @@ export function BellMenu() {
);
}
function markAllRead() {
function markSeen() {
const readAt = Date.now() * 1_000_000;
setItems((current) => current.map((item) => (item.read_at ? item : { ...item, read_at: readAt })));
setUnread(0);
void repos.notifications.markAllRead().then(
() => window.dispatchEvent(new Event("harbor:store")),
() => {
window.dispatchEvent(new Event("harbor:store"));
return loadNotifs();
},
() => void loadNotifs(),
);
}
function toggleOpen() {
setOpen((wasOpen) => {
const next = !wasOpen;
if (next) markSeen();
return next;
});
}
const preview = items.slice(0, PREVIEW);
const more = Math.max(0, items.length - PREVIEW);
@ -108,7 +119,7 @@ export function BellMenu() {
<button
type="button"
className={`hb-bell__trigger${open ? " is-open" : ""}${unread > 0 ? " has-unread" : ""}`}
onClick={() => setOpen((v) => !v)}
onClick={toggleOpen}
aria-expanded={open}
aria-haspopup="menu"
aria-label={
@ -129,21 +140,7 @@ export function BellMenu() {
<div className="hb-bell__head">
<div className="hb-bell__head-title">
<strong>{t("topbar.notifications")}</strong>
{unread > 0 ? (
<span className="hb-bell__head-count">
{t("topbar.unread", { n: unread })}
</span>
) : null}
</div>
{items.length > 0 ? (
<button
type="button"
className="hb-bell__text-btn"
onClick={markAllRead}
>
{t("topbar.markAllRead")}
</button>
) : null}
</div>
{items.length === 0 ? (

View File

@ -1,4 +1,5 @@
import { useState } from "react";
import { useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { IntentBand, Opportunity } from "../../domain/types";
import { useI18n } from "../../i18n/I18nContext";
import { Badge, Button } from "../ui";
@ -6,6 +7,17 @@ import { PrimaryProductPicker } from "./PrimaryProductPicker";
import { ProductMatchDetails } from "./ProductMatchDetails";
import { ReplyComposer } from "./ReplyComposer";
/** 殼層 overflow-x: clip 會把 fixed 鎖在整頁座標;量頂欄/底欄,抽屜才不會衝過頭。 */
function pinDrawerToChrome(el: HTMLElement) {
const header = document.querySelector<HTMLElement>(".hb-shell__header");
const dock = document.querySelector<HTMLElement>(".hb-dock");
const top = header?.getBoundingClientRect().height ?? 0;
const dockHidden = !dock || getComputedStyle(dock).display === "none";
const bottom = dockHidden ? 0 : dock.getBoundingClientRect().height;
el.style.top = `${Math.round(top)}px`;
el.style.bottom = `${Math.round(bottom)}px`;
}
const BANDS: IntentBand[] = ["high", "mid", "low"];
type Props = {
@ -31,11 +43,30 @@ export function OpportunityDetailDrawer({
}: Props) {
const { t } = useI18n();
const [overrideOpen, setOverrideOpen] = useState(false);
const drawerRef = useRef<HTMLElement>(null);
const pending = (opportunity.review_state || "pending") === "pending";
const accepted = opportunity.status === "accepted" || Boolean(opportunity.contact_id);
const matches = opportunity.product_matches ?? [];
return (
<aside className="hb-opp-drawer" role="dialog" aria-modal="true" aria-label={t("radar.drawer.aria")}>
useLayoutEffect(() => {
const el = drawerRef.current;
if (!el) return;
const apply = () => pinDrawerToChrome(el);
apply();
const header = document.querySelector(".hb-shell__header");
const dock = document.querySelector(".hb-dock");
const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(apply);
if (ro && header) ro.observe(header);
if (ro && dock) ro.observe(dock);
window.addEventListener("resize", apply);
return () => {
ro?.disconnect();
window.removeEventListener("resize", apply);
};
}, []);
const drawer = (
<aside ref={drawerRef} className="hb-opp-drawer" role="dialog" aria-modal="true" aria-label={t("radar.drawer.aria")}>
<div className="hb-opp-drawer__head">
<div>
<span className="hb-radar-section__hint">{t("radar.drawer.title")}</span>
@ -106,4 +137,6 @@ export function OpportunityDetailDrawer({
</div>
</aside>
);
return createPortal(drawer, document.body);
}

View File

@ -18,6 +18,7 @@ import type {
Opportunity,
ProductFitReason,
ProductMatch,
RadarSchedule,
RadarSweep,
RadarToday,
RadarWatch,
@ -95,6 +96,16 @@ function mapServiceProfile(raw: Raw): ServiceProfile {
};
}
function mapSchedule(raw: Raw): RadarSchedule {
const hours = Array.isArray(raw.hours)
? raw.hours.map((h) => Number(h)).filter((h) => Number.isInteger(h) && h >= 0 && h <= 23)
: [6];
return {
hours: hours.length ? hours : [6],
timezone: str(raw.timezone) || "Asia/Taipei",
};
}
function mapWatch(raw: Raw): RadarWatch {
return {
id: str(raw.id),
@ -387,6 +398,17 @@ export function createLiveRadarRepo(): RadarRepo {
});
return mapServiceProfile(raw);
},
async getRadarSchedule() {
const raw = await apiRequest<Raw>(`${RADAR_BASE}/schedule`);
return mapSchedule(raw);
},
async saveRadarSchedule(hours: number[]) {
const raw = await apiRequest<Raw>(`${RADAR_BASE}/schedule`, {
method: "PUT",
body: { hours },
});
return mapSchedule(raw);
},
async listWatches(page = 1, pageSize = 20, status, contextMode, brandId, productId) {
const raw = await apiRequest<Raw>(
`${RADAR_BASE}/watches${query({ page, pageSize, status, context_mode: contextMode, brand_id: brandId, product_id: productId })}`,

View File

@ -8,6 +8,7 @@ import type {
OpportunityRemovalReason,
OpportunityReviewState,
RadarToday,
RadarSchedule,
RadarSweep,
RadarWatch,
ServiceProfile,
@ -123,6 +124,7 @@ export function createMockRadarRepo(seed: MockRadarSeed = {}): RadarRepo {
remote_ok: false,
...copy(effectiveSeed.profile ?? {}),
};
const schedule: RadarSchedule = { hours: [6], timezone: "Asia/Taipei" };
function productFor(brandId: string, productId: string): BrandProduct {
const brand = brands.get(brandId);
@ -171,6 +173,16 @@ export function createMockRadarRepo(seed: MockRadarSeed = {}): RadarRepo {
Object.assign(profile, copy(patch), { exists: true, updated_at: nanoNow() });
return copy(profile);
},
async getRadarSchedule() {
return copy(schedule);
},
async saveRadarSchedule(hours: number[]) {
const next = [...new Set(hours.filter((h) => Number.isInteger(h) && h >= 0 && h <= 23))].sort((a, b) => a - b);
if (!next.length) error("hours required");
if (next.length > 6) error("at most 6 patrol hours");
schedule.hours = next;
return copy(schedule);
},
async listWatches(page = 1, pageSize = 20, status, contextMode, brandId, productId) {
const all = [...watches.values()].filter((w) =>
(!status || w.status === status) &&

View File

@ -677,6 +677,8 @@ export type GrowthRepo = {
*/
export type RadarRepo = {
getServiceProfile(): Promise<import("../domain/types").ServiceProfile>;
getRadarSchedule(): Promise<import("../domain/types").RadarSchedule>;
saveRadarSchedule(hours: number[]): Promise<import("../domain/types").RadarSchedule>;
saveServiceProfile(
patch: Omit<import("../domain/types").ServiceProfile, "exists" | "updated_at">,
): Promise<import("../domain/types").ServiceProfile>;

View File

@ -895,6 +895,12 @@ export type RadarWatchStatus = "active" | "paused" | "archived";
export type RadarWatchContextMode = "generic" | "product";
export type RadarWatchPauseReason = "user" | "product_unavailable" | "brand_unavailable";
/** 自動巡邏時段(台北當地小時 023 */
export type RadarSchedule = {
hours: number[];
timezone: string;
};
/** 雷達訂閱:常駐關鍵字監控 */
export type RadarWatch = {
id: string;

View File

@ -109,7 +109,7 @@ export const en: MessageDict = {
"help.page.radar_watches.what": "Add keywords customers search. Daily patrol runs on a schedule; you can also run one immediately. Results land on Today.",
"help.page.radar_watches.step1": "Type short customer search words. Brand and service profile can wait.",
"help.page.radar_watches.step2": "Add excludes if needed, or pick a brand to seed product terms.",
"help.page.radar_watches.step3": "Leave daily patrol on, or go back to Today and run another sweep.",
"help.page.radar_watches.step3": "Pick one or more Taipei patrol hours, or go back to Today and run another sweep.",
"help.page.radar_watches.tips": "Active slots are plan-capped; pause one to free a slot.",
"help.page.crm_board.title": "Contact management",
@ -2280,8 +2280,16 @@ export const en: MessageDict = {
"radar.watches.emptyHint":
"Add short buyer phrases (e.g. “find designer”); the system sweeps daily. Whoever we find shows up under Today.",
"radar.watches.emptyFiltered": "No watches in this status",
"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.scheduleTitle": "Automatic patrol hours (Taipei time)",
"radar.watches.scheduleHint": "Pick one or more slots. Every active watch runs at each selected time. Use Run now on a watch for an extra pass.",
"radar.watches.scheduleSaved": "Patrol hours updated",
"radar.watches.scheduleNeedOne": "Choose at least one slot",
"radar.watches.slot.6": "Morning 06:00",
"radar.watches.slot.9": "Late morning 09:00",
"radar.watches.slot.12": "Noon 12:00",
"radar.watches.slot.15": "Afternoon 15:00",
"radar.watches.slot.18": "Evening 18:00",
"radar.watches.slot.21": "Night 21:00",
"radar.watches.openToday": "Back to findings",
"radar.watches.sweepNow": "Run now",
"radar.watches.sweepQueued": "Demand sweep queued",
@ -2367,7 +2375,7 @@ export const en: MessageDict = {
"radar.inbox.patrolAria": "Patrol status",
"radar.inbox.scheduledOn": "Daily patrol: on",
"radar.inbox.scheduledOff": "Daily patrol: off",
"radar.inbox.scheduleHint": "Runs every day at 06:00 Taipei time. Turning off Run now does not stop the daily patrol.",
"radar.inbox.scheduleHint": "Runs at the hours you picked under Patrol settings. Turning off Run now does not stop the scheduled patrol.",
"radar.inbox.lastSweep": "Last patrol: {time}",
"radar.inbox.neverSwept": "Not patrolled yet",
"radar.inbox.activeWatches": "{n} watches on",

View File

@ -112,7 +112,7 @@ export const zhTW: MessageDict = {
"help.page.radar_watches.what": "填客人會搜的關鍵字後,每日定時巡邏會自動跑;也可隨時再巡一輪。結果回到「今日」。",
"help.page.radar_watches.step1": "填客人會打的短詞;品牌與服務檔案可之後再補。",
"help.page.radar_watches.step2": "需要時再加排除詞,或選品牌帶入產品關鍵字。",
"help.page.radar_watches.step3": "打開每日定時,或回今日按「再找一輪」。",
"help.page.radar_watches.step3": "多選巡邏時段(台北時間),或回今日按「再找一輪」。",
"help.page.radar_watches.tips": "啟用數有方案上限;滿了要先暫停一組。",
"help.page.crm_board.title": "名單管理",
@ -2278,8 +2278,16 @@ export const zhTW: MessageDict = {
"radar.watches.empty": "還沒有商機訂閱",
"radar.watches.emptyHint": "加客人會用的短詞(例如「室內設計」「找設計師」),系統會每天自動幫你巡。找到的人會出現在「今日」。",
"radar.watches.emptyFiltered": "這個狀態下沒有訂閱",
"radar.watches.scheduleTitle": "每日定時巡邏:每天台北 06:00UTC 22:00",
"radar.watches.scheduleHint": "開著的訂閱每天自動巡一輪。要現在看結果,按「立即巡邏」。關掉立即巡邏不會停每日定時。",
"radar.watches.scheduleTitle": "自動巡邏時段(台北時間)",
"radar.watches.scheduleHint": "可多選。到點後所有開著的訂閱會各巡一輪。要現在看結果,到訂閱列按「立即巡邏」。",
"radar.watches.scheduleSaved": "巡邏時段已更新",
"radar.watches.scheduleNeedOne": "至少選一個時段",
"radar.watches.slot.6": "早晨 06:00",
"radar.watches.slot.9": "上午 09:00",
"radar.watches.slot.12": "中午 12:00",
"radar.watches.slot.15": "下午 15:00",
"radar.watches.slot.18": "傍晚 18:00",
"radar.watches.slot.21": "晚上 21:00",
"radar.watches.openToday": "回商機結果",
"radar.watches.sweepNow": "立即巡邏",
"radar.watches.sweepQueued": "已排入商機巡檢",
@ -2365,7 +2373,7 @@ export const zhTW: MessageDict = {
"radar.inbox.patrolAria": "巡邏狀態",
"radar.inbox.scheduledOn": "每日定時巡邏:開著",
"radar.inbox.scheduledOff": "每日定時巡邏:關著",
"radar.inbox.scheduleHint": "每天台北 06:00 自動巡一輪。關掉立即巡邏不會停每日定時。",
"radar.inbox.scheduleHint": "依你在巡邏設定選的時段自動巡。關掉立即巡邏不會停定時巡邏。",
"radar.inbox.lastSweep": "上次巡邏:{time}",
"radar.inbox.neverSwept": "還沒巡邏過",
"radar.inbox.activeWatches": "啟用中 {n} 組",

View File

@ -17,6 +17,7 @@ const backend = vi.hoisted(() => ({
suggestions: [] as WatchTermSuggestion[],
suggestError: null as unknown,
seq: 0,
hours: [6] as number[],
}));
function activeCount(): number {
@ -103,6 +104,13 @@ vi.mock("../data/DataContext", () => {
if (backend.suggestError) throw backend.suggestError;
return backend.suggestions;
},
async getRadarSchedule() {
return { hours: backend.hours, timezone: "Asia/Taipei" };
},
async saveRadarSchedule(hours: number[]) {
backend.hours = [...hours].sort((a, b) => a - b);
return { hours: backend.hours, timezone: "Asia/Taipei" };
},
},
};
return { useRepos: () => repos };
@ -144,6 +152,7 @@ beforeEach(() => {
backend.suggestions = [];
backend.suggestError = null;
backend.seq = 0;
backend.hours = [6];
vi.spyOn(window, "confirm").mockReturnValue(true);
});
@ -276,4 +285,14 @@ describe("RadarWatchesPage", () => {
expect(screen.getByText("晚上睡覺")).toBeTruthy();
expect(screen.getByText("口乾舌燥")).toBeTruthy();
});
it("可以多選巡邏時段", async () => {
renderPage();
const evening = await screen.findByRole("button", { name: t("radar.watches.slot.18") });
expect(screen.getByRole("button", { name: t("radar.watches.slot.6") })).toHaveAttribute("aria-pressed", "true");
fireEvent.click(evening);
await screen.findByText(t("radar.watches.scheduleSaved"));
expect(backend.hours).toEqual([6, 18]);
expect(evening).toHaveAttribute("aria-pressed", "true");
});
});

View File

@ -17,6 +17,7 @@ import { DemandMapEditor, type DemandMapPatch } from "../components/radar/Demand
import { SweepFunnelSummary } from "../components/radar/SweepFunnelSummary";
const PAGE_SIZE = 20;
const SWEEP_SLOTS = [6, 9, 12, 15, 18, 21] as const;
type StatusFilter = "" | RadarWatchStatus;
@ -76,6 +77,7 @@ export function RadarWatchesPage() {
const [message, setMessage] = useState("");
const [lastSweep, setLastSweep] = useState<RadarSweep | null>(null);
const [justTriggeredFirstSweep, setJustTriggeredFirstSweep] = useState(false);
const [hours, setHours] = useState<number[]>([6]);
const [brands, setBrands] = useState<Brand[]>([]);
const [products, setProducts] = useState<BrandProduct[]>([]);
const [demandMap, setDemandMap] = useState<DemandMap | null>(null);
@ -136,6 +138,21 @@ export function RadarWatchesPage() {
setProfileExists(res.profile_exists);
}, [repos, page, statusFilter]);
useEffect(() => {
let alive = true;
void repos.radar
.getRadarSchedule()
.then((s) => {
if (alive) setHours(s.hours);
})
.catch(() => {
/* 排程讀不到仍可用預設 06:00 操作訂閱 */
});
return () => {
alive = false;
};
}, [repos.radar]);
useEffect(() => {
let alive = true;
setLoading(true);
@ -299,6 +316,23 @@ export function RadarWatchesPage() {
if (ok) setFormOpen(false);
}
async function toggleHour(hour: number) {
const next = hours.includes(hour) ? hours.filter((h) => h !== hour) : [...hours, hour];
if (!next.length) {
setError(t("radar.watches.scheduleNeedOne"));
return;
}
const ok = await run(
"schedule",
async () => {
const saved = await repos.radar.saveRadarSchedule(next);
setHours(saved.hours);
},
t("radar.watches.scheduleSaved"),
);
if (ok) setError("");
}
async function persistDemandMap(patch: DemandMapPatch, showBusy = true): Promise<DemandMap | null> {
if (showBusy) setBusy("demand-map");
setDemandMapError("");
@ -364,17 +398,31 @@ export function RadarWatchesPage() {
{t("radar.watches.quota", { used: activeCount, max: maxActive })}
{quotaFull ? ` · ${t("radar.watches.quotaFull")}` : ""}
</p>
<div className="hb-radar-schedule" role="note">
<div className="hb-radar-schedule">
<div>
<strong>{t("radar.watches.scheduleTitle")}</strong>
<p>{t("radar.watches.scheduleHint")}</p>
<div className="hb-radar-slot-grid" role="group" aria-label={t("radar.watches.scheduleTitle")}>
{SWEEP_SLOTS.map((hour) => (
<button
key={hour}
type="button"
className={`hb-radar-chip${hours.includes(hour) ? " is-active" : ""}`}
aria-pressed={hours.includes(hour)}
disabled={busy === "schedule"}
onClick={() => void toggleHour(hour)}
>
{t(`radar.watches.slot.${hour}`)}
</button>
))}
</div>
</div>
<Link className="hb-btn hb-btn--ghost" to="/app/today">
{t("radar.watches.openToday")}
</Link>
</div>
<div className="hb-radar-actions">
<Button type="button" onClick={openCreate} disabled={formOpen}>
<Button type="button" variant="ghost" onClick={openCreate} disabled={formOpen}>
{t("radar.watches.add")}
</Button>
<Select

View File

@ -178,6 +178,22 @@ describe("今日簡化流程", () => {
expect(backend.sweeps).toEqual([]);
});
it("回覆抽屜貼在頂欄下方,不會衝過頭被遮住", async () => {
backend.byScope = () => ({ list: [opportunity()], total: 1 });
const header = document.createElement("div");
header.className = "hb-shell__header";
Object.defineProperty(header, "getBoundingClientRect", {
value: () => ({ height: 96, width: 800, top: 0, left: 0, bottom: 96, right: 800, x: 0, y: 0, toJSON() {} }),
});
document.body.append(header);
renderToday();
fireEvent.click((await screen.findAllByRole("button", { name: "幫我想回覆" }))[0]);
const drawer = await screen.findByRole("dialog");
expect(drawer).toHaveStyle({ top: "96px" });
header.remove();
});
it("想不到怎麼回時,同一頁就能拿到草稿", async () => {
backend.byScope = () => ({ list: [opportunity()], total: 1 });
renderToday();
@ -185,6 +201,8 @@ describe("今日簡化流程", () => {
// 卡片上的入口打開抽屜;抽屜裡才真的花點數生成。
fireEvent.click((await screen.findAllByRole("button", { name: "幫我想回覆" }))[0]);
const drawer = await screen.findByRole("dialog");
// portal 出殼層,才不會被 overflow-x: clip + sticky 頂欄裁掉。
expect(drawer.parentElement).toBe(document.body);
fireEvent.click(within(drawer).getByRole("button", { name: "幫我想回覆" }));
await waitFor(() => expect(backend.replies).toEqual([{ id: "opp-1", variant: "public_comment" }]));

View File

@ -176,7 +176,7 @@ export function MentionsPanel({ accountId, personaId, accounts, personas }: Prop
</>
) : null}
</p>
<p style={{ marginTop: 0 }}>{m.text}</p>
<p className="hb-post-body" style={{ marginTop: 0 }}>{m.text}</p>
{m.status === "pending" && !composeOpen[m.id] ? (
<div className="hb-wizard-actions">
<Button type="button" variant="ghost" onClick={() => openCompose(m.id)}>

View File

@ -379,7 +379,7 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
</Badge>
) : null}
</div>
<p style={{ margin: "0 0 0.5rem" }}>{post.text || t("posts.noText")}</p>
<p className="hb-post-body" style={{ margin: "0 0 0.5rem" }}>{post.text || t("posts.noText")}</p>
<PostMetrics post={post} variant="compact" />
<p className="text-muted hb-compact-row__meta">
{formatLocalDateTime(post.published_at)}

View File

@ -29,20 +29,11 @@ body,
body {
font-family: var(--hb-font-sans);
font-size: var(--hb-text-base);
line-height: 1.58;
letter-spacing: -0.011em;
line-height: 1.5;
letter-spacing: 0;
font-synthesis: none;
color: var(--hb-ink);
/* 星圖網格 + 潮汐法光,低對比避免干擾資訊。 */
background:
radial-gradient(circle at 24px 24px, color-mix(in srgb, var(--hb-brand) 16%, transparent) 0 1px, transparent 1.4px) 0 0 / 56px 56px,
linear-gradient(color-mix(in srgb, var(--hb-brand) 4%, transparent) 1px, transparent 1px) 0 0 / 80px 80px,
linear-gradient(90deg, color-mix(in srgb, var(--hb-brand) 4%, transparent) 1px, transparent 1px) 0 0 / 80px 80px,
radial-gradient(ellipse 70% 50% at 0% -5%, var(--hb-aurora-1), transparent 55%),
radial-gradient(ellipse 55% 40% at 100% 0%, var(--hb-aurora-2), transparent 50%),
radial-gradient(ellipse 50% 35% at 50% 100%, var(--hb-aurora-3), transparent 55%),
var(--hb-bg);
background-attachment: fixed;
background: var(--hb-bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@ -55,7 +46,8 @@ h5,
h6 {
margin: 0;
font-weight: 700;
line-height: 1.35;
line-height: 1.2;
letter-spacing: var(--hb-track-heading);
color: var(--hb-ink);
}
@ -101,11 +93,10 @@ svg {
width: 2rem;
height: 2rem;
margin: 20vh auto;
border: 2px solid color-mix(in srgb, var(--hb-brand) 22%, var(--hb-line));
border-top-color: var(--hb-magic);
border: 2px solid var(--hb-line);
border-top-color: var(--hb-brand);
border-right-color: var(--hb-brand);
border-radius: 50%;
box-shadow: 0 0 18px var(--hb-magic-glow);
animation: hb-route-spin 0.7s linear infinite;
}
@ -155,15 +146,10 @@ svg {
color: var(--hb-ink);
}
/** 首頁:乾淨背景少一點「AI 漸層點陣」感 */
/** 首頁:暖紙畫布,白卡圖底 */
.hb-public--home {
padding: var(--hb-space-5) var(--hb-space-6) var(--hb-space-8);
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--hb-surface-solid) 88%, var(--hb-bg)) 0%,
var(--hb-bg) 12rem
);
padding: 0;
background: var(--hb-bg);
}
.hb-public__header {
@ -172,11 +158,22 @@ svg {
align-items: center;
justify-content: space-between;
gap: var(--hb-space-4);
max-width: 56rem;
max-width: 68rem;
width: 100%;
margin: 0 auto var(--hb-space-6);
}
.hb-public--home .hb-public__header {
position: sticky;
top: 0;
z-index: 20;
max-width: none;
margin: 0 0 var(--hb-space-6);
padding: var(--hb-space-4) var(--hb-space-6);
background: var(--hb-surface-solid);
border-bottom: 1px solid var(--hb-line);
}
.hb-public__brand {
display: flex;
align-items: center;
@ -191,12 +188,9 @@ svg {
.hb-public__brand .hb-brand-mark {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.75rem;
border-radius: var(--hb-radius-lg);
object-fit: cover;
box-shadow:
0 0 0 1px color-mix(in srgb, var(--hb-brand) 24%, transparent),
0 0 24px var(--hb-magic-glow),
0 8px 20px color-mix(in srgb, var(--hb-ink) 22%, transparent);
box-shadow: 0 0 0 1px var(--hb-line);
}
.hb-public__brand-text {
@ -268,8 +262,8 @@ svg {
.hb-public__nav-link {
font-size: var(--hb-text-sm);
font-weight: 600;
color: var(--hb-brand-deep);
font-weight: 400;
color: var(--hb-ink);
text-decoration: none;
}
@ -283,7 +277,7 @@ svg {
.hb-public__main {
flex: 1;
max-width: 56rem;
max-width: 68rem;
width: 100%;
margin: 0 auto;
display: flex;
@ -293,7 +287,7 @@ svg {
.hb-public--home .hb-public__main {
gap: clamp(2.5rem, 5vw, 4rem);
padding-bottom: var(--hb-space-6);
padding: 0 var(--hb-space-6) var(--hb-space-8);
}
.hb-public__main--narrow {
@ -304,8 +298,14 @@ svg {
display: flex;
flex-direction: column;
gap: var(--hb-space-5);
max-width: 36rem;
padding: var(--hb-space-4) 0 var(--hb-space-2);
max-width: 40rem;
padding: var(--hb-space-8) 0 var(--hb-space-2);
}
.hb-public__hero-cta {
display: flex;
flex-wrap: wrap;
gap: var(--hb-space-3);
}
.hb-public__hero-points {
@ -347,10 +347,8 @@ svg {
.hb-public__free-band {
padding: var(--hb-space-6);
border-radius: var(--hb-radius-lg);
border: 1px solid color-mix(in srgb, var(--hb-brand) 35%, var(--hb-line));
background:
radial-gradient(ellipse 70% 80% at 0% 0%, color-mix(in srgb, var(--hb-brand-soft) 70%, transparent), transparent 55%),
var(--hb-surface);
border: 1px solid var(--hb-line);
background: var(--hb-surface-solid);
display: flex;
flex-direction: column;
gap: var(--hb-space-5);
@ -465,17 +463,12 @@ svg {
}
.hb-public-preview__frame--radar {
box-shadow:
0 0 0 1px color-mix(in srgb, var(--hb-brand) 14%, transparent),
var(--hb-shadow-card);
box-shadow: none;
}
.hb-public-preview__frame--hero {
min-height: 16rem;
transform: rotate(-1.2deg);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--hb-brand) 18%, transparent),
0 18px 40px color-mix(in srgb, var(--hb-ink) 14%, transparent);
box-shadow: var(--hb-shadow-soft);
}
.hb-public-preview__chrome {
@ -519,9 +512,7 @@ svg {
display: flex;
flex-direction: column;
gap: 0.65rem;
background:
linear-gradient(180deg, color-mix(in srgb, var(--hb-brand-soft) 35%, transparent), transparent 60%),
var(--hb-bg);
background: var(--hb-bg);
}
.hb-mock-row {
@ -750,9 +741,9 @@ svg {
.hb-public__title {
margin: 0;
font-size: clamp(1.65rem, 3.2vw, 2.1rem);
line-height: 1.25;
letter-spacing: -0.025em;
font-size: var(--hb-text-display);
line-height: 1.04;
letter-spacing: var(--hb-track-display);
font-weight: 700;
color: var(--hb-ink);
}
@ -865,7 +856,7 @@ svg {
border-radius: var(--hb-radius);
border: 1px solid var(--hb-line-strong);
background: var(--hb-surface-solid, var(--hb-surface));
box-shadow: var(--hb-shadow-card);
box-shadow: var(--hb-shadow-float);
opacity: 0;
visibility: hidden;
pointer-events: none;
@ -917,20 +908,41 @@ svg {
.hb-public__feature-item {
margin: 0;
padding: var(--hb-space-4);
padding: var(--hb-space-6);
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg);
background: var(--hb-surface-solid, var(--hb-surface));
display: flex;
flex-direction: column;
gap: var(--hb-space-2);
box-shadow: none;
overflow: hidden;
position: relative;
}
.hb-public__feature-item::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 0;
height: 0.35rem;
background: var(--hb-brand);
}
.hb-public__feature-item[data-outcome="reply"]::before {
background: var(--hb-magic);
}
.hb-public__feature-item[data-outcome="close"]::before {
background: var(--hb-accent-warm);
}
.hb-public__feature-name {
margin: 0;
font-size: var(--hb-text-base);
font-weight: 650;
letter-spacing: -0.01em;
font-size: var(--hb-text-lg);
font-weight: 700;
letter-spacing: var(--hb-track-heading);
color: var(--hb-ink);
}
@ -966,9 +978,9 @@ svg {
.hb-public__section-title {
margin: 0;
font-size: var(--hb-text-lg);
font-weight: 650;
letter-spacing: -0.02em;
font-size: var(--hb-text-xl);
font-weight: 700;
letter-spacing: var(--hb-track-heading);
color: var(--hb-ink);
}
@ -992,11 +1004,12 @@ svg {
}
.hb-public__footer {
max-width: 64rem;
max-width: none;
width: 100%;
margin: var(--hb-space-8) auto 0;
padding-top: var(--hb-space-5);
margin: var(--hb-space-8) 0 0;
padding: var(--hb-space-8) var(--hb-space-6);
border-top: 1px solid var(--hb-line);
background: var(--hb-bg);
display: flex;
flex-wrap: wrap;
align-items: center;
@ -1005,6 +1018,13 @@ svg {
font-size: var(--hb-text-sm);
}
.hb-public:not(.hb-public--home) .hb-public__footer {
max-width: 68rem;
margin-left: auto;
margin-right: auto;
background: transparent;
}
.hb-public__footer-links {
display: flex;
flex-wrap: wrap;
@ -1037,12 +1057,7 @@ svg {
display: grid;
place-items: center;
padding: var(--hb-space-6);
background:
radial-gradient(circle at 28px 28px, color-mix(in srgb, var(--hb-brand) 20%, transparent) 0 1px, transparent 1.5px) 0 0 / 64px 64px,
radial-gradient(ellipse 70% 50% at 15% 0%, color-mix(in srgb, var(--hb-brand-dim) 18%, transparent), transparent 55%),
radial-gradient(ellipse 60% 45% at 90% 10%, color-mix(in srgb, var(--hb-brand) 13%, transparent), transparent 50%),
radial-gradient(ellipse 50% 40% at 50% 100%, color-mix(in srgb, var(--hb-brand-dim) 10%, transparent), transparent 50%),
var(--hb-bg);
background: var(--hb-bg);
}
.hb-login-brand {
@ -1055,12 +1070,9 @@ svg {
.hb-login-brand .hb-brand-mark {
width: 2.75rem;
height: 2.75rem;
border-radius: 0.75rem;
border-radius: var(--hb-radius-lg);
object-fit: cover;
box-shadow:
0 0 0 1px color-mix(in srgb, var(--hb-brand) 24%, transparent),
0 0 24px var(--hb-magic-glow),
0 8px 20px color-mix(in srgb, var(--hb-ink) 22%, transparent);
box-shadow: 0 0 0 1px var(--hb-line);
}
.hb-login-brand__text {
@ -1082,7 +1094,7 @@ svg {
.hb-login .hb-card {
width: min(100%, 26rem);
box-shadow: var(--hb-shadow-card);
box-shadow: var(--hb-shadow-soft);
}
.hb-login a {
@ -1289,10 +1301,9 @@ svg {
0 3px 10px color-mix(in srgb, var(--hb-brand) 12%, transparent);
}
/* 底色由 avatar_color 帶入後端配色皆為中間調近黑字在那組上有 6:1 以上
不再需要原本用來救白字的暗色陰影 */
/* 底色由 avatar_color 帶入(中間調),字色固定近黑,不跟主色 CTA 的白字走。 */
.hb-avatar--initials {
color: var(--hb-brand-on);
color: var(--hb-on-tint);
font-size: var(--hb-text-2xs);
font-weight: 700;
letter-spacing: 0.02em;
@ -2030,7 +2041,7 @@ svg {
background: var(--hb-surface);
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg, 0.75rem);
box-shadow: 0 18px 48px color-mix(in srgb, #000 28%, transparent);
box-shadow: var(--hb-shadow-float);
display: flex;
flex-direction: column;
gap: 0.85rem;
@ -2342,7 +2353,7 @@ svg {
background: var(--hb-surface);
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg, 0.75rem);
box-shadow: 0 18px 48px color-mix(in srgb, #000 28%, transparent);
box-shadow: var(--hb-shadow-float);
display: flex;
flex-direction: column;
overflow: hidden;
@ -2828,10 +2839,20 @@ svg {
line-height: 1.55;
}
.hb-post-body {
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.55;
}
.hb-scout-now__text {
margin: 0;
font-size: var(--hb-text-base);
line-height: 1.55;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.hb-scout-queue-item {
@ -2869,6 +2890,9 @@ svg {
.hb-scout-queue-item__text {
font-size: var(--hb-text-sm);
line-height: 1.45;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.hb-chip-row {
@ -2909,9 +2933,9 @@ svg {
.hb-brands__rail {
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-xl);
background: var(--hb-surface);
box-shadow: var(--hb-shadow-card);
border-radius: var(--hb-radius-lg);
background: var(--hb-surface-solid);
box-shadow: none;
padding: var(--hb-space-5);
display: flex;
flex-direction: column;
@ -3049,20 +3073,15 @@ svg {
width: 1.85rem;
height: 1.85rem;
border-radius: 50%;
background: linear-gradient(
145deg,
var(--hb-accent-warm),
var(--hb-brand)
);
background: var(--hb-brand);
color: var(--hb-brand-on);
font-size: var(--hb-text-xs);
font-weight: 700;
flex-shrink: 0;
}
/* 選中時只加深暖色那一端;漸層收到 --hb-brand-deep 會讓近黑字在深端看不見。 */
.hb-brands__chip.is-active .hb-brands__chip-mark {
background: linear-gradient(145deg, var(--hb-accent-warm), var(--hb-brand));
background: var(--hb-brand);
}
.hb-brands__chip-name {
@ -3073,9 +3092,9 @@ svg {
.hb-brands__panel {
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-xl);
background: var(--hb-surface);
box-shadow: var(--hb-shadow-card);
border-radius: var(--hb-radius-lg);
background: var(--hb-surface-solid);
box-shadow: none;
padding: var(--hb-space-5);
min-height: 14rem;
display: flex;
@ -3111,13 +3130,13 @@ svg {
width: 3rem;
height: 3rem;
border-radius: 50%;
background: linear-gradient(145deg, var(--hb-accent-warm), var(--hb-brand));
background: var(--hb-brand);
color: var(--hb-brand-on);
font-size: var(--hb-text-lg);
font-weight: 700;
flex-shrink: 0;
box-shadow: var(--hb-shadow-soft);
border: 2px solid color-mix(in srgb, var(--hb-surface) 70%, transparent);
box-shadow: none;
border: 1px solid var(--hb-line);
}
.hb-brands__panel-titles {
@ -3762,20 +3781,21 @@ svg {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.1rem 1.15rem;
padding: var(--hb-space-6);
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg);
background: var(--hb-surface);
border-radius: var(--hb-radius);
background: var(--hb-surface-solid);
min-width: 0;
}
.hb-pricing__card.is-popular {
border-color: color-mix(in srgb, var(--hb-brand) 45%, var(--hb-line));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--hb-brand) 18%, transparent);
background: var(--hb-bg);
border-color: var(--hb-line);
box-shadow: none;
}
.hb-pricing__card.is-current {
background: color-mix(in srgb, var(--hb-brand-soft, var(--hb-surface-muted)) 45%, var(--hb-surface));
background: var(--hb-bg);
}
.hb-pricing__title-row {
@ -5105,8 +5125,8 @@ a.hb-today-metric:hover {
flex-direction: column;
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg);
background: var(--hb-surface);
box-shadow: var(--hb-shadow-card);
background: var(--hb-surface-solid);
box-shadow: var(--hb-shadow-float);
z-index: 50;
overflow: hidden;
}
@ -5323,8 +5343,8 @@ a.hb-today-metric:hover {
width: min(15rem, 88vw);
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg);
background: var(--hb-surface);
box-shadow: var(--hb-shadow-card);
background: var(--hb-surface-solid);
box-shadow: var(--hb-shadow-float);
z-index: 50;
overflow: hidden;
}

View File

@ -20,9 +20,7 @@
z-index: 40;
display: flex;
flex-direction: column;
background: color-mix(in srgb, var(--hb-surface-solid) 82%, transparent);
backdrop-filter: saturate(130%) blur(16px);
-webkit-backdrop-filter: saturate(130%) blur(16px);
background: var(--hb-surface-solid);
}
.hb-topbar {
@ -269,21 +267,15 @@
height: 2rem;
flex-shrink: 0;
display: block;
border-radius: 0.65rem;
border-radius: var(--hb-radius-lg);
object-fit: cover;
box-shadow:
0 0 0 1px color-mix(in srgb, var(--hb-brand) 24%, transparent),
0 0 16px var(--hb-magic-glow),
0 5px 14px color-mix(in srgb, var(--hb-ink) 24%, transparent);
box-shadow: 0 0 0 1px var(--hb-line);
transition: transform 0.16s ease, box-shadow 0.16s ease;
}
.hb-topbar__brand:hover .hb-topbar__mark {
transform: translateY(-1px);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--hb-magic) 52%, transparent),
0 0 22px var(--hb-magic-glow),
0 7px 18px color-mix(in srgb, var(--hb-ink) 30%, transparent);
box-shadow: 0 0 0 1px var(--hb-line-strong);
}
.hb-topbar__title {
@ -335,9 +327,9 @@
gap: 0.4rem;
height: var(--hb-top-ctrl);
padding: 0 0.65rem 0 0.55rem;
border: 1px solid color-mix(in srgb, var(--hb-line) 90%, transparent);
border-radius: 0.55rem;
background: color-mix(in srgb, var(--hb-surface) 92%, var(--hb-surface-muted));
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius);
background: var(--hb-surface-solid);
color: var(--hb-ink-secondary);
font-size: var(--hb-text-xs);
cursor: pointer;
@ -388,16 +380,16 @@
/* 用量 + 鈴鐺 + 帳號:同一工具列(略方圓角,非膠囊) */
.hb-topbar__cluster {
--hb-top-ctrl: 2rem;
--hb-top-cluster-radius: 0.55rem;
--hb-top-item-radius: 0.4rem;
--hb-top-cluster-radius: var(--hb-radius);
--hb-top-item-radius: var(--hb-radius-xs);
display: inline-flex;
align-items: center;
gap: 0.15rem;
padding: 0.2rem;
border: 1px solid color-mix(in srgb, var(--hb-line) 90%, transparent);
border: 1px solid var(--hb-line);
border-radius: var(--hb-top-cluster-radius);
background: color-mix(in srgb, var(--hb-surface) 92%, var(--hb-surface-muted));
box-shadow: 0 1px 0 color-mix(in srgb, #fff 40%, transparent) inset;
background: var(--hb-surface-solid);
box-shadow: none;
}
.hb-topbar__cluster .hb-usage-widget__trigger,
@ -453,7 +445,7 @@
background: var(--hb-surface-solid, var(--hb-surface));
color: var(--hb-ink);
border-left: 1px solid var(--hb-line);
box-shadow: var(--hb-shadow-card, 0 8px 32px color-mix(in srgb, var(--hb-ink) 12%, transparent));
box-shadow: var(--hb-shadow-float);
padding: env(safe-area-inset-top, 0) env(safe-area-inset-right, 0) env(safe-area-inset-bottom, 0) 0;
}
@ -639,67 +631,14 @@ a.hb-topbar__chip:hover {
text-shadow: none;
}
/*
* 方案字光只貼在方案名上多層 text-shadow 朦朧
* 比極簡版明顯比整顆光暈小
*/
.hb-usage-widget__trigger.is-plan-starter .hb-usage-widget__badge {
color: var(--hb-brand-deep);
font-weight: 770;
animation: hb-plan-text-breathe-starter 2.8s ease-in-out infinite;
}
.hb-usage-widget__trigger.is-plan-pro .hb-usage-widget__badge {
color: color-mix(in srgb, var(--hb-gold) 70%, var(--hb-gold-bright) 30%);
color: var(--hb-gold);
font-weight: 790;
animation: hb-plan-text-breathe-pro 2.8s ease-in-out infinite;
}
@keyframes hb-plan-text-breathe-starter {
0%,
100% {
text-shadow:
0 0 3px color-mix(in srgb, var(--hb-brand) 35%, transparent),
0 0 8px color-mix(in srgb, var(--hb-brand) 22%, transparent),
0 0 14px color-mix(in srgb, var(--hb-brand) 12%, transparent);
}
50% {
text-shadow:
0 0 5px color-mix(in srgb, var(--hb-brand) 70%, transparent),
0 0 12px color-mix(in srgb, var(--hb-brand) 45%, transparent),
0 0 20px color-mix(in srgb, var(--hb-brand) 22%, transparent);
}
}
@keyframes hb-plan-text-breathe-pro {
0%,
100% {
text-shadow:
0 0 3px color-mix(in srgb, var(--hb-gold-bright) 40%, transparent),
0 0 8px color-mix(in srgb, var(--hb-gold) 28%, transparent),
0 0 14px color-mix(in srgb, var(--hb-gold) 14%, transparent);
}
50% {
text-shadow:
0 0 5px color-mix(in srgb, var(--hb-gold-bright) 75%, transparent),
0 0 12px color-mix(in srgb, var(--hb-gold-bright) 55%, transparent),
0 0 20px color-mix(in srgb, var(--hb-gold) 28%, transparent);
}
}
@media (prefers-reduced-motion: reduce) {
.hb-usage-widget__trigger.is-plan-starter .hb-usage-widget__badge {
animation: none;
text-shadow:
0 0 4px color-mix(in srgb, var(--hb-brand) 50%, transparent),
0 0 10px color-mix(in srgb, var(--hb-brand) 28%, transparent);
}
.hb-usage-widget__trigger.is-plan-pro .hb-usage-widget__badge {
animation: none;
text-shadow:
0 0 4px color-mix(in srgb, var(--hb-gold-bright) 55%, transparent),
0 0 10px color-mix(in srgb, var(--hb-gold) 30%, transparent);
}
}
.hb-usage-widget__sep {
@ -746,10 +685,8 @@ a.hb-topbar__chip:hover {
padding: 0.9rem 1rem;
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg);
background: var(--hb-surface);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--hb-line) 35%, transparent),
0 14px 36px color-mix(in srgb, #000 10%, transparent);
background: var(--hb-surface-solid);
box-shadow: var(--hb-shadow-float);
display: flex;
flex-direction: column;
gap: 0.75rem;
@ -942,10 +879,8 @@ a.hb-topbar__chip:hover {
.hb-sidebar {
display: none;
border-right: 1px solid color-mix(in srgb, var(--hb-line-strong, var(--hb-line)) 70%, transparent);
background: color-mix(in srgb, var(--hb-surface-solid) 84%, transparent);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-right: 1px solid var(--hb-line);
background: var(--hb-surface-solid);
padding: var(--hb-space-4) var(--hb-space-3);
}
@ -1048,18 +983,24 @@ a.hb-topbar__chip:hover {
color: var(--hb-brand-deep);
}
/*
* 選中 = 實心主色塊 + 近黑字10.3:1hover 只是淡底
* 兩者一眼分得出來也不必再靠一條被圓角切掉的 inset 3px 直線來區別
* 原本 hover 與選中的底色字色完全相同只差那條線
*/
.hb-nav__item--active,
.hb-nav__item--active:hover,
.hb-nav__item--active:focus-visible {
background: var(--hb-brand);
color: var(--hb-brand-on);
background: var(--hb-surface-muted);
color: var(--hb-ink);
font-weight: 700;
box-shadow: 0 1px 2px color-mix(in srgb, var(--hb-ink) 10%, transparent);
box-shadow: none;
}
.hb-nav__item--active::before {
content: "";
position: absolute;
left: 0.2rem;
top: 0.5rem;
bottom: 0.5rem;
width: 0.18rem;
border-radius: var(--hb-radius-pill);
background: var(--hb-brand);
}
.hb-nav__item--active .hb-nav__ico {
@ -1160,11 +1101,10 @@ a.hb-topbar__chip:hover {
.hb-page-title::after {
content: "";
width: 3.25rem;
height: 2px;
width: 2rem;
height: 1px;
border-radius: 99px;
background: linear-gradient(90deg, var(--hb-brand), var(--hb-magic), transparent);
box-shadow: 0 0 12px var(--hb-magic-glow);
background: var(--hb-line);
}
.hb-page-title p {
@ -1187,10 +1127,8 @@ a.hb-topbar__chip:hover {
gap: 0.15rem;
min-height: calc(var(--hb-dock-height) + env(safe-area-inset-bottom, 0px));
padding: 0.4rem 0.4rem calc(0.4rem + env(safe-area-inset-bottom, 0px));
border-top: 1px solid color-mix(in srgb, var(--hb-line-strong, var(--hb-line)) 50%, transparent);
background: color-mix(in srgb, var(--hb-surface-solid) 84%, transparent);
backdrop-filter: saturate(160%) blur(22px);
-webkit-backdrop-filter: saturate(160%) blur(22px);
border-top: 1px solid var(--hb-line);
background: var(--hb-surface-solid);
}
.hb-dock__item {
@ -1226,11 +1164,10 @@ a.hb-topbar__chip:hover {
background: var(--hb-brand-soft);
}
/* 與側欄選單同一套:選中是實心主色塊。 */
.hb-dock__item--active,
.hb-dock__item--active:focus-visible {
color: var(--hb-brand-on);
background: var(--hb-brand);
color: var(--hb-brand-deep);
background: var(--hb-brand-soft);
font-weight: 700;
}
@ -1310,8 +1247,8 @@ a.hb-topbar__chip:hover {
z-index: 1;
margin: 0;
padding: 0.45rem 0.85rem calc(0.85rem + env(safe-area-inset-bottom, 0px));
border-radius: 1.15rem 1.15rem 0 0;
background: var(--hb-surface);
border-radius: var(--hb-radius-xl) var(--hb-radius-xl) 0 0;
background: var(--hb-surface-solid);
box-shadow: var(--hb-shadow-float);
border: 1px solid var(--hb-line);
border-bottom: 0;
@ -1396,8 +1333,8 @@ a.hb-topbar__chip:hover {
.hb-dock-more__row.is-active,
.hb-dock-more__row.is-active:hover {
border-color: transparent;
background: var(--hb-brand);
color: var(--hb-brand-on);
background: var(--hb-brand-soft);
color: var(--hb-brand-deep);
font-weight: 700;
}

View File

@ -29,7 +29,8 @@
.hb-radar-section__title {
font-size: var(--hb-text-lg);
font-weight: 600;
font-weight: 700;
letter-spacing: var(--hb-track-heading);
color: var(--hb-ink);
}
@ -90,9 +91,9 @@
flex-direction: column;
gap: var(--hb-space-2);
padding: var(--hb-space-6);
border: 1px dashed var(--hb-line-strong);
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg);
background: var(--hb-surface-muted);
background: var(--hb-surface-solid);
color: var(--hb-muted);
font-size: var(--hb-text-sm);
}
@ -160,11 +161,23 @@
.hb-radar-actions {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
min-width: 0;
max-width: 100%;
gap: var(--hb-space-3);
}
.hb-radar-actions > .hb-btn {
flex: 0 0 auto;
align-self: flex-end;
min-height: 2.25rem;
padding: 0.35rem 0.85rem;
font-size: var(--hb-text-sm);
white-space: nowrap;
word-break: normal;
overflow-wrap: normal;
}
.hb-radar-watch-list {
display: flex;
flex-direction: column;
@ -180,8 +193,8 @@
padding: var(--hb-space-4);
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg);
background: var(--hb-surface);
box-shadow: var(--hb-shadow-card);
background: var(--hb-surface-solid);
box-shadow: none;
}
.hb-radar-watch--paused {
@ -299,12 +312,12 @@
display: flex;
flex-direction: column;
gap: var(--hb-space-3);
padding: var(--hb-space-4);
padding: var(--hb-space-6);
border: 1px solid var(--hb-line);
border-left: 3px solid var(--hb-line-strong);
border-left: 3px solid var(--hb-line);
border-radius: var(--hb-radius-lg);
background: var(--hb-surface);
box-shadow: var(--hb-shadow-card);
background: var(--hb-surface-solid);
box-shadow: none;
}
.hb-opp-card--high {
@ -316,7 +329,7 @@
}
.hb-opp-card--low {
border-left-color: var(--hb-subtle);
border-left-color: var(--hb-line-strong);
}
.hb-opp-card__head {
@ -345,11 +358,11 @@
justify-content: space-between;
gap: var(--hb-space-5);
margin-bottom: var(--hb-space-4);
padding: var(--hb-space-5);
border: 1px solid color-mix(in srgb, var(--hb-brand) 30%, var(--hb-line));
border-radius: var(--hb-radius-xl);
background: linear-gradient(135deg, var(--hb-brand-soft), var(--hb-surface));
box-shadow: var(--hb-shadow-card);
padding: var(--hb-space-6);
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg);
background: var(--hb-surface-solid);
box-shadow: none;
}
.hb-radar-intro > div {
@ -476,10 +489,17 @@
justify-content: space-between;
flex-wrap: wrap;
gap: var(--hb-space-3);
padding: var(--hb-space-3) var(--hb-space-4);
border: 1px solid color-mix(in srgb, var(--hb-brand) 28%, var(--hb-line));
padding: var(--hb-space-4);
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg);
background: var(--hb-brand-soft);
background: var(--hb-surface-solid);
}
.hb-radar-slot-grid {
display: flex;
flex-wrap: wrap;
gap: var(--hb-space-2);
margin-top: var(--hb-space-3);
}
.hb-radar-schedule > div {
@ -531,10 +551,10 @@
gap: var(--hb-space-4);
margin-bottom: var(--hb-space-5);
padding: var(--hb-space-5);
border: 1px solid var(--hb-line-strong);
border-radius: var(--hb-radius-xl);
background: var(--hb-surface);
box-shadow: var(--hb-shadow-card);
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg);
background: var(--hb-surface-solid);
box-shadow: none;
}
.hb-radar-setup__head {
@ -622,10 +642,13 @@
}
.hb-opp-card__text {
margin: 0;
font-size: var(--hb-text-base);
color: var(--hb-ink);
line-height: 1.65;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.hb-opp-card__secondary-actions {
@ -697,11 +720,16 @@
.hb-opp-drawer {
position: fixed;
z-index: 20;
inset: 0 0 0 auto;
z-index: 30;
top: var(--hb-header-offset, calc(var(--hb-topbar-height) + env(safe-area-inset-top, 0px)));
right: 0;
bottom: var(--hb-dock-offset, 0px);
left: auto;
display: flex;
flex-direction: column;
width: min(34rem, 100vw);
max-height: 100dvh;
overflow: hidden;
border-left: 1px solid var(--hb-line-strong);
background: var(--hb-surface-solid);
box-shadow: var(--hb-shadow-float);
@ -712,6 +740,7 @@
align-items: flex-start;
justify-content: space-between;
gap: var(--hb-space-3);
flex-shrink: 0;
padding: var(--hb-space-5);
border-bottom: 1px solid var(--hb-line);
}
@ -725,7 +754,10 @@
.hb-opp-drawer__body {
display: grid;
gap: var(--hb-space-4);
flex: 1;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
padding: var(--hb-space-5);
}
@ -1090,8 +1122,9 @@
flex-direction: column;
gap: var(--hb-space-2);
padding: var(--hb-space-5);
border: 1px dashed var(--hb-line);
border-radius: var(--hb-radius);
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg);
background: var(--hb-surface-solid);
}
.radar-card {

View File

@ -1,6 +1,7 @@
/**
* 巡樓 · Lapras Harbor Desk UI
* pale blue 主色 / cream 次色 / burnt orange 點綴Pokémon Palette
* 紙感畫布hairline 卡片pill CTA8px 工具鈕緊字距大標
* 字體Inter拉丁+ Noto Sans TC中文可變字重 400700
*
* 唯一真相顏色字級間距只在這個檔案定義其他 css tsx 一律吃 var()
@ -21,87 +22,94 @@
* 混出來一定是濁褐要調淡就跟 surfacelinetransparent 要一深一淺的
* 同一顏色就用 X X-deep
* 2. 中性色bg / surface / line / muted / subtle全部維持 H180-191 的冷青
* ink 與主色同溫palette 原本的 muted H=44 的暖褐疊在偏冷的白底上
* 會整片發黃發濁暖色只出現在刻意的強調上warningaccent方案金標
* ink 與主色同溫暖色只出現在刻意的強調上warningaccent方案金標
* cream--hb-magic與暖橘只當裝飾點綴不畫 CTA
*/
:root {
color-scheme: light;
--hb-bg: #fcfdfd;
--hb-surface: rgba(255, 255, 255, 0.9);
--hb-surface: #ffffff;
--hb-surface-solid: #ffffff;
--hb-surface-muted: #f2f6f6;
--hb-ink: #152628;
--hb-text: #152628;
--hb-fg: #152628;
--hb-ink-secondary: #33474a;
--hb-muted: #566869; /* 5.8:1 於 bg、5.4:1 於 surface-muted */
--hb-subtle: #748687; /* 3.8:1 — 裝飾/大字限定 */
--hb-line: color-mix(in srgb, #e0e9eb 88%, transparent);
--hb-muted: #566869;
--hb-subtle: #748687;
--hb-line: #e0e9eb;
--hb-line-strong: #e0e9eb;
--hb-scrim: rgb(21 38 40 / 0.55);
--hb-brand: #8bc5cd;
--hb-brand-hover: #76b8c1;
--hb-brand-soft: color-mix(in srgb, #8bc5cd 20%, #ffffff);
--hb-brand-on: #0a0a0a; /* 10.3:1 於 brand */
--hb-brand-deep: #2d5f66; /* 7.0:1 於 bg主色當文字時用 */
--hb-magic: #fee5a1; /* 次色奶油黃:只當淡底與裝飾 */
--hb-magic-glow: rgb(139 197 205 / 0.2);
--hb-brand-on: #0a0a0a;
--hb-on-tint: #0a0a0a;
--hb-brand-deep: #2d5f66;
--hb-magic: #fee5a1;
--hb-magic-glow: rgb(139 197 205 / 0.16);
--hb-focus: #2d5f66;
--hb-success: #2f7d3f; /* 純綠 H=135與 brand-deep 的青藍明顯分開 */
--hb-success: #2f7d3f;
--hb-success-soft: #eaf4ec;
--hb-success-on: #ffffff; /* 5.2:1 */
--hb-success-on: #ffffff;
--hb-danger: #ef4444;
--hb-danger-soft: #fdecec;
--hb-danger-on: #0a0a0a; /* 5.3:1白字只有 3.8:1 */
--hb-danger-deep: #c0392b; /* 5.3:1 於 bg */
--hb-danger-on: #0a0a0a;
--hb-danger-deep: #c0392b;
--hb-warning: #b96b1c;
--hb-warning-soft: #fbf1e3;
--hb-warning-on: #0a0a0a; /* 4.9:1白字只有 4.1:1 */
--hb-warning-deep: #a35d16; /* 5.1:1 於白卡 */
--hb-accent-warm: #b96b1c; /* 與 warning 同色palette 只有這一個暖點綴 */
--hb-warning-on: #0a0a0a;
--hb-warning-deep: #a35d16;
--hb-accent-warm: #b96b1c;
/* 畫布極光:主色的暗一階,只用來鋪背景 */
--hb-brand-dim: #6f9ba3;
--hb-aurora-1: color-mix(in srgb, #8bc5cd 20%, transparent);
--hb-aurora-2: color-mix(in srgb, #8bc5cd 11%, transparent);
--hb-aurora-3: color-mix(in srgb, #6f9ba3 12%, transparent);
--hb-aurora-1: color-mix(in srgb, #8bc5cd 10%, transparent);
--hb-aurora-2: color-mix(in srgb, #8bc5cd 6%, transparent);
--hb-aurora-3: color-mix(in srgb, #6f9ba3 6%, transparent);
/* 付費方案的金色字光(裝飾用,不當文字色) */
--hb-gold: #d9a531;
--hb-gold-bright: #fee5a1;
--hb-radius: 0.8rem;
--hb-radius-lg: 1rem;
--hb-radius-xl: 1.15rem;
--hb-radius-xs: 0.25rem;
--hb-radius-sm: 0.3125rem;
--hb-radius: 0.5rem;
--hb-radius-lg: 0.75rem;
--hb-radius-xl: 1rem;
--hb-radius-pill: 9999px;
--hb-shadow-card:
0 0 0 1px color-mix(in srgb, var(--hb-line-strong) 70%, transparent),
0 1px 2px rgb(21 38 40 / 0.04),
0 9px 28px rgb(21 38 40 / 0.06);
--hb-shadow-soft: 0 4px 18px var(--hb-magic-glow);
--hb-shadow-float: 0 16px 48px rgb(21 38 40 / 0.12);
--hb-shadow-glow: 0 0 24px rgb(139 197 205 / 0.4);
--hb-shadow-card: none;
--hb-shadow-soft:
rgb(21 38 40 / 0.01) 0 0.175px 1.041px,
rgb(21 38 40 / 0.02) 0 0.8px 2.925px,
rgb(21 38 40 / 0.027) 0 2.025px 7.847px,
rgb(21 38 40 / 0.04) 0 4px 18px;
--hb-shadow-float:
rgb(21 38 40 / 0.015) 0 0.7px 2.2px,
rgb(21 38 40 / 0.02) 0 2.4px 7px,
rgb(21 38 40 / 0.03) 0 6px 18px,
rgb(21 38 40 / 0.04) 0 12px 32px,
rgb(21 38 40 / 0.05) 0 23px 52px;
--hb-shadow-glow: 0 0 0 3px var(--hb-brand-soft);
/* Latin → Inter中文只走 Noto Sans TC400700含 500600650。 */
--hb-font-sans: "Inter", "Noto Sans TC", system-ui, -apple-system, "Segoe UI", sans-serif;
--hb-font-en: var(--hb-font-sans);
--hb-font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
/* 字階只有這 7 之間沒有中間值 0.8 / 0.82 / 0.85 併存看不出差別
只會讓整頁失去節奏輸入框維持 16px 避免 iOS 聚焦時縮放 */
--hb-text-2xs: 0.75rem; /* 12px · badge極小 meta */
--hb-text-xs: 0.8125rem; /* 13px · 次要 meta */
--hb-text-sm: 0.875rem; /* 14px · 按鈕/輔助 */
--hb-text-base: 1rem; /* 16px · 內文 */
--hb-text-lg: 1.125rem; /* 18px · 卡片標題 */
--hb-text-xl: 1.375rem; /* 22px · 頁標題 */
--hb-text-2xl: 1.75rem; /* 28px · 登入/定價大標 */
--hb-text-input: 1rem; /* 16px · 表單 */
--hb-text-2xs: 0.75rem;
--hb-text-xs: 0.8125rem;
--hb-text-sm: 0.875rem;
--hb-text-base: 1rem;
--hb-text-lg: 1.125rem;
--hb-text-xl: 1.375rem;
--hb-text-2xl: 1.75rem;
--hb-text-display: clamp(2.25rem, 4.8vw, 3.375rem);
--hb-text-input: 1rem;
--hb-track-display: -0.035em;
--hb-track-heading: -0.025em;
--hb-space-1: 0.25rem;
--hb-space-2: 0.5rem;
@ -111,12 +119,12 @@
--hb-space-6: 1.5rem;
--hb-space-8: 2rem;
--hb-space-10: 2.5rem;
--hb-gap-tight: 0.5rem; /* 列內小元件 */
--hb-gap-inline: 0.625rem; /* 同列按鈕、chip */
--hb-gap-stack: 0.8rem; /* 表單欄位、stack 預設 */
--hb-gap-block: 1.05rem; /* 卡片內大段 */
--hb-gap-section: 1.3rem; /* 頁面區塊與區塊 */
--hb-gap-page: 1.75rem; /* 頁面上下大留白(桌面) */
--hb-gap-tight: 0.5rem;
--hb-gap-inline: 0.625rem;
--hb-gap-stack: 0.8rem;
--hb-gap-block: 1.05rem;
--hb-gap-section: 1.3rem;
--hb-gap-page: 1.75rem;
--hb-touch: 2.75rem;
--hb-sidebar-width: 15rem;
@ -125,60 +133,62 @@
}
/**
* Dark冷灰藍畫布暖褐色作為 muted palette --muted 就是暖色
* 深底下亮色本身對比就夠所以 -deep 反而是更亮的一階
* Dark冷灰藍畫布主色仍是淺藍當文字走更亮的 --hb-brand-deep
*/
[data-theme="dark"] {
color-scheme: dark;
--hb-bg: #141a1a;
--hb-surface: rgba(27, 38, 39, 0.9);
--hb-surface: #1b2627;
--hb-surface-solid: #1b2627;
--hb-surface-muted: #243132;
--hb-ink: #f4f6f6;
--hb-text: #f4f6f6;
--hb-fg: #f4f6f6;
--hb-ink-secondary: #d3dcdc;
--hb-muted: #a9b7b7; /* 8.5:1 */
--hb-subtle: #8b9a9a; /* 6.0:1 */
--hb-line: color-mix(in srgb, #2d4143 92%, transparent);
--hb-muted: #a9b7b7;
--hb-subtle: #8b9a9a;
--hb-line: #2d4143;
--hb-line-strong: #2d4143;
--hb-scrim: rgb(0 0 0 / 0.66);
--hb-brand: #89c5cd;
--hb-brand-hover: #a3d4da;
--hb-brand-soft: color-mix(in srgb, #89c5cd 14%, #1b2627);
--hb-brand-on: #0a0a0a; /* 10.3:1 */
--hb-brand-on: #0a0a0a;
--hb-on-tint: #0a0a0a;
--hb-brand-deep: #b7e3e8;
--hb-magic: #fee59f;
--hb-magic-glow: rgb(137 197 205 / 0.18);
--hb-magic-glow: rgb(137 197 205 / 0.16);
--hb-focus: #89c5cd;
--hb-success: #74d189; /* 9.4:1純綠 */
--hb-success: #74d189;
--hb-success-soft: #16251a;
--hb-success-on: #0a0a0a;
--hb-danger: #dc2626;
--hb-danger-soft: #341919;
--hb-danger-on: #ffffff; /* 4.8:1 —— 深底的紅維持白字 */
--hb-danger-deep: #f87171; /* 5.6:1 於卡片;#ef4444 只有 4.1:1 */
--hb-warning: #e18c37; /* 6.7:1 */
--hb-danger-on: #ffffff;
--hb-danger-deep: #f87171;
--hb-warning: #e18c37;
--hb-warning-soft: #2f2416;
--hb-warning-on: #0a0a0a;
--hb-warning-deep: #e18c37; /* 深底不必再加深 */
--hb-warning-deep: #e18c37;
--hb-accent-warm: #e18c37;
--hb-brand-dim: #7fb0b8;
--hb-aurora-1: color-mix(in srgb, #89c5cd 14%, transparent);
--hb-aurora-2: color-mix(in srgb, #89c5cd 7%, transparent);
--hb-aurora-3: color-mix(in srgb, #7fb0b8 8%, transparent);
--hb-aurora-1: color-mix(in srgb, #89c5cd 10%, transparent);
--hb-aurora-2: color-mix(in srgb, #89c5cd 5%, transparent);
--hb-aurora-3: color-mix(in srgb, #7fb0b8 6%, transparent);
--hb-gold: #e1b84f;
--hb-gold-bright: #fee59f;
--hb-shadow-card:
0 0 0 1px color-mix(in srgb, #2d4143 90%, transparent),
0 10px 36px rgb(0 0 0 / 0.45);
--hb-shadow-soft: 0 4px 22px var(--hb-magic-glow);
--hb-shadow-float: 0 20px 56px rgb(0 0 0 / 0.55);
--hb-shadow-glow: 0 0 32px rgb(137 197 205 / 0.28);
--hb-shadow-card: none;
--hb-shadow-soft:
rgba(0, 0, 0, 0.22) 0 1px 2px,
rgba(0, 0, 0, 0.16) 0 4px 16px;
--hb-shadow-float:
rgba(0, 0, 0, 0.26) 0 8px 24px,
rgba(0, 0, 0, 0.34) 0 20px 48px;
--hb-shadow-glow: 0 0 0 3px var(--hb-brand-soft);
}

View File

@ -7,14 +7,14 @@
min-width: 0;
max-width: 100%;
gap: var(--hb-space-2);
min-height: 2.65rem;
min-height: var(--hb-touch);
padding: 0.5rem 1rem;
border-radius: var(--hb-radius);
border: 1px solid transparent;
font-weight: 600;
font-size: var(--hb-text-sm);
line-height: 1.35;
letter-spacing: -0.01em;
font-weight: 500;
font-size: var(--hb-text-base);
line-height: 1.5;
letter-spacing: 0;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
@ -39,46 +39,60 @@
}
/*
* 主按鈕選中的 tab側欄與 dock 的選中項共用同一個配方實心 --hb-brand +
* --hb-brand-on主色是淺藍漸層到 --hb-brand-deep 會讓近黑字在深端失去對比
* 所以這裡不用漸層深淺兩個主題也不需要各寫一份
* CTAHarbor 主色填滿 + pillutilityghost 才走 8px
* cream暖橘只當裝飾不畫按鈕
*/
.hb-btn--primary {
background: var(--hb-brand);
color: var(--hb-brand-on);
box-shadow: var(--hb-shadow-soft);
border-color: transparent;
border-radius: var(--hb-radius-pill);
}
.hb-btn--primary:hover:not(:disabled) {
transform: translateY(-0.5px);
background: var(--hb-brand-hover);
box-shadow: var(--hb-shadow-glow), var(--hb-shadow-soft);
}
.hb-btn--primary:active:not(:disabled) {
background: var(--hb-brand-hover);
transform: scale(0.96);
}
.hb-btn--secondary {
background: color-mix(in srgb, var(--hb-brand) 10%, var(--hb-surface-solid));
background: var(--hb-surface-solid);
color: var(--hb-ink);
border-color: transparent;
box-shadow: 0 1px 2px color-mix(in srgb, var(--hb-ink) 6%, transparent);
border-radius: var(--hb-radius-pill);
box-shadow: var(--hb-shadow-soft);
}
.hb-btn--secondary:hover:not(:disabled) {
background: color-mix(in srgb, var(--hb-magic) 22%, var(--hb-surface-solid));
background: var(--hb-surface-solid);
color: var(--hb-ink);
}
.hb-btn--secondary:active:not(:disabled) {
transform: scale(0.96);
}
.hb-btn--ghost {
background: color-mix(in srgb, var(--hb-surface-solid) 70%, transparent);
color: var(--hb-ink-secondary);
border-color: var(--hb-line-strong, var(--hb-line));
backdrop-filter: blur(8px);
background: var(--hb-surface-solid);
color: var(--hb-ink);
border-color: var(--hb-line);
border-radius: var(--hb-radius);
padding: 0.25rem 0.875rem;
}
.hb-btn--ghost:hover:not(:disabled) {
background: var(--hb-brand-soft);
color: var(--hb-brand-deep);
border-color: color-mix(in srgb, var(--hb-brand) 40%, var(--hb-line));
background: var(--hb-surface-muted);
color: var(--hb-ink);
}
@media (prefers-reduced-motion: reduce) {
.hb-btn--primary:active:not(:disabled),
.hb-btn--secondary:active:not(:disabled) {
transform: none;
}
}
.hb-btn--danger {
@ -187,9 +201,9 @@
.hb-select {
width: 100%;
min-height: 2.8rem;
padding: 0.65rem 0.95rem;
border: 1px solid var(--hb-line-strong, var(--hb-line));
border-radius: var(--hb-radius);
padding: 0.4rem 0.65rem;
border: 1px solid var(--hb-line-strong);
border-radius: var(--hb-radius-xs);
background: var(--hb-surface-solid, var(--hb-surface));
color: var(--hb-ink);
font-size: var(--hb-text-input, 1rem);
@ -201,8 +215,8 @@
.hb-input:focus,
.hb-textarea:focus,
.hb-select:focus {
border-color: color-mix(in srgb, var(--hb-brand) 65%, var(--hb-line));
box-shadow: 0 0 0 3px var(--hb-brand-soft);
border-color: var(--hb-line-strong);
box-shadow: var(--hb-shadow-soft);
}
.hb-input::placeholder,
@ -226,13 +240,11 @@
}
.hb-card {
background: var(--hb-surface);
backdrop-filter: blur(12px) saturate(125%);
-webkit-backdrop-filter: blur(12px) saturate(125%);
border: 1px solid color-mix(in srgb, var(--hb-brand) 14%, var(--hb-line));
border-radius: var(--hb-radius-xl);
box-shadow: var(--hb-shadow-card);
padding: var(--hb-space-4);
background: var(--hb-surface-solid);
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg);
box-shadow: none;
padding: var(--hb-space-6);
min-width: 0;
max-width: 100%;
margin: 0;
@ -247,7 +259,7 @@
@media (min-width: 600px) {
.hb-card {
padding: var(--hb-space-5);
padding: var(--hb-space-6);
}
}
@ -255,8 +267,8 @@
margin: 0;
font-size: var(--hb-text-lg);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.35;
letter-spacing: var(--hb-track-heading);
line-height: 1.27;
}
.hb-card__body {
@ -278,11 +290,11 @@
display: inline-flex;
align-items: center;
max-width: 100%;
padding: 0.25rem 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: var(--hb-radius-pill);
font-size: var(--hb-text-xs);
font-weight: 650;
line-height: 1.35;
font-size: var(--hb-text-2xs);
font-weight: 600;
line-height: 1.33;
white-space: normal;
overflow-wrap: anywhere;
letter-spacing: 0.01em;
@ -296,9 +308,9 @@
}
.hb-badge--brand {
background: var(--hb-brand-soft);
background: var(--hb-surface-solid);
color: var(--hb-brand-deep);
border-color: color-mix(in srgb, var(--hb-brand) 28%, transparent);
border-color: var(--hb-line);
}
.hb-badge--success {
@ -324,10 +336,10 @@
flex-direction: column;
align-items: flex-start;
gap: var(--hb-gap-stack);
padding: var(--hb-space-6) var(--hb-space-5);
border: 1px dashed var(--hb-line);
padding: var(--hb-space-6);
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg);
background: var(--hb-surface);
background: var(--hb-surface-solid);
margin: 0;
}
@ -350,12 +362,12 @@
gap: 0.25rem;
margin: 0 0 var(--hb-gap-stack);
padding: 0.28rem;
border: 1px solid color-mix(in srgb, var(--hb-line) 85%, transparent);
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-pill);
background: color-mix(in srgb, var(--hb-surface) 70%, var(--hb-brand-soft));
background: var(--hb-surface-muted);
width: 100%;
max-width: 100%;
box-shadow: 0 1px 2px color-mix(in srgb, var(--hb-brand) 8%, transparent);
box-shadow: none;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
@ -422,28 +434,21 @@
/* segment 樣式的 tab 是「白片浮起」,選中只換字色。 */
.hb-tab.is-active {
background: var(--hb-surface);
color: var(--hb-brand-deep);
box-shadow: 0 1px 4px color-mix(in srgb, var(--hb-brand-deep) 18%, transparent);
background: var(--hb-surface-solid);
color: var(--hb-ink);
box-shadow: var(--hb-shadow-soft);
}
/* 一般 tab 走與側欄選單相同的實心主色塊。 */
.hb-tabs:not(.hb-tabs--segment) .hb-tab.is-active {
background: var(--hb-brand);
color: var(--hb-brand-on);
box-shadow: 0 2px 8px color-mix(in srgb, var(--hb-brand-deep) 24%, transparent);
background: var(--hb-surface-solid);
color: var(--hb-brand-deep);
box-shadow: var(--hb-shadow-soft);
}
[data-theme="dark"] .hb-badge--brand {
background: color-mix(in srgb, var(--hb-brand) 14%, var(--hb-surface-solid));
color: var(--hb-brand-deep);
border-color: color-mix(in srgb, var(--hb-brand) 28%, transparent);
}
[data-theme="dark"] .hb-nav__item--active,
[data-theme="dark"] .hb-dock__item--active {
background: color-mix(in srgb, var(--hb-brand) 14%, transparent);
background: var(--hb-surface-solid);
color: var(--hb-brand-deep);
border-color: var(--hb-line);
}
.hb-grid-2 {
@ -905,10 +910,10 @@
list-style: none;
margin: 0;
padding: 0.25rem 0;
border: 1px solid color-mix(in srgb, var(--hb-line) 90%, transparent);
border-radius: var(--hb-radius-xl);
background: var(--hb-surface);
box-shadow: var(--hb-shadow-card);
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg);
background: var(--hb-surface-solid);
box-shadow: none;
overflow: hidden;
display: flex;
flex-direction: column;