chore: seed migration objects batch 52

This commit is contained in:
dev
2026-10-18 13:38:08 +08:00
parent f440b9e0e8
commit 042fd3f826
23 changed files with 793 additions and 99 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

@@ -25,7 +25,7 @@
| 个人资料、密码 | 已实现 | 会员概要、头像、登录密码和交易密码 | | 个人资料、密码 | 已实现 | 会员概要、头像、登录密码和交易密码 |
| 首页、内容、Banner、等级 | 已实现 | 只返回已发布且已生效的配置和内容 | | 首页、内容、Banner、等级 | 已实现 | 只返回已发布且已生效的配置和内容 |
| 任务、订单、任务提交 | 已实现 | 大厅、资格预检、领单、查询和凭证提交;审核结算由后台负责 | | 任务、订单、任务提交 | 已实现 | 大厅、资格预检、领单、查询和凭证提交;审核结算由后台负责 |
| 钱包、账户明细 | 待实现 | 钱包和不可变流水表已存在 | | 钱包、账户明细 | 已实现 | 钱包余额随会员资料返回;账户明细读取不可变流水表并支持分页、业务类型和收支方向筛选 |
| 收款账户、取出 | 已实现(沙箱) | 加密绑定、试算、申请、资金冻结和记录查询;上线前仍需支付与合规评审 | | 收款账户、取出 | 已实现(沙箱) | 加密绑定、试算、申请、资金冻结和记录查询;上线前仍需支付与合规评审 |
| 储入 | 待实现 | 需在支付与合规评审后开放 | | 储入 | 待实现 | 需在支付与合规评审后开放 |
| 手工商城 | 已实现 | 已发布商品列表、分类筛选和详情;当前只提供客服咨询动作 | | 手工商城 | 已实现 | 已发布商品列表、分类筛选和详情;当前只提供客服咨询动作 |
@@ -1040,7 +1040,28 @@ GET /v1/notices/{notice_id}
POST /v1/notices/{notice_id}/read POST /v1/notices/{notice_id}/read
``` ```
列表包含当前会员通知和 `member_id=0` 的全体通知,只返回有效期内且未撤回记录。当前数据库没有会员通知已读关系表;如需精确已读状态,实现前应补充数据模型。 当前已实现通知列表接口。列表包含当前会员通知和 `member_id=0` 的全体通知,只返回有效期内且未撤回记录,按创建时间倒序排列。
```json
{
"items": [
{
"id": 1,
"title": "系统维护通知",
"content": "通知内容",
"source_type": "real",
"start_at": "2026-10-13T00:00:00Z",
"end_at": "2026-10-20T00:00:00Z",
"created_at": "2026-10-13T00:00:00Z"
}
],
"page": 1,
"page_size": 20,
"total": 1
}
```
`source_type` 为 `real`(系统通知)或 `operational_simulation`(活动通知)。详情和已读接口尚未实现;当前数据库没有会员通知已读关系表,如需精确已读状态,实现前应补充数据模型。
### 12.3 上传 ### 12.3 上传
+1
View File
@@ -20,4 +20,5 @@ var (
ErrRechargeOrderIDInvalid = errors.New("recharge order id invalid") ErrRechargeOrderIDInvalid = errors.New("recharge order id invalid")
ErrRemarkInvalid = errors.New("remark invalid") ErrRemarkInvalid = errors.New("remark invalid")
ErrVoucherInvalid = errors.New("voucher attachment ids invalid") ErrVoucherInvalid = errors.New("voucher attachment ids invalid")
ErrLedgerFilterInvalid = errors.New("wallet ledger filter invalid")
) )
+82 -7
View File
@@ -55,6 +55,7 @@ type Repository interface {
CreateRecharge(context.Context, store.RechargeCreation) (*store.RechargeOrder, error) CreateRecharge(context.Context, store.RechargeCreation) (*store.RechargeOrder, error)
RechargeOrders(context.Context, int64, int64, int, int, int) (*store.RechargePage, error) RechargeOrders(context.Context, int64, int64, int, int, int) (*store.RechargePage, error)
RechargeOrder(context.Context, int64, int64, int64) (*store.RechargeOrder, error) RechargeOrder(context.Context, int64, int64, int64) (*store.RechargeOrder, error)
WalletLedgers(context.Context, int64, int64, string, int, int, int) (*store.WalletLedgerPage, error)
} }
type Service struct { type Service struct {
@@ -209,6 +210,29 @@ type RechargePage struct {
HasNext bool `json:"has_next"` HasNext bool `json:"has_next"`
} }
type WalletLedger struct {
ID int64 `json:"id"`
LedgerNo string `json:"ledger_no"`
BizType string `json:"biz_type"`
BizNo string `json:"biz_no"`
EntryType string `json:"entry_type"`
Direction string `json:"direction"`
Amount int64 `json:"amount"`
BeforeAmount int64 `json:"before_amount"`
AfterAmount int64 `json:"after_amount"`
Currency string `json:"currency"`
Remark string `json:"remark"`
OccurredAt string `json:"occurred_at"`
}
type WalletLedgerPage struct {
Items []WalletLedger `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
HasNext bool `json:"has_next"`
}
func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig) *Service { func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig) *Service {
service := &Service{repository: repository, redis: redisClient, now: time.Now} service := &Service{repository: repository, redis: redisClient, now: time.Now}
encryptionKey, encryptionErr := decodeKey(cfg.DataEncryptionKey) encryptionKey, encryptionErr := decodeKey(cfg.DataEncryptionKey)
@@ -231,7 +255,7 @@ func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig
} }
func (s *Service) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) { func (s *Service) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) {
if !s.ready { if s.repository == nil {
return nil, ErrUnavailable return nil, ErrUnavailable
} }
rows, err := s.repository.PaymentAccounts(ctx, tenantID, memberID) rows, err := s.repository.PaymentAccounts(ctx, tenantID, memberID)
@@ -368,7 +392,7 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
} }
func (s *Service) WithdrawalOrders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*WithdrawalPage, error) { func (s *Service) WithdrawalOrders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*WithdrawalPage, error) {
if !s.ready { if s.repository == nil {
return nil, ErrUnavailable return nil, ErrUnavailable
} }
if page == 0 { if page == 0 {
@@ -399,7 +423,7 @@ func (s *Service) WithdrawalOrders(ctx context.Context, tenantID, memberID int64
} }
func (s *Service) WithdrawalOrder(ctx context.Context, tenantID, memberID, orderID int64) (*Withdrawal, error) { func (s *Service) WithdrawalOrder(ctx context.Context, tenantID, memberID, orderID int64) (*Withdrawal, error) {
if !s.ready { if s.repository == nil {
return nil, ErrUnavailable return nil, ErrUnavailable
} }
if orderID <= 0 { if orderID <= 0 {
@@ -414,7 +438,7 @@ func (s *Service) WithdrawalOrder(ctx context.Context, tenantID, memberID, order
} }
func (s *Service) RechargeChannels(ctx context.Context, tenantID int64) ([]RechargeChannel, error) { func (s *Service) RechargeChannels(ctx context.Context, tenantID int64) ([]RechargeChannel, error) {
if !s.ready { if s.repository == nil {
return nil, ErrUnavailable return nil, ErrUnavailable
} }
rows, err := s.repository.RechargeChannels(ctx, tenantID) rows, err := s.repository.RechargeChannels(ctx, tenantID)
@@ -432,7 +456,7 @@ func (s *Service) RechargeChannels(ctx context.Context, tenantID int64) ([]Recha
} }
func (s *Service) CreateRecharge(ctx context.Context, tenantID, memberID int64, input CreateRechargeInput) (*Recharge, error) { func (s *Service) CreateRecharge(ctx context.Context, tenantID, memberID int64, input CreateRechargeInput) (*Recharge, error) {
if !s.ready { if s.repository == nil {
return nil, ErrUnavailable return nil, ErrUnavailable
} }
input.Currency = strings.ToUpper(strings.TrimSpace(input.Currency)) input.Currency = strings.ToUpper(strings.TrimSpace(input.Currency))
@@ -495,7 +519,7 @@ func (s *Service) CreateRecharge(ctx context.Context, tenantID, memberID int64,
} }
func (s *Service) RechargeOrders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*RechargePage, error) { func (s *Service) RechargeOrders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*RechargePage, error) {
if !s.ready { if s.repository == nil {
return nil, ErrUnavailable return nil, ErrUnavailable
} }
if page == 0 { if page == 0 {
@@ -527,7 +551,7 @@ func (s *Service) RechargeOrders(ctx context.Context, tenantID, memberID int64,
} }
func (s *Service) RechargeOrder(ctx context.Context, tenantID, memberID, orderID int64) (*Recharge, error) { func (s *Service) RechargeOrder(ctx context.Context, tenantID, memberID, orderID int64) (*Recharge, error) {
if !s.ready { if s.repository == nil {
return nil, ErrUnavailable return nil, ErrUnavailable
} }
if orderID <= 0 { if orderID <= 0 {
@@ -541,6 +565,57 @@ func (s *Service) RechargeOrder(ctx context.Context, tenantID, memberID, orderID
return &result, nil return &result, nil
} }
func (s *Service) WalletLedgers(ctx context.Context, tenantID, memberID int64, page, pageSize int, bizType, direction string) (*WalletLedgerPage, error) {
if s.repository == nil {
return nil, ErrUnavailable
}
if page == 0 {
page = 1
}
if pageSize == 0 {
pageSize = defaultPageSize
}
if page < 1 {
return nil, ErrPageInvalid
}
if pageSize < 1 || pageSize > maxPageSize {
return nil, ErrPageSizeInvalid
}
bizType = strings.TrimSpace(bizType)
if len(bizType) > 32 {
return nil, ErrLedgerFilterInvalid
}
directionCode := 0
switch strings.ToLower(strings.TrimSpace(direction)) {
case "", "all":
case "income":
directionCode = 1
case "expense":
directionCode = 2
default:
return nil, ErrLedgerFilterInvalid
}
rows, err := s.repository.WalletLedgers(ctx, tenantID, memberID, bizType, directionCode, (page-1)*pageSize, pageSize)
if err != nil {
return nil, err
}
items := make([]WalletLedger, 0, len(rows.Items))
for _, row := range rows.Items {
directionName := "expense"
if row.Direction == 1 {
directionName = "income"
}
items = append(items, WalletLedger{
ID: row.ID, LedgerNo: row.LedgerNo, BizType: row.BizType, BizNo: row.BizNo,
EntryType: row.EntryType, Direction: directionName, Amount: row.Amount,
BeforeAmount: row.BeforeAmount, AfterAmount: row.AfterAmount, Currency: row.Currency,
Remark: row.Remark, OccurredAt: row.OccurredAt,
})
}
return &WalletLedgerPage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total,
HasNext: int64(page*pageSize) < rows.Total}, nil
}
func (s *Service) quote(ctx context.Context, tenantID, memberID int64, input QuoteInput) (*Quote, error) { func (s *Service) quote(ctx context.Context, tenantID, memberID int64, input QuoteInput) (*Quote, error) {
quote, _, _, err := s.quoteData(ctx, tenantID, memberID, input) quote, _, _, err := s.quoteData(ctx, tenantID, memberID, input)
return quote, err return quote, err
+42 -1
View File
@@ -19,6 +19,7 @@ type fakeRepository struct {
orders []store.WithdrawalOrder orders []store.WithdrawalOrder
rechargeChannels []store.RechargeChannel rechargeChannels []store.RechargeChannel
rechargeOrders []store.RechargeOrder rechargeOrders []store.RechargeOrder
ledgers []store.WalletLedger
createdRecharge store.RechargeCreation createdRecharge store.RechargeCreation
} }
@@ -67,6 +68,9 @@ func (f *fakeRepository) RechargeOrders(context.Context, int64, int64, int, int,
func (f *fakeRepository) RechargeOrder(context.Context, int64, int64, int64) (*store.RechargeOrder, error) { func (f *fakeRepository) RechargeOrder(context.Context, int64, int64, int64) (*store.RechargeOrder, error) {
return nil, errors.New("not implemented") return nil, errors.New("not implemented")
} }
func (f *fakeRepository) WalletLedgers(context.Context, int64, int64, string, int, int, int) (*store.WalletLedgerPage, error) {
return &store.WalletLedgerPage{Items: f.ledgers, Total: int64(len(f.ledgers))}, nil
}
func TestQuoteCalculatesRateFeeAndLimits(t *testing.T) { func TestQuoteCalculatesRateFeeAndLimits(t *testing.T) {
repository := &fakeRepository{config: json.RawMessage(`{"features":{"withdrawal_enabled":true}}`), repository := &fakeRepository{config: json.RawMessage(`{"features":{"withdrawal_enabled":true}}`),
@@ -127,7 +131,7 @@ func TestCreateRechargeValidatesChannelAndMapsOrder(t *testing.T) {
repository := &fakeRepository{rechargeChannels: []store.RechargeChannel{{ repository := &fakeRepository{rechargeChannels: []store.RechargeChannel{{
ID: 4, Code: "manual", Name: "人工审核", MinAmount: 100, MaxAmount: 100000, ID: 4, Code: "manual", Name: "人工审核", MinAmount: 100, MaxAmount: 100000,
}}} }}}
service := &Service{repository: repository, ready: true, now: time.Now} service := New(repository, nil, config.AuthConfig{})
result, err := service.CreateRecharge(context.Background(), 0, 9, CreateRechargeInput{ result, err := service.CreateRecharge(context.Background(), 0, 9, CreateRechargeInput{
ChannelID: 4, Amount: 50000, Currency: "cny", Remark: "测试", ChannelID: 4, Amount: 50000, Currency: "cny", Remark: "测试",
}) })
@@ -157,6 +161,43 @@ func TestRechargeOrdersMapsStatusAndPagination(t *testing.T) {
} }
} }
func TestFinanceOrderListsDoNotRequireEncryptionKeys(t *testing.T) {
repository := &fakeRepository{
orders: []store.WithdrawalOrder{{ID: 2, WithdrawalNo: "WD2", Status: 1}},
rechargeOrders: []store.RechargeOrder{{ID: 3, RechargeNo: "RC3", Status: 1}},
}
service := New(repository, nil, config.AuthConfig{})
withdrawals, err := service.WithdrawalOrders(context.Background(), 0, 9, 1, 20, "all")
if err != nil || len(withdrawals.Items) != 1 {
t.Fatalf("withdrawal records should remain readable without encryption keys: %#v, %v", withdrawals, err)
}
recharges, err := service.RechargeOrders(context.Background(), 0, 9, 1, 20, "all")
if err != nil || len(recharges.Items) != 1 {
t.Fatalf("recharge records should remain readable without encryption keys: %#v, %v", recharges, err)
}
if _, err = service.PaymentAccounts(context.Background(), 0, 9); err != nil {
t.Fatalf("masked payment accounts should remain readable without encryption keys, got %v", err)
}
if _, err = service.CreatePaymentAccount(context.Background(), 0, 9, PaymentAccountInput{}); !errors.Is(err, ErrUnavailable) {
t.Fatalf("sensitive finance operations must remain unavailable without encryption keys, got %v", err)
}
}
func TestWalletLedgersDoNotRequireEncryptionKeys(t *testing.T) {
service := New(&fakeRepository{ledgers: []store.WalletLedger{{
ID: 7, LedgerNo: "WL7", Direction: 1, Amount: 947, Remark: "下级交易返佣",
}}}, nil, config.AuthConfig{})
result, err := service.WalletLedgers(context.Background(), 0, 9, 1, 20, "", "all")
if err != nil || result.Total != 1 || len(result.Items) != 1 ||
result.Items[0].Direction != "income" || result.Items[0].Amount != 947 {
t.Fatalf("wallet ledgers should remain readable without encryption keys: %#v, %v", result, err)
}
if _, err = service.WalletLedgers(context.Background(), 0, 9, 1, 20, "", "sideways"); !errors.Is(err, ErrLedgerFilterInvalid) {
t.Fatalf("expected invalid ledger direction, got %v", err)
}
}
func TestDeterministicNumberIsStableAndScoped(t *testing.T) { func TestDeterministicNumberIsStableAndScoped(t *testing.T) {
first := deterministicNumber("WD", 0, 1, "request-key") first := deterministicNumber("WD", 0, 1, "request-key")
if first != deterministicNumber("WD", 0, 1, "request-key") || first == deterministicNumber("WD", 0, 2, "request-key") { if first != deterministicNumber("WD", 0, 1, "request-key") || first == deterministicNumber("WD", 0, 2, "request-key") {
+2
View File
@@ -38,6 +38,7 @@ func (a *API) Register(mux *http.ServeMux) {
mux.HandleFunc("/v1/member-levels", a.memberLevels) mux.HandleFunc("/v1/member-levels", a.memberLevels)
mux.HandleFunc("/v1/leaderboard", a.leaderboard) mux.HandleFunc("/v1/leaderboard", a.leaderboard)
mux.HandleFunc("/v1/customer-services", a.customerServices) mux.HandleFunc("/v1/customer-services", a.customerServices)
mux.HandleFunc("/v1/notices", a.notices)
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)
mux.HandleFunc("/v1/tasks/stats", a.taskStats) mux.HandleFunc("/v1/tasks/stats", a.taskStats)
@@ -47,6 +48,7 @@ func (a *API) Register(mux *http.ServeMux) {
mux.HandleFunc("/v1/manual-products", a.manualProducts) mux.HandleFunc("/v1/manual-products", a.manualProducts)
mux.HandleFunc("/v1/manual-products/", a.manualProduct) mux.HandleFunc("/v1/manual-products/", a.manualProduct)
mux.HandleFunc("/v1/payment-accounts", a.paymentAccounts) mux.HandleFunc("/v1/payment-accounts", a.paymentAccounts)
mux.HandleFunc("/v1/wallet/ledgers", a.walletLedgers)
mux.HandleFunc("/v1/recharge/channels", a.rechargeChannels) mux.HandleFunc("/v1/recharge/channels", a.rechargeChannels)
mux.HandleFunc("/v1/recharge-orders", a.rechargeOrders) mux.HandleFunc("/v1/recharge-orders", a.rechargeOrders)
mux.HandleFunc("/v1/recharge-orders/", a.rechargeOrder) mux.HandleFunc("/v1/recharge-orders/", a.rechargeOrder)
+30
View File
@@ -41,6 +41,34 @@ func (a *API) paymentAccounts(w http.ResponseWriter, r *http.Request) {
writeSuccess(w, r, result) writeSuccess(w, r, result)
} }
func (a *API) walletLedgers(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, r, http.MethodGet)
return
}
principal, ok := a.authenticateFinanceMember(w, r)
if !ok {
return
}
page, ok := optionalPositiveInt(w, r, "page")
if !ok {
writeFinanceError(w, r, finance.ErrPageInvalid)
return
}
pageSize, ok := optionalPositiveInt(w, r, "page_size")
if !ok {
writeFinanceError(w, r, finance.ErrPageSizeInvalid)
return
}
result, err := a.finance.WalletLedgers(r.Context(), principal.TenantID, principal.MemberID,
page, pageSize, r.URL.Query().Get("biz_type"), r.URL.Query().Get("direction"))
if err != nil {
writeFinanceError(w, r, err)
return
}
writeSuccess(w, r, result)
}
func (a *API) withdrawalQuote(w http.ResponseWriter, r *http.Request) { func (a *API) withdrawalQuote(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
methodNotAllowed(w, r, http.MethodPost) methodNotAllowed(w, r, http.MethodPost)
@@ -256,6 +284,8 @@ func writeFinanceError(w http.ResponseWriter, r *http.Request, err error) {
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_INVALID", "validation failed", map[string]any{"field": "page"}) writeError(w, r, http.StatusBadRequest, 30000, "PAGE_INVALID", "validation failed", map[string]any{"field": "page"})
case errors.Is(err, finance.ErrPageSizeInvalid): case errors.Is(err, finance.ErrPageSizeInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_SIZE_INVALID", "validation failed", map[string]any{"field": "page_size"}) writeError(w, r, http.StatusBadRequest, 30000, "PAGE_SIZE_INVALID", "validation failed", map[string]any{"field": "page_size"})
case errors.Is(err, finance.ErrLedgerFilterInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "LEDGER_FILTER_INVALID", "validation failed", nil)
case errors.Is(err, finance.ErrStatusInvalid): case errors.Is(err, finance.ErrStatusInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "WITHDRAWAL_STATUS_INVALID", "validation failed", map[string]any{"field": "status"}) writeError(w, r, http.StatusBadRequest, 30000, "WITHDRAWAL_STATUS_INVALID", "validation failed", map[string]any{"field": "status"})
case errors.Is(err, finance.ErrRechargeStatusInvalid): case errors.Is(err, finance.ErrRechargeStatusInvalid):
+34
View File
@@ -157,6 +157,36 @@ func (a *API) customerServices(w http.ResponseWriter, r *http.Request) {
writeSuccess(w, r, items) writeSuccess(w, r, items)
} }
func (a *API) notices(w http.ResponseWriter, r *http.Request) {
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
}
page, ok := optionalPositiveInt(w, r, "page")
if !ok {
writePortalError(w, r, portal.ErrPageInvalid)
return
}
pageSize, ok := optionalPositiveInt(w, r, "page_size")
if !ok {
writePortalError(w, r, portal.ErrPageSizeInvalid)
return
}
result, err := a.portal.Notices(r.Context(), principal.TenantID, principal.MemberID, page, pageSize)
if err != nil {
writePortalError(w, r, err)
return
}
writeSuccess(w, r, result)
}
func (a *API) ensurePortal(w http.ResponseWriter, r *http.Request) bool { func (a *API) ensurePortal(w http.ResponseWriter, r *http.Request) bool {
if a.portal != nil { if a.portal != nil {
return true return true
@@ -171,6 +201,10 @@ func writePortalError(w http.ResponseWriter, r *http.Request, err error) {
writeError(w, r, http.StatusBadRequest, 30000, "BANNER_POSITION_INVALID", "validation failed", map[string]any{"field": "position"}) writeError(w, r, http.StatusBadRequest, 30000, "BANNER_POSITION_INVALID", "validation failed", map[string]any{"field": "position"})
case errors.Is(err, portal.ErrContentKeyInvalid): case errors.Is(err, portal.ErrContentKeyInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "CONTENT_KEY_INVALID", "validation failed", map[string]any{"field": "content_key"}) writeError(w, r, http.StatusBadRequest, 30000, "CONTENT_KEY_INVALID", "validation failed", map[string]any{"field": "content_key"})
case errors.Is(err, portal.ErrPageInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_INVALID", "validation failed", map[string]any{"field": "page"})
case errors.Is(err, portal.ErrPageSizeInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_SIZE_INVALID", "validation failed", map[string]any{"field": "page_size"})
case errors.Is(err, store.ErrResourceNotFound): case errors.Is(err, store.ErrResourceNotFound):
writeError(w, r, http.StatusNotFound, 30044, "RESOURCE_NOT_FOUND", "resource not found", nil) writeError(w, r, http.StatusNotFound, 30044, "RESOURCE_NOT_FOUND", "resource not found", nil)
default: default:
+2
View File
@@ -5,4 +5,6 @@ import "errors"
var ( var (
ErrPositionInvalid = errors.New("banner position invalid") ErrPositionInvalid = errors.New("banner position invalid")
ErrContentKeyInvalid = errors.New("content key invalid") ErrContentKeyInvalid = errors.New("content key invalid")
ErrPageInvalid = errors.New("page invalid")
ErrPageSizeInvalid = errors.New("page size invalid")
) )
+41 -6
View File
@@ -22,6 +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)
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)
TaskLeaderboard(context.Context, int64, int) ([]store.LeaderboardMember, error) TaskLeaderboard(context.Context, int64, int) ([]store.LeaderboardMember, error)
ActiveCustomerServices(context.Context, int64) ([]store.CustomerService, error) ActiveCustomerServices(context.Context, int64) ([]store.CustomerService, error)
@@ -53,6 +54,14 @@ type Notice struct {
SourceType string `json:"source_type"` SourceType string `json:"source_type"`
StartAt string `json:"start_at"` StartAt string `json:"start_at"`
EndAt string `json:"end_at"` EndAt string `json:"end_at"`
CreatedAt string `json:"created_at"`
}
type NoticePage struct {
Items []Notice `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
} }
type Home struct { type Home struct {
@@ -168,6 +177,32 @@ func (s *Service) Banners(ctx context.Context, tenantID int64, position string)
return items, nil return items, nil
} }
func (s *Service) Notices(ctx context.Context, tenantID, memberID int64, page, pageSize int) (*NoticePage, error) {
if page == 0 {
page = 1
}
if pageSize == 0 {
pageSize = 20
}
if page < 1 {
return nil, ErrPageInvalid
}
if pageSize < 1 || pageSize > 100 {
return nil, ErrPageSizeInvalid
}
rows, err := s.repository.MemberNotices(ctx, tenantID, memberID, (page-1)*pageSize, pageSize)
if err != nil {
return nil, err
}
items := make([]Notice, 0, len(rows.Items))
for _, row := range rows.Items {
items = append(items, Notice{ID: row.ID, Title: row.Title, Content: row.Content,
SourceType: noticeSourceName(row.SourceType), StartAt: row.StartAt, EndAt: row.EndAt,
CreatedAt: row.CreatedAt})
}
return &NoticePage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil
}
func (s *Service) Content(ctx context.Context, tenantID int64, key string) (*Content, error) { func (s *Service) Content(ctx context.Context, tenantID int64, key string) (*Content, error) {
key = strings.ToLower(strings.TrimSpace(key)) key = strings.ToLower(strings.TrimSpace(key))
if !contentKeyPattern.MatchString(key) { if !contentKeyPattern.MatchString(key) {
@@ -256,14 +291,14 @@ func parseFeatures(raw json.RawMessage) Features {
return Features{} return Features{}
} }
return Features{ return Features{
TaskEnabled: findBool(config, true, "task_enabled", "taskEnabled", "TASK"), TaskEnabled: findBool(config, "task_enabled", "taskEnabled", "TASK"),
RechargeEnabled: findBool(config, true, "recharge_enabled", "rechargeEnabled", "RECHARGE"), RechargeEnabled: findBool(config, "recharge_enabled", "rechargeEnabled", "RECHARGE"),
WithdrawalEnabled: findBool(config, true, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"), WithdrawalEnabled: findBool(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"),
ManualMallEnabled: findBool(config, true, "manual_mall_enabled", "manualMallEnabled", "SHOP_STATUS"), ManualMallEnabled: findBool(config, "manual_mall_enabled", "manualMallEnabled", "SHOP_STATUS"),
} }
} }
func findBool(config map[string]any, fallback bool, keys ...string) bool { func findBool(config map[string]any, keys ...string) bool {
for _, key := range keys { for _, key := range keys {
if value, ok := config[key]; ok { if value, ok := config[key]; ok {
return boolValue(value) return boolValue(value)
@@ -276,7 +311,7 @@ func findBool(config map[string]any, fallback bool, keys ...string) bool {
} }
} }
} }
return fallback return false
} }
func boolValue(value any) bool { func boolValue(value any) bool {
+37 -15
View File
@@ -13,14 +13,15 @@ import (
) )
type fakeRepository struct { type fakeRepository struct {
config json.RawMessage config json.RawMessage
banners []store.Banner banners []store.Banner
content *store.Content content *store.Content
levels []store.MemberLevel levels []store.MemberLevel
notices []store.MemberNotice notices []store.MemberNotice
summary *store.TaskSummary summary *store.TaskSummary
leaders []store.LeaderboardMember leaders []store.LeaderboardMember
services []store.CustomerService services []store.CustomerService
noticePage *store.MemberNoticePage
} }
func (r fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) { func (r fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) {
@@ -41,6 +42,12 @@ 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) MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error) {
if r.noticePage == nil {
return &store.MemberNoticePage{Items: []store.MemberNotice{}}, nil
}
return r.noticePage, nil
}
func (r fakeRepository) MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error) { func (r fakeRepository) MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error) {
return r.summary, nil return r.summary, nil
} }
@@ -61,13 +68,6 @@ func TestParseFeaturesSupportsNestedAndLegacyKeys(t *testing.T) {
} }
} }
func TestParseFeaturesDefaultsToEnabled(t *testing.T) {
features := parseFeatures(json.RawMessage(`{"siteName":"demo"}`))
if !features.TaskEnabled || !features.RechargeEnabled || !features.WithdrawalEnabled || !features.ManualMallEnabled {
t.Fatalf("unexpected feature defaults: %+v", features)
}
}
func TestBannersSanitizeTargets(t *testing.T) { func TestBannersSanitizeTargets(t *testing.T) {
service := New(fakeRepository{banners: []store.Banner{ service := New(fakeRepository{banners: []store.Banner{
{ID: 1, TargetType: 2, TargetValue: "/receive"}, {ID: 1, TargetType: 2, TargetValue: "/receive"},
@@ -171,3 +171,25 @@ func TestCustomerServicesSanitizeURLsAndMapStatus(t *testing.T) {
t.Fatalf("unexpected customer services: %+v", items) t.Fatalf("unexpected customer services: %+v", items)
} }
} }
func TestNoticesMapsPageAndSourceType(t *testing.T) {
service := New(fakeRepository{noticePage: &store.MemberNoticePage{
Items: []store.MemberNotice{{ID: 3, Title: "活动通知", SourceType: 2, CreatedAt: "2026-10-13T08:00:00Z"}},
Total: 21,
}})
page, err := service.Notices(context.Background(), 1, 7, 2, 20)
if err != nil {
t.Fatal(err)
}
if page.Page != 2 || page.PageSize != 20 || page.Total != 21 || len(page.Items) != 1 ||
page.Items[0].SourceType != "operational_simulation" || page.Items[0].CreatedAt == "" {
t.Fatalf("unexpected notice page: %+v", page)
}
}
func TestNoticesRejectsOversizedPage(t *testing.T) {
service := New(fakeRepository{})
if _, err := service.Notices(context.Background(), 1, 7, 1, 101); !errors.Is(err, ErrPageSizeInvalid) {
t.Fatalf("error = %v, want %v", err, ErrPageSizeInvalid)
}
}
+29 -1
View File
@@ -53,6 +53,12 @@ type MemberNotice struct {
SourceType int `db:"source_type"` SourceType int `db:"source_type"`
StartAt string `db:"start_at"` StartAt string `db:"start_at"`
EndAt string `db:"end_at"` EndAt string `db:"end_at"`
CreatedAt string `db:"created_at"`
}
type MemberNoticePage struct {
Items []MemberNotice
Total int64
} }
type TaskSummary struct { type TaskSummary struct {
@@ -147,13 +153,35 @@ func (s *Store) ActiveMemberNotices(ctx context.Context, tenantID, memberID int6
rows := make([]MemberNotice, 0) rows := make([]MemberNotice, 0)
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
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<=UTC_TIMESTAMP(3) AND end_at>UTC_TIMESTAMP(3)
ORDER BY start_at DESC,id DESC LIMIT ?`, tenantID, memberID, limit) ORDER BY start_at DESC,id DESC LIMIT ?`, tenantID, memberID, limit)
return rows, err return rows, err
} }
func (s *Store) MemberNotices(ctx context.Context, tenantID, memberID int64, offset, limit int) (*MemberNoticePage, error) {
if s == nil || s.db == nil {
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}
var total int64
if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_app_member_notice`+where, args...); err != nil {
return nil, err
}
items := make([]MemberNotice, 0)
args = append(args, limit, offset)
err := s.db.SelectContext(ctx, &items, `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+` ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?`, args...)
return &MemberNoticePage{Items: items, Total: total}, err
}
func (s *Store) MemberTaskSummary(ctx context.Context, tenantID, memberID int64) (*TaskSummary, error) { func (s *Store) MemberTaskSummary(ctx context.Context, tenantID, memberID int64) (*TaskSummary, 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")
+50
View File
@@ -0,0 +1,50 @@
package store
import (
"context"
)
type WalletLedger struct {
ID int64 `db:"id"`
LedgerNo string `db:"ledger_no"`
BizType string `db:"biz_type"`
BizNo string `db:"biz_no"`
EntryType string `db:"entry_type"`
Direction int `db:"direction"`
Amount int64 `db:"amount"`
BeforeAmount int64 `db:"before_amount"`
AfterAmount int64 `db:"after_amount"`
Currency string `db:"currency"`
Remark string `db:"remark"`
OccurredAt string `db:"occurred_at"`
}
type WalletLedgerPage struct {
Items []WalletLedger
Total int64
}
func (s *Store) WalletLedgers(ctx context.Context, tenantID, memberID int64, bizType string, direction, offset, limit int) (*WalletLedgerPage, error) {
where := ` WHERE tenant_id=? AND member_id=?`
args := []any{tenantID, memberID}
if bizType != "" {
where += ` AND biz_type=?`
args = append(args, bizType)
}
if direction > 0 {
where += ` AND direction=?`
args = append(args, direction)
}
var total int64
if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_wallet_ledger`+where, args...); err != nil {
return nil, err
}
args = append(args, limit, offset)
items := make([]WalletLedger, 0)
err := s.db.SelectContext(ctx, &items, `SELECT id,ledger_no,biz_type,biz_no,entry_type,direction,
amount,before_amount,after_amount,currency,remark,
DATE_FORMAT(occurred_at,'%Y-%m-%dT%H:%i:%sZ') AS occurred_at
FROM hg_wallet_ledger`+where+` ORDER BY occurred_at DESC,id DESC LIMIT ? OFFSET ?`, args...)
return &WalletLedgerPage{Items: items, Total: total}, err
}
+177 -1
View File
@@ -1038,7 +1038,6 @@ body {
margin: 7px 0 14px; margin: 7px 0 14px;
color: var(--m-muted); color: var(--m-muted);
} }
.m-product-grid { .m-product-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -2388,6 +2387,173 @@ body {
color: #ff681c; color: #ff681c;
} }
.m-notices-page {
padding-inline: 0;
background: #f7f7f7;
}
.m-notices-page .m-page-header {
padding-inline: 20px;
background: #fff;
}
.m-notices-list {
display: grid;
gap: 10px;
padding: 12px 14px 0;
}
.m-notices-item {
padding: 16px;
border: 1px solid #eeeeec;
border-radius: 8px;
background: #fff;
}
.m-notices-heading {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
align-items: center;
gap: 11px;
}
.m-notices-icon {
width: 42px;
height: 42px;
border-radius: 50%;
display: grid;
place-items: center;
color: #fff;
background: linear-gradient(145deg, #ffa840, #f2661c);
}
.m-notices-icon .n-icon {
font-size: 23px;
}
.m-notices-heading h2 {
margin: 0 0 5px;
overflow-wrap: anywhere;
font-size: 17px;
line-height: 1.35;
font-weight: 600;
}
.m-notices-tag {
display: inline-block;
padding: 2px 7px;
border-radius: 4px;
color: #7a531c;
background: #fff4df;
font-size: 12px;
line-height: 18px;
}
.m-notices-tag.is-activity {
color: #24704b;
background: #e9f7ef;
}
.m-notices-item > p {
margin: 13px 0 10px;
color: #3f4247;
font-size: 15px;
line-height: 1.7;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.m-notices-item > time {
color: #999;
font-size: 13px;
}
.m-notices-footer {
min-height: 58px;
display: grid;
place-items: center;
padding: 8px 14px 20px;
color: #999;
font-size: 13px;
}
.m-notices-footer p {
margin: 0;
color: #b05b24;
}
.m-notices-footer button {
min-width: 110px;
min-height: 40px;
border: 0;
background: transparent;
color: #986024;
}
.m-notices-footer button:disabled {
opacity: 0.55;
}
.m-ledgers {
padding-inline: 0;
background: #f7f7f7;
}
.m-ledgers .m-page-header {
padding-inline: 20px;
background: #fff;
}
.m-ledger-tabs {
height: 58px;
padding: 8px 18px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
background: #fff;
border-top: 1px solid #f2f2f2;
}
.m-ledger-tabs button {
border: 0;
border-radius: 6px;
background: #f5f5f5;
color: #777;
font-size: 15px;
}
.m-ledger-tabs button.active {
color: #fff;
background: #986024;
}
.m-ledger-list {
padding: 12px 14px 24px;
display: grid;
gap: 10px;
}
.m-ledger-item {
padding: 15px 16px;
border-radius: 8px;
background: #fff;
box-shadow: 0 3px 14px rgba(73, 54, 31, 0.06);
}
.m-ledger-head {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.m-ledger-head strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 16px;
font-weight: 500;
}
.m-ledger-head b {
flex: 0 0 auto;
font-size: 18px;
font-weight: 600;
}
.m-ledger-head .is-income {
color: #1f9d68;
}
.m-ledger-head .is-expense {
color: #d55d43;
}
.m-ledger-item p {
margin: 9px 0 6px;
color: #444;
font-size: 15px;
}
.m-ledger-item time {
color: #9a9da3;
font-size: 13px;
}
@media (min-width: 751px) { @media (min-width: 751px) {
body { body {
background: #e9eaec; background: #e9eaec;
@@ -2413,6 +2579,12 @@ body {
margin-inline: -14px; margin-inline: -14px;
padding-inline: 14px; padding-inline: 14px;
} }
.m-product-detail {
margin-inline: -14px;
}
.m-plain-form {
margin-inline: -14px;
}
} }
.m-section-title a { .m-section-title a {
@@ -3172,6 +3344,10 @@ body {
margin: 8px 0 0; margin: 8px 0 0;
color: #777; color: #777;
line-height: 1.6; line-height: 1.6;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
overflow: hidden;
} }
.m-qualification-card > img { .m-qualification-card > img {
position: absolute; position: absolute;
@@ -46,11 +46,21 @@
const loading = ref(true); const loading = ref(true);
const error = ref(''); const error = ref('');
const fallback = '我们致力于通过高效的供应链和优质的服务为用户带来更多实惠与更美好的生活乐趣,让品质好物走进更多人的生活。'; const fallback = '我们致力于通过高效的供应链和优质的服务为用户带来更多实惠与更美好的生活乐趣,让品质好物走进更多人的生活。';
const paragraphs = computed(() => const bodyText = computed(() => String(content.value?.body_text || '').replace(/\s+/g, ' ').trim());
String(content.value?.body_text || '').split(/\n+/).map((item) => item.trim()).filter(Boolean), function sectionText(start: string, end: string) {
const body = bodyText.value;
const startAt = body.indexOf(start);
if (startAt < 0) return '';
const from = startAt + start.length;
const endAt = body.indexOf(end, from);
return body.slice(from, endAt < 0 ? undefined : endAt).trim().replace(/^[,。:“”\s]+/, '');
}
const operationText = computed(() =>
sectionText('多实惠,多乐趣', '新国潮,新消费') || content.value?.summary || fallback,
);
const developmentText = computed(() =>
sectionText('新国潮,新消费', '合作品牌') || content.value?.summary || fallback,
); );
const operationText = computed(() => content.value?.summary || paragraphs.value[0] || fallback);
const developmentText = computed(() => paragraphs.value[1] || paragraphs.value[0] || fallback);
async function load() { async function load() {
loading.value = true; loading.value = true;
+1 -1
View File
@@ -348,7 +348,7 @@
loading.value = true; loading.value = true;
error.value = ''; error.value = '';
const key = String(route.params.key || ''); const key = String(route.params.key || '');
if (['recharge_records', 'wallet_ledgers', 'notices'].includes(key)) { if (['recharge_records', 'wallet_ledgers'].includes(key)) {
loading.value = false; loading.value = false;
return; return;
} }
+2 -2
View File
@@ -128,13 +128,13 @@
label: '储入', label: '储入',
path: '/app/recharge', path: '/app/recharge',
icon: mobileAssets.shortcutRecharge, icon: mobileAssets.shortcutRecharge,
enabled: home.value?.features?.recharge_enabled !== false, enabled: home.value?.features?.recharge_enabled === true,
}, },
{ {
label: '取出', label: '取出',
path: '/app/profile/withdrawal', path: '/app/profile/withdrawal',
icon: mobileAssets.shortcutWithdraw, icon: mobileAssets.shortcutWithdraw,
enabled: home.value?.features?.withdrawal_enabled !== false, enabled: home.value?.features?.withdrawal_enabled === true,
}, },
{ {
label: '商城', label: '商城',
+105
View File
@@ -0,0 +1,105 @@
<template>
<section class="m-page m-page--white m-notices-page">
<PageHeader title="弹窗明细" />
<AsyncState
:loading="loading && !items.length"
:error="error && !items.length ? error : ''"
:empty="!items.length"
empty-text="暂无弹窗记录"
@retry="reload"
>
<div class="m-notices-list">
<article v-for="item in items" :key="item.id" class="m-notices-item">
<div class="m-notices-heading">
<span class="m-notices-icon" aria-hidden="true">
<n-icon :component="NotificationsOutline" />
</span>
<div>
<h2>{{ item.title || '通知' }}</h2>
<span :class="['m-notices-tag', { 'is-activity': item.source_type === 'operational_simulation' }]">
{{ sourceText(item.source_type) }}
</span>
</div>
</div>
<p>{{ item.content }}</p>
<time>{{ dateTime(item.created_at || item.start_at) }}</time>
</article>
</div>
<div class="m-notices-footer">
<p v-if="error">{{ error }}</p>
<button v-if="hasMore" type="button" :disabled="loading" @click="loadMore">
{{ loading ? '加载中...' : '加载更多' }}
</button>
<span v-else>没有更多了</span>
</div>
</AsyncState>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { NotificationsOutline } from '@vicons/ionicons5';
import PageHeader from '../components/PageHeader.vue';
import AsyncState from '../components/AsyncState.vue';
import { dateTime, errorText, request } from '../api';
interface NoticeItem {
id: number;
title: string;
content: string;
source_type: 'real' | 'operational_simulation' | string;
start_at: string;
end_at: string;
created_at: string;
}
interface NoticePage {
items: NoticeItem[];
page: number;
page_size: number;
total: number;
}
const pageSize = 20;
const items = ref<NoticeItem[]>([]);
const page = ref(0);
const total = ref(0);
const loading = ref(false);
const error = ref('');
const hasMore = computed(() => items.value.length < total.value);
const sourceText = (value: string) =>
value === 'operational_simulation' ? '活动通知' : '系统通知';
async function load(targetPage: number) {
if (loading.value) return;
loading.value = true;
error.value = '';
try {
const data = await request<NoticePage>({
url: '/v1/notices',
params: { page: targetPage, page_size: pageSize },
});
items.value = targetPage === 1 ? data.items || [] : [...items.value, ...(data.items || [])];
page.value = data.page;
total.value = data.total;
} catch (err) {
error.value = errorText(err);
} finally {
loading.value = false;
}
}
function reload() {
items.value = [];
page.value = 0;
total.value = 0;
load(1);
}
function loadMore() {
load(page.value + 1);
}
onMounted(reload);
</script>
+2 -58
View File
@@ -99,46 +99,10 @@
><span>数量</span><b>×{{ detail.quantity || 1 }}</b></div ><span>数量</span><b>×{{ detail.quantity || 1 }}</b></div
> >
<div <div
><span>截止时间</span><b>{{ dateTime(detail.deadline_at) }}</b></div ><span>规格</span
><b>{{ detail.product?.specification || detail.product?.spec || '--' }}</b></div
> >
</section> </section>
<section v-if="detail.task?.description || detail.message" class="m-rule-box m-card"
><h2>任务说明</h2><p>{{ detail.task?.description || detail.message }}</p></section
>
<section v-if="detail.submission" class="m-detail-fields m-card">
<div
><span>提交状态</span
><b>{{ detail.submission.status_text || detail.submission.status }}</b></div
>
<div v-if="detail.submission.review_reason"
><span>处理说明</span><b>{{ detail.submission.review_reason }}</b></div
>
<div
><span>提交时间</span><b>{{ dateTime(detail.submission.submitted_at) }}</b></div
>
</section>
<form
v-if="detail.allowed_actions?.includes('submit')"
class="m-submit-form"
@submit.prevent="submitTask"
>
<h2>提交任务凭证</h2>
<textarea
v-model.trim="evidence"
maxlength="2000"
placeholder="请填写任务完成说明"
></textarea>
<small>{{ evidence.length }}/2000,附件上传接口开放后可补充图片或 PDF</small>
<button class="m-primary-btn" :disabled="submitting || !evidence">{{
submitting ? '提交中...' : '提交审核'
}}</button>
</form>
<router-link
v-if="detail.allowed_actions?.includes('contact_support')"
class="m-primary-btn m-btn-link"
to="/app/support"
>联系客服</router-link
>
</div> </div>
</AsyncState> </AsyncState>
</section> </section>
@@ -160,8 +124,6 @@
const orders = ref<any[]>([]); const orders = ref<any[]>([]);
const detail = ref<any>(); const detail = ref<any>();
const stats = ref<any>(); const stats = ref<any>();
const evidence = ref('');
const submitting = ref(false);
let listController: AbortController | undefined; let listController: AbortController | undefined;
const tabs = [ const tabs = [
{ label: '全部', value: 'all' }, { label: '全部', value: 'all' },
@@ -212,24 +174,6 @@
if (listController === controller) loading.value = false; if (listController === controller) loading.value = false;
} }
} }
async function submitTask() {
if (!evidence.value || submitting.value) return;
submitting.value = true;
try {
await request({
url: `/v1/task-orders/${route.params.id}/submissions`,
method: 'POST',
data: { evidence_text: evidence.value, evidence_attachment_ids: [] },
});
window.$message?.success('提交成功,等待审核');
evidence.value = '';
await loadDetail();
} catch (err) {
window.$message?.error(errorText(err));
} finally {
submitting.value = false;
}
}
async function loadDetail() { async function loadDetail() {
loading.value = true; loading.value = true;
error.value = ''; error.value = '';
+1 -1
View File
@@ -73,7 +73,7 @@
path: '/app/profile/records?tab=withdrawal', path: '/app/profile/records?tab=withdrawal',
icon: mobileAssets.menuWithdraw, icon: mobileAssets.menuWithdraw,
}, },
{ label: '账户明细', path: '/app/content/wallet_ledgers', icon: mobileAssets.menuLedger }, { label: '账户明细', path: '/app/profile/ledgers', icon: mobileAssets.menuLedger },
{ label: '弹窗明细', path: '/app/content/notices', icon: mobileAssets.menuNotices }, { label: '弹窗明细', path: '/app/content/notices', icon: mobileAssets.menuNotices },
]; ];
async function load() { async function load() {
+105
View File
@@ -0,0 +1,105 @@
<template>
<section class="m-page m-page--white m-ledgers">
<PageHeader title="账户明细" />
<div class="m-ledger-tabs" role="tablist" aria-label="收支类型">
<button
v-for="item in tabs"
:key="item.value"
:class="{ active: direction === item.value }"
@click="changeDirection(item.value)"
>
{{ item.label }}
</button>
</div>
<AsyncState
:loading="loading"
:error="error"
:empty="!items.length"
empty-text="暂无账户明细"
@retry="load"
>
<div class="m-ledger-list">
<article v-for="item in items" :key="item.id" class="m-ledger-item">
<div class="m-ledger-head">
<strong>{{ item.ledger_no }}</strong>
<b :class="item.direction === 'income' ? 'is-income' : 'is-expense'">
{{ item.direction === 'income' ? '+' : '-' }}{{ plainMoney(item.amount) }}
</b>
</div>
<p>{{ item.remark || entryText(item.entry_type) }}</p>
<time>{{ dateTimeSeconds(item.occurred_at) }}</time>
</article>
</div>
</AsyncState>
</section>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import PageHeader from '../components/PageHeader.vue';
import AsyncState from '../components/AsyncState.vue';
import { errorText, request } from '../api';
type Direction = 'all' | 'income' | 'expense';
const tabs: Array<{ label: string; value: Direction }> = [
{ label: '全部', value: 'all' },
{ label: '收入', value: 'income' },
{ label: '支出', value: 'expense' },
];
const direction = ref<Direction>('all');
const loading = ref(true);
const error = ref('');
const items = ref<any[]>([]);
const entryLabels: Record<string, string> = {
task_commission: '任务佣金',
task_commission_reversal: '任务佣金冲正',
recharge_credit: '储入到账',
withdrawal_debit: '取出扣款',
manual_add: '人工调增',
manual_deduct: '人工调减',
available: '可用余额变动',
frozen: '冻结余额变动',
};
const plainMoney = (value: number) => (Number(value || 0) / 100).toFixed(2);
const entryText = (value: string) => entryLabels[value] || '账户余额变动';
function dateTimeSeconds(value?: string) {
if (!value) return '--';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '--';
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
.format(date)
.replaceAll('/', '-');
}
async function load() {
loading.value = true;
error.value = '';
try {
const data = await request<any>({
url: '/v1/wallet/ledgers',
params: { page: 1, page_size: 50, direction: direction.value },
});
items.value = data.items || [];
} catch (err) {
error.value = errorText(err);
items.value = [];
} finally {
loading.value = false;
}
}
function changeDirection(value: Direction) {
if (direction.value === value) return;
direction.value = value;
load();
}
onMounted(load);
</script>
+13
View File
@@ -124,6 +124,13 @@ const routes: RouteRecordRaw[] = [
component: () => import('../mobile/views/FinanceRecordsView.vue'), component: () => import('../mobile/views/FinanceRecordsView.vue'),
meta: { title: '记录' }, meta: { title: '记录' },
}, },
{
path: 'profile/ledgers',
alias: '/app/content/wallet_ledgers',
name: 'MobileWalletLedgers',
component: () => import('../mobile/views/WalletLedgersView.vue'),
meta: { title: '账户明细' },
},
{ {
path: 'profile/password', path: 'profile/password',
name: 'MobileLoginPassword', name: 'MobileLoginPassword',
@@ -154,6 +161,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('../mobile/views/PlatformIntroView.vue'), component: () => import('../mobile/views/PlatformIntroView.vue'),
meta: { title: '平台介绍' }, meta: { title: '平台介绍' },
}, },
{
path: 'content/notices',
name: 'MobileNotices',
component: () => import('../mobile/views/NoticesView.vue'),
meta: { title: '弹窗明细' },
},
{ {
path: 'content/:key', path: 'content/:key',
name: 'MobileContent', name: 'MobileContent',