fix all bug
This commit is contained in:
parent
2f1336b932
commit
e4569d1c2a
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -115,24 +115,34 @@ async function readPosts(page: Page, query: string, limit: number): Promise<Post
|
|||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
// 找小範圍卡片:往上最多 8 層,取文字長度 20–800 的最近祖先
|
||||
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] || "";
|
||||
|
|
|
|||
|
|
@ -95,6 +95,17 @@ type (
|
|||
ProductId string `json:"product_id,optional"`
|
||||
}
|
||||
|
||||
// Hours are 0–23 in Asia/Taipei. Empty GET means default [6] (台北 06:00).
|
||||
// int64:go-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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 0–23 (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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 lock(value = 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++
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })}`,
|
||||
|
|
|
|||
|
|
@ -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) &&
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -895,6 +895,12 @@ export type RadarWatchStatus = "active" | "paused" | "archived";
|
|||
export type RadarWatchContextMode = "generic" | "product";
|
||||
export type RadarWatchPauseReason = "user" | "product_unavailable" | "brand_unavailable";
|
||||
|
||||
/** 自動巡邏時段(台北當地小時 0–23) */
|
||||
export type RadarSchedule = {
|
||||
hours: number[];
|
||||
timezone: string;
|
||||
};
|
||||
|
||||
/** 雷達訂閱:常駐關鍵字監控 */
|
||||
export type RadarWatch = {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:00(UTC 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} 組",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" }]));
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:1);hover 只是淡底。
|
||||
* 兩者一眼分得出來,也不必再靠一條被圓角切掉的 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/**
|
||||
* 巡樓 · Lapras — Harbor Desk UI
|
||||
* 色:pale blue 主色 / cream 次色 / burnt orange 點綴(Pokémon Palette)
|
||||
* 形:紙感畫布、hairline 卡片、pill 主 CTA、8px 工具鈕、緊字距大標。
|
||||
* 字體:Inter(拉丁)+ Noto Sans TC(中文,可變字重 400–700)
|
||||
*
|
||||
* 唯一真相:顏色、字級、間距只在這個檔案定義。其他 css 與 tsx 一律吃 var(),
|
||||
|
|
@ -21,87 +22,94 @@
|
|||
* 混出來一定是濁褐;要調淡就跟 surface/line/transparent 混。要一深一淺的
|
||||
* 同一顏色就用 X 與 X-deep。
|
||||
* 2. 中性色(bg / surface / line / muted / subtle)全部維持 H≈180-191 的冷青,
|
||||
* 跟 ink 與主色同溫。palette 原本的 muted 是 H=44 的暖褐,疊在偏冷的白底上
|
||||
* 會整片發黃發濁。暖色只出現在刻意的強調上:warning、accent、方案金標。
|
||||
* 跟 ink 與主色同溫。暖色只出現在刻意的強調上:warning、accent、方案金標。
|
||||
* 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 TC(400–700,含 500/600/650)。 */
|
||||
--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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 會讓近黑字在深端失去對比,
|
||||
* 所以這裡不用漸層,深淺兩個主題也不需要各寫一份。
|
||||
* 主 CTA:Harbor 主色填滿 + 全 pill。utility/ghost 才走 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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue