From 8ce67648b132c59c399a544100f22ee0d32e6b2d Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Thu, 22 Oct 2026 21:56:07 +0800 Subject: [PATCH] feat: add global system notices and Beijing order time display --- internal/httpapi/api.go | 1 + internal/httpapi/portal.go | 21 +++ internal/portal/service.go | 12 +- internal/portal/service_test.go | 2 +- internal/store/portal.go | 49 ++++--- .../store/portal_notice_integration_test.go | 85 ++++++++++++ internal/store/portal_notice_test.go | 13 ++ internal/store/tasks.go | 2 +- internal/store/time.go | 8 ++ internal/store/time_integration_test.go | 48 +++++++ internal/tasks/service.go | 9 +- internal/tasks/service_test.go | 9 ++ web/src/mobile/MobileShell.vue | 10 ++ web/src/mobile/api.test.ts | 25 ++++ web/src/mobile/api.ts | 3 +- .../mobile/assets/system-notice-popup@2x.png | Bin 0 -> 1446336 bytes .../mobile/components/AnnouncementPopup.vue | 99 ++++++-------- .../mobile/components/SystemNoticeHost.vue | 100 +++++++++++++++ web/src/mobile/styles.css | 58 ++++++++- web/src/mobile/systemNotices.ts | 13 ++ web/src/mobile/views/HomeView.vue | 121 +++++++++++++----- web/src/mobile/views/OrdersView.vue | 14 ++ 22 files changed, 581 insertions(+), 121 deletions(-) create mode 100644 internal/store/portal_notice_integration_test.go create mode 100644 internal/store/portal_notice_test.go create mode 100644 internal/store/time.go create mode 100644 internal/store/time_integration_test.go create mode 100644 web/src/mobile/api.test.ts create mode 100644 web/src/mobile/assets/system-notice-popup@2x.png create mode 100644 web/src/mobile/components/SystemNoticeHost.vue create mode 100644 web/src/mobile/systemNotices.ts diff --git a/internal/httpapi/api.go b/internal/httpapi/api.go index 49c242d..0fe52c5 100644 --- a/internal/httpapi/api.go +++ b/internal/httpapi/api.go @@ -45,6 +45,7 @@ func (a *API) Register(mux *http.ServeMux) { mux.HandleFunc("/v1/customer-services/", a.customerServiceResource) mux.HandleFunc("/v1/notices", a.notices) mux.HandleFunc("/v1/notices/popup", a.popupNotice) + mux.HandleFunc("/v1/notices/pending", a.pendingNotice) mux.HandleFunc("/v1/notices/", a.noticeResource) mux.HandleFunc("/v1/task-halls", a.taskHalls) mux.HandleFunc("/v1/tasks/eligibility", a.taskEligibility) diff --git a/internal/httpapi/portal.go b/internal/httpapi/portal.go index 6a9f6ed..296c187 100644 --- a/internal/httpapi/portal.go +++ b/internal/httpapi/portal.go @@ -211,6 +211,27 @@ func (a *API) popupNotice(w http.ResponseWriter, r *http.Request) { writeSuccess(w, r, result) } +func (a *API) pendingNotice(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "private, no-store") + if r.Method != http.MethodGet { + methodNotAllowed(w, r, http.MethodGet) + return + } + if !a.ensurePortal(w, r) { + return + } + principal, ok := a.authenticateMember(w, r) + if !ok { + return + } + result, err := a.portal.PendingNotice(r.Context(), principal.TenantID, principal.MemberID) + if err != nil { + writePortalError(w, r, err) + return + } + writeSuccess(w, r, result) +} + func (a *API) noticeResource(w http.ResponseWriter, r *http.Request) { path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/notices/"), "/") parts := strings.Split(path, "/") diff --git a/internal/portal/service.go b/internal/portal/service.go index 805e8fb..209c7c0 100644 --- a/internal/portal/service.go +++ b/internal/portal/service.go @@ -22,7 +22,7 @@ type Repository interface { PublishedContent(context.Context, int64, string) (*store.Content, error) ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error) ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error) - LatestPopupNotice(context.Context, int64, int64) (*store.MemberNotice, error) + NextUnreadMemberNotice(context.Context, int64, int64) (*store.MemberNotice, error) MarkMemberNoticeRead(context.Context, int64, int64, int64) error MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error) MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error) @@ -220,6 +220,16 @@ func (s *Service) PopupNotice(ctx context.Context, tenantID, memberID int64) (*N SourceType: "managed_content", CreatedAt: row.PublishedAt}, nil } +func (s *Service) PendingNotice(ctx context.Context, tenantID, memberID int64) (*Notice, error) { + row, err := s.repository.NextUnreadMemberNotice(ctx, tenantID, memberID) + if err != nil || row == nil { + return nil, err + } + return &Notice{ID: row.ID, Title: row.Title, Content: row.Content, + SourceType: noticeSourceName(row.SourceType), StartAt: row.StartAt, + EndAt: row.EndAt, CreatedAt: row.CreatedAt}, nil +} + func (s *Service) MarkNoticeRead(ctx context.Context, tenantID, memberID, noticeID int64) error { return s.repository.MarkMemberNoticeRead(ctx, tenantID, memberID, noticeID) } diff --git a/internal/portal/service_test.go b/internal/portal/service_test.go index ff49b09..4df575b 100644 --- a/internal/portal/service_test.go +++ b/internal/portal/service_test.go @@ -42,7 +42,7 @@ func (r fakeRepository) ActiveMemberLevels(context.Context, int64) ([]store.Memb func (r fakeRepository) ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error) { return r.notices, nil } -func (r fakeRepository) LatestPopupNotice(context.Context, int64, int64) (*store.MemberNotice, error) { +func (r fakeRepository) NextUnreadMemberNotice(context.Context, int64, int64) (*store.MemberNotice, error) { if len(r.notices) == 0 { return nil, nil } diff --git a/internal/store/portal.go b/internal/store/portal.go index 4b3a539..447252f 100644 --- a/internal/store/portal.go +++ b/internal/store/portal.go @@ -5,10 +5,21 @@ import ( "database/sql" "encoding/json" "errors" + "time" ) const publishStatusPublished = 3 +var noticeBusinessLocation = time.FixedZone("Asia/Shanghai", 8*60*60) + +func noticeBusinessNow() string { + return formatNoticeBusinessTime(time.Now()) +} + +func formatNoticeBusinessTime(value time.Time) string { + return value.In(noticeBusinessLocation).Format("2006-01-02 15:04:05.999999999") +} + type Banner struct { ID int64 `db:"id"` ImageURL string `db:"image_url"` @@ -154,29 +165,33 @@ func (s *Store) ActiveMemberNotices(ctx context.Context, tenantID, memberID int6 return nil, errors.New("store is unavailable") } rows := make([]MemberNotice, 0) + now := noticeBusinessNow() err := s.db.SelectContext(ctx, &rows, `SELECT id,title,content,source_type, DATE_FORMAT(start_at,'%Y-%m-%dT%H:%i:%sZ') AS start_at, DATE_FORMAT(end_at,'%Y-%m-%dT%H:%i:%sZ') AS end_at, DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at FROM hg_app_member_notice WHERE tenant_id=? AND member_id IN (0,?) AND notice_status=1 - AND start_at<=UTC_TIMESTAMP(3) AND end_at>UTC_TIMESTAMP(3) - ORDER BY start_at DESC,id DESC LIMIT ?`, tenantID, memberID, limit) + AND start_at<=? AND end_at>? + ORDER BY start_at DESC,id DESC LIMIT ?`, tenantID, memberID, now, now, limit) return rows, err } -func (s *Store) LatestPopupNotice(ctx context.Context, tenantID, memberID int64) (*MemberNotice, error) { +func (s *Store) NextUnreadMemberNotice(ctx context.Context, tenantID, memberID int64) (*MemberNotice, error) { if s == nil || s.db == nil { return nil, errors.New("store is unavailable") } var row MemberNotice - err := s.db.GetContext(ctx, &row, `SELECT id,title,content,source_type, - DATE_FORMAT(start_at,'%Y-%m-%dT%H:%i:%sZ') AS start_at, - DATE_FORMAT(end_at,'%Y-%m-%dT%H:%i:%sZ') AS end_at, - DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at - FROM hg_app_member_notice - WHERE tenant_id=? AND member_id IN (0,?) AND notice_status=1 - AND start_at<=UTC_TIMESTAMP(3) AND end_at>UTC_TIMESTAMP(3) - ORDER BY start_at DESC,id DESC LIMIT 1`, tenantID, memberID) + now := noticeBusinessNow() + err := s.db.GetContext(ctx, &row, `SELECT n.id,n.title,n.content,n.source_type, + DATE_FORMAT(n.start_at,'%Y-%m-%dT%H:%i:%sZ') AS start_at, + DATE_FORMAT(n.end_at,'%Y-%m-%dT%H:%i:%sZ') AS end_at, + DATE_FORMAT(n.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at + FROM hg_app_member_notice n + WHERE n.tenant_id=? AND n.member_id=? AND n.notice_status=1 + AND n.start_at<=? AND n.end_at>? + AND NOT EXISTS (SELECT 1 FROM hg_app_member_notice_read r + WHERE r.tenant_id=n.tenant_id AND r.notice_id=n.id AND r.member_id=?) + ORDER BY n.start_at ASC,n.id ASC LIMIT 1`, tenantID, memberID, now, now, memberID) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -187,13 +202,14 @@ func (s *Store) MarkMemberNoticeRead(ctx context.Context, tenantID, memberID, no if s == nil || s.db == nil { return errors.New("store is unavailable") } + now := noticeBusinessNow() result, err := s.db.ExecContext(ctx, `INSERT INTO hg_app_member_notice_read (tenant_id,notice_id,member_id,read_at,created_at) - SELECT n.tenant_id,n.id,?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3) + SELECT n.tenant_id,n.id,?,?,? FROM hg_app_member_notice n WHERE n.tenant_id=? AND n.id=? AND n.member_id IN (0,?) AND n.notice_status=1 - AND n.start_at<=UTC_TIMESTAMP(3) AND n.end_at>UTC_TIMESTAMP(3) - ON DUPLICATE KEY UPDATE read_at=VALUES(read_at)`, memberID, tenantID, noticeID, memberID) + AND n.start_at<=? AND n.end_at>? + ON DUPLICATE KEY UPDATE read_at=VALUES(read_at)`, memberID, now, now, tenantID, noticeID, memberID, now, now) if err != nil { return err } @@ -212,8 +228,9 @@ func (s *Store) MemberNotices(ctx context.Context, tenantID, memberID int64, off return nil, errors.New("store is unavailable") } const where = ` WHERE tenant_id=? AND member_id IN (0,?) AND notice_status=1 - AND start_at<=UTC_TIMESTAMP(3) AND end_at>UTC_TIMESTAMP(3)` - args := []any{tenantID, memberID} + AND start_at<=? AND end_at>?` + now := noticeBusinessNow() + args := []any{tenantID, memberID, now, now} var total int64 if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_app_member_notice`+where, args...); err != nil { return nil, err diff --git a/internal/store/portal_notice_integration_test.go b/internal/store/portal_notice_integration_test.go new file mode 100644 index 0000000..b559685 --- /dev/null +++ b/internal/store/portal_notice_integration_test.go @@ -0,0 +1,85 @@ +package store + +import ( + "context" + "errors" + "os" + "testing" + "time" + + "github.com/jmoiron/sqlx" +) + +func TestPendingNoticeAudienceAndReadAcknowledgement(t *testing.T) { + dsn := os.Getenv("OLONS_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("set OLONS_TEST_MYSQL_DSN to run the notice integration test") + } + db, err := sqlx.Connect("mysql", dsn) + if err != nil { + t.Fatal(err) + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + t.Cleanup(func() { _ = db.Close() }) + // Connection-local temporary tables shadow the real tables without changing application data. + for _, statement := range []string{ + `CREATE TEMPORARY TABLE hg_app_member_notice ( + id BIGINT PRIMARY KEY, tenant_id BIGINT, member_id BIGINT, + title VARCHAR(120), content TEXT, source_type INT, notice_status INT, + start_at DATETIME(3), end_at DATETIME(3), created_at DATETIME(3))`, + `CREATE TEMPORARY TABLE hg_app_member_notice_read ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, tenant_id BIGINT, notice_id BIGINT, + member_id BIGINT, read_at DATETIME(3), created_at DATETIME(3), + UNIQUE KEY (tenant_id, notice_id, member_id))`, + } { + if _, err := db.Exec(statement); err != nil { + t.Fatal(err) + } + } + now := time.Now() + for _, notice := range []struct { + id, tenantID, memberID int64 + status int + start, end time.Time + }{ + {1, 0, 9, 1, now.Add(-2 * time.Hour), now.Add(time.Hour)}, + {2, 0, 9, 1, now.Add(-time.Hour), now.Add(time.Hour)}, + {3, 0, 10, 1, now.Add(-time.Hour), now.Add(time.Hour)}, + {4, 1, 9, 1, now.Add(-time.Hour), now.Add(time.Hour)}, + {5, 0, 0, 1, now.Add(-time.Hour), now.Add(time.Hour)}, + {6, 0, 9, 2, now.Add(-time.Hour), now.Add(time.Hour)}, + {7, 0, 9, 1, now.Add(-2 * time.Hour), now.Add(-time.Hour)}, + {8, 0, 9, 1, now.Add(time.Hour), now.Add(2 * time.Hour)}, + } { + _, err := db.Exec(`INSERT INTO hg_app_member_notice + (id,tenant_id,member_id,title,content,source_type,notice_status,start_at,end_at,created_at) + VALUES (?,?,?,'系统通知','通知内容',1,?,?,?,?)`, notice.id, notice.tenantID, notice.memberID, + notice.status, formatNoticeBusinessTime(notice.start), formatNoticeBusinessTime(notice.end), formatNoticeBusinessTime(now)) + if err != nil { + t.Fatal(err) + } + } + ctx := context.Background() + s := &Store{db: db} + for _, expectedID := range []int64{1, 2} { + notice, err := s.NextUnreadMemberNotice(ctx, 0, 9) + if err != nil || notice == nil || notice.ID != expectedID { + t.Fatalf("pending notice = %+v, error = %v, want id %d", notice, err, expectedID) + } + if err := s.MarkMemberNoticeRead(ctx, 0, 9, expectedID); err != nil { + t.Fatal(err) + } + } + if notice, err := s.NextUnreadMemberNotice(ctx, 0, 9); err != nil || notice != nil { + t.Fatalf("read, expired, revoked, foreign and broadcast notices must not pop up: %+v, %v", notice, err) + } + for _, forbiddenID := range []int64{3, 4} { + if err := s.MarkMemberNoticeRead(ctx, 0, 9, forbiddenID); !errors.Is(err, ErrResourceNotFound) { + t.Fatalf("reading foreign notice %d: error = %v", forbiddenID, err) + } + } + if notice, err := s.NextUnreadMemberNotice(ctx, 0, 10); err != nil || notice == nil || notice.ID != 3 { + t.Fatalf("other recipient must retain their unread notice: %+v, %v", notice, err) + } +} diff --git a/internal/store/portal_notice_test.go b/internal/store/portal_notice_test.go new file mode 100644 index 0000000..01ad32b --- /dev/null +++ b/internal/store/portal_notice_test.go @@ -0,0 +1,13 @@ +package store + +import ( + "testing" + "time" +) + +func TestFormatNoticeBusinessTimeUsesShanghaiWallClock(t *testing.T) { + value := time.Date(2026, 10, 22, 6, 12, 38, 0, time.UTC) + if got, want := formatNoticeBusinessTime(value), "2026-10-22 14:12:38"; got != want { + t.Fatalf("formatNoticeBusinessTime() = %q, want %q", got, want) + } +} diff --git a/internal/store/tasks.go b/internal/store/tasks.go index 50be719..d7e506f 100644 --- a/internal/store/tasks.go +++ b/internal/store/tasks.go @@ -541,7 +541,7 @@ func orderSelectSQL() string { COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.rule_snapshot,'$.deadline_at')),'') AS deadline_at, COALESCE(DATE_FORMAT(o.matched_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS matched_at, COALESCE(DATE_FORMAT(o.completed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS completed_at, - DATE_FORMAT(o.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at, + ` + databaseWallTimeUTC("o.created_at") + ` AS created_at, DATE_FORMAT(o.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at, COALESCE(t.title,'') AS task_title,COALESCE(t.description,'') AS task_description, COALESCE(t.detail_html,'') AS task_detail_html,COALESCE(s.submission_no,'') AS submission_no, diff --git a/internal/store/time.go b/internal/store/time.go new file mode 100644 index 0000000..6ea49cc --- /dev/null +++ b/internal/store/time.go @@ -0,0 +1,8 @@ +package store + +// databaseWallTimeUTC normalizes DATETIME values written by CURRENT_TIMESTAMP. +// The database session can use UTC or Beijing time independently of the Go process. +// Do not use it for fields explicitly written as UTC by application code. +func databaseWallTimeUTC(column string) string { + return `DATE_FORMAT(TIMESTAMPADD(SECOND,TIMESTAMPDIFF(SECOND,NOW(),UTC_TIMESTAMP()),` + column + `),'%Y-%m-%dT%H:%i:%sZ')` +} diff --git a/internal/store/time_integration_test.go b/internal/store/time_integration_test.go new file mode 100644 index 0000000..5827650 --- /dev/null +++ b/internal/store/time_integration_test.go @@ -0,0 +1,48 @@ +package store + +import ( + "os" + "testing" + + "github.com/jmoiron/sqlx" +) + +func TestOrderCreatedTimeRespectsDatabaseSessionZone(t *testing.T) { + dsn := os.Getenv("OLONS_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("set OLONS_TEST_MYSQL_DSN to run the time integration test") + } + db, err := sqlx.Connect("mysql", dsn) + if err != nil { + t.Fatal(err) + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + t.Cleanup(func() { _ = db.Close() }) + if _, err := db.Exec(`CREATE TEMPORARY TABLE order_time_fixture (created_at DATETIME(3))`); err != nil { + t.Fatal(err) + } + for _, test := range []struct{ zone, wallTime string }{ + {"+00:00", "2026-10-22 13:06:00"}, + {"+08:00", "2026-10-22 21:06:00"}, + } { + t.Run(test.zone, func(t *testing.T) { + if _, err := db.Exec(`SET SESSION time_zone=?`, test.zone); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`DELETE FROM order_time_fixture`); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO order_time_fixture (created_at) VALUES (?)`, test.wallTime); err != nil { + t.Fatal(err) + } + var actual string + if err := db.Get(&actual, `SELECT `+databaseWallTimeUTC("created_at")+` FROM order_time_fixture`); err != nil { + t.Fatal(err) + } + if actual != "2026-10-22T13:06:00Z" { + t.Fatalf("normalized timestamp = %q, want 2026-10-22T13:06:00Z", actual) + } + }) + } +} diff --git a/internal/tasks/service.go b/internal/tasks/service.go index bb57301..a0e26f5 100644 --- a/internal/tasks/service.go +++ b/internal/tasks/service.go @@ -438,8 +438,13 @@ func allowedActions(row store.TaskOrder) []string { switch row.OrderStatus { case 2: var snapshot map[string]any - if json.Unmarshal(row.RuleSnapshot, &snapshot) == nil && snapshot["dispatch_mode"] == "manual" { - return []string{"submit", "contact_support"} + if json.Unmarshal(row.RuleSnapshot, &snapshot) == nil { + if snapshot["balance_shortage_pending"] == true { + return []string{"contact_support"} + } + if snapshot["dispatch_mode"] == "manual" { + return []string{"submit", "contact_support"} + } } return []string{"contact_support"} case 3, 5, 6, 8: diff --git a/internal/tasks/service_test.go b/internal/tasks/service_test.go index d11b772..116612e 100644 --- a/internal/tasks/service_test.go +++ b/internal/tasks/service_test.go @@ -170,3 +170,12 @@ func TestOrdersRejectsUnknownStatus(t *testing.T) { t.Fatalf("expected status error, got %v", err) } } + +func TestAllowedActionsBalancePendingOrderCannotBeSubmitted(t *testing.T) { + row := store.TaskOrder{OrderStatus: 2, RuleSnapshot: json.RawMessage(`{"dispatch_mode":"manual","balance_shortage_pending":true}`)} + for _, action := range allowedActions(row) { + if action == "submit" { + t.Fatal("余额不足待处理订单不应允许提交") + } + } +} diff --git a/web/src/mobile/MobileShell.vue b/web/src/mobile/MobileShell.vue index bd91d9f..e089aee 100644 --- a/web/src/mobile/MobileShell.vue +++ b/web/src/mobile/MobileShell.vue @@ -1,6 +1,7 @@ - - diff --git a/web/src/mobile/components/SystemNoticeHost.vue b/web/src/mobile/components/SystemNoticeHost.vue new file mode 100644 index 0000000..d3c978f --- /dev/null +++ b/web/src/mobile/components/SystemNoticeHost.vue @@ -0,0 +1,100 @@ + + + diff --git a/web/src/mobile/styles.css b/web/src/mobile/styles.css index 508a9db..11082fa 100644 --- a/web/src/mobile/styles.css +++ b/web/src/mobile/styles.css @@ -94,11 +94,12 @@ body { } .m-bottom-nav { position: fixed; - left: 50%; + left: 0; + right: 0; bottom: 0; - transform: translateX(-50%); + margin-inline: auto; z-index: 20; - width: min(100%, 750px); + max-width: 750px; height: calc(76px + env(safe-area-inset-bottom)); padding: 8px 18px env(safe-area-inset-bottom); background: rgba(255, 255, 255, 0.98); @@ -540,10 +541,23 @@ body { flex: 1; min-width: 0; margin: 0; + overflow: hidden; white-space: nowrap; +} +.m-notice-track { + display: block; overflow: hidden; text-overflow: ellipsis; } +.m-notice-track.is-marquee { + display: inline-flex; + gap: 28px; + padding-right: 28px; + width: max-content; + overflow: visible; + text-overflow: clip; + animation: m-notice-marquee 18s linear infinite; +} .m-notice-tag { flex: none; padding: 2px 6px; @@ -575,6 +589,19 @@ body { transform: translateY(0); } } +@keyframes m-notice-marquee { + from { + transform: translateX(0); + } + to { + transform: translateX(-50%); + } +} +@media (prefers-reduced-motion: reduce) { + .m-notice-track.is-marquee { + animation: none; + } +} .m-section { margin-top: 12px; padding: 17px 16px; @@ -1059,6 +1086,31 @@ body { color: #ce8d3d; font-weight: 500; } +.m-order-balance-warning { + display: grid; + gap: 5px; + margin-top: 12px; + padding: 12px 14px; + border: 1px solid #f2a5a5; + border-radius: 8px; + background: #fff0f0; + color: #b42318; + font-size: 13px; + line-height: 1.55; +} +.m-order-balance-warning strong { + font-size: 15px; +} +.m-order-balance-warning--detail { + padding: 16px; + font-size: 14px; +} +.m-order-balance-warning--detail p { + margin: 5px 0 0; +} +.m-order-balance-warning--detail .m-order-recharge-link { + margin: 10px 0 0; +} .m-detail { display: grid; gap: 12px; diff --git a/web/src/mobile/systemNotices.ts b/web/src/mobile/systemNotices.ts new file mode 100644 index 0000000..afae683 --- /dev/null +++ b/web/src/mobile/systemNotices.ts @@ -0,0 +1,13 @@ +import { shallowRef } from 'vue'; +import type { AnnouncementNotice } from './components/AnnouncementPopup.vue'; + +export const activeSystemNotice = shallowRef(null); + +export function openSystemNotice(notice: AnnouncementNotice) { + if (activeSystemNotice.value) return; + activeSystemNotice.value = notice; +} + +export function closeSystemNotice() { + activeSystemNotice.value = null; +} diff --git a/web/src/mobile/views/HomeView.vue b/web/src/mobile/views/HomeView.vue index 0576720..52b0a7c 100644 --- a/web/src/mobile/views/HomeView.vue +++ b/web/src/mobile/views/HomeView.vue @@ -1,10 +1,5 @@ diff --git a/web/src/mobile/views/OrdersView.vue b/web/src/mobile/views/OrdersView.vue index cb50df3..c37b820 100644 --- a/web/src/mobile/views/OrdersView.vue +++ b/web/src/mobile/views/OrdersView.vue @@ -53,6 +53,10 @@ >佣金:{{ money(item.commission_amount, item.currency) }} +
+ 余额不足 · 待处理 + {{ item.message }} +
@@ -103,6 +107,14 @@ >{{ detail.product?.specification || detail.product?.spec || '--' }} +
{{ balanceInsufficient ? '账户余额不足' : '大额订单已匹配' }} @@ -198,6 +210,8 @@ const balanceInsufficient = computed( () => canSubmit.value && availableAmount.value < requiredAmount.value ); + const isBalancePending = (order: any) => + order?.status === 'pending' && String(order?.message || '').startsWith('余额不足:'); async function loadList() { listController?.abort(); const controller = new AbortController();