feat: add global system notices and Beijing order time display
Deploy olons-server production / deploy (push) Successful in 3m12s
Deploy olons-server production / deploy (push) Successful in 3m12s
This commit is contained in:
@@ -45,6 +45,7 @@ func (a *API) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/v1/customer-services/", a.customerServiceResource)
|
mux.HandleFunc("/v1/customer-services/", a.customerServiceResource)
|
||||||
mux.HandleFunc("/v1/notices", a.notices)
|
mux.HandleFunc("/v1/notices", a.notices)
|
||||||
mux.HandleFunc("/v1/notices/popup", a.popupNotice)
|
mux.HandleFunc("/v1/notices/popup", a.popupNotice)
|
||||||
|
mux.HandleFunc("/v1/notices/pending", a.pendingNotice)
|
||||||
mux.HandleFunc("/v1/notices/", a.noticeResource)
|
mux.HandleFunc("/v1/notices/", a.noticeResource)
|
||||||
mux.HandleFunc("/v1/task-halls", a.taskHalls)
|
mux.HandleFunc("/v1/task-halls", a.taskHalls)
|
||||||
mux.HandleFunc("/v1/tasks/eligibility", a.taskEligibility)
|
mux.HandleFunc("/v1/tasks/eligibility", a.taskEligibility)
|
||||||
|
|||||||
@@ -211,6 +211,27 @@ func (a *API) popupNotice(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeSuccess(w, r, result)
|
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) {
|
func (a *API) noticeResource(w http.ResponseWriter, r *http.Request) {
|
||||||
path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/notices/"), "/")
|
path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/notices/"), "/")
|
||||||
parts := strings.Split(path, "/")
|
parts := strings.Split(path, "/")
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type Repository interface {
|
|||||||
PublishedContent(context.Context, int64, string) (*store.Content, error)
|
PublishedContent(context.Context, int64, string) (*store.Content, error)
|
||||||
ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error)
|
ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error)
|
||||||
ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, 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
|
MarkMemberNoticeRead(context.Context, int64, int64, int64) error
|
||||||
MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error)
|
MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error)
|
||||||
MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, 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
|
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 {
|
func (s *Service) MarkNoticeRead(ctx context.Context, tenantID, memberID, noticeID int64) error {
|
||||||
return s.repository.MarkMemberNoticeRead(ctx, tenantID, memberID, noticeID)
|
return s.repository.MarkMemberNoticeRead(ctx, tenantID, memberID, noticeID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func (r fakeRepository) ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error) {
|
||||||
return r.notices, nil
|
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 {
|
if len(r.notices) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-16
@@ -5,10 +5,21 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const publishStatusPublished = 3
|
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 {
|
type Banner struct {
|
||||||
ID int64 `db:"id"`
|
ID int64 `db:"id"`
|
||||||
ImageURL string `db:"image_url"`
|
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")
|
return nil, errors.New("store is unavailable")
|
||||||
}
|
}
|
||||||
rows := make([]MemberNotice, 0)
|
rows := make([]MemberNotice, 0)
|
||||||
|
now := noticeBusinessNow()
|
||||||
err := s.db.SelectContext(ctx, &rows, `SELECT id,title,content,source_type,
|
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(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(end_at,'%Y-%m-%dT%H:%i:%sZ') AS end_at,
|
||||||
DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_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
|
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)
|
AND start_at<=? AND end_at>?
|
||||||
ORDER BY start_at DESC,id DESC LIMIT ?`, tenantID, memberID, limit)
|
ORDER BY start_at DESC,id DESC LIMIT ?`, tenantID, memberID, now, now, limit)
|
||||||
return rows, err
|
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 {
|
if s == nil || s.db == nil {
|
||||||
return nil, errors.New("store is unavailable")
|
return nil, errors.New("store is unavailable")
|
||||||
}
|
}
|
||||||
var row MemberNotice
|
var row MemberNotice
|
||||||
err := s.db.GetContext(ctx, &row, `SELECT id,title,content,source_type,
|
now := noticeBusinessNow()
|
||||||
DATE_FORMAT(start_at,'%Y-%m-%dT%H:%i:%sZ') AS start_at,
|
err := s.db.GetContext(ctx, &row, `SELECT n.id,n.title,n.content,n.source_type,
|
||||||
DATE_FORMAT(end_at,'%Y-%m-%dT%H:%i:%sZ') AS end_at,
|
DATE_FORMAT(n.start_at,'%Y-%m-%dT%H:%i:%sZ') AS start_at,
|
||||||
DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at
|
DATE_FORMAT(n.end_at,'%Y-%m-%dT%H:%i:%sZ') AS end_at,
|
||||||
FROM hg_app_member_notice
|
DATE_FORMAT(n.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at
|
||||||
WHERE tenant_id=? AND member_id IN (0,?) AND notice_status=1
|
FROM hg_app_member_notice n
|
||||||
AND start_at<=UTC_TIMESTAMP(3) AND end_at>UTC_TIMESTAMP(3)
|
WHERE n.tenant_id=? AND n.member_id=? AND n.notice_status=1
|
||||||
ORDER BY start_at DESC,id DESC LIMIT 1`, tenantID, memberID)
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -187,13 +202,14 @@ func (s *Store) MarkMemberNoticeRead(ctx context.Context, tenantID, memberID, no
|
|||||||
if s == nil || s.db == nil {
|
if s == nil || s.db == nil {
|
||||||
return errors.New("store is unavailable")
|
return errors.New("store is unavailable")
|
||||||
}
|
}
|
||||||
|
now := noticeBusinessNow()
|
||||||
result, err := s.db.ExecContext(ctx, `INSERT INTO hg_app_member_notice_read
|
result, err := s.db.ExecContext(ctx, `INSERT INTO hg_app_member_notice_read
|
||||||
(tenant_id,notice_id,member_id,read_at,created_at)
|
(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
|
FROM hg_app_member_notice n
|
||||||
WHERE n.tenant_id=? AND n.id=? AND n.member_id IN (0,?) AND n.notice_status=1
|
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)
|
AND n.start_at<=? AND n.end_at>?
|
||||||
ON DUPLICATE KEY UPDATE read_at=VALUES(read_at)`, memberID, tenantID, noticeID, memberID)
|
ON DUPLICATE KEY UPDATE read_at=VALUES(read_at)`, memberID, now, now, tenantID, noticeID, memberID, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -212,8 +228,9 @@ func (s *Store) MemberNotices(ctx context.Context, tenantID, memberID int64, off
|
|||||||
return nil, errors.New("store is unavailable")
|
return nil, errors.New("store is unavailable")
|
||||||
}
|
}
|
||||||
const where = ` WHERE tenant_id=? AND member_id IN (0,?) AND notice_status=1
|
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)`
|
AND start_at<=? AND end_at>?`
|
||||||
args := []any{tenantID, memberID}
|
now := noticeBusinessNow()
|
||||||
|
args := []any{tenantID, memberID, now, now}
|
||||||
var total int64
|
var total int64
|
||||||
if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_app_member_notice`+where, args...); err != nil {
|
if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_app_member_notice`+where, args...); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -541,7 +541,7 @@ func orderSelectSQL() string {
|
|||||||
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.rule_snapshot,'$.deadline_at')),'') AS deadline_at,
|
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.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,
|
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,
|
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.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,
|
COALESCE(t.detail_html,'') AS task_detail_html,COALESCE(s.submission_no,'') AS submission_no,
|
||||||
|
|||||||
@@ -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')`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -438,8 +438,13 @@ func allowedActions(row store.TaskOrder) []string {
|
|||||||
switch row.OrderStatus {
|
switch row.OrderStatus {
|
||||||
case 2:
|
case 2:
|
||||||
var snapshot map[string]any
|
var snapshot map[string]any
|
||||||
if json.Unmarshal(row.RuleSnapshot, &snapshot) == nil && snapshot["dispatch_mode"] == "manual" {
|
if json.Unmarshal(row.RuleSnapshot, &snapshot) == nil {
|
||||||
return []string{"submit", "contact_support"}
|
if snapshot["balance_shortage_pending"] == true {
|
||||||
|
return []string{"contact_support"}
|
||||||
|
}
|
||||||
|
if snapshot["dispatch_mode"] == "manual" {
|
||||||
|
return []string{"submit", "contact_support"}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return []string{"contact_support"}
|
return []string{"contact_support"}
|
||||||
case 3, 5, 6, 8:
|
case 3, 5, 6, 8:
|
||||||
|
|||||||
@@ -170,3 +170,12 @@ func TestOrdersRejectsUnknownStatus(t *testing.T) {
|
|||||||
t.Fatalf("expected status error, got %v", err)
|
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("余额不足待处理订单不应允许提交")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="m-app" :class="{ 'm-app--with-nav': showNav }">
|
<main class="m-app" :class="{ 'm-app--with-nav': showNav }">
|
||||||
<router-view />
|
<router-view />
|
||||||
|
<SystemNoticeHost />
|
||||||
<nav v-if="showNav" class="m-bottom-nav" aria-label="主要导航">
|
<nav v-if="showNav" class="m-bottom-nav" aria-label="主要导航">
|
||||||
<router-link v-for="item in nav" :key="item.path" :to="item.path" :aria-label="item.label">
|
<router-link v-for="item in nav" :key="item.path" :to="item.path" :aria-label="item.label">
|
||||||
<img :src="isActive(item.path) ? item.activeIcon : item.icon" alt="" />
|
<img :src="isActive(item.path) ? item.activeIcon : item.icon" alt="" />
|
||||||
@@ -13,12 +14,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, watch } from 'vue';
|
import { computed, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { lockHtmlScrollRightCompensationRef } from 'naive-ui/es/_utils';
|
||||||
import { hasSession } from './api';
|
import { hasSession } from './api';
|
||||||
import { mobileAssets } from './assets';
|
import { mobileAssets } from './assets';
|
||||||
|
import SystemNoticeHost from './components/SystemNoticeHost.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const showNav = computed(() => Boolean(route.meta.mobileNav));
|
const showNav = computed(() => Boolean(route.meta.mobileNav));
|
||||||
|
const scrollbarCompensation = lockHtmlScrollRightCompensationRef;
|
||||||
const nav = [
|
const nav = [
|
||||||
{
|
{
|
||||||
label: '首页',
|
label: '首页',
|
||||||
@@ -71,3 +75,9 @@
|
|||||||
|
|
||||||
watch(() => route.path, ensureAccess, { immediate: true });
|
watch(() => route.path, ensureAccess, { immediate: true });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.m-bottom-nav {
|
||||||
|
right: v-bind(scrollbarCompensation);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { dateTime } from './api';
|
||||||
|
|
||||||
|
describe('Beijing business time display', () => {
|
||||||
|
it.each(['UTC', 'America/New_York', 'Asia/Shanghai'])(
|
||||||
|
'displays the same Beijing time on a device using %s',
|
||||||
|
(deviceTimeZone) => {
|
||||||
|
const previous = process.env.TZ;
|
||||||
|
process.env.TZ = deviceTimeZone;
|
||||||
|
try {
|
||||||
|
expect(dateTime('2026-10-22T13:06:00Z')).toBe('2026-10-22 21:06');
|
||||||
|
expect(dateTime('2026-10-22T21:06:00+08:00')).toBe('2026-10-22 21:06');
|
||||||
|
expect(dateTime('2026-10-22T16:00:00Z')).toBe('2026-10-23 00:00');
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) delete process.env.TZ;
|
||||||
|
else process.env.TZ = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('uses a placeholder for missing or invalid timestamps', () => {
|
||||||
|
expect(dateTime()).toBe('--');
|
||||||
|
expect(dateTime('invalid-time')).toBe('--');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -184,12 +184,13 @@ export function dateTime(value?: string) {
|
|||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (Number.isNaN(date.getTime())) return '--';
|
if (Number.isNaN(date.getTime())) return '--';
|
||||||
return new Intl.DateTimeFormat('zh-CN', {
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
hour12: false,
|
hourCycle: 'h23',
|
||||||
})
|
})
|
||||||
.format(date)
|
.format(date)
|
||||||
.replaceAll('/', '-');
|
.replaceAll('/', '-');
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -1,34 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<n-modal :show="show" :mask-closable="false" :close-on-esc="false" :z-index="4000">
|
||||||
<Transition name="announcement-fade">
|
<section
|
||||||
<div v-if="show" class="m-announcement-mask" role="presentation">
|
class="m-announcement-dialog"
|
||||||
<section
|
role="dialog"
|
||||||
class="m-announcement-dialog"
|
aria-modal="true"
|
||||||
role="dialog"
|
aria-labelledby="announcement-title"
|
||||||
aria-modal="true"
|
aria-describedby="announcement-content"
|
||||||
aria-labelledby="announcement-title"
|
tabindex="-1"
|
||||||
aria-describedby="announcement-content"
|
>
|
||||||
>
|
<h2 id="announcement-title" class="m-announcement-sr-title">系统通知</h2>
|
||||||
<h2 id="announcement-title" class="m-announcement-sr-title">
|
<img class="m-announcement-board" :src="announcementBoard" alt="" />
|
||||||
{{ notice?.title || '公告通知' }}
|
<div class="m-announcement-body">
|
||||||
</h2>
|
<div id="announcement-content" class="m-announcement-content">
|
||||||
<img class="m-announcement-board" :src="announcementBoard" alt="" />
|
<h3
|
||||||
<div class="m-announcement-body">
|
v-if="notice && notice.id > 0 && notice.title && notice.title !== '系统通知'"
|
||||||
<div id="announcement-content" class="m-announcement-content">
|
class="m-announcement-subtitle"
|
||||||
<div v-if="notice?.body_html" v-html="notice.body_html"></div>
|
>{{ notice.title }}</h3>
|
||||||
<template v-else>{{ notice?.content }}</template>
|
<div v-if="notice?.body_html" v-html="notice.body_html"></div>
|
||||||
</div>
|
<template v-else>{{ notice?.content }}</template>
|
||||||
<button type="button" @click="$emit('read')">我已阅读</button>
|
</div>
|
||||||
</div>
|
<button type="button" :disabled="reading" @click="$emit('read')">
|
||||||
</section>
|
{{ reading ? '正在确认…' : '我已阅读' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</section>
|
||||||
</Teleport>
|
</n-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, watch } from 'vue';
|
import announcementBoard from '../assets/system-notice-popup@2x.png';
|
||||||
import announcementBoard from '../assets/announcement-popup@2x.png';
|
|
||||||
|
|
||||||
export interface AnnouncementNotice {
|
export interface AnnouncementNotice {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -38,39 +38,22 @@
|
|||||||
source_type?: string;
|
source_type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
defineProps<{
|
||||||
show: boolean;
|
show: boolean;
|
||||||
notice?: AnnouncementNotice | null;
|
notice?: AnnouncementNotice | null;
|
||||||
|
reading?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineEmits<{ read: [] }>();
|
defineEmits<{ read: [] }>();
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.show,
|
|
||||||
(show) => {
|
|
||||||
document.body.classList.toggle('m-announcement-open', show);
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
onBeforeUnmount(() => document.body.classList.remove('m-announcement-open'));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.m-announcement-mask {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 3000;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
padding: 20px 16px;
|
|
||||||
background: rgba(0, 0, 0, 0.62);
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-announcement-dialog {
|
.m-announcement-dialog {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: min(312px, calc(100vw - 32px), calc((100vh - 32px) * 2 / 3));
|
width: min(312px, calc(100vw - 32px), calc((100vh - 32px) * 2 / 3));
|
||||||
aspect-ratio: 2 / 3;
|
aspect-ratio: 2 / 3;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-announcement-board {
|
.m-announcement-board {
|
||||||
@@ -107,6 +90,12 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.m-announcement-subtitle {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.m-announcement-body button {
|
.m-announcement-body button {
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -132,20 +121,4 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.announcement-fade-enter-active,
|
|
||||||
.announcement-fade-leave-active {
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.announcement-fade-enter-from,
|
|
||||||
.announcement-fade-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
body.m-announcement-open {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<AnnouncementPopup
|
||||||
|
:show="Boolean(activeSystemNotice)"
|
||||||
|
:notice="activeSystemNotice"
|
||||||
|
:reading="reading"
|
||||||
|
@read="markRead"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import AnnouncementPopup, { type AnnouncementNotice } from './AnnouncementPopup.vue';
|
||||||
|
import {
|
||||||
|
completeLoginNotice,
|
||||||
|
errorText,
|
||||||
|
hasSession,
|
||||||
|
MobileApiError,
|
||||||
|
request,
|
||||||
|
shouldShowLoginNotice,
|
||||||
|
} from '../api';
|
||||||
|
import { activeSystemNotice, closeSystemNotice, openSystemNotice } from '../systemNotices';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const reading = ref(false);
|
||||||
|
let timer: number | undefined;
|
||||||
|
let refreshing = false;
|
||||||
|
let disposed = false;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
if (disposed || refreshing || activeSystemNotice.value || !hasSession() ||
|
||||||
|
document.visibilityState === 'hidden') return;
|
||||||
|
refreshing = true;
|
||||||
|
let loadingLoginNotice = false;
|
||||||
|
try {
|
||||||
|
const pending = await request<AnnouncementNotice | null>({
|
||||||
|
url: '/v1/notices/pending', signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (disposed || activeSystemNotice.value) return;
|
||||||
|
if (pending) {
|
||||||
|
openSystemNotice(pending);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shouldShowLoginNotice()) {
|
||||||
|
loadingLoginNotice = true;
|
||||||
|
const loginNotice = await request<AnnouncementNotice | null>({
|
||||||
|
url: '/v1/notices/popup', signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (disposed || activeSystemNotice.value) return;
|
||||||
|
if (loginNotice) openSystemNotice(loginNotice);
|
||||||
|
else completeLoginNotice();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (loadingLoginNotice && error instanceof MobileApiError && error.status === 404) completeLoginNotice();
|
||||||
|
// Leave the page usable and retry on the next poll or when the page becomes visible.
|
||||||
|
} finally {
|
||||||
|
refreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markRead() {
|
||||||
|
const notice = activeSystemNotice.value;
|
||||||
|
if (!notice || reading.value) return;
|
||||||
|
reading.value = true;
|
||||||
|
try {
|
||||||
|
if (notice.id > 0) {
|
||||||
|
await request({
|
||||||
|
url: `/v1/notices/${notice.id}/read`, method: 'POST', signal: controller.signal,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
completeLoginNotice();
|
||||||
|
}
|
||||||
|
closeSystemNotice();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof MobileApiError && error.status === 404) closeSystemNotice();
|
||||||
|
else if (!disposed) window.$message.error(errorText(error));
|
||||||
|
} finally {
|
||||||
|
reading.value = false;
|
||||||
|
}
|
||||||
|
if (!disposed && !activeSystemNotice.value) void refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
const resume = () => void refresh();
|
||||||
|
watch(() => route.fullPath, resume);
|
||||||
|
onMounted(() => {
|
||||||
|
void refresh();
|
||||||
|
timer = window.setInterval(resume, 15000);
|
||||||
|
document.addEventListener('visibilitychange', resume);
|
||||||
|
window.addEventListener('online', resume);
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disposed = true;
|
||||||
|
controller.abort();
|
||||||
|
if (timer) window.clearInterval(timer);
|
||||||
|
document.removeEventListener('visibilitychange', resume);
|
||||||
|
window.removeEventListener('online', resume);
|
||||||
|
closeSystemNotice();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -94,11 +94,12 @@ body {
|
|||||||
}
|
}
|
||||||
.m-bottom-nav {
|
.m-bottom-nav {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 50%;
|
left: 0;
|
||||||
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
transform: translateX(-50%);
|
margin-inline: auto;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
width: min(100%, 750px);
|
max-width: 750px;
|
||||||
height: calc(76px + env(safe-area-inset-bottom));
|
height: calc(76px + env(safe-area-inset-bottom));
|
||||||
padding: 8px 18px env(safe-area-inset-bottom);
|
padding: 8px 18px env(safe-area-inset-bottom);
|
||||||
background: rgba(255, 255, 255, 0.98);
|
background: rgba(255, 255, 255, 0.98);
|
||||||
@@ -540,10 +541,23 @@ body {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.m-notice-track {
|
||||||
|
display: block;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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 {
|
.m-notice-tag {
|
||||||
flex: none;
|
flex: none;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
@@ -575,6 +589,19 @@ body {
|
|||||||
transform: translateY(0);
|
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 {
|
.m-section {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
padding: 17px 16px;
|
padding: 17px 16px;
|
||||||
@@ -1059,6 +1086,31 @@ body {
|
|||||||
color: #ce8d3d;
|
color: #ce8d3d;
|
||||||
font-weight: 500;
|
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 {
|
.m-detail {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { shallowRef } from 'vue';
|
||||||
|
import type { AnnouncementNotice } from './components/AnnouncementPopup.vue';
|
||||||
|
|
||||||
|
export const activeSystemNotice = shallowRef<AnnouncementNotice | null>(null);
|
||||||
|
|
||||||
|
export function openSystemNotice(notice: AnnouncementNotice) {
|
||||||
|
if (activeSystemNotice.value) return;
|
||||||
|
activeSystemNotice.value = notice;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeSystemNotice() {
|
||||||
|
activeSystemNotice.value = null;
|
||||||
|
}
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="m-home">
|
<section class="m-home">
|
||||||
<AnnouncementPopup
|
|
||||||
:show="popupVisible"
|
|
||||||
:notice="loginPopupNotice"
|
|
||||||
@read="markPopupRead"
|
|
||||||
/>
|
|
||||||
<div class="m-home-hero" :style="bannerStyle">
|
<div class="m-home-hero" :style="bannerStyle">
|
||||||
<button
|
<button
|
||||||
v-if="currentBanner && currentBanner.target_type !== 'none'"
|
v-if="currentBanner && currentBanner.target_type !== 'none'"
|
||||||
@@ -64,8 +59,11 @@
|
|||||||
<span :class="['m-notice-tag', { 'is-activity': displayNotice.source_type === 'operational_simulation' }]">
|
<span :class="['m-notice-tag', { 'is-activity': displayNotice.source_type === 'operational_simulation' }]">
|
||||||
{{ displayNotice.source_type === 'operational_simulation' ? '活动' : '公告' }}
|
{{ displayNotice.source_type === 'operational_simulation' ? '活动' : '公告' }}
|
||||||
</span>
|
</span>
|
||||||
<p :key="displayNotice.id" class="m-notice-copy">
|
<p ref="noticeCopyRef" :key="displayNotice.id" class="m-notice-copy">
|
||||||
{{ displayNotice.title || displayNotice.content || '查看最新通知' }}
|
<span :class="['m-notice-track', { 'is-marquee': noticeNeedsMarquee }]">
|
||||||
|
<span ref="noticeTextRef">{{ noticeBarText }}</span>
|
||||||
|
<span v-if="noticeNeedsMarquee" aria-hidden="true">{{ noticeBarText }}</span>
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<n-icon class="m-notice-arrow" :component="ChevronForwardOutline" />
|
<n-icon class="m-notice-arrow" :component="ChevronForwardOutline" />
|
||||||
</button>
|
</button>
|
||||||
@@ -111,20 +109,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { ChevronForwardOutline, MegaphoneOutline, SearchOutline } from '@vicons/ionicons5';
|
import { ChevronForwardOutline, MegaphoneOutline, SearchOutline } from '@vicons/ionicons5';
|
||||||
import AsyncState from '../components/AsyncState.vue';
|
import AsyncState from '../components/AsyncState.vue';
|
||||||
import AnnouncementPopup, {
|
import type { AnnouncementNotice } from '../components/AnnouncementPopup.vue';
|
||||||
type AnnouncementNotice,
|
import { openSystemNotice } from '../systemNotices';
|
||||||
} from '../components/AnnouncementPopup.vue';
|
|
||||||
import {
|
import {
|
||||||
completeLoginNotice,
|
|
||||||
errorText,
|
errorText,
|
||||||
money,
|
money,
|
||||||
request,
|
request,
|
||||||
safeImage,
|
safeImage,
|
||||||
shouldShowLoginNotice,
|
|
||||||
} from '../api';
|
} from '../api';
|
||||||
import { mobileAssets } from '../assets';
|
import { mobileAssets } from '../assets';
|
||||||
|
|
||||||
@@ -153,6 +148,9 @@
|
|||||||
content?: string;
|
content?: string;
|
||||||
source_type?: string;
|
source_type?: string;
|
||||||
}
|
}
|
||||||
|
interface NoticePageData {
|
||||||
|
items: NoticeData[];
|
||||||
|
}
|
||||||
interface LeaderboardEntry {
|
interface LeaderboardEntry {
|
||||||
rank: number;
|
rank: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -165,20 +163,30 @@
|
|||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const leaderboard = ref<LeaderboardEntry[]>([]);
|
const leaderboard = ref<LeaderboardEntry[]>([]);
|
||||||
const loginPopupNotice = ref<AnnouncementNotice | null>(null);
|
|
||||||
const popupVisible = ref(false);
|
|
||||||
const homeAnnouncement = ref<AnnouncementNotice | null>(null);
|
const homeAnnouncement = ref<AnnouncementNotice | null>(null);
|
||||||
const activeBannerIndex = ref(0);
|
const activeBannerIndex = ref(0);
|
||||||
const activeNoticeIndex = ref(0);
|
const activeNoticeIndex = ref(0);
|
||||||
let bannerTimer: number | undefined;
|
let bannerTimer: number | undefined;
|
||||||
let noticeTimer: number | undefined;
|
let noticeTimer: number | undefined;
|
||||||
|
let noticeRefreshTimer: number | undefined;
|
||||||
|
let refreshingNotices = false;
|
||||||
const formatPoints = (value: number) =>
|
const formatPoints = (value: number) =>
|
||||||
new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
|
new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
|
||||||
(value || 0) / 100
|
(value || 0) / 100
|
||||||
);
|
);
|
||||||
const currentBanner = computed(() => home.value?.banners?.[activeBannerIndex.value]);
|
const currentBanner = computed(() => home.value?.banners?.[activeBannerIndex.value]);
|
||||||
const currentNotice = computed(() => home.value?.notices?.[activeNoticeIndex.value]);
|
const currentNotice = computed(() => home.value?.notices?.[activeNoticeIndex.value]);
|
||||||
const displayNotice = computed(() => homeAnnouncement.value || currentNotice.value);
|
const displayNotice = computed(() => currentNotice.value || homeAnnouncement.value);
|
||||||
|
const noticeBarText = computed(() => {
|
||||||
|
const title = displayNotice.value?.title?.trim() || '';
|
||||||
|
const content = displayNotice.value?.content?.trim() || '';
|
||||||
|
if (title && content && title !== content) return `${title}:${content}`;
|
||||||
|
return title || content || '查看最新通知';
|
||||||
|
});
|
||||||
|
const noticeCopyRef = ref<HTMLElement>();
|
||||||
|
const noticeTextRef = ref<HTMLElement>();
|
||||||
|
const noticeNeedsMarquee = ref(false);
|
||||||
|
let noticeResizeObserver: ResizeObserver | undefined;
|
||||||
const bannerStyle = computed(() => {
|
const bannerStyle = computed(() => {
|
||||||
const url = safeImage(currentBanner.value?.image_url);
|
const url = safeImage(currentBanner.value?.image_url);
|
||||||
return {
|
return {
|
||||||
@@ -266,10 +274,16 @@
|
|||||||
if (count > 1) {
|
if (count > 1) {
|
||||||
noticeTimer = window.setInterval(() => {
|
noticeTimer = window.setInterval(() => {
|
||||||
activeNoticeIndex.value = (activeNoticeIndex.value + 1) % count;
|
activeNoticeIndex.value = (activeNoticeIndex.value + 1) % count;
|
||||||
}, 4000);
|
}, 12000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function measureNoticeMarquee() {
|
||||||
|
const element = noticeCopyRef.value;
|
||||||
|
const text = noticeTextRef.value;
|
||||||
|
noticeNeedsMarquee.value = Boolean(element && text && text.getBoundingClientRect().width > element.clientWidth + 1);
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
@@ -283,8 +297,6 @@
|
|||||||
home.value = homeData;
|
home.value = homeData;
|
||||||
leaderboard.value = rankData || [];
|
leaderboard.value = rankData || [];
|
||||||
homeAnnouncement.value = noticeData;
|
homeAnnouncement.value = noticeData;
|
||||||
loginPopupNotice.value = shouldShowLoginNotice() ? noticeData : null;
|
|
||||||
popupVisible.value = Boolean(loginPopupNotice.value);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = errorText(err);
|
error.value = errorText(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -292,25 +304,55 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function markPopupRead() {
|
|
||||||
if (!loginPopupNotice.value) return;
|
|
||||||
const noticeId = loginPopupNotice.value.id;
|
|
||||||
popupVisible.value = false;
|
|
||||||
loginPopupNotice.value = null;
|
|
||||||
completeLoginNotice();
|
|
||||||
if (noticeId > 0) {
|
|
||||||
request<void>({ url: `/v1/notices/${noticeId}/read`, method: 'POST' }).catch(() => undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openNotice() {
|
function openNotice() {
|
||||||
|
if (currentNotice.value) {
|
||||||
|
openSystemNotice({
|
||||||
|
id: currentNotice.value.id,
|
||||||
|
title: currentNotice.value.title || '会员公告',
|
||||||
|
content: currentNotice.value.content || '',
|
||||||
|
source_type: currentNotice.value.source_type,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (homeAnnouncement.value) {
|
if (homeAnnouncement.value) {
|
||||||
loginPopupNotice.value = homeAnnouncement.value;
|
openSystemNotice(homeAnnouncement.value);
|
||||||
popupVisible.value = true;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void router.push('/app/content/notices');
|
void router.push('/app/content/notices');
|
||||||
}
|
}
|
||||||
|
async function refreshNotices() {
|
||||||
|
if (document.visibilityState === 'hidden' || refreshingNotices) return;
|
||||||
|
refreshingNotices = true;
|
||||||
|
try {
|
||||||
|
const page = await request<NoticePageData>({
|
||||||
|
url: '/v1/notices',
|
||||||
|
params: { page: 1, page_size: 5 },
|
||||||
|
});
|
||||||
|
if (home.value) {
|
||||||
|
const nextNotices = page.items || [];
|
||||||
|
const previousIds = home.value.notices.map((notice) => notice.id).join(',');
|
||||||
|
const nextIds = nextNotices.map((notice) => notice.id).join(',');
|
||||||
|
home.value.notices = nextNotices;
|
||||||
|
if (previousIds !== nextIds) {
|
||||||
|
activeNoticeIndex.value = 0;
|
||||||
|
restartNoticeTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep the latest successfully loaded announcement visible during transient failures.
|
||||||
|
} finally {
|
||||||
|
refreshingNotices = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleVisibilityChange() {
|
||||||
|
if (document.visibilityState === 'visible') void refreshNotices();
|
||||||
|
}
|
||||||
|
watch(noticeBarText, () => void nextTick(measureNoticeMarquee), { flush: 'post' });
|
||||||
|
watch(noticeCopyRef, (element, previous) => {
|
||||||
|
if (previous) noticeResizeObserver?.unobserve(previous);
|
||||||
|
if (element) noticeResizeObserver?.observe(element);
|
||||||
|
void nextTick(measureNoticeMarquee);
|
||||||
|
}, { flush: 'post' });
|
||||||
watch(() => home.value?.banners?.length, () => {
|
watch(() => home.value?.banners?.length, () => {
|
||||||
activeBannerIndex.value = 0;
|
activeBannerIndex.value = 0;
|
||||||
restartBannerTimer();
|
restartBannerTimer();
|
||||||
@@ -319,9 +361,22 @@
|
|||||||
activeNoticeIndex.value = 0;
|
activeNoticeIndex.value = 0;
|
||||||
restartNoticeTimer();
|
restartNoticeTimer();
|
||||||
});
|
});
|
||||||
onMounted(load);
|
onMounted(async () => {
|
||||||
|
await load();
|
||||||
|
await nextTick();
|
||||||
|
measureNoticeMarquee();
|
||||||
|
if (noticeCopyRef.value && typeof ResizeObserver !== 'undefined') {
|
||||||
|
noticeResizeObserver = new ResizeObserver(measureNoticeMarquee);
|
||||||
|
noticeResizeObserver.observe(noticeCopyRef.value);
|
||||||
|
}
|
||||||
|
noticeRefreshTimer = window.setInterval(() => void refreshNotices(), 15000);
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
});
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (bannerTimer) window.clearInterval(bannerTimer);
|
if (bannerTimer) window.clearInterval(bannerTimer);
|
||||||
if (noticeTimer) window.clearInterval(noticeTimer);
|
if (noticeTimer) window.clearInterval(noticeTimer);
|
||||||
|
if (noticeRefreshTimer) window.clearInterval(noticeRefreshTimer);
|
||||||
|
noticeResizeObserver?.disconnect();
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -53,6 +53,10 @@
|
|||||||
><strong>佣金:{{ money(item.commission_amount, item.currency) }}</strong></div
|
><strong>佣金:{{ money(item.commission_amount, item.currency) }}</strong></div
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="isBalancePending(item)" class="m-order-balance-warning">
|
||||||
|
<strong>余额不足 · 待处理</strong>
|
||||||
|
<span>{{ item.message }}</span>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</AsyncState>
|
</AsyncState>
|
||||||
@@ -103,6 +107,14 @@
|
|||||||
><b>{{ detail.product?.specification || detail.product?.spec || '--' }}</b></div
|
><b>{{ detail.product?.specification || detail.product?.spec || '--' }}</b></div
|
||||||
>
|
>
|
||||||
</section>
|
</section>
|
||||||
|
<section v-if="isBalancePending(detail)" class="m-order-balance-warning m-order-balance-warning--detail" role="alert">
|
||||||
|
<div>
|
||||||
|
<strong>余额不足,订单暂未完成</strong>
|
||||||
|
<p>{{ detail.message }}</p>
|
||||||
|
<p>充值审核通过后,系统会自动检查余额;余额足够时自动完成订单。</p>
|
||||||
|
</div>
|
||||||
|
<router-link class="m-order-recharge-link" to="/app/recharge">立即储入</router-link>
|
||||||
|
</section>
|
||||||
<section v-if="canSubmit" class="m-order-submit-panel">
|
<section v-if="canSubmit" class="m-order-submit-panel">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ balanceInsufficient ? '账户余额不足' : '大额订单已匹配' }}</strong>
|
<strong>{{ balanceInsufficient ? '账户余额不足' : '大额订单已匹配' }}</strong>
|
||||||
@@ -198,6 +210,8 @@
|
|||||||
const balanceInsufficient = computed(
|
const balanceInsufficient = computed(
|
||||||
() => canSubmit.value && availableAmount.value < requiredAmount.value
|
() => canSubmit.value && availableAmount.value < requiredAmount.value
|
||||||
);
|
);
|
||||||
|
const isBalancePending = (order: any) =>
|
||||||
|
order?.status === 'pending' && String(order?.message || '').startsWith('余额不足:');
|
||||||
async function loadList() {
|
async function loadList() {
|
||||||
listController?.abort();
|
listController?.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|||||||
Reference in New Issue
Block a user