From 208f12ddb47fa36ec6a674fa5edf32d37e56a6a4 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 2 Sep 2026 13:50:19 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=9C=AC=E5=9C=B0=E8=B4=A8?= =?UTF-8?q?=E9=87=8F=E9=97=A8=E7=A6=81=E4=B8=8E=E8=B5=84=E9=87=91=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- backend/internal/e2e/rental_flow_test.go | 14 +- backend/internal/handler/health.go | 47 +- backend/internal/handler/health_test.go | 75 ++ backend/internal/jobs/refundretry/job_test.go | 45 + .../payment/repository_integration_test.go | 71 ++ .../withdrawal/repository_integration_test.go | 176 ++++ backend/internal/router/admin_routes.go | 97 ++ backend/internal/router/health_checks.go | 29 + backend/internal/router/providers.go | 48 + backend/internal/router/router.go | 126 +-- backend/internal/router/router_test.go | 68 ++ deploy/README.md | 1 + deploy/caddy/Caddyfile | 2 +- deploy/caddy/Dockerfile | 2 +- docs/business-rules.md | 33 + docs/database.md | 25 + docs/project-plan.md | 23 + docs/代码质量改进计划.md | 658 +------------ frontend/package-lock.json | 286 ++++-- frontend/package.json | 2 + .../admin/components/AdminOrderMetrics.css | 18 + .../admin/components/AdminOrderMetrics.vue | 22 + .../components/AdminOrderPaymentRecords.css | 67 ++ .../components/AdminOrderPaymentRecords.vue | 52 + .../__tests__/adminOrderPresentation.spec.ts | 42 + .../admin/utils/adminOrderPresentation.ts | 51 + .../features/admin/views/AdminChatsView.css | 560 +++++++++++ .../features/admin/views/AdminChatsView.vue | 563 +---------- .../admin/views/AdminDashboardView.css | 882 +++++++++++++++++ .../admin/views/AdminDashboardView.vue | 885 +----------------- .../admin/views/AdminDisputesView.css | 263 ++++++ .../admin/views/AdminDisputesView.vue | 266 +----- .../admin/views/AdminFinanceDashboardView.css | 662 +++++++++++++ .../admin/views/AdminFinanceDashboardView.vue | 665 +------------ .../views/AdminFinanceDisbursementsView.css | 545 +++++++++++ .../views/AdminFinanceDisbursementsView.vue | 548 +---------- .../admin/views/AdminListingDetailView.css | 489 ++++++++++ .../admin/views/AdminListingDetailView.vue | 492 +--------- .../admin/views/AdminListingReviewView.css | 590 ++++++++++++ .../admin/views/AdminListingReviewView.vue | 593 +----------- .../views/AdminOperatingExpensesView.css | 398 ++++++++ .../views/AdminOperatingExpensesView.vue | 401 +------- .../admin/views/AdminOrderDetailView.css | 318 +++++++ .../admin/views/AdminOrderDetailView.vue | 505 ++-------- .../admin/views/AdminPickupDetailView.css | 209 +++++ .../admin/views/AdminPickupDetailView.vue | 212 +---- .../features/admin/views/AdminPickupView.css | 120 +++ .../features/admin/views/AdminPickupView.vue | 123 +-- .../admin/views/AdminQrCodePoolView.css | 448 +++++++++ .../admin/views/AdminQrCodePoolView.vue | 451 +-------- .../admin/views/AdminSystemConfigsView.css | 89 ++ .../admin/views/AdminSystemConfigsView.vue | 92 +- .../features/admin/views/AdminUsersView.css | 149 +++ .../features/admin/views/AdminUsersView.vue | 152 +-- .../features/auth/views/MobileProfileView.css | 665 +++++++++++++ .../features/auth/views/MobileProfileView.vue | 668 +------------ .../financial/__tests__/financialApi.spec.ts | 93 ++ .../listings/views/ListingDetailView.css | 680 ++++++++++++++ .../listings/views/ListingDetailView.vue | 683 +------------- .../views/MobileListingDetailView.css | 485 ++++++++++ .../views/MobileListingDetailView.vue | 488 +--------- .../__tests__/useOrderPaymentCashier.spec.ts | 103 ++ .../orders/views/MobileOrderDetailView.css | 259 +++++ .../orders/views/MobileOrderDetailView.vue | 262 +----- .../features/orders/views/OrderDetailView.css | 773 +++++++++++++++ .../features/orders/views/OrderDetailView.vue | 776 +-------------- .../src/features/orders/views/OrdersView.css | 396 ++++++++ .../src/features/orders/views/OrdersView.vue | 399 +------- .../wallet/views/MobileWithdrawalView.css | 484 ++++++++++ .../wallet/views/MobileWithdrawalView.vue | 487 +--------- frontend/src/layouts/AdminLayout.css | 373 ++++++++ frontend/src/layouts/AdminLayout.vue | 376 +------- frontend/src/layouts/AppLayout.css | 732 +++++++++++++++ frontend/src/layouts/AppLayout.vue | 735 +-------------- frontend/vitest.config.ts | 17 + scripts/check-backend-coverage.sh | 49 + scripts/check-vue-size.sh | 18 + scripts/check.sh | 6 +- scripts/extract-vue-styles.sh | 23 + 80 files changed, 12245 insertions(+), 11541 deletions(-) create mode 100644 backend/internal/handler/health_test.go create mode 100644 backend/internal/modules/withdrawal/repository_integration_test.go create mode 100644 backend/internal/router/admin_routes.go create mode 100644 backend/internal/router/health_checks.go create mode 100644 backend/internal/router/providers.go create mode 100644 backend/internal/router/router_test.go create mode 100644 docs/business-rules.md create mode 100644 docs/database.md create mode 100644 docs/project-plan.md create mode 100644 frontend/src/features/admin/components/AdminOrderMetrics.css create mode 100644 frontend/src/features/admin/components/AdminOrderMetrics.vue create mode 100644 frontend/src/features/admin/components/AdminOrderPaymentRecords.css create mode 100644 frontend/src/features/admin/components/AdminOrderPaymentRecords.vue create mode 100644 frontend/src/features/admin/utils/__tests__/adminOrderPresentation.spec.ts create mode 100644 frontend/src/features/admin/utils/adminOrderPresentation.ts create mode 100644 frontend/src/features/admin/views/AdminChatsView.css create mode 100644 frontend/src/features/admin/views/AdminDashboardView.css create mode 100644 frontend/src/features/admin/views/AdminDisputesView.css create mode 100644 frontend/src/features/admin/views/AdminFinanceDashboardView.css create mode 100644 frontend/src/features/admin/views/AdminFinanceDisbursementsView.css create mode 100644 frontend/src/features/admin/views/AdminListingDetailView.css create mode 100644 frontend/src/features/admin/views/AdminListingReviewView.css create mode 100644 frontend/src/features/admin/views/AdminOperatingExpensesView.css create mode 100644 frontend/src/features/admin/views/AdminOrderDetailView.css create mode 100644 frontend/src/features/admin/views/AdminPickupDetailView.css create mode 100644 frontend/src/features/admin/views/AdminPickupView.css create mode 100644 frontend/src/features/admin/views/AdminQrCodePoolView.css create mode 100644 frontend/src/features/admin/views/AdminSystemConfigsView.css create mode 100644 frontend/src/features/admin/views/AdminUsersView.css create mode 100644 frontend/src/features/auth/views/MobileProfileView.css create mode 100644 frontend/src/features/financial/__tests__/financialApi.spec.ts create mode 100644 frontend/src/features/listings/views/ListingDetailView.css create mode 100644 frontend/src/features/listings/views/MobileListingDetailView.css create mode 100644 frontend/src/features/orders/composables/__tests__/useOrderPaymentCashier.spec.ts create mode 100644 frontend/src/features/orders/views/MobileOrderDetailView.css create mode 100644 frontend/src/features/orders/views/OrderDetailView.css create mode 100644 frontend/src/features/orders/views/OrdersView.css create mode 100644 frontend/src/features/wallet/views/MobileWithdrawalView.css create mode 100644 frontend/src/layouts/AdminLayout.css create mode 100644 frontend/src/layouts/AppLayout.css create mode 100755 scripts/check-backend-coverage.sh create mode 100755 scripts/check-vue-size.sh create mode 100755 scripts/extract-vue-styles.sh diff --git a/README.md b/README.md index 6f62fae..f4e88c4 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ go run github.com/pressly/goose/v3/cmd/goose@v3.27.1 \ go run ./cmd/api cd ../frontend -npm install +npm ci npm run dev ``` @@ -50,6 +50,10 @@ npm run dev `./scripts/deploy-prod.sh` 在构建镜像前会自动调用 `check.sh`(本地有 `go` 和 `npm` 时)。检查未通过会中止部署;确需跳过用 `--skip-check`(不推荐)。`check.sh` 也支持 `--skip-backend`、`--skip-frontend`、`--skip-tests` 做局部调试。 +本地门禁由 `scripts/check.sh` 统一执行;后端覆盖率基线由 `scripts/check-backend-coverage.sh` 维护。需要验证完整租赁链路时,先启动本地 MySQL 8.4,再设置 `HFB_E2E_DSN` 运行 `go test ./internal/e2e`。 + +服务探针:`GET /health` 仅表示进程存活;`GET /health/ready` 和 `GET /api/health` 会检查 MySQL、Redis,任一依赖不可用时返回 503。 + 数据库 SQL 约束、执行计划、慢查询观测、连接池和备份恢复要求见 [`docs/数据库SQL约束与运维.md`](docs/数据库SQL约束与运维.md)。 需要在本地推送前自动执行同一套检查时,启用 Git hook: diff --git a/backend/internal/e2e/rental_flow_test.go b/backend/internal/e2e/rental_flow_test.go index 1d82311..cfc0263 100644 --- a/backend/internal/e2e/rental_flow_test.go +++ b/backend/internal/e2e/rental_flow_test.go @@ -170,13 +170,19 @@ func TestArbitrationReleaseDepositDoesNotRepublishListing(t *testing.T) { t.Fatalf("读取仲裁后订单模型失败: %v", err) } assertEqual(t, "仲裁退款状态", orderRow.RefundStatus, "refunded") - assertEqual(t, "仲裁退款金额", orderRow.RefundAmountCent, int64(15000)) + assertEqual(t, "仲裁退款金额", orderRow.RefundAmountCent, int64(25800)) walletAccount, err := services.wallet.Account(t.Context(), owner.ID) if err != nil { t.Fatalf("读取号主钱包失败: %v", err) } - assertEqual(t, "仲裁后号主可用余额", walletAccount.AvailableBalanceCent, int64(35300)) + assertEqual(t, "仲裁后号主可用余额", walletAccount.AvailableBalanceCent, int64(24500)) + assertEqual( + t, + "仲裁资金守恒", + orderRow.RefundAmountCent+walletAccount.AvailableBalanceCent+3000, + orderRow.RentAmountCent+orderRow.DepositAmountCent, + ) var listingRow model.RentalListing if err := db.First(&listingRow, approved.ID).Error; err != nil { @@ -432,6 +438,10 @@ func createListingUnderReview(t *testing.T, service *listing.Service, ownerID ui ScreenshotURLS: []string{"https://example.com/e2e-account.png"}, AssetSummary: map[string]any{ "fire_level": 50, + "online_time": map[string]any{ + "start": "09:00", + "end": "23:00", + }, "price_breakdown": map[string]any{ "seller_coin_base_price": 233, "seller_total_price": 353, diff --git a/backend/internal/handler/health.go b/backend/internal/handler/health.go index 256c445..c4a9cb6 100644 --- a/backend/internal/handler/health.go +++ b/backend/internal/handler/health.go @@ -1,7 +1,9 @@ package handler import ( + "context" "net/http" + "sort" "time" "github.com/gin-gonic/gin" @@ -9,16 +11,55 @@ import ( type HealthHandler struct { startedAt time.Time + checks map[string]ReadinessCheck } -func NewHealthHandler() *HealthHandler { - return &HealthHandler{startedAt: time.Now()} +type ReadinessCheck func(context.Context) error + +func NewHealthHandler(checks map[string]ReadinessCheck) *HealthHandler { + return &HealthHandler{startedAt: time.Now(), checks: checks} } -func (h *HealthHandler) Check(c *gin.Context) { +func (h *HealthHandler) Live(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "status": "ok", "service": "hfb-api", "started_at": h.startedAt.Format(time.RFC3339), }) } + +func (h *HealthHandler) Ready(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) + defer cancel() + + names := make([]string, 0, len(h.checks)) + for name := range h.checks { + names = append(names, name) + } + sort.Strings(names) + + ready := true + dependencies := make(map[string]string, len(names)) + for _, name := range names { + check := h.checks[name] + if check == nil || check(ctx) != nil { + dependencies[name] = "unavailable" + ready = false + continue + } + dependencies[name] = "ok" + } + + statusCode := http.StatusOK + status := "ok" + if !ready { + statusCode = http.StatusServiceUnavailable + status = "unavailable" + } + c.JSON(statusCode, gin.H{ + "status": status, + "service": "hfb-api", + "started_at": h.startedAt.Format(time.RFC3339), + "dependencies": dependencies, + }) +} diff --git a/backend/internal/handler/health_test.go b/backend/internal/handler/health_test.go new file mode 100644 index 0000000..a5fbac5 --- /dev/null +++ b/backend/internal/handler/health_test.go @@ -0,0 +1,75 @@ +package handler + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestHealthLiveDoesNotDependOnReadiness(t *testing.T) { + gin.SetMode(gin.TestMode) + h := NewHealthHandler(map[string]ReadinessCheck{ + "mysql": func(context.Context) error { return errors.New("down") }, + }) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodGet, "/health", nil) + + h.Live(ctx) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) + } +} + +func TestHealthReadyReportsDependencyState(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + checks map[string]ReadinessCheck + wantStatus int + wantBody string + }{ + { + name: "all dependencies ready", + checks: map[string]ReadinessCheck{ + "mysql": func(context.Context) error { return nil }, + "redis": func(context.Context) error { return nil }, + }, + wantStatus: http.StatusOK, + wantBody: `"status":"ok"`, + }, + { + name: "dependency unavailable", + checks: map[string]ReadinessCheck{ + "mysql": func(context.Context) error { return errors.New("down") }, + "redis": func(context.Context) error { return nil }, + }, + wantStatus: http.StatusServiceUnavailable, + wantBody: `"mysql":"unavailable"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := NewHealthHandler(tt.checks) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodGet, "/health/ready", nil) + + h.Ready(ctx) + + if recorder.Code != tt.wantStatus { + t.Fatalf("status = %d, want %d; body=%s", recorder.Code, tt.wantStatus, recorder.Body.String()) + } + if body := recorder.Body.String(); !strings.Contains(body, tt.wantBody) { + t.Fatalf("body = %s, want substring %s", body, tt.wantBody) + } + }) + } +} diff --git a/backend/internal/jobs/refundretry/job_test.go b/backend/internal/jobs/refundretry/job_test.go index b9068e3..9bc299d 100644 --- a/backend/internal/jobs/refundretry/job_test.go +++ b/backend/internal/jobs/refundretry/job_test.go @@ -161,6 +161,51 @@ func TestResetRetryClearsRetryFields(t *testing.T) { } } +func TestSyncRefundPaymentsMarksFailedAttemptForRetry(t *testing.T) { + db := setupRefundRetryTestDB(t) + repo := payment.NewRepository(db, nil, nil) + job := New(db, nil, zap.NewNop(), repo) + now := time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC) + row := model.PaymentOrder{ + PaymentNo: "PAY-RETRY-FAIL", + OrderID: 1, + OrderNo: "ORD-RETRY-FAIL", + UserID: 2, + Provider: "lakala", + MerchantID: "merchant-missing-config", + ThirdOrderID: "REF-RETRY-FAIL", + AmountCent: 1000, + BizType: "admin_refund", + Status: "refunding", + UpdatedAt: now.Add(-10 * time.Minute), + RetryCount: 1, + } + if err := db.Create(&row).Error; err != nil { + t.Fatalf("create refund payment: %v", err) + } + + processed, err := job.syncRefundPayments(t.Context(), now) + if err != nil { + t.Fatalf("syncRefundPayments() error = %v", err) + } + if processed != 0 { + t.Fatalf("processed = %d, want 0 when channel config is unavailable", processed) + } + var latest model.PaymentOrder + if err := db.First(&latest, row.ID).Error; err != nil { + t.Fatalf("find refund payment: %v", err) + } + if latest.RetryCount != 2 { + t.Fatalf("retry_count = %d, want 2", latest.RetryCount) + } + if latest.LastRetryAt == nil || !latest.LastRetryAt.Equal(now) { + t.Fatalf("last_retry_at = %v, want %v", latest.LastRetryAt, now) + } + if latest.NextRetryAt == nil || !latest.NextRetryAt.After(now) { + t.Fatalf("next_retry_at = %v, want a future retry time", latest.NextRetryAt) + } +} + // createArbitrationOrphanFixture 构造一个仲裁孤儿订单:已支付原单 + 仲裁已结案但退款单缺失。 func createArbitrationOrphanFixture(t *testing.T, db *gorm.DB, suffix string, settlementStatus string) model.RentalOrder { t.Helper() diff --git a/backend/internal/modules/payment/repository_integration_test.go b/backend/internal/modules/payment/repository_integration_test.go index 8d803b8..f8fa1da 100644 --- a/backend/internal/modules/payment/repository_integration_test.go +++ b/backend/internal/modules/payment/repository_integration_test.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "errors" + "strings" + "sync" "testing" "time" @@ -694,6 +696,75 @@ func TestConfirmPaidRollsBackOrderWhenPaymentUpdateFails(t *testing.T) { } } +func TestConcurrentPaidNotificationsAreIdempotent(t *testing.T) { + db := database.NewTestDBWithName("payment-concurrent") + if err := database.MigrateRentalTransactionTestSchema(db); err != nil { + t.Fatalf("数据库迁移失败: %v", err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("get sqlite db: %v", err) + } + sqlDB.SetMaxOpenConns(8) + orderRepo := ordermodule.NewRepository(db) + repo := NewRepository(db, nil, orderRepo) + fixture := createPayableOrderFixture(t, db, "CONCURRENT", true) + params := map[string]string{ + "third_order_id": fixture.Payment.ThirdOrderID, + "status": "paid", + "amount": "1000", + "provider_order_id": "PROVIDER-CONCURRENT", + } + + const callers = 8 + results := make(chan error, callers) + var group sync.WaitGroup + for i := 0; i < callers; i++ { + group.Add(1) + go func() { + defer group.Done() + var callErr error + for attempt := 0; attempt < 50; attempt++ { + _, callErr = repo.HandleNotify(t.Context(), "mock", params, "status=paid", "application/x-www-form-urlencoded", "") + if callErr == nil || !strings.Contains(callErr.Error(), "locked") { + break + } + time.Sleep(5 * time.Millisecond) + } + results <- callErr + }() + } + group.Wait() + close(results) + for callErr := range results { + if callErr != nil { + t.Fatalf("concurrent HandleNotify() error = %v", callErr) + } + } + + var latestPayment model.PaymentOrder + if err := db.First(&latestPayment, fixture.Payment.ID).Error; err != nil { + t.Fatalf("find payment: %v", err) + } + if latestPayment.Status != "paid" { + t.Fatalf("payment status = %q, want paid", latestPayment.Status) + } + var latestOrder model.RentalOrder + if err := db.First(&latestOrder, fixture.Order.ID).Error; err != nil { + t.Fatalf("find order: %v", err) + } + if latestOrder.Status != "pending_handoff" || latestOrder.HandoffStatus != "pending_owner" { + t.Fatalf("order status = %q/%q, want pending_handoff/pending_owner", latestOrder.Status, latestOrder.HandoffStatus) + } + var paidCount int64 + if err := db.Model(&model.PaymentOrder{}).Where("id = ? AND status = 'paid'", fixture.Payment.ID).Count(&paidCount).Error; err != nil { + t.Fatalf("count paid payment: %v", err) + } + if paidCount != 1 { + t.Fatalf("paid payment rows = %d, want 1", paidCount) + } +} + // TestQueryClosedPaymentReturnsLocalStatus 验证关闭状态不再依赖渠道配置或继续轮询渠道。 func TestQueryClosedPaymentReturnsLocalStatus(t *testing.T) { db := setupPaymentTestDB(t) diff --git a/backend/internal/modules/withdrawal/repository_integration_test.go b/backend/internal/modules/withdrawal/repository_integration_test.go new file mode 100644 index 0000000..98bd2b0 --- /dev/null +++ b/backend/internal/modules/withdrawal/repository_integration_test.go @@ -0,0 +1,176 @@ +package withdrawal + +import ( + "context" + "errors" + "fmt" + "testing" + + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/wallet" + "hfb_sys/backend/pkg/crypto" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +type withdrawalFixture struct { + db *gorm.DB + repo *Repository + userID uint64 + adminID uint64 + accountID uint64 +} + +func newWithdrawalFixture(t *testing.T) withdrawalFixture { + t.Helper() + dsn := fmt.Sprintf("file:withdrawal-%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate( + &model.User{}, + &model.AdminUser{}, + &model.UserPaymentAccount{}, + &model.WithdrawalRequest{}, + &model.WalletAccount{}, + &model.WalletLedger{}, + ); err != nil { + t.Fatalf("migrate fixture: %v", err) + } + + user := model.User{Phone: "13800000001", Nickname: "test-user", Status: "active"} + admin := model.AdminUser{Username: "reviewer", PasswordHash: "test", Nickname: "reviewer", Status: "active"} + if err := db.Create(&user).Error; err != nil { + t.Fatalf("create user: %v", err) + } + if err := db.Create(&admin).Error; err != nil { + t.Fatalf("create admin: %v", err) + } + account := model.UserPaymentAccount{ + UserID: user.ID, AccountType: "bank", AccountName: "Test User", + AccountNo: "6222021234567890", BankName: "Test Bank", Status: "active", + } + if err := db.Create(&account).Error; err != nil { + t.Fatalf("create payment account: %v", err) + } + if err := db.Create(&model.WalletAccount{ + UserID: user.ID, AvailableBalanceCent: 100_000, Status: "active", + }).Error; err != nil { + t.Fatalf("create wallet: %v", err) + } + + return withdrawalFixture{ + db: db, repo: NewRepository(db, wallet.NewRepository(db), &crypto.MockEncryptor{}), + userID: user.ID, adminID: admin.ID, accountID: account.ID, + } +} + +func (f withdrawalFixture) create(t *testing.T, amountCent int64) *WithdrawalDTO { + t.Helper() + dto, err := f.repo.Create(context.Background(), f.userID, CreateWithdrawalRequest{ + PaymentAccountID: f.accountID, + AmountCent: amountCent, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + return dto +} + +func (f withdrawalFixture) assertBalance(t *testing.T, available, frozen int64) { + t.Helper() + var account model.WalletAccount + if err := f.db.Where("user_id = ?", f.userID).First(&account).Error; err != nil { + t.Fatalf("load wallet: %v", err) + } + if account.AvailableBalanceCent != available || account.FrozenBalanceCent != frozen { + t.Fatalf("balance = available:%d frozen:%d, want available:%d frozen:%d", + account.AvailableBalanceCent, account.FrozenBalanceCent, available, frozen) + } +} + +func TestWithdrawalCreateAndCancelPreservesBalance(t *testing.T) { + f := newWithdrawalFixture(t) + dto := f.create(t, 20_000) + if dto.Status != "pending" || dto.FeeCent != 0 || dto.ActualAmountCent != 20_000 { + t.Fatalf("created withdrawal = %+v", dto) + } + f.assertBalance(t, 80_000, 20_000) + + if err := f.repo.Cancel(context.Background(), f.userID, dto.ID); err != nil { + t.Fatalf("Cancel: %v", err) + } + f.assertBalance(t, 100_000, 0) + if err := f.repo.Cancel(context.Background(), f.userID, dto.ID); !errors.Is(err, ErrWithdrawalLocked) { + t.Fatalf("second Cancel error = %v, want ErrWithdrawalLocked", err) + } + f.assertBalance(t, 100_000, 0) +} + +func TestWithdrawalRejectedReturnsFrozenBalanceOnce(t *testing.T) { + f := newWithdrawalFixture(t) + dto := f.create(t, 30_000) + approved := false + result, err := f.repo.Review(context.Background(), f.adminID, dto.ID, ReviewWithdrawalRequest{ + Approved: &approved, + Remark: "account mismatch", + }) + if err != nil { + t.Fatalf("Review rejected: %v", err) + } + if result.Status != "rejected" || result.ReviewedByName != "reviewer" { + t.Fatalf("review result = %+v", result) + } + f.assertBalance(t, 100_000, 0) + if _, err := f.repo.Review(context.Background(), f.adminID, dto.ID, ReviewWithdrawalRequest{Approved: &approved}); !errors.Is(err, ErrWithdrawalLocked) { + t.Fatalf("second Review error = %v, want ErrWithdrawalLocked", err) + } +} + +func TestWithdrawalApprovedAndPaidConsumesFrozenBalanceOnce(t *testing.T) { + f := newWithdrawalFixture(t) + dto := f.create(t, 40_000) + approved := true + if _, err := f.repo.Review(context.Background(), f.adminID, dto.ID, ReviewWithdrawalRequest{Approved: &approved}); err != nil { + t.Fatalf("Review approved: %v", err) + } + f.assertBalance(t, 60_000, 40_000) + + result, err := f.repo.ConfirmPayment(context.Background(), f.adminID, dto.ID, ConfirmPaymentRequest{ + PaymentProofURL: "proof/test.png", + Remark: "paid", + }) + if err != nil { + t.Fatalf("ConfirmPayment: %v", err) + } + if result.Status != "completed" || result.PaidByName != "reviewer" { + t.Fatalf("payment result = %+v", result) + } + f.assertBalance(t, 60_000, 0) + if _, err := f.repo.ConfirmPayment(context.Background(), f.adminID, dto.ID, ConfirmPaymentRequest{}); !errors.Is(err, ErrWithdrawalLocked) { + t.Fatalf("second ConfirmPayment error = %v, want ErrWithdrawalLocked", err) + } + f.assertBalance(t, 60_000, 0) +} + +func TestWithdrawalCreateRollsBackWhenBalanceIsInsufficient(t *testing.T) { + f := newWithdrawalFixture(t) + _, err := f.repo.Create(context.Background(), f.userID, CreateWithdrawalRequest{ + PaymentAccountID: f.accountID, + AmountCent: 100_001, + }) + if !errors.Is(err, wallet.ErrInsufficientBalance) { + t.Fatalf("Create error = %v, want ErrInsufficientBalance", err) + } + var count int64 + if err := f.db.Model(&model.WithdrawalRequest{}).Count(&count).Error; err != nil { + t.Fatalf("count withdrawals: %v", err) + } + if count != 0 { + t.Fatalf("withdrawal count = %d, want 0", count) + } + f.assertBalance(t, 100_000, 0) +} diff --git a/backend/internal/router/admin_routes.go b/backend/internal/router/admin_routes.go new file mode 100644 index 0000000..dea53bd --- /dev/null +++ b/backend/internal/router/admin_routes.go @@ -0,0 +1,97 @@ +package router + +import ( + "hfb_sys/backend/internal/modules/adminmgr" + "hfb_sys/backend/internal/modules/adminrole" + "hfb_sys/backend/internal/modules/announcement" + "hfb_sys/backend/internal/modules/chat" + "hfb_sys/backend/internal/modules/mohong" + "hfb_sys/backend/internal/modules/payment" + "hfb_sys/backend/internal/modules/supportgroup" + + "github.com/gin-gonic/gin" +) + +type permissionMiddleware func(string) gin.HandlerFunc + +func registerCrashAdminRoutes(routes *gin.RouterGroup, requirePerm permissionMiddleware, mohongHandler *mohong.Handler, paymentHandler *payment.Handler) { + if mohongHandler == nil { + return + } + register := func(prefix string) { + routes.GET(prefix+"/categories", requirePerm("mohong:product_view"), mohongHandler.AdminListCategories) + routes.POST(prefix+"/categories", requirePerm("mohong:category"), mohongHandler.AdminCreateCategory) + routes.PUT(prefix+"/categories/:id", requirePerm("mohong:category"), mohongHandler.AdminUpdateCategory) + routes.DELETE(prefix+"/categories/:id", requirePerm("mohong:category"), mohongHandler.AdminDeleteCategory) + routes.GET(prefix+"/products", requirePerm("mohong:product_view"), mohongHandler.AdminListProducts) + routes.GET(prefix+"/products/:id", requirePerm("mohong:product_view"), mohongHandler.AdminGetProduct) + routes.POST(prefix+"/products", requirePerm("mohong:product_manage"), mohongHandler.AdminCreateProduct) + routes.PUT(prefix+"/products/:id", requirePerm("mohong:product_manage"), mohongHandler.AdminUpdateProduct) + routes.DELETE(prefix+"/products/:id", requirePerm("mohong:product_manage"), mohongHandler.AdminDeleteProduct) + routes.GET(prefix+"/orders", requirePerm("mohong:order_view"), mohongHandler.AdminListOrders) + routes.GET(prefix+"/orders/:id", requirePerm("mohong:order_view"), mohongHandler.AdminGetOrder) + routes.POST(prefix+"/orders/:id/receive", requirePerm("mohong:order_manage"), mohongHandler.AdminReceiveOrder) + routes.POST(prefix+"/orders/:id/complete", requirePerm("mohong:order_manage"), mohongHandler.AdminCompleteOrder) + routes.POST(prefix+"/orders/:id/cancel", requirePerm("mohong:order_manage"), mohongHandler.AdminCancelOrder) + routes.POST(prefix+"/orders/:id/refund", requirePerm("mohong:order_manage"), paymentHandler.AdminRefundMohong) + routes.GET(prefix+"/config", requirePerm("mohong:config"), mohongHandler.AdminGetConfig) + routes.PUT(prefix+"/config", requirePerm("mohong:config"), mohongHandler.AdminUpdateConfig) + } + register("/crash") + register("/mohong") +} + +type adminManagementHandlers struct { + chat *chat.Handler + supportGroup *supportgroup.Handler + role *adminrole.Handler + admin *adminmgr.Handler + announcement *announcement.Handler +} + +func registerAdminManagementRoutes(routes *gin.RouterGroup, requirePerm permissionMiddleware, handlers adminManagementHandlers) { + routes.POST("/chats/qrcodes", requirePerm("qrcode:manage"), handlers.chat.CreateQrCodeHandler) + routes.POST("/chats/qrcodes/batch", requirePerm("qrcode:manage"), handlers.chat.BatchCreateQrCodeHandler) + routes.POST("/chats/qrcodes/batch-delete", requirePerm("qrcode:manage"), handlers.chat.BatchDeleteQrCodeHandler) + routes.GET("/chats/qrcodes", requirePerm("qrcode:view"), handlers.chat.ListQrCodesHandler) + routes.GET("/chats/qrcodes/stats", requirePerm("qrcode:view"), handlers.chat.GetQrCodeStatsHandler) + routes.POST("/chats/qrcodes/ocr-group-name", requirePerm("qrcode:manage"), handlers.chat.RecognizeQrCodeGroupNameHandler) + routes.POST("/chats/qrcodes/ocr-group-name/async", requirePerm("qrcode:manage"), handlers.chat.SubmitOCRJobHandler) + routes.GET("/chats/qrcodes/ocr-group-name/async/:jobId", requirePerm("qrcode:manage"), handlers.chat.GetOCRJobResultHandler) + routes.PATCH("/chats/qrcodes/:id", requirePerm("qrcode:manage"), handlers.chat.UpdateQrCodeHandler) + routes.DELETE("/chats/qrcodes/:id", requirePerm("qrcode:manage"), handlers.chat.DeleteQrCodeHandler) + + routes.GET("/chat-support-groups", requirePerm("chat:manage"), handlers.supportGroup.List) + routes.GET("/chat-support-groups/support-admins", requirePerm("chat:manage"), handlers.supportGroup.ListSupportAdmins) + routes.GET("/chat-support-groups/:id", requirePerm("chat:manage"), handlers.supportGroup.FindByID) + routes.POST("/chat-support-groups", requirePerm("chat:manage"), handlers.supportGroup.Create) + routes.PUT("/chat-support-groups/:id", requirePerm("chat:manage"), handlers.supportGroup.Update) + routes.DELETE("/chat-support-groups/:id", requirePerm("chat:manage"), handlers.supportGroup.Delete) + routes.PUT("/chat-support-groups/:id/members", requirePerm("chat:manage"), handlers.supportGroup.AssignMembers) + + routes.GET("/roles", requirePerm("role:manage"), handlers.role.List) + routes.GET("/roles/:id", requirePerm("role:manage"), handlers.role.FindByID) + routes.POST("/roles", requirePerm("role:manage"), handlers.role.Create) + routes.PUT("/roles/:id", requirePerm("role:manage"), handlers.role.Update) + routes.DELETE("/roles/:id", requirePerm("role:manage"), handlers.role.Delete) + routes.PUT("/roles/:id/permissions", requirePerm("role:manage"), handlers.role.AssignPermissions) + routes.GET("/permissions", requirePerm("role:manage"), handlers.role.ListPermissions) + + routes.GET("/admin-users", requirePerm("admin_user:manage"), handlers.admin.List) + routes.GET("/admin-users/:id", requirePerm("admin_user:manage"), handlers.admin.FindByID) + routes.POST("/admin-users", requirePerm("admin_user:manage"), handlers.admin.Create) + routes.PUT("/admin-users/:id", requirePerm("admin_user:manage"), handlers.admin.Update) + routes.DELETE("/admin-users/:id", requirePerm("admin_user:manage"), handlers.admin.Delete) + routes.PUT("/admin-users/:id/roles", requirePerm("admin_user:manage"), handlers.admin.AssignRoles) + routes.PUT("/admin-users/me/password", handlers.admin.ChangeOwnPassword) + routes.PUT("/admin-users/:id/password", requirePerm("admin_user:manage"), handlers.admin.ResetPassword) + + routes.GET("/announcements", requirePerm("announcement:view"), handlers.announcement.AdminList) + routes.GET("/announcements/:id", requirePerm("announcement:view"), handlers.announcement.AdminGetByID) + routes.POST("/announcements", requirePerm("announcement:manage"), handlers.announcement.Create) + routes.PUT("/announcements/:id", requirePerm("announcement:manage"), handlers.announcement.Update) + routes.POST("/announcements/:id/publish", requirePerm("announcement:manage"), handlers.announcement.Publish) + routes.POST("/announcements/:id/archive", requirePerm("announcement:manage"), handlers.announcement.Archive) + routes.POST("/announcements/:id/unarchive", requirePerm("announcement:manage"), handlers.announcement.Unarchive) + routes.DELETE("/announcements/:id", requirePerm("announcement:manage"), handlers.announcement.Delete) +} diff --git a/backend/internal/router/health_checks.go b/backend/internal/router/health_checks.go new file mode 100644 index 0000000..b368bf0 --- /dev/null +++ b/backend/internal/router/health_checks.go @@ -0,0 +1,29 @@ +package router + +import ( + "context" + "errors" + + "hfb_sys/backend/internal/handler" +) + +func newReadinessChecks(deps Dependencies) map[string]handler.ReadinessCheck { + return map[string]handler.ReadinessCheck{ + "mysql": func(ctx context.Context) error { + if deps.DB == nil { + return errors.New("mysql unavailable") + } + sqlDB, err := deps.DB.DB() + if err != nil { + return err + } + return sqlDB.PingContext(ctx) + }, + "redis": func(ctx context.Context) error { + if deps.Redis == nil { + return errors.New("redis unavailable") + } + return deps.Redis.Ping(ctx).Err() + }, + } +} diff --git a/backend/internal/router/providers.go b/backend/internal/router/providers.go new file mode 100644 index 0000000..e81f0f5 --- /dev/null +++ b/backend/internal/router/providers.go @@ -0,0 +1,48 @@ +package router + +import ( + "strings" + + "hfb_sys/backend/internal/config" + smsintegration "hfb_sys/backend/internal/integrations/sms" + "hfb_sys/backend/internal/modules/realname" + "hfb_sys/backend/pkg/crypto" + + "go.uber.org/zap" +) + +func newSMSProvider(cfg config.Config, logger *zap.Logger) smsintegration.Provider { + switch strings.ToLower(strings.TrimSpace(cfg.SMS.Provider)) { + case "aliyun": + provider, err := smsintegration.NewAliyunProvider(smsintegration.AliyunConfig{ + AccessKeyID: cfg.SMS.AliyunAccessKeyID, + AccessKeySecret: cfg.SMS.AliyunAccessKeySecret, + Endpoint: cfg.SMS.AliyunEndpoint, + SignName: cfg.SMS.AliyunSignName, + LoginTemplateCode: cfg.SMS.AliyunLoginTemplateCode, + }, logger) + if err != nil { + logger.Warn("阿里云短信服务初始化失败", zap.Error(err)) + return smsintegration.NewUnavailableProvider(err) + } + return provider + default: + return smsintegration.NewMockProvider(logger) + } +} + +func newRealnameProvider(cfg config.Config, encryptor crypto.Encryptor, logger *zap.Logger) realname.Provider { + switch strings.ToLower(strings.TrimSpace(cfg.Realname.Provider)) { + case "cloudmarket", "aliyun_cloudmarket": + provider, err := realname.NewCloudMarketProvider(realname.CloudMarketConfig{ + URL: cfg.Realname.CloudMarketURL, AppCode: cfg.Realname.CloudMarketAppCode, Encryptor: encryptor, + }) + if err != nil { + logger.Warn("实名认证服务初始化失败", zap.Error(err)) + return realname.NewUnavailableProvider(err) + } + return provider + default: + return realname.NewMockProvider(encryptor) + } +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index aea3188..21d8804 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -2,11 +2,9 @@ package router import ( "context" - "strings" "hfb_sys/backend/internal/config" "hfb_sys/backend/internal/handler" - smsintegration "hfb_sys/backend/internal/integrations/sms" "hfb_sys/backend/internal/middleware" "hfb_sys/backend/internal/modules/adminaudit" "hfb_sys/backend/internal/modules/adminauth" @@ -62,8 +60,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { engine.Use(middleware.Recovery(logger)) jwtManager := auth.NewJWTManager(cfg.JWTSecret) - health := handler.NewHealthHandler() - engine.GET("/health", health.Check) + health := handler.NewHealthHandler(newReadinessChecks(deps)) + engine.GET("/health", health.Live) + engine.GET("/health/ready", health.Ready) // Swagger 文档路由(仅在非生产环境) if !config.IsProductionEnv(cfg.AppEnv) { @@ -399,7 +398,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { api := engine.Group("/api") { - api.GET("/health", health.Check) + api.GET("/health", health.Ready) api.GET("/listing-publish-options", systemConfigHandler.PublishOptions) api.GET("/listing-publish-agreements", systemConfigHandler.ListingPublishAgreements) api.GET("/listing-sale-price-config", systemConfigHandler.SalePriceConfig) @@ -653,31 +652,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/listings/:id/offline", requirePerm("listing:offline"), listingHandler.AdminOffline) adminRoutes.POST("/listings/:id/mark-abnormal", requirePerm("listing:offline"), listingHandler.AdminMarkAbnormal) - // 撞车后台(/admin/crash;兼容 /admin/mohong) - if mohongHandler != nil { - // 商品编辑需要拉分类列表,故 GET 用 product_view - registerCrashAdmin := func(prefix string) { - adminRoutes.GET(prefix+"/categories", requirePerm("mohong:product_view"), mohongHandler.AdminListCategories) - adminRoutes.POST(prefix+"/categories", requirePerm("mohong:category"), mohongHandler.AdminCreateCategory) - adminRoutes.PUT(prefix+"/categories/:id", requirePerm("mohong:category"), mohongHandler.AdminUpdateCategory) - adminRoutes.DELETE(prefix+"/categories/:id", requirePerm("mohong:category"), mohongHandler.AdminDeleteCategory) - adminRoutes.GET(prefix+"/products", requirePerm("mohong:product_view"), mohongHandler.AdminListProducts) - adminRoutes.GET(prefix+"/products/:id", requirePerm("mohong:product_view"), mohongHandler.AdminGetProduct) - adminRoutes.POST(prefix+"/products", requirePerm("mohong:product_manage"), mohongHandler.AdminCreateProduct) - adminRoutes.PUT(prefix+"/products/:id", requirePerm("mohong:product_manage"), mohongHandler.AdminUpdateProduct) - adminRoutes.DELETE(prefix+"/products/:id", requirePerm("mohong:product_manage"), mohongHandler.AdminDeleteProduct) - adminRoutes.GET(prefix+"/orders", requirePerm("mohong:order_view"), mohongHandler.AdminListOrders) - adminRoutes.GET(prefix+"/orders/:id", requirePerm("mohong:order_view"), mohongHandler.AdminGetOrder) - adminRoutes.POST(prefix+"/orders/:id/receive", requirePerm("mohong:order_manage"), mohongHandler.AdminReceiveOrder) - adminRoutes.POST(prefix+"/orders/:id/complete", requirePerm("mohong:order_manage"), mohongHandler.AdminCompleteOrder) - adminRoutes.POST(prefix+"/orders/:id/cancel", requirePerm("mohong:order_manage"), mohongHandler.AdminCancelOrder) - adminRoutes.POST(prefix+"/orders/:id/refund", requirePerm("mohong:order_manage"), paymentHandler.AdminRefundMohong) - adminRoutes.GET(prefix+"/config", requirePerm("mohong:config"), mohongHandler.AdminGetConfig) - adminRoutes.PUT(prefix+"/config", requirePerm("mohong:config"), mohongHandler.AdminUpdateConfig) - } - registerCrashAdmin("/crash") - registerCrashAdmin("/mohong") - } + registerCrashAdminRoutes(adminRoutes, requirePerm, mohongHandler, paymentHandler) adminRoutes.GET("/disputes", requirePerm("dispute:view"), disputeHandler.AdminList) adminRoutes.POST("/disputes/:id/arbitrate", requirePerm("dispute:arbitrate"), disputeHandler.AdminArbitrate) @@ -750,95 +725,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/chats/listing-group-welcome", requirePerm("system_config:view"), chatHandler.AdminGetListingGroupWelcome) adminRoutes.PUT("/chats/listing-group-welcome", requirePerm("system_config:update"), chatHandler.AdminUpdateListingGroupWelcome) - // 二维码池管理 - adminRoutes.POST("/chats/qrcodes", requirePerm("qrcode:manage"), chatHandler.CreateQrCodeHandler) - adminRoutes.POST("/chats/qrcodes/batch", requirePerm("qrcode:manage"), chatHandler.BatchCreateQrCodeHandler) - adminRoutes.POST("/chats/qrcodes/batch-delete", requirePerm("qrcode:manage"), chatHandler.BatchDeleteQrCodeHandler) - adminRoutes.GET("/chats/qrcodes", requirePerm("qrcode:view"), chatHandler.ListQrCodesHandler) - adminRoutes.GET("/chats/qrcodes/stats", requirePerm("qrcode:view"), chatHandler.GetQrCodeStatsHandler) - adminRoutes.POST("/chats/qrcodes/ocr-group-name", requirePerm("qrcode:manage"), chatHandler.RecognizeQrCodeGroupNameHandler) - adminRoutes.POST("/chats/qrcodes/ocr-group-name/async", requirePerm("qrcode:manage"), chatHandler.SubmitOCRJobHandler) - adminRoutes.GET("/chats/qrcodes/ocr-group-name/async/:jobId", requirePerm("qrcode:manage"), chatHandler.GetOCRJobResultHandler) - adminRoutes.PATCH("/chats/qrcodes/:id", requirePerm("qrcode:manage"), chatHandler.UpdateQrCodeHandler) - adminRoutes.DELETE("/chats/qrcodes/:id", requirePerm("qrcode:manage"), chatHandler.DeleteQrCodeHandler) - - // 客服分组 - adminRoutes.GET("/chat-support-groups", requirePerm("chat:manage"), supportGroupHandler.List) - adminRoutes.GET("/chat-support-groups/support-admins", requirePerm("chat:manage"), supportGroupHandler.ListSupportAdmins) - adminRoutes.GET("/chat-support-groups/:id", requirePerm("chat:manage"), supportGroupHandler.FindByID) - adminRoutes.POST("/chat-support-groups", requirePerm("chat:manage"), supportGroupHandler.Create) - adminRoutes.PUT("/chat-support-groups/:id", requirePerm("chat:manage"), supportGroupHandler.Update) - adminRoutes.DELETE("/chat-support-groups/:id", requirePerm("chat:manage"), supportGroupHandler.Delete) - adminRoutes.PUT("/chat-support-groups/:id/members", requirePerm("chat:manage"), supportGroupHandler.AssignMembers) - - // 角色管理 - adminRoutes.GET("/roles", requirePerm("role:manage"), adminRoleHandler.List) - adminRoutes.GET("/roles/:id", requirePerm("role:manage"), adminRoleHandler.FindByID) - adminRoutes.POST("/roles", requirePerm("role:manage"), adminRoleHandler.Create) - adminRoutes.PUT("/roles/:id", requirePerm("role:manage"), adminRoleHandler.Update) - adminRoutes.DELETE("/roles/:id", requirePerm("role:manage"), adminRoleHandler.Delete) - adminRoutes.PUT("/roles/:id/permissions", requirePerm("role:manage"), adminRoleHandler.AssignPermissions) - adminRoutes.GET("/permissions", requirePerm("role:manage"), adminRoleHandler.ListPermissions) - - // 管理员管理 - adminRoutes.GET("/admin-users", requirePerm("admin_user:manage"), adminMgrHandler.List) - adminRoutes.GET("/admin-users/:id", requirePerm("admin_user:manage"), adminMgrHandler.FindByID) - adminRoutes.POST("/admin-users", requirePerm("admin_user:manage"), adminMgrHandler.Create) - adminRoutes.PUT("/admin-users/:id", requirePerm("admin_user:manage"), adminMgrHandler.Update) - adminRoutes.DELETE("/admin-users/:id", requirePerm("admin_user:manage"), adminMgrHandler.Delete) - adminRoutes.PUT("/admin-users/:id/roles", requirePerm("admin_user:manage"), adminMgrHandler.AssignRoles) - adminRoutes.PUT("/admin-users/me/password", adminMgrHandler.ChangeOwnPassword) - adminRoutes.PUT("/admin-users/:id/password", requirePerm("admin_user:manage"), adminMgrHandler.ResetPassword) - - // 公告管理 - adminRoutes.GET("/announcements", requirePerm("announcement:view"), announcementHandler.AdminList) - adminRoutes.GET("/announcements/:id", requirePerm("announcement:view"), announcementHandler.AdminGetByID) - adminRoutes.POST("/announcements", requirePerm("announcement:manage"), announcementHandler.Create) - adminRoutes.PUT("/announcements/:id", requirePerm("announcement:manage"), announcementHandler.Update) - adminRoutes.POST("/announcements/:id/publish", requirePerm("announcement:manage"), announcementHandler.Publish) - adminRoutes.POST("/announcements/:id/archive", requirePerm("announcement:manage"), announcementHandler.Archive) - adminRoutes.POST("/announcements/:id/unarchive", requirePerm("announcement:manage"), announcementHandler.Unarchive) - adminRoutes.DELETE("/announcements/:id", requirePerm("announcement:manage"), announcementHandler.Delete) + registerAdminManagementRoutes(adminRoutes, requirePerm, adminManagementHandlers{ + chat: chatHandler, supportGroup: supportGroupHandler, role: adminRoleHandler, + admin: adminMgrHandler, announcement: announcementHandler, + }) } } return engine } - -func newSMSProvider(cfg config.Config, logger *zap.Logger) smsintegration.Provider { - switch strings.ToLower(strings.TrimSpace(cfg.SMS.Provider)) { - case "aliyun": - provider, err := smsintegration.NewAliyunProvider(smsintegration.AliyunConfig{ - AccessKeyID: cfg.SMS.AliyunAccessKeyID, - AccessKeySecret: cfg.SMS.AliyunAccessKeySecret, - Endpoint: cfg.SMS.AliyunEndpoint, - SignName: cfg.SMS.AliyunSignName, - LoginTemplateCode: cfg.SMS.AliyunLoginTemplateCode, - }, logger) - if err != nil { - logger.Warn("阿里云短信服务初始化失败", zap.Error(err)) - return smsintegration.NewUnavailableProvider(err) - } - return provider - default: - return smsintegration.NewMockProvider(logger) - } -} - -func newRealnameProvider(cfg config.Config, encryptor crypto.Encryptor, logger *zap.Logger) realname.Provider { - switch strings.ToLower(strings.TrimSpace(cfg.Realname.Provider)) { - case "cloudmarket", "aliyun_cloudmarket": - provider, err := realname.NewCloudMarketProvider(realname.CloudMarketConfig{ - URL: cfg.Realname.CloudMarketURL, - AppCode: cfg.Realname.CloudMarketAppCode, - Encryptor: encryptor, - }) - if err != nil { - logger.Warn("实名认证服务初始化失败", zap.Error(err)) - return realname.NewUnavailableProvider(err) - } - return provider - default: - return realname.NewMockProvider(encryptor) - } -} diff --git a/backend/internal/router/router_test.go b/backend/internal/router/router_test.go new file mode 100644 index 0000000..4b6e5f0 --- /dev/null +++ b/backend/internal/router/router_test.go @@ -0,0 +1,68 @@ +package router + +import ( + "net/http" + "net/http/httptest" + "testing" + + "hfb_sys/backend/internal/config" + + "go.uber.org/zap" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func testConfig() config.Config { + return config.Config{AppEnv: "test", JWTSecret: "test-secret"} +} + +func TestHealthRoutesSeparateLivenessAndReadiness(t *testing.T) { + engine := New(testConfig(), Dependencies{}, zap.NewNop()) + + for _, tt := range []struct { + path string + wantStatus int + }{ + {path: "/health", wantStatus: http.StatusOK}, + {path: "/health/ready", wantStatus: http.StatusServiceUnavailable}, + {path: "/api/health", wantStatus: http.StatusServiceUnavailable}, + } { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, tt.path, nil) + engine.ServeHTTP(recorder, request) + if recorder.Code != tt.wantStatus { + t.Fatalf("GET %s status = %d, want %d; body=%s", tt.path, recorder.Code, tt.wantStatus, recorder.Body.String()) + } + } +} + +func TestExtractedAdminRoutesRemainRegisteredWithoutDuplicates(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:router-routes?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + engine := New(testConfig(), Dependencies{DB: db}, zap.NewNop()) + routes := engine.Routes() + registered := make(map[string]struct{}, len(routes)) + for _, route := range routes { + key := route.Method + " " + route.Path + if _, exists := registered[key]; exists { + t.Fatalf("duplicate route: %s", key) + } + registered[key] = struct{}{} + } + + for _, key := range []string{ + "GET /api/admin/crash/products", + "GET /api/admin/mohong/products", + "POST /api/admin/chats/qrcodes/batch", + "PUT /api/admin/chat-support-groups/:id/members", + "PUT /api/admin/roles/:id/permissions", + "PUT /api/admin/admin-users/:id/password", + "POST /api/admin/announcements/:id/publish", + } { + if _, exists := registered[key]; !exists { + t.Errorf("missing route after extraction: %s", key) + } + } +} diff --git a/deploy/README.md b/deploy/README.md index addfa99..5f33128 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -216,6 +216,7 @@ docker compose -f deploy/docker-compose.prod.yml run --rm --no-deps backend \ ```bash curl https://主站域名/health +curl https://主站域名/health/ready curl https://主站域名/api/health # 若启用了选号网: curl -I https://选号网域名/ diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index dd0cada..4a8efe1 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -30,7 +30,7 @@ } } - handle /health { + handle /health /health/ready { reverse_proxy backend:8080 { import backend_proxy } diff --git a/deploy/caddy/Dockerfile b/deploy/caddy/Dockerfile index b058afb..02bd276 100644 --- a/deploy/caddy/Dockerfile +++ b/deploy/caddy/Dockerfile @@ -6,7 +6,7 @@ ENV VITE_ADMIN_BASE_PATH=${VITE_ADMIN_BASE_PATH} WORKDIR /src COPY frontend/package*.json ./ -RUN npm install +RUN npm ci COPY frontend/ . RUN npm run build diff --git a/docs/business-rules.md b/docs/business-rules.md new file mode 100644 index 0000000..d4b5a8a --- /dev/null +++ b/docs/business-rules.md @@ -0,0 +1,33 @@ +# 业务规则 + +## 商品 + +- 用户完成登录和实名认证后才能发布租赁商品。 +- 商品提交审核并通过后才可公开上架;处于交易中的商品不能被另一订单锁定。 +- 主动下架、交易离架、异常和封存是不同状态,必须保留状态事件。 + +## 订单 + +- 创建订单后进入待支付;支付成功后进入待交接并锁定商品。 +- 号主提交交接信息,租客确认后开始计算租期。 +- 租客归还后进入结账协商,双方确认或客服仲裁后完成结算。 +- 订单状态、交接状态和结算状态共同描述流程,禁止仅修改其中一个字段绕过状态机。 + +## 支付与退款 + +- 金额均使用整数分,支付单和退款单必须具有唯一业务单号。 +- 渠道通知必须先验签,再按幂等规则推进本地状态。 +- 已进入终态的支付或退款记录不能被迟到通知回退。 +- 人工退款、关闭订单、封存订单和仲裁退款的业务语义不同,均需审计记录。 + +## 钱包与提现 + +- 钱包区分可用余额和冻结余额,每次变更必须产生流水。 +- 创建提现申请时,可用余额转为冻结余额;取消或拒绝时原额返还。 +- 审核通过后只有确认打款才能最终扣减冻结余额,重复审核或重复确认必须被拒绝。 + +## 管理操作 + +- 管理后台使用独立认证和 RBAC 权限。 +- 关闭、退款、封存、异常、仲裁和余额调整等高风险操作必须写入审计日志。 +- 客服只能通过服务端返回的可用动作执行订单状态转换。 diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..20a822c --- /dev/null +++ b/docs/database.md @@ -0,0 +1,25 @@ +# 数据库设计 + +## 设计原则 + +- MySQL 8.4 是生产事实源,Redis 只保存限流、验证码、会话辅助和缓存数据。 +- 金额统一以 `*_cent` 的整数分存储;数量和比例字段按业务精度单独定义。 +- 核心写入通过 GORM 事务完成,钱包账户更新使用行锁并同步写入不可变流水。 +- 表结构由 `backend/migrations` 下的 Goose 迁移管理,应用启动不自动修改生产表结构。 + +## 核心领域 + +- 用户与权限:`users`、`admin_users`、角色、权限及关联表。 +- 商品:租赁商品、账号、资产截图、审核记录和状态事件。 +- 订单:租赁订单、交接记录、结账协商、过程事件和申诉。 +- 资金:支付单、退款单、钱包账户、钱包流水、收款账号和提现申请。 +- 运营:系统配置、公告、通知、聊天、二维码池、推送规则和撞车商品。 + +## 一致性约束 + +- 下单时在事务内锁定可租商品,避免重复出租。 +- 支付状态、订单状态和商品状态在同一业务事务内推进。 +- 钱包每次余额变化必须生成对应流水,提现在待审核阶段将可用余额转为冻结余额。 +- 支付通知和业务单号使用唯一索引实现幂等。 + +索引、慢查询、连接池和备份恢复细则见[数据库 SQL 约束与运维](数据库SQL约束与运维.md)。 diff --git a/docs/project-plan.md b/docs/project-plan.md new file mode 100644 index 0000000..2567f1d --- /dev/null +++ b/docs/project-plan.md @@ -0,0 +1,23 @@ +# 项目计划 + +## 产品目标 + +HFB Sys 面向《三角洲行动》账号租赁,提供从商品发布、审核、下单、支付、账号交接、租用、归还、结账到申诉仲裁的闭环,并支持平台代管账号和撞车商品交易。 + +## 当前阶段 + +系统已经进入生产运营与稳定性建设阶段。主流程、管理后台、支付渠道、财务统计、消息通知、对象存储和备份恢复均已落地,后续工作以资金正确性、自动化回归和运维可观测性为主。 + +## 迭代顺序 + +1. 资金链路:支付回调幂等、退款、钱包余额守恒、提现审核和结算测试。 +2. 稳定性:真实 MySQL E2E、就绪探针、超时任务和恢复演练。 +3. 可维护性:拆分大型前端页面和后端路由组装文件。 +4. 体验:根据运营数据优化商品筛选、订单操作和客服工作台。 + +## 发布标准 + +- `./scripts/check.sh` 全部通过。 +- 数据库迁移同时具备 Up/Down,并通过 SQL 门禁。 +- 涉及金额或状态机的变更必须包含回归测试。 +- 生产部署后 `/health/ready` 返回 200,关键下单、支付查询和后台登录完成冒烟验证。 diff --git a/docs/代码质量改进计划.md b/docs/代码质量改进计划.md index ca166ae..463b71e 100644 --- a/docs/代码质量改进计划.md +++ b/docs/代码质量改进计划.md @@ -1,643 +1,55 @@ # HFB Sys 代码质量改进计划 -## 文档说明 +## 当前基线 -本文档记录了项目代码审查发现的问题和优化计划。 +更新时间:2026-09-02。 -**生成时间**: 2026-06-10 -**项目版本**: refactor/features-architecture 分支 +- 后端约 374 个 Go 文件、6.6 万行代码,64 个测试文件。 +- 前端约 249 个 Vue/TypeScript 文件、8.4 万行代码,7 个测试文件、32 个测试;大型页面样式已外置,订单详情 setup 已独立为 composable 目录中的脚本模块。 +- 数据库使用 61 个 Goose 迁移文件,SQL 约束由 `scripts/check-sql-guard.sh` 检查。 +- 后端总语句覆盖率基线为 30%;核心模块门禁为 order 40%、payment 20%、wallet 34%、withdrawal 30%。 +- 前端支付、钱包、提现和订单展示目标文件已建立 V8 覆盖率门禁:语句 80%、分支 70%、函数 80%、行 80%。 +- Vue 页面大小门禁为 1800 行,`scripts/check-vue-size.sh` 在本地检查中执行,防止大型 SFC 回涨;订单详情页的指标、支付流水和展示工具已拆出独立模块。 +- Repository 已普遍传递请求 Context;当前约 376 处 GORM `WithContext` 调用。 ---- +## 已落地门禁 -## 📊 当前状态 +本地和部署前统一运行: -### 代码规模 -- **后端**: ~28,000 行 Go 代码,122 个模块文件 -- **前端**: ~46,000 行 Vue/TypeScript 代码 -- **数据库**: 876 行 SQL 初始化脚本,114 个索引 -- **测试覆盖率**: - - 后端约 10%(12 个测试文件) - - 前端接近 0%(仅 2 个测试文件) - -### 测试覆盖率详情 - -| 模块 | 覆盖率 | 状态 | -|------|--------|------| -| realname | 28.2% | ⚠️ 需改进 | -| listing | 11.1% | 🔴 严重不足 | -| wallet | 11.0% | 🔴 严重不足 | -| payment | 8.9% | 🔴 严重不足 | -| dispute | 8.8% | 🔴 严重不足 | -| order | 7.7% | 🔴 严重不足 | -| paymentconfig | 2.2% | 🔴 严重不足 | -| adminuser | 0.7% | 🔴 严重不足 | - ---- - -## 🔴 严重问题(需要尽快处理) - -### 1. 测试覆盖率严重不足 ⚠️ - -**现状**: -- 核心金融模块(payment, wallet, order)测试覆盖率低于 12% -- 复杂的订单状态机、结算逻辑、退款流程缺少测试保护 -- 前端几乎没有单元测试 - -**风险**: -- 金融相关的钱包、支付、退款逻辑出错会导致资金损失 -- 重构时容易引入 bug,没有安全网 -- 订单状态转换错误会导致业务流程异常 - -**改进计划**: -- ✅ **已完成** (2026-06-10 第一批): - - 为 wallet 模块补充 Service 层测试(8 个测试用例) - - 为 order 模块补充 Service 层测试(11 个测试用例) - - 为 payment 模块补充 Service 层测试(10 个测试用例) - -- ✅ **已完成** (2026-06-10 第二批): - - 为 wallet.Repository 补充核心业务逻辑测试(14 个逻辑测试 + 9 个集成测试) - - 为 order.Repository 补充测试(11 个集成测试) - - 为 payment.Repository 补充测试(18 个集成测试) - -- 🔄 **进行中**: - - [ ] 为 wallet.Repository 补充更多集成测试 - - [ ] 并发场景测试(账户锁定) - - [ ] Withdraw 提现测试 - - [ ] AdminLedger 管理员账本查询测试 - - - [ ] 为 order.Repository 补充更多集成测试 - - [ ] Pay 支付状态转换测试 - - [ ] SubmitHandoff/ConfirmReceive 交接流程测试 - - [ ] CounterCheckout 反价逻辑测试 - - - [ ] 为 payment.Repository 补充更多集成测试 - - [ ] Start 支付单创建与复用测试 - - [ ] StartRefund 退款流程测试 - - [ ] HandleNotify 支付回调处理测试 - -- 📅 **计划中**(1-2 周内完成): - - [ ] 前端关键组件测试 - - [ ] 支付组件单元测试 - - [ ] 钱包组件单元测试 - - [ ] 订单流程 E2E 测试 - - - [ ] 集成测试扩展 - - [ ] 完整租号流程测试(发布→下单→支付→交接→归还→结算) - - [ ] 退款流程测试 - - [ ] 仲裁流程测试 - -**目标**: -- 短期(1 个月内):核心模块测试覆盖率达到 **60%** -- 中期(3 个月内):整体测试覆盖率达到 **70%** -- 长期:建立 CI/CD 测试流程,强制测试覆盖率不低于 60% - ---- - -### 2. 缺少 Context 超时控制 🚨 - -**现状**: -- 122 个模块文件中只有 8 个使用 `context.Context` -- 数据库查询、HTTP 调用、外部 API(支付、短信、实名)都没有超时控制 - -**风险**: -- 数据库慢查询会导致 goroutine 泄漏 -- 外部 API 超时会拖垮整个服务 -- 无法实现请求级别的超时控制和取消机制 - -**改进计划**: -- 📅 **Week 1**: - - [ ] 为所有 Repository 方法签名添加 `ctx context.Context` 参数 - - [ ] 更新 Service 层传递 context - - [ ] 在 handler 层从 `c.Request.Context()` 获取 context - -- 📅 **Week 2**: - - [ ] 为数据库操作添加超时控制 - ```go - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - return r.db.WithContext(ctx).Create(...) - ``` - - - [ ] 为外部 API 调用添加超时控制 - - 支付渠道调用:10 秒超时 - - 短信服务:5 秒超时 - - 实名认证:10 秒超时 - -**示例代码**: -```go -// 修改前 -func (r *Repository) Create(userID uint64, req CreateRequest) (*OrderDTO, error) { - return r.db.Transaction(func(tx *gorm.DB) error { - // ... - }) -} - -// 修改后 -func (r *Repository) Create(ctx context.Context, userID uint64, req CreateRequest) (*OrderDTO, error) { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - - return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // ... - }) -} +```bash +./scripts/check.sh ``` ---- +门禁包含: -### 3. 代码文件过大,职责不清 📦 +1. SQL 迁移约束与完整性检查。 +2. `go vet`、Go 全量构建、测试和覆盖率基线。 +3. Prettier、ESLint、Vue/TypeScript 类型检查。 +4. Vitest 和 Vite 生产构建。 -**现状**: -- `listing/repository.go`: **1731 行** -- `payment/repository.go`: **1282 行** -- `chat/repository.go`: **1155 行** -- 平均文件行数:**184 行**(偏高,建议 150 行以下) +本地门禁通过 `scripts/check.sh` 统一执行;需要完整租赁链路时,在本地 MySQL 8.4 上设置 `HFB_E2E_DSN` 运行 E2E。覆盖率门禁采用“只能提高、不能回退”的基线策略,新增测试后应同步上调阈值。 -**问题**: -- 单个文件包含太多逻辑,难以维护和测试 -- 违反单一职责原则 -- 代码复用困难 +## 后续重点 -**改进计划**: -- 📅 **Week 3-4**: - - **listing 模块拆分**: - ``` - listing/ - ├── repository.go # 核心仓储(保留 Create/Update/Delete) - ├── query.go # 查询逻辑(List/Detail/Search) - ├── review.go # 审核逻辑(SubmitReview/Approve/Reject) - └── cache.go # 缓存逻辑(PublicZoneCount) - ``` - - **payment 模块拆分**: - ``` - payment/ - ├── repository.go # 核心仓储 - ├── refund.go # 退款逻辑(StartRefund/QueryRefund) - ├── channel.go # 渠道适配(lakala/leshua/mock) - └── notify.go # 回调处理(HandleNotify) - ``` - - **chat 模块拆分**: - ``` - chat/ - ├── repository.go # 核心仓储 - ├── message.go # 消息逻辑(Send/List/MarkRead) - └── websocket.go # WebSocket 连接管理 - ``` +### P0:资金正确性 ---- +- 扩充支付通知乱序、重复回调和退款重试测试。 +- 扩充钱包并发入账、提现审核竞争和事务回滚测试。 +- 保持金额使用整数分存储,禁止业务层使用浮点数累计金额。 -## 🟡 重要问题(影响性能和可维护性) +### P1:前端回归保护 -### 4. 缺少接口抽象,模块耦合严重 +- 为支付收银台、订单操作、提现和后台财务组件增加测试。 +- 优先测试状态转换、权限可见性和请求参数,不追求低价值快照数量。 +- 逐步建立前端覆盖率基线。 -**现状**: -```go -// order/repository.go 直接依赖具体实现 -type Repository struct { - db *gorm.DB - chatRepo *chat.Repository // 直接依赖 - refundFunc RefundFunc // 通过函数注入 -} -``` +### P1:结构治理 -**问题**: -- 难以进行单元测试(无法 mock 依赖) -- 模块间耦合紧密,修改一个模块可能影响其他模块 -- 依赖注入通过 `SetXxx` 方法,容易忘记初始化 +- 将大型管理页面拆成展示组件、操作对话框和 composable。 +- 将路由组装按公开、用户、卖家、管理员领域拆分,保持 `router.New` 只负责依赖组装。 +- 单次拆分必须保持接口、路由名称和用户行为不变。 -**改进方案**: -```go -// 定义接口解耦 -type ChatService interface { - CreateOrderChat(ctx context.Context, orderID uint64) error -} +### P2:可观测性 -type RefundService interface { - Refund(ctx context.Context, orderID uint64, amount int64, bizType, remark string) error -} - -type Repository struct { - db *gorm.DB - chatSvc ChatService - refundSvc RefundService -} - -func NewRepository(db *gorm.DB, chatSvc ChatService, refundSvc RefundService) *Repository { - return &Repository{ - db: db, - chatSvc: chatSvc, - refundSvc: refundSvc, - } -} -``` - -**改进计划**: -- 📅 **Week 5-6**: - - [ ] 定义核心接口(ChatService, RefundService, WalletService) - - [ ] 重构 Repository 依赖注入 - - [ ] 更新 router 初始化代码 - ---- - -### 5. 数据库查询存在性能隐患 - -**现状**: -- 数据库索引定义相对较少(114 个索引,20+ 张表) -- 部分复杂查询缺少索引覆盖 -- 缺少慢查询监控和分析 - -**具体问题**: -```sql --- adminfinance/repository.go 中的财务汇总查询 -SELECT ... FROM rental_orders AS ro -JOIN order_checkouts AS oc ON oc.order_id = ro.id -LEFT JOIN (...) AS w ON w.order_id = ro.id -- 子查询可能很慢 -WHERE ro.settled_at >= ? AND ro.settled_at <= ? -``` - -**改进计划**: -- 📅 **Week 7**: - - [ ] 审查所有复杂查询,使用 `EXPLAIN` 分析 - - [ ] 补充缺失的索引: - ```sql - -- 订单结算查询优化 - CREATE INDEX idx_rental_orders_settled_at_status - ON rental_orders(settled_at, settlement_status); - - -- 财务流水查询优化 - CREATE INDEX idx_wallet_ledger_user_created - ON wallet_ledger(user_id, created_at DESC); - - -- 支付订单查询优化 - CREATE INDEX idx_payment_orders_biz_status - ON payment_orders(biz_type, status, created_at DESC); - ``` - - - [ ] 对复杂统计查询考虑预计算方案 - - 每日财务汇总定时任务(凌晨 1 点) - - 用户钱包余额缓存(Redis) - - 订单统计数据缓存(5 分钟 TTL) - -- 📅 **Week 8**: - - [ ] 启用慢查询日志(记录 > 1 秒的查询) - - [ ] 建立慢查询分析流程 - - [ ] 优化 TOP 10 慢查询 - ---- - -### 6. 缺少 API 限流和熔断机制 - -**现状**: -- 只有短信验证码有简单的限流(`ErrCodeRateLimited`) -- **没有全局 API 限流** -- **没有熔断机制**保护外部依赖(支付渠道、短信服务) - -**风险**: -- 恶意攻击会拖垮服务 -- 外部服务故障会级联影响整个系统 -- 无法防止暴力破解 - -**改进计划**: -- 📅 **Week 9**: - - **添加全局限流中间件**: - ```go - import "github.com/ulule/limiter/v3" - - func RateLimitMiddleware() gin.HandlerFunc { - rate := limiter.Rate{ - Period: time.Minute, - Limit: 100, // 每分钟 100 次请求 - } - store := memory.NewStore() - middleware := mgin.NewMiddleware(limiter.New(store, rate)) - return middleware - } - ``` - - **添加熔断器保护外部服务**: - ```go - import "github.com/sony/gobreaker" - - type PaymentChannelWithBreaker struct { - channel channelClient - breaker *gobreaker.CircuitBreaker - } - - func NewPaymentChannel(channel channelClient) *PaymentChannelWithBreaker { - breaker := gobreaker.NewCircuitBreaker(gobreaker.Settings{ - Name: "payment_channel", - MaxRequests: 3, - Timeout: 10 * time.Second, - ReadyToTrip: func(counts gobreaker.Counts) bool { - failureRatio := float64(counts.TotalFailures) / float64(counts.Requests) - return counts.Requests >= 3 && failureRatio >= 0.6 - }, - }) - return &PaymentChannelWithBreaker{channel: channel, breaker: breaker} - } - ``` - -- 📅 **限流策略**: - - 全局:100 req/min per IP - - 登录接口:5 req/min per IP - - 短信发送:1 req/min per phone - - 支付接口:10 req/min per user - - 文件上传:20 req/hour per user - ---- - -### 7. 前端错误处理不统一 - -**现状**: -- 4 个 Vue 文件中仍有 `console.log/error` 调试代码 -- 错误处理分散在各个组件中 -- 缺少全局错误拦截和统一提示 - -**改进计划**: -- 📅 **Week 10**: - - **统一 HTTP 错误处理**: - ```typescript - // shared/utils/http.ts - import { ElMessage } from 'element-plus' - - axios.interceptors.response.use( - response => response, - error => { - const message = error.response?.data?.message || '请求失败' - ElMessage.error(message) - - // 统一错误上报(生产环境) - if (import.meta.env.PROD) { - errorReporter.report({ - message, - stack: error.stack, - url: error.config?.url, - method: error.config?.method, - }) - } - - return Promise.reject(error) - } - ) - ``` - - **清理调试代码**: - - [ ] 移除所有 `console.log` 调试代码 - - [ ] 添加 ESLint 规则禁止 `console.log` - - [ ] 使用统一的日志工具(开发环境) - ---- - -## 🟢 改进建议(提升代码质量) - -### 8. 日志记录不完整 - -**现状**: -- 有基础日志框架(zap)和请求日志中间件 -- 但业务日志记录不充分 -- 缺少关键业务节点的日志追踪 - -**改进建议**: -```go -// 为关键业务操作添加结构化日志 -logger.Info("order paid successfully", - zap.Uint64("order_id", orderID), - zap.Uint64("user_id", userID), - zap.Int64("amount_cent", amount), - zap.String("payment_no", paymentNo), - zap.String("provider", provider), -) - -logger.Warn("refund failed", - zap.Uint64("order_id", orderID), - zap.Int64("amount_cent", amount), - zap.String("reason", reason), - zap.Error(err), -) -``` - -**补充日志点**: -- [ ] 订单支付成功/失败 -- [ ] 退款申请/成功/失败 -- [ ] 账户交接关键节点 -- [ ] 仲裁处理结果 -- [ ] 钱包余额变更(大额) -- [ ] 系统配置修改 - ---- - -### 9. 配置管理可以改进 - -**现状**: -- 配置都通过环境变量管理 -- 缺少配置验证 -- 部分配置硬编码在代码中 - -**改进方案**: -```go -// 添加配置验证 -func (c Config) Validate() error { - if c.JWTSecret == "change-me" { - return errors.New("JWT_SECRET must be set in production") - } - if c.AppEnv == "production" && c.SMS.Provider == "mock" { - return errors.New("SMS provider cannot be mock in production") - } - if c.AppEnv == "production" && c.Realname.Provider == "mock" { - return errors.New("Realname provider cannot be mock in production") - } - return nil -} - -// 在 main.go 中调用 -cfg := config.Load() -if err := cfg.Validate(); err != nil { - log.Fatal("配置验证失败:", err) -} -``` - -**支持配置文件**: -- [ ] 支持 `config.yaml` 作为环境变量补充 -- [ ] 支持多环境配置文件(dev/staging/prod) -- [ ] 敏感配置仍使用环境变量 - ---- - -### 10. 前端共享逻辑可以进一步抽象 - -**现状**: -- `shared/utils` 目录已经做了不错的抽象 -- 但部分工具函数文件过大(`listingDisplay.ts` 11KB、`pricing.ts` 11KB) - -**拆分建议**: -``` -shared/utils/ -├── listing/ -│ ├── display.ts # 展示相关 -│ ├── format.ts # 格式化函数 -│ └── filters.ts # 过滤逻辑 -├── pricing/ -│ ├── pricing.ts # 价格计算 -│ ├── discount.ts # 折扣计算 -│ └── commission.ts # 佣金计算 -└── ... -``` - ---- - -### 11. 缺少文档和注释 - -**现状**: -- 代码注释较少 -- 没有完整的架构文档 -- API 文档依赖 Swagger - -**改进计划**: -- [ ] 补充架构文档 - - [ ] 数据库设计文档(`database.md`) - - [ ] 订单状态机文档 - - [ ] 支付流程文档 - - [ ] 结算规则文档 - -- [ ] 补充开发者指南 - - [ ] 如何添加新功能模块 - - [ ] 如何编写测试 - - [ ] 如何部署到生产环境 - -- [ ] 生成并维护 API 文档 - - [ ] 完善 Swagger 注释 - - [ ] 自动生成 API 文档 - ---- - -### 12. 缺少监控和告警 - -**现状**: -- 有基础日志(按天切分) -- 有审计日志 -- **但没有性能监控、错误追踪、告警机制** - -**改进方案**: - -**集成监控工具**: -- Prometheus + Grafana(应用监控) -- Sentry(错误追踪) -- OpenTelemetry(链路追踪,可选) - -**关键指标**: -- API 响应时间(P95, P99) -- 错误率 -- 订单支付成功率 -- 数据库慢查询 -- 外部 API 调用成功率 - -**告警规则**: -- API 错误率 > 5% -- 支付成功率 < 95% -- 数据库慢查询 > 10 次/分钟 -- 外部服务调用失败率 > 10% - ---- - -## 📊 优先级和时间计划 - -### 🔥 立即处理(已完成) -- ✅ 为核心金融模块补充单元测试(wallet, order, payment Service 层) - -### ⚡ 短期优化(1-2 周) -- **Week 1-2**: 添加 Context 超时控制 -- **Week 3-4**: 拆分大文件(listing/payment/chat repository) -- **Week 5-6**: 引入接口抽象,降低模块耦合 -- **Week 7-8**: 补充数据库索引,优化慢查询 - -### 🎯 中期改进(1-2 个月) -- **Week 9**: 添加 API 限流和熔断机制 -- **Week 10**: 统一前端错误处理 -- **Week 11-12**: 补充 Repository 层集成测试 -- **Week 13-14**: 补充前端组件测试 -- **Week 15-16**: 添加监控和告警系统 - -### 🌟 长期目标(3-6 个月) -- 建立 CI/CD 测试流程 -- 测试覆盖率达到 70%+ -- 完善技术文档 -- 性能优化(P95 响应时间 < 200ms) - ---- - -## 📈 成功指标 - -### 代码质量 -- [ ] 测试覆盖率 ≥ 60%(核心模块 ≥ 70%) -- [ ] 平均文件行数 < 150 行 -- [ ] 代码重复率 < 5% - -### 性能指标 -- [ ] API P95 响应时间 < 200ms -- [ ] API P99 响应时间 < 500ms -- [ ] 数据库慢查询 < 5 次/分钟 - -### 可靠性指标 -- [ ] API 可用性 > 99.9% -- [ ] 错误率 < 0.1% -- [ ] 支付成功率 > 99% - ---- - -## 🎉 已完成的改进 - -### 2026-06-10(第一批) -- ✅ 为 `wallet` 模块补充 Service 层单元测试(8 个测试用例) -- ✅ 为 `order` 模块补充 Service 层单元测试(11 个测试用例) -- ✅ 为 `payment` 模块补充 Service 层单元测试(10 个测试用例) -- ✅ 为 `wallet` 模块补充 Repository 逻辑测试(14 个测试用例) -- ✅ 为 `wallet` 模块补充 Repository 集成测试(9 个测试用例) -- ✅ 所有新增测试通过验证 -- ✅ 测试覆盖率基线建立: - - wallet: 11.0% → **36.1%**(提升 25%) - - order: 7.7% - - payment: 8.9% - -### 2026-06-10(第二批 - Week 1-2 完成) -- ✅ 为 `order` 模块补充 Repository 集成测试(11 个测试用例) - - 订单创建验证(商品状态、不能租自己的商品) - - 订单定价计算测试 - - 结账结算计算测试(全额租用、扣押金、额外消耗) - - 状态常量验证测试 - - 订单时长计算测试 -- ✅ 为 `payment` 模块补充 Repository 集成测试(18 个测试用例) - - 支付单复用逻辑测试 - - 支付状态转换测试 - - 退款业务类型覆盖测试 - - 支付金额验证测试 - - 支付渠道验证测试 - - Mock 模式判断测试 - - 钱包充值开关测试 -- ✅ 测试覆盖率提升: - - wallet: **36.1%**(保持) - - order: 7.7% → **10.2%**(提升 2.5%) - - payment: **8.9%**(保持) - -### 测试统计(Week 1-2 完成后) -- **测试文件总数**: 16 个 -- **测试用例总数**: 103 个(估算) - - wallet: 34 个 - - order: 28 个(17 + 11) - - payment: 41 个(23 + 18) -- **所有测试通过率**: 100% - ---- - -## 📝 备注 - -- 本文档是活文档,随着项目演进持续更新 -- 每完成一项改进,更新进度标记(✅) -- 定期回顾(每 2 周),调整优先级 - -**文档维护者**: Claude Code -**最后更新**: 2026-06-10 +- 区分 `/health` 存活探针和 `/health/ready` 就绪探针。 +- 持续观察慢查询、退款重试、订单超时任务和备份恢复演练结果。 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b784352..691e0c0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "@types/qrcode": "^1.5.6", "@vant/auto-import-resolver": "^1.3.0", "@vitejs/plugin-vue": "^6.0.2", + "@vitest/coverage-v8": "^4.1.11", "@vue/tsconfig": "^0.8.1", "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", @@ -92,30 +93,30 @@ "license": "MIT" }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -125,18 +126,28 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -1801,17 +1812,48 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.11", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", - "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1820,13 +1862,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", - "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.8", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1857,9 +1899,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", - "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -1870,13 +1912,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", - "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -1884,14 +1926,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", - "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1900,9 +1942,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", - "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -1910,13 +1952,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", - "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -2252,6 +2294,35 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async-validator": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", @@ -3262,6 +3333,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -3320,6 +3401,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -3411,6 +3499,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -3586,6 +3713,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/marked": { "version": "18.0.5", "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", @@ -4306,6 +4461,19 @@ "node": ">=16" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -4749,19 +4917,19 @@ } }, "node_modules/vitest": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", - "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.8", - "@vitest/mocker": "4.1.8", - "@vitest/pretty-format": "4.1.8", - "@vitest/runner": "4.1.8", - "@vitest/snapshot": "4.1.8", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -4789,12 +4957,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.8", - "@vitest/browser-preview": "4.1.8", - "@vitest/browser-webdriverio": "4.1.8", - "@vitest/coverage-istanbul": "4.1.8", - "@vitest/coverage-v8": "4.1.8", - "@vitest/ui": "4.1.8", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/frontend/package.json b/frontend/package.json index 49bf970..c7ac654 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "format:check": "prettier --check .", "check": "npm run format:check && npm run lint:strict && npm run typecheck", "test": "vitest run", + "test:coverage": "vitest run --coverage", "test:watch": "vitest" }, "dependencies": { @@ -33,6 +34,7 @@ "@types/qrcode": "^1.5.6", "@vant/auto-import-resolver": "^1.3.0", "@vitejs/plugin-vue": "^6.0.2", + "@vitest/coverage-v8": "^4.1.11", "@vue/tsconfig": "^0.8.1", "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", diff --git a/frontend/src/features/admin/components/AdminOrderMetrics.css b/frontend/src/features/admin/components/AdminOrderMetrics.css new file mode 100644 index 0000000..f82fa52 --- /dev/null +++ b/frontend/src/features/admin/components/AdminOrderMetrics.css @@ -0,0 +1,18 @@ +.order-metrics { + margin-bottom: 20px; +} + +.metric-card strong { + overflow-wrap: anywhere; + font-size: 20px; + line-height: 1.25; +} + +.metric-card small { + display: block; + margin-top: 8px; + color: #8f9bba; + font-size: 12px; + line-height: 1.4; + word-break: break-word; +} diff --git a/frontend/src/features/admin/components/AdminOrderMetrics.vue b/frontend/src/features/admin/components/AdminOrderMetrics.vue new file mode 100644 index 0000000..b3ce1e6 --- /dev/null +++ b/frontend/src/features/admin/components/AdminOrderMetrics.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/frontend/src/features/admin/components/AdminOrderPaymentRecords.css b/frontend/src/features/admin/components/AdminOrderPaymentRecords.css new file mode 100644 index 0000000..d1751d4 --- /dev/null +++ b/frontend/src/features/admin/components/AdminOrderPaymentRecords.css @@ -0,0 +1,67 @@ +.panel-heading, +.record-main { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.panel-heading { + margin-bottom: 16px; +} + +.panel-heading h2 { + margin: 0; +} + +.panel-subtitle { + color: #8f9bba; + font-size: 13px; + line-height: 1.6; +} + +.record-list, +.record-item { + display: grid; + gap: 12px; +} + +.record-item { + gap: 8px; + border: 1px solid #eef2f7; + border-radius: 8px; + background: #fbfcff; + padding: 14px; +} + +.record-main strong { + color: #1b2559; + font-size: 15px; +} + +.record-meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 6px 12px; + color: #52616f; + font-size: 12px; + line-height: 1.5; +} + +.record-item p { + margin: 0; + color: #52616f; + line-height: 1.6; + white-space: pre-wrap; +} + +.danger-text { + color: #dc2626; +} + +@media (max-width: 640px) { + .panel-heading, + .record-main { + flex-direction: column; + } +} diff --git a/frontend/src/features/admin/components/AdminOrderPaymentRecords.vue b/frontend/src/features/admin/components/AdminOrderPaymentRecords.vue new file mode 100644 index 0000000..826da4a --- /dev/null +++ b/frontend/src/features/admin/components/AdminOrderPaymentRecords.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/frontend/src/features/admin/utils/__tests__/adminOrderPresentation.spec.ts b/frontend/src/features/admin/utils/__tests__/adminOrderPresentation.spec.ts new file mode 100644 index 0000000..82ab90f --- /dev/null +++ b/frontend/src/features/admin/utils/__tests__/adminOrderPresentation.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' + +import { + estimateOrderEndAt, + paymentBizTypeLabel, + paymentPaidAt, + paymentStatusLabel, + paymentStatusType, +} from '../adminOrderPresentation' + +describe('admin order presentation', () => { + it('prefers the server-provided estimated end time', () => { + expect(estimateOrderEndAt('2026-09-03T12:00:00Z', '2026-09-02T12:00:00Z', 2)).toBe( + '2026-09-03T12:00:00Z' + ) + }) + + it('calculates the end time from rental start and duration', () => { + expect(estimateOrderEndAt(undefined, '2026-09-02T12:00:00Z', 2)).toBe( + '2026-09-02T14:00:00.000Z' + ) + expect(estimateOrderEndAt(undefined, 'invalid', 2)).toBeUndefined() + }) + + it('maps known payment states and preserves unknown channel values', () => { + expect(paymentStatusLabel('refunded')).toBe('已退款') + expect(paymentStatusType('failed')).toBe('danger') + expect(paymentBizTypeLabel('order_pay')).toBe('订单支付') + expect(paymentBizTypeLabel('future_type')).toBe('future_type') + }) + + it('selects the most meaningful payment completion time', () => { + expect( + paymentPaidAt({ + paid_at: 'paid', + updated_at: 'updated', + created_at: 'created', + }) + ).toBe('paid') + expect(paymentPaidAt({ updated_at: 'updated', created_at: 'created' })).toBe('updated') + }) +}) diff --git a/frontend/src/features/admin/utils/adminOrderPresentation.ts b/frontend/src/features/admin/utils/adminOrderPresentation.ts new file mode 100644 index 0000000..779dab4 --- /dev/null +++ b/frontend/src/features/admin/utils/adminOrderPresentation.ts @@ -0,0 +1,51 @@ +import type { AdminPayment } from '@/features/admin/api/adminPayments' + +export function estimateOrderEndAt( + estimatedEndAt?: string, + rentedAt?: string, + durationHours?: number +) { + if (estimatedEndAt) return estimatedEndAt + const hours = Number(durationHours || 0) + if (!rentedAt || hours <= 0) return undefined + const startedAt = new Date(rentedAt).getTime() + if (!Number.isFinite(startedAt)) return undefined + return new Date(startedAt + hours * 60 * 60 * 1000).toISOString() +} + +export function paymentStatusLabel(status: string) { + const labels: Record = { + created: '已创建', + paying: '支付中', + paid: '已支付', + refunding: '退款中', + refunded: '已退款', + failed: '失败', + } + return labels[status] || status +} + +export function paymentStatusType(status: string) { + if (['paid', 'refunded'].includes(status)) return 'success' as const + if (status === 'failed') return 'danger' as const + if (['paying', 'refunding'].includes(status)) return 'warning' as const + return 'info' as const +} + +export function paymentBizTypeLabel(type: string) { + const labels: Record = { + order_pay: '订单支付', + checkout_refund: '结账退款', + arbitration_refund: '仲裁退款', + admin_refund: '人工退款', + cancel_refund: '取消退款', + admin_close_refund: '客服关闭退款', + admin_seal_refund: '封存退款', + deposit_refund: '暂扣押金归还', + } + return labels[type] || type +} + +export function paymentPaidAt(record: Pick) { + return record.paid_at || record.updated_at || record.created_at +} diff --git a/frontend/src/features/admin/views/AdminChatsView.css b/frontend/src/features/admin/views/AdminChatsView.css new file mode 100644 index 0000000..9b898cd --- /dev/null +++ b/frontend/src/features/admin/views/AdminChatsView.css @@ -0,0 +1,560 @@ +.admin-page { + display: grid; + height: 100%; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); +} + +.page-head { + display: flex; + flex: none; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.page-head h1 { + margin: 0; + font-size: 22px; +} + +.page-head p { + margin: 6px 0 0; + color: #6b7280; +} + +.head-right { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.chat-workbench { + display: grid; + min-height: 0; + grid-template-columns: 330px minmax(0, 1fr); + overflow: hidden; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; +} + +.conversation-pane { + min-height: 0; + overflow-y: auto; + border-right: 1px solid #e5e7eb; + background: #f8fafc; +} + +.filter-tabs { + display: grid; + gap: 10px; + padding: 10px 12px 12px; + border-bottom: 1px solid #e5e7eb; +} + +.filter-tabs :deep(.el-radio-group) { + display: flex; + width: 100%; +} + +.filter-tabs :deep(.el-radio-button) { + flex: 1; +} + +.filter-tabs :deep(.el-radio-button__inner) { + width: 100%; + padding: 7px 8px; +} + +.stage-tabs { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.stage-tabs button { + display: inline-flex; + align-items: center; + gap: 4px; + height: 26px; + padding: 0 8px; + border: 1px solid #d8dee8; + border-radius: 999px; + background: #fff; + color: #4b5563; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.stage-tabs button.active { + border-color: #3b82f6; + background: #eff6ff; + color: #2563eb; +} + +.stage-tabs em { + color: #94a3b8; + font-style: normal; + font-weight: 800; +} + +.stage-tabs button.active em { + color: #2563eb; +} + +.conversation-search { + width: 100%; +} + +.conversation-pagination { + display: flex; + justify-content: center; + padding: 12px 8px; + border-top: 1px solid #e5e7eb; + background: #fff; +} + +.conversation-row { + position: relative; + display: block; + width: 100%; + padding: 14px; + border: 0; + border-bottom: 1px solid #e5e7eb; + background: transparent; + text-align: left; +} + +.conversation-row.active { + background: #eef6ff; +} + +.row-title { + display: flex; + gap: 10px; + align-items: center; +} + +.row-title strong { + min-width: 0; + flex: 1; + overflow: hidden; + color: #111827; + text-overflow: ellipsis; + white-space: nowrap; +} + +.row-title span { + flex: none; + color: #9ca3af; + font-size: 12px; +} + +.conversation-row p { + margin: 8px 24px 0 0; + overflow: hidden; + color: #6b7280; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.row-tags { + display: flex; + min-width: 0; + gap: 6px; + align-items: center; + margin-top: 8px; +} + +.row-tags span { + overflow: hidden; + max-width: 160px; + padding: 2px 7px; + border-radius: 999px; + background: #eef2f7; + color: #64748b; + font-size: 11px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.row-tags .stage-tag { + flex: none; + max-width: none; + font-weight: 700; +} + +.stage-tag.pending { + background: #fee2e2; + color: #dc2626; +} + +.stage-tag.unjoined { + background: #fef3c7; + color: #b45309; +} + +.stage-tag.handoff { + background: #dbeafe; + color: #2563eb; +} + +.stage-tag.renting { + background: #dcfce7; + color: #15803d; +} + +.stage-tag.after-sale { + background: #f3e8ff; + color: #7e22ce; +} + +.stage-tag.ended { + background: #e5e7eb; + color: #4b5563; +} + +.row-meta { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; +} + +.support-name { + color: #8a94a6; + font-size: 12px; +} + +.row-meta em { + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background: #ef4444; + color: #fff; + font-size: 11px; + font-style: normal; + font-weight: 700; + line-height: 18px; + text-align: center; +} + +.message-pane { + display: grid; + min-width: 0; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr) auto; +} + +.message-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid #e5e7eb; +} + +.head-title { + flex: 1; + min-width: 0; +} + +.head-title h2 { + margin: 0; + font-size: 18px; + display: flex; + align-items: center; + gap: 8px; +} + +.head-title p { + margin: 6px 0 0; + color: #6b7280; + font-size: 13px; +} + +.head-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.head-actions a { + text-decoration: none; +} + +.message-body { + display: grid; + min-width: 0; + min-height: 0; + background: #f3f6fa; +} + +.message-body.has-order { + grid-template-columns: minmax(0, 1fr) minmax(330px, 38%); +} + +.message-list { + min-height: 0; + overflow-y: auto; + padding: 18px; + background: #f3f6fa; +} + +.order-side-panel { + min-width: 0; + overflow-y: auto; + padding: 16px; + border-left: 1px solid #e5e7eb; + background: #f8fafc; +} + +.order-side-head, +.order-side-section { + padding: 14px; + border: 1px solid #edf2f7; + border-radius: 8px; + background: #fff; + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.03); +} + +.order-side-head { + display: grid; + gap: 6px; + margin-bottom: 12px; +} + +.order-side-head span { + color: #2563eb; + font-size: 12px; + font-weight: 700; +} + +.order-side-head strong { + color: #071947; + font-size: 18px; +} + +.order-side-head p, +.order-side-section p { + margin: 0; + color: #7c8aaa; + font-size: 13px; + line-height: 1.6; +} + +.order-side-metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.order-side-metrics div { + display: grid; + gap: 5px; + min-width: 0; + padding: 12px; + border: 1px solid #edf2f7; + border-radius: 8px; + background: #fff; +} + +.order-side-metrics span { + color: #8a94a6; + font-size: 12px; +} + +.order-side-metrics strong { + overflow: hidden; + color: #071947; + font-size: 16px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.order-side-section { + display: grid; + gap: 8px; +} + +.order-side-section + .order-side-section { + margin-top: 12px; +} + +.order-side-section h3 { + margin: 0; + color: #071947; + font-size: 15px; +} + +.order-side-section strong { + color: #071947; + font-size: 13px; +} + +.message-row { + max-width: 70%; + margin-bottom: 14px; +} + +.message-row.self { + margin-left: auto; + text-align: right; +} + +.message-row.system { + max-width: none; + text-align: center; +} + +.system-block { + display: inline-flex; + max-width: min(100%, 320px); + flex-direction: column; + align-items: center; + gap: 8px; +} + +.system-attachments { + display: grid; + justify-items: center; + gap: 6px; +} + +.message-row small { + display: block; + margin-bottom: 5px; + color: #8a94a6; +} + +.read-status { + margin-top: 4px; + margin-bottom: 0; + font-size: 11px; +} + +.read-status.read { + color: #67c23a; +} + +.message-row p { + display: inline-block; + margin: 0; + padding: 10px 12px; + border-radius: 8px; + background: #fff; + color: #111827; + line-height: 1.5; + text-align: left; +} + +.message-row.self p { + background: #dff5eb; +} + +.message-row.admin p { + background: #fff4e6; + border-left: 3px solid #ff9800; +} + +.admin-label { + color: #ff9800 !important; + font-weight: 600; +} + +.message-attachments { + display: grid; + justify-items: start; + gap: 6px; +} + +.message-row.self .message-attachments { + justify-items: end; +} + +.message-row.system span { + display: inline-block; + padding: 5px 10px; + border-radius: 8px; + background: #e5e7eb; + color: #6b7280; + font-size: 12px; +} + +.composer { + border-top: 1px solid #e5e7eb; +} + +.composer-tools { + display: flex; + gap: 8px; + align-items: center; + padding: 8px 14px; + border-bottom: 1px solid #f0f0f0; +} + +.hidden-file { + display: none; +} + +.pending-attachments { + display: flex; + gap: 8px; + padding: 10px 14px 0; + overflow-x: auto; +} + +.pending-item { + position: relative; + flex: none; +} + +.pending-item :deep(.chat-image-button) { + width: 84px; + height: 84px; +} + +.pending-item :deep(.chat-image-button img) { + height: 84px; +} + +.remove-attachment { + position: absolute; + top: 4px; + right: 4px; + display: grid; + width: 22px; + height: 22px; + place-items: center; + border: 0; + border-radius: 50%; + background: rgba(17, 24, 39, 0.72); + color: #fff; + cursor: pointer; +} + +.composer-input { + display: grid; + grid-template-columns: minmax(0, 1fr) 88px; + gap: 12px; + align-items: end; + padding: 14px; +} + +.reply-title { + font-weight: 500; + margin-right: 8px; +} + +.reply-preview { + color: #9ca3af; + font-size: 12px; +} diff --git a/frontend/src/features/admin/views/AdminChatsView.vue b/frontend/src/features/admin/views/AdminChatsView.vue index 79f211f..270c78b 100644 --- a/frontend/src/features/admin/views/AdminChatsView.vue +++ b/frontend/src/features/admin/views/AdminChatsView.vue @@ -990,565 +990,4 @@ function firstQueryValue(value: unknown) { - + diff --git a/frontend/src/features/admin/views/AdminDashboardView.css b/frontend/src/features/admin/views/AdminDashboardView.css new file mode 100644 index 0000000..20db491 --- /dev/null +++ b/frontend/src/features/admin/views/AdminDashboardView.css @@ -0,0 +1,882 @@ +.finance-page-container { + display: flex; + flex-direction: column; + gap: 20px; + padding: 0 4px 32px; +} + +/* ================= 头部卡片 ================= */ +.page-header-card { + background: #ffffff; + border-radius: 16px; + padding: 24px 28px; + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); + border: 1px solid rgba(226, 232, 240, 0.8); +} + +.header-main { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.title-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.eyebrow-tag { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.8px; + color: #3b82f6; + background: #eff6ff; + padding: 3px 10px; + border-radius: 20px; + width: fit-content; +} + +.page-title { + margin: 0; + font-size: 26px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.5px; +} + +.page-subtitle { + margin: 0; + font-size: 13.5px; + color: #64748b; + line-height: 1.5; +} + +.toolbar-actions { + display: flex; + align-items: center; + gap: 10px; +} + +/* ================= 状态卡片 ================= */ +.state-card { + background: #ffffff; + border-radius: 16px; + padding: 48px 24px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + border: 1px solid rgba(226, 232, 240, 0.8); +} + +.loading-state { + color: #64748b; +} + +.loading-state .el-icon { + font-size: 36px; + color: #3b82f6; +} + +.error-state { + color: #e11d48; +} + +.error-state .el-icon { + font-size: 36px; +} + +/* ================= 核心指标卡片 ================= */ +.summary-metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: 16px; +} + +.metric-card { + background: #ffffff; + border-radius: 16px; + padding: 20px 22px; + border: 1px solid rgba(226, 232, 240, 0.8); + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); + display: flex; + align-items: center; + gap: 16px; + position: relative; + overflow: hidden; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; +} + +.metric-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08); +} + +.metric-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; +} + +.metric-card--users::before { + background: linear-gradient(90deg, #6366f1, #818cf8); +} + +.metric-card--listings::before { + background: linear-gradient(90deg, #10b981, #34d399); +} + +.metric-card--orders::before { + background: linear-gradient(90deg, #f59e0b, #fbbf24); +} + +.metric-card--flow::before { + background: linear-gradient(90deg, #f43f5e, #fb7185); +} + +.metric-icon-wrap { + width: 48px; + height: 48px; + border-radius: 14px; + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + flex-shrink: 0; +} + +.metric-icon-wrap.indigo { + background: #eef2ff; + color: #6366f1; +} + +.metric-icon-wrap.emerald { + background: #ecfdf5; + color: #10b981; +} + +.metric-icon-wrap.amber { + background: #fffbeb; + color: #f59e0b; +} + +.metric-icon-wrap.rose { + background: #fff1f2; + color: #f43f5e; +} + +.metric-icon-wrap.blue { + background: #eff6ff; + color: #3b82f6; +} + +.metric-icon-wrap.teal { + background: #f0fdfa; + color: #0d9488; +} + +.metric-icon-wrap.slate { + background: #f1f5f9; + color: #64748b; +} + +.metric-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.metric-label { + font-size: 13px; + font-weight: 500; + color: #64748b; +} + +.metric-val { + font-size: 26px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.5px; + line-height: 1.2; +} + +.indigo-val { + color: #4f46e5; +} + +.emerald-val { + color: #059669; +} + +.amber-val { + color: #d97706; +} + +.rose-val { + color: #e11d48; +} + +.metric-sub-pill { + display: inline-flex; + align-items: center; + font-size: 11.5px; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; + width: fit-content; +} + +.metric-sub-pill.info { + background: #eef2ff; + color: #4f46e5; +} + +.metric-sub-pill.success { + background: #f0fdf4; + color: #16a34a; +} + +.metric-sub-pill.warning { + background: #fffbeb; + color: #d97706; +} + +.metric-sub-pill.danger { + background: #fff1f2; + color: #e11d48; +} + +/* ================= 通用 Section 卡片 ================= */ +.section-card { + background: #ffffff; + border-radius: 16px; + padding: 22px 24px; + border: 1px solid rgba(226, 232, 240, 0.8); + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); +} + +.section-header-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} + +.section-header-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 17px; + font-weight: 700; + color: #1e293b; +} + +.header-icon { + color: #3b82f6; + font-size: 20px; +} + +.header-icon.rose-icon { + color: #f43f5e; +} + +.header-badge { + font-size: 12px; + font-weight: 500; + color: #64748b; + background: #f1f5f9; + padding: 3px 10px; + border-radius: 12px; +} + +/* ================= 上下架 3 组 Mini-Bars ================= */ +.listing-daily-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 20px; +} + +.listing-stat-card { + padding: 20px; + border-radius: 14px; + background: #f8fafc; + border: 1px solid #e2e8f0; + transition: all 0.2s ease; +} + +.listing-stat-card:hover { + background: #ffffff; + box-shadow: 0 4px 16px rgba(15, 23, 42, 0.06); + border-color: #cbd5e1; +} + +.listing-stat-head { + display: flex; + align-items: center; + gap: 12px; +} + +.listing-stat-icon { + width: 40px; + height: 40px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + flex-shrink: 0; +} + +.tone-publish .listing-stat-icon { + background: #ecfdf5; + color: #10b981; +} + +.tone-offline .listing-stat-icon { + background: #fffbeb; + color: #f59e0b; +} + +.tone-trade .listing-stat-icon { + background: #eff6ff; + color: #3b82f6; +} + +.listing-stat-meta span { + display: block; + font-size: 12.5px; + font-weight: 500; + color: #64748b; +} + +.listing-stat-meta strong { + display: block; + font-size: 24px; + font-weight: 800; + color: #0f172a; + line-height: 1.1; + margin-top: 2px; +} + +.listing-stat-hint { + margin: 10px 0 16px; + font-size: 11.5px; + color: #94a3b8; + line-height: 1.4; +} + +.mini-bars { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 8px; + align-items: end; + min-height: 90px; + padding-top: 8px; + border-top: 1px dashed #e2e8f0; +} + +.mini-bar-col { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + min-width: 0; +} + +.mini-bar { + width: 100%; + max-width: 20px; + border-radius: 4px 4px 1px 1px; + min-height: 6px; + transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.mini-bar.publish { + background: linear-gradient(180deg, #34d399, #10b981); +} + +.mini-bar.offline { + background: linear-gradient(180deg, #fbbf24, #f59e0b); +} + +.mini-bar.trade { + background: linear-gradient(180deg, #60a5fa, #3b82f6); +} + +.mini-bar-col em { + font-style: normal; + font-size: 10px; + color: #94a3b8; +} + +.mini-bar-col b { + font-size: 11px; + color: #1e293b; + font-weight: 700; +} + +/* ================= 渠道总览 ================= */ +.channel-summary-title { + font-size: 14px; + font-weight: 700; + color: #334155; + margin: 16px 0 10px; +} + +.listing-channel-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; + margin-bottom: 20px; +} + +.listing-channel-item { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 14px; + transition: all 0.2s ease; +} + +.listing-channel-item:hover { + background: #ffffff; + border-color: #cbd5e1; + box-shadow: 0 2px 10px rgba(15, 23, 42, 0.05); +} + +.listing-channel-item-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} + +.channel-name { + font-size: 13.5px; + font-weight: 700; + color: #1e293b; +} + +.channel-total { + font-size: 18px; + font-weight: 800; + color: #3b82f6; +} + +.listing-channel-counts { + display: flex; + flex-direction: column; + gap: 4px; +} + +.count-tag { + font-size: 11.5px; + font-weight: 600; +} + +.count-tag.publish { + color: #059669; +} + +.count-tag.offline { + color: #d97706; +} + +.count-tag.trade { + color: #2563eb; +} + +.trend-table-wrap { + margin-bottom: 20px; +} + +.listing-channel-block { + margin-top: 20px; +} + +.listing-uploader-block { + margin-top: 20px; +} + +.listing-uploader-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 20px; +} + +.listing-uploader-item { + display: flex; + align-items: baseline; + gap: 8px; + border: 1px solid #dbeafe; + border-radius: 10px; + background: #eff6ff; + padding: 14px; +} + +.uploader-name { + overflow: hidden; + color: #1e3a8a; + font-size: 13.5px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.uploader-total { + margin-left: auto; + color: #2563eb; + font-size: 20px; + font-weight: 800; +} + +.uploader-unit { + color: #64748b; + font-size: 11.5px; + white-space: nowrap; +} + +.channel-badge { + font-weight: 600; +} + +.trend-val { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-weight: 700; + font-size: 12.5px; +} + +.text-emerald { + color: #059669; +} +.text-amber { + color: #d97706; +} +.text-blue { + color: #2563eb; +} +.text-slate { + color: #475569; +} + +/* ================= 待处理事项 & 快捷入口 ================= */ +.dashboard-panels-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; +} + +.panel-card { + display: flex; + flex-direction: column; +} + +.panel-header-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.panel-header-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 16px; + font-weight: 700; + color: #1e293b; +} + +.panel-icon { + font-size: 18px; +} + +.amber-icon { + color: #f59e0b; +} + +.blue-icon { + color: #3b82f6; +} + +.pending-total-badge { + font-size: 12px; + font-weight: 600; + color: #64748b; + background: #f1f5f9; + padding: 3px 10px; + border-radius: 20px; +} + +.pending-total-badge.has-pending { + color: #e11d48; + background: #fff1f2; +} + +.pending-list-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.pending-item-card { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 12px; + text-decoration: none; + transition: all 0.2s ease; +} + +.pending-item-card:hover:not(.static-card) { + background: #ffffff; + border-color: #cbd5e1; + box-shadow: 0 2px 10px rgba(15, 23, 42, 0.05); + transform: translateX(2px); +} + +.pending-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.pending-label { + font-size: 13.5px; + font-weight: 700; + color: #1e293b; +} + +.pending-desc { + font-size: 11.5px; + color: #94a3b8; +} + +.pending-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + height: 28px; + padding: 0 8px; + border-radius: 14px; + font-size: 13px; + font-weight: 800; +} + +.badge-urgent { + background: #fff1f2; + color: #e11d48; + border: 1px solid #fecdd3; +} + +.badge-zero { + background: #f1f5f9; + color: #94a3b8; +} + +/* ================= 快捷入口 ================= */ +.quick-nav-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} + +.quick-tile { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 18px 12px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 14px; + text-decoration: none; + transition: all 0.2s ease; +} + +.quick-tile:hover { + background: #ffffff; + border-color: #cbd5e1; + box-shadow: 0 4px 16px rgba(15, 23, 42, 0.06); + transform: translateY(-2px); +} + +.quick-icon-wrap { + width: 44px; + height: 44px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; +} + +.quick-icon-wrap.indigo { + background: #eef2ff; + color: #6366f1; +} + +.quick-icon-wrap.blue { + background: #eff6ff; + color: #3b82f6; +} + +.quick-icon-wrap.rose { + background: #fff1f2; + color: #f43f5e; +} + +.quick-icon-wrap.emerald { + background: #ecfdf5; + color: #10b981; +} + +.quick-icon-wrap.teal { + background: #f0fdfa; + color: #0d9488; +} + +.quick-icon-wrap.slate { + background: #f1f5f9; + color: #64748b; +} + +.quick-title { + font-size: 12.5px; + font-weight: 700; + color: #334155; +} + +/* ================= 表格容器与通用组件 ================= */ +.table-section-card { + padding-bottom: 16px; +} + +.table-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.table-header-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 16px; + font-weight: 700; + color: #1e293b; +} + +.view-all-link { + font-size: 12.5px; + font-weight: 600; + color: #3b82f6; + text-decoration: none; +} + +.view-all-link:hover { + text-decoration: underline; +} + +.table-container { + border-radius: 12px; + overflow: hidden; + border: 1px solid #e2e8f0; +} + +.custom-enterprise-table :deep(.el-table__header-wrapper th) { + background-color: #f8fafc !important; + color: #475569; + font-size: 13px; + font-weight: 600; + padding: 12px 0; + border-bottom: 1px solid #e2e8f0; +} + +.custom-enterprise-table :deep(.el-table__body-wrapper td) { + padding: 12px 0; + font-size: 13px; + color: #334155; +} + +.order-link { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12.5px; + font-weight: 600; + color: #3b82f6; + text-decoration: none; +} + +.order-link:hover { + text-decoration: underline; +} + +.mono-code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + color: #64748b; +} + +.status-tag { + font-weight: 600; +} + +.amount-val { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 13.5px; + font-weight: 700; + color: #0f172a; +} + +.time-cell { + font-size: 12px; + color: #64748b; +} + +.dispute-type { + font-size: 12.5px; + color: #475569; +} + +@media (max-width: 1100px) { + .listing-daily-grid { + grid-template-columns: 1fr; + } + + .dashboard-panels-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .quick-nav-grid { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/frontend/src/features/admin/views/AdminDashboardView.vue b/frontend/src/features/admin/views/AdminDashboardView.vue index dd1339d..38f1d46 100644 --- a/frontend/src/features/admin/views/AdminDashboardView.vue +++ b/frontend/src/features/admin/views/AdminDashboardView.vue @@ -674,887 +674,4 @@ function shortDate(date: string) { - + diff --git a/frontend/src/features/admin/views/AdminDisputesView.css b/frontend/src/features/admin/views/AdminDisputesView.css new file mode 100644 index 0000000..6348763 --- /dev/null +++ b/frontend/src/features/admin/views/AdminDisputesView.css @@ -0,0 +1,263 @@ +.dispute-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 14px; +} + +.summary-item { + display: grid; + gap: 6px; + padding: 14px 16px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.summary-item span { + color: #64748b; + font-size: 13px; +} + +.summary-item strong { + color: #0f172a; + font-size: 24px; + line-height: 1; +} + +.filter-panel { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; + padding: 12px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.filter-control { + width: 180px; +} + +.keyword-filter { + width: 280px; +} + +.arbitration-field { + display: grid; + gap: 6px; + margin-top: 12px; +} + +.arbitration-field span { + color: #475569; + font-size: 13px; + font-weight: 700; +} + +.main-cell, +.party-cell { + display: grid; + gap: 4px; +} + +.main-cell strong { + color: #0f172a; + font-weight: 700; +} + +.main-cell span, +.party-cell span { + color: #64748b; + font-size: 12px; + line-height: 1.4; +} + +.dispute-table :deep(.el-table__cell) { + vertical-align: top; +} + +.evidence-dialog { + display: grid; + gap: 14px; +} + +.evidence-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.evidence-header div { + display: grid; + gap: 5px; +} + +.evidence-header strong { + color: #0f172a; + font-size: 17px; +} + +.evidence-header span, +.evidence-meta-grid span, +.evidence-description span { + color: #64748b; + font-size: 12px; + font-weight: 700; +} + +.evidence-meta-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.evidence-meta-grid > div { + display: grid; + gap: 5px; + min-width: 0; + padding: 10px 12px; + background: #f8fafc; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.evidence-meta-grid strong { + min-width: 0; + overflow: hidden; + color: #1f2937; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.evidence-description { + display: grid; + gap: 6px; + padding: 12px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.evidence-description p { + margin: 0; + color: #334155; + font-size: 14px; + line-height: 1.6; + white-space: pre-wrap; +} + +.evidence-list { + display: grid; + gap: 12px; + max-height: 420px; + padding-right: 4px; + overflow: auto; +} + +.evidence-card { + display: grid; + grid-template-columns: 132px minmax(0, 1fr); + gap: 14px; + padding: 12px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #ffffff; +} + +.evidence-preview { + display: flex; + align-items: center; + justify-content: center; + min-height: 96px; + overflow: hidden; + background: #f8fafc; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.evidence-file-icon { + display: flex; + align-items: center; + justify-content: center; + width: 58px; + height: 58px; + border-radius: 8px; + background: #eef2ff; + color: #4f46e5; + font-weight: 800; +} + +.evidence-card :deep(.evidence-image) { + display: block; + width: 100%; + height: 96px; + object-fit: cover; +} + +.evidence-card :deep(.evidence-image-fallback) { + min-height: 96px; + border: 0; +} + +.evidence-info { + display: grid; + align-content: start; + gap: 8px; + min-width: 0; +} + +.evidence-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.evidence-title-row strong { + min-width: 0; + overflow: hidden; + color: #0f172a; + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.evidence-info p { + margin: 0; + color: #64748b; + font-size: 12px; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.evidence-actions { + display: flex; + gap: 8px; +} + +@media (max-width: 960px) { + .dispute-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .filter-panel { + align-items: stretch; + flex-direction: column; + } + + .filter-control { + width: 100%; + } + + .evidence-meta-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .evidence-card { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/features/admin/views/AdminDisputesView.vue b/frontend/src/features/admin/views/AdminDisputesView.vue index 11d5d86..f1b71e0 100644 --- a/frontend/src/features/admin/views/AdminDisputesView.vue +++ b/frontend/src/features/admin/views/AdminDisputesView.vue @@ -676,268 +676,4 @@ async function handleArbitrate() { - + diff --git a/frontend/src/features/admin/views/AdminFinanceDashboardView.css b/frontend/src/features/admin/views/AdminFinanceDashboardView.css new file mode 100644 index 0000000..b973f55 --- /dev/null +++ b/frontend/src/features/admin/views/AdminFinanceDashboardView.css @@ -0,0 +1,662 @@ +.finance-dashboard-page { + padding: 0 4px 32px; + display: flex; + flex-direction: column; + gap: 24px; +} + +/* ================= 头部 Header & 筛选栏 ================= */ +.dashboard-header { + background: #ffffff; + border-radius: 16px; + padding: 24px 28px; + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); + border: 1px solid rgba(226, 232, 240, 0.8); + display: flex; + flex-direction: column; + gap: 20px; +} + +.header-main { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.title-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.eyebrow-tag { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.8px; + color: #4f46e5; + background: #eef2ff; + padding: 3px 10px; + border-radius: 20px; + width: fit-content; +} + +.page-title { + margin: 0; + font-size: 26px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.5px; +} + +.page-subtitle { + margin: 0; + font-size: 13.5px; + color: #64748b; + line-height: 1.5; +} + +.header-meta { + display: flex; + align-items: center; +} + +.meta-item { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12.5px; + color: #64748b; + background: #f8fafc; + padding: 6px 12px; + border-radius: 8px; + border: 1px solid #e2e8f0; +} + +.filter-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + padding-top: 16px; + border-top: 1px solid #f1f5f9; +} + +.preset-buttons { + display: inline-flex; + background: #f1f5f9; + padding: 3px; + border-radius: 10px; + gap: 2px; +} + +.preset-btn { + border: none; + background: transparent; + padding: 6px 14px; + font-size: 13px; + font-weight: 500; + color: #64748b; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; +} + +.preset-btn:hover { + color: #1e293b; +} + +.preset-btn.active { + background: #ffffff; + color: #4f46e5; + font-weight: 600; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.date-picker-group { + display: flex; + align-items: center; + gap: 8px; +} + +.date-input { + width: 140px; +} + +.date-separator { + color: #94a3b8; + font-size: 13px; +} + +.action-buttons { + display: flex; + align-items: center; + gap: 10px; +} + +/* ================= 核心 Hero KPI 卡片 ================= */ +.hero-kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; +} + +.hero-card { + background: #ffffff; + border-radius: 16px; + padding: 20px 22px; + border: 1px solid rgba(226, 232, 240, 0.8); + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); + display: flex; + flex-direction: column; + justify-content: space-between; + position: relative; + overflow: hidden; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; +} + +.hero-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08); +} + +.hero-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; +} + +.hero-card--flow::before { + background: linear-gradient(90deg, #3b82f6, #60a5fa); +} + +.hero-card--profit::before { + background: linear-gradient(90deg, #10b981, #34d399); +} + +.hero-card--net::before { + background: linear-gradient(90deg, #06b6d4, #22d3ee); +} + +.hero-card--outflow::before { + background: linear-gradient(90deg, #8b5cf6, #a78bfa); +} + +.hero-card--risk::before { + background: linear-gradient(90deg, #f59e0b, #fbbf24); +} + +.hero-card-header { + display: flex; + align-items: center; + gap: 10px; +} + +.hero-icon-box { + width: 36px; + height: 36px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; +} + +.hero-card--flow .hero-icon-box { + background: #eff6ff; + color: #3b82f6; +} + +.hero-card--profit .hero-icon-box { + background: #ecfdf5; + color: #10b981; +} + +.hero-card--net .hero-icon-box { + background: #ecfeff; + color: #06b6d4; +} + +.hero-card--outflow .hero-icon-box { + background: #f5f3ff; + color: #8b5cf6; +} + +.hero-card--risk .hero-icon-box { + background: #fffbeb; + color: #f59e0b; +} + +.hero-label { + font-size: 13.5px; + font-weight: 600; + color: #64748b; +} + +.hero-value { + font-size: 28px; + font-weight: 800; + color: #0f172a; + margin: 12px 0 10px; + letter-spacing: -0.5px; + line-height: 1.2; +} + +.diff-value.danger { + color: #ef4444; +} + +.diff-value.success { + color: #10b981; +} + +.hero-footer { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + font-size: 12px; +} + +.hero-pill { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 6px; + font-weight: 600; + font-size: 11.5px; +} + +.hero-pill.success { + background: #f0fdf4; + color: #15803d; +} + +.hero-pill.emerald { + background: #ecfdf5; + color: #047857; +} + +.hero-pill.teal { + background: #f0fdfa; + color: #0f766e; +} + +.hero-pill.purple { + background: #f5f3ff; + color: #6d28d9; +} + +.hero-pill.warning { + background: #fffbeb; + color: #b45309; +} + +.hero-pill.danger { + background: #fef2f2; + color: #b91c1c; +} + +.hero-pill.neutral { + background: #f1f5f9; + color: #475569; +} + +.hero-note { + color: #94a3b8; + font-size: 11.5px; +} + +/* ================= 模块化 Section 卡片 ================= */ +.dashboard-sections { + display: flex; + flex-direction: column; + gap: 24px; +} + +.section-container { + background: #ffffff; + border-radius: 16px; + padding: 24px; + border: 1px solid rgba(226, 232, 240, 0.8); + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} + +.section-title-wrap { + display: flex; + align-items: center; + gap: 12px; +} + +.section-badge { + width: 36px; + height: 36px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; +} + +.section-badge.primary { + background: #eff6ff; + color: #3b82f6; +} + +.section-badge.teal { + background: #f0fdfa; + color: #0d9488; +} + +.section-badge.blue { + background: #eff6ff; + color: #2563eb; +} + +.section-badge.purple { + background: #f5f3ff; + color: #7c3aed; +} + +.section-badge.indigo { + background: #eef2ff; + color: #4f46e5; +} + +.section-title { + margin: 0; + font-size: 17px; + font-weight: 700; + color: #1e293b; +} + +.section-desc { + margin: 3px 0 0; + font-size: 12.5px; + color: #64748b; +} + +/* 子指标网格 */ +.sub-metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 14px; +} + +.sub-metric-grid.cols-2 { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.sub-metric-grid.cols-3 { + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); +} + +.sub-card { + background: #f8fafc; + border-radius: 12px; + padding: 16px 18px; + border: 1px solid #f1f5f9; + display: flex; + flex-direction: column; + gap: 6px; + transition: all 0.2s ease; +} + +.sub-card:hover { + background: #f1f5f9; + border-color: #e2e8f0; +} + +.sub-card.highlight-pending { + background: #fffbeb; + border-color: #fef3c7; +} + +.sub-card-label { + font-size: 12.5px; + font-weight: 500; + color: #64748b; +} + +.sub-card-value { + font-size: 20px; + font-weight: 700; + color: #0f172a; + letter-spacing: -0.3px; +} + +.sub-card-sub { + font-size: 11.5px; + color: #64748b; + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.dot-sep { + color: #cbd5e1; +} + +.danger-text { + color: #ef4444; +} + +.warning-text { + color: #d97706; +} + +.success-text { + color: #10b981; +} + +.emerald-text { + color: #059669; +} + +.orange-text { + color: #ea580c; +} + +.primary-text { + color: #3b82f6; +} + +/* 出款区域两栏布局 */ +.disbursement-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; +} + +@media (max-width: 1024px) { + .disbursement-layout { + grid-template-columns: 1fr; + } +} + +.disbursement-subgroup { + display: flex; + flex-direction: column; + gap: 12px; +} + +.subgroup-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 13.5px; + font-weight: 600; + color: #334155; +} + +.status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.success-dot { + background: #10b981; +} + +.warning-dot { + background: #f59e0b; +} + +/* 专项分部卡片 */ +.segments-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; +} + +@media (max-width: 1024px) { + .segments-row { + grid-template-columns: 1fr; + } +} + +/* ================= 每日明细表格美化 ================= */ +.table-container { + padding-bottom: 16px; +} + +.table-section-header { + margin-bottom: 16px; +} + +.record-badge { + font-size: 12px; + font-weight: 600; + color: #4f46e5; + background: #eef2ff; + padding: 4px 12px; + border-radius: 20px; +} + +.table-wrapper { + border-radius: 12px; + overflow: hidden; + border: 1px solid #e2e8f0; +} + +.custom-finance-table :deep(.el-table__header-wrapper th) { + background-color: #f8fafc !important; + color: #475569; + font-size: 13px; + font-weight: 600; + padding: 12px 0; + border-bottom: 1px solid #e2e8f0; +} + +.custom-finance-table :deep(.el-table__body-wrapper td) { + padding: 14px 0; + font-size: 13px; + color: #334155; +} + +.date-cell { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 600; + color: #1e293b; +} + +.amount-cell { + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.profit-cell { + color: #059669; + font-weight: 700; +} + +.flow-cell { + color: #1d4ed8; +} + +.refund-cell { + color: #dc2626; +} + +.outflow-cell { + color: #7c3aed; +} + +.expense-cell { + color: #d97706; +} + +.count-pill { + display: inline-block; + min-width: 24px; + padding: 2px 8px; + border-radius: 12px; + background: #f1f5f9; + color: #475569; + font-size: 12px; + font-weight: 600; +} + +.count-pill.primary-pill { + background: #eef2ff; + color: #4f46e5; +} + +.refund-cancel-tag-group { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.mini-tag { + display: inline-block; + padding: 2px 6px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; +} + +.mini-tag.refund-tag { + background: #fef2f2; + color: #dc2626; + border: 1px solid #fee2e2; +} + +.mini-tag.cancel-tag { + background: #fffbeb; + color: #b45309; + border: 1px solid #fef3c7; +} + +.mini-tag.muted-tag { + background: #f8fafc; + color: #94a3b8; +} diff --git a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue index 166178d..488b47b 100644 --- a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue +++ b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue @@ -743,667 +743,4 @@ function diffType(value: number | undefined | null) { - + diff --git a/frontend/src/features/admin/views/AdminFinanceDisbursementsView.css b/frontend/src/features/admin/views/AdminFinanceDisbursementsView.css new file mode 100644 index 0000000..a3962ec --- /dev/null +++ b/frontend/src/features/admin/views/AdminFinanceDisbursementsView.css @@ -0,0 +1,545 @@ +.finance-page-container { + display: flex; + flex-direction: column; + gap: 20px; + padding: 0 4px 32px; +} + +/* ================= 头部卡片 ================= */ +.page-header-card { + background: #ffffff; + border-radius: 16px; + padding: 24px 28px; + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); + border: 1px solid rgba(226, 232, 240, 0.8); +} + +.header-main { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.title-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.eyebrow-tag { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.8px; + color: #4f46e5; + background: #eef2ff; + padding: 3px 10px; + border-radius: 20px; + width: fit-content; +} + +.page-title { + margin: 0; + font-size: 26px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.5px; +} + +.page-subtitle { + margin: 0; + font-size: 13.5px; + color: #64748b; + line-height: 1.5; +} + +.toolbar-actions { + display: flex; + align-items: center; + gap: 10px; +} + +/* ================= 核心指标卡片 ================= */ +.summary-metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; +} + +.metric-card { + background: #ffffff; + border-radius: 16px; + padding: 18px 20px; + border: 1px solid rgba(226, 232, 240, 0.8); + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); + display: flex; + align-items: center; + gap: 16px; + position: relative; + overflow: hidden; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; +} + +.metric-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08); +} + +.metric-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; +} + +.metric-card--paid::before { + background: linear-gradient(90deg, #10b981, #34d399); +} + +.metric-card--pending::before { + background: linear-gradient(90deg, #f59e0b, #fbbf24); +} + +.metric-card--review::before { + background: linear-gradient(90deg, #6366f1, #818cf8); +} + +.metric-card--fee::before { + background: linear-gradient(90deg, #06b6d4, #22d3ee); +} + +.metric-card--total::before { + background: linear-gradient(90deg, #64748b, #94a3b8); +} + +.metric-icon-wrap { + width: 44px; + height: 44px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; +} + +.metric-icon-wrap.emerald { + background: #ecfdf5; + color: #10b981; +} + +.metric-icon-wrap.amber { + background: #fffbeb; + color: #f59e0b; +} + +.metric-icon-wrap.indigo { + background: #eef2ff; + color: #6366f1; +} + +.metric-icon-wrap.teal { + background: #ecfeff; + color: #06b6d4; +} + +.metric-icon-wrap.slate { + background: #f1f5f9; + color: #64748b; +} + +.metric-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.metric-label { + font-size: 12.5px; + font-weight: 500; + color: #64748b; +} + +.metric-val { + font-size: 22px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.3px; + line-height: 1.2; +} + +.emerald-val { + color: #059669; +} + +.amber-val { + color: #d97706; +} + +.indigo-val { + color: #4f46e5; +} + +.teal-val { + color: #0891b2; +} + +.metric-sub-pill { + display: inline-flex; + align-items: center; + font-size: 11px; + font-weight: 600; + padding: 1px 6px; + border-radius: 4px; + width: fit-content; +} + +.metric-sub-pill.success { + background: #f0fdf4; + color: #16a34a; +} + +.metric-sub-pill.warning { + background: #fffbeb; + color: #d97706; +} + +.metric-sub-pill.info { + background: #eef2ff; + color: #4f46e5; +} + +.metric-sub-text { + font-size: 11.5px; + color: #94a3b8; +} + +/* ================= 筛选面板 ================= */ +.section-card { + background: #ffffff; + border-radius: 16px; + padding: 22px 24px; + border: 1px solid rgba(226, 232, 240, 0.8); + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); +} + +.filter-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 14px 16px; +} + +.filter-grid :deep(.el-form-item) { + margin-bottom: 0; +} + +.filter-grid :deep(.el-form-item__label) { + padding-bottom: 4px; + font-size: 12px; + font-weight: 600; + color: #64748b; +} + +.full-control { + width: 100%; +} + +/* ================= 表格卡片 ================= */ +.table-section-card { + padding-bottom: 16px; +} + +.table-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.table-header-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 16px; + font-weight: 700; + color: #1e293b; +} + +.header-icon { + color: #4f46e5; + font-size: 18px; +} + +.count-badge { + font-size: 12px; + font-weight: 600; + color: #4f46e5; + background: #eef2ff; + padding: 3px 10px; + border-radius: 20px; +} + +.table-container { + border-radius: 12px; + overflow: hidden; + border: 1px solid #e2e8f0; +} + +.custom-enterprise-table :deep(.el-table__header-wrapper th) { + background-color: #f8fafc !important; + color: #475569; + font-size: 13px; + font-weight: 600; + padding: 12px 0; + border-bottom: 1px solid #e2e8f0; +} + +.custom-enterprise-table :deep(.el-table__body-wrapper td) { + padding: 12px 0; + font-size: 13px; + color: #334155; +} + +.business-cell { + display: flex; + flex-direction: column; + gap: 4px; +} + +.source-tag { + width: fit-content; + font-weight: 600; + font-size: 11px; +} + +.business-no { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + color: #1e293b; +} + +.link-no { + color: #3b82f6; + text-decoration: none; + font-weight: 600; +} + +.link-no:hover { + text-decoration: underline; +} + +.stack-cell { + display: flex; + flex-direction: column; + gap: 2px; + line-height: 1.35; +} + +.payee-name { + font-weight: 600; + color: #0f172a; +} + +.phone-text { + font-size: 11.5px; + color: #64748b; +} + +.muted-note { + font-size: 11.5px; + color: #94a3b8; +} + +.channel-note { + font-size: 11px; + color: #64748b; +} + +.account-line { + display: flex; + align-items: center; + gap: 4px; + font-weight: 500; + font-size: 12.5px; +} + +.account-type-badge { + font-size: 10.5px; + padding: 1px 4px; + border-radius: 4px; + background: #f1f5f9; + color: #475569; +} + +.account-num { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11.5px; + color: #64748b; +} + +.bank-name { + font-size: 11.5px; + color: #94a3b8; +} + +.category-tag { + width: fit-content; + margin-bottom: 2px; +} + +.voucher-uploaded { + font-size: 11px; + color: #10b981; +} + +.voucher-missing { + font-size: 11px; + color: #94a3b8; +} + +.amount-stack { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; +} + +.actual-amount { + font-size: 14.5px; + font-weight: 700; + color: #0f172a; +} + +.fee-detail { + font-size: 11px; + color: #94a3b8; +} + +.status-pill { + font-weight: 600; +} + +.time-cell { + font-size: 12px; + color: #475569; +} + +.empty-time { + color: #cbd5e1; +} + +.operator-name { + font-size: 12.5px; + color: #475569; +} + +.remark-text { + font-size: 12px; + color: #64748b; +} + +.action-cell { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +/* ================= 弹窗样式 ================= */ +.voucher-upload-box { + display: flex; + align-items: center; + gap: 16px; +} + +.voucher-preview-wrap { + width: 60px; + height: 60px; + border-radius: 8px; + overflow: hidden; + border: 1px solid #e2e8f0; +} + +.voucher-img-preview { + width: 100%; + height: 100%; + object-fit: cover; +} + +.detail-content-wrap { + display: flex; + flex-direction: column; + gap: 12px; + padding: 4px 0; +} + +.detail-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13.5px; + border-bottom: 1px dashed #f1f5f9; + padding-bottom: 8px; +} + +.detail-row.column-row { + flex-direction: column; + align-items: flex-start; + gap: 8px; +} + +.detail-label { + color: #64748b; +} + +.detail-val { + color: #0f172a; +} + +.detail-img-container { + max-width: 100%; + max-height: 240px; + border-radius: 8px; + overflow: hidden; + border: 1px solid #e2e8f0; +} + +.detail-voucher-img { + max-width: 100%; + max-height: 240px; + object-fit: contain; +} + +.void-info-box { + background: #fef2f2; + border-radius: 8px; + padding: 12px 14px; + border: 1px solid #fee2e2; + color: #b91c1c; + font-size: 12.5px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.void-title { + display: flex; + align-items: center; + gap: 6px; + font-weight: 700; + margin-bottom: 2px; +} diff --git a/frontend/src/features/admin/views/AdminFinanceDisbursementsView.vue b/frontend/src/features/admin/views/AdminFinanceDisbursementsView.vue index 732b6e2..1c4e3d5 100644 --- a/frontend/src/features/admin/views/AdminFinanceDisbursementsView.vue +++ b/frontend/src/features/admin/views/AdminFinanceDisbursementsView.vue @@ -849,550 +849,4 @@ function accountTypeLabel(type: string) { - + diff --git a/frontend/src/features/admin/views/AdminListingDetailView.css b/frontend/src/features/admin/views/AdminListingDetailView.css new file mode 100644 index 0000000..3ddcc46 --- /dev/null +++ b/frontend/src/features/admin/views/AdminListingDetailView.css @@ -0,0 +1,489 @@ +.listing-detail-header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 20px; + margin-bottom: 18px; +} + +.listing-title-block { + min-width: 0; +} + +.listing-code-line { + display: inline-flex; + align-items: center; + gap: 8px; + color: #64748b; + font-size: 13px; + font-weight: 700; +} + +.listing-code-line strong { + color: #2563eb; + letter-spacing: 0; +} + +.listing-title-block h1 { + margin: 8px 0 10px; + color: #0f172a; + font-size: 30px; + line-height: 1.15; +} + +.listing-subtitle { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.listing-subtitle span { + padding: 4px 9px; + border: 1px solid #dbe3ef; + border-radius: 8px; + background: #fff; + color: #64748b; + font-size: 13px; +} + +.listing-summary-bar { + display: grid; + grid-template-columns: repeat(6, minmax(120px, 1fr)); + gap: 1px; + overflow: hidden; + margin-bottom: 16px; + border: 1px solid #e5e7eb; + border-radius: 10px; + background: #e5e7eb; +} + +.listing-summary-bar > div { + display: grid; + min-height: 74px; + align-content: center; + gap: 8px; + padding: 12px 14px; + background: #fff; +} + +.listing-summary-bar span { + color: #64748b; + font-size: 13px; + font-weight: 700; +} + +.listing-summary-bar strong { + color: #0f172a; + font-size: 21px; + line-height: 1.1; +} + +.listing-workspace { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(360px, 0.7fr); + gap: 16px; + align-items: start; +} + +.detail-panel { + min-width: 0; + border: 1px solid #e5e7eb; + border-radius: 10px; + background: #fff; + padding: 16px; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04); +} + +.screenshots-panel { + grid-column: 1 / -1; +} + +.panel-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + padding-bottom: 10px; + border-bottom: 1px solid #eef2f7; +} + +.panel-heading h2 { + margin: 0; + color: #0f172a; + font-size: 16px; + font-weight: 800; +} + +.panel-heading span { + overflow: hidden; + color: #94a3b8; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.compact-detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1px; + overflow: hidden; + margin: 0; + border: 1px solid #eef2f7; + border-radius: 8px; + background: #eef2f7; +} + +.compact-detail-grid.single { + grid-template-columns: 1fr; +} + +.compact-detail-grid div { + display: grid; + grid-template-columns: 88px minmax(0, 1fr); + gap: 10px; + align-items: start; + min-height: 38px; + padding: 10px 12px; + background: #fff; +} + +.compact-detail-grid dt { + color: #64748b; + font-size: 13px; + font-weight: 700; +} + +.compact-detail-grid dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + color: #0f172a; + font-size: 13px; + font-weight: 700; +} + +.resource-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 10px; +} + +.resource-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 12px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f8fafc; +} + +.resource-item span { + color: #64748b; + font-size: 13px; + font-weight: 700; +} + +.resource-item strong { + color: #0f172a; + font-size: 18px; +} + +.skin-chip-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.screenshot-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); + gap: 12px; +} + +.screenshot-thumb { + display: grid; + gap: 8px; + padding: 8px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; + color: #64748b; + cursor: pointer; + text-align: left; +} + +.screenshot-thumb:hover { + border-color: #93c5fd; + background: #eff6ff; +} + +.screenshot-thumb img { + width: 100%; + aspect-ratio: 4 / 3; + border-radius: 6px; + background: #f1f5f9; + object-fit: cover; +} + +.screenshot-image { + width: 100%; + aspect-ratio: 4 / 3; + border-radius: 6px; +} + +.screenshot-thumb span { + color: #334155; + font-size: 13px; + font-weight: 700; +} + +.empty-line { + margin: 0; + color: #94a3b8; + font-size: 13px; +} + +.transfer-owner-trigger { + min-width: 120px; + font-weight: 700; +} + +.external-adjust-form { + display: grid; + gap: 4px; +} + +:global(.external-adjust-dialog) { + max-width: calc(100vw - 32px); +} + +:global(.external-adjust-dialog .el-dialog__body) { + max-height: calc(100vh - 180px); + overflow-y: auto; +} + +.external-adjust-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 18px; +} + +.external-adjust-grid :deep(.el-input-number), +.external-adjust-form :deep(.el-input-number) { + width: 100%; +} + +.external-adjust-preview { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 1px; + overflow: hidden; + margin: 4px 0 18px 112px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #e5e7eb; +} + +.external-adjust-preview > div { + display: grid; + min-height: 70px; + align-content: center; + gap: 7px; + padding: 10px; + background: #f8fafc; +} + +.external-adjust-preview span { + color: #64748b; + font-size: 12px; + font-weight: 700; +} + +.external-adjust-preview strong { + color: #0f172a; + font-size: 16px; +} + +.transfer-dialog { + display: grid; + gap: 18px; +} + +.transfer-route { + display: grid; + grid-template-columns: minmax(0, 1fr) 32px minmax(0, 1fr); + align-items: stretch; + gap: 12px; + padding: 14px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f8fafc; +} + +.transfer-owner-card { + display: flex; + min-width: 0; + flex-direction: column; + justify-content: center; + gap: 6px; + padding: 12px 14px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; + color: #64748b; +} + +.transfer-owner-card.target { + border-color: #86efac; + background: #f0fdf4; +} + +.transfer-owner-card.target.empty { + border-color: #bfdbfe; + background: #eff6ff; +} + +.transfer-owner-card span { + font-size: 13px; +} + +.transfer-owner-card strong { + overflow: hidden; + color: #0f172a; + font-size: 18px; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.transfer-owner-card small { + overflow: hidden; + color: #94a3b8; + text-overflow: ellipsis; + white-space: nowrap; +} + +.transfer-arrow { + display: grid; + place-items: center; + color: #2563eb; + font-size: 22px; + font-weight: 800; +} + +.transfer-section { + display: grid; + gap: 8px; +} + +.transfer-section label { + color: #334155; + font-size: 14px; + font-weight: 700; +} + +.transfer-user-list { + min-height: 138px; + max-height: 280px; + overflow: auto; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; +} + +.transfer-user { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 13px 14px; + border: 0; + border-bottom: 1px solid #eef2f7; + background: #fff; + color: #334155; + text-align: left; + cursor: pointer; +} + +.transfer-user:last-child { + border-bottom: 0; +} + +.transfer-user:hover, +.transfer-user.is-selected { + background: #eff6ff; +} + +.transfer-user.is-selected { + box-shadow: inset 3px 0 0 #2563eb; +} + +.transfer-user-main { + min-width: 0; +} + +.transfer-user strong { + display: block; + overflow: hidden; + color: #0f172a; + text-overflow: ellipsis; + white-space: nowrap; +} + +.transfer-user small { + display: block; + margin-top: 4px; + overflow: hidden; + color: #94a3b8; + text-overflow: ellipsis; + white-space: nowrap; +} + +.transfer-user-meta { + display: inline-flex; + flex-shrink: 0; + align-items: center; + gap: 8px; +} + +@media (max-width: 760px) { + .listing-detail-header, + .listing-workspace { + grid-template-columns: 1fr; + } + + .listing-summary-bar { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .compact-detail-grid { + grid-template-columns: 1fr; + } + + .transfer-route { + grid-template-columns: 1fr; + } + + .external-adjust-grid, + .external-adjust-preview { + grid-template-columns: 1fr; + } + + .external-adjust-preview { + margin-left: 0; + } + + .transfer-arrow { + transform: rotate(90deg); + } + + .transfer-user { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/frontend/src/features/admin/views/AdminListingDetailView.vue b/frontend/src/features/admin/views/AdminListingDetailView.vue index e98b51b..63ca82f 100644 --- a/frontend/src/features/admin/views/AdminListingDetailView.vue +++ b/frontend/src/features/admin/views/AdminListingDetailView.vue @@ -1027,494 +1027,4 @@ async function openScreenshot(url: string) { - + diff --git a/frontend/src/features/admin/views/AdminListingReviewView.css b/frontend/src/features/admin/views/AdminListingReviewView.css new file mode 100644 index 0000000..20599a7 --- /dev/null +++ b/frontend/src/features/admin/views/AdminListingReviewView.css @@ -0,0 +1,590 @@ +.review-page { + min-height: calc(100vh - 128px); +} + +.review-header { + margin-bottom: 18px; +} + +.review-summary { + display: grid; + grid-template-columns: repeat(4, minmax(120px, 1fr)); + gap: 12px; + margin-bottom: 14px; +} + +.summary-card { + min-width: 0; + border: 1px solid #e8edf4; + border-radius: 8px; + background: #ffffff; + padding: 14px 16px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + text-align: left; + cursor: pointer; + transition: all 0.2s ease; +} + +.summary-card:hover { + border-color: #1477ff; + box-shadow: 0 2px 8px rgba(20, 119, 255, 0.15); + transform: translateY(-1px); +} + +.summary-card.active { + border-color: #1477ff; + background: #1477ff; + box-shadow: 0 2px 8px rgba(20, 119, 255, 0.25); +} + +.summary-card.active span { + color: #ffffff; +} + +.summary-card.active strong { + color: #ffffff; +} + +.review-summary span, +.queue-footer span, +.panel-title span, +.inventory-grid span, +.resource-table span, +.price-decision-card span, +.adjust-current span, +.adjust-field span { + color: #8f9bba; + font-size: 12px; + font-weight: 600; +} + +.review-summary strong { + display: block; + margin-top: 5px; + color: #1b2559; + font-size: 24px; + font-weight: 800; +} + +.review-workbench { + display: grid; + grid-template-columns: minmax(360px, 420px) minmax(0, 1fr); + gap: 16px; + align-items: start; +} + +.review-queue, +.review-detail, +.review-panel, +.review-hero-panel { + min-width: 0; +} + +.review-queue { + position: sticky; + top: 16px; + border-radius: 8px; + background: #ffffff; + padding: 14px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.queue-toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr) 120px; + gap: 10px; + margin-bottom: 12px; +} + +.queue-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: calc(100vh - 300px); + overflow: auto; + padding-right: 2px; +} + +.queue-item { + width: 100%; + min-height: 112px; + border: 1px solid #e8ecf1; + border-radius: 8px; + background: #ffffff; + padding: 12px; + text-align: left; + cursor: pointer; + transition: + border-color 0.2s, + box-shadow 0.2s, + background 0.2s; +} + +.queue-item:hover, +.queue-item.active { + border-color: #4f7cff; + background: #f8faff; + box-shadow: 0 6px 18px rgba(79, 124, 255, 0.1); +} + +.queue-title-row, +.queue-footer, +.panel-title, +.hero-title { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.queue-title-row strong { + display: block; + min-width: 0; + color: #1b2559; + font-size: 14px; + font-weight: 800; + line-height: 1.4; +} + +.queue-title-row span { + flex-shrink: 0; + color: #4f7cff; + font-weight: 800; +} + +.queue-title-row .queue-code { + display: inline-flex; + margin-bottom: 4px; + padding: 2px 7px; + border-radius: 999px; + background: #eff6ff; + font-size: 12px; +} + +.queue-meta, +.risk-strip, +.skin-list, +.reject-reasons { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.queue-meta { + margin: 10px 0; +} + +.queue-meta span { + border-radius: 6px; + background: #f0f2f5; + padding: 4px 7px; + color: #4b5563; + font-size: 12px; + font-weight: 700; +} + +.queue-footer { + align-items: center; +} + +.queue-tags, +.panel-tags { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.review-detail { + display: flex; + flex-direction: column; + gap: 14px; +} + +.review-hero-panel, +.review-panel { + border-radius: 8px; + background: #ffffff; + padding: 18px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.hero-title h2, +.panel-title h3 { + margin: 0; + color: #1b2559; + font-size: 18px; + font-weight: 800; + line-height: 1.35; +} + +.hero-title p { + margin: 0 0 6px; + color: #8f9bba; + font-size: 12px; + font-weight: 700; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.risk-strip { + margin-top: 14px; +} + +.review-metrics { + grid-template-columns: repeat(5, minmax(132px, 1fr)); + gap: 12px; + margin-bottom: 0; +} + +.review-metrics .metric-card { + border-radius: 8px; + padding: 16px; +} + +.review-metrics .metric-card strong { + font-size: 20px; +} + +.review-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.panel-title { + align-items: center; + margin-bottom: 14px; +} + +.price-panel-title p { + margin: 5px 0 0; + color: #8f9bba; + font-size: 12px; + font-weight: 600; +} + +.price-decision-grid { + display: grid; + grid-template-columns: repeat(4, minmax(150px, 1fr)); + gap: 12px; +} + +.price-decision-card { + display: grid; + gap: 7px; + min-width: 0; + min-height: 128px; + border: 1px solid #e8ecf1; + border-radius: 8px; + background: #f8f9fe; + padding: 14px; +} + +.price-decision-card strong { + color: #1b2559; + font-size: 24px; + font-weight: 900; + line-height: 1.1; +} + +.price-decision-card p, +.price-decision-card small { + margin: 0; + color: #4b5563; + font-size: 12px; + font-weight: 700; + line-height: 1.45; +} + +.price-decision-card small { + color: #8f9bba; +} + +.price-decision-card.buyer { + border-color: #bfdbfe; + background: #eff6ff; +} + +.price-decision-card.buyer strong { + color: #2563eb; +} + +.price-decision-card.rule { + border-color: #fed7aa; + background: #fff7ed; +} + +.price-decision-card.rule strong { + color: #ea580c; +} + +.price-decision-card.deposit { + border-color: #e0e7ff; + background: #f5f7ff; +} + +.detail-list { + display: grid; + gap: 10px; + margin: 0; +} + +.detail-list div { + display: grid; + grid-template-columns: 86px minmax(0, 1fr); + gap: 10px; + align-items: start; +} + +.detail-list dt { + color: #8f9bba; + font-size: 12px; + font-weight: 700; +} + +.detail-list dd { + min-width: 0; + margin: 0; + color: #1f2937; + font-size: 13px; + font-weight: 700; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.inventory-grid { + display: grid; + grid-template-columns: repeat(3, minmax(110px, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.inventory-grid div { + border-radius: 8px; + background: #f8f9fe; + padding: 12px; +} + +.inventory-grid strong { + display: block; + margin-top: 4px; + color: #1b2559; + font-size: 22px; + font-weight: 900; +} + +.resource-table { + display: grid; + gap: 8px; + margin-bottom: 12px; +} + +.resource-table div { + display: grid; + grid-template-columns: minmax(120px, 1fr) 72px minmax(120px, 1fr); + gap: 10px; + align-items: center; + border-bottom: 1px solid #f0f2f5; + padding-bottom: 8px; +} + +.resource-table strong { + color: #1f2937; + font-size: 13px; + font-weight: 800; +} + +.resource-table em { + color: #8f9bba; + font-size: 12px; + font-style: normal; + font-weight: 600; +} + +.skin-list { + align-items: center; +} + +.skin-list > span { + color: #8f9bba; + font-size: 13px; +} + +.screenshot-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 12px; +} + +.screenshot-tile { + position: relative; + display: block; + width: 100%; + aspect-ratio: 16 / 9; + overflow: hidden; + border: 1px solid #e8ecf1; + border-radius: 8px; + background: #f8f9fe; + padding: 0; + cursor: pointer; +} + +.screenshot-tile img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.screenshot-tile span { + position: absolute; + top: 8px; + left: 8px; + border-radius: 6px; + background: rgba(245, 158, 11, 0.95); + padding: 3px 7px; + color: #ffffff; + font-size: 12px; + font-weight: 800; +} + +.empty-warning { + display: flex; + align-items: center; + gap: 8px; + min-height: 84px; + border-radius: 8px; + background: #fff7ed; + padding: 16px; + color: #c2410c; + font-weight: 700; +} + +.dialog-screenshots { + max-height: 540px; + overflow: auto; +} + +.reject-dialog p { + margin-top: 0; +} + +.reject-reasons { + margin: 12px 0; +} + +.price-adjust-dialog { + display: grid; + gap: 14px; +} + +.adjust-current { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.adjust-current div { + display: grid; + gap: 5px; + border-radius: 8px; + background: #f8f9fe; + padding: 12px; +} + +.adjust-current strong { + color: #1b2559; + font-size: 18px; + font-weight: 900; +} + +.adjust-current .preview-value { + color: #2563eb; +} + +.adjust-mode { + width: 100%; +} + +.adjust-mode :deep(.el-radio-button) { + width: 50%; +} + +.adjust-mode :deep(.el-radio-button__inner) { + width: 100%; + border-color: #d7deea; + font-weight: 800; +} + +.adjust-mode :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) { + border-color: #2563eb; + background: #2563eb; + box-shadow: -1px 0 0 0 #2563eb; +} + +.adjust-field { + display: grid; + gap: 8px; +} + +.adjust-field > span { + color: #52607a; + font-size: 12px; + font-weight: 800; +} + +.adjust-field :deep(.el-input-number) { + width: 100%; +} + +.empty-detail { + min-height: 420px; + display: grid; + place-items: center; + border-radius: 8px; + background: #ffffff; +} + +@media (max-width: 1280px) { + .review-workbench { + grid-template-columns: 1fr; + } + + .review-queue { + position: static; + } + + .queue-list { + max-height: none; + } +} + +@media (max-width: 900px) { + .review-summary, + .price-decision-grid, + .review-grid { + grid-template-columns: 1fr; + } + + .adjust-current { + grid-template-columns: 1fr; + } + + .hero-title { + flex-direction: column; + } + + .hero-actions { + justify-content: flex-start; + } + + .queue-toolbar { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/features/admin/views/AdminListingReviewView.vue b/frontend/src/features/admin/views/AdminListingReviewView.vue index 6eed773..0559b70 100644 --- a/frontend/src/features/admin/views/AdminListingReviewView.vue +++ b/frontend/src/features/admin/views/AdminListingReviewView.vue @@ -1053,595 +1053,4 @@ async function openScreenshot(url: string) { - + diff --git a/frontend/src/features/admin/views/AdminOperatingExpensesView.css b/frontend/src/features/admin/views/AdminOperatingExpensesView.css new file mode 100644 index 0000000..1815fb5 --- /dev/null +++ b/frontend/src/features/admin/views/AdminOperatingExpensesView.css @@ -0,0 +1,398 @@ +.finance-page-container { + display: flex; + flex-direction: column; + gap: 20px; + padding: 0 4px 32px; +} + +/* ================= 头部卡片 ================= */ +.page-header-card { + background: #ffffff; + border-radius: 16px; + padding: 24px 28px; + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); + border: 1px solid rgba(226, 232, 240, 0.8); +} + +.header-main { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.title-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.eyebrow-tag { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.8px; + color: #ea580c; + background: #fff7ed; + padding: 3px 10px; + border-radius: 20px; + width: fit-content; +} + +.page-title { + margin: 0; + font-size: 26px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.5px; +} + +.page-subtitle { + margin: 0; + font-size: 13.5px; + color: #64748b; + line-height: 1.5; +} + +.toolbar-actions { + display: flex; + align-items: center; + gap: 10px; +} + +/* ================= 核心指标卡片 ================= */ +.summary-metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; +} + +.metric-card { + background: #ffffff; + border-radius: 16px; + padding: 18px 20px; + border: 1px solid rgba(226, 232, 240, 0.8); + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); + display: flex; + align-items: center; + gap: 16px; + position: relative; + overflow: hidden; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; +} + +.metric-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08); +} + +.metric-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; +} + +.metric-card--paid::before { + background: linear-gradient(90deg, #f59e0b, #fbbf24); +} + +.metric-card--total::before { + background: linear-gradient(90deg, #64748b, #94a3b8); +} + +.metric-icon-wrap { + width: 44px; + height: 44px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; +} + +.metric-icon-wrap.amber { + background: #fffbeb; + color: #f59e0b; +} + +.metric-icon-wrap.slate { + background: #f1f5f9; + color: #64748b; +} + +.metric-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.metric-label { + font-size: 12.5px; + font-weight: 500; + color: #64748b; +} + +.metric-val { + font-size: 22px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.3px; + line-height: 1.2; +} + +.amber-val { + color: #d97706; +} + +.metric-sub-pill { + display: inline-flex; + align-items: center; + font-size: 11px; + font-weight: 600; + padding: 1px 6px; + border-radius: 4px; + width: fit-content; +} + +.metric-sub-pill.warning { + background: #fffbeb; + color: #d97706; +} + +.metric-sub-text { + font-size: 11.5px; + color: #94a3b8; +} + +/* ================= 筛选面板 ================= */ +.section-card { + background: #ffffff; + border-radius: 16px; + padding: 22px 24px; + border: 1px solid rgba(226, 232, 240, 0.8); + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.02); +} + +.filter-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 14px 16px; +} + +.filter-grid :deep(.el-form-item) { + margin-bottom: 0; +} + +.filter-grid :deep(.el-form-item__label) { + padding-bottom: 4px; + font-size: 12px; + font-weight: 600; + color: #64748b; +} + +.full-control { + width: 100%; +} + +/* ================= 表格卡片 ================= */ +.table-section-card { + padding-bottom: 16px; +} + +.table-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.table-header-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 16px; + font-weight: 700; + color: #1e293b; +} + +.header-icon { + color: #ea580c; + font-size: 18px; +} + +.count-badge { + font-size: 12px; + font-weight: 600; + color: #ea580c; + background: #fff7ed; + padding: 3px 10px; + border-radius: 20px; +} + +.table-container { + border-radius: 12px; + overflow: hidden; + border: 1px solid #e2e8f0; +} + +.custom-enterprise-table :deep(.el-table__header-wrapper th) { + background-color: #f8fafc !important; + color: #475569; + font-size: 13px; + font-weight: 600; + padding: 12px 0; + border-bottom: 1px solid #e2e8f0; +} + +.custom-enterprise-table :deep(.el-table__body-wrapper td) { + padding: 12px 0; + font-size: 13px; + color: #334155; +} + +.mono-code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12.5px; + font-weight: 600; + color: #1e293b; +} + +.category-tag { + font-weight: 600; +} + +.payee-name { + color: #0f172a; +} + +.amount-val { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 14px; + font-weight: 700; +} + +.orange-val { + color: #ea580c; +} + +.time-cell { + font-size: 12px; + color: #475569; +} + +.operator-name { + font-size: 12.5px; + color: #475569; +} + +.remark-text { + font-size: 12px; + color: #64748b; +} + +.action-cell { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +/* ================= 弹窗样式 ================= */ +.voucher-upload-box { + display: flex; + align-items: center; + gap: 16px; +} + +.voucher-preview-wrap { + width: 60px; + height: 60px; + border-radius: 8px; + overflow: hidden; + border: 1px solid #e2e8f0; +} + +.voucher-img-preview { + width: 100%; + height: 100%; + object-fit: cover; +} + +.detail-content-wrap { + display: flex; + flex-direction: column; + gap: 12px; + padding: 4px 0; +} + +.detail-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13.5px; + border-bottom: 1px dashed #f1f5f9; + padding-bottom: 8px; +} + +.detail-row.column-row { + flex-direction: column; + align-items: flex-start; + gap: 8px; +} + +.detail-label { + color: #64748b; +} + +.detail-val { + color: #0f172a; +} + +.detail-img-container { + max-width: 100%; + max-height: 240px; + border-radius: 8px; + overflow: hidden; + border: 1px solid #e2e8f0; +} + +.detail-voucher-img { + max-width: 100%; + max-height: 240px; + object-fit: contain; +} + +.void-info-box { + background: #fef2f2; + border-radius: 8px; + padding: 12px 14px; + border: 1px solid #fee2e2; + color: #b91c1c; + font-size: 12.5px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.void-title { + display: flex; + align-items: center; + gap: 6px; + font-weight: 700; + margin-bottom: 2px; +} diff --git a/frontend/src/features/admin/views/AdminOperatingExpensesView.vue b/frontend/src/features/admin/views/AdminOperatingExpensesView.vue index 323040d..a12682d 100644 --- a/frontend/src/features/admin/views/AdminOperatingExpensesView.vue +++ b/frontend/src/features/admin/views/AdminOperatingExpensesView.vue @@ -560,403 +560,4 @@ function statusType(status: string): 'success' | 'danger' { - + diff --git a/frontend/src/features/admin/views/AdminOrderDetailView.css b/frontend/src/features/admin/views/AdminOrderDetailView.css new file mode 100644 index 0000000..00c90e9 --- /dev/null +++ b/frontend/src/features/admin/views/AdminOrderDetailView.css @@ -0,0 +1,318 @@ +.order-metrics { + margin-bottom: 20px; +} + +.order-metrics .metric-card strong { + font-size: 20px; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.metric-card small { + display: block; + margin-top: 8px; + color: #8f9bba; + font-size: 12px; + line-height: 1.4; + word-break: break-word; +} + +.platform-handoff-button { + min-width: 96px; + border-color: #2563eb; + background: #2563eb; + color: #ffffff; + font-weight: 700; + box-shadow: 0 6px 14px rgba(37, 99, 235, 0.18); +} + +.platform-handoff-button:hover, +.platform-handoff-button:focus { + border-color: #1d4ed8; + background: #1d4ed8; + color: #ffffff; +} + +.platform-handoff-button:active { + border-color: #1e40af; + background: #1e40af; +} + +.order-detail-grid { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(360px, 0.9fr); + gap: 20px; + margin-bottom: 28px; +} + +.detail-panel { + min-width: 0; +} + +.order-wide-panel { + grid-column: 1 / -1; +} + +.panel-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.panel-heading h2 { + margin: 0; +} + +.panel-subtitle { + color: #8f9bba; + font-size: 13px; + line-height: 1.6; + text-align: right; +} + +.status-tags, +.snapshot-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.detail-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 18px; + margin: 0; +} + +.detail-list div { + min-width: 0; + border-top: 1px solid #f0f2f5; + padding-top: 10px; +} + +.detail-list .wide { + grid-column: 1 / -1; +} + +.detail-list dt { + margin-bottom: 5px; + color: #8f9bba; + font-size: 12px; + font-weight: 600; +} + +.detail-list dd { + margin: 0; + color: #1b2559; + font-size: 14px; + font-weight: 700; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.money-list { + display: grid; + gap: 8px; +} + +.money-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-top: 1px solid #f0f2f5; + padding-top: 9px; + color: #52616f; + font-size: 13px; +} + +.money-row strong { + color: #1b2559; + font-size: 15px; +} + +.money-row.total { + margin-top: 4px; + border-top-color: #d8e2f0; + color: #1b2559; + font-weight: 700; +} + +.money-row.total strong { + font-size: 18px; +} + +.record-list { + display: grid; + gap: 12px; +} + +.record-item { + display: grid; + gap: 8px; + border: 1px solid #eef2f7; + border-radius: 8px; + background: #fbfcff; + padding: 14px; +} + +.record-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.record-main strong { + color: #1b2559; + font-size: 15px; +} + +.record-main span { + color: #8f9bba; + font-size: 13px; +} + +.record-meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 6px 12px; + color: #52616f; + font-size: 12px; + line-height: 1.5; +} + +.record-item p { + margin: 0; + color: #52616f; + line-height: 1.6; + white-space: pre-wrap; +} + +.snapshot-detail-list { + margin-bottom: 14px; +} + +.ratio-row { + display: flex; + gap: 24px; + margin-bottom: 14px; + padding: 12px 14px; + border: 1px solid #eef2f7; + border-radius: 8px; + background: #fbfcff; +} + +.ratio-item { + display: flex; + flex-direction: column; + gap: 4px; +} + +.ratio-label { + font-size: 12px; + color: #8f9bba; + font-weight: 600; +} + +.ratio-value { + font-size: 20px; + font-weight: 700; + color: #1b2559; +} + +.snapshot-tags { + justify-content: flex-start; + margin-bottom: 14px; +} + +.snapshot-resources { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; + margin-bottom: 14px; +} + +.resource-item { + display: grid; + gap: 4px; + border: 1px solid #eef2f7; + border-radius: 8px; + background: #fbfcff; + padding: 10px 12px; +} + +.resource-item strong { + color: #1b2559; + font-size: 13px; +} + +.resource-item span { + color: #52616f; + font-size: 12px; +} + +.snapshot-json { + overflow-x: auto; + max-height: 360px; + margin: 0; + border-radius: 8px; + background: #111827; + color: #dbe4ee; + padding: 14px; + line-height: 1.6; +} + +.dialog-body { + display: grid; + gap: 12px; +} + +.dialog-body p { + margin: 0; + color: #52616f; + line-height: 1.6; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; +} + +.form-grid label { + display: grid; + gap: 6px; + color: #52616f; + font-size: 13px; + font-weight: 600; +} + +.form-grid :deep(.el-input-number) { + width: 100%; +} + +@media (max-width: 960px) { + .order-detail-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .panel-heading, + .record-main { + align-items: flex-start; + flex-direction: column; + } + + .detail-list { + grid-template-columns: 1fr; + } + + .status-tags { + justify-content: flex-start; + } +} diff --git a/frontend/src/features/admin/views/AdminOrderDetailView.vue b/frontend/src/features/admin/views/AdminOrderDetailView.vue index 01d4aec..7fcef82 100644 --- a/frontend/src/features/admin/views/AdminOrderDetailView.vue +++ b/frontend/src/features/admin/views/AdminOrderDetailView.vue @@ -46,7 +46,10 @@ import { } from '@/shared/utils/statusLabels' import { formatDateTime } from '@/shared/utils/time' import { formatGameName, formatListingNo } from '@/shared/utils/listingDisplay' +import AdminOrderMetrics, { type AdminOrderMetric } from '../components/AdminOrderMetrics.vue' +import AdminOrderPaymentRecords from '../components/AdminOrderPaymentRecords.vue' import ProcessTimeline from '../components/ProcessTimeline.vue' +import { estimateOrderEndAt, paymentStatusLabel } from '../utils/adminOrderPresentation' const route = useRoute() const router = useRouter() @@ -255,6 +258,63 @@ const latestPayment = computed(() => { const paidOrderPayment = computed(() => paymentRecords.value.find(item => item.biz_type === 'order_pay' && item.status === 'paid') ) +const orderMetricRows = computed(() => { + const current = order.value + if (!current) return [] + const rows: AdminOrderMetric[] = [ + { + key: 'order-status', + label: '订单状态', + value: orderStatusLabel(current.status), + detail: orderHandoffStatusLabel(current), + }, + { + key: 'settlement-status', + label: '结算状态', + value: settlementStatusLabel(current.settlement_status), + detail: `更新 ${formatDateTime(current.updated_at)}`, + }, + ] + if (isPlatformManaged.value) { + rows.push({ + key: 'offline-settlement', + label: '线下结算', + value: offlineSettlementStatusLabel(offlineSettlementStatus.value), + detail: moneyCent(current.offline_settlement_amount_cent), + }) + } + rows.push( + { + key: 'total', + label: '订单总额', + value: moneyCent(orderTotalCent.value), + detail: `租金 ${moneyCent(current.rent_amount_cent)} / 押金 ${moneyCent(current.deposit_amount_cent)}`, + }, + { + key: 'payment', + label: '支付状态', + value: latestPayment.value ? paymentStatusLabel(latestPayment.value.status) : '暂无流水', + detail: paidOrderPayment.value + ? `支付 ${moneyCent(paidOrderPayment.value.amount_cent)}` + : '尚未支付', + }, + { + key: 'refund', + label: '退款状态', + value: refundStatusLabel(refundStatus.value?.refund_status || ''), + detail: refundStatus.value?.refund_amount_cent + ? moneyCent(refundStatus.value.refund_amount_cent) + : '暂无退款金额', + }, + { + key: 'estimated-end', + label: '预计截止', + value: formatDateTime(orderEstimatedEndAt(), '未设置'), + detail: `${current.estimated_duration_hours} 小时`, + } + ) + return rows +}) const currentSnapshot = computed(() => readSnapshot(order.value)) const snapshotHafCoinM = computed(() => getSnapshotHafCoinM(order.value)) const checkoutRemainingHafCoinM = computed(() => { @@ -695,12 +755,11 @@ function orderRentedAt() { } function orderEstimatedEndAt() { - if (!order.value) return undefined - if (order.value.estimated_end_at) return order.value.estimated_end_at - const rentedAt = orderRentedAt() - const durationHours = Number(order.value.estimated_duration_hours || 0) - if (!rentedAt || durationHours <= 0) return undefined - return new Date(new Date(rentedAt).getTime() + durationHours * 60 * 60 * 1000).toISOString() + return estimateOrderEndAt( + order.value?.estimated_end_at, + orderRentedAt(), + Number(order.value?.estimated_duration_hours || 0) + ) } async function handleRefund() { @@ -739,39 +798,6 @@ async function confirmRefund() { await handleRefund() } -function paymentStatusLabel(status: string) { - const map: Record = { - created: '已创建', - paying: '支付中', - paid: '已支付', - refunding: '退款中', - refunded: '已退款', - failed: '失败', - } - return map[status] || status -} - -function paymentStatusType(status: string) { - if (['paid', 'refunded'].includes(status)) return 'success' - if (status === 'failed') return 'danger' - if (['paying', 'refunding'].includes(status)) return 'warning' - return 'info' -} - -function paymentBizTypeLabel(type: string) { - const map: Record = { - order_pay: '订单支付', - checkout_refund: '结账退款', - arbitration_refund: '仲裁退款', - admin_refund: '人工退款', - cancel_refund: '取消退款', - admin_close_refund: '客服关闭退款', - admin_seal_refund: '封存退款', - deposit_refund: '暂扣押金归还', - } - return map[type] || type -} - function moneyCent(value: number | undefined | null) { return formatCentWithSymbol(Number(value || 0)) } @@ -943,10 +969,6 @@ function formatRatioNumber(value: number) { const rounded = Math.round(value * 100) / 100 return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(2) } - -function paymentPaidAt(record: AdminPayment) { - return record.paid_at || record.updated_at || record.created_at -} - + diff --git a/frontend/src/features/admin/views/AdminPickupDetailView.css b/frontend/src/features/admin/views/AdminPickupDetailView.css new file mode 100644 index 0000000..2dbd2d1 --- /dev/null +++ b/frontend/src/features/admin/views/AdminPickupDetailView.css @@ -0,0 +1,209 @@ +.pickup-metrics { + margin-bottom: 20px; +} + +.pickup-metrics .metric-card strong { + font-size: 20px; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.metric-card small { + display: block; + margin-top: 8px; + color: #8f9bba; + font-size: 12px; + line-height: 1.4; + word-break: break-word; +} + +.form-hint { + margin-top: 4px; + color: #64748b; + font-size: 12px; + line-height: 1.5; +} + +.adjustment-table { + width: 100%; +} + +.pickup-detail-grid { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(360px, 0.9fr); + gap: 20px; + margin-bottom: 28px; +} + +.detail-panel { + min-width: 0; +} + +.pickup-wide-panel { + grid-column: 1 / -1; +} + +.panel-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.panel-heading h2 { + margin: 0; +} + +.panel-subtitle { + color: #8f9bba; + font-size: 13px; + line-height: 1.6; + text-align: right; +} + +.detail-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 18px; + margin: 0; +} + +.detail-list div { + min-width: 0; + border-top: 1px solid #f0f2f5; + padding-top: 10px; +} + +.detail-list .wide { + grid-column: 1 / -1; +} + +.detail-list dt { + margin-bottom: 5px; + color: #8f9bba; + font-size: 12px; + font-weight: 600; +} + +.detail-list dd { + margin: 0; + color: #1b2559; + font-size: 14px; + font-weight: 700; + line-height: 1.5; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.source-detail { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; +} + +.money-list { + display: grid; + gap: 8px; +} + +.money-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-top: 1px solid #f0f2f5; + padding-top: 9px; + color: #52616f; + font-size: 13px; +} + +.money-row strong { + color: #1b2559; + font-size: 15px; +} + +.money-row.is-subtotal { + border-top-color: #d8e2f0; + color: #1b2559; + font-weight: 700; +} + +.money-row.is-profit strong { + color: #0f766e; +} + +.money-row.is-total { + margin-top: 4px; + border-top-color: #bfdbfe; + color: #1b2559; + font-weight: 800; +} + +.money-row.is-total strong { + font-size: 18px; +} + +.snapshot-detail-list { + margin-bottom: 14px; +} + +.snapshot-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 14px; +} + +.snapshot-resources { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; + margin-bottom: 14px; +} + +.resource-item { + display: grid; + gap: 4px; + border: 1px solid #eef2f7; + border-radius: 8px; + background: #fbfcff; + padding: 10px 12px; +} + +.resource-item strong { + color: #1b2559; + font-size: 13px; +} + +.resource-item span { + color: #52616f; + font-size: 12px; +} + +.snapshot-json { + overflow-x: auto; + max-height: 360px; + margin: 0; + border-radius: 8px; + background: #0f172a; + color: #dbeafe; + padding: 14px; + font-size: 12px; + line-height: 1.6; +} + +@media (max-width: 960px) { + .pickup-detail-grid { + grid-template-columns: 1fr; + } + + .pickup-wide-panel { + grid-column: auto; + } + + .detail-list { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/features/admin/views/AdminPickupDetailView.vue b/frontend/src/features/admin/views/AdminPickupDetailView.vue index 0d0a373..159b46d 100644 --- a/frontend/src/features/admin/views/AdminPickupDetailView.vue +++ b/frontend/src/features/admin/views/AdminPickupDetailView.vue @@ -697,214 +697,4 @@ function readSnapshotResources(summary: Record | null): Snapsho - + diff --git a/frontend/src/features/admin/views/AdminPickupView.css b/frontend/src/features/admin/views/AdminPickupView.css new file mode 100644 index 0000000..639fda6 --- /dev/null +++ b/frontend/src/features/admin/views/AdminPickupView.css @@ -0,0 +1,120 @@ +.form-hint { + margin-top: 4px; + font-size: 12px; + color: #64748b; +} +.pickup-filter-bar { + margin-bottom: 16px; +} + +.pickup-filter-bar :deep(.el-form) { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 16px; +} + +.pickup-filter-bar :deep(.el-form-item) { + margin: 0; +} + +.pickup-filter-bar :deep(.el-form-item__label) { + padding-right: 7px; + color: #52616f; + font-size: 13px; +} + +.pickup-filter-bar :deep(.el-select) { + width: 128px; +} + +.pickup-filter-bar :deep(.el-form-item:nth-child(3) .el-select) { + width: 150px; +} + +.pickup-filter-bar :deep(.el-form-item:nth-child(4) .el-select) { + width: 175px; +} + +.pickup-keyword-filter :deep(.el-input) { + width: 280px; +} + +.pickup-filter-actions :deep(.el-form-item__content) { + gap: 8px; +} + +@media (max-width: 900px) { + .pickup-filter-bar :deep(.el-form) { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .pickup-filter-bar :deep(.el-form-item), + .pickup-filter-bar :deep(.el-select), + .pickup-keyword-filter :deep(.el-input) { + width: 100%; + } + + .pickup-filter-bar :deep(.el-form-item__content) { + min-width: 0; + flex: 1; + } +} + +@media (max-width: 600px) { + .pickup-filter-bar :deep(.el-form) { + grid-template-columns: minmax(0, 1fr); + } +} +.text-muted { + color: #94a3b8; +} +.price-snapshot { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + color: #475569; + font-size: 13px; + line-height: 1.6; +} +.source-cell { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 3px; +} +.source-cell small { + color: #64748b; +} +.listing-option { + display: flex; + align-items: center; + gap: 8px; + width: 100%; +} +.listing-option-title { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.listing-option-price { + flex: none; + color: #64748b; + font-variant-numeric: tabular-nums; +} +.selected-source { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + color: #475569; + font-size: 13px; +} +.action-buttons { + display: flex; + flex-wrap: wrap; + gap: 8px; +} diff --git a/frontend/src/features/admin/views/AdminPickupView.vue b/frontend/src/features/admin/views/AdminPickupView.vue index ef6d8fd..2ee53db 100644 --- a/frontend/src/features/admin/views/AdminPickupView.vue +++ b/frontend/src/features/admin/views/AdminPickupView.vue @@ -902,125 +902,4 @@ function listingOptionLabel(item: AvailableListing) { - + diff --git a/frontend/src/features/admin/views/AdminQrCodePoolView.css b/frontend/src/features/admin/views/AdminQrCodePoolView.css new file mode 100644 index 0000000..4e40066 --- /dev/null +++ b/frontend/src/features/admin/views/AdminQrCodePoolView.css @@ -0,0 +1,448 @@ +.qrcode-pool-view { + display: flex; + flex-direction: column; + gap: 16px; +} + +.stats-cards { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; +} + +.stat-card { + text-align: center; +} + +.toolbar-card, +.table-card { + border-radius: 10px; +} + +.table-card :deep(.el-card__body) { + padding: 20px; +} + +.stat-value { + font-size: 28px; + font-weight: 600; + line-height: 1.2; +} + +.stat-value.success { + color: var(--el-color-success); +} +.stat-value.primary { + color: var(--el-color-primary); +} +.stat-value.danger { + color: var(--el-color-danger); +} + +.stat-label { + margin-top: 6px; + color: var(--el-text-color-secondary); + font-size: 13px; +} + +.toolbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.filter-area, +.action-area { + display: flex; + gap: 8px; + align-items: center; +} + +.filter-area { + flex: 1 1 auto; + flex-wrap: wrap; +} + +.action-area { + flex: 0 0 auto; + flex-wrap: wrap; + justify-content: flex-end; +} + +.keyword-filter { + width: 240px; +} + +.compact-filter { + width: 128px; +} + +.date-range-filter { + width: 330px; +} + +.qrcode-table { + border-radius: 10px; + box-shadow: none; + padding: 0; +} + +.qrcode-table :deep(.el-table__inner-wrapper) { + min-width: 0; +} + +.qrcode-table :deep(.el-table__header th) { + height: 42px; + background: #f8f9fb; +} + +.qrcode-table :deep(.el-table__cell) { + padding: 10px 0; +} + +.qrcode-table :deep(.cell) { + padding: 0 14px; +} + +.qrcode-table :deep(.el-table__fixed-right) { + box-shadow: -8px 0 12px rgba(31, 45, 61, 0.04); +} + +.qrcode-table :deep(.el-table__header), +.qrcode-table :deep(.el-table__body) { + width: 100% !important; +} + +.qrcode-table :deep(.el-table__header-wrapper), +.qrcode-table :deep(.el-table__body-wrapper) { + overflow-x: auto; +} + +.qrcode-table :deep(.selection-col) { + width: 4%; +} + +.qrcode-table :deep(.qr-col) { + width: 8%; +} + +.qrcode-table :deep(.status-col) { + width: 8%; +} + +.qrcode-table :deep(.name-col) { + width: 17%; +} + +.qrcode-table :deep(.note-col) { + width: 17%; +} + +.qrcode-table :deep(.group-col) { + width: 17%; +} + +.qrcode-table :deep(.rename-col) { + width: 8%; +} + +.qrcode-table :deep(.time-col) { + width: 12%; +} + +.qrcode-table :deep(.action-col) { + width: 10%; +} + +.qrcode-thumb { + display: inline-flex; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + border: 1px solid #eef1f5; + border-radius: 8px; + background: #ffffff; +} + +.note-cell { + color: #303846; + font-weight: 500; +} + +.time-stack { + display: inline-flex; + flex-direction: column; + gap: 2px; + color: #5f6b7a; + font-size: 12px; + line-height: 18px; + text-align: left; + white-space: nowrap; +} + +.copy-cell-button { + display: inline-flex; + max-width: 100%; + align-items: center; + gap: 4px; + padding: 0; + border: 0; + background: transparent; + color: #303846; + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 500; + line-height: 20px; + text-align: left; +} + +.copy-cell-button span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.copy-cell-button .el-icon { + flex: 0 0 auto; + color: var(--el-color-primary); + font-size: 14px; +} + +.copy-cell-button:disabled { + color: #a8b0bd; + cursor: default; +} + +.table-actions { + display: flex; + justify-content: center; + gap: 6px; + white-space: nowrap; +} + +.table-actions :deep(.el-button) { + margin-left: 0; + padding: 4px 6px; +} + +@media (max-width: 1280px) { + .toolbar { + flex-direction: column; + align-items: stretch; + } + + .action-area { + justify-content: flex-start; + } +} + +@media (max-width: 720px) { + .keyword-filter, + .compact-filter, + .date-range-filter { + width: 100%; + } + + .filter-area, + .action-area { + align-items: stretch; + } +} + +.upload-preview { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.batch-upload-area { + width: 100%; +} + +.qrcode-upload-form :deep(.el-form-item__label) { + align-items: center; + padding-right: 18px; + line-height: 32px; + white-space: nowrap; + word-break: keep-all; +} + +.qrcode-upload-form :deep(.el-form-item) { + margin-bottom: 18px; +} + +.qrcode-upload-form :deep(.el-form-item__content) { + min-width: 0; +} + +.batch-upload-area :deep(.el-upload) { + width: 100%; +} + +.batch-upload-area :deep(.el-upload-dragger) { + width: 100%; + padding: 12px 18px; +} + +.batch-upload-area :deep(.el-icon--upload) { + margin-bottom: 4px; + font-size: 28px; + line-height: 1; +} + +.batch-upload-area :deep(.el-upload__text) { + line-height: 20px; +} + +.upload-tip { + color: var(--el-text-color-secondary); + font-size: 12px; + line-height: 18px; +} + +.uploaded-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px; + margin-top: 10px; + max-height: 320px; + overflow-y: auto; + padding: 10px; + border: 1px solid #eef1f5; + border-radius: 8px; + background: #fafbfc; +} + +.uploaded-item { + min-width: 0; + padding: 10px; + border: 1px solid #e7ebf2; + border-radius: 8px; + background: #ffffff; +} + +.uploaded-main { + display: flex; + min-width: 0; + align-items: center; + gap: 10px; +} + +.uploaded-thumb { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 60px; + height: 60px; + flex: 0 0 auto; + border: 1px solid #e7ebf2; + border-radius: 8px; + background: #ffffff; +} + +.uploaded-local-preview { + display: block; + width: 52px; + height: 52px; + border-radius: 6px; + object-fit: cover; +} + +.uploaded-remove { + position: absolute; + top: -7px; + right: -7px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + border: 1px solid #ffffff; + border-radius: 50%; + background: var(--el-color-danger); + color: #ffffff; + cursor: pointer; + font-size: 14px; + line-height: 1; +} + +.uploaded-remove:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.uploaded-name { + overflow: hidden; + color: var(--el-text-color-secondary); + font-size: 12px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.uploaded-meta { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + align-items: flex-start; + gap: 6px; +} + +.uploaded-meta .el-tag { + height: 20px; +} + +.uploaded-group-editor { + margin-top: 8px; +} + +.expired-tag { + color: var(--el-color-danger); +} + +.edit-image-area { + display: flex; + align-items: center; + gap: 12px; +} + +.edit-image-preview { + flex: 0 0 auto; + width: 88px; + height: 88px; + overflow: hidden; + border: 1px solid #e7ebf2; + border-radius: 8px; + background: #fafbfc; +} + +.edit-image-preview :deep(.el-image), +.edit-image-preview :deep(img) { + display: block; + width: 100%; + height: 100%; +} + +.edit-image-empty { + display: grid; + width: 100%; + height: 100%; + place-items: center; + color: var(--el-text-color-secondary); + font-size: 12px; +} + +.expire-editor { + display: flex; + align-items: center; + gap: 8px; + width: 100%; +} diff --git a/frontend/src/features/admin/views/AdminQrCodePoolView.vue b/frontend/src/features/admin/views/AdminQrCodePoolView.vue index d53a026..ef78aac 100644 --- a/frontend/src/features/admin/views/AdminQrCodePoolView.vue +++ b/frontend/src/features/admin/views/AdminQrCodePoolView.vue @@ -979,453 +979,4 @@ onMounted(reloadAll) - + diff --git a/frontend/src/features/admin/views/AdminSystemConfigsView.css b/frontend/src/features/admin/views/AdminSystemConfigsView.css new file mode 100644 index 0000000..99f3d4c --- /dev/null +++ b/frontend/src/features/admin/views/AdminSystemConfigsView.css @@ -0,0 +1,89 @@ +.publish-config-panel { + display: grid; + gap: 18px; + margin-bottom: 18px; + padding: 20px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; +} + +.publish-config-main { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.panel-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.publish-config-main h2 { + margin: 4px 0 6px; + color: #111827; + font-size: 20px; +} + +.publish-config-main span { + color: #6b7280; + font-size: 13px; +} + +.publish-stat-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; +} + +.welcome-config-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.home-stat-grid .publish-stat strong { + font-size: 18px; +} + +.publish-stat { + display: grid; + gap: 4px; + padding: 12px; + border-radius: 8px; + background: #f8fafc; +} + +.publish-stat strong { + color: #1477ff; + font-size: 22px; + line-height: 1; +} + +.publish-stat span, +.config-value { + color: #4b5563; + font-size: 13px; + overflow-wrap: anywhere; +} + +/* 仅实体主按钮提升文字清晰度,其他按钮保持组件默认配色。 */ +:deep(.el-button--primary:not(.is-plain):not(.is-dashed):not(.is-text):not(.is-link)) { + font-weight: 700; +} + +:deep(.el-button--primary:not(.is-plain):not(.is-dashed):not(.is-text):not(.is-link) span) { + color: #ffffff !important; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + font-weight: 700; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +@media (max-width: 1100px) { + .publish-stat-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} diff --git a/frontend/src/features/admin/views/AdminSystemConfigsView.vue b/frontend/src/features/admin/views/AdminSystemConfigsView.vue index 31a1021..efcaf4b 100644 --- a/frontend/src/features/admin/views/AdminSystemConfigsView.vue +++ b/frontend/src/features/admin/views/AdminSystemConfigsView.vue @@ -965,94 +965,4 @@ function formatConfigValue(row: SystemConfig) { - + diff --git a/frontend/src/features/admin/views/AdminUsersView.css b/frontend/src/features/admin/views/AdminUsersView.css new file mode 100644 index 0000000..c51733e --- /dev/null +++ b/frontend/src/features/admin/views/AdminUsersView.css @@ -0,0 +1,149 @@ +.user-filter-bar { + margin-bottom: 16px; + padding: 16px 16px 0; + border-radius: 8px; + background: #fff; +} + +.user-filter-bar :deep(.el-form-item) { + margin-bottom: 16px; +} + +.balance-available { + color: #0f172a; + font-variant-numeric: tabular-nums; + font-weight: 700; +} + +.balance-frozen { + color: #94a3b8; + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.balance-frozen.is-positive { + color: #c2410c; +} + +.quota-cell { + display: grid; + gap: 2px; +} + +.quota-cell strong { + color: #0f172a; + font-variant-numeric: tabular-nums; +} + +.quota-cell span { + color: #64748b; + font-size: 11px; + line-height: 1.3; +} + +.table-actions { + display: inline-flex; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-end; + gap: 2px; + white-space: nowrap; +} + +.table-actions :deep(.el-button) { + margin: 0; + height: 28px; + padding: 0 8px; + font-weight: 600; +} + +.table-actions :deep(.el-button + .el-button) { + margin-left: 0; +} + +.table-actions :deep(.el-button.is-text) { + border-radius: 6px; +} + +.table-actions :deep(.el-button.is-text:hover) { + background: #f1f5f9; +} + +.wallet-dialog { + display: grid; + gap: 14px; +} + +.quota-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + padding: 12px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f8fafc; + color: #64748b; + font-size: 13px; +} + +.quota-summary strong { + color: #0f172a; +} + +.growth-dialog { + gap: 16px; +} + +.growth-current { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f8fafc; + color: #64748b; +} + +.growth-current strong { + color: #0f172a; + font-size: 22px; + font-variant-numeric: tabular-nums; +} + +.wallet-balance-cards { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.wallet-balance-cards > div { + display: grid; + gap: 6px; + padding: 12px 14px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f8fafc; +} + +.wallet-balance-cards span { + color: #64748b; + font-size: 13px; + font-weight: 700; +} + +.wallet-balance-cards strong { + color: #0f172a; + font-size: 22px; + line-height: 1.1; + font-variant-numeric: tabular-nums; +} + +.full-control { + width: 100%; +} + +.dialog-body { + display: grid; + gap: 12px; +} diff --git a/frontend/src/features/admin/views/AdminUsersView.vue b/frontend/src/features/admin/views/AdminUsersView.vue index 5674c42..c47740d 100644 --- a/frontend/src/features/admin/views/AdminUsersView.vue +++ b/frontend/src/features/admin/views/AdminUsersView.vue @@ -755,154 +755,4 @@ function moneyCent(value: number | string | undefined) { - + diff --git a/frontend/src/features/auth/views/MobileProfileView.css b/frontend/src/features/auth/views/MobileProfileView.css new file mode 100644 index 0000000..0f2c930 --- /dev/null +++ b/frontend/src/features/auth/views/MobileProfileView.css @@ -0,0 +1,665 @@ +.mobile-profile-shell { + min-height: 100dvh; + background: #f6f8fa; + color: #1a202c; + padding: 0 0 calc(64px + env(safe-area-inset-bottom)); +} + +/* ========== Hero Header ========== */ +.profile-hero { + background: #ffffff; + padding: 20px 16px 20px; + border-bottom: 1px solid rgba(243, 244, 246, 0.8); +} + +.hero-top-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.app-title { + font-size: 16px; + font-weight: 700; + color: #111827; + letter-spacing: 0.5px; +} + +.hero-setting-btn { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border: none; + background: #f3f4f6; + border-radius: 999px; + cursor: pointer; + transition: background 0.2s; +} + +.hero-setting-btn:active { + background: #e5e7eb; +} + +.profile-hero-content { + display: flex; + align-items: center; + gap: 16px; +} + +.avatar-circle { + display: grid; + width: 60px; + height: 60px; + place-items: center; + border-radius: 999px; + overflow: hidden; + background: #eaf2ff; + color: #1477ff; + font-size: 24px; + font-weight: 900; + border: 1px solid #e5e7eb; +} + +.avatar-circle img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.hero-user { + flex: 1; + min-width: 0; +} + +.name-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.user-nickname { + margin: 0; + font-size: 18px; + font-weight: 800; + color: #111827; +} + +.verified-tag, +.unverified-tag { + font-size: 10px !important; + height: 18px; + padding: 0 6px; + font-weight: 700; + border: none; +} + +.user-meta { + margin: 4px 0 0; + color: #6b7280; + font-size: 12px; +} + +.growth-card { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin: 12px 16px 0; +} + +.growth-card > div { + display: grid; + gap: 4px; + min-width: 0; + padding: 14px; + border: 1px solid rgba(243, 244, 246, 0.9); + border-radius: 16px; + background: #ffffff; + box-shadow: + 0 4px 16px rgba(0, 0, 0, 0.02), + 0 1px 4px rgba(0, 0, 0, 0.02); +} + +.growth-card span { + color: #9ca3af; + font-size: 11px; + font-weight: 700; +} + +.growth-card strong { + color: #7c3aed; + font-size: 20px; + line-height: 1.2; + font-weight: 900; +} + +/* ========== Balance Card ========== */ +.balance-card { + margin: 12px 16px 14px; + padding: 16px; + border-radius: 16px; + background: #ffffff; + box-shadow: + 0 4px 16px rgba(0, 0, 0, 0.02), + 0 1px 4px rgba(0, 0, 0, 0.02); + border: 1px solid rgba(243, 244, 246, 0.9); +} + +.balance-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.balance-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.balance-label { + color: #9ca3af; + font-size: 11px; + font-weight: 600; +} + +.balance-value { + font-size: 26px; + font-weight: 800; + font-family: Inter, system-ui, sans-serif; + color: #111827; +} + +.withdraw-btn { + background: transparent; + border: 1px solid #1477ff; + color: #1477ff; + font-weight: 700; + font-size: 12px; + height: 28px; + padding: 0 16px; + border-radius: 999px; + cursor: pointer; + transition: + background 0.15s, + color 0.15s; +} + +.withdraw-btn:active { + background: #1477ff; + color: #ffffff; +} + +.balance-footer { + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid #f3f4f6; +} + +.detail-btn { + background: transparent; + border: none; + color: #1477ff; + font-weight: 700; + font-size: 11px; + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + padding: 0; +} + +/* ========== Menu Cards ========== */ +.menu-card { + margin: 0 16px 14px; + background: #ffffff; + border-radius: 16px; + padding: 16px; + box-shadow: + 0 4px 16px rgba(0, 0, 0, 0.02), + 0 1px 4px rgba(0, 0, 0, 0.02); + border: 1px solid rgba(243, 244, 246, 0.9); +} + +.menu-card.cell-card { + padding: 0; + overflow: hidden; +} + +.menu-card .card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 14px; +} + +.menu-card .card-header h3 { + margin: 0; + font-size: 14px; + font-weight: 800; + color: #111827; +} + +.see-all { + font-size: 11px; + color: #6b7280; + display: flex; + align-items: center; + gap: 2px; + cursor: pointer; +} + +/* Grid Menu Layout */ +.grid-menu { + display: flex; + justify-content: space-between; + align-items: center; +} + +.grid-menu.col-4 .grid-item { + flex: 0 0 25%; +} + +.grid-menu.col-5 .grid-item { + flex: 0 0 20%; +} + +.grid-menu.col-3 .grid-item { + flex: 0 0 33.33%; +} + +.grid-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + cursor: pointer; +} + +.grid-item:active .icon-wrap { + transform: scale(0.92); +} + +.icon-wrap { + display: grid; + width: 44px; + height: 44px; + place-items: center; + border-radius: 12px; + transition: transform 0.15s ease; +} + +/* Vibrant pastel colors for menu icon wraps */ +.icon-wrap.warning { + color: #f59e0b; + background: rgba(245, 158, 11, 0.08); +} +.icon-wrap.info { + color: #2563eb; + background: rgba(37, 99, 235, 0.08); +} +.icon-wrap.primary { + color: #1477ff; + background: rgba(20, 119, 255, 0.08); +} +.icon-wrap.success { + color: #10b981; + background: rgba(16, 185, 129, 0.08); +} +.icon-wrap.orange { + color: #ff5f00; + background: rgba(255, 95, 0, 0.08); +} +.icon-wrap.purple { + color: #8b5cf6; + background: rgba(139, 92, 246, 0.08); +} +.icon-wrap.teal { + color: #0d9488; + background: rgba(13, 148, 136, 0.08); +} + +.grid-item span { + font-size: 11px; + color: #374151; + font-weight: 600; +} + +/* Vant Cell Adjustments */ +:deep(.van-cell-group) { + background: transparent; +} +:deep(.van-cell) { + padding: 14px 16px; + font-size: 14px; +} +.verified-color { + color: #10b981; + font-weight: 600; +} +.unverified-color { + color: #9ca3af; +} +.unread-cell-value { + color: #ef4444; + font-weight: 700; +} + +/* 收支明细 Popup */ +.ledgers-popup { + display: flex; + flex-direction: column; +} +.popup-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + border-bottom: 1px solid #f3f4f6; + flex-shrink: 0; +} +.popup-header h3 { + margin: 0; + font-size: 16px; + font-weight: 800; +} +.popup-close { + border: none; + background: transparent; + font-size: 16px; + color: #9ca3af; + cursor: pointer; +} +.popup-body { + flex: 1; + overflow-y: auto; + padding: 0 16px; +} +.ledger-list { + display: flex; + flex-direction: column; +} +.ledger-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 0; + border-bottom: 1px solid #f3f4f6; +} +.ledger-item:last-child { + border-bottom: none; +} +.ledger-left { + display: flex; + flex-direction: column; + gap: 4px; +} +.ledger-remark { + font-size: 14px; + color: #1f2937; + font-weight: 600; +} +.ledger-time { + font-size: 11px; + color: #9ca3af; +} +.ledger-right { + font-size: 15px; + font-weight: 800; +} +.in-color { + color: #10b981; +} +.out-color { + color: #ef4444; +} + +/* 租后须知 Popup */ +.notice-popup { + display: flex; + flex-direction: column; +} +.notice-content { + padding: 16px 0; + color: #374151; + font-size: 14px; + line-height: 1.8; + white-space: pre-wrap; + word-wrap: break-word; +} +.center-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 40px 0; +} + +/* ========== 设置面板 ========== */ +.settings-panel { + display: flex; + flex-direction: column; + height: 100%; + background: #f5f6f8; +} + +.settings-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + background: #fff; + border-bottom: 1px solid #eee; +} + +.settings-header h2 { + margin: 0; + font-size: 17px; + font-weight: 700; +} + +.settings-close { + display: grid; + width: 32px; + height: 32px; + place-items: center; + border: none; + background: none; + color: #999; + cursor: pointer; +} + +.settings-group { + margin-top: 12px; +} + +.settings-group-title { + margin: 0 0 4px 16px; + font-size: 13px; + font-weight: 600; + color: #999; +} + +.settings-group :deep(.van-cell) { + font-size: 15px; +} + +.settings-logout { + margin: auto 16px 24px; + padding-top: 20px; +} + +/* ========== 资料修改 ========== */ +.profile-editor { + padding: 16px 16px max(18px, env(safe-area-inset-bottom)); + background: #fff; +} + +.profile-editor-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.profile-editor-header h2 { + margin: 0; + color: #17233d; + font-size: 18px; + font-weight: 800; +} + +.profile-editor-header button { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border: none; + border-radius: 12px; + background: #f4f6f8; + color: #64748b; +} + +.profile-preview { + display: flex; + align-items: center; + gap: 12px; + margin: 18px 0; + padding: 14px; + border-radius: 14px; + background: #f7faff; +} + +.profile-preview-avatar { + display: grid; + width: 52px; + height: 52px; + flex: 0 0 auto; + place-items: center; + overflow: hidden; + border-radius: 16px; + background: #1477ff; + color: #fff; + font-size: 22px; + font-weight: 900; +} + +.profile-preview-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.profile-preview-avatar.clickable { + cursor: pointer; + position: relative; +} + +.avatar-upload-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + color: #ffffff; + opacity: 0; + transition: opacity 0.2s ease; +} + +.profile-preview-avatar.clickable:hover .avatar-upload-overlay, +.profile-preview-avatar.clickable:active .avatar-upload-overlay { + opacity: 1; +} + +.avatar-loading { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.8); +} + +.preview-info { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; +} + +.preview-info strong { + display: block; + max-width: 230px; + overflow: hidden; + color: #17233d; + font-size: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.preview-info p { + margin: 0; + color: #7b8798; + font-size: 12px; +} + +.upload-btn { + margin-top: 4px; + font-weight: 600; +} + +.editor-field { + display: block; + margin-bottom: 12px; +} + +.password-hint { + margin: 8px 0 14px; + color: #8b9cb5; + font-size: 12px; + line-height: 1.5; +} + +.editor-field span { + display: block; + margin-bottom: 6px; + color: #697386; + font-size: 13px; + font-weight: 700; +} + +.editor-field input { + box-sizing: border-box; + width: 100%; + height: 46px; + border: 1px solid #e1e7ef; + border-radius: 12px; + background: #fff; + color: #17233d; + font-size: 14px; + outline: none; + padding: 0 12px; +} + +.editor-field input:focus { + border-color: #1477ff; + box-shadow: 0 0 0 3px rgba(20, 119, 255, 0.1); +} + +.profile-save-btn { + height: 44px; + margin-top: 6px; + background: #1477ff; + border-color: transparent; + font-weight: 800; +} + +/* ========== 响应式适配 ========== */ +@media (min-width: 520px) { + .mobile-profile-shell { + max-width: 430px; + margin: 0 auto; + box-shadow: 0 0 0 1px rgba(23, 35, 61, 0.08); + } +} diff --git a/frontend/src/features/auth/views/MobileProfileView.vue b/frontend/src/features/auth/views/MobileProfileView.vue index bc178c0..856cdb9 100644 --- a/frontend/src/features/auth/views/MobileProfileView.vue +++ b/frontend/src/features/auth/views/MobileProfileView.vue @@ -691,670 +691,4 @@ function resolveAvatarURL(url: string | undefined | null) { - + diff --git a/frontend/src/features/financial/__tests__/financialApi.spec.ts b/frontend/src/features/financial/__tests__/financialApi.spec.ts new file mode 100644 index 0000000..549e9e8 --- /dev/null +++ b/frontend/src/features/financial/__tests__/financialApi.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest' + +const apiClient = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), +})) + +vi.mock('@/shared/api/client', () => ({ apiClient })) + +import { fetchWalletBalance, fetchWalletLedger } from '@/features/wallet/api/wallet' +import { + cancelWithdrawal, + createPaymentAccount, + createWithdrawal, + deletePaymentAccount, + fetchPaymentAccount, + fetchPaymentAccounts, + fetchWithdrawal, + fetchWithdrawals, + setDefaultPaymentAccount, + updatePaymentAccount, +} from '@/features/wallet/api/withdrawal' +import { fetchAdminPayments } from '@/features/admin/api/adminPayments' + +describe('financial API clients', () => { + it('fetches wallet balance and paginated ledger with server data', async () => { + apiClient.get + .mockResolvedValueOnce({ data: { data: { available_balance_cent: 100 } } }) + .mockResolvedValueOnce({ data: { data: { items: [], total: 0, page: 2, page_size: 10 } } }) + + await expect(fetchWalletBalance()).resolves.toEqual({ available_balance_cent: 100 }) + await expect(fetchWalletLedger(2, 10)).resolves.toEqual({ + items: [], + total: 0, + page: 2, + page_size: 10, + }) + expect(apiClient.get).toHaveBeenNthCalledWith(2, '/wallet/ledger', { + params: { page: 2, page_size: 10 }, + }) + }) + + it('supports payment-account CRUD and default selection', async () => { + const account = { id: 7, account_type: 'alipay' } + apiClient.get + .mockResolvedValueOnce({ data: { data: { items: [account], total: 1 } } }) + .mockResolvedValueOnce({ data: { data: account } }) + apiClient.post.mockResolvedValueOnce({ data: { data: account } }) + apiClient.put.mockResolvedValueOnce({ data: { data: account } }) + apiClient.delete.mockResolvedValueOnce({ data: { data: { deleted: true } } }) + apiClient.post.mockResolvedValueOnce({ data: { data: { updated: true } } }) + + await expect(fetchPaymentAccounts()).resolves.toMatchObject({ items: [account], total: 1 }) + await expect(fetchPaymentAccount(7)).resolves.toEqual(account) + await expect( + createPaymentAccount({ account_type: 'alipay', account_name: 'A', account_no: 'x' }) + ).resolves.toEqual(account) + await expect(updatePaymentAccount(7, { is_default: true })).resolves.toEqual(account) + await expect(deletePaymentAccount(7)).resolves.toEqual({ deleted: true }) + await expect(setDefaultPaymentAccount(7)).resolves.toEqual({ updated: true }) + }) + + it('supports withdrawal lifecycle API calls', async () => { + const withdrawal = { id: 9, status: 'pending' } + apiClient.post + .mockResolvedValueOnce({ data: { data: withdrawal } }) + .mockResolvedValueOnce({ data: { data: { cancelled: true } } }) + apiClient.get + .mockResolvedValueOnce({ data: { data: { items: [withdrawal], total: 1 } } }) + .mockResolvedValueOnce({ data: { data: withdrawal } }) + + await expect(createWithdrawal({ payment_account_id: 7, amount_cent: 1000 })).resolves.toEqual( + withdrawal + ) + await expect(fetchWithdrawals(1, 20)).resolves.toMatchObject({ items: [withdrawal] }) + await expect(fetchWithdrawal(9)).resolves.toEqual(withdrawal) + await expect(cancelWithdrawal(9)).resolves.toEqual({ cancelled: true }) + }) + + it('filters empty admin-payment query values and normalizes pagination', async () => { + apiClient.get.mockResolvedValueOnce({ data: { data: undefined } }) + + await expect( + fetchAdminPayments({ status: '', provider: 'mock', page: 3, silent: true }) + ).resolves.toEqual({ items: [], total: 0, page: 3, page_size: 20 }) + expect(apiClient.get).toHaveBeenLastCalledWith('/admin/payments', { + params: { provider: 'mock', page: 3 }, + silent: true, + }) + }) +}) diff --git a/frontend/src/features/listings/views/ListingDetailView.css b/frontend/src/features/listings/views/ListingDetailView.css new file mode 100644 index 0000000..acae137 --- /dev/null +++ b/frontend/src/features/listings/views/ListingDetailView.css @@ -0,0 +1,680 @@ +.detail-back-bar { + /* 吸顶固定:始终显示在顶部导航栏(64px)下方,滚动时不跟随页面移走 */ + position: sticky; + top: 78px; + z-index: 20; + display: flex; + align-items: center; + margin-bottom: 14px; +} + +.detail-back-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 36px; + padding: 0 14px; + border: 1px solid #eef1f5; + border-radius: 10px; + background: #ffffff; + color: #475569; + font-size: 13px; + font-weight: 700; + cursor: pointer; + box-shadow: 0 2px 8px rgba(23, 35, 61, 0.08); + transition: + border-color 0.2s ease, + color 0.2s ease, + background 0.2s ease, + box-shadow 0.2s ease; +} + +.detail-back-btn:hover { + border-color: #ff6a00; + background: #fff7ed; + color: #ff6a00; + box-shadow: 0 4px 12px rgba(255, 106, 0, 0.12); +} + +.pc-detail-layout { + grid-template-columns: minmax(0, 1fr) 420px; + align-items: start; +} + +.pc-detail-main { + overflow: hidden; +} + +.detail-hero-card { + position: relative; + display: grid; + height: 340px; + overflow: hidden; + background: #111827; + color: #ffffff; +} + +.detail-hero-card img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.detail-hero-card > span { + display: grid; + height: 340px; + place-items: center; + font-size: 32px; + font-weight: 900; +} + +.detail-hero-card::after { + position: absolute; + inset: 0; + content: ''; + background: + linear-gradient(180deg, rgba(15, 23, 42, 0.06), rgba(15, 23, 42, 0.58)), + linear-gradient(90deg, rgba(15, 23, 42, 0.76), rgba(15, 23, 42, 0.08) 62%); +} + +.detail-hero-overlay { + position: absolute; + inset: auto 0 0; + z-index: 1; + max-width: 860px; + padding: 28px; +} + +.detail-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 14px; +} + +.detail-tags span { + display: inline-flex; + align-items: center; + min-height: 30px; + padding: 0 10px; + border: 1px solid rgba(255, 255, 255, 0.34); + border-radius: 999px; + background: rgba(255, 255, 255, 0.14); + color: #ffffff; + font-size: 13px; + font-weight: 900; +} + +.detail-code-chip { + width: fit-content; + margin-top: 10px; + padding: 5px 10px; + border: 1px solid rgba(147, 197, 253, 0.6); + border-radius: 999px; + background: rgba(239, 246, 255, 0.92); + color: #2563eb; + font-size: 12px; + font-weight: 900; + cursor: pointer; +} + +.detail-favorite-chip { + display: inline-flex; + align-items: center; + gap: 6px; + width: fit-content; + margin-top: 8px; + padding: 7px 12px; + border: 1px solid rgba(255, 106, 0, 0.42); + border-radius: 999px; + background: rgba(255, 106, 0, 0.18); + color: #fff7ed; + font-size: 12px; + font-weight: 900; + cursor: pointer; +} + +.detail-hero-overlay h1 { + margin: 0; + color: #ffffff; + font-size: 30px; + line-height: 1.25; +} + +.detail-hero-overlay p { + margin: 8px 0 0; + color: rgba(255, 255, 255, 0.82); + font-size: 16px; + font-weight: 800; +} + +.detail-body { + display: grid; + gap: 18px; + padding: 24px; +} + +.detail-summary-row { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 14px; +} + +.detail-metric, +.detail-section { + border: 1px solid #edf0f4; + border-radius: 16px; + background: #ffffff; +} + +.detail-metric { + padding: 18px; + background: #f8fafc; +} + +.detail-metric span { + display: block; + color: #8b9cb5; + font-size: 13px; + font-weight: 900; +} + +.detail-metric strong { + display: block; + margin-top: 8px; + color: #17233d; + font-size: 28px; + line-height: 1.1; +} + +.detail-metric.is-coin strong { + color: #1477ff; +} + +.detail-metric.is-price strong { + color: #ef4444; +} + +.detail-section { + padding: 22px; +} + +.detail-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.detail-section-head h2 { + margin: 0; + color: #17233d; + font-size: 20px; +} + +.detail-section-head span { + color: #8b9cb5; + font-size: 13px; + font-weight: 900; +} + +.detail-chip-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.detail-chip-row span { + display: inline-flex; + align-items: center; + min-height: 32px; + padding: 0 10px; + border: 1px solid rgba(255, 106, 0, 0.28); + border-radius: 8px; + background: #fff7ed; + color: #ea580c; + font-size: 13px; + font-weight: 900; +} + +.detail-info-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin: 18px 0 0; +} + +.detail-info-grid div { + min-width: 0; + border-radius: 12px; + background: #f8fafc; + padding: 14px; +} + +.detail-info-grid dt { + color: #8b9cb5; + font-size: 12px; + font-weight: 900; +} + +.detail-info-grid dd { + margin: 8px 0 0; + overflow-wrap: anywhere; + color: #17233d; + font-size: 15px; + font-weight: 900; +} + +.detail-resource-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.detail-resource-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px 12px; + border-radius: 12px; + background: #f8fafc; + padding: 14px; +} + +.detail-resource-card span { + min-width: 0; + color: #475569; + font-size: 14px; + font-weight: 900; +} + +.detail-resource-card strong { + color: #17233d; + font-size: 18px; +} + +.detail-resource-card em { + display: flex; + align-items: center; + justify-content: space-between; + grid-column: 1 / -1; + font-size: 12px; + font-style: normal; + font-weight: 900; +} + +.detail-resource-card em b { + color: #ff6a00; +} + +.detail-resource-card em small { + color: #17233d; + font-size: 13px; + font-weight: 900; +} + +.skin-groups { + display: grid; + gap: 16px; +} + +.skin-group h3 { + margin: 0 0 10px; + color: #475569; + font-size: 15px; +} + +.detail-description { + margin: 0; + color: #52616f; + font-size: 15px; + line-height: 1.8; +} + +.detail-screenshot-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.detail-screenshot { + margin: 0; +} + +.detail-screenshot-image { + width: 100%; + aspect-ratio: 16 / 10; + overflow: hidden; + border-radius: 12px; + border: 1px solid #edf0f4; + background: #f8fafc; + cursor: zoom-in; +} + +.detail-screenshot-image :deep(.el-image__inner) { + width: 100%; + height: 100%; + object-fit: cover; +} + +.detail-screenshot figcaption { + margin-top: 8px; + color: #64748b; + font-size: 13px; + font-weight: 800; + text-align: center; +} + +.pc-order-card { + max-width: none; + border-radius: 20px; + padding: 24px; + overflow: hidden; +} + +.order-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + margin-bottom: 10px; +} + +.order-card-head h2 { + margin: 4px 0 0; + color: #17233d; + font-size: 22px; + line-height: 1.2; +} + +.order-eyebrow { + display: inline-flex; + align-items: center; + min-height: 24px; + border-radius: 999px; + background: #f1f5f9; + padding: 0 10px; + color: #64748b; + font-size: 12px; + font-weight: 900; +} + +.order-state { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 58px; + min-height: 28px; + border-radius: 999px; + background: #ecfdf5; + color: #16a34a; + font-size: 12px; + font-weight: 900; + white-space: nowrap; +} + +.order-state.is-busy { + background: #fff7ed; + color: #ea580c; +} + +.order-form { + display: grid; + gap: 14px; +} + +.order-safe-text { + margin-bottom: 18px; + color: #7a89a5; + font-size: 15px; + font-weight: 700; +} + +.order-total-box { + position: relative; + display: grid; + gap: 12px; + margin-bottom: 0; + border: 1px solid rgba(245, 158, 11, 0.2); + background: + linear-gradient(135deg, rgba(255, 251, 235, 0.96), rgba(255, 247, 237, 0.92)), #fffbeb; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.86); +} + +.order-total-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding-bottom: 14px; + border-bottom: 1px solid rgba(217, 119, 6, 0.14); +} + +.order-total-head span { + color: #9a5a16; + font-size: 14px; + font-weight: 900; +} + +.order-total-head strong { + margin: 0; + color: #ef4444; + font-size: 34px; + line-height: 1; + text-align: right; +} + +.order-price-breakdown { + display: grid; + gap: 10px; + padding: 0; +} + +.order-price-breakdown div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.order-price-breakdown span, +.order-deposit-row span { + color: #8a5a12; + font-size: 13px; + font-weight: 900; +} + +.order-price-breakdown strong, +.order-deposit-row strong { + margin: 0; + color: #17233d; + font-size: 16px; + line-height: 1.2; +} + +.order-deposit-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-top: 12px; + border-top: 1px solid rgba(217, 119, 6, 0.14); +} + +.order-deposit-row strong { + color: #92400e; + font-size: 18px; +} + +.order-check-list { + display: grid; + gap: 10px; + margin: 0; +} + +.order-check-list div { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 16px; + min-height: 44px; + border: 1px solid #eef2f7; + border-radius: 12px; + background: #f8fafc; + padding: 0 14px; +} + +.order-check-list dt { + color: #8b9cb5; + font-size: 13px; + font-weight: 900; +} + +.order-check-list dd { + margin: 0; + color: #17233d; + font-size: 14px; + font-weight: 900; + text-align: right; + overflow-wrap: anywhere; +} + +.full-control { + width: 100%; +} + +.order-action-group { + display: grid; + gap: 10px; + padding-top: 2px; +} + +.order-primary-btn { + min-height: 48px; + border: 0; + background: #ff8a00; + color: #ffffff; + font-size: 15px; + font-weight: 900; + box-shadow: 0 12px 24px rgba(255, 106, 0, 0.24); +} + +.order-primary-btn:hover, +.order-primary-btn:focus { + background: #ff7a00; + color: #ffffff; +} + +.order-primary-btn.is-disabled, +.order-primary-btn.is-disabled:hover { + background: #f3a94a; + color: rgba(255, 255, 255, 0.86); + box-shadow: none; +} + +.favorite-order-btn { + min-height: 46px; + margin-left: 0; + border-color: #ff6a00; + background: #ffffff; + color: #ff6a00; + font-size: 15px; + font-weight: 900; +} + +.favorite-order-btn:hover, +.favorite-order-btn:focus { + border-color: #ff6a00; + background: #fff7ed; + color: #ea580c; +} + +.agreement-dialog-body { + display: grid; + gap: 14px; +} + +.agreement-tip { + margin: 0; + color: #64748b; + font-size: 14px; + font-weight: 700; +} + +.agreement-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.agreement-panel { + display: grid; + gap: 10px; + min-width: 0; +} + +.agreement-panel h3 { + margin: 0; + color: #17233d; + font-size: 16px; + font-weight: 900; +} + +.agreement-content { + height: 260px; + overflow: auto; + border: 1px solid #d8dee9; + border-radius: 8px; + background: #f8fafc; + padding: 14px; + color: #1f2937; + font-size: 13px; + font-weight: 700; + line-height: 1.75; + white-space: pre-wrap; +} + +.agreement-read-hint { + color: #f97316; + font-size: 12px; + font-weight: 800; +} + +@media (max-width: 1180px) { + .pc-detail-layout, + .detail-screenshot-grid { + grid-template-columns: 1fr; + } + + .detail-summary-row, + .detail-info-grid, + .detail-resource-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .detail-hero-card, + .detail-hero-card img, + .detail-hero-card > span { + height: 300px; + } + + .detail-hero-overlay { + padding: 24px; + } + + .detail-hero-overlay h1 { + font-size: 26px; + } +} + +@media (max-width: 720px) { + .detail-summary-row, + .detail-info-grid, + .detail-resource-grid { + grid-template-columns: 1fr; + } + + .agreement-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/features/listings/views/ListingDetailView.vue b/frontend/src/features/listings/views/ListingDetailView.vue index eb34b75..5b4edf6 100644 --- a/frontend/src/features/listings/views/ListingDetailView.vue +++ b/frontend/src/features/listings/views/ListingDetailView.vue @@ -630,685 +630,4 @@ function goBack() { - + diff --git a/frontend/src/features/listings/views/MobileListingDetailView.css b/frontend/src/features/listings/views/MobileListingDetailView.css new file mode 100644 index 0000000..0fe21ed --- /dev/null +++ b/frontend/src/features/listings/views/MobileListingDetailView.css @@ -0,0 +1,485 @@ +.mobile-detail { + min-height: 100dvh; + background: #f5f7fa; + padding-bottom: calc(80px + env(safe-area-inset-bottom)); +} + +/* ========== 顶部导航 ========== */ +.page-header { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + height: 48px; + padding: 0 12px; + background: #fff; + border-bottom: 1px solid #eee; +} + +.page-header h1 { + flex: 1; + margin: 0; + font-size: 17px; + font-weight: 700; + text-align: center; +} + +.back-btn, +.favorite-header-btn { + display: grid; + width: 36px; + height: 36px; + place-items: center; + border: none; + background: none; + color: #333; + cursor: pointer; +} + +.favorite-header-btn { + color: #ff6a00; +} + +.header-spacer { + width: 36px; +} + +.center-loading { + display: flex; + justify-content: center; + padding: 60px 0; +} + +/* ========== 防骗提示 ========== */ +.fraud-tip { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background: #fff8e1; + font-size: 11px; + color: #e65100; + line-height: 1.4; +} + +/* ========== 封面图 ========== */ +.cover-area { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + background: #e8e8e8; + overflow: hidden; +} + +.cover-img { + width: 100%; + height: 100%; + cursor: zoom-in; +} + +.cover-img :deep(.el-image__inner) { + width: 100%; + height: 100%; + object-fit: cover; +} + +.cover-placeholder { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + color: #999; + font-size: 12px; +} + +.cover-count { + position: absolute; + right: 10px; + bottom: 10px; + padding: 2px 8px; + border-radius: 8px; + background: rgba(0, 0, 0, 0.5); + color: #fff; + font-size: 11px; +} + +/* ========== 信息卡片 ========== */ +.info-card { + margin: 10px 12px; + padding: 14px; + background: #fff; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.info-tag-row { + display: flex; + gap: 6px; + margin-bottom: 8px; +} + +.mobile-code-chip { + margin-bottom: 8px; + padding: 4px 9px; + border: 1px solid #dbeafe; + border-radius: 999px; + background: #eff6ff; + color: #2563eb; + font-size: 12px; + font-weight: 800; +} + +.mobile-favorite-chip { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: 6px; + margin-bottom: 8px; + padding: 4px 9px; + border: 1px solid #fed7aa; + border-radius: 999px; + background: #fff7ed; + color: #ff6a00; + font-size: 12px; + font-weight: 800; +} + +.detail-title { + margin: 0 0 6px; + font-size: 20px; + font-weight: 700; + color: #1a1a1a; + line-height: 1.35; +} + +.detail-desc { + margin: 0; + font-size: 13px; + color: #666; + line-height: 1.5; +} + +/* ========== 资产指标 ========== */ +.metric-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + padding: 0 12px; +} + +.metric-item { + background: #fff; + border-radius: 10px; + padding: 10px 4px; + text-align: center; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.metric-label { + display: block; + font-size: 11px; + color: #999; + margin-bottom: 4px; +} + +.metric-value { + display: block; + overflow: hidden; + font-size: 15px; + color: #1a1a1a; + text-overflow: ellipsis; + white-space: nowrap; +} + +.metric-value.coin { + color: #1477ff; +} + +.metric-value.price { + color: #ff5f00; +} + +/* ========== 账号资料 ========== */ +.info-line { + display: flex; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid #f5f5f5; + font-size: 13px; +} + +.info-line:last-child { + border-bottom: none; +} + +.info-label { + color: #999; +} + +.info-text { + color: #333; + font-weight: 500; +} + +/* ========== 截图列表 ========== */ +.card-subtitle { + margin: 0 0 10px; + font-size: 14px; + font-weight: 700; +} + +.detail-chip-row { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-bottom: 10px; +} + +.detail-chip-row span { + display: inline-flex; + align-items: center; + min-height: 28px; + border: 1px solid #ff8a1f; + border-radius: 5px; + color: #ff7900; + padding: 0 7px; + font-size: 12px; + font-weight: 800; +} + +.resource-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.resource-pill { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 4px 8px; + border-radius: 8px; + background: #f7f9fc; + padding: 10px; +} + +.resource-pill span { + min-width: 0; + color: #5f6b7a; + font-size: 12px; + font-weight: 700; +} + +.resource-pill strong { + color: #17233d; + font-size: 14px; +} + +.resource-pill em { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + grid-column: 1 / -1; + font-size: 11px; + font-style: normal; + font-weight: 800; +} + +.resource-pill em b { + color: #ff7900; +} + +.resource-pill em small { + min-width: 0; + color: #17233d; + font-size: 11px; + font-weight: 800; + text-align: right; +} + +.skin-detail-group + .skin-detail-group { + margin-top: 12px; +} + +.skin-detail-group p { + margin: 0 0 8px; + color: #5f6b7a; + font-size: 12px; + font-weight: 900; +} + +.screenshot-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; +} + +.screenshot-item { + margin: 0; +} + +.screenshot-thumb { + width: 100%; + aspect-ratio: 4 / 3; + overflow: hidden; + border-radius: 8px; + background: #f8fafc; + cursor: pointer; +} + +.screenshot-thumb :deep(.el-image__inner) { + width: 100%; + height: 100%; + object-fit: cover; +} + +.screenshot-item figcaption { + margin-top: 4px; + color: #6b7280; + font-size: 11px; + font-weight: 700; + text-align: center; +} + +/* ========== 底部留白 ========== */ +.bottom-spacer { + height: 20px; +} + +/* ========== 底部下单栏 ========== */ +.order-bar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 100; + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px calc(8px + env(safe-area-inset-bottom)); + background: #fff; + border-top: 1px solid #eee; + box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.04); +} + +.order-bar-left { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +.order-price { + display: flex; + align-items: baseline; + gap: 4px; +} + +.price-label { + font-size: 11px; + color: #999; +} + +.price-amount { + font-size: 18px; + font-weight: 900; + color: #ff5f00; +} + +.order-price-detail { + display: flex; + flex-wrap: wrap; + gap: 4px 8px; + color: #8a5a12; + font-size: 10px; + font-weight: 700; + line-height: 1.3; +} + +.order-btn { + flex-shrink: 0; + padding: 0 20px; + height: 40px; + font-size: 14px; + font-weight: 700; + background: #ff6a00 !important; + border: none !important; +} + +.agreement-popup { + max-height: 92vh; +} + +.agreement-popup-body { + display: grid; + gap: 12px; + max-height: 92vh; + overflow: auto; + padding: 20px 16px calc(16px + env(safe-area-inset-bottom)); +} + +.agreement-popup-body h3 { + margin: 0; + color: #17233d; + font-size: 18px; + font-weight: 900; +} + +.agreement-popup-body > p { + margin: 0; + color: #6b7280; + font-size: 13px; + font-weight: 700; +} + +.agreement-panel { + display: grid; + gap: 8px; +} + +.agreement-panel > strong { + color: #17233d; + font-size: 14px; +} + +.agreement-content { + height: 190px; + overflow: auto; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f8fafc; + padding: 12px; + color: #1f2937; + font-size: 12px; + font-weight: 700; + line-height: 1.7; + white-space: pre-wrap; +} + +.agreement-read-hint { + color: #ff6a00; + font-size: 12px; + font-weight: 800; +} + +.agreement-confirm-btn { + margin-top: 4px; + background: #ff6a00 !important; + border: none !important; + font-weight: 800; +} + +/* ========== 空状态 ========== */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 80px 0; + color: #999; + font-size: 14px; +} diff --git a/frontend/src/features/listings/views/MobileListingDetailView.vue b/frontend/src/features/listings/views/MobileListingDetailView.vue index 5ab074a..12bf339 100644 --- a/frontend/src/features/listings/views/MobileListingDetailView.vue +++ b/frontend/src/features/listings/views/MobileListingDetailView.vue @@ -571,490 +571,4 @@ function handleToggleFavorite() { - + diff --git a/frontend/src/features/orders/composables/__tests__/useOrderPaymentCashier.spec.ts b/frontend/src/features/orders/composables/__tests__/useOrderPaymentCashier.spec.ts new file mode 100644 index 0000000..549d251 --- /dev/null +++ b/frontend/src/features/orders/composables/__tests__/useOrderPaymentCashier.spec.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('qrcode', () => ({ + default: { toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,qr') }, +})) + +import type { PaymentOrder } from '@/features/orders/api/orders' +import { + paymentPayURL, + useOrderPaymentCashier, +} from '@/features/orders/composables/useOrderPaymentCashier' + +function payment(overrides: Partial = {}): PaymentOrder { + return { + id: 1, + payment_no: 'PAY1', + order_id: 2, + order_no: 'ORD1', + provider: 'mock', + third_order_id: 'THIRD1', + provider_order_id: '', + pay_way: 'ZFBZF', + jspay_flag: '2', + amount_cent: 100, + status: 'paying', + paid: false, + created_at: '', + updated_at: '', + ...overrides, + } +} + +function options() { + return { + notifySuccess: vi.fn(), + notifyError: vi.fn(), + notifyInfo: vi.fn(), + notifyFallback: vi.fn(), + queryPayment: vi.fn(), + } +} + +describe('useOrderPaymentCashier', () => { + beforeEach(() => vi.clearAllMocks()) + + it('selects the first available payment URL', () => { + expect(paymentPayURL(payment({ jspay_url: '', td_code: 'https://td' }))).toBe('https://td') + expect(paymentPayURL(payment({ jspay_info: 'https://info' }))).toBe('https://info') + expect(paymentPayURL(null)).toBe('') + }) + + it('handles an already-paid payment and invokes the callback', async () => { + const current = options() + const onPaid = vi.fn() + const cashier = useOrderPaymentCashier(current) + + await cashier.openPaymentCashier(payment({ paid: true }), onPaid) + + expect(current.notifySuccess).toHaveBeenCalledWith('支付成功,正在进入群消息') + expect(onPaid).toHaveBeenCalledOnce() + expect(cashier.paymentPopupVisible.value).toBe(false) + }) + + it('uses fallback feedback for non-http payment URLs', async () => { + const current = options() + const cashier = useOrderPaymentCashier(current) + + await cashier.openPaymentCashier(payment({ jspay_url: 'alipay://pay' })) + + expect(current.notifyFallback).toHaveBeenCalledWith('alipay://pay') + }) + + it('renders a QR code, reports pending status, then handles paid polling result', async () => { + const current = options() + current.queryPayment + .mockResolvedValueOnce(payment({ jspay_url: 'https://pay', paid: false })) + .mockResolvedValueOnce(payment({ paid: true })) + const onPaid = vi.fn() + const cashier = useOrderPaymentCashier({ ...current, pollIntervalMs: 60_000 }) + + await cashier.openPaymentCashier(payment({ jspay_url: 'https://pay' }), onPaid) + expect(cashier.paymentQRCodeURL.value).toContain('data:image') + await cashier.refreshPaymentStatus() + expect(current.notifyInfo).toHaveBeenCalledWith('支付暂未完成') + await cashier.refreshPaymentStatus() + expect(current.notifySuccess).toHaveBeenCalled() + expect(onPaid).toHaveBeenCalledOnce() + cashier.stopPaymentPolling() + }) + + it('reports QR rendering errors without leaving the generating state set', async () => { + const current = options() + const cashier = useOrderPaymentCashier(current) + const qrcode = await import('qrcode') + vi.mocked(qrcode.default.toDataURL).mockRejectedValueOnce(new Error('render failed')) + + await cashier.openPaymentCashier(payment({ jspay_url: 'https://pay' })) + + expect(current.notifyError).toHaveBeenCalledWith('二维码生成失败') + expect(cashier.qrGenerating.value).toBe(false) + cashier.stopPaymentPolling() + }) +}) diff --git a/frontend/src/features/orders/views/MobileOrderDetailView.css b/frontend/src/features/orders/views/MobileOrderDetailView.css new file mode 100644 index 0000000..5869495 --- /dev/null +++ b/frontend/src/features/orders/views/MobileOrderDetailView.css @@ -0,0 +1,259 @@ +.mobile-order-detail-view { + min-height: 100dvh; + background: #f5f7fa; + padding-bottom: 40px; +} + +/* ========== Header ========== */ +.page-header { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + height: 48px; + padding: 0 12px; + background: #fff; + border-bottom: 1px solid #eee; +} + +.page-header h1 { + flex: 1; + margin: 0; + font-size: 17px; + font-weight: 700; + text-align: center; +} + +.back-btn, +.chat-btn { + display: grid; + width: 36px; + height: 36px; + place-items: center; + border: none; + background: none; + color: #333; + cursor: pointer; +} + +.chat-btn:disabled { + color: #a1a1aa; +} + +.header-spacer { + width: 36px; +} + +.center-loading { + display: flex; + justify-content: center; + padding: 60px 0; +} + +/* ========== Card layout ========== */ +.card-section { + margin: 12px; + background: #fff; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + overflow: hidden; + padding: 14px; +} + +.info-card { + padding: 16px; +} + +.card-header-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.order-no { + font-size: 12px; + color: #999; + font-family: monospace; +} + +.listing-code-chip { + margin-bottom: 10px; + padding: 4px 9px; + border: 1px solid #dbeafe; + border-radius: 999px; + background: #eff6ff; + color: #2563eb; + font-size: 12px; + font-weight: 800; +} + +.order-title { + font-size: 18px; + font-weight: 700; + color: #1a1a1a; + margin: 0 0 14px 0; +} + +.order-meta-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + background: #f8f9fa; + border-radius: 8px; + padding: 10px; +} + +.meta-item { + display: flex; + flex-direction: column; + align-items: center; +} + +.meta-label { + font-size: 11px; + color: #8c96a0; + margin-bottom: 4px; +} + +.meta-value { + font-size: 13px; + color: #2c3e50; + font-weight: 600; +} + +.meta-value.accent { + color: #ff5f00; + font-weight: 700; +} + +.meta-value.income { + color: #16a34a; + font-weight: 700; +} + +.meta-note { + margin-top: 2px; + font-size: 10px; + color: #2563eb; +} + +.action-card { + padding: 16px; +} + +.section-title { + font-size: 15px; + font-weight: 700; + color: #2c3e50; + margin: 0 0 8px 0; +} + +.action-hint { + font-size: 12px; + color: #7f8c8d; + line-height: 1.5; + margin: 0 0 14px 0; +} + +.action-buttons { + display: flex; + flex-direction: column; + gap: 10px; +} + +.action-field { + background: #f8fafc; + border-radius: 8px; + margin-bottom: 12px; + overflow: hidden; +} + +.custom-inline-input { + border: none; + background: transparent; + outline: none; + /* iOS Safari:font-size < 16px 时聚焦会自动放大页面 */ + font-size: 16px; + flex: 1; +} + +.no-snapshot-hint { + text-align: center; + padding: 10px; + color: #cbd5e0; + font-size: 13px; +} + +/* ========== Popups ========== */ +.mobile-popup-form { + max-height: 85%; + padding-bottom: env(safe-area-inset-bottom); +} + +.popup-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + border-bottom: 1px solid #edf2f7; +} + +.popup-header h3 { + margin: 0; + font-size: 16px; + font-weight: 700; +} + +.popup-close { + border: none; + background: transparent; + font-size: 16px; + color: #a0aec0; + cursor: pointer; +} + +.popup-body { + padding: 16px; + overflow-y: auto; +} + +.form-hint-info { + font-size: 12px; + color: #718096; + line-height: 1.5; + margin: 0 0 12px 0; +} + +.popup-field { + background: #f7fafc; + border-radius: 8px; + margin-bottom: 12px; +} + +.evidence-upload-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13px; + color: #4a5568; + margin-bottom: 16px; +} + +.popup-submit { + margin-top: 10px; +} + +.custom-select { + border: none; + background: transparent; + width: 100%; + font-size: 14px; + color: #2d3748; + outline: none; +} + +.empty-wrap { + padding: 60px 0; +} diff --git a/frontend/src/features/orders/views/MobileOrderDetailView.vue b/frontend/src/features/orders/views/MobileOrderDetailView.vue index e92194f..a3d1e62 100644 --- a/frontend/src/features/orders/views/MobileOrderDetailView.vue +++ b/frontend/src/features/orders/views/MobileOrderDetailView.vue @@ -660,264 +660,4 @@ async function copyListingCode() { - + diff --git a/frontend/src/features/orders/views/OrderDetailView.css b/frontend/src/features/orders/views/OrderDetailView.css new file mode 100644 index 0000000..b66e5cf --- /dev/null +++ b/frontend/src/features/orders/views/OrderDetailView.css @@ -0,0 +1,773 @@ +.order-detail-page { + max-width: 1280px; + margin: 0 auto; +} + +.page-header { + margin-bottom: 18px; +} + +.page-header.centered { + max-width: 940px; + margin-right: auto; + margin-left: auto; +} + +.header-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.header-info { + flex: 1; +} + +.header-info h1 { + margin: 4px 0 0; + font-size: 26px; + line-height: 1.25; +} + +.order-meta { + color: #64748b; + margin: 6px 0 10px; +} + +.content-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 280px; + gap: 16px; + align-items: start; +} + +.content-layout.centered { + grid-template-columns: minmax(0, 1fr); + max-width: 940px; + margin: 0 auto; +} + +.main-content { + min-width: 0; +} + +.action-sidebar { + position: sticky; + top: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.sidebar-card { + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; + box-shadow: 0 1px 4px rgba(15, 23, 42, 0.05); +} + +.sidebar-card.highlight-card { + background: #f8fbff; + border-color: #3b82f6; +} + +.sidebar-title { + font-size: 14px; + font-weight: 700; + color: #1f2937; + margin: 0; +} + +.sidebar-hint { + margin: 0; + font-size: 12px; + color: #64748b; + line-height: 1.45; +} + +.sidebar-card .el-button { + width: 100%; +} + +.support-dispute-icon { + display: grid; + flex: none; + width: 32px; + height: 32px; + place-items: center; + border-radius: 8px; + background: #fff1d6; + color: #d97706; + font-size: 18px; +} + +.support-dispute-copy { + display: grid; + gap: 2px; + min-width: 0; + flex: 1; +} + +.support-dispute-copy strong { + color: #1f2937; + font-size: 14px; + line-height: 1.3; +} + +.support-dispute-copy span { + color: #7c5a16; + font-size: 12px; + line-height: 1.45; +} + +.order-progress-section { + margin-bottom: 12px; + padding: 16px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.metric-card { + display: flex; + flex-direction: column; + gap: 6px; + min-height: 86px; + padding: 14px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #ffffff; + transition: all 0.2s; +} + +.metric-card:hover { + border-color: #cbd5e1; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.metric-card.primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border: none; + color: #ffffff; +} + +.metric-card.primary .metric-label { + color: rgba(255, 255, 255, 0.9); +} + +.metric-card.primary .metric-value { + color: #ffffff; +} + +.metric-card.highlight { + background: linear-gradient(135deg, #ff6a00 0%, #ee0979 100%); + border: none; + color: #ffffff; +} + +.metric-card.highlight .metric-label { + color: rgba(255, 255, 255, 0.9); +} + +.metric-card.highlight .metric-value { + color: #ffffff; +} + +.metric-card.income { + background: #f0fdf4; + border-color: #bbf7d0; +} + +.metric-card.income .metric-label { + color: #15803d; +} + +.metric-card.income .metric-value { + color: #16a34a; +} + +.metric-label { + font-size: 12px; + color: #64748b; + font-weight: 500; +} + +.metric-value { + font-size: 20px; + font-weight: 700; + color: #1f2937; + line-height: 1.2; +} + +.metric-value.amount { + color: #ff6a00; +} + +.metric-note { + font-size: 12px; + color: #2563eb; +} + +.info-section { + margin-bottom: 16px; +} + +.info-card { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + padding: 14px 16px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.info-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0; +} + +.info-label { + font-size: 13px; + color: #64748b; + font-weight: 500; +} + +.info-value { + font-size: 13px; + color: #1f2937; + font-weight: 600; + text-align: right; +} + +.action-card { + margin-top: 12px; + padding: 14px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.action-card.warning { + background: #fffbeb; + border-color: #fde68a; +} + +.action-message { + margin: 0 0 12px 0; + color: #1f2937; + font-size: 14px; +} + +.action-buttons { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} + +.section-header.compact span { + color: #64748b; + font-size: 12px; +} + +.section-header h2 { + font-size: 18px; + font-weight: 700; + color: #1f2937; + margin: 0; +} + +.form-section { + margin-bottom: 20px; +} + +.form-card { + display: grid; + gap: 12px; + padding: 16px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; +} + +.handoff-action-section { + margin-bottom: 14px; +} + +.handoff-action-card { + display: grid; + grid-template-columns: minmax(0, 1fr) 160px; + gap: 12px; + align-items: stretch; + padding: 16px; + border: 1px solid #bfdbfe; + border-radius: 8px; + background: linear-gradient(180deg, #f8fbff 0%, #eff6ff 100%); +} + +.handoff-action-card .el-button { + height: 100%; + min-height: 96px; +} + +.handoff-confirm-strip { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; + padding: 16px; + border: 1px solid #bfdbfe; + border-radius: 8px; + background: linear-gradient(180deg, #f8fbff 0%, #eff6ff 100%); +} + +.handoff-confirm-strip div { + display: grid; + gap: 4px; + flex: 1; + min-width: 0; +} + +.handoff-confirm-strip strong { + color: #1f2937; + font-size: 14px; +} + +.handoff-confirm-strip span { + color: #475569; + font-size: 12px; + line-height: 1.45; +} + +.secondary-actions { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 10px; + margin-top: 12px; +} + +.secondary-action-card { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border-radius: 8px; +} + +.secondary-action-card > div { + display: grid; + gap: 2px; + min-width: 0; + flex: 1; +} + +.secondary-action-card strong { + color: #1f2937; + font-size: 13px; + line-height: 1.3; +} + +.secondary-action-card span { + color: #64748b; + font-size: 12px; + line-height: 1.45; +} + +.secondary-action-card.danger { + border: 1px solid #fecaca; + background: #fff7f7; +} + +.secondary-action-card.support { + border: 1px solid #f8d99b; + background: #fffaf0; +} + +.form-hint { + margin: 0; + padding: 10px 12px; + background: #f8fafc; + border-left: 3px solid #3b82f6; + color: #475569; + font-size: 13px; + line-height: 1.5; + border-radius: 4px; +} + +.section-divider { + display: flex; + align-items: center; + justify-content: center; + margin: 24px 0; + position: relative; +} + +.section-divider::before, +.section-divider::after { + content: ''; + flex: 1; + height: 1px; + background: #e5e7eb; +} + +.section-divider span { + padding: 0 16px; + color: #9ca3af; + font-size: 14px; + font-weight: 500; +} + +.section-divider-mini { + display: flex; + align-items: center; + justify-content: center; + margin: 8px 0; + position: relative; +} + +.section-divider-mini::before, +.section-divider-mini::after { + content: ''; + flex: 1; + height: 1px; + background: #e5e7eb; +} + +.section-divider-mini span { + padding: 0 12px; + color: #9ca3af; + font-size: 12px; + font-weight: 500; +} + +.sub-title { + font-size: 15px; + font-weight: 600; + color: #374151; + margin: 0 0 6px 0; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 12px; +} + +.field-hint { + display: block; + margin-top: 6px; + color: #6b7280; + font-size: 12px; +} + +.dispute-dialog-body { + display: grid; + gap: 12px; +} + +.dispute-dialog-tip { + display: flex; + gap: 10px; + align-items: center; + padding: 10px 12px; + border: 1px solid #f8d99b; + border-radius: 8px; + background: #fffaf0; +} + +.dispute-dialog-tip p { + margin: 0; + color: #7c5a16; + font-size: 13px; + line-height: 1.5; +} + +.dispute-dialog-footer { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.upload-line { + padding: 12px; + background: #f9fafb; + border: 1px dashed #cbd5e1; + border-radius: 6px; +} + +.upload-line input[type='file'] { + width: 100%; + font-size: 14px; +} + +:global(.dispute-dialog) { + border-radius: 12px; +} + +:global(.dispute-dialog .el-dialog__body) { + padding: 14px 20px 8px; +} + +:global(.dispute-dialog .el-dialog__footer) { + padding: 12px 20px 18px; +} + +.pay-dialog-body { + display: grid; + gap: 20px; +} + +.pay-way-tip { + margin: 0 0 16px 0; + color: #64748b; + font-size: 14px; +} + +.pay-way-options { + display: grid; + gap: 12px; +} + +.pay-way-option { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 18px; + background: #ffffff; + border: 2px solid #e5e7eb; + border-radius: 12px; + cursor: pointer; + text-align: left; + transition: all 0.18s ease; +} + +.pay-way-option:hover { + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08); +} + +.pay-way-icon { + display: grid; + place-items: center; + width: 48px; + height: 48px; + border-radius: 12px; + color: #ffffff; + flex-shrink: 0; +} + +.pay-way-option.wechat:hover { + border-color: #07c160; +} + +.pay-way-option.wechat .pay-way-icon { + background: #07c160; +} + +.pay-way-option.alipay:hover { + border-color: #1677ff; +} + +.pay-way-option.alipay .pay-way-icon { + background: #1677ff; +} + +.pay-way-text { + display: flex; + flex-direction: column; + gap: 3px; +} + +.pay-way-text strong { + font-size: 16px; + font-weight: 600; + color: #1f2937; +} + +.pay-way-text small { + font-size: 13px; + color: #94a3b8; +} + +:global(.pay-way-dialog) { + border-radius: 16px; +} + +:global(.pay-way-dialog .el-dialog__header) { + padding: 20px 24px 0; +} + +:global(.pay-way-dialog .el-dialog__body) { + padding: 16px 24px 24px; +} + +.pay-summary { + padding: 16px 20px; + background: #f8fafc; + border-radius: 8px; +} + +.pay-summary-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.pay-summary-row span { + color: #64748b; + font-size: 14px; +} + +.pay-summary-row strong { + color: #111a44; + font-size: 28px; + font-weight: 700; +} + +.pay-qr-section { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + padding: 24px; + background: #f8fafc; + border-radius: 12px; +} + +.pay-qr-box { + width: 240px; + height: 240px; + display: grid; + place-items: center; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #ffffff; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +.pay-qr-box img { + width: 220px; + height: 220px; + display: block; +} + +.pay-instructions { + text-align: center; +} + +.pay-instructions h3 { + margin: 0 0 8px 0; + color: #111a44; + font-size: 18px; + font-weight: 600; +} + +.pay-instructions p { + margin: 0; + color: #64748b; + font-size: 14px; + line-height: 1.7; +} + +.pay-hint { + margin: 0; + padding: 16px; + background: #f8fafc; + color: #64748b; + font-size: 14px; + text-align: center; + border-radius: 8px; +} + +.pay-dialog-footer { + display: flex; + justify-content: center; + padding-top: 8px; +} + +:global(.order-pay-dialog) { + border-radius: 16px; +} + +:global(.order-pay-dialog .el-dialog__header) { + padding: 20px 24px; + border-bottom: 1px solid #e5e7eb; +} + +:global(.order-pay-dialog .el-dialog__body) { + padding: 24px; +} + +:global(.order-pay-dialog .el-dialog__footer) { + padding: 16px 24px; + border-top: 1px solid #e5e7eb; +} + +@media (max-width: 768px) { + .content-layout { + grid-template-columns: 1fr; + } + + .action-sidebar { + position: static; + order: -1; + } + + .header-top { + flex-direction: column; + } + + .detail-grid { + grid-template-columns: repeat(2, 1fr); + } + + .form-grid { + grid-template-columns: 1fr; + } + + .handoff-action-card, + .handoff-confirm-strip, + .secondary-action-card { + grid-template-columns: 1fr; + flex-direction: column; + align-items: stretch; + } + + .handoff-action-card .el-button { + min-height: 40px; + } + + .action-buttons { + flex-direction: column; + } + + .action-buttons .el-button { + width: 100%; + } + + .pay-qr-section { + padding: 16px; + } +} diff --git a/frontend/src/features/orders/views/OrderDetailView.vue b/frontend/src/features/orders/views/OrderDetailView.vue index 93d5c8f..9a5c690 100644 --- a/frontend/src/features/orders/views/OrderDetailView.vue +++ b/frontend/src/features/orders/views/OrderDetailView.vue @@ -823,778 +823,4 @@ watch([() => route.query.focus, order, loading], () => { - + diff --git a/frontend/src/features/orders/views/OrdersView.css b/frontend/src/features/orders/views/OrdersView.css new file mode 100644 index 0000000..0952e8b --- /dev/null +++ b/frontend/src/features/orders/views/OrdersView.css @@ -0,0 +1,396 @@ +.orders-toolbar { + margin-top: 24px; + display: flex; + align-items: center; + gap: 12px; +} + +.search-input { + max-width: 400px; +} + +.order-tabs { + margin-top: 16px; + border: 1px solid #e4e7ed; + border-radius: 8px; + background: #ffffff; + padding: 0 16px; +} + +.order-tabs :deep(.el-tabs__header) { + margin: 0; +} + +.order-tabs :deep(.el-tabs__nav-wrap::after) { + height: 0; +} + +.tab-label { + display: flex; + align-items: center; + gap: 6px; +} + +.tab-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 10px; + background: #ff6a00; + color: #ffffff; + font-size: 12px; + font-weight: 600; +} + +.order-tabs :deep(.is-active) .tab-count { + background: #ffffff; + color: #ff6a00; +} + +.orders-container { + margin-top: 24px; +} + +.empty-state { + margin-top: 60px; +} + +.orders-table { + display: block; +} + +.mobile-cards { + display: none; +} + +.biz-tag { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 40px; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; +} + +.biz-tag.rental { + color: #1477ff; + background: rgba(20, 119, 255, 0.1); +} + +.biz-tag.crash { + color: #ff6a00; + background: rgba(255, 106, 0, 0.1); +} + +.order-no-cell { + display: flex; + align-items: center; + gap: 8px; +} + +.order-no-text { + font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; + font-size: 13px; + color: #4a5568; +} + +.listing-code-line { + display: inline-flex; + width: fit-content; + align-items: center; + gap: 5px; + margin-top: 10px; + padding: 4px 9px; + border: 1px solid #dbeafe; + border-radius: 999px; + background: #eff6ff; + color: #2563eb; + font-size: 12px; + font-weight: 800; + cursor: pointer; +} + +.listing-code-line.compact { + margin-top: 0; + margin-bottom: 4px; +} + +.copy-icon { + cursor: pointer; + color: #9ca3af; + font-size: 14px; + transition: color 0.2s; +} + +.copy-icon:hover { + color: #ff6a00; +} + +.amount-cell { + display: flex; + flex-direction: column; + gap: 2px; +} + +.amount-value { + font-weight: 600; + color: #1f2937; +} + +.amount-label { + font-size: 12px; + color: #9ca3af; +} + +.amount-sub { + font-size: 12px; + color: #10b981; + font-weight: 600; +} + +.status-cell { + display: flex; + flex-direction: column; + gap: 4px; +} + +.countdown-badge { + display: inline-block; + padding: 2px 6px; + border-radius: 4px; + background: #fef3c7; + color: #d97706; + font-size: 11px; + font-weight: 500; +} + +.action-buttons { + display: flex; + gap: 8px; +} + +.muted { + color: #9ca3af; + font-size: 13px; +} + +.crash-product-cell, +.rental-product-cell { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.crash-product-cell { + flex-direction: row; + align-items: center; + gap: 12px; +} + +.crash-cover { + width: 48px; + height: 48px; + border-radius: 10px; + overflow: hidden; + background: #f0f3f8; + display: flex; + align-items: center; + justify-content: center; + color: #a0aec0; + font-size: 12px; + flex-shrink: 0; +} + +.crash-cover.lg { + width: 64px; + height: 64px; +} + +.crash-cover img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.crash-product-info { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.crash-product-info strong, +.rental-title { + font-size: 14px; + color: #1f2937; +} + +.crash-product-info span { + font-size: 12px; + color: #8a94a6; +} + +.crash-status { + display: inline-flex; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + background: #f1f5f9; + color: #64748b; +} + +.crash-status.pending_payment { + background: #fff7ed; + color: #ff6a00; +} + +.crash-status.paid { + background: #ecfdf5; + color: #059669; +} + +.crash-status.receiving { + background: #fff7ed; + color: #d97706; +} + +.crash-status.completed { + background: #eff6ff; + color: #2563eb; +} + +.crash-status.cancelled, +.crash-status.refunded { + background: #f8fafc; + color: #94a3b8; +} + +.crash-card-body { + display: grid; + grid-template-columns: 64px 1fr; + gap: 12px; +} + +.order-card { + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 16px; + background: #ffffff; + cursor: pointer; + transition: all 0.2s; + margin-bottom: 12px; +} + +.order-card:hover { + border-color: #ff6a00; + box-shadow: 0 4px 12px rgba(255, 106, 0, 0.1); +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.order-no-row { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.card-title { + font-size: 16px; + font-weight: 600; + color: #1f2937; + margin-bottom: 12px; +} + +.card-meta { + display: grid; + gap: 8px; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid #f3f4f6; +} + +.meta-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.meta-label { + font-size: 13px; + color: #6b7280; +} + +.meta-value { + display: flex; + flex-direction: column; + align-items: flex-end; + font-size: 14px; + color: #374151; + font-weight: 500; +} + +.meta-value em { + font-style: normal; + font-size: 12px; + color: #2563eb; +} + +.meta-value.amount { + color: #ff6a00; + font-weight: 600; + font-size: 15px; +} + +.card-footer { + display: flex; + align-items: center; + justify-content: space-between; +} + +.status-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.status-label { + font-size: 14px; + font-weight: 500; + color: #1f2937; +} + +@media (max-width: 768px) { + .orders-table { + display: none; + } + + .mobile-cards { + display: block; + } + + .search-input { + max-width: 100%; + } + + .order-tabs { + padding: 0 8px; + } + + .order-tabs :deep(.el-tabs__item) { + padding: 0 12px; + font-size: 14px; + } +} diff --git a/frontend/src/features/orders/views/OrdersView.vue b/frontend/src/features/orders/views/OrdersView.vue index ca41b6d..4fec8ce 100644 --- a/frontend/src/features/orders/views/OrdersView.vue +++ b/frontend/src/features/orders/views/OrdersView.vue @@ -586,401 +586,4 @@ function bizTypeLabel(bizType: BizType) { - + diff --git a/frontend/src/features/wallet/views/MobileWithdrawalView.css b/frontend/src/features/wallet/views/MobileWithdrawalView.css new file mode 100644 index 0000000..2e36b65 --- /dev/null +++ b/frontend/src/features/wallet/views/MobileWithdrawalView.css @@ -0,0 +1,484 @@ +.mobile-withdrawal { + --van-primary-color: #ff6a00; + --van-button-primary-background: #ff6a00; + --van-button-primary-border-color: #ff6a00; + --van-radio-checked-icon-color: #ff6a00; + --van-checkbox-checked-icon-color: #ff6a00; + --van-tag-warning-color: #ff6a00; + min-height: 100vh; + padding: 12px 14px 28px; + background: #fff7ed; +} + +.page-header { + display: grid; + grid-template-columns: 40px 1fr 40px; + align-items: center; + height: 44px; + margin-bottom: 12px; +} + +.page-header h1 { + margin: 0; + text-align: center; + font-size: 17px; + color: #101828; +} + +.back-btn, +.refresh-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: 0; + border-radius: 18px; + background: #fff; + color: #1f2937; +} + +.refresh-btn { + justify-self: end; +} + +.balance-panel { + display: grid; + gap: 6px; + padding: 18px; + border-radius: 8px; + background: linear-gradient(135deg, #ff8a1f 0%, #ff5a00 100%); + color: #fff; + box-shadow: 0 14px 28px rgba(255, 106, 0, 0.22); +} + +.balance-panel span, +.balance-panel small { + color: rgba(255, 255, 255, 0.72); + font-size: 12px; +} + +.balance-panel strong { + font-size: 31px; + line-height: 1.1; +} + +.withdraw-card, +.records-card { + margin-top: 12px; + padding: 14px; + border-radius: 8px; + background: #fff; +} + +.section-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.section-title h2 { + margin: 0; + font-size: 16px; + color: #101828; +} + +.section-title button { + display: inline-flex; + align-items: center; + gap: 4px; + border: 0; + background: transparent; + color: #ff6a00; + font-size: 13px; +} + +.section-title button:disabled { + color: #98a2b3; +} + +.section-title span { + color: #98a2b3; + font-size: 12px; +} + +.center-loading { + display: flex; + justify-content: center; + padding: 28px 0; +} + +.empty-state { + display: grid; + justify-items: center; + gap: 10px; + padding: 24px 0; + color: #98a2b3; +} + +.empty-state p { + margin: 0; +} + +.account-list { + display: grid; + gap: 8px; + margin-bottom: 12px; +} + +.account-option { + display: grid; + gap: 8px; + padding: 10px; + border: 1px solid #eaecf0; + border-radius: 8px; + background: #fff; +} + +.account-option.active { + border-color: #ff6a00; + background: #fff7ed; +} + +.account-select { + display: grid; + grid-template-columns: 34px 1fr auto 20px; + align-items: center; + gap: 8px; + width: 100%; + min-height: 44px; + padding: 0; + border: 0; + background: transparent; + text-align: left; +} + +.account-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 8px; + background: #fff3e8; + color: #ff6a00; +} + +.account-main { + display: grid; + gap: 3px; + min-width: 0; +} + +.account-main strong { + overflow: hidden; + color: #101828; + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-main small { + overflow: hidden; + color: #667085; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 8px; + border-top: 1px solid #f2f4f7; +} + +.account-actions button { + border: 0; + background: transparent; + color: #ff6a00; + font-size: 12px; +} + +.account-actions button.danger { + color: #b42318; +} + +.account-actions button:disabled { + color: #98a2b3; +} + +:deep(.van-cell) { + padding-right: 0; + padding-left: 0; +} + +.amount-meta, +.amount-preview { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: 10px 0; + color: #667085; + font-size: 12px; +} + +.amount-meta button { + border: 0; + background: transparent; + color: #ff6a00; +} + +.amount-preview { + padding: 10px 12px; + border-radius: 8px; + background: #f8fafc; +} + +.amount-preview strong { + color: #ff6a00; + font-size: 18px; +} + +.submit-btn { + margin-top: 14px; +} + +.record-list { + display: grid; + gap: 10px; +} + +.record-item { + padding: 12px; + border: 1px solid #eaecf0; + border-radius: 8px; + background: #fff; +} + +.record-top, +.record-meta { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.record-top strong { + display: block; + color: #101828; + font-size: 18px; +} + +.record-top small, +.record-meta { + color: #98a2b3; + font-size: 12px; +} + +.record-meta { + margin-top: 8px; +} + +.record-remark { + margin: 10px 0 0; + color: #b42318; + font-size: 12px; +} + +.cancel-btn { + margin-top: 10px; + padding: 6px 10px; + border: 1px solid #fecdca; + border-radius: 999px; + background: #fff; + color: #b42318; + font-size: 12px; +} + +.account-popup { + max-height: 86vh; +} + +.popup-body { + padding: 20px 16px 24px; +} + +.popup-body h2 { + margin: 0 0 16px; + font-size: 17px; + color: #101828; +} + +.delete-account-btn { + margin-top: 10px; +} + +.type-tabs { + gap: 8px 12px; + flex-wrap: wrap; + margin-bottom: 6px; +} + +.type-tabs :deep(.van-radio) { + margin-right: 0; +} + +.type-tabs :deep(.van-radio__label) { + color: #1f2937; + font-size: 15px; +} + +.type-label { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.recommend-badge { + display: inline-flex; + align-items: center; + height: 17px; + padding: 0 6px; + border-radius: 999px; + background: #fff7ed; + color: #c2410c; + font-size: 11px; + font-weight: 700; + line-height: 1; +} + +.type-tip { + margin: 0 0 12px; + color: #8a5a13; + font-size: 12px; + line-height: 1.5; +} + +.account-tip { + margin: 12px 0; + padding: 10px 12px; + border-radius: 8px; + background: #fffbeb; + color: #92400e; + font-size: 12px; + line-height: 1.6; +} + +.receive-qr-tip { + display: grid; + grid-template-columns: minmax(0, 1fr) 108px; + gap: 10px; + align-items: center; + margin: 12px 0; + padding: 10px; + border: 1px solid #fed7aa; + border-radius: 10px; + background: #fff7ed; + color: #92400e; +} + +.receive-qr-tip strong { + display: block; + margin-bottom: 5px; + color: #c2410c; + font-size: 13px; +} + +.receive-qr-tip p { + margin: 0; + font-size: 12px; + line-height: 1.55; +} + +.receive-qr-tip img { + display: block; + width: 108px; + border: 1px solid #fde6c8; + border-radius: 8px; + background: #ffffff; +} + +.certificate-section { + margin: 14px 0; +} + +.certificate-title { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + color: #1f2937; + font-size: 14px; + font-weight: 600; +} + +.certificate-title small { + color: #98a2b3; + font-size: 12px; + font-weight: 400; +} + +.certificate-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.certificate-item, +.certificate-upload { + position: relative; + overflow: hidden; + aspect-ratio: 1; + border-radius: 8px; + background: #fff7ed; +} + +.certificate-item { + border: 1px solid #fed7aa; +} + +.certificate-item img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.certificate-remove { + position: absolute; + top: 6px; + right: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: 0; + border-radius: 50%; + background: rgba(15, 23, 42, 0.72); + color: #fff; +} + +.certificate-upload { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + border: 1px dashed #ff9f43; + color: #ff6a00; + font-size: 12px; +} + +.certificate-upload input { + display: none; +} + +.certificate-upload span { + line-height: 1; +} diff --git a/frontend/src/features/wallet/views/MobileWithdrawalView.vue b/frontend/src/features/wallet/views/MobileWithdrawalView.vue index a419f23..24b3b90 100644 --- a/frontend/src/features/wallet/views/MobileWithdrawalView.vue +++ b/frontend/src/features/wallet/views/MobileWithdrawalView.vue @@ -664,489 +664,4 @@ function statusType(status: string): 'primary' | 'success' | 'danger' | 'warning - + diff --git a/frontend/src/layouts/AdminLayout.css b/frontend/src/layouts/AdminLayout.css new file mode 100644 index 0000000..7cf5377 --- /dev/null +++ b/frontend/src/layouts/AdminLayout.css @@ -0,0 +1,373 @@ +.admin-shell { + display: flex; + height: 100vh; + min-height: 100vh; + background-color: #f5f7fa; + overflow: hidden; +} + +.admin-sidebar { + width: 220px; + height: 100vh; + flex: none; + background-color: #304156; + transition: width 0.3s ease; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.admin-shell--collapsed .admin-sidebar { + width: 64px; +} + +.admin-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-bottom: 1px solid #3d4e5f; +} + +.admin-brand { + display: flex; + align-items: center; + gap: 8px; + text-decoration: none; + color: #fff; + overflow: hidden; +} + +.brand-mark { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background-color: #409eff; + border-radius: 4px; + font-size: 18px; + font-weight: 700; + flex-shrink: 0; +} + +.brand-text { + font-size: 16px; + font-weight: 600; + white-space: nowrap; +} + +.collapse-btn { + color: #bfcbd9; + cursor: pointer; + font-size: 20px; + flex-shrink: 0; +} + +.collapse-btn:hover { + color: #fff; +} + +.admin-menu { + --el-menu-bg-color: transparent; + --el-menu-text-color: #bfcbd9; + --el-menu-active-color: #409eff; + --el-menu-hover-bg-color: #263445; + + flex: 1; + min-height: 0; + border-right: none; + background-color: transparent; + overflow-y: auto; + overflow-x: hidden; +} + +.admin-sidebar-footer { + display: grid; + flex: none; + gap: 10px; + padding: 12px; + border-top: 1px solid #3d4e5f; + background: rgba(16, 24, 39, 0.18); +} + +.admin-shell--collapsed .admin-sidebar-footer { + padding: 10px 8px; +} + +.sidebar-notification { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + min-height: 32px; + padding: 0 10px; + border: 1px solid rgba(64, 158, 255, 0.35); + border-radius: 8px; + color: #d7dfec; + text-decoration: none; + transition: + background-color 0.2s, + border-color 0.2s; +} + +.sidebar-notification:hover, +.sidebar-notification.router-link-active { + background-color: rgba(64, 158, 255, 0.14); + border-color: rgba(64, 158, 255, 0.8); + color: #fff; +} + +.admin-shell--collapsed .sidebar-notification { + padding: 0; +} + +.sidebar-notification-icon { + position: relative; + display: grid; + width: 20px; + height: 20px; + place-items: center; +} + +.sidebar-notification-text { + font-size: 13px; + font-weight: 700; +} + +.sidebar-badge { + position: absolute; + top: -8px; + right: -13px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border: 2px solid #304156; + border-radius: 999px; + background: #ef4444; + color: #fff; + font-size: 10px; + font-style: normal; + font-weight: 800; + line-height: 12px; + text-align: center; + box-sizing: border-box; +} + +.sidebar-status { + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + width: 100%; + min-height: 30px; + padding: 0 10px; + border: 1px solid; + border-radius: 8px; + color: #d7dfec; + cursor: pointer; + transition: + background-color 0.2s, + border-color 0.2s; +} + +.sidebar-status:hover { + background-color: rgba(255, 255, 255, 0.08); +} + +.admin-shell--collapsed .sidebar-status { + padding: 0; +} + +.sidebar-user { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + width: 100%; + padding: 8px; + border-radius: 10px; + cursor: pointer; + transition: background-color 0.2s; +} + +.sidebar-user:hover { + background-color: rgba(255, 255, 255, 0.08); +} + +.admin-shell--collapsed .sidebar-user { + justify-content: center; + padding: 8px 0; +} + +.sidebar-user-text { + display: grid; + min-width: 0; + gap: 2px; +} + +.sidebar-user-text strong { + overflow: hidden; + color: #fff; + font-size: 13px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-user-text small { + overflow: hidden; + color: #8fa0ba; + font-size: 12px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-menu:not(.el-menu--collapse) { + width: 100%; +} + +:deep(.el-menu) { + background-color: transparent; +} + +:deep(.el-sub-menu__title) { + color: #bfcbd9; +} + +:deep(.el-sub-menu__title:hover) { + background-color: #263445; +} + +:deep(.el-sub-menu.is-active > .el-sub-menu__title) { + color: #409eff; +} + +:deep(.el-menu-item) { + color: #bfcbd9; +} + +:deep(.el-menu-item:hover) { + background-color: #263445; +} + +:deep(.el-menu-item.is-active) { + color: #409eff; + background-color: #263445; +} + +:deep(.el-menu--inline) { + background-color: #263445; +} + +:deep(.el-menu--inline .el-menu-item) { + height: 46px; + background-color: #263445; + font-size: 14px; +} + +:deep(.el-menu--inline .el-menu-item:hover), +:deep(.el-menu--inline .el-menu-item.is-active) { + background-color: #1f2d3d; +} + +.menu-item-label { + min-width: 0; +} + +.menu-item-badge { + margin-left: auto; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background: #f43f5e; + color: #ffffff; + font-size: 11px; + font-style: normal; + font-weight: 700; + line-height: 18px; + text-align: center; +} + +:deep(.el-menu--collapse .menu-item-badge) { + position: absolute; + top: 6px; + right: 5px; + min-width: 8px; + width: 8px; + height: 8px; + padding: 0; + border: 2px solid #304156; + border-radius: 50%; + color: transparent; + font-size: 0; + line-height: 0; +} + +:global(.admin-menu-popper) { + --el-menu-bg-color: #304156; + --el-menu-text-color: #bfcbd9; + --el-menu-active-color: #409eff; + --el-menu-hover-bg-color: #263445; +} + +:global(.admin-menu-popper .el-menu) { + border-right: none; + background-color: #304156; +} + +:global(.admin-menu-popper .el-menu-item) { + color: #bfcbd9; +} + +:global(.admin-menu-popper .el-menu-item:hover), +:global(.admin-menu-popper .el-menu-item.is-active) { + color: #409eff; + background-color: #263445; +} + +.admin-workspace { + flex: 1; + min-width: 0; + min-height: 0; + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.status-text { + font-size: 13px; + color: inherit; + font-weight: 500; +} + +:deep(.el-dropdown-menu__item .status-dot) { + margin-right: 8px; +} + +.admin-main { + flex: 1; + min-height: 0; + padding: 18px 24px 24px; + overflow-y: auto; +} + +:global(.force-password-dialog) { + max-width: calc(100vw - 32px); +} + +.force-password-form { + display: grid; + gap: 2px; +} diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index 5b6a348..4019d54 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -542,378 +542,4 @@ async function handleForcedPasswordChange() { - + diff --git a/frontend/src/layouts/AppLayout.css b/frontend/src/layouts/AppLayout.css new file mode 100644 index 0000000..c983d8e --- /dev/null +++ b/frontend/src/layouts/AppLayout.css @@ -0,0 +1,732 @@ +/* ========== 顶部栏 ========== */ +.pc-app-shell { + display: flex; + flex-direction: column; + min-height: 100vh; + max-width: 100vw; + overflow-x: clip; + background: #f5f6f8; +} + +.pc-topbar { + position: sticky; + top: 0; + z-index: 100; + display: flex; + align-items: center; + gap: 20px; + min-width: 0; + max-width: 100%; + height: 64px; + padding: 0 24px; + background: #fff; + border-bottom: 1px solid #eef1f5; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04); +} + +/* ========== 品牌 ========== */ +.pc-brand { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + flex-shrink: 0; +} + +.pc-brand-mark { + display: grid; + width: 38px; + height: 38px; + place-items: center; + border-radius: 12px; + background: linear-gradient(135deg, #fff3e8 0%, #ffe4cc 100%); + color: #ff6a00; + font-size: 20px; + font-weight: 900; +} + +.pc-brand-info { + display: flex; + flex-direction: column; + line-height: 1.2; +} + +.pc-brand-text { + font-size: 16px; + font-weight: 800; + color: #17233d; +} + +.pc-brand-sub { + font-size: 11px; + color: #8b9cb5; + font-weight: 600; +} + +/* ========== 导航 ========== */ +.pc-nav { + display: flex; + align-items: center; + gap: 4px; + margin-left: 10px; + min-width: 0; + height: 100%; +} + +.pc-nav-link { + position: relative; + display: flex; + align-items: center; + gap: 5px; + padding: 8px 14px; + border-radius: 10px; + color: #5a6577; + font-size: 14px; + font-weight: 700; + text-decoration: none; + transition: all 0.2s; +} + +.pc-nav-badge { + position: absolute; + top: 2px; + right: 2px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 999px; + background: #ef4444; + color: #fff; + font-size: 10px; + font-style: normal; + font-weight: 800; + line-height: 16px; + text-align: center; +} + +.pc-nav-link:hover, +.pc-nav-link.router-link-active { + background: #fff3e8; + color: #ff6a00; +} + +/* ========== 搜索 ========== */ +.pc-search { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; + padding: 0 14px; + height: 40px; + border-radius: 12px; + background: #f4f6f8; + border: 1px solid transparent; + max-width: 320px; + min-width: 180px; + flex: 1; + transition: all 0.2s; +} + +.pc-search:focus-within { + background: #fff; + border-color: #ff6a00; + box-shadow: 0 0 0 4px rgba(255, 106, 0, 0.08); +} + +.pc-search input { + border: none; + outline: none; + background: transparent; + color: #17233d; + font-size: 13px; + width: 100%; +} + +.pc-search input::placeholder { + color: #a0aab6; +} + +.pc-search .el-icon { + color: #a0aab6; + font-size: 16px; + flex-shrink: 0; +} + +/* ========== 用户操作区 ========== */ +.pc-user-actions { + display: flex; + align-items: center; + gap: 12px; + margin-left: 12px; + min-width: 0; + flex-shrink: 0; + height: 100%; +} + +.pc-icon-link { + display: flex; + align-items: center; + gap: 10px; + padding: 0 22px; + height: 40px; + border-radius: 12px; + border: 1px solid #eef1f5; + background: #ffffff; + color: #5a6577; + font-size: 14px; + font-weight: 700; + text-decoration: none; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.pc-icon-link:hover { + background: #fff3e8; + border-color: #ff6a00; + color: #ff6a00; + box-shadow: 0 4px 12px rgba(255, 106, 0, 0.05); +} + +.service-link { + gap: 7px; + min-width: 92px; + justify-content: center; + padding: 0 18px; + border-color: rgba(255, 106, 0, 0.32); + border-radius: 10px; + background: #fff8f2; + color: #ff6a00; + font-size: 14px; + font-weight: 800; +} + +.service-link .el-icon { + width: 18px; + height: 18px; + font-size: 18px; + stroke-width: 2.2; +} + +.service-link:hover { + background: #fff3e8; + border-color: #ff6a00; + box-shadow: 0 6px 16px rgba(255, 106, 0, 0.1); + transform: translateY(-1px); +} + +.pc-login-link { + display: flex; + align-items: center; + gap: 6px; + padding: 0 16px; + height: 40px; + border-radius: 10px; + background: #f4f6f8; + color: #17233d; + font-size: 13px; + font-weight: 700; + text-decoration: none; + transition: all 0.2s; +} + +.pc-login-link:hover { + background: #eaeff5; +} + +.pc-post-link { + display: flex; + align-items: center; + gap: 6px; + padding: 0 18px; + height: 40px; + border-radius: 10px; + background: #ff6a00; + color: #fff; + font-size: 14px; + font-weight: 800; + text-decoration: none; + box-shadow: 0 6px 16px rgba(255, 106, 0, 0.2); + transition: all 0.2s; +} + +.pc-post-link:hover { + background: #e55d00; + transform: translateY(-1px); +} + +/* ========== 已登录 - 用户块 ========== */ +.pc-user-block { + position: relative; + height: 40px; + display: flex; + align-items: center; +} + +.pc-user-trigger { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 10px 4px 4px; + height: 36px; + border: none; + border-radius: 999px; + background: #f4f6f8; + cursor: pointer; + transition: all 0.2s; +} + +.pc-user-trigger:hover, +.pc-user-trigger.active { + background: #eaeff5; +} + +.pc-avatar-chip { + display: grid; + width: 28px; + height: 28px; + place-items: center; + border-radius: 50%; + overflow: hidden; + background: linear-gradient(135deg, #ff6a00 0%, #e55d00 100%); + color: #fff; + font-size: 13px; + font-weight: 800; + flex-shrink: 0; +} + +.pc-avatar-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.pc-avatar-text { + line-height: 1; +} + +.pc-user-name { + max-width: 100px; + overflow: hidden; + color: #17233d; + font-size: 14px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pc-realname-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 48px; + height: 22px; + padding: 0 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 800; + line-height: 1; + white-space: nowrap; +} + +.pc-growth-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 42px; + height: 22px; + padding: 0 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 900; + line-height: 1; + white-space: nowrap; +} + +.pc-growth-badge.is-normal { + background: #eef2ff; + color: #4f46e5; +} + +.pc-growth-badge.is-platinum { + background: #eff6ff; + color: #2563eb; +} + +.pc-growth-badge.is-diamond { + background: #ecfeff; + color: #0891b2; +} + +.pc-growth-badge.is-peak { + background: #fff7ed; + color: #ea580c; +} + +.pc-realname-badge.is-verified, +.dropdown-status.is-verified { + background: #ecfdf3; + color: #16a34a; +} + +.pc-realname-badge.is-pending, +.dropdown-status.is-pending { + background: #fff7ed; + color: #f97316; +} + +.pc-realname-badge.is-rejected, +.dropdown-status.is-rejected { + background: #fff1f2; + color: #e11d48; +} + +.pc-realname-badge.is-unverified, +.dropdown-status.is-unverified { + background: #f1f5f9; + color: #64748b; +} + +.arrow-icon { + font-size: 12px; + color: #8b9cb5; + transition: transform 0.3s; +} + +.arrow-icon.rotated { + transform: rotate(180deg); +} + +/* ========== 下拉菜单容器 (Hover Bridge) ========== */ +.pc-user-dropdown-container { + position: absolute; + top: 100%; + right: 0; + z-index: 200; + padding-top: 12px; +} + +.pc-user-dropdown { + width: 388px; + padding: 14px; + border-radius: 14px; + background: #fff; + border: 1px solid #eef1f5; + box-shadow: 0 10px 30px rgba(23, 35, 61, 0.15); +} + +.dropdown-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 14px; + border: none; + border-radius: 10px; + background: none; + color: #475569; + font-size: 14px; + font-weight: 600; + text-decoration: none; + cursor: pointer; + transition: all 0.2s; +} + +.dropdown-growth-panel { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-bottom: 10px; +} + +.dropdown-growth-panel > div { + display: grid; + gap: 5px; + min-width: 0; + min-height: 62px; + padding: 11px 12px; + border-radius: 12px; + background: #f8fafc; +} + +.dropdown-growth-panel span { + color: #8b9cb5; + font-size: 12px; + font-weight: 800; +} + +.dropdown-growth-panel strong { + min-width: 0; + overflow: hidden; + color: #7c3aed; + font-size: 18px; + font-weight: 900; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dropdown-status { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 50px; + height: 20px; + margin-left: auto; + padding: 0 7px; + border-radius: 999px; + font-size: 11px; + font-weight: 800; + line-height: 1; + white-space: nowrap; +} + +.dropdown-item:hover { + background: #fff3e8; + color: #ff6a00; + transform: translateX(4px); +} + +.dropdown-item .el-icon { + font-size: 16px; + color: #94a3b8; + transition: color 0.2s; +} + +.dropdown-item:hover .el-icon { + color: #ff6a00; +} + +.dropdown-item.logout { + color: #ef4444; +} + +.dropdown-item.logout .el-icon { + color: #ef4444; +} + +.dropdown-item.logout:hover { + background: #fff1f0; +} + +.dropdown-divider { + height: 1px; + margin: 12px 6px; + background: #f1f5f9; +} + +.dropdown-basic-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.dropdown-basic-item { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; + min-height: 42px; + padding: 0 12px; + border-radius: 10px; + background: #f8fafc; + color: #475569; + font-size: 13px; + font-weight: 800; + text-decoration: none; + transition: all 0.2s; +} + +.dropdown-basic-item .el-icon { + flex: 0 0 auto; + color: #94a3b8; + font-size: 16px; +} + +.dropdown-basic-item span:not(.dropdown-status) { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dropdown-basic-item:hover { + background: #fff3e8; + color: #ff6a00; + transform: translateY(-1px); +} + +.dropdown-basic-item:hover .el-icon { + color: #ff6a00; +} + +.dropdown-service-group { + padding: 0 2px; +} + +.dropdown-service-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 2px 8px; + color: #17233d; + font-size: 14px; + font-weight: 900; +} + +.dropdown-service-more { + color: #64748b; + font-size: 12px; + font-weight: 800; + text-decoration: none; +} + +.dropdown-service-more:hover { + color: #ff6a00; +} + +.dropdown-service-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.dropdown-service-card, +.dropdown-seller-card { + text-decoration: none; + transition: all 0.2s; +} + +.dropdown-service-card { + display: grid; + min-width: 0; + min-height: 74px; + place-items: center; + gap: 6px; + padding: 8px 4px; + border-radius: 10px; + background: #f8fafc; + color: #475569; + font-size: 12px; + font-weight: 800; + text-align: center; +} + +.dropdown-service-card .el-icon { + color: #ff6a00; + font-size: 18px; +} + +.dropdown-service-card:hover { + background: #fff3e8; + color: #ff6a00; + transform: translateY(-1px); +} + +.dropdown-seller-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.dropdown-seller-card { + display: grid; + min-width: 0; + min-height: 74px; + place-items: center; + gap: 6px; + padding: 8px 4px; + border-radius: 10px; + background: #f8fafc; + color: #475569; + font-size: 12px; + font-weight: 800; + text-align: center; +} + +.dropdown-seller-card .el-icon { + color: #ff6a00; + font-size: 18px; +} + +.dropdown-seller-card:hover { + background: #fff3e8; + color: #ff6a00; + transform: translateY(-1px); +} + +.dropdown-seller-card:hover .el-icon { + color: #ff6a00; +} + +/* 下拉动画 */ +.dropdown-enter-active, +.dropdown-leave-active { + transition: + opacity 0.2s ease, + transform 0.2s ease; +} +.dropdown-enter-from { + opacity: 0; + transform: translateY(10px); +} +.dropdown-leave-to { + opacity: 0; + transform: translateY(5px); +} + +/* ========== 主体 ---------- */ +.pc-main { + flex: 1; + min-width: 0; + padding: 20px 24px; + overflow-x: clip; +} + +/* ========== 页脚 ========== */ +.pc-footer { + flex-shrink: 0; + padding: 16px 24px; + background: #fff; + border-top: 1px solid #eef1f5; +} + +.pc-footer-inner { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + max-width: 1280px; + margin: 0 auto; + color: #8b9cb5; + font-size: 12px; + line-height: 1.6; +} + +.pc-footer-icp { + color: #8b9cb5; + text-decoration: none; + transition: color 0.2s; +} + +.pc-footer-icp:hover { + color: #5a6577; +} + +.pc-footer-divider { + color: #d8dfe8; +} + +.pc-footer-copyright { + color: #8b9cb5; +} + +@media (max-width: 1280px) { + .pc-growth-badge { + display: none; + } + + .pc-realname-badge { + display: none; + } +} diff --git a/frontend/src/layouts/AppLayout.vue b/frontend/src/layouts/AppLayout.vue index 0f6a13f..53a0ec2 100644 --- a/frontend/src/layouts/AppLayout.vue +++ b/frontend/src/layouts/AppLayout.vue @@ -359,737 +359,4 @@ async function handleSupportClick() { - + diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 1ec8cf8..2c4ee2f 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -15,5 +15,22 @@ export default defineConfig({ environment: 'jsdom', globals: true, include: ['src/**/*.{spec,test}.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json-summary'], + include: [ + 'src/features/admin/api/adminPayments.ts', + 'src/features/admin/utils/adminOrderPresentation.ts', + 'src/features/orders/composables/useOrderPaymentCashier.ts', + 'src/features/wallet/api/wallet.ts', + 'src/features/wallet/api/withdrawal.ts', + ], + thresholds: { + statements: 80, + branches: 70, + functions: 80, + lines: 80, + }, + }, }, }) diff --git a/scripts/check-backend-coverage.sh b/scripts/check-backend-coverage.sh new file mode 100755 index 0000000..fb65cde --- /dev/null +++ b/scripts/check-backend-coverage.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BACKEND_DIR="${ROOT_DIR}/backend" +PROFILE="$(mktemp -t hfb-backend-cover.XXXXXX)" +OUTPUT="$(mktemp -t hfb-backend-cover-output.XXXXXX)" +trap 'rm -f "${PROFILE}" "${OUTPUT}"' EXIT + +cd "${BACKEND_DIR}" +go test -p 1 -count=1 -coverprofile="${PROFILE}" ./... | tee "${OUTPUT}" + +coverage_for_package() { + local package="$1" + awk -v package="${package}" ' + $1 == "ok" && $2 == package { + for (i = 1; i <= NF; i++) { + if ($i == "coverage:") { + value = $(i + 1) + gsub(/%/, "", value) + print value + exit + } + } + } + ' "${OUTPUT}" +} + +require_at_least() { + local label="$1" + local actual="$2" + local minimum="$3" + if [[ -z "${actual}" ]]; then + printf '[coverage] 无法读取 %s 覆盖率\n' "${label}" >&2 + return 1 + fi + if ! awk -v actual="${actual}" -v minimum="${minimum}" 'BEGIN { exit !(actual + 0 >= minimum + 0) }'; then + printf '[coverage] %s 覆盖率 %s%%,低于门禁 %s%%\n' "${label}" "${actual}" "${minimum}" >&2 + return 1 + fi + printf '[coverage] %s %s%%(门禁 %s%%)\n' "${label}" "${actual}" "${minimum}" +} + +total="$(go tool cover -func="${PROFILE}" | awk '/^total:/ { gsub(/%/, "", $3); print $3 }')" +require_at_least "后端总语句" "${total}" "30.0" +require_at_least "order" "$(coverage_for_package hfb_sys/backend/internal/modules/order)" "40.0" +require_at_least "payment" "$(coverage_for_package hfb_sys/backend/internal/modules/payment)" "20.0" +require_at_least "wallet" "$(coverage_for_package hfb_sys/backend/internal/modules/wallet)" "34.0" +require_at_least "withdrawal" "$(coverage_for_package hfb_sys/backend/internal/modules/withdrawal)" "30.0" diff --git a/scripts/check-vue-size.sh b/scripts/check-vue-size.sh new file mode 100755 index 0000000..275c0ed --- /dev/null +++ b/scripts/check-vue-size.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MAX_LINES="${VUE_MAX_LINES:-1800}" +failed=0 +while IFS= read -r -d '' file; do + lines="$(wc -l < "${file}")" + if (( lines > MAX_LINES )); then + printf '[vue-size] %s %s 行,超过上限 %s\n' "${file#${ROOT_DIR}/}" "${lines}" "${MAX_LINES}" >&2 + failed=1 + fi +done < <(find "${ROOT_DIR}/frontend/src" -name '*.vue' -print0) + +if (( failed != 0 )); then + exit 1 +fi +printf '[vue-size] 所有 Vue 页面均不超过 %s 行\n' "${MAX_LINES}" diff --git a/scripts/check.sh b/scripts/check.sh index 9329876..a2d7876 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -97,7 +97,7 @@ if [[ "${SKIP_BACKEND}" == "0" ]]; then run_step "go build ./..." go build ./... || FAILED=1 if [[ "${SKIP_TESTS}" == "0" ]]; then # -p 1 串行避免 SQLite 内存库并发; 真实 MySQL e2e 默认 skip - run_step "go test ./..." go test -p 1 -count=1 ./... || FAILED=1 + run_step "go test + coverage" bash "${ROOT_DIR}/scripts/check-backend-coverage.sh" || FAILED=1 else log_warn "跳过后端测试 (--skip-tests)" fi @@ -112,6 +112,8 @@ if [[ "${SKIP_FRONTEND}" == "0" ]]; then log "前端检查目录: ${FRONTEND_DIR}" cd "${FRONTEND_DIR}" + run_step "Vue 页面大小门禁" bash "${ROOT_DIR}/scripts/check-vue-size.sh" || FAILED=1 + if [[ ! -d node_modules ]]; then run_step "npm ci (首次安装依赖)" npm ci || FAILED=1 fi @@ -119,7 +121,7 @@ if [[ "${SKIP_FRONTEND}" == "0" ]]; then # npm run check = prettier --check + eslint --max-warnings=0 + vue-tsc run_step "npm run check (prettier + eslint + typecheck)" npm run check || FAILED=1 if [[ "${SKIP_TESTS}" == "0" ]]; then - run_step "npm run test (vitest)" npm run test || FAILED=1 + run_step "npm run test + coverage (vitest)" npm run test:coverage || FAILED=1 else log_warn "跳过前端测试 (--skip-tests)" fi diff --git a/scripts/extract-vue-styles.sh b/scripts/extract-vue-styles.sh new file mode 100755 index 0000000..6b336ca --- /dev/null +++ b/scripts/extract-vue-styles.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# 将大型 SFC 的内联 scoped CSS 外置,保留 ##s" "${file}" +done < <(find "${ROOT_DIR}/frontend/src" -name '*.vue' -print0)