feat: add global system notices and Beijing order time display
Deploy olons-server production / deploy (push) Successful in 3m12s

This commit is contained in:
richjohnrambo
2026-10-22 21:56:07 +08:00
parent 9bb479b506
commit 8ce67648b1
22 changed files with 581 additions and 121 deletions
+1
View File
@@ -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)
+21
View File
@@ -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, "/")
+11 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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
View File
@@ -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)
}
}
+13
View File
@@ -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)
}
}
+1 -1
View File
@@ -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,
+8
View File
@@ -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')`
}
+48
View File
@@ -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)
}
})
}
}
+7 -2
View File
@@ -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:
+9
View File
@@ -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("余额不足待处理订单不应允许提交")
}
}
}
+10
View File
@@ -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>
+25
View File
@@ -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('--');
});
});
+2 -1
View File
@@ -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

+36 -63
View File
@@ -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>
+55 -3
View File
@@ -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;
+13
View File
@@ -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;
}
+88 -33
View File
@@ -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>
+14
View File
@@ -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();