chore: seed migration objects batch 52
This commit is contained in:
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、等级 | 已实现 | 只返回已发布且已生效的配置和内容 |
|
||||
| 任务、订单、任务提交 | 已实现 | 大厅、资格预检、领单、查询和凭证提交;审核结算由后台负责 |
|
||||
| 钱包、账户明细 | 待实现 | 钱包和不可变流水表已存在 |
|
||||
| 钱包、账户明细 | 已实现 | 钱包余额随会员资料返回;账户明细读取不可变流水表并支持分页、业务类型和收支方向筛选 |
|
||||
| 收款账户、取出 | 已实现(沙箱) | 加密绑定、试算、申请、资金冻结和记录查询;上线前仍需支付与合规评审 |
|
||||
| 储入 | 待实现 | 需在支付与合规评审后开放 |
|
||||
| 手工商城 | 已实现 | 已发布商品列表、分类筛选和详情;当前只提供客服咨询动作 |
|
||||
@@ -1040,7 +1040,28 @@ GET /v1/notices/{notice_id}
|
||||
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 上传
|
||||
|
||||
|
||||
@@ -20,4 +20,5 @@ var (
|
||||
ErrRechargeOrderIDInvalid = errors.New("recharge order id invalid")
|
||||
ErrRemarkInvalid = errors.New("remark invalid")
|
||||
ErrVoucherInvalid = errors.New("voucher attachment ids invalid")
|
||||
ErrLedgerFilterInvalid = errors.New("wallet ledger filter invalid")
|
||||
)
|
||||
|
||||
@@ -55,6 +55,7 @@ type Repository interface {
|
||||
CreateRecharge(context.Context, store.RechargeCreation) (*store.RechargeOrder, error)
|
||||
RechargeOrders(context.Context, int64, int64, int, int, int) (*store.RechargePage, 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 {
|
||||
@@ -209,6 +210,29 @@ type RechargePage struct {
|
||||
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 {
|
||||
service := &Service{repository: repository, redis: redisClient, now: time.Now}
|
||||
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) {
|
||||
if !s.ready {
|
||||
if s.repository == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
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) {
|
||||
if !s.ready {
|
||||
if s.repository == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
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) {
|
||||
if !s.ready {
|
||||
if s.repository == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
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) {
|
||||
if !s.ready {
|
||||
if s.repository == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
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) {
|
||||
if !s.ready {
|
||||
if s.repository == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
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) {
|
||||
if !s.ready {
|
||||
if s.repository == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
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) {
|
||||
if !s.ready {
|
||||
if s.repository == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if orderID <= 0 {
|
||||
@@ -541,6 +565,57 @@ func (s *Service) RechargeOrder(ctx context.Context, tenantID, memberID, orderID
|
||||
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) {
|
||||
quote, _, _, err := s.quoteData(ctx, tenantID, memberID, input)
|
||||
return quote, err
|
||||
|
||||
@@ -19,6 +19,7 @@ type fakeRepository struct {
|
||||
orders []store.WithdrawalOrder
|
||||
rechargeChannels []store.RechargeChannel
|
||||
rechargeOrders []store.RechargeOrder
|
||||
ledgers []store.WalletLedger
|
||||
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) {
|
||||
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) {
|
||||
repository := &fakeRepository{config: json.RawMessage(`{"features":{"withdrawal_enabled":true}}`),
|
||||
@@ -127,7 +131,7 @@ func TestCreateRechargeValidatesChannelAndMapsOrder(t *testing.T) {
|
||||
repository := &fakeRepository{rechargeChannels: []store.RechargeChannel{{
|
||||
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{
|
||||
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) {
|
||||
first := deterministicNumber("WD", 0, 1, "request-key")
|
||||
if first != deterministicNumber("WD", 0, 1, "request-key") || first == deterministicNumber("WD", 0, 2, "request-key") {
|
||||
|
||||
@@ -38,6 +38,7 @@ func (a *API) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/v1/member-levels", a.memberLevels)
|
||||
mux.HandleFunc("/v1/leaderboard", a.leaderboard)
|
||||
mux.HandleFunc("/v1/customer-services", a.customerServices)
|
||||
mux.HandleFunc("/v1/notices", a.notices)
|
||||
mux.HandleFunc("/v1/task-halls", a.taskHalls)
|
||||
mux.HandleFunc("/v1/tasks/eligibility", a.taskEligibility)
|
||||
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.manualProduct)
|
||||
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-orders", a.rechargeOrders)
|
||||
mux.HandleFunc("/v1/recharge-orders/", a.rechargeOrder)
|
||||
|
||||
@@ -41,6 +41,34 @@ func (a *API) paymentAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
if r.Method != 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"})
|
||||
case errors.Is(err, finance.ErrPageSizeInvalid):
|
||||
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):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "WITHDRAWAL_STATUS_INVALID", "validation failed", map[string]any{"field": "status"})
|
||||
case errors.Is(err, finance.ErrRechargeStatusInvalid):
|
||||
|
||||
@@ -157,6 +157,36 @@ func (a *API) customerServices(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
if a.portal != nil {
|
||||
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"})
|
||||
case errors.Is(err, portal.ErrContentKeyInvalid):
|
||||
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):
|
||||
writeError(w, r, http.StatusNotFound, 30044, "RESOURCE_NOT_FOUND", "resource not found", nil)
|
||||
default:
|
||||
|
||||
@@ -5,4 +5,6 @@ import "errors"
|
||||
var (
|
||||
ErrPositionInvalid = errors.New("banner position invalid")
|
||||
ErrContentKeyInvalid = errors.New("content key invalid")
|
||||
ErrPageInvalid = errors.New("page invalid")
|
||||
ErrPageSizeInvalid = errors.New("page size invalid")
|
||||
)
|
||||
|
||||
@@ -22,6 +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)
|
||||
MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error)
|
||||
MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error)
|
||||
TaskLeaderboard(context.Context, int64, int) ([]store.LeaderboardMember, error)
|
||||
ActiveCustomerServices(context.Context, int64) ([]store.CustomerService, error)
|
||||
@@ -53,6 +54,14 @@ type Notice struct {
|
||||
SourceType string `json:"source_type"`
|
||||
StartAt string `json:"start_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 {
|
||||
@@ -168,6 +177,32 @@ func (s *Service) Banners(ctx context.Context, tenantID int64, position string)
|
||||
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) {
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
if !contentKeyPattern.MatchString(key) {
|
||||
@@ -256,14 +291,14 @@ func parseFeatures(raw json.RawMessage) Features {
|
||||
return Features{}
|
||||
}
|
||||
return Features{
|
||||
TaskEnabled: findBool(config, true, "task_enabled", "taskEnabled", "TASK"),
|
||||
RechargeEnabled: findBool(config, true, "recharge_enabled", "rechargeEnabled", "RECHARGE"),
|
||||
WithdrawalEnabled: findBool(config, true, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"),
|
||||
ManualMallEnabled: findBool(config, true, "manual_mall_enabled", "manualMallEnabled", "SHOP_STATUS"),
|
||||
TaskEnabled: findBool(config, "task_enabled", "taskEnabled", "TASK"),
|
||||
RechargeEnabled: findBool(config, "recharge_enabled", "rechargeEnabled", "RECHARGE"),
|
||||
WithdrawalEnabled: findBool(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"),
|
||||
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 {
|
||||
if value, ok := config[key]; ok {
|
||||
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 {
|
||||
|
||||
@@ -13,14 +13,15 @@ import (
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
config json.RawMessage
|
||||
banners []store.Banner
|
||||
content *store.Content
|
||||
levels []store.MemberLevel
|
||||
notices []store.MemberNotice
|
||||
summary *store.TaskSummary
|
||||
leaders []store.LeaderboardMember
|
||||
services []store.CustomerService
|
||||
config json.RawMessage
|
||||
banners []store.Banner
|
||||
content *store.Content
|
||||
levels []store.MemberLevel
|
||||
notices []store.MemberNotice
|
||||
summary *store.TaskSummary
|
||||
leaders []store.LeaderboardMember
|
||||
services []store.CustomerService
|
||||
noticePage *store.MemberNoticePage
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
service := New(fakeRepository{banners: []store.Banner{
|
||||
{ID: 1, TargetType: 2, TargetValue: "/receive"},
|
||||
@@ -171,3 +171,25 @@ func TestCustomerServicesSanitizeURLsAndMapStatus(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,12 @@ type MemberNotice struct {
|
||||
SourceType int `db:"source_type"`
|
||||
StartAt string `db:"start_at"`
|
||||
EndAt string `db:"end_at"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
}
|
||||
|
||||
type MemberNoticePage struct {
|
||||
Items []MemberNotice
|
||||
Total int64
|
||||
}
|
||||
|
||||
type TaskSummary struct {
|
||||
@@ -147,13 +153,35 @@ func (s *Store) ActiveMemberNotices(ctx context.Context, tenantID, memberID int6
|
||||
rows := make([]MemberNotice, 0)
|
||||
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(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)
|
||||
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) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
|
||||
@@ -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
@@ -1038,7 +1038,6 @@ body {
|
||||
margin: 7px 0 14px;
|
||||
color: var(--m-muted);
|
||||
}
|
||||
|
||||
.m-product-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -2388,6 +2387,173 @@ body {
|
||||
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) {
|
||||
body {
|
||||
background: #e9eaec;
|
||||
@@ -2413,6 +2579,12 @@ body {
|
||||
margin-inline: -14px;
|
||||
padding-inline: 14px;
|
||||
}
|
||||
.m-product-detail {
|
||||
margin-inline: -14px;
|
||||
}
|
||||
.m-plain-form {
|
||||
margin-inline: -14px;
|
||||
}
|
||||
}
|
||||
|
||||
.m-section-title a {
|
||||
@@ -3172,6 +3344,10 @@ body {
|
||||
margin: 8px 0 0;
|
||||
color: #777;
|
||||
line-height: 1.6;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
overflow: hidden;
|
||||
}
|
||||
.m-qualification-card > img {
|
||||
position: absolute;
|
||||
|
||||
@@ -46,11 +46,21 @@
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const fallback = '我们致力于通过高效的供应链和优质的服务为用户带来更多实惠与更美好的生活乐趣,让品质好物走进更多人的生活。';
|
||||
const paragraphs = computed(() =>
|
||||
String(content.value?.body_text || '').split(/\n+/).map((item) => item.trim()).filter(Boolean),
|
||||
const bodyText = computed(() => String(content.value?.body_text || '').replace(/\s+/g, ' ').trim());
|
||||
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() {
|
||||
loading.value = true;
|
||||
|
||||
@@ -348,7 +348,7 @@
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
const key = String(route.params.key || '');
|
||||
if (['recharge_records', 'wallet_ledgers', 'notices'].includes(key)) {
|
||||
if (['recharge_records', 'wallet_ledgers'].includes(key)) {
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -128,13 +128,13 @@
|
||||
label: '储入',
|
||||
path: '/app/recharge',
|
||||
icon: mobileAssets.shortcutRecharge,
|
||||
enabled: home.value?.features?.recharge_enabled !== false,
|
||||
enabled: home.value?.features?.recharge_enabled === true,
|
||||
},
|
||||
{
|
||||
label: '取出',
|
||||
path: '/app/profile/withdrawal',
|
||||
icon: mobileAssets.shortcutWithdraw,
|
||||
enabled: home.value?.features?.withdrawal_enabled !== false,
|
||||
enabled: home.value?.features?.withdrawal_enabled === true,
|
||||
},
|
||||
{
|
||||
label: '商城',
|
||||
|
||||
@@ -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>
|
||||
@@ -99,46 +99,10 @@
|
||||
><span>数量</span><b>×{{ detail.quantity || 1 }}</b></div
|
||||
>
|
||||
<div
|
||||
><span>截止时间</span><b>{{ dateTime(detail.deadline_at) }}</b></div
|
||||
><span>规格</span
|
||||
><b>{{ detail.product?.specification || detail.product?.spec || '--' }}</b></div
|
||||
>
|
||||
</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>
|
||||
</AsyncState>
|
||||
</section>
|
||||
@@ -160,8 +124,6 @@
|
||||
const orders = ref<any[]>([]);
|
||||
const detail = ref<any>();
|
||||
const stats = ref<any>();
|
||||
const evidence = ref('');
|
||||
const submitting = ref(false);
|
||||
let listController: AbortController | undefined;
|
||||
const tabs = [
|
||||
{ label: '全部', value: 'all' },
|
||||
@@ -212,24 +174,6 @@
|
||||
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() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
path: '/app/profile/records?tab=withdrawal',
|
||||
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 },
|
||||
];
|
||||
async function load() {
|
||||
|
||||
@@ -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>
|
||||
@@ -124,6 +124,13 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('../mobile/views/FinanceRecordsView.vue'),
|
||||
meta: { title: '记录' },
|
||||
},
|
||||
{
|
||||
path: 'profile/ledgers',
|
||||
alias: '/app/content/wallet_ledgers',
|
||||
name: 'MobileWalletLedgers',
|
||||
component: () => import('../mobile/views/WalletLedgersView.vue'),
|
||||
meta: { title: '账户明细' },
|
||||
},
|
||||
{
|
||||
path: 'profile/password',
|
||||
name: 'MobileLoginPassword',
|
||||
@@ -154,6 +161,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('../mobile/views/PlatformIntroView.vue'),
|
||||
meta: { title: '平台介绍' },
|
||||
},
|
||||
{
|
||||
path: 'content/notices',
|
||||
name: 'MobileNotices',
|
||||
component: () => import('../mobile/views/NoticesView.vue'),
|
||||
meta: { title: '弹窗明细' },
|
||||
},
|
||||
{
|
||||
path: 'content/:key',
|
||||
name: 'MobileContent',
|
||||
|
||||
Reference in New Issue
Block a user