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/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)
|
||||
|
||||
@@ -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, "/")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+33
-16
@@ -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
|
||||
|
||||
@@ -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(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,
|
||||
|
||||
@@ -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 {
|
||||
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:
|
||||
|
||||
@@ -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("余额不足待处理订单不应允许提交")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<main class="m-app" :class="{ 'm-app--with-nav': showNav }">
|
||||
<router-view />
|
||||
<SystemNoticeHost />
|
||||
<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">
|
||||
<img :src="isActive(item.path) ? item.activeIcon : item.icon" alt="" />
|
||||
@@ -13,12 +14,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { lockHtmlScrollRightCompensationRef } from 'naive-ui/es/_utils';
|
||||
import { hasSession } from './api';
|
||||
import { mobileAssets } from './assets';
|
||||
import SystemNoticeHost from './components/SystemNoticeHost.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const showNav = computed(() => Boolean(route.meta.mobileNav));
|
||||
const scrollbarCompensation = lockHtmlScrollRightCompensationRef;
|
||||
const nav = [
|
||||
{
|
||||
label: '首页',
|
||||
@@ -71,3 +75,9 @@
|
||||
|
||||
watch(() => route.path, ensureAccess, { immediate: true });
|
||||
</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);
|
||||
if (Number.isNaN(date.getTime())) return '--';
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
hourCycle: 'h23',
|
||||
})
|
||||
.format(date)
|
||||
.replaceAll('/', '-');
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -1,34 +1,34 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="announcement-fade">
|
||||
<div v-if="show" class="m-announcement-mask" role="presentation">
|
||||
<section
|
||||
class="m-announcement-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="announcement-title"
|
||||
aria-describedby="announcement-content"
|
||||
>
|
||||
<h2 id="announcement-title" class="m-announcement-sr-title">
|
||||
{{ notice?.title || '公告通知' }}
|
||||
</h2>
|
||||
<img class="m-announcement-board" :src="announcementBoard" alt="" />
|
||||
<div class="m-announcement-body">
|
||||
<div id="announcement-content" class="m-announcement-content">
|
||||
<div v-if="notice?.body_html" v-html="notice.body_html"></div>
|
||||
<template v-else>{{ notice?.content }}</template>
|
||||
</div>
|
||||
<button type="button" @click="$emit('read')">我已阅读</button>
|
||||
</div>
|
||||
</section>
|
||||
<n-modal :show="show" :mask-closable="false" :close-on-esc="false" :z-index="4000">
|
||||
<section
|
||||
class="m-announcement-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="announcement-title"
|
||||
aria-describedby="announcement-content"
|
||||
tabindex="-1"
|
||||
>
|
||||
<h2 id="announcement-title" class="m-announcement-sr-title">系统通知</h2>
|
||||
<img class="m-announcement-board" :src="announcementBoard" alt="" />
|
||||
<div class="m-announcement-body">
|
||||
<div id="announcement-content" class="m-announcement-content">
|
||||
<h3
|
||||
v-if="notice && notice.id > 0 && notice.title && notice.title !== '系统通知'"
|
||||
class="m-announcement-subtitle"
|
||||
>{{ notice.title }}</h3>
|
||||
<div v-if="notice?.body_html" v-html="notice.body_html"></div>
|
||||
<template v-else>{{ notice?.content }}</template>
|
||||
</div>
|
||||
<button type="button" :disabled="reading" @click="$emit('read')">
|
||||
{{ reading ? '正在确认…' : '我已阅读' }}
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</section>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, watch } from 'vue';
|
||||
import announcementBoard from '../assets/announcement-popup@2x.png';
|
||||
import announcementBoard from '../assets/system-notice-popup@2x.png';
|
||||
|
||||
export interface AnnouncementNotice {
|
||||
id: number;
|
||||
@@ -38,39 +38,22 @@
|
||||
source_type?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
show: boolean;
|
||||
notice?: AnnouncementNotice | null;
|
||||
reading?: boolean;
|
||||
}>();
|
||||
|
||||
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>
|
||||
|
||||
<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 {
|
||||
position: relative;
|
||||
width: min(312px, calc(100vw - 32px), calc((100vh - 32px) * 2 / 3));
|
||||
aspect-ratio: 2 / 3;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.m-announcement-board {
|
||||
@@ -107,6 +90,12 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m-announcement-subtitle {
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m-announcement-body button {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
@@ -132,20 +121,4 @@
|
||||
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>
|
||||
|
||||
@@ -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 {
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
<section class="m-home">
|
||||
<AnnouncementPopup
|
||||
:show="popupVisible"
|
||||
:notice="loginPopupNotice"
|
||||
@read="markPopupRead"
|
||||
/>
|
||||
<div class="m-home-hero" :style="bannerStyle">
|
||||
<button
|
||||
v-if="currentBanner && currentBanner.target_type !== 'none'"
|
||||
@@ -64,8 +59,11 @@
|
||||
<span :class="['m-notice-tag', { 'is-activity': displayNotice.source_type === 'operational_simulation' }]">
|
||||
{{ displayNotice.source_type === 'operational_simulation' ? '活动' : '公告' }}
|
||||
</span>
|
||||
<p :key="displayNotice.id" class="m-notice-copy">
|
||||
{{ displayNotice.title || displayNotice.content || '查看最新通知' }}
|
||||
<p ref="noticeCopyRef" :key="displayNotice.id" class="m-notice-copy">
|
||||
<span :class="['m-notice-track', { 'is-marquee': noticeNeedsMarquee }]">
|
||||
<span ref="noticeTextRef">{{ noticeBarText }}</span>
|
||||
<span v-if="noticeNeedsMarquee" aria-hidden="true">{{ noticeBarText }}</span>
|
||||
</span>
|
||||
</p>
|
||||
<n-icon class="m-notice-arrow" :component="ChevronForwardOutline" />
|
||||
</button>
|
||||
@@ -111,20 +109,17 @@
|
||||
</template>
|
||||
|
||||
<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 { ChevronForwardOutline, MegaphoneOutline, SearchOutline } from '@vicons/ionicons5';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import AnnouncementPopup, {
|
||||
type AnnouncementNotice,
|
||||
} from '../components/AnnouncementPopup.vue';
|
||||
import type { AnnouncementNotice } from '../components/AnnouncementPopup.vue';
|
||||
import { openSystemNotice } from '../systemNotices';
|
||||
import {
|
||||
completeLoginNotice,
|
||||
errorText,
|
||||
money,
|
||||
request,
|
||||
safeImage,
|
||||
shouldShowLoginNotice,
|
||||
} from '../api';
|
||||
import { mobileAssets } from '../assets';
|
||||
|
||||
@@ -153,6 +148,9 @@
|
||||
content?: string;
|
||||
source_type?: string;
|
||||
}
|
||||
interface NoticePageData {
|
||||
items: NoticeData[];
|
||||
}
|
||||
interface LeaderboardEntry {
|
||||
rank: number;
|
||||
username: string;
|
||||
@@ -165,20 +163,30 @@
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const leaderboard = ref<LeaderboardEntry[]>([]);
|
||||
const loginPopupNotice = ref<AnnouncementNotice | null>(null);
|
||||
const popupVisible = ref(false);
|
||||
const homeAnnouncement = ref<AnnouncementNotice | null>(null);
|
||||
const activeBannerIndex = ref(0);
|
||||
const activeNoticeIndex = ref(0);
|
||||
let bannerTimer: number | undefined;
|
||||
let noticeTimer: number | undefined;
|
||||
let noticeRefreshTimer: number | undefined;
|
||||
let refreshingNotices = false;
|
||||
const formatPoints = (value: number) =>
|
||||
new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
|
||||
(value || 0) / 100
|
||||
);
|
||||
const currentBanner = computed(() => home.value?.banners?.[activeBannerIndex.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 url = safeImage(currentBanner.value?.image_url);
|
||||
return {
|
||||
@@ -266,10 +274,16 @@
|
||||
if (count > 1) {
|
||||
noticeTimer = window.setInterval(() => {
|
||||
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() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
@@ -283,8 +297,6 @@
|
||||
home.value = homeData;
|
||||
leaderboard.value = rankData || [];
|
||||
homeAnnouncement.value = noticeData;
|
||||
loginPopupNotice.value = shouldShowLoginNotice() ? noticeData : null;
|
||||
popupVisible.value = Boolean(loginPopupNotice.value);
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} 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() {
|
||||
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) {
|
||||
loginPopupNotice.value = homeAnnouncement.value;
|
||||
popupVisible.value = true;
|
||||
openSystemNotice(homeAnnouncement.value);
|
||||
return;
|
||||
}
|
||||
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, () => {
|
||||
activeBannerIndex.value = 0;
|
||||
restartBannerTimer();
|
||||
@@ -319,9 +361,22 @@
|
||||
activeNoticeIndex.value = 0;
|
||||
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(() => {
|
||||
if (bannerTimer) window.clearInterval(bannerTimer);
|
||||
if (noticeTimer) window.clearInterval(noticeTimer);
|
||||
if (noticeRefreshTimer) window.clearInterval(noticeRefreshTimer);
|
||||
noticeResizeObserver?.disconnect();
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -53,6 +53,10 @@
|
||||
><strong>佣金:{{ money(item.commission_amount, item.currency) }}</strong></div
|
||||
>
|
||||
</div>
|
||||
<div v-if="isBalancePending(item)" class="m-order-balance-warning">
|
||||
<strong>余额不足 · 待处理</strong>
|
||||
<span>{{ item.message }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</AsyncState>
|
||||
@@ -103,6 +107,14 @@
|
||||
><b>{{ detail.product?.specification || detail.product?.spec || '--' }}</b></div
|
||||
>
|
||||
</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">
|
||||
<div>
|
||||
<strong>{{ balanceInsufficient ? '账户余额不足' : '大额订单已匹配' }}</strong>
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user